Source code for hsr4hci.forward_modeling

"""
Methods for forward modeling (= simulating a pure planet signal).
"""

# -----------------------------------------------------------------------------
# IMPORTS
# -----------------------------------------------------------------------------

from typing import List, Tuple, Union, overload
from typing_extensions import Literal

from astropy.units import Quantity
from scipy.interpolate import RegularGridInterpolator

import numpy as np

from hsr4hci.coordinates import cartesian2polar, get_center
from hsr4hci.general import (
    crop_or_pad,
    rotate_position,
    shift_image,
)
from hsr4hci.utils import (
    check_consistent_size,
    check_frame_size,
    check_cartesian_position,
)


# -----------------------------------------------------------------------------
# FUNCTION DEFINITIONS
# -----------------------------------------------------------------------------

@overload
def add_fake_planet(
    stack: np.ndarray,
    parang: np.ndarray,
    psf_template: np.ndarray,
    polar_position: Tuple[Quantity, Quantity],
    magnitude: float,
    extra_scaling: float,
    dit_stack: float,
    dit_psf_template: float,
    return_planet_positions: Literal[True],
    interpolation: str = 'bilinear',
) -> Tuple[np.ndarray, List[Tuple[float, float]]]:
    ...  # pragma: no cover


@overload
def add_fake_planet(
    stack: np.ndarray,
    parang: np.ndarray,
    psf_template: np.ndarray,
    polar_position: Tuple[Quantity, Quantity],
    magnitude: float,
    extra_scaling: float,
    dit_stack: float,
    dit_psf_template: float,
    return_planet_positions: Literal[False],
    interpolation: str = 'bilinear',
) -> np.ndarray:
    ...  # pragma: no cover


[docs]def add_fake_planet( stack: np.ndarray, parang: np.ndarray, psf_template: np.ndarray, polar_position: Tuple[Quantity, Quantity], magnitude: float, extra_scaling: float, dit_stack: float, dit_psf_template: float, return_planet_positions: bool = False, interpolation: str = 'bilinear', ) -> Union[np.ndarray, Tuple[np.ndarray, List[Tuple[float, float]]]]: """ Add a fake planet to the given ``stack`` which, when derotating and merging the stack, will show up at the given ``position``. This function can also be used to *remove* planets from a stack by setting the ``psf_scaling`` to a negative number. If you simply want to use this function to generate a fake signal stack, set ``stack`` to all zeros, the ``magnitude`` to zero, both the ``dit_stack`` and ``dit_psf_template`` to 1 (or any other non-zero number), and use the `extra_scaling` factor to linearly control the "brightness" of the injected planet. This function is essentially a simplified port of the corresponding PynPoint function :py:func:`pynpoint.util.analysis.fake_planet()`. Args: stack: A 3D numpy array of shape `(n_frames, width, height)` which contains the stack of images / frames into which we want to inject a fake planet. parang: A 1D numpy array of shape `(n_frames,)` that contains the respective parallactic angle for every frame in `stack`. psf_template: A 2D numpy array that contains the (centered) PSF template which will be used for the fake planet. This should *not* be normalized to `(0, 1]` if we want to work with actual astrophysical magnitudes for the contrast. polar_position: A tuple `(separation, angle)` which specifies the position at which the planet will show up after de-rotating with ``parang``. ``separation`` needs to be a ``Quantity`` that can be converted to pixel; `angle` needs to be a ``Quantity`` that can be converted to radian. Additionally, ``angle`` should be using *astronomical* polar coordinates, that is, 0 degrees will be "up" (= North), not "right". This function will internally add 90ยฐ to the angles to convert them to mathematical pilar coordinates. magnitude: The magnitude difference used to scale the PSF. Note: This is the contrast ratio in *magnitudes*, meaning that increasing this value by a factor of 5 will result in a planet that is 100 times brighter. In case you want to keep things linear, set this value to 0 and only use the ``psf_scaling`` parameter. extra_scaling: An additional scaling factor that is used for the PSF template. This number is simply multiplied with the PSF template, meaning that it changes the brightness linearly, not on a logarithmic scale. For example, you could use `-1` to add a *negative* planet to remove an actual planet in the data. This can also be used to incorporate an additional dimming factor due to a neutral density (ND) filter. dit_stack: The detector integration time of the frames in the ``stack`` (in seconds). Necessary to compute the correct scaling factor for the planet that we inject. dit_psf_template: The detector integration time of the ``psf_template`` (in seconds). Necessary to compute the correct scaling factor for the planet that we inject. return_planet_positions: Whether to return the (Cartesian) positions at which the fake planet was injected, as a 2D numpy array of shape `(n_frames, 2)`. interpolation: ``interpolation`` argument that is passed to :py:func:`scipy.ndimage.shift` that is used internally. Returns: A 3D numpy array of shape `(n_frames, width, height)` which contains the original ``stack`` into which a fake planet has been injected, as well as a list of tuples `(x, y)` that, for each frame, contain the position at which the fake planet has been added. If desired (i.e., if ``return_planet_positions`` is ``True``), the function also returns a 2D numpy array of shape `(n_frames, 2)` containing the Cartesian positions at which the fake planet has been injected. """ # Make sure that the stack and the parallactic angles are compatible check_consistent_size(stack, parang) # Define shortcut for the number of frames and the frame_size n_frames, frame_size = stack.shape[0], (stack.shape[1], stack.shape[2]) # Split the target planet position into separation and angles, convert # the quantities to pixels / convert to mathematical polar coordinates rho = polar_position[0].to('pixel').value phi = np.radians(polar_position[1].to('degree').value + 90 - parang) # Convert `magnitude` from logarithmic contrast to linear flux ratio flux_ratio = 10.0 ** (-magnitude / 2.5) # Compute scaling factor that is due to the different integration times # for the science images and the PSF template dit_scaling = dit_stack / dit_psf_template # Combine all scaling factors and scale the PSF template scaling_factor = flux_ratio * dit_scaling * extra_scaling psf_scaled = scaling_factor * np.copy(psf_template) # Make sure that the PSF has a compatible shape, that is, either crop or # pad the PSF template to the same spatial shape as the `stack`. psf_scaled = crop_or_pad(psf_scaled, frame_size) # Compute the shift for each frame x_shift = rho * np.cos(phi) y_shift = rho * np.sin(phi) # Initialize the "pure signal" stack (can use empty() here, because all # values will be overwritten and allocation should be slightly faster) signal_stack = np.empty_like(stack) # For each frame, move the scaled PSF template to the correct position # Note: We use mode='constant' instead of 'reflect' here (unlike PynPoint) # because the latter just does not seem to make a lot of sense? for i in range(n_frames): signal_stack[i] = shift_image( image=psf_scaled, offset=(float(x_shift[i]), float(y_shift[i])), interpolation=interpolation, mode='constant', ) # Add the planet stack to the original input stack output_stack = stack + signal_stack # Either return only the output stack, or the output stack and # the planet positions if return_planet_positions: center = get_center(frame_size) planet_positions = np.column_stack( (x_shift + center[0], y_shift + center[1]) ) return np.asarray(output_stack), planet_positions # type: ignore return np.asarray(output_stack)
[docs]def get_time_series_for_position( position: Tuple[float, float], signal_time: int, frame_size: Tuple[int, int], parang: np.ndarray, psf_template: np.ndarray, interpolation: str = 'bilinear', ) -> np.ndarray: """ Compute the expected signal time series for a pixel at ``position`` under the assumption that the planet signal is centered on this pixel at the given ``signal_time``. If we are only interested in a single such time series, using this function will be *dramatically* faster than computing the full stack using :func:`get_signal_stack()` and selecting the position of interest. The idea behind this function is that we can get the time series of interest (or a reasonably good approximation of it) by creating a single frame of all zeros, into which we place the PSF template at the target ``position``, and then sample this array along the implied path (determined by the fact that the signal is supposed to be at ``position`` at the ``signal_time``) of the planet. This avoids the computationally expensive generation of the full stack (of which only a single spatial pixel would be used). The exact speed-up of this version depends on the number of frames, but is typically on the order of a factor of $10^2$ to $10^3$. .. note: This function uses the *numpy convention* for `position`! Args: position: A tuple `(x, y)` for which we want to compute the time series under a given planet path hypothesis. signal_time: An integer specifying the time (= the frame number) at which the signal is to be assumed to be centered on the pixel at the given `position`. frame_size: A tuple `(x_size, y_size)` giving the spatial size of the stack. parang: A numpy array containing the parallactic angle for every frame. psf_template: A 2D numpy array containing the unsaturated PSF template. interpolation: ``interpolation`` parameter that is passed to the :func:`hsr4hci.general.shift_image` function. Default is `"bilinear"`. Returns: The time series for ``position`` computed under the hypothesis for the planet movement explained above. """ # Run basic sanity checks check_frame_size(frame_size) check_cartesian_position(position) # Compute center of the frame center = get_center(frame_size=frame_size) # Make sure that the PSF has a compatible shape, that is, either crop or # pad the PSF template to the same shape as the `stack`. psf_cropped = np.copy(psf_template) psf_cropped = crop_or_pad(psf_cropped, frame_size) # Create array where we place the PSF template at the target `position` array = shift_image( image=psf_cropped, offset=(position[0] - center[0], position[1] - center[1]), interpolation=interpolation, ) # Find the starting position of the planet under the hypothesis given by # `position` and `signal_time` starting_position = rotate_position( position=position, angle=-parang[signal_time], center=center ) # Compute the full array of all planet positions (at all times) planet_positions = np.asarray( rotate_position( position=starting_position, angle=parang, center=center, ) ).T # Create an interpolator for the array that allows us to evaluate it also # at non-integer positions. This function uses (bi)-linear interpolation. x_range = np.arange(frame_size[0]) y_range = np.arange(frame_size[1]) interpolator = RegularGridInterpolator((x_range, y_range), array) # The target time series is given by (interpolated) array values at the # positions along the planet path. Note that we need to flip the order of # the planet positions because we are basically accessing a numpy array. time_series = np.array(interpolator(planet_positions[:, ::-1])) # Make sure that the time series is normalized to a maximum of 1 time_series /= np.nanmax(time_series) return time_series
[docs]def get_time_series_for_position__full_stack( position: Tuple[float, float], signal_time: int, frame_size: Tuple[int, int], parang: np.ndarray, psf_template: np.ndarray, interpolation: str = 'spline', ) -> np.ndarray: """ This function does conceptually exactly the same as :func:`get_time_series_for_position()`, but it does not use our trick to speed up the computation; instead, the *full* signal stack is generated. .. caution:: This function should pretty only ever be used to verify the correctness of :func:`get_time_series_for_position()`, as it will probably be too slow for most practical applications. """ # Run basic sanity checks check_frame_size(frame_size) check_cartesian_position(position) # Compute center of the frame n_frames = len(parang) center = get_center(frame_size=frame_size) # Make sure that the PSF has a compatible shape, that is, either crop or # pad the PSF template to the same shape as the `stack`. psf_cropped = np.copy(psf_template) psf_cropped = crop_or_pad(psf_cropped, frame_size) # Given that the hypothesis that the planet is at `position` at the given # `signal_time`, compute the planet position in the final images final_position_cartesian = rotate_position( position=position, angle=parang[signal_time], center=center ) final_position_polar = cartesian2polar( position=(final_position_cartesian[0], final_position_cartesian[1]), frame_size=frame_size, ) # Compute the full signal stack signal_stack = add_fake_planet( stack=np.zeros((n_frames,) + frame_size), parang=parang, psf_template=psf_cropped, polar_position=final_position_polar, magnitude=0, extra_scaling=1, dit_stack=1, dit_psf_template=1, return_planet_positions=False, interpolation=interpolation, ) # Create an interpolator for the array that allows us to evaluate it also # at non-integer positions. This function uses (bi)-linear interpolation. t_range = np.arange(n_frames) x_range = np.arange(frame_size[0]) y_range = np.arange(frame_size[1]) interpolator = RegularGridInterpolator( points=(t_range, x_range, y_range), values=signal_stack ) # Get the stack values for the given target position using the interpolator # Note that we need to flip the order of position because we are basically # accessing a numpy array here. dummy = np.array([[t, position[1], position[0]] for t in t_range]) time_series = np.array(interpolator(dummy)) # Make sure that the time series is normalized to a maximum of 1 time_series /= np.nanmax(time_series) return time_series