def __init__(self, tiles_file=None): log = desiutil.log.get_logger() config = desisurvey.config.Configuration() # Read the specified tiles file. self.tiles_file = tiles_file or config.tiles_file() tiles = desimodel.io.load_tiles( onlydesi=True, extra=False, tilesfile=self.tiles_file) # Check for any unknown program names. tile_programs = np.unique(tiles['PROGRAM']) unknown = set(tile_programs) - set(self.PROGRAMS) if unknown: raise RuntimeError('Cannot schedule unknown program(s): {}.'.format(unknown)) # Copy tile arrays. self.tileID = tiles['TILEID'].copy() self.passnum = tiles['PASS'].copy() self.tileRA = tiles['RA'].copy() self.tileDEC = tiles['DEC'].copy() # Count tiles. self.ntiles = len(self.tileID) self.pass_ntiles = {p: np.count_nonzero(self.passnum == p) for p in np.unique(self.passnum)} # Get list of passes. self.passes = np.unique(self.passnum) self.npasses = len(self.passes) # Map each pass to a small integer index. self.pass_index = {p: idx for idx, p in enumerate(self.passes)} # Can remove this when tile_index no longer uses searchsorted. if not np.all(np.diff(self.tileID) > 0): raise RuntimeError('Tile IDs are not increasing.') # Build program -> [passes] maps. A program with no tiles will map to an empty array. self.program_passes = { p: np.unique(self.passnum[tiles['PROGRAM'] == p]) for p in self.PROGRAMS} # Build pass -> program maps. self.pass_program = {} for p in self.PROGRAMS: self.pass_program.update({passnum: p for passnum in self.program_passes[p]}) # Build tile masks for each program. A program will no tiles with have an empty mask. self.program_mask = {} for p in self.PROGRAMS: mask = np.zeros(self.ntiles, bool) for pnum in self.program_passes[p]: mask |= (self.passnum == pnum) self.program_mask[p] = mask # Calculate and save dust exposure factors. self.dust_factor = desisurvey.etc.dust_exposure_factor(tiles['EBV_MED']) # Precompute coefficients to calculate tile observing airmass. latitude = np.radians(config.location.latitude()) tile_dec_rad = np.radians(self.tileDEC) self.tile_coef_A = np.sin(tile_dec_rad) * np.sin(latitude) self.tile_coef_B = np.cos(tile_dec_rad) * np.cos(latitude) # Placeholders for overlap attributes that are expensive to calculate # so we use lazy evaluation the first time they are accessed. self._tile_over = None self._overlapping = None self._fiberassign_delay = None
def get_tiles(tiles_file=None, use_cache=True, write_cache=True): """Return a Tiles object with optional caching. You should normally always use the default arguments to ensure that tiles are defined consistently and efficiently between different classes. Parameters ---------- tiles_file : str or None Use the specified name to override config.tiles_file. use_cache : bool Use tiles previously cached in memory when True. Otherwise, (re)load tiles from disk. write_cache : bool If tiles need to be loaded from disk with this call, save them in a memory cache for future calls. """ global _cached_tiles log = desiutil.log.get_logger() config = desisurvey.config.Configuration() tiles_file = tiles_file or config.tiles_file() if use_cache and tiles_file in _cached_tiles: tiles = _cached_tiles[tiles_file] log.debug('Using cached tiles for "{}".'.format(tiles_file)) else: tiles = Tiles(tiles_file) log.info('Initialized tiles from "{}".'.format(tiles_file)) for pname in Tiles.PROGRAMS: pinfo = [] for passnum in tiles.program_passes[pname]: pinfo.append('{}({})'.format(passnum, tiles.pass_ntiles[passnum])) log.info('{:6s} passes(tiles): {}.'.format(pname, ', '.join(pinfo))) if write_cache: _cached_tiles[tiles_file] = tiles else: log.info('Tiles not cached for "{}".'.format(tiles_file)) return tiles
def get_exposures(self, start=None, stop=None, tile_fields='tileid,pass,ra,dec,ebmv', exp_fields=('night,mjd,exptime,seeing,transparency,' + 'airmass,moonfrac,moonalt,moonsep,' + 'program,flavor')): """Create a table listing exposures in time order. Parameters ---------- start : date or None First date to include in the list of exposures, or date of the first observation if None. stop : date or None Last date to include in the list of exposures, or date of the last observation if None. tile_fields : str Comma-separated list of per-tile field names to include. The special name 'index' denotes the index into the visible tile array. The special name 'ebmv' adds median E(B-V) values for each tile from the tile design file. exp_fields : str Comma-separated list of per-exposure field names to include. The special name 'snr2cum' denotes the cummulative snr2frac on each tile, since the start of the survey. The special name 'night' denotes a string YYYYMMDD specifying the date on which each night starts. The special name 'lst' denotes the apparent local sidereal time of the shutter open timestamp. The special name 'expid' denotes the index of each exposure in the full progress record starting from zero. Returns ------- astropy.table.Table Table with the specified columns as uppercase and one row per exposure. """ # Get MJD range to show. if start is None: start = self.first_mjd start = desisurvey.utils.local_noon_on_date( desisurvey.utils.get_date(start)).mjd if stop is None: stop = self.last_mjd stop = desisurvey.utils.local_noon_on_date( desisurvey.utils.get_date(stop)).mjd + 1 if start >= stop: raise ValueError('Expected start < stop.') # Build a list of exposures in time sequence. table = self._table mjd = table['mjd'].data.flatten() order = np.argsort(mjd) tile_index = (order // self.max_exposures) # Assign each exposure a sequential index starting from zero. ntot = len(mjd) nexp = np.count_nonzero(mjd > 0) expid = np.empty(ntot, int) expid[order] = np.arange(nexp - ntot, nexp) # Restrict to the requested date range. first, last = np.searchsorted(mjd, [start, stop], sorter=order) tile_index = tile_index[first:last + 1] order = order[first:last + 1] assert np.all(expid[order] >= 0) # Create the output table. tileinfo = None output = astropy.table.Table() output.meta['EXTNAME'] = 'EXPOSURES' for name in tile_fields.split(','): name = name.lower() if name == 'index': output[name.upper()] = tile_index elif name == 'ebmv': if tileinfo is None: config = desisurvey.config.Configuration() tileinfo = astropy.table.Table( desimodel.io.load_tiles(onlydesi=True, extra=False, tilesfile=config.tiles_file())) assert np.all(tileinfo['TILEID'] == table['tileid']) output[name.upper()] = tileinfo['EBV_MED'][tile_index] else: if name not in table.colnames or len(table[name].shape) != 1: raise ValueError( 'Invalid tile field name: {0}.'.format(name)) output[name.upper()] = table[name][tile_index] for name in exp_fields.split(','): name = name.lower() if name == 'snr2cum': snr2cum = np.cumsum( table['snr2frac'], axis=1).flatten()[order] output[name.upper()] = astropy.table.Column( snr2cum, format='%.3f', description='Cummulative fraction of target S/N**2') elif name == 'night': mjd = table['mjd'].flatten()[order] night = np.empty(len(mjd), dtype=(str, 8)) for i in range(len(mjd)): night[i] = str(desisurvey.utils.get_date(mjd[i])).replace('-', '') output[name.upper()] = astropy.table.Column( night, description='Date at start of night when exposure taken') elif name == 'lst': mjd = table['mjd'].flatten()[order] times = astropy.time.Time( mjd, format='mjd', location=desisurvey.utils.get_location()) lst = times.sidereal_time('apparent').to(u.deg).value output[name.upper()] = astropy.table.Column( lst, format='%.1f', unit='deg', description='Apparent local sidereal time in degrees') elif name == 'program': exppass = table['pass'][tile_index] try: from desimodel.footprint import pass2program program = pass2program(exppass) except ImportError: #- desimodel < 0.9.1 doesn't have pass2program, so #- hardcode the mapping that it did have program = np.empty(len(exppass), dtype=(str, 6)) program[:] = 'BRIGHT' program[exppass < 4] = 'DARK' program[exppass == 4] = 'GRAY' proglen = len(max(program, key=len)) if proglen < 6: # need at least six characters for 'CALIB' program proglen = 6 output[name.upper()] = astropy.table.Column(program, dtype='<U{}'.format(proglen), description='Program name') elif name == 'flavor': flavor = np.empty(len(exppass), dtype=(str, 7)) flavor[:] = 'science' output[name.upper()] = astropy.table.Column(flavor, description='Exposure flavor') elif name == 'expid': output[name.upper()] = astropy.table.Column( expid[order], description='Exposure index') else: if name not in table.colnames or len(table[name].shape) != 2: raise ValueError( 'Invalid exposure field name: {0}.'.format(name)) output[name.upper()] = table[name].flatten()[order] return output
def __init__(self, restore=None, max_exposures=32): self.log = desiutil.log.get_logger() # Lookup the completeness SNR2 threshold to use. config = desisurvey.config.Configuration() self.min_snr2 = config.min_snr2_fraction() if restore is None: # Load the list of tiles to observe. tiles = astropy.table.Table( desimodel.io.load_tiles(onlydesi=True, extra=False, tilesfile=config.tiles_file() )) num_tiles = len(tiles) # Initialize a new progress table. meta = dict(VERSION=_version) table = astropy.table.Table(meta=meta) table['tileid'] = astropy.table.Column( length=num_tiles, dtype=np.int32, description='DESI footprint tile ID') table['pass'] = astropy.table.Column( length=num_tiles, dtype=np.int32, description='Observing pass number starting at zero') table['ra'] = astropy.table.Column( length=num_tiles, description='TILE center RA in degrees', unit='deg', format='%.1f') table['dec'] = astropy.table.Column( length=num_tiles, description='TILE center DEC in degrees', unit='deg', format='%.1f') table['status'] = astropy.table.Column( length=num_tiles, dtype=np.int32, description='Observing status: 0=none, 1=partial, 2=done') table['covered'] = astropy.table.Column( length=num_tiles, dtype=np.int32, description='Tile covered on this day number >=0 (or -1)') table['available'] = astropy.table.Column( length=num_tiles, dtype=np.int32, description='Tile available on this day number >=0 (or -1)') table['planned'] = astropy.table.Column( length=num_tiles, dtype=np.int32, description='Tile first planned on this day number >=0 (or -1)') # Add per-exposure columns. table['mjd'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.5f', description='MJD of exposure start time') table['exptime'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.1f', description='Exposure duration in seconds', unit='s') table['snr2frac'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.3f', description='Fraction of target S/N**2 ratio achieved') table['airmass'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.1f', description='Estimated airmass of observation') table['seeing'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.1f', description='Estimated FWHM seeing of observation in arcsecs', unit='arcsec') table['transparency'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.1f', description='Estimated transparency of observation') table['moonfrac'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.3f', description='Moon illuminated fraction (0-1)') table['moonalt'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.1f', description='Moon altitude angle in degrees', unit='deg') table['moonsep'] = astropy.table.Column( length=num_tiles, shape=(max_exposures,), format='%.1f', description='Moon-tile separation angle in degrees', unit='deg') # Copy tile data. table['tileid'] = tiles['TILEID'] table['pass'] = tiles['PASS'] table['ra'] = tiles['RA'] table['dec'] = tiles['DEC'] # Initialize other columns. table['status'] = 0 table['covered'] = -1 table['available'] = -1 table['planned'] = -1 table['mjd'] = 0. table['exptime'] = 0. table['snr2frac'] = 0. table['airmass'] = 0. table['seeing'] = 0. table['transparency'] = 0. else: if isinstance(restore, Progress): table = restore._table elif isinstance(restore, astropy.table.Table): table = restore else: filename = config.get_path(restore) if not os.path.exists(filename): raise ValueError('Invalid restore: {0}.'.format(restore)) table = astropy.table.Table.read(filename) self.log.info('Loaded progress from {0}.'.format(filename)) # Check that this table has the current version. if table.meta['VERSION'] != _version: raise RuntimeError( 'Progress table has incompatible version {0}.' .format(table.meta['VERSION'])) # Check that the status column matches the current min_snr2. snr2sum = table['snr2frac'].data.sum(axis=1) if not np.all(snr2sum >= 0): raise RuntimeError('Found invalid snr2frac values.') status = np.ones_like(table['status']) status[snr2sum == 0] = 0 status[snr2sum >= self.min_snr2] = 2 if not np.all(table['status'] == status): self.log.warn('Updating status values for min(SNR2) = {0:.1f}.' .format(self.min_snr2)) table['status'] = status # We could do more sanity checks here, but they shouldn't be # necessary unless the table has been modified outside this class. # Initialize attributes from table data. self._table = table mjd = table['mjd'].data observed = mjd > 0 if np.any(observed): self._num_exp = np.count_nonzero(observed) self._first_mjd = np.min(mjd[observed]) self._last_mjd = np.max(mjd[observed]) last = np.argmax(mjd.max(axis=1)) self._last_tile = self._table[last] else: self._num_exp = 0 self._first_mjd = self._last_mjd = 0. self._last_tile = None
def initialize(ephem, start_date=None, stop_date=None, step_size=5.0, healpix_nside=16, output_name='scheduler.fits'): """Calculate exposure-time factors over a grid of times and pointings. Takes about 9 minutes to run and writes a 1.3Gb output file with the default parameters. Requires that healpy is installed. Parameters ---------- ephem : desisurvey.ephem.Ephemerides Tabulated ephemerides data to use for planning. start_date : date or None Survey planning starts on the evening of this date. Must be convertible to a date using :func:`desisurvey.utils.get_date`. Use the first night of the ephemerides when None. stop_date : date or None Survey planning stops on the morning of this date. Must be convertible to a date using :func:`desisurvey.utils.get_date`. Use the first night of the ephemerides when None. step_size : :class:`astropy.units.Quantity` Exposure-time factors are tabulated at this interval during each night. healpix_nside : int Healpix NSIDE parameter to use for binning the sky. Must be a power of two. Values larger than 16 will lead to holes in the footprint with the current implementation. output_name : str Name of the FITS output file where results are saved. A relative path refers to the :meth:`configuration output path <desisurvey.config.Configuration.get_path>`. """ import healpy if not isinstance(step_size, u.Quantity): step_size = step_size * u.min log = desiutil.log.get_logger() # Freeze IERS table for consistent results. desisurvey.utils.freeze_iers() config = desisurvey.config.Configuration() output_name = config.get_path(output_name) start_date = desisurvey.utils.get_date(start_date or config.first_day()) stop_date = desisurvey.utils.get_date(stop_date or config.last_day()) if start_date >= stop_date: raise ValueError('Expected start_date < stop_date.') mjd = ephem._table['noon'] sel = ((mjd >= desisurvey.utils.local_noon_on_date(start_date).mjd) & (mjd < desisurvey.utils.local_noon_on_date(stop_date).mjd)) t = ephem._table[sel] num_nights = len(t) # Build a grid of elapsed time relative to local midnight during each night. midnight = t['noon'] + 0.5 t_edges = desisurvey.ephem.get_grid(step_size) t_centers = 0.5 * (t_edges[1:] + t_edges[:-1]) num_points = len(t_centers) # Create an empty HDU0 with header info. header = astropy.io.fits.Header() header['START'] = str(start_date) header['STOP'] = str(stop_date) header['NSIDE'] = healpix_nside header['NPOINTS'] = num_points header['STEP'] = step_size.to(u.min).value hdus = astropy.io.fits.HDUList() hdus.append(astropy.io.fits.ImageHDU(header=header)) # Save time grid. hdus.append(astropy.io.fits.ImageHDU(name='GRID', data=t_edges)) # Load the list of tiles to observe. tiles = astropy.table.Table( desimodel.io.load_tiles(onlydesi=True, extra=False, tilesfile=config.tiles_file())) # Build the footprint as a healpix map of the requested size. # The footprint includes any pixel containing at least one tile center. npix = healpy.nside2npix(healpix_nside) footprint = np.zeros(npix, bool) pixels = healpy.ang2pix(healpix_nside, np.radians(90 - tiles['DEC'].data), np.radians(tiles['RA'].data)) footprint[np.unique(pixels)] = True footprint_pixels = np.where(footprint)[0] num_footprint = len(footprint_pixels) log.info('Footprint contains {0} pixels.'.format(num_footprint)) # Sort pixels in order of increasing phi + 60deg so that the north and south # galactic caps are contiguous in the arrays we create below. pix_theta, pix_phi = healpy.pix2ang(healpix_nside, footprint_pixels) pix_dphi = np.fmod(pix_phi + np.pi / 3, 2 * np.pi) sort_order = np.argsort(pix_dphi) footprint_pixels = footprint_pixels[sort_order] # Calculate sorted pixel (ra,dec). pix_theta, pix_phi = healpy.pix2ang(healpix_nside, footprint_pixels) pix_ra, pix_dec = np.degrees(pix_phi), 90 - np.degrees(pix_theta) # Record per-tile info needed for planning. table = astropy.table.Table() table['tileid'] = tiles['TILEID'].astype(np.int32) table['ra'] = tiles['RA'].astype(np.float32) table['dec'] = tiles['DEC'].astype(np.float32) table['EBV'] = tiles['EBV_MED'].astype(np.float32) table['pass'] = tiles['PASS'].astype(np.int16) # Map each tile ID to the corresponding index in our spatial arrays. mapper = np.zeros(npix, int) mapper[footprint_pixels] = np.arange(len(footprint_pixels)) table['map'] = mapper[pixels].astype(np.int16) # Use a small int to identify the program, ordered by sky brightness: # 1=DARK, 2=GRAY, 3=BRIGHT. table['program'] = np.full(len(tiles), 4, np.int16) for i, program in enumerate(('DARK', 'GRAY', 'BRIGHT')): table['program'][tiles['PROGRAM'] == program] = i + 1 assert np.all(table['program'] > 0) hdu = astropy.io.fits.table_to_hdu(table) hdu.name = 'TILES' hdus.append(hdu) # Average E(B-V) for all tiles falling into a pixel. tiles_per_pixel = np.bincount(pixels, minlength=npix) EBV = np.bincount(pixels, weights=tiles['EBV_MED'], minlength=npix) EBV[footprint] /= tiles_per_pixel[footprint] # Calculate dust extinction exposure-time factor. f_EBV = 1. / desisurvey.etc.dust_exposure_factor(EBV) # Save HDU with the footprint and static dust exposure map. table = astropy.table.Table() table['pixel'] = footprint_pixels table['dust'] = f_EBV[footprint_pixels] table['ra'] = pix_ra table['dec'] = pix_dec hdu = astropy.io.fits.table_to_hdu(table) hdu.name = 'STATIC' hdus.append(hdu) # Prepare a table of calendar data. calendar = astropy.table.Table() calendar['midnight'] = midnight calendar['monsoon'] = np.zeros(num_nights, bool) calendar['fullmoon'] = np.zeros(num_nights, bool) calendar['weather'] = np.zeros(num_nights, np.float32) # Hardcode annualized average weight. weather_weights = np.full(num_nights, 0.723) # Prepare a table of ephemeris data. etable = astropy.table.Table() # Program codes ordered by increasing sky brightness: # 1=DARK, 2=GRAY, 3=BRIGHT, 4=DAYTIME. etable['program'] = np.full(num_nights * num_points, 4, dtype=np.int16) etable['moon_frac'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['moon_ra'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['moon_dec'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['moon_alt'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['zenith_ra'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['zenith_dec'] = np.zeros(num_nights * num_points, dtype=np.float32) # Tabulate MJD and apparent LST values for each time step. We don't save # MJD values since they are cheap to reconstruct from the index, but # do use them below. mjd0 = desisurvey.utils.local_noon_on_date(start_date).mjd + 0.5 mjd = mjd0 + np.arange(num_nights)[:, np.newaxis] + t_centers times = astropy.time.Time(mjd, format='mjd', location=desisurvey.utils.get_location()) etable['lst'] = times.sidereal_time('apparent').flatten().to(u.deg).value # Build sky coordinates for each pixel in the footprint. pix_theta, pix_phi = healpy.pix2ang(healpix_nside, footprint_pixels) pix_ra, pix_dec = np.degrees(pix_phi), 90 - np.degrees(pix_theta) pix_sky = astropy.coordinates.ICRS(pix_ra * u.deg, pix_dec * u.deg) # Initialize exposure factor calculations. alt, az = np.full(num_points, 90.) * u.deg, np.zeros(num_points) * u.deg fexp = np.zeros((num_nights * num_points, num_footprint), dtype=np.float32) vband_extinction = 0.15154 one = np.ones((num_points, num_footprint)) # Loop over nights. for i in range(num_nights): night = ephem.get_night(midnight[i]) date = desisurvey.utils.get_date(midnight[i]) if date.day == 1: log.info('Starting {0} (completed {1}/{2} nights)'.format( date.strftime('%b %Y'), i, num_nights)) # Initialize the slice of the fexp[] time index for this night. sl = slice(i * num_points, (i + 1) * num_points) # Do we expect to observe on this night? calendar[i]['monsoon'] = desisurvey.utils.is_monsoon(midnight[i]) calendar[i]['fullmoon'] = ephem.is_full_moon(midnight[i]) # Look up expected dome-open fraction due to weather. calendar[i]['weather'] = weather_weights[i] # Calculate the program during this night (default is 4=DAYTIME). mjd = midnight[i] + t_centers dark, gray, bright = ephem.tabulate_program(mjd) etable['program'][sl][dark] = 1 etable['program'][sl][gray] = 2 etable['program'][sl][bright] = 3 # Zero the exposure factor whenever we are not oberving. ##fexp[sl] = (dark | gray | bright)[:, np.newaxis] fexp[sl] = 1. # Transform the local zenith to (ra,dec). zenith = desisurvey.utils.get_observer( times[i], alt=alt, az=az).transform_to(astropy.coordinates.ICRS) etable['zenith_ra'][sl] = zenith.ra.to(u.deg).value etable['zenith_dec'][sl] = zenith.dec.to(u.deg).value # Calculate zenith angles to each pixel in the footprint. pix_sep = pix_sky.separation(zenith[:, np.newaxis]) # Zero the exposure factor for pixels below the horizon. visible = pix_sep < 90 * u.deg fexp[sl][~visible] = 0. # Calculate the airmass exposure-time penalty. X = desisurvey.utils.cos_zenith_to_airmass(np.cos(pix_sep[visible])) fexp[sl][visible] /= desisurvey.etc.airmass_exposure_factor(X) # Loop over objects we need to avoid. for name in config.avoid_bodies.keys: f_obj = desisurvey.ephem.get_object_interpolator(night, name) # Calculate this object's (dec,ra) path during the night. obj_dec, obj_ra = f_obj(mjd) sky_obj = astropy.coordinates.ICRS( ra=obj_ra[:, np.newaxis] * u.deg, dec=obj_dec[:, np.newaxis] * u.deg) # Calculate the separation angles to each pixel in the footprint. obj_sep = pix_sky.separation(sky_obj) if name == 'moon': etable['moon_ra'][sl] = obj_ra etable['moon_dec'][sl] = obj_dec # Calculate moon altitude during the night. moon_alt, _ = desisurvey.ephem.get_object_interpolator( night, 'moon', altaz=True)(mjd) etable['moon_alt'][sl] = moon_alt moon_zenith = (90 - moon_alt[:, np.newaxis]) * u.deg moon_up = moon_alt > 0 assert np.all(moon_alt[gray] > 0) # Calculate the moon illuminated fraction during the night. moon_frac = ephem.get_moon_illuminated_fraction(mjd) etable['moon_frac'][sl] = moon_frac # Convert to temporal moon phase. moon_phase = np.arccos(2 * moon_frac[:, np.newaxis] - 1) / np.pi # Calculate scattered moon V-band brightness at each pixel. V = specsim.atmosphere.krisciunas_schaefer( pix_sep, moon_zenith, obj_sep, moon_phase, desisurvey.etc._vband_extinction).value # Estimate the exposure time factor from V. X = np.dstack((one, np.exp(-V), 1 / V, 1 / V**2, 1 / V**3)) T = X.dot(desisurvey.etc._moonCoefficients) # No penalty when the moon is below the horizon. T[moon_alt < 0, :] = 1. fexp[sl] *= 1. / T # Veto pointings within avoidance size when the moon is # above the horizon. Apply Gaussian smoothing to the veto edge. veto = np.ones_like(T) dsep = (obj_sep - config.avoid_bodies.moon()).to(u.deg).value veto[dsep <= 0] = 0. veto[dsep > 0] = 1 - np.exp(-0.5 * (dsep[dsep > 0] / 3)**2) veto[moon_alt < 0] = 1. fexp[sl] *= veto else: # Lookup the avoidance size for this object. size = getattr(config.avoid_bodies, name)() # Penalize the exposure-time with a factor # 1 - exp(-0.5*(obj_sep/size)**2) penalty = 1. - np.exp(-0.5 * (obj_sep / size).to(1).value**2) fexp[sl] *= penalty # Save calendar table. hdu = astropy.io.fits.table_to_hdu(calendar) hdu.name = 'CALENDAR' hdus.append(hdu) # Save ephemerides table. hdu = astropy.io.fits.table_to_hdu(etable) hdu.name = 'EPHEM' hdus.append(hdu) # Save dynamic exposure-time factors. hdus.append(astropy.io.fits.ImageHDU(name='DYNAMIC', data=fexp)) # Finalize the output file. try: hdus.writeto(output_name, overwrite=True) except TypeError: # astropy < 1.3 uses the now deprecated clobber. hdus.writeto(output_name, clobber=True) log.info('Plan initialization saved to {0}'.format(output_name))
def initialize(ephem, start_date=None, stop_date=None, step_size=5.0, healpix_nside=16, output_name='scheduler.fits'): """Calculate exposure-time factors over a grid of times and pointings. Takes about 9 minutes to run and writes a 1.3Gb output file with the default parameters. Requires that healpy is installed. Parameters ---------- ephem : desisurvey.ephem.Ephemerides Tabulated ephemerides data to use for planning. start_date : date or None Survey planning starts on the evening of this date. Must be convertible to a date using :func:`desisurvey.utils.get_date`. Use the first night of the ephemerides when None. stop_date : date or None Survey planning stops on the morning of this date. Must be convertible to a date using :func:`desisurvey.utils.get_date`. Use the first night of the ephemerides when None. step_size : :class:`astropy.units.Quantity` Exposure-time factors are tabulated at this interval during each night. healpix_nside : int Healpix NSIDE parameter to use for binning the sky. Must be a power of two. Values larger than 16 will lead to holes in the footprint with the current implementation. output_name : str Name of the FITS output file where results are saved. A relative path refers to the :meth:`configuration output path <desisurvey.config.Configuration.get_path>`. """ import healpy if not isinstance(step_size, u.Quantity): step_size = step_size * u.min log = desiutil.log.get_logger() # Freeze IERS table for consistent results. desisurvey.utils.freeze_iers() config = desisurvey.config.Configuration() output_name = config.get_path(output_name) start_date = desisurvey.utils.get_date(start_date or config.first_day()) stop_date = desisurvey.utils.get_date(stop_date or config.last_day()) if start_date >= stop_date: raise ValueError('Expected start_date < stop_date.') mjd = ephem._table['noon'] sel = ((mjd >= desisurvey.utils.local_noon_on_date(start_date).mjd) & (mjd < desisurvey.utils.local_noon_on_date(stop_date).mjd)) t = ephem._table[sel] num_nights = len(t) # Build a grid of elapsed time relative to local midnight during each night. midnight = t['noon'] + 0.5 t_edges = desisurvey.ephem.get_grid(step_size) t_centers = 0.5 * (t_edges[1:] + t_edges[:-1]) num_points = len(t_centers) # Create an empty HDU0 with header info. header = astropy.io.fits.Header() header['START'] = str(start_date) header['STOP'] = str(stop_date) header['NSIDE'] = healpix_nside header['NPOINTS'] = num_points header['STEP'] = step_size.to(u.min).value hdus = astropy.io.fits.HDUList() hdus.append(astropy.io.fits.ImageHDU(header=header)) # Save time grid. hdus.append(astropy.io.fits.ImageHDU(name='GRID', data=t_edges)) # Load the list of tiles to observe. tiles = astropy.table.Table( desimodel.io.load_tiles(onlydesi=True, extra=False, tilesfile=config.tiles_file())) # Build the footprint as a healpix map of the requested size. # The footprint includes any pixel containing at least one tile center. npix = healpy.nside2npix(healpix_nside) footprint = np.zeros(npix, bool) pixels = healpy.ang2pix( healpix_nside, np.radians(90 - tiles['DEC'].data), np.radians(tiles['RA'].data)) footprint[np.unique(pixels)] = True footprint_pixels = np.where(footprint)[0] num_footprint = len(footprint_pixels) log.info('Footprint contains {0} pixels.'.format(num_footprint)) # Sort pixels in order of increasing phi + 60deg so that the north and south # galactic caps are contiguous in the arrays we create below. pix_theta, pix_phi = healpy.pix2ang(healpix_nside, footprint_pixels) pix_dphi = np.fmod(pix_phi + np.pi / 3, 2 * np.pi) sort_order = np.argsort(pix_dphi) footprint_pixels = footprint_pixels[sort_order] # Calculate sorted pixel (ra,dec). pix_theta, pix_phi = healpy.pix2ang(healpix_nside, footprint_pixels) pix_ra, pix_dec = np.degrees(pix_phi), 90 - np.degrees(pix_theta) # Record per-tile info needed for planning. table = astropy.table.Table() table['tileid'] = tiles['TILEID'].astype(np.int32) table['ra'] = tiles['RA'].astype(np.float32) table['dec'] = tiles['DEC'].astype(np.float32) table['EBV'] = tiles['EBV_MED'].astype(np.float32) table['pass'] = tiles['PASS'].astype(np.int16) # Map each tile ID to the corresponding index in our spatial arrays. mapper = np.zeros(npix, int) mapper[footprint_pixels] = np.arange(len(footprint_pixels)) table['map'] = mapper[pixels].astype(np.int16) # Use a small int to identify the program, ordered by sky brightness: # 1=DARK, 2=GRAY, 3=BRIGHT. table['program'] = np.full(len(tiles), 4, np.int16) for i, program in enumerate(('DARK', 'GRAY', 'BRIGHT')): table['program'][tiles['PROGRAM'] == program] = i + 1 assert np.all(table['program'] > 0) hdu = astropy.io.fits.table_to_hdu(table) hdu.name = 'TILES' hdus.append(hdu) # Average E(B-V) for all tiles falling into a pixel. tiles_per_pixel = np.bincount(pixels, minlength=npix) EBV = np.bincount(pixels, weights=tiles['EBV_MED'], minlength=npix) EBV[footprint] /= tiles_per_pixel[footprint] # Calculate dust extinction exposure-time factor. f_EBV = 1. / desisurvey.etc.dust_exposure_factor(EBV) # Save HDU with the footprint and static dust exposure map. table = astropy.table.Table() table['pixel'] = footprint_pixels table['dust'] = f_EBV[footprint_pixels] table['ra'] = pix_ra table['dec'] = pix_dec hdu = astropy.io.fits.table_to_hdu(table) hdu.name = 'STATIC' hdus.append(hdu) # Prepare a table of calendar data. calendar = astropy.table.Table() calendar['midnight'] = midnight calendar['monsoon'] = np.zeros(num_nights, bool) calendar['fullmoon'] = np.zeros(num_nights, bool) calendar['weather'] = np.zeros(num_nights, np.float32) # Hardcode annualized average weight. weather_weights = np.full(num_nights, 0.723) # Prepare a table of ephemeris data. etable = astropy.table.Table() # Program codes ordered by increasing sky brightness: # 1=DARK, 2=GRAY, 3=BRIGHT, 4=DAYTIME. etable['program'] = np.full(num_nights * num_points, 4, dtype=np.int16) etable['moon_frac'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['moon_ra'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['moon_dec'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['moon_alt'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['zenith_ra'] = np.zeros(num_nights * num_points, dtype=np.float32) etable['zenith_dec'] = np.zeros(num_nights * num_points, dtype=np.float32) # Tabulate MJD and apparent LST values for each time step. We don't save # MJD values since they are cheap to reconstruct from the index, but # do use them below. mjd0 = desisurvey.utils.local_noon_on_date(start_date).mjd + 0.5 mjd = mjd0 + np.arange(num_nights)[:, np.newaxis] + t_centers times = astropy.time.Time( mjd, format='mjd', location=desisurvey.utils.get_location()) etable['lst'] = times.sidereal_time('apparent').flatten().to(u.deg).value # Build sky coordinates for each pixel in the footprint. pix_theta, pix_phi = healpy.pix2ang(healpix_nside, footprint_pixels) pix_ra, pix_dec = np.degrees(pix_phi), 90 - np.degrees(pix_theta) pix_sky = astropy.coordinates.ICRS(pix_ra * u.deg, pix_dec * u.deg) # Initialize exposure factor calculations. alt, az = np.full(num_points, 90.) * u.deg, np.zeros(num_points) * u.deg fexp = np.zeros((num_nights * num_points, num_footprint), dtype=np.float32) vband_extinction = 0.15154 one = np.ones((num_points, num_footprint)) # Loop over nights. for i in range(num_nights): night = ephem.get_night(midnight[i]) date = desisurvey.utils.get_date(midnight[i]) if date.day == 1: log.info('Starting {0} (completed {1}/{2} nights)' .format(date.strftime('%b %Y'), i, num_nights)) # Initialize the slice of the fexp[] time index for this night. sl = slice(i * num_points, (i + 1) * num_points) # Do we expect to observe on this night? calendar[i]['monsoon'] = desisurvey.utils.is_monsoon(midnight[i]) calendar[i]['fullmoon'] = ephem.is_full_moon(midnight[i]) # Look up expected dome-open fraction due to weather. calendar[i]['weather'] = weather_weights[i] # Calculate the program during this night (default is 4=DAYTIME). mjd = midnight[i] + t_centers dark, gray, bright = ephem.tabulate_program(mjd) etable['program'][sl][dark] = 1 etable['program'][sl][gray] = 2 etable['program'][sl][bright] = 3 # Zero the exposure factor whenever we are not oberving. ##fexp[sl] = (dark | gray | bright)[:, np.newaxis] fexp[sl] = 1. # Transform the local zenith to (ra,dec). zenith = desisurvey.utils.get_observer( times[i], alt=alt, az=az).transform_to(astropy.coordinates.ICRS) etable['zenith_ra'][sl] = zenith.ra.to(u.deg).value etable['zenith_dec'][sl] = zenith.dec.to(u.deg).value # Calculate zenith angles to each pixel in the footprint. pix_sep = pix_sky.separation(zenith[:, np.newaxis]) # Zero the exposure factor for pixels below the horizon. visible = pix_sep < 90 * u.deg fexp[sl][~visible] = 0. # Calculate the airmass exposure-time penalty. X = desisurvey.utils.cos_zenith_to_airmass(np.cos(pix_sep[visible])) fexp[sl][visible] /= desisurvey.etc.airmass_exposure_factor(X) # Loop over objects we need to avoid. for name in config.avoid_bodies.keys: f_obj = desisurvey.ephem.get_object_interpolator(night, name) # Calculate this object's (dec,ra) path during the night. obj_dec, obj_ra = f_obj(mjd) sky_obj = astropy.coordinates.ICRS( ra=obj_ra[:, np.newaxis] * u.deg, dec=obj_dec[:, np.newaxis] * u.deg) # Calculate the separation angles to each pixel in the footprint. obj_sep = pix_sky.separation(sky_obj) if name == 'moon': etable['moon_ra'][sl] = obj_ra etable['moon_dec'][sl] = obj_dec # Calculate moon altitude during the night. moon_alt, _ = desisurvey.ephem.get_object_interpolator( night, 'moon', altaz=True)(mjd) etable['moon_alt'][sl] = moon_alt moon_zenith = (90 - moon_alt[:,np.newaxis]) * u.deg moon_up = moon_alt > 0 assert np.all(moon_alt[gray] > 0) # Calculate the moon illuminated fraction during the night. moon_frac = ephem.get_moon_illuminated_fraction(mjd) etable['moon_frac'][sl] = moon_frac # Convert to temporal moon phase. moon_phase = np.arccos(2 * moon_frac[:,np.newaxis] - 1) / np.pi # Calculate scattered moon V-band brightness at each pixel. V = specsim.atmosphere.krisciunas_schaefer( pix_sep, moon_zenith, obj_sep, moon_phase, desisurvey.etc._vband_extinction).value # Estimate the exposure time factor from V. X = np.dstack((one, np.exp(-V), 1/V, 1/V**2, 1/V**3)) T = X.dot(desisurvey.etc._moonCoefficients) # No penalty when the moon is below the horizon. T[moon_alt < 0, :] = 1. fexp[sl] *= 1. / T # Veto pointings within avoidance size when the moon is # above the horizon. Apply Gaussian smoothing to the veto edge. veto = np.ones_like(T) dsep = (obj_sep - config.avoid_bodies.moon()).to(u.deg).value veto[dsep <= 0] = 0. veto[dsep > 0] = 1 - np.exp(-0.5 * (dsep[dsep > 0] / 3) ** 2) veto[moon_alt < 0] = 1. fexp[sl] *= veto else: # Lookup the avoidance size for this object. size = getattr(config.avoid_bodies, name)() # Penalize the exposure-time with a factor # 1 - exp(-0.5*(obj_sep/size)**2) penalty = 1. - np.exp(-0.5 * (obj_sep / size).to(1).value ** 2) fexp[sl] *= penalty # Save calendar table. hdu = astropy.io.fits.table_to_hdu(calendar) hdu.name = 'CALENDAR' hdus.append(hdu) # Save ephemerides table. hdu = astropy.io.fits.table_to_hdu(etable) hdu.name = 'EPHEM' hdus.append(hdu) # Save dynamic exposure-time factors. hdus.append(astropy.io.fits.ImageHDU(name='DYNAMIC', data=fexp)) # Finalize the output file. try: hdus.writeto(output_name, overwrite=True) except TypeError: # astropy < 1.3 uses the now deprecated clobber. hdus.writeto(output_name, clobber=True) log.info('Plan initialization saved to {0}'.format(output_name))