forked from James-Durant/experimental-design
-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathbase.py
548 lines (447 loc) · 19.9 KB
/
base.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
"""Base classes for different sample types """
import os
from abc import ABC, abstractmethod
from typing import Optional
import matplotlib.pyplot as plt
import refnx.dataset
import refnx.reflect
import refnx.analysis
from refnx.reflect import ReflectModel
from refnx.reflect.structure import Slab
from refnx._lib import flatten
from hogben.simulate import SimulateReflectivity
from hogben.utils import Fisher, Sampler, save_plot
plt.rcParams['figure.figsize'] = (9, 7)
plt.rcParams['figure.dpi'] = 600
class VariableAngle(ABC):
"""Abstract class representing whether the measurement angle of a sample
can be varied."""
@abstractmethod
def angle_info(self):
"""Calculates the Fisher information matrix for a sample measured
over a number of angles."""
pass
class VariableContrast(ABC):
"""Abstract class representing whether the contrast of a sample
dan be varied."""
@abstractmethod
def contrast_info(self):
"""Calculates the Fisher information matrix for a sample with contrasts
measured over a number of angles."""
pass
class VariableUnderlayer(ABC):
"""Abstract class representing whether the underlayer(s) of a sample
can be varied."""
@abstractmethod
def underlayer_info(self):
"""Calculates the Fisher information matrix for a sample with
underlayers, and contrasts measured over a number of angles."""
pass
class BaseSample(VariableAngle):
"""Abstract class representing a "standard" neutron reflectometry sample
defined by a series of contiguous layers."""
def __init__(self):
"""Initialise the sample and define class attributes"""
self._structures = []
self._bkg = None
self._dq = None
self._scale = None
self.polarised = self.is_magnetic()
def get_structures(self) -> list:
"""
Get a list of the possible sample structures.
"""
spin_structures = []
if self.polarised:
for structure in self._structures:
up_structure = structure.copy()
down_structure = structure.copy()
for i, layer in enumerate(structure):
if isinstance(layer, MagneticSLD):
up_structure[i] = layer.spin_up
down_structure[i] = layer.spin_down
if self.is_magnetic():
spin_structures.extend([up_structure, down_structure])
else:
spin_structures.extend([structure.copy()])
return spin_structures
return self._structures
@property
def structures(self):
"""Return the structures that belong to the sample"""
return self.get_structures()
@structures.setter
def structures(self, structures: list):
self._structures = structures
def is_magnetic(self) -> bool:
"""Checks whether the sample contains at least one magnetic layer"""
for structure in self._structures:
for layer in structure:
if isinstance(layer, MagneticSLD):
return True
return False
def get_param_by_attribute(self, attr: str) -> list:
"""
Get all parameters defined in the sample model that have a given
attribute. Returns a list with all parameters with this attribute,
e.g. `attr='vary'` returns all varying parameters.
Args:
attr (str): The attribute to filter for
Returns:
list: A list of all parameters with the given attribute
"""
params = []
for model in self.get_models():
for p in flatten(model.parameters):
if hasattr(p, attr) and getattr(p, attr):
params.append(p)
continue
# Get parameters that are coupled to model attributes as
# dependencies:
if p._deps:
params.extend([_p for _p in p.dependencies() if
hasattr(_p, attr) and getattr(_p, attr)])
return list(set(params))
def get_models(self) -> list:
"""
Generates a refnx `ReflectModel` for each structure associated with the
all structures of the Sample, and returns these in a list.
"""
return [refnx.reflect.ReflectModel(structure,
scale=scale,
bkg=bkg,
dq=dq)
for structure, scale, bkg, dq
in zip(self.structures, self.scale, self.bkg, self.dq)]
def simulate_reflectivity(self, angle_times,
inst_or_path='OFFSPEC') -> None:
"""
Plot a simulated reflectivity curve given a set of `angle_times` and
the neutron instrument.
Args:
angle_times (list): points and times for each angle.
inst_or_path (str): either the name of an instrument already in ,
HOGBEN or the path to a direct beam file,
defaults to 'OFFSPEC'
"""
if not isinstance(angle_times[0], list):
angle_times = [angle_times for _ in self.get_models()]
# Plot the model and simulated reflectivity against Q.
fig = plt.figure()
ax = fig.add_subplot(111)
current_xmax = 0
for i, model in enumerate(self.get_models()):
data = SimulateReflectivity(model, angle_times[i],
inst_or_path).simulate()
# Extract each column of the simulated `data`.
q, r, dr, _ = data[0], data[1], data[2], data[3]
# Calculate the model reflectivity.
r_model = SimulateReflectivity(model, angle_times[i],
inst_or_path).reflectivity(q)
label = f', {self.labels[i]}' if len(self.structures) > 1 else ''
# Model reflectivity.
ax.plot(q, r_model, zorder=20, label=f'Model Reflectivity{label}')
# Simulated reflectivity
ax.errorbar(q, r, dr, marker='o', ms=3, lw=0,
elinewidth=1, capsize=1.5, label='Simulated Data'
f'{label}')
if max(q) > current_xmax:
current_xmax = max(q)
ax.set_xlabel('$\mathregular{Q\ (Å^{-1})}$',
weight='bold')
ax.set_ylabel('Reflectivity (arb.)', weight='bold')
ax.set_yscale('log')
ax.set_title('Reflectivity Profile')
ax.set_xlim(0, 1.05 * current_xmax)
ax.legend()
@abstractmethod
def nested_sampling(self):
"""Runs nested sampling on measured or simulated data of the sample."""
pass
class BaseLipid(BaseSample, VariableContrast, VariableUnderlayer):
"""Abstract class representing the base class for a lipid model."""
def __init__(self):
"""
Initialize a BaseLipid object sample, and loads the
experimentally measured data
"""
super().__init__()
self._create_objectives() # Load experimentally-measured data.
@abstractmethod
def _create_objectives(self):
"""Loads the measured data for the lipid sample."""
pass
def angle_info(self, angle_times, contrasts):
"""Calculates the Fisher information matrix for the lipid sample
measured over a number of angles.
Args:
angle_times (list): points and times for each angle to simulate.
contrasts (list): SLDs of contrasts to simulate.
Returns:
numpy.ndarray: Fisher information matrix.
"""
return self.__conditions_info(angle_times, contrasts, None)
def contrast_info(self, angle_times, contrasts):
"""Calculates the Fisher information matrix for the lipid sample
with contrasts measured over a number of angles.
Args:
angle_times (list): points and times for each angle to simulate.
contrasts (list): SLDs of contrasts to simulate.
Returns:
numpy.ndarray: Fisher information matrix.
"""
return self.__conditions_info(angle_times, contrasts, None)
def underlayer_info(self, angle_times, contrasts, underlayers):
"""Calculates the Fisher information matrix for the lipid sample with
`underlayers`, and `contrasts` measured over a number of angles.
Args:
angle_times (list): points and times for each angle to simulate.
contrasts (list): SLDs of contrasts to simulate.
underlayers (list): thickness and SLD of each underlayer to add.
Returns:
numpy.ndarray: Fisher information matrix.
"""
return self.__conditions_info(angle_times, contrasts, underlayers)
def __conditions_info(self, angle_times, contrasts, underlayers):
"""Calculates the Fisher information object for the lipid sample
with given conditions.
Args:
angle_times (list): points and times for each angle to simulate.
contrasts (list): SLDs of contrasts to simulate.
underlayers (list): thickness and SLD of each underlayer to add.
Returns:
Fisher: Fisher information matrix object
"""
# Iterate over each contrast to simulate.
qs, counts, models = [], [], []
for contrast in contrasts:
# Simulate data for the contrast.
sample = self._using_conditions(contrast, underlayers)
contrast_point = (contrast + 0.56) / (6.35 + 0.56)
background_level = (2e-6 * contrast_point
+ 4e-6 * (1 - contrast_point))
model = ReflectModel(sample)
model.bkg = background_level
model.dq = 2
data = SimulateReflectivity(model, angle_times).simulate()
qs.append(data[0])
counts.append(data[3])
models.append(model)
# Exclude certain parameters if underlayers are being used.
if underlayers is None:
return Fisher(qs, self.params, counts, models)
else:
return Fisher(qs, self.underlayer_params, counts, models)
@abstractmethod
def _using_conditions(self):
"""Creates a structure describing the given measurement conditions."""
pass
def sld_profile(self,
save_path: str,
filename: str = 'sld_profile',
ylim: Optional[tuple] = None,
legend: bool = True) -> None:
"""Plots the SLD profile of the lipid sample.
Args:
save_path (str): path to directory to save SLD profile to.
filename (str): file name to use when saving the SLD profile.
ylim (tuple): limits to place on the SLD profile y-axis.
legend (bool): whether to include a legend in the SLD profile.
"""
fig = plt.figure()
ax = fig.add_subplot(111)
# Plot the SLD profile for each measured contrast.
for structure in self.structures:
ax.plot(*structure.sld_profile(self.distances))
x_label = '$\mathregular{Distance\ (\AA)}$'
y_label = '$\mathregular{SLD\ (10^{-6} \AA^{-2})}$'
ax.set_xlabel(x_label, fontsize=11, weight='bold')
ax.set_ylabel(y_label, fontsize=11, weight='bold')
# Limit the y-axis if specified.
if ylim:
ax.set_ylim(*ylim)
# Add a legend if specified.
if legend and len(self.structures) > 1:
ax.legend(self.labels, loc='upper left')
# Save the plot.
save_path = os.path.join(save_path, self.name)
save_plot(fig, save_path, filename)
def reflectivity_profile(self,
save_path: str,
filename: str = 'reflectivity_profile') -> None:
"""Plots the reflectivity profile of the lipid sample.
Args:
save_path (str): path to directory to save profile to.
filename (str): file name to use when saving the profile.
"""
fig = plt.figure()
ax = fig.add_subplot(111)
# Iterate over each measured contrast.
colours = plt.rcParams['axes.prop_cycle'].by_key()['color']
for i, objective in enumerate(self.objectives):
# Get the measured data and calculate the model reflectivity.
q, r, dr = objective.data.x, objective.data.y, objective.data.y_err
r_model = objective.model(q)
# Offset the data, for clarity.
offset = 10 ** (-2 * i)
r *= offset
dr *= offset
r_model *= offset
# Add the offset in the label.
label = self.labels[i]
if offset != 1:
label += ' $\\mathregular{(x10^{-' + str(2 * i) + '})}$'
# Plot the measured data and the model reflectivity.
ax.errorbar(q, r, dr,
marker='o', ms=3, lw=0, elinewidth=1, capsize=1.5,
color=colours[i], label=label)
ax.plot(q, r_model, color=colours[i], zorder=20)
x_label = '$\\mathregular{Q\\ (Å^{-1})}$'
y_label = 'Reflectivity (arb.)'
ax.set_xlabel(x_label, fontsize=11, weight='bold')
ax.set_ylabel(y_label, fontsize=11, weight='bold')
ax.set_xscale('log')
ax.set_yscale('log')
ax.set_ylim(1e-10, 3)
ax.set_title('Reflectivity profile')
if len(self.structures) > 1:
ax.legend()
# Save the plot.
save_path = os.path.join(save_path, self.name)
save_plot(fig, save_path, filename)
def nested_sampling(self,
contrasts: list,
angle_times: list,
save_path: str,
filename: str,
underlayers=None,
dynamic=False) -> None:
"""Runs nested sampling on simulated data of the lipid sample.
Args:
contrasts (list): SLDs of contrasts to simulate.
angle_times (list): points and times for each angle to simulate.
save_path (str): path to directory to save corner plot to.
filename (str): file name to use when saving corner plot.
underlayers (list): thickness and SLD of each underlayer to add.
dynamic (bool): whether to use static or dynamic nested sampling.
"""
# Create objectives for each contrast to sample with.
objectives = []
for contrast in contrasts:
# Simulate an experiment using the given contrast.
sample = self._using_conditions(contrast, underlayers)
contrast_point = (contrast + 0.56) / (6.35 + 0.56)
background_level = 2e-6 * contrast_point + 4e-6 * (
1 - contrast_point)
model = ReflectModel(sample)
model.bkg = background_level
model.dq = 2
data = SimulateReflectivity(model, angle_times).simulate()
dataset = refnx.dataset.ReflectDataset(
[data[0], data[1], data[2]]
)
objectives.append(refnx.analysis.Objective(model, dataset))
# Combine objectives into a single global objective.
global_objective = refnx.analysis.GlobalObjective(objectives)
# Exclude certain parameters if underlayers are being used.
if underlayers is None:
global_objective.varying_parameters = lambda: self.params
else:
global_objective.varying_parameters = (
lambda: self.underlayer_params
)
# Sample the objective using nested sampling.
sampler = Sampler(global_objective)
fig = sampler.sample(dynamic=dynamic)
# Save the sampling corner plot.
save_path = os.path.join(save_path, self.name)
save_plot(fig, save_path, 'nested_sampling_' + filename)
class MagneticSLD(Slab):
"""
A class to represent a layer with a magnetic SLD component.
This class extends the `Slab` class from `refnx.reflect.structure` to
include properties for magnetic Scattering Length Density (SLD) as well.
Attributes:
SLD_n (float): Nuclear scattering length density.
SLD_m (float): Magnetic scattering length density.
thickness (float): Thickness of the layer.
roughness (float): Roughness of the layer.
name (str): Name of the layer.
"""
def __init__(self,
SLDn: float = 0,
SLDm: float = 0,
thick: float = 0,
rough: float = 0,
vfsolv: float = 0,
interface: refnx.reflect.interface = None,
name: str = 'Magnetic Layer'):
"""
Initialize a MagneticSLD object.
Parameters:
SLDn (float): Nuclear scattering length density. Default is 0.
SLDm (float): Magnetic scattering length density. Default is 0.
thick (float): Thickness of the layer. Default is 0.
rough (float): Roughness of the layer. Default is 0.
vfsolv (float): Volume fraction of the solvent, between 0 and 1.
Default is 0.
name (str): Name of the layer. Default is "Magnetic Layer".
interface (`refnx.reflect.Interface`):
The type of interfacial roughness associated with the Slab.
If `None`, then the default interfacial roughness is an Error
function (also known as Gaussian roughness).
"""
self.SLDn = SLDn
self.SLDm = SLDm
self.thick = thick
self.rough = rough
self.vfsolv = vfsolv
self.interface = interface
self.name = name
super().__init__(thick=self.thick, sld=self.SLDn,
rough=self.rough, name=self.name, vfsolv=self.vfsolv,
interface=self.interface)
@property
def spin_up(self):
"""
Calculate the spin-up scattering length density.
Returns:
SLD: An SLD object representing the spin-up component with the
appropriate thickness and roughness.
"""
SLD_value = self.SLDn + self.SLDm
return Slab(thick=self.thick, sld=SLD_value, rough=self.rough,
vfsolv=self.vfsolv, name='Spin up',
interface=self.interface)
@property
def spin_down(self):
"""
Calculate the spin-down scattering length density.
Returns:
SLD: An SLD object representing the spin-down component with the
appropriate thickness and roughness.
"""
SLD_value = self.SLDn - self.SLDm
return Slab(thick=self.thick, sld=SLD_value, rough=self.rough,
vfsolv=self.vfsolv, name='Spin down',
interface=self.interface)
def __call__(self, thick=None, rough=None, vfsolv=None):
"""
Update the thickness and roughness of the layer.
Parameters:
thick (float): New thickness of the layer. If None, the
current thickness is retained.
rough (float): New roughness of the layer. If None, the
current roughness is retained.
vfsolv (float): New volume fraction of the solvent, between 0 and
1. Default is 0.
Returns:
MagneticSLD: The updated MagneticSLD object.
"""
self.thick = thick if thick is not None else self.thick
self.rough = rough if rough is not None else self.rough
self.vfsolv = vfsolv if vfsolv is not None else self.vfsolv
super().__init__(thick=self.thick, sld=self.SLDn,
rough=self.rough, name=self.name, vfsolv=self.vfsolv,
interface=self.interface)
return self