class ToyKernel(Kernel): """ Simple toy kernel that selects healpix pixels within the given extension radius. Similar to 'RadialDisk'. """ _params = odict(Kernel._params.items() + [ ('extension', Parameter(0.1, [0.0001, 5.0])), ('nside', Parameter(4096, [4096, 4096])), ]) def _cache(self, name=None): pixel_area = healpy.nside2pixarea(self.nside, degrees=True) vec = ang2vec(self.lon, self.lat) self.pix = query_disc(self.nside, vec, self.extension) self._norm = 1. / (len(self.pix) * pixel_area) @property def norm(self): return self._norm def _pdf(self, pix): return np.in1d(pix, self.pix) def pdf(self, lon, lat): pix = ang2pix(self.nside, lon, lat) return self.norm * self._pdf(pix)
class Kernel(Model): """ Base class for kernels. """ _params = odict([ ('lon', Parameter(0.0, [0.0, 360.])), ('lat', Parameter(0.0, [-90., 90.])), ]) _mapping = odict([]) _proj = 'ait' def __init__(self, proj='ait', **kwargs): # This __init__ is probably not necessary... self.proj = proj super(Kernel, self).__init__(**kwargs) def __call__(self, lon, lat): return self.pdf(lon, lat) @abstractmethod def _kernel(self, r): # unnormalized, untruncated kernel pass def _pdf(self, radius): # unnormalized, truncated kernel return np.where(radius <= self.edge, self._kernel(radius), 0.) @abstractmethod def pdf(self, lon, lat): # normalized, truncated pdf pass @property def norm(self): # Numerically integrate the pdf return 1. / self.integrate() @property def projector(self): if self.proj is None or self.proj.lower() == 'none': return None else: return Projector(self.lon, self.lat, self.proj) def integrate(self, rmin=0, rmax=numpy.inf): """ Calculate the 2D integral of the 1D surface brightness profile (i.e, the flux) between rmin and rmax (elliptical radii). rmin : minimum integration radius (deg) rmax : maximum integration radius (deg) return : Solid angle integral (deg^2) """ if rmin < 0: raise Exception('rmin must be >= 0') integrand = lambda r: self._pdf(r) * 2 * numpy.pi * r return scipy.integrate.quad(integrand, rmin, rmax, full_output=True, epsabs=0)[0]
class DESDwarfs(EmpiricalPadova): """ Empirical isochrone derived from spectroscopic members of the DES dwarfs. """ _params = odict([ ('distance_modulus', Parameter(15.0, [10.0, 30.0])), ('age', Parameter(12.5, [12.5, 12.5])), # Gyr ('metallicity', Parameter(1e-4, [1e-4, 1e-4])), ]) _prefix = 'dsph' _basename = '%(prefix)s_a12.5_z0.00010.dat'
class M92(EmpiricalPadova): """ Empirical isochrone derived from the M92 ridgeline dereddened and transformed to the DES system. """ _params = odict([ ('distance_modulus', Parameter(15.0, [10.0, 30.0])), ('age', Parameter(13.7, [13.7, 13.7])), # Gyr ('metallicity', Parameter(7e-5, [7e-5, 7e-5])), ]) _prefix = 'm92' _basename = '%(prefix)s_a13.7_z0.00007.dat'
class Richness(Model): """Dummy model to hold the richness, which is not directly connected to either the spatial or color information and doesn't require a sync when updated. """ _params = odict([ ('richness', Parameter(1000.0, [0.0, np.inf])), ])
class EllipticalKing(EllipticalKernel): """ Stellar density distribution for King profile: f(r) = C * [ 1/sqrt(1 + (r/r_c)**2) - 1/sqrt(1 + (r_t/r_c)**2) ]**2 http://adsabs.harvard.edu//abs/2006MNRAS.365.1263M (Eq. 4) The half-light radius is related to the King radius for c = log10(r_t/r_c) = 0.7 : r_h = 1.185 * r_c http://adsabs.harvard.edu/abs/2010MNRAS.406.1220W (App.B) """ _params = odict( EllipticalKernel._params.items() + [ ('truncate', Parameter(3.0, [0.0, np.inf]) ), # Truncation radius ]) _mapping = odict( EllipticalKernel._mapping.items() + [ ('r_c','extension'), # Core radius ('r_t','truncate'), # Tidal radius ]) def _kernel(self, radius): return ((1./np.sqrt(1.+(radius/self.r_c)**2))-(1./np.sqrt(1.+(self.r_t/self.r_c)**2)))**2 def _cache(self, name=None): if name in ['extension','ellipticity','truncate']: self._norm = 1./self.integrate() else: return @property def norm(self): return self._norm @property def c(self): return np.log10(self.r_t/self.r_c) @property def edge(self): return self.r_t
class EllipticalPlummer(EllipticalKernel): """ Stellar density distribution for Plummer profile: f(r) = C * r_c**2 / (r_c**2 + r**2)**2 http://adsabs.harvard.edu//abs/2006MNRAS.365.1263M (Eq. 6) """ _params = odict( EllipticalKernel._params.items() + [ ('truncate', Parameter(3.0, [0.0, np.inf]) ), # Truncation radius ]) _mapping = odict( EllipticalKernel._mapping.items() + [ ('r_c','extension'), # Plummer radius ('r_h','extension'), # ADW: Depricated ('r_t','truncate'), # Tidal radius ]) def _kernel(self, radius): return 1./(numpy.pi*self.r_h**2 * (1.+(radius/self.r_h)**2)**2) def _cache(self, name=None): if name in [None,'extension','ellipticity','truncate']: self._norm = 1./self.integrate() * 1./self.jacobian else: return @property def norm(self): return self._norm @property def u_t(self): # Truncation factor return self.truncate/self.extension @property def edge(self): return self.r_t
class EllipticalKernel(Kernel): """ Base class for elliptical kernels. Ellipticity is defined as 1 - b/a where a,b are the semi-major,semi-minor axes respectively. The position angle is defined in degrees east of north. This definition follows from Martin et al. 2008: http://adsabs.harvard.edu/abs/2008ApJ...684.1075M ### This is a depricated warning (2015/08/12) ### ADW: WARNING!!! This is actually the PA *WEST* of North! ### to get the conventional PA EAST of North take 90-PA ### Documentation? """ _params = odict(Kernel._params.items() + [ ('extension', Parameter(0.1, [0.0001, 0.5])), ('ellipticity', Parameter(0.0, [0.0, 0.99])), # Default 0 for RadialKernel ('position_angle', Parameter(0.0, [0.0, 180.0])), # Default 0 for RadialKernel # This is the PA *WEST* of North. # to get the conventional PA EAST of North take 90-PA # Would it be better to have bounds [-90,90]? ]) _mapping = odict([ ('e', 'ellipticity'), ('theta', 'position_angle'), ]) @property def norm(self): norm = super(EllipticalKernel, self).norm return norm * 1. / self.jacobian @property def jacobian(self): return 1. - self.e @property def a(self): return self.extension @property def b(self): return self.a * self.jacobian @property def edge(self): return 5. * self.extension def angsep(self, lon, lat): return angsep(self.lon, self.lat, lon, lat) def radius(self, lon, lat): x, y = self.projector.sphereToImage(lon, lat) costh = np.cos(np.radians(self.theta)) sinth = np.sin(np.radians(self.theta)) return np.sqrt(((x * costh - y * sinth) / (1 - self.e))**2 + (x * sinth + y * costh)**2) def pdf(self, lon, lat): radius = self.radius(lon, lat) return self.norm * self._pdf(radius) def sample_radius(self, n): """ Sample the radial distribution (deg) from the 2D stellar density. Output is elliptical radius in true projected coordinates. """ edge = self.edge if self.edge < 20 * self.extension else 20 * self.extension radius = np.linspace(0, edge, 1.e5) pdf = self._pdf(radius) * np.sin(np.radians(radius)) cdf = np.cumsum(pdf) cdf /= cdf[-1] fn = scipy.interpolate.interp1d(cdf, range(0, len(cdf))) index = numpy.floor(fn(numpy.random.uniform(size=n))).astype(int) return radius[index] def sample_lonlat(self, n): """ Sample 2D distribution of points in lon, lat """ # From http://en.wikipedia.org/wiki/Ellipse#General_parametric_form # However, Martin et al. (2009) use PA theta "from North to East" # Definition of phi (position angle) is offset by pi/4 # Definition of t (eccentric anamoly) remains the same (x,y-frame usual) # In the end, everything is trouble because we use glon, glat... radius = self.sample_radius(n) a = radius b = self.jacobian * radius t = 2. * np.pi * numpy.random.rand(n) cost, sint = np.cos(t), np.sin(t) phi = np.pi / 2. - np.deg2rad(self.theta) cosphi, sinphi = np.cos(phi), np.sin(phi) x = a * cost * cosphi - b * sint * sinphi y = a * cost * sinphi + b * sint * cosphi if self.projector is None: logger.debug("Creating AITOFF projector for sampling") projector = Projector(self.lon, self.lat, 'ait') else: projector = self.projector lon, lat = projector.imageToSphere(x, y) return lon, lat simulate = sample_lonlat sample = sample_lonlat # Back-compatibility def setExtension(self, extension): self.extension = extension def setCenter(self, lon, lat): self.lon = lon self.lat = lat
class IsochroneModel(Model): """ Abstract base class for dealing with isochrone models. """ _params = odict([ ('distance_modulus', Parameter(15.0, [10.0, 30.0]) ), ('age', Parameter(10.0, [0.1, 15.0]) ), # Gyr ('metallicity', Parameter(0.0002, [0.0,0.02]) ), ]) _mapping = odict([ ('mod','distance_modulus'), ('a','age'), ('z','metallicity'), ]) # ADW: Careful, there are weird things going on with adding # defaults to subclasses... When converted to a dict, the # last duplicate entry is filled. # ADW: Need to explicitly call '_cache' when updating these parameters. defaults = ( ('survey','des','Name of survey filter system'), ('dirname',get_iso_dir(),'Directory name for isochrone files'), ('band_1','g','Field name for magnitude one'), ('band_2','r','Field name for magnitude two'), ('band_1_detection',True,'Band one is detection band'), ('imf_type','Chabrier2003','Initial mass function'), ('hb_stage',None,'Horizontal branch stage name'), ('hb_spread',0.0,'Intrinisic spread added to horizontal branch'), ) def __init__(self, **kwargs): self._setup(**kwargs) super(IsochroneModel,self).__init__(**kwargs) def _setup(self, **kwargs): # ADW: Should we add a warning for kwargs not in defaults (and # thus not set)? defaults = odict([(d[0],d[1]) for d in self.defaults]) [defaults.update([i]) for i in list(kwargs.items()) if i[0] in defaults] for k,v in list(defaults.items()): setattr(self,k,v) self.imf = ugali.analysis.imf.factory(defaults['imf_type']) self.index = None def _parse(self,filename): msg = "Not implemented for base class" raise Exception(msg) def get_dirname(self): return os.path.expandvars(self.dirname.format(survey=self.survey)) def todict(self): ret = super(IsochroneModel,self).todict() defaults = odict([(d[0],d[1]) for d in self.defaults]) for k,v in defaults.items(): if getattr(self,k) != v: ret[k] = getattr(self,k) return ret @property def distance(self): """ Convert to physical distance (kpc) """ return mod2dist(self.distance_modulus) def sample(self, mode='data', mass_steps=1000, mass_min=0.1, full_data_range=False): """Sample the isochrone in steps of mass interpolating between the originally defined isochrone points. Parameters: ----------- mode : mass_steps : mass_min : Minimum mass [Msun] full_data_range : Returns: -------- mass_init : Initial mass of each point mass_pdf : PDF of number of stars in each point mass_act : Actual (current mass) of each stellar point mag_1 : Array of magnitudes in first band (distance modulus applied) mag_2 : Array of magnitudes in second band (distance modulus applied) """ if full_data_range: # ADW: Might be depricated 02/10/2015 # Generate points over full isochrone data range select = slice(None) else: # Not generating points for the post-AGB stars, # but still count those stars towards the normalization select = slice(self.index) mass_steps = int(mass_steps) mass_init = self.mass_init[select] mass_act = self.mass_act[select] mag_1 = self.mag_1[select] mag_2 = self.mag_2[select] # ADW: Assume that the isochrones are pre-sorted by mass_init # This avoids some numerical instability from points that have the same # mass_init value (discontinuities in the isochrone). # ADW: Might consider using np.interp for speed mass_act_interpolation = scipy.interpolate.interp1d(mass_init, mass_act,assume_sorted=True) mag_1_interpolation = scipy.interpolate.interp1d(mass_init, mag_1,assume_sorted=True) mag_2_interpolation = scipy.interpolate.interp1d(mass_init, mag_2,assume_sorted=True) # ADW: Any other modes possible? if mode=='data': # Mass interpolation with uniform coverage between data points from isochrone file mass_interpolation = scipy.interpolate.interp1d(np.arange(len(mass_init)), mass_init) mass_array = mass_interpolation(np.linspace(0, len(mass_init)-1, mass_steps+1)) d_mass = mass_array[1:] - mass_array[:-1] mass_init_array = np.sqrt(mass_array[1:] * mass_array[:-1]) mass_pdf_array = d_mass * self.imf.pdf(mass_init_array, log_mode=False) mass_act_array = mass_act_interpolation(mass_init_array) mag_1_array = mag_1_interpolation(mass_init_array) mag_2_array = mag_2_interpolation(mass_init_array) # Horizontal branch dispersion if self.hb_spread and (self.stage==self.hb_stage).any(): logger.debug("Performing dispersion of horizontal branch...") mass_init_min = self.mass_init[self.stage==self.hb_stage].min() mass_init_max = self.mass_init[self.stage==self.hb_stage].max() cut = (mass_init_array>mass_init_min)&(mass_init_array<mass_init_max) if isinstance(self.hb_spread,collections.Iterable): # Explicit dispersion spacing dispersion_array = self.hb_spread n = len(dispersion_array) else: # Default dispersion spacing dispersion = self.hb_spread spacing = 0.025 n = int(round(2.0*self.hb_spread/spacing)) if n % 2 != 1: n += 1 dispersion_array = np.linspace(-dispersion, dispersion, n) # Reset original values mass_pdf_array[cut] = mass_pdf_array[cut] / float(n) # Isochrone values for points on the HB mass_init_hb = mass_init_array[cut] mass_pdf_hb = mass_pdf_array[cut] mass_act_hb = mass_act_array[cut] mag_1_hb = mag_1_array[cut] mag_2_hb = mag_2_array[cut] # Add dispersed values for dispersion in dispersion_array: if dispersion == 0.: continue msg = 'Dispersion=%-.4g, HB Points=%i, Iso Points=%i'%(dispersion,cut.sum(),len(mass_init_array)) logger.debug(msg) mass_init_array = np.append(mass_init_array, mass_init_hb) mass_pdf_array = np.append(mass_pdf_array, mass_pdf_hb) mass_act_array = np.append(mass_act_array, mass_act_hb) mag_1_array = np.append(mag_1_array, mag_1_hb + dispersion) mag_2_array = np.append(mag_2_array, mag_2_hb + dispersion) # Note that the mass_pdf_array is not generally normalized to unity # since the isochrone data range typically covers a different range # of initial masses #mass_pdf_array /= np.sum(mass_pdf_array) # ORIGINAL # Normalize to the number of stars in the satellite with mass > mass_min mass_pdf_array /= self.imf.integrate(mass_min, self.mass_init_upper_bound) out = np.vstack([mass_init_array,mass_pdf_array,mass_act_array,mag_1_array,mag_2_array]) return out def stellar_mass(self, mass_min=0.1, steps=10000): """ Compute the stellar mass (Msun; average per star). PDF comes from IMF, but weight by actual stellar mass. Parameters: ----------- mass_min : Minimum mass to integrate the IMF steps : Number of steps to sample the isochrone Returns: -------- mass : Stellar mass [Msun] """ mass_max = self.mass_init_upper_bound d_log_mass = (np.log10(mass_max) - np.log10(mass_min)) / float(steps) log_mass = np.linspace(np.log10(mass_min), np.log10(mass_max), steps) mass = 10.**log_mass if mass_min < np.min(self.mass_init): mass_act_interpolation = scipy.interpolate.interp1d(np.insert(self.mass_init, 0, mass_min), np.insert(self.mass_act, 0, mass_min)) else: mass_act_interpolation = scipy.interpolate.interp1d(self.mass_init, self.mass_act) mass_act = mass_act_interpolation(mass) return np.sum(mass_act * d_log_mass * self.imf.pdf(mass, log_mode=True)) def stellar_luminosity(self, steps=10000): """ Compute the stellar luminosity (Lsun; average per star). PDF comes from IMF. The range of integration only covers the input isochrone data (no extrapolation used), but this seems like a sub-percent effect if the isochrone goes to 0.15 Msun for the old and metal-poor stellar populations of interest. Note that the stellar luminosity is very sensitive to the post-AGB population. Parameters: ----------- steps : Number of steps to sample the isochrone. Returns: -------- lum : The stellar luminosity [Lsun] """ mass_min = np.min(self.mass_init) mass_max = self.mass_init_upper_bound d_log_mass = (np.log10(mass_max) - np.log10(mass_min)) / float(steps) log_mass = np.linspace(np.log10(mass_min), np.log10(mass_max), steps) mass = 10.**log_mass luminosity_interpolation = scipy.interpolate.interp1d(self.mass_init, self.luminosity,fill_value=0,bounds_error=False) luminosity = luminosity_interpolation(mass) return np.sum(luminosity * d_log_mass * self.imf.pdf(mass, log_mode=True)) def stellar_luminosity2(self, steps=10000): """ DEPRECATED: ADW 2017-09-20 Compute the stellar luminosity (L_Sol; average per star). Uses "sample" to generate mass sample and pdf. The range of integration only covers the input isochrone data (no extrapolation used), but this seems like a sub-percent effect if the isochrone goes to 0.15 Msun for the old and metal-poor stellar populations of interest. Note that the stellar luminosity is very sensitive to the post-AGB population. """ msg = "'%s.stellar_luminosity2': ADW 2017-09-20"%self.__class__.__name__ DeprecationWarning(msg) mass_init, mass_pdf, mass_act, mag_1, mag_2 = self.sample(mass_steps=steps) luminosity_interpolation = scipy.interpolate.interp1d(self.mass_init, self.luminosity,fill_value=0,bounds_error=False) luminosity = luminosity_interpolation(mass_init) return np.sum(luminosity * mass_pdf) # ADW: For temporary backward compatibility stellarMass = stellar_mass stellarLuminosity = stellar_luminosity def absolute_magnitude(self, richness=1, steps=1e4): """ Calculate the absolute magnitude (Mv) by integrating the stellar luminosity. Parameters: ----------- richness : isochrone normalization parameter steps : number of isochrone sampling steps Returns: -------- abs_mag : Absolute magnitude (Mv) """ # Using the SDSS g,r -> V from Jester 2005 [arXiv:0506022] # for stars with R-I < 1.15 # V = g_sdss - 0.59(g_sdss-r_sdss) - 0.01 # g_des = g_sdss - 0.104(g_sdss - r_sdss) + 0.01 # r_des = r_sdss - 0.102(g_sdss - r_sdss) + 0.02 if self.survey.lower() != 'des': raise Exception('Only valid for DES') if 'g' not in [self.band_1,self.band_2]: msg = "Need g-band for absolute magnitude" raise Exception(msg) if 'r' not in [self.band_1,self.band_2]: msg = "Need r-band for absolute magnitude" raise Exception(msg) mass_init,mass_pdf,mass_act,mag_1,mag_2=self.sample(mass_steps = steps) g,r = (mag_1,mag_2) if self.band_1 == 'g' else (mag_2,mag_1) V = g - 0.487*(g - r) - 0.0249 flux = np.sum(mass_pdf*10**(-V/2.5)) Mv = -2.5*np.log10(richness*flux) return Mv def absolute_magnitude_martin(self, richness=1, steps=1e4, n_trials=1000, mag_bright=16., mag_faint=23., alpha=0.32, seed=None): """ Calculate the absolute magnitude (Mv) of the isochrone using the prescription of Martin et al. 2008. Parameters: ----------- richness : Isochrone nomalization factor steps : Number of steps for sampling the isochrone. n_trials : Number of bootstrap samples mag_bright : Bright magnitude limit for calculating luminosity. mag_faint : Faint magnitude limit for calculating luminosity. alpha : Output confidence interval (1-alpha) seed : Random seed Returns: -------- med,lo,hi : Absolute magnitude interval """ # ADW: This function is not quite right. You should be restricting # the catalog to the obsevable space (using the function named as such) # Also, this needs to be applied in each pixel individually # Using the SDSS g,r -> V from Jester 2005 [arXiv:0506022] # for stars with R-I < 1.15 # V = g_sdss - 0.59(g_sdss-r_sdss) - 0.01 # g_des = g_sdss - 0.104(g_sdss - r_sdss) + 0.01 # r_des = r_sdss - 0.102(g_sdss - r_sdss) + 0.02 np.random.seed(seed) if self.survey.lower() != 'des': raise Exception('Only valid for DES') if 'g' not in [self.band_1,self.band_2]: msg = "Need g-band for absolute magnitude" raise Exception(msg) if 'r' not in [self.band_1,self.band_2]: msg = "Need r-band for absolute magnitude" raise Exception(msg) def visual(g, r, pdf=None): v = g - 0.487 * (g - r) - 0.0249 if pdf is None: flux = np.sum(10**(-v / 2.5)) else: flux = np.sum(pdf * 10**(-v / 2.5)) abs_mag_v = -2.5 * np.log10(flux) return abs_mag_v def sumMag(mag_1, mag_2): flux_1 = 10**(-mag_1 / 2.5) flux_2 = 10**(-mag_2 / 2.5) return -2.5 * np.log10(flux_1 + flux_2) # Analytic part mass_init, mass_pdf, mass_act, mag_1, mag_2 = self.sample(mass_steps = steps) g,r = (mag_1,mag_2) if self.band_1 == 'g' else (mag_2,mag_1) #cut = np.logical_not((g > mag_bright) & (g < mag_faint) & (r > mag_bright) & (r < mag_faint)) cut = ((g + self.distance_modulus) > mag_faint) if self.band_1 == 'g' else ((r + self.distance_modulus) > mag_faint) mag_unobs = visual(g[cut], r[cut], richness * mass_pdf[cut]) # Stochastic part abs_mag_obs_array = np.zeros(n_trials) for ii in range(0, n_trials): if ii%100==0: logger.debug('%i absolute magnitude trials'%ii) g, r = self.simulate(richness * self.stellar_mass()) #cut = (g > 16.) & (g < 23.) & (r > 16.) & (r < 23.) cut = (g < mag_faint) if self.band_1 == 'g' else (r < mag_faint) mag_obs = visual(g[cut] - self.distance_modulus, r[cut] - self.distance_modulus) abs_mag_obs_array[ii] = sumMag(mag_obs, mag_unobs) # ADW: This shouldn't be necessary #abs_mag_obs_array = np.sort(abs_mag_obs_array)[::-1] # ADW: Careful, fainter abs mag is larger (less negative) number q = [100*alpha/2., 50, 100*(1-alpha/2.)] hi,med,lo = np.percentile(abs_mag_obs_array,q) return ugali.utils.stats.interval(med,lo,hi) def simulate(self, stellar_mass, distance_modulus=None, **kwargs): """ Simulate a set of stellar magnitudes (no uncertainty) for a satellite of a given stellar mass and distance. """ if distance_modulus is None: distance_modulus = self.distance_modulus # Total number of stars in system n = int(stellar_mass / self.stellar_mass()) f_1 = scipy.interpolate.interp1d(self.mass_init, self.mag_1) f_2 = scipy.interpolate.interp1d(self.mass_init, self.mag_2) mass_init_sample = self.imf.sample(n, np.min(self.mass_init), np.max(self.mass_init), **kwargs) mag_1_sample, mag_2_sample = f_1(mass_init_sample), f_2(mass_init_sample) return mag_1_sample + distance_modulus, mag_2_sample + distance_modulus def observableFractionCMDX(self, mask, distance_modulus, mass_min=0.1): """ Compute observable fraction of stars with masses greater than mass_min in each pixel in the interior region of the mask. ADW: Careful, this function is fragile! The selection here should be the same as mask.restrictCatalogToObservable space. However, for technical reasons it is faster to do the calculation with broadcasting here. ADW: Could this function be even faster / more readable? ADW: Should this include magnitude error leakage? """ mass_init_array,mass_pdf_array,mass_act_array,mag_1_array,mag_2_array = self.sample(mass_min=mass_min,full_data_range=False) mag = mag_1_array if self.band_1_detection else mag_2_array color = mag_1_array - mag_2_array # ADW: Only calculate observable fraction over interior pixels... pixels = mask.roi.pixels_interior mag_1_mask = mask.mask_1.mask_roi_sparse[mask.roi.pixel_interior_cut] mag_2_mask = mask.mask_2.mask_roi_sparse[mask.roi.pixel_interior_cut] # ADW: Restrict mag and color to range of mask with sufficient solid angle cmd_cut = ugali.utils.binning.take2D(mask.solid_angle_cmd,color,mag+distance_modulus, mask.roi.bins_color, mask.roi.bins_mag) > 0 # Pre-apply these cuts to the 1D mass_pdf_array to save time mass_pdf_cut = mass_pdf_array*cmd_cut # Create 2D arrays of cuts for each pixel mask_1_cut = (mag_1_array+distance_modulus)[:,np.newaxis] < mag_1_mask mask_2_cut = (mag_2_array+distance_modulus)[:,np.newaxis] < mag_2_mask mask_cut_repeat = mask_1_cut & mask_2_cut observable_fraction = (mass_pdf_cut[:,np.newaxis]*mask_cut_repeat).sum(axis=0) return observable_fraction def observableFractionCMD(self, mask, distance_modulus, mass_min=0.1): """ Compute observable fraction of stars with masses greater than mass_min in each pixel in the interior region of the mask. ADW: Careful, this function is fragile! The selection here should be the same as mask.restrictCatalogToObservable space. However, for technical reasons it is faster to do the calculation with broadcasting here. ADW: Could this function be even faster / more readable? ADW: Should this include magnitude error leakage? """ if distance_modulus is None: distance_modulus = self.distance_modulus mass_init,mass_pdf,mass_act,mag_1,mag_2 = self.sample(mass_min=mass_min,full_data_range=False) mag = mag_1 if self.band_1_detection else mag_2 color = mag_1 - mag_2 # ADW: Only calculate observable fraction for unique mask values mag_1_mask,mag_2_mask = mask.mask_roi_unique.T # ADW: Restrict mag and color to range of mask with sufficient solid angle cmd_cut = ugali.utils.binning.take2D(mask.solid_angle_cmd,color,mag+distance_modulus, mask.roi.bins_color, mask.roi.bins_mag) > 0 # Pre-apply these cuts to the 1D mass_pdf_array to save time mass_pdf_cut = mass_pdf*cmd_cut # Create 2D arrays of cuts for each pixel mask_1_cut = (mag_1+distance_modulus)[:,np.newaxis] < mag_1_mask mask_2_cut = (mag_2+distance_modulus)[:,np.newaxis] < mag_2_mask mask_cut_repeat = (mask_1_cut & mask_2_cut) # Condense back into one per digi observable_fraction = (mass_pdf_cut[:,np.newaxis]*mask_cut_repeat).sum(axis=0) # Expand to the roi and multiply by coverage fraction return observable_fraction[mask.mask_roi_digi[mask.roi.pixel_interior_cut]] * mask.frac_interior_sparse def observableFractionCDF(self, mask, distance_modulus, mass_min=0.1): """ Compute observable fraction of stars with masses greater than mass_min in each pixel in the interior region of the mask. Incorporates simplistic photometric errors. ADW: Careful, this function is fragile! The selection here should be the same as mask.restrictCatalogToObservable space. However, for technical reasons it is faster to do the calculation with broadcasting here. ADW: This function is currently a rate-limiting step in the likelihood calculation. Could it be faster? """ method = 'step' mass_init,mass_pdf,mass_act,mag_1,mag_2 = self.sample(mass_min=mass_min,full_data_range=False) mag_1 = mag_1+distance_modulus mag_2 = mag_2+distance_modulus mask_1,mask_2 = mask.mask_roi_unique.T mag_err_1 = mask.photo_err_1(mask_1[:,np.newaxis]-mag_1) mag_err_2 = mask.photo_err_2(mask_2[:,np.newaxis]-mag_2) # "upper" bound set by maglim delta_hi_1 = (mask_1[:,np.newaxis]-mag_1)/mag_err_1 delta_hi_2 = (mask_2[:,np.newaxis]-mag_2)/mag_err_2 # "lower" bound set by bins_mag (maglim shouldn't be 0) delta_lo_1 = (mask.roi.bins_mag[0]-mag_1)/mag_err_1 delta_lo_2 = (mask.roi.bins_mag[0]-mag_2)/mag_err_2 cdf_1 = norm_cdf(delta_hi_1) - norm_cdf(delta_lo_1) cdf_2 = norm_cdf(delta_hi_2) - norm_cdf(delta_lo_2) cdf = cdf_1*cdf_2 if method is None or method == 'none': comp_cdf = cdf elif self.band_1_detection == True: comp = mask.mask_1.completeness(mag_1, method=method) comp_cdf = comp*cdf elif self.band_1_detection == False: comp =mask.mask_2.completeness(mag_2, method=method) comp_cdf = comp*cdf else: comp_1 = mask.mask_1.completeness(mag_1, method=method) comp_2 = mask.mask_2.completeness(mag_2, method=method) comp_cdf = comp_1*comp_2*cdf observable_fraction = (mass_pdf[np.newaxis]*comp_cdf).sum(axis=-1) return observable_fraction[mask.mask_roi_digi[mask.roi.pixel_interior_cut]] def observableFractionMMD(self, mask, distance_modulus, mass_min=0.1): # This can be done faster... logger.info('Calculating observable fraction from MMD') mmd = self.signalMMD(mask,distance_modulus) obs_frac = mmd.sum(axis=-1).sum(axis=-1)[mask.mask_roi_digi[mask.roi.pixel_interior_cut]] return obs_frac observable_fraction = observableFractionCMD observableFraction = observable_fraction def signalMMD(self, mask, distance_modulus, mass_min=0.1, nsigma=5, delta_mag=0.03, mass_steps=1000, method='step'): roi = mask.roi mass_init,mass_pdf,mass_act,mag_1,mag_2 = self.sample(mass_steps=mass_steps,mass_min=mass_min,full_data_range=False) mag_1 = mag_1+distance_modulus mag_2 = mag_2+distance_modulus mask_1,mask_2 = mask.mask_roi_unique.T mag_err_1 = mask.photo_err_1(mask_1[:,np.newaxis]-mag_1) mag_err_2 = mask.photo_err_2(mask_2[:,np.newaxis]-mag_2) # Set mag_err for mask==0 to epsilon mag_err_1[mask_1==0] *= -np.inf mag_err_2[mask_2==0] *= -np.inf #edges_mag = np.arange(mask.roi.bins_mag[0] - (0.5*delta_mag), # mask.roi.bins_mag[-1] + (0.5*delta_mag), # delta_mag) #nedges = edges_mag.shape[0] nedges = np.rint((roi.bins_mag[-1]-roi.bins_mag[0])/delta_mag)+1 edges_mag,delta_mag = np.linspace(roi.bins_mag[0],roi.bins_mag[-1],nedges,retstep=True) edges_mag_1 = edges_mag_2 = edges_mag nbins = nedges - 1 mag_err_1_max = mag_err_1.max(axis=0) mag_err_2_max = mag_err_2.max(axis=0) max_idx_1 = np.searchsorted(edges_mag[:-1],mag_1+nsigma*mag_err_1_max) min_idx_1 = np.searchsorted(edges_mag[:-1],mag_1-nsigma*mag_err_1_max) max_idx_2 = np.searchsorted(edges_mag[:-1],mag_2+nsigma*mag_err_1_max) min_idx_2 = np.searchsorted(edges_mag[:-1],mag_2-nsigma*mag_err_1_max) # Select only isochrone values that will contribute to the MMD space sel = (max_idx_1>0)&(min_idx_1<nbins)&(max_idx_2>0)&(min_idx_2<nbins) if sel.sum() == 0: msg = 'No isochrone points in magnitude selection range' raise Exception(msg) mag_1,mag_2 = mag_1[sel],mag_2[sel] mag_err_1,mag_err_2 = mag_err_1[:,sel],mag_err_2[:,sel] mass_pdf = mass_pdf[sel] mag_err_1_max = mag_err_1.max(axis=0) mag_err_2_max = mag_err_2.max(axis=0) min_idx_1,max_idx_1 = min_idx_1[sel],max_idx_1[sel] min_idx_2,max_idx_2 = min_idx_2[sel],max_idx_2[sel] nmaglim,niso = mag_err_1.shape # Find valid indices in MMD space (can we avoid this loop?) nidx = ((max_idx_1-min_idx_1)*(max_idx_2-min_idx_2)) mag_idx = np.arange(niso).repeat(nidx) bin_idx = np.zeros(nidx.sum(),dtype=int) ii = 0 # ADW: Can we avoid this loop? for i in range(niso): x = np.ravel_multi_index(np.mgrid[min_idx_1[i]:max_idx_1[i], min_idx_2[i]:max_idx_2[i]], [nbins,nbins]).ravel() bin_idx[ii:ii+len(x)] = x ii += len(x) #idx = np.unique(idx) idx_1,idx_2 = np.unravel_index(bin_idx,[nbins,nbins]) # Pre-compute the indexed arrays to save time at the cost of memory mag_1_idx,mag_2_idx = mag_1[mag_idx],mag_2[mag_idx] mag_err_1_idx,mag_err_2_idx = mag_err_1[:,mag_idx],mag_err_2[:,mag_idx] edges_mag_1_idx,edges_mag_2_idx = edges_mag[idx_1],edges_mag[idx_2] arg_mag_1_hi = (mag_1_idx - edges_mag_1_idx) / mag_err_1_idx arg_mag_1_lo = arg_mag_1_hi - delta_mag/mag_err_1_idx arg_mag_2_hi = (mag_2_idx - edges_mag_2_idx) / mag_err_2_idx arg_mag_2_lo = arg_mag_2_hi - delta_mag/mag_err_2_idx del mag_1_idx,mag_2_idx del mag_err_1_idx,mag_err_2_idx del edges_mag_1_idx,edges_mag_2_idx # This may become necessary with more maglim bins ### # PDF is only ~nonzero for object-bin pairs within 5 sigma in both magnitudes ### index_nonzero = np.nonzero((arg_mag_1_hi > -nsigma)*(arg_mag_1_lo < nsigma) \ ### *(arg_mag_2_hi > -nsigma)*(arg_mag_2_lo < nsigma)) ### idx_maglim,idx_iso,idx_idx = index_nonzero ### subidx = idx[idx_idx] pdf_val_1 = norm_cdf(arg_mag_1_hi)-norm_cdf(arg_mag_1_lo) pdf_val_2 = norm_cdf(arg_mag_2_hi)-norm_cdf(arg_mag_2_lo) pdf_val = pdf_val_1 * pdf_val_2 # Deal with completeness if method is None or method == 'none': comp = None elif self.band_1_detection == True: comp=mask.completeness(mask_1[:,np.newaxis]-mag_1, method=method) elif self.band_1_detection == False: comp=mask.completeness(mask_2[:,np.newaxis]-mag_2, method=method) else: comp_1 = mask.completeness(mask_1[:,np.newaxis]-mag_1, method=method) comp_2 = mask.completeness(mask_2[:,np.newaxis]-mag_2, method=method) comp = comp_1*comp_2 if comp is not None: comp_pdf_val = pdf_val*comp[:,mag_idx] else: comp_pdf_val = pdf_val # Deal with mass pdf values scaled_pdf_val = comp_pdf_val*mass_pdf[mag_idx] # Do the sum without creating the huge sparse array. label_idx = np.arange(nmaglim*nbins**2).reshape(nmaglim,nbins**2) labels = label_idx[:,bin_idx] sum_pdf = ndimage.sum(scaled_pdf_val,labels,label_idx.flat).reshape(nmaglim,nbins**2) # This is the clipping of the pdf at the maglim # Probably want to move this out of this function. final_pdf = sum_pdf.reshape(nmaglim,nbins,nbins) argmax_hi_1 = np.argmax((mask_1[:,np.newaxis] <= edges_mag[1:]),axis=1) argmax_hi_2 = np.argmax((mask_2[:,np.newaxis] <= edges_mag[1:]),axis=1) bin_frac_1 = (mask_1 - edges_mag[argmax_hi_1])/delta_mag bin_frac_2 = (mask_2 - edges_mag[argmax_hi_2])/delta_mag for i,(argmax_1,argmax_2) in enumerate(zip(argmax_hi_1,argmax_hi_2)): final_pdf[i,argmax_1,:] *= bin_frac_1[i] final_pdf[i,:,argmax_2] *= bin_frac_2[i] final_pdf[i,argmax_1+1:,:] = 0 final_pdf[i,:,argmax_2+1:] = 0 ## This is the actual data selection cut... #bins_2,bins_1 = np.meshgrid(edges_mag[:-1],edges_mag[:-1]) #cut = (bins_1 < mask_1[:,np.newaxis,np.newaxis])*(bins_2 < mask_2[:,np.newaxis,np.newaxis]) #final_pdf = sum_pdf.reshape(nmaglim,nbins,nbins)*cut return final_pdf def histogram2d(self,distance_modulus=None,delta_mag=0.03,steps=10000): """ Return a 2D histogram the isochrone in mag-mag space. Parameters: ----------- distance_modulus : distance modulus to calculate histogram at delta_mag : magnitude bin size mass_steps : number of steps to sample isochrone at Returns: -------- bins_mag_1 : bin edges for first magnitude bins_mag_2 : bin edges for second magnitude isochrone_pdf : weighted pdf of isochrone in each bin """ if distance_modulus is not None: self.distance_modulus = distance_modulus # Isochrone will be binned, so might as well sample lots of points mass_init,mass_pdf,mass_act,mag_1,mag_2 = self.sample(mass_steps=steps) #logger.warning("Fudging intrinisic dispersion in isochrone.") #mag_1 += np.random.normal(scale=0.02,size=len(mag_1)) #mag_2 += np.random.normal(scale=0.02,size=len(mag_2)) # We cast to np.float32 to save memory bins_mag_1 = np.arange(self.mod+mag_1.min() - (0.5*delta_mag), self.mod+mag_1.max() + (0.5*delta_mag), delta_mag).astype(np.float32) bins_mag_2 = np.arange(self.mod+mag_2.min() - (0.5*delta_mag), self.mod+mag_2.max() + (0.5*delta_mag), delta_mag).astype(np.float32) # ADW: Completeness needs to go in mass_pdf here... isochrone_pdf = np.histogram2d(self.mod + mag_1, self.mod + mag_2, bins=[bins_mag_1, bins_mag_2], weights=mass_pdf)[0].astype(np.float32) return isochrone_pdf, bins_mag_1, bins_mag_2 def pdf_mmd(self, lon, lat, mag_1, mag_2, distance_modulus, mask, delta_mag=0.03, steps=1000): """ Ok, now here comes the beauty of having the signal MMD. """ logger.info('Running MMD pdf') roi = mask.roi mmd = self.signalMMD(mask,distance_modulus,delta_mag=delta_mag,mass_steps=steps) # This is fragile, store this information somewhere else... nedges = np.rint((roi.bins_mag[-1]-roi.bins_mag[0])/delta_mag)+1 edges_mag,delta_mag = np.linspace(roi.bins_mag[0],roi.bins_mag[-1],nedges,retstep=True) idx_mag_1 = np.searchsorted(edges_mag,mag_1) idx_mag_2 = np.searchsorted(edges_mag,mag_2) if np.any(idx_mag_1 > nedges) or np.any(idx_mag_1 == 0): msg = "Magnitude out of range..." raise Exception(msg) if np.any(idx_mag_2 > nedges) or np.any(idx_mag_2 == 0): msg = "Magnitude out of range..." raise Exception(msg) idx = mask.roi.indexROI(lon,lat) u_color = mmd[(mask.mask_roi_digi[idx],idx_mag_1,idx_mag_2)] # Remove the bin size to convert the pdf to units of mag^-2 u_color /= delta_mag**2 return u_color #import memory_profiler #@memory_profiler.profile def pdf(self, mag_1, mag_2, mag_err_1, mag_err_2, distance_modulus=None, delta_mag=0.03, steps=10000): """ Compute isochrone probability for each catalog object. ADW: This is a memory intensive function, so try as much as possible to keep array types at `float32` or smaller (maybe using add.at would be good?) ADW: Still a little speed to be gained here (broadcasting) ADW: Units? [mag^-2] [per sr?] Parameters: ----------- mag_1 : magnitude of stars (pdf sample points) in first band mag_2 : magnitude of stars (pdf sample points) in second band mag_err_1 : magnitude error of stars (pdf sample points) in first band mag_err_2 : magnitude error of stars (pdf sample points) in second band distance_modulus : distance modulus of isochrone delta_mag : magnitude binning for evaluating the pdf steps : number of isochrone sample points Returns: -------- u_color : probability that the star belongs to the isochrone [mag^-2] """ nsigma = 5.0 #pad = 1. # mag if distance_modulus is None: distance_modulus = self.distance_modulus # ADW: HACK TO ADD SYSTEMATIC UNCERTAINTY (0.010 mag) mag_err_1 = np.sqrt(mag_err_1**2 + 0.01**2) mag_err_2 = np.sqrt(mag_err_2**2 + 0.01**2) # Binned pdf of the isochrone histo_pdf,bins_mag_1,bins_mag_2 = self.histogram2d(distance_modulus,delta_mag,steps) # Keep only isochrone bins that are within the magnitude # space of the sample mag_1_mesh, mag_2_mesh = np.meshgrid(bins_mag_2[1:], bins_mag_1[1:]) # pdf contribution only calculated out to nsigma, # so padding shouldn't be necessary. mag_1_max = np.max(mag_1+nsigma*mag_err_1)# +pad mag_1_min = np.min(mag_1-nsigma*mag_err_1)# -pad mag_2_max = np.max(mag_2+nsigma*mag_err_2)# +pad mag_2_min = np.min(mag_2-nsigma*mag_err_2)# -pad in_mag_space = ((mag_1_mesh>=mag_1_min)&(mag_1_mesh<=mag_1_max)) in_mag_space*= ((mag_2_mesh>=mag_2_min)&(mag_2_mesh<=mag_2_max)) histo_pdf *= in_mag_space idx_mag_1, idx_mag_2 = np.nonzero(histo_pdf) isochrone_pdf = histo_pdf[idx_mag_1, idx_mag_2] n_catalog = len(mag_1) n_isochrone_bins = len(idx_mag_1) mag_1 = mag_1.reshape([n_catalog, 1]) mag_err_1 = mag_err_1.reshape([n_catalog, 1]) mag_2 = mag_2.reshape([n_catalog, 1]) mag_err_2 = mag_err_2.reshape([n_catalog, 1]) # Calculate (normalized) distance between each catalog object # and isochrone bin. Assume normally distributed photometric # uncertainties so that the normalized distance is: # norm_dist = (mag_1 - bins_mag_1)/mag_err_1 # ADW: Creating the dist arrays is memory intensive. # Can we cut it down (maybe with add.at)? dist_mag_1_hi = (mag_1-bins_mag_1[idx_mag_1])/mag_err_1 dist_mag_1_lo = (mag_1-bins_mag_1[idx_mag_1+1])/mag_err_1 dist_mag_2_hi = (mag_2-bins_mag_2[idx_mag_2])/mag_err_2 dist_mag_2_lo = (mag_2-bins_mag_2[idx_mag_2+1])/mag_err_2 # Only calculate the PDF using bins that are < nsigma from the # data point (i.e., where it is ~nonzero). idx_nonzero_0,idx_nonzero_1 = np.nonzero((dist_mag_1_hi > -nsigma) \ *(dist_mag_1_lo < nsigma)\ *(dist_mag_2_hi > -nsigma)\ *(dist_mag_2_lo < nsigma)) # Now calculate the pdf as the delta of the normalized cdf # (more accurate than the point evaluation of the pdf) pdf_mag_1 = np.zeros([n_catalog, n_isochrone_bins],dtype=np.float32) pdf_mag_1[idx_nonzero_0,idx_nonzero_1] = norm_cdf(dist_mag_1_hi[idx_nonzero_0,idx_nonzero_1]) \ - norm_cdf(dist_mag_1_lo[idx_nonzero_0,idx_nonzero_1]) pdf_mag_2 = np.zeros([n_catalog, n_isochrone_bins],dtype=np.float32) pdf_mag_2[idx_nonzero_0,idx_nonzero_1] = norm_cdf(dist_mag_2_hi[idx_nonzero_0,idx_nonzero_1]) \ - norm_cdf(dist_mag_2_lo[idx_nonzero_0,idx_nonzero_1]) # Signal "color probability" (as opposed to "spatial # probability", but more accurately "isochrone probability") # is the product of PDFs for each object-bin pair summed over # isochrone bins #ADW: Here is where add.at would be good... u_color = np.sum(pdf_mag_1 * pdf_mag_2 * isochrone_pdf, axis=1) # Remove the bin size to convert the pdf to units of mag^-2 u_color /= delta_mag**2 return u_color.astype(np.float32) def raw_separation(self,mag_1,mag_2,steps=10000): """ Calculate the separation in magnitude-magnitude space between points and isochrone. Uses a dense sampling of the isochrone and calculates the metric distance from any isochrone sample point. Parameters: ----------- mag_1 : The magnitude of the test points in the first band mag_2 : The magnitude of the test points in the second band steps : Number of steps to sample the isochrone Returns: -------- sep : Minimum separation between test points and isochrone sample """ # http://stackoverflow.com/q/12653120/ mag_1 = np.array(mag_1,copy=False,ndmin=1) mag_2 = np.array(mag_2,copy=False,ndmin=1) init,pdf,act,iso_mag_1,iso_mag_2 = self.sample(mass_steps=steps) iso_mag_1+=self.distance_modulus iso_mag_2+=self.distance_modulus iso_cut = (iso_mag_1<np.max(mag_1))&(iso_mag_1>np.min(mag_1)) | \ (iso_mag_2<np.max(mag_2))&(iso_mag_2>np.min(mag_2)) iso_mag_1 = iso_mag_1[iso_cut] iso_mag_2 = iso_mag_2[iso_cut] dist_mag_1 = mag_1[:,np.newaxis]-iso_mag_1 dist_mag_2 = mag_2[:,np.newaxis]-iso_mag_2 return np.min(np.sqrt(dist_mag_1**2 + dist_mag_2**2),axis=1) def separation(self, mag_1, mag_2): """ Calculate the separation between a specific point and the isochrone in magnitude-magnitude space. Uses an interpolation ADW: Could speed this up... Parameters: ----------- mag_1 : The magnitude of the test points in the first band mag_2 : The magnitude of the test points in the second band Returns: -------- sep : Minimum separation between test points and isochrone interpolation """ iso_mag_1 = self.mag_1 + self.distance_modulus iso_mag_2 = self.mag_2 + self.distance_modulus def interp_iso(iso_mag_1,iso_mag_2,mag_1,mag_2): interp_1 = scipy.interpolate.interp1d(iso_mag_1,iso_mag_2,bounds_error=False) interp_2 = scipy.interpolate.interp1d(iso_mag_2,iso_mag_1,bounds_error=False) dy = interp_1(mag_1) - mag_2 dx = interp_2(mag_2) - mag_1 dmag_1 = np.fabs(dx*dy) / (dx**2 + dy**2) * dy dmag_2 = np.fabs(dx*dy) / (dx**2 + dy**2) * dx return dmag_1, dmag_2 # Separate the various stellar evolution stages if np.issubdtype(self.stage.dtype,np.number): sel = (self.stage < self.hb_stage) else: sel = (self.stage != self.hb_stage) # First do the MS/RGB rgb_mag_1 = iso_mag_1[sel] rgb_mag_2 = iso_mag_2[sel] dmag_1,dmag_2 = interp_iso(rgb_mag_1,rgb_mag_2,mag_1,mag_2) # Then do the HB (if it exists) if not np.all(sel): hb_mag_1 = iso_mag_1[~sel] hb_mag_2 = iso_mag_2[~sel] hb_dmag_1,hb_dmag_2 = interp_iso(hb_mag_1,hb_mag_2,mag_1,mag_2) dmag_1 = np.nanmin([dmag_1,hb_dmag_1],axis=0) dmag_2 = np.nanmin([dmag_2,hb_dmag_2],axis=0) #return dmag_1,dmag_2 return np.sqrt(dmag_1**2 + dmag_2**2)
class CompositeIsochrone(IsochroneModel): _params = odict([ ('distance_modulus', Parameter(15.0, [10.0, 30.0]) ), ]) _mapping = odict([ ('mod','distance_modulus'), ('a','age'), ('z','metallicity'), ]) defaults = (IsochroneModel.defaults) + ( ('type','Bressan2012','Default type of isochrone to create'), ('weights',None,'Relative weights for each isochrone'), ) def __init__(self, isochrones, **kwargs): super(CompositeIsochrone,self).__init__(**kwargs) # Remove composite kwargs so that others can be used as defaults kwargs.pop('type',None) kwargs.pop('weights',None) self.isochrones = [] for i in isochrones: if isinstance(i,Isochrone): iso = i else: # Set the defaults from composite [i.setdefault(*kw) for kw in kwargs.items()] # Default isochrone type (ADW: do we want this?) name = i.pop('name',self.type) #iso = isochroneFactory(name=name,**i) iso = factory(name=name,**i) # Tie the distance modulus iso.params['distance_modulus'] = self.params['distance_modulus'] self.isochrones.append(iso) if self.weights is None: self.weights = np.ones(len(self.isochrones)) self.weights /= np.sum(np.asarray(self.weights)) self.weights = self.weights.tolist() if len(self.isochrones) != len(self.weights): msg = 'Length of isochrone and weight arrays must be equal' raise ValueError(msg) def __getitem__(self, key): return self.isochrones[key] def __str__(self,indent=0): ret = super(CompositeIsochrone,self).__str__(indent) ret += '\n{0:>{2}}{1}'.format('','Isochrones:',indent+2) for i in self: ret += '\n{0}'.format(i.__str__(indent+4)) return ret @property def age(self): return np.array([i.age for i in self]) @property def metallicity(self): return np.array([i.metallicity for i in self]) def composite_decorator(func): """ Decorator for wrapping functions that calculate a weighted sum """ @wraps(func) def wrapper(self, *args, **kwargs): total = [] for weight,iso in zip(self.weights,self.isochrones): subfunc = getattr(iso,func.__name__) total.append(weight*subfunc(*args,**kwargs)) return np.sum(total,axis=0) return wrapper def sample(self, **kwargs): samples = [iso.sample(**kwargs) for iso in self.isochrones] for weight,sample in zip(self.weights,samples): sample[1] *= weight return np.hstack(samples) def separation(self, *args, **kwargs): separations = [iso.separation(*args,**kwargs) for iso in self.isochrones] return np.nanmin(separations,axis=0) def todict(self): ret = super(CompositeIsochrone,self).todict() ret['isochrones'] = [iso.todict() for iso in self.isochrones] return ret @composite_decorator def stellar_mass(self, *args, **kwargs): pass @composite_decorator def stellar_luminosity(self, *args, **kwargs): pass @composite_decorator def observable_fraction(self, *args, **kwargs): pass @composite_decorator def observableFractionX(self, *args, **kwargs): pass @composite_decorator def signalMMD(self, *args, **kwargs): pass # ADW: For temporary backwards compatibility stellarMass = stellar_mass stellarLuminosity = stellar_luminosity observableFraction = observable_fraction def absolute_magnitude(self, richness=1, steps=1e4): raise ValueError("Not implemented for CompositeIsochrone") def absolute_magnitude_martin(self, richness=1, steps=1e4): raise ValueError("Not implemented for CompositeIsochrone")
class Kernel(Model): """ Base class for kernels. """ _params = odict([ ('lon', Parameter(0.0, [0.0, 360.])), ('lat', Parameter(0.0, [-90., 90.])), ]) _mapping = odict([]) _proj = 'ait' def __init__(self, proj='ait', **kwargs): # This __init__ is probably not necessary... self.proj = proj super(Kernel, self).__init__(**kwargs) def __call__(self, lon, lat): """ Return the value of the pdf at a give location. Parameters ---------- lon : longitude (deg) lat : latitude (deg) Returns ------- pdf : normalized, truncated pdf """ return self.pdf(lon, lat) @abstractmethod def _kernel(self, r): """Unnormalized, untruncated kernel""" pass def _pdf(self, radius): """Unnormalized, truncated kernel""" return np.where(radius <= self.edge, self._kernel(radius), 0.) @abstractmethod def pdf(self, lon, lat): """Normalized, truncated pdf""" pass @property def norm(self): """Normalization from the integated pdf""" return 1. / self.integrate() @property def projector(self): """Projector used to transform to local sky coordinates.""" if self.proj is None or self.proj.lower() == 'none': return None else: return Projector(self.lon, self.lat, self.proj) def integrate(self, rmin=0, rmax=np.inf): """ Calculate the 2D integral of the 1D surface brightness profile (i.e, the flux) between rmin and rmax (elliptical radii). Parameters: ----------- rmin : minimum integration radius (deg) rmax : maximum integration radius (deg) Returns: -------- integral : Solid angle integral (deg^2) """ if rmin < 0: raise Exception('rmin must be >= 0') integrand = lambda r: self._pdf(r) * 2 * np.pi * r return scipy.integrate.quad(integrand, rmin, rmax, full_output=True, epsabs=0)[0]