• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

desihub / desisim / 17589223409

09 Sep 2025 04:28PM UTC coverage: 44.285% (-0.04%) from 44.326%
17589223409

push

github

Stephen Bailey
update changelog and dev version after PR #589

4328 of 9773 relevant lines covered (44.29%)

0.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.79
/py/desisim/io.py
1
"""
2
desisim.io
3
==========
4

5
I/O routines for desisim
6
"""
7

8
import os
1✔
9
import time
1✔
10
from glob import glob
1✔
11
import warnings
1✔
12

13
import fitsio
1✔
14
from astropy.io import fits
1✔
15
from astropy.table import Table
1✔
16
import numpy as np
1✔
17

18
from desispec.interpolation import resample_flux
1✔
19
from desispec.io.util import write_bintable, native_endian, header2wave
1✔
20
import desispec.io
1✔
21
import desimodel.io
1✔
22

23
from desispec.image import Image
1✔
24
import desispec.io.util
1✔
25

26
from desiutil.log import get_logger
1✔
27
log = get_logger()
1✔
28

29
from desisim.util import spline_medfilt2d
1✔
30

31
#- support API change from astropy 2 -> 4
32
import astropy
1✔
33
from astropy.stats import sigma_clipped_stats
1✔
34
if astropy.__version__.startswith('2.'):
1✔
35
    astropy_sigma_clipped_stats = sigma_clipped_stats
×
36
    def sigma_clipped_stats(data, sigma=3.0, maxiters=5):
×
37
        return astropy_sigma_clipped_stats(data, sigma=sigma, iters=maxiters)
×
38

39
#-------------------------------------------------------------------------
40
def findfile(filetype, night, expid, camera=None, outdir=None, mkdir=True):
1✔
41
    """Return canonical location of where a file should be on disk
42

43
    Args:
44
        filetype (str): file type, e.g. 'preproc' or 'simpix'
45
        night (str): YEARMMDD string
46
        expid (int): exposure id integer
47
        camera (str): e.g. 'b0', 'r1', 'z9'
48
        outdir (Optional[str]): output directory; defaults to $DESI_SPECTRO_SIM/$PIXPROD
49
        mkdir (Optional[bool]): create output directory if needed; default True
50

51
    Returns:
52
        str: full file path to output file
53

54
    Also see desispec.io.findfile() which has equivalent functionality for
55
    real data files; this function is only be for simulation files.
56
    """
57

58
    #- outdir default = $DESI_SPECTRO_SIM/$PIXPROD/{night}/
59
    if outdir is None:
1✔
60
        outdir = simdir(night, expid)
1✔
61

62
    #- Definition of where files go
63
    location = dict(
1✔
64
        simspec = '{outdir:s}/simspec-{expid:08d}.fits',
65
        simpix = '{outdir:s}/simpix-{expid:08d}.fits',
66
        simfibermap = '{outdir:s}/fibermap-{expid:08d}.fits',
67
        fastframelog = '{outdir:s}/fastframe-{expid:08d}.log',
68
        newexplog = '{outdir:s}/newexp-{expid:08d}.log',
69
    )
70

71
    #- Do we know about this kind of file?
72
    if filetype not in location:
1✔
73
        raise ValueError("Unknown filetype {}; known types are {}".format(filetype, list(location.keys())))
1✔
74

75
    #- Some but not all filetypes require camera
76
    # if filetype == 'preproc' and camera is None:
77
    #     raise ValueError('camera is required for filetype '+filetype)
78

79
    #- get outfile location and cleanup extraneous // from path
80
    outfile = location[filetype].format(
1✔
81
        outdir=outdir, night=night, expid=expid, camera=camera)
82
    outfile = os.path.normpath(outfile)
1✔
83

84
    #- Create output directory path if needed
85
    #- Do this only after confirming that all previous parsing worked
86
    if mkdir and not os.path.exists(outdir):
1✔
87
        os.makedirs(outdir)
1✔
88

89
    return outfile
1✔
90

91

92
#-------------------------------------------------------------------------
93
#- simspec
94

95
def write_simspec(sim, truth, fibermap, obs, expid, night, objmeta=None,
1✔
96
                  outdir=None, filename=None, header=None, overwrite=False):
97
    '''
98
    Write a simspec file
99

100
    Args:
101
        sim (Simulator): specsim Simulator object
102
        truth (Table): truth metadata Table
103
        fibermap (Table): fibermap Table
104
        obs (dict-like): dict-like observation conditions with keys
105
            SEEING (arcsec), EXPTIME (sec), AIRMASS,
106
            MOONFRAC (0-1), MOONALT (deg), MOONSEP (deg)
107
        expid (int): integer exposure ID
108
        night (str): YEARMMDD string
109
        objmeta (dict): objtype-specific metadata
110
        outdir (str, optional): output directory
111
        filename (str, optional): if None, auto-derive from envvars, night, expid, and outdir
112
        header (dict-like): header to include in HDU0
113
        overwrite (bool, optional): overwrite pre-existing files
114

115
    Notes:
116
        Calibration exposures can use ``truth=None`` and ``obs=None``.
117
    '''
118
    import astropy.table
1✔
119
    import astropy.units as u
1✔
120
    import desiutil.depend
1✔
121
    from desiutil.log import get_logger
1✔
122
    log = get_logger()
1✔
123

124
    warnings.filterwarnings("ignore", message=".*Dex.* did not parse as fits unit")
1✔
125
    warnings.filterwarnings("ignore", message=".*nanomaggies.* did not parse as fits unit")
1✔
126
    warnings.filterwarnings("ignore", message=".*nmgy.* did not parse as fits unit")
1✔
127
    warnings.filterwarnings("ignore", message=".*nmgy.* could not be saved")
1✔
128
    warnings.filterwarnings("ignore", message=r".*10\*\*6 arcsec.* did not parse as fits unit")
1✔
129

130
    if filename is None:
1✔
131
        filename = findfile('simspec', night, expid, outdir=outdir)
×
132

133
    # sim.simulated is table of pre-convolution quantities that we want
134
    # to ouput.  sim.camera_output is post-convolution.
135

136
    #- Create HDU 0 header with keywords to propagate
137
    header = desispec.io.util.fitsheader(header)
1✔
138
    desiutil.depend.add_dependencies(header)
1✔
139
    header['EXPID'] = expid
1✔
140
    header['NIGHT'] = night
1✔
141
    header['EXPTIME'] = sim.observation.exposure_time.to('s').value
1✔
142
    if obs is not None:
1✔
143
        try:
1✔
144
            keys = obs.keys()
1✔
145
        except AttributeError:
×
146
            keys = obs.dtype.names
×
147

148
        for key in keys:
1✔
149
            shortkey = key[0:8]  #- FITS keywords can only be 8 char
1✔
150
            if shortkey not in header:
1✔
151
                header[shortkey] = obs[key]
×
152
    if 'DOSVER' not in header:
1✔
153
        header['DOSVER'] = 'SIM'
1✔
154
    if 'FEEVER' not in header:
1✔
155
        header['FEEVER'] = 'SIM'
1✔
156

157
    if 'FLAVOR' not in header:
1✔
158
        log.warning('FLAVOR not provided; guessing "science"')
×
159
        header['FLAVOR'] = 'science'    #- optimistically guessing
×
160

161
    if 'DATE-OBS' not in header:
1✔
162
        header['DATE-OBS'] = sim.observation.exposure_start.isot
×
163

164
    log.info('DATE-OBS {} UTC'.format(header['DATE-OBS']))
1✔
165

166
    #- Check truth and obs for science exposures
167
    if header['FLAVOR'] == 'science':
1✔
168
        if obs is None:
1✔
169
            raise ValueError('obs Table must be included for science exposures')
×
170
        if truth is None:
1✔
171
            raise ValueError('truth Table must be included for science exposures')
×
172

173
    hx = fits.HDUList()
1✔
174
    header['EXTNAME'] = 'WAVE'
1✔
175
    header['BUNIT'] = 'Angstrom'
1✔
176
    header['AIRORVAC']  = ('vac', 'Vacuum wavelengths')
1✔
177

178
    wave = sim.simulated['wavelength'].to('Angstrom').value
1✔
179
    hx.append(fits.PrimaryHDU(wave, header=header))
1✔
180

181
    fluxunits = 1e-17 * u.erg / (u.s * u.cm**2 * u.Angstrom)
1✔
182
    flux32 = sim.simulated['source_flux'].to(fluxunits).astype(np.float32).value.T
1✔
183
    nspec = flux32.shape[0]
1✔
184
    assert flux32.shape == (nspec, wave.shape[0])
1✔
185
    hdu_flux = fits.ImageHDU(flux32, name='FLUX')
1✔
186
    hdu_flux.header['BUNIT'] = str(fluxunits)
1✔
187
    hx.append(hdu_flux)
1✔
188

189
    #- sky_fiber_flux is not flux per fiber area, it is normal flux density
190
    skyflux32 = sim.simulated['sky_fiber_flux'].to(fluxunits).astype(np.float32).value.T
1✔
191
    assert skyflux32.shape == (nspec, wave.shape[0])
1✔
192
    hdu_skyflux = fits.ImageHDU(skyflux32, name='SKYFLUX')
1✔
193
    hdu_skyflux.header['BUNIT'] = str(fluxunits)
1✔
194
    hx.append(hdu_skyflux)
1✔
195

196
    #- DEPRECATE?  per-camera photons (derivable from flux and throughput)
197
    for i, camera in enumerate(sim.camera_names):
1✔
198
        wavemin = sim.instrument.cameras[i].wavelength_min.to('Angstrom').value
1✔
199
        wavemax = sim.instrument.cameras[i].wavelength_max.to('Angstrom').value
1✔
200
        ii = (wavemin <= wave) & (wave <= wavemax)
1✔
201
        hx.append(fits.ImageHDU(wave[ii], name='WAVE_'+camera.upper()))
1✔
202

203
        phot32 = sim.simulated['num_source_electrons_'+camera][ii].astype(np.float32).T
1✔
204

205
        assert phot32.shape == (nspec, wave[ii].shape[0])
1✔
206
        hdu_phot = fits.ImageHDU(phot32, name='PHOT_'+camera.upper())
1✔
207
        hdu_phot.header['BUNIT'] = 'photon'
1✔
208
        hx.append(hdu_phot)
1✔
209

210
        skyphot32 = sim.simulated['num_sky_electrons_'+camera][ii].astype(np.float32).T
1✔
211
        assert skyphot32.shape == (nspec, wave[ii].shape[0])
1✔
212
        hdu_skyphot = fits.ImageHDU(skyphot32, name='SKYPHOT_'+camera.upper())
1✔
213
        hdu_skyphot.header['BUNIT'] = 'photon'
1✔
214
        hx.append(hdu_skyphot)
1✔
215

216
    #- TRUTH HDU: table with truth metadata
217
    if truth is not None:
1✔
218
        assert len(truth) == nspec
1✔
219
        truthhdu = fits.table_to_hdu(Table(truth))
1✔
220
        truthhdu.header['EXTNAME'] = 'TRUTH'
1✔
221
        hx.append(truthhdu)
1✔
222

223
    #- FIBERMAP HDU
224
    assert len(fibermap) == nspec
1✔
225
    fibermap_hdu = fits.table_to_hdu(Table(fibermap))
1✔
226
    fibermap_hdu.header['EXTNAME'] = 'FIBERMAP'
1✔
227
    hx.append(fibermap_hdu)
1✔
228

229
    #- OBSCONDITIONS HDU: Table with 1 row with observing conditions
230
    #- is None for flat calibration calibration exposures
231
    if obs is not None:
1✔
232
        if isinstance(obs, astropy.table.Row):
1✔
233
            obstable = astropy.table.Table(obs)
×
234
        else:
235
            obstable = astropy.table.Table([obs,])
1✔
236
        obs_hdu = fits.table_to_hdu(obstable)
1✔
237
        obs_hdu.header['EXTNAME'] = 'OBSCONDITIONS'
1✔
238
        hx.append(obs_hdu)
1✔
239

240
    # Objtype-specific metadata
241
    if truth is not None and objmeta is not None:
1✔
242
        for obj in sorted(objmeta.keys()):
1✔
243
            extname = 'TRUTH_{}'.format(obj.upper())
1✔
244
            if hasattr(objmeta[obj], 'data'): # simqso.sqgrids.QsoSimPoints object
1✔
245
                tab = objmeta[obj].data
×
246
                tab.meta['COSMO'] = objmeta[obj].cosmo_str(objmeta[obj].cosmo)
×
247
                tab.meta['GRIDUNIT'] = objmeta[obj].units
×
248
                tab.meta['GRIDDIM'] = str(objmeta[obj].gridShape)
×
249
                tab.meta['RANDSEED'] = str(objmeta[obj].seed)
×
250
                if objmeta[obj].photoMap is not None:
×
251
                    tab.meta['OBSBANDS'] = ','.join(objmeta[obj].photoMap['bandpasses'])
×
252
                for i,var in enumerate(objmeta[obj].qsoVars):
×
253
                    var.updateMeta(tab.meta,'AX%d'%i)
×
254
                tab.meta['NSIMVAR'] = len(objmeta[obj].qsoVars)
×
255

256
                objhdu = fits.table_to_hdu(tab)
×
257
                objhdu.header['EXTNAME'] = extname
×
258
                hx.append(objhdu)
×
259
            else:
260
                if len(objmeta) > 0 and len(objmeta[obj]) > 0:
1✔
261
                    objhdu = fits.table_to_hdu(objmeta[obj])
1✔
262
                    objhdu.header['EXTNAME'] = extname
1✔
263
                    hx.append(objhdu)
1✔
264

265
    tmpfilename = filename + '.tmp'
1✔
266
    if not overwrite and os.path.exists(filename):
1✔
267
        os.rename(filename, tmpfilename)
×
268

269
    hx.writeto(tmpfilename, overwrite=overwrite)
1✔
270
    os.rename(tmpfilename, filename)
1✔
271
    log.info(f'Wrote {filename}')
1✔
272

273
def write_simspec_arc(filename, wave, phot, header, fibermap, overwrite=False):
1✔
274
    '''
275
    Alternate writer for arc simspec files which just have photons
276
    '''
277
    import astropy.table
1✔
278
    import astropy.units as u
1✔
279

280
    warnings.filterwarnings("ignore", message=".*Dex.* did not parse as fits unit")
1✔
281
    warnings.filterwarnings("ignore", message=".*nanomaggies.* did not parse as fits unit")
1✔
282
    warnings.filterwarnings("ignore", message=".*nmgy.* did not parse as fits unit")
1✔
283
    warnings.filterwarnings("ignore", message=".*nmgy.* could not be saved")
1✔
284
    warnings.filterwarnings("ignore", message=r".*10\*\*6 arcsec.* did not parse as fits unit")
1✔
285
                    
286
    hx = fits.HDUList()
1✔
287
    hdr = desispec.io.util.fitsheader(header)
1✔
288
    hdr['FLAVOR'] = 'arc'
1✔
289
    if 'DOSVER' not in hdr:
1✔
290
        hdr['DOSVER'] = 'SIM'
1✔
291
    if 'FEEVER' not in header:
1✔
292
        hdr['FEEVER'] = 'SIM'
1✔
293

294
    hx.append(fits.PrimaryHDU(None, header=hdr))
1✔
295

296
    for camera in ['b', 'r', 'z']:
1✔
297
        thru = desimodel.io.load_throughput(camera)
1✔
298
        ii = (thru.wavemin <= wave) & (wave <= thru.wavemax)
1✔
299
        hdu_wave = fits.ImageHDU(wave[ii], name='WAVE_'+camera.upper())
1✔
300
        hdu_wave.header['AIRORVAC']  = ('vac', 'Vacuum wavelengths')
1✔
301
        hx.append(hdu_wave)
1✔
302

303
        phot32 = phot[:,ii].astype(np.float32)
1✔
304
        hdu_phot = fits.ImageHDU(phot32, name='PHOT_'+camera.upper())
1✔
305
        hdu_phot.header['BUNIT'] = 'photon'
1✔
306
        hx.append(hdu_phot)
1✔
307

308
    #- FIBERMAP HDU
309
    fibermap_hdu = fits.table_to_hdu(fibermap)
1✔
310
    fibermap_hdu.header['EXTNAME'] = 'FIBERMAP'
1✔
311
    hx.append(fibermap_hdu)
1✔
312

313
    log.info('Writing {}'.format(filename))
1✔
314
    hx.writeto(filename, overwrite=overwrite)
1✔
315
    return filename
1✔
316

317
class SimSpecCamera(object):
1✔
318
    """Wrapper of per-camera photon data from a simspec file"""
319
    def __init__(self, camera, wave, phot, skyphot=None):
1✔
320
        """
321
        Create a SimSpecCamera object
322

323
        Args:
324
            camera: camera name, e.g. b0, r1, z9
325
            wave: 1D[nwave] array of wavelengths [Angstrom]
326
            phot: 2D[nspec, nwave] photon counts per bin (not per Angstrom)
327

328
        Options:
329
            skyphot: 2D[nspec, nwave] sky photon counts per bin
330
        """
331
        #- Check inputs
332
        assert camera in [
1✔
333
            'b0', 'r0', 'z0', 'b1', 'r1', 'z1',
334
            'b2', 'r2', 'z2', 'b3', 'r3', 'z3',
335
            'b4', 'r4', 'z4', 'b5', 'r5', 'z5',
336
            'b6', 'r6', 'z6', 'b7', 'r7', 'z7',
337
            'b8', 'r8', 'z8', 'b9', 'r9', 'z9',
338
        ]
339
        assert wave.ndim == 1, 'wave.ndim should be 1 not {}'.format(wave.ndim)
1✔
340
        assert phot.ndim == 2, 'phot.ndim should be 2 not {}'.format(phot.ndim)
1✔
341
        assert phot.shape[1] == wave.shape[0]
1✔
342
        if skyphot is not None:
1✔
343
            assert skyphot.ndim == 2, \
1✔
344
                'skyphot.ndim should be 2 not {}'.format(skyphot.ndim)
345
            assert skyphot.shape == phot.shape, \
1✔
346
                'skyphot.shape {} != phot.shape {}'.format(skyphot.shape, phot.shape)
347

348
        self.camera = camera
1✔
349
        self.wave = wave
1✔
350
        self.phot = phot
1✔
351
        self.skyphot = skyphot
1✔
352

353
class SimSpec(object):
1✔
354
    """Lightweight wrapper object for simspec data
355

356
    Object has properties flavor, nspec, wave, flux, skyflux, fibermap, truth,
357
    obsconditions, header, cameras.
358

359
    cameras is dict, keyed by camera name, of SimSpecCameras objects with
360
    properies wave, phot, skyphot.
361

362
    """
363
    def __init__(self, flavor, wave, flux, skyflux, fibermap,
1✔
364
                 truth, obsconditions, header, objtruth=None):
365
        """
366
        Create a SimSpec object
367

368
        Args:
369
            flavor (str): e.g. 'arc', 'flat', 'science'
370
            wave : 1D[nwave] array of wavelengths in Angstroms
371
            flux : 2D[nspec, nwave] flux in 1e-17 erg/s/cm2/Angstrom
372
            skyflux : 2D[nspec, nwave] flux in 1e-17 erg/s/cm2/Angstrom
373
            fibermap: fibermap table
374
            truth: table of truth metadata information about these spectra
375
            obsconditions (dict-like): observing conditions; see notes below
376
            header : FITS header from HDU0
377
            objtruth: additional object type specific metadata information 
378

379
        Notes:
380
          * all inputs must be specified but can be None, depending upon flavor,
381
            e.g. arc and flat don't have skyflux or obsconditions
382
          * obsconditions keys SEEING (arcsec), EXPTIME (sec), AIRMASS,
383
            MOONFRAC (0-1), MOONALT (deg), MOONSEP (deg)
384
          * use self.add_camera to add per-camera photons
385
        """
386

387
        if wave is not None and flux is not None:
1✔
388
            assert wave.ndim == 1, 'wave.ndim should be 1 not {}'.format(wave.ndim)
1✔
389
            assert flux.ndim == 2, 'flux.ndim should be 2 not {}'.format(flux.ndim)
1✔
390
            assert flux.shape[1] == wave.shape[0]
1✔
391
        if skyflux is not None:
1✔
392
            assert wave is not None
1✔
393
            assert flux is not None
1✔
394
            assert skyflux.ndim == 2, \
1✔
395
                'skyflux.ndim should be 2 not {}'.format(skyflux.ndim)
396
            assert skyflux.shape == flux.shape, \
1✔
397
                'skyflux.shape {} != flux.shape {}'.format(skyflux.shape, flux.shape)
398

399
        self.flavor = flavor
1✔
400
        self.nspec = len(fibermap)
1✔
401
        self.wave = wave
1✔
402
        self.flux = flux
1✔
403
        self.skyflux = skyflux
1✔
404
        self.fibermap = fibermap
1✔
405
        self.truth = truth
1✔
406
        self.objtruth = objtruth
1✔
407
        self.obsconditions = obsconditions
1✔
408
        self.header = header
1✔
409

410
        self.cameras = dict()
1✔
411

412
    def add_camera(self, camera, wave, phot, skyphot=None):
1✔
413
        """
414
        Add per-camera photons to this SimSpec object, using SimSpecCamera
415

416
        Args:
417
            camera: camera name, e.g. b0, r1, z9
418
            wave: 1D[nwave] array of wavelengths
419
            phot: 2D[nspec, nwave] array of photons per bin (not per Angstrom)
420

421
        Optional:
422
            skyphot: 2D[nspec, nwave] array of sky photons per bin
423
        """
424
        self.cameras[camera] = SimSpecCamera(camera, wave, phot, skyphot)
1✔
425

426
def fibers2cameras(fibers):
1✔
427
    """
428
    Return a list of cameras covered by an input array of fiber IDs
429
    """
430
    cameras = list()
1✔
431
    for spectrograph in range(10):
1✔
432
        ii = np.arange(500) + spectrograph*500
1✔
433
        if np.any(np.isin(ii, fibers)):
1✔
434
            for channel in ['b', 'r', 'z']:
1✔
435
                cameras.append(channel + str(spectrograph))
1✔
436
    return cameras
1✔
437

438
def read_simspec(filename, cameras=None, comm=None, readflux=True, readphot=True):
1✔
439
    """
440
    Read a simspec file and return a SimSpec object
441

442
    Args:
443
        filename: input simspec file name
444

445
    Options:
446
        cameras: camera name or list of names, e.g. b0, r1, z9
447
        comm: MPI communicator
448
        readflux: if True (default), include flux
449
        readphot: if True (default), include per-camera photons
450
    """
451
    if comm is not None:
1✔
452
        rank, size = comm.rank, comm.size
×
453
    else:
454
        rank, size = 0, 1
1✔
455

456
    if cameras is None:
1✔
457
        #- Build the potential cameras list based upon the fibermap
458
        if rank == 0:
1✔
459
            fibermap = fits.getdata(filename, 'FIBERMAP')
1✔
460
            cameras = fibers2cameras(fibermap['FIBER'])
1✔
461

462
        if comm is not None:
1✔
463
            cameras = comm.bcast(cameras, root=0)
×
464

465
    elif isinstance(cameras, str):
1✔
466
        cameras = [cameras,]
×
467

468
    #- Read and broadcast data that are common across cameras
469
    header = flavor = truth = fibermap = obsconditions = None
1✔
470
    wave = flux = skyflux = None
1✔
471
    objtruth = dict() # =objmeta in desisim.templates
1✔
472
    if rank == 0:
1✔
473
        with fits.open(filename, memmap=False) as fx:
1✔
474
            header = fx[0].header.copy()
1✔
475
            flavor = header['FLAVOR']
1✔
476

477
            if 'WAVE' in fx and readflux:
1✔
478
                wave = native_endian(fx['WAVE'].data.copy())
1✔
479

480
            if 'FLUX' in fx and readflux:
1✔
481
                flux = native_endian(fx['FLUX'].data.astype('f8'))
1✔
482

483
            if 'SKYFLUX' in fx and readflux:
1✔
484
                skyflux = native_endian(fx['SKYFLUX'].data.astype('f8'))
1✔
485

486
            if 'TRUTH' in fx:
1✔
487
                truth = Table(fx['TRUTH'].data)
1✔
488

489
                if 'OBJTYPE' in truth.colnames:
1✔
490
                    objtype = truth['OBJTYPE'] # output of desisim.obs.new_exposure
1✔
491
                else:
492
                    objtype = truth['TEMPLATETYPE'] # output of desitarget.mock.build.write_targets_truth
×
493

494
                for obj in set(objtype):
1✔
495
                    extname = 'TRUTH_{}'.format(obj.upper())
1✔
496
                    if extname in fx:
1✔
497
                        # This snippet of code reads a QsoSimObjects extension,
498
                        # which is currently deprecated.
499
                        if 'COSMO' in fx[extname].header:
1✔
500
                            from simqso.sqgrids import QsoSimObjects
×
501
                            qsometa = QsoSimObjects()
×
502
                            qsometa.read(filename, extname=extname)
×
503
                            objtruth[obj] = qsometa
×
504
                        else:
505
                            objtruth[obj] = Table(fx[extname].data)
1✔
506

507
            if 'FIBERMAP' in fx:
1✔
508
                fibermap = Table(fx['FIBERMAP'].data)
1✔
509

510
            if 'OBSCONDITIONS' in fx:
1✔
511
                obsconditions = Table(fx['OBSCONDITIONS'].data)[0]
1✔
512

513
    if comm is not None:
1✔
514
        header = comm.bcast(header, root=0)
×
515
        flavor = comm.bcast(flavor, root=0)
×
516
        truth = comm.bcast(truth, root=0)
×
517
        objtruth = comm.bcast(objtruth, root=0)
×
518
        fibermap = comm.bcast(fibermap, root=0)
×
519
        obsconditions = comm.bcast(obsconditions, root=0)
×
520

521
        wave = comm.bcast(wave, root=0)
×
522
        flux = comm.bcast(flux, root=0)
×
523
        skyflux = comm.bcast(skyflux, root=0)
×
524

525
    #- Trim arrays to match camera
526
    #- Note: we do this after the MPI broadcast because rank 0 doesn't know
527
    #- which ranks need which cameras.  Although inefficient, in practice
528
    #- this doesn't matter (yet?) because the place we use parallelism is
529
    #- pixsim which uses readflux=False and thus doesn't broadcast flux anyway
530
    ii = np.zeros(len(fibermap), dtype=bool)
1✔
531
    for camera in cameras:
1✔
532
        spectrograph = int(camera[1])   #- e.g. b0
1✔
533
        fibers = np.arange(500, dtype=int) + spectrograph*500
1✔
534
        ii |= np.isin(fibermap['FIBER'], fibers)
1✔
535

536
    assert np.any(ii), "input simspec doesn't cover cameras {}".format(cameras)
1✔
537

538
    full_fibermap = fibermap
1✔
539
    fibermap = fibermap[ii]
1✔
540
    if flux is not None:
1✔
541
        flux = flux[ii]
1✔
542
    if skyflux is not None:
1✔
543
        skyflux = skyflux[ii]
1✔
544
    if truth is not None:
1✔
545
        truth = truth[ii]
1✔
546
        # @sbailey - Not sure if we need to do anything with objtruth here
547

548
    simspec = SimSpec(flavor, wave, flux, skyflux,
1✔
549
                      fibermap, truth, obsconditions, header,
550
                      objtruth=objtruth)
551

552
    #- Now read individual camera photons
553
    #- NOTE: this is somewhat inefficient since the same PHOT_B/R/Z HDUs
554
    #- are read multiple times by different nodes for different cameras,
555
    #- though at least only one reader per camera (node) not per rank.
556
    #- If this is slow, this would be an area for optimization.
557
    if readphot:
1✔
558
        for camera in cameras:
1✔
559
            channel = camera[0].upper()
1✔
560
            spectrograph = int(camera[1])
1✔
561
            fiber = full_fibermap['FIBER']
1✔
562
            ii = (spectrograph*500 <= fiber) & (fiber < (spectrograph+1)*500)
1✔
563
            assert np.any(ii), 'Camera {} is not in fibers {}-{}'.format(
1✔
564
                                            camera, np.min(fiber), np.max(fiber) )
565

566
            #- Split MPI communicator by camera
567
            #- read and broadcast each camera
568
            if comm is not None:
1✔
569
                tmp = 'b0 r0 z0 b1 r1 z1 b2 r2 z2 b3 r3 z3 b4 r4 z4 b5 r5 z5 b6 r6 z6 b7 r7 z7 b8 r8 z8 b9 r9 z9'.split()
×
570
                camcomm = comm.Split(color=tmp.index(camera))
×
571
                camrank = camcomm.rank
×
572
            else:
573
                camcomm = None
1✔
574
                camrank = 0
1✔
575

576
            wave = phot = skyphot = None
1✔
577
            if camrank == 0:
1✔
578
                with fits.open(filename, memmap=False) as fx:
1✔
579
                    wave = native_endian(fx['WAVE_'+channel].data.copy())
1✔
580
                    phot = native_endian(fx['PHOT_'+channel].data[ii].astype('f8'))
1✔
581
                    if 'SKYPHOT_'+channel in fx:
1✔
582
                        skyphot = native_endian(fx['SKYPHOT_'+channel].data[ii].astype('f8'))
1✔
583
                    else:
584
                        skyphot = None
1✔
585

586
            if camcomm is not None:
1✔
587
                wave = camcomm.bcast(wave, root=0)
×
588
                phot = camcomm.bcast(phot, root=0)
×
589
                skyphot = camcomm.bcast(skyphot, root=0)
×
590

591
            simspec.add_camera(camera, wave, phot, skyphot)
1✔
592

593
    return simspec
1✔
594

595
#-------------------------------------------------------------------------
596
#- simpix
597

598
def write_simpix(outfile, image, camera, meta):
1✔
599
    """Write simpix data to outfile.
600

601
    Args:
602
        outfile : output file name, e.g. from io.findfile('simpix', ...)
603
        image : 2D noiseless simulated image (numpy.ndarray)
604
        meta : dict-like object that should include FLAVOR and EXPTIME,
605
            e.g. from HDU0 FITS header of input simspec file
606
    """
607

608
    meta = desispec.io.util.fitsheader(meta)
1✔
609

610
    #- Create a new file with a blank primary HDU if needed
611
    if not os.path.exists(outfile):
1✔
612
        header = meta.copy()
1✔
613
        try:
1✔
614
            import specter
1✔
615
            header['DEPNAM00'] = 'specter'
1✔
616
            header['DEPVER00'] = (specter.__version__, 'Specter version')
1✔
617
        except ImportError:
618
            pass
619

620
        fits.PrimaryHDU(None, header=header).writeto(outfile)
1✔
621

622
    #- Add the new HDU
623
    hdu = fits.ImageHDU(image.astype(np.float32), header=meta, name=camera.upper())
1✔
624
    hdus = fits.open(outfile, mode='append', memmap=False)
1✔
625
    hdus.append(hdu)
1✔
626
    hdus.flush()
1✔
627
    hdus.close()
1✔
628

629
def load_simspec_summary(indir, verbose=False):
1✔
630
    '''
631
    Combine fibermap and simspec files under indir into single truth catalog
632

633
    Args:
634
        indir: path to input directory; search this and all subdirectories
635

636
    Returns:
637
        astropy.table.Table with true Z catalog
638
    '''
639
    import astropy.table
×
640
    truth = list()
×
641
    for fibermapfile in desispec.io.iterfiles(indir, 'fibermap'):
×
642
        fibermap = astropy.table.Table.read(fibermapfile, 'FIBERMAP')
×
643
        if verbose:
×
644
            print('')
×
645
        #- skip calibration frames
646
        if 'FLAVOR' in fibermap.meta:
×
647
            if fibermap.meta['FLAVOR'].lower() in ('arc', 'flat', 'bias'):
×
648
                continue
×
649
        elif 'OBSTYPE' in fibermap.meta:
×
650
            if fibermap.meta['OBSTYPE'].lower() in ('arc', 'flat', 'bias', 'dark'):
×
651
                continue
×
652

653
        simspecfile = fibermapfile.replace('fibermap-', 'simspec-')
×
654
        if not os.path.exists(simspecfile):
×
655
            raise IOError('fibermap without matching simspec: {}'.format(fibermapfile))
×
656

657
        simspec = astropy.table.Table.read(simspecfile, 'METADATA')
×
658

659
        #- cleanup prior to merging
660
        if 'REDSHIFT' in simspec.colnames:
×
661
            simspec.rename_column('REDSHIFT', 'TRUEZ')
×
662
        if 'OBJTYPE' in simspec.colnames:
×
663
            simspec.rename_column('OBJTYPE', 'TRUETYPE')
×
664
        for key in ('DATASUM', 'CHECKSUM', 'TELRA', 'TELDEC', 'EXTNAME'):
×
665
            if key in fibermap.meta:
×
666
                del fibermap.meta[key]
×
667
            if key in simspec.meta:
×
668
                del simspec.meta[key]
×
669

670
        #- convert some header keywords to new columns
671
        for key in ('TILEID', 'EXPID', 'FLAVOR', 'NIGHT'):
×
672
            fibermap[key] = fibermap.meta[key]
×
673
            del fibermap.meta[key]
×
674

675
        truth.append(astropy.table.hstack([fibermap, simspec]))
×
676

677
    truth = astropy.table.vstack(truth)
×
678
    return truth
×
679

680

681
#-------------------------------------------------------------------------
682
#- Cosmics
683

684
#- Utility function to resize an image while preserving its 2D arrangement
685
#- (unlike np.resize)
686
def _resize(image, shape):
1✔
687
    """
688
    Resize input image to have new shape, preserving its 2D arrangement
689

690
    Args:
691
        image : 2D ndarray
692
        shape : tuple (ny,nx) for desired output shape
693

694
    Returns:
695
        new image with image.shape == shape
696
    """
697

698
    #- Tile larger in odd integer steps so that sub-/super-selection can
699
    #- be centered on the input image
700
    fx = shape[1] / image.shape[1]
1✔
701
    fy = shape[0] / image.shape[0]
1✔
702
    nx = int(2*np.ceil( (fx-1) / 2) + 1)
1✔
703
    ny = int(2*np.ceil( (fy-1) / 2) + 1)
1✔
704

705
    newpix = np.tile(image, (ny, nx))
1✔
706
    ix = newpix.shape[1] // 2 - shape[1] // 2
1✔
707
    iy = newpix.shape[0] // 2 - shape[0] // 2
1✔
708
    return newpix[iy:iy+shape[0], ix:ix+shape[1]]
1✔
709

710
def find_cosmics(camera, exptime=1000, cosmics_dir=None):
1✔
711
    '''
712
    Return full path to cosmics template file to use
713

714
    Args:
715
        camera (str): e.g. 'b0', 'r1', 'z9'
716
        exptime (int, optional): exposure time in seconds
717
        cosmics_dir (str, optional): directory to look for cosmics templates; defaults to
718
            $DESI_COSMICS_TEMPLATES if set or otherwise
719
            $DESI_ROOT/spectro/templates/cosmics/v0.3  (note HARDCODED version)
720

721
    Exposure times <120 sec will use the bias templates; otherwise they will
722
    use the dark cosmics templates
723
    '''
724
    if cosmics_dir is None:
1✔
725
        if 'DESI_COSMICS_TEMPLATES' in os.environ:
1✔
726
            cosmics_dir = os.environ['DESI_COSMICS_TEMPLATES']
×
727
        else:
728
            cosmics_dir = os.environ['DESI_ROOT']+'/spectro/templates/cosmics/v0.3/'
1✔
729

730
    if exptime < 120:
1✔
731
        exptype = 'bias'
1✔
732
    else:
733
        exptype = 'dark'
×
734

735
    channel = camera[0].lower()
1✔
736
    assert channel in 'brz', 'Unknown camera {}'.format(camera)
1✔
737

738
    cosmicsfile = '{}/cosmics-{}-{}.fits'.format(cosmics_dir, exptype, channel)
1✔
739
    return os.path.normpath(cosmicsfile)
1✔
740

741
def read_cosmics(filename, expid=1, shape=None, jitter=True):
1✔
742
    """
743
    Reads a dark image with cosmics from the input filename.
744

745
    The input might have multiple dark images; use the `expid%n` image where
746
    `n` is the number of images in the input cosmics file.
747

748
    Args:
749
        filename : FITS filename with EXTNAME=IMAGE-*, IVAR-*, MASK-* HDUs
750
        expid : integer, use `expid % n` image where `n` is number of images
751
        shape : (ny, nx, optional) tuple for output image shape
752
        jitter (bool, optional): If True (default), apply random flips and rolls so you
753
            don't get the exact same cosmics every time
754

755
    Returns:
756
        `desisim.image.Image` object with attributes pix, ivar, mask
757
    """
758
    fx = fits.open(filename)
1✔
759
    imagekeys = list()
1✔
760
    for i in range(len(fx)):
1✔
761
        if fx[i].name.startswith('IMAGE-'):
1✔
762
            imagekeys.append(fx[i].name.split('-', 1)[1])
1✔
763

764
    assert len(imagekeys) > 0, 'No IMAGE-* extensions found in '+filename
1✔
765
    i = expid % len(imagekeys)
1✔
766
    pix  = native_endian(fx['IMAGE-'+imagekeys[i]].data.astype(np.float64))
1✔
767
    ivar = native_endian(fx['IVAR-'+imagekeys[i]].data.astype(np.float64))
1✔
768
    mask = native_endian(fx['MASK-'+imagekeys[i]].data)
1✔
769
    meta = fx['IMAGE-'+imagekeys[i]].header
1✔
770
    meta['CRIMAGE'] = (imagekeys[i], 'input cosmic ray image')
1✔
771

772
    #- De-trend each amplifier
773
    nx = pix.shape[1] // 2
1✔
774
    ny = pix.shape[0] // 2
1✔
775
    kernel_size = min(201, ny//3, nx//3)
1✔
776

777
    pix[0:ny, 0:nx] -= spline_medfilt2d(pix[0:ny, 0:nx], kernel_size)
1✔
778
    pix[0:ny, nx:2*nx] -= spline_medfilt2d(pix[0:ny, nx:2*nx], kernel_size)
1✔
779
    pix[ny:2*ny, 0:nx] -= spline_medfilt2d(pix[ny:2*ny, 0:nx], kernel_size)
1✔
780
    pix[ny:2*ny, nx:2*nx] -= spline_medfilt2d(pix[ny:2*ny, nx:2*nx], kernel_size)
1✔
781

782
    if shape is not None:
1✔
783
        if len(shape) != 2: raise ValueError('Invalid shape {}'.format(shape))
1✔
784
        pix = _resize(pix, shape)
1✔
785
        ivar = _resize(ivar, shape)
1✔
786
        mask = _resize(mask, shape)
1✔
787

788
    if jitter:
1✔
789
        #- Randomly flip left-right and/or up-down
790
        if np.random.uniform(0, 1) > 0.5:
1✔
791
            pix = np.fliplr(pix)
×
792
            ivar = np.fliplr(ivar)
×
793
            mask = np.fliplr(mask)
×
794
            meta['CRFLIPLR'] = (True, 'Input cosmics image flipped Left/Right')
×
795
        else:
796
            meta['CRFLIPLR'] = (False, 'Input cosmics image NOT flipped Left/Right')
1✔
797

798
        if np.random.uniform(0, 1) > 0.5:
1✔
799
            pix = np.flipud(pix)
1✔
800
            ivar = np.flipud(ivar)
1✔
801
            mask = np.flipud(mask)
1✔
802
            meta['CRFLIPUD'] = (True, 'Input cosmics image flipped Up/Down')
1✔
803
        else:
804
            meta['CRFLIPUD'] = (False, 'Input cosmics image NOT flipped Up/Down')
1✔
805

806
        #- Randomly roll image a bit
807
        nx, ny = np.random.randint(-100, 100, size=2)
1✔
808
        pix = np.roll(np.roll(pix, ny, axis=0), nx, axis=1)
1✔
809
        ivar = np.roll(np.roll(ivar, ny, axis=0), nx, axis=1)
1✔
810
        mask = np.roll(np.roll(mask, ny, axis=0), nx, axis=1)
1✔
811
        meta['CRSHIFTX'] = (nx, 'Input cosmics image shift in x')
1✔
812
        meta['CRSHIFTY'] = (nx, 'Input cosmics image shift in y')
1✔
813
    else:
814
        meta['CRFLIPLR'] = (False, 'Input cosmics image NOT flipped Left/Right')
1✔
815
        meta['CRFLIPUD'] = (False, 'Input cosmics image NOT flipped Up/Down')
1✔
816
        meta['CRSHIFTX'] = (0, 'Input cosmics image shift in x')
1✔
817
        meta['CRSHIFTY'] = (0, 'Input cosmics image shift in y')
1✔
818

819
    if 'RDNOISE0' in meta :
1✔
820
        del meta['RDNOISE0']
×
821
    #- Amp A lower left
822
    nx = pix.shape[1] // 2
1✔
823
    ny = pix.shape[0] // 2
1✔
824
    iixy = np.s_[0:ny, 0:nx]
1✔
825
    cx = pix[iixy][mask[iixy] == 0]
1✔
826
    mean, median, std = sigma_clipped_stats(cx, sigma=3, maxiters=5)
1✔
827
    meta['RDNOISEA'] = std
1✔
828

829
    #- Amp B lower right
830
    iixy = np.s_[0:ny, nx:2*nx]
1✔
831
    cx = pix[iixy][mask[iixy] == 0]
1✔
832
    mean, median, std = sigma_clipped_stats(cx, sigma=3, maxiters=5)
1✔
833
    meta['RDNOISEB'] = std
1✔
834

835
    #- Amp C upper left
836
    iixy = np.s_[ny:2*ny, 0:nx]
1✔
837
    mean, median, std = sigma_clipped_stats(pix[iixy], sigma=3, maxiters=5)
1✔
838
    meta['RDNOISEC'] = std
1✔
839

840
    #- Amp D upper right
841
    iixy = np.s_[ny:2*ny, nx:2*nx]
1✔
842
    mean, median, std = sigma_clipped_stats(pix[iixy], sigma=3, maxiters=5)
1✔
843
    meta['RDNOISED'] = std
1✔
844
    fx.close()
1✔
845

846
    return Image(pix, ivar, mask, meta=meta)
1✔
847

848
#-------------------------------------------------------------------------
849
#- desimodel
850

851
def get_tile_radec(tileid):
1✔
852
    """
853
    Return (ra, dec) in degrees for the requested tileid.
854

855
    If tileid is not in DESI, return (0.0, 0.0)
856
    TODO: should it raise an exception instead?
857
    """
858
    if not isinstance(tileid, (int, np.int64, np.int32, np.int16)):
1✔
859
        raise ValueError('tileid should be an int, not {}'.format(type(tileid)))
1✔
860

861
    tiles = desimodel.io.load_tiles()
1✔
862
    if tileid in tiles['TILEID']:
1✔
863
        i = np.where(tiles['TILEID'] == tileid)[0][0]
1✔
864
        return tiles[i]['RA'], tiles[i]['DEC']
1✔
865
    else:
866
        return (0.0, 0.0)
1✔
867

868
#-------------------------------------------------------------------------
869
#- spectral templates
870

871
#- Utility function to wrap resample_flux for multiprocessing map
872
def _resample_flux(args):
1✔
873
    return resample_flux(*args)
×
874

875
def find_basis_template(objtype, indir=None):
1✔
876
    """
877
    Return the most recent template in $DESI_BASIS_TEMPLATE/{objtype}_template*.fits
878
    """
879
    if indir is None:
1✔
880
        indir = os.environ['DESI_BASIS_TEMPLATES']
1✔
881

882
    objfile_wild = os.path.join(indir, objtype.lower()+'_templates_*.fits')
1✔
883
    objfiles = glob(objfile_wild)
1✔
884
    objfiles.sort(key=os.path.getmtime)
1✔
885
    if len(objfiles) > 0:
1✔
886
        return objfiles[-1]
1✔
887
    else:
888
        raise IOError('No {} templates found in {}'.format(objtype, objfile_wild))
×
889

890
def _qso_format_version(filename):
1✔
891
    '''Return 1 or 2 depending upon QSO basis template file structure'''
892
    with fits.open(filename) as fx:
1✔
893
        if fx[1].name == 'METADATA':
1✔
894
            return 1
×
895
        elif fx[1].name == 'BOSS_PCA':
1✔
896
            return 2
1✔
897
        else:
898
            raise IOError('Unknown QSO basis template format '+filename)
×
899

900
def read_basis_templates(objtype, subtype='', outwave=None, nspec=None,
1✔
901
                         infile=None, onlymeta=False, verbose=False):
902
    """Return the basis (continuum) templates for a given object type.  Optionally
903
    returns a randomly selected subset of nspec spectra sampled at
904
    wavelengths outwave.
905

906
    Args:
907

908
        objtype (str): object type to read (e.g., ELG, LRG, QSO, STAR, STD, WD,
909
          MWS_STAR, BGS).
910
        subtype (str, optional): template subtype, currently only for white
911
            dwarfs.  The choices are DA and DB and the default is to read both
912
            types.
913
        outwave (numpy.array, optional): array of wavelength at which to sample
914
            the spectra.
915
        nspec (int, optional): number of templates to return
916
        infile (str, optional): full path to input template file to read,
917
            over-riding the contents of the $DESI_BASIS_TEMPLATES environment
918
            variable.
919
        onlymeta (Bool, optional): read just the metadata table and return
920
        verbose: bool
921
            Be verbose. (Default: False)
922

923
    Returns:
924
        Tuple of (outflux, outwave, meta) where
925
        outflux is an Array [ntemplate,npix] of flux values [erg/s/cm2/A];
926
        outwave is an Array [npix] of wavelengths for FLUX [Angstrom];
927
        meta is a Meta-data table for each object.  The contents of this
928
        table varies depending on what OBJTYPE has been read.
929

930
    Raises:
931
        EnvironmentError: If the required $DESI_BASIS_TEMPLATES environment
932
            variable is not set.
933
        IOError: If the basis template file is not found.
934

935
    """
936
    from desiutil.log import get_logger, DEBUG
1✔
937
    if verbose:
1✔
938
        log = get_logger(DEBUG)
×
939
    else:
940
        log = get_logger()
1✔
941

942
    ltype = objtype.lower()
1✔
943
    if objtype == 'STD':
1✔
944
        ltype = 'star'
1✔
945
    if objtype == 'MWS_STAR':
1✔
946
        ltype = 'star'
1✔
947

948
    if infile is None:
1✔
949
        infile = find_basis_template(ltype)
1✔
950

951
    if onlymeta:
1✔
952
        log.info('Reading {} metadata.'.format(infile))
×
953

954
        if objtype.upper() == 'BAL': # non-standard data model
×
955
            meta = Table(fitsio.read(infile, ext=1, upper=True,
×
956
                                     columns=('BI_CIV','ERR_BI_CIV', 'NCIV_2000', 'VMIN_CIV_2000',
957
                                          'VMAX_CIV_2000', 'POSMIN_CIV_2000','FMIN_CIV_2000',
958
                                          'AI_CIV', 'ERR_AI_CIV','NCIV_450', 'VMIN_CIV_450',
959
                                          'VMAX_CIV_450', 'POSMIN_CIV_450', 'FMIN_CIV_450')))
960
        else:
961
            meta = Table(fitsio.read(infile, ext=1, upper=True))
×
962

963
        if (objtype.upper() == 'WD') and (subtype != ''):
×
964
            keep = np.where(meta['WDTYPE'] == subtype.upper())[0]
×
965
            if len(keep) == 0:
×
966
                log.warning('Unrecognized white dwarf subtype {}!'.format(subtype))
×
967
            else:
968
                meta = meta[keep]
×
969

970
        return meta
×
971

972
    log.info('Reading {}'.format(infile))
1✔
973

974
    if objtype.upper() == 'QSO':
1✔
975
        with fits.open(infile) as fx:
1✔
976
            format_version = _qso_format_version(infile)
1✔
977
            if format_version == 1:
1✔
978
                flux = fx[0].data * 1E-17
×
979
                hdr = fx[0].header
×
980
                from desispec.io.util import header2wave
×
981
                wave = header2wave(hdr)
×
982
                meta = Table(fx[1].data)
×
983
            elif format_version == 2:
1✔
984
                flux = fx['SDSS_EIGEN'].data.copy()
1✔
985
                wave = fx['SDSS_EIGEN_WAVE'].data.copy()
1✔
986
                meta = Table([np.arange(flux.shape[0]),], names=['PCAVEC',])
1✔
987
            else:
988
                raise IOError('Unknown QSO basis template format version {}'.format(format_version))
×
989
    elif objtype.upper() == 'BAL':
1✔
990
        flux, hdr = fitsio.read(infile, ext=1, columns='TEMP', header=True)
1✔
991
        w1 = hdr['CRVAL1']
1✔
992
        dw = hdr['CDELT1']
1✔
993
        w2 = w1 + dw*flux.shape[1]
1✔
994
        wave = np.arange(w1, w2, dw)
1✔
995

996
        meta = Table(fitsio.read(infile, ext=1, upper=True,
1✔
997
                                 columns=('BI_CIV','ERR_BI_CIV', 'NCIV_2000', 'VMIN_CIV_2000',
998
                                          'VMAX_CIV_2000', 'POSMIN_CIV_2000','FMIN_CIV_2000',
999
                                          'AI_CIV', 'ERR_AI_CIV','NCIV_450', 'VMIN_CIV_450',
1000
                                          'VMAX_CIV_450', 'POSMIN_CIV_450', 'FMIN_CIV_450')))
1001
    else:
1002
        with fits.open(infile) as fx:
1✔
1003
            try:
1✔
1004
                flux = fx['FLUX'].data
1✔
1005
                meta = Table(fx['METADATA'].data)
1✔
1006
                wave = fx['WAVE'].data
1✔
1007
            except:
×
1008
                flux = fx[0].data
×
1009
                meta = Table(fx[1].data)
×
1010
                wave = fx[2].data
×
1011

1012
            if 'COLORS' in fx:
1✔
1013
                colors = fx['COLORS'].data
1✔
1014
                hdr = fx['COLORS'].header
1✔
1015
                for ii, col in enumerate(hdr['COLORS'].split(',')):
1✔
1016
                    meta[col.upper()] = colors[:, ii].flatten()
1✔
1017

1018
            if 'DESI-COLORS' in fx:
1✔
1019
                colors = fx['DESI-COLORS'].data
1✔
1020
                for col in colors.names:
1✔
1021
                    meta[col.upper()] = colors[col]
1✔
1022

1023
        #- Check if we have correct version
1024
        if objtype.upper() in ('ELG', 'LRG'):
1✔
1025
            if 'BASS_G' not in meta.keys():
1✔
1026
                log.error('missing BASS_G from template metadata')
×
1027
                log.error('Is your DESI_BASIS_TEMPLATES too old? {}'.format(os.getenv('DESI_BASIS_TEMPLATES')))
×
1028
                log.error('Please update DESI_BASIS_TEMPLATES to v3.0 or later')
×
1029
                raise IOError('Incompatible basis templates; please update to v3.0 or later')
×
1030

1031
        if (objtype.upper() == 'WD') and (subtype != ''):
1✔
1032
            if 'WDTYPE' not in meta.colnames:
1✔
1033
                raise RuntimeError('Please upgrade to basis_templates >=2.3 to get WDTYPE support')
×
1034

1035
            keep = np.where(meta['WDTYPE'] == subtype.upper())[0]
1✔
1036
            if len(keep) == 0:
1✔
1037
                log.warning('Unrecognized white dwarf subtype {}!'.format(subtype))
×
1038
            else:
1039
                meta = meta[keep]
1✔
1040
                flux = flux[keep, :]
1✔
1041

1042
    # Optionally choose a random subset of spectra. There must be a fast way to
1043
    # do this using fitsio.
1044
    ntemplates = flux.shape[0]
1✔
1045
    if nspec is not None:
1✔
1046
        these = np.random.choice(np.arange(ntemplates),nspec)
1✔
1047
        flux = flux[these,:]
1✔
1048
        meta = meta[these]
1✔
1049

1050
    # Optionally resample the templates at specific wavelengths.  Use
1051
    # multiprocessing to speed this up.
1052
    if outwave is None:
1✔
1053
        outflux = flux # Do I really need to copy these variables!
1✔
1054
        outwave = wave
1✔
1055
    else:
1056
        args = list()
1✔
1057
        for jj in range(nspec):
1✔
1058
            args.append((outwave, wave, flux[jj,:]))
1✔
1059
        import multiprocessing
1✔
1060
        ncpu = multiprocessing.cpu_count() // 2   #- avoid hyperthreading
1✔
1061
        with multiprocessing.Pool(ncpu) as P:
1✔
1062
            outflux = P.map(_resample_flux, args)
1✔
1063
        outflux = np.array(outflux)
1✔
1064

1065
    return outflux, outwave, meta
1✔
1066

1067
#-------------------------------------------------------------------------
1068
#- Utility functions
1069

1070
def simdir(night=None, expid=None, mkdir=False):
1✔
1071
    """
1072
    Return $DESI_SPECTRO_SIM/$PIXPROD/{night}
1073
    If mkdir is True, create directory if needed
1074
    """
1075

1076
    if (night is None) and (expid is None):
1✔
1077
        dirname = os.path.join(
1✔
1078
            os.getenv('DESI_SPECTRO_SIM'), os.getenv('PIXPROD')
1079
            )
1080
    #- must provide night+expid, and catch old usage where mkdir was 2nd arg
1081
    elif (expid is None) or isinstance(expid, bool):
1✔
1082
        raise ValueError("Must provide int expid, not {}".format(expid))
1✔
1083
    else:
1084
        dirname = os.path.join(
1✔
1085
            os.getenv('DESI_SPECTRO_SIM'), os.getenv('PIXPROD'),
1086
            str(night), '{:08d}'.format(expid)
1087
            )
1088
    if mkdir and not os.path.exists(dirname):
1✔
1089
        os.makedirs(dirname)
×
1090

1091
    return dirname
1✔
1092

1093
def _parse_filename(filename):
1✔
1094
    """
1095
    Parse filename and return (prefix, camera, expid)
1096

1097
    camera=None if the filename isn't camera specific
1098

1099
    e.g. /blat/foo/simspec-00000003.fits -> ('simspec', None, 3)
1100
    e.g. /blat/foo/preproc-r2-00000003.fits -> ('preproc', 'r2', 3)
1101
    """
1102
    base = os.path.basename(os.path.splitext(filename)[0])
1✔
1103
    x = base.split('-')
1✔
1104
    if len(x) == 2:
1✔
1105
        return x[0], None, int(x[1])
1✔
1106
    elif len(x) == 3:
1✔
1107
        return x[0], x[1].lower(), int(x[2])
1✔
1108

1109
                
1110
def empty_metatable(nmodel=1, objtype='ELG', subtype='', simqso=False, input_meta=False):
1✔
1111
    """Initialize template metadata tables depending on the given object type. 
1112

1113
    Parameters
1114
    ----------
1115
    nmodel : :class:`int`
1116
        Number of rows in output table.  Defaults to 1.
1117
    objtype : :class:`str`
1118
        Object type.  Defaults to ELG.
1119
    subtype : :class:`str`
1120
        Subtype for the given object type (e.g., LYA is objtype=QSO).
1121
        Defaults to `.`
1122
    simqso : :class:`bool`
1123
        Initialize a templates.SIMQSO-style objmeta table rather than a
1124
        templates.QSO one.  Defaults to False.
1125
    input_meta : :class:`bool`
1126
        Initialize an input_meta table for use with the various
1127
        desisim.templates classes (see its use in, e.g.,
1128
        desitarget.mock.mockmaker) Defaults to False.
1129

1130
    Returns
1131
    -------
1132
    meta : :class:`astropy.table.Table`
1133
        Metadata table which is agnostic about the object type. 
1134
    objmeta : :class:`astropy.table.Table`
1135
        Objtype-specific supplemental metadata table (e.g., containing the [OII]
1136
        flux for ELG targets and surface gravity for stars.
1137

1138
    """
1139
    from astropy.table import Table, Column
1✔
1140

1141
    targetid = np.arange(nmodel).astype(np.int64)
1✔
1142
        
1143
    # Objtype-agnostic metadata
1144
    meta = Table()
1✔
1145
    if input_meta:
1✔
1146
        meta.add_column(Column(name='TEMPLATEID', length=nmodel, dtype='i2', data=np.zeros(nmodel)-1))
×
1147
        meta.add_column(Column(name='SEED', length=nmodel, dtype='int64', data=np.zeros(nmodel)-1))
×
1148
        meta.add_column(Column(name='REDSHIFT', length=nmodel, dtype='f8', data=np.zeros(nmodel)))
×
1149
        meta.add_column(Column(name='MAG', length=nmodel, dtype='f4', data=np.zeros(nmodel)-1, unit='mag')) # normalization magnitude
×
1150
        meta.add_column(Column(name='MAGFILTER', length=nmodel, dtype='U15')) # normalization filter
×
1151
        return meta
×
1152
    else:
1153
        meta.add_column(Column(name='TARGETID', data=targetid))
1✔
1154
        meta.add_column(Column(name='OBJTYPE', length=nmodel, dtype='U10'))
1✔
1155
        meta.add_column(Column(name='SUBTYPE', length=nmodel, dtype='U10'))
1✔
1156
        meta.add_column(Column(name='TEMPLATEID', length=nmodel, dtype='i2', data=np.zeros(nmodel)-1))
1✔
1157
        meta.add_column(Column(name='SEED', length=nmodel, dtype='int64', data=np.zeros(nmodel)-1))
1✔
1158
        meta.add_column(Column(name='REDSHIFT', length=nmodel, dtype='f8', data=np.zeros(nmodel)))
1✔
1159
        meta.add_column(Column(name='MAG', length=nmodel, dtype='f4', data=np.zeros(nmodel)-1, unit='mag')) # normalization magnitude
1✔
1160
        meta.add_column(Column(name='MAGFILTER', length=nmodel, dtype='U15')) # normalization filter
1✔
1161
        meta.add_column(Column(name='FLUX_G', length=nmodel, dtype='f4', unit='nanomaggies'))
1✔
1162
        meta.add_column(Column(name='FLUX_R', length=nmodel, dtype='f4', unit='nanomaggies'))
1✔
1163
        meta.add_column(Column(name='FLUX_Z', length=nmodel, dtype='f4', unit='nanomaggies'))
1✔
1164
        meta.add_column(Column(name='FLUX_W1', length=nmodel, dtype='f4', unit='nanomaggies'))
1✔
1165
        meta.add_column(Column(name='FLUX_W2', length=nmodel, dtype='f4', unit='nanomaggies'))
1✔
1166

1167
        meta['OBJTYPE'] = objtype.upper()
1✔
1168
        meta['SUBTYPE'] = subtype.upper()
1✔
1169

1170
    # Objtype-specific metadata
1171
    objmeta = Table()
1✔
1172
    if objtype.upper() == 'ELG' or objtype.upper() == 'LRG' or objtype.upper() == 'BGS':
1✔
1173
        objmeta.add_column(Column(name='TARGETID', data=targetid))
1✔
1174
        objmeta.add_column(Column(name='OIIFLUX', length=nmodel, dtype='f4',
1✔
1175
                                  data=np.zeros(nmodel)-1, unit='erg/(s*cm2)'))
1176
        objmeta.add_column(Column(name='HBETAFLUX', length=nmodel, dtype='f4',
1✔
1177
                                  data=np.zeros(nmodel)-1, unit='erg/(s*cm2)'))
1178
        objmeta.add_column(Column(name='EWOII', length=nmodel, dtype='f4',
1✔
1179
                                  data=np.zeros(nmodel)-1, unit='Angstrom'))
1180
        objmeta.add_column(Column(name='EWHBETA', length=nmodel, dtype='f4',
1✔
1181
                                  data=np.zeros(nmodel)-1, unit='Angstrom'))
1182
        objmeta.add_column(Column(name='D4000', length=nmodel, dtype='f4', data=np.zeros(nmodel)-1))
1✔
1183
        objmeta.add_column(Column(name='VDISP', length=nmodel, dtype='f4',
1✔
1184
                                  data=np.zeros(nmodel)-1, unit='km/s'))
1185
        objmeta.add_column(Column(name='OIIDOUBLET', length=nmodel, dtype='f4', data=np.zeros(nmodel)-1))
1✔
1186
        objmeta.add_column(Column(name='OIIIHBETA', length=nmodel, dtype='f4',
1✔
1187
                                  data=np.zeros(nmodel)-1, unit='Dex'))
1188
        objmeta.add_column(Column(name='OIIHBETA', length=nmodel, dtype='f4',
1✔
1189
                                  data=np.zeros(nmodel)-1, unit='Dex'))
1190
        objmeta.add_column(Column(name='NIIHBETA', length=nmodel, dtype='f4',
1✔
1191
                                  data=np.zeros(nmodel)-1, unit='Dex'))
1192
        objmeta.add_column(Column(name='SIIHBETA', length=nmodel, dtype='f4',
1✔
1193
                                  data=np.zeros(nmodel)-1, unit='Dex'))
1194
        objmeta.add_column(Column(name='TRANSIENT_MODEL', length=nmodel, dtype='U20'))
1✔
1195
        objmeta.add_column(Column(name='TRANSIENT_TYPE', length=nmodel, dtype='U10'))
1✔
1196
        objmeta.add_column(Column(name='TRANSIENT_EPOCH', length=nmodel, dtype='f4',
1✔
1197
                                  data=np.zeros(nmodel)-1, unit='day'))
1198
        objmeta.add_column(Column(name='TRANSIENT_RFLUXRATIO', length=nmodel, dtype='f4',
1✔
1199
                                  data=np.zeros(nmodel)-1))
1200

1201
    elif objtype.upper() == 'QSO':
1✔
1202
        objmeta.add_column(Column(name='TARGETID', data=targetid))
1✔
1203
        if simqso:
1✔
1204
            objmeta.add_column(Column(name='MABS_1450', length=nmodel, dtype='f4',
1✔
1205
                                      data=np.zeros(nmodel)-1, unit='mag'))
1206
            objmeta.add_column(Column(name='SLOPES', length=nmodel, dtype='f4',
1✔
1207
                                      data=np.zeros( (nmodel, 5) )-1))
1208
            objmeta.add_column(Column(name='EMLINES', length=nmodel, dtype='f4',
1✔
1209
                                      data=np.zeros( (nmodel, 73, 3) )-1))
1210
        else:
1211
            objmeta.add_column(Column(name='PCA_COEFF', length=nmodel, dtype='f4',
1✔
1212
                                      data=np.zeros( (nmodel, 4) )))
1213
        objmeta.add_column(Column(name='BAL_TEMPLATEID', length=nmodel, dtype='i2', data=np.zeros(nmodel)-1))
1✔
1214
        objmeta.add_column(Column(name='DLA', length=nmodel, dtype=bool))
1✔
1215
        #objmeta.add_column(Column(name='METALS', length=nmodel, dtype=bool))
1216

1217
    elif objtype.upper() == 'STAR' or objtype.upper() == 'STD' or objtype.upper() == 'MWS_STAR':
1✔
1218
        objmeta.add_column(Column(name='TARGETID', data=targetid))
1✔
1219
        objmeta.add_column(Column(name='TEFF', length=nmodel, dtype='f4',
1✔
1220
                                  data=np.zeros(nmodel)-1, unit='K'))
1221
        objmeta.add_column(Column(name='LOGG', length=nmodel, dtype='f4',
1✔
1222
                                  data=np.zeros(nmodel)-1, unit='m/(s**2)'))
1223
        objmeta.add_column(Column(name='FEH', length=nmodel, dtype='f4',
1✔
1224
                                  data=np.zeros(nmodel)-1))
1225

1226
    elif objtype.upper() == 'WD':
1✔
1227
        objmeta.add_column(Column(name='TARGETID', data=targetid))
1✔
1228
        objmeta.add_column(Column(name='TEFF', length=nmodel, dtype='f4',
1✔
1229
                                  data=np.zeros(nmodel)-1, unit='K'))
1230
        objmeta.add_column(Column(name='LOGG', length=nmodel, dtype='f4',
1✔
1231
                                  data=np.zeros(nmodel)-1, unit='m/(s**2)'))
1232

1233
    return meta, objmeta
1✔
1234

1235
                
1236
def empty_snemetatable(nmodel=1):
1✔
1237
    """Initialize a metadata table for SNE.
1238

1239
    Parameters
1240
    ----------
1241
    nmodel : :class:`int`
1242
        Number of rows in output table.  Defaults to 1.
1243

1244
    Returns
1245
    -------
1246
    snemeta : :class:`astropy.table.Table`
1247
        Metadata table.
1248

1249
    """
1250
    from astropy.table import Table, Column
×
1251

1252
    targetid = np.arange(nmodel).astype(np.int64)
×
1253

1254
    snemeta = Table()
×
1255
    snemeta.add_column(Column(name='TARGETID', data=targetid))
×
1256
    snemeta.add_column(Column(name='SNE_TEMPLATEID', length=nmodel, dtype='i2',
×
1257
                              data=np.zeros(nmodel)-1))
1258
    snemeta.add_column(Column(name='SNE_FLUXRATIO', length=nmodel, dtype='f4',
×
1259
                              data=np.zeros(nmodel)-1))
1260
    snemeta.add_column(Column(name='SNE_EPOCH', length=nmodel, dtype='f4',
×
1261
                              data=np.zeros(nmodel)-1, unit='days'))
1262
    snemeta.add_column(Column(name='SNE_FILTER', length=nmodel, dtype='U15')) # normalization filter
×
1263
    
1264
    return snemeta
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc