rm-tables 0.1.0__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.
rm_tables/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Rosseland mean opacity for gas and dust, at any composition.
2
+
3
+ Two ways in. `opacity` returns a callable and builds no table: it evaluates the
4
+ sources at the point requested. `build` returns a tabulated grid for anyone who
5
+ wants to fit their own interpolant or write the result to a file.
6
+
7
+ Sources are used as their authors published them. OPAL supplies the ionised gas
8
+ above 5623 K; either Semenov 2003 or Ferguson 2005 supplies dust and molecules
9
+ below. The two cold sources are alternatives and are never combined; the README
10
+ says which to choose. Both entry points take a `dataset` naming the OPAL file,
11
+ which sets the metal mixture, and the excess carbon and oxygen that file
12
+ tabulates.
13
+
14
+ `build` raises `rm_tables.coverage.CoverageError` for a range the chosen sources
15
+ do not hold. A callable from `opacity` raises it for a temperature no source
16
+ reaches, and holds the nearest value it has in density. `CoverageError`
17
+ subclasses `ValueError`.
18
+ """
19
+
20
+ from . import coverage, defaults
21
+ from .lookup import opacity
22
+ from .tables import build, load
23
+
24
+ __all__ = ["opacity", "build", "load"]
25
+ __version__ = "0.1.0"
rm_tables/coverage.py ADDED
@@ -0,0 +1,216 @@
1
+ """What each source covers, and the error raised when a request leaves it.
2
+
3
+ Coverage is not always a rectangle. OPAL and Ferguson are tabulated on a grid of
4
+ ``log10 T`` and ``log10 R``, so theirs are. Semenov is a function of temperature
5
+ and density, and its gas branch is bounded in density, so its coverage narrows
6
+ with temperature above 1000 K.
7
+
8
+ A request outside coverage raises and names a source that would answer it.
9
+ Nothing here fills, holds or extrapolates.
10
+ """
11
+ import numpy as np
12
+
13
+ __all__ = ["Coverage", "CoverageError", "OPAL", "FERGUSON", "SEMENOV",
14
+ "covers", "check_composition"]
15
+
16
+
17
+ class CoverageError(ValueError):
18
+ """A requested temperature or density lies outside every chosen source."""
19
+
20
+
21
+ class Coverage:
22
+ """The region of ``(log10 T, log10 R)`` one source can answer in.
23
+
24
+ Parameters
25
+ ----------
26
+ name : str
27
+ The source, as it appears in an error message.
28
+ log_T : tuple of float
29
+ Lowest and highest ``log10`` of temperature in K.
30
+ log_R : tuple of float or callable
31
+ Lowest and highest ``log10 R``, where ``R = rho / (T / 1e6)**3`` in
32
+ g/cm^3. A callable takes ``log10 T`` and returns the pair, for a source
33
+ whose density range depends on temperature.
34
+ reference : str
35
+ Author, year and the journal reference, for the provenance record.
36
+ """
37
+
38
+ def __init__(self, name, log_T, log_R, reference):
39
+ self.name = name
40
+ self.log_T = tuple(log_T)
41
+ self._log_R = log_R
42
+ self.reference = reference
43
+
44
+ def log_R_at(self, log_T):
45
+ """Lowest and highest ``log10 R`` this source answers at one temperature.
46
+
47
+ Both are NaN at a temperature the source answers at no density.
48
+ """
49
+ if callable(self._log_R):
50
+ return self._log_R(log_T)
51
+ return self._log_R
52
+
53
+ def covers(self, log_T, log_R):
54
+ """True where this source has a value. Broadcasts over arrays."""
55
+ log_T = np.asarray(log_T, float)
56
+ log_R = np.asarray(log_R, float)
57
+ inside = (log_T >= self.log_T[0]) & (log_T <= self.log_T[1])
58
+ lo, hi = np.vectorize(self.log_R_at)(log_T)
59
+ return inside & (log_R >= lo) & (log_R <= hi)
60
+
61
+ def __repr__(self):
62
+ return (f"Coverage({self.name!r}, log_T={self.log_T}, "
63
+ f"log_R={'variable' if callable(self._log_R) else self._log_R})")
64
+
65
+
66
+ def _f32(x):
67
+ """The bound as the stored axis holds it.
68
+
69
+ Both tabulated sources ship their axes in 32-bit floats, so an axis written
70
+ from 2.70 reads back as 2.700000047683716. A bound declared at the nominal
71
+ value claims a sliver the data does not have, and a request landing in that
72
+ sliver passes the coverage check and then finds no value.
73
+ """
74
+ return float(np.float32(x))
75
+
76
+
77
+ # Iglesias & Rogers 1996, ApJ 464, 943. A rectangle, as tabulated.
78
+ OPAL = Coverage("opal", (_f32(3.75), _f32(8.70)), (_f32(-8.0), _f32(1.0)),
79
+ "Iglesias & Rogers 1996, ApJ 464, 943")
80
+
81
+ # Ferguson et al. 2005, ApJ 623, 585. Also a rectangle, on the same density axis.
82
+ FERGUSON = Coverage("ferguson", (_f32(2.70), _f32(4.50)),
83
+ (_f32(-8.0), _f32(1.0)),
84
+ "Ferguson et al. 2005, ApJ 623, 585")
85
+
86
+
87
+ def _semenov_log_R(log_T):
88
+ """Semenov's density range at one temperature, in the table's coordinates.
89
+
90
+ Semenov is bounded in density, not in the density parameter. Its gas grid
91
+ runs 1e-19 to 1e-7 g/cm^3. Since ``R = rho / (T / 1e6)**3``, a constant
92
+ density bound is a straight line of slope -3 in ``log10 R``:
93
+
94
+ log10 T 3.30 3.40 3.70 4.00
95
+ lower -10.90 -11.20 -12.10 -13.00
96
+ upper 1.10 0.80 -0.10 -1.00
97
+
98
+ Below the dust-to-gas transition the polynomial has no density dependence
99
+ and answers at any density. The transition sits 100 K above the interpolated
100
+ evaporation temperature of iron, olivine and orthopyroxene, so it moves with
101
+ density: 1001.21 K in the dilute limit, 1001.29 K at 1e-19 g/cm^3 and
102
+ 1514.73 K at 1e-7 g/cm^3. The two bounds therefore begin binding at
103
+ different temperatures, ``log10 T`` of 3.0005 and 3.1803.
104
+
105
+ Past the dense bound the routine returns to the dust polynomial once the
106
+ density is high enough to carry the transition temperature above ``T``, at
107
+ ``log10 R`` of 2.08 at ``log10 T = 3.20`` and 4.30 at 3.30. That second
108
+ region is not claimed, so the pair returned is a subset of where the source
109
+ has a value rather than all of it.
110
+ """
111
+ if log_T > 4.0:
112
+ return (np.nan, np.nan) # above 10,000 K Semenov stops
113
+ lo = -1.0 - 3.0 * log_T if log_T >= 3.0005 else -24.0
114
+ hi = 11.0 - 3.0 * log_T if log_T >= 3.1803 else 4.0
115
+ return (lo, hi)
116
+
117
+
118
+ # Semenov et al. 2003, A&A 410, 611. NOT a rectangle.
119
+ SEMENOV = Coverage("semenov", (np.log10(5.0), 4.0), _semenov_log_R,
120
+ "Semenov et al. 2003, A&A 410, 611")
121
+
122
+ _BY_NAME = {c.name: c for c in (OPAL, FERGUSON, SEMENOV)}
123
+
124
+
125
+ def covers(sources, log_T, log_R, what="the requested range"):
126
+ """Raise unless every point is answered by at least one source.
127
+
128
+ Parameters
129
+ ----------
130
+ sources : sequence of str
131
+ Source names, from ``'opal'``, ``'ferguson'`` and ``'semenov'``.
132
+ log_T, log_R : ndarray
133
+ The grid to check, broadcast against each other.
134
+ what : str, optional
135
+ How the range is described in the error message.
136
+
137
+ Raises
138
+ ------
139
+ CoverageError
140
+ Naming the worst uncovered corner, and any source that would cover it.
141
+ """
142
+ chosen = [_BY_NAME[s] for s in sources]
143
+ log_T, log_R = np.broadcast_arrays(np.asarray(log_T, float),
144
+ np.asarray(log_R, float))
145
+ ok = np.zeros(log_T.shape, bool)
146
+ for c in chosen:
147
+ ok |= c.covers(log_T, log_R)
148
+ if ok.all():
149
+ return
150
+ i = int(np.argmin(ok.ravel()))
151
+ bad_T, bad_R = log_T.ravel()[i], log_R.ravel()[i]
152
+ rescue = [c.name for c in _BY_NAME.values()
153
+ if c.name not in sources and bool(c.covers(bad_T, bad_R))]
154
+ hint = (f" Add {' or '.join(rescue)} to cover it."
155
+ if rescue else " No source covers it.")
156
+ raise CoverageError(
157
+ f"{what} is not covered by {', '.join(sources)}: "
158
+ f"{int(np.size(ok) - ok.sum())} of {np.size(ok)} points fall outside, "
159
+ f"the first at log10 T = {bad_T:.3f} ({10 ** bad_T:.4g} K), "
160
+ f"log10 R = {bad_R:.3f}.{hint}")
161
+
162
+
163
+ def check_composition(X, Z, dataset):
164
+ """Refuse a composition the physics or the tables cannot hold.
165
+
166
+ Parameters
167
+ ----------
168
+ X : float
169
+ Hydrogen mass fraction.
170
+ Z : float
171
+ Metal mass fraction.
172
+ dataset : str
173
+ The OPAL set the hot opacity comes from, from
174
+ `rm_tables.sources.opal.sets`.
175
+
176
+ Raises
177
+ ------
178
+ CoverageError
179
+ If helium would be negative, if either fraction is negative, or if the
180
+ pair lies outside what the chosen set tabulates. OPAL interpolates
181
+ linearly in hydrogen and in the logarithm of the metallicity, and
182
+ continues that straight line past its own edge, so a request outside
183
+ returns a number with nothing behind it. Hydrogen of 1.5 returns
184
+ 5.5e-136 cm^2/g at 1e5 K.
185
+ """
186
+ from .sources import opal
187
+ X, Z = float(X), float(Z)
188
+ if not (np.isfinite(X) and np.isfinite(Z)):
189
+ raise CoverageError(
190
+ f"mass fractions must be finite, and X={X:g}, Z={Z:g} was "
191
+ f"requested.")
192
+ if X < 0.0 or Z < 0.0:
193
+ raise CoverageError(
194
+ f"mass fractions cannot be negative, and X={X:g}, Z={Z:g} "
195
+ f"was requested.")
196
+ if X + Z > 1.0:
197
+ raise CoverageError(
198
+ f"X={X:g} and Z={Z:g} leave helium at {1.0 - X - Z:g}. "
199
+ f"Helium is the remainder, so X + Z cannot exceed 1.")
200
+ xs, zs = opal.compositions(dataset)
201
+ x_hi, z_hi = float(np.max(xs)), float(np.max(zs))
202
+ x_lo = float(np.min(xs))
203
+ # The tabulated fractions are stored in 32-bit floats, so 0.7 reads back as
204
+ # 0.699999988079071. Compare at that precision or an exactly tabulated
205
+ # value falls outside its own set.
206
+ Xc, Zc = float(np.float32(X)), float(np.float32(Z))
207
+ if not x_lo <= Xc <= x_hi:
208
+ raise CoverageError(
209
+ f"set {dataset!r} tabulates hydrogen from {x_lo:g} to {x_hi:g}, "
210
+ f"and X={X:g} was requested."
211
+ + (f" That set holds one hydrogen fraction only."
212
+ if x_lo == x_hi else ""))
213
+ if Zc > z_hi:
214
+ raise CoverageError(
215
+ f"set {dataset!r} tabulates metals up to {z_hi:g}, and Z={Z:g} "
216
+ f"was requested. rm_tables.sources.opal.sets() lists the others.")
Binary file