def mkTinyTimPSF( x, y, fltfile, ext=1, fileroot='tinytim', psfdir='tinytim', specfile='flat_flam_tinytim.dat', verbose=False, clobber=False ): """ run tinytim to construct a model psf in the distorted (flt) frame """ # TODO : use a redshifted SN spectrum !!! # (will have to build each psf separately) # TinyTim generates a psf image centered on # the middle of a pixel # we use "tiny3 SUB=5" for 5x sub-sampling, # then interpolate and shift the sub-sampled psf # to recenter it away from the center of the pixel # Re-bin into normal pixel sampling, and then # convolve with the charge diffusion kernel from # the fits header. import time from numpy import iterable, zeros from scipy.ndimage import zoom # from util.fitsio import tofits import cntrd import pyfits imhdr0 = pyfits.getheader( fltfile, ext=0 ) imhdr = pyfits.getheader( fltfile, ext=ext ) instrument = imhdr0['instrume'] detector = imhdr0['detector'] if ( instrument=='WFC3' and detector=='IR' ) : camera='ir' filt = imhdr0['filter'] ccdchip = None elif ( instrument=='WFC3' and detector=='UVIS' ) : camera='uvis' filt = imhdr0['filter'] ccdchip = imhdr['CCDCHIP'] elif ( instrument=='ACS' and detector=='WFC' ) : camera='acs' filter1 = imhdr0['filter1'] filter2 = imhdr0['filter2'] if filter1.startswith('F') : filt=filter1 else : filt=filter2 ccdchip = imhdr['CCDCHIP'] pixscale = getpixscale( fltfile, ext=('SCI',1) ) if not os.path.isfile( specfile ) : if 'TINYTIM' in os.environ : tinytimdir = os.environ['TINYTIM'] specfile = os.path.join( tinytimdir, specfile ) if not os.path.isfile( specfile ) : thisfile = sys.argv[0] if thisfile.startswith('ipython'): thisfile = __file__ thisdir = os.path.dirname( thisfile ) specfile = os.path.join( thisdir, specfile ) if not os.path.isfile( specfile ) : raise exceptions.RuntimeError("Can't find TinyTim spec file %s"% os.path.basename(specfile) ) if verbose : print( "Using TinyTim spectrum file : %s"%specfile ) if not iterable(x) : x = [x] if not iterable(y) : y = [y] if iterable( ext ) : extname = ''.join( str(extbit).lower() for extbit in ext ) else : extname = str(ext).lower() coordfile = "%s.%s.coord"%(os.path.join(psfdir,fileroot),extname) if not os.path.isdir(psfdir) : os.mkdir( psfdir ) newcoords=True if os.path.isfile( coordfile ) and not clobber : print( "%s exists. Not clobbering."%coordfile ) newcoords=False psfstamplist = [] if newcoords: fout = open( coordfile ,'w') allexist = True i=0 for xx,yy in zip(x,y): # here we give the integer component of the x,y coordinates. # after running tiny3, we will shift the psf to account for the # fractional coordinate components if newcoords: print >>fout,"%6i %6i"%(int(xx),int(yy)) if len(x)<=100: psfstamp = os.path.join(psfdir,"%s.%s.%02i.fits"%(fileroot,extname,i)) else : psfstamp = os.path.join(psfdir,"%s.%s.%03i.fits"%(fileroot,extname,i)) if not os.path.isfile( psfstamp ) : allexist = False psfstamplist.append( psfstamp) i+=1 if newcoords: fout.close() if allexist and not clobber : print( "All necessary tinytim psf files exist. Not clobbering.") return( psfstamplist ) queryfile = "%s.%s.query"%(os.path.join(psfdir,fileroot),extname) fout = open( queryfile ,'w') despace = 0.0 # as of tinytim v7.1 : must provide 2ndary mirror despace if camera == 'ir' : print >> fout, """23\n @%s\n %s\n 5\n %s\n %.1f\n %.1f\n %s.%s."""%( coordfile, filt.lower(), specfile, PSFSIZE, despace, os.path.join(psfdir,fileroot), extname ) elif camera == 'uvis' : print >> fout,"""22\n %i\n @%s\n %s\n 5\n %s\n %.1f\n %.1f\n %s.%s."""%( ccdchip, coordfile, filt.lower(), specfile, PSFSIZE, despace, os.path.join(psfdir,fileroot), extname ) elif camera == 'acs' : print >> fout,"""15\n %i\n @%s\n %s\n 5\n %s\n %.1f\n %.1f\n %s.%s."""%( ccdchip, coordfile, filt.lower(), specfile, PSFSIZE, despace, os.path.join(psfdir,fileroot), extname ) fout.close() # run tiny1 to generate the tinytim paramater file command1 = "cat %s | %s %s.%s.in"%( queryfile, 'tiny1', os.path.join(psfdir,fileroot), extname ) if verbose : print command1 os.system( command1 ) # run tiny2 to generate the distortion-free psfs command2 = "%s %s.%s.in"%( 'tiny2', os.path.join(psfdir,fileroot), extname ) if verbose : print command2 os.system( command2 ) xgeo_offsets = [] ygeo_offsets = [] # fluxcorrs = [] # 2014.07.18 Flux correction disabled by Steve #run tiny3 and measure the how much offset the geometric distortion adds for Npsf in range(len(x)): command3 = "%s %s.%s.in POS=%i"%( 'tiny3', os.path.join(psfdir,fileroot), extname, Npsf ) if verbose : print time.asctime() print command3 os.system( command3 ) #Calculate the expected center of the image #Get the dimensions of the stamp. if len(x) <= 100 : this_stamp = '%s.%s.%02i.fits' %(os.path.join(psfdir,fileroot),extname,Npsf) else : this_stamp = '%s.%s.%03i.fits' %(os.path.join(psfdir,fileroot),extname,Npsf) xdim = int(pyfits.getval(this_stamp,'NAXIS1')) ydim = int(pyfits.getval(this_stamp,'NAXIS2')) #The center will be in dimension/2 + 1 xcen = float(xdim/2 + 1) ycen = float(ydim/2 + 1) #run phot to measure the true center position if instrument =='WFC3': instrument = instrument +'_'+detector fwhmpix = 0.13 / pixscale # approximate HST psf size, in pixels this_stamp_data = pyfits.getdata( this_stamp ) meas_xcen, meas_ycen = cntrd.cntrd(this_stamp_data,xcen,ycen,fwhmpix) #Subtract the expected center from the measured center # note the +1 to account for 0-indexed python convention in cntrd # which is different from the 1-indexed fits convention #Save the offsets xgeo_offsets.append(meas_xcen + 1 - xcen) ygeo_offsets.append(meas_ycen + 1 - ycen) # fluxcorrs.append(meas_fluxcorr) #Move this stamp so that it doesn't get overwritten. os.rename(this_stamp,os.path.splitext(this_stamp)[0]+'_tiny3.fits') # run tiny3 to add in geometric distortion and 5x sub-sampling for Npsf in range(len(x)): command3 = "%s %s.%s.in POS=%i SUB=5"%( 'tiny3', os.path.join(psfdir,fileroot), extname, Npsf ) if verbose : print time.asctime() print command3 os.system( command3 ) outstamplist = [] for xx,yy,psfstamp,xgeo,ygeo in zip( x,y,psfstamplist,xgeo_offsets,ygeo_offsets): if verbose : print("sub-sampling psf at %.2f %.2f to 0.01 pix"%(xx,yy)) # read in tiny3 output psf (sub-sampled to a 5th of a pixel) psfim = pyfits.open( psfstamp ) psfdat = psfim[0].data.copy() hdr = psfim[0].header.copy() psfim.close() #If the number of pixels is even then the psf is centered at pixel n/2 + 1 # if you are one indexed or n/2 if you are zero indexed. #If the psf image is even, we need to pad the right side with a row (or column) of zeros if psfdat.shape[0] % 2 == 0: tmpdat = zeros([psfdat.shape[0]+1,psfdat.shape[1]]) tmpdat[:-1,:] = psfdat[:,:] psfdat = tmpdat if psfdat.shape[1] % 2 == 0: tmpdat = zeros([psfdat.shape[0],psfdat.shape[1]+1]) tmpdat[:,:-1] = psfdat[:,:] psfdat = tmpdat #Now the center of the psf is exactly in the center of the image and the psf image has #odd dimensions #TinyTim returns the psf subsampled at a 5th of a pixel #but not necessarily psfim.shape % 5 == 0. #Now we need to pad the array with zeros so that center of the image will be in #the center of both the 1/5th pixel image and the psf at native scale. #As the center of the psf is at the center, all we need to do is add pixels to both #sides evenly until we have an integer number of native pixels. # All of the rules assume that the dimensions are odd which makes the rules #a little confusing. xpad,ypad = psfdat.shape[1] % 5, psfdat.shape[0] % 5 if xpad == 2: tmpdat = zeros([psfdat.shape[0],psfdat.shape[1]+8]) tmpdat[:,4:-4] = psfdat[:,:] psfdat = tmpdat elif xpad == 4: tmpdat = zeros([psfdat.shape[0],psfdat.shape[1]+6]) tmpdat[:,3:-3] = psfdat[:,:] psfdat = tmpdat elif xpad == 1: tmpdat = zeros([psfdat.shape[0],psfdat.shape[1]+4]) tmpdat[:,2:-2] = psfdat[:,:] psfdat = tmpdat elif xpad == 3: tmpdat = zeros([psfdat.shape[0],psfdat.shape[1]+2]) tmpdat[:,1:-1] = psfdat[:,:] psfdat = tmpdat if ypad == 2: tmpdat = zeros([psfdat.shape[0]+8,psfdat.shape[1]]) tmpdat[4:-4,:] = psfdat[:,:] psfdat = tmpdat elif ypad == 4: tmpdat = zeros([psfdat.shape[0]+6,psfdat.shape[1]]) tmpdat[3:-3,:] = psfdat[:,:] psfdat = tmpdat elif ypad == 1: tmpdat = zeros([psfdat.shape[0]+4,psfdat.shape[1]]) tmpdat[2:-2,:] = psfdat[:,:] psfdat = tmpdat elif ypad == 3: tmpdat = zeros([psfdat.shape[0]+2,psfdat.shape[1]]) tmpdat[1:-1,:] = psfdat[:,:] psfdat = tmpdat #Add 2 extra pixels on both sides (+ 400 in each dimension) to account for the fractional shift #and the geometric distortion psfdat100 = zeros([psfdat.shape[0]*20 + 400, psfdat.shape[1]*20 + 400]) #Calculate the fractional shifts xfrac,yfrac = int(round(xx % 1 * 100)), int(round(yy % 1 * 100)) if yfrac == 100: yfrac = 0 if xfrac == 100: xfrac = 0 #Add the geometric distorition offsets of the centroid into xfrac and yfrac. #This makes the assumption that the distortion centroid offsets are less than 1 pixel #This has been the case for all of my tests. if verbose: print('Adding %0.2f, %0.2f to correct the center of the psf for geometric distortion' % (xgeo,ygeo)) xfrac -= int(100*xgeo) yfrac -= int(100*ygeo) if verbose : print(" Interpolating and re-sampling with sub-pixel shift") # interpolate at a 20x smaller grid to get # sub-sampling at the 100th of a pixel level #Right now we use the ndimage zoom function which does a spline interpolation, but is fast try : psfdat100[200+yfrac:-(200-yfrac),200+xfrac:-(200-xfrac)] = zoom(psfdat,20) except ValueError as e: print( e ) import pdb; pdb.set_trace() # re-bin on a new grid to get the psf at the full-pixel scale psfdat1 = rebin(psfdat100, 100) #remove any reference to psfdat100 in hopes of it getting garbage collected #as it is by far the biggest thing we have in memory del psfdat100 psfdat1 = psfdat1 / psfdat1.sum() # Blur the re-binned psf to account for detector effects: # For UVIS and ACS, read in the charge diffusion kernel # For IR, we use a fixed IR inter-pixel capacitance kernel, defined above if verbose : print(" convolving with charged diffusion or inter-pixel capacitance kernel") if camera == 'ir': kernel=KERNEL_WFC3IR else : kernel = getCDkernel( psfim[0].header ) psfdat2 = convolvepsf( psfdat1, kernel ) # 2014.07.18 : Disabled by Steve # Rescale the TinyTim psf to match the measured aperture corrections. # psfdat2 *= fluxcorr # if verbose : print('Applying a %f flux correction to the TinyTim psf.' % fluxcorr) # write out the new recentered psf stamp outstamp = psfstamp.replace('.fits','_final.fits') hdr['naxis1']=psfdat2.shape[1] hdr['naxis2']=psfdat2.shape[0] pyfits.writeto( outstamp, psfdat2, header=hdr, clobber=True ) if verbose : print(" Shifted, resampled psf written to %s"%outstamp) outstamplist.append( outstamp ) # return a list of psf stamps return( outstamplist )
def add_and_recover(imagedat, psfmodel, xy, fluxscale=1, psfradius=5, skyannpix=None, skyalgorithm='sigmaclipping', setskyval=None, recenter=False, ronoise=1, phpadu=1, cleanup=True, verbose=False, debug=False): """ Add a single fake star psf model to the image at the given position and flux scaling, re-measure the flux at that position and report it, Also deletes the planted psf from the imagedat array so that we don't pollute that image array. :param imagedat: target image numpy data array :param psfmodel: psf model fits file or tuple with [gaussparam,lookuptable] :param xy: x,y position for fake psf, using the IDL/python convention where [0,0] is the lower left corner. :param fluxscale: flux scaling to apply to the planted psf :param recenter: use cntrd to locate the center of the added psf, instead of relying on the input x,y position to define the psf fitting :param cleanup: remove the planted psf from the input imagedat array. :return: """ if not skyannpix: skyannpix = [8, 15] # add the psf to the image data array imdatwithpsf = addtoimarray(imagedat, psfmodel, xy, fluxscale=fluxscale) # TODO: allow for uncertainty in the x,y positions gaussparam, lookuptable, psfmag, psfzpt = rdpsfmodel(psfmodel) # generate an instance of the pkfit class for this psf model # and target image pk = pkfit_class(imdatwithpsf, gaussparam, lookuptable, ronoise, phpadu) x, y = xy if debug: from .photfunctions import showpkfit from matplotlib import pyplot as pl, cm fig = pl.figure(3) showpkfit(imdatwithpsf, psfmodel, xy, 11, fluxscale, verbose=True) fig = pl.figure(1) pl.imshow(imdatwithpsf[y - 20:y + 20, x - 20:x + 20], cmap=cm.Greys, interpolation='nearest') pl.colorbar() import pdb pdb.set_trace() if recenter: xc, yc = cntrd(imdatwithpsf, x, y, psfradius, verbose=verbose) if xc > 0 and yc > 0 and abs(xc - xy[0]) < 5 and abs(yc - xy[1]) < 5: x, y = xc, yc # do aperture photometry to get the sky aperout = aper(imdatwithpsf, x, y, phpadu=phpadu, apr=psfradius * 3, skyrad=skyannpix, setskyval=(setskyval is not None and setskyval), zeropoint=psfzpt, exact=False, verbose=verbose, skyalgorithm=skyalgorithm, debug=debug) apmag, apmagerr, apflux, apfluxerr, sky, skyerr, apbadflag, apoutstr\ = aperout # do the psf fitting try: scale = pk.pkfit_fast_norecenter(1, x, y, sky, psfradius) fluxpsf = scale * 10 ** (-0.4 * (psfmag - psfzpt)) except RuntimeWarning: print("photfunctions.add_and_recover failed on RuntimeWarning") fluxpsf = -99 if cleanup: # remove the fake psf from the image imagedat = addtoimarray(imdatwithpsf, psfmodel, xy, fluxscale=-fluxscale) return apflux[0], fluxpsf, [x, y]
def get_flux_and_err(imagedat, psfmodel, xy, ntestpositions=100, psfradpix=3, apradpix=3, skyannpix=None, skyalgorithm='sigmaclipping', setskyval=None, recenter_target=True, recenter_fakes=True, exptime=1, exact=True, ronoise=1, phpadu=1, verbose=False, debug=False): """ Measure the flux and flux uncertainty for a source at the given x,y position using both aperture and psf-fitting photometry. Flux errors are measured by planting fake psfs or empty apertures into the sky annulus and recovering a distribution of fluxes with forced photometry. :param imagedat: target image numpy data array (with the star still there) :param psfmodel: psf model fits file or a 4-tuple with [gaussparam,lookuptable,psfmag,psfzpt] :param xy: x,y position of the center of the fake planting field :param ntestpositions: number of test positions for empty apertures and/or fake stars to use for determining the flux error empirically. :param psfradpix: radius to use for psf fitting, in pixels :param apradpix: radius of photometry aperture, in pixels :param skyannpix: inner and outer radius of sky annulus, in pixels :param skyalgorithm: algorithm to use for determining the sky value from the pixels within the sky annulus: 'sigmaclipping' or 'mmm' :param setskyval: if not None, use this value for the sky, ignoring the skyannulus :param recenter_target: use cntrd to locate the target center near the given xy position. :param recenter_fakes: recenter on each planted fake when recovering it :param exptime: exposure time of the image, for determining poisson noise :param ronoise: read-out noise, for determining aperture flux error analytically :param phpadu: photons-per-ADU, for determining aper flux err analytically :param verbose: turn verbosity on :param debug: enter pdb debugging mode :return: apflux, apfluxerr, psfflux, psffluxerr The flux measured through the given aperture and through psf fitting, along with associated errors. """ if not np.any(skyannpix): skyannpix = [8, 15] # locate the target star center position x, y = xy if recenter_target: x, y = cntrd(imagedat, x, y, psfradpix) if x < 0 or y < 0: print("WARNING [photfunctions.py] : recentering failed") import pdb pdb.set_trace() # do aperture photometry directly on the source # (Note : using an arbitrary zeropoint of 25 here) aperout = aper(imagedat, x, y, phpadu=phpadu, apr=apradpix, skyrad=skyannpix, setskyval=setskyval, zeropoint=25, exact=exact, verbose=verbose, skyalgorithm=skyalgorithm, debug=debug) apmag, apmagerr, apflux, apfluxerr, sky, skyerr, apbadflag, apoutstr = \ aperout # define a set of test position points that uniformly samples the sky # annulus region, for planting empty apertures and/or fake stars rmin = float(skyannpix[0]) rmax = float(skyannpix[1]) u = np.random.uniform(rmin, rmax, ntestpositions) v = np.random.uniform(0, rmin + rmax, ntestpositions) r = np.where(v < u, u, rmax + rmin - u) theta = np.random.uniform(0, 2 * np.pi, ntestpositions) xtestpositions = r * np.cos(theta) + x ytestpositions = r * np.sin(theta) + y psfflux = psffluxerr = np.nan if psfmodel is not None: # set up the psf model realization gaussparam, lookuptable, psfmag, psfzpt = rdpsfmodel(psfmodel) psfmodel = [gaussparam, lookuptable, psfmag, psfzpt] pk = pkfit_class(imagedat, gaussparam, lookuptable, ronoise, phpadu) # do the psf fitting try: scale = pk.pkfit_fast_norecenter(1, x, y, sky, psfradpix) psfflux = scale * 10 ** (0.4 * (25. - psfmag)) except RuntimeWarning: print("PythonPhot.pkfit_norecenter failed.") psfflux = np.nan if np.isfinite(psfflux): # remove the target star from the image imagedat = addtoimarray(imagedat, psfmodel, [x, y], fluxscale=-psfflux) # plant fakes and recover their fluxes with psf fitting # imdatsubarray = imagedat[y-rmax-2*psfradpix:y+rmax+2*psfradpix, # x-rmax-2*psfradpix:x+rmax+2*psfradpix] fakecoordlist, fakefluxlist = [], [] for xt, yt in zip(xtestpositions, ytestpositions): # To ensure appropriate sampling of sub-pixel positions, # we assign random sub-pixel offsets to each position. xt = int(xt) + np.random.random() yt = int(yt) + np.random.random() fakefluxaper, fakefluxpsf, fakecoord = add_and_recover( imagedat, psfmodel, [xt, yt], fluxscale=psfflux, cleanup=True, psfradius=psfradpix, recenter=recenter_fakes) if np.isfinite(fakefluxpsf): fakecoordlist.append(fakecoord) fakefluxlist.append(fakefluxpsf) fakefluxlist = np.array(fakefluxlist) fakefluxmean, fakefluxsigma = gaussian_fit_to_histogram(fakefluxlist) if abs(fakefluxmean - psfflux) > fakefluxsigma and verbose: print("WARNING: psf flux may be biased. Fake psf flux tests " "found a significantly non-zero sky value not accounted for " "in measurement of the target flux: \\" "Mean psf flux offset in sky annulus = %.3e\\" % (fakefluxmean - psfflux) + "sigma of fake flux distribution = %.3e" % fakefluxsigma + "NOTE: this is included as a systematic error, added in " "quadrature to the psf flux err derived from fake psf " "recovery.") psfflux_poissonerr = (poissonErr(psfflux * exptime, confidence=1) / exptime) # Total flux error is the quadratic sum of the poisson noise with # the systematic (shift) and statistical (dispersion) errors # inferred from fake psf planting and recovery psffluxerr = np.sqrt(psfflux_poissonerr**2 + (fakefluxmean - psfflux)**2 + fakefluxsigma**2) # drop down empty apertures and recover their fluxes with aperture phot # NOTE : if the star was removed for psf fitting, then we take advantage # of that to get aperture flux errors with the star gone. emptyaperout = aper(imagedat, np.array(xtestpositions), np.array(ytestpositions), phpadu=phpadu, apr=apradpix, setskyval=sky, zeropoint=25, exact=False, verbose=verbose, skyalgorithm=skyalgorithm, debug=debug) emptyapflux = emptyaperout[2] if np.any(np.isfinite(emptyapflux)): emptyapmeanflux, emptyapsigma = gaussian_fit_to_histogram(emptyapflux) emptyapbias = abs(emptyapmeanflux) - emptyapsigma if np.any(emptyapbias > 0) and verbose: print("WARNING: aperture flux may be biased. Empty aperture flux tests" " found a significantly non-zero sky value not accounted for in " "measurement of the target flux: \\" "Mean empty aperture flux in sky annulus = %s\\" % emptyapmeanflux + "sigma of empty aperture flux distribution = %s" % emptyapsigma) if np.iterable(apflux): apflux_poissonerr = np.array( [poissonErr(fap * exptime, confidence=1) / exptime for fap in apflux]) else: apflux_poissonerr = (poissonErr(apflux * exptime, confidence=1) / exptime) apfluxerr = np.sqrt(apflux_poissonerr**2 + emptyapbias**2 + emptyapsigma**2) else: if np.iterable(apradpix): apfluxerr = [np.nan for aprad in apradpix] else: apfluxerr = np.nan if psfmodel is not None and np.isfinite(psfflux): # return the target star back into the image imagedat = addtoimarray(imagedat, psfmodel, [x, y], fluxscale=psfflux) if debug > 1: import pdb pdb.set_trace() return apflux, apfluxerr, psfflux, psffluxerr, sky, skyerr
# FWHM for centroiding def FWHM(X,Y): half_max = max(Y) / 2 d = sign(half_max - array(Y[0:-1])) - sign(half_max - array(Y[1:])) left_idx = find(d > 0)[0] right_idx = find(d < 0)[-1] return X[right_idx] - X[left_idx] FWHM1 = FWHM(cntrdx1,cntrdy1) FWHM2 = FWHM(cntrdx2,cntrdy2) FWHM3 = FWHM(cntrdx3,cntrdy3) FWHM4 = FWHM(cntrdx4,cntrdy4) FWHM5 = FWHM(cntrdx5,cntrdy5) # Putting it together... ShiftLoc1 = cntrd.cntrd(Corr1,cntrdx1,cntrdy1,FWHM1) ShiftLoc2 = cntrd.cntrd(Corr2,cntrdx2,cntrdy2,FWHM2) ShiftLoc3 = cntrd.cntrd(Corr3,cntrdx3,cntrdy3,FWHM3) ShiftLoc4 = cntrd.cntrd(Corr4,cntrdx4,cntrdy4,FWHM4) ShiftLoc5 = cntrd.cntrd(Corr5,cntrdx5,cntrdy5,FWHM5) # Drizzling NewShiftLoc1 = mskpy.image.imshift(ShiftLoc1,cntrdx1,cntrdy1) NewShiftLoc2 = mskpy.image.imshift(ShiftLoc2,cntrdx2,cntrdy2) NewShiftLoc3 = mskpy.image.imshift(ShiftLoc3,cntrdx3,cntrdy3) NewShiftLoc4 = mskpy.image.imshift(ShiftLoc4,cntrdx4,cntrdy4) NewShiftLoc5 = mskpy.image.imshift(ShiftLoc5,cntrdx5,cntrdy5) # Total results ShiftTot = ShiftLoc1 + ShiftLoc2 + ShiftLoc3 + ShiftLoc4 + ShiftLoc5 medianShiftTot = np.median(ShiftTot)