Skip to content

Commit 5bdfdce

Browse files
authored
Merge branch 'master' into use-dQ-Data-slit-length-and-width-switched
2 parents a6d8ec0 + 366f9b3 commit 5bdfdce

File tree

6 files changed

+216
-79
lines changed

6 files changed

+216
-79
lines changed

sasmodels/compare.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,7 @@ def compare(opts, limits=None, maxdim=None):
812812
if have_comp:
813813
plot_profile(comp._kernel.info, label='comp', **comp_pars)
814814
pylab.legend()
815-
if opts['plot']:
815+
if opts['plot'] or opts['show_weights'] or opts['show_profile']:
816816
import matplotlib.pyplot as plt
817817
plt.show()
818818
return limits

sasmodels/data.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ class Source:
134134
class Sample:
135135
...
136136

137+
def _as_numpy(data):
138+
return None if data is None else np.asarray(data)
139+
137140
class Data1D(object):
138141
"""
139142
1D data object.
@@ -163,11 +166,12 @@ class Data1D(object):
163166
"""
164167
def __init__(self, x=None, y=None, dx=None, dy=None):
165168
# type: (OptArray, OptArray, OptArray, OptArray) -> None
166-
self.x, self.y, self.dx, self.dy = x, y, dx, dy
169+
self.x, self.dx = _as_numpy(x), _as_numpy(dx)
170+
self.y, self.dy = _as_numpy(y), _as_numpy(dy)
167171
self.dxl = None
168172
self.filename = None
169-
self.qmin = x.min() if x is not None else np.NaN
170-
self.qmax = x.max() if x is not None else np.NaN
173+
self.qmin = self.x.min() if self.x is not None else np.NaN
174+
self.qmax = self.x.max() if self.x is not None else np.NaN
171175
# TODO: why is 1D mask False and 2D mask True?
172176
self.mask = (np.isnan(y) if y is not None
173177
else np.zeros_like(x, 'b') if x is not None
@@ -240,13 +244,13 @@ class Data2D(object):
240244
"""
241245
def __init__(self, x=None, y=None, z=None, dx=None, dy=None, dz=None):
242246
# type: (OptArray, OptArray, OptArray, OptArray, OptArray, OptArray) -> None
243-
self.qx_data, self.dqx_data = x, dx
244-
self.qy_data, self.dqy_data = y, dy
245-
self.data, self.err_data = z, dz
247+
self.qx_data, self.dqx_data = _as_numpy(x), _as_numpy(dx)
248+
self.qy_data, self.dqy_data = _as_numpy(y), _as_numpy(dy)
249+
self.data, self.err_data = _as_numpy(z), _as_numpy(dz)
246250
self.mask = (np.isnan(z) if z is not None
247251
else np.zeros_like(x, dtype='bool') if x is not None
248252
else None)
249-
self.q_data = np.sqrt(x**2 + y**2)
253+
self.q_data = np.sqrt(self.qx_data**2 + self.qy_data**2)
250254
self.qmin = 1e-16
251255
self.qmax = np.inf
252256
self.detector = []

sasmodels/direct_model.py

Lines changed: 143 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,15 @@ def get_mesh(model_info, values, dim='1d', mono=False):
118118
active = lambda name: True
119119

120120
#print("in get_mesh: pars:",[p.id for p in parameters.call_parameters])
121-
mesh = [_get_par_weights(p, values, active(p.name))
121+
values = values.copy()
122+
mesh = [_pop_par_weights(p, values, active(p.name))
122123
for p in parameters.call_parameters]
124+
if values:
125+
raise TypeError(f"Unused parameters in call: {', '.join(values.keys())}")
123126
return mesh
124127

125128

126-
def _get_par_weights(parameter, values, active=True):
129+
def _pop_par_weights(parameter, values, active=True):
127130
# type: (Parameter, Dict[str, float], bool) -> Tuple[float, np.ndarray, np.ndarray]
128131
"""
129132
Generate the distribution for parameter *name* given the parameter values
@@ -132,34 +135,27 @@ def _get_par_weights(parameter, values, active=True):
132135
Uses "name", "name_pd", "name_pd_type", "name_pd_n", "name_pd_sigma"
133136
from the *pars* dictionary for parameter value and parameter dispersion.
134137
"""
135-
value = float(values.get(parameter.name, parameter.default))
136-
npts = values.get(parameter.name+'_pd_n', 0)
137-
width = values.get(parameter.name+'_pd', 0.0)
138-
relative = parameter.relative_pd
139-
if npts == 0 or width == 0.0 or not active:
140-
# Note: orientation parameters have the viewing angle as the parameter
141-
# value and the jitter in the distribution, so be sure to set the
142-
# empty pd for orientation parameters to 0.
143-
pd = [value if relative or not parameter.polydisperse else 0.0], [1.0]
138+
value = float(values.pop(parameter.name, parameter.default))
139+
if parameter.polydisperse:
140+
npts = values.pop(parameter.name+'_pd_n', 0)
141+
width = values.pop(parameter.name+'_pd', 0.0)
142+
nsigma = values.pop(parameter.name+'_pd_nsigma', 3.0)
143+
distribution = values.pop(parameter.name+'_pd_type', 'gaussian')
144+
relative = parameter.relative_pd
145+
if npts == 0 or width == 0.0 or not active:
146+
# Note: orientation parameters have the viewing angle as the parameter
147+
# value and the jitter in the distribution, so be sure to set the
148+
# empty pd for orientation parameters to 0.
149+
pd = [value if relative else 0.0], [1.0]
150+
else:
151+
limits = parameter.limits
152+
pd = weights.get_weights(distribution, npts, width, nsigma,
153+
value, limits, relative)
144154
else:
145-
limits = parameter.limits
146-
disperser = values.get(parameter.name+'_pd_type', 'gaussian')
147-
nsigma = values.get(parameter.name+'_pd_nsigma', 3.0)
148-
pd = weights.get_weights(disperser, npts, width, nsigma,
149-
value, limits, relative)
155+
pd = [value], [1.0]
150156
return value, pd[0], pd[1]
151157

152158

153-
def _vol_pars(model_info, values):
154-
# type: (ModelInfo, ParameterSet) -> Tuple[np.ndarray, np.ndarray]
155-
vol_pars = [_get_par_weights(p, values)
156-
for p in model_info.parameters.call_parameters
157-
if p.type == 'volume']
158-
#import pylab; pylab.plot(vol_pars[0][0],vol_pars[0][1]); pylab.show()
159-
dispersity, weight = dispersion_mesh(model_info, vol_pars)
160-
return dispersity, weight
161-
162-
163159
def _make_sesans_transform(data):
164160
# Pre-compute the Hankel matrix (H)
165161
SElength, SEunits = data.x, data._xunit
@@ -260,10 +256,11 @@ def _interpret_data(self, data: Data, model: KernelModel) -> None:
260256
else:
261257
res = resolution.Perfect1D(q)
262258
elif (getattr(data, 'dxl', None) is not None
263-
and getattr(data, 'dxw', None) is not None):
264-
res = resolution.Slit1D(data.x[index],
265-
qx_width=data.dxl[index],
266-
qy_width=data.dxw[index])
259+
or getattr(data, 'dxw', None) is not None):
260+
res = resolution.Slit1D(
261+
data.x[index],
262+
q_length=None if data.dxl is None else data.dxl[index],
263+
q_width=None if data.dxw is None else data.dxw[index])
267264
else:
268265
res = resolution.Perfect1D(data.x[index])
269266
elif self.data_type == 'Iq-oriented':
@@ -278,9 +275,10 @@ def _interpret_data(self, data: Data, model: KernelModel) -> None:
278275
or getattr(data, 'dxw', None) is None):
279276
raise ValueError("oriented sample with 1D data needs slit resolution")
280277

281-
res = resolution2d.Slit2D(data.x[index],
282-
qx_width=data.dxw[index],
283-
qy_width=data.dxl[index])
278+
res = resolution2d.Slit2D(
279+
data.x[index],
280+
qx_width=data.dxw[index],
281+
qy_width=data.dxl[index])
284282
else:
285283
raise ValueError("Unknown data type") # never gets here
286284

@@ -456,43 +454,142 @@ def test_reparameterize():
456454
except Exception:
457455
pass
458456

457+
def _direct_calculate(model, data, pars):
458+
from .core import load_model_info, build_model
459+
model_info = load_model_info(model)
460+
kernel = build_model(model_info)
461+
calculator = DirectModel(data, kernel)
462+
return calculator(**pars)
463+
464+
def Iq(model, q, dq=None, ql=None, qw=None, **pars):
465+
"""
466+
Compute I(q) for *model*. Resolution is *dq* for pinhole or *ql* and *qw*
467+
for slit geometry. Use 0 or None for infinite slits.
468+
469+
Model is the name of a builtin or custom model, or a model expression, such
470+
as sphere+sphere for a mixture of spheres of different radii, or
471+
sphere@hardsphere for concentrated solutions where the dilute approximation
472+
no longer applies.
473+
474+
Use additional keywords for model parameters, tagged with *_pd*, *_pd_n*,
475+
*_pd_nsigma*, *_pd_type* to set polydispersity parameters, or *_M0*,
476+
*_mphi*, *_mtheta* for magnetic parameters.
477+
478+
This is not intended for use when the same I(q) is evaluated many times
479+
with different parameter values. For that you should set up the model
480+
with `model = build_model(load_model_info(model_name))`, set up a data
481+
object to define q values and resolution, then use
482+
`calculator = DirectModel(data, model)` to set up a calculator, or
483+
`problem = bumps.FitProblem(sasmodels.bumps_model.Experiment(data, model))`
484+
to define a fit problem for uses with the bumps optimizer. Data can be
485+
loaded using the `sasdata` package, or use one of the empty data generators
486+
from `sasmodels.data`.
487+
488+
Models are cached. Custom models will not be reloaded even if the
489+
underlying files have changed. If you are using this in a long running
490+
application then you will need to call
491+
`sasmodels.direct_model._model_cache.clear()` to reset the cache and force
492+
custom model reload.
493+
"""
494+
from .data import Data1D, _as_numpy
495+
data = Data1D(x=q, dx=dq)
496+
def broadcast(v):
497+
return (
498+
None if v is None
499+
else np.full(len(q), v) if np.isscalar(v)
500+
else _as_numpy(v))
501+
data.dxl, data.dxw = broadcast(ql), broadcast(qw)
502+
return _direct_calculate(model, data, pars)
503+
504+
def Iqxy(model, qx, qy, dqx=None, dqy=None, **pars):
505+
"""
506+
Compute I(qx, qy) for *model*. Resolution is *dqx* and *dqy*.
507+
See :func:`Iq` for details on model and parameters.
508+
"""
509+
from .data import Data2D
510+
data = Data2D(x=qx, y=qy, dx=dqx, dy=dqy)
511+
return _direct_calculate(model, data, pars)
512+
513+
def Gxi(model, xi, **pars):
514+
"""
515+
Compute SESANS correlation G' = G(xi) - G(0) for *model*.
516+
See :func:`Iq` for details on model and parameters.
517+
"""
518+
from .data import empty_sesans
519+
data = empty_sesans(z=xi)
520+
return _direct_calculate(model, data, pars)
459521

460522
def main():
461523
# type: () -> None
462524
"""
463525
Program to evaluate a particular model at a set of q values.
464526
"""
465527
import sys
466-
from .data import empty_data1D, empty_data2D
467-
from .core import load_model_info, build_model
468528

469529
if len(sys.argv) < 3:
470530
print("usage: python -m sasmodels.direct_model modelname (q|qx,qy) par=val ...")
471531
sys.exit(1)
472-
model_name = sys.argv[1]
532+
model = sys.argv[1]
473533
call = sys.argv[2].upper()
534+
pars = dict((k, (float(v) if not k.endswith("_pd_type") else v))
535+
for pair in sys.argv[3:]
536+
for k, v in [pair.split('=')])
474537
try:
475538
values = [float(v) for v in call.split(',')]
476539
except ValueError:
477540
values = []
478541
if len(values) == 1:
479542
q, = values
480-
data = empty_data1D([q])
543+
dq = dqw = dql = None
544+
#dq = [q*0.05] # 5% pinhole resolution
545+
#dqw, dql = [q*0.05], [1.0] # 5% horizontal slit resolution
546+
print(Iq(model, [q], dq=dq, qw=dqw, ql=dql, **pars)[0])
547+
#print(Gxi(model, [q], **pars)[0])
481548
elif len(values) == 2:
482549
qx, qy = values
483-
data = empty_data2D([qx], [qy])
550+
dq = None
551+
#dq = [0.005] # 5% pinhole resolution at q = 0.1
552+
print(Iqxy(model, [qx], [qy], dqx=dq, dqy=dq, **pars)[0])
484553
else:
485554
print("use q or qx,qy")
486555
sys.exit(1)
487556

488-
model_info = load_model_info(model_name)
489-
model = build_model(model_info)
490-
calculator = DirectModel(data, model)
491-
pars = dict((k, (float(v) if not k.endswith("_pd_type") else v))
492-
for pair in sys.argv[3:]
493-
for k, v in [pair.split('=')])
494-
Iq = calculator(**pars)
495-
print(Iq[0])
557+
def test_simple_interface():
558+
def near(value, target):
559+
"""Close enough in single precision"""
560+
#print(f"value: {value}, target: {target}")
561+
return np.allclose(value, target, rtol=1e-6, atol=0, equal_nan=True)
562+
# Note: target values taken from running main() on parameters.
563+
# Resolution was 5% dq/q.
564+
pars = dict(radius=200)
565+
# simple sphere in 1D (perfect, pinhole, slit)
566+
assert near(Iq('sphere', [0.1], **pars), [0.6200146273894904])
567+
assert near(Iq('sphere', [0.1], dq=[0.005], **pars), [2.3019224683980215])
568+
assert near(Iq('sphere', [0.1], qw=[0.005], ql=[1.0], **pars), [0.3673431784535172])
569+
# simple sphere in 2D (perfect, pinhole)
570+
assert near(Iqxy('sphere', [0.1], [0.1], **pars), [1.1781532874802199])
571+
assert near(Iqxy('sphere', [0.1], [0.1], dqx=[0.005], dqy=[0.005], **pars),
572+
[0.8177780778578667])
573+
# sesans
574+
assert near(Gxi('sphere', [100], **pars), [-0.19146959126623486])
575+
# Check that single point sesans matches value in an array
576+
xi = np.logspace(1, 3, 100)
577+
y = Gxi('sphere', xi, **pars)
578+
for k in (0, len(xi)//5, len(xi)//2, len(xi)-1):
579+
ysingle = Gxi('sphere', [xi[k]], **pars)[0]
580+
print(f"SESANS point check {k}: xi={xi[k]:.1f} single={ysingle:.4f} vector={y[k]:.4f}")
581+
assert abs((ysingle-y[k])/y[k]) < 0.1, "SESANS point value not matching vector value within 10%"
582+
# magnetic 2D
583+
pars = dict(radius=200, sld_M0=3, sld_mtheta=30)
584+
assert near(Iqxy('sphere', [0.1], [0.1], **pars), [1.5577852226925908])
585+
# polydisperse 1D
586+
pars = dict(
587+
radius=200, radius_pd=0.1, radius_pd_n=15, radius_pd_nsigma=2.5,
588+
radius_pd_type="uniform")
589+
assert near(Iq('sphere', [0.1], **pars), [2.703169824954617])
496590

497591
if __name__ == "__main__":
592+
import logging
593+
logging.disable(logging.ERROR)
498594
main()
595+
#test_simple_interface()

0 commit comments

Comments
 (0)