Skip to content

Commit baca1bb

Browse files
authored
Rename ip property and fix setter logic for abundance, ionization potential, and ionization fraction. (#342)
* unskip IDL ioneq comparison tests * add get method to datalayer * rename ip to ionization_potential; refactor setter logic for abundance, ip, ioneq
1 parent 0232377 commit baca1bb

File tree

7 files changed

+177
-107
lines changed

7 files changed

+177
-107
lines changed

fiasco/base.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ def _ion_fraction(self):
147147
data_path = '/'.join([self.atomic_symbol.lower(), self._ion_name, 'ioneq'])
148148
return DataIndexer.create_indexer(self.hdf5_dbase_root, data_path)
149149

150+
@property
151+
def _ip(self):
152+
data_path = '/'.join([self.atomic_symbol.lower(), self._ion_name, 'ip'])
153+
return DataIndexer.create_indexer(self.hdf5_dbase_root, data_path)
154+
150155

151156
def add_property(cls, filetype):
152157
"""
@@ -166,4 +171,3 @@ def property_template(self):
166171
for filetype in all_ext:
167172
add_property(IonBase, filetype)
168173
add_property(IonBase, '/'.join(['dielectronic', filetype]))
169-
add_property(IonBase, 'ip')

fiasco/io/datalayer.py

+6
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ def __getitem__(self, key):
124124
data = data.astype(str)
125125
return data
126126

127+
def get(self, key, default=None):
128+
try:
129+
return self[key]
130+
except KeyError:
131+
return default
132+
127133
def __repr__(self):
128134

129135
def ufilter(x):

fiasco/ions.py

+92-47
Original file line numberDiff line numberDiff line change
@@ -40,30 +40,29 @@ class Ion(IonBase, ContinuumBase):
4040
input formats.
4141
temperature : `~astropy.units.Quantity`
4242
Temperature array over which to evaluate temperature dependent quantities.
43-
ionization_fraction : `str` or `float` or array-like, optional
44-
If a string is provided, use the appropriate "ioneq" dataset. If an array is provided, it must be the same shape
45-
as ``temperature``. If a scalar value is passed in, the ionization fraction is assumed constant at all temperatures.
4643
abundance : `str` or `float`, optional
4744
If a string is provided, use the appropriate abundance dataset.
4845
If a float is provided, use that value as the abundance.
49-
ip_filename : `str`, optional
50-
Ionization potential dataset
46+
ionization_fraction : `str` or `float` or array-like, optional
47+
If a string is provided, use the appropriate "ioneq" dataset. If an array is provided, it must be the same shape
48+
as ``temperature``. If a scalar value is passed in, the ionization fraction is assumed constant at all temperatures.
49+
ionization_potential : `str` or `~astropy.units.Quantity`, optional
50+
If a string is provided, use the appropriate "ip" dataset.
51+
If a scalar value is provided, use that value for the ionization potential. This value should be convertible to eV.
5152
"""
5253

5354
@u.quantity_input
5455
def __init__(self, ion_name, temperature: u.K,
55-
abundance = 'sun_coronal_1992_feldman_ext',
56-
ionization_fraction = 'chianti',
56+
abundance='sun_coronal_1992_feldman_ext',
57+
ionization_fraction='chianti',
58+
ionization_potential='chianti',
5759
*args, **kwargs):
5860
super().__init__(ion_name, *args, **kwargs)
5961
self.temperature = np.atleast_1d(temperature)
60-
# Get selected datasets
61-
# TODO: do not hardcode defaults, pull from rc file
6262
self._dset_names = {}
63-
self._dset_names['ionization_fraction'] = kwargs.get('ionization_fraction', 'chianti')
64-
self._dset_names['ip_filename'] = kwargs.get('ip_filename', 'chianti')
6563
self.abundance = abundance
6664
self.ionization_fraction = ionization_fraction
65+
self.ionization_potential = ionization_potential
6766
self.gaunt_factor = GauntFactor(hdf5_dbase_root=self.hdf5_dbase_root)
6867

6968
def _new_instance(self, temperature=None, **kwargs):
@@ -101,8 +100,8 @@ def __repr__(self):
101100
HDF5 Database: {self.hdf5_dbase_root}
102101
Using Datasets:
103102
ionization_fraction: {self._dset_names['ionization_fraction']}
104-
abundance: {self._dset_names.get('abundance', self.abundance)}
105-
ip: {self._dset_names['ip_filename']}"""
103+
abundance: {self._dset_names['abundance']}
104+
ip: {self._dset_names['ionization_potential']}"""
106105

107106
@cached_property
108107
def _all_levels(self):
@@ -141,16 +140,16 @@ def _instance_kwargs(self):
141140
'hdf5_dbase_root': self.hdf5_dbase_root,
142141
**self._dset_names,
143142
}
144-
# If the abundance is set using a string specifying the abundance dataset,
143+
# If any of the datasets are set using a string specifying the name of the dataset,
145144
# the dataset name is in _dset_names. We want to pass this to the new instance
146-
# so that the new instance knows that the abundance was specified using a
147-
# dataset name. Otherwise, we can just pass the actual abundance value.
145+
# so that the new instance knows that the dataset was specified using a
146+
# dataset name. Otherwise, we can just pass the actual value.
148147
if kwargs['abundance'] is None:
149148
kwargs['abundance'] = self.abundance
150-
151149
if kwargs['ionization_fraction'] is None:
152150
kwargs['ionization_fraction'] = self.ionization_fraction
153-
151+
if kwargs['ionization_potential'] is None:
152+
kwargs['ionization_potential'] = self.ionization_potential
154153
return kwargs
155154

156155
def _has_dataset(self, dset_name):
@@ -193,29 +192,42 @@ def transitions(self):
193192
return Transitions(self._elvlc, self._wgfa)
194193

195194
@property
196-
def ionization_fraction(self):
195+
@u.quantity_input
196+
def ionization_fraction(self) -> u.dimensionless_unscaled:
197197
"""
198198
Ionization fraction of an ion
199199
"""
200+
if self._ionization_fraction is None:
201+
raise MissingDatasetException(
202+
f"{self._dset_names['ionization_fraction']} ionization fraction data missing for {self.ion_name}"
203+
)
200204
return self._ionization_fraction
201205

202206
@ionization_fraction.setter
203207
def ionization_fraction(self, ionization_fraction):
204208
if isinstance(ionization_fraction, str):
205209
self._dset_names['ionization_fraction'] = ionization_fraction
206-
self._ionization_fraction = self._interpolate_ionization_fraction()
210+
ionization_fraction = None
211+
if self._has_dataset('ion_fraction') and (ionization_fraction := self._ion_fraction.get(self._dset_names['ionization_fraction'])):
212+
ionization_fraction = self._interpolate_ionization_fraction(
213+
self.temperature,
214+
ionization_fraction['temperature'],
215+
ionization_fraction['ionization_fraction']
216+
)
217+
self._ionization_fraction = ionization_fraction
207218
else:
208219
# Multiplying by np.ones allows for passing in scalar values
209-
_ionization_fraction = np.atleast_1d(ionization_fraction) * np.ones(self.temperature.shape)
220+
ionization_fraction = np.atleast_1d(ionization_fraction) * np.ones(self.temperature.shape)
210221
self._dset_names['ionization_fraction'] = None
211-
self._ionization_fraction = _ionization_fraction
222+
self._ionization_fraction = ionization_fraction
212223

213-
def _interpolate_ionization_fraction(self):
224+
@staticmethod
225+
def _interpolate_ionization_fraction(temperature, temperature_data, ionization_data):
214226
"""
215227
Ionization equilibrium data interpolated to the given temperature
216228
217-
Interpolated the pre-computed ionization fractions stored in CHIANTI to the temperature
218-
of the ion. Returns NaN where interpolation is out of range of the data. For computing
229+
Interpolated the pre-computed ionization fractions stored in CHIANTI to a new temperature
230+
array. Returns NaN where interpolation is out of range of the data. For computing
219231
ionization equilibrium outside of this temperature range, it is better to use the ionization
220232
and recombination rates.
221233
@@ -225,13 +237,22 @@ def _interpolate_ionization_fraction(self):
225237
Interpolating Polynomial with `~scipy.interpolate.PchipInterpolator`. This helps to
226238
ensure smoothness while reducing oscillations in the interpolated ionization fractions.
227239
240+
Parameters
241+
----------
242+
temperature: `~astropy.units.Quantity`
243+
Temperature array to interpolation onto.
244+
temperature_data: `~astropy.units.Quantity`
245+
Temperature array on which the ionization fraction is defined
246+
ionization_data: `~astropy.units.Quantity`
247+
Ionization fraction as a function of temperature.
248+
228249
See Also
229250
--------
230251
fiasco.Element.equilibrium_ionization
231252
"""
232-
temperature = self.temperature.to_value('K')
233-
temperature_data = self._ion_fraction[self._dset_names['ionization_fraction']]['temperature'].to_value('K')
234-
ionization_data = self._ion_fraction[self._dset_names['ionization_fraction']]['ionization_fraction'].value
253+
temperature = temperature.to_value('K')
254+
temperature_data = temperature_data.to_value('K')
255+
ionization_data = ionization_data.to_value()
235256
# Perform PCHIP interpolation in log-space on only the non-zero ionization fractions.
236257
# See https://github.com/wtbarnes/fiasco/pull/223 for additional discussion.
237258
is_nonzero = ionization_data > 0.0
@@ -255,6 +276,10 @@ def abundance(self) -> u.dimensionless_unscaled:
255276
"""
256277
Elemental abundance relative to H.
257278
"""
279+
if self._abundance is None:
280+
raise MissingDatasetException(
281+
f"{self._dset_names['abundance']} abundance data missing for {self.ion_name}"
282+
)
258283
return self._abundance
259284

260285
@abundance.setter
@@ -264,21 +289,43 @@ def abundance(self, abundance):
264289
If the abundance is given as a string, use the matching abundance set.
265290
If the abundance is given as a float, use that value directly.
266291
"""
292+
self._dset_names['abundance'] = None
267293
if isinstance(abundance, str):
268294
self._dset_names['abundance'] = abundance
269-
self._abundance = self._abund[self._dset_names['abundance']]
270-
else:
271-
self._dset_names['abundance'] = None
272-
self._abundance = abundance
295+
abundance = None
296+
if self._has_dataset('abund'):
297+
abundance = self._abund.get(self._dset_names['abundance'])
298+
self._abundance = abundance
273299

274300
@property
275-
@needs_dataset('ip')
276301
@u.quantity_input
277-
def ip(self) -> u.erg:
302+
def ionization_potential(self) -> u.eV:
278303
"""
279304
Ionization potential.
280305
"""
281-
return self._ip[self._dset_names['ip_filename']] * const.h * const.c
306+
if self._ionization_potential is None:
307+
raise MissingDatasetException(
308+
f"{self._dset_names['ionization_potential']} ionization potential data missing for {self.ion_name}"
309+
)
310+
# NOTE: Ionization potentials in CHIANTI are stored in units of cm^-1
311+
# Using this here also means that ionization potentials can be passed
312+
# in wavenumber units as well.
313+
return self._ionization_potential.to('eV', equivalencies=u.spectral())
314+
315+
@ionization_potential.setter
316+
def ionization_potential(self, ionization_potential):
317+
"""
318+
Sets the ionization potential of an ion.
319+
If the ionization potential is given as a string, use the matching ionization potential set.
320+
if the ionization potential is given as a float, use that value directly.
321+
"""
322+
self._dset_names['ionization_potential'] = None
323+
if isinstance(ionization_potential, str):
324+
self._dset_names['ionization_potential'] = ionization_potential
325+
ionization_potential = None
326+
if self._has_dataset('ip'):
327+
ionization_potential = self._ip.get(self._dset_names['ionization_potential'])
328+
self._ionization_potential = ionization_potential
282329

283330
@property
284331
def hydrogenic(self):
@@ -718,7 +765,7 @@ def _population_correction(self, population, density, rate_matrix):
718765
ratio[:, 0] = 0.0
719766
return 1.0 + ratio
720767

721-
@needs_dataset('abundance', 'elvlc')
768+
@needs_dataset('elvlc')
722769
@u.quantity_input
723770
def contribution_function(self, density: u.cm**(-3), **kwargs) -> u.cm**3 * u.erg / u.s:
724771
r"""
@@ -921,7 +968,6 @@ def spectrum(self, *args, **kwargs):
921968
return IonCollection(self).spectrum(*args, **kwargs)
922969

923970
@cached_property
924-
@needs_dataset('ip')
925971
@u.quantity_input
926972
def direct_ionization_rate(self) -> u.cm**3 / u.s:
927973
r"""
@@ -961,11 +1007,11 @@ def direct_ionization_rate(self) -> u.cm**3 / u.s:
9611007
"""
9621008
xgl, wgl = np.polynomial.laguerre.laggauss(12)
9631009
kBT = const.k_B * self.temperature
964-
energy = np.outer(xgl, kBT) + self.ip
1010+
energy = np.outer(xgl, kBT) + self.ionization_potential
9651011
cross_section = self.direct_ionization_cross_section(energy)
966-
term1 = np.sqrt(8./np.pi/const.m_e)*np.sqrt(kBT)*np.exp(-self.ip/kBT)
1012+
term1 = np.sqrt(8./np.pi/const.m_e)*np.sqrt(kBT)*np.exp(-self.ionization_potential/kBT)
9671013
term2 = ((wgl*xgl)[:, np.newaxis]*cross_section).sum(axis=0)
968-
term3 = (wgl[:, np.newaxis]*cross_section).sum(axis=0)*self.ip/kBT
1014+
term3 = (wgl[:, np.newaxis]*cross_section).sum(axis=0)*self.ionization_potential/kBT
9691015
return term1*(term2 + term3)
9701016

9711017
@u.quantity_input
@@ -1038,10 +1084,9 @@ def _dere_cross_section(self, energy: u.erg) -> u.cm**2:
10381084

10391085
return cross_section_total
10401086

1041-
@needs_dataset('ip')
10421087
@u.quantity_input
10431088
def _fontes_cross_section(self, energy: u.erg) -> u.cm**2:
1044-
U = energy/self.ip
1089+
U = energy/self.ionization_potential
10451090
A = 1.13
10461091
B = 1 if self.hydrogenic else 2
10471092
F = 1 if self.atomic_number < 20 else (140 + (self.atomic_number/20)**3.2)/141
@@ -1057,7 +1102,7 @@ def _fontes_cross_section(self, energy: u.erg) -> u.cm**2:
10571102

10581103
# NOTE: conversion to Rydbergs equivalent to scaling to the ionization energy
10591104
# of hydrogen such that it is effectively unitless
1060-
return B * (np.pi * const.a0**2) * F * Qrp / (self.ip.to(u.Ry).value**2)
1105+
return B * (np.pi * const.a0**2) * F * Qrp / (self.ionization_potential.to_value(u.Ry)**2)
10611106

10621107
@cached_property
10631108
@needs_dataset('easplups')
@@ -1440,7 +1485,7 @@ def free_bound(self,
14401485
self._fblvl['L'],
14411486
level_fb):
14421487
# Energy required to ionize ion from level i
1443-
E_ionize = self.ip - E
1488+
E_ionize = self.ionization_potential - E
14441489
# Check if ionization potential and photon energy sufficient
14451490
if (E_ionize < 0*u.erg) or (E_photon.max() < E):
14461491
continue
@@ -1528,19 +1573,19 @@ def free_bound_radiative_loss(self) -> u.erg * u.cm**3 / u.s:
15281573
E_th = recombined._fblvl['E_th']*const.h*const.c
15291574
n0 = recombined._fblvl['n'][0]
15301575
E_fb = np.where(E_obs==0*u.erg, E_th, E_obs)
1531-
wvl_n0 = const.h * const.c / (recombined.ip - E_fb[0])
1576+
wvl_n0 = 1 / (recombined.ionization_potential - E_fb[0]).to('cm-1', equivalencies=u.spectral())
15321577
wvl_n1 = (n0 + 1)**2 /(const.Ryd * self.charge_state**2)
15331578
g_fb0 = self.gaunt_factor.free_bound_integrated(self.temperature,
15341579
self.atomic_number,
15351580
self.charge_state,
15361581
n0,
1537-
recombined.ip,
1582+
recombined.ionization_potential,
15381583
ground_state=True)
15391584
g_fb1 = self.gaunt_factor.free_bound_integrated(self.temperature,
15401585
self.atomic_number,
15411586
self.charge_state,
15421587
n0,
1543-
recombined.ip,
1588+
recombined.ionization_potential,
15441589
ground_state=False)
15451590
term1 = g_fb0 * np.exp(-const.h*const.c/(const.k_B * self.temperature * wvl_n0))
15461591
term2 = g_fb1 * np.exp(-const.h*const.c/(const.k_B * self.temperature * wvl_n1))

fiasco/tests/idl/test_idl_ioneq.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,17 @@
2525
def test_ionization_fraction_from_idl(ion_name, idl_env, dbase_version, hdf5_dbase_root):
2626
Z, iz = parse_ion_name(ion_name)
2727
script = """
28-
ioneq_file = FILEPATH('{{ionization_fraction}}.ioneq', ROOT_DIR=!xuvtop, SUBDIR='ioneq')
28+
ioneq_file = FILEPATH('{{ioneq_filename}}.ioneq', ROOT_DIR=!xuvtop, SUBDIR='ioneq')
2929
read_ioneq, ioneq_file, temperature, ioneq, ioneq_ref
3030
ioneq = ioneq[*,{{Z-1}},{{iz-1}}]
3131
"""
3232
formatters = {'temperature': lambda x: 10**x*u.K,
33-
'ionization_fraction': lambda x: x*u.dimensionless_unscaled}
33+
'ioneq': lambda x: x*u.dimensionless_unscaled}
3434
idl_result = run_idl_script(idl_env,
3535
script,
36-
{'ionization_fraction': 'chianti', 'Z': Z, 'iz': iz},
37-
['temperature', 'ionization_fraction'],
38-
f'ionization_{Z}_{iz}',
36+
{'ioneq_filename': 'chianti', 'Z': Z, 'iz': iz},
37+
['temperature', 'ioneq'],
38+
f'ioneq_{Z}_{iz}',
3939
dbase_version,
4040
format_func=formatters)
4141
ion = fiasco.Ion(ion_name,

fiasco/tests/test_gaunt.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ def test_gaunt_factor_free_bound_nl_missing(gaunt_factor):
7878

7979
@pytest.mark.parametrize(('ground_state', 'expected'), [(True, 55.18573076316151), (False, 11.849092513590998)])
8080
def test_gaunt_factor_free_bound_integrated(ion, ground_state, expected):
81-
gf = ion.gaunt_factor.free_bound_integrated(ion.temperature, ion.atomic_number, ion.charge_state, ion.previous_ion()._fblvl['n'][0], ion.previous_ion().ip, ground_state=ground_state)
81+
gf = ion.gaunt_factor.free_bound_integrated(ion.temperature,
82+
ion.atomic_number,
83+
ion.charge_state,
84+
ion.previous_ion()._fblvl['n'][0],
85+
ion.previous_ion().ionization_potential,
86+
ground_state=ground_state)
8287
assert gf.shape == ion.temperature.shape
8388
# These values have not been tested for correctness
8489
assert u.isclose(gf[20], expected * u.dimensionless_unscaled)
@@ -91,5 +96,10 @@ def test_free_bound_gaunt_factor_low_temperature(gs, hdf5_dbase_root):
9196
# At low temperatures (~1e4 K), exponential terms in the gaunt factor used to compute the
9297
# free-bound radiative loss can blow up. This just tests to make sure those are handled correctly
9398
ion = fiasco.Ion('N 8', np.logspace(4,6,100)*u.K, hdf5_dbase_root=hdf5_dbase_root)
94-
gf_fb_int = ion.gaunt_factor.free_bound_integrated(ion.temperature, ion.atomic_number, ion.charge_state, ion.previous_ion()._fblvl['n'][0], ion.previous_ion().ip, ground_state=gs)
99+
gf_fb_int = ion.gaunt_factor.free_bound_integrated(ion.temperature,
100+
ion.atomic_number,
101+
ion.charge_state,
102+
ion.previous_ion()._fblvl['n'][0],
103+
ion.previous_ion().ionization_potential,
104+
ground_state=gs)
95105
assert not np.isinf(gf_fb_int).any()

0 commit comments

Comments
 (0)