forked from glentner/SLiPy
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Profile.py
1162 lines (916 loc) · 44.1 KB
/
Profile.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
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) Geoffrey Lentner 2015. All Rights Reserved.
# See LICENSE (GPLv3)
# slipy/SLiPy/Profile.py
"""
Profile fitting tasks for spectra.
"""
import numpy as np
from scipy.optimize import curve_fit
from scipy.interpolate.interpolate import interp1d
from scipy.special import wofz as w
from scipy.integrate import simps as Integrate
from matplotlib import pyplot as plt
from matplotlib.lines import Line2D
from matplotlib import widgets
from astropy import units as u
from astropy.constants import m_e, e, c
from .. import SlipyError
from .Observatory import Observatory
from .Spectrum import Spectrum, SpectrumError
from .Plot import SPlot, PlotError
from ..Framework.Options import Options, OptionsError
from ..Framework.Measurement import Measurement
from ..Algorithms.Functions import Gaussian, Lorentzian, Voigt, InvertedLorentzian
from ..Algorithms.KernelFit import KernelFit1D
from ..Data import Atomic
class ProfileError(SlipyError):
"""
Exception specific to Profile module.
"""
pass
def Pick(event):
"""
Used to hand selection events.
"""
if isinstance(event.artist, Line2D):
# get the x, y data from the pick event
thisline = event.artist
xdata = thisline.get_xdata()
ydata = thisline.get_ydata()
ind = event.ind
# update the selection dictionary
global selected
selected['wave'].append(np.take(xdata,ind)[0])
selected['data'].append(np.take(ydata,ind)[0])
# display points as a visual aid
plt.scatter(np.take(xdata,ind)[0], np.take(ydata,ind)[0],
marker = 'o', s=75, edgecolor='red', facecolor='None', lw=2)
plt.draw()
# empty selection dictionary is filled with the Select() function
selected = {'wave': [], 'data': []}
def Select(splot):
"""
Select points from the `splot`. This should be of type SPlot
(or it can optionally be a Spectrum type, for which a SPlot will be
created). The splot will be rendered and the user clicks on the
figure. When finished, return to the terminal prompt. A dictionary is
returned with two entries, `wave` and `data`, representing the x-y
locations selected by the user. This can always be retrieved later by
accessing the module member `Profile.selected`.
"""
if type(splot) is Spectrum:
splot = SPlot(splot)
elif type(splot) is not SPlot:
raise ProfileError('Select() requires either a Spectrum or SPlot '
'object as an argument')
# reset the selection dictionary
global selected
selected = { 'wave': [], 'data': []}
splot.draw(picker = True)
splot.fig.canvas.mpl_connect('pick_event', Pick)
input(' Press <Return> after making your selections ... ')
return selected
def AutoFit(splot, function = InvertedLorentzian, params = None):
"""
Given `splot` of type SPlot, the user selects four points on the
spectrum and a parameterized function is fit (an inverted Lorentzian by
default). Optionally, `splot` can be of type spectrum and a basic SPlot
will be created for you. If the user gives an alternative `function`,
`params` (parameters) must be provided. `params` is to be the first guess,
`p0` given to scipy...curve_fit; the user can provide them expicitely,
or in the form of functions with the template `function(xarray, yarray)`
where `xarray` and `yarray` are the `wave` and `data` arrays extracted
between the two points selected by the user.
"""
print(' Please select four points identifying the spectral line.')
print(' Outer points mark the domain of the line.')
print(' Inner points mark the sample of the line to fit.')
# make selections
selected = Select(splot)
if len( selected['wave'] ) != 4:
raise ProfileError('Exactly 4 locations should be selected for '
'the Profile.AutoFit() routine!')
# order the selected wavelength locations
points = selected['wave']
points.sort()
# get data and wavelength arrays
if type(splot) is SPlot:
wave, data = splot.wave[0], splot.data[0]
else:
wave, data = splot.wave, splot.data
# extract the domains `selected` (i.e., the sample interval)
x_inner = wave[np.where(np.logical_and(points[1] < wave, wave < points[2]))]
x_outer = wave[np.where(np.logical_and(points[0] < wave, wave < points[3]))]
y_inner = data[np.where(np.logical_and(points[1] < wave, wave < points[2]))]
y_outer = data[np.where(np.logical_and(points[0] < wave, wave < points[3]))]
if function.__name__ == 'InvertedLorentzian':
# First guess for default behavior
params = [ y_inner.min().value, x_inner[ y_inner.argmin() ].value,
(x_inner[-1] - x_inner[0]).value / 2]
elif not params:
# the user gave a different function but not parameters!
raise ProfileError('The user must provide `params` when giving an '
'alternative `function` in Profile.Fit()!')
else:
if not hasattr(params, '__iter__'):
raise ProfileError('`params` must be an iterable type in '
'Profile.AutoFit()!')
try:
for a, parameter in enumerate(params):
if type(parameter) is type(InvertedLorentzian):
# replace parameter with function evaluation
params[a] = parameter(x_inner, y_inner)
except TypeError as err:
print(' --> TypeError:', err)
raise ProfileError('Profile.AutoFit() failed to call user functions '
'correctly in `params`!')
# fit a parameterized curve
coeff, var_matrix = curve_fit(
function, # function to call, default is InvertedLorentzian
x_inner.value, # domain (without units)
y_inner.value, # data (without units)
p0 = params # list of parameters to try as a first guess
)
# display visual aids ...
# evaluation of the fit profile over larger domain
plt.plot(x_outer, function(x_outer.value, *coeff) * y_outer.unit,
'b--', linewidth = 4)
plt.plot(x_inner, function(x_inner.value, *coeff) * y_inner.unit,
'r-', linewidth = 4)
# return the larger domain, evaluated and of type Spectrum
return Spectrum(function(x_outer.value, *coeff) * y_outer.unit, x_outer)
def Extract(splot, kernel = Gaussian, **kwargs):
"""
Select locations in the `splot` figure, expected to be of type SPlot.
Exactly four points should be selected. These are used to extract a
line profile from the spectrum plotted in the splot figure. The inner
section is used for the line, and the outer selection is used to model
the continuum; these, respectively, and both returned as Spectrum objects.
The gap is jumped using 1D interpolation (scipy...interp1d).
"""
try:
options = Options( kwargs, {
'kind' : 'cubic' , # given to scipy...interp1d for continuum
'bandwidth' : 0.1*u.nm # user should provide this!
# 'rms' : False # measure the RMS of the line, continuum
})
kind = options('kind')
bandwidth = options('bandwidth')
# rms = options('rms')
except OptionsError as err:
print(' --> OptionsError:', err)
raise ProfileError('Unrecognized option given to Extract()!')
print(' Please select four points identifying the spectral line.')
print(' Outer intervals sample the continuum.')
print(' Center interval contains the line.')
# make selections
selected = Select(splot)
if len( selected['wave'] ) != 4:
raise ProfileError('Exactly 4 locations should be selected for '
'the profile modeling to work!')
# order the selected wavelength locations
wave = selected['wave']
wave.sort()
# create `line` profile
xl = splot.wave[0].copy()
yl = splot.data[0].copy()
yl = yl[ xl[ xl < wave[2] ] > wave[1] ]
xl = xl[ xl[ xl < wave[2] ] > wave[1] ]
line = Spectrum(yl, xl)
# extract continuum arrays
xc = splot.wave[0].copy()
yc = splot.data[0].copy()
# inside outer-most selections
yc = yc[ xc[ xc < wave[3] ] > wave[0] ]
xc = xc[ xc[ xc < wave[3] ] > wave[0] ]
# keep wavelengths whole domain for later
xx = xc.copy()
yy = yc.copy()
# but not the line
yc = yc[np.where(np.logical_or(xc < wave[1], xc > wave[2]))]
xc = xc[np.where(np.logical_or(xc < wave[1], xc > wave[2]))]
# use `kernel smoothing` to model the continuum
model = KernelFit1D(xc, yc, kernel = kernel, bandwidth = bandwidth)
# interpolate to cross `the gap`
interp = interp1d(xc, model.mean(xc), kind = kind)
# continuum model outside the line
cont_outside = interp(xc)
# continuum inside the line
cont_inside = interp(xl)
# continuum model for whole domain
cont_domain = interp(xx)
# build a spectrum from the arrays
continuum = Spectrum(cont_domain, xx)
# display visual aid
plt.plot(xx, cont_domain, 'r--', linewidth = 3)
plt.fill_between(xl, yl, cont_inside, color = 'blue', alpha = 0.25)
plt.draw()
# if not rms:
# return line, continuum
continuum.rms = np.sqrt( np.sum( (cont_outside * yc.unit - yc)**2 ) / len(yc) )
continuum.rms *= continuum.data.unit
return line, continuum
def OpticalDepth(line, continuum, error=None, boost=None):
"""
Given an absorption `line` and its background `continuum`, compute the
apparent optical depth Spectrum. Both the line and the continuum must
be of Spectrum type. The continuum will be resampled to the pixel space of the
line if it is not currently.
If provided an `error` spectrum, it should either have the same units as the `line`
or be in percent units (they should have identical wavelength arrays). An upper and
lower uncertainty will be computed by adding and subtracting before the calculation.
Further, if an `rms` value is attached to the `continuum` giving the percent error in
the continuum fit, this will be added in quadrature to the error spectrum before hand.
`boost` allows for artificially increasing the resolution by resampling to more pixels.
If the spectrum is not of sufficiently high resolution, the integration could suffer
numerical errors. If provided, this should be a percentage to increase the resolution
(e.g., `boost=2` would double the resolution by interpolating in between the pixels).
This function returns a Spectrum with `upper` and `lower` bounds attached.
"""
if not isinstance(line, Spectrum):
raise ProfileError('OpticalDepth() expects the first argument to be of '
'`Spectrum` type!')
if not isinstance(continuum, Spectrum):
raise ProfileError('OpticalDepth() expects the second argument to be of '
'`Spectrum` type!')
line = line.copy()
continuum = continuum.copy()
if error:
if not isinstance(error, Spectrum):
raise ProfileError('OpticalDepth() expects the `error` to be of `Spectrum` type!')
if error.data.unit not in [line.data.unit, u.percent]:
raise ProfileError('OpticalDepth() expects the `error` spectrum to have either '
'the same units as the `line` or units of `percent`.')
error = error.copy()
if error.data.unit == u.percent:
if np.logical_and(error.data.value < 0, error.data.value > 100).any():
raise ProfileError('OpticalDepth() was given an `error` spectrum in '
'units of `percent` with values outside of 0 and 100!')
error.resample(line)
error.data = (error.data * line.data).to(line.data.unit)
if hasattr(continuum, 'rms'):
if not hasattr(continuum.rms, 'unit') or (continuum.rms.unit not in
[continuum.data.unit, u.percent]):
raise ProfileError('OpticalDepth() expects a `continuum` with an `rms` '
'attribute to have the same units or `percent` units.')
rms = continuum.rms
if rms.unit == u.percent:
rms = rms * continuum.data
rms = rms.to(continuum.data.unit)
if not error:
error = rms
else:
# add the two error components in quadrature
error.resample(line)
error = Spectrum(np.sqrt(rms**2 + error.data**2) * line.data.unit, line.wave)
# boost the resolution of the specrum if requested
if boost: line.resample( line.wave[0], line.wave[-1], len(line) * boost )
# resample the continuum spectrum, nothing happens if they are already the same
continuum.resample(line)
# compute the apparent optical depth
tau = Spectrum(np.log((continuum / line).data.decompose()), line.wave)
if error:
tau.lower = Spectrum(np.log((continuum / (line - error)).data.decompose()), line.wave)
tau.upper = Spectrum(np.log((continuum / (line + error)).data.decompose()), line.wave)
return tau
def EquivalentWidth(line, continuum, error=None, boost=None):
"""
Given an absorption `line` and its background `continuum`, compute the
equivalent width, `W`, of the feature. Both the line and the continuum must
be of Spectrum type. The continuum will be resampled to the pixel space of the
line if it is not currently.
If provided an `error` spectrum, it should either have the same units as the `line`
or be in percent units (they should have identical wavelength arrays). An upper and
lower uncertainty will be computed by adding and subtracting before the calculation.
Further, if an `rms` value is attached to the `continuum` giving the percent error in
the continuum fit, this will be added in quadrature to the error spectrum before hand.
`boost` allows for artificially increasing the resolution by resampling to more pixels.
If the spectrum is not of sufficiently high resolution, the integration could suffer
numerical errors. If provided, this should be a percentage to increase the resolution
(e.g., `boost=2` would double the resolution by interpolating in between the pixels).
Integration is performed using the composite Simpson`s rule (scipy.integrate.simps)
This function returns a `Measurement` object (..Framework.Measurement.Measurement).
"""
tau = OpticalDepth(line, continuum, error=error, boost=boost)
W = Integrate(1 - np.exp(-tau.data.decompose()), tau.wave) * line.wave.unit
if error:
upper = Integrate(1 - np.exp(-tau.lower.data.decompose()), tau.wave) * line.wave.unit
lower = Integrate(1 - np.exp(-tau.upper.data.decompose()), tau.wave) * line.wave.unit
# join upper/lower into +/- set
uncertainty = np.array([(upper - W).value, (lower - W).value]) * tau.wave.unit
else: uncertainty = None
return Measurement(W, error = uncertainty, name = 'Equivalent Width',
notes = 'Measured using Profile.EquivalentWidth() from SLiPy')
def ColumnDensity(line, continuum, ion, error=None, boost=None, weakline=None, integrate=True):
"""
Given an absorption `line` and its background `continuum`, compute the
apparent column density, `N`, of the feature. Both the line and the continuum must
be of Spectrum type. The continuum will be resampled to the pixel space of the
line if it is not currently. An `ion` must be provided (type Atomic.Ion) with
members, `fvalue` (oscillator strength) and `wavelength` (will be converted to
Angstroms).
If provided an `error` spectrum, it should either have the same units as the `line`
or be in percent units (they should have identical wavelength arrays). An upper and
lower uncertainty will be computed by adding and subtracting before the calculation.
Further, if an `rms` value is attached to the `continuum` giving the percent error in
the continuum fit, this will be added in quadrature to the error spectrum before hand.
With `integrate` set to True (the default) the integrated apparent column density will
be returned (as type Measurement and in units of cm-2). Otherwise, a Spectrum is
returned as a function of wavelength and the `.upper` and `.lower` bounds are attached.
`boost` allows for artificially increasing the resolution by resampling to more pixels.
If the spectrum is not of sufficiently high resolution, the integration could suffer
numerical errors. If provided, this should be a percentage to increase the resolution
(e.g., `boost=2` would double the resolution by interpolating in between the pixels).
With a `weakline` provided (the results of this function on the weaker absorption
line for a doublet) a correction gives an approximate `true` column density (attached
to the returned Measurement as `.true`), see Savage & Sembach, 1991.
Integration is performed using the composite Simpson`s rule (scipy.integrate.simps)
This function returns a `Measurement` object (..Framework.Measurement.Measurement).
"""
if (not isinstance(ion, Atomic.Ion) or not hasattr(ion, 'fvalue') or
not hasattr(ion, 'wavelength')): raise ProfileError("From ColumnDensity(), `ion` is "
"expected to be of type Atomic.Ion and have members `fvalue` and `wavelength`!")
if weakline and not hasattr(weakline, 'unit'):
raise ProfileError("From ColumnDensity(), `weakline` is expected to have units!")
const = (1.13e12 / u.cm) / (ion.fvalue * ion.wavelength.to(u.cm))
tau = OpticalDepth(line, continuum, error=error, boost=boost)
N = tau * const / ion.wavelength.to(u.AA).value
N.upper = tau.upper * const / ion.wavelength.to(u.AA).value
N.lower = tau.lower * const / ion.wavelength.to(u.AA).value
if not integrate and not weakline:
return N
elif weakline and not integrate:
raise ProfileError("From ColumnDensity(), you gave `integrate` as False but we "
"need to integrate to solve for the correct N using the `weakline`!")
upper = Integrate(N.upper.data, N.wave) / u.cm**2
lower = Integrate(N.lower.data, N.wave) / u.cm**2
N = Integrate(N.data, N.wave) / u.cm**2
uncertainty = np.array([(N - upper).value, (lower - N).value]) / u.cm**2
N = Measurement(N, name="Column Density (Apparent)",
error=uncertainty, notes="Measured using Profile.ColumnDensity() from SLiPy")
if not weakline:
return N
# attach the `correction` given the already computed `weakline` column density.
diff = np.log10(weakline.to(1 / u.cm**2).value) - np.log10(N.value)
if diff < 0.0 or diff > 0.24:
raise ProfileError("From ColumnDensity(), the difference between the doublet "
"lines is too great to solve for a correction using published results!")
# Table 4: Column Density Corrections for an Isolated Gaussian Component
# Savage & Sembach (1991)
table = np.array([ [0.000, 0.000], [0.010, 0.010], [0.020, 0.020], [0.030, 0.030],
[0.040, 0.040], [0.050, 0.051], [0.060, 0.061], [0.070, 0.073], [0.080, 0.085],
[0.090, 0.097], [0.100, 0.111], [0.110, 0.125], [0.120, 0.140], [0.130, 0.157],
[0.140, 0.175], [0.150, 0.195], [0.160, 0.217], [0.170, 0.243], [0.180, 0.273],
[0.190, 0.307], [0.200, 0.348], [0.210, 0.396], [0.220, 0.453], [0.230, 0.520],
[0.240, 0.600]])
N.correction = interp1d(table[:,0], table[:,1], kind='cubic')(diff)
N.true = 10**(np.log10(weakline.to(1/u.cm**2).value) + N.correction) / u.cm**2
return N
class FittingGUI:
"""
Graphical Interface (keeps references alive) for fitting analytical
profiles to spectral data.
"""
def __init__(self, splot, obs=None, ion=None, function='Voigt', **kwargs):
"""
Build widget elements based on a spectrum plotted in `splot` (type SPlot).
`splot` may also be a Spectrum object (for which a SPlot will be created).
Make initial guesses at parameters, build widgets, render the figure.
"""
try:
options = Options( kwargs, {
'kind' : 'cubic' , # given to interp1d for continuum
'bandwidth' : 0.1*u.nm, # user should provide this!
'linecolor' : 'k' # color passed to plt.plot() for lines
})
kind = options('kind')
bandwidth = options('bandwidth')
linecolor = options('linecolor')
except OptionsError as err:
print(' --> OptionsError:', err)
raise ProfileError('Unrecognized option given to FittingGUI.__init__()!')
if function not in ['Voigt', 'Lorentzian', 'Gaussian']:
raise ProfileError('The only currently implemented functions '
'for fitting profiles are `Voigt`, `Lorentzian` and `Gaussian`!')
# grab and/or create the SPlot
if isinstance(splot, Spectrum):
splot = SPlot(splot, marker='k-', label='spectrum')
elif not isinstance(splot, SPlot):
raise ProfileError('FittingGUI() expects the `splot` argument to '
'either be a Spectrum or a SPlot!')
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# if the profile `function` is `Voigt` and we have necessary parameters,
# show a preview for `b` and `N` and `v`.
self.any_line_parameters = True if obs or ion else False
self.has_line_parameters = True if obs and ion else False
if self.any_line_parameters and not self.has_line_parameters:
raise ProfileError('From FittingGUI.__init__(), if the absoption line '
'parameters `obs` or `ion` are provided, both must be given!')
if self.has_line_parameters:
if function != 'Voigt':
raise ProfileError('From FittingGUI.__init__(), in order to compute the '
'broadening from the instrument profile of the `obs`ervatory and the '
'column density for the `ion`, it is expected that we try to fit a `Voigt` '
'profile `function`!')
if not isinstance(obs, Observatory):
raise ProfileError('From FittingGUI.__init__(), if `obs` is provided it should '
'be of type Observatory!')
if not hasattr(obs, 'resolution'):
raise ProfileError('From FittingGUI.__init__(), if an observatory `obs` is '
'provided, it needs to have a member `resolution`!')
if not hasattr(ion, 'wavelength') or not hasattr(ion.wavelength, 'unit'):
raise ProfileError('From FittingGUI.__init__(), the provided `ion` either '
'did not have a `wavelength` attribute or it has one without units!')
if not hasattr(ion, 'fvalue'):
raise ProfileError('From FittingGUI.__init__(), the provide `ion` does not '
'have an `fvalue` (oscillator strength) attribute!')
if hasattr(ion.fvalue, 'unit') and ion.fvalue.unit != u.dimensionless_unscaled:
raise ProfileError('From FittingGUI.__init__(), the osciallator strength, '
'`fvalue` for an ion must be a dimensionless Quantity!')
if not hasattr(ion, 'A') or not ion.A:
raise ProfileError('From FittingGUI.__init__(), the provided `ion` does not '
'have an `A` (transition probability) attribute!')
if hasattr(ion.A, 'unit') and ion.A.unit != u.Unit('s-1'):
raise ProfileError('From FittingGUI.__init__(), the transition probability, '
'`A`, from the `ion` must be in units of `s-1` if units are present!')
# the instrument broadening is from R = lambda / delta_lambda
# FWHM = 2 sqrt( 2 log 2) sigma for the Gaussian instrument profile
self.R = obs.resolution
self.sigma_instrument = (ion.wavelength / self.R) / (2 * np.sqrt(2 * np.log(2)))
self.sigma_instrument_squared = self.sigma_instrument.value ** 2
# save parameters for later
self.wavelength = ion.wavelength
self.fvalue = ion.fvalue
self.A = ion.A
# the FWHM of the intrinsic line profile (Lorentzian) is proportional to the
# transition probability (Einstein coeff.) `A`...
# convert from km s-1 to wavelength units
self.gamma = (ion.wavelength * (ion.wavelength * ion.A / (2 * np.pi)).to(u.km / u.s) /
c.si).to(ion.wavelength.unit).value
# the leading constant in the computation of `N` (per Angstrom per cm-2)
# self.leading_constant = (m_e.si * c.si / (np.sqrt(np.pi) * e.si**2 * ion.fvalue *
# ion.wavelength.to(u.Angstrom))).value
self.leading_constant = 1 / (ion.fvalue * ion.wavelength.to(u.AA)**2 * np.pi *
e.emu**2 / (m_e.si * c.to(u.km/u.s)**2)).value
# setting `function` to `ModifiedVoigt` only makes a change in how the `Voigt`
# profile is evaluated by using `self.gamma` instead of self.Params[...]['Gamma']
function = 'ModifiedVoigt'
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
print('\n We need to extract the lines from the continuum before we '
'begin the fitting process.')
# extract the line, continuum, rms from the spectrum
self.line, self.continuum = Extract(splot, bandwidth=bandwidth, kind=kind)
self.rms = self.continuum.rms
print('\n Now select the peaks of each line to be fit.')
print(' Initial guesses will be made for each line markered.')
input(' Press <Return> after making your selections ... ')
# grab all the selected points
global selected
points = np.array([
[ entry.value for entry in selected['wave'] ],
[ entry.value for entry in selected['data'] ]
])
# point pairs in ascending order by wavelength
points = points[:, points[0].argsort()]
# domain size of the line and the number of components
self.domainsize = self.line.wave.value[-1] - self.line.wave.value[0]
self.numlines = np.shape(points)[1] - 4
if self.numlines < 1:
raise ProfileError('FittingGUI() expects at least one line to '
'be selected for fitting!')
# final spectral line profile is convolution of the LSP and the
# line profile function. Gaussian on Gaussian, Gaussian on Lorentzian,
# etc ... Set the functional form given requested profile function
self.Evaluate = self.SetFunction(function)
# initial guesses for parameters given the line locations,
# containing `L1`, `L2`, etc ... the values of which are themselves
# dictionaries of parameters (e.g., `FWHM`, `Depth`, etc...) whose values
# are the initial guesses for those parameters given the location of
# it`s peak and the number of peaks within the `line`s domain.
self.Params = {
'L' + str(line + 1) : self.Parameterize(function, loc)
for line, loc in enumerate(points[:, 2:-2].T)
}
# grab the actual Figure object and it's axis to keep references
self.splot = splot
self.fig = splot.fig
self.ax = splot.ax
# refresh image, but keep any changes in axis limits
splot.xlim( *splot.ax.get_xlim() )
splot.ylim( *splot.ax.get_ylim() )
splot.draw()
# bring up plot to make room for sliders
plt.subplots_adjust(bottom = 0.30)
# resample continuum onto line domain
self.continuum = self.continuum.copy()
self.continuum.resample(self.line)
# common domain for plotting, strip units, wavelengths from continuum
self.x = self.continuum.wave.value
self.continuum = self.continuum.data.value
# copy of continuum allows for adjustments
self.y = self.continuum.copy()
# save the mid-level of the continuum to avoid over-calculation
self.continuum_level = self.continuum.mean()
# add plots of each line, keep dictionary of handles
self.Component = {
line : plt.plot(self.x, self.y - self.Evaluate(self.x, **parameters),
linecolor+'--')[0] for line, parameters in self.Params.items()
}
# add plot of the continuum
self.Continuum, = plt.plot(self.x, self.y, 'r-', lw=2)
# add plot of superposition of each component
self.Combination, = plt.plot(self.x, self.y - self.SuperPosition(), 'g-')
# fix the limits on plot
xmin, xmax = splot.ax.get_xlim()
ymin, ymax = splot.ax.get_ylim()
plt.axis([xmin, xmax, ymin, ymax])
# color scheme for sliders
theme = {
'light': {'bg': 'white', 'fg': 'blue'},
'dark' : {'bg': 'black', 'fg': 'red'}
}
# axes for parameter sliders
self.Axis = {
# key : axis xpos , ypos + dy , xsize, ysize
line : plt.axes([0.10, 0.05 + (k+1) * 0.03, 0.65, 0.02], axisbg = 'black')
for k, line in enumerate( self.Params['L1'].keys() )
}
# add an axis to make adjustments to the continuum
self.Axis['Continuum'] = plt.axes([0.10, 0.05, 0.65, 0.02], axisbg='black')
# create the sliders for the parameters
self.Slider = {
# Slider `key` and widget
param : widgets.Slider(
self.Axis[param], # which axis to put slider on
param, # name of parameter (and the slider)
self.Minimum(param), # set the minimum of the slider
self.Maximum(param), # set the maximum of the slider
valinit = self.Params['L1'][param], # initial value
facecolor = 'c'
)
# create a slider for each parameter
for param in self.Params['L1'].keys()
}
# create the slider for the continuum (10% adjustments)
self.Slider['Continuum'] = widgets.Slider(self.Axis['Continuum'],
'Continuum', 0.90 * self.continuum_level, 1.10 *
self.continuum_level, valinit = self.continuum_level,
facecolor = 'c')
# connect sliders to update function
for slider in self.Slider.values():
slider.on_changed(self.Update)
# create axis for radio buttons
self.RadioAxis = plt.axes([0.85, 0.01, 0.1, 0.2],
axisbg = 'black', frameon = False)
# create the radio button widget
self.Radio = widgets.RadioButtons(self.RadioAxis,
tuple(['L' + str(i+1) for i in range(self.numlines)]),
active = 0, activecolor = 'c')
# connect the radio button to it's update function
self.Radio.on_clicked(self.ToggleComponent)
# set current component as the first line
self.current_component = 'L1'
# make currently selected line bold
self.Component['L1'].set_linewidth(2)
# variable allows for the radio buttons to not screw-up the sliders/graphs
# when switching between lines
self.stall = False
# add the initial text for `N` and `b` if applicable.
# text is along 20% below the y-axis
if self.has_line_parameters:
# self.b, self.N, self.v = self.solve_b(), self.solve_N(), self.solve_v()
self.preview = self.ax.text(xmin, ymin - 0.1 * (ymax - ymin),
'v: {:>10.4f} km s-1 b: {:>10.4f} km s-1'
' W: {:>10.4f}\nN: {:>10.4e} cm-2 tau_0: {:>10.4f}'.format(
self.solve_v().value, self.solve_b().value, self.solve_W(), self.solve_N().value,
self.solve_tau0()), va = 'top')
# display the text
self.fig.canvas.draw_idle()
def Parameterize(self, function, loc):
"""
Choose the initial parameters of `function` given the peak `loc`.
"""
if function == 'Voigt':
return {'Gamma': 0.10 * self.domainsize / self.numlines,
'Sigma': 0.20 * self.domainsize / self.numlines, 'Peak' : loc[0],
'Depth': self.continuum[ loc[0] ].value - loc[1]}
if function == 'Lorentzian':
return {'FWHM': 0.5 * self.domainsize / self.numlines, 'Peak': loc[0],
'Depth': self.continuum[ loc[0] ].value - loc[1]}
elif function == 'Gaussian':
return {'FWHM': 0.5 * self.domainsize / self.numlines, 'Peak': loc[0],
'Depth': self.continuum[ loc[0] ].value - loc[1]}
elif function == 'ModifiedVoigt':
return {'Sigma': (self.Maximum('Sigma') - self.Minimum('Sigma'))/2,
'Peak' : loc[0], 'Depth': self.continuum[ loc[0] ].value - loc[1]}
else:
raise ProfileError('From FittingGUI.Parameterize(), the only '
'currently implemented functions are the `Gaussian`, `Lorentzian`, and '
'`Voigt` profiles!')
def SetFunction(self, function):
"""
Return how to `Evaluate` the profile given the `function`.
"""
options = {'Voigt': self.__Voigt, 'Lorentzian': self.__Lorentzian,
'Gaussian': self.__Gaussian, 'ModifiedVoigt': self.__ModifiedVoigt}
if function not in options:
raise ProfileError('From FittingGUI.SetFunction(), the only '
'currently implemented functions are the `Voigt`, `Lorentzian`, and '
'the `Gaussian` profiles!!')
return options[function]
def __Gaussian(self, x, **params):
"""
A Gaussian profile. See ..Algorithms.Functions.Gaussian
"""
return Gaussian(x, params['Depth'], params['Peak'], params['FWHM'] / 2.3548200450309493)
def __Lorentzian(self, x, **params):
"""
A Lorentzian profile. See ..Algorithms.Functions.Lorentzian
"""
return params['Depth'] * Lorentzian(x, params['Peak'], params['FWHM'])
def __Voigt(self, x, **params):
"""
The Voigt profile. See ..Algorithms.Functions.Voigt
"""
return Voigt(x, params['Depth'], params['Peak'], params['Sigma'], params['Gamma'])
def __ModifiedVoigt(self, x, **params):
"""
This is the Voigt profile, but `gamma` was already set.
"""
return Voigt(x, params['Depth'], params['Peak'], params['Sigma'], self.gamma)
def SuperPosition(self):
"""
Superposition of each line component (blended line)
"""
combined = np.zeros(np.shape(self.x))
for parameters in self.Params.values():
combined += self.Evaluate(self.x, **parameters)
return combined
def Minimum(self, param):
"""
Set the lower bound on the `param`eter for its slider.
"""
if param == 'Peak':
return self.x[0]
elif param == 'FWHM':
return 1e-6
elif param == 'Depth':
return 1e-6
elif param == 'Sigma':
if self.Evaluate != self.__ModifiedVoigt:
return 1e-6
else:
return self.sigma_instrument.value
elif param == 'Gamma':
return 1e-6
else:
raise ProfileError('From FittingGUI.Minimum(), `{}` is not '
'currently implemented as a parameter to set the minumum '
'for!'.format(param))
def Maximum(self, param):
"""
Set the upper bound on the `param`eter for its slider.
"""
if param == 'Peak':
return self.x[-1]
elif param == 'FWHM':
return 0.9 * self.domainsize
elif param == 'Depth':
return 1.5 * self.continuum.max()
elif param == 'Sigma':
return 0.9 * self.domainsize / self.numlines
elif param == 'Gamma':
return 0.9 * self.domainsize / self.numlines
else:
raise ProfileError('From FittingGUI.Maximum(), `{}` is not '
'currently implemented as a parameter to set the maximum '
'for!'.format(param))
def Update(self, val):
"""
Cycle thru Sliders and update Parameter dictionary. Re-draw graphs.
"""
# `self.stall` suppresses this procedure while inside `ToggleComponent`
if not self.stall:
# the currently selected line component
line = self.current_component
# update the appropriate parameters in the dictionary
for parameter, slider in self.Slider.items():
if parameter == 'Continuum':
self.y = self.continuum + (slider.val - self.continuum_level)
else:
self.Params[line][parameter] = slider.val
# update the appropriate graph data, based on new parameters
for line, parameters in self.Params.items():
self.Component[line].set_ydata(self.y - self.Evaluate(self.x, **parameters))
# update the continuum graph
self.Continuum.set_ydata(self.y)
# update the combined graph
self.Combination.set_ydata(self.y - self.SuperPosition())
# update the displayed `N` and `b` if present
if self.has_line_parameters:
self.Update_Preview()
# push updates to graph
self.fig.canvas.draw_idle()
def ToggleComponent(self, label):
"""
Toggle function for the radio buttons. Switch between line components
`L1`, `L2`, etc. Update the sliders to reflect changing parameters.
"""
# reassign the current component that is selected
self.current_component = label
# don't update the parameter dictionary or draw the graph!!!!!!
self.stall = True
# make current feature bold and the rest thin
for line in self.Component.keys():
if line == label:
self.Component[line].set_linewidth(2)
else:
self.Component[line].set_linewidth(1)
# update the sliders to reflect the current component
for parameter, slider in self.Slider.items():
if parameter != 'Continuum':
slider.set_val(self.Params[label][parameter])
# update the displayed `b`, `N` and `z` if present
if self.has_line_parameters:
self.Update_Preview()
# give control back to sliders
self.stall = False
# push updates to graph
self.fig.canvas.draw_idle()
def solve_b(self):
"""
Given the current line component, solve for the broadening parameter, `b`,
given the instrument profile and the observed broadening.
"""
# b = sqrt(2) * sigma_v
sigma_obs = self.Params[self.current_component]['Sigma']
sigma_v = np.sqrt(sigma_obs**2 - self.sigma_instrument_squared) * self.wavelength.unit
b = np.sqrt(2) * (c * sigma_v / self.wavelength)
return b.to(u.km / u.second)
# return (c.si * (1.4142135623730951 *
# np.sqrt(self.Params[self.current_component]['Sigma']**2
# - self.sigma_instrument_squared) * self.wavelength.unit) / self.wavelength).to(u.km /
# u.second)
def solve_tau0(self):
"""
Solve for the apparent optical depth at line center.
"""
line_data = self.Component[self.current_component].get_ydata()
line_wave = self.Component[self.current_component].get_xdata()
line_center = line_data.argmin()
return np.log(self.y[line_center] / line_data[line_center])
def solve_W(self):
"""
Solve for the equivalent width of the currently selected line.
"""
line_data = self.Component[self.current_component].get_ydata()
line_wave = self.Component[self.current_component].get_xdata()
# apparent optical depth
tau = np.log(self.y / line_data)
return Integrate(1 - np.exp(-tau), line_wave) * self.wavelength.unit
def solve_N(self):
"""
Solve for the column density of the currently selected line component.
"""
# equivalent width (dimensionless)
W = self.solve_W() / self.wavelength
# m_e c^2 / pi e^2 \approx 1.13e12 cm-1 ???? How is that?
const = (1.13e12 / u.cm) / (self.fvalue * self.wavelength.to(u.cm))
return const * W
def solve_v(self):
"""
Solve for the velocity of the line given the expected wavelength.
The result is returned in km s-1.
"""
line_center = self.Params[self.current_component]['Peak'] * self.wavelength.unit
return c.to(u.km / u.second) * (line_center - self.wavelength) / self.wavelength