Source code for lezargus.simulator.target

"""Simulation code to simulate an astrophysical object or target.

This code simulates an astrophysical target, creating the theoretical cube
which represents it on sky.

We name this file "target.py" to prematurely avoid name conflicts with the
Python built-in "object".
"""

# isort: split
# Import required to remove circular dependencies from type checking.
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from lezargus.library import hint
# isort: split


import astropy.constants
import numpy as np

import lezargus
from lezargus.library import logging


[docs] class TargetSimulator: """Simulate an astrophysical target. This class is a bit of a wrapper, acting as an effective means of generating the simulation of an astrophysical field. However, this class nevertheless acts as a nice point of abstraction for the instruments simulators. Other property attributes exist but are not documented here as they have their own docstrings. In order to reduce the amount of repeat computing, every important computed property likely has a cache variant which stores the most recently calculated result as a cache and uses it instead of recomputing things. This is generally an internal application. """ target = None """LezargusCube : The target, represented as a LezargusCube. The exact properties of the cube are determined from how the simulator was created.""" target_spectrum = None """LezargusSpectrum : The spectrum of the target. This is only made if the target cube was made by extending a point source, making the cube from a Spectrum; otherwise, this is None.""" atmosphere = None """AtmosphereSimulator : The atmosphere simulator which describes and simulates atmospheric effects. If not provided by py:meth:`add_atmosphere`, this defaults to None.""" use_cache = True """bool : If True, we cache calculated values so that they do not need to be calculated every time when not needed. If False, caches are never returned and instead everything is always recomputed.""" # Cache objects. _cache_target_photon = None _cache_observed = None
[docs] def __init__( self: TargetSimulator, *args: hint.Any, _cube: hint.LezargusCube, **kwargs: hint.Any, ) -> None: """Create the target simulator. This class should not be called directly, but the helper class functions should be used instead to create the simulator. Internal options are provided and documented but their use is discouraged. Parameters ---------- *args : Any Arguments we catch. If there are any arguments, we give an error. _cube : hint.LezargusCube The main target cube. It should be the same as py:attr:`target`. **kwargs : Any Extra keyword arguments we catch. If there are any, we give an error. Returns ------- None """ if len(args) != 0 or len(kwargs) != 0: logging.error( error_type=logging.InputError, message=( "User arguments not accepted. TargetSimulator should be" " created from helper class methods." ), ) # The input cube is the same as the target cube. self.target = _cube
[docs] @classmethod def from_blackbody( cls: type[hint.Self], wavelength: hint.NDArray, temperature: float, magnitude: float, photometric_filter: ( hint.PhotometricABFilter | hint.PhotometricVegaFilter ), spatial_grid_shape: tuple, spatial_fov_shape: tuple, spectral_scale: float, **kwargs: hint.Any, ) -> hint.Self: """Create a target simulation object from a point source blackbody. This is a convenience wrapper around the :py:meth:`from_spectrum` function. Some required parameters for that function are needed and not otherwise described here. See the documentation for that function to properly use this wrapper. Parameters ---------- wavelength : ndarray The wavelength sampling that we will sample the black body at. temperature : float The temperature of the black body spectrum. magnitude : float The magnitude of the object in the photometric filter system provided. photometric_filter : PhotometricVegaFilter | PhotometricABFilter The photometric filter which we are using to scale the blackbody to match the magnitude provided; which is assumed to be in the correct photometric system. spatial_grid_shape : tuple The spatial pixel grid shape. This defines the array shape of the simulation's spatial component. The pixel and slice scale is calculated from this and the field of view. spatial_fov_shape : tuple The defined field of view shape. This defines the on-sky field of view shape of the array, and is in radians. The pixel and slice scale is calculated from this and the field of view. spectral_scale : float The spectral scale of the simulated spectra, as a resolution, in wavelength separation (in meters) per pixel. **kwargs : Any Additional keyword arguments passed to the py:meth:`from_spectrum` function which does the heavy lifting. Returns ------- target_instance : TargetSimulator The target simulator instance derived from the input parameters. """ # We construct the blackbody function. blackbody_function = lezargus.library.wrapper.blackbody_function( temperature=temperature, ) # Then we evaluate the blackbody function, of course the scale of which # will be wrong but it will be fixed. blackbody_flux = blackbody_function(wavelength) # We integrate over the solid angle. solid_angle = np.pi integrated_blackbody_flux = blackbody_flux * solid_angle # Packaging the spectra. The pixel scale and slice scales are handled # later. blackbody_spectra = lezargus.library.container.LezargusSpectrum( wavelength=wavelength, data=integrated_blackbody_flux, uncertainty=None, wavelength_unit="m", data_unit="W m^-2 m^-1", spectral_scale=spectral_scale, pixel_scale=None, slice_scale=None, mask=None, flags=None, header=None, ) # We scale the flux, applying a photometric correction for the # provided filter profile, zero point, and filter magnitude. # We do not really care about the error term. correction_factor, __ = ( photometric_filter.calculate_photometric_correction( spectrum=blackbody_spectra, magnitude=magnitude, magnitude_uncertainty=0, ) ) # Photometrically calibrating it. target_spectrum = blackbody_spectra * correction_factor # We pass it to the main function for us to create the actual target # from the spectrum. target_instance = cls.from_spectrum( spectrum=target_spectrum, spatial_grid_shape=spatial_grid_shape, spatial_fov_shape=spatial_fov_shape, **kwargs, ) return target_instance
[docs] @classmethod def from_spectrum( cls: type[hint.Self], spectrum: hint.LezargusSpectrum, spatial_grid_shape: tuple, spatial_fov_shape: tuple, location: tuple | str = "center", ) -> hint.Self: """Create a target simulation object from a point source spectrum. Parameters ---------- spectrum : LezargusSpectrum The point source spectrum which we will use as the target to make the target cube of. The spectrum should be an energy-based spectrum. spatial_grid_shape : tuple The spatial pixel grid shape. This defines the array shape of the simulation's spatial component. The pixel and slice scale is calculated from this and the field of view. spatial_fov_shape : tuple The defined field of view shape. This defines the on-sky field of view shape of the array, and is in radians The pixel and slice scale is calculated from this and the field of view. location : tuple or str, default = "center" Where the spectra, as a point source, be placed spatially. If a string, we compute the location from the instruction: - `center` : It is placed in the center, or close to it, rounded down, for even valued shapes. Returns ------- target_instance : TargetSimulator The target simulator instance derived from the input parameters. """ # We first check if we have a proper LezargusCube spectrum. if not isinstance( spectrum, lezargus.library.container.LezargusSpectrum, ): logging.error( error_type=logging.InputError, message=( f"Input cube is type {type(spectrum)}, not an expected" " LezargusSpectrum." ), ) # We calculate the pixel and slice scale from the provided grid. pixel_scale = spatial_fov_shape[0] / spatial_grid_shape[0] slice_scale = spatial_fov_shape[1] / spatial_grid_shape[1] spectrum.pixel_scale = pixel_scale spectrum.slice_scale = slice_scale # We assume that background space is dark, so a zero fill value. background_data = 0 background_uncertainty = 0 # From there, we can create a cube based on broadcasting the spectrum # into a cube. broadcast_cube = ( lezargus.library.container.functionality.broadcast_spectrum_to_cube( input_spectrum=spectrum, shape=spatial_grid_shape, location=location, fill_value=background_data, fill_uncertainty=background_uncertainty, ) ) # We pass it to the main function for us to create the actual target # from the derived cube. target_instance = cls.from_cube(cube=broadcast_cube) # We save the target spectrum in the event it is needed. target_instance.target_spectrum = spectrum return target_instance
[docs] @classmethod def from_cube( cls: type[hint.Self], cube: hint.LezargusCube, ) -> hint.Self: """Create a target simulation object from a provided cube. This function is just a formality. Usually if a cube has already been defined and provided, the cube itself is the data of the simulated target. Nevertheless, we allow for the specification of a target based on a cube to provide this common interface. Parameters ---------- cube : LezargusCube The target cube which define the object we are simulating. Returns ------- target_instance : TargetSimulator The target simulator instance derived from the input parameters. """ # We first check if we have a proper LezargusCube. if not isinstance(cube, lezargus.library.container.LezargusCube): logging.error( error_type=logging.InputError, message=( f"Input cube is type {type(cube)}, not an expected" " LezargusCube." ), ) # The cube provided is the same as the target cube. target_instance = cls(_cube=cube) return target_instance
[docs] def clear_cache(self: hint.Self) -> None: """Clear the cache of computed result objects. This function clears the cache of computed results, allowing for updated values to properly be utilized in future calculations and simulations. Parameters ---------- None Returns ------- None """ # We get all of the names of the cache attributes to then clear. cache_prefix = "_cache" self_attributes = dir(self) cache_attributes = [ keydex for keydex in self_attributes if keydex.startswith(cache_prefix) ] # Removing the cache values by removing their reference and then # setting them to None as the default. for keydex in cache_attributes: setattr(self, keydex, None)
# All done.
[docs] @staticmethod def _convert_to_photon( container: hint.LezargusContainerArithmetic, ) -> hint.LezargusContainerArithmetic: """Convert Lezargus spectral flux density to photon flux density. This function is a convenience function to convert the spectral flux density of any container to a photon flux density. Please note that the units may change in unexpected ways because of unit conversions related to the constants and unit decomposition. Parameters ---------- container : LezargusContainerArithmetic The container we are converting, or more accurately, a subclass of the container. Returns ------- photon_container : LezargusContainerArithmetic The converted container as a photon flux instead of an energy flux. However, please note that the units may change in unexpected ways. """ # It is easiest to work in SI units. si_wavelength_unit = "m" si_data_unit = "W m^-2 m^-1" si_container = container.to_unit( data_unit=si_data_unit, wavelength_unit=si_wavelength_unit, ) # We determine the energy of the photon at the provided wavelength. # The value is the only one needed as we are working in SI. photon_energy = (astropy.constants.h * astropy.constants.c) / ( si_container.wavelength * si_container.wavelength_unit ) photon_energy_value = photon_energy.value # We keep the "photon" unit implicit to avoid unit conversion # problems. photon_energy_unit = photon_energy.unit # Broadcasting it so we can apply it as a simple division. broadcast_conversion = np.broadcast_to( photon_energy_value, shape=si_container.data.shape, ) # Converting to a photon flux only requires us to do calculations # on the data and uncertainty. si_container.data = si_container.data / broadcast_conversion si_container.uncertainty = ( si_container.uncertainty / broadcast_conversion ) si_container.data_unit = si_container.data_unit / photon_energy_unit # As the actual "photon" unit was implicit this entire time, we add it # to be explicit. photon_unit = lezargus.library.conversion.parse_astropy_unit( unit_string="photon", ) si_container.data_unit = ( si_container.data_unit * photon_unit ).decompose() # Aliasing. photon_container = si_container # All done. return photon_container
@property def at_target_spectrum(self: hint.Self) -> hint.LezargusCube | None: """Alias for py:attr:`target_spectrum` to match naming convention. By self-imposed convention, the attributes are generally named as `at_[stage]` where the result is the simulated result right after simulating whichever stage is named. This may be a read-only alias, for for most cases, that is fine. Parameters ---------- None Returns ------- None """ return self.target_spectrum @property def at_target(self: hint.Self) -> hint.LezargusCube: """Alias for py:attr:`target` to match naming convention. By self-imposed convention, the attributes are generally named as `at_[stage]` where the result is the simulated result right after simulating whichever stage is named. This may be a read-only alias, for for most cases, that is fine. Parameters ---------- None Returns ------- target : LezargusCube The energy based flux simulation data cube of the target. """ return self.target @property def at_target_photon(self: hint.Self) -> hint.LezargusCube: """Target photon flux, calculated from target spectral energy density. Please note that the units may change in unexpected ways because of unit conversions related to the constants and unit decomposition. Parameters ---------- None Returns ------- target_photon : LezargusCube Exactly the same as the target, except the data (flux) is a photon flux and not an energy based flux. """ # We use a cached value if there exists one. if self._cache_target_photon is not None and self.use_cache: return self._cache_target_photon # No valid cache, computing it ourselves. # A simple conversion. target_photon = self._convert_to_photon(container=self.at_target) # Saving the result later in the cache. if self.use_cache: self._cache_target_photon = target_photon return target_photon
[docs] def add_atmosphere( self: hint.Self, atmosphere: hint.AtmosphereSimulator, ) -> None: """Add an atmosphere simulator to simulate the atmospheric effects. Note, we only allow one atmosphere at a time. Parameters ---------- atmosphere : AtmosphereSimulator The atmosphere simulator to add to this target simulator to allow it to simulate different effects of the atmosphere. Returns ------- None """ # We just make sure the atmosphere is of the proper type before # just adding it. if isinstance(atmosphere, lezargus.simulator.AtmosphereSimulator): self.atmosphere = atmosphere else: logging.error( error_type=logging.InputError, message=( f"Input atmosphere, type {type(atmosphere)}, is not an" " AtmosphereSimulator." ), ) self.atmosphere = None
# All done. @property def at_transmission(self: hint.Self) -> hint.LezargusCube: """State of simulation after atmospheric transmission. Parameters ---------- None Returns ------- current_state : LezargusCube The state of the simulation after applying the effects of atmospheric transmission. """ # No cached value, we calculate it from the previous state. previous_state = self.at_target_photon # We actually need an atmosphere specified to simulate the atmosphere. if not isinstance( self.atmosphere, lezargus.simulator.AtmosphereSimulator, ): logging.error( error_type=logging.WrongOrderError, message=( "No atmosphere applied, cannot apply transmission effects." ), ) return previous_state # Determining the atmospheric transmission function. We broadcast it # to a cube to apply it to the previous state. transmission_spectrum = self.atmosphere.generate_transmission( template=previous_state, ) transmission_cube = ( lezargus.library.container.functionality.broadcast_spectrum_to_cube( input_spectrum=transmission_spectrum, shape=previous_state.data.shape, location="full", fill_value=0, fill_uncertainty=0, ) ) # Applying the transmission via simple multiplication of the # efficiencies. current_state = previous_state * transmission_cube return current_state @property def at_radiance(self: hint.Self) -> hint.LezargusCube: """State of simulation after atmospheric radiance. Parameters ---------- None Returns ------- current_state : LezargusCube The state of the simulation after applying the effects of atmospheric radiance. """ # No cached value, we calculate it from the previous state. previous_state = self.at_transmission # We actually need an atmosphere specified to simulate the atmosphere. if not isinstance( self.atmosphere, lezargus.simulator.AtmosphereSimulator, ): logging.error( error_type=logging.WrongOrderError, message="No atmosphere applied, cannot apply radiance effects.", ) return previous_state # Determining the atmospheric transmission function. We broadcast it # to a cube to apply it to the previous state. radiance_spectrum = self.atmosphere.generate_radiance( template=previous_state, ) radiance_cube = ( lezargus.library.container.functionality.broadcast_spectrum_to_cube( input_spectrum=radiance_spectrum, shape=previous_state.data.shape, location="full", fill_value=0, fill_uncertainty=0, ) ) # We integrate the radiance to provide a proper photon spectral # irradiance which can be added. solid_angle = np.pi solid_angle_unit = lezargus.library.conversion.parse_astropy_unit("sr") irradiance_cube = radiance_cube * solid_angle irradiance_cube.data_unit = radiance_cube.data_unit * solid_angle_unit # The radiance provided by the atmosphere is in energy units, while # we are working in photon units. irradiance_photon_cube = self._convert_to_photon( container=irradiance_cube, ) # The data units ought to be the same, else adding them together # becomes problematic. if irradiance_photon_cube.data_unit != previous_state.data_unit: logging.error( error_type=logging.DevelopmentError, message=( "Irradiance photon cube unit" f" {irradiance_photon_cube.data_unit} not the same as the" f" previous state {previous_state.data_unit}." ), ) # Applying the transmission via simple multiplication of the # efficiencies. current_state = previous_state + irradiance_photon_cube return current_state @property def at_seeing(self: hint.Self) -> hint.LezargusCube: """State of simulation after atmospheric seeing. Parameters ---------- None Returns ------- current_state : LezargusCube The state of the simulation after applying the effects of atmospheric seeing. """ # No cached value, we calculate it from the previous state. previous_state = self.at_radiance # We actually need an atmosphere specified to simulate the atmosphere. if not isinstance( self.atmosphere, lezargus.simulator.AtmosphereSimulator, ): logging.error( error_type=logging.WrongOrderError, message="No atmosphere applied, cannot apply radiance effects.", ) return previous_state # We use the atmosphere to derive our atmospheric seeing kernels. # There are multiple kernels because seeing changes with wavelength. seeing_kernels = self.atmosphere.generate_seeing_kernels( template=previous_state, ) # Applying the seeing effects via convolution. current_state = previous_state.convolve_image( kernel_stack=seeing_kernels, ) return current_state @property def at_refraction(self: hint.Self) -> hint.LezargusCube: """State of simulation after atmospheric refraction. Parameters ---------- None Returns ------- current_state : LezargusCube The state of the simulation after applying the effects of atmospheric refraction. """ # No cached value, we calculate it from the previous state. previous_state = self.at_seeing # We actually need an atmosphere specified to simulate the atmosphere. if not isinstance( self.atmosphere, lezargus.simulator.AtmosphereSimulator, ): logging.error( error_type=logging.WrongOrderError, message=( "No atmosphere applied, cannot apply refraction effects." ), ) return previous_state # We use the atmosphere to derive our refraction vectors. We # rearrange the data to break the vectors into their components. refraction_vectors = self.atmosphere.generate_refraction_vectors( template=previous_state, ) refraction_x = refraction_vectors[:, 0] refraction_y = refraction_vectors[:, 1] # Applying the refraction, modeling it as a shear transformation # along the spectral axis (parallel to the spatial axes). # We assume that it is only just more "uniform" sky everywhere else. # (We split up the function just for line length.) _functionality = lezargus.library.container.functionality current_state = _functionality.transform_shear_cube_spectral( cube=previous_state, x_shifts=refraction_x, y_shifts=refraction_y, mode="nearest", constant=np.nan, ) return current_state @property def at_observed(self: hint.Self) -> hint.LezargusCube: """State of simulation after an "observation". This object is basically the preferred alias for referring to the simulation at the point right after atmospheric effects. This is where the a target simulation ends and further simulation is done by specific instrument simulators. Parameters ---------- None Returns ------- current_state : LezargusCube The state of the simulation after an "observation". """ # We use a cached value if there exists one. if self._cache_observed is not None and self.use_cache: return self._cache_observed # No cached value, we calculate it from the previous state. previous_state = self.at_refraction # This is just an alias so the current state is the same. current_state = previous_state # Saving the result later in the cache. if self.use_cache: self._cache_observed = current_state return current_state