def latlon_to_cartesian(latitude, longitude, stellar_inclination): """ Convert coordinates in latitude/longitude for a star with a given stellar inclination into cartesian coordinates. The X-Y plane is the sky plane: x is aligned with the stellar equator, y is aligned with the stellar rotation axis. Parameters ---------- latitude : float or `~astropy.units.Quantity` Spot latitude. Will assume unit=deg if none is specified. longitude : float or `~astropy.units.Quantity` Spot longitude. Will assume unit=deg if none is specified. stellar_inclination : float Stellar inclination angle, measured away from the line of sight, in [deg]. Returns ------- cartesian : `~astropy.coordinates.CartesianRepresentation` Cartesian representation in the frame described above. """ if not hasattr(longitude, 'unit') and not hasattr(latitude, 'unit'): longitude *= u.deg latitude *= u.deg c = UnitSphericalRepresentation(longitude, latitude) cartesian = c.to_cartesian() rotate_about_z = rotation_matrix(90 * u.deg, axis='z') rotate_is = rotation_matrix(stellar_inclination * u.deg, axis='y') transform_matrix = matrix_product(rotate_about_z, rotate_is) cartesian = cartesian.transform(transform_matrix) return cartesian
class Star: mass = AffectsOmegaCrit('mass', u.kg) radius = AffectsOmegaCrit('radius', u.m) beta = ResetsGrid('beta') distortion = ResetsGrid('distortion') @u.quantity_input(mass=u.kg) @u.quantity_input(radius=u.m) @u.quantity_input(period=u.s) def __init__(self, mass, radius, period, beta=0.08, ulimb=0.9, ntiles=3072, distortion=True): self.distortion = distortion self.mass = mass self.radius = radius # will also set self.omega_crit self.beta = beta self.ulimb = ulimb self.ntiles = ntiles self.period = period self.clear_grid() def clear_grid(self): # now set up tile locations, velocities and directions. # These will be (nlon, nlat) CartesianRepresentations self.tile_locs = None self.tile_dirs = None self.tile_velocities = None # and arrays of tile properties - shape (nlon, nlat) self.tile_areas = None self.tile_fluxes = None # the next array is the main one that gets tweaked self.tile_scales = None """ We define many of the attributes as properties, so we can wipe the grid when they are set, and also check for violation of critical rotation """ @property def omega(self): """ Ratio of angular velocity to critical number. read-only property """ return (self.Omega/self.omega_crit).decompose() @property def period(self): return 2.0*np.pi/self.Omega @period.setter @u.quantity_input(value=u.s) def period(self, value): Omega = 2.0*np.pi/value if (Omega/self.omega_crit).decompose() > 1: raise ValueError('This rotation period exceeds critical value') self.Omega = Omega self.clear_grid() """ ntiles is also a property, since we need to remap to an appropriate value for HEALPIX. """ @property def ntiles(self): """ Number of tiles. Is checked to see if appropriate for HEALPIX algorithm. """ return self._ntiles @ntiles.setter def ntiles(self, value): allowed_values = [48, 192, 768, 3072, 12288, 49152, 196608] if int(value) not in allowed_values: raise ValueError('{} not one of allowed values: {!r}'.format( value, allowed_values )) self._ntiles = int(12*np.floor(np.sqrt(value/12.))**2) self.clear_grid() @u.quantity_input(wavelength=u.nm) def setup_grid(self, wavelength=656*u.nm): # use HEALPIX to get evenly sized tiles NSIDE = hp.npix2nside(self.ntiles) colat, lon = hp.pix2ang(NSIDE, np.arange(0, self.ntiles)) # co-latitude theta_values = u.Quantity(colat, unit=u.rad) # longitude phi_values = u.Quantity(lon, unit=u.rad) # the following formulae use the Roche approximation and assume # solid body rotation # solve for radius of rotating star at these co-latitudes if self.distortion: radii = self.radius*np.array([newton(surface, 1.01, args=(self.omega, x)) for x in theta_values]) else: radii = self.radius*np.ones(self.ntiles) # and effective gravities geff = np.sqrt((-const.G*self.mass/radii**2 + self.Omega**2 * radii * np.sin(theta_values)**2)**2 + self.Omega**4 * radii**2 * np.sin(theta_values)**2 * np.cos(theta_values)**2) # now make a ntiles sized CartesianRepresentation of positions self.tile_locs = SphericalRepresentation(phi_values, 90*u.deg-theta_values, radii).to_cartesian() # normal to tile is the direction of the derivate of the potential # this is the vector form of geff above # the easiest way to express it is that it differs from (r, theta, phi) # by a small amount in the theta direction epsilon x = radii/self.radius a = 1./x**2 - (8./27.)*self.omega**2 * x * np.sin(theta_values)**2 b = np.sqrt( (-1./x**2 + (8./27)*self.omega**2 * x * np.sin(theta_values)**2)**2 + ((8./27)*self.omega**2 * x * np.sin(theta_values) * np.cos(theta_values))**2 ) epsilon = np.arccos(a/b) self.tile_dirs = UnitSphericalRepresentation(phi_values, 90*u.deg - theta_values - epsilon) self.tile_dirs = self.tile_dirs.to_cartesian() # and ntiles sized arrays of tile properties tile_temperatures = 2000.0 * u.K * (geff / geff.max())**self.beta # fluxes, not accounting for limb darkening self.tile_scales = np.ones(self.ntiles) self.tile_fluxes = blackbody_nu(wavelength, tile_temperatures) # tile areas spher = self.tile_locs.represent_as(SphericalRepresentation) self.tile_areas = spher.distance**2 * hp.nside2pixarea(NSIDE) * u.rad * u.rad omega_vec = CartesianRepresentation( u.Quantity([0.0, 0.0, self.Omega.value], unit=self.Omega.unit) ) # get velocities of tiles self.tile_velocities = cross(omega_vec, self.tile_locs) @u.quantity_input(inclination=u.deg) def plot(self, inclination=90*u.deg, phase=0.0, savefig=False, filename='star_surface.png', cmap='magma', what='fluxes', cstride=1, rstride=1, shade=False): ax = plt.axes(projection='3d') ax.view_init(90-inclination.to(u.deg).value, 360*phase) # get map values if what == 'fluxes': vals = self.tile_fluxes * self.tile_scales vals = vals / vals.max() elif what == 'vels': earth = set_earth(inclination.to(u.deg).value, phase) velocities = self.tile_velocities.xyz vals = dot(earth, velocities).to(u.km/u.s) # can't plot negative values, so rescale from 0 - 1 vals = (vals - vals.min())/(vals.max()-vals.min()) elif what == 'areas': vals = self.tile_areas / self.tile_areas.max() colors = getattr(cm, cmap)(vals.value) # project the map to a rectangular matrix nlat = nlon = int(np.floor(np.sqrt(self.ntiles))) theta = np.linspace(np.pi, 0, nlat) phi = np.linspace(-np.pi, np.pi, nlon) PHI, THETA = np.meshgrid(phi, theta) NSIDE = hp.npix2nside(self.ntiles) grid_pix = hp.ang2pix(NSIDE, THETA, PHI) grid_map = colors[grid_pix] # Create a sphere r = 0.3 x = r*np.sin(THETA)*np.cos(PHI) y = r*np.sin(THETA)*np.sin(PHI) z = r*np.cos(THETA) ax.plot_surface(x, y, z, cstride=cstride, rstride=rstride, facecolors=grid_map, shade=shade) if savefig: plt.savefig(filename) else: plt.show() @u.quantity_input(inclination=u.deg) def view(self, inclination=90*u.deg, phase=0.0, what='fluxes', projection='mollweide', cmap='magma', savefig=False, filename='star_surface.png', dlat=30, dlon=30, **kwargs): rot = (360*phase, 90-inclination.to(u.deg).value, 0) if what == 'fluxes': vals = self.tile_fluxes * self.tile_scales vals = vals / vals.max() elif what == 'areas': vals = self.tile_areas / self.tile_areas.max() if 'mollweide'.find(projection) == 0: hp.mollview(vals, rot=rot, cmap=cmap, **kwargs) elif 'cartesian'.find(projection) == 0: hp.cartview(vals, rot=rot, cmap=cmap, **kwargs) elif 'orthographic'.find(projection) == 0: hp.orthview(vals, rot=rot, cmap=cmap, **kwargs) else: raise ValueError('Unrecognised projection') hp.graticule(dlat, dlon) if savefig: plt.savefig(filename) else: plt.show() @u.quantity_input(inclination=u.deg) def _luminosity_array(self, phase, inclination): if self.tile_locs is None: self.setup_grid() # get CartesianRepresentation pointing to earth at these phases earth = set_earth(inclination, phase) mu = dot(earth, self.tile_dirs, normalise=True) # mask of visible elements mask = mu >= 0.0 # broadcast and calculate phase = np.asarray(phase) new_shape = phase.shape + self.tile_fluxes.shape assert(new_shape == mu.shape), "Broadcasting has gone wrong" fluxes = np.tile(self.tile_fluxes, phase.size).reshape(new_shape) scales = np.tile(self.tile_scales, phase.size).reshape(new_shape) areas = np.tile(self.tile_areas, phase.size).reshape(new_shape) # limb darkened sum of all tile fluxes lum = (fluxes * scales * (1.0 - self.ulimb + np.fabs(mu)*self.ulimb) * areas * mu) # no contribution from invisible tiles lum[mask] = 0.0 return lum @u.quantity_input(inclination=u.deg) def calc_luminosity(self, phase, inclination): lum = self._luminosity_array(phase, inclination) return np.sum(lum, axis=1) @u.quantity_input(inclination=u.deg) @u.quantity_input(v_macro=u.km/u.s) @u.quantity_input(v_inst=u.km/u.s) @u.quantity_input(v_min=u.km/u.s) @u.quantity_input(v_max=u.km/u.s) def calc_line_profile(self, phase, inclination, nbins=100, v_macro=2*u.km/u.s, v_inst=4*u.km/u.s, v_min=-40*u.km/u.s, v_max=40*u.km/u.s): # get CartesianRepresentation pointing to earth at these phases earth = set_earth(inclination, phase) # get CartesianRepresentation of projected velocities vproj = dot(earth, self.tile_velocities).to(u.km/u.s) # which tiles fall in which bin? bins = np.linspace(v_min, v_max, nbins) indices = np.digitize(vproj, bins) lum = self._luminosity_array(phase, inclination) phase = np.asarray(phase) trailed_spectrum = np.zeros((phase.size, nbins)) for i in range(nbins): mask = (indices == i) trailed_spectrum[:, i] = np.sum(lum*mask, axis=1) # convolve with instrumental and local line profiles # TODO: realistic Line Profile Treatment # For now we assume every element has same intrinsic # line profile bin_width = (v_max-v_min)/(nbins-1) profile_width_in_bins = np.sqrt(v_macro**2 + v_inst**2) / bin_width gauss_kernel = Gaussian1DKernel(stddev=profile_width_in_bins, mode='linear_interp') for i in range(phase.size): trailed_spectrum[i, :] = convolve(trailed_spectrum[i, :], gauss_kernel, boundary='extend') return bins, trailed_spectrum
def surface_normal(self, lat, lon): usr = UnitSphericalRepresentation(lon=lon+self.rotation_angle, lat=lat) rotm = matrix_utilities.rotation_matrix(self.obliquity, axis='x') return usr.to_cartesian().transform(rotm)