ffsi 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.
ffsi-0.1.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, The Science and Technology Facilities Council (STFC)
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
ffsi-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: ffsi
3
+ Version: 0.1.0
4
+ Summary: A package for the free-form inversion of Small Angle Scattering problems arising from X-ray and Neutron sources
5
+ Author-email: Jaroslav Fowkes <jaroslav.fowkes@maths.ox.ac.uk>
6
+ Maintainer-email: Jaroslav Fowkes <jaroslav.fowkes@maths.ox.ac.uk>
7
+ License-Expression: BSD-3-Clause
8
+ Project-URL: Homepage, https://github.com/ralna/free-form-sas-inversion/
9
+ Project-URL: Download, https://github.com/ralna/free-form-sas-inversion/releases/
10
+ Project-URL: Bug Tracker, https://github.com/ralna/free-form-sas-inversion/issues/
11
+ Project-URL: Documentation, https://ralna.github.io/free-form-sas-inversion/
12
+ Project-URL: Source Code, https://github.com/ralna/free-form-sas-inversion/
13
+ Keywords: physics,optimization
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Environment :: GPU
17
+ Classifier: Environment :: GPU :: NVIDIA CUDA
18
+ Classifier: Intended Audience :: Science/Research
19
+ Classifier: Operating System :: Unix
20
+ Classifier: Operating System :: MacOS
21
+ Classifier: Operating System :: Microsoft :: Windows
22
+ Classifier: Programming Language :: Python
23
+ Classifier: Programming Language :: Python :: 3
24
+ Classifier: Topic :: Scientific/Engineering
25
+ Classifier: Topic :: Scientific/Engineering :: Physics
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: setuptools
30
+ Requires-Dist: numpy
31
+ Requires-Dist: scipy
32
+ Requires-Dist: galahad-optrove
33
+ Dynamic: license-file
34
+
35
+ # Free-Form SAS Inversion for SAXS/SANS
36
+ A package for the *free-form* inversion of Small Angle Scattering (SAS) problems arising from X-ray and Neutron sources.
37
+
38
+ This is a much improved algorithm and implementation over that proposed in the paper:
39
+ https://journals.iucr.org/j/issues/2022/04/00/jl5041/index.html
40
+
41
+ ## SAS Inversion
42
+ Small Angle Scattering is used to probe and analyse the nanoscale structure of materials.
43
+ An excellent introduction to (fixed-form) SAS inversion can be found in:
44
+ https://journals.iucr.org/j/issues/2021/06/00/gj5274/gj5274.pdf
45
+
46
+ ## Free-Form SAS Inversion
47
+ Free-form SAS inversion seeks to determine the distributions of structural properties of nanoparticles (e.g. radius, length),
48
+ but cruically (unlike conventional SAS inversion) does not make any assumptions about the forms these distributions take (hence *free-form*).
49
+
50
+ A Small Angle Scattering experiment records the intensity, i.e. squared amplitude, $I(q)$ of the scattered wave as a function of the scattering vector $q$.
51
+ The scattering vector $q$ is itself a function of the small angle $\theta$ and is given by
52
+ ```math
53
+ q = \frac{4 \pi}{\lambda} \sin\left(\frac{\theta}{2}\right)
54
+ ```
55
+ where $\lambda$ is the wavelength of the radiation source. For small angles, $\sin(\theta) \approx \theta$ and the scattering vector reduces to
56
+ ```math
57
+ q \approx \frac{2 \pi}{\lambda}\theta
58
+ ```
59
+ i.e. essentially the small angle normalised by the wavelength of the radiation.
60
+
61
+ ### Form Factors (aka Green's Functions)
62
+ The form factor $F(q)$ describes the intensity $I(q)$ of scattering from a *single* nanoparticle over a full range of q and constitutes its SAS fingerprint.
63
+ For example, the form factor $F(q)$ for a sphere of radius $r$ is given via $j_1(z)$ the spherical Bessel function of the first kind as
64
+ ```math
65
+ F(q) = \left[ V(r)\Delta\rho \dfrac{3j_1(qr)}{qr} \right]^2
66
+ ```
67
+ where $V$ is the sphere volume and $\Delta\rho$ the scattering length density difference.
68
+
69
+ This repository currently contains the form factors for:
70
+
71
+ - [Sphere (1D)](https://www.sasview.org/docs/user/models/sphere.html)
72
+ - [Cylinder (1D and 2D)](https://www.sasview.org/docs/user/models/cylinder.html)
73
+ - [Ellipsoid (1D and 2D)](https://www.sasview.org/docs/user/models/ellipsoid.html)
74
+
75
+ More form-factors will be added as the project progresses (as the code in this repository is vectorized and GPU accelerated, we are unable to directly use the form factors from SASView).
76
+
77
+ ### Polydispersity
78
+ In a real SAS experiment, we are likely to see a population of nanoparticles that possess size and/or orientational distributions, this is called *polydispersity*.
79
+ The resultant intensity $I(q)$ is then averaged over the distributions.
80
+ For example, for spheres with different radii,
81
+ ```math
82
+ I(q) = \int F(q,r) w(r) dr
83
+ ```
84
+ where $w(r)$ is the distribution over the radius.
ffsi-0.1.0/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Free-Form SAS Inversion for SAXS/SANS
2
+ A package for the *free-form* inversion of Small Angle Scattering (SAS) problems arising from X-ray and Neutron sources.
3
+
4
+ This is a much improved algorithm and implementation over that proposed in the paper:
5
+ https://journals.iucr.org/j/issues/2022/04/00/jl5041/index.html
6
+
7
+ ## SAS Inversion
8
+ Small Angle Scattering is used to probe and analyse the nanoscale structure of materials.
9
+ An excellent introduction to (fixed-form) SAS inversion can be found in:
10
+ https://journals.iucr.org/j/issues/2021/06/00/gj5274/gj5274.pdf
11
+
12
+ ## Free-Form SAS Inversion
13
+ Free-form SAS inversion seeks to determine the distributions of structural properties of nanoparticles (e.g. radius, length),
14
+ but cruically (unlike conventional SAS inversion) does not make any assumptions about the forms these distributions take (hence *free-form*).
15
+
16
+ A Small Angle Scattering experiment records the intensity, i.e. squared amplitude, $I(q)$ of the scattered wave as a function of the scattering vector $q$.
17
+ The scattering vector $q$ is itself a function of the small angle $\theta$ and is given by
18
+ ```math
19
+ q = \frac{4 \pi}{\lambda} \sin\left(\frac{\theta}{2}\right)
20
+ ```
21
+ where $\lambda$ is the wavelength of the radiation source. For small angles, $\sin(\theta) \approx \theta$ and the scattering vector reduces to
22
+ ```math
23
+ q \approx \frac{2 \pi}{\lambda}\theta
24
+ ```
25
+ i.e. essentially the small angle normalised by the wavelength of the radiation.
26
+
27
+ ### Form Factors (aka Green's Functions)
28
+ The form factor $F(q)$ describes the intensity $I(q)$ of scattering from a *single* nanoparticle over a full range of q and constitutes its SAS fingerprint.
29
+ For example, the form factor $F(q)$ for a sphere of radius $r$ is given via $j_1(z)$ the spherical Bessel function of the first kind as
30
+ ```math
31
+ F(q) = \left[ V(r)\Delta\rho \dfrac{3j_1(qr)}{qr} \right]^2
32
+ ```
33
+ where $V$ is the sphere volume and $\Delta\rho$ the scattering length density difference.
34
+
35
+ This repository currently contains the form factors for:
36
+
37
+ - [Sphere (1D)](https://www.sasview.org/docs/user/models/sphere.html)
38
+ - [Cylinder (1D and 2D)](https://www.sasview.org/docs/user/models/cylinder.html)
39
+ - [Ellipsoid (1D and 2D)](https://www.sasview.org/docs/user/models/ellipsoid.html)
40
+
41
+ More form-factors will be added as the project progresses (as the code in this repository is vectorized and GPU accelerated, we are unable to directly use the form factors from SASView).
42
+
43
+ ### Polydispersity
44
+ In a real SAS experiment, we are likely to see a population of nanoparticles that possess size and/or orientational distributions, this is called *polydispersity*.
45
+ The resultant intensity $I(q)$ is then averaged over the distributions.
46
+ For example, for spheres with different radii,
47
+ ```math
48
+ I(q) = \int F(q,r) w(r) dr
49
+ ```
50
+ where $w(r)$ is the distribution over the radius.
@@ -0,0 +1,16 @@
1
+ """
2
+ Free-Form SAS Inversion
3
+
4
+ Copyright (C) 2026 The Science and Technology Facilities Council (STFC)
5
+ Author: Jaroslav Fowkes (STFC)
6
+ """
7
+ CUPY_INSTALLED = False
8
+
9
+ try:
10
+ import cupy as _cupy
11
+ CUPY_INSTALLED = True
12
+ print('INFO: CuPy is installed, GPU computation is available')
13
+ except Exception as e:
14
+ print('WARNING: CuPy is not installed, cannot use GPU computation')
15
+ print(e)
16
+ print('WARNING: continuing with CPU computation only')
ffsi-0.1.0/ffsi/api.py ADDED
@@ -0,0 +1,191 @@
1
+ """
2
+ Public API for free-form SAS inversion.
3
+ """
4
+ import importlib.util
5
+ from dataclasses import dataclass, field
6
+ from importlib import import_module
7
+ from inspect import getmembers, isabstract, isclass
8
+
9
+ import numpy as np
10
+
11
+ from ffsi.array_module import get_array_module, to_device, from_device
12
+ from ffsi.models.basemodel import SASModel
13
+ from ffsi.optimize_galahad import optimize
14
+ from ffsi.utils import contract_tensor, xi_to_scale
15
+
16
+
17
+ # Model names available through invert(), for error messages only
18
+ _MODEL_NAMES = ("sphere", "cylinder", "cylinder2d", "ellipsoid", "ellipsoid2d")
19
+
20
+
21
+ def _resolve_model(model):
22
+ """
23
+ Resolve `model` (a case-insensitive name or a `SASModel` subclass) to
24
+ `(class, name)`.
25
+
26
+ The model class is imported lazily from its own module `ffsi.models.<name>`
27
+ (module name == class name lowercased), so only the requested model is
28
+ loaded rather than every model up front.
29
+ """
30
+ name = (model if isinstance(model, str) else model.__name__).lower()
31
+
32
+ # return a proper error
33
+ if importlib.util.find_spec(f"ffsi.models.{name}") is None:
34
+ raise ValueError(
35
+ "Unknown model '{}', available models: {}".format(
36
+ name, ", ".join(_MODEL_NAMES)
37
+ )
38
+ )
39
+
40
+ module = import_module(f"ffsi.models.{name}")
41
+
42
+ classes = getmembers(
43
+ module,
44
+ lambda m: (
45
+ isclass(m)
46
+ and not isabstract(m)
47
+ and issubclass(m, SASModel)
48
+ and m is not SASModel
49
+ and m.__module__ == module.__name__
50
+ ),
51
+ )
52
+ if not classes:
53
+ raise ValueError(f"Module 'ffsi.models.{name}' defines no SASModel subclass")
54
+
55
+ return classes[0][1], name
56
+
57
+
58
+ @dataclass
59
+ class ParamDistribution:
60
+ """Fitted distribution of one model parameter."""
61
+
62
+ name: str # model parameter name, e.g. 'r', 'l', 'rp', 're'
63
+ grid: np.ndarray # bin centers
64
+ weights: np.ndarray # weights
65
+
66
+ volume_weights: np.ndarray = None
67
+
68
+
69
+ @dataclass
70
+ class InversionResult:
71
+ """Output of `invert()`: I_opt = xi * Gw + background."""
72
+
73
+ model: str # model name
74
+ xi: float # raw scale factor
75
+ background: float
76
+ distributions: list = field(default_factory=list)
77
+ theory: np.ndarray = None # I_opt on the input q
78
+ residuals: np.ndarray = None # (theory - intensity) / intensity_std
79
+ chi2: float = None # sum(residuals**2) / residuals.size
80
+ average_volume: float = None # <V> under the optimal weights
81
+ drho: float = None # contrast: sld - sld_solvent
82
+ scale: float = None # volume fraction: xi * <V> * 1e4
83
+
84
+ def distribution(self, name):
85
+ """The fitted `ParamDistribution` for parameter `name`."""
86
+ for dist in self.distributions:
87
+ if dist.name == name:
88
+ return dist
89
+ raise KeyError("No distribution for parameter '{}', have: {}".format(name, ", ".join(d.name for d in self.distributions)))
90
+
91
+
92
+ def _build_grid(spec, xp):
93
+ """Bin centers (on backend `xp`) from a (min, max, nbins) triple"""
94
+ if isinstance(spec, np.ndarray) or (np.ndim(spec) == 1 and len(spec) > 3):
95
+ return xp.asarray(spec, dtype=float)
96
+ lo, hi, nbins = spec
97
+ return xp.linspace(float(lo), float(hi), int(nbins))
98
+
99
+
100
+ def invert(model, q, intensity, intensity_std, grids, *, sld, sld_solvent, sigma=None):
101
+ """
102
+ Free-form inversion of 1D SAS data.
103
+
104
+ :param model: model name ('sphere', 'cylinder', 'ellipsoid';
105
+ :param q: scattering vectors
106
+ :param intensity: measured intensity `I(q)`
107
+ :param intensity_std: intensity standard deviations `dI(q)`
108
+ :param grids: `dict` keyed by the model's parameter names; each value
109
+ is a `(min, max, nbins)` triple or a prebuilt 1D array of bin centers
110
+ :param sld: scattering length density of the particle,
111
+ with `sld_solvent` it gives the contrast `drho = sld - sld_solvent`
112
+ :param sld_solvent: scattering length density of the solvent, in 1e-6 A^-2
113
+ :param sigma: smoothness regularization weight (`None` disables it)
114
+ :return: an `InversionResult`; `scale` is the volume fraction `xi * <V> * 1e4`
115
+
116
+ Computation runs on the GPU automatically whenever CuPy is installed; inputs
117
+ may be plain numpy arrays (they are moved onto the GPU here), so callers need
118
+ no CuPy dependency of their own. Without CuPy it runs on the CPU.
119
+ """
120
+
121
+ # resolve a name or a SASModel subclass to (class, name)
122
+ model_class, model_name = _resolve_model(model)
123
+ param_names = list(model_class.param_names_scattering_intensity)
124
+
125
+ # contrast: drho = sld - sld_solvent
126
+ drho = float(sld) - float(sld_solvent)
127
+
128
+ # move host inputs onto the compute backend (GPU when CuPy is available)
129
+ # array-module dispatch below and everything downstream run on that backend
130
+ q, intensity, intensity_std = to_device(q, intensity, intensity_std)
131
+
132
+ # xp resolves to cupy when the inputs are on the GPU, else numpy
133
+ xp = get_array_module(q, intensity, intensity_std)
134
+ q = xp.ascontiguousarray(q, dtype=float)
135
+ intensity = xp.asarray(intensity, dtype=float)
136
+ intensity_std = xp.asarray(intensity_std, dtype=float)
137
+ # build grids on the same backend
138
+ param_list = [_build_grid(grids[name], xp) for name in param_names]
139
+
140
+ # scattering intensity (Green's tensor) and inversion
141
+ G = model_class.compute_scattering_intensity([q], param_list, drho)
142
+ xi, background, w_opt_list = optimize(G, intensity, intensity_std, sigma=sigma)
143
+ xi, background = float(xi), float(background)
144
+ # GALAHAD returns numpy weights; move them onto G's backend to reconstruct
145
+ w_list = [xp.asarray(w) for w in w_opt_list]
146
+
147
+ # fitted intensity, residuals and chi-squared
148
+ theory = xi * contract_tensor(G, w_list, skip_axes=[0]) + background
149
+ residuals = (theory - intensity) / intensity_std
150
+ chi2 = float(xp.sum(residuals**2) / residuals.size)
151
+
152
+ # average volume
153
+ volume_params = [param_list[param_names.index(name)]
154
+ for name in model_class.param_names_average_volume]
155
+ volume_weights_list = [w_list[param_names.index(name)]
156
+ for name in model_class.param_names_average_volume]
157
+ average_volume = float(model_class.compute_average_volume(volume_params, volume_weights_list))
158
+
159
+ # convert xi to SasView scale
160
+ scale = xi_to_scale(xi, average_volume)
161
+
162
+ # package results as host numpy as plotters and the GUI
163
+ # cannot take cupy arrays
164
+ distributions = []
165
+ for name, grid, weights in zip(param_names, param_list, w_list):
166
+ volume_weights = None
167
+ if len(param_names) == 1:
168
+ volume = model_class.compute_volume(param_list)
169
+ weighted = weights * volume
170
+ volume_weights = from_device(weighted / xp.sum(weighted))
171
+ distributions.append(
172
+ ParamDistribution(
173
+ name=name,
174
+ grid=from_device(grid),
175
+ weights=from_device(weights),
176
+ volume_weights=volume_weights,
177
+ )
178
+ )
179
+
180
+ return InversionResult(
181
+ model=model_name,
182
+ xi=xi,
183
+ background=background,
184
+ distributions=distributions,
185
+ theory=from_device(theory),
186
+ residuals=from_device(residuals),
187
+ chi2=chi2,
188
+ average_volume=average_volume,
189
+ drho=drho,
190
+ scale=scale,
191
+ )
@@ -0,0 +1,42 @@
1
+ """
2
+ Get array module that works when CuPy is not installed
3
+
4
+ Copyright (C) 2026 The Science and Technology Facilities Council (STFC)
5
+ Author: Jaroslav Fowkes (STFC)
6
+ """
7
+ import numpy as _numpy
8
+ import scipy as _scipy
9
+
10
+ from ffsi import CUPY_INSTALLED
11
+
12
+ def get_array_module(*args):
13
+ if CUPY_INSTALLED:
14
+ import cupy as cp
15
+ return cp.get_array_module(*args)
16
+ else:
17
+ return _numpy
18
+
19
+ def get_science_module(*args):
20
+ if CUPY_INSTALLED:
21
+ import cupyx.scipy as cps
22
+ return cps.get_array_module(*args)
23
+ else:
24
+ return _scipy
25
+
26
+ def to_device(*arrays):
27
+ """
28
+ Bring arrays from the host to the device.
29
+ """
30
+ if CUPY_INSTALLED:
31
+ import cupy as cp
32
+ return (cp.asarray(a) for a in arrays)
33
+ return arrays
34
+
35
+ def from_device(array):
36
+ """
37
+ Bring an array from the device to the host.
38
+ """
39
+ if CUPY_INSTALLED:
40
+ import cupy as cp
41
+ return cp.asnumpy(array)
42
+ return array
@@ -0,0 +1,34 @@
1
+ """
2
+ Crazy distributions generator
3
+
4
+ Copyright (C) 2026 The Science and Technology Facilities Council (STFC)
5
+ Author: Jaroslav Fowkes (STFC)
6
+ """
7
+ from ffsi.array_module import get_array_module
8
+
9
+ def crazy_distribution(x, gaussians, noise_level, fade_start, fade_end, seed=0):
10
+
11
+ # use CPU or GPU as appropriate
12
+ xp = get_array_module(x)
13
+
14
+ # create
15
+ w_true = xp.zeros(x.shape)
16
+
17
+ # add Gaussians
18
+ for factor, mean, stddev in gaussians:
19
+ w_true += factor * xp.exp(-((x - mean) / stddev) ** 2)
20
+
21
+ # add noise
22
+ xp.random.seed(seed)
23
+ w_true += noise_level * xp.random.rand(*x.shape) * xp.random.rand(*x.shape)
24
+
25
+ # fade both ends to make it look nicer
26
+ if len(x) >= 3:
27
+ w_true[0:fade_start] = 0.
28
+ w_true[fade_start:fade_end] *= xp.linspace(0, 1, fade_end - fade_start)
29
+ w_true[-fade_start:] = 0.
30
+ w_true[-fade_end:-fade_start] *= xp.linspace(1, 0, fade_end - fade_start)
31
+
32
+ # normalize to 1
33
+ w_true /= xp.sum(w_true)
34
+ return w_true