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
@@ -0,0 +1,350 @@
|
|
1
|
+
import os
|
2
|
+
import numpy as np
|
3
|
+
from astropy.table import Table
|
4
|
+
from functools import partial, cached_property
|
5
|
+
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
|
6
|
+
from typing import Optional, Tuple, Union
|
7
|
+
|
8
|
+
from almanac import logger, config, utils
|
9
|
+
|
10
|
+
from almanac.data_models.fps import FPSTarget
|
11
|
+
from almanac.data_models.plate import PlateTarget
|
12
|
+
from almanac.data_models.types import *
|
13
|
+
from almanac.data_models.utils import (
|
14
|
+
get_headers, match_planned_to_plugged, get_exposure_path,
|
15
|
+
get_exposure_string, mjd_to_exposure_prefix
|
16
|
+
)
|
17
|
+
|
18
|
+
from almanac.qa import lookup_bad_exposures
|
19
|
+
|
20
|
+
|
21
|
+
class Exposure(BaseModel):
|
22
|
+
|
23
|
+
#> Basic Information
|
24
|
+
observatory: Observatory = Field(description="Observatory name")
|
25
|
+
mjd: int = Field(description="MJD of the exposure")
|
26
|
+
exposure: int = Field(description="Exposure number", ge=1)
|
27
|
+
prefix: Optional[Prefix] = Field(description="Raw exposure basename prefix", default=None)
|
28
|
+
|
29
|
+
#> Exposure Information
|
30
|
+
name: Optional[str] = Field(
|
31
|
+
default="",
|
32
|
+
description=(
|
33
|
+
"The `name` field in the exposure header often refers to the plugged"
|
34
|
+
"plate name, which describes which targets were observed."
|
35
|
+
)
|
36
|
+
)
|
37
|
+
n_read: int = Field(default=0, alias="nread", ge=0)
|
38
|
+
image_type: ImageType = Field(alias="imagetyp")
|
39
|
+
observer_comment: Optional[str] = Field(default="", alias="obscmnt")
|
40
|
+
|
41
|
+
#> Identifiers
|
42
|
+
map_id: int = Field(default=-1, alias="mapid")
|
43
|
+
cart_id: int = Field(default=-1, alias="cartid")
|
44
|
+
plate_id: int = Field(default=-1, alias="plateid")
|
45
|
+
field_id: int = Field(default=-1, alias="fieldid")
|
46
|
+
design_id: int = Field(default=-1, alias="designid")
|
47
|
+
config_id: int = Field(default=-1, alias="configid")
|
48
|
+
|
49
|
+
#> Observing Conditions
|
50
|
+
seeing: float = Field(default=float('NaN'))
|
51
|
+
|
52
|
+
#> Instrument State
|
53
|
+
focus: float = Field(default=float('NaN'))
|
54
|
+
collpist: float = Field(default=float('NaN'))
|
55
|
+
colpitch: float = Field(default=float('NaN'))
|
56
|
+
dithered_pixels: float = Field(default=float('NaN'), alias="dithpix")
|
57
|
+
lamp_quartz: int = Field(default=-1, alias="lampqrtz", ge=-1, le=1)
|
58
|
+
lamp_thar: int = Field(default=-1, alias="lampthar", ge=-1, le=1)
|
59
|
+
lamp_une: int = Field(default=-1, alias="lampune", ge=-1, le=1)
|
60
|
+
|
61
|
+
_targets: Optional[Tuple[Union[FPSTarget, PlateTarget]]] = None
|
62
|
+
|
63
|
+
@computed_field(description="Exposure string used in path")
|
64
|
+
def exposure_string(self) -> str:
|
65
|
+
return get_exposure_string(self.mjd, self.exposure)
|
66
|
+
|
67
|
+
@computed_field(description="Whether this exposure is from the FPS era")
|
68
|
+
def fps(self) -> bool:
|
69
|
+
start = dict(apo=59423, lco=59810)[self.observatory]
|
70
|
+
return self.mjd >= start
|
71
|
+
|
72
|
+
#@computed_field(description="FPI")
|
73
|
+
#def fpi(self) -> bool:
|
74
|
+
# return "fpi" in self.observer_comment.lower()
|
75
|
+
|
76
|
+
#@computed_field(description="Sparse pak mode")
|
77
|
+
#def sparse_pak(self) -> bool:
|
78
|
+
# return "sparse" in self.observer_comment.lower()
|
79
|
+
|
80
|
+
@computed_field
|
81
|
+
def flagged_bad(self) -> bool:
|
82
|
+
return (self.observatory, self.mjd, self.exposure) in lookup_bad_exposures
|
83
|
+
|
84
|
+
@computed_field
|
85
|
+
def chip_flags(self) -> int:
|
86
|
+
return int(np.sum(2**np.where(list(map(os.path.exists, self.paths)))[0]))
|
87
|
+
|
88
|
+
# Validations
|
89
|
+
@field_validator('prefix', mode="before")
|
90
|
+
def validate_prefix(cls, v, values):
|
91
|
+
if v is None:
|
92
|
+
return dict(apo="apR", lco="asR").get(values.get("observatory"))
|
93
|
+
return v
|
94
|
+
|
95
|
+
@field_validator("observer_comment", mode="before")
|
96
|
+
def sanitise_observer_comment(cls, v) -> str:
|
97
|
+
if v is None or str(v).lower() == "none":
|
98
|
+
return ""
|
99
|
+
return v.strip()
|
100
|
+
|
101
|
+
@field_validator('image_type', mode="before")
|
102
|
+
def validate_descriptive_type(cls, v):
|
103
|
+
return v.lower()
|
104
|
+
|
105
|
+
@field_validator('cart_id', mode="before")
|
106
|
+
def validate_cart_id(cls, v):
|
107
|
+
if isinstance(v, str) and v.strip().upper() == 'FPS':
|
108
|
+
return 0
|
109
|
+
return empty_string_to_int(v, -1)
|
110
|
+
|
111
|
+
@field_validator('map_id', 'plate_id', 'field_id', 'design_id', 'config_id', mode="before")
|
112
|
+
def validate_identifiers(cls, v):
|
113
|
+
return empty_string_to_int(v, -1)
|
114
|
+
|
115
|
+
@field_validator('seeing', 'focus', 'collpist', 'colpitch', 'dithered_pixels', mode="before")
|
116
|
+
def validate_floats(cls, v):
|
117
|
+
try:
|
118
|
+
return float(v)
|
119
|
+
except:
|
120
|
+
return float('NaN')
|
121
|
+
|
122
|
+
@field_validator('lamp_quartz', 'lamp_thar', 'lamp_une', mode="before")
|
123
|
+
def validate_lamps(cls, v):
|
124
|
+
return {'F': 0, 'T': 1}.get(str(v).strip().upper(), -1)
|
125
|
+
|
126
|
+
@model_validator(mode="after")
|
127
|
+
def check_fields(self):
|
128
|
+
if self.observer_comment is not None and self.image_type != "twilightflat":
|
129
|
+
sanitised = self.observer_comment.lower().replace(' ', '')
|
130
|
+
if 'skyflat' in sanitised or 'twilight' in sanitised:
|
131
|
+
self.image_type = 'twilightflat'
|
132
|
+
return self
|
133
|
+
|
134
|
+
# TODO: we may want to change this to be way more descriptive, particularly
|
135
|
+
# when we start doing QA to make sure exposures look like they should
|
136
|
+
@property
|
137
|
+
def qa_metadata(self) -> Optional[dict]:
|
138
|
+
print("Warning: The `qa_metadata` property will change in the future")
|
139
|
+
return lookup_bad_exposures.get((self.observatory, self.mjd, self.exposure), None)
|
140
|
+
|
141
|
+
@property
|
142
|
+
def plugged_mjd(self) -> int:
|
143
|
+
try:
|
144
|
+
return int(self.name.split("-")[1])
|
145
|
+
except:
|
146
|
+
return -1
|
147
|
+
|
148
|
+
@property
|
149
|
+
def plugged_iteration(self) -> str:
|
150
|
+
try:
|
151
|
+
return self.name.split("-")[2]
|
152
|
+
except:
|
153
|
+
return ""
|
154
|
+
|
155
|
+
@property
|
156
|
+
def paths(self) -> Tuple[str]:
|
157
|
+
return tuple(
|
158
|
+
map(
|
159
|
+
partial(
|
160
|
+
get_exposure_path,
|
161
|
+
self.observatory,
|
162
|
+
self.mjd,
|
163
|
+
self.prefix,
|
164
|
+
self.exposure
|
165
|
+
),
|
166
|
+
"abc"
|
167
|
+
)
|
168
|
+
)
|
169
|
+
|
170
|
+
@property
|
171
|
+
def plate_hole_path(self):
|
172
|
+
return (
|
173
|
+
f"{config.platelist_dir}/"
|
174
|
+
f"{str(self.plate_id)[:-2].zfill(4)}XX/"
|
175
|
+
f"{self.plate_id:0>6.0f}/"
|
176
|
+
f"plateHoles-{self.plate_id:0>6.0f}.par"
|
177
|
+
)
|
178
|
+
|
179
|
+
@property
|
180
|
+
def plug_map_path(self):
|
181
|
+
return (
|
182
|
+
f"{config.mapper_dir}/"
|
183
|
+
f"{self.observatory}/"
|
184
|
+
f"{self.plugged_mjd}/"
|
185
|
+
f"plPlugMapM-{self.plate_id}-{self.plugged_mjd}-{self.plugged_iteration}.par"
|
186
|
+
)
|
187
|
+
|
188
|
+
@property
|
189
|
+
def config_summary_path(self):
|
190
|
+
directory = (
|
191
|
+
f"{config.sdsscore_dir}/"
|
192
|
+
f"{self.observatory}/"
|
193
|
+
f"summary_files/"
|
194
|
+
f"{str(self.config_id)[:-3].zfill(3)}XXX/"
|
195
|
+
f"{str(self.config_id)[:-2].zfill(4)}XX/"
|
196
|
+
)
|
197
|
+
# fall back to confSummary if confSummaryFS does not exist
|
198
|
+
for flavor in ("FS", ""):
|
199
|
+
path = f"{directory}/confSummary{flavor}-{self.config_id}.par"
|
200
|
+
if os.path.exists(path):
|
201
|
+
return path
|
202
|
+
|
203
|
+
raise FileNotFoundError(f"Could not find confSummary file for config {self.config_id} in {directory}")
|
204
|
+
|
205
|
+
@classmethod
|
206
|
+
def from_keys(
|
207
|
+
cls,
|
208
|
+
mjd: int,
|
209
|
+
observatory: str,
|
210
|
+
exposure: int,
|
211
|
+
prefix: Optional[Prefix] = None,
|
212
|
+
chip: Optional[Chip] = None
|
213
|
+
) -> "Exposure":
|
214
|
+
"""
|
215
|
+
Create an Exposure instance from basic identifying keys.
|
216
|
+
|
217
|
+
:param mjd:
|
218
|
+
MJD of the exposure.
|
219
|
+
|
220
|
+
:param observatory:
|
221
|
+
Observatory name (e.g., 'apo', 'lco').
|
222
|
+
|
223
|
+
:param exposure:
|
224
|
+
Exposure number.
|
225
|
+
|
226
|
+
:param prefix: [optional]
|
227
|
+
Prefix for the exposure file (e.g., 'apR', 'asR'). If not provided,
|
228
|
+
defaults to 'apR' for APO and 'asR' for LCO.
|
229
|
+
|
230
|
+
:param chip: [optional]
|
231
|
+
Chip identifier ('a', 'b', or 'c'). If not provided, will check
|
232
|
+
all chips in order.
|
233
|
+
|
234
|
+
:returns:
|
235
|
+
An instance of the Exposure class populated with data extracted from
|
236
|
+
the file headers.
|
237
|
+
"""
|
238
|
+
|
239
|
+
if prefix is None:
|
240
|
+
prefix = dict(apo="apR", lco="asR").get(observatory, "apR")
|
241
|
+
|
242
|
+
for chip in (chip or "abc"):
|
243
|
+
path = get_exposure_path(observatory, mjd, prefix, exposure, chip)
|
244
|
+
if os.path.exists(path):
|
245
|
+
headers = get_headers(path)
|
246
|
+
return cls(
|
247
|
+
observatory=observatory,
|
248
|
+
mjd=mjd,
|
249
|
+
exposure=exposure,
|
250
|
+
prefix=prefix,
|
251
|
+
**headers
|
252
|
+
)
|
253
|
+
raise FileNotFoundError(f"No exposure files found for {observatory} {mjd} {exposure} {prefix}")
|
254
|
+
|
255
|
+
@classmethod
|
256
|
+
def from_path(cls, path: str) -> "Exposure":
|
257
|
+
"""
|
258
|
+
Create an Exposure instance from a given file path.
|
259
|
+
|
260
|
+
:param path:
|
261
|
+
Full path to the exposure file.
|
262
|
+
|
263
|
+
:returns:
|
264
|
+
An instance of the Exposure class populated with data extracted from
|
265
|
+
the file path and headers.
|
266
|
+
"""
|
267
|
+
*_, observatory, mjd, basename = path.split("/")
|
268
|
+
prefix, chip, cumulative_exposure = basename.split("-")
|
269
|
+
exposure = int(cumulative_exposure.split(".")[0]) - mjd_to_exposure_prefix(mjd)
|
270
|
+
headers = get_headers(path)
|
271
|
+
return cls(
|
272
|
+
observatory=observatory,
|
273
|
+
mjd=mjd,
|
274
|
+
exposure=exposure,
|
275
|
+
prefix=prefix,
|
276
|
+
**headers
|
277
|
+
)
|
278
|
+
|
279
|
+
@cached_property
|
280
|
+
def headers(self) -> dict:
|
281
|
+
for path in self.paths:
|
282
|
+
if os.path.exists(path):
|
283
|
+
return get_headers(path)
|
284
|
+
raise FileNotFoundError(f"No exposure files found for {self.observatory} {self.mjd} {self.exposure} {self.prefix}")
|
285
|
+
|
286
|
+
@cached_property
|
287
|
+
def fiber_map(self):
|
288
|
+
return Table.read(self.config_summary_path, format="yanny", tablename="FIBERMAP")
|
289
|
+
|
290
|
+
@cached_property
|
291
|
+
def plug_map(self):
|
292
|
+
return Table.read(self.plug_map_path, format="yanny", tablename="PLUGMAPOBJ")
|
293
|
+
|
294
|
+
@cached_property
|
295
|
+
def plate_hole_map(self):
|
296
|
+
return Table.read(self.plate_hole_path, format="yanny", tablename="STRUCT1")
|
297
|
+
|
298
|
+
|
299
|
+
@cached_property
|
300
|
+
def targets(self) -> Tuple[Union[FPSTarget, PlateTarget]]:
|
301
|
+
if self._targets is None:
|
302
|
+
|
303
|
+
if (
|
304
|
+
(self.image_type == "object")
|
305
|
+
& (
|
306
|
+
(self.fps and self.config_id > 0)
|
307
|
+
| (not self.fps and self.plate_id > 0)
|
308
|
+
)
|
309
|
+
):
|
310
|
+
if self.fps:
|
311
|
+
factory = FPSTarget
|
312
|
+
targets = self.fiber_map
|
313
|
+
keep = (targets["fiberType"] == "APOGEE") & (targets["fiberId"] > 0)
|
314
|
+
targets = targets[keep]
|
315
|
+
else:
|
316
|
+
factory = PlateTarget
|
317
|
+
bad_exposure_notes = (
|
318
|
+
lookup_bad_exposures
|
319
|
+
.get((self.observatory, self.mjd, self.exposure), {})
|
320
|
+
.get("notes", None)
|
321
|
+
)
|
322
|
+
if bad_exposure_notes == "missing_plug_map_file":
|
323
|
+
targets = []
|
324
|
+
else:
|
325
|
+
targets = match_planned_to_plugged(self.plate_hole_map, self.plug_map)
|
326
|
+
if targets:
|
327
|
+
# Plugged MJD is necessary to understand where the fiber mapping
|
328
|
+
# went wrong in early plate era.
|
329
|
+
targets["plugged_mjd"] = self.plugged_mjd
|
330
|
+
targets["observatory"] = self.observatory
|
331
|
+
|
332
|
+
self._targets = tuple([factory(**r) for r in targets])
|
333
|
+
else:
|
334
|
+
self._targets = tuple()
|
335
|
+
return self._targets
|
336
|
+
|
337
|
+
def __repr__(self):
|
338
|
+
return f"{self.__repr_name__()}(observatory={self.observatory}, mjd={self.mjd}, exposure={self.exposure}, image_type={self.image_type})"
|
339
|
+
|
340
|
+
class Config:
|
341
|
+
validate_by_name = True
|
342
|
+
validate_assignment = True
|
343
|
+
|
344
|
+
|
345
|
+
def empty_string_to_int(v, default) -> int:
|
346
|
+
if isinstance(v, str) and v.strip() == '':
|
347
|
+
return default
|
348
|
+
elif v is None:
|
349
|
+
return default
|
350
|
+
return int(v)
|
@@ -0,0 +1,109 @@
|
|
1
|
+
import numpy as np
|
2
|
+
from typing import Literal
|
3
|
+
from typing_extensions import Annotated
|
4
|
+
from pydantic import BaseModel, Field, model_validator, field_validator
|
5
|
+
|
6
|
+
from almanac.data_models.types import *
|
7
|
+
from almanac.data_models.utils import sanitise_twomass_designation
|
8
|
+
|
9
|
+
class FPSTarget(BaseModel):
|
10
|
+
|
11
|
+
"""A target that was observed with the SDSS-V Fiber Positioning System."""
|
12
|
+
|
13
|
+
# Target information
|
14
|
+
sdss_id: Int64 = Field(default=-1)
|
15
|
+
catalogid: Int64 = Field(default=-1)
|
16
|
+
twomass_designation: str = Field(default="", alias="tmass_id")
|
17
|
+
category: Literal[Category] = Field(description="Category of the target")
|
18
|
+
cadence: str = Field(description="Cadence identifier", default="")
|
19
|
+
firstcarton: str = Field(description="Main carton from which this carton was drawn", default="")
|
20
|
+
program: str = Field(description="Program for 'firstcarton'", default="")
|
21
|
+
|
22
|
+
# Positioner and hole identifiers
|
23
|
+
positioner_id: int = Field(alias='positionerId', description="Positioner identifier", default=-1)
|
24
|
+
hole_id: str = Field(alias='holeId', description="Hole ID in which the positioner is sitting", default="")
|
25
|
+
hole_type: HoleType = Field(alias="holeType", description="Type of hole", default="fps")
|
26
|
+
planned_hole_type: HoleType = Field(alias="holetype", description="Hole type string", default="fps")
|
27
|
+
|
28
|
+
fiber_type: str = Field(alias='fiberType', description="Type of fiber", default="")
|
29
|
+
assigned: bool = Field(
|
30
|
+
default=False,
|
31
|
+
description=(
|
32
|
+
"Target is assigned to this fiber in `robostrategy`. If False, no "
|
33
|
+
"target assigned for this fiber (likely BOSS instead), and no "
|
34
|
+
"targeting information available"
|
35
|
+
)
|
36
|
+
)
|
37
|
+
|
38
|
+
# Status flags
|
39
|
+
on_target: bool = Field(description="Fiber placed on target", default=False)
|
40
|
+
disabled: bool = Field(description="Fiber is disabled", default=False)
|
41
|
+
valid: bool = Field(description="Converted on-sky coordinates to robot (α,β)", default=False)
|
42
|
+
decollided: bool = Field(description="Positioner had to be moved to decollide it", default=False)
|
43
|
+
|
44
|
+
# Position coordinates
|
45
|
+
x_wok: float = Field(description="x-coordinate in the wok frame", default=float('NaN'), alias="xwok")
|
46
|
+
y_wok: float = Field(description="y-coordinate in the wok frame", default=float('NaN'), alias="ywok")
|
47
|
+
z_wok: float = Field(description="z-coordinate in the wok frame", default=float('NaN'), alias="zwok")
|
48
|
+
x_focal: float = Field(description="x-coordinate in the focal plane", default=float('NaN'), alias='xFocal')
|
49
|
+
y_focal: float = Field(description="y-coordinate in the focal plane", default=float('NaN'), alias='yFocal')
|
50
|
+
|
51
|
+
# Angles
|
52
|
+
alpha: float = Field(description="Alpha angle of the positioner arm", default=float('NaN'))
|
53
|
+
beta: float = Field(description="Beta angle of the positioner arm", default=float('NaN'))
|
54
|
+
|
55
|
+
# Target coordinates
|
56
|
+
ra: float = Field(alias="racat", description="Right Ascension [deg]")
|
57
|
+
dec: float = Field(alias="deccat", description="Declination [deg]")
|
58
|
+
alt: float = Field(description="Altitude of the fiber on the sky [deg]", default=float('NaN'), alias="alt_observed")
|
59
|
+
az: float = Field(description="Azimuth of the fiber on the sky [deg]", default=float('NaN'), alias="az_observed")
|
60
|
+
|
61
|
+
# Wavelength information
|
62
|
+
lambda_design: float = Field(default=0.0)
|
63
|
+
lambda_eff: float = Field(default=0.0)
|
64
|
+
coord_epoch: float = Field(default=0.0)
|
65
|
+
|
66
|
+
# Instrument identifiers
|
67
|
+
spectrograph_id: int = Field(description="Spectrograph identifier", alias='spectrographId', default=-1)
|
68
|
+
fiber_id: int = Field(description="Fiber identifier", alias='fiberId', default=-1)
|
69
|
+
|
70
|
+
# Position deltas
|
71
|
+
delta_ra: float = Field(description="The amount in RA this fiber has been offset", default=float('NaN'))
|
72
|
+
delta_dec: float = Field(description="The amount in Dec this fiber has been offset", default=float('NaN'))
|
73
|
+
|
74
|
+
# Target of opportunity
|
75
|
+
too: bool = Field(default=False, description="Target of opportunity")
|
76
|
+
too_id: int = Field(default=-1)
|
77
|
+
too_program: str = Field(default="")
|
78
|
+
|
79
|
+
@property
|
80
|
+
def expected_to_be_assigned_sdss_id(self) -> bool:
|
81
|
+
""" A helper function so we don't try to cross-match sky targets for SDSS IDs. """
|
82
|
+
return (self.catalogid > 0
|
83
|
+
and not self.category.startswith("sky_")
|
84
|
+
and self.category != ""
|
85
|
+
)
|
86
|
+
|
87
|
+
@field_validator("twomass_designation", mode="before")
|
88
|
+
def strip_twomass_designation(cls, v) -> str:
|
89
|
+
""" Convert a target ID to a standardized designation format. """
|
90
|
+
return sanitise_twomass_designation(v)
|
91
|
+
|
92
|
+
|
93
|
+
class Config:
|
94
|
+
validate_by_name = True
|
95
|
+
validate_assignment = True
|
96
|
+
arbitrary_types_allowed = True
|
97
|
+
|
98
|
+
|
99
|
+
@model_validator(mode="after")
|
100
|
+
def fix_early_fiber_duplicates(self):
|
101
|
+
# From https://github.com/sdss/apogee_drp/blob/4ab6a04e448b279f2514550802b6732693e9847a/python/apogee_drp/utils/plugmap.py#L170-L180
|
102
|
+
if self.spectrograph_id == 2:
|
103
|
+
if self.positioner_id == 650 and self.fiber_id == 175:
|
104
|
+
self.fiber_id = 275
|
105
|
+
if self.positioner_id == 880 and self.fiber_id == 176:
|
106
|
+
self.fiber_id = 276
|
107
|
+
if self.positioner_id == 177 and self.fiber_id == 186:
|
108
|
+
self.fiber_id = 286
|
109
|
+
return self
|
@@ -0,0 +1,142 @@
|
|
1
|
+
from typing import Literal
|
2
|
+
from pydantic import BaseModel, Field, validator, model_validator, computed_field
|
3
|
+
|
4
|
+
from almanac.data_models.types import *
|
5
|
+
from almanac.data_models.utils import sanitise_twomass_designation
|
6
|
+
|
7
|
+
class PlateTarget(BaseModel):
|
8
|
+
|
9
|
+
""" A target that was observed with plates. """
|
10
|
+
|
11
|
+
# Target information
|
12
|
+
sdss_id: Int64 = Field(default=-1)
|
13
|
+
catalogid: Int64 = Field(default=-1)
|
14
|
+
|
15
|
+
@computed_field
|
16
|
+
def twomass_designation(self) -> str:
|
17
|
+
""" Convert a target ID to a standardized designation format. """
|
18
|
+
return sanitise_twomass_designation(self.twomass_id or self.target_ids)
|
19
|
+
|
20
|
+
twomass_id: str = Field(alias="tmass_id", default="")
|
21
|
+
target_ids: str = Field(alias="targetids", default="")
|
22
|
+
category: Literal[Category] = Field(description="Category of the target", alias="targettype")
|
23
|
+
|
24
|
+
# Positioner and hole identifiers
|
25
|
+
observatory: Literal[Observatory] = Field(description="Observatory") # necessary for fiber mapping fixes
|
26
|
+
hole_type: HoleType = Field(alias="holeType", description="Type of hole")
|
27
|
+
planned_hole_type: HoleType = Field(alias="holetype", description="Hole type string")
|
28
|
+
obj_type: ObjType = Field(alias="objType", description="Object type", default="na")
|
29
|
+
assigned: bool = Field(description="Assigned flag", default=False)
|
30
|
+
|
31
|
+
# Status flags
|
32
|
+
conflicted: bool = Field(description="Conflicted flag", default=False)
|
33
|
+
ranout: bool = Field(description="Ran out flag", default=False)
|
34
|
+
outside: bool = Field(description="Outside flag", default=False)
|
35
|
+
|
36
|
+
# Position coordinates
|
37
|
+
x_focal: float = Field(description="X focal plane coordinate", alias="xfocal", default=float('NaN'))
|
38
|
+
y_focal: float = Field(description="Y focal plane coordinate", alias="yfocal", default=float('NaN'))
|
39
|
+
xf_default: float = Field(description="Default X focal coordinate", default=float('NaN'))
|
40
|
+
yf_default: float = Field(description="Default Y focal coordinate", default=float('NaN'))
|
41
|
+
|
42
|
+
# Target coordinates
|
43
|
+
ra: float = Field(description="Right ascension [deg]")
|
44
|
+
dec: float = Field(description="Declination [deg]")
|
45
|
+
|
46
|
+
# Wavelength information
|
47
|
+
lambda_eff: float = Field(description="Effective wavelength", default=0.0)
|
48
|
+
zoffset: float = Field(description="Z offset", default=0.0)
|
49
|
+
|
50
|
+
# Instrument identifiers
|
51
|
+
spectrograph_id: int = Field(alias="spectrographId", description="Spectrograph ID", default=-1)
|
52
|
+
fiber_id: int = Field(alias="fiberId", description="Fiber ID", default=-1)
|
53
|
+
planned_fiber_id: int = Field(alias="fiberid", description="Fiber ID", default=-1)
|
54
|
+
throughput: int = Field(description="Throughput value", default=-1)
|
55
|
+
|
56
|
+
# Plate-specific information
|
57
|
+
iplateinput: int = Field(description="Plate input ID", default=-1)
|
58
|
+
pointing: int = Field(description="Pointing number", default=-1)
|
59
|
+
offset: int = Field(description="Offset value", default=-1)
|
60
|
+
block: int = Field(description="Block number", default=-1)
|
61
|
+
iguide: int = Field(description="Guide flag", default=-1)
|
62
|
+
bluefiber: int = Field(description="Blue fiber flag", default=-1)
|
63
|
+
chunk: int = Field(description="Chunk number", default=-1)
|
64
|
+
ifinal: int = Field(description="Final flag", default=-1)
|
65
|
+
plugged_mjd: int = Field(description="MJD when this plate was plugged", default=-1) # necessary for fiber mapping fixes
|
66
|
+
fix_fiber_flag: int = Field(default=0, description="Whether this fiber mapping was fixed in software")
|
67
|
+
|
68
|
+
# Physical properties
|
69
|
+
diameter: float = Field(default=-1, description="Diameter")
|
70
|
+
buffer: float = Field(default=-1, description="Buffer size")
|
71
|
+
priority: int = Field(default=-1, description="Target priority")
|
72
|
+
|
73
|
+
|
74
|
+
@validator('hole_type', 'planned_hole_type', 'obj_type', pre=True)
|
75
|
+
def enforce_lower_case(cls, v):
|
76
|
+
return v.lower()
|
77
|
+
|
78
|
+
@validator('category', pre=True)
|
79
|
+
def validate_category(cls, v):
|
80
|
+
# Make consistent across FPS and plate era.
|
81
|
+
translate_from_plate_to_fps = {
|
82
|
+
"sky": "sky_apogee",
|
83
|
+
"standard": "standard_apogee",
|
84
|
+
"na": ""
|
85
|
+
}
|
86
|
+
return translate_from_plate_to_fps.get(v.lower(), v)
|
87
|
+
|
88
|
+
class Config:
|
89
|
+
validate_by_name = True
|
90
|
+
validate_assignment = True
|
91
|
+
arbitrary_types_allowed = True
|
92
|
+
|
93
|
+
@property
|
94
|
+
def expected_to_be_assigned_sdss_id(self) -> bool:
|
95
|
+
""" A helper function so we don't try to cross-match sky targets for SDSS IDs. """
|
96
|
+
return (
|
97
|
+
(self.catalogid > 0) | (self.twomass_designation != "")
|
98
|
+
|
99
|
+
and not self.category.startswith("sky_")
|
100
|
+
and self.category != ""
|
101
|
+
)
|
102
|
+
|
103
|
+
|
104
|
+
@model_validator(mode="after")
|
105
|
+
def fix_fiber_mappings(self):
|
106
|
+
# Dates from /uufs/chpc.utah.edu/common/home/sdss09/software/apogee/Linux/apogee/trunk/data/cal/apogee-n.par
|
107
|
+
# The Python mapping logic originates from:
|
108
|
+
# https://github.com/sdss/apogee_drp/blob/4ab6a04e448b279f2514550802b6732693e9847a/python/apogee_drp/utils/plugmap.py#L210-L238
|
109
|
+
# but that code has a bug in it (see below)
|
110
|
+
|
111
|
+
# Check against fix_fiber_flag to avoid recursively fixing things.
|
112
|
+
if self.observatory == "apo" and self.fix_fiber_flag == 0:
|
113
|
+
if 56764 <= self.plugged_mjd <= 56773 and self.fiber_id >= 0:
|
114
|
+
self.fix_fiber_flag = 1
|
115
|
+
sub_id = (self.fiber_id - 1) % 30
|
116
|
+
bundle_id = (self.fiber_id - sub_id) // 30
|
117
|
+
self.fiber_id = (9 - bundle_id) * 30 + sub_id + 1
|
118
|
+
if 58034 <= self.plugged_mjd <= 58046 and self.hole_type == "object" and self.spectrograph_id == 2:
|
119
|
+
self.fix_fiber_flag = 2
|
120
|
+
# Note that the DRP code has this in a way where it ONLY changes
|
121
|
+
# fibers 31, 37, 45, and 54, but their expressions are written in
|
122
|
+
# a way that you would think they are meant to be ranges.
|
123
|
+
|
124
|
+
# This is because the DRP code has a transcription error from the
|
125
|
+
# original IDL code:
|
126
|
+
# https://github.com/sdss/apogee/blob/master/pro/apogeereduce/aploadplugmap.pro#L210-L221
|
127
|
+
|
128
|
+
offset_ranges = [
|
129
|
+
(31, 36, +23),
|
130
|
+
(37, 44, +8),
|
131
|
+
(45, 52, -8),
|
132
|
+
(54, 59, -23),
|
133
|
+
]
|
134
|
+
for lower, upper, offset in offset_ranges:
|
135
|
+
if (lower <= self.fiber_id) & (self.fiber_id <= upper):
|
136
|
+
self.fiber_id += offset
|
137
|
+
break
|
138
|
+
|
139
|
+
if self.fiber_id in (53, 60):
|
140
|
+
self.fiber_id = -1 # unpopulated fiber
|
141
|
+
|
142
|
+
return self
|
@@ -0,0 +1,87 @@
|
|
1
|
+
import numpy as np
|
2
|
+
from typing import Literal
|
3
|
+
from typing_extensions import Annotated
|
4
|
+
from pydantic import BeforeValidator
|
5
|
+
|
6
|
+
def validate_np_int64(v):
|
7
|
+
if not isinstance(v, np.int64):
|
8
|
+
return np.int64(v)
|
9
|
+
return v
|
10
|
+
|
11
|
+
Int64 = Annotated[np.int64, BeforeValidator(validate_np_int64)]
|
12
|
+
|
13
|
+
|
14
|
+
Observatory = Literal["apo", "lco"]
|
15
|
+
Prefix = Literal["apR", "asR"]
|
16
|
+
Chip = Literal["a", "b", "c"]
|
17
|
+
|
18
|
+
ImageType = Literal[
|
19
|
+
"blackbody",
|
20
|
+
"dark",
|
21
|
+
"object",
|
22
|
+
"domeflat",
|
23
|
+
"arclamp",
|
24
|
+
"twilightflat",
|
25
|
+
"internalflat",
|
26
|
+
"quartzflat",
|
27
|
+
"missing"
|
28
|
+
]
|
29
|
+
Category = Literal[
|
30
|
+
"",
|
31
|
+
"science",
|
32
|
+
"sky_apogee",
|
33
|
+
"sky_boss",
|
34
|
+
"standard_apogee",
|
35
|
+
"standard_boss",
|
36
|
+
"open_fiber"
|
37
|
+
]
|
38
|
+
|
39
|
+
HoleType = Literal[
|
40
|
+
"object",
|
41
|
+
"coherent_sky",
|
42
|
+
"guide",
|
43
|
+
"light_trap",
|
44
|
+
"alignment",
|
45
|
+
"quality",
|
46
|
+
"manga",
|
47
|
+
"manga_single",
|
48
|
+
"manga_alignment",
|
49
|
+
"acquisition_center",
|
50
|
+
"acquisition_offaxis",
|
51
|
+
"apogee",
|
52
|
+
"center",
|
53
|
+
"trap",
|
54
|
+
"boss",
|
55
|
+
"apogee_shared",
|
56
|
+
"apogee_south",
|
57
|
+
"bosshalf",
|
58
|
+
"boss_shared",
|
59
|
+
"fps"
|
60
|
+
]
|
61
|
+
|
62
|
+
ObjType = Literal[
|
63
|
+
"galaxy",
|
64
|
+
"qso",
|
65
|
+
"star_bhb",
|
66
|
+
"star_carbon",
|
67
|
+
"star_brown_dwarf",
|
68
|
+
"star_sub_dwarf",
|
69
|
+
"star_caty_var",
|
70
|
+
"star_red_dwarf",
|
71
|
+
"star_white_dwarf",
|
72
|
+
"redden_std",
|
73
|
+
"spectrophoto_std",
|
74
|
+
"hot_std",
|
75
|
+
"rosat_a",
|
76
|
+
"rosat_b",
|
77
|
+
"rosat_c",
|
78
|
+
"rosat_d",
|
79
|
+
"serendipity_blue",
|
80
|
+
"serendipity_first",
|
81
|
+
"serendipity_red",
|
82
|
+
"serendipity_distant",
|
83
|
+
"serendipity_manual",
|
84
|
+
"qa",
|
85
|
+
"sky",
|
86
|
+
"na",
|
87
|
+
]
|