midas-saxs 0.1.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.
@@ -0,0 +1,31 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, UChicago Argonne, LLC, operator of Argonne National
4
+ Laboratory, and the midas-diffract authors.
5
+ All rights reserved.
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ 1. Redistributions of source code must retain the above copyright notice,
11
+ this list of conditions and the following disclaimer.
12
+
13
+ 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ 3. Neither the name of the copyright holder nor the names of its
18
+ contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
24
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
25
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
26
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
27
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
28
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
29
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
30
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31
+ POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: midas-saxs
3
+ Version: 0.1.0
4
+ Summary: Differentiable small-angle X-ray scattering for MIDAS: particle form factors and polydispersity, plus a 2-D detector-image forward model for dislocation loops and voids driven by a discrete-dislocation network.
5
+ Author-email: Hemant Sharma <hsharma@anl.gov>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/marinerhemant/MIDAS
8
+ Project-URL: Documentation, https://github.com/marinerhemant/MIDAS/tree/master/packages/midas_saxs
9
+ Project-URL: Issues, https://github.com/marinerhemant/MIDAS/issues
10
+ Keywords: MIDAS,SAXS,USAXS,small-angle scattering,X-ray,dislocation loop,irradiation,void,form factor,differentiable,PyTorch
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Scientific/Engineering :: Physics
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=1.22
20
+ Requires-Dist: torch>=2.0
21
+ Requires-Dist: midas-hkls>=0.9.0
22
+ Requires-Dist: midas-transforms>=0.10.0
23
+ Requires-Dist: midas-distortion>=0.2.0
24
+ Provides-Extra: dislocations
25
+ Requires-Dist: midas-ddd>=0.1.0; extra == "dislocations"
26
+ Provides-Extra: viz
27
+ Requires-Dist: matplotlib>=3.5; extra == "viz"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.0; extra == "dev"
30
+ Requires-Dist: matplotlib>=3.5; extra == "dev"
31
+ Requires-Dist: midas-ddd>=0.1.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # midas-saxs
35
+
36
+ Differentiable small-angle X-ray scattering for MIDAS. Two source terms, one
37
+ detector:
38
+
39
+ - **Density contrast** — voids, gas bubbles, precipitates. Sphere / ellipsoid /
40
+ cylinder form factors, lognormal polydispersity, Percus-Yevick `S(Q)`, Guinier
41
+ and Porod analysis. These came from `midas_pdf.saxs`, which now re-exports them
42
+ from here, so MIDAS has one definition of "SAXS form factor".
43
+ - **Strain contrast** — dislocation loops, driven by a `midas_ddd` network read
44
+ from ExaDiS. Optional extra: `pip install 'midas-saxs[dislocations]'`.
45
+
46
+ ```python
47
+ from midas_saxs import SAXSGeometry, SpherePopulation, simulate_frame
48
+
49
+ geom = SAXSGeometry(lsd_um=2.0e6, bcy_px=512, bcz_px=512, px_um=75.0,
50
+ wavelength_A=0.7293, n_pix_y=1024, n_pix_z=1024,
51
+ beamstop_radius_px=30)
52
+ voids = SpherePopulation(radius_A=50.0, number_density_per_A3=1e-8,
53
+ delta_rho_e_per_A3=-2.45, label="voids")
54
+ frame = simulate_frame(geom, particles=[voids], sample_volume_A3=1e15)
55
+ ```
56
+
57
+ `dev/paper/loop_vs_void_demo.py` renders the five-panel figure that motivates the
58
+ whole package.
59
+
60
+ ## Read this before promising anyone a loop measurement
61
+
62
+ Three numbers, all reproducible from the demo script.
63
+
64
+ **1. Loops are faint.** A dislocation loop of radius R displaces only its
65
+ relaxation volume `pi R^2 b`; a void of the same radius displaces
66
+ `4 pi R^3 / 3`. For R = 5 nm in Cu that is 26x in volume and **680x in forward
67
+ intensity**. The loop scatters like a *sphere of radius 1.7 nm*.
68
+
69
+ **2. What separates a loop from a void is shape, not size.** The loop's
70
+ small-angle amplitude is direction-dependent — `kappa dV` in its own plane,
71
+ `dV` along its normal, with `kappa = lambda/(lambda + 2 mu)`. Intensity is
72
+ therefore modulated by `1 - kappa^2` (0.84 for `lambda = 100, mu = 75`). A void
73
+ is isotropic. **Radially averaging a frame destroys exactly this**, which is why
74
+ this package renders 2-D frames and offers `azimuthal_profile` alongside
75
+ `radial_average`.
76
+
77
+ **3. Real voids bury the signature.** Measured on the demo: 24 aligned loops
78
+ alone give an azimuthal contrast of **6.30x** (the closed form predicts
79
+ `1/kappa^2 = 6.25`). Add equal-radius voids at the same number density — the
80
+ physically realistic irradiated case — and the contrast collapses to **1.002x**,
81
+ with the voids outshining the loops by **1089x** integrated over the frame.
82
+
83
+ So: loops are detectable at small angle in a clean matrix, and essentially
84
+ undetectable alongside a void or bubble population of comparable size. If the
85
+ question is defect-type discrimination in an irradiated material, near-Bragg
86
+ diffuse (Huang) scattering is stronger than the small-angle signal by
87
+ `(G/q)^2` ~ 1e4 to 1e7 and is the better measurement.
88
+
89
+ ## Scope
90
+
91
+ **Closed dislocation loops only.** A cut surface exists only for a closed
92
+ circuit. Open lines — the *deformation* population — enclose no area, carry no
93
+ relaxation volume, and contribute nothing as `q -> 0`; their small-angle
94
+ signature is a weak transverse streak that is not modelled. `simulate_frame`
95
+ reports how much line length it ignored rather than returning a number that
96
+ looks complete.
97
+
98
+ ## Units
99
+
100
+ | Quantity | Unit |
101
+ |---|---|
102
+ | distances, geometry | micrometers |
103
+ | wavelength, particle radii, electron density | angstroms |
104
+ | `q` from `midas_saxs.geometry` | **inverse angstroms** |
105
+ | `q` into the `midas_ddd` kernel | **inverse micrometers** |
106
+
107
+ The 1e4 between the last two is easy to lose, so `inv_A_to_inv_um` /
108
+ `inv_um_to_inv_A` exist and `strain_source` uses them rather than a literal.
109
+
110
+ ## Detector geometry
111
+
112
+ `pixel_to_q` goes through `midas_transforms.apply_tilt_distortion` — the same
113
+ tilt (`R_z R_y R_x`) and 15-coefficient distortion model the HEDM side uses.
114
+ That is deliberate: a SAXS geometry and an FF geometry calibrated from the same
115
+ detector must agree about where q sits. Transmission SAXS has no omega, so lab q
116
+ is sample q.
117
+
118
+ ## Migration note
119
+
120
+ `form_factors`, `model`, `wide_band` and `core_shell` moved here from
121
+ `midas_pdf.saxs`. Both the package-level path (`from midas_pdf.saxs import
122
+ SAXSModel`) and the deep path (`from midas_pdf.saxs.form_factors import ...`)
123
+ still resolve, to the same objects — asserted by
124
+ `test_midas_pdf_reexports_the_same_objects`. `midas_pdf.saxs` keeps the genuinely
125
+ PDF-coupled part: joint SAXS + PDF refinement and its Bayesian variants.
126
+
127
+ One convention worth knowing: `sphere_form_factor_squared` returns `V^2 |F|^2`,
128
+ **not** the normalised `|F|^2`. `SpherePopulation.intensity` multiplies it by
129
+ `n * delta_rho^2` and nothing else, which is only correct because the volume is
130
+ already in there.
@@ -0,0 +1,97 @@
1
+ # midas-saxs
2
+
3
+ Differentiable small-angle X-ray scattering for MIDAS. Two source terms, one
4
+ detector:
5
+
6
+ - **Density contrast** — voids, gas bubbles, precipitates. Sphere / ellipsoid /
7
+ cylinder form factors, lognormal polydispersity, Percus-Yevick `S(Q)`, Guinier
8
+ and Porod analysis. These came from `midas_pdf.saxs`, which now re-exports them
9
+ from here, so MIDAS has one definition of "SAXS form factor".
10
+ - **Strain contrast** — dislocation loops, driven by a `midas_ddd` network read
11
+ from ExaDiS. Optional extra: `pip install 'midas-saxs[dislocations]'`.
12
+
13
+ ```python
14
+ from midas_saxs import SAXSGeometry, SpherePopulation, simulate_frame
15
+
16
+ geom = SAXSGeometry(lsd_um=2.0e6, bcy_px=512, bcz_px=512, px_um=75.0,
17
+ wavelength_A=0.7293, n_pix_y=1024, n_pix_z=1024,
18
+ beamstop_radius_px=30)
19
+ voids = SpherePopulation(radius_A=50.0, number_density_per_A3=1e-8,
20
+ delta_rho_e_per_A3=-2.45, label="voids")
21
+ frame = simulate_frame(geom, particles=[voids], sample_volume_A3=1e15)
22
+ ```
23
+
24
+ `dev/paper/loop_vs_void_demo.py` renders the five-panel figure that motivates the
25
+ whole package.
26
+
27
+ ## Read this before promising anyone a loop measurement
28
+
29
+ Three numbers, all reproducible from the demo script.
30
+
31
+ **1. Loops are faint.** A dislocation loop of radius R displaces only its
32
+ relaxation volume `pi R^2 b`; a void of the same radius displaces
33
+ `4 pi R^3 / 3`. For R = 5 nm in Cu that is 26x in volume and **680x in forward
34
+ intensity**. The loop scatters like a *sphere of radius 1.7 nm*.
35
+
36
+ **2. What separates a loop from a void is shape, not size.** The loop's
37
+ small-angle amplitude is direction-dependent — `kappa dV` in its own plane,
38
+ `dV` along its normal, with `kappa = lambda/(lambda + 2 mu)`. Intensity is
39
+ therefore modulated by `1 - kappa^2` (0.84 for `lambda = 100, mu = 75`). A void
40
+ is isotropic. **Radially averaging a frame destroys exactly this**, which is why
41
+ this package renders 2-D frames and offers `azimuthal_profile` alongside
42
+ `radial_average`.
43
+
44
+ **3. Real voids bury the signature.** Measured on the demo: 24 aligned loops
45
+ alone give an azimuthal contrast of **6.30x** (the closed form predicts
46
+ `1/kappa^2 = 6.25`). Add equal-radius voids at the same number density — the
47
+ physically realistic irradiated case — and the contrast collapses to **1.002x**,
48
+ with the voids outshining the loops by **1089x** integrated over the frame.
49
+
50
+ So: loops are detectable at small angle in a clean matrix, and essentially
51
+ undetectable alongside a void or bubble population of comparable size. If the
52
+ question is defect-type discrimination in an irradiated material, near-Bragg
53
+ diffuse (Huang) scattering is stronger than the small-angle signal by
54
+ `(G/q)^2` ~ 1e4 to 1e7 and is the better measurement.
55
+
56
+ ## Scope
57
+
58
+ **Closed dislocation loops only.** A cut surface exists only for a closed
59
+ circuit. Open lines — the *deformation* population — enclose no area, carry no
60
+ relaxation volume, and contribute nothing as `q -> 0`; their small-angle
61
+ signature is a weak transverse streak that is not modelled. `simulate_frame`
62
+ reports how much line length it ignored rather than returning a number that
63
+ looks complete.
64
+
65
+ ## Units
66
+
67
+ | Quantity | Unit |
68
+ |---|---|
69
+ | distances, geometry | micrometers |
70
+ | wavelength, particle radii, electron density | angstroms |
71
+ | `q` from `midas_saxs.geometry` | **inverse angstroms** |
72
+ | `q` into the `midas_ddd` kernel | **inverse micrometers** |
73
+
74
+ The 1e4 between the last two is easy to lose, so `inv_A_to_inv_um` /
75
+ `inv_um_to_inv_A` exist and `strain_source` uses them rather than a literal.
76
+
77
+ ## Detector geometry
78
+
79
+ `pixel_to_q` goes through `midas_transforms.apply_tilt_distortion` — the same
80
+ tilt (`R_z R_y R_x`) and 15-coefficient distortion model the HEDM side uses.
81
+ That is deliberate: a SAXS geometry and an FF geometry calibrated from the same
82
+ detector must agree about where q sits. Transmission SAXS has no omega, so lab q
83
+ is sample q.
84
+
85
+ ## Migration note
86
+
87
+ `form_factors`, `model`, `wide_band` and `core_shell` moved here from
88
+ `midas_pdf.saxs`. Both the package-level path (`from midas_pdf.saxs import
89
+ SAXSModel`) and the deep path (`from midas_pdf.saxs.form_factors import ...`)
90
+ still resolve, to the same objects — asserted by
91
+ `test_midas_pdf_reexports_the_same_objects`. `midas_pdf.saxs` keeps the genuinely
92
+ PDF-coupled part: joint SAXS + PDF refinement and its Bayesian variants.
93
+
94
+ One convention worth knowing: `sphere_form_factor_squared` returns `V^2 |F|^2`,
95
+ **not** the normalised `|F|^2`. `SpherePopulation.intensity` multiplies it by
96
+ `n * delta_rho^2` and nothing else, which is only correct because the volume is
97
+ already in there.
@@ -0,0 +1,105 @@
1
+ """midas-saxs — differentiable small-angle X-ray scattering for MIDAS.
2
+
3
+ Two source terms, one detector.
4
+
5
+ **Density contrast** (:mod:`midas_saxs.particles`, :mod:`midas_saxs.form_factors`,
6
+ :mod:`midas_saxs.model`, :mod:`midas_saxs.core_shell`, :mod:`midas_saxs.wide_band`)
7
+ — voids, bubbles, precipitates; sphere / ellipsoid / cylinder form factors,
8
+ lognormal polydispersity, Percus-Yevick S(Q), Guinier and Porod analysis. These
9
+ came from ``midas_pdf.saxs``, which now re-exports them from here, so there is
10
+ one definition of "SAXS form factor" in MIDAS.
11
+
12
+ **Strain contrast** (:mod:`midas_saxs.strain_source`) — dislocation loops, driven
13
+ by a :mod:`midas_ddd` network read from ExaDiS. Optional extra; plain particle
14
+ SAXS does not pull a dislocation-dynamics stack.
15
+
16
+ pip install 'midas-saxs[dislocations]'
17
+
18
+ Quick start
19
+ -----------
20
+ from midas_saxs import SAXSGeometry, SpherePopulation, simulate_frame
21
+
22
+ geom = SAXSGeometry(lsd_um=2.0e6, bcy_px=512, bcz_px=512, px_um=75.0,
23
+ wavelength_A=0.7293, n_pix_y=1024, n_pix_z=1024,
24
+ beamstop_radius_px=30)
25
+ voids = SpherePopulation(radius_A=50.0, number_density_per_A3=1e-32,
26
+ delta_rho_e_per_A3=-2.2, label="voids")
27
+ frame = simulate_frame(geom, particles=[voids], sample_volume_A3=1e15)
28
+
29
+ The one number to keep in mind
30
+ ------------------------------
31
+ A dislocation loop of radius R displaces only its relaxation volume
32
+ ``pi R^2 b``, while a void of the same radius displaces ``4 pi R^3 / 3``. At
33
+ R = 5 nm in Cu that is a factor 27 in volume and ~700 in forward intensity. If
34
+ an irradiated sample has voids or bubbles, they dominate the small-angle image,
35
+ and a dislocation-only simulation will not resemble the measurement.
36
+
37
+ What distinguishes them is not magnitude but **shape**: voids are isotropic,
38
+ loops are not (``kappa dV`` in-plane versus ``dV`` along the normal, with
39
+ ``kappa = lambda/(lambda + 2 mu)``). That lives on the 2-D frame and is destroyed
40
+ by radial averaging — see :func:`midas_saxs.detector.azimuthal_profile`.
41
+ """
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ from .core_shell import (
46
+ core_shell_sphere_form_factor_squared,
47
+ multi_shell_sphere_form_factor_squared,
48
+ )
49
+ from .detector import (
50
+ Frame,
51
+ azimuthal_profile,
52
+ radial_average,
53
+ simulate_frame,
54
+ )
55
+ from .form_factors import (
56
+ cylinder_form_factor_squared,
57
+ ellipsoid_form_factor_squared,
58
+ percus_yevick_S,
59
+ sphere_form_factor_squared,
60
+ )
61
+ from .geometry import (
62
+ SAXSGeometry,
63
+ inv_A_to_inv_um,
64
+ inv_um_to_inv_A,
65
+ pixel_to_q,
66
+ q_magnitude_grid,
67
+ two_theta_to_q,
68
+ )
69
+ from .model import SAXSModel, lognormal_quadrature_nodes
70
+ from .particles import (
71
+ SpherePopulation,
72
+ loop_equivalent_sphere_radius_A,
73
+ void_intensity,
74
+ )
75
+ from .wide_band import (
76
+ GuinierFit,
77
+ PorodFit,
78
+ guinier_fit,
79
+ kratky_plot,
80
+ porod_fit,
81
+ porod_invariant,
82
+ worm_like_chain_form_factor_squared,
83
+ )
84
+
85
+ # `midas_saxs.strain_source` is NOT imported here: it needs midas_ddd, which is
86
+ # an optional extra. Import it explicitly, or go through `simulate_frame`, which
87
+ # only reaches for it when a network is actually passed.
88
+
89
+ __all__ = [
90
+ # geometry
91
+ "SAXSGeometry", "pixel_to_q", "q_magnitude_grid", "two_theta_to_q",
92
+ "inv_A_to_inv_um", "inv_um_to_inv_A",
93
+ # detector
94
+ "Frame", "simulate_frame", "radial_average", "azimuthal_profile",
95
+ # particles
96
+ "SpherePopulation", "void_intensity", "loop_equivalent_sphere_radius_A",
97
+ # form factors (migrated from midas_pdf.saxs)
98
+ "sphere_form_factor_squared", "ellipsoid_form_factor_squared",
99
+ "cylinder_form_factor_squared", "percus_yevick_S",
100
+ "core_shell_sphere_form_factor_squared", "multi_shell_sphere_form_factor_squared",
101
+ "SAXSModel", "lognormal_quadrature_nodes",
102
+ "GuinierFit", "guinier_fit", "PorodFit", "porod_fit", "porod_invariant",
103
+ "kratky_plot", "worm_like_chain_form_factor_squared",
104
+ "__version__",
105
+ ]
@@ -0,0 +1,122 @@
1
+ """Core-shell and multi-shell SAXS form factors.
2
+
3
+ The single-density sphere / ellipsoid / cylinder in
4
+ :mod:`midas_saxs.form_factors` model a uniform-density particle.
5
+ Many real nanoparticles are structured:
6
+
7
+ * Coated nanoparticles: metallic core + oxide / ligand shell.
8
+ * Reverse micelles: solvent core + amphiphile shell.
9
+ * Multi-shell nanocrystals: layered lattice + interface + surface region.
10
+
11
+ For these, the SAXS form factor is a difference of nested-sphere
12
+ amplitudes weighted by the contrast (electron-density difference)
13
+ in each layer.
14
+
15
+ For a two-shell (core + shell) spherical particle:
16
+
17
+ F(Q) = 4π (ρ_core − ρ_shell) V_core · j₁(Q R_core) / (Q R_core)
18
+ + 4π (ρ_shell − ρ_solvent) V_total · j₁(Q R_total) / (Q R_total)
19
+
20
+ where ``j₁`` is the spherical Bessel of order 1 and V is the volume of
21
+ each region. This generalises to N shells recursively.
22
+
23
+ All routines here return **|F(Q)|²** and are torch-differentiable in the
24
+ shell radii and contrast ratios.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from typing import Optional, Sequence
29
+
30
+ import numpy as np
31
+ import torch
32
+
33
+ _FOUR_PI = 4.0 * float(np.pi)
34
+
35
+
36
+ def _sphere_amplitude(q: torch.Tensor, R: torch.Tensor) -> torch.Tensor:
37
+ """Complex spherical-shell amplitude at Q = 0 is V (the volume).
38
+
39
+ Returns ``F(Q, R) = V · 3 [sin(QR) − QR cos(QR)] / (QR)³``
40
+ with a stable Q → 0 limit.
41
+ """
42
+ V = _FOUR_PI * R ** 3 / 3.0
43
+ x = q * R
44
+ small = x.abs() < 1e-3
45
+ # small-x: 1 − x²/10 + x⁴/280
46
+ shape_small = 1.0 - x ** 2 / 10.0 + x ** 4 / 280.0
47
+ x_safe = x.clamp(min=1e-9)
48
+ shape_general = 3.0 * (torch.sin(x_safe) - x_safe * torch.cos(x_safe)) / x_safe ** 3
49
+ return V * torch.where(small, shape_small, shape_general)
50
+
51
+
52
+ def core_shell_sphere_form_factor_squared(
53
+ q: torch.Tensor,
54
+ R_core_A: float | torch.Tensor,
55
+ R_total_A: float | torch.Tensor,
56
+ contrast_core: float | torch.Tensor = 1.0,
57
+ contrast_shell: float | torch.Tensor = 0.5,
58
+ ) -> torch.Tensor:
59
+ """|F(Q)|² for a core-shell spherical particle.
60
+
61
+ Parameters
62
+ ----------
63
+ R_core_A : radius of the core (Å).
64
+ R_total_A : outer radius (core + shell, Å); shell thickness =
65
+ R_total − R_core.
66
+ contrast_core : electron-density difference (core − solvent), arbitrary
67
+ units. Only the ratio (core − shell)/(shell − solvent) matters
68
+ for the shape of I(Q); the absolute scale drops into the caller's
69
+ overall scale factor.
70
+ contrast_shell : electron-density difference (shell − solvent).
71
+
72
+ Returns
73
+ -------
74
+ |F(Q)|² of the same units as (contrast · ų)².
75
+ """
76
+ q_t = torch.as_tensor(q, dtype=torch.float64)
77
+ R_c = torch.as_tensor(R_core_A, dtype=torch.float64)
78
+ R_t = torch.as_tensor(R_total_A, dtype=torch.float64)
79
+ delta_core = torch.as_tensor(contrast_core, dtype=torch.float64) \
80
+ - torch.as_tensor(contrast_shell, dtype=torch.float64)
81
+ delta_shell = torch.as_tensor(contrast_shell, dtype=torch.float64)
82
+ F_core = _sphere_amplitude(q_t, R_c)
83
+ F_total = _sphere_amplitude(q_t, R_t)
84
+ F = delta_core * F_core + delta_shell * F_total
85
+ return F ** 2
86
+
87
+
88
+ def multi_shell_sphere_form_factor_squared(
89
+ q: torch.Tensor,
90
+ radii_A: Sequence[float | torch.Tensor],
91
+ contrasts: Sequence[float | torch.Tensor],
92
+ ) -> torch.Tensor:
93
+ """|F(Q)|² for a multi-shell spherical particle.
94
+
95
+ ``radii_A``: list of *outer* radii of each shell (monotonically
96
+ increasing). E.g. for a 3-shell particle, ``[R_1, R_2, R_3]``.
97
+
98
+ ``contrasts``: contrast of *each* shell against the solvent (same
99
+ length as ``radii_A``). The interfacial contrast between successive
100
+ shells drives the SAXS signal.
101
+ """
102
+ q_t = torch.as_tensor(q, dtype=torch.float64)
103
+ if len(radii_A) != len(contrasts):
104
+ raise ValueError("len(radii_A) must equal len(contrasts)")
105
+ if len(radii_A) < 1:
106
+ raise ValueError("need at least one shell")
107
+ # F = sum_i (ρ_i − ρ_{i+1}) F_i, with ρ_{N+1} = solvent ≡ 0
108
+ F = torch.zeros_like(q_t)
109
+ for i in range(len(radii_A)):
110
+ rho_here = torch.as_tensor(contrasts[i], dtype=torch.float64)
111
+ rho_next = (torch.as_tensor(contrasts[i + 1], dtype=torch.float64)
112
+ if i + 1 < len(contrasts) else torch.tensor(0.0, dtype=torch.float64))
113
+ delta = rho_here - rho_next
114
+ R = torch.as_tensor(radii_A[i], dtype=torch.float64)
115
+ F = F + delta * _sphere_amplitude(q_t, R)
116
+ return F ** 2
117
+
118
+
119
+ __all__ = [
120
+ "core_shell_sphere_form_factor_squared",
121
+ "multi_shell_sphere_form_factor_squared",
122
+ ]