pyreduce-astro 0.6.0__cp312-cp312-macosx_11_0_arm64.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.
Files changed (154) hide show
  1. pyreduce/__init__.py +67 -0
  2. pyreduce/__main__.py +106 -0
  3. pyreduce/clib/__init__.py +0 -0
  4. pyreduce/clib/_slitfunc_2d.cpython-311-darwin.so +0 -0
  5. pyreduce/clib/_slitfunc_2d.cpython-312-darwin.so +0 -0
  6. pyreduce/clib/_slitfunc_bd.cpython-311-darwin.so +0 -0
  7. pyreduce/clib/_slitfunc_bd.cpython-312-darwin.so +0 -0
  8. pyreduce/clib/build_extract.py +75 -0
  9. pyreduce/clib/slit_func_2d_xi_zeta_bd.c +1313 -0
  10. pyreduce/clib/slit_func_2d_xi_zeta_bd.h +55 -0
  11. pyreduce/clib/slit_func_bd.c +362 -0
  12. pyreduce/clib/slit_func_bd.h +17 -0
  13. pyreduce/clipnflip.py +147 -0
  14. pyreduce/combine_frames.py +855 -0
  15. pyreduce/configuration.py +186 -0
  16. pyreduce/continuum_normalization.py +329 -0
  17. pyreduce/cwrappers.py +404 -0
  18. pyreduce/datasets.py +231 -0
  19. pyreduce/echelle.py +413 -0
  20. pyreduce/estimate_background_scatter.py +129 -0
  21. pyreduce/extract.py +1361 -0
  22. pyreduce/extraction_width.py +77 -0
  23. pyreduce/instruments/__init__.py +0 -0
  24. pyreduce/instruments/andes.json +61 -0
  25. pyreduce/instruments/andes.py +102 -0
  26. pyreduce/instruments/common.json +46 -0
  27. pyreduce/instruments/common.py +683 -0
  28. pyreduce/instruments/crires_plus.json +63 -0
  29. pyreduce/instruments/crires_plus.py +103 -0
  30. pyreduce/instruments/filters.py +195 -0
  31. pyreduce/instruments/harpn.json +136 -0
  32. pyreduce/instruments/harpn.py +201 -0
  33. pyreduce/instruments/harps.json +155 -0
  34. pyreduce/instruments/harps.py +310 -0
  35. pyreduce/instruments/instrument_info.py +140 -0
  36. pyreduce/instruments/instrument_schema.json +318 -0
  37. pyreduce/instruments/jwst_miri.json +53 -0
  38. pyreduce/instruments/jwst_miri.py +29 -0
  39. pyreduce/instruments/jwst_niriss.json +52 -0
  40. pyreduce/instruments/jwst_niriss.py +98 -0
  41. pyreduce/instruments/lick_apf.json +53 -0
  42. pyreduce/instruments/lick_apf.py +35 -0
  43. pyreduce/instruments/mcdonald.json +59 -0
  44. pyreduce/instruments/mcdonald.py +123 -0
  45. pyreduce/instruments/metis_ifu.json +63 -0
  46. pyreduce/instruments/metis_ifu.py +45 -0
  47. pyreduce/instruments/metis_lss.json +65 -0
  48. pyreduce/instruments/metis_lss.py +45 -0
  49. pyreduce/instruments/micado.json +53 -0
  50. pyreduce/instruments/micado.py +45 -0
  51. pyreduce/instruments/neid.json +51 -0
  52. pyreduce/instruments/neid.py +154 -0
  53. pyreduce/instruments/nirspec.json +56 -0
  54. pyreduce/instruments/nirspec.py +215 -0
  55. pyreduce/instruments/nte.json +47 -0
  56. pyreduce/instruments/nte.py +42 -0
  57. pyreduce/instruments/uves.json +59 -0
  58. pyreduce/instruments/uves.py +46 -0
  59. pyreduce/instruments/xshooter.json +66 -0
  60. pyreduce/instruments/xshooter.py +39 -0
  61. pyreduce/make_shear.py +606 -0
  62. pyreduce/masks/mask_crires_plus_det1.fits.gz +0 -0
  63. pyreduce/masks/mask_crires_plus_det2.fits.gz +0 -0
  64. pyreduce/masks/mask_crires_plus_det3.fits.gz +0 -0
  65. pyreduce/masks/mask_ctio_chiron.fits.gz +0 -0
  66. pyreduce/masks/mask_elodie.fits.gz +0 -0
  67. pyreduce/masks/mask_feros3.fits.gz +0 -0
  68. pyreduce/masks/mask_flames_giraffe.fits.gz +0 -0
  69. pyreduce/masks/mask_harps_blue.fits.gz +0 -0
  70. pyreduce/masks/mask_harps_red.fits.gz +0 -0
  71. pyreduce/masks/mask_hds_blue.fits.gz +0 -0
  72. pyreduce/masks/mask_hds_red.fits.gz +0 -0
  73. pyreduce/masks/mask_het_hrs_2x5.fits.gz +0 -0
  74. pyreduce/masks/mask_jwst_miri_lrs_slitless.fits.gz +0 -0
  75. pyreduce/masks/mask_jwst_niriss_gr700xd.fits.gz +0 -0
  76. pyreduce/masks/mask_lick_apf_.fits.gz +0 -0
  77. pyreduce/masks/mask_mcdonald.fits.gz +0 -0
  78. pyreduce/masks/mask_nes.fits.gz +0 -0
  79. pyreduce/masks/mask_nirspec_nirspec.fits.gz +0 -0
  80. pyreduce/masks/mask_sarg.fits.gz +0 -0
  81. pyreduce/masks/mask_sarg_2x2a.fits.gz +0 -0
  82. pyreduce/masks/mask_sarg_2x2b.fits.gz +0 -0
  83. pyreduce/masks/mask_subaru_hds_red.fits.gz +0 -0
  84. pyreduce/masks/mask_uves_blue.fits.gz +0 -0
  85. pyreduce/masks/mask_uves_blue_binned_2_2.fits.gz +0 -0
  86. pyreduce/masks/mask_uves_middle.fits.gz +0 -0
  87. pyreduce/masks/mask_uves_middle_2x2_split.fits.gz +0 -0
  88. pyreduce/masks/mask_uves_middle_binned_2_2.fits.gz +0 -0
  89. pyreduce/masks/mask_uves_red.fits.gz +0 -0
  90. pyreduce/masks/mask_uves_red_2x2.fits.gz +0 -0
  91. pyreduce/masks/mask_uves_red_2x2_split.fits.gz +0 -0
  92. pyreduce/masks/mask_uves_red_binned_2_2.fits.gz +0 -0
  93. pyreduce/masks/mask_xshooter_nir.fits.gz +0 -0
  94. pyreduce/rectify.py +138 -0
  95. pyreduce/reduce.py +2205 -0
  96. pyreduce/settings/settings_ANDES.json +89 -0
  97. pyreduce/settings/settings_CRIRES_PLUS.json +89 -0
  98. pyreduce/settings/settings_HARPN.json +73 -0
  99. pyreduce/settings/settings_HARPS.json +69 -0
  100. pyreduce/settings/settings_JWST_MIRI.json +55 -0
  101. pyreduce/settings/settings_JWST_NIRISS.json +55 -0
  102. pyreduce/settings/settings_LICK_APF.json +62 -0
  103. pyreduce/settings/settings_MCDONALD.json +58 -0
  104. pyreduce/settings/settings_METIS_IFU.json +77 -0
  105. pyreduce/settings/settings_METIS_LSS.json +77 -0
  106. pyreduce/settings/settings_MICADO.json +78 -0
  107. pyreduce/settings/settings_NEID.json +73 -0
  108. pyreduce/settings/settings_NIRSPEC.json +58 -0
  109. pyreduce/settings/settings_NTE.json +60 -0
  110. pyreduce/settings/settings_UVES.json +54 -0
  111. pyreduce/settings/settings_XSHOOTER.json +78 -0
  112. pyreduce/settings/settings_pyreduce.json +178 -0
  113. pyreduce/settings/settings_schema.json +827 -0
  114. pyreduce/tools/__init__.py +0 -0
  115. pyreduce/tools/combine.py +117 -0
  116. pyreduce/trace_orders.py +645 -0
  117. pyreduce/util.py +1288 -0
  118. pyreduce/wavecal/MICADO_HK_3arcsec_chip5.npz +0 -0
  119. pyreduce/wavecal/atlas/thar.fits +4946 -13
  120. pyreduce/wavecal/atlas/thar_list.txt +4172 -0
  121. pyreduce/wavecal/atlas/une.fits +0 -0
  122. pyreduce/wavecal/convert.py +38 -0
  123. pyreduce/wavecal/crires_plus_J1228_Open_det1.npz +0 -0
  124. pyreduce/wavecal/crires_plus_J1228_Open_det2.npz +0 -0
  125. pyreduce/wavecal/crires_plus_J1228_Open_det3.npz +0 -0
  126. pyreduce/wavecal/harpn_harpn_2D.npz +0 -0
  127. pyreduce/wavecal/harps_blue_2D.npz +0 -0
  128. pyreduce/wavecal/harps_blue_pol_2D.npz +0 -0
  129. pyreduce/wavecal/harps_red_2D.npz +0 -0
  130. pyreduce/wavecal/harps_red_pol_2D.npz +0 -0
  131. pyreduce/wavecal/mcdonald.npz +0 -0
  132. pyreduce/wavecal/metis_lss_l_2D.npz +0 -0
  133. pyreduce/wavecal/metis_lss_m_2D.npz +0 -0
  134. pyreduce/wavecal/nirspec_K2.npz +0 -0
  135. pyreduce/wavecal/uves_blue_360nm_2D.npz +0 -0
  136. pyreduce/wavecal/uves_blue_390nm_2D.npz +0 -0
  137. pyreduce/wavecal/uves_blue_437nm_2D.npz +0 -0
  138. pyreduce/wavecal/uves_middle_2x2_2D.npz +0 -0
  139. pyreduce/wavecal/uves_middle_565nm_2D.npz +0 -0
  140. pyreduce/wavecal/uves_middle_580nm_2D.npz +0 -0
  141. pyreduce/wavecal/uves_middle_600nm_2D.npz +0 -0
  142. pyreduce/wavecal/uves_middle_665nm_2D.npz +0 -0
  143. pyreduce/wavecal/uves_middle_860nm_2D.npz +0 -0
  144. pyreduce/wavecal/uves_red_580nm_2D.npz +0 -0
  145. pyreduce/wavecal/uves_red_600nm_2D.npz +0 -0
  146. pyreduce/wavecal/uves_red_665nm_2D.npz +0 -0
  147. pyreduce/wavecal/uves_red_760nm_2D.npz +0 -0
  148. pyreduce/wavecal/uves_red_860nm_2D.npz +0 -0
  149. pyreduce/wavecal/xshooter_nir.npz +0 -0
  150. pyreduce/wavelength_calibration.py +1873 -0
  151. pyreduce_astro-0.6.0.dist-info/METADATA +114 -0
  152. pyreduce_astro-0.6.0.dist-info/RECORD +154 -0
  153. pyreduce_astro-0.6.0.dist-info/WHEEL +6 -0
  154. pyreduce_astro-0.6.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,683 @@
1
+ """
2
+ Abstract parent module for all other instruments
3
+ Contains some general functionality, which may be overridden by the children of course
4
+ """
5
+
6
+ import datetime
7
+ import glob
8
+ import json
9
+ import logging
10
+ import os.path
11
+ from itertools import product
12
+
13
+ import numpy as np
14
+ from astropy.io import fits
15
+ from astropy.time import Time
16
+ from dateutil import parser
17
+ from tqdm import tqdm
18
+
19
+ from ..clipnflip import clipnflip
20
+ from .filters import Filter, InstrumentFilter, ModeFilter, NightFilter, ObjectFilter
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def find_first_index(arr, value):
26
+ """find the first element equal to value in the array arr"""
27
+ try:
28
+ return next(i for i, v in enumerate(arr) if v == value)
29
+ except StopIteration as e:
30
+ raise KeyError(f"Value {value} not found") from e
31
+
32
+
33
+ def observation_date_to_night(observation_date):
34
+ """Convert an observation timestamp into the date of the observation night
35
+ Nights start at 12am and end at 12 am the next day
36
+
37
+ Parameters
38
+ ----------
39
+ observation_date : datetime
40
+ timestamp of the observation
41
+
42
+ Returns
43
+ -------
44
+ night : datetime.date
45
+ night of the observation
46
+ """
47
+ if observation_date == "":
48
+ return None
49
+
50
+ observation_date = parser.parse(observation_date)
51
+ oneday = datetime.timedelta(days=1)
52
+
53
+ if observation_date.hour < 12:
54
+ observation_date -= oneday
55
+ return observation_date.date()
56
+
57
+
58
+ class getter:
59
+ """Get data from a header/dict, based on the given mode, and applies replacements"""
60
+
61
+ def __init__(self, header, info, mode):
62
+ self.header = header
63
+ self.info = info.copy()
64
+ try:
65
+ self.index = find_first_index(info["modes"], mode.upper())
66
+ except KeyError:
67
+ logger.warning("No instrument modes found in instrument info")
68
+ self.index = 0
69
+
70
+ # Pick values for the given mode
71
+ for k, v in self.info.items():
72
+ if isinstance(v, list):
73
+ self.info[k] = v[self.index]
74
+
75
+ def __call__(self, key, alt=None):
76
+ return self.get(key, alt)
77
+
78
+ def get(self, key, alt=None):
79
+ """Get data
80
+
81
+ Parameters
82
+ ----------
83
+ key : str
84
+ key of the data in the header
85
+ alt : obj, optional
86
+ alternative value, if key does not exist (default: None)
87
+
88
+ Returns
89
+ -------
90
+ value : obj
91
+ value found in header (or alternatively alt)
92
+ """
93
+
94
+ value = self.info.get(key, key)
95
+ # if isinstance(value, list):
96
+ # value = value[self.index]
97
+ if isinstance(value, str):
98
+ value = value.format(**self.info)
99
+ value = self.header.get(value, alt)
100
+ return value
101
+
102
+
103
+ class Instrument:
104
+ """
105
+ Abstract parent class for all instruments
106
+ Handles the instrument specific information
107
+ """
108
+
109
+ def __init__(self):
110
+ #:str: Name of the instrument (lowercase)
111
+ self.name = self.__class__.__name__.lower()
112
+ #:dict: Information about the instrument
113
+ self.info = self.load_info()
114
+
115
+ self.filters = {
116
+ "instrument": InstrumentFilter(self.info["instrument"], regex=True),
117
+ "night": NightFilter(
118
+ self.info["date"], timeformat=self.info.get("date_format", "fits")
119
+ ),
120
+ "target": ObjectFilter(self.info["target"], regex=True),
121
+ "bias": Filter(self.info["kw_bias"]),
122
+ "flat": Filter(self.info["kw_flat"]),
123
+ "orders": Filter(self.info["kw_orders"]),
124
+ "curvature": Filter(self.info["kw_curvature"]),
125
+ "scatter": Filter(self.info["kw_scatter"]),
126
+ "wave": Filter(self.info["kw_wave"]),
127
+ "comb": Filter(self.info["kw_comb"]),
128
+ "spec": Filter(self.info["kw_spec"]),
129
+ }
130
+
131
+ self.night = "night"
132
+ self.science = "science"
133
+ self.shared = ["instrument", "night"]
134
+ self.find_closest = [
135
+ "bias",
136
+ "flat",
137
+ "wavecal_master",
138
+ "freq_comb_master",
139
+ "orders",
140
+ "scatter",
141
+ "curvature",
142
+ ]
143
+
144
+ def __str__(self):
145
+ return self.name
146
+
147
+ def get(self, key, header, mode, alt=None):
148
+ get = getter(header, self.info, mode)
149
+ return get(key, alt=alt)
150
+
151
+ def get_extension(self, header, mode):
152
+ mode = mode.upper()
153
+ extension = self.info.get("extension", 0)
154
+
155
+ if isinstance(extension, list):
156
+ imode = find_first_index(self.info["modes"], mode)
157
+ extension = extension[imode]
158
+
159
+ return extension
160
+
161
+ def load_info(self):
162
+ """
163
+ Load static instrument information
164
+ Either as fits header keywords or static values
165
+
166
+ Returns
167
+ ------
168
+ info : dict(str:object)
169
+ dictionary of REDUCE names for properties to Header keywords/static values
170
+ """
171
+ # Tips & Tricks:
172
+ # if several modes are supported, use a list for modes
173
+ # if a value changes depending on the mode, use a list with the same order as "modes"
174
+ # you can also use values from this dictionary as placeholders using {name}, just like str.format
175
+
176
+ this = os.path.dirname(__file__)
177
+ fname = f"{self.name}.json"
178
+ fname = os.path.join(this, fname)
179
+ with open(fname) as f:
180
+ info = json.load(f)
181
+ return info
182
+
183
+ def load_fits(
184
+ self, fname, mode, extension=None, mask=None, header_only=False, dtype=None
185
+ ):
186
+ """
187
+ load fits file, REDUCE style
188
+
189
+ primary and extension header are combined
190
+ modeinfo is applied to header
191
+ data is clipnflipped
192
+ mask is applied
193
+
194
+ Parameters
195
+ ----------
196
+ fname : str
197
+ filename
198
+ instrument : str
199
+ name of the instrument
200
+ mode : str
201
+ instrument mode
202
+ extension : int
203
+ data extension of the FITS file to load
204
+ mask : array, optional
205
+ mask to add to the data
206
+ header_only : bool, optional
207
+ only load the header, not the data
208
+ dtype : str, optional
209
+ numpy datatype to convert the read data to
210
+
211
+ Returns
212
+ --------
213
+ data : masked_array
214
+ FITS data, clipped and flipped, and with mask
215
+ header : fits.header
216
+ FITS header (Primary and Extension + Modeinfo)
217
+
218
+ ONLY the header is returned if header_only is True
219
+ """
220
+
221
+ mode = mode.upper()
222
+
223
+ hdu = fits.open(fname)
224
+ h_prime = hdu[0].header
225
+ if extension is None:
226
+ extension = self.get_extension(h_prime, mode)
227
+
228
+ header = hdu[extension].header
229
+ if extension != 0:
230
+ header.extend(h_prime, strip=False)
231
+ header = self.add_header_info(header, mode)
232
+ header["e_input"] = (os.path.basename(fname), "Original input filename")
233
+
234
+ if header_only:
235
+ hdu.close()
236
+ return header
237
+
238
+ data = clipnflip(hdu[extension].data, header)
239
+
240
+ if dtype is not None:
241
+ data = data.astype(dtype)
242
+
243
+ data = np.ma.masked_array(data, mask=mask)
244
+
245
+ hdu.close()
246
+ return data, header
247
+
248
+ def add_header_info(self, header, mode, **kwargs):
249
+ """read data from header and add it as REDUCE keyword back to the header
250
+
251
+ Parameters
252
+ ----------
253
+ header : fits.header, dict
254
+ header to read/write info from/to
255
+ mode : str
256
+ instrument mode
257
+
258
+ Returns
259
+ -------
260
+ header : fits.header, dict
261
+ header with added information
262
+ """
263
+
264
+ info = self.info
265
+ get = getter(header, info, mode)
266
+
267
+ # Use HIERARCH prefix only for FITS Header objects to avoid warnings
268
+ # For dict objects, HIERARCH is not needed and would break key access
269
+ from astropy.io.fits import Header as FitsHeader
270
+
271
+ hierarch = "HIERARCH " if isinstance(header, FitsHeader) else ""
272
+
273
+ header[f"{hierarch}e_instrument"] = get("instrument", self.__class__.__name__)
274
+ header[f"{hierarch}e_telescope"] = get("telescope", "")
275
+ header[f"{hierarch}e_exptime"] = get("exposure_time", 0)
276
+
277
+ jd = get("date")
278
+ if jd is not None:
279
+ jd = Time(jd, format=self.info.get("date_format", "fits"))
280
+ jd = jd.to_value("mjd")
281
+
282
+ header["e_orient"] = get("orientation", 0)
283
+ # As per IDL rotate if orient is 4 or larger and transpose is undefined
284
+ # the image is transposed
285
+ header[f"{hierarch}e_transpose"] = get(
286
+ "transpose", (header["e_orient"] % 8 >= 4)
287
+ )
288
+
289
+ naxis_x = get("naxis_x", 0)
290
+ naxis_y = get("naxis_y", 0)
291
+
292
+ prescan_x = get("prescan_x", 0)
293
+ overscan_x = get("overscan_x", 0)
294
+ prescan_y = get("prescan_y", 0)
295
+ overscan_y = get("overscan_y", 0)
296
+
297
+ header["e_xlo"] = prescan_x
298
+ header["e_xhi"] = naxis_x - overscan_x
299
+
300
+ header["e_ylo"] = prescan_y
301
+ header["e_yhi"] = naxis_y - overscan_y
302
+
303
+ header["e_gain"] = get("gain", 1)
304
+ header["e_readn"] = get("readnoise", 0)
305
+
306
+ header["e_sky"] = get("sky", 0)
307
+ header["e_drk"] = get("dark", 0)
308
+ header["e_backg"] = header["e_gain"] * (header["e_drk"] + header["e_sky"])
309
+
310
+ header["e_imtype"] = get("image_type")
311
+ header["e_ctg"] = get("category")
312
+
313
+ header["e_ra"] = get("ra", 0)
314
+ header["e_dec"] = get("dec", 0)
315
+ header["e_jd"] = jd
316
+
317
+ header["e_obslon"] = get("longitude")
318
+ header["e_obslat"] = get("latitude")
319
+ header["e_obsalt"] = get("altitude")
320
+
321
+ if info.get("wavecal_element", None) is not None:
322
+ header["HIERARCH e_wavecal_element"] = get(
323
+ "wavecal_element", info.get("wavecal_element", None)
324
+ )
325
+ return header
326
+
327
+ def find_files(self, input_dir):
328
+ """Find fits files in the given folder
329
+
330
+ Parameters
331
+ ----------
332
+ input_dir : string
333
+ directory to look for fits and fits.gz files in, may include bash style wildcards
334
+
335
+ Returns
336
+ -------
337
+ files: array(string)
338
+ absolute path filenames
339
+ """
340
+ files = glob.glob(input_dir + "/*.fits")
341
+ files += glob.glob(input_dir + "/*.fits.gz")
342
+ files = np.array(files)
343
+ return files
344
+
345
+ def get_expected_values(self, target, night, *args, **kwargs):
346
+ expectations = {
347
+ "bias": {
348
+ "instrument": self.info["id_instrument"],
349
+ "night": night,
350
+ "bias": self.info["id_bias"],
351
+ },
352
+ "flat": {
353
+ "instrument": self.info["id_instrument"],
354
+ "night": night,
355
+ "flat": self.info["id_flat"],
356
+ },
357
+ "orders": {
358
+ "instrument": self.info["id_instrument"],
359
+ "night": night,
360
+ "orders": self.info["id_orders"],
361
+ },
362
+ "scatter": {
363
+ "instrument": self.info["id_instrument"],
364
+ "night": night,
365
+ "scatter": self.info["id_scatter"],
366
+ },
367
+ "curvature": {
368
+ "instrument": self.info["id_instrument"],
369
+ "night": night,
370
+ "curvature": self.info["id_curvature"],
371
+ },
372
+ "wavecal_master": {
373
+ "instrument": self.info["id_instrument"],
374
+ "night": night,
375
+ "wave": self.info["id_wave"],
376
+ },
377
+ "freq_comb_master": {
378
+ "instrument": self.info["id_instrument"],
379
+ "night": night,
380
+ "comb": self.info["id_comb"],
381
+ },
382
+ "science": {
383
+ "instrument": self.info["id_instrument"],
384
+ "night": night,
385
+ "target": target,
386
+ "spec": self.info["id_spec"],
387
+ },
388
+ }
389
+ return expectations
390
+
391
+ def populate_filters(self, files):
392
+ """Extract values from the fits headers and store them in self.filters
393
+
394
+ Parameters
395
+ ----------
396
+ files : list(str)
397
+ list of fits files
398
+
399
+ Returns
400
+ -------
401
+ filters: list(Filter)
402
+ list of populated filters (identical to self.filters)
403
+ """
404
+ # Empty filters
405
+ for _, fil in self.filters.items():
406
+ fil.clear()
407
+
408
+ for f in tqdm(files):
409
+ with fits.open(f) as hdu:
410
+ h = hdu[0].header
411
+ for _, fil in self.filters.items():
412
+ fil.collect(h)
413
+
414
+ return self.filters
415
+
416
+ def apply_filters(self, files, expected, allow_calibration_only=False):
417
+ """
418
+ Determine the relevant files for a given set of expected values.
419
+
420
+ Parameters
421
+ ----------
422
+ files : list(files)
423
+ list if fits files
424
+ expected : dict
425
+ dictionary with expected header values for each reduction step
426
+
427
+ Returns
428
+ -------
429
+ files: list((dict, dict))
430
+ list of files. The first element of each tuple is the used setting,
431
+ and the second are the files for each step.
432
+ """
433
+
434
+ # Fill the filters with header information
435
+ self.populate_filters(files)
436
+
437
+ # Use the header information determined in populate filters
438
+ # to find potential science and calibration files in the list of files
439
+ # result = {step : [ {setting : value}, [files] ] }
440
+ result = {}
441
+ for step, values in expected.items():
442
+ result[step] = []
443
+ data = {}
444
+ for name, value in values.items():
445
+ if isinstance(value, list):
446
+ for v in value:
447
+ data[name] = self.filters[name].classify(v)
448
+ if len(data[name]) > 0:
449
+ break
450
+ else:
451
+ data[name] = self.filters[name].classify(value)
452
+ # Get all combinations of possible filter values
453
+ # e.g. if several nights are allowed
454
+ for thingy in product(*data.values()):
455
+ mask = np.copy(thingy[0][1])
456
+ for i in range(1, len(thingy)):
457
+ mask &= thingy[i][1]
458
+ if np.count_nonzero(mask) == 0:
459
+ continue
460
+ d = {k: v[0] for k, v in zip(values.keys(), thingy, strict=False)}
461
+ f = files[mask]
462
+ result[step].append((d, f))
463
+
464
+ # Filter for only nights that have a science observation
465
+ # files = [{setting: value}, {step: files}]
466
+ files = []
467
+ if allow_calibration_only:
468
+ # Use all unique nights
469
+ settings = {}
470
+ for shared in self.shared:
471
+ keys = [k for k in set(self.filters[shared].data) if k is not None]
472
+ settings[shared] = keys
473
+ else:
474
+ # Or use only science nights
475
+ settings = {}
476
+ for shared in self.shared:
477
+ keys = [key[shared] for key, _ in result[self.science]]
478
+ settings[shared] = keys
479
+
480
+ values = [settings[k] for k in self.shared]
481
+ for setting in product(*values):
482
+ setting = dict(zip(self.shared, setting, strict=False))
483
+ night = setting[self.night]
484
+ f = {}
485
+ # For each step look for files with matching settings
486
+ for step, step_data in result.items():
487
+ f[step] = []
488
+ for step_key, step_files in step_data:
489
+ match = [
490
+ setting[shared] == step_key[shared]
491
+ for shared in self.shared
492
+ if shared in step_key.keys()
493
+ ]
494
+ if all(match):
495
+ f[step] = step_files
496
+ break
497
+ # If no matching files are found ...
498
+ if len(f[step]) == 0:
499
+ if step not in self.find_closest:
500
+ # Show a warning
501
+ logger.warning(
502
+ "Could not find any files for step '%s' with settings %s, sharing parameters %s",
503
+ step,
504
+ setting,
505
+ self.shared,
506
+ )
507
+ else:
508
+ # Or find the closest night instead
509
+ j = None
510
+ for i, (step_key, _) in enumerate(step_data):
511
+ match = [
512
+ setting[shared] == step_key[shared]
513
+ for shared in self.shared
514
+ if shared in step_key.keys() and shared != self.night
515
+ ]
516
+ if all(match):
517
+ if j is None:
518
+ j = i
519
+ else:
520
+ diff_old = abs(step_data[j][0][self.night] - night)
521
+ diff_new = abs(step_data[i][0][self.night] - night)
522
+ if diff_new < diff_old:
523
+ j = i
524
+ if j is None:
525
+ # We still dont find any files
526
+ logger.warning(
527
+ "Could not find any files for step '%s' in any night with settings %s, sharing parameters %s",
528
+ step,
529
+ setting,
530
+ self.shared,
531
+ )
532
+ else:
533
+ # We found files in a close night
534
+ closest_key, closest_files = step_data[j]
535
+ logger.warning(
536
+ "Using '%s' files from night %s for observations of night %s",
537
+ step,
538
+ night,
539
+ closest_key["night"],
540
+ )
541
+ f[step] = closest_files
542
+
543
+ if any(len(a) > 0 for a in f.values()):
544
+ files.append((setting, f))
545
+ if len(files) == 0:
546
+ logger.warning(
547
+ "No %s files found matching the expected values %s",
548
+ self.science,
549
+ expected[self.science],
550
+ )
551
+ return files
552
+
553
+ def sort_files(
554
+ self, input_dir, target, night, *args, allow_calibration_only=False, **kwargs
555
+ ):
556
+ """
557
+ Sort a set of fits files into different categories
558
+ types are: bias, flat, wavecal, orderdef, spec
559
+
560
+ Parameters
561
+ ----------
562
+ input_dir : str
563
+ input directory containing the files to sort
564
+ target : str
565
+ name of the target as in the fits headers
566
+ night : str
567
+ observation night, possibly with wildcards
568
+ mode : str
569
+ instrument mode
570
+ Returns
571
+ -------
572
+ files_per_night : list[dict{str:dict{str:list[str]}}]
573
+ a list of file sets, one entry per night, where each night consists of a dictionary with one entry per setting,
574
+ each fileset has five lists of filenames: "bias", "flat", "order", "wave", "spec", organised in another dict
575
+ nights_out : list[datetime]
576
+ a list of observation times, same order as files_per_night
577
+ """
578
+ input_dir = input_dir.format(
579
+ **kwargs, target=target, night=night, instrument=self.name
580
+ )
581
+ files = self.find_files(input_dir)
582
+ ev = self.get_expected_values(target, night, *args, **kwargs)
583
+ files = self.apply_filters(
584
+ files, ev, allow_calibration_only=allow_calibration_only
585
+ )
586
+ return files
587
+
588
+ def get_wavecal_filename(self, header, mode, **kwargs):
589
+ """Get the filename of the pre-existing wavelength solution for the current setting
590
+
591
+ Parameters
592
+ ----------
593
+ header : fits.header, dict
594
+ header of the wavelength calibration file
595
+ mode : str
596
+ instrument mode
597
+
598
+ Returns
599
+ -------
600
+ filename : str
601
+ name of the wavelength solution file
602
+ """
603
+
604
+ specifier = header.get(self.info.get("wavecal_specifier", ""), "")
605
+ instrument = "wavecal"
606
+
607
+ cwd = os.path.dirname(__file__)
608
+ fname = f"{instrument.lower()}_{mode}_{specifier}.npz"
609
+ fname = os.path.join(cwd, "..", "wavecal", fname)
610
+ return fname
611
+
612
+ def get_supported_modes(self):
613
+ return self.info["modes"]
614
+
615
+ def get_mask_filename(self, mode, **kwargs):
616
+ i = self.name.lower()
617
+ m = mode.lower()
618
+ fname = f"mask_{i}_{m}.fits.gz"
619
+ cwd = os.path.dirname(__file__)
620
+ fname = os.path.join(cwd, "..", "masks", fname)
621
+ return fname
622
+
623
+ def get_wavelength_range(self, header, mode, **kwargs):
624
+ return self.get("wavelength_range", header, mode)
625
+
626
+
627
+ class InstrumentWithModes(Instrument):
628
+ def __init__(self):
629
+ super().__init__()
630
+
631
+ # replacement = {k: v for k, v in zip(self.info["id_modes"], self.info["modes"])}
632
+ self.filters["mode"] = ModeFilter(self.info["kw_modes"])
633
+ self.shared += ["mode"]
634
+
635
+ def get_expected_values(self, target, night, mode):
636
+ expectations = super().get_expected_values(target, night, mode)
637
+
638
+ id_mode = [
639
+ self.info["id_modes"][i]
640
+ for i, m in enumerate(self.info["modes"])
641
+ if m == mode
642
+ ][0]
643
+
644
+ for key in expectations.keys():
645
+ expectations[key]["mode"] = id_mode
646
+
647
+ return expectations
648
+
649
+
650
+ class COMMON(Instrument):
651
+ pass
652
+
653
+
654
+ def create_custom_instrument(
655
+ name, extension=0, info=None, mask_file=None, wavecal_file=None, hasModes=False
656
+ ):
657
+ cls = Instrument if not hasModes else InstrumentWithModes
658
+
659
+ class CUSTOM(cls):
660
+ def __init__(self):
661
+ super().__init__()
662
+ self.name = name
663
+
664
+ def load_info(self):
665
+ if info is None:
666
+ return COMMON().info
667
+ try:
668
+ with open(info) as f:
669
+ data = json.load(f)
670
+ return data
671
+ except:
672
+ return info
673
+
674
+ def get_extension(self, header, mode):
675
+ return extension
676
+
677
+ def get_mask_filename(self, mode, **kwargs):
678
+ return mask_file
679
+
680
+ def get_wavecal_filename(self, header, mode, **kwargs):
681
+ return wavecal_file
682
+
683
+ return CUSTOM()