lightweaver 0.16.1__cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.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.
Files changed (69) hide show
  1. lightweaver/Data/AbundancesAsplund09.pickle +0 -0
  2. lightweaver/Data/AtomicMassesNames.pickle +0 -0
  3. lightweaver/Data/Barklem_dfdata.dat +41 -0
  4. lightweaver/Data/Barklem_pddata.dat +40 -0
  5. lightweaver/Data/Barklem_spdata.dat +46 -0
  6. lightweaver/Data/DefaultMolecules/C2.molecule +27 -0
  7. lightweaver/Data/DefaultMolecules/CH/CH_X-A.asc +46409 -0
  8. lightweaver/Data/DefaultMolecules/CH/CH_X-A_12.asc +28322 -0
  9. lightweaver/Data/DefaultMolecules/CH/CH_X-B.asc +4272 -0
  10. lightweaver/Data/DefaultMolecules/CH/CH_X-B_12.asc +2583 -0
  11. lightweaver/Data/DefaultMolecules/CH/CH_X-C.asc +20916 -0
  12. lightweaver/Data/DefaultMolecules/CH/CH_X-C_12.asc +13106 -0
  13. lightweaver/Data/DefaultMolecules/CH.molecule +35 -0
  14. lightweaver/Data/DefaultMolecules/CN.molecule +30 -0
  15. lightweaver/Data/DefaultMolecules/CO/vmax=3_Jmax=49_dv=1_26 +296 -0
  16. lightweaver/Data/DefaultMolecules/CO/vmax=9_Jmax=120_dv=1_26 +2162 -0
  17. lightweaver/Data/DefaultMolecules/CO.molecule +30 -0
  18. lightweaver/Data/DefaultMolecules/CO_NLTE.molecule +29 -0
  19. lightweaver/Data/DefaultMolecules/CaH.molecule +29 -0
  20. lightweaver/Data/DefaultMolecules/H2+.molecule +27 -0
  21. lightweaver/Data/DefaultMolecules/H2.molecule +27 -0
  22. lightweaver/Data/DefaultMolecules/H2O.molecule +27 -0
  23. lightweaver/Data/DefaultMolecules/HF.molecule +29 -0
  24. lightweaver/Data/DefaultMolecules/LiH.molecule +27 -0
  25. lightweaver/Data/DefaultMolecules/MgH.molecule +34 -0
  26. lightweaver/Data/DefaultMolecules/N2.molecule +28 -0
  27. lightweaver/Data/DefaultMolecules/NH.molecule +27 -0
  28. lightweaver/Data/DefaultMolecules/NO.molecule +27 -0
  29. lightweaver/Data/DefaultMolecules/O2.molecule +27 -0
  30. lightweaver/Data/DefaultMolecules/OH.molecule +27 -0
  31. lightweaver/Data/DefaultMolecules/SiO.molecule +26 -0
  32. lightweaver/Data/DefaultMolecules/TiO.molecule +30 -0
  33. lightweaver/Data/Quadratures.pickle +0 -0
  34. lightweaver/Data/pf_Kurucz.input +0 -0
  35. lightweaver/DefaultIterSchemes/.placeholder +0 -0
  36. lightweaver/DefaultIterSchemes/SimdImpl_AVX2FMA.cpython-312-x86_64-linux-gnu.so +0 -0
  37. lightweaver/DefaultIterSchemes/SimdImpl_AVX512.cpython-312-x86_64-linux-gnu.so +0 -0
  38. lightweaver/DefaultIterSchemes/SimdImpl_SSE2.cpython-312-x86_64-linux-gnu.so +0 -0
  39. lightweaver/LwCompiled.cpython-312-x86_64-linux-gnu.so +0 -0
  40. lightweaver/__init__.py +33 -0
  41. lightweaver/atmosphere.py +1767 -0
  42. lightweaver/atomic_model.py +852 -0
  43. lightweaver/atomic_set.py +1286 -0
  44. lightweaver/atomic_table.py +653 -0
  45. lightweaver/barklem.py +151 -0
  46. lightweaver/benchmark.py +113 -0
  47. lightweaver/broadening.py +605 -0
  48. lightweaver/collisional_rates.py +337 -0
  49. lightweaver/config.py +106 -0
  50. lightweaver/constants.py +22 -0
  51. lightweaver/crtaf.py +197 -0
  52. lightweaver/fal.py +440 -0
  53. lightweaver/iterate_ctx.py +241 -0
  54. lightweaver/iteration_update.py +134 -0
  55. lightweaver/libenkiTS.so +0 -0
  56. lightweaver/molecule.py +225 -0
  57. lightweaver/multi.py +113 -0
  58. lightweaver/nr_update.py +106 -0
  59. lightweaver/rh_atoms.py +19743 -0
  60. lightweaver/simd_management.py +42 -0
  61. lightweaver/utils.py +509 -0
  62. lightweaver/version.py +34 -0
  63. lightweaver/wittmann.py +1375 -0
  64. lightweaver/zeeman.py +157 -0
  65. lightweaver-0.16.1.dist-info/METADATA +81 -0
  66. lightweaver-0.16.1.dist-info/RECORD +69 -0
  67. lightweaver-0.16.1.dist-info/WHEEL +6 -0
  68. lightweaver-0.16.1.dist-info/licenses/LICENSE +21 -0
  69. lightweaver-0.16.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1286 @@
1
+ from copy import copy
2
+ from dataclasses import dataclass
3
+ from typing import Dict, Iterable, List, Optional, Set, Tuple, Union, cast
4
+
5
+ import astropy.units as u
6
+ import numpy as np
7
+ from numba import njit
8
+ from scipy.linalg import solve
9
+ from scipy.optimize import newton_krylov
10
+
11
+ import lightweaver.constants as Const
12
+ from .atmosphere import Atmosphere
13
+ from .atomic_model import AtomicModel, LineType, element_sort
14
+ from .atomic_table import (AtomicAbundance, DefaultAtomicAbundance, Element,
15
+ KuruczPf, KuruczPfTable, PeriodicTable)
16
+ from .molecule import MolecularTable
17
+
18
+
19
+ @njit(cache=True)
20
+ def lte_pops_impl(temperature, ne, nTotal, stages, energies,
21
+ gs, nStar=None, debye=True, computeDiff=False):
22
+ Nlevel = stages.shape[0]
23
+ Nspace = ne.shape[0]
24
+ c1 = ((Const.HPlanck / (2.0 * np.pi * Const.MElectron))
25
+ * (Const.HPlanck / Const.KBoltzmann))
26
+
27
+ c2 = 0.0
28
+ nDebye = np.zeros(Nlevel)
29
+ if debye:
30
+ c2 = (np.sqrt(8.0 * np.pi / Const.KBoltzmann)
31
+ * (Const.QElectron**2 / (4.0 * np.pi * Const.Epsilon0))**1.5)
32
+ for i in range(1, Nlevel):
33
+ stage = stages[i]
34
+ Z = stage
35
+ for m in range(1, stage - stages[0] + 1):
36
+ nDebye[i] += Z
37
+ Z += 1
38
+
39
+ if nStar is None:
40
+ nStar = np.empty((Nlevel, Nspace))
41
+
42
+ if computeDiff:
43
+ prev = np.empty(Nlevel)
44
+ # NOTE(cmo): Will remain 0 and be returned as second return value if
45
+ # computeDiff is not set to True
46
+ maxDiff = 0.0
47
+
48
+ # NOTE(cmo): For some reason this is consistently faster with these hoisted
49
+ # from below, despite the allocations this causes
50
+ dE = energies - energies[0]
51
+ gi0 = gs / gs[0]
52
+ dZ = stages - stages[0]
53
+
54
+ for k in range(Nspace):
55
+ if debye:
56
+ dEion = c2 * np.sqrt(ne[k] / temperature[k])
57
+ else:
58
+ dEion = 0.0
59
+ cNe_T = 0.5 * ne[k] * (c1 / temperature[k])**1.5
60
+ total = 1.0
61
+ if computeDiff:
62
+ for i in range(Nlevel):
63
+ prev[i] = nStar[i, k]
64
+ for i in range(1, Nlevel):
65
+ dE_kT = (dE[i] - nDebye[i] * dEion) / (Const.KBoltzmann * temperature[k])
66
+ neFactor = cNe_T**dZ[i]
67
+
68
+ nst = gi0[i] * np.exp(-dE_kT)
69
+ nStar[i, k] = nst
70
+ nStar[i, k] /= neFactor
71
+ total += nStar[i, k]
72
+ nStar[0, k] = nTotal[k] / total
73
+
74
+ for i in range(1, Nlevel):
75
+ nStar[i, k] *= nStar[0, k]
76
+
77
+ if computeDiff:
78
+ for i in range(Nlevel):
79
+ maxDiff = max((nStar[i, k] - prev[i]) / nStar[i, k], maxDiff)
80
+
81
+ return nStar, maxDiff
82
+
83
+ def lte_pops(atomicModel: AtomicModel, temperature: np.ndarray,
84
+ ne: np.ndarray, nTotal: np.ndarray, nStar=None,
85
+ debye: bool=True) -> np.ndarray:
86
+ '''
87
+ Compute the LTE populations for a given atomic model under given
88
+ thermodynamic conditions.
89
+
90
+ Parameters
91
+ ----------
92
+ atomicModel : AtomicModel
93
+ The atomic model to consider.
94
+ temperature : np.ndarray
95
+ The temperature structure in the atmosphere.
96
+ ne : np.ndarray
97
+ The electron density in the atmosphere.
98
+ nTotal : np.ndarray
99
+ The total population of the species at each point in the atmosphere.
100
+ nStar : np.ndarray, optional
101
+ An optional array to store the result in.
102
+ debye : bool, optional
103
+ Whether to consider Debye shielding (default: True).1
104
+
105
+ Returns
106
+ -------
107
+ ltePops : np.ndarray
108
+ The ltePops for the species.
109
+ '''
110
+ stages = np.array([l.stage for l in atomicModel.levels])
111
+ energies = np.array([l.E_SI for l in atomicModel.levels])
112
+ gs = np.array([l.g for l in atomicModel.levels])
113
+ return lte_pops_impl(temperature, ne, nTotal, stages,
114
+ energies, gs, nStar=nStar, debye=debye)[0]
115
+
116
+ def update_lte_pops_inplace(atomicModel: AtomicModel, temperature: np.ndarray,
117
+ ne: np.ndarray, nTotal: np.ndarray, nStar: np.ndarray,
118
+ debye: bool=True) -> Tuple[np.ndarray, float]:
119
+ stages = np.array([l.stage for l in atomicModel.levels])
120
+ energies = np.array([l.E_SI for l in atomicModel.levels])
121
+ gs = np.array([l.g for l in atomicModel.levels])
122
+ return lte_pops_impl(temperature, ne, nTotal, stages, energies, gs,
123
+ debye=debye, nStar=nStar, computeDiff=True)
124
+
125
+ class LteNeIterator:
126
+ def __init__(self, atoms: Iterable[AtomicModel], temperature: np.ndarray,
127
+ nHTot: np.ndarray, abundance: AtomicAbundance,
128
+ nlteStartingPops: Dict[Element, np.ndarray]):
129
+ sortedAtoms = sorted(atoms, key=element_sort)
130
+ self.nTotal = [abundance[a.element] * nHTot
131
+ for a in sortedAtoms]
132
+ self.stages = [np.array([l.stage for l in a.levels])
133
+ for a in sortedAtoms]
134
+ self.temperature = temperature
135
+ self.nHTot = nHTot
136
+ self.sortedAtoms = sortedAtoms
137
+ self.abundances = [abundance[a.element] for a in sortedAtoms]
138
+ self.nlteStartingPops = nlteStartingPops
139
+
140
+ def __call__(self, prevNeRatio: np.ndarray) -> np.ndarray:
141
+ atomicPops = []
142
+ ne = np.zeros_like(prevNeRatio)
143
+ prevNe = prevNeRatio * self.nHTot
144
+
145
+ for i, a in enumerate(self.sortedAtoms):
146
+ nStar = lte_pops(a, self.temperature, prevNe,
147
+ self.nTotal[i], debye=True)
148
+ atomicPops.append(AtomicState(model=a, abundance=self.abundances[i],
149
+ nStar=nStar, nTotal=self.nTotal[i]))
150
+ # NOTE(cmo): Take into account NLTE pops if provided
151
+ if a.element in self.nlteStartingPops:
152
+ if self.nlteStartingPops[a.element].shape != nStar.shape:
153
+ raise ValueError(('Starting populations provided for %s '
154
+ 'do not match model.') % a.element)
155
+ nStar = self.nlteStartingPops[a.element]
156
+
157
+ ne += np.sum(nStar * self.stages[i][:, None], axis=0)
158
+
159
+ self.atomicPops = atomicPops
160
+ diff = (ne - prevNe) / self.nHTot
161
+ return diff
162
+
163
+
164
+ @dataclass
165
+ class SpectrumConfiguration:
166
+ '''
167
+ Container for the configuration of common wavelength grid and species
168
+ active at each wavelength.
169
+
170
+ Attributes
171
+ ----------
172
+ radSet : RadiativeSet
173
+ The set of atoms involved in the creation of this simulation.
174
+ wavelength : np.ndarray
175
+ The common wavelength array used for this simulation.
176
+ models : list of AtomicModel
177
+ The models for the active and detailed static atoms present in this
178
+ simulation.
179
+ transWavelengths : Dict[(Element, i, j), np.ndarray]
180
+ The local wavelength grid for each transition stored in a dictionary
181
+ by transition ID.
182
+ blueIdx : Dict[(Element, i, j), int]
183
+ The index at which each local grid starts in the global wavelength
184
+ array.
185
+ redIdx : Dict[(Element, i, j), int]
186
+ The index at which each local grid has ended in the global wavelength
187
+ array (exclusive,
188
+ i.e. transWavelength = globalWavelength[blueIdx:redIdx]).
189
+ activeTrans : Dict[(Element, i, j), bool]
190
+ Whether this transition is ever active (contributing in either an
191
+ active or detailed static sense) over the range of wavelength.
192
+ activeWavelengths : Dict[(Element, i, j), np.ndarray]
193
+ A mask of the wavelengths at which this transition is active.
194
+
195
+ Properties
196
+ ----------
197
+ NprdTrans : int
198
+ The number of PRD transitions present on the active transitions.
199
+ '''
200
+ radSet: 'RadiativeSet'
201
+ wavelength: np.ndarray
202
+ models: List[AtomicModel]
203
+ transWavelengths: Dict[Tuple[Element, int, int], np.ndarray]
204
+ blueIdx: Dict[Tuple[Element, int, int], int]
205
+ redIdx: Dict[Tuple[Element, int, int], int]
206
+ activeTrans: Dict[Tuple[Element, int, int], bool]
207
+ activeWavelengths: Dict[Tuple[Element, int, int], np.ndarray]
208
+
209
+ def subset_configuration(self, wavelengths) -> 'SpectrumConfiguration':
210
+ '''
211
+ Computes a SpectrumConfiguration for a sub-region of the global wavelength array.
212
+
213
+ This is typically used for computing a final formal solution on a single
214
+ ray through the atmosphere. In this situation all lines are set to
215
+ contribute throughout the entire grid, to avoid situations where small
216
+ jumps in intensity occur from lines being cut off.
217
+
218
+ Parameters
219
+ ----------
220
+ wavelengths : np.ndarray
221
+ The grid on which to produce the new SpectrumConfiguration.
222
+
223
+ Returns
224
+ -------
225
+ spectrumConfig : SpectrumConfiguration
226
+ The subset spectrum configuration.
227
+ '''
228
+ Nblue = np.searchsorted(self.wavelength, wavelengths[0])
229
+ Nred = min(np.searchsorted(self.wavelength, wavelengths[-1])+1,
230
+ self.wavelength.shape[0])
231
+ Nwavelengths = wavelengths.shape[0]
232
+
233
+ activeTrans = {k: bool(np.any(v[Nblue:Nred]))
234
+ for k, v in self.activeWavelengths.items()}
235
+ transGrids = {k: np.copy(wavelengths) for k, active in activeTrans.items()
236
+ if active}
237
+ activeWavelengths = {k: np.ones_like(wavelengths, dtype=bool)
238
+ for k in transGrids}
239
+ blueIdx = {k: 0 for k in transGrids}
240
+ redIdx = {k: Nwavelengths for k in transGrids}
241
+
242
+ def test_atom_active(atom: AtomicModel) -> bool:
243
+ for t in atom.transitions:
244
+ if activeTrans[t.transId]:
245
+ return True
246
+ return False
247
+
248
+ models = []
249
+ for atom in self.models:
250
+ if test_atom_active(atom):
251
+ models.append(atom)
252
+
253
+ return SpectrumConfiguration(radSet=self.radSet, wavelength=wavelengths,
254
+ models=models, transWavelengths=transGrids,
255
+ blueIdx=blueIdx, redIdx=redIdx,
256
+ activeTrans=activeTrans,
257
+ activeWavelengths=activeWavelengths)
258
+
259
+ @property
260
+ def NprdTrans(self):
261
+ try:
262
+ return self._NprdTrans
263
+ except AttributeError:
264
+ count = 0
265
+ for element in self.radSet.activeSet:
266
+ atom = self.radSet.atoms[element]
267
+ for l in atom.lines:
268
+ if l.type == LineType.PRD:
269
+ count += 1
270
+ self._NprdTrans = count
271
+ return count
272
+
273
+
274
+ @dataclass
275
+ class AtomicState:
276
+ '''
277
+ Container for the state of an atomic model during a simulation.
278
+
279
+ This hold both the model, as well as the simulations properties such as
280
+ abundance, populations and radiative rates.
281
+
282
+ Attributes
283
+ ----------
284
+ model : AtomicModel
285
+ The python model of the atom.
286
+ abundance : float
287
+ The abundance of the species as a fraction of H abundance.
288
+ nStar : np.ndarray
289
+ The LTE populations of the species.
290
+ nTotal : np.ndarray
291
+ The total species population at each point in the atmosphere.
292
+ detailed : bool
293
+ Whether the species has detailed populations.
294
+ pops : np.ndarray, optional
295
+ The NLTE populations for the species, if detailed is True.
296
+ radiativeRates: Dict[(int, int), np.ndarray], optional
297
+ If detailed the radiative rates for the species will be present here,
298
+ stored under (i, j) and (j, i) for each transition.
299
+ '''
300
+ model: AtomicModel
301
+ abundance: float
302
+ nStar: np.ndarray
303
+ nTotal: np.ndarray
304
+ detailed: bool = False
305
+ pops: Optional[np.ndarray] = None
306
+ radiativeRates: Optional[Dict[Tuple[int, int], np.ndarray]] = None
307
+
308
+ def __post_init__(self):
309
+ if self.detailed:
310
+ self.radiativeRates = {}
311
+ ratesShape = self.nStar.shape[1:]
312
+ for t in self.model.transitions:
313
+ self.radiativeRates[(t.i, t.j)] = np.zeros(ratesShape)
314
+ self.radiativeRates[(t.j, t.i)] = np.zeros(ratesShape)
315
+
316
+ def __str__(self):
317
+ s = 'AtomicState(%s)' % self.element
318
+ return s
319
+
320
+ def __hash__(self):
321
+ # return hash(repr(self))
322
+ raise NotImplementedError
323
+
324
+ def dimensioned_view(self, shape):
325
+ '''
326
+ Returns a view over the contents of AtomicState reshaped so all data
327
+ has the correct (1/2/3D) dimensionality for the atmospheric model, as
328
+ these are all stored under a flat scheme.
329
+
330
+ Parameters
331
+ ----------
332
+ shape : tuple
333
+ The shape to reshape to, this can be obtained from
334
+ Atmosphere.structure.dimensioned_shape
335
+
336
+ Returns
337
+ -------
338
+ state : AtomicState
339
+ An instance of self with the arrays reshaped to the appropriate
340
+ dimensionality.
341
+ '''
342
+ state = copy(self)
343
+ state.nStar = self.nStar.reshape(-1, *shape)
344
+ state.nTotal = self.nTotal.reshape(shape)
345
+ if self.pops is not None:
346
+ state.pops = self.pops.reshape(-1, *shape)
347
+ state.radiativeRates = {k: v.reshape(shape) for k, v in
348
+ self.radiativeRates.items()}
349
+ return state
350
+
351
+ def unit_view(self):
352
+ '''
353
+ Returns a view over the contents of the AtomicState with the correct
354
+ `astropy.units`.
355
+ '''
356
+ state = copy(self)
357
+ m3 = u.m**(-3)
358
+ state.nStar = self.nStar << m3
359
+ state.nTotal = self.nTotal << m3
360
+ if self.pops is not None:
361
+ state.pops = self.pops << m3
362
+ state.radiativeRates = {k: v << u.s**-1 for k, v in self.radiativeRates.items()}
363
+ return state
364
+
365
+ def dimensioned_unit_view(self, shape):
366
+ '''
367
+ Returns a view over the contents of AtomicState reshaped so all data
368
+ has the correct (1/2/3D) dimensionality for the atmospheric model,
369
+ and the correct `astropy.units`.
370
+
371
+ Parameters
372
+ ----------
373
+ shape : tuple
374
+ The shape to reshape to, this can be obtained from
375
+ Atmosphere.structure.dimensioned_shape
376
+
377
+ Returns
378
+ -------
379
+ state : AtomicState
380
+ An instance of self with the arrays reshaped to the appropriate
381
+ dimensionality.
382
+ '''
383
+ state = self.dimensioned_view(shape)
384
+ return state.unit_view()
385
+
386
+ def update_nTotal(self, atmos: Atmosphere):
387
+ '''
388
+ Update nTotal assuming either the abundance or nHTot have changed.
389
+ '''
390
+ self.nTotal[:] = self.abundance * atmos.nHTot # type: ignore
391
+
392
+ @property
393
+ def element(self) -> Element:
394
+ '''
395
+ The element associated with this model.
396
+ '''
397
+ return self.model.element
398
+
399
+ @property
400
+ def mass(self) -> float:
401
+ '''
402
+ The mass of the element associated with this model.
403
+ '''
404
+ return self.element.mass
405
+
406
+ @property
407
+ def n(self) -> np.ndarray:
408
+ '''
409
+ The NLTE populations, if present, or the LTE populations.
410
+ '''
411
+ if self.pops is None:
412
+ return self.nStar
413
+ return self.pops
414
+
415
+ @n.setter
416
+ def n(self, val: np.ndarray):
417
+ if val.shape != self.nStar.shape:
418
+ raise ValueError(('Incorrect dimensions for population array, '
419
+ 'expected %s') % self.nStar.shape)
420
+
421
+ self.pops = val
422
+
423
+ @property
424
+ def name(self) -> str:
425
+ '''
426
+ The name of the element associated with this model.
427
+ '''
428
+ return self.model.element.name
429
+
430
+ def fjk(self, atmos, k):
431
+ # Nstage: int = (self.model.levels[-1].stage - self.model.levels[0].stage) + 1
432
+ Nstage: int = self.model.levels[-1].stage + 1
433
+
434
+ fjk = np.zeros(Nstage)
435
+ # TODO(cmo): Proper derivative treatment
436
+ dfjk = np.zeros(Nstage)
437
+
438
+ for i, l in enumerate(self.model.levels):
439
+ fjk[l.stage] += self.n[i, k]
440
+
441
+ fjk /= self.nTotal[k]
442
+
443
+ return fjk, dfjk
444
+
445
+ def fj(self, atmos):
446
+ Nstage: int = self.model.levels[-1].stage + 1
447
+ Nspace: int = atmos.Nspace
448
+
449
+ fj = np.zeros((Nstage, Nspace))
450
+ # TODO(cmo): Proper derivative treatment
451
+ dfj = np.zeros((Nstage, Nspace))
452
+
453
+ for i, l in enumerate(self.model.levels):
454
+ fj[l.stage] += self.n[i]
455
+
456
+ fj /= self.nTotal
457
+
458
+ return fj, dfj
459
+
460
+ def set_n_to_lte(self):
461
+ '''
462
+ Reset the NLTE populations to LTE.
463
+ '''
464
+ if self.pops is not None:
465
+ self.pops[:] = self.nStar
466
+
467
+
468
+ class AtomicStateTable:
469
+ '''
470
+ Container for AtomicStates.
471
+
472
+ The __getitem__ on this class is intended to be smart, and should work
473
+ correctly with ints, strings, or Elements and return the associated
474
+ AtomicState.
475
+ This object is not normally constructed directly by the user, but will
476
+ instead be interacted with as a means of transporting information to and
477
+ from the backend.
478
+
479
+ '''
480
+ def __init__(self, atoms: List[AtomicState]):
481
+ self.atoms = {a.element: a for a in atoms}
482
+
483
+ def __contains__(self, name: Union[int, Tuple[int, int], str, Element]) -> bool:
484
+ try:
485
+ x = PeriodicTable[name]
486
+ return x in self.atoms
487
+ except KeyError:
488
+ return False
489
+
490
+ def __len__(self) -> int:
491
+ return len(self.atoms)
492
+
493
+ def __getitem__(self, name: Union[int, Tuple[int, int], str, Element]) -> AtomicState:
494
+ x = PeriodicTable[name]
495
+ return self.atoms[x]
496
+
497
+ def __iter__(self):
498
+ return iter(sorted(self.atoms.values(), key=element_sort))
499
+
500
+ def dimensioned_view(self, shape):
501
+ '''
502
+ Returns a view over the contents of AtomicStateTable reshaped so all data
503
+ has the correct (1/2/3D) dimensionality for the atmospheric model, as
504
+ these are all stored under a flat scheme.
505
+
506
+ Parameters
507
+ ----------
508
+ shape : tuple
509
+ The shape to reshape to, this can be obtained from
510
+ Atmosphere.structure.dimensioned_shape
511
+
512
+ Returns
513
+ -------
514
+ state : AtomicStateTable
515
+ An instance of self with the arrays reshaped to the appropriate
516
+ dimensionality.
517
+ '''
518
+ table = copy(self)
519
+ table.atoms = {k: a.dimensioned_view(shape) for k, a in self.atoms.items()}
520
+ return table
521
+
522
+ def unit_view(self):
523
+ '''
524
+ Returns a view over the contents of the AtomicStateTable with the correct
525
+ `astropy.units`.
526
+ '''
527
+ table = copy(self)
528
+ table.atoms = {k: a.unit_view() for k, a in self.atoms.items()}
529
+ return table
530
+
531
+ def dimensioned_unit_view(self, shape):
532
+ '''
533
+ Returns a view over the contents of AtomicStateTable reshaped so all data
534
+ has the correct (1/2/3D) dimensionality for the atmospheric model,
535
+ and the correct `astropy.units`.
536
+
537
+ Parameters
538
+ ----------
539
+ shape : tuple
540
+ The shape to reshape to, this can be obtained from
541
+ Atmosphere.structure.dimensioned_shape
542
+
543
+ Returns
544
+ -------
545
+ state : AtomicStateTable
546
+ An instance of self with the arrays reshaped to the appropriate
547
+ dimensionality.
548
+ '''
549
+ table = self.dimensioned_view(shape)
550
+ return table.unit_view()
551
+
552
+
553
+ @dataclass
554
+ class SpeciesStateTable:
555
+ '''
556
+ Container for the species populations in the simulation. Similar to
557
+ AtomicStateTable but also holding the molecular populations and the
558
+ atmosphere object.
559
+
560
+ The __getitem__ is intended to be smart, returning in order of priority
561
+ on the name match (int, str, Element), H- populations, molecular
562
+ populations, NLTE atomic populations, LTE atomic populations.
563
+ This object is not normally constructed directly by the user, but will
564
+ instead be interacted with as a means of transporting information to and
565
+ from the backend.
566
+
567
+ Attributes
568
+ ----------
569
+ atmosphere : Atmosphere
570
+ The atmosphere object.
571
+ abundance : AtomicAbundance
572
+ The abundance of all species present in the atmosphere.
573
+ atomicPops : AtomicStateTable
574
+ The atomic populations state container.
575
+ molecularTable : MolecularTable
576
+ The molecules present in the simulation.
577
+ molecularPops : list of np.ndarray
578
+ The populations of each molecule in the molecularTable
579
+ HminPops : np.ndarray
580
+ H- ion populations throughout the atmosphere.
581
+ '''
582
+ atmosphere: Atmosphere
583
+ abundance: AtomicAbundance
584
+ atomicPops: AtomicStateTable
585
+ molecularTable: MolecularTable
586
+ molecularPops: List[np.ndarray]
587
+ HminPops: np.ndarray
588
+
589
+ def dimensioned_view(self):
590
+ '''
591
+ Returns a view over the contents of SpeciesStateTable reshaped so all data
592
+ has the correct (1/2/3D) dimensionality for the atmospheric model, as
593
+ these are all stored under a flat scheme.
594
+ '''
595
+ shape = self.atmosphere.structure.dimensioned_shape
596
+ table = copy(self)
597
+ table.atmosphere = self.atmosphere.dimensioned_view()
598
+ table.atomicPops = self.atomicPops.dimensioned_view(shape)
599
+ table.molecularPops = [m.reshape(shape) for m in self.molecularPops]
600
+ table.HminPops = self.HminPops.reshape(shape)
601
+ return table
602
+
603
+ def unit_view(self):
604
+ '''
605
+ Returns a view over the contents of the SpeciesStateTable with the correct
606
+ `astropy.units`.
607
+ '''
608
+ table = copy(self)
609
+ table.atmosphere = self.atmosphere.unit_view()
610
+ table.atomicPops = self.atomicPops.unit_view()
611
+ table.molecularPops = [(m << u.m**(-3)) for m in self.molecularPops]
612
+ table.HminPops = self.HminPops << u.m**(-3)
613
+ return table
614
+
615
+ def dimensioned_unit_view(self):
616
+ '''
617
+ Returns a view over the contents of SpeciesStateTable reshaped so all data
618
+ has the correct (1/2/3D) dimensionality for the atmospheric model,
619
+ and the correct `astropy.units`.
620
+ '''
621
+ table = self.dimensioned_view()
622
+ return table.unit_view()
623
+
624
+ def __getitem__(self, name: Union[int, Tuple[int, int], str, Element]) -> np.ndarray:
625
+ if isinstance(name, str) and name == 'H-':
626
+ return self.HminPops
627
+
628
+ if name in self.molecularTable:
629
+ name = cast(str, name)
630
+ key = self.molecularTable.indices[name]
631
+ return self.molecularPops[key]
632
+
633
+ if name in self.atomicPops:
634
+ return self.atomicPops[name].n
635
+
636
+ raise LookupError(f'Element defined by "{name}" not found.')
637
+
638
+ def __contains__(self, name: Union[int, Tuple[int, int], str, Element]) -> bool:
639
+ if name == 'H-':
640
+ return True
641
+
642
+ if name in self.molecularTable:
643
+ return True
644
+
645
+ if name in self.atomicPops:
646
+ return True
647
+
648
+ return False
649
+
650
+ def update_lte_atoms_Hmin_pops(self, atmos: Atmosphere, conserveCharge=False,
651
+ updateTotals=False, maxIter=2000, quiet=False, tol=1e-3):
652
+ '''
653
+ Under the assumption that the atmosphere has changed, update the LTE
654
+ atomic populations and the H- populations.
655
+
656
+ Parameters
657
+ ----------
658
+ atmos : Atmosphere
659
+ The atmosphere object.
660
+ conserveCharge : bool
661
+ Whether to conserveCharge and adjust the electron density in
662
+ atmos based on the change in ionisation of the non-detailed
663
+ species (default: False).
664
+ updateTotals : bool, optional
665
+ Whether to update the totals of each species from the abundance
666
+ and total hydrogen density (default: False).
667
+ maxIter : int, optional
668
+ The maximum number of iterations to take looking for a stable
669
+ solution (default: 2000).
670
+ quiet : bool, optional
671
+ Whether to print information about the update (default: False)
672
+ tol : float, optional
673
+ The tolerance of relative change at which to consider the
674
+ populations converged (default: 1e-3)
675
+ '''
676
+ if updateTotals:
677
+ for atom in self.atomicPops:
678
+ atom.update_nTotal(atmos)
679
+ for i in range(maxIter):
680
+ maxDiff = 0.0
681
+ maxName = '--'
682
+ ne = np.zeros_like(atmos.ne)
683
+ diffs = [update_lte_pops_inplace(atom.model, atmos.temperature,
684
+ atmos.ne, atom.nTotal, atom.nStar,
685
+ debye=True)[1] for atom in self.atomicPops]
686
+
687
+ for j, atom in enumerate(self.atomicPops):
688
+ if conserveCharge:
689
+ stages = np.array([l.stage for l in atom.model.levels])
690
+ if atom.pops is None:
691
+ ne += np.sum(atom.nStar * stages[:, None], axis=0)
692
+ else:
693
+ ne += np.sum(atom.n * stages[:, None], axis=0)
694
+
695
+ diff = diffs[j]
696
+ if diff > maxDiff:
697
+ maxDiff = diff
698
+ maxName = atom.name
699
+ if conserveCharge:
700
+ ne[ne < 1e6] = 1e6
701
+ atmos.ne[:] = ne
702
+ if maxDiff < tol:
703
+ if not quiet:
704
+ print('LTE Iterations %d (%s slowest convergence)' % (i+1, maxName))
705
+ break
706
+
707
+ else:
708
+ raise ValueError('No convergence in LTE update')
709
+
710
+ self.HminPops[:] = hminus_pops(atmos, self.atomicPops['H'])
711
+
712
+
713
+ class RadiativeSet:
714
+ '''
715
+ Used to configure the atomic models present in the simulation and then
716
+ set up the global wavelength grid and initial populations.
717
+ All atoms start passive.
718
+
719
+ Parameters
720
+ ----------
721
+ atoms : list of AtomicModel
722
+ The atomic models to be used in the simulation (active, detailed, and
723
+ background).
724
+ abundance : AtomicAbundance, optional
725
+ The abundance to be used for each species.
726
+
727
+ Attributes
728
+ ----------
729
+ abundance : AtomicAbundance
730
+ The abundances in use.
731
+ elements : list of Elements
732
+ The elements present in the simulation.
733
+ atoms : Dict[Element, AtomicModel]
734
+ Mapping from Element to associated model.
735
+ passiveSet : set of Elements
736
+ Set of atoms (designmated by their Elements) set to passive in the
737
+ simulation.
738
+ detailedStaticSet : set of Elements
739
+ Set of atoms (designmated by their Elements) set to "detailed static" in the
740
+ simulation.
741
+ activeSet : set of Elements
742
+ Set of atoms (designmated by their Elements) set to active in the
743
+ simulation.
744
+ '''
745
+ def __init__(self, atoms: List[AtomicModel],
746
+ abundance: AtomicAbundance=DefaultAtomicAbundance):
747
+ self.abundance = abundance
748
+ self.elements = [a.element for a in atoms]
749
+ self.atoms = {k: v for k, v in zip(self.elements, atoms)}
750
+ self.passiveSet = set(self.elements)
751
+ self.detailedStaticSet: Set[Element] = set()
752
+ self.activeSet: Set[Element] = set()
753
+
754
+ if len(self.passiveSet) > len(self.elements):
755
+ raise ValueError('Multiple entries for an atom: %s' % self.atoms)
756
+
757
+ def __contains__(self, x: Union[int, Tuple[int, int], str, Element]) -> bool:
758
+ return PeriodicTable[x] in self.elements
759
+
760
+ def is_active(self, name: Union[int, Tuple[int, int], str, Element]) -> bool:
761
+ '''
762
+ Check if an atom (designated by int, (int, int), str, or Element) is
763
+ active.
764
+ '''
765
+ x = PeriodicTable[name]
766
+ return x in self.activeSet
767
+
768
+ def is_passive(self, name: Union[int, Tuple[int, int], str, Element]) -> bool:
769
+ '''
770
+ Check if an atom (designated by int, (int, int), str, or Element) is
771
+ passive.
772
+ '''
773
+ x = PeriodicTable[name]
774
+ return x in self.passiveSet
775
+
776
+ def is_detailed(self, name: Union[int, Tuple[int, int], str, Element]) -> bool:
777
+ '''
778
+ Check if an atom (designated by int, (int, int), str, or Element) is
779
+ passive.
780
+ '''
781
+ x = PeriodicTable[name]
782
+ return x in self.detailedStaticSet
783
+
784
+ @property
785
+ def activeAtoms(self) -> List[AtomicModel]:
786
+ '''
787
+ List of AtomicModels set to active.
788
+ '''
789
+ activeAtoms : List[AtomicModel] = [self.atoms[e] for e in self.activeSet]
790
+ activeAtoms = sorted(activeAtoms, key=element_sort)
791
+ return activeAtoms
792
+
793
+ @property
794
+ def detailedAtoms(self) -> List[AtomicModel]:
795
+ '''
796
+ List of AtomicModels set to detailed static.
797
+ '''
798
+ detailedAtoms : List[AtomicModel] = [self.atoms[e] for e in self.detailedStaticSet]
799
+ detailedAtoms = sorted(detailedAtoms, key=element_sort)
800
+ return detailedAtoms
801
+
802
+ @property
803
+ def passiveAtoms(self) -> List[AtomicModel]:
804
+ '''
805
+ List of AtomicModels set to passive.
806
+ '''
807
+ passiveAtoms : List[AtomicModel] = [self.atoms[e] for e in self.passiveSet]
808
+ passiveAtoms = sorted(passiveAtoms, key=element_sort)
809
+ return passiveAtoms
810
+
811
+ def __getitem__(self, name: Union[int, Tuple[int, int], str, Element]) -> AtomicModel:
812
+ x = PeriodicTable[name]
813
+ return self.atoms[x]
814
+
815
+ def __iter__(self):
816
+ return iter(self.atoms.values())
817
+
818
+ def set_active(self, *args: str):
819
+ '''
820
+ Set one (or multiple) atoms active.
821
+ '''
822
+ names = set(args)
823
+ xs = [PeriodicTable[name] for name in names]
824
+ for x in xs:
825
+ self.activeSet.add(x)
826
+ self.detailedStaticSet.discard(x)
827
+ self.passiveSet.discard(x)
828
+
829
+ def set_detailed_static(self, *args: str):
830
+ '''
831
+ Set one (or multiple) atoms to detailed static
832
+ '''
833
+ names = set(args)
834
+ xs = [PeriodicTable[name] for name in names]
835
+ for x in xs:
836
+ self.detailedStaticSet.add(x)
837
+ self.activeSet.discard(x)
838
+ self.passiveSet.discard(x)
839
+
840
+ def set_passive(self, *args: str):
841
+ '''
842
+ Set one (or multiple) atoms passive.
843
+ '''
844
+ names = set(args)
845
+ xs = [PeriodicTable[name] for name in names]
846
+ for x in xs:
847
+ self.passiveSet.add(x)
848
+ self.activeSet.discard(x)
849
+ self.detailedStaticSet.discard(x)
850
+
851
+ def iterate_lte_ne_eq_pops(self, atmos: Atmosphere,
852
+ mols: Optional[MolecularTable]=None,
853
+ nlteStartingPops: Optional[Dict[Element, np.ndarray]]=None,
854
+ direct: bool=True, quiet: bool=True) -> SpeciesStateTable:
855
+ '''
856
+ Compute the starting populations for the simulation with all NLTE
857
+ atoms in LTE or otherwise using the provided populations.
858
+ Additionally computes a self-consistent LTE electron density.
859
+
860
+ Parameters
861
+ ----------
862
+ atmos : Atmosphere
863
+ The atmosphere for which to compute the populations.
864
+ mols : MolecularTable, optional
865
+ Molecules to be included in the populations (default: None)
866
+ nlteStartingPops : Dict[Element, np.ndarray], optional
867
+ Starting population override for any active or detailed static
868
+ species.
869
+ direct : bool
870
+ Whether to use the direct electron density solver (essentially,
871
+ damped Lambda iteration for the fixpoint). With the new damping this
872
+ appears to converge very quickly with little need for the
873
+ Newton-Krylov selected if this is set to False. Default: True
874
+ quiet : bool, optional
875
+ Whether to print convergence info (default: True, i.e. don't print).
876
+
877
+ Returns
878
+ -------
879
+ eqPops : SpeciesStatTable
880
+ The configured initial populations.
881
+ '''
882
+ if mols is None:
883
+ mols = MolecularTable([])
884
+
885
+ if nlteStartingPops is None:
886
+ nlteStartingPops = {}
887
+ else:
888
+ for e in nlteStartingPops:
889
+ if (e not in self.activeSet) \
890
+ and (e not in self.detailedStaticSet):
891
+ raise ValueError(('Provided NLTE Populations for %s assumed LTE. '
892
+ 'Ensure these are indexed by `Element` '
893
+ 'rather than str.') % e)
894
+
895
+ if direct:
896
+ maxIter = 3000
897
+ prevNe = np.copy(atmos.ne)
898
+ ne = np.copy(atmos.ne)
899
+ atoms = sorted(self.atoms.values(), key=element_sort)
900
+ for it in range(maxIter):
901
+ atomicPops = []
902
+ prevNe[:] = ne
903
+ ne.fill(0.0)
904
+ for a in atoms:
905
+ abund = self.abundance[a.element]
906
+ nTotal = abund * atmos.nHTot
907
+ nStar = lte_pops(a, atmos.temperature, atmos.ne, nTotal, debye=True)
908
+ atomicPops.append(AtomicState(model=a, abundance=abund,
909
+ nStar=nStar, nTotal=nTotal))
910
+
911
+ # NOTE(cmo): Take into account NLTE pops if provided
912
+ if a.element in nlteStartingPops:
913
+ if nlteStartingPops[a.element].shape != nStar.shape:
914
+ raise ValueError(('Starting populations provided for %s '
915
+ 'do not match model.') % a.element)
916
+ nStar = nlteStartingPops[a.element]
917
+
918
+ stages = np.array([l.stage for l in a.levels])
919
+ ne += np.sum(nStar * stages[:, None], axis=0)
920
+ # NOTE(cmo): Damp correction: dramatically improves convergence.
921
+ atmos.ne[:] = 0.55 * ne + 0.45 * prevNe
922
+
923
+ max_err = np.nanmax(np.abs(1.0 - prevNe / atmos.ne))
924
+ if max_err < 1e-5:
925
+ if not quiet:
926
+ print("Iterate LTE: %d iterations" % it)
927
+ break
928
+ else:
929
+ raise ValueError("LTE ne failed to converge")
930
+ else:
931
+ neRatio = np.copy(atmos.ne) / atmos.nHTot
932
+ iterator = LteNeIterator(self.atoms.values(), atmos.temperature,
933
+ atmos.nHTot, self.abundance, nlteStartingPops)
934
+ neRatio += iterator(neRatio)
935
+ newNeRatio = newton_krylov(iterator, neRatio)
936
+ atmos.ne[:] = newNeRatio * atmos.nHTot
937
+
938
+ atomicPops = iterator.atomicPops
939
+
940
+ detailedAtomicPops = []
941
+ for pop in atomicPops:
942
+ ele = pop.model.element
943
+ if ele in self.passiveSet:
944
+ if ele in nlteStartingPops:
945
+ # NOTE(cmo): I don't believe this is possible; it would need
946
+ # to be detailed_static as per the contract on passive atoms
947
+ # being "true" LTE. Leaving for now for safety.
948
+ pop.n = np.copy(nlteStartingPops[ele])
949
+ detailedAtomicPops.append(pop)
950
+ else:
951
+ nltePops = np.copy(nlteStartingPops[ele]) if ele in nlteStartingPops \
952
+ else np.copy(pop.nStar)
953
+ detailedAtomicPops.append(AtomicState(model=pop.model,
954
+ abundance=self.abundance[ele],
955
+ nStar=pop.nStar, nTotal=pop.nTotal,
956
+ detailed=True, pops=nltePops))
957
+
958
+ table = AtomicStateTable(detailedAtomicPops)
959
+ eqPops = chemical_equilibrium_fixed_ne(atmos, mols, table, self.abundance, quiet=quiet)
960
+ # NOTE(cmo): This is technically not quite correct, because we adjust
961
+ # nTotal and the atomic populations to account for the atoms bound up
962
+ # in molecules, but not n_e, this is unlikely to make much difference
963
+ # in reality, other than in very cool atmospheres with a lot of
964
+ # molecules (even then it should be pretty tiny)
965
+ return eqPops
966
+
967
+ def compute_eq_pops(self, atmos: Atmosphere,
968
+ mols: Optional[MolecularTable]=None,
969
+ nlteStartingPops: Optional[Dict[Element, np.ndarray]]=None):
970
+ '''
971
+ Compute the starting populations for the simulation with all NLTE
972
+ atoms in LTE or otherwise using the provided populations.
973
+
974
+ Parameters
975
+ ----------
976
+ atmos : Atmosphere
977
+ The atmosphere for which to compute the populations.
978
+ mols : MolecularTable, optional
979
+ Molecules to be included in the populations (default: None)
980
+ nlteStartingPops : Dict[Element, np.ndarray], optional
981
+ Starting population override for any active or detailed static
982
+ species.
983
+
984
+ Returns
985
+ -------
986
+ eqPops : SpeciesStatTable
987
+ The configured initial populations.
988
+ '''
989
+ if mols is None:
990
+ mols = MolecularTable([])
991
+
992
+ if nlteStartingPops is None:
993
+ nlteStartingPops = {}
994
+ else:
995
+ for e in nlteStartingPops:
996
+ if (e not in self.activeSet) \
997
+ and (e not in self.detailedStaticSet):
998
+ raise ValueError(('Provided NLTE Populations for %s assumed LTE. '
999
+ 'Ensure these are indexed by `Element` '
1000
+ 'rather than str.') % e)
1001
+
1002
+ atomicPops = []
1003
+ atoms = sorted(self.atoms.values(), key=element_sort)
1004
+ for a in atoms:
1005
+ nTotal = self.abundance[a.element] * atmos.nHTot
1006
+ nStar = lte_pops(a, atmos.temperature, atmos.ne, nTotal, debye=True)
1007
+
1008
+ ele = a.element
1009
+ if ele in self.passiveSet:
1010
+ n = None
1011
+ atomicPops.append(AtomicState(model=a, abundance=self.abundance[ele], nStar=nStar,
1012
+ nTotal=nTotal, pops=n))
1013
+ else:
1014
+ nltePops = np.copy(nlteStartingPops[ele]) if ele in nlteStartingPops \
1015
+ else np.copy(nStar)
1016
+ atomicPops.append(AtomicState(model=a, abundance=self.abundance[ele],
1017
+ nStar=nStar, nTotal=nTotal, detailed=True,
1018
+ pops=nltePops))
1019
+
1020
+ table = AtomicStateTable(atomicPops)
1021
+ eqPops = chemical_equilibrium_fixed_ne(atmos, mols, table, self.abundance)
1022
+ # NOTE(cmo): This is technically not quite correct, because we adjust
1023
+ # nTotal and the atomic populations to account for the atoms bound up
1024
+ # in molecules, but not n_e, this is unlikely to make much difference
1025
+ # in reality, other than in very cool atmospheres with a lot of
1026
+ # molecules (even then it should be pretty tiny)
1027
+ return eqPops
1028
+
1029
+ def compute_wavelength_grid(self, extraWavelengths: Optional[np.ndarray]=None,
1030
+ lambdaReference=500.0) -> SpectrumConfiguration:
1031
+ '''
1032
+ Compute the global wavelength grid from the current configuration of
1033
+ the RadiativeSet.
1034
+
1035
+ Parameters
1036
+ ----------
1037
+ extraWavelengths : np.ndarray, optional
1038
+ Extra wavelengths to add to the global array [nm].
1039
+ lambdaReference : float, optional
1040
+ If a difference reference wavelength is to be used then it should
1041
+ be specified here to ensure it is in the global array.
1042
+
1043
+ Returns
1044
+ -------
1045
+ spect : SpectrumConfiguration
1046
+ The configured wavelength grids needed to set up the backend.
1047
+ '''
1048
+ if len(self.activeSet) == 0 and len(self.detailedStaticSet) == 0:
1049
+ raise ValueError('Need at least one atom active or in detailed'
1050
+ ' calculation with static populations.')
1051
+ extraGrids = []
1052
+ if extraWavelengths is not None:
1053
+ extraGrids.append(extraWavelengths)
1054
+ extraGrids.append(np.array([lambdaReference]))
1055
+
1056
+ models: List[AtomicModel] = []
1057
+ ids: List[Tuple[Element, int, int]] = []
1058
+ grids = []
1059
+
1060
+ for ele in (self.activeSet | self.detailedStaticSet):
1061
+ atom = self.atoms[ele]
1062
+ models.append(atom)
1063
+ for trans in atom.transitions:
1064
+ grids.append(trans.wavelength())
1065
+ ids.append(trans.transId)
1066
+
1067
+ grid = np.concatenate(grids + extraGrids)
1068
+ grid = np.sort(grid)
1069
+ grid = np.unique(grid)
1070
+ # grid = np.unique(np.floor(1e10*grid)) / 1e10
1071
+ blueIdx = {}
1072
+ redIdx = {}
1073
+
1074
+ for i, g in enumerate(grids):
1075
+ ident = ids[i]
1076
+ blueIdx[ident] = np.searchsorted(grid, g[0])
1077
+ redIdx[ident] = np.searchsorted(grid, g[-1])+1
1078
+
1079
+ transGrids: Dict[Tuple[Element, int, int], np.ndarray] = {}
1080
+ for ident in ids:
1081
+ transGrids[ident] = np.copy(grid[blueIdx[ident]:redIdx[ident]])
1082
+
1083
+ activeWavelengths = {k: ((grid >= v[0]) & (grid <= v[-1])) for k, v in transGrids.items()}
1084
+ activeTrans = {k: True for k in transGrids}
1085
+
1086
+ return SpectrumConfiguration(radSet=self, wavelength=grid, models=models,
1087
+ transWavelengths=transGrids,
1088
+ blueIdx=blueIdx, redIdx=redIdx,
1089
+ activeTrans=activeTrans,
1090
+ activeWavelengths=activeWavelengths)
1091
+
1092
+
1093
+ def hminus_pops(atmos: Atmosphere, hPops: AtomicState) -> np.ndarray:
1094
+ '''
1095
+ Compute the H- ion populations for a given atmosphere
1096
+
1097
+ Parameters
1098
+ ----------
1099
+ atmos : Atmosphere
1100
+ The atmosphere object.
1101
+
1102
+ hPops : AtomicState
1103
+ The hydrogen populations state associated with atmos.
1104
+
1105
+ Returns
1106
+ -------
1107
+ HminPops : np.ndarray
1108
+ The H- populations.
1109
+ '''
1110
+ CI = (Const.HPlanck / (2.0 * np.pi * Const.MElectron)) * (Const.HPlanck / Const.KBoltzmann)
1111
+ Nspace = atmos.Nspace
1112
+
1113
+ PhiHmin = 0.25 * (CI / atmos.temperature)**1.5 \
1114
+ * np.exp(Const.E_ION_HMIN / (Const.KBoltzmann * atmos.temperature))
1115
+ HminPops = atmos.ne * np.sum(hPops.n, axis=0) * PhiHmin
1116
+
1117
+ return HminPops
1118
+
1119
+ def chemical_equilibrium_fixed_ne(atmos: Atmosphere, molecules: MolecularTable,
1120
+ atomicPops: AtomicStateTable,
1121
+ abundance: AtomicAbundance,
1122
+ quiet: bool = False) -> SpeciesStateTable:
1123
+ '''
1124
+ Compute the molecular populations from the current atmospheric model and
1125
+ atomic populations.
1126
+
1127
+ This method assumes that the number of electrons bound in molecules is
1128
+ insignificant, and neglects this.
1129
+ Intended for internal use.
1130
+
1131
+ Parameters
1132
+ ----------
1133
+ atmos : Atmosphere
1134
+ The model atmosphere of the simulation.
1135
+ molecules : MolecularTable
1136
+ The molecules to consider.
1137
+ atomicPops : AtomicStateTable
1138
+ The atomic populations.
1139
+ abundance : AtomicAbundance
1140
+ The abundance of each species in the simulation.
1141
+ quiet : bool, optional
1142
+ Whether to print convergence info (default: True).
1143
+
1144
+
1145
+ Returns
1146
+ -------
1147
+ state : SpeciesState
1148
+ The combined state object of atomic and molecular populations.
1149
+ '''
1150
+ nucleiSet: Set[Element] = set()
1151
+ for mol in molecules:
1152
+ nucleiSet |= set(mol.elements)
1153
+ nuclei: List[Element] = list(nucleiSet)
1154
+ nuclei = sorted(nuclei)
1155
+
1156
+ if len(nuclei) == 0:
1157
+ HminPops = hminus_pops(atmos, atomicPops['H'])
1158
+ result = SpeciesStateTable(atmos, abundance, atomicPops, molecules, [], HminPops)
1159
+ return result
1160
+
1161
+ if nuclei[0] != PeriodicTable[1]:
1162
+ raise ValueError('H not list of nuclei -- check H2 molecule')
1163
+ # print([n.name for n in nuclei])
1164
+
1165
+ nuclIndex = [[nuclei.index(ele) for ele in mol.elements] for mol in molecules]
1166
+
1167
+ # Replace basic elements with full Models if present
1168
+ kuruczTable = KuruczPfTable(atomicAbundance=abundance)
1169
+ nucData: Dict[Element, Union[KuruczPf, AtomicState]] = {}
1170
+ for nuc in nuclei:
1171
+ if nuc in atomicPops:
1172
+ nucData[nuc] = atomicPops[nuc]
1173
+ else:
1174
+ nucData[nuc] = kuruczTable[nuc]
1175
+
1176
+ Nnuclei = len(nuclei)
1177
+
1178
+ Neqn = Nnuclei + len(molecules)
1179
+ f = np.zeros(Neqn)
1180
+ n = np.zeros(Neqn)
1181
+ df = np.zeros((Neqn, Neqn))
1182
+ a = np.zeros(Neqn)
1183
+
1184
+ # Equilibrium constant per molecule
1185
+ Phi = np.zeros(len(molecules))
1186
+ # Neutral fraction
1187
+ fn0 = np.zeros(Nnuclei)
1188
+
1189
+ CI = (Const.HPlanck / (2.0 * np.pi * Const.MElectron)) * (Const.HPlanck / Const.KBoltzmann)
1190
+ Nspace = atmos.Nspace
1191
+ HminPops = np.zeros(Nspace)
1192
+ molPops = [np.zeros(Nspace) for mol in molecules]
1193
+ maxIter = 0
1194
+ for k in range(Nspace):
1195
+ for i, nuc in enumerate(nuclei):
1196
+ nucleus = nucData[nuc]
1197
+ a[i] = nucleus.abundance * atmos.nHTot[k]
1198
+ fjk, dfjk = nucleus.fjk(atmos, k)
1199
+ fn0[i] = fjk[0]
1200
+
1201
+ PhiHmin = 0.25 * (CI / atmos.temperature[k])**1.5 \
1202
+ * np.exp(Const.E_ION_HMIN / (Const.KBoltzmann * atmos.temperature[k]))
1203
+ fHmin = atmos.ne[k] * fn0[0] * PhiHmin
1204
+
1205
+
1206
+ # Eq constant for each molecule at this location
1207
+ for i, mol in enumerate(molecules):
1208
+ Phi[i] = mol.equilibrium_constant(atmos.temperature[k])
1209
+
1210
+ # Setup initial solution. Everything dissociated
1211
+ # n[:Nnuclei] = a[:Nnuclei]
1212
+ # n[Nnuclei:] = 0.0
1213
+ n[:] = a[:]
1214
+ # print('a', a)
1215
+
1216
+ nIter = 1
1217
+ NmaxIter = 50
1218
+ IterLimit = 1e-3
1219
+ prevN = n.copy()
1220
+ while nIter < NmaxIter:
1221
+ # print(k, ',', nIter)
1222
+ # Save previous solution
1223
+ prevN[:] = n[:]
1224
+
1225
+ # Set up iteration
1226
+ f[:] = n - a
1227
+ df[:, :] = 0.0
1228
+ np.fill_diagonal(df, 1.0)
1229
+
1230
+ # Add nHmin to number conservation for H
1231
+ f[0] += fHmin * n[0]
1232
+ df[0, 0] += fHmin
1233
+
1234
+ # Fill population vector f and derivative matrix df
1235
+ for i, mol in enumerate(molecules):
1236
+ saha = Phi[i]
1237
+ for j, ele in enumerate(mol.elements):
1238
+ nu = nuclIndex[i][j]
1239
+ saha *= (fn0[nu] * n[nu])**mol.elementCount[j]
1240
+ # Contribution to conservation for each nucleus in this molecule
1241
+ f[nu] += mol.elementCount[j] * n[Nnuclei + i]
1242
+
1243
+ saha /= atmos.ne[k]**mol.charge
1244
+ f[Nnuclei + i] -= saha
1245
+ # if Nnuclei + i == f.shape[0]-1:
1246
+ # print(i)
1247
+ # print(saha)
1248
+
1249
+ # Compute derivative matrix
1250
+ for j, ele in enumerate(mol.elements):
1251
+ nu = nuclIndex[i][j]
1252
+ df[nu, Nnuclei + i] += mol.elementCount[j]
1253
+ df[Nnuclei + i, nu] = -saha * (mol.elementCount[j] / n[nu])
1254
+
1255
+ correction = solve(df, f)
1256
+ n -= correction
1257
+
1258
+ dnMax = np.nanmax(np.abs(1.0 - prevN / n))
1259
+ if dnMax <= IterLimit:
1260
+ maxIter = max(maxIter, nIter)
1261
+ break
1262
+
1263
+ nIter += 1
1264
+ if dnMax > IterLimit:
1265
+ raise ValueError(('ChemEq iteration not converged: T: %e [K],'
1266
+ ' density %e [m^-3], dnmax %e') % (atmos.temperature[k],
1267
+ atmos.nHTot[k], dnMax))
1268
+
1269
+ for i, ele in enumerate(nuclei):
1270
+ if ele in atomicPops:
1271
+ atomPop = atomicPops[ele]
1272
+ fraction = n[i] / atomPop.nTotal[k]
1273
+ atomPop.nStar[:, k] *= fraction
1274
+ atomPop.nTotal[k] *= fraction
1275
+ if atomPop.pops is not None:
1276
+ atomPop.pops[:, k] *= fraction
1277
+
1278
+ HminPops[k] = atmos.ne[k] * n[0] * PhiHmin
1279
+
1280
+ for i, pop in enumerate(molPops):
1281
+ pop[k] = n[Nnuclei + i]
1282
+
1283
+ result = SpeciesStateTable(atmos, abundance, atomicPops, molecules, molPops, HminPops)
1284
+ if not quiet:
1285
+ print("chem_eq: maximum number of iterations taken: %d" % maxIter)
1286
+ return result