def get_night_expid_header(hdr): ''' Returns NIGHT, EXPID from input header keywords ''' from desispec.util import header2night night = header2night(hdr) try: expid = int(hdr['EXPID']) except (KeyError, ValueError, TypeError): # missing, not int, None log = get_logger() msg = 'Unable to determine int EXPID from header' log.error(msg) raise ValueError(msg) return night, expid
def summarize_exposure(raw_data_dir, night, exp, obstypes=None, colnames=None, coldefaults=None, verbosely=False): """ Given a raw data directory and exposure information, this searches for the raw DESI data files for that exposure and loads in relevant information for that flavor+obstype. It returns a dictionary if the obstype is one of interest for the exposure table, a string if the exposure signifies the end of a calibration sequence, and None if the exposure is not in the given obstypes. Args: raw_data_dir, str. The path to where the raw data is stored. It should be the upper level directory where the nightly subdirectories reside. night, str or int. Used to know what nightly subdirectory to look for the given exposure in. exp, str or int or float. The exposure number of interest. obstypes, list or np.array of str's. The list of 'OBSTYPE' keywords to match to. If a match is found, the information about that exposure is taken and returned for the exposure table. Otherwise None is returned (or str if it is an end-of-cal manifest). If None, the default list in default_exptypes_for_exptable() is used. colnames, list or np.array. List of column names for an exposure table. If None, the defaults are taken from get_exposure_table_column_defs(). coldefaults, list or np.array. List of default values for the corresponding colnames. If None, the defaults are taken from get_exposure_table_column_defs(). verbosely, bool. Whether to print more detailed output (True) or more succinct output (False). Returns: outdict, dict. Dictionary with keys corresponding to the column names of an exposure table. Values are taken from the data when found, otherwise the values are the corresponding default given in coldefaults. OR str. If the exposures signifies the end of a calibration sequence, it returns a string describing the type of sequence that ended. Either "(short|long|arc) calib complete". OR NoneType. If the exposure obstype was not in the requested types (obstypes). """ log = get_logger() ## Make sure the inputs are in the right format if type(exp) is not str: exp = int(exp) exp = f'{exp:08d}' night = str(night) ## Use defaults if things aren't defined if obstypes is None: obstypes = default_exptypes_for_exptable() if colnames is None or coldefaults is None: cnames, cdtypes, cdflts = get_exposure_table_column_defs(return_default_values=True) if colnames is None: colnames = cnames if coldefaults is None or len(coldefaults)!=len(colnames): coldefaults = cdflts colnames,coldefaults = np.asarray(colnames),np.asarray(coldefaults,dtype=object) ## Give a header for the exposure if verbosely: log.info(f'\n\n###### Summarizing exposure: {exp} ######\n') else: log.info(f'Summarizing exposure: {exp}') ## Request json file is first used to quickly identify science exposures ## If a request file doesn't exist for an exposure, it shouldn't be an exposure we care about reqpath = pathjoin(raw_data_dir, night, exp, f'request-{exp}.json') if not os.path.isfile(reqpath): if verbosely: log.info(f'{reqpath} did not exist!') else: log.info(f'{exp}: skipped -- request not found') return None ## Load the json file in as a dictionary req_dict = get_json_dict(reqpath) ## Check to see if it is a manifest file for calibrations if "SEQUENCE" in req_dict and req_dict["SEQUENCE"].lower() == "manifest": ## standardize the naming of end of arc/flats as best we can if int(night) < 20200310: pass elif int(night) < 20200801: if 'PROGRAM' in req_dict: prog = req_dict['PROGRAM'].lower() if 'calib' in prog and 'done' in prog: if 'short' in prog: return "endofshortflats" elif 'long' in prog: return 'endofflats' elif 'arc' in prog: return 'endofarcs' else: if 'MANIFEST' in req_dict: manifest = req_dict['MANIFEST'] if 'name' in manifest: name = manifest['name'].lower() if name in ['endofarcs', 'endofflats', 'endofshortflats']: return name ## If FLAVOR is wrong or no obstype is defines, skip it if 'FLAVOR' not in req_dict.keys(): if verbosely: log.info(f'WARNING: {reqpath} -- flavor not given!') else: log.info(f'{exp}: skipped -- flavor not given!') return None flavor = req_dict['FLAVOR'].lower() if flavor != 'science' and 'dark' not in obstypes and 'zero' not in obstypes: ## If FLAVOR is wrong if verbosely: log.info(f'ignoring: {reqpath} -- {flavor} not a flavor we care about') else: log.info(f'{exp}: skipped -- {flavor} not a relevant flavor') return None if 'OBSTYPE' not in req_dict.keys(): ## If no obstype is defines, skip it if verbosely: log.info(f'ignoring: {reqpath} -- {flavor} flavor but obstype not defined') else: log.info(f'{exp}: skipped -- obstype not given') return None else: if verbosely: log.info(f'using: {reqpath}') ## If obstype isn't in our list of ones we care about, skip it obstype = req_dict['OBSTYPE'].lower() if obstype not in obstypes: ## If obstype is wrong if verbosely: log.info(f'ignoring: {reqpath} -- {obstype} not an obstype we care about') else: log.info(f'{exp}: skipped -- {obstype} not relevant obstype') return None ## Look for the data. If it's not there, say so then move on datapath = pathjoin(raw_data_dir, night, exp, f'desi-{exp}.fits.fz') if not os.path.exists(datapath): if verbosely: log.info(f'could not find {datapath}! It had obstype={obstype}. Skipping') else: log.info(f'{exp}: skipped -- data not found') return None else: if verbosely: log.info(f'using: {datapath}') ## Raw data, so ensure it's read only and close right away just to be safe # log.debug(hdulist.info()) header,fx = load_raw_data_header(pathname=datapath, return_filehandle=True) # log.debug(header) # log.debug(specs) ## Define the column values for the current exposure in a dictionary outdict = {} ## Set HEADERERR and EXPFLAG before loop because they may be set if other columns have missing information outdict['HEADERERR'] = coldefaults[colnames == 'HEADERERR'][0] outdict['EXPFLAG'] = coldefaults[colnames == 'EXPFLAG'][0] ## Loop over columns and fill in the information. If unavailable report/flag if necessary and assign default for key,default in zip(colnames,coldefaults): ## These are dealt with separately if key in ['NIGHT','HEADERERR','EXPFLAG']: continue ## These just need defaults, as they are user defined (except FA_SURV which comes from the request.json file elif key in ['CAMWORD', 'FA_SURV', 'BADCAMWORD', 'BADAMPS', 'LASTSTEP', 'COMMENTS']: outdict[key] = default ## Try to find the key in the raw data header elif key in header.keys(): val = header[key] if type(val) is str: outdict[key] = val.lower() else: outdict[key] = val ## If key not in the header, identify that and place a default value ## If obstype isn't arc or flat, don't worry about seqnum or seqtot elif key in ['SEQNUM','SEQTOT'] and obstype not in ['arc','flat']: outdict[key] = default ## If tileid or TARGT and not science, just replace with default elif key in ['TILEID','TARGTRA','TARGTDEC'] and obstype not in ['science']: outdict[key] = default ## If trying to assign purpose and it's before that was defined, just give default elif key in ['PURPOSE'] and int(night) < 20201201: outdict[key] = default ## if something else, flag as missing metadata and replace with default else: if 'metadata_missing' not in outdict['EXPFLAG']: outdict['EXPFLAG'] = np.append(outdict['EXPFLAG'], 'metadata_missing') outdict[key] = default if np.isscalar(default): reporting = keyval_change_reporting(key, '', default) outdict['HEADERERR'] = np.append(outdict['HEADERERR'], reporting) ## Make sure that the night is defined: try: outdict['NIGHT'] = int(header['NIGHT']) except (KeyError, ValueError, TypeError): if 'metadata_missing' not in outdict['EXPFLAG']: outdict['EXPFLAG'] = np.append(outdict['EXPFLAG'], 'metadata_missing') outdict['NIGHT'] = header2night(header) try: orig = str(header['NIGHT']) except (KeyError, ValueError, TypeError): orig = '' reporting = keyval_change_reporting('NIGHT',orig,outdict['NIGHT']) outdict['HEADERERR'] = np.append(outdict['HEADERERR'],reporting) ## Get the cameras available in the raw data and summarize with camword cams = cameras_from_raw_data(fx) camword = create_camword(cams) outdict['CAMWORD'] = camword fx.close() ## Add the fiber assign survey, if it doesn't exist use the pre-defined one if "FA_SURV" in req_dict and "FA_SURV" in colnames: outdict['FA_SURV'] = req_dict['FA_SURV'] ## Flag the exposure based on PROGRAM information if 'system test' in outdict['PROGRAM'].lower(): outdict['LASTSTEP'] = 'ignore' outdict['EXPFLAG'] = np.append(outdict['EXPFLAG'], 'test') log.info(f"Exposure {exp} identified as system test. Not processing.") elif obstype == 'science' and float(outdict['EXPTIME']) < 59.0: outdict['LASTSTEP'] = 'skysub' outdict['EXPFLAG'] = np.append(outdict['EXPFLAG'], 'short_exposure') log.info(f"Science exposure {exp} with EXPTIME less than 59s. Processing through sky subtraction.") elif obstype == 'science' and 'undither' in outdict['PROGRAM']: outdict['LASTSTEP'] = 'fluxcal' log.info(f"Science exposure {exp} identified as undithered. Processing through flux calibration.") elif obstype == 'science' and 'dither' in outdict['PROGRAM']: outdict['LASTSTEP'] = 'skysub' log.info(f"Science exposure {exp} identified as dither. Processing through sky subtraction.") ## For Things defined in both request and data, if they don't match, flag in the ## output file for followup/clarity for check in ['OBSTYPE']:#, 'FLAVOR']: rval, hval = req_dict[check], header[check] if rval != hval: log.warning(f'In keyword {check}, request and data header disagree: req:{rval}\tdata:{hval}') if 'metadata_mismatch' not in outdict['EXPFLAG']: outdict['EXPFLAG'] = np.append(outdict['EXPFLAG'], 'metadata_mismatch') outdict['COMMENTS'] = np.append(outdict['COMMENTS'],f'For {check}: req={rval} but hdu={hval}') else: if verbosely: log.info(f'{check} checks out') ## Special logic for EXPTIME because of real-world variance on order 10's - 100's of ms check = 'EXPTIME' rval, hval = req_dict[check], header[check] if np.abs(float(rval)-float(hval))>0.5: log.warning(f'In keyword {check}, request and data header disagree: req:{rval}\tdata:{hval}') if 'aborted' not in outdict['EXPFLAG']: outdict['EXPFLAG'] = np.append(outdict['EXPFLAG'], 'aborted') outdict['COMMENTS'] = np.append(outdict['COMMENTS'],f'For {check}: req={rval} but hdu={hval}') else: if verbosely: log.info(f'{check} checks out') log.info(f'Done summarizing exposure: {exp}') return outdict
def __init__(self, headers, yaml_file=None): """ Class to read and select calibration data from $DESI_SPECTRO_CALIB using the keywords found in the headers Args: headers: list of fits headers, or list of dictionnaries Optional: yaml_file: path to a specific yaml file. By default, the code will automatically find the yaml file from the environment variable DESI_SPECTRO_CALIB and the CAMERA keyword in the headers """ log = get_logger() old_version = False # temporary backward compatibility if not "DESI_SPECTRO_CALIB" in os.environ: if "DESI_CCD_CALIBRATION_DATA" in os.environ: log.warning( "Using deprecated DESI_CCD_CALIBRATION_DATA env. variable to find calibration data\nPlease switch to DESI_SPECTRO_CALIB a.s.a.p." ) self.directory = os.environ["DESI_CCD_CALIBRATION_DATA"] old_version = True else: log.error("Need environment variable DESI_SPECTRO_CALIB") raise KeyError("Need environment variable DESI_SPECTRO_CALIB") else: self.directory = os.environ["DESI_SPECTRO_CALIB"] if len(headers) == 0: log.error("Need at least a header") raise RuntimeError("Need at least a header") header = dict() for other_header in headers: for k in other_header: if k not in header: try: header[k] = other_header[k] except KeyError: # it happens with the current version of fitsio # if the value = 'None'. pass if "CAMERA" not in header: log.error("no 'CAMERA' keyword in header, cannot find calib") log.error("header is:") for k in header: log.error("{} : {}".format(k, header[k])) raise KeyError("no 'CAMERA' keyword in header, cannot find calib") log.debug("header['CAMERA']={}".format(header['CAMERA'])) camera = header["CAMERA"].strip().lower() if "SPECID" in header: log.debug("header['SPECID']={}".format(header['SPECID'])) specid = int(header["SPECID"]) else: specid = None dateobs = header2night(header) detector = header["DETECTOR"].strip() if "CCDCFG" in header: ccdcfg = header["CCDCFG"].strip() else: ccdcfg = None if "CCDTMING" in header: ccdtming = header["CCDTMING"].strip() else: ccdtming = None #if "DOSVER" in header : # dosver = str(header["DOSVER"]).strip() #else : # dosver = None #if "FEEVER" in header : # feever = str(header["FEEVER"]).strip() #else : # feever = None # Support simulated data even if $DESI_SPECTRO_CALIB points to # real data calibrations self.directory = os.path.normpath(self.directory) # strip trailing / if detector == "SIM" and (not self.directory.endswith("sim")): newdir = os.path.join(self.directory, "sim") if os.path.isdir(newdir): self.directory = newdir if not os.path.isdir(self.directory): raise IOError("Calibration directory {} not found".format( self.directory)) if dateobs < 20191211 or detector == 'SIM': # old spectro identifiers cameraid = camera spectro = int(camera[-1]) if yaml_file is None: if old_version: yaml_file = os.path.join(self.directory, "ccd_calibration.yaml") else: yaml_file = "{}/spec/sp{}/{}.yaml".format( self.directory, spectro, cameraid) else: if specid is None: log.error( "dateobs = {} >= 20191211 but no SPECID keyword in header!" .format(dateobs)) raise RuntimeError( "dateobs = {} >= 20191211 but no SPECID keyword in header!" .format(dateobs)) log.debug("Use spectrograph hardware identifier SMY") cameraid = "sm{}-{}".format(specid, camera[0].lower()) if yaml_file is None: yaml_file = "{}/spec/sm{}/{}.yaml".format( self.directory, specid, cameraid) if not os.path.isfile(yaml_file): log.error("Cannot read {}".format(yaml_file)) raise IOError("Cannot read {}".format(yaml_file)) log.debug("reading calib data in {}".format(yaml_file)) stream = open(yaml_file, 'r') data = yaml.safe_load(stream) stream.close() if not cameraid in data: log.error("Cannot find data for camera %s in filename %s" % (cameraid, yaml_file)) raise KeyError("Cannot find data for camera %s in filename %s" % (cameraid, yaml_file)) data = data[cameraid] log.debug("Found %d data for camera %s in filename %s" % (len(data), cameraid, yaml_file)) log.debug("Finding matching version ...") log.debug("DATE-OBS=%d" % dateobs) found = False matching_data = None for version in data: log.debug("Checking version %s" % version) datebegin = int(data[version]["DATE-OBS-BEGIN"]) if dateobs < datebegin: log.debug( "Skip version %s with DATE-OBS-BEGIN=%d > DATE-OBS=%d" % (version, datebegin, dateobs)) continue if "DATE-OBS-END" in data[version] and data[version][ "DATE-OBS-END"].lower() != "none": dateend = int(data[version]["DATE-OBS-END"]) if dateobs > dateend: log.debug( "Skip version %s with DATE-OBS-END=%d < DATE-OBS=%d" % (version, datebegin, dateobs)) continue if detector != data[version]["DETECTOR"].strip(): log.debug("Skip version %s with DETECTOR=%s != %s" % (version, data[version]["DETECTOR"], detector)) continue if "CCDCFG" in data[version]: if ccdcfg is None or ccdcfg != data[version]["CCDCFG"].strip(): log.debug("Skip version %s with CCDCFG=%s != %s " % (version, data[version]["CCDCFG"], ccdcfg)) continue if "CCDTMING" in data[version]: if ccdtming is None or ccdtming != data[version][ "CCDTMING"].strip(): log.debug("Skip version %s with CCDTMING=%s != %s " % (version, data[version]["CCDTMING"], ccdtming)) continue #if dosver is not None and "DOSVER" in data[version] and dosver != str(data[version]["DOSVER"]).strip() : # log.debug("Skip version %s with DOSVER=%s != %s "%(version,data[version]["DOSVER"],dosver)) # continue #if feever is not None and "FEEVER" in data[version] and feever != str(data[version]["FEEVER"]).strip() : # log.debug("Skip version %s with FEEVER=%s != %s"%(version,data[version]["FEEVER"],feever)) # continue log.debug("Found data version %s for camera %s in %s" % (version, cameraid, yaml_file)) if found: log.error( "But we already has a match. Please fix this ambiguity in %s" % yaml_file) raise KeyError( "Duplicate possible calibration data. Please fix this ambiguity in %s" % yaml_file) found = True matching_data = data[version] if not found: log.error("Didn't find matching calibration data in %s" % (yaml_file)) raise KeyError("Didn't find matching calibration data in %s" % (yaml_file)) self.data = matching_data
def read_raw(filename, camera, fibermapfile=None, **kwargs): ''' Returns preprocessed raw data from `camera` extension of `filename` Args: filename : input fits filename with DESI raw data camera : camera name (B0,R1, .. Z9) or FITS extension name or number Options: fibermapfile : read fibermap from this file; if None create blank fm Other keyword arguments are passed to desispec.preproc.preproc(), e.g. bias, pixflat, mask. See preproc() documentation for details. Returns Image object with member variables pix, ivar, mask, readnoise ''' log = get_logger() t0 = time.time() fx = fits.open(filename, memmap=False) if camera.upper() not in fx: raise IOError('Camera {} not in {}'.format(camera, filename)) rawimage = fx[camera.upper()].data header = fx[camera.upper()].header hdu=0 while True : primary_header= fx[hdu].header if "EXPTIME" in primary_header : break if len(fx)>hdu+1 : if hdu > 0: log.warning("Did not find header keyword EXPTIME in hdu {}, moving to the next".format(hdu)) hdu +=1 else : log.error("Did not find header keyword EXPTIME in any HDU of {}".format(filename)) raise KeyError("Did not find header keyword EXPTIME in any HDU of {}".format(filename)) #- Check if NIGHT keyword is present and valid; fix if needed #- e.g. 20210105 have headers with NIGHT='None' instead of YEARMMDD try: tmp = int(primary_header['NIGHT']) except (KeyError, ValueError, TypeError): primary_header['NIGHT'] = header2night(primary_header) try: tmp = int(header['NIGHT']) except (KeyError, ValueError, TypeError): try: header['NIGHT'] = header2night(header) except (KeyError, ValueError, TypeError): #- early teststand data only have NIGHT/timestamps in primary hdr header['NIGHT'] = primary_header['NIGHT'] #- early data (e.g. 20200219/51053) had a mix of int vs. str NIGHT primary_header['NIGHT'] = int(primary_header['NIGHT']) header['NIGHT'] = int(header['NIGHT']) if primary_header['NIGHT'] != header['NIGHT']: msg = 'primary header NIGHT={} != camera header NIGHT={}'.format( primary_header['NIGHT'], header['NIGHT']) log.error(msg) raise ValueError(msg) #- early data have >8 char FIBERASSIGN key; rename to match current data if 'FIBERASSIGN' in primary_header: log.warning('renaming long header keyword FIBERASSIGN -> FIBASSGN') primary_header['FIBASSGN'] = primary_header['FIBERASSIGN'] del primary_header['FIBERASSIGN'] if 'FIBERASSIGN' in header: header['FIBASSGN'] = header['FIBERASSIGN'] del header['FIBERASSIGN'] skipkeys = ["EXTEND","SIMPLE","NAXIS1","NAXIS2","CHECKSUM","DATASUM","XTENSION","EXTNAME","COMMENT"] if 'INHERIT' in header and header['INHERIT']: h0 = fx[0].header for key in h0: if ( key not in skipkeys ) and ( key not in header ): header[key] = h0[key] if "fill_header" in kwargs : hdus = kwargs["fill_header"] if hdus is None : hdus=[0,] if "PLC" in fx : hdus.append("PLC") if hdus is not None : log.info("will add header keywords from hdus %s"%str(hdus)) for hdu in hdus : try : ihdu = int(hdu) hdu = ihdu except ValueError: pass if hdu in fx : hdu_header = fx[hdu].header for key in hdu_header: if ( key not in skipkeys ) and ( key not in header ) : log.debug("adding {} = {}".format(key,hdu_header[key])) header[key] = hdu_header[key] else : log.debug("key %s already in header or in skipkeys"%key) else : log.warning("warning HDU %s not in fits file"%str(hdu)) kwargs.pop("fill_header") fx.close() duration = time.time() - t0 log.info(iotime.format('read', filename, duration)) img = desispec.preproc.preproc(rawimage, header, primary_header, **kwargs) if fibermapfile is not None and os.path.exists(fibermapfile): fibermap = desispec.io.read_fibermap(fibermapfile) else: log.warning('creating blank fibermap') fibermap = desispec.io.empty_fibermap(5000) #- Add image header keywords inherited from raw data to fibermap too desispec.io.util.addkeys(fibermap.meta, img.meta) #- Augment the image header with some tile info from fibermap if needed for key in ['TILEID', 'TILERA', 'TILEDEC']: if key in fibermap.meta: if key not in img.meta: log.info('Updating header from fibermap {}={}'.format( key, fibermap.meta[key])) img.meta[key] = fibermap.meta[key] elif img.meta[key] != fibermap.meta[key]: #- complain loudly, but don't crash and don't override log.error('Inconsistent {}: raw header {} != fibermap header {}'.format(key, img.meta[key], fibermap.meta[key])) #- Trim to matching camera based upon PETAL_LOC, but that requires #- a mapping prior to 20191211 #- HACK HACK HACK #- TODO: replace this with a mapping from calibfinder, as soon as #- that is implemented in calibfinder / desi_spectro_calib #- HACK HACK HACK #- From DESI-5286v5 page 3 where sp=sm-1 and #- "spectro logical number" = petal_loc spec_to_petal = {4:2, 2:9, 3:0, 5:3, 1:8, 0:4, 6:6, 7:7, 8:5, 9:1} assert set(spec_to_petal.keys()) == set(range(10)) assert set(spec_to_petal.values()) == set(range(10)) #- Mapping only for dates < 20191211 if "NIGHT" in primary_header: dateobs = int(primary_header["NIGHT"]) elif "DATE-OBS" in primary_header: dateobs=parse_date_obs(primary_header["DATE-OBS"]) else: msg = "Need either NIGHT or DATE-OBS in primary header" log.error(msg) raise KeyError(msg) if dateobs < 20191211 : petal_loc = spec_to_petal[int(camera[1])] log.warning('Prior to 20191211, mapping camera {} to PETAL_LOC={}'.format(camera, petal_loc)) else : petal_loc = int(camera[1]) log.debug('Since 20191211, camera {} is PETAL_LOC={}'.format(camera, petal_loc)) if 'PETAL_LOC' in fibermap.dtype.names : # not the case in early teststand data ii = (fibermap['PETAL_LOC'] == petal_loc) fibermap = fibermap[ii] if 'FIBER' in fibermap.dtype.names : # not the case in early teststand data ## Mask fibers cfinder = CalibFinder([header,primary_header]) mod_fibers = fibermap['FIBER'].data % 500 ## Mask blacklisted fibers fiberblacklist = cfinder.fiberblacklist() for fiber in fiberblacklist: loc = np.where(mod_fibers==fiber)[0] fibermap['FIBERSTATUS'][loc] |= maskbits.fibermask.BADFIBER # Mask Fibers that are set to be excluded due to CCD/amp/readout issues camname = camera.upper()[0] if camname == 'B': badamp_bit = maskbits.fibermask.BADAMPB elif camname == 'R': badamp_bit = maskbits.fibermask.BADAMPR else: #elif camname == 'Z': badamp_bit = maskbits.fibermask.BADAMPZ fibers_to_exclude = cfinder.fibers_to_exclude() for fiber in fibers_to_exclude: loc = np.where(mod_fibers==fiber)[0] fibermap['FIBERSTATUS'][loc] |= badamp_bit img.fibermap = fibermap return img
def test_header2night(self): from astropy.time import Time night = 20210105 dateobs = '2021-01-06T04:33:55.704316928' mjd = Time(dateobs).mjd hdr = dict() #- Missing NIGHT and DATE-OBS falls back to MJD-OBS hdr['MJD-OBS'] = mjd self.assertEqual(util.header2night(hdr), night) #- Missing NIGHT and MJD-OBS falls back to DATE-OBS del hdr['MJD-OBS'] hdr['DATE-OBS'] = dateobs self.assertEqual(util.header2night(hdr), night) #- NIGHT is NIGHT del hdr['DATE-OBS'] hdr['NIGHT'] = night self.assertEqual(util.header2night(hdr), night) hdr['NIGHT'] = str(night) self.assertEqual(util.header2night(hdr), night) #- NIGHT trumps DATE-OBS hdr['NIGHT'] = night + 1 hdr['DATE-OBS'] = dateobs self.assertEqual(util.header2night(hdr), night + 1) #- Bogus NIGHT falls back to DATE-OBS hdr['NIGHT'] = None self.assertEqual(util.header2night(hdr), night) hdr['NIGHT'] = ' ' self.assertEqual(util.header2night(hdr), night) hdr['NIGHT'] = 'Sunday' self.assertEqual(util.header2night(hdr), night) #- Check rollover at noon KPNO (MST) = UTC 19:00 hdr = dict() hdr['DATE-OBS'] = '2021-01-05T18:59:00' self.assertEqual(util.header2night(hdr), 20210104) hdr['DATE-OBS'] = '2021-01-05T19:00:01' self.assertEqual(util.header2night(hdr), 20210105) hdr['DATE-OBS'] = '2021-01-06T01:00:01' self.assertEqual(util.header2night(hdr), 20210105) hdr['DATE-OBS'] = '2021-01-06T18:59:59' self.assertEqual(util.header2night(hdr), 20210105)