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.
@@ -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
+ ]