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