YALIP 0.9.2__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.
- yalip/__init__.py +26 -0
- yalip/ameli.py +420 -0
- yalip/fits.py +656 -0
- yalip/lanthanide.py +162 -0
- yalip/levels.py +269 -0
- yalip/matrix.py +189 -0
- yalip/spectrum.py +209 -0
- yalip/states.py +442 -0
- yalip-0.9.2.dist-info/METADATA +168 -0
- yalip-0.9.2.dist-info/RECORD +13 -0
- yalip-0.9.2.dist-info/WHEEL +5 -0
- yalip-0.9.2.dist-info/licenses/LICENSE +21 -0
- yalip-0.9.2.dist-info/top_level.txt +1 -0
yalip/fits.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
##########################################################################
|
|
2
|
+
# Copyright (c) 2026 Reinhard Caspary #
|
|
3
|
+
# <reinhard.caspary@phoenixd.uni-hannover.de> #
|
|
4
|
+
# This program is free software under the terms of the MIT license. #
|
|
5
|
+
##########################################################################
|
|
6
|
+
#
|
|
7
|
+
# This module is used to perform energy level and Judd-Ofelt fits to
|
|
8
|
+
# determine optimised radial integrals and Judd-Ofelt parameters matching
|
|
9
|
+
# measured absorption lines through the class `Fits`.
|
|
10
|
+
#
|
|
11
|
+
##########################################################################
|
|
12
|
+
import math
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy.optimize import least_squares
|
|
18
|
+
|
|
19
|
+
from . import Coupling
|
|
20
|
+
from .spectrum import jo_factors, Transition
|
|
21
|
+
from .matrix import normalize_radial
|
|
22
|
+
from .levels import Levels
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("yalip.fit")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
##########################################################################
|
|
28
|
+
# Formatter functions
|
|
29
|
+
##########################################################################
|
|
30
|
+
|
|
31
|
+
def format_significant(value, num):
|
|
32
|
+
""" Return floating point string representation of the given value rounded to the given number of significant
|
|
33
|
+
digits."""
|
|
34
|
+
|
|
35
|
+
value = f"{value:.{num}g}"
|
|
36
|
+
if 'e' in value:
|
|
37
|
+
value = f"{float(value):.15f}".rstrip('0').rstrip('.')
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_params(params, num):
|
|
42
|
+
""" Return string representation of the given parameter set rounded to the given number of significant digits. """
|
|
43
|
+
|
|
44
|
+
return ", ".join([f"'{k}': {format_significant(params[k], num)}" for k in sorted(params.keys())])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_fixed(params, num):
|
|
48
|
+
""" Return string representation of the given parameter set rounded to the given number of decimal places. """
|
|
49
|
+
|
|
50
|
+
return ", ".join([f"'{k}': {params[k]:.{num}f}" for k in sorted(params.keys())])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
##########################################################################
|
|
54
|
+
# Result table
|
|
55
|
+
##########################################################################
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class MeasLine:
|
|
59
|
+
""" Dataclass containing measured values. """
|
|
60
|
+
|
|
61
|
+
level: int | tuple[int, ...] | None
|
|
62
|
+
name: str
|
|
63
|
+
value: float | str
|
|
64
|
+
delta: float | str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MeasLines:
|
|
68
|
+
""" This class provides level-based access to measured absorption lines. """
|
|
69
|
+
|
|
70
|
+
def __init__(self, lines, num_states):
|
|
71
|
+
""" Extract and store measurement data for each energy level. """
|
|
72
|
+
|
|
73
|
+
assert isinstance(lines, list)
|
|
74
|
+
assert isinstance(num_states, int)
|
|
75
|
+
self.num_states = num_states
|
|
76
|
+
|
|
77
|
+
# Extract measured data for each energy level
|
|
78
|
+
self.lines = [MeasLine(None, "", "", "") for i in range(num_states)]
|
|
79
|
+
for line in lines:
|
|
80
|
+
idx, name, value, delta = line[:4]
|
|
81
|
+
if isinstance(idx, tuple):
|
|
82
|
+
assert all(a - b == 1 for a, b in zip(idx[1:], idx[:-1]))
|
|
83
|
+
self.lines[idx[0]] = MeasLine(idx, name[0], value, delta)
|
|
84
|
+
for i, n in zip(idx[1:], name[1:]):
|
|
85
|
+
self.lines[i] = MeasLine(None, n, "...", "")
|
|
86
|
+
else:
|
|
87
|
+
self.lines[idx] = MeasLine(idx, name, value, delta)
|
|
88
|
+
|
|
89
|
+
def __getitem__(self, i):
|
|
90
|
+
""" Return measurement data of the given level as MeasLine object. """
|
|
91
|
+
|
|
92
|
+
return self.lines[i]
|
|
93
|
+
|
|
94
|
+
def __iter__(self):
|
|
95
|
+
""" Generate measurement data of each level as MeasLine object. """
|
|
96
|
+
|
|
97
|
+
for line in self.lines:
|
|
98
|
+
yield line
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MeasBase:
|
|
102
|
+
""" Base class for level-based comparison classes. """
|
|
103
|
+
|
|
104
|
+
val_fmt: dict
|
|
105
|
+
row_fmt: str
|
|
106
|
+
levels: list
|
|
107
|
+
|
|
108
|
+
def __getitem__(self, i):
|
|
109
|
+
""" Return measured and calculated data of the given level as MeasEnergy object. """
|
|
110
|
+
|
|
111
|
+
return self.levels[i]
|
|
112
|
+
|
|
113
|
+
def __iter__(self):
|
|
114
|
+
""" Generate measured and calculated data of each level as MeasEnergy object. """
|
|
115
|
+
|
|
116
|
+
for level in self.levels:
|
|
117
|
+
yield level
|
|
118
|
+
|
|
119
|
+
def get_sigma(self, name_delta, name_diff):
|
|
120
|
+
""" Return mean error margin and weighted average deviation of measured and calculated values. """
|
|
121
|
+
|
|
122
|
+
levels = [level for level in self.levels if not isinstance(getattr(level, name_delta), str)]
|
|
123
|
+
delta, diff = zip(*[(getattr(level, name_delta), getattr(level, name_diff)) for level in levels])
|
|
124
|
+
weights = [1 / value for value in delta]
|
|
125
|
+
|
|
126
|
+
mean = sum(delta) / len(delta)
|
|
127
|
+
sigma = math.sqrt(sum((w * v) ** 2 for w, v in zip(weights, diff)) / sum(w ** 2 for w in weights))
|
|
128
|
+
return mean, sigma
|
|
129
|
+
|
|
130
|
+
def str_values(self, name, fmt):
|
|
131
|
+
""" Returns formatted level values as strings and their maximum size. """
|
|
132
|
+
|
|
133
|
+
values = [getattr(level, name) for level in self.levels]
|
|
134
|
+
values = [value if isinstance(value, str) else format(value, fmt) for value in values]
|
|
135
|
+
size = max(len(value) for value in values)
|
|
136
|
+
return values, size
|
|
137
|
+
|
|
138
|
+
def row_str(self, row, sizes):
|
|
139
|
+
""" Return given row elements as string. """
|
|
140
|
+
|
|
141
|
+
return self.row_fmt.format(*[format(v, f">{s}s") for v, s in zip(row, sizes)])
|
|
142
|
+
|
|
143
|
+
def iter_table(self, head, foot):
|
|
144
|
+
""" Generate table lines with given header and footer. """
|
|
145
|
+
|
|
146
|
+
assert isinstance(head, list)
|
|
147
|
+
assert isinstance(foot, list)
|
|
148
|
+
|
|
149
|
+
# Data lines and columns widths
|
|
150
|
+
data, sizes = zip(*[self.str_values(name, fmt) for name, fmt in self.val_fmt.items()])
|
|
151
|
+
|
|
152
|
+
# Separation line
|
|
153
|
+
sep = [size * "-" for size in sizes]
|
|
154
|
+
fmt = self.row_fmt.replace("|", "+").replace(" ", "-")
|
|
155
|
+
sep = fmt.format(*sep)
|
|
156
|
+
|
|
157
|
+
# Generate text table
|
|
158
|
+
yield self.row_str(head, sizes)
|
|
159
|
+
yield sep
|
|
160
|
+
for row in list(zip(*data)):
|
|
161
|
+
yield self.row_str(list(row), sizes)
|
|
162
|
+
yield sep
|
|
163
|
+
yield self.row_str(foot, sizes)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class MeasEnergy:
|
|
168
|
+
""" Dataclass containing measured and calculated wavenumbers. """
|
|
169
|
+
|
|
170
|
+
level: int
|
|
171
|
+
line_name: str
|
|
172
|
+
meas: float | str
|
|
173
|
+
delta: float | str
|
|
174
|
+
calc: float
|
|
175
|
+
diff: float | str
|
|
176
|
+
name: str
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class MeasEnergies(MeasBase):
|
|
180
|
+
""" This class provides a level-based comparison of measured and calculated wavenumbers. """
|
|
181
|
+
|
|
182
|
+
val_fmt = {
|
|
183
|
+
"level": "d", "line_name": "s",
|
|
184
|
+
"meas": ".0f", "delta": ".0f", "calc": ".0f", "diff": ".1f",
|
|
185
|
+
"name": "s",
|
|
186
|
+
}
|
|
187
|
+
row_fmt = "{} {} | {} {} {} {} | {}"
|
|
188
|
+
|
|
189
|
+
def __init__(self, lines, names, energies, mult):
|
|
190
|
+
assert isinstance(lines, list)
|
|
191
|
+
assert isinstance(names, list)
|
|
192
|
+
assert isinstance(energies, list)
|
|
193
|
+
assert isinstance(mult, list)
|
|
194
|
+
assert len(energies) == len(mult) == len(names)
|
|
195
|
+
|
|
196
|
+
self.names = names
|
|
197
|
+
self.energies = energies
|
|
198
|
+
self.mult = mult
|
|
199
|
+
|
|
200
|
+
# Prepare line measurement objects
|
|
201
|
+
self.num_states = len(names)
|
|
202
|
+
lines = [line[:4] for line in lines]
|
|
203
|
+
self.lines = MeasLines(lines, self.num_states)
|
|
204
|
+
|
|
205
|
+
# Prepare measured and calculated data for all energy levels
|
|
206
|
+
self.levels = []
|
|
207
|
+
for i in range(self.num_states):
|
|
208
|
+
meas = self.lines[i]
|
|
209
|
+
calc = self.energies[i]
|
|
210
|
+
if isinstance(meas.level, int):
|
|
211
|
+
diff = self.energies[i] - meas.value
|
|
212
|
+
elif isinstance(meas.level, tuple):
|
|
213
|
+
weight = sum(self.mult[j] for j in meas.level)
|
|
214
|
+
diff = sum(self.energies[j] * self.mult[j] for j in meas.level) / weight - meas.value
|
|
215
|
+
else:
|
|
216
|
+
diff = meas.value
|
|
217
|
+
name = self.names[i]
|
|
218
|
+
self.levels.append(MeasEnergy(i, meas.name, meas.value, meas.delta, calc, diff, name))
|
|
219
|
+
|
|
220
|
+
# Mean error margin and weighted average deviation of measured and calculated values
|
|
221
|
+
self.mean, self.sigma = self.get_sigma("delta", "diff")
|
|
222
|
+
|
|
223
|
+
def table(self):
|
|
224
|
+
""" Generate line strings of result table. """
|
|
225
|
+
|
|
226
|
+
# Header elements
|
|
227
|
+
head = ["", "", "kmeas", "", "kcalc", "", ""]
|
|
228
|
+
|
|
229
|
+
# Footer elements
|
|
230
|
+
sigma = format(self.sigma, self.val_fmt["diff"])
|
|
231
|
+
mean = format(self.mean, self.val_fmt["delta"])
|
|
232
|
+
foot = ["", "", "", mean, "", sigma, ""]
|
|
233
|
+
|
|
234
|
+
# Generate table lines
|
|
235
|
+
yield from self.iter_table(head, foot)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class MeasStrength:
|
|
240
|
+
""" Dataclass containing measured and calculated oscillator strengths. """
|
|
241
|
+
|
|
242
|
+
level: int
|
|
243
|
+
line_name: str
|
|
244
|
+
meas: float | str
|
|
245
|
+
delta: float | str
|
|
246
|
+
ed: float
|
|
247
|
+
md: float
|
|
248
|
+
diff: float | str
|
|
249
|
+
name: str
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class MeasStrengths(MeasBase):
|
|
253
|
+
""" This class provides a level-based comparison of measured and calculated oscillator strengths. """
|
|
254
|
+
|
|
255
|
+
val_fmt = {
|
|
256
|
+
"level": "d", "line_name": "s",
|
|
257
|
+
"meas": ".1f", "delta": ".1f", "ed": ".1f", "md": ".1f", "diff": ".1f",
|
|
258
|
+
"name": "s",
|
|
259
|
+
}
|
|
260
|
+
row_fmt = "{} {} | {} {} {} {} {} | {}"
|
|
261
|
+
|
|
262
|
+
def __init__(self, lines, names, strengths):
|
|
263
|
+
assert isinstance(lines, list)
|
|
264
|
+
assert isinstance(names, list)
|
|
265
|
+
assert isinstance(strengths, Transition)
|
|
266
|
+
assert len(strengths.ed) == len(strengths.md) == len(names)
|
|
267
|
+
|
|
268
|
+
self.names = names
|
|
269
|
+
self.strengths = strengths
|
|
270
|
+
|
|
271
|
+
# Prepare line measurement objects
|
|
272
|
+
self.num_states = len(names)
|
|
273
|
+
lines = [line[:2] + line[4:6] for line in lines]
|
|
274
|
+
self.lines = MeasLines(lines, self.num_states)
|
|
275
|
+
|
|
276
|
+
# Prepare measured and calculated data for all energy levels
|
|
277
|
+
self.levels = []
|
|
278
|
+
for i in range(self.num_states):
|
|
279
|
+
meas = self.lines[i]
|
|
280
|
+
ed = self.strengths.ed[i]
|
|
281
|
+
md = self.strengths.md[i]
|
|
282
|
+
if isinstance(meas.level, int):
|
|
283
|
+
diff = (self.strengths.ed[i] + self.strengths.md[i]) - meas.value
|
|
284
|
+
elif isinstance(meas.level, tuple):
|
|
285
|
+
diff = sum(self.strengths.ed[j] + self.strengths.md[j] for j in meas.level) - meas.value
|
|
286
|
+
else:
|
|
287
|
+
diff = meas.value
|
|
288
|
+
name = self.names[i]
|
|
289
|
+
self.levels.append(MeasStrength(i, meas.name, meas.value, meas.delta, ed, md, diff, name))
|
|
290
|
+
|
|
291
|
+
# Mean error margin and weighted average deviation of measured and calculated values
|
|
292
|
+
self.mean, self.sigma = self.get_sigma("delta", "diff")
|
|
293
|
+
|
|
294
|
+
def table(self):
|
|
295
|
+
""" Generate line strings of result table. """
|
|
296
|
+
|
|
297
|
+
# Header elements
|
|
298
|
+
head = ["", "", "fmeas", "", "fed", "fmd", "", ""]
|
|
299
|
+
|
|
300
|
+
# Footer_elements
|
|
301
|
+
sigma = format(self.sigma, self.val_fmt["diff"])
|
|
302
|
+
mean = format(self.mean, self.val_fmt["delta"])
|
|
303
|
+
foot = ["", "", "", mean, "", "", sigma, ""]
|
|
304
|
+
|
|
305
|
+
# Generate table lines
|
|
306
|
+
yield from self.iter_table(head, foot)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass
|
|
310
|
+
class MeasLevel:
|
|
311
|
+
""" Dataclass containing measured and calculated wavenumbers and oscillator strengths. """
|
|
312
|
+
|
|
313
|
+
level: int
|
|
314
|
+
line_name: str
|
|
315
|
+
k_meas: float | str
|
|
316
|
+
k_delta: float | str
|
|
317
|
+
k_calc: float
|
|
318
|
+
k_diff: float | str
|
|
319
|
+
f_meas: float | str
|
|
320
|
+
f_delta: float | str
|
|
321
|
+
f_ed: float
|
|
322
|
+
f_md: float
|
|
323
|
+
f_diff: float | str
|
|
324
|
+
name: str
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class MeasLevels(MeasBase):
|
|
328
|
+
""" This class provides a level-based comparison of measured and calculated wavenumbers and oscillator
|
|
329
|
+
strengths. """
|
|
330
|
+
|
|
331
|
+
val_fmt = {
|
|
332
|
+
"level": "d", "line_name": "s",
|
|
333
|
+
"k_meas": ".0f", "k_delta": ".0f", "k_calc": ".0f", "k_diff": ".1f",
|
|
334
|
+
"f_meas": ".1f", "f_delta": ".1f", "f_ed": ".1f", "f_md": ".1f", "f_diff": ".1f",
|
|
335
|
+
"name": "s",
|
|
336
|
+
}
|
|
337
|
+
row_fmt = "{} {} | {} {} {} {} | {} {} {} {} {} | {}"
|
|
338
|
+
|
|
339
|
+
def __init__(self, lines, names, energies, mult, strengths):
|
|
340
|
+
self.num_states = len(names)
|
|
341
|
+
|
|
342
|
+
# Comparison objects for wavenumbers and oscillator strengths
|
|
343
|
+
self.energies = MeasEnergies(lines, names, energies, mult)
|
|
344
|
+
self.strengths = MeasStrengths(lines, names, strengths)
|
|
345
|
+
|
|
346
|
+
# Prepare measured and calculated data for all energy levels
|
|
347
|
+
self.levels = []
|
|
348
|
+
for i in range(self.num_states):
|
|
349
|
+
k_meas = self.energies[i]
|
|
350
|
+
f_meas = self.strengths[i]
|
|
351
|
+
k_data = (k_meas.meas, k_meas.delta, k_meas.calc, k_meas.diff)
|
|
352
|
+
f_data = (f_meas.meas, f_meas.delta, f_meas.ed, f_meas.md, f_meas.diff)
|
|
353
|
+
self.levels.append(MeasLevel(i, k_meas.line_name, *k_data, *f_data, f_meas.name))
|
|
354
|
+
|
|
355
|
+
# Mean error margin and weighted average deviation of measured and calculated values
|
|
356
|
+
self.k_mean, self.k_sigma = self.get_sigma("k_delta", "k_diff")
|
|
357
|
+
self.f_mean, self.f_sigma = self.get_sigma("f_delta", "f_diff")
|
|
358
|
+
|
|
359
|
+
def table(self):
|
|
360
|
+
""" Generate line strings of result table. """
|
|
361
|
+
|
|
362
|
+
# Header elements
|
|
363
|
+
head = ["", "", "kmeas", "", "kcalc", "", "fmeas", "", "fed", "fmd", "", ""]
|
|
364
|
+
|
|
365
|
+
# Footer elements
|
|
366
|
+
k_sigma = format(self.k_sigma, self.val_fmt["k_diff"])
|
|
367
|
+
k_mean = format(self.k_mean, self.val_fmt["k_delta"])
|
|
368
|
+
f_sigma = format(self.f_sigma, self.val_fmt["f_diff"])
|
|
369
|
+
f_mean = format(self.f_mean, self.val_fmt["f_delta"])
|
|
370
|
+
foot = ["", "", "", k_mean, "", k_sigma, "", f_mean, "", "", f_sigma, ""]
|
|
371
|
+
|
|
372
|
+
# Generate table lines
|
|
373
|
+
yield from self.iter_table(head, foot)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
##########################################################################
|
|
377
|
+
# Energy level fit
|
|
378
|
+
##########################################################################
|
|
379
|
+
|
|
380
|
+
class LevelFit:
|
|
381
|
+
""" This class is used to perform an energy level fit using the Levenberg-Marquardt algorithm. """
|
|
382
|
+
|
|
383
|
+
def __init__(self, matrices, mult, lines):
|
|
384
|
+
""" Store operator matrices, state multiplicities, and measured absorption lines. """
|
|
385
|
+
|
|
386
|
+
self.matrices = matrices
|
|
387
|
+
self.mult = mult
|
|
388
|
+
self.lines = lines
|
|
389
|
+
|
|
390
|
+
self.params = {}
|
|
391
|
+
self.num_states = self.matrices[next(iter(matrices.keys()))].shape[0]
|
|
392
|
+
|
|
393
|
+
def set_params(self, params):
|
|
394
|
+
""" Store a set of radial parameters. """
|
|
395
|
+
|
|
396
|
+
assert isinstance(params, dict)
|
|
397
|
+
self.params = params
|
|
398
|
+
|
|
399
|
+
def get_energies(self):
|
|
400
|
+
""" Diagonalize the total perturbation Hamiltonian and return the energies and multiplicities of all states
|
|
401
|
+
in intermediate coupling. """
|
|
402
|
+
|
|
403
|
+
# Total perturbation Hamiltonian
|
|
404
|
+
H = np.zeros((self.num_states, self.num_states), dtype=float)
|
|
405
|
+
for name in self.params:
|
|
406
|
+
if name != "base":
|
|
407
|
+
H += self.params[name] * self.matrices[name]
|
|
408
|
+
|
|
409
|
+
# Diagonalize Hamiltonian
|
|
410
|
+
energies, transform = np.linalg.eigh(H)
|
|
411
|
+
|
|
412
|
+
# Multiplicity of each energy level in intermediate coupling
|
|
413
|
+
weight = np.abs(transform ** 2)
|
|
414
|
+
mult = self.mult[np.argmax(weight, axis=0)]
|
|
415
|
+
|
|
416
|
+
# Return energies and multiplicities of all states
|
|
417
|
+
return energies, mult
|
|
418
|
+
|
|
419
|
+
def compare(self):
|
|
420
|
+
""" Return measured and calculated wavenumbers, measurement accuracies, and multiplicities of all measured
|
|
421
|
+
absorption lines. """
|
|
422
|
+
|
|
423
|
+
# Calculated energies and multiplicities of all energy levels
|
|
424
|
+
energies, mult = self.get_energies()
|
|
425
|
+
|
|
426
|
+
# Collect comparison data
|
|
427
|
+
results = []
|
|
428
|
+
for idx, k_meas, dk_meas in self.lines:
|
|
429
|
+
if isinstance(idx, int):
|
|
430
|
+
m = mult[idx]
|
|
431
|
+
k_calc = energies[idx]
|
|
432
|
+
else:
|
|
433
|
+
idx = np.array(idx)
|
|
434
|
+
m = np.sum(mult[idx])
|
|
435
|
+
k_calc = np.sum(energies[idx] * mult[idx]) / m
|
|
436
|
+
results.append((k_meas, k_calc, dk_meas, m))
|
|
437
|
+
k_meas, k_calc, dk_meas, m = np.array(results).T
|
|
438
|
+
|
|
439
|
+
# Equalise barycenter of measured and calculated energy levels
|
|
440
|
+
k0 = np.sum((k_meas - k_calc) * m) / sum(m)
|
|
441
|
+
k_calc += k0
|
|
442
|
+
self.params["base"] = float(energies[0] + k0)
|
|
443
|
+
|
|
444
|
+
# Return results
|
|
445
|
+
return k_meas, k_calc, dk_meas, m
|
|
446
|
+
|
|
447
|
+
def get_residuals(self):
|
|
448
|
+
""" Return residuals of all measured absorption lines weighted by their multiplicities and measurement
|
|
449
|
+
accuracies. """
|
|
450
|
+
|
|
451
|
+
k_meas, k_calc, dk_meas, m = self.compare()
|
|
452
|
+
return (k_meas - k_calc) / dk_meas
|
|
453
|
+
|
|
454
|
+
def get_chi2(self):
|
|
455
|
+
""" Return square sum of residuals. """
|
|
456
|
+
|
|
457
|
+
residuals = self.get_residuals()
|
|
458
|
+
return float(np.sum(residuals ** 2))
|
|
459
|
+
|
|
460
|
+
def get_sigma(self):
|
|
461
|
+
""" Return weighted average deviation of measured and calculated absorption lines. """
|
|
462
|
+
|
|
463
|
+
k_meas, k_calc, dk_meas, m = self.compare()
|
|
464
|
+
return float(np.sqrt(np.sum(((k_meas - k_calc) / dk_meas) ** 2) / np.sum(1 / dk_meas ** 2)))
|
|
465
|
+
|
|
466
|
+
def update_params(self, names, values):
|
|
467
|
+
""" Update given radial integrals. """
|
|
468
|
+
|
|
469
|
+
self.params = self.params | dict(zip(names, values))
|
|
470
|
+
|
|
471
|
+
def run(self, opt_names):
|
|
472
|
+
""" Perform an energy level fit by optimizing the given radial integrals to match measured energy levels. """
|
|
473
|
+
|
|
474
|
+
def calculate(values):
|
|
475
|
+
self.update_params(opt_names, values)
|
|
476
|
+
return self.get_residuals()
|
|
477
|
+
|
|
478
|
+
# Perform the optimization
|
|
479
|
+
initial = [self.params[n] for n in opt_names]
|
|
480
|
+
res = least_squares(calculate, initial, method='lm')
|
|
481
|
+
self.update_params(opt_names, res.x.tolist())
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
##########################################################################
|
|
485
|
+
# Judd-Ofelt fit
|
|
486
|
+
##########################################################################
|
|
487
|
+
|
|
488
|
+
def judd_ofelt_fit(ion, lines):
|
|
489
|
+
""" Perform a Judd-Ofelt fit using a linear least squares operation. """
|
|
490
|
+
|
|
491
|
+
assert isinstance(ion, Levels)
|
|
492
|
+
assert isinstance(lines, list)
|
|
493
|
+
|
|
494
|
+
# Prepare oscillator strength factors
|
|
495
|
+
factor_ed, factor_md = jo_factors(ion.mult[0], ion.energies, ion.material)
|
|
496
|
+
fed = np.column_stack((ion.dipole.U2[:, 0], ion.dipole.U4[:, 0], ion.dipole.U6[:, 0])) * factor_ed[:, None]
|
|
497
|
+
fmd = ion.dipole.LS[:, 0] * factor_md
|
|
498
|
+
|
|
499
|
+
# Build matrix A of calculated values and result vector b of measured values
|
|
500
|
+
A = np.zeros((len(lines), 3), dtype=float)
|
|
501
|
+
b = np.zeros(len(lines), dtype=float)
|
|
502
|
+
for i, (idx, f_meas, df_meas) in enumerate(lines):
|
|
503
|
+
if isinstance(idx, int):
|
|
504
|
+
b[i] = (f_meas - fmd[idx]) / df_meas
|
|
505
|
+
A[i, :] = fed[idx, :] / df_meas
|
|
506
|
+
else:
|
|
507
|
+
idx = np.array(idx)
|
|
508
|
+
b[i] = (f_meas - np.sum(fmd[idx])) / df_meas
|
|
509
|
+
A[i, :] = np.sum(fed[idx, :], axis=0) / df_meas
|
|
510
|
+
|
|
511
|
+
# Perform linear least squares fit resulting in the Judd-Ofelt parameters omega
|
|
512
|
+
omega, residuals, rank, _ = np.linalg.lstsq(A, b, rcond=None)
|
|
513
|
+
chi2 = residuals[0]
|
|
514
|
+
assert rank == 3
|
|
515
|
+
|
|
516
|
+
# Return parameter dictionary and weighted mean deviation of measured and calculated oscillator strengths
|
|
517
|
+
df_meas = np.array([line[2] for line in lines])
|
|
518
|
+
sigma = float(np.sqrt(chi2 / np.sum(1 / df_meas ** 2))) * 1e8
|
|
519
|
+
judd_ofelt = {f"JO/{2 * i + 2}": value for i, value in enumerate(omega)}
|
|
520
|
+
return judd_ofelt, sigma
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
##########################################################################
|
|
524
|
+
# Class Fits
|
|
525
|
+
##########################################################################
|
|
526
|
+
|
|
527
|
+
class Fits:
|
|
528
|
+
""" This class is used to perform energy level and Judd-Ofelt fits to determine optimised radial integrals and
|
|
529
|
+
Judd-Ofelt parameters matching measured absorption lines. """
|
|
530
|
+
|
|
531
|
+
def __init__(self, config, coupling, radial, material=None):
|
|
532
|
+
|
|
533
|
+
assert isinstance(config, str)
|
|
534
|
+
assert isinstance(coupling, Coupling)
|
|
535
|
+
assert isinstance(radial, dict)
|
|
536
|
+
|
|
537
|
+
# Electron configuration
|
|
538
|
+
self.config = config
|
|
539
|
+
|
|
540
|
+
# Coupling scheme
|
|
541
|
+
self.coupling = coupling
|
|
542
|
+
|
|
543
|
+
# Radial integrals
|
|
544
|
+
self.radial = normalize_radial(radial)
|
|
545
|
+
|
|
546
|
+
# Intermediate coupling object
|
|
547
|
+
self.ion = Levels(config, coupling, radial, None, material)
|
|
548
|
+
|
|
549
|
+
# Material object providing spectral refractive indices
|
|
550
|
+
self.material = material
|
|
551
|
+
self.has_strengths = material is not None
|
|
552
|
+
|
|
553
|
+
# State multiplicities
|
|
554
|
+
self.mult = np.array(self.base_states.mult)
|
|
555
|
+
|
|
556
|
+
# Perturbation energy matrices
|
|
557
|
+
self.matrices = {name: self.base_states.matrix(name) for name in radial.keys() if name != "base"}
|
|
558
|
+
|
|
559
|
+
# No fit yet
|
|
560
|
+
self.levels = None
|
|
561
|
+
self.sigma_k = None
|
|
562
|
+
self.sigma_f = None
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def base_states(self):
|
|
566
|
+
""" Basis states. """
|
|
567
|
+
|
|
568
|
+
return self.ion.base_states
|
|
569
|
+
|
|
570
|
+
@property
|
|
571
|
+
def radial_integrals(self):
|
|
572
|
+
""" Radial integrals. """
|
|
573
|
+
|
|
574
|
+
return self.ion.radial_integrals
|
|
575
|
+
|
|
576
|
+
@property
|
|
577
|
+
def judd_ofelt(self):
|
|
578
|
+
""" Judd-Ofelt parameters. """
|
|
579
|
+
|
|
580
|
+
return self.ion.judd_ofelt
|
|
581
|
+
|
|
582
|
+
def run(self, lines, stages=None):
|
|
583
|
+
""" Multi-stage combined energy level and Judd_ofelt fit to measured absorption lines. """
|
|
584
|
+
|
|
585
|
+
# Measured absorption lines
|
|
586
|
+
assert isinstance(lines, list)
|
|
587
|
+
size = set(len(line) for line in lines)
|
|
588
|
+
assert len(size) == 1
|
|
589
|
+
size = size.pop()
|
|
590
|
+
assert size == 6
|
|
591
|
+
self.lines = lines
|
|
592
|
+
|
|
593
|
+
if stages is None:
|
|
594
|
+
self.ion = Levels(self.config, self.coupling, self.radial, None, self.material)
|
|
595
|
+
|
|
596
|
+
else:
|
|
597
|
+
# Handle single optimisation stage
|
|
598
|
+
if not isinstance(stages[0], (list, tuple)):
|
|
599
|
+
stages = [stages]
|
|
600
|
+
|
|
601
|
+
k_lines = [[line[0], line[2], line[3]] for line in lines]
|
|
602
|
+
opt = LevelFit(self.matrices, self.mult, k_lines)
|
|
603
|
+
for i, names in enumerate(stages):
|
|
604
|
+
raw_names = [n[1:] if n.startswith(":") else n for n in names]
|
|
605
|
+
assert len(set(raw_names)) == len(raw_names)
|
|
606
|
+
opt.set_params({n: self.radial[n] for n in raw_names})
|
|
607
|
+
|
|
608
|
+
p = format_params(opt.params, 6)
|
|
609
|
+
dk = opt.get_sigma()
|
|
610
|
+
logger.info(f"Stage {i}: Initial dk: {dk:.2f}, parameters: {p}")
|
|
611
|
+
|
|
612
|
+
names = [n for n in names if n != "base" and not n.startswith(":")]
|
|
613
|
+
opt.run(names)
|
|
614
|
+
self.radial |= opt.params
|
|
615
|
+
|
|
616
|
+
p = format_params(opt.params, 6)
|
|
617
|
+
self.sigma_k = opt.get_sigma()
|
|
618
|
+
logger.info(f"Stage {i}: Final dk: {self.sigma_k:.2f}, parameters: {p}")
|
|
619
|
+
|
|
620
|
+
self.ion = Levels(self.config, self.coupling, opt.params, None, self.material)
|
|
621
|
+
|
|
622
|
+
# Judd-Ofelt fit
|
|
623
|
+
if self.has_strengths:
|
|
624
|
+
f_lines = [[line[0], line[4], line[5]] for line in lines]
|
|
625
|
+
judd_ofelt, self.sigma_f = judd_ofelt_fit(self.ion, f_lines)
|
|
626
|
+
self.ion.judd_ofelt = judd_ofelt
|
|
627
|
+
p = format_fixed(judd_ofelt, 3)
|
|
628
|
+
logger.info(f"Judd-Ofelt fit: df: {self.sigma_f:.2f}, parameters: {p}")
|
|
629
|
+
|
|
630
|
+
# Return optimised Levels object
|
|
631
|
+
return self.ion
|
|
632
|
+
|
|
633
|
+
def table(self):
|
|
634
|
+
""" Generate text table comparing measured and calculated absorption lines. """
|
|
635
|
+
|
|
636
|
+
assert self.lines is not None, "Run an energy level fit first!"
|
|
637
|
+
|
|
638
|
+
# Scale oscillator strengths
|
|
639
|
+
lines = [line.copy() for line in self.lines]
|
|
640
|
+
if self.has_strengths:
|
|
641
|
+
strengths = self.ion.oscillator_strengths()
|
|
642
|
+
strengths.ed = strengths.ed[:, 0] * 1e8
|
|
643
|
+
strengths.md = strengths.md[:, 0] * 1e8
|
|
644
|
+
for i in range(len(lines)):
|
|
645
|
+
lines[i][4] *= 1e8
|
|
646
|
+
lines[i][5] *= 1e8
|
|
647
|
+
else:
|
|
648
|
+
strengths = None
|
|
649
|
+
|
|
650
|
+
# Generate line strings of table
|
|
651
|
+
names = [state.short() for state in self.ion.states]
|
|
652
|
+
energies = list(self.ion.states.energies)
|
|
653
|
+
mult = list(self.ion.states.mult)
|
|
654
|
+
meas = MeasLevels(lines, names, energies, mult, strengths)
|
|
655
|
+
for line in meas.table():
|
|
656
|
+
yield line
|