def test_constant_as_ell_0_mode(special_angles): indices = np.array([[0, 0]]) np.random.seed(123) for imaginary_part in [0.0, 1.0j]: # Test both real and imaginary constants for rep in range(1000): constant = np.random.uniform(-1, 1) + imaginary_part * np.random.uniform(-1, 1) const_ell_m = sf.constant_as_ell_0_mode(constant) assert abs(constant - sf.constant_from_ell_0_mode(const_ell_m)) < 1e-15 for theta in special_angles: for phi in special_angles: dot = np.dot(const_ell_m, sf.SWSH(quaternion.from_spherical_coords(theta, phi), 0, indices)) assert abs(constant - dot) < 1e-15
def test_constant_as_ell_0_mode(special_angles): indices = np.array([[0, 0]]) np.random.seed(123) for imaginary_part in [0.0, 1.0j]: # Test both real and imaginary constants for rep in range(1000): constant = np.random.uniform( -1, 1) + imaginary_part * np.random.uniform(-1, 1) const_ell_m = sf.constant_as_ell_0_mode(constant) assert abs(constant - sf.constant_from_ell_0_mode(const_ell_m)) < 1e-15 for theta in special_angles: for phi in special_angles: dot = np.dot( const_ell_m, sf.SWSH(quaternion.from_spherical_coords(theta, phi), 0, indices)) assert abs(constant - dot) < 1e-15
def _process_transformation_kwargs(input_ell_max, **kwargs): original_kwargs = kwargs.copy() # Build the supertranslation and spacetime_translation arrays supertranslation = np.zeros((4, ), dtype=complex) # For now; may be resized below ell_max_supertranslation = 1 # For now; may be increased below if "supertranslation" in kwargs: supertranslation = np.array(kwargs.pop("supertranslation"), dtype=complex) if supertranslation.dtype != "complex" and supertranslation.size > 0: # I don't actually think this can ever happen... raise TypeError( "Input argument `supertranslation` should be a complex array with size>0. " f"Got a {supertranslation.dtype} array of shape {supertranslation.shape}" ) # Make sure the array has size at least 4, by padding with zeros if supertranslation.size <= 4: supertranslation = np.lib.pad(supertranslation, (0, 4 - supertranslation.size), "constant", constant_values=(0.0, )) # Check that the shape is a possible array of scalar modes with complete (ell,m) data ell_max_supertranslation = int(np.sqrt(len(supertranslation))) - 1 if (ell_max_supertranslation + 1)**2 != len(supertranslation): raise ValueError( "Input supertranslation parameter must contain modes from ell=0 up to some ell_max, " "including\n all relevant m modes in standard order (see `spherical_functions` " "documentation for details).\n Thus, it must be an array with length given by a " "perfect square; its length is {len(supertranslation)}") # Check that the resulting supertranslation will be real for ell in range(ell_max_supertranslation + 1): for m in range(ell + 1): i_pos = sf.LM_index(ell, m, 0) i_neg = sf.LM_index(ell, -m, 0) a = supertranslation[i_pos] b = supertranslation[i_neg] if abs(a - (-1.0)**m * b.conjugate()) > 3e-16 + 1e-15 * abs(b): raise ValueError( f"\nsupertranslation[{i_pos}]={a} # (ell,m)=({ell},{m})\n" + "supertranslation[{}]={} # (ell,m)=({},{})\n".format( i_neg, b, ell, -m) + "Will result in an imaginary supertranslation.") spacetime_translation = np.zeros((4, ), dtype=float) spacetime_translation[0] = sf.constant_from_ell_0_mode( supertranslation[0]).real spacetime_translation[1:4] = -sf.vector_from_ell_1_modes( supertranslation[1:4]).real if "spacetime_translation" in kwargs: st_trans = np.array(kwargs.pop("spacetime_translation"), dtype=float) if st_trans.shape != (4, ) or st_trans.dtype != "float": raise TypeError( "\nInput argument `spacetime_translation` should be a float array of shape (4,).\n" "Got a {} array of shape {}.".format(st_trans.dtype, st_trans.shape)) spacetime_translation = st_trans[:] supertranslation[0] = sf.constant_as_ell_0_mode( spacetime_translation[0]) supertranslation[1:4] = sf.vector_as_ell_1_modes( -spacetime_translation[1:4]) if "space_translation" in kwargs: s_trans = np.array(kwargs.pop("space_translation"), dtype=float) if s_trans.shape != (3, ) or s_trans.dtype != "float": raise TypeError( "\nInput argument `space_translation` should be an array of floats of shape (3,).\n" "Got a {} array of shape {}.".format(s_trans.dtype, s_trans.shape)) spacetime_translation[1:4] = s_trans[:] supertranslation[1:4] = sf.vector_as_ell_1_modes( -spacetime_translation[1:4]) if "time_translation" in kwargs: t_trans = kwargs.pop("time_translation") if not isinstance(t_trans, float): raise TypeError( "Input argument `time_translation` should be a single float. " f"Got {t_trans}") spacetime_translation[0] = t_trans supertranslation[0] = sf.constant_as_ell_0_mode( spacetime_translation[0]) # Decide on the number of points to use in each direction. A nontrivial supertranslation will # introduce power in higher modes, so for best accuracy, we need to account for that. But we'll # make it a firm requirement to have enough points to capture the original waveform, at least output_ell_max = kwargs.pop("output_ell_max", input_ell_max) working_ell_max = kwargs.pop("working_ell_max", 2 * input_ell_max + ell_max_supertranslation) if working_ell_max < input_ell_max: raise ValueError( f"working_ell_max={working_ell_max} is too small; it must be at least ell_max={input_ell_max}" ) # Get the rotor for the frame rotation frame_rotation = np.quaternion( *np.array(kwargs.pop("frame_rotation", [1, 0, 0, 0]), dtype=float)) if frame_rotation.abs() < 3e-16: raise ValueError( f"frame_rotation={frame_rotation} should be a single unit quaternion" ) frame_rotation = frame_rotation.normalized() # Get the boost velocity vector boost_velocity = np.array(kwargs.pop("boost_velocity", [0.0] * 3), dtype=float) beta = np.linalg.norm(boost_velocity) if boost_velocity.dtype != float or boost_velocity.shape != ( 3, ) or beta >= 1.0: raise ValueError( f"Input boost_velocity=`{boost_velocity}` should be a 3-vector with " "magnitude strictly less than 1.0") return frame_rotation, boost_velocity, supertranslation, working_ell_max, output_ell_max
def transform(self, **kwargs): """Apply BMS transformation to AsymptoticBondiData object It is important to note that the input transformation parameters are applied in this order: 1. (Super)Translations 2. Rotation (about the origin) 3. Boost (about the origin) All input parameters refer to the transformation required to take the input data's inertial frame onto the inertial frame of the output data's inertial observers. In what follows, the coordinates of and functions in the input inertial frame will be unprimed, while corresponding values of the output inertial frame will be primed. The translations (space, time, spacetime, or super) can be given in various ways, which may override each other. Ultimately, however, they are essentially combined into a single function `α`, representing the supertranslation, which transforms the asymptotic time variable `u` as u'(u, θ, ϕ) = u(u, θ, ϕ) - α(θ, ϕ) A simple time translation by δt would correspond to α(θ, ϕ) = δt # Independent of (θ, ϕ) A pure spatial translation δx would correspond to α(θ, ϕ) = -δx · n̂(θ, ϕ) where `·` is the usual dot product, and `n̂` is the unit vector in the given direction. Parameters ========== abd: AsymptoticBondiData The object storing the modes of the original data, which will be transformed in this function. This is the only required argument to this function. time_translation: float, optional Defaults to zero. Nonzero overrides corresponding components of `spacetime_translation` and `supertranslation` parameters. Note that this is the actual change in the coordinate value, rather than the corresponding mode weight (which is what `supertranslation` represents). space_translation : float array of length 3, optional Defaults to empty (no translation). Non-empty overrides corresponding components of `spacetime_translation` and `supertranslation` parameters. Note that this is the actual change in the coordinate value, rather than the corresponding mode weight (which is what `supertranslation` represents). spacetime_translation : float array of length 4, optional Defaults to empty (no translation). Non-empty overrides corresponding components of `supertranslation`. Note that this is the actual change in the coordinate value, rather than the corresponding mode weight (which is what `supertranslation` represents). supertranslation : complex array [defaults to 0] This gives the complex components of the spherical-harmonic expansion of the supertranslation in standard form, starting from ell=0 up to some ell_max, which may be different from the ell_max of the input `abd` object. Supertranslations must be real, so these values should obey the condition α^{ℓ,m} = (-1)^m ᾱ^{ℓ,-m} This condition is actually imposed on the input data, so imaginary parts of α(θ, ϕ) will essentially be discarded. Defaults to empty, which causes no supertranslation. Note that some components may be overridden by the parameters above. frame_rotation : quaternion [defaults to 1] Transformation applied to (x,y,z) basis of the input mode's inertial frame. For example, the basis z vector of the new frame may be written as z' = frame_rotation * z * frame_rotation.inverse() Defaults to 1, corresponding to the identity transformation (no rotation). boost_velocity : float array of length 3 [defaults to (0, 0, 0)] This is the three-velocity vector of the new frame relative to the input frame. The norm of this vector is required to be smaller than 1. output_ell_max: int [defaults to abd.ell_max] Maximum ell value in the output data. working_ell_max: int [defaults to 2 * abd.ell_max] Maximum ell value to use during the intermediate calculations. Rotations and time translations do not require this to be any larger than abd.ell_max, but other transformations will require more values of ell for accurate results. In particular, boosts are multiplied by time, meaning that a large boost of data with large values of time will lead to very large power in higher modes. Similarly, large (super)translations will couple power through a lot of modes. To avoid aliasing, this value should be large, to accomodate power in higher modes. Returns ------- abdprime: AsymptoticBondiData Object representing the transformed data. """ from quaternion import rotate_vectors from scipy.interpolate import CubicSpline # Parse the input arguments, and define the basic parameters for this function ( frame_rotation, boost_velocity, supertranslation, working_ell_max, output_ell_max, ) = _process_transformation_kwargs(self.ell_max, **kwargs) n_theta = 2 * working_ell_max + 1 n_phi = n_theta β = np.linalg.norm(boost_velocity) γ = 1 / math.sqrt(1 - β**2) # Make this into a Modes object, so it can keep track of its spin weight, etc., through the # various operations needed below. supertranslation = sf.Modes(supertranslation, spin_weight=0).real # This is a 2-d array of unit quaternions, which are what the spin-weighted functions should be # evaluated on (even for spin 0 functions, for simplicity). That will be equivalent to # evaluating the spin-weighted functions with respect to the transformed grid -- although on the # original time slices. distorted_grid_rotors = boosted_grid(frame_rotation, boost_velocity, n_theta, n_phi) # Compute u, α, ðα, ððα, k, ðk/k, 1/k, and 1/k³ on the distorted grid, including new axes to # enable broadcasting with time-dependent functions. Note that the first axis should represent # variation in u, the second axis variation in θ', and the third axis variation in ϕ'. u = self.u α = sf.Grid(supertranslation.evaluate(distorted_grid_rotors), spin_weight=0).real[np.newaxis, :, :] # The factors of 1/sqrt(2) and 1/2 come from using the GHP eth instead of the NP eth. ðα = sf.Grid(supertranslation.eth.evaluate(distorted_grid_rotors) / np.sqrt(2), spin_weight=α.s + 1)[np.newaxis, :, :] ððα = sf.Grid(0.5 * supertranslation.eth.eth.evaluate(distorted_grid_rotors), spin_weight=α.s + 2)[np.newaxis, :, :] k, ðk_over_k, one_over_k, one_over_k_cubed = conformal_factors( boost_velocity, distorted_grid_rotors) # ðu'(u, θ', ϕ') exp(iλ) / k(θ', ϕ') ðuprime_over_k = ðk_over_k * (u - α) - ðα # ψ0(u, θ', ϕ') exp(2iλ) ψ0 = sf.Grid(self.psi0.evaluate(distorted_grid_rotors), spin_weight=2) # ψ1(u, θ', ϕ') exp(iλ) ψ1 = sf.Grid(self.psi1.evaluate(distorted_grid_rotors), spin_weight=1) # ψ2(u, θ', ϕ') ψ2 = sf.Grid(self.psi2.evaluate(distorted_grid_rotors), spin_weight=0) # ψ3(u, θ', ϕ') exp(-1iλ) ψ3 = sf.Grid(self.psi3.evaluate(distorted_grid_rotors), spin_weight=-1) # ψ4(u, θ', ϕ') exp(-2iλ) ψ4 = sf.Grid(self.psi4.evaluate(distorted_grid_rotors), spin_weight=-2) # σ(u, θ', ϕ') exp(2iλ) σ = sf.Grid(self.sigma.evaluate(distorted_grid_rotors), spin_weight=2) ### The following calculations are done using in-place Horner form. I suspect this will be the ### most efficient form of this calculation, within reason. Note that the factors of exp(isλ) ### were computed automatically by evaluating in terms of quaternions. # fprime_of_timenaught_directionprime = np.empty( (6, self.n_times, n_theta, n_phi), dtype=complex) # ψ0'(u, θ', ϕ') fprime_temp = ψ4.copy() fprime_temp *= ðuprime_over_k fprime_temp += -4 * ψ3 fprime_temp *= ðuprime_over_k fprime_temp += 6 * ψ2 fprime_temp *= ðuprime_over_k fprime_temp += -4 * ψ1 fprime_temp *= ðuprime_over_k fprime_temp += ψ0 fprime_temp *= one_over_k_cubed fprime_of_timenaught_directionprime[0] = fprime_temp # ψ1'(u, θ', ϕ') fprime_temp = -ψ4 fprime_temp *= ðuprime_over_k fprime_temp += 3 * ψ3 fprime_temp *= ðuprime_over_k fprime_temp += -3 * ψ2 fprime_temp *= ðuprime_over_k fprime_temp += ψ1 fprime_temp *= one_over_k_cubed fprime_of_timenaught_directionprime[1] = fprime_temp # ψ2'(u, θ', ϕ') fprime_temp = ψ4.copy() fprime_temp *= ðuprime_over_k fprime_temp += -2 * ψ3 fprime_temp *= ðuprime_over_k fprime_temp += ψ2 fprime_temp *= one_over_k_cubed fprime_of_timenaught_directionprime[2] = fprime_temp # ψ3'(u, θ', ϕ') fprime_temp = -ψ4 fprime_temp *= ðuprime_over_k fprime_temp += ψ3 fprime_temp *= one_over_k_cubed fprime_of_timenaught_directionprime[3] = fprime_temp # ψ4'(u, θ', ϕ') fprime_temp = ψ4.copy() fprime_temp *= one_over_k_cubed fprime_of_timenaught_directionprime[4] = fprime_temp # σ'(u, θ', ϕ') fprime_temp = σ.copy() fprime_temp -= ððα fprime_temp *= one_over_k fprime_of_timenaught_directionprime[5] = fprime_temp # Determine the new time slices. The set timeprime is chosen so that on each slice of constant # u'_i, the average value of u=(u'/k)+α is precisely <u>=u'γ+<α>=u_i. But then, we have to # narrow that set down, so that every grid point on all the u'_i' slices correspond to data in # the range of input data. timeprime = (u - sf.constant_from_ell_0_mode(supertranslation[0]).real) / γ timeprime_of_initialtime_directionprime = k * (u[0] - α) timeprime_of_finaltime_directionprime = k * (u[-1] - α) earliest_complete_timeprime = np.max( timeprime_of_initialtime_directionprime.view(np.ndarray)) latest_complete_timeprime = np.min( timeprime_of_finaltime_directionprime.view(np.ndarray)) timeprime = timeprime[(timeprime >= earliest_complete_timeprime) & (timeprime <= latest_complete_timeprime)] # This will store the values of f'(u', θ', ϕ') for the various functions `f` fprime_of_timeprime_directionprime = np.zeros( (6, timeprime.size, n_theta, n_phi), dtype=complex) # Interpolate the various transformed function values on the transformed grid from the original # time coordinate to the new set of time coordinates, independently for each direction. for i in range(n_theta): for j in range(n_phi): k_i_j = k[0, i, j] α_i_j = α[0, i, j] # u'(u, θ', ϕ') timeprime_of_timenaught_directionprime_i_j = k_i_j * (u - α_i_j) # f'(u', θ', ϕ') fprime_of_timeprime_directionprime[:, :, i, j] = CubicSpline( timeprime_of_timenaught_directionprime_i_j, fprime_of_timenaught_directionprime[:, :, i, j], axis=1)(timeprime) # Finally, transform back from the distorted grid to the SWSH mode weights as measured in that # grid. I'll abuse notation slightly here by indicating those "distorted" mode weights with # primes, so that f'(u')_{ℓ', m'} = ∫ f'(u', θ', ϕ') sȲ_{ℓ', m'}(θ', ϕ') sin(θ') dθ' dϕ' abdprime = type(self)(timeprime, output_ell_max) # ψ0'(u')_{ℓ', m'} abdprime.psi0 = spinsfast.map2salm(fprime_of_timeprime_directionprime[0], 2, output_ell_max) # ψ1'(u')_{ℓ', m'} abdprime.psi1 = spinsfast.map2salm(fprime_of_timeprime_directionprime[1], 1, output_ell_max) # ψ2'(u')_{ℓ', m'} abdprime.psi2 = spinsfast.map2salm(fprime_of_timeprime_directionprime[2], 0, output_ell_max) # ψ3'(u')_{ℓ', m'} abdprime.psi3 = spinsfast.map2salm(fprime_of_timeprime_directionprime[3], -1, output_ell_max) # ψ4'(u')_{ℓ', m'} abdprime.psi4 = spinsfast.map2salm(fprime_of_timeprime_directionprime[4], -2, output_ell_max) # σ'(u')_{ℓ', m'} abdprime.sigma = spinsfast.map2salm(fprime_of_timeprime_directionprime[5], 2, output_ell_max) return abdprime
def transform_moreschi_supermomentum(supermomentum, **kwargs): """Apply a BMS transformation to the Moreschi supermomentum using the Moreschi formula, Eq. (9) of arXiv:gr-qc/0203075. NOTE: This transformation only holds for the Moreschi supermomentum! It is important to note that the input transformation parameters are applied in this order: 1. (Super)Translations 2. Rotation (about the origin) 3. Boost (about the origin) All input parameters refer to the transformation required to take the input data's inertial frame onto the inertial frame of the output data's inertial observers. In what follows, the coordinates of and functions in the input inertial frame will be unprimed, while corresponding values of the output inertial frame will be primed. The translations (space, time, spacetime, or super) can be given in various ways, which may override each other. Ultimately, however, they are essentially combined into a single function `α`, representing the supertranslation, which transforms the asymptotic time variable `u` as u'(u, θ, ϕ) = u(u, θ, ϕ) - α(θ, ϕ) A simple time translation by δt would correspond to α(θ, ϕ) = δt # Independent of (θ, ϕ) A pure spatial translation δx would correspond to α(θ, ϕ) = -δx · n̂(θ, ϕ) where `·` is the usual dot product, and `n̂` is the unit vector in the given direction. Parameters ---------- supermomentum: ModesTimeSeries The object storing the modes of the original data, which will be transformed in this function. This is the only required argument to this function. time_translation: float, optional Defaults to zero. Nonzero overrides corresponding components of `spacetime_translation` and `supertranslation` parameters. Note that this is the actual change in the coordinate value, rather than the corresponding mode weight (which is what `supertranslation` represents). space_translation : float array of length 3, optional Defaults to empty (no translation). Non-empty overrides corresponding components of `spacetime_translation` and `supertranslation` parameters. Note that this is the actual change in the coordinate value, rather than the corresponding mode weight (which is what `supertranslation` represents). spacetime_translation : float array of length 4, optional Defaults to empty (no translation). Non-empty overrides corresponding components of `supertranslation`. Note that this is the actual change in the coordinate value, rather than the corresponding mode weight (which is what `supertranslation` represents). supertranslation : complex array [defaults to 0] This gives the complex components of the spherical-harmonic expansion of the supertranslation in standard form, starting from ell=0 up to some ell_max, which may be different from the ell_max of the input `supermomentum`. Supertranslations must be real, so these values should obey the condition α^{ℓ,m} = (-1)^m ᾱ^{ℓ,-m} This condition is actually imposed on the input data, so imaginary parts of α(θ, ϕ) will essentially be discarded. Defaults to empty, which causes no supertranslation. Note that some components may be overridden by the parameters above. frame_rotation : quaternion [defaults to 1] Transformation applied to (x,y,z) basis of the input mode's inertial frame. For example, the basis z vector of the new frame may be written as z' = frame_rotation * z * frame_rotation.inverse() Defaults to 1, corresponding to the identity transformation (no rotation). boost_velocity : float array of length 3 [defaults to (0, 0, 0)] This is the three-velocity vector of the new frame relative to the input frame. The norm of this vector is required to be smaller than 1. output_ell_max: int [defaults to supermomentum.ell_max] Maximum ell value in the output data. working_ell_max: int [defaults to 2 * supermomentum.ell_max] Maximum ell value to use during the intermediate calculations. Rotations and time translations do not require this to be any larger than supermomentum.ell_max, but other transformations will require more values of ell for accurate results. In particular, boosts are multiplied by time, meaning that a large boost of data with large values of time will lead to very large power in higher modes. Similarly, large (super)translations will couple power through a lot of modes. To avoid aliasing, this value should be large, to accomodate power in higher modes. Returns ------- ModesTimeSeries """ from quaternion import rotate_vectors from scipy.interpolate import CubicSpline import spherical_functions as sf import spinsfast import math from .transformations import _process_transformation_kwargs, boosted_grid, conformal_factors from ..modes_time_series import ModesTimeSeries # Parse the input arguments, and define the basic parameters for this function frame_rotation, boost_velocity, supertranslation, working_ell_max, output_ell_max, = _process_transformation_kwargs( supermomentum.ell_max, **kwargs ) n_theta = 2 * working_ell_max + 1 n_phi = n_theta β = np.linalg.norm(boost_velocity) γ = 1 / math.sqrt(1 - β ** 2) # Make this into a Modes object, so it can keep track of its spin weight, etc., through the # various operations needed below. supertranslation = sf.Modes(supertranslation, spin_weight=0).real # This is a 2-d array of unit quaternions, which are what the spin-weighted functions should be # evaluated on (even for spin 0 functions, for simplicity). That will be equivalent to # evaluating the spin-weighted functions with respect to the transformed grid -- although on the # original time slices. distorted_grid_rotors = boosted_grid(frame_rotation, boost_velocity, n_theta, n_phi) # Compute u, α, Δα, k, ðk/k, 1/k, and 1/k³ on the distorted grid, including new axes to # enable broadcasting with time-dependent functions. Note that the first axis should represent # variation in u, the second axis variation in θ', and the third axis variation in ϕ'. u = supermomentum.u α = sf.Grid(supertranslation.evaluate(distorted_grid_rotors), spin_weight=0).real[np.newaxis, :, :] # The factor of 0.25 comes from using the GHP eth instead of the NP eth. Δα = sf.Grid(0.25 * supertranslation.ethbar.ethbar.eth.eth.evaluate(distorted_grid_rotors), spin_weight=α.s)[ np.newaxis, :, : ] k, ðk_over_k, one_over_k, one_over_k_cubed = conformal_factors(boost_velocity, distorted_grid_rotors) # Ψ(u, θ', ϕ') exp(2iλ) Ψ = sf.Grid(supermomentum.evaluate(distorted_grid_rotors), spin_weight=0) ### The following calculations are done using in-place Horner form. I suspect this will be the ### most efficient form of this calculation, within reason. Note that the factors of exp(isλ) ### were computed automatically by evaluating in terms of quaternions. # # Ψ'(u, θ', ϕ') = k⁻³ (Ψ - ð²ðbar²α) Ψprime_of_timenaught_directionprime = Ψ.copy() - Δα Ψprime_of_timenaught_directionprime *= one_over_k_cubed # Determine the new time slices. The set timeprime is chosen so that on each slice of constant # u'_i, the average value of u=(u'/k)+α is precisely <u>=u'γ+<α>=u_i. But then, we have to # narrow that set down, so that every grid point on all the u'_i' slices correspond to data in # the range of input data. timeprime = (u - sf.constant_from_ell_0_mode(supertranslation[0]).real) / γ timeprime_of_initialtime_directionprime = k * (u[0] - α) timeprime_of_finaltime_directionprime = k * (u[-1] - α) earliest_complete_timeprime = np.max(timeprime_of_initialtime_directionprime.view(np.ndarray)) latest_complete_timeprime = np.min(timeprime_of_finaltime_directionprime.view(np.ndarray)) timeprime = timeprime[(timeprime >= earliest_complete_timeprime) & (timeprime <= latest_complete_timeprime)] # This will store the values of Ψ'(u', θ', ϕ') Ψprime_of_timeprime_directionprime = np.zeros((timeprime.size, n_theta, n_phi), dtype=complex) # Interpolate the various transformed function values on the transformed grid from the original # time coordinate to the new set of time coordinates, independently for each direction. for i in range(n_theta): for j in range(n_phi): k_i_j = k[0, i, j] α_i_j = α[0, i, j] # u'(u, θ', ϕ') timeprime_of_timenaught_directionprime_i_j = k_i_j * (u - α_i_j) # Ψ'(u', θ', ϕ') Ψprime_of_timeprime_directionprime[:, i, j] = CubicSpline( timeprime_of_timenaught_directionprime_i_j, Ψprime_of_timenaught_directionprime[:, i, j], axis=0 )(timeprime) # Finally, transform back from the distorted grid to the SWSH mode weights as measured in that # grid. I'll abuse notation slightly here by indicating those "distorted" mode weights with # primes, so that Ψ'(u')_{ℓ', m'} = ∫ Ψ'(u', θ', ϕ') sȲ_{ℓ', m'}(θ', ϕ') sin(θ') dθ' dϕ' supermomentum_prime = spinsfast.map2salm(Ψprime_of_timeprime_directionprime, 0, output_ell_max) supermomentum_prime = ModesTimeSeries( sf.SWSH_modes.Modes( supermomentum_prime, spin_weight=0, ell_min=0, ell_max=output_ell_max, multiplication_truncator=max ), time=timeprime, ) return supermomentum_prime
def from_modes(cls, w_modes, **kwargs): """Construct grid object from modes, with optional BMS transformation This "constructor" is designed with the goal of transforming the frame in which the modes are measured. If this is not desired, it can be called without those parameters. It is important to note that the input transformation parameters are applied in the order listed in the parameter list below: 1. (Super)Translations 2. Rotation (about the origin of the translated system) 3. Boost All input parameters refer to the transformation required to take the mode's inertial frame onto the inertial frame of the grid's inertial observers. In what follows, the inertial frame of the modes will be unprimed, while the inertial frame of the grid will be primed. NOTE: These are passive transformations, e.g. supplying the option space_translation=[0, 0, 5] to a Schwarzschild spacetime will move the coordinates to z'=z+5 and so the center of mass will be shifted in the negative z direction by 5 in the new coordinates. The translations (space, time, spacetime, or super) can be given in various ways, which may override each other. Ultimately, however, they are essentially combined into a single function `alpha`, representing the supertranslation, which transforms the asymptotic time variable `u` as u'(theta, phi) = u - alpha(theta, phi) A simple time translation would correspond to alpha(theta, phi) = time_translation A pure spatial translation would correspond to alpha(theta, phi) = np.dot(space_translation, -nhat(theta, phi)) where `np.dot` is the usual dot product, and `nhat` is the unit vector in the given direction. Parameters ---------- w_modes : WaveformModes The object storing the modes of the original waveform, which will be converted to values on a grid in this function. This is the only required argument to this function. n_theta : int, optional n_phi : int, optional Number of points in the equi-angular grid in the colatitude (theta) and azimuth (phi) directions. Each defaults to 2*ell_max+1, which is optimal for accuracy and speed. However, this ell_max will typically be greater than the input waveform's ell_max by at least one, or the ell_max of the input supertranslation (whichever is greater). This is to minimally account for the power at higher orders that such a supertranslation introduces. You may wish to increase this further if the spatial size of your supertranslation is large compared to the smallest wavelength you wish to capture in your data [e.g., ell_max*Omega_orbital_max/speed_of_light], or if your boost speed is close to the speed of light. time_translation : float, optional Defaults to zero. Nonzero overrides spacetime_translation and supertranslation. space_translation : float array of length 3, optional Defaults to empty (no translation). Non-empty overrides spacetime_translation and supertranslation. spacetime_translation : float array of length 4, optional Defaults to empty (no translation). Non-empty overrides supertranslation. supertranslation : complex array, optional This gives the complex components of the spherical-harmonic expansion of the supertranslation in standard form, starting from ell=0 up to some ell_max, which may be different from the ell_max of the input WaveformModes object. Supertranslations must be real, so these values must obey the condition alpha^{ell,m} = (-1)^m \bar{alpha}^{ell,-m} Defaults to empty, which causes no supertranslation. frame_rotation : quaternion, optional Transformation applied to (x,y,z) basis of the mode's inertial frame. For example, the basis z vector of the new grid frame may be written as z' = frame_rotation * z * frame_rotation.inverse() Defaults to 1, corresponding to the identity transformation (no frame_rotation). boost_velocity : float array of length 3, optional This is the three-velocity vector of the grid frame relative to the mode frame. The norm of this vector is checked to ensure that it is smaller than 1. Defaults to [], corresponding to no boost. psi4_modes : WaveformModes, required only if w_modes is type psi3, psi2, psi1, or psi0 psi3_modes : WaveformModes, required only if w_modes is type psi2, psi1, or psi0 psi2_modes : WaveformModes, required only if w_modes is type psi1 or psi0 psi1_modes : WaveformModes, required only if w_modes is type psi0 The objects storing the modes of the original waveforms of the same spacetime that w_modes belongs to. A BMS transformation of an asymptotic Weyl scalar requires mode information from all higher index Weyl scalars. E.g. if w_modes is of type scri.psi2, then psi4_modes and psi3_modes will be required. Note: the WaveformModes objects supplied to these arguments will themselves NOT be transformed. Please use the AsymptoticBondiData class to efficiently transform all asymptotic data at once. Returns ------- WaveformGrid """ # Check input object type and frame type # # The data in `w_modes` is given in the original frame. We need to get the value of the field on a grid of # points corresponding to the points in the new grid frame. But we must also remember that this is a # spin-weighted and boost-weighted field, which means that we need to account for the frame_rotation due to # `frame_rotation` and `boost_velocity`. The way to do this is to use rotors to transform the points as needed, # and evaluate the SWSHs. But for now, let's just reject any waveforms in a non-inertial frame if not isinstance(w_modes, WaveformModes): raise TypeError( "\nInput waveform object must be an instance of `WaveformModes`; " "this is of type `{}`".format(type(w_modes).__name__)) if w_modes.frameType != Inertial: raise ValueError( "\nInput waveform object must be in an inertial frame; " "this is in a frame of type `{}`".format( w_modes.frame_type_string)) # The first task is to establish a set of constant u' slices on which the new grid should be evaluated. This # is done simply by translating the original set of slices by the time translation (the lowest moment of the # supertranslation). But some of these slices (at the beginning and end) will not have complete data, # because of the direction-dependence of the rest of the supertranslation. That is, in some directions, # the data for the complete slice (data in all directions on the sphere) of constant u' will actually refer to # spacetime events that were not in the original set of time slices; we would have to extrapolate the original # data. So, for nontrivial supertranslations, the output data will actually represent a proper subset of the # input data. # # We can invert the equation for u' to obtain u as a function of angle assuming constant u' # u'(theta, phi) = u + alpha(theta, phi) + u * np.dot(boost_velocity, nhat(theta, phi)) # u(theta, phi) = (u' - alpha(theta, phi)) / (1 + np.dot(boost_velocity, nhat(theta, phi))) # But really, we want u'(theta', phi') for given values # # Note that `space_translation` (and the spatial part of `spacetime_translation`) get reversed signs when # transformed into supertranslation modes, because these pieces enter the time transformation with opposite # sign compared to the time translation, as can be seen by looking at the retarded time: `t-r`. original_kwargs = kwargs.copy() ( supertranslation, ell_max_supertranslation, ell_max, n_theta, n_phi, boost_velocity, beta, gamma, varphi, R_j_k, Bprm_j_k, thetaprm_j_phiprm_k, kwargs, ) = process_transformation_kwargs(w_modes.ell_max, **kwargs) # TODO: Incorporate the w_modes.frame information into rotors, which will require time dependence throughout # It would be best to leave the waveform in its frame. But we'll have to apply the frame_rotation to the BMS # elements, which will be a little tricky. Still probably not as tricky as applying to the waveform... # We need values of (1) waveform, (2) conformal factor, and (3) supertranslation, at each point of the # transformed grid, at each instant of time. SWSH_j_k = sf.SWSH_grid(R_j_k, w_modes.spin_weight, ell_max) SH_j_k = sf.SWSH_grid(R_j_k, 0, ell_max_supertranslation ) # standard (spin-zero) spherical harmonics r_j_k = np.array([(R * quaternion.z * R.inverse()).vec for R in R_j_k.flat]).T kconformal_j_k = 1.0 / ( gamma * (1 - np.dot(boost_velocity, r_j_k).reshape(R_j_k.shape))) alphasupertranslation_j_k = np.tensordot(supertranslation, SH_j_k, axes=([0], [2])).real fprm_i_j_k = np.tensordot( w_modes.data, SWSH_j_k[:, :, sf.LM_index(w_modes.ell_min, -w_modes.ell_min, 0):sf. LM_index(w_modes.ell_max, w_modes.ell_max, 0) + 1, ], axes=([1], [2]), ) if beta != 0 or (supertranslation[1:] != 0).any(): if w_modes.dataType == h: # Note that SWSH_j_k will use s=-2 in this case, so it can be used in the tensordot correctly supertranslation_deriv = 0.5 * sf.ethbar_GHP( sf.ethbar_GHP(supertranslation, 0, 0), -1, 0) supertranslation_deriv_values = np.tensordot( supertranslation_deriv, SWSH_j_k[:, :, :sf.LM_index(ell_max_supertranslation, ell_max_supertranslation, 0) + 1], axes=([0], [2]), ) fprm_i_j_k -= supertranslation_deriv_values[np.newaxis, :, :] elif w_modes.dataType == sigma: # Note that SWSH_j_k will use s=+2 in this case, so it can be used in the tensordot correctly supertranslation_deriv = 0.5 * sf.eth_GHP( sf.eth_GHP(supertranslation, 0, 0), 1, 0) supertranslation_deriv_values = np.tensordot( supertranslation_deriv, SWSH_j_k[:, :, :sf.LM_index(ell_max_supertranslation, ell_max_supertranslation, 0) + 1], axes=([0], [2]), ) fprm_i_j_k -= supertranslation_deriv_values[np.newaxis, :, :] elif w_modes.dataType in [psi0, psi1, psi2, psi3]: from scipy.special import comb eth_alphasupertranslation_j_k = np.tensordot( 1 / np.sqrt(2) * sf.eth_GHP(supertranslation, spin_weight=0), sf.SWSH_grid(R_j_k, 1, ell_max_supertranslation), axes=([0], [2]), ) v_dot_rhat = np.insert( sf.vector_as_ell_1_modes(boost_velocity), 0, 0.0) eth_v_dot_rhat_j_k = np.tensordot(1 / np.sqrt(2) * v_dot_rhat, sf.SWSH_grid(R_j_k, 1, 1), axes=([0], [2])) eth_uprm_over_kconformal_i_j_k = ( (w_modes.t[:, np.newaxis, np.newaxis] - alphasupertranslation_j_k[np.newaxis, :, :]) * gamma * kconformal_j_k[np.newaxis, :, :] * eth_v_dot_rhat_j_k[np.newaxis, :, :] - eth_alphasupertranslation_j_k[np.newaxis, :, :]) # Loop over the Weyl scalars of higher index than w_modes, and sum # them with appropriate prefactors. for DT in range(w_modes.dataType + 1, psi4 + 1): try: w_modes_temp = kwargs.pop("psi{}_modes".format( DataNames[DT][-1])) except KeyError: raise ValueError( "\nA BMS transformation of {} requires information from {}, which " "has not been supplied.".format( w_modes.data_type_string, DataNames[DT])) SWSH_temp_j_k = sf.SWSH_grid(R_j_k, w_modes_temp.spin_weight, w_modes_temp.ell_max) f_i_j_k = np.tensordot( w_modes_temp.data, SWSH_temp_j_k[:, :, sf.LM_index(w_modes_temp.ell_min, -w_modes_temp.ell_min, 0):sf. LM_index(w_modes_temp.ell_max, w_modes_temp.ell_max, 0) + 1, ], axes=([1], [2]), ) fprm_i_j_k += (comb(5 - w_modes.dataType, 5 - DT) * f_i_j_k * eth_uprm_over_kconformal_i_j_k **(DT - w_modes.dataType)) elif w_modes.dataType not in [psi4, hdot, news]: warning = ( "\nNo BMS transformation is implemented for waveform objects " "of dataType '{}'. Proceeding with the transformation as if it " "were dataType 'Psi4'.".format(w_modes.data_type_string)) warnings.warn(warning) fprm_i_j_k *= ( kconformal_j_k**w_modes.conformal_weight)[np.newaxis, :, :] # Determine the new time slices. The set u' is chosen so that on each slice of constant u'_i, the average value # of u is precisely u_i. But then, we have to narrow that set down, so that every physical point on all the # u'_i' slices correspond to data in the range of input data. time_translation = sf.constant_from_ell_0_mode( supertranslation[0]).real uprm_i = (1 / gamma) * (w_modes.t - time_translation) uprm_min = (kconformal_j_k * (w_modes.t[0] - alphasupertranslation_j_k)).max() uprm_max = (kconformal_j_k * (w_modes.t[-1] - alphasupertranslation_j_k)).min() uprm_iprm = uprm_i[(uprm_i >= uprm_min) & (uprm_i <= uprm_max)] # Interpolate along each grid line to the new time in that direction. Note that if there are additional # dimensions in the waveform data, InterpolatedUnivariateSpline will not be able to handle them automatically, # so we have to loop over them explicitly; an Ellipsis can't handle them. Also, we are doing all time steps in # one go, for each j,k,... value, which means that we can overwrite the original data final_dim = int(np.prod(fprm_i_j_k.shape[3:])) fprm_i_j_k = fprm_i_j_k.reshape(fprm_i_j_k.shape[:3] + (final_dim, )) for j in range(n_theta): for k in range(n_phi): uprm_i_j_k = kconformal_j_k[j, k] * ( w_modes.t - alphasupertranslation_j_k[j, k]) for final_indices in range(final_dim): re_fprm_iprm_j_k = interpolate.InterpolatedUnivariateSpline( uprm_i_j_k, fprm_i_j_k[:, j, k, final_indices].real) im_fprm_iprm_j_k = interpolate.InterpolatedUnivariateSpline( uprm_i_j_k, fprm_i_j_k[:, j, k, final_indices].imag) fprm_i_j_k[:len(uprm_iprm), j, k, final_indices] = re_fprm_iprm_j_k( uprm_iprm ) + 1j * im_fprm_iprm_j_k(uprm_iprm) # Delete the extra rows from fprm_i_j_k, corresponding to values of u' outside of [u'min, u'max] fprm_iprm_j_k = np.delete(fprm_i_j_k, np.s_[len(uprm_iprm):], 0) # Reshape, to have correct final dimensions fprm_iprm_j_k = fprm_iprm_j_k.reshape((fprm_iprm_j_k.shape[0], n_theta * n_phi) + w_modes.data.shape[2:]) # Encapsulate into a new grid waveform g = cls( t=uprm_iprm, data=fprm_iprm_j_k, history=w_modes.history, n_theta=n_theta, n_phi=n_phi, frameType=w_modes.frameType, dataType=w_modes.dataType, r_is_scaled_out=w_modes.r_is_scaled_out, m_is_scaled_out=w_modes.m_is_scaled_out, constructor_statement= f"{cls.__name__}.from_modes({w_modes}, **{original_kwargs})", ) if kwargs: warnings.warn( "\nUnused kwargs passed to this function:\n{}".format( pprint.pformat(kwargs, width=1))) return g
def process_transformation_kwargs(ell_max, **kwargs): # Build the supertranslation and spacetime_translation arrays supertranslation = np.zeros((4, ), dtype=complex) # For now; may be resized below ell_max_supertranslation = 1 # For now; may be increased below if "supertranslation" in kwargs: supertranslation = np.array(kwargs.pop("supertranslation"), dtype=complex) if supertranslation.dtype != "complex" and supertranslation.size > 0: # I don't actually think this can ever happen... raise TypeError( "\nInput argument `supertranslation` should be a complex array with size>0.\n" "Got a {} array of shape {}.".format(supertranslation.dtype, supertranslation.shape)) # Make sure the array has size at least 4, by padding with zeros if supertranslation.size <= 4: supertranslation = np.lib.pad(supertranslation, (0, 4 - supertranslation.size), "constant", constant_values=(0.0, )) # Check that the shape is a possible array of scalar modes with complete (ell,m) data ell_max_supertranslation = int(np.sqrt(len(supertranslation))) - 1 if (ell_max_supertranslation + 1)**2 != len(supertranslation): raise ValueError( "\nInput supertranslation parameter must contain modes from ell=0 up to some ell_max, " "including\nall relevant m modes in standard order (see `spherical_functions` " "documentation for details).\nThus, it must be an array with length given by a " "perfect square; its length is {}".format( len(supertranslation))) # Check that the resulting supertranslation will be real for ell in range(ell_max_supertranslation + 1): for m in range(ell + 1): i_pos = sf.LM_index(ell, m, 0) i_neg = sf.LM_index(ell, -m, 0) a = supertranslation[i_pos] b = supertranslation[i_neg] if abs(a - (-1.0)**m * b.conjugate()) > 3e-16 + 1e-15 * abs(b): raise ValueError( f"\nsupertranslation[{i_pos}]={a} # (ell,m)=({ell},{m})\n" + "supertranslation[{}]={} # (ell,m)=({},{})\n".format( i_neg, b, ell, -m) + "Will result in an imaginary supertranslation.") spacetime_translation = np.zeros((4, ), dtype=float) spacetime_translation[0] = sf.constant_from_ell_0_mode( supertranslation[0]).real spacetime_translation[1:4] = -sf.vector_from_ell_1_modes( supertranslation[1:4]).real if "spacetime_translation" in kwargs: st_trans = np.array(kwargs.pop("spacetime_translation"), dtype=float) if st_trans.shape != (4, ) or st_trans.dtype != "float": raise TypeError( "\nInput argument `spacetime_translation` should be a float array of shape (4,).\n" "Got a {} array of shape {}.".format(st_trans.dtype, st_trans.shape)) spacetime_translation = st_trans[:] supertranslation[0] = sf.constant_as_ell_0_mode( spacetime_translation[0]) supertranslation[1:4] = sf.vector_as_ell_1_modes( -spacetime_translation[1:4]) if "space_translation" in kwargs: s_trans = np.array(kwargs.pop("space_translation"), dtype=float) if s_trans.shape != (3, ) or s_trans.dtype != "float": raise TypeError( "\nInput argument `space_translation` should be an array of floats of shape (3,).\n" "Got a {} array of shape {}.".format(s_trans.dtype, s_trans.shape)) spacetime_translation[1:4] = s_trans[:] supertranslation[1:4] = sf.vector_as_ell_1_modes( -spacetime_translation[1:4]) if "time_translation" in kwargs: t_trans = kwargs.pop("time_translation") if not isinstance(t_trans, float): raise TypeError( "\nInput argument `time_translation` should be a single float.\n" "Got {}.".format(t_trans)) spacetime_translation[0] = t_trans supertranslation[0] = sf.constant_as_ell_0_mode( spacetime_translation[0]) # Decide on the number of points to use in each direction. A nontrivial supertranslation will introduce # power in higher modes, so for best accuracy, we need to account for that. But we'll make it a firm # requirement to have enough points to capture the original waveform, at least w_ell_max = ell_max ell_max = w_ell_max + ell_max_supertranslation n_theta = kwargs.pop("n_theta", 2 * ell_max + 1) n_phi = kwargs.pop("n_phi", 2 * ell_max + 1) if n_theta < 2 * ell_max + 1 and abs(supertranslation[1:]).max() > 0.0: warning = ( f"n_theta={n_theta} is small; because of the supertranslation, " + f"it will lose accuracy for anything less than 2*ell+1={ell_max}") warnings.warn(warning) if n_theta < 2 * w_ell_max + 1: raise ValueError(f"n_theta={n_theta} is too small; " + "must be at least 2*ell+1={}".format(2 * w_ell_max + 1)) if n_phi < 2 * ell_max + 1 and abs(supertranslation[1:]).max() > 0.0: warning = ( f"n_phi={n_phi} is small; because of the supertranslation, " + f"it will lose accuracy for anything less than 2*ell+1={ell_max}") warnings.warn(warning) if n_phi < 2 * w_ell_max + 1: raise ValueError(f"n_phi={n_phi} is too small; " + "must be at least 2*ell+1={}".format(2 * w_ell_max + 1)) # Get the rotor for the frame rotation frame_rotation = np.quaternion( *np.array(kwargs.pop("frame_rotation", [1, 0, 0, 0]), dtype=float)) if frame_rotation.abs() < 3e-16: raise ValueError( f"frame_rotation={frame_rotation} should be a unit quaternion") frame_rotation = frame_rotation.normalized() # Get the boost velocity vector boost_velocity = np.array(kwargs.pop("boost_velocity", [0.0] * 3), dtype=float) beta = np.linalg.norm(boost_velocity) if boost_velocity.shape != (3, ) or beta >= 1.0: raise ValueError( "Input boost_velocity=`{}` should be a 3-vector with " "magnitude strictly less than 1.0.".format(boost_velocity)) gamma = 1 / math.sqrt(1 - beta**2) varphi = math.atanh(beta) # These are the angles in the transformed system at which we need to know the function values thetaprm_j_phiprm_k = np.array([[ [thetaprm_j, phiprm_k] for phiprm_k in np.linspace(0.0, 2 * np.pi, num=n_phi, endpoint=False) ] for thetaprm_j in np.linspace(0.0, np.pi, num=n_theta, endpoint=True)]) # Construct the function that modifies our rotor grid to account for the boost if beta > 3e-14: # Tolerance for beta; any smaller and numerical errors will have greater effect vhat = boost_velocity / beta def Bprm_j_k(thetaprm, phiprm): """Construct rotor taking r' to r I derived this result in a different way, but I've also found it described in Penrose-Rindler Vol. 1, around Eq. (1.3.5). Note, however, that their discussion is for the past celestial sphere, so there's a sign difference. """ # Note: It doesn't matter which we use -- r' or r; all we need is the direction of the bivector # spanned by v and r', which is the same as the direction of the bivector spanned by v and r, # since either will be normalized, and one cross product is zero iff the other is zero. rprm = np.array([ math.cos(phiprm) * math.sin(thetaprm), math.sin(phiprm) * math.sin(thetaprm), math.cos(thetaprm) ]) Thetaprm = math.acos(np.dot(vhat, rprm)) Theta = 2 * math.atan(math.exp(-varphi) * math.tan(Thetaprm / 2.0)) rprm_cross_vhat = np.quaternion(0.0, *np.cross(rprm, vhat)) if rprm_cross_vhat.abs() > 1e-200: return (rprm_cross_vhat.normalized() * (Thetaprm - Theta) / 2).exp() else: return quaternion.one else: def Bprm_j_k(thetaprm, phiprm): return quaternion.one # Set up rotors that we can use to evaluate the SWSHs in the original frame R_j_k = np.empty(thetaprm_j_phiprm_k.shape[:2], dtype=np.quaternion) for j in range(thetaprm_j_phiprm_k.shape[0]): for k in range(thetaprm_j_phiprm_k.shape[1]): thetaprm_j, phiprm_k = thetaprm_j_phiprm_k[j, k] R_j_k[j, k] = (Bprm_j_k(thetaprm_j, phiprm_k) * frame_rotation * quaternion.from_spherical_coords(thetaprm_j, phiprm_k)) return ( supertranslation, ell_max_supertranslation, ell_max, n_theta, n_phi, boost_velocity, beta, gamma, varphi, R_j_k, Bprm_j_k, thetaprm_j_phiprm_k, kwargs, )
def from_modes(cls, w_modes, **kwargs): """Construct grid object from modes, with optional BMS transformation This "constructor" is designed with the goal of transforming the frame in which the modes are measured. If this is not desired, it can be called without those parameters. It is important to note that the input transformation parameters are applied in the order listed in the parameter list below: 1. (Super)Translations 2. Rotation (about the origin of the translated system) 3. Boost All input parameters refer to the transformation required to take the mode's inertial frame onto the inertial frame of the grid's inertial observers. In what follows, the inertial frame of the modes will be unprimed, while the inertial frame of the grid will be primed. The translations (space, time, spacetime, or super) can be given in various ways, which may override each other. Ultimately, however, they are essentially combined into a single function `alpha`, representing the supertranslation, which transforms the asymptotic time variable `u` as u'(theta, phi) = u - alpha(theta, phi) A simple time translation would correspond to alpha(theta, phi) = time_translation A pure spatial translation would correspond to alpha(theta, phi) = np.dot(space_translation, -nhat(theta, phi)) where `np.dot` is the usual dot product, and `nhat` is the unit vector in the given direction. Parameters ---------- w_modes : WaveformModes The object storing the modes of the original waveform, which will be converted to values on a grid in this function. This is the only required argument to this function. n_theta : int, optional n_phi : int, optional Number of points in the equi-angular grid in the colatitude (theta) and azimuth (phi) directions. Each defaults to 2*ell_max+1, which is optimal for accuracy and speed. However, this ell_max will typically be greater than the input waveform's ell_max by at least one, or the ell_max of the input supertranslation (whichever is greater). This is to minimally account for the power at higher orders that such a supertranslation introduces. You may wish to increase this further if the spatial size of your supertranslation is large compared to the smallest wavelength you wish to capture in your data [e.g., ell_max*Omega_orbital_max/speed_of_light], or if your boost speed is close to the speed of light. time_translation : float, optional Defaults to zero. Nonzero overrides spacetime_translation and supertranslation. space_translation : float array of length 3, optional Defaults to empty (no translation). Non-empty overrides spacetime_translation and supertranslation. spacetime_translation : float array of length 4, optional Defaults to empty (no translation). Non-empty overrides supertranslation. supertranslation : complex array, optional This gives the complex components of the spherical-harmonic expansion of the supertranslation in standard form, starting from ell=0 up to some ell_max, which may be different from the ell_max of the input WaveformModes object. Supertranslations must be real, so these values must obey the condition alpha^{ell,m} = (-1)^m \bar{alpha}^{ell,-m} Defaults to empty, which causes no supertranslation. frame_rotation : quaternion, optional Transformation applied to (x,y,z) basis of the mode's inertial frame. For example, the basis z vector of the new grid frame may be written as z' = frame_rotation * z * frame_rotation.inverse() Defaults to 1, corresponding to the identity transformation (no frame_rotation). boost_velocity : float array of length 3, optional This is the three-velocity vector of the grid frame relative to the mode frame. The norm of this vector is checked to ensure that it is smaller than 1. Defaults to [], corresponding to no boost. Returns ------- WaveformGrid """ # Check input object type and frame type # # The data in `w_modes` is given in the original frame. We need to get the value of the field on a grid of # points corresponding to the points in the new grid frame. But we must also remember that this is a # spin-weighted and boost-weighted field, which means that we need to account for the frame_rotation due to # `frame_rotation` and `boost_velocity`. The way to do this is to use rotors to transform the points as needed, # and evaluate the SWSHs. But for now, let's just reject any waveforms in a non-inertial frame if not isinstance(w_modes, WaveformModes): raise TypeError("\nInput waveform object must be an instance of `WaveformModes`; " "this is of type `{0}`".format(type(w_modes).__name__)) if w_modes.frameType != Inertial: raise ValueError("\nInput waveform object must be in an inertial frame; " "this is in a frame of type `{0}`".format(w_modes.frame_type_string)) # The first task is to establish a set of constant u' slices on which the new grid should be evaluated. This # is done simply by translating the original set of slices by the time translation (the lowest moment of the # supertranslation). But some of these slices (at the beginning and end) will not have complete data, # because of the direction-dependence of the rest of the supertranslation. That is, in some directions, # the data for the complete slice (data in all directions on the sphere) of constant u' will actually refer to # spacetime events that were not in the original set of time slices; we would have to extrapolate the original # data. So, for nontrivial supertranslations, the output data will actually represent a proper subset of the # input data. # # We can invert the equation for u' to obtain u as a function of angle assuming constant u' # u'(theta, phi) = u + alpha(theta, phi) + u * np.dot(boost_velocity, nhat(theta, phi)) # u(theta, phi) = (u' - alpha(theta, phi)) / (1 + np.dot(boost_velocity, nhat(theta, phi))) # But really, we want u'(theta', phi') for given values # # Note that `space_translation` (and the spatial part of `spacetime_translation`) get reversed signs when # transformed into supertranslation modes, because these pieces enter the time transformation with opposite # sign compared to the time translation, as can be seen by looking at the retarded time: `t-r`. original_kwargs = kwargs.copy() # Build the supertranslation and spacetime_translation arrays supertranslation = np.zeros((4,), dtype=complex) # For now; may be resized below ell_max_supertranslation = 1 # For now; may be increased below if 'supertranslation' in kwargs: supertranslation = np.array(kwargs.pop('supertranslation'), dtype=complex) if supertranslation.dtype != 'complex' and supertranslation.size > 0: # I don't actually think this can ever happen... raise TypeError("\nInput argument `supertranslation` should be a complex array with size>0.\n" "Got a {0} array of shape {1}.".format(supertranslation.dtype, supertranslation.shape)) # Make sure the array has size at least 4, by padding with zeros if supertranslation.size <= 4: supertranslation = np.lib.pad(supertranslation, (0, 4-supertranslation.size), 'constant', constant_values=(0.0,)) # Check that the shape is a possible array of scalar modes with complete (ell,m) data ell_max_supertranslation = int(np.sqrt(len(supertranslation))) - 1 if (ell_max_supertranslation + 1)**2 != len(supertranslation): raise ValueError('\nInput supertranslation parameter must contain modes from ell=0 up to some ell_max, ' 'including\nall relevant m modes in standard order (see `spherical_functions` ' 'documentation for details).\nThus, it must be an array with length given by a ' 'perfect square; its length is {0}'.format(len(supertranslation))) # Check that the resulting supertranslation will be real for ell in range(ell_max_supertranslation+1): for m in range(ell+1): i_pos = sf.LM_index(ell, m, 0) i_neg = sf.LM_index(ell, -m, 0) a = supertranslation[i_pos] b = supertranslation[i_neg] if abs(a - (-1.)**m * b.conjugate()) > 3e-16 + 1e-15 * abs(b): raise ValueError("\nsupertranslation[{0}]={1} # (ell,m)=({2},{3})\n".format(i_pos, a, ell, m) + "supertranslation[{0}]={1} # (ell,m)=({2},{3})\n".format(i_neg, b, ell, -m) + "Will result in an imaginary supertranslation.") spacetime_translation = np.zeros((4,), dtype=float) spacetime_translation[0] = sf.constant_from_ell_0_mode(supertranslation[0]).real spacetime_translation[1:4] = -sf.vector_from_ell_1_modes(supertranslation[1:4]).real if 'spacetime_translation' in kwargs: st_trans = np.array(kwargs.pop('spacetime_translation'), dtype=float) if st_trans.shape != (4,) or st_trans.dtype != 'float': raise TypeError("\nInput argument `spacetime_translation` should be a float array of shape (4,).\n" "Got a {0} array of shape {1}.".format(st_trans.dtype, st_trans.shape)) spacetime_translation = st_trans[:] supertranslation[0] = sf.constant_as_ell_0_mode(spacetime_translation[0]) supertranslation[1:4] = sf.vector_as_ell_1_modes(-spacetime_translation[1:4]) if 'space_translation' in kwargs: s_trans = np.array(kwargs.pop('space_translation'), dtype=float) if s_trans.shape != (3,) or s_trans.dtype != 'float': raise TypeError("\nInput argument `space_translation` should be an array of floats of shape (3,).\n" "Got a {0} array of shape {1}.".format(s_trans.dtype, s_trans.shape)) spacetime_translation[1:4] = s_trans[:] supertranslation[1:4] = sf.vector_as_ell_1_modes(-spacetime_translation[1:4]) if 'time_translation' in kwargs: t_trans = kwargs.pop('time_translation') if not isinstance(t_trans, float): raise TypeError("\nInput argument `time_translation` should be a single float.\n" "Got {0}.".format(t_trans)) spacetime_translation[0] = t_trans supertranslation[0] = sf.constant_as_ell_0_mode(spacetime_translation[0]) # Decide on the number of points to use in each direction. A nontrivial supertranslation will introduce # power in higher modes, so for best accuracy, we need to account for that. But we'll make it a firm # requirement to have enough points to capture the original waveform, at least ell_max = w_modes.ell_max + ell_max_supertranslation n_theta = kwargs.pop('n_theta', 2*ell_max+1) n_phi = kwargs.pop('n_phi', 2*ell_max+1) if n_theta < 2*ell_max+1 and abs(supertranslation[1:]).max() > 0.0: warning = ("n_theta={0} is small; because of the supertranslation, ".format(n_theta) + "it will lose accuracy for anything less than 2*ell+1={1}".format(ell_max)) warnings.warn(warning) if n_theta < 2*w_modes.ell_max+1: raise ValueError('n_theta={0} is too small; '.format(n_theta) + 'must be at least 2*ell+1={1}'.format(2*w_modes.ell_max+1)) if n_phi < 2*ell_max+1 and abs(supertranslation[1:]).max() > 0.0: warning = ("n_phi={0} is small; because of the supertranslation, ".format(n_phi) + "it will lose accuracy for anything less than 2*ell+1={1}".format(ell_max)) warnings.warn(warning) if n_phi < 2*w_modes.ell_max+1: raise ValueError('n_phi={0} is too small; '.format(n_phi) + 'must be at least 2*ell+1={1}'.format(2*w_modes.ell_max+1)) # Get the rotor for the frame rotation frame_rotation = np.quaternion(*np.array(kwargs.pop('frame_rotation', [1, 0, 0, 0]), dtype=float)) if frame_rotation.abs() < 3e-16: raise ValueError('frame_rotation={0} should be a unit quaternion'.format(frame_rotation)) frame_rotation = frame_rotation.normalized() # Get the boost velocity vector boost_velocity = np.array(kwargs.pop('boost_velocity', [0.0]*3), dtype=float) beta = np.linalg.norm(boost_velocity) if boost_velocity.shape != (3,) or beta >= 1.0: raise ValueError('Input boost_velocity=`{0}` should be a 3-vector with ' 'magnitude strictly less than 1.0.'.format(boost_velocity)) gamma = 1 / math.sqrt(1 - beta**2) varphi = math.atanh(beta) if kwargs: import pprint warnings.warn("\nUnused kwargs passed to this function:\n{0}".format(pprint.pformat(kwargs, width=1))) # These are the angles in the transformed system at which we need to know the function values thetaprm_j_phiprm_k = np.array([[[thetaprm_j, phiprm_k] for phiprm_k in np.linspace(0.0, 2*np.pi, num=n_phi, endpoint=False)] for thetaprm_j in np.linspace(0.0, np.pi, num=n_theta, endpoint=True)]) # Construct the function that modifies our rotor grid to account for the boost if beta > 3e-14: # Tolerance for beta; any smaller and numerical errors will have greater effect vhat = boost_velocity / beta def Bprm_j_k(thetaprm, phiprm): """Construct rotor taking r' to r I derived this result in a different way, but I've also found it described in Penrose-Rindler Vol. 1, around Eq. (1.3.5). Note, however, that their discussion is for the past celestial sphere, so there's a sign difference. """ # Note: It doesn't matter which we use -- r' or r; all we need is the direction of the bivector # spanned by v and r', which is the same as the direction of the bivector spanned by v and r, # since either will be normalized, and one cross product is zero iff the other is zero. rprm = np.array([math.cos(phiprm)*math.sin(thetaprm), math.sin(phiprm)*math.sin(thetaprm), math.cos(thetaprm)]) Thetaprm = math.acos(np.dot(vhat, rprm)) Theta = 2 * math.atan(math.exp(-varphi) * math.tan(Thetaprm/2.0)) rprm_cross_vhat = np.quaternion(0.0, *np.cross(rprm, vhat)) if rprm_cross_vhat.abs() > 1e-200: return (rprm_cross_vhat.normalized() * (Thetaprm - Theta) / 2).exp() else: return quaternion.one else: def Bprm_j_k(thetaprm, phiprm): return quaternion.one # Set up rotors that we can use to evaluate the SWSHs in the original frame R_j_k = np.empty(thetaprm_j_phiprm_k.shape[:2], dtype=np.quaternion) for j in range(thetaprm_j_phiprm_k.shape[0]): for k in range(thetaprm_j_phiprm_k.shape[1]): thetaprm_j, phiprm_k = thetaprm_j_phiprm_k[j, k] R_j_k[j, k] = (Bprm_j_k(thetaprm_j, phiprm_k) * frame_rotation * quaternion.from_spherical_coords(thetaprm_j, phiprm_k)) # TODO: Incorporate the w_modes.frame information into rotors, which will require time dependence throughout # It would be best to leave the waveform in its frame. But we'll have to apply the frame_rotation to the BMS # elements, which will be a little tricky. Still probably not as tricky as applying to the waveform... # We need values of (1) waveform, (2) conformal factor, and (3) supertranslation, at each point of the # transformed grid, at each instant of time. SWSH_j_k = sf.SWSH_grid(R_j_k, w_modes.spin_weight, ell_max) SH_j_k = sf.SWSH_grid(R_j_k, 0, ell_max_supertranslation) # standard (spin-zero) spherical harmonics r_j_k = np.array([(R*quaternion.z*R.inverse()).vec for R in R_j_k.flat]).T kconformal_j_k = 1. / (gamma*(1-np.dot(boost_velocity, r_j_k).reshape(R_j_k.shape))) alphasupertranslation_j_k = np.tensordot(supertranslation, SH_j_k, axes=([0], [2])).real fprm_i_j_k = np.tensordot( w_modes.data, SWSH_j_k[:, :, sf.LM_index(w_modes.ell_min, -w_modes.ell_min, 0) :sf.LM_index(w_modes.ell_max, w_modes.ell_max, 0)+1], axes=([1], [2])) if w_modes.dataType == h: # Note that SWSH_j_k will use s=-2 in this case, so it can be used in the tensordot correctly supertranslation_deriv = sf.ethbar_GHP(sf.ethbar_GHP(supertranslation, 0, 0), -1, 0) supertranslation_deriv_values = np.tensordot( supertranslation_deriv, SWSH_j_k[:, :, :sf.LM_index(ell_max_supertranslation, ell_max_supertranslation, 0)+1], axes=([0], [2])) fprm_i_j_k -= supertranslation_deriv_values[np.newaxis, :, :] elif w_modes.dataType == sigma: # Note that SWSH_j_k will use s=+2 in this case, so it can be used in the tensordot correctly supertranslation_deriv = sf.eth_GHP(sf.eth_GHP(supertranslation, 0, 0), 1, 0) supertranslation_deriv_values = np.tensordot( supertranslation_deriv, SWSH_j_k[:, :, :sf.LM_index(ell_max_supertranslation, ell_max_supertranslation, 0)+1], axes=([0], [2])) fprm_i_j_k -= supertranslation_deriv_values[np.newaxis, :, :] elif w_modes.dataType in [psi0, psi1, psi2, psi3]: warning = ("\nTechnically, waveforms of dataType `{0}` ".format(w_modes.data_type_string) + "do not transform among themselves;\n there is mixing from psi_n, for each n greater than " + "this waveform's.\nProceeding on the assumption other contributions are small. However,\n" + "note that it is possible to construct a `psin` data type containing all necessary modes.") warnings.warn(warning) fprm_i_j_k *= (gamma**w_modes.gamma_weight * kconformal_j_k**w_modes.conformal_weight)[np.newaxis, :, :] # Determine the new time slices. The set u' is chosen so that on each slice of constant u'_i, the average value # of u is precisely u_i. But then, we have to narrow that set down, so that every physical point on all the # u'_i' slices correspond to data in the range of input data. uprm_i = (1/gamma) * (w_modes.t - spacetime_translation[0]) uprm_min = (kconformal_j_k * (w_modes.t[0] - alphasupertranslation_j_k)).max() uprm_max = (kconformal_j_k * (w_modes.t[-1] - alphasupertranslation_j_k)).min() uprm_iprm = uprm_i[(uprm_i >= uprm_min) & (uprm_i <= uprm_max)] # Interpolate along each grid line to the new time in that direction. Note that if there are additional # dimensions in the waveform data, InterpolatedUnivariateSpline will not be able to handle them automatically, # so we have to loop over them explicitly; an Ellipsis can't handle them. Also, we are doing all time steps in # one go, for each j,k,... value, which means that we can overwrite the original data final_dim = int(np.prod(fprm_i_j_k.shape[3:])) fprm_i_j_k = fprm_i_j_k.reshape(fprm_i_j_k.shape[:3] + (final_dim,)) for j in range(n_theta): for k in range(n_phi): uprm_i_j_k = kconformal_j_k[j, k] * (w_modes.t - alphasupertranslation_j_k[j, k]) for final_indices in range(final_dim): re_fprm_iprm_j_k = interpolate.InterpolatedUnivariateSpline(uprm_i_j_k, fprm_i_j_k[:, j, k, final_indices].real) im_fprm_iprm_j_k = interpolate.InterpolatedUnivariateSpline(uprm_i_j_k, fprm_i_j_k[:, j, k, final_indices].imag) fprm_i_j_k[:len(uprm_iprm), j, k, final_indices] = ( re_fprm_iprm_j_k(uprm_iprm) + 1j * im_fprm_iprm_j_k(uprm_iprm)) # Delete the extra rows from fprm_i_j_k, corresponding to values of u' outside of [u'min, u'max] fprm_iprm_j_k = np.delete(fprm_i_j_k, np.s_[len(uprm_iprm):], 0) # Reshape, to have correct final dimensions fprm_iprm_j_k = fprm_iprm_j_k.reshape((fprm_iprm_j_k.shape[0], n_theta*n_phi)+w_modes.data.shape[2:]) # Encapsulate into a new grid waveform g = cls(t=uprm_iprm, data=fprm_iprm_j_k, history=w_modes.history, n_theta=n_theta, n_phi=n_phi, frameType=w_modes.frameType, dataType=w_modes.dataType, r_is_scaled_out=w_modes.r_is_scaled_out, m_is_scaled_out=w_modes.m_is_scaled_out, constructor_statement="{0}.from_modes({1}, **{2})".format(cls.__name__, w_modes, original_kwargs)) return g