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/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)
@@ -0,0 +1,3 @@
1
+ from .exposure import Exposure
2
+ from .fps import FPSTarget
3
+ from .plate import PlateTarget