Source code for galsim.lensing_ps

# Copyright (c) 2012-2023 by the GalSim developers team on GitHub
# https://github.com/GalSim-developers
#
# This file is part of GalSim: The modular galaxy image simulation toolkit.
# https://github.com/GalSim-developers/GalSim
#
# GalSim is free software: redistribution and use in source and binary forms,
# with or without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions, and the disclaimer given in the accompanying LICENSE
#    file.
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions, and the disclaimer given in the documentation
#    and/or other materials provided with the distribution.
#

__all__ = [ 'PowerSpectrum' ]

import os
import numpy as np

from .angle import arcsec, AngleUnit
from .position import PositionD, PositionI
from .bounds import BoundsD, BoundsI
from .interpolant import Quintic, Lanczos
from .image import Image, ImageD
from .random import GaussianDeviate
from .table import LookupTable, LookupTable2D
from . import utilities
from . import integ
from .errors import GalSimError, GalSimValueError, GalSimIncompatibleValuesError
from .errors import GalSimNotImplementedError, galsim_warn
from .bessel import j0, jn

[docs]def theoryToObserved(gamma1, gamma2, kappa): """Helper function to convert theoretical lensing quantities to observed ones. This helper function is used internally by the methods `PowerSpectrum.getShear`, `PowerSpectrum.getMagnification`, and `PowerSpectrum.getLensing` to convert from theoretical quantities (shear and convergence) to observable ones (reduced shear and magnification). Users of `PowerSpectrum.buildGrid` outputs can also apply this method directly to the outputs in order to get the values of reduced shear and magnification on the output grid. Parameters: gamma1: The first shear component, which must be the NON-reduced shear. This and all other inputs may be supplied either as individual floating point numbers or lists/arrays of floats. gamma2: The second (x) shear component, which must be the NON-reduced shear. kappa: The convergence. Returns: the reduced shear and magnification as a tuple (g1, g2, mu) """ gamma1 = np.array(gamma1, copy=False, dtype=float) gamma2 = np.array(gamma2, copy=False, dtype=float) kappa = np.array(kappa, copy=False, dtype=float) g1 = gamma1/(1.-kappa) g2 = gamma2/(1.-kappa) mu = 1./((1.-kappa)**2 - (gamma1**2 + gamma2**2)) return g1, g2, mu
[docs]class PowerSpectrum: r"""Class to represent a lensing shear field according to some power spectrum :math:`P(k)`. **General considerations**: A PowerSpectrum represents some (flat-sky) shear power spectrum, either for gridded points or at arbitary positions. This class is originally initialized with a power spectrum from which we would like to generate g1 and g2 (and, optionally, convergence kappa) values. It generates shears on a grid, and if necessary, when `getShear` (or another "get" method) is called, it will interpolate to the requested positions. For detail on how these processes are carried out, please see the document in the GalSim repository, ``devel/modules/lensing_engine.pdf``. This class generates the shears according to the input power spectrum using a DFT approach, which means that we implicitly assume our discrete representation of :math:`P(k)` on a grid is one complete cell in an infinite periodic series. We are making assumptions about what :math:`P(k)` is doing outside of our minimum and maximum k range, and those must be kept in mind when comparing with theoretical expectations. Specifically, since the power spectrum is realized on only a finite grid it has been been effectively bandpass filtered between a minimum and maximum k value in each of the k1, k2 directions. See the `buildGrid` method for more information. As a result, the shear generation currently does not include sample variance due to coverage of a finite patch. We explicitly enforce :math:`P(0)=0`, which is true for the full sky in a reasonable cosmological model, but it ignores the fact that our little patch of sky might reasonably live in some special region with respect to shear correlations. Our :math:`P(0)=0` is essentially setting the integrated power below our minimum k value to zero. The implications of the discrete representation, and the :math:`P(0)=0` choice, are discussed in more detail in ``devel/modules/lensing_engine.pdf``. The effective shear correlation function for the gridded points will be modified both because of the DFT approach to representing shears according to a power spectrum, and because of the power cutoff below and above the minimum k values. The latter effect can be particularly important on large scales, so the `buildGrid` method has some keywords that can be used to reduce the impact of the minimum k set by the grid extent. The calculateXi() method can be used to calculate the expected shear correlation functions given the minimum and maximum k for some grid (but ignoring the discrete vs. continuous Fourier transform effects), for comparison with some ideal theoretical correlation function given an infinite k range. When interpolating the shears to non-gridded points, the shear correlation function and power spectrum are modified; see the `getShear` and other "get" method docstrings for more details. **The power spectra to be used**: When creating a PowerSpectrum instance, you must specify at least one of the E or B mode power spectra, which is normally given as a function :math:`P(k)`. The typical thing is to just use a lambda function in Python (i.e., a function that is not associated with a name); for example, to define :math:`P(k)=k^2`, one would use ``lambda k : k**2``. But the power spectra can also be more complicated user-defined functions that take a single argument ``k`` and return the power at that ``k`` value, or they can be instances of the `LookupTable` class for power spectra that are known at particular ``k`` values but for which there is not a simple analytic form. Cosmologists often express the power spectra in terms of an expansion in spherical harmonics (ell), i.e., the :math:`C_\ell` values. In the flat-sky limit, we can replace :math:`\ell` with :math:`k` and :math:`C_\ell` with :math:`P(k)`. Thus, :math:`k` and :math:`P(k)` have dimensions of inverse angle and angle^2, respectively. It is quite common for people to plot :math:`\ell(\ell+1) C_\ell/2\pi`, a dimensionless quantity; the analogous flat-sky quantity is :math:`\Delta^2 = k^2 P(k)/2\pi`. By default, the PowerSpectrum object assumes it is getting :math:`P(k)`, but it is possible to instead give it :math:`\Delta^2` by setting the optional keyword ``delta2 = True`` in the constructor. The power functions must return a list/array that is the same size as what they are given, e.g., in the case of no power or constant power, a function that just returns a float would not be permitted; it would have to return an array of floats all with the same value. It is important to note that the power spectra used to initialize the PowerSpectrum object should use the same units for k and :math:`P(k)`, i.e., if k is in inverse radians then :math:`P(k)` should be in radians^2 (as is natural for outputs from a cosmological shear power spectrum calculator). However, when we actually draw images, there is a natural scale that defines the pitch of the image, which is typically taken to be arcsec. This definition of a specific length scale means that by default we assume all quantities to the PowerSpectrum are in arcsec, and those are the units used for internal calculations, but the ``units`` keyword can be used to specify different input units for :math:`P(k)` (again, within the constraint that k and :math:`P(k)` must be consistent). If the ``delta2`` keyword is set to specify that the input is actually the dimensionless power :math:`\Delta^2`, then the input ``units`` are taken to apply only to the k values. Parameters: e_power_function: A function or other callable that accepts a NumPy array of abs(k) values, and returns the E-mode power spectrum P_E(abs(k)) in an array of the same shape. The function should return the power spectrum desired in the E (gradient) mode of the image. It may also be a string that can be converted to a function using ``eval('lambda k : '+e_power_function)``, a `LookupTable`, or ``file_name`` from which to read in a `LookupTable`. If a ``file_name`` is given, the resulting `LookupTable` uses the defaults for the `LookupTable` class, namely spline interpolation in :math:`P(k)`. Users who wish to deviate from those defaults (for example, to interpolate in log(P) and log(k), as might be more natural for power-law functions) should instead read in the file to create a `LookupTable` using the necessary non-default settings. [default: None, which means no E-mode power.] b_power_function: A function or other callable that accepts a NumPy array of abs(k) values, and returns the B-mode power spectrum P_B(abs(k)) in an array of the same shape. The function should return the power spectrum desired in the B (curl) mode of the image. See description of ``e_power_function`` for input format options. [default: None, which means no B-mode power.] delta2: Is the power actually given as dimensionless :math:`\Delta^2`, which requires us to multiply by :math:`2\pi / k^2` to get the shear power :math:`P(k)` in units of angle^2? [default: False] units: The angular units used for the power spectrum (i.e. the units of k^-1 and sqrt(P)). This should be either an `AngleUnit` instance (e.g. galsim.radians) or a string (e.g. 'radians'). [default: arcsec] """ _opt_params = { 'e_power_function' : str, 'b_power_function' : str, 'delta2' : bool, 'units' : str } def __init__(self, e_power_function=None, b_power_function=None, delta2=False, units=arcsec): # Check that at least one power function is not None if e_power_function is None and b_power_function is None: raise GalSimIncompatibleValuesError( "At least one of e_power_function or b_power_function must be provided.", e_power_function=e_power_function, b_power_function=b_power_function) self.e_power_function = e_power_function self.b_power_function = b_power_function self.delta2 = delta2 self.units = units # Try these conversions, but we don't actually keep the output. This just # provides a way to test if the arguments are sane. # Note: we redo this in buildGrid for real rather than keeping the outputs # (e.g. in self.e_power_function, self.b_power_function) so that PowerSpectrum is # picklable. It turns out lambda functions are not picklable. self._convert_power_function(self.e_power_function,'e_power_function') self._convert_power_function(self.b_power_function,'b_power_function') # Check validity of units if isinstance(units, str): # if the string is invalid, this raises a reasonable error message. units = AngleUnit.from_name(units) if not isinstance(units, AngleUnit): raise GalSimValueError("units must be either an AngleUnit or a string", units, ('arcsec', 'arcmin', 'degree', 'hour', 'radian')) if units == arcsec: self.scale = 1 else: self.scale = units / arcsec def __repr__(self): s = 'galsim.PowerSpectrum(e_power_function=%r'%self.e_power_function if self.b_power_function is not None: s += ', b_power_function=%r'%self.b_power_function if self.delta2: s += ', delta2=%r'%self.delta2 if self.units != arcsec: s += ', units=%r'%self.units s += ')' return s def __str__(self): s = 'galsim.PowerSpectrum(e_power_function=%s'%self.e_power_function if self.b_power_function is not None: s += ', b_power_function=%s'%self.b_power_function s += ')' return s def __eq__(self, other): return (self is other or (isinstance(other, PowerSpectrum) and self.e_power_function == other.e_power_function and self.b_power_function == other.b_power_function and self.delta2 == other.delta2 and self.scale == other.scale)) def __ne__(self, other): return not self.__eq__(other) def __hash__(self): return hash(repr(self)) def _get_scale_fac(self, units): if isinstance(units, str): # if the string is invalid, this raises a reasonable error message. units = AngleUnit.from_name(units) if not isinstance(units, AngleUnit): raise GalSimValueError("units must be either an AngleUnit or a string", units, ('arcsec', 'arcmin', 'degree', 'hour', 'radian')) return units / arcsec def _get_bandlimit_func(self, bandlimit): if bandlimit == 'hard': return self._hard_cutoff elif bandlimit == 'soft': return self._softening_function elif bandlimit is None: return lambda k, kmax: 1.0 else: raise GalSimValueError("Unrecognized option for band limit!", bandlimit, (None, 'soft', 'hard')) def _get_pk(self, power_function, k_max, bandlimit_func): if power_function is None: return None elif self.delta2: # Here we have to go from Delta^2 (dimensionless) to P = 2pi Delta^2 / k^2. We want to # have P and therefore 1/k^2 in units of arcsec, so we won't rescale the k that goes in # the denominator. This naturally gives P(k) in arcsec^2. return lambda k : (2.*np.pi) * power_function(self.scale*k)/(k**2) * \ bandlimit_func(self.scale*k, self.scale*k_max) elif self.scale != 1: # Here, the scale comes in two places: # The units of k have to be converted from 1/arcsec, which GalSim wants to use, into # whatever the power spectrum function was defined to use. # The units of power have to be converted from (input units)^2 as returned by the power # function, to Galsim's units of arcsec^2. # Recall that scale is (input units)/arcsec. return lambda k : power_function(self.scale*k)*(self.scale**2) * \ bandlimit_func(self.scale*k, self.scale*k_max) else: return lambda k : power_function(k) * bandlimit_func(k, k_max)
[docs] def buildGrid(self, grid_spacing, ngrid, rng=None, interpolant=None, center=PositionD(0,0), units=arcsec, get_convergence=False, kmax_factor=1, kmin_factor=1, bandlimit="hard", variance=None): """Generate a realization of the current power spectrum on the specified grid. **Basic functionality**: This function will generate a Gaussian random realization of the specified E and B mode shear power spectra at a grid of positions, specified by the input parameters ``grid_spacing`` (distance between grid points) and ``ngrid`` (number of grid points in each direction.) Units for ``grid_spacing`` and ``center`` can be specified using the ``units`` keyword; the default is arcsec, which is how all values are stored internally. It automatically computes and stores grids for the shears and convergence. However, since many users are primarily concerned with shape distortion due to shear, the default is to return only the shear components; the ``get_convergence`` keyword can be used to also return the convergence. The quantities that are returned are the theoretical shears and convergences, usually denoted gamma and kappa, respectively. Users who wish to obtain the more observationally-relevant reduced shear and magnification (that describe real lensing distortions) can either use the `getShear`, `getMagnification`, or `getLensing` methods after `buildGrid`, or can use the convenience function `galsim.lensing_ps.theoryToObserved` to convert from theoretical to observed quantities. **Caveats of the DFT approach**: Note that the shears generated using this method correspond to the PowerSpectrum multiplied by a sharp bandpass filter, set by the dimensions of the grid. The filter sets :math:`P(k) = 0` for:: abs(k1), abs(k2) < kmin / 2 and:: abs(k1), abs(k2) > kmax + kmin / 2 where:: kmin = 2. * pi / (ngrid * grid_spacing) kmax = pi / grid_spacing and where we have adopted the convention that grid points at a given ``k`` represent the interval between (k - dk/2) and (k + dk/2) (noting that the grid spacing dk in k space is equivalent to ``kmin``). It is worth remembering that this bandpass filter will *not* look like a circular annulus in 2D ``k`` space, but is rather more like a thick-sided picture frame, having a small square central cutout of dimensions ``kmin`` by ``kmin``. These properties are visible in the shears generated by this method. If you care about these effects and want to ameliorate their effect, there are two optional kwargs you can provide: ``kmin_factor`` and ``kmax_factor``, both of which are 1 by default. These should be integers >= 1 that specify some factor smaller or larger (for kmin and kmax respectively) you want the code to use for the underlying grid in fourier space. The final shear grid is returned using the specified ``ngrid`` and ``grid_spacing`` parameters. But the intermediate grid in Fourier space will be larger by the specified factors. Note: These are really just for convenience, since you could easily get the same effect by providing different values of ngrid and grid_spacing and then take a subset of them. The ``kmin_factor`` and ``kmax_factor`` just handle the scalings appropriately for you. Use of ``kmin_factor`` and ``kmax_factor`` should depend on the desired application. For accurate representation of power spectra, one should not change these values from their defaults of 1. Changing them from one means the E- and B-mode power spectra that are input will be valid for the larger intermediate grids that get generated in Fourier space, but not necessarily for the smaller ones that get returned to the user. However, for accurate representation of cosmological shear correlation functions, use of ``kmin_factor`` larger than one can be helpful in getting the shear correlations closer to the ideal theoretical ones (see ``devel/module/lensing_engine.pdf`` for details). **Aliasing**: If the user provides a power spectrum that does not include a cutoff at kmax, then our method of generating shears will result in aliasing that will show up in both E- and B-modes. Thus the `buildGrid` method accepts an optional keyword argument called ``bandlimit`` that can tell the PowerSpectrum object to cut off power above kmax automatically, where the relevant kmax is larger than the grid Nyquist frequency by a factor of ``kmax_factor``. The allowed values for ``bandlimit`` are None (i.e., do nothing), ``hard`` (set power to zero above the band limit), or ``soft`` (use an arctan-based softening function to make the power go gradually to zero above the band limit). By default, ``bandlimit=hard``. Use of this keyword does nothing to the internal representation of the power spectrum, so if the user calls the `buildGrid` method again, they will need to set ``bandlimit`` again (and if their grid setup is different in a way that changes ``kmax``, then that's fine). **Interpolation**: If the grid is being created for the purpose of later interpolating to random positions, the following findings should be kept in mind: since the interpolant modifies the effective shear correlation function on scales comparable to <~3x the grid spacing, the grid spacing should be chosen to be at least 3 times smaller than the minimum scales on which the user wishes to reproduce the shear correlation function accurately. Ideally, the grid should be somewhat larger than the region in which shears at random points are needed, so that edge effects in the interpolation will not be important. For this purpose, there should be >~5 grid points outside of the region in which interpolation will take place. Ignoring this edge effect and using the grid for interpolation out to its edges can suppress shear correlations on all scales by an amount that depends on the grid size; for a 100x100 grid, the suppression is ~2-3%. Note that the above numbers came from tests that use a cosmological shear power spectrum; precise figures for this suppression can also depend on the shear correlation function itself. **Sign conventions and other info**: Note also that the convention for axis orientation differs from that for the GREAT10 challenge, so when using codes that deal with GREAT10 challenge outputs, the sign of our g2 shear component must be flipped. For more information on the effects of finite grid representation of the power spectrum see ``devel/modules/lensing_engine.pdf``. **Examples**: 1. Get shears on a grid of points separated by 1 arcsec:: >>> my_ps = galsim.PowerSpectrum(lambda k : k**2) >>> g1, g2 = my_ps.buildGrid(grid_spacing = 1., ngrid = 100) The returned g1, g2 are 2-d NumPy arrays of values, corresponding to the values of g1 and g2 at the locations of the grid points. For a given value of ``grid_spacing`` and ``ngrid``, we could get the x and y values on the grid using:: >>> import numpy as np >>> min = (-ngrid/2 + 0.5) * grid_spacing >>> max = (ngrid/2 - 0.5) * grid_spacing >>> x, y = np.meshgrid(np.arange(min,max+grid_spacing,grid_spacing), ... np.arange(min,max+grid_spacing,grid_spacing)) where the center of the grid is taken to be (0,0). 2. Rebuild the grid using a particular rng and set the location of the center of the grid to be something other than the default (0,0):: >>> g1, g2 = my_ps.buildGrid(grid_spacing = 8., ngrid = 65, ... rng = galsim.BaseDeviate(1413231), ... center = galsim.PositionD(256.5, 256.5) ) 3. Make a `PowerSpectrum` from a tabulated :math:`P(k)` that gets interpolated to find the power at all necessary values of k, then generate shears and convergences on a grid, and convert to reduced shear and magnification so they can be used to transform galaxy images. E.g., assuming that k and P_k are NumPy arrays containing k and :math:`P(k)`:: >>> tab_pk = galsim.LookupTable(k, P_k) >>> my_ps = galsim.PowerSpectrum(tab_pk) >>> g1, g2, kappa = my_ps.buildGrid(grid_spacing = 1., ngrid = 100, ... get_convergence = True) >>> g1_r, g2_r, mu = galsim.lensing_ps.theoryToObserved(g1, g2, kappa) Parameters: grid_spacing: Spacing for an evenly spaced grid of points, by default in arcsec for consistency with the natural length scale of images created using the `GSObject.drawImage` method. Other units can be specified using the ``units`` keyword. ngrid: Number of grid points in each dimension. [Must be an integer] rng: A `BaseDeviate` object for drawing the random numbers. [default: None] interpolant: `Interpolant` that will be used for interpolating the gridded shears by methods like `getShear`, `getConvergence`, etc. if they are later called. [default: galsim.Lanczos(5)] center: If setting up a new grid, define what position you want to consider the center of that grid. Units must be consistent with those for ``grid_spacing``. [default: galsim.PositionD(0,0)] units: The angular units used for the positions. [default: arcsec] get_convergence: Return the convergence in addition to the shear? Regardless of the value of ``get_convergence``, the convergence will still be computed and stored for future use. [default: False] kmin_factor: Factor by which the grid spacing in fourier space is smaller than the default. i.e.:: kmin = 2. * pi / (ngrid * grid_spacing) / kmin_factor [default: 1; must be an integer] kmax_factor: Factor by which the overall grid in fourier space is larger than the default. i.e.:: kmax = pi / grid_spacing * kmax_factor [default: 1; must be an integer] bandlimit: Keyword determining how to handle power :math:`P(k)` above the limiting k value, kmax. The options None, 'hard', and 'soft' correspond to doing nothing (i.e., allow P(>kmax) to be aliased to lower k values), cutting off all power above kmax, and applying a softening filter to gradually cut off power above kmax. Use of this keyword does not modify the internally-stored power spectrum, just the shears generated for this particular call to `buildGrid`. [default: "hard"] variance: Optionally renormalize the variance of the output shears to a given value. This is useful if you know the functional form of the power spectrum you want, but not the normalization. This lets you set the normalization separately. The resulting shears should have var(g1) + var(g2) ~= variance. If only ``e_power_function`` is given, then this is also the variance of kappa. Otherwise, the variance of kappa may be smaller than the specified variance. [default: None] Returns: the tuple (g1,g2[,kappa]), where each is a 2-d NumPy array and kappa is included iff ``get_convergence`` is set to True. """ # Check for validity of integer values if not isinstance(ngrid, int): if ngrid != int(ngrid): raise GalSimValueError("ngrid must be an integer", ngrid) ngrid = int(ngrid) if not isinstance(kmin_factor, int): if kmin_factor != int(kmin_factor): raise GalSimValueError("kmin_factor must be an integer", kmin_factor) kmin_factor = int(kmin_factor) if not isinstance(kmax_factor, int): if kmax_factor != int(kmax_factor): raise GalSimValueError("kmax_factor must be an integer", kmax_factor) kmax_factor = int(kmax_factor) # Check if center is a PositionD if not isinstance(center, PositionD): raise GalSimValueError("center argument for buildGrid must be a PositionD instance", center) # Automatically convert units to arcsec at the outset, then forget about it. This is # because PowerSpectrum by default wants to work in arsec, and all power functions are # automatically converted to do so, so we'll also do that here. scale_fac = self._get_scale_fac(units) center *= scale_fac grid_spacing *= scale_fac # The final grid spacing that will be in the computed images is grid_spacing/kmax_factor. self.grid_spacing = grid_spacing / kmax_factor self.center = center # It is also convenient to store the bounds within which an input position is allowed. self.bounds = BoundsD( center.x - (ngrid-1) * grid_spacing / 2. , center.x + (ngrid-1) * grid_spacing / 2. , center.y - (ngrid-1) * grid_spacing / 2. , center.y + (ngrid-1) * grid_spacing / 2. ) # Expand the bounds slightly to make sure rounding errors don't lead to points on the # edge being considered off the edge. self.bounds = self.bounds.expand( 1. + 1.e-15 ) self.x_grid = np.linspace(self.bounds.xmin, self.bounds.xmax, ngrid) self.y_grid = np.linspace(self.bounds.ymin, self.bounds.ymax, ngrid) gd = GaussianDeviate(rng) # Check that the interpolant is valid. if interpolant is None: self.interpolant = Lanczos(5) else: self.interpolant = utilities.convert_interpolant(interpolant) # Convert power_functions into callables: e_power_function = self._convert_power_function(self.e_power_function,'e_power_function') b_power_function = self._convert_power_function(self.b_power_function,'b_power_function') # Figure out how to apply band limit if requested. # Start by calculating kmax in the appropriate units: # Generally, it should be kmax_factor*pi/(input grid spacing). We have already converted # the user-input grid spacing to arcsec, the units that the PowerSpectrum class uses # internally, and divided it by kmax_factor to get self.grid_spacing, so here we just use # pi/self.grid_spacing. k_max = np.pi / self.grid_spacing bandlimit_func = self._get_bandlimit_func(bandlimit) # If we actually have dimensionless Delta^2, then we must convert to power # P(k) = 2pi Delta^2 / k^2, # which has dimensions of angle^2. # Also apply the bandlimit and/or scale as appropriate. p_E = self._get_pk(e_power_function, k_max, bandlimit_func) p_B = self._get_pk(b_power_function, k_max, bandlimit_func) # Build the grid self.ngrid_tot = ngrid * kmin_factor * kmax_factor self.pixel_size = grid_spacing/kmax_factor psr = PowerSpectrumRealizer(self.ngrid_tot, self.pixel_size, p_E, p_B) self.grid_g1, self.grid_g2, self.grid_kappa = psr(gd, variance) if kmin_factor != 1 or kmax_factor != 1: # Need to make sure the rows are contiguous so we can use it in the constructor # of the ImageD objects below. This requires a copy. s = slice(0,ngrid*kmax_factor,kmax_factor) self.grid_g1 = np.array(self.grid_g1[s,s], copy=True, order='C') self.grid_g2 = np.array(self.grid_g2[s,s], copy=True, order='C') self.grid_kappa = np.array(self.grid_kappa[s,s], copy=True, order='C') # Set up the images to be interpolated. # Note: We don't make the LookupTable2D's yet, since we don't know if # the user wants periodic wrapping or not. # So we wait to create them when we are actually going to use them. self.im_g1 = ImageD(self.grid_g1, scale=self.grid_spacing) self.im_g2 = ImageD(self.grid_g2, scale=self.grid_spacing) self.im_kappa = ImageD(self.grid_kappa, scale=self.grid_spacing) if get_convergence: return self.grid_g1, self.grid_g2, self.grid_kappa else: return self.grid_g1, self.grid_g2
[docs] def nRandCallsForBuildGrid(self): """Return the number of times the rng() was called the last time `buildGrid` was called. This can be useful for keeping rngs in sync if the connection between them is broken (e.g. when calling the function through a Proxy object). """ if not hasattr(self,'ngrid_tot'): raise GalSimError("BuildGrid has not been called yet.") ntot = 0 # cf. PowerSpectrumRealizer._generate_power_array temp = 2 * np.prod( (self.ngrid_tot, self.ngrid_tot//2 +1 ) ) if self.e_power_function is not None: ntot += temp if self.b_power_function is not None: ntot += temp return int(ntot)
def _convert_power_function(self, pf, pf_str): if pf is None: return None # Convert string inputs to either a lambda function or LookupTable if isinstance(pf,str): origpf = pf if os.path.isfile(pf): pf = LookupTable.from_file(pf) else: # Detect at least _some_ forms of malformed string input. Note that this # test assumes that the eval string completion is defined for k=1.0. try: pf = utilities.math_eval('lambda k : ' + pf) pf(1.0) except Exception as e: raise GalSimValueError( "String {0} must either be a valid filename or something that " "can eval to a function of k.\n" "Caught error: {1}".format(pf_str, e), origpf) # Check that the function is sane. # Note: Only try tests below if it's not a LookupTable. # (If it's a LookupTable, then it could be a valid function that isn't # defined at k=1.) if not isinstance(pf, LookupTable): pf(np.array((0.1,1.))) return pf
[docs] def calculateXi(self, grid_spacing, ngrid, kmax_factor=1, kmin_factor=1, n_theta=100, units=arcsec, bandlimit="hard"): r"""Calculate shear correlation functions for the current power spectrum on the specified grid. This function will calculate the theoretical shear correlation functions, :math:`\xi_+` and :math:`\xi_-`, for this power spectrum and the grid configuration specified using keyword arguments, taking into account the minimum and maximum k range implied by the grid parameters, ``kmin_factor`` and ``kmax_factor``. Most theoretical correlation function calculators assume an infinite k range, so this utility can be used to check how close the chosen grid parameters (and the implied minimum and maximum k) come to the "ideal" result. This is particularly useful on large scales, since in practice the finite grid extent limits the minimum k value and therefore can suppress shear correlations on large scales. Note that the actual shear correlation function in the generated shears will still differ from the one calculated here due to differences between the discrete and continuous Fourier transform. The quantities that are returned are three NumPy arrays: separation theta (in the adopted units), :math:`\xi_+`, and :math:`\xi_-`. These are defined in terms of the E- and B-mode shear power spectrum as in the document ``devel/modules/lensing_engine.pdf``, equations 2 and 3. The values that are returned are for a particular theta value, not an average over a range of theta values in some bin of finite width. This method has been tested with cosmological shear power spectra; users should check for sanity of outputs if attempting to use power spectra that have very different scalings with k. Parameters: grid_spacing: Spacing for an evenly spaced grid of points, by default in arcsec for consistency with the natural length scale of images created using the `GSObject.drawImage` method. Other units can be specified using the ``units`` keyword. ngrid: Number of grid points in each dimension. [Must be an integer] units: The angular units used for the positions. [default = arcsec] kmin_factor: (Optional) Factor by which the grid spacing in fourier space is smaller than the default. i.e.:: kmin = 2. * pi / (ngrid * grid_spacing) / kmin_factor [default ``kmin_factor = 1``; must be an integer] kmax_factor: (Optional) Factor by which the overall grid in fourier space is larger than the default. i.e.:: kmax = pi / grid_spacing * kmax_factor [default ``kmax_factor = 1``; must be an integer] n_theta: (Optional) Number of logarithmically spaced bins in angular separation. [default ``n_theta=100``] bandlimit: (Optional) Keyword determining how to handle power :math:`P(k)` above the limiting k value, kmax. The options None, 'hard', and 'soft' correspond to doing nothing (i.e., allow P(>kmax) to be aliased to lower k values), cutting off all power above kmax, and applying a softening filter to gradually cut off power above kmax. Use of this keyword does not modify the internally-stored power spectrum, just the result generated by this particular call to `calculateXi`. [default ``bandlimit="hard"``] Returns: the tuple (theta, xi_p, xi_m), 1-d NumPy arrays for the angular separation theta and the two shear correlation functions. """ # Normalize inputs grid_spacing = float(grid_spacing) ngrid = int(ngrid) kmin_factor = int(kmin_factor) kmax_factor = int(kmax_factor) n_theta = int(n_theta) # Automatically convert units to arcsec at the outset, then forget about it. This is # because PowerSpectrum by default wants to work in arsec, and all power functions are # automatically converted to do so, so we'll also do that here. scale_fac = self._get_scale_fac(units) grid_spacing *= scale_fac # Decide on a grid of separation values. Do this in arcsec, for consistency with the # internals of the PowerSpectrum class. min_sep = grid_spacing max_sep = ngrid*grid_spacing theta = np.logspace(np.log10(min_sep), np.log10(max_sep), n_theta) # Set up the power spectrum to use for the calculations, just as in buildGrid. # Convert power_functions into callables: e_power_function = self._convert_power_function(self.e_power_function,'e_power_function') b_power_function = self._convert_power_function(self.b_power_function,'b_power_function') # Apply band limit if requested; see comments in 'buildGrid()' for more details. k_max = kmax_factor * np.pi / grid_spacing bandlimit_func = self._get_bandlimit_func(bandlimit) # If we actually have dimensionless Delta^2, then we must convert to power # P(k) = 2pi Delta^2 / k^2, # which has dimensions of angle^2. # Also apply the bandlimit and/or scale as appropriate. p_E = self._get_pk(e_power_function, k_max, bandlimit_func) p_B = self._get_pk(b_power_function, k_max, bandlimit_func) # Get k_min value in arcsec: k_min = 2.*np.pi / (ngrid * grid_spacing * kmin_factor) # Do the actual integration for each of the separation values, now that we have power # spectrum functions p_E and p_B. xi_p = np.zeros(n_theta) xi_m = np.zeros(n_theta) for i_theta in range(n_theta): # Usually theory calculations use radians. However, our k and P are already set up to # use arcsec, so we need theta to be in arcsec (which it already is) in order for the # units to work out right. # xi_p = (1/2pi) \int (P_E + P_B) J_0(k theta) k dk # xi_m = (1/2pi) \int (P_E - P_B) J_4(k theta) k dk if p_E is not None and p_B is not None: integrand_p = xip_integrand(lambda k: p_E(k) + p_B(k), theta[i_theta]) integrand_m = xim_integrand(lambda k: p_E(k) - p_B(k), theta[i_theta]) elif p_E is not None: integrand_p = xip_integrand(p_E, theta[i_theta]) integrand_m = xim_integrand(p_E, theta[i_theta]) else: integrand_p = xip_integrand(p_B, theta[i_theta]) integrand_m = xim_integrand(lambda k: -p_B(k), theta[i_theta]) xi_p[i_theta] = integ.int1d(integrand_p, k_min, k_max, rel_err=1.e-6, abs_err=1.e-12) xi_m[i_theta] = integ.int1d(integrand_m, k_min, k_max, rel_err=1.e-6, abs_err=1.e-12) xi_p /= (2.*np.pi) xi_m /= (2.*np.pi) # Now convert the array of separation values back to whatever units were used as inputs to # this function. theta /= scale_fac # Return arrays with results. return theta, xi_p, xi_m
@staticmethod def _softening_function(k, k_max): # Softening function for the power spectrum band-limiting step, instead of a hard cut in k. # We use an arctan function to go smoothly from 1 to 0 above k_max. The input k values # can be in any units, as long as the choice of units for k and k_max is the same. # The magic numbers in the code below come from the following: # We define the function as # (arctan[A log(k/k_max) + B] + pi/2)/pi # For our current purposes, we will define A and B by requiring that this function go to # 0.95 (0.05) for k/k_max = 0.95 (1). This gives two equations: # 0.95 = (arctan[log(0.95) A + B] + pi/2)/pi # 0.05 = (arctan[B] + pi/2)/pi. # We will solve the second equation: # -0.45 pi = arctan(B), or # B = tan(-0.45 pi). b = np.tan(-0.45*np.pi) # Then, we get A from the first equation: # 0.45 pi = arctan[log(0.95) A + B] # tan(0.45 pi) = log(0.95) A + B a = (np.tan(0.45*np.pi)-b) / np.log(0.95) return (np.arctan(a*np.log(k/k_max)+b) + np.pi/2.)/np.pi @staticmethod def _hard_cutoff(k, k_max): if isinstance(k, float): return float(k < k_max) else: return (k < k_max).astype(float)
[docs] def getShear(self, pos, units=arcsec, reduced=True, periodic=False): """ This function can interpolate between grid positions to find the shear values for a given list of input positions (or just a single position). Before calling this function, you must call `buildGrid` first to define the grid of shears and convergences on which to interpolate. The docstring for `buildGrid` provides some guidance on appropriate grid configurations to use when building a grid that is to be later interpolated to random positions. By default, this method returns the reduced shear, which is defined in terms of shear and convergence as reduced shear ``g=gamma/(1-kappa)``; the ``reduced`` keyword can be set to False in order to return the non-reduced shear. Note that the interpolation (specified when calling `buildGrid`) modifies the effective shear power spectrum and correlation function somewhat, though the effects can be limited by careful choice of grid parameters (see buildGrid() docstring for details). Assuming those guidelines are followed, then the shear correlation function modifications due to use of the quintic, Lanczos-3, and Lanczos-5 interpolants are below 5% on all scales from the grid spacing to the total grid extent, typically below 2%. The linear, cubic, and nearest interpolants perform significantly more poorly, with modifications of the correlation functions that can reach tens of percent on the scales where the recommended interpolants perform well. Thus, the default interpolant is Lanczos-5, and users should think carefully about the acceptability of significant modification of the shear correlation function before changing to use linear, cubic, or nearest. Users who wish to ensure that the shear power spectrum is preserved post-interpolation should consider using the ``periodic`` interpolation option, which assumes the shear field is periodic (i.e., the sky is tiled with many copies of the given shear field). Those who care about the correlation function should not use this option, and for this reason it's not the default. **Examples**: 1. Get the shear for a particular point:: >>> g1, g2 = my_ps.getShear(pos = galsim.PositionD(12, 412)) This time the returned values are just floats and correspond to the shear for the provided position. 2. You can also provide a position as a tuple to save the explicit PositionD construction:: >>> g1, g2 = my_ps.getShear(pos = (12, 412)) 3. Get the shears for a bunch of points at once:: >>> xlist = [ 141, 313, 12, 241, 342 ] >>> ylist = [ 75, 199, 306, 225, 489 ] >>> poslist = [ galsim.PositionD(xlist[i],ylist[i]) for i in range(len(xlist)) ] >>> g1, g2 = my_ps.getShear( poslist ) >>> g1, g2 = my_ps.getShear( (xlist, ylist) ) Both calls do the same thing. The returned g1, g2 this time are numpy arrays of g1, g2 values. The arrays are the same length as the number of input positions. Parameters: pos: Position(s) of the source(s), assumed to be post-lensing! Valid ways to input this: - single `Position` instance - tuple of floats: (x,y) - list/array of `Position` instances - tuple of lists/arrays: ( xlist, ylist ) units: The angular units used for the positions. [default: arcsec] reduced: Whether returned shear(s) should be reduced shears. [default: True] periodi: Whether the interpolation should treat the positions as being defined with respect to a periodic grid, which will wrap them around if they are outside the bounds of the original grid on which shears were defined. If not, then shears are set to zero for positions outside the original grid. [default: False] Returns: the shear as a tuple, (g1,g2) If the input ``pos`` is given a single position, (g1,g2) are the two shear components. If the input ``pos`` is given a list/array of positions, they are NumPy arrays. """ if not hasattr(self, 'im_g1'): raise GalSimError("PowerSpectrum.buildGrid must be called before getShear") # Convert to numpy arrays for internal usage: pos_x, pos_y = utilities._convertPositions(pos, units, 'getShear') return self._getShear(pos_x, pos_y, reduced, periodic)
[docs] def _getShear(self, pos_x, pos_y, reduced=True, periodic=False): """Equivalent to `getShear`, but without some sanity checks and the positions must be given as ``pos_x``, ``pos_y`` in arcsec. Parameters: pos_x: x position in arcsec (either a scalar or a numpy array) pos_y: y position in arcsec (either a scalar or a numpy array) reduced: Whether returned shear(s) should be reduced shears. [default: True] periodic: Whether the interpolation should treat the positions as being defined with respect to a periodic grid. [default: False] Returns: the (possibly reduced) shears as a tuple (g1,g2) (either scalars or numpy arrays) """ g1_grid = self.im_g1.array g2_grid = self.im_g2.array if reduced: # get reduced shear (just discard magnification) g1_grid, g2_grid, _ = theoryToObserved(g1_grid, g2_grid, self.im_kappa.array) lut_g1 = LookupTable2D(self.x_grid, self.y_grid, g1_grid, edge_mode='wrap' if periodic else 'warn', interpolant=self.interpolant) lut_g2 = LookupTable2D(self.x_grid, self.y_grid, g2_grid, edge_mode='wrap' if periodic else 'warn', interpolant=self.interpolant) ret = lut_g1(pos_x, pos_y), lut_g2(pos_x, pos_y) return ret
[docs] def getConvergence(self, pos, units=arcsec, periodic=False): """ This function can interpolate between grid positions to find the convergence values for a given list of input positions (or just a single position). Before calling this function, you must call `buildGrid` first to define the grid of convergences on which to interpolate. The docstring for `buildGrid` provides some guidance on appropriate grid configurations to use when building a grid that is to be later interpolated to random positions. Note that the interpolation (specified when calling `buildGrid`) modifies the effective 2-point functions of these quantities. See docstring for `getShear` docstring for caveats about interpolation. The user is advised to be very careful about deviating from the default Lanczos-5 interpolant. The usage of getConvergence() is the same as for `getShear`, except that it returns only a single quantity (convergence value or array of convergence values) rather than two quantities. See documentation for `getShear` for some examples. Parameters: pos: Position(s) of the source(s), assumed to be post-lensing! Valid ways to input this: - single `Position` instance - tuple of floats: (x,y) - list or array of `Position` instances - tuple of lists/arrays: ( xlist, ylist ) units: The angular units used for the positions. [default: arcsec] periodic: Whether the interpolation should treat the positions as being defined with respect to a periodic grid, which will wrap them around if they are outside the bounds of the original grid on which shears and convergences were defined. If not, then convergences are set to zero for positions outside the original grid. [default: False] Returns: the convergence, kappa (either a scalar or a numpy array) If the input ``pos`` is given a single position, kappa is the convergence value. If the input ``pos`` is given a list/array of positions, kappa is a NumPy array. """ if not hasattr(self, 'im_kappa'): raise GalSimError("PowerSpectrum.buildGrid must be called before getConvergence") # Convert to numpy arrays for internal usage: pos_x, pos_y = utilities._convertPositions(pos, units, 'getConvergence') return self._getConvergence(pos_x, pos_y, periodic)
[docs] def _getConvergence(self, pos_x, pos_y, periodic=False): """Equivalent to `getConvergence`, but without some sanity checks and the positions must be given as ``pos_x``, ``pos_y`` in arcsec. Parameters: pos_x: x position in arcsec (either a scalar or a numpy array) pos_y: y position in arcsec (either a scalar or a numpy array) periodic: Whether the interpolation should treat the positions as being defined with respect to a periodic grid. [default: False] Returns: the convergence, kappa (either a scalar or a numpy array) """ kappa_grid = self.im_kappa.array lut_kappa = LookupTable2D(self.x_grid, self.y_grid, kappa_grid, edge_mode='wrap' if periodic else 'warn', interpolant=self.interpolant) return lut_kappa(pos_x, pos_y)
[docs] def getMagnification(self, pos, units=arcsec, periodic=False): """ This function can interpolate between grid positions to find the lensing magnification (mu) values for a given list of input positions (or just a single position). Before calling this function, you must call `buildGrid` first to define the grid of shears and convergences on which to interpolate. The docstring for `buildGrid` provides some guidance on appropriate grid configurations to use when building a grid that is to be later interpolated to random positions. Note that the interpolation (specified when calling `buildGrid`) modifies the effective 2-point functions of these quantities. See docstring for `getShear` docstring for caveats about interpolation. The user is advised to be very careful about deviating from the default Lanczos-5 interpolant. The usage of `getMagnification` is the same as for `getShear`, except that it returns only a single quantity (a magnification value or array of magnification values) rather than a pair of quantities. See documentation for `getShear` for some examples. Parameters: pos: Position(s) of the source(s), assumed to be post-lensing! Valid ways to input this: - single `Position` instance - tuple of floats: (x,y) - list/array of `Position` instances - tuple of lists/arrays: ( xlist, ylist ) units: The angular units used for the positions. [default: arcsec] periodic: Whether the interpolation should treat the positions as being defined with respect to a periodic grid, which will wrap them around if they are outside the bounds of the original grid on which shears and convergences were defined. If not, then magnification is set to 1 for positions outside the original grid. [default: False] Returns: the magnification, mu (either a scalar or a numpy array) If the input ``pos`` is given a single position, mu is the magnification value. If the input ``pos`` is given a list/array of positions, mu is a NumPy array. """ if not hasattr(self, 'im_kappa'): raise GalSimError("PowerSpectrum.buildGrid must be called before getMagnification") # Convert to numpy arrays for internal usage: pos_x, pos_y = utilities._convertPositions(pos, units, 'getMagnification') return self._getMagnification(pos_x, pos_y, periodic)
[docs] def _getMagnification(self, pos_x, pos_y, periodic=False): """Equivalent to `getMagnification`, but without some sanity checks and the positions must be given as ``pos_x``, ``pos_y`` in arcsec. Parameters: pos_x: x position in arcsec (either a scalar or a numpy array) pos_y: y position in arcsec (either a scalar or a numpy array) periodic: Whether the interpolation should treat the positions as being defined with respect to a periodic grid. [default: False] Returns: the magnification, mu (either a scalar or a numpy array) """ _, _, mu_grid = theoryToObserved(self.im_g1.array, self.im_g2.array, self.im_kappa.array) lut_mu = LookupTable2D(self.x_grid, self.y_grid, mu_grid - 1, edge_mode='wrap' if periodic else 'warn', interpolant=self.interpolant) return lut_mu(pos_x, pos_y) + 1
[docs] def getLensing(self, pos, units=arcsec, periodic=False): """ This function can interpolate between grid positions to find the lensing observable quantities (reduced shears g1 and g2, and magnification mu) for a given list of input positions (or just a single position). Before calling this function, you must call `buildGrid` first to define the grid of shears and convergences on which to interpolate. The docstring for `buildGrid` provides some guidance on appropriate grid configurations to use when building a grid that is to be later interpolated to random positions. Note that the interpolation (specified when calling `buildGrid`) modifies the effective 2-point functions of these quantities. See docstring for `getShear` docstring for caveats about interpolation. The user is advised to be very careful about deviating from the default Lanczos-5 interpolant. The usage of `getLensing` is the same as for `getShear`, except that it returns three quantities (two reduced shear components and magnification) rather than two. See documentation for `getShear` for some examples. Parameters: pos: Position(s) of the source(s), assumed to be post-lensing! Valid ways to input this: - single `Position` instance - tuple of floats: (x,y) - list/array of `Position` instances - tuple of lists/arrays: ( xlist, ylist ) units: The angular units used for the positions. [default: arcsec] periodic: Whether the interpolation should treat the positions as being defined with respect to a periodic grid, which will wrap them around if they are outside the bounds of the original grid on which shears and convergences were defined. If not, then shear is set to zero and magnification is set to 1 for positions outside the original grid. [default: False] Returns: shear and magnification as a tuple (g1,g2,mu). If the input ``pos`` is given a single position, the return values are the shear and magnification values at that position. If the input ``pos`` is given a list/array of positions, they are NumPy arrays. """ if not hasattr(self, 'im_kappa'): raise GalSimError("PowerSpectrum.buildGrid must be called before getLensing") # Convert to numpy arrays for internal usage: pos_x, pos_y = utilities._convertPositions(pos, units, 'getLensing') return self._getLensing(pos_x, pos_y, periodic)
[docs] def _getLensing(self, pos_x, pos_y, periodic=False): """Equivalent to `getLensing`, but without some sanity checks and the positions must be given as ``pos_x``, ``pos_y`` in arcsec. Parameters: pos_x: x position in arcsec (either a scalar or a numpy array) pos_y: y position in arcsec (either a scalar or a numpy array) periodic: Whether the interpolation should treat the positions as being defined with respect to a periodic grid. [default: False] Returns: the reduced shear and magnification as a tuple (g1,g2,mu) (either scalars or numpy arrays) """ g1_grid, g2_grid, mu_grid = theoryToObserved( self.im_g1.array, self.im_g2.array, self.im_kappa.array) lut_g1 = LookupTable2D(self.x_grid, self.y_grid, g1_grid, edge_mode='wrap' if periodic else 'warn', interpolant=self.interpolant) lut_g2 = LookupTable2D(self.x_grid, self.y_grid, g2_grid, edge_mode='wrap' if periodic else 'warn', interpolant=self.interpolant) lut_mu = LookupTable2D(self.x_grid, self.y_grid, mu_grid-1, edge_mode='wrap' if periodic else 'warn', interpolant=self.interpolant) return lut_g1(pos_x, pos_y), lut_g2(pos_x, pos_y), lut_mu(pos_x, pos_y)+1
[docs]class PowerSpectrumRealizer: """Class for generating realizations of power spectra with any area and pixel size. This class is not one that end-users should expect to interact with. It is designed to quickly generate many realizations of the same shear power spectra on a square grid. The initializer sets up the grids in k-space and computes the power on them. It also computes spin weighting terms. You can alter any of the setup properties later. It currently only works for square grids (at least, much of the internals would be incorrect for non-square grids), so while it nominally contains arrays that could be allowed to be non-square, the constructor itself enforces squareness. Parameters: ngrid: The size of the grid in one dimension. pixel_size: The size of the pixel sides, in units consistent with the units expected by the power spectrum functions. p_E: Equivalent to ``e_power_function`` in the documentation for the `PowerSpectrum` class. p_B: Equivalent to ``b_power_function`` in the documentation for the `PowerSpectrum` class. """ def __init__(self, ngrid, pixel_size, p_E, p_B): # Set up the k grids in x and y, and the instance variables self.set_size(ngrid, pixel_size) self.set_power(p_E, p_B) def __repr__(self): return "galsim.lensing_ps.PowerSpectrumRealizer(ngrid=%r, pixel_size=%r, p_E=%r, p_B=%r)"%( self.nx, self.pixel_size, self.p_E, self.p_B) def __str__(self): return "galsim.lensing_ps.PowerSpectrumRealizer(ngrid=%r, pixel_size=%r, p_E=%s, p_B=%s)"%( self.nx, self.pixel_size, self.p_E, self.p_B) def __eq__(self, other): return self is other or repr(self) == repr(other) def __ne__(self, other): return not self.__eq__(other) def __hash__(self): return hash(repr(self)) def set_size(self, ngrid, pixel_size): self.nx = ngrid self.ny = ngrid self.pixel_size = float(pixel_size) # Setup some handy slices for indexing different parts of k space self.ikx = slice(0,self.nx//2+1) # positive kx values, including 0, nx/2 self.ikxp = slice(1,(self.nx+1)//2) # limit to only values with a negative value self.ikxn = slice(-1,self.nx//2,-1) # negative kx values # We always call this with nx=ny, so behavior with nx != ny is not tested. # However, we make a basic attempt to enable such behavior in the future if needed. self.iky = slice(0,self.ny//2+1) self.ikyp = slice(1,(self.ny+1)//2) self.ikyn = slice(-1,self.ny//2,-1) # Set up the scalar k grid. Generally, for a box size of L (in one dimension), the grid # spacing in k_x or k_y is Delta k=2pi/L self.kx, self.ky = utilities.kxky((self.ny,self.nx)) self.kx /= self.pixel_size self.ky /= self.pixel_size # Compute the spin weightings self._generate_exp2ipsi() def set_power(self, p_E, p_B): self.p_E = p_E self.p_B = p_B if p_E is None: self.amplitude_E = None else: self.amplitude_E = np.sqrt(self._generate_power_array(p_E))/self.pixel_size if p_B is None: self.amplitude_B = None else: self.amplitude_B = np.sqrt(self._generate_power_array(p_B))/self.pixel_size
[docs] def __call__(self, gd, variance=None): """Generate a realization of the current power spectrum. Parameters: gd: A Gaussian deviate to use when generating the shear fields. variance: Optionally renormalize the variance of the output shears to a given value. This is useful if you know the functional form of the power spectrum you want, but not the normalization. This lets you set the normalization separately. The resulting shears should have var(g1) + var(g2) ~= variance. If only ``e_power_function`` is given, then this is also the variance of kappa. Otherwise, the variance of kappa may be smaller than the specified variance. [default: None] @return a tuple of NumPy arrays (g1,g2,kappa) for the shear and convergence. """ ISQRT2 = np.sqrt(1.0/2.0) if not isinstance(gd, GaussianDeviate): raise TypeError( "The gd provided to the PowerSpectrumRealizer is not a GaussianDeviate!") # Generate a random complex realization for the E-mode, if there is one if self.amplitude_E is not None: r1 = utilities.rand_arr(self.amplitude_E.shape, gd) r2 = utilities.rand_arr(self.amplitude_E.shape, gd) E_k = np.empty((self.ny,self.nx), dtype=complex) E_k[:,self.ikx] = self.amplitude_E * (r1 + 1j*r2) * ISQRT2 # E_k corresponds to real kappa, so E_k[-k] = conj(E_k[k]) self._make_hermitian(E_k) else: E_k = 0 # Generate a random complex realization for the B-mode, if there is one if self.amplitude_B is not None: r1 = utilities.rand_arr(self.amplitude_B.shape, gd) r2 = utilities.rand_arr(self.amplitude_B.shape, gd) B_k = np.empty((self.ny,self.nx), dtype=complex) B_k[:,self.ikx] = self.amplitude_B * (r1 + 1j*r2) * ISQRT2 # B_k corresponds to imag kappa, so B_k[-k] = -conj(B_k[k]) # However, we later multiply this by i, so that means here B_k[-k] = conj(B_k[k]) self._make_hermitian(B_k) else: B_k = 0 # In terms of kappa, the E mode is the real kappa, and the B mode is imaginary kappa: # In fourier space, both E_k and B_k are complex, but the same E + i B relation holds. kappa_k = E_k + 1j * B_k # Renormalize the variance if desired if variance is not None: current_var = np.sum(np.abs(kappa_k)**2) / (self.nx * self.ny) factor = np.sqrt(variance / current_var) kappa_k *= factor E_k *= factor # Need this for the k return value below. # Compute gamma_k as exp(2i psi) kappa_k # Equation 2.1.12 of Kaiser & Squires (1993, ApJ, 404, 441) is equivalent to: # gamma_k = -self.exp2ipsi * kappa_k # But of course, they only considered real (E-mode) kappa. # However, this equation has a sign error. There should not be a minus in front. # If you follow their subsequent deviation, you will see that they drop the minus sign # when they get to 2.1.15 (another - appears from the derivative). 2.1.15 is correct. # e.g. it correctly produces a positive point mass for tangential shear ~ 1/r^2. # So this implies that the minus sign in 2.1.12 should not be there. gamma_k = self.exp2ipsi * kappa_k # And go to real space to get the real-space shear and convergence fields. # Note the multiplication by N is needed because the np.fft.ifft2 implicitly includes a # 1/N^2, and for proper normalization we need a factor of 1/N. gamma = self.nx * np.fft.ifft2(gamma_k) # Make them contiguous, since we need to use them in an Image, which requires it. g1 = np.ascontiguousarray(np.real(gamma)) g2 = np.ascontiguousarray(np.imag(gamma)) # Could do the same thing with kappa.. #kappa = self.nx * np.fft.ifft2(kappa_k) #k = np.ascontiguousarray(np.real(kappa)) # But, since we don't care about imag(kappa), this is a bit faster: if np.all(E_k == 0): k = np.zeros((self.ny,self.nx)) else: k = self.nx * np.fft.irfft2(E_k[:,self.ikx], s=(self.ny,self.nx)) return g1, g2, k
def _make_hermitian(self, P_k): # Make P_k[-k] = conj(P_k[k]) # First update the kx=0 values to be consistent with this. P_k[self.ikyn,0] = np.conj(P_k[self.ikyp,0]) P_k[0,0] = np.real(P_k[0,0]) # Not reall necessary, since P_k[0,0] = 0, but # I do it anyway for the sake of pedantry... # Then fill the kx<0 values appropriately P_k[self.ikyp,self.ikxn] = np.conj(P_k[self.ikyn,self.ikxp]) P_k[self.ikyn,self.ikxn] = np.conj(P_k[self.ikyp,self.ikxp]) P_k[0,self.ikxn] = np.conj(P_k[0,self.ikxp]) # For even nx,ny, there are a few more changes needed. if self.ny % 2 == 0: # Note: this is a bit more complicated if you have to separately check whether # nx and/or ny are even. I ignore this subtlety until we decide it is needed. P_k[self.ikyn,self.nx//2] = np.conj(P_k[self.ikyp,self.nx//2]) P_k[self.ny//2,self.ikxn] = np.conj(P_k[self.ny//2,self.ikxp]) P_k[self.ny//2,0] = np.real(P_k[self.ny//2,0]) P_k[0,self.nx//2] = np.real(P_k[0,self.nx//2]) P_k[self.ny//2,self.nx//2] = np.real(P_k[self.ny//2,self.nx//2]) def _generate_power_array(self, power_function): # Internal function to generate the result of a power function evaluated on a grid, # taking into account the symmetries. power_array = np.empty((self.ny, self.nx//2+1)) # Set up the scalar |k| grid using just the positive kx,ky k = np.sqrt(self.kx[self.iky,self.ikx]**2 + self.ky[self.iky,self.ikx]**2) # Fudge the value at k=0, so we don't have to evaluate power there k[0,0] = k[1,0] P_k = np.empty_like(k) P_k[:,:] = power_function(k) # Now fix the k=0 value of power to zero P_k[0,0] = type(P_k[0,1])(0.) if np.any(P_k < 0): raise GalSimError("Negative power found for some values of k!") power_array[self.iky, self.ikx] = P_k power_array[self.ikyn, self.ikx] = P_k[self.ikyp, self.ikx] return power_array def _generate_exp2ipsi(self): # exp2ipsi = (kx + iky)^2 / |kx + iky|^2 is the phase of the k vector. kz = self.kx + self.ky*1j # exp(2i psi) = kz^2 / |kz|^2 ksq = kz*np.conj(kz) # Need to adjust denominator for kz=0 to avoid division by 0. ksq[0,0] = 1. self.exp2ipsi = kz*kz/ksq
# Note: this leaves exp2ipsi[0,0] = 0, but it turns out that's ok, since we only # ever multiply it by something that is 0 anyway (amplitude[0,0] = 0). def kappaKaiserSquires(g1, g2): """Perform a Kaiser & Squires (1993) inversion to get a convergence map from gridded shears. This function takes gridded shears and constructs a convergence map from them. While this is complicated in reality by the non-gridded galaxy positions, it is a straightforward implementation using Fourier transforms for the case of gridded galaxy positions. Note that there are additional complications when dealing with real observational issues like shape noise that are not handled by this function, and likewise there are known edge effects. Note that, like any process that attempts to recover information from discretely sampled data, the ``kappa_E`` and ``kappa_B`` maps returned by this function are subject to aliasing. There will be distortions if there are non-zero frequency modes in the lensing field represented by ``g1`` and ``g2`` at more than half the frequency represented by the ``g1``, ``g2`` grid spacing. To avoid this issue in practice you can smooth the input ``g1``, ``g2`` to effectively bandlimit them (the same smoothing kernel will be present in the output ``kappa_E``, ``kappa_B``). If applying this function to shears drawn randomly according to some power spectrum, the power spectrum that is used should be modified to go to zero above the relevant maximum k value for the grid being used. Parameters: g1: Square NumPy array containing the first component of shear. g2: Square NumPy array containing the second component of shear. Returns: the tuple (kappa_E, kappa_B), as NumPy arrays. The returned kappa_E represents the convergence field underlying the input shears. The returned kappa_B is the convergence field generated were all shears rotated by 45 degrees prior to input. """ # Checks on inputs if not (isinstance(g1, np.ndarray) and isinstance(g2, np.ndarray)): raise TypeError("Input g1 and g2 must be NumPy arrays.") if g1.shape != g2.shape: raise GalSimIncompatibleValuesError("Input g1 and g2 must be the same shape.", g1=g1, g2=g2) if g1.shape[0] != g1.shape[1]: raise GalSimNotImplementedError("Non-square input shear grids not supported.") # Then setup the kx, ky grids kx, ky = utilities.kxky(g1.shape) kz = kx + ky*1j # exp(2i psi) = kz^2 / |kz|^2 ksq = kz*np.conj(kz) # Need to adjust denominator for kz=0 to avoid division by 0. ksq[0,0] = 1. exp2ipsi = kz*kz/ksq # Build complex g = g1 + i g2 gz = g1 + g2*1j # Go to fourier space gz_k = np.fft.fft2(gz) # Equation 2.1.12 of Kaiser & Squires (1993) is equivalent to: # kz_k = -np.conj(exp2ipsi)*gz_k # However, this equation has a sign error. There should not be a minus in front. # If you follow their subsequent deviation, you will see that they drop the minus sign # when they get to 2.1.15 (another - appears from the derivative). 2.1.15 is correct. # e.g. it correctly produces a positive point mass for tangential shear ~ 1/r^2. # So this implies that the minus sign in 2.1.12 should not be there. kz_k = np.conj(exp2ipsi)*gz_k # Come back to real space kz = np.fft.ifft2(kz_k) # kz = kappa_E + i kappa_B kappaE = np.real(kz) kappaB = np.imag(kz) return kappaE, kappaB class xip_integrand: """Utility class to assist in calculating the xi_+ shear correlation function from power spectra.""" def __init__(self, pk, r): self.pk = pk self.r = r def __call__(self, k): return k * self.pk(k) * j0(self.r*k) class xim_integrand: """Utility class to assist in calculating the xi_- shear correlation function from power spectra.""" def __init__(self, pk, r): self.pk = pk self.r = r def __call__(self, k): return k * self.pk(k) * jn(4,self.r*k)