aspcap 0.2.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.
- aspcap/__init__.py +8 -0
- aspcap/abundances.py +73 -0
- aspcap/cli.py +64 -0
- aspcap/continuum.py +437 -0
- aspcap/data/apogee-dr17-mgiant-full-weights.json +1 -0
- aspcap/data/apogee-dr17-mgiant-full.hdr +15 -0
- aspcap/data/apogee-dr17-mgiant-full.unf +0 -0
- aspcap/ferre.py +456 -0
- aspcap/spectrum.py +551 -0
- aspcap-0.2.0.dist-info/METADATA +185 -0
- aspcap-0.2.0.dist-info/RECORD +16 -0
- aspcap-0.2.0.dist-info/WHEEL +5 -0
- aspcap-0.2.0.dist-info/entry_points.txt +2 -0
- aspcap-0.2.0.dist-info/licenses/LICENSE +21 -0
- aspcap-0.2.0.dist-info/licenses/LICENSE-DOPPLER +21 -0
- aspcap-0.2.0.dist-info/top_level.txt +1 -0
aspcap/ferre.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Load, interpolate, and fit a synthetic spectral library with FERRE."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from bisect import bisect_left
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from math import isfinite, prod
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Grid:
|
|
17
|
+
"""Inspect, interpolate, and fit a synthetic spectral library with FERRE.
|
|
18
|
+
|
|
19
|
+
The header is read when the grid is opened. FERRE is invoked only when
|
|
20
|
+
evaluating models or fitting spectra. Supported libraries have a single
|
|
21
|
+
segment, positive spectral resolution, and uncompressed spectra.
|
|
22
|
+
Evaluation and fitting require at least two points on every label axis.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
path : pathlib.Path
|
|
27
|
+
Resolved path to the ASCII library or binary header.
|
|
28
|
+
labels : tuple of str
|
|
29
|
+
Case-sensitive labels in FERRE axis order.
|
|
30
|
+
lower, step, counts : tuple
|
|
31
|
+
Lower limits, grid spacings, and point counts in label order.
|
|
32
|
+
ranges : tuple of tuple of float
|
|
33
|
+
Inclusive physical bounds, one (lower, upper) pair per label.
|
|
34
|
+
wavelengths : tuple of float
|
|
35
|
+
Native wavelength pixels in the library's wavelength units.
|
|
36
|
+
Logarithmically sampled headers are converted to physical wavelengths.
|
|
37
|
+
vacuum, binary : bool
|
|
38
|
+
Whether wavelengths are in vacuum and whether library data are binary.
|
|
39
|
+
resolution : float
|
|
40
|
+
Spectral resolution recorded in the header.
|
|
41
|
+
nlabels, nspectra, npix : int
|
|
42
|
+
Number of axes, product of the axis sizes, and native pixel count.
|
|
43
|
+
outwave : tuple of float or None
|
|
44
|
+
Default output sampling for model evaluation.
|
|
45
|
+
executable : str or path-like
|
|
46
|
+
FERRE executable used for evaluation and fitting.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, path, outwave=None, *, executable="ferre"):
|
|
50
|
+
"""Open a library and validate its metadata.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
path : str or path-like
|
|
55
|
+
ASCII library path, or a binary library's .hdr path. The binary
|
|
56
|
+
data must be in a sibling .unf file with the same stem.
|
|
57
|
+
outwave : sequence of float, optional
|
|
58
|
+
Default output wavelengths for evaluation. Values must lie
|
|
59
|
+
within the native wavelength range and use its units and frame.
|
|
60
|
+
executable : str or path-like, optional
|
|
61
|
+
Executable name on PATH or a path to a FERRE executable. Defaults
|
|
62
|
+
to ferre. It is resolved when a run starts.
|
|
63
|
+
|
|
64
|
+
Raises
|
|
65
|
+
------
|
|
66
|
+
OSError
|
|
67
|
+
The library header cannot be opened.
|
|
68
|
+
ValueError
|
|
69
|
+
The header or wavelength selection is invalid or unsupported,
|
|
70
|
+
or the binary data file is missing.
|
|
71
|
+
"""
|
|
72
|
+
path = Path(path).resolve()
|
|
73
|
+
binary = path.suffix == ".hdr"
|
|
74
|
+
if binary and not path.with_suffix(".unf").is_file():
|
|
75
|
+
raise ValueError(f"Missing binary grid data: {path.with_suffix('.unf')}")
|
|
76
|
+
with path.open("rb") as file:
|
|
77
|
+
lines = []
|
|
78
|
+
for raw in file:
|
|
79
|
+
line = raw.decode("ascii").split("!", 1)[0].strip()
|
|
80
|
+
if line == "/":
|
|
81
|
+
break
|
|
82
|
+
lines.append(line)
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError(f"Unterminated FERRE header: {path}")
|
|
85
|
+
if file.peek(256).lstrip().startswith(b"&"):
|
|
86
|
+
raise ValueError("Only single-segment FERRE libraries are supported")
|
|
87
|
+
header = " ".join(lines)
|
|
88
|
+
fields = re.split(r"(\b[A-Za-z_][\w]*(?:\(\s*\d+\s*\))?)\s*=", header)
|
|
89
|
+
values = {
|
|
90
|
+
re.sub(r"\s+", "", key).upper(): value.strip().rstrip(",")
|
|
91
|
+
for key, value in zip(fields[1::2], fields[2::2])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def numbers(key):
|
|
95
|
+
return tuple(float(x.replace("D", "E").replace("d", "e"))
|
|
96
|
+
for x in values[key].replace(",", " ").split())
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
ndim = int(values.get("N_OF_DIM", values.get("NDIM", "0")))
|
|
100
|
+
labels = tuple(values[f"LABEL({i})"].strip("'\042")
|
|
101
|
+
for i in range(1, ndim + 1))
|
|
102
|
+
lower, step = numbers("LLIMITS"), numbers("STEPS")
|
|
103
|
+
counts = tuple(int(n) for n in numbers("N_P"))
|
|
104
|
+
start, spacing = numbers("WAVE")
|
|
105
|
+
npix = int(values["NPIX"])
|
|
106
|
+
logw = int(values["LOGW"])
|
|
107
|
+
vacuum = int(values["VACUUM"])
|
|
108
|
+
resolution, = numbers("RESOLUTION")
|
|
109
|
+
except (KeyError, ValueError) as error:
|
|
110
|
+
raise ValueError(f"Invalid or incomplete FERRE header in {path}: {error}") from error
|
|
111
|
+
if (int(values.get("MULTI", "0")) != 0
|
|
112
|
+
or ("NPCA" in values and any(numbers("NPCA")))
|
|
113
|
+
or not isfinite(resolution) or resolution <= 0
|
|
114
|
+
or logw not in (0, 1) or vacuum not in (0, 1)
|
|
115
|
+
or not ndim or len(set(labels)) != ndim
|
|
116
|
+
or not (len(lower) == len(step) == len(counts) == ndim)
|
|
117
|
+
or not all(isfinite(x) for x in (*lower, *step, start, spacing))
|
|
118
|
+
or any(n < 1 for n in counts) or any(s <= 0 for s in step)
|
|
119
|
+
or npix < 2 or spacing <= 0):
|
|
120
|
+
raise ValueError(f"Unsupported FERRE header in {path}")
|
|
121
|
+
wave = tuple(start + i * spacing for i in range(npix))
|
|
122
|
+
if logw:
|
|
123
|
+
wave = tuple(10**x for x in wave)
|
|
124
|
+
self.path = path
|
|
125
|
+
self.labels = labels
|
|
126
|
+
self.lower = lower
|
|
127
|
+
self.step = step
|
|
128
|
+
self.counts = counts
|
|
129
|
+
self.wavelengths = wave
|
|
130
|
+
self.vacuum = bool(vacuum)
|
|
131
|
+
self.binary = binary
|
|
132
|
+
self.resolution = resolution
|
|
133
|
+
self.executable = executable
|
|
134
|
+
self.nlabels = len(labels)
|
|
135
|
+
self.npix = len(wave)
|
|
136
|
+
self.nspectra = prod(counts)
|
|
137
|
+
self.ranges = tuple((lo, lo + dx * (n - 1))
|
|
138
|
+
for lo, dx, n in zip(lower, step, counts))
|
|
139
|
+
self.outwave = None if outwave is None else self._output_wave(outwave)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def read(cls, path, **options):
|
|
143
|
+
"""Open a library using the same path and options as the constructor.
|
|
144
|
+
|
|
145
|
+
Pass outwave or executable as keyword options. Returns an instance
|
|
146
|
+
of the class on which this method is called.
|
|
147
|
+
"""
|
|
148
|
+
return cls(path, **options)
|
|
149
|
+
|
|
150
|
+
def __repr__(self):
|
|
151
|
+
"""Show library size, label ranges, and native wavelength coverage."""
|
|
152
|
+
summary = (f"Grid({str(self.path)!r}, {self.nlabels} labels, "
|
|
153
|
+
f"{self.nspectra} spectra, {self.npix} pixels)")
|
|
154
|
+
axes = [f" {name}: {lo:g} to {hi:g} (step {step:g}, {count} points)"
|
|
155
|
+
for name, (lo, hi), step, count
|
|
156
|
+
in zip(self.labels, self.ranges, self.step, self.counts)]
|
|
157
|
+
frame = "vacuum" if self.vacuum else "air"
|
|
158
|
+
wave = f" Wavelength: {self.wavelengths[0]:g} to {self.wavelengths[-1]:g} ({frame})"
|
|
159
|
+
return "\n".join([summary, *axes, wave])
|
|
160
|
+
|
|
161
|
+
def _parameters(self, pars):
|
|
162
|
+
if isinstance(pars, Mapping):
|
|
163
|
+
if set(pars) != set(self.labels):
|
|
164
|
+
raise ValueError(f"Parameters must match grid labels: {self.labels}")
|
|
165
|
+
pars = [pars[label] for label in self.labels]
|
|
166
|
+
pars = tuple(float(value) for value in pars)
|
|
167
|
+
if len(pars) != self.nlabels:
|
|
168
|
+
raise ValueError(f"Expected {self.nlabels} parameters in order {self.labels}")
|
|
169
|
+
for label, value, (low, high) in zip(self.labels, pars, self.ranges):
|
|
170
|
+
if not low <= value <= high:
|
|
171
|
+
raise ValueError(f"Value for {label!r} is outside [{low:g}, {high:g}]")
|
|
172
|
+
return pars
|
|
173
|
+
|
|
174
|
+
def _output_wave(self, wave):
|
|
175
|
+
wave = tuple(float(value) for value in wave)
|
|
176
|
+
if not wave or not all(self.wavelengths[0] <= value <= self.wavelengths[-1]
|
|
177
|
+
for value in wave):
|
|
178
|
+
raise ValueError("Output wavelengths must fall within the grid")
|
|
179
|
+
return wave
|
|
180
|
+
|
|
181
|
+
def __call__(self, pars, wave=None):
|
|
182
|
+
"""Interpolate one model or a batch of models in a single FERRE run.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
pars : mapping or sequence
|
|
187
|
+
One complete mapping from labels to values, one vector in
|
|
188
|
+
grid-label order, or a nonempty batch of mappings or vectors.
|
|
189
|
+
Values must be finite and within their label bounds.
|
|
190
|
+
wave : sequence of float, optional
|
|
191
|
+
Output wavelengths within the native range, in the library's
|
|
192
|
+
units and frame. Overrides outwave for this call. If neither is
|
|
193
|
+
supplied, use the native pixels.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
dict
|
|
198
|
+
The wave entry is a wavelength tuple. The flux entry is one flux
|
|
199
|
+
tuple for a single model or a tuple of flux tuples in batch order,
|
|
200
|
+
including for a one-row batch. Flux is on the library's scale.
|
|
201
|
+
|
|
202
|
+
Notes
|
|
203
|
+
-----
|
|
204
|
+
FERRE interpolates linearly in the label dimensions. Any wavelength
|
|
205
|
+
resampling is linear and preserves the requested wavelength order.
|
|
206
|
+
|
|
207
|
+
Raises
|
|
208
|
+
------
|
|
209
|
+
ValueError
|
|
210
|
+
The parameters or output wavelengths are invalid, or the
|
|
211
|
+
executable cannot be located.
|
|
212
|
+
RuntimeError
|
|
213
|
+
FERRE failed or returned invalid model output. The run directory
|
|
214
|
+
is retained and included in the error message.
|
|
215
|
+
"""
|
|
216
|
+
if isinstance(pars, Mapping):
|
|
217
|
+
batch, rows = False, (pars,)
|
|
218
|
+
else:
|
|
219
|
+
rows = tuple(pars)
|
|
220
|
+
if not rows:
|
|
221
|
+
raise ValueError("Supply at least one set of parameters")
|
|
222
|
+
batch = isinstance(rows[0], Iterable) and not isinstance(rows[0], (str, bytes))
|
|
223
|
+
if not batch:
|
|
224
|
+
rows = (rows,)
|
|
225
|
+
rows = tuple(self._parameters(row) for row in rows)
|
|
226
|
+
output_wave = self.outwave if wave is None else self._output_wave(wave)
|
|
227
|
+
if output_wave is None:
|
|
228
|
+
output_wave = self.wavelengths
|
|
229
|
+
models = _execute(self, rows, (), executable=self.executable)
|
|
230
|
+
if output_wave != self.wavelengths:
|
|
231
|
+
samples = []
|
|
232
|
+
for value in output_wave:
|
|
233
|
+
right = max(1, bisect_left(self.wavelengths, value))
|
|
234
|
+
left = right - 1
|
|
235
|
+
fraction = ((value - self.wavelengths[left]) /
|
|
236
|
+
(self.wavelengths[right] - self.wavelengths[left]))
|
|
237
|
+
samples.append((left, right, fraction))
|
|
238
|
+
models = tuple(tuple(model[i] + t * (model[j] - model[i])
|
|
239
|
+
for i, j, t in samples) for model in models)
|
|
240
|
+
return {"wave": output_wave, "flux": models if batch else models[0]}
|
|
241
|
+
|
|
242
|
+
def model(self, wave, *pars):
|
|
243
|
+
"""Evaluate one model and return its flux tuple.
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
wave : sequence of float or None
|
|
248
|
+
Output wavelengths as in __call__. None uses the grid default.
|
|
249
|
+
*pars : float
|
|
250
|
+
One positional value per grid label, in the order of labels.
|
|
251
|
+
|
|
252
|
+
Returns
|
|
253
|
+
-------
|
|
254
|
+
tuple of float
|
|
255
|
+
Model flux at the requested wavelengths. The wavelength-first
|
|
256
|
+
signature is suitable for optimizers such as scipy.optimize.curve_fit.
|
|
257
|
+
"""
|
|
258
|
+
return self(pars, wave=wave)["flux"]
|
|
259
|
+
|
|
260
|
+
def fit(self, flux, error, start=None, *, free_labels=None, weights=None,
|
|
261
|
+
executable=None, keep_runs=False, run_root=None):
|
|
262
|
+
"""Fit a prepared spectrum on the native grid with FERRE.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
flux, error : sequence of float
|
|
267
|
+
Observed flux and one-sigma uncertainties, in native pixel order
|
|
268
|
+
and on the library's flux scale. Each sequence must have npix
|
|
269
|
+
entries. Weighted pixels require finite flux and positive finite
|
|
270
|
+
errors. The fit does not apply wavelength or continuum corrections.
|
|
271
|
+
start : mapping or sequence of float, optional
|
|
272
|
+
Full starting parameters keyed by label or in grid-label order.
|
|
273
|
+
Defaults to the midpoint of each axis. Values for frozen labels
|
|
274
|
+
are taken from this vector.
|
|
275
|
+
free_labels : str or sequence of str, optional
|
|
276
|
+
One or more distinct labels to vary. Defaults to every grid label.
|
|
277
|
+
weights : sequence of float, optional
|
|
278
|
+
Nonnegative finite weights with one entry per native pixel. Zero
|
|
279
|
+
excludes a pixel. Defaults to unit weights. More pixels must have
|
|
280
|
+
positive weight than there are free labels.
|
|
281
|
+
executable : str or path-like, optional
|
|
282
|
+
Override this grid's executable for the fit.
|
|
283
|
+
keep_runs : bool, optional
|
|
284
|
+
Keep the FERRE files after success. Failed runs retain their files.
|
|
285
|
+
run_root : str or path-like, optional
|
|
286
|
+
Existing parent directory for the run files. Defaults to the
|
|
287
|
+
system temporary directory.
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
FerreFit
|
|
292
|
+
Fitted parameters and conditional errors keyed by label, FERRE's
|
|
293
|
+
reduced chi-square, and model flux on the native grid.
|
|
294
|
+
|
|
295
|
+
Raises
|
|
296
|
+
------
|
|
297
|
+
ValueError
|
|
298
|
+
The inputs are invalid or the executable cannot be located.
|
|
299
|
+
RuntimeError
|
|
300
|
+
FERRE failed or returned invalid fit output. The run directory
|
|
301
|
+
is retained and included in the error message.
|
|
302
|
+
"""
|
|
303
|
+
if start is None:
|
|
304
|
+
start = tuple((lo + hi) / 2 for lo, hi in self.ranges)
|
|
305
|
+
start = self._parameters(start)
|
|
306
|
+
if free_labels is None:
|
|
307
|
+
free_labels = self.labels
|
|
308
|
+
elif isinstance(free_labels, str):
|
|
309
|
+
free_labels = (free_labels,)
|
|
310
|
+
else:
|
|
311
|
+
free_labels = tuple(free_labels)
|
|
312
|
+
if not free_labels or len(set(free_labels)) != len(free_labels):
|
|
313
|
+
raise ValueError("Supply one or more distinct free grid labels")
|
|
314
|
+
axes = tuple(self.labels.index(label) for label in free_labels)
|
|
315
|
+
if weights is None:
|
|
316
|
+
weights = (1,) * self.npix
|
|
317
|
+
if any(len(values) != self.npix for values in (flux, error, weights)):
|
|
318
|
+
raise ValueError("Flux, error, and weights must match the grid pixel count")
|
|
319
|
+
if not all(isfinite(w) and w >= 0 for w in weights):
|
|
320
|
+
raise ValueError("Weights must be finite and nonnegative")
|
|
321
|
+
if sum(w > 0 for w in weights) <= len(axes):
|
|
322
|
+
raise ValueError("The fit requires more weighted pixels than free labels")
|
|
323
|
+
if any(w > 0 and not (isfinite(f) and isfinite(e) and e > 0)
|
|
324
|
+
for f, e, w in zip(flux, error, weights)):
|
|
325
|
+
raise ValueError("Weighted pixels require finite flux and positive finite error")
|
|
326
|
+
flux = tuple(f if w > 0 else 1.0 for f, w in zip(flux, weights))
|
|
327
|
+
error = tuple(e if w > 0 else 1e10 for e, w in zip(error, weights))
|
|
328
|
+
return _execute(self, (start,), axes, flux=flux, error=error, weights=weights,
|
|
329
|
+
executable=self.executable if executable is None else executable,
|
|
330
|
+
keep_runs=keep_runs, run_root=run_root)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class FerreFit:
|
|
335
|
+
"""Result of a native-grid FERRE fit.
|
|
336
|
+
|
|
337
|
+
Parameters and errors are keyed by grid label. Errors are conditional
|
|
338
|
+
on any frozen labels and are None when FERRE reports no usable positive
|
|
339
|
+
uncertainty. reduced_chi2 is FERRE's reported statistic, and model is the
|
|
340
|
+
best-fit flux tuple in native pixel order.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
parameters: dict[str, float]
|
|
344
|
+
errors: dict[str, float | None]
|
|
345
|
+
reduced_chi2: float
|
|
346
|
+
model: tuple[float, ...]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def run_ferre(grid, flux, error, start, free_label, weights, *,
|
|
350
|
+
executable="ferre", keep_runs=False, run_root=None):
|
|
351
|
+
"""Run the abundance workflow's single-label fit on the native grid."""
|
|
352
|
+
return grid.fit(flux, error, start, free_labels=(free_label,), weights=weights,
|
|
353
|
+
executable=executable, keep_runs=keep_runs, run_root=run_root)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _execute(grid, rows, axes, *, flux=None, error=None, weights=None,
|
|
357
|
+
executable, keep_runs=False, run_root=None):
|
|
358
|
+
ndim = grid.nlabels
|
|
359
|
+
if any(count < 2 for count in grid.counts):
|
|
360
|
+
raise ValueError("FERRE requires at least two points on every library axis")
|
|
361
|
+
resolved = shutil.which(str(executable))
|
|
362
|
+
if resolved is None:
|
|
363
|
+
raise ValueError(f"FERRE executable not found or not executable: {executable}")
|
|
364
|
+
executable = Path(resolved).resolve()
|
|
365
|
+
directory = Path(tempfile.mkdtemp(prefix="aspcap-", dir=run_root))
|
|
366
|
+
library = directory / ("grid.hdr" if grid.binary else "grid.dat")
|
|
367
|
+
try:
|
|
368
|
+
if grid.binary:
|
|
369
|
+
library.with_suffix(".hdr").symlink_to(grid.path)
|
|
370
|
+
library.with_suffix(".unf").symlink_to(grid.path.with_suffix(".unf"))
|
|
371
|
+
else:
|
|
372
|
+
library.symlink_to(grid.path)
|
|
373
|
+
|
|
374
|
+
def write_row(name, values):
|
|
375
|
+
row = " ".join(f"{x:.12e}" for x in values)
|
|
376
|
+
(directory / name).write_text(row + "\n", encoding="ascii")
|
|
377
|
+
|
|
378
|
+
# FERRE uses a half-open normalized interval [0, 1) internally.
|
|
379
|
+
# Nudge exact upper endpoints inwards far enough for its single precision.
|
|
380
|
+
inputs = [
|
|
381
|
+
" ".join(f"{(hi - 2e-6 * (hi - lo) if value == hi else value):.12e}"
|
|
382
|
+
for value, (lo, hi) in zip(row, grid.ranges))
|
|
383
|
+
for row in rows
|
|
384
|
+
]
|
|
385
|
+
(directory / "start.ipf").write_text(
|
|
386
|
+
"".join(f"star{i} {row}\n" for i, row in enumerate(inputs)), encoding="ascii"
|
|
387
|
+
)
|
|
388
|
+
fit_files = ""
|
|
389
|
+
if axes:
|
|
390
|
+
write_row("flux.frd", flux)
|
|
391
|
+
write_row("error.err", error)
|
|
392
|
+
write_row("weights.dat", weights)
|
|
393
|
+
indices = " ".join(str(axis + 1) for axis in axes)
|
|
394
|
+
fit_files = f""" INDV={indices}
|
|
395
|
+
FFILE='flux.frd'
|
|
396
|
+
ERFILE='error.err'
|
|
397
|
+
FILTERFILE='weights.dat'
|
|
398
|
+
OPFILE='fit.opf'
|
|
399
|
+
"""
|
|
400
|
+
(directory / "input.nml").write_text(f"""&LISTA
|
|
401
|
+
NDIM={ndim}
|
|
402
|
+
NOV={len(axes)}
|
|
403
|
+
NOBJ={len(rows)}
|
|
404
|
+
SYNTHFILE(1)='{library.name}'
|
|
405
|
+
F_FORMAT={int(grid.binary)}
|
|
406
|
+
INTER=1
|
|
407
|
+
WINTER=0
|
|
408
|
+
ALGOR=1
|
|
409
|
+
INIT=0
|
|
410
|
+
NRUNS=1
|
|
411
|
+
NTHREADS=1
|
|
412
|
+
CONT=0
|
|
413
|
+
NCONT=0
|
|
414
|
+
OBSCONT=0
|
|
415
|
+
ERRBAR=1
|
|
416
|
+
COVPRINT=1
|
|
417
|
+
PFILE='start.ipf'
|
|
418
|
+
{fit_files} OFFILE='model.mdl'
|
|
419
|
+
/
|
|
420
|
+
""", encoding="ascii")
|
|
421
|
+
with (directory / "ferre.log").open("w") as log:
|
|
422
|
+
process = subprocess.run([str(executable)], cwd=directory, stdout=log,
|
|
423
|
+
stderr=subprocess.STDOUT)
|
|
424
|
+
# FERRE 5.1.3 reports a normal completion with STOP 1.
|
|
425
|
+
if process.returncode and not (process.returncode == 1 and
|
|
426
|
+
"quit: Run ended" in (directory / "ferre.log").read_text(errors="replace")):
|
|
427
|
+
process.check_returncode()
|
|
428
|
+
models = tuple(tuple(float(x) for x in line.split())
|
|
429
|
+
for line in (directory / "model.mdl").read_text().splitlines())
|
|
430
|
+
if len(models) != len(rows) or any(
|
|
431
|
+
len(model) != grid.npix or not all(map(isfinite, model)) for model in models
|
|
432
|
+
):
|
|
433
|
+
raise ValueError("FERRE returned an invalid model")
|
|
434
|
+
result = models
|
|
435
|
+
if axes:
|
|
436
|
+
fields = (directory / "fit.opf").read_text().split()
|
|
437
|
+
if len(fields) < 2 * ndim + 4 or fields[0] != "star0":
|
|
438
|
+
raise ValueError("Unexpected FERRE parameter output")
|
|
439
|
+
params = tuple(float(x) for x in fields[1:ndim + 1])
|
|
440
|
+
sigma = tuple(float(x) for x in fields[ndim + 1:2 * ndim + 1])
|
|
441
|
+
chi2 = 10 ** float(fields[2 * ndim + 3])
|
|
442
|
+
if not all(map(isfinite, (*params, chi2))) or any(x == -999.999 for x in params):
|
|
443
|
+
raise ValueError("FERRE returned an invalid solution")
|
|
444
|
+
for i, label in enumerate(grid.labels):
|
|
445
|
+
lo, hi = grid.ranges[i]
|
|
446
|
+
tolerance = max(5e-5 * abs(params[i]), 3e-6 * (hi - lo), 1e-8)
|
|
447
|
+
if i not in axes and abs(params[i] - rows[0][i]) > tolerance:
|
|
448
|
+
raise ValueError(f"FERRE changed frozen label {label!r}")
|
|
449
|
+
result = FerreFit(dict(zip(grid.labels, params)),
|
|
450
|
+
{label: x if isfinite(x) and x > 0 else None
|
|
451
|
+
for label, x in zip(grid.labels, sigma)}, chi2, models[0])
|
|
452
|
+
except (OSError, ValueError, subprocess.SubprocessError, OverflowError) as error:
|
|
453
|
+
raise RuntimeError(f"FERRE fit failed. Run files kept in {directory}. {error}") from error
|
|
454
|
+
if not keep_runs:
|
|
455
|
+
shutil.rmtree(directory)
|
|
456
|
+
return result
|