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/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