ffsi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ffsi/__init__.py ADDED
@@ -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/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
+ )
ffsi/array_module.py ADDED
@@ -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
@@ -0,0 +1,252 @@
1
+ """
2
+ Free-form SAS Optimization Interface to GALAHAD
3
+
4
+ Mandatory Parameters:
5
+ G - Green's function
6
+ I_data - intensity data
7
+ I_data_std - error on intensity data
8
+
9
+ Optional Parameters:
10
+ sigma - regularization parameter value
11
+
12
+ Returns:
13
+ xi_opt - optimal xi
14
+ b_opt - optimal b
15
+ w_opt_list - list of optimal parameters
16
+
17
+ Example usage:
18
+
19
+ xi_opt, b_opt, w_opt_list = optimize(G, I_data, I_data_std)
20
+
21
+ Copyright (C) 2026 The Science and Technology Facilities Council (STFC)
22
+ Author: Jaroslav Fowkes (STFC)
23
+ """
24
+ import numpy as np
25
+ from galahad import snls
26
+
27
+ from ffsi.array_module import get_array_module
28
+
29
+ from ffsi.utils import contract_tensor
30
+
31
+
32
+ def optimize(G, I_data, I_data_std, sigma=None):
33
+
34
+ # use CPU or GPU as appropriate
35
+ xp = get_array_module(G, I_data, I_data_std)
36
+ print("INFO: using " + xp.__name__ + " for residual and Jacobian computation")
37
+
38
+ # determine if data is 1D or 2D
39
+ if len(I_data.shape) == 1:
40
+ q_axes = (0,)
41
+ p_axes = tuple(range(1,G.ndim))
42
+ q_dims = G.shape[:1]
43
+ p_dims = G.shape[1:]
44
+ else:
45
+ q_axes = (0,1)
46
+ p_axes = tuple(range(2,G.ndim))
47
+ q_dims = G.shape[:2]
48
+ p_dims = G.shape[2:]
49
+
50
+ # w0 are uniform distributions
51
+ w0_list = [xp.ones(n) / n for n in p_dims]
52
+
53
+ # this averages out G over the parameters
54
+ G_ave = xp.sum(G, axis=p_axes) / np.prod(p_dims)
55
+
56
+ # and xi0 and b0 can be determined from
57
+ # min [1/sigma * (xi G_ave + b 1 - mu) ]^ 2
58
+ mu_over_nv = I_data / I_data_std
59
+ one_over_nv = 1 / I_data_std
60
+ G_ave_over_nv = G_ave / I_data_std
61
+ a11 = xp.sum(G_ave_over_nv ** 2)
62
+ a12 = xp.sum(G_ave_over_nv * one_over_nv)
63
+ a22 = xp.sum(one_over_nv ** 2)
64
+ b1 = xp.sum(mu_over_nv * G_ave_over_nv)
65
+ b2 = xp.sum(mu_over_nv * one_over_nv)
66
+
67
+ # solve xi0 and b0 using Cramer's rule
68
+ A = a11 * a22 - a12 * a12
69
+ xi0 = (b1 * a22 - b2 * a12) / A
70
+ b0 = (b2 * a11 - b1 * a12) / A
71
+ print('xi0: %.2e' % xi0)
72
+ print('b0: %.2e' % b0)
73
+
74
+ # determine xi0 and b0 scaling
75
+ xi_sc = 10 ** xp.floor(xp.log10(xp.abs(xi0)))
76
+ b_sc = 10 ** xp.floor(xp.log10(xp.abs(b0)))
77
+
78
+ # form residuals
79
+ def eval_r(x):
80
+
81
+ # move x to GPU if required
82
+ x = xp.asarray(x)
83
+
84
+ # extract variables and unscale
85
+ xi = x[0] * xi_sc
86
+ b = x[1] * b_sc
87
+ split_inds = np.cumsum(p_dims)[:-1]
88
+ w_list = xp.split(x[2:], split_inds)
89
+
90
+ # form Gw (the form factor)
91
+ Gw = contract_tensor(G, w_list, skip_axes=q_axes)
92
+
93
+ # intensity from forward model
94
+ I_model = xi * Gw + b
95
+
96
+ # intensity misfit
97
+ eps = (I_model - I_data) / I_data_std
98
+
99
+ # handle regularization
100
+ if sigma is None: # no regularization
101
+ res = eps.reshape(-1)
102
+ else: # regularisation terms sigma(w[i+1]-w[i])
103
+ reg = [sigma * xp.diff(w) for w in w_list]
104
+ res = xp.concatenate((eps.reshape(-1), *reg))
105
+
106
+ # move residual to CPU if required
107
+ if xp.__name__ == 'cupy':
108
+ return res.get()
109
+ else:
110
+ return res
111
+
112
+ # form Jacobian
113
+ def eval_Jr(x):
114
+
115
+ # move x to GPU if required
116
+ x = xp.asarray(x)
117
+
118
+ # extract variables and unscale
119
+ xi = x[0] * xi_sc
120
+ split_inds = np.cumsum(p_dims)[:-1]
121
+ w_list = xp.split(x[2:], split_inds)
122
+
123
+ # form Gw (the form factor)
124
+ Gw = contract_tensor(G, w_list, skip_axes=q_axes)
125
+
126
+ # preallocate storage for intensity misfit derivative
127
+ deps = xp.empty((*q_dims, 2+np.sum(p_dims)))
128
+
129
+ # xi derivative
130
+ deps[...,0] = (xi_sc * Gw ) / I_data_std # scaled
131
+
132
+ # b derivative
133
+ deps[...,1] = b_sc / I_data_std # scaled
134
+
135
+ # w derivatives
136
+ inds = np.cumsum((2,*p_dims))
137
+ for i in range(len(p_dims)):
138
+ slice_i = slice(inds[i],inds[i+1])
139
+ w_contract_list = [w for k,w in enumerate(w_list) if k != i]
140
+ Gw_dw = contract_tensor(G, w_contract_list, skip_axes=[*q_axes,p_axes[0]+i])
141
+ deps[...,slice_i] = ( xi * Gw_dw ) / I_data_std[...,None]
142
+
143
+ # flatten intensity misfit derivative
144
+ deps = deps.reshape(-1)
145
+
146
+ # handle regularization
147
+ if sigma is None: # no regularization
148
+ jac = deps
149
+ else: # regularization term derivatives (sparse)
150
+ dreg1 = [sigma * xp.ones(n-1) for n in p_dims] # w[i+1] terms
151
+ dreg2 = [-sigma * xp.ones(n - 1) for n in p_dims] # -w[i] terms
152
+ jac = xp.concatenate((deps, *dreg1, *dreg2))
153
+
154
+ # move Jacobian to CPU if required
155
+ if xp.__name__ == 'cupy':
156
+ return jac.get()
157
+ else:
158
+ return jac
159
+
160
+ # set GALAHAD SNLS options
161
+ options = snls.initialize()
162
+ options['maxit'] = 1000
163
+ options['print_level'] = 2
164
+ options['jacobian_available'] = 2
165
+ #options['slls_options']['print_level'] = 1
166
+ options['slls_options']['maxit'] = 250
167
+ options['slls_options']['sbls_options']['factorization'] = 1 # use Schur-complement
168
+ options['slls_options']['sbls_options']['symmetric_linear_solver'] = 'sytr '
169
+ options['slls_options']['sbls_options']['definite_linear_solver'] = 'potr '
170
+ options['sllsb_options']['symmetric_linear_solver'] = 'sytr '
171
+ options['sllsb_options']['fdc_options']['symmetric_linear_solver'] = 'sytr '
172
+ options['sllsb_options']['cro_options']['symmetric_linear_solver'] = 'sytr '
173
+ # stopping criteria
174
+ options['stop_pg_relative'] = 1e-15
175
+ options['stop_pg_absolute'] = 1e-7
176
+
177
+ # form and scale initial optimization variable
178
+ x0_scaled = xp.hstack((xi0/xi_sc,b0/b_sc,*w0_list))
179
+
180
+ # move initial guess to CPU if required
181
+ if xp.__name__ == 'cupy':
182
+ x0_scaled = x0_scaled.get()
183
+
184
+ # set GALAHAD SNLS dimensions
185
+ n = 2 + np.sum(p_dims)
186
+ if sigma is None: # no regularization
187
+ m_r = np.prod(q_dims)
188
+ else: # regularization requested
189
+ m_r = np.prod(q_dims) + np.sum(np.array(p_dims)-1)
190
+ m_c = len(p_dims)
191
+
192
+ # set GALAHAD SNLS cohorts
193
+ ch_list = [i * np.ones(n, dtype=int) for i,n in enumerate(p_dims)]
194
+ cohort = np.concat(( np.array([-1,-1]), *ch_list))
195
+
196
+ # set GALAHAD SNLS Jacobian info
197
+ if sigma is None: # no regularization
198
+ Jr_type = 'dense'
199
+ Jr_ne = m_r * n
200
+ Jr_row = None
201
+ Jr_col = None
202
+ else: # regularization requested
203
+ Jr_type = 'coordinate'
204
+ nq = np.prod(q_dims)
205
+ Jr_ne = nq*n + 2*np.sum(np.array(p_dims)-1)
206
+ # flattened intensity misfit derivative
207
+ Jr_eps_row = np.tile(np.arange(nq),(n,1)).flatten('F')
208
+ Jr_eps_col = np.tile(np.arange(n),nq)
209
+ # sparse regularization derivatives for w
210
+ split_inds = np.cumsum(np.array(p_dims)-1)[:-1]
211
+ Jr_reg1_row = np.split(np.arange(nq,nq+np.sum(np.array(p_dims)-1)), split_inds)
212
+ Jr_reg2_row = Jr_reg1_row.copy()
213
+ Jr_reg1_col = [] # w[i+1] terms
214
+ Jr_reg2_col = [] # -w[i] terms
215
+ starts = np.cumsum(p_dims) + 2 - p_dims
216
+ for st, dim in zip(starts, p_dims):
217
+ Jr_reg1_col.append(np.arange(st+1, st+dim))
218
+ Jr_reg2_col.append(np.arange(st, st+dim-1))
219
+ # combined derivative
220
+ Jr_row = np.concat((Jr_eps_row,*Jr_reg1_row,*Jr_reg2_row))
221
+ Jr_col = np.concat((Jr_eps_col,*Jr_reg1_col,*Jr_reg2_col))
222
+ Jr_ptr_ne = 0
223
+ Jr_ptr = None
224
+
225
+ # initialise GALAHAD SNLS
226
+ snls.load(n, m_r, m_c, Jr_type, Jr_ne, Jr_row, Jr_col, Jr_ptr_ne, Jr_ptr, cohort, options)
227
+
228
+ # call GALAHAD SNLS with variable scaling
229
+ print('\nCalling GALAHAD SNLS...')
230
+ x, y, z, r, g, x_stat = snls.solve(n, m_r, m_c, x0_scaled, eval_r, Jr_ne, eval_Jr)
231
+
232
+ # get information
233
+ info = snls.information()
234
+ #print("inform:", inform)
235
+ print(" f: %.4f" % info['obj'])
236
+ print('** snls exit status:', info['status'])
237
+
238
+ # extract results and unscale
239
+ xi_opt = x[0] * xi_sc
240
+ b_opt = x[1] * b_sc
241
+ split_inds = np.cumsum(p_dims)[:-1]
242
+ w_opt_list = np.split(x[2:], split_inds)
243
+
244
+ print()
245
+ print('xi*: %.2e' % xi_opt)
246
+ print('b*: %.2e' % b_opt)
247
+ print('r*: %.15e' % np.linalg.norm(eval_r(x)))
248
+
249
+ # finalise GALAHAD SNLS
250
+ snls.terminate()
251
+
252
+ return xi_opt, b_opt, w_opt_list