sdss-almanac 0.2.1__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.
- almanac/__init__.py +4 -0
- almanac/apogee.py +300 -0
- almanac/cli.py +556 -0
- almanac/config.py +110 -0
- almanac/data_models/__init__.py +3 -0
- almanac/data_models/exposure.py +350 -0
- almanac/data_models/fps.py +109 -0
- almanac/data_models/plate.py +142 -0
- almanac/data_models/types.py +87 -0
- almanac/data_models/utils.py +185 -0
- almanac/database.py +22 -0
- almanac/display.py +422 -0
- almanac/etc/__init__.py +0 -0
- almanac/etc/bad_exposures.csv +432 -0
- almanac/io.py +320 -0
- almanac/logger.py +27 -0
- almanac/qa.py +24 -0
- almanac/stash/data_models.py +0 -0
- almanac/stash/plugmap_models.py +165 -0
- almanac/utils.py +141 -0
- sdss_almanac-0.2.1.dist-info/METADATA +201 -0
- sdss_almanac-0.2.1.dist-info/RECORD +26 -0
- sdss_almanac-0.2.1.dist-info/WHEEL +5 -0
- sdss_almanac-0.2.1.dist-info/entry_points.txt +2 -0
- sdss_almanac-0.2.1.dist-info/licenses/LICENSE.md +29 -0
- sdss_almanac-0.2.1.dist-info/top_level.txt +1 -0
almanac/cli.py
ADDED
@@ -0,0 +1,556 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
|
3
|
+
import click
|
4
|
+
|
5
|
+
|
6
|
+
@click.group(invoke_without_command=True)
|
7
|
+
@click.option("-v", "--verbosity", count=True, help="Verbosity level")
|
8
|
+
@click.option("--mjd", default=None, type=int, help="Modified Julian date to query. Use negative values to indicate relative to current MJD")
|
9
|
+
@click.option("--mjd-start", default=None, type=int, help="Start of MJD range to query")
|
10
|
+
@click.option("--mjd-end", default=None, type=int, help="End of MJD range to query")
|
11
|
+
@click.option("--date", default=None, type=str, help="Date to query (e.g., 2024-01-15)")
|
12
|
+
@click.option("--date-start", default=None, type=str, help="Start of date range to query")
|
13
|
+
@click.option("--date-end", default=None, type=str, help="End of date range to query")
|
14
|
+
@click.option("--apo", is_flag=True, help="Query Apache Point Observatory data")
|
15
|
+
@click.option("--lco", is_flag=True, help="Query Las Campanas Observatory data")
|
16
|
+
@click.option("--fibers", "--fibres", is_flag=True, help="Include fibre mappings to targets")
|
17
|
+
@click.option("--no-x-match", is_flag=True, help="Do not cross-match targets with SDSS-V database")
|
18
|
+
@click.option("--output", "-O", default=None, type=str, help="Output file")
|
19
|
+
@click.option("--processes", "-p", default=None, type=int, help="Number of processes to use")
|
20
|
+
@click.pass_context
|
21
|
+
def main(
|
22
|
+
ctx,
|
23
|
+
verbosity,
|
24
|
+
mjd,
|
25
|
+
mjd_start,
|
26
|
+
mjd_end,
|
27
|
+
date,
|
28
|
+
date_start,
|
29
|
+
date_end,
|
30
|
+
apo,
|
31
|
+
lco,
|
32
|
+
fibers,
|
33
|
+
no_x_match,
|
34
|
+
output,
|
35
|
+
processes,
|
36
|
+
):
|
37
|
+
"""
|
38
|
+
Almanac collects metadata from planned and actual APOGEE exposures,
|
39
|
+
and identifies sequences of exposures that constitute epoch visits.
|
40
|
+
"""
|
41
|
+
|
42
|
+
# This keeps the default behaviour as 'query mode' but allows for commands like 'config'.
|
43
|
+
if ctx.invoked_subcommand is not None:
|
44
|
+
command = dict(config=config, dump=dump)[ctx.invoked_subcommand]
|
45
|
+
return ctx.invoke(command, **ctx.params)
|
46
|
+
|
47
|
+
import h5py as h5
|
48
|
+
from itertools import product
|
49
|
+
from rich.live import Live
|
50
|
+
from almanac.display import ObservationsDisplay, display_exposures
|
51
|
+
from almanac import apogee, logger, io, utils
|
52
|
+
from contextlib import nullcontext
|
53
|
+
from time import time, sleep
|
54
|
+
|
55
|
+
mjds, mjd_min, mjd_max = utils.parse_mjds(mjd, mjd_start, mjd_end, date, date_start, date_end)
|
56
|
+
observatories = utils.get_observatories(apo, lco)
|
57
|
+
|
58
|
+
n_iterables = len(mjds) * len(observatories)
|
59
|
+
iterable = product(mjds, observatories)
|
60
|
+
results = []
|
61
|
+
|
62
|
+
display = ObservationsDisplay(mjd_min, mjd_max, observatories)
|
63
|
+
|
64
|
+
buffered_critical_logs = []
|
65
|
+
buffered_result_rows = []
|
66
|
+
|
67
|
+
refresh_per_second = 1
|
68
|
+
context_manager = (
|
69
|
+
Live(
|
70
|
+
display.create_display(),
|
71
|
+
refresh_per_second=refresh_per_second,
|
72
|
+
screen=True
|
73
|
+
)
|
74
|
+
if verbosity >= 1
|
75
|
+
else nullcontext()
|
76
|
+
)
|
77
|
+
io_kwds = dict(fibers=fibers, compression=False)
|
78
|
+
with (h5.File(output, "a") if output else nullcontext()) as fp:
|
79
|
+
with context_manager as live:
|
80
|
+
if processes is not None:
|
81
|
+
def initializer():
|
82
|
+
from sdssdb.peewee.sdss5db import database
|
83
|
+
|
84
|
+
if hasattr(database, "_state"):
|
85
|
+
database._state.closed = True
|
86
|
+
database._state.conn = None
|
87
|
+
from almanac.database import database
|
88
|
+
|
89
|
+
# Parallel
|
90
|
+
import os
|
91
|
+
import signal
|
92
|
+
import concurrent.futures
|
93
|
+
if processes < 0:
|
94
|
+
processes = os.cpu_count()
|
95
|
+
with concurrent.futures.ProcessPoolExecutor(
|
96
|
+
max_workers=processes, initializer=initializer
|
97
|
+
) as pool:
|
98
|
+
|
99
|
+
try:
|
100
|
+
futures = set()
|
101
|
+
for n, (mjd, observatory) in enumerate(iterable, start=1):
|
102
|
+
futures.add(
|
103
|
+
pool.submit(
|
104
|
+
apogee.get_almanac_data,
|
105
|
+
observatory,
|
106
|
+
mjd,
|
107
|
+
fibers,
|
108
|
+
not no_x_match,
|
109
|
+
)
|
110
|
+
)
|
111
|
+
if n == processes:
|
112
|
+
break
|
113
|
+
|
114
|
+
t = time()
|
115
|
+
while len(futures) > 0:
|
116
|
+
|
117
|
+
future = next(concurrent.futures.as_completed(futures))
|
118
|
+
|
119
|
+
observatory, mjd, exposures, sequences = result = future.result()
|
120
|
+
|
121
|
+
v = mjd - mjd_min + display.offset
|
122
|
+
missing = [e.image_type == "missing" for e in exposures]
|
123
|
+
if any(missing):
|
124
|
+
display.missing.add(v)
|
125
|
+
#buffered_critical_logs.extend(missing)
|
126
|
+
|
127
|
+
if not exposures:
|
128
|
+
display.no_data[observatory].add(v)
|
129
|
+
else:
|
130
|
+
display.completed[observatory].add(v)
|
131
|
+
results.append(result)
|
132
|
+
if output:
|
133
|
+
io.update(fp, observatory, mjd, exposures, sequences, **io_kwds)
|
134
|
+
|
135
|
+
if live is not None and (time() - t) > 1 / refresh_per_second:
|
136
|
+
live.update(display.create_display())
|
137
|
+
t = time()
|
138
|
+
futures.remove(future)
|
139
|
+
|
140
|
+
try:
|
141
|
+
mjd, observatory = next(iterable)
|
142
|
+
except StopIteration:
|
143
|
+
None
|
144
|
+
else:
|
145
|
+
futures.add(
|
146
|
+
pool.submit(
|
147
|
+
apogee.get_almanac_data,
|
148
|
+
observatory,
|
149
|
+
mjd,
|
150
|
+
fibers,
|
151
|
+
not no_x_match,
|
152
|
+
)
|
153
|
+
)
|
154
|
+
|
155
|
+
|
156
|
+
except KeyboardInterrupt:
|
157
|
+
for pid in pool._processes:
|
158
|
+
os.kill(pid, signal.SIGKILL)
|
159
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
160
|
+
try:
|
161
|
+
fp.close()
|
162
|
+
except:
|
163
|
+
None
|
164
|
+
raise KeyboardInterrupt
|
165
|
+
else:
|
166
|
+
t = time()
|
167
|
+
for mjd, observatory in iterable:
|
168
|
+
*_, exposures, sequences = result = apogee.get_almanac_data(observatory, mjd, fibers, not no_x_match)
|
169
|
+
v = mjd - mjd_min + display.offset
|
170
|
+
if any([e.image_type == "missing" for e in exposures]):
|
171
|
+
display.missing.add(v)
|
172
|
+
#buffered_critical_logs.extend(missing)
|
173
|
+
|
174
|
+
if not exposures:
|
175
|
+
display.no_data[observatory].add(v)
|
176
|
+
else:
|
177
|
+
display.completed[observatory].add(v)
|
178
|
+
results.append(result)
|
179
|
+
if output:
|
180
|
+
io.update(fp, observatory, mjd, exposures, sequences, **io_kwds)
|
181
|
+
|
182
|
+
if live is not None and (time() - t) > 1 / refresh_per_second:
|
183
|
+
live.update(display.create_display())
|
184
|
+
t = time()
|
185
|
+
|
186
|
+
if live is not None:
|
187
|
+
live.update(display.create_display())
|
188
|
+
if verbosity <= 1 and output is None:
|
189
|
+
sleep(3)
|
190
|
+
|
191
|
+
if verbosity >= 2:
|
192
|
+
for observatory, mjd, exposures, sequences in results:
|
193
|
+
display_exposures(exposures, sequences)
|
194
|
+
|
195
|
+
# Show critical logs at the end to avoid disrupting the display
|
196
|
+
for item in buffered_critical_logs:
|
197
|
+
logger.critical(item)
|
198
|
+
|
199
|
+
@main.group()
|
200
|
+
def config(**kwargs):
|
201
|
+
"""View or update configuration settings."""
|
202
|
+
pass
|
203
|
+
|
204
|
+
|
205
|
+
@config.command()
|
206
|
+
def show(**kwargs):
|
207
|
+
"""Show all configuration settings"""
|
208
|
+
|
209
|
+
from almanac import config, get_config_path
|
210
|
+
from dataclasses import asdict
|
211
|
+
|
212
|
+
click.echo(f"Configuration path: {get_config_path()}")
|
213
|
+
click.echo(f"Configuration:")
|
214
|
+
|
215
|
+
def _pretty_print(config_dict, indent=""):
|
216
|
+
for k, v in config_dict.items():
|
217
|
+
if isinstance(v, dict):
|
218
|
+
click.echo(f"{indent}{k}:")
|
219
|
+
_pretty_print(v, indent=indent + " ")
|
220
|
+
else:
|
221
|
+
click.echo(f"{indent}{k}: {v}")
|
222
|
+
|
223
|
+
_pretty_print(asdict(config), " ")
|
224
|
+
|
225
|
+
|
226
|
+
@config.command
|
227
|
+
@click.argument("key", type=str)
|
228
|
+
def get(key, **kwargs):
|
229
|
+
"""Get a configuration value"""
|
230
|
+
|
231
|
+
from almanac import config
|
232
|
+
from dataclasses import asdict
|
233
|
+
|
234
|
+
def traverse(config, key, provenance=None, sep="."):
|
235
|
+
parent, *child = key.split(sep, 1)
|
236
|
+
try:
|
237
|
+
# TODO: Should we even allow dicts in config?
|
238
|
+
if isinstance(config, dict):
|
239
|
+
v = config[parent]
|
240
|
+
else:
|
241
|
+
v = getattr(config, parent)
|
242
|
+
except (AttributeError, KeyError):
|
243
|
+
context = sep.join(provenance or [])
|
244
|
+
if context:
|
245
|
+
context = f" within '{context}'"
|
246
|
+
|
247
|
+
if not isinstance(config, dict):
|
248
|
+
config = asdict(config)
|
249
|
+
|
250
|
+
raise click.ClickException(
|
251
|
+
f"No configuration key '{parent}'{context}. "
|
252
|
+
f"Available{context}: {', '.join(config.keys())}"
|
253
|
+
)
|
254
|
+
|
255
|
+
provenance = (provenance or []) + [parent]
|
256
|
+
return traverse(v, child[0], provenance) if child else v
|
257
|
+
|
258
|
+
value = traverse(config, key)
|
259
|
+
click.echo(value)
|
260
|
+
|
261
|
+
|
262
|
+
@config.command
|
263
|
+
@click.argument("key")
|
264
|
+
@click.argument("value")
|
265
|
+
def update(key, value, **kwargs):
|
266
|
+
"""Update a configuration value"""
|
267
|
+
|
268
|
+
from almanac import config, get_config_path, ConfigManager
|
269
|
+
from dataclasses import asdict, is_dataclass
|
270
|
+
|
271
|
+
def traverse(config, key, value, provenance=None, sep="."):
|
272
|
+
parent, *child = key.split(sep, 1)
|
273
|
+
|
274
|
+
try:
|
275
|
+
scope = getattr(config, parent)
|
276
|
+
except AttributeError:
|
277
|
+
context = sep.join(provenance or [])
|
278
|
+
if context:
|
279
|
+
context = f" within '{context}'"
|
280
|
+
|
281
|
+
if not isinstance(config, dict):
|
282
|
+
config = asdict(config)
|
283
|
+
|
284
|
+
raise click.ClickException(
|
285
|
+
f"No configuration key '{parent}'{context}. "
|
286
|
+
f"Available{context}: {', '.join(config.keys())}"
|
287
|
+
)
|
288
|
+
|
289
|
+
else:
|
290
|
+
|
291
|
+
if not child:
|
292
|
+
|
293
|
+
fields = {f.name: f.type for f in config.__dataclass_fields__.values()}
|
294
|
+
field_type = fields[parent]
|
295
|
+
if is_dataclass(field_type):
|
296
|
+
context = sep.join(provenance or [])
|
297
|
+
if context:
|
298
|
+
context = f" within '{context}'"
|
299
|
+
|
300
|
+
raise click.ClickException(
|
301
|
+
f"Key '{parent}'{context} refers to a configuration class. "
|
302
|
+
f"You must set the values of the configuration class individually. "
|
303
|
+
f"Sorry! "
|
304
|
+
f"Or you can directly edit the configuration file {get_config_path()}"
|
305
|
+
)
|
306
|
+
|
307
|
+
setattr(config, parent, value)
|
308
|
+
else:
|
309
|
+
provenance = (provenance or []) + [parent]
|
310
|
+
traverse(scope, child[0], value)
|
311
|
+
|
312
|
+
traverse(config, key, value)
|
313
|
+
config_path = get_config_path()
|
314
|
+
ConfigManager.save(config, config_path)
|
315
|
+
click.echo(f"Updated configuration {key} to {value} in {config_path}")
|
316
|
+
|
317
|
+
|
318
|
+
@main.group()
|
319
|
+
def dump(**kwargs):
|
320
|
+
"""Dump data to a summary file"""
|
321
|
+
pass
|
322
|
+
|
323
|
+
# almanac dump star[s] almanac.h5 output.fits
|
324
|
+
def check_paths_and_format(input_path, output_path, given_format, overwrite):
|
325
|
+
import os
|
326
|
+
import click
|
327
|
+
|
328
|
+
if not os.path.exists(input_path):
|
329
|
+
raise click.ClickException(f"Input path {input_path} does not exist")
|
330
|
+
|
331
|
+
if os.path.exists(output_path) and not overwrite:
|
332
|
+
raise click.ClickException(f"Output path {output_path} already exists. Use --overwrite to overwrite.")
|
333
|
+
|
334
|
+
if given_format is None:
|
335
|
+
if output_path.lower().endswith(".fits"):
|
336
|
+
return "fits"
|
337
|
+
elif output_path.lower().endswith(".csv"):
|
338
|
+
return "csv"
|
339
|
+
elif output_path.lower().endswith(".hdf5") or output_path.lower().endswith(".h5"):
|
340
|
+
return "hdf5"
|
341
|
+
else:
|
342
|
+
raise click.ClickException("Cannot infer output format from output path. Please specify --format")
|
343
|
+
return given_format
|
344
|
+
|
345
|
+
|
346
|
+
@dump.command()
|
347
|
+
@click.argument("input_path", type=str)
|
348
|
+
@click.argument("output_path", type=str)
|
349
|
+
@click.option("--format", "-f", default=None, type=click.Choice(["fits", "csv", "hdf5"]), help="Output format")
|
350
|
+
@click.option("--overwrite", is_flag=True, help="Overwrite existing output file")
|
351
|
+
def stars(input_path, output_path, overwrite, format, **kwargs):
|
352
|
+
"""Create a star-level summary file"""
|
353
|
+
|
354
|
+
import h5py as h5
|
355
|
+
from copy import deepcopy
|
356
|
+
from collections import Counter
|
357
|
+
|
358
|
+
stars = {}
|
359
|
+
default = dict(
|
360
|
+
mjds_apo=set(),
|
361
|
+
mjds_lco=set(),
|
362
|
+
n_visits=0,
|
363
|
+
n_visits_apo=0,
|
364
|
+
n_visits_lco=0,
|
365
|
+
n_exposures=0,
|
366
|
+
n_exposures_apo=0,
|
367
|
+
n_exposures_lco=0,
|
368
|
+
)
|
369
|
+
|
370
|
+
output_format = check_paths_and_format(input_path, output_path, format, overwrite)
|
371
|
+
assert format != "hdf5", "HDF5 output not yet supported for star summaries."
|
372
|
+
with h5.File(input_path, "r") as fp:
|
373
|
+
for observatory in fp:
|
374
|
+
for mjd in fp[f"{observatory}"]:
|
375
|
+
group = fp[f"{observatory}/{mjd}"]
|
376
|
+
|
377
|
+
is_object = (
|
378
|
+
(group["exposures/image_type"][:].astype(str) == "object")
|
379
|
+
)
|
380
|
+
fps = is_object * (group["exposures/config_id"][:] > 0)
|
381
|
+
plate = is_object * (group["exposures/plate_id"][:] > 0)
|
382
|
+
|
383
|
+
if not any(fps) and not any(plate) or "fibers" not in group:
|
384
|
+
continue
|
385
|
+
|
386
|
+
# fps era
|
387
|
+
n_exposures_on_this_mjd = {}
|
388
|
+
|
389
|
+
if any(fps):
|
390
|
+
config_ids = Counter(group["exposures/config_id"][:][fps])
|
391
|
+
elif any(plate):
|
392
|
+
config_ids = Counter(group["exposures/plate_id"][:][plate])
|
393
|
+
else:
|
394
|
+
continue
|
395
|
+
|
396
|
+
for config_id, n_exposures in config_ids.items():
|
397
|
+
try:
|
398
|
+
config_group = group[f"fibers/{config_id}"]
|
399
|
+
except KeyError:
|
400
|
+
print(f"Warning couldnt get config {config_id} for {observatory} {mjd}")
|
401
|
+
continue
|
402
|
+
|
403
|
+
ok = (
|
404
|
+
(
|
405
|
+
(config_group["catalogid"][:] > 0)
|
406
|
+
| (config_group["sdss_id"][:] > 0)
|
407
|
+
| (config_group["twomass_designation"][:].astype(str) != "")
|
408
|
+
)
|
409
|
+
* (
|
410
|
+
(config_group["category"][:].astype(str) == "science")
|
411
|
+
| (config_group["category"][:].astype(str) == "standard_apogee")
|
412
|
+
| (config_group["category"][:].astype(str) == "standard_boss")
|
413
|
+
| (config_group["category"][:].astype(str) == "open_fiber")
|
414
|
+
)
|
415
|
+
)
|
416
|
+
sdss_ids = config_group["sdss_id"][:][ok]
|
417
|
+
catalogids = config_group["catalogid"][:][ok]
|
418
|
+
for sdss_id, catalogid in zip(sdss_ids, catalogids):
|
419
|
+
stars.setdefault(sdss_id, deepcopy(default))
|
420
|
+
stars[sdss_id].setdefault("catalogid", catalogid) # this can change over time,... should we track that/
|
421
|
+
n_exposures_on_this_mjd.setdefault(sdss_id, 0)
|
422
|
+
n_exposures_on_this_mjd[sdss_id] += n_exposures
|
423
|
+
|
424
|
+
|
425
|
+
for sdss_id, n_exposures in n_exposures_on_this_mjd.items():
|
426
|
+
stars[sdss_id]["n_exposures"] += n_exposures
|
427
|
+
stars[sdss_id][f"n_exposures_{observatory}"] += n_exposures
|
428
|
+
stars[sdss_id]["n_visits"] += 1
|
429
|
+
stars[sdss_id][f"n_visits_{observatory}"] += 1
|
430
|
+
stars[sdss_id][f"mjds_{observatory}"].add(int(mjd))
|
431
|
+
|
432
|
+
rows = []
|
433
|
+
for sdss_id, meta in stars.items():
|
434
|
+
stars[sdss_id].update(
|
435
|
+
mjd_min_apo=min(meta["mjds_apo"]) if meta["mjds_apo"] else -1,
|
436
|
+
mjd_max_apo=max(meta["mjds_apo"]) if meta["mjds_apo"] else -1,
|
437
|
+
mjd_min_lco=min(meta["mjds_lco"]) if meta["mjds_lco"] else -1,
|
438
|
+
mjd_max_lco=max(meta["mjds_lco"]) if meta["mjds_lco"] else -1,
|
439
|
+
)
|
440
|
+
stars[sdss_id].pop("mjds_apo")
|
441
|
+
stars[sdss_id].pop("mjds_lco")
|
442
|
+
rows.append(dict(sdss_id=sdss_id, **meta))
|
443
|
+
|
444
|
+
from astropy.table import Table
|
445
|
+
t = Table(rows=rows)
|
446
|
+
t.write(output_path, format=output_format, overwrite=overwrite)
|
447
|
+
|
448
|
+
|
449
|
+
|
450
|
+
@dump.command()
|
451
|
+
@click.argument("input_path", type=str)
|
452
|
+
@click.argument("output_path", type=str)
|
453
|
+
@click.option("--format", "-f", default=None, type=click.Choice(["fits", "csv", "hdf5"]), help="Output format")
|
454
|
+
@click.option("--overwrite", is_flag=True, help="Overwrite existing output file")
|
455
|
+
def visits(input_path, output_path, format, overwrite, **kwargs):
|
456
|
+
"""Create a visit-level summary file"""
|
457
|
+
|
458
|
+
pass
|
459
|
+
|
460
|
+
|
461
|
+
|
462
|
+
@dump.command()
|
463
|
+
@click.argument("input_path", type=str)
|
464
|
+
@click.argument("output_path", type=str)
|
465
|
+
@click.option("--format", "-f", default=None, type=click.Choice(["fits", "csv", "hdf5"]), help="Output format")
|
466
|
+
@click.option("--overwrite", is_flag=True, help="Overwrite existing output file")
|
467
|
+
def exposures(input_path, output_path, format, overwrite, **kwargs):
|
468
|
+
"""Create an exposure-level summary file"""
|
469
|
+
|
470
|
+
import os
|
471
|
+
import h5py as h5
|
472
|
+
import numpy as np
|
473
|
+
|
474
|
+
output_format = check_paths_and_format(input_path, output_path, format, overwrite)
|
475
|
+
|
476
|
+
from almanac.data_models import Exposure
|
477
|
+
|
478
|
+
fields = { **Exposure.model_fields, **Exposure.model_computed_fields }
|
479
|
+
data = dict()
|
480
|
+
for field_name, field_spec in fields.items():
|
481
|
+
data[field_name] = []
|
482
|
+
|
483
|
+
with h5.File(input_path, "r") as fp:
|
484
|
+
for observatory in ("apo", "lco"):
|
485
|
+
for mjd in fp[observatory].keys():
|
486
|
+
group = fp[f"{observatory}/{mjd}/exposures"]
|
487
|
+
for key in group.keys():
|
488
|
+
data[key].extend(group[key][:])
|
489
|
+
|
490
|
+
if output_format == "hdf5":
|
491
|
+
from almanac.io import _write_models_to_hdf5_group
|
492
|
+
|
493
|
+
fields = { **Exposure.model_fields, **Exposure.model_computed_fields }
|
494
|
+
|
495
|
+
with h5.File(output_path, "w", track_order=True) as fp:
|
496
|
+
_write_models_to_hdf5_group(fields, data, fp)
|
497
|
+
else:
|
498
|
+
from astropy.table import Table
|
499
|
+
t = Table(data=data)
|
500
|
+
t.write(output_path, format=output_format, overwrite=overwrite)
|
501
|
+
|
502
|
+
|
503
|
+
@dump.command()
|
504
|
+
@click.argument("input_path", type=str)
|
505
|
+
@click.argument("output_path", type=str)
|
506
|
+
@click.option("--format", "-f", default=None, type=click.Choice(["fits", "csv", "hdf5"]), help="Output format")
|
507
|
+
@click.option("--overwrite", is_flag=True, help="Overwrite existing output file")
|
508
|
+
def fibers(input_path, output_path, format, overwrite, **kwargs):
|
509
|
+
"""Create a fiber-level summary file"""
|
510
|
+
|
511
|
+
import os
|
512
|
+
import h5py as h5
|
513
|
+
import numpy as np
|
514
|
+
|
515
|
+
output_format = check_paths_and_format(input_path, output_path, format, overwrite)
|
516
|
+
|
517
|
+
from almanac.data_models.fps import FPSTarget
|
518
|
+
from almanac.data_models.plate import PlateTarget
|
519
|
+
|
520
|
+
fields = { **FPSTarget.model_fields, **FPSTarget.model_computed_fields,
|
521
|
+
**PlateTarget.model_fields, **PlateTarget.model_computed_fields }
|
522
|
+
|
523
|
+
defaults = { name: spec.default for name, spec in fields.items() if hasattr(spec, "default") }
|
524
|
+
defaults["twomass_designation"] = ""
|
525
|
+
|
526
|
+
data = dict()
|
527
|
+
for field_name, field_spec in fields.items():
|
528
|
+
data[field_name] = []
|
529
|
+
|
530
|
+
with h5.File(input_path, "r") as fp:
|
531
|
+
for observatory in ("apo", "lco"):
|
532
|
+
for mjd in fp[observatory].keys():
|
533
|
+
group = fp[f"{observatory}/{mjd}/fibers"]
|
534
|
+
for config_id in group.keys():
|
535
|
+
group = fp[f"{observatory}/{mjd}/fibers/{config_id}"]
|
536
|
+
n = len(group["sdss_id"][:])
|
537
|
+
|
538
|
+
for field_name in data:
|
539
|
+
if field_name in group.keys():
|
540
|
+
data[field_name].extend(group[field_name][:])
|
541
|
+
else:
|
542
|
+
data[field_name].extend([defaults[field_name]] * n)
|
543
|
+
|
544
|
+
if output_format == "hdf5":
|
545
|
+
from almanac.io import _write_models_to_hdf5_group
|
546
|
+
|
547
|
+
with h5.File(output_path, "w", track_order=True) as fp:
|
548
|
+
_write_models_to_hdf5_group(fields, data, fp)
|
549
|
+
else:
|
550
|
+
from astropy.table import Table
|
551
|
+
t = Table(data=data)
|
552
|
+
t.write(output_path, format=output_format, overwrite=overwrite)
|
553
|
+
|
554
|
+
|
555
|
+
if __name__ == "__main__":
|
556
|
+
main()
|
almanac/config.py
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
import os
|
2
|
+
import yaml
|
3
|
+
from typing import List, Dict
|
4
|
+
from dataclasses import dataclass, field, is_dataclass, asdict
|
5
|
+
from pathlib import Path
|
6
|
+
|
7
|
+
|
8
|
+
@dataclass
|
9
|
+
class DatabaseConfig:
|
10
|
+
user: str = "sdss_user"
|
11
|
+
host: str = "operations.sdss.org"
|
12
|
+
port: int = 5432
|
13
|
+
domain: str = "operations.sdss.*"
|
14
|
+
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class ObservatoryMJD:
|
18
|
+
apo: int = 59_558
|
19
|
+
lco: int = 59_558
|
20
|
+
|
21
|
+
|
22
|
+
@dataclass
|
23
|
+
class Config:
|
24
|
+
sdssdb: DatabaseConfig = field(default_factory=DatabaseConfig)
|
25
|
+
database_connect_time_warning: int = 3 # seconds
|
26
|
+
|
27
|
+
sdssdb_exposure_min_mjd: ObservatoryMJD = field(default_factory=ObservatoryMJD)
|
28
|
+
logging_level: int = 20 # logging.INFO
|
29
|
+
|
30
|
+
# Paths
|
31
|
+
platelist_dir: str = "/uufs/chpc.utah.edu/common/home/sdss09/software/svn.sdss.org/data/sdss/platelist/trunk/plates/"
|
32
|
+
sdsscore_dir: str = "/uufs/chpc.utah.edu/common/home/sdss50/software/git/sdss/sdsscore/main/"
|
33
|
+
apogee_dir: str = "/uufs/chpc.utah.edu/common/home/sdss/sdsswork/data/apogee/"
|
34
|
+
mapper_dir: str = "/uufs/chpc.utah.edu/common/home/sdss50/sdsswork/data/mapper/"
|
35
|
+
|
36
|
+
|
37
|
+
display_field_names: List[str] = field(
|
38
|
+
default_factory=lambda: [
|
39
|
+
"exposure",
|
40
|
+
"image_type",
|
41
|
+
"n_read",
|
42
|
+
"field_id",
|
43
|
+
"plate_id",
|
44
|
+
"config_id",
|
45
|
+
"design_id",
|
46
|
+
"dithered_pixels",
|
47
|
+
"lamp_quartz",
|
48
|
+
"lamp_thar",
|
49
|
+
"lamp_une",
|
50
|
+
"name",
|
51
|
+
"observer_comment"
|
52
|
+
]
|
53
|
+
)
|
54
|
+
|
55
|
+
def get_config_path():
|
56
|
+
config_dir = Path.home() / ".almanac"
|
57
|
+
config_dir.mkdir(exist_ok=True)
|
58
|
+
return config_dir / "config.yaml"
|
59
|
+
|
60
|
+
|
61
|
+
class ConfigManager:
|
62
|
+
"""A utility class to save and load dataclass configurations using YAML."""
|
63
|
+
|
64
|
+
@staticmethod
|
65
|
+
def save(config: object, file_path: str):
|
66
|
+
"""Saves a dataclass object to a YAML file."""
|
67
|
+
if not is_dataclass(config):
|
68
|
+
raise TypeError("Provided object is not a dataclass.")
|
69
|
+
|
70
|
+
data = asdict(config)
|
71
|
+
with open(file_path, "w") as f:
|
72
|
+
yaml.dump(data, f, sort_keys=False)
|
73
|
+
|
74
|
+
@staticmethod
|
75
|
+
def load(cls, file_path: str):
|
76
|
+
"""Loads a dataclass object from a YAML file."""
|
77
|
+
if not is_dataclass(cls):
|
78
|
+
raise TypeError("Provided class is not a dataclass.")
|
79
|
+
|
80
|
+
with open(file_path, "r") as f:
|
81
|
+
data = yaml.safe_load(f)
|
82
|
+
|
83
|
+
# Recursively create nested dataclasses from the dictionary
|
84
|
+
def _load_recursive(cls, data):
|
85
|
+
if not is_dataclass(cls):
|
86
|
+
return data
|
87
|
+
|
88
|
+
fields = {f.name: f.type for f in cls.__dataclass_fields__.values()}
|
89
|
+
kwargs = {}
|
90
|
+
for key, value in data.items():
|
91
|
+
field_type = fields.get(key)
|
92
|
+
if is_dataclass(field_type):
|
93
|
+
kwargs[key] = _load_recursive(field_type, value)
|
94
|
+
else:
|
95
|
+
kwargs[key] = value
|
96
|
+
return cls(**kwargs)
|
97
|
+
|
98
|
+
if data:
|
99
|
+
config = _load_recursive(cls, data)
|
100
|
+
else:
|
101
|
+
config = cls()
|
102
|
+
return config
|
103
|
+
|
104
|
+
|
105
|
+
config_path = get_config_path()
|
106
|
+
if not os.path.exists(config_path):
|
107
|
+
config = Config()
|
108
|
+
ConfigManager.save(config, config_path)
|
109
|
+
else:
|
110
|
+
config = ConfigManager.load(Config, config_path)
|