Module pyaurorax.tools.ccd_contour
Obtain contours in pixel coordinates from a skymap for plotting over CCD images.
Expand source code
# Copyright 2024 University of Calgary
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Obtain contours in pixel coordinates from a skymap for plotting over CCD images.
"""
from ._elevation import elevation
from ._azimuth import azimuth
from ._geo import geo
from ._mag import mag
__all__ = ["elevation", "azimuth", "geo", "mag"]
Functions
def azimuth(skymap: pyucalgarysrs.data.classes.Skymap, constant_azimuth: Union[float, int], min_elevation: Union[int, float, ForwardRef(None)] = None, max_elevation: Union[int, float, ForwardRef(None)] = None, n_points: Optional[int] = None, remove_edge_cases: bool = True) ‑> Tuple[numpy.ndarray, numpy.ndarray]
-
Obtain CCD Coordinates of a line of constant latitude.
Args
skymap
:Skymap
- The skymap corresponding to the CCD image data to generate contours for.
constant_elevation (int | float): The elevation angle, in degrees from the horizon, to create contour of.
min_elevation (int | float): Optionally specify the elevation angle at which contour begins. Defaults to 5.
min_elevation (int | float): Optionally specify the elevation angle at which contour begins. Defaults to 90.
n_points (int | float): Optionally specify the number of points used to define a contour. By default a reasonable value is selected automatically.
remove_edge_cases (bool): Due to the nature of skymaps, often, around the edge of CCD data, contours will have often undesired behaviour due to being bounded within the CCD range. The result is flattened contours along the edge of CCD boundaries. This is completely expected, and these points are removed by default, completely for aesthetic choices. Set this keyword to False to keep all points in the contour.
Returns
A tuple (x_pix, y_pix) of numpy arrays containing the coordinates, in pixel units, of the azimuth contour.
Raises
ValueError
- invalid azimuth supplied.
Expand source code
def azimuth(skymap: Skymap, constant_azimuth: Union[int, float], min_elevation: Optional[Union[int, float]] = None, max_elevation: Optional[Union[int, float]] = None, n_points: Optional[int] = None, remove_edge_cases: bool = True) -> Tuple[np.ndarray, np.ndarray]: """ Obtain CCD Coordinates of a line of constant latitude. Args: skymap (pyaurorax.data.ucalgary.Skymap): The skymap corresponding to the CCD image data to generate contours for. constant_elevation (int | float): The elevation angle, in degrees from the horizon, to create contour of. min_elevation (int | float): Optionally specify the elevation angle at which contour begins. Defaults to 5. min_elevation (int | float): Optionally specify the elevation angle at which contour begins. Defaults to 90. n_points (int | float): Optionally specify the number of points used to define a contour. By default a reasonable value is selected automatically. remove_edge_cases (bool): Due to the nature of skymaps, often, around the edge of CCD data, contours will have often undesired behaviour due to being bounded within the CCD range. The result is flattened contours along the edge of CCD boundaries. This is completely expected, and these points are removed by default, completely for aesthetic choices. Set this keyword to False to keep all points in the contour. Returns: A tuple (x_pix, y_pix) of numpy arrays containing the coordinates, in pixel units, of the azimuth contour. Raises: ValueError: invalid azimuth supplied. """ # First check that azimuth is valid if constant_azimuth < 0 or constant_azimuth > 360: raise ValueError(f"Azimuth of {constant_azimuth} is outside of valid range (0,360).") # Check that min/max elevations are valid if supplied if (min_elevation is not None): if (min_elevation < 0) or (min_elevation > 90): raise ValueError(f"Min_elevation of {min_elevation} is outside of valid range (0,90).") if (max_elevation is not None): if (max_elevation < 0) or (max_elevation > 90): raise ValueError(f"Minimum elevation of {min_elevation} is outside of valid range (0,90).") if (min_elevation is not None) and (max_elevation is not None): if (min_elevation >= max_elevation): raise ValueError(f"Min_elevation of {min_elevation} is outside of valid range (0,90).") # Pull azimuth and elevation arrays from skymap azimuth = skymap.full_azimuth elevation = skymap.full_elevation # 360 degrees is just zero in skymap if constant_azimuth == 360: constant_azimuth = 0 # Take a guess at a good number of points if None is supplied if n_points is None: step = 1 else: step = 90 / n_points # Set bounds of elevation based on input if min_elevation is None: min_elevation = 5 if max_elevation is None: max_elevation = 90 # Iterate through elevation array in steps x_list = [] y_list = [] for el_min in np.arange(min_elevation, max_elevation + 1, step): # Get indices of this elevation slice el_max = el_min + step el_slice_idx = np.logical_and(elevation >= el_min, elevation < el_max) # Get index of pixel nearest to target azimuth masked_azimuth = np.where(el_slice_idx, azimuth, np.nan) diffs = np.abs(masked_azimuth - constant_azimuth) y, x = np.where(diffs == np.nanmin(diffs)) if x.shape == (0, ) or y.shape == (0, ): continue # Add to master lists x_list.append(x[0]) y_list.append(y[0]) if remove_edge_cases: # Remove any points lying on the edge of CCD bounds and return x_list = np.array(x_list) y_list = np.array(y_list) edge_case_idx = np.where(np.logical_and.reduce([x_list > 0, x_list < elevation.shape[1] - 1, y_list > 0, y_list < elevation.shape[0] - 1])) x_list = x_list[edge_case_idx] y_list = y_list[edge_case_idx] return (x_list, y_list) else: # Convert to arrays, return return (np.array(x_list), np.array(y_list))
def elevation(skymap: pyucalgarysrs.data.classes.Skymap, constant_elevation: Union[float, int], n_points: Optional[int] = None, remove_edge_cases: bool = True) ‑> Tuple[numpy.ndarray, numpy.ndarray]
-
Obtain CCD Coordinates of a line of constant elevation.
Args
skymap
:Skymap
- The skymap corresponding to the CCD image data to generate contours for.
constant_elevation (int | float): The elevation angle, in degrees from the horizon, to create contour of.
n_points (int | float): Optionally specify the number of points used to define a contour. By default a reasonable value is selected automatically.
remove_edge_cases (bool): Due to the nature of skymaps, often, around the edge of CCD data, contours will have often undesired behaviour due to being bounded within the CCD range. The result is flattened contours along the edge of CCD boundaries. This is completely expected, and these points are removed by default, completely for aesthetic choices. Set this keyword to False to keep all points in the contour.
Returns
A tuple (x_pix, y_pix) of numpy arrays containing the coordinates, in pixel units, of the elevation contour.
Raises
ValueError
- invalid elevation supplied.
Expand source code
def elevation(skymap: Skymap, constant_elevation: Union[int, float], n_points: Optional[int] = None, remove_edge_cases: bool = True) -> Tuple[np.ndarray, np.ndarray]: """ Obtain CCD Coordinates of a line of constant elevation. Args: skymap (pyaurorax.data.ucalgary.Skymap): The skymap corresponding to the CCD image data to generate contours for. constant_elevation (int | float): The elevation angle, in degrees from the horizon, to create contour of. n_points (int | float): Optionally specify the number of points used to define a contour. By default a reasonable value is selected automatically. remove_edge_cases (bool): Due to the nature of skymaps, often, around the edge of CCD data, contours will have often undesired behaviour due to being bounded within the CCD range. The result is flattened contours along the edge of CCD boundaries. This is completely expected, and these points are removed by default, completely for aesthetic choices. Set this keyword to False to keep all points in the contour. Returns: A tuple (x_pix, y_pix) of numpy arrays containing the coordinates, in pixel units, of the elevation contour. Raises: ValueError: invalid elevation supplied. """ # First check that elevation is valid if constant_elevation < 0 or constant_elevation > 90: raise ValueError(f"Elevation of {constant_elevation} is outside of valid range (0,90).") # Pull azimuth and elevation arrays from skymap azimuth = skymap.full_azimuth elevation = skymap.full_elevation # In the case that the user requests 90 degrees, return the single closest pixel if constant_elevation == 90: diffs = np.abs(elevation - constant_elevation) y, x = np.where(diffs == np.nanmin(diffs)) return (np.array(x[0]), np.array(y[0])) # Take a guess at a good number of points if None is supplied if n_points is None: if constant_elevation < 30: step = 1 * (257 / float(elevation.shape[0])) elif constant_elevation >= 30 and constant_elevation < 60: step = 3 * (257 / float(elevation.shape[0])) elif constant_elevation >= 60 and constant_elevation < 85: step = 10 * (257 / float(elevation.shape[0])) else: step = 30 else: step = 360 / n_points # Iterate through azimuth array in steps x_list = [] y_list = [] for az_min in np.arange(0, 360, step): # Get indices of this azimuth slice az_max = az_min + step az_slice_idx = np.logical_and(azimuth >= az_min, azimuth < az_max) # Get index of pixel nearest to target elevation masked_elevation = np.where(az_slice_idx, elevation, np.nan) diffs = np.abs(masked_elevation - constant_elevation) y, x = np.where(diffs == np.nanmin(diffs)) if x.shape == (0, ) or y.shape == (0, ): continue # Add to master lists x_list.append(x[0]) y_list.append(y[0]) # Close contour x_list.append(x_list[0]) y_list.append(y_list[0]) if remove_edge_cases: # Remove any points lying on the edge of CCD bounds and return x_list = np.array(x_list) y_list = np.array(y_list) edge_case_idx = np.where(np.logical_and.reduce([x_list > 0, x_list < elevation.shape[1] - 0, y_list > 1, y_list < elevation.shape[0] - 1])) x_list = x_list[edge_case_idx] y_list = y_list[edge_case_idx] return (x_list, y_list) else: # Convert to arrays, return return (np.array(x_list), np.array(y_list))
def geo(skymap: pyucalgarysrs.data.classes.Skymap, altitude_km: Union[float, int], contour_lats: Union[numpy.ndarray, list, ForwardRef(None)] = None, contour_lons: Union[numpy.ndarray, list, ForwardRef(None)] = None, constant_lat: Union[int, float, ForwardRef(None)] = None, constant_lon: Union[int, float, ForwardRef(None)] = None, n_points: Optional[int] = None, remove_edge_cases: bool = True) ‑> Tuple[numpy.ndarray, numpy.ndarray]
-
Obtain CCD Coordinates of a line of constant geographic latitude, constant geographic longitude, or a custom contour defined in geographic coordinates.
Args
skymap
:Skymap
- The skymap corresponding to the CCD image data to generate contours for.
altitude_km
:int
orfloat
- The altitude of the image data to create contours for, in kilometers.
lats (ndarray or list): Sequence of geographic latitudes defining a contour.
lons (ndarray or list): Sequence of geographic longitudes defining a contour.
constant_lats (float or int): Geographic Latitude at which to create line of constant latitude.
constant_lons (float or int): Geographic Longitude at which to create line of constant longitude.
n_points (int or float): Optionally specify the number of points used to define a contour. By default a reasonable value is selected automatically.
remove_edge_cases (bool): Due to the nature of skymaps, often, around the edge of CCD data, contours will have often undesired behaviour due to being bounded within the CCD range. The result is flattened contours along the edge of CCD boundaries. This is completely expected, and these points are removed by default, completely for aesthetic choices. Set this keyword to False to keep all points in the contour.
Returns
A tuple (x_pix, y_pix) of numpy arrays containing the coordinates, in pixel units, of the elevation contour.
Raises
ValueError
- invalid elevation supplied.
Expand source code
def geo(skymap: Skymap, altitude_km: Union[int, float], contour_lats: Optional[Union[np.ndarray, list]] = None, contour_lons: Optional[Union[np.ndarray, list]] = None, constant_lat: Optional[Union[float, int]] = None, constant_lon: Optional[Union[float, int]] = None, n_points: Optional[int] = None, remove_edge_cases: bool = True) -> Tuple[np.ndarray, np.ndarray]: """ Obtain CCD Coordinates of a line of constant geographic latitude, constant geographic longitude, or a custom contour defined in geographic coordinates. Args: skymap (pyaurorax.data.ucalgary.Skymap): The skymap corresponding to the CCD image data to generate contours for. altitude_km (int or float): The altitude of the image data to create contours for, in kilometers. lats (ndarray or list): Sequence of geographic latitudes defining a contour. lons (ndarray or list): Sequence of geographic longitudes defining a contour. constant_lats (float or int): Geographic Latitude at which to create line of constant latitude. constant_lons (float or int): Geographic Longitude at which to create line of constant longitude. n_points (int or float): Optionally specify the number of points used to define a contour. By default a reasonable value is selected automatically. remove_edge_cases (bool): Due to the nature of skymaps, often, around the edge of CCD data, contours will have often undesired behaviour due to being bounded within the CCD range. The result is flattened contours along the edge of CCD boundaries. This is completely expected, and these points are removed by default, completely for aesthetic choices. Set this keyword to False to keep all points in the contour. Returns: A tuple (x_pix, y_pix) of numpy arrays containing the coordinates, in pixel units, of the elevation contour. Raises: ValueError: invalid elevation supplied. """ # Check that multiple contours are not defined based on arguments passed if (contour_lats is None) != (contour_lons is None): raise ValueError("When defining a custom contour, both 'contour_lats' and 'contour_lons' must be supplied.") if sum([((contour_lats is not None) and (contour_lons is not None)), (constant_lat is not None), (constant_lon is not None)]) > 1: raise ValueError( "Only one contour can be defined per call: Pass only one of 'contour_lats & contour_lons', 'constant_lat', or 'constant_lon'.") if sum([((contour_lats is not None) and (contour_lons is not None)), (constant_lat is not None), (constant_lon is not None)]) == 0: raise ValueError("No contour defined in input: Pass one of 'contour_lats & contour_lons', 'constant_lat', or 'constant_lon'.") # Obtain lat/lon arrays from skymap at desired altitude if (altitude_km * 1000.0 in skymap.full_map_altitude): altitude_idx = np.where(altitude_km * 1000.0 == skymap.full_map_altitude) lats = np.squeeze(skymap.full_map_latitude[altitude_idx, :, :]) lons = np.squeeze(skymap.full_map_longitude[altitude_idx, :, :]) lons[np.where(lons > 180)] -= 360.0 # Fix skymap to be in (-180,180) format else: # Make sure altitude is in range that can be interpolated if (altitude_km * 1000.0 < skymap.full_map_altitude[0]) or (altitude_km * 1000.0 > skymap.full_map_altitude[2]): raise ValueError("Altitude " + str(altitude_km) + " outside valid range of " + str((skymap.full_map_altitude[0] / 1000.0, skymap.full_map_altitude[2] / 1000.0))) # Initialze empty lat/lon arrays lats = np.full(np.squeeze(skymap.full_map_latitude[0, :, :]).shape, np.nan, dtype=skymap.full_map_latitude[0, :, :].dtype) lons = lats.copy() # Interpolate lats and lons at desired altitude for i in range(skymap.full_map_latitude.shape[1]): for j in range(skymap.full_map_latitude.shape[2]): lats[i, j] = np.interp(altitude_km * 1000.0, skymap.full_map_altitude, skymap.full_map_latitude[:, i, j]) lons[i, j] = np.interp(altitude_km * 1000.0, skymap.full_map_altitude, skymap.full_map_longitude[:, i, j]) lons[np.where(lons > 180)] -= 360.0 # Fix skymap to be in (-180,180) format # First handle case of a contour of constant latidude: if (constant_lat is not None): # First check that the supplied latitude is valid for this skymap if (constant_lat < np.nanmin(lats)) or (constant_lat > np.nanmax(lats)): raise ValueError(f"Latitude {constant_lat} does not coincide with input skymap: latitude range " + f"at {altitude_km} km is ({np.nanmin(lats), np.nanmax(lats)}).") # Get the longitude bounds of the skymap min_skymap_lon, max_skymap_lon = np.nanmin(lons), np.nanmax(lons) # Determine the step in longitudes for grabbing target lat, based on n_points if (n_points is None): n_points = round((lats.shape[0] - 1) / 5.12) # Iterate though longitude ranges iteration_lons = np.linspace(min_skymap_lon, max_skymap_lon, n_points) x_list = [] y_list = [] for i in range(len(iteration_lons) - 1): # Get lon domain for current iteration through skymap lon_min = iteration_lons[i] lon_max = iteration_lons[i + 1] # Get indices of this longitude slice lon_slice_idx = np.logical_and(lons >= lon_min, lons < lon_max) # Get index of pixel nearest to target elevation masked_lats = np.where(lon_slice_idx, lats, np.nan) diffs = np.abs(masked_lats - constant_lat) y, x = np.where(diffs == np.nanmin(diffs)) if x.shape == (0, ) or y.shape == (0, ): continue # Add to master lists x_list.append(x[0]) y_list.append(y[0]) if remove_edge_cases: # Remove any points lying on the edge of CCD bounds and return x_list = np.array(x_list) y_list = np.array(y_list) edge_case_idx = np.where(np.logical_and.reduce([x_list > 0, x_list < lats.shape[1] - 1, y_list > 0, y_list < lats.shape[0] - 1])) x_list = x_list[edge_case_idx] y_list = y_list[edge_case_idx] return (x_list, y_list) else: # Convert to arrays, return return (np.array(x_list), np.array(y_list)) # Next handle case of a contour of constant longitude: elif (constant_lon is not None): # First check that the supplied longitude is valid for this skymap if (constant_lon < np.nanmin(lons)) or (constant_lon > np.nanmax(lons)): raise ValueError(f"Longitude {constant_lat} does not coincide with input skymap: longitude range " + f"at {altitude_km} km is ({np.nanmin(lons), np.nanmax(lons)}).") # Get the latitude bounds of the skymap min_skymap_lat, max_skymap_lat = np.nanmin(lats), np.nanmax(lats) # Determine the step in longitudes for grabbing target lat, based on n_points if (n_points is None): n_points = round((lats.shape[0] - 1) / 5.12) # Iterate though latitude ranges iteration_lats = np.linspace(min_skymap_lat, max_skymap_lat, n_points) x_list = [] y_list = [] for i in range(len(iteration_lats) - 1): # Get lon domain for current iteration through skymap lat_min = iteration_lats[i] lat_max = iteration_lats[i + 1] # Get indices of this longitude slice lat_slice_idx = np.logical_and(lats >= lat_min, lats < lat_max) # Get index of pixel nearest to target elevation masked_lons = np.where(lat_slice_idx, lons, np.nan) diffs = np.abs(masked_lons - constant_lon) y, x = np.where(diffs == np.nanmin(diffs)) if x.shape == (0, ) or y.shape == (0, ): continue # Add to master lists x_list.append(x[0]) y_list.append(y[0]) if remove_edge_cases: # Remove any points lying on the edge of CCD bounds and return x_list = np.array(x_list) y_list = np.array(y_list) edge_case_idx = np.where(np.logical_and.reduce([x_list > 0, x_list < lats.shape[1] - 1, y_list > 0, y_list < lats.shape[0] - 1])) x_list = x_list[edge_case_idx] y_list = y_list[edge_case_idx] return (x_list, y_list) else: # Convert to arrays, return return (np.array(x_list), np.array(y_list)) # Finally, handle case of a custom contour elif (contour_lats is not None) and (contour_lons is not None): # Convert lists to ndarrays if necessary if isinstance(lats, list): lats = np.array(lats) if isinstance(lons, list): lons = np.array(lons) # Remove any invalid lat/lon pairs invalid_mask = (contour_lons < np.nanmin(lons)) | (contour_lons > np.nanmax(lons)) | (contour_lats < np.nanmin(lats)) | (contour_lats > np.nanmax(lats)) # Filter out invalid contour points valid_contour_lats = contour_lats[~invalid_mask] valid_contour_lons = contour_lons[~invalid_mask] # Get the latitude bounds of the skymap # Iterate through each target point x_list = [] y_list = [] for target_lat, target_lon in zip(valid_contour_lats, valid_contour_lons): # Make sure lat/lon falls within skymap if target_lat < np.nanmin(lats) or target_lat > np.nanmax(lats): raise ValueError(f"Latitude {target_lat} is outside this skymap's valid range of {(np.nanmin(lats),np.nanmax(lats))}.") if target_lon < np.nanmin(lons) or target_lon > np.nanmax(lons): raise ValueError(f"Longitude {target_lon} is outside this skymap's valid range of {(np.nanmin(lons),np.nanmax(lons))}.") # Compute haversine distance between all points in skymap haversine_diff = __haversine_distances(target_lat, target_lon, lats, lons) # Obtain the skymap indices of the nearest point nan_mask = np.isnan(haversine_diff) masked_data = np.ma.masked_array(haversine_diff, mask=nan_mask) nearest_indices = np.unravel_index(np.ma.argmin(masked_data), haversine_diff.shape) # Convert indices to CCD Coordinates y_loc = nearest_indices[0] - 1 if y_loc < 0: y_loc = 0 x_loc = nearest_indices[1] - 1 if x_loc < 0: x_loc = 0 x_list.append(x_loc) y_list.append(y_loc) if remove_edge_cases: # Remove any points lying on the edge of CCD bounds and return x_list = np.array(x_list) y_list = np.array(y_list) edge_case_idx = np.where(np.logical_and.reduce([x_list > 0, x_list < lats.shape[1] - 1, y_list > 0, y_list < lats.shape[0] - 1])) x_list = x_list[edge_case_idx] y_list = y_list[edge_case_idx] return (x_list, y_list) else: # Convert to arrays, return return (np.array(x_list), np.array(y_list)) else: # This shouldn't occur, but typing claims there is a missed case somewhere that could not be identified... raise ValueError("Something went wrong. Please verify your inputs are in expected format.")
def mag(skymap: pyucalgarysrs.data.classes.Skymap, timestamp: datetime.datetime, altitude_km: Union[float, int], contour_lats: Union[numpy.ndarray, list, ForwardRef(None)] = None, contour_lons: Union[numpy.ndarray, list, ForwardRef(None)] = None, constant_lat: Union[int, float, ForwardRef(None)] = None, constant_lon: Union[int, float, ForwardRef(None)] = None, n_points: Optional[int] = None, remove_edge_cases: bool = True) ‑> Tuple[numpy.ndarray, numpy.ndarray]
-
Obtain CCD Coordinates of a line of constant magnetic latitude, constant magnetic longitude, or a custom contour defined in magnetic coordinates.
Args
skymap
:Skymap
- The skymap corresponding to the CCD image data to generate contours for.
timestamp (datetime.datetime): The timestamp used for AACGM Conversions.
altitude_km
:int
orfloat
- The altitude of the image data to create contours for, in kilometers.
lats (ndarray or list): Sequence of magnetic latitudes defining a contour.
lons (ndarray or list): Sequence of magnetic longitudes defining a contour.
constant_lats (float or int): Magnetic Latitude at which to create line of constant latitude.
constant_lons (float or int): Magnetic Longitude at which to create line of constant longitude.
n_points (int or float): Optionally specify the number of points used to define a contour. By default a reasonable value is selected automatically.
remove_edge_cases (bool): Due to the nature of skymaps, often, around the edge of CCD data, contours will have often undesired behaviour due to being bounded within the CCD range. The result is flattened contours along the edge of CCD boundaries. This is completely expected, and these points are removed by default, completely for aesthetic choices. Set this keyword to False to keep all points in the contour.
Returns
A tuple (x_pix, y_pix) of numpy arrays containing the coordinates, in pixel units, of the elevation contour.
Raises
ValueError
- invalid elevation supplied.
Expand source code
def mag(skymap: Skymap, timestamp: datetime.datetime, altitude_km: Union[int, float], contour_lats: Optional[Union[np.ndarray, list]] = None, contour_lons: Optional[Union[np.ndarray, list]] = None, constant_lat: Optional[Union[float, int]] = None, constant_lon: Optional[Union[float, int]] = None, n_points: Optional[int] = None, remove_edge_cases: bool = True) -> Tuple[np.ndarray, np.ndarray]: """ Obtain CCD Coordinates of a line of constant magnetic latitude, constant magnetic longitude, or a custom contour defined in magnetic coordinates. Args: skymap (pyaurorax.data.ucalgary.Skymap): The skymap corresponding to the CCD image data to generate contours for. timestamp (datetime.datetime): The timestamp used for AACGM Conversions. altitude_km (int or float): The altitude of the image data to create contours for, in kilometers. lats (ndarray or list): Sequence of magnetic latitudes defining a contour. lons (ndarray or list): Sequence of magnetic longitudes defining a contour. constant_lats (float or int): Magnetic Latitude at which to create line of constant latitude. constant_lons (float or int): Magnetic Longitude at which to create line of constant longitude. n_points (int or float): Optionally specify the number of points used to define a contour. By default a reasonable value is selected automatically. remove_edge_cases (bool): Due to the nature of skymaps, often, around the edge of CCD data, contours will have often undesired behaviour due to being bounded within the CCD range. The result is flattened contours along the edge of CCD boundaries. This is completely expected, and these points are removed by default, completely for aesthetic choices. Set this keyword to False to keep all points in the contour. Returns: A tuple (x_pix, y_pix) of numpy arrays containing the coordinates, in pixel units, of the elevation contour. Raises: ValueError: invalid elevation supplied. """ # Check that multiple contours are not defined based on arguments passed if (contour_lats is None) != (contour_lons is None): raise ValueError("When defining a custom contour, both 'contour_lats' and 'contour_lons' must be supplied.") if sum([((contour_lats is not None) and (contour_lons is not None)), (constant_lat is not None), (constant_lon is not None)]) > 1: raise ValueError( "Only one contour can be defined per call: Pass only one of 'contour_lats & contour_lons', 'constant_lat', or 'constant_lon'.") if sum([((contour_lats is not None) and (contour_lons is not None)), (constant_lat is not None), (constant_lon is not None)]) == 0: raise ValueError("No contour defined in input: Pass one of 'contour_lats & contour_lons', 'constant_lat', or 'constant_lon'.") # Obtain lat/lon arrays from skymap at desired altitude if (altitude_km * 1000.0 in skymap.full_map_altitude): altitude_idx = np.where(altitude_km * 1000.0 == skymap.full_map_altitude) lats = np.squeeze(skymap.full_map_latitude[altitude_idx, :, :]) lons = np.squeeze(skymap.full_map_longitude[altitude_idx, :, :]) lons[np.where(lons > 180)] -= 360.0 # Fix skymap to be in (-180,180) format else: # Make sure altitude is in range that can be interpolated if (altitude_km * 1000.0 < skymap.full_map_altitude[0]) or (altitude_km * 1000.0 > skymap.full_map_altitude[2]): raise ValueError("Altitude " + str(altitude_km) + " outside valid range of " + str((skymap.full_map_altitude[0] / 1000.0, skymap.full_map_altitude[2] / 1000.0))) # Initialze empty lat/lon arrays lats = np.full(np.squeeze(skymap.full_map_latitude[0, :, :]).shape, np.nan, dtype=skymap.full_map_latitude[0, :, :].dtype) lons = lats.copy() # Interpolate lats and lons at desired altitude for i in range(skymap.full_map_latitude.shape[1]): for j in range(skymap.full_map_latitude.shape[2]): lats[i, j] = np.interp(altitude_km * 1000.0, skymap.full_map_altitude, skymap.full_map_latitude[:, i, j]) lons[i, j] = np.interp(altitude_km * 1000.0, skymap.full_map_altitude, skymap.full_map_longitude[:, i, j]) lons[np.where(lons > 180)] -= 360.0 # Fix skymap to be in (-180,180) format # Convert to magnetic coords mag_lats, mag_lons, _ = aacgmv2.convert_latlon_arr( lats.flatten(), lons.flatten(), (lons * 0.0).flatten(), timestamp, method_code='G2A', ) lats = np.reshape(mag_lats, lats.shape) lons = np.reshape(mag_lons, lons.shape) # First handle case of a contour of constant latidude: if (constant_lat is not None): # First check that the supplied latitude is valid for this skymap if (constant_lat < np.nanmin(lats)) or (constant_lat > np.nanmax(lats)): raise ValueError(f"Latitude {constant_lat} does not coincide with input skymap: magnetic latitude " + f"range at {altitude_km} km is ({np.nanmin(lats), np.nanmax(lats)}).") # Get the longitude bounds of the skymap min_skymap_lon, max_skymap_lon = np.nanmin(lons), np.nanmax(lons) # Determine the step in longitudes for grabbing target lat, based on n_points if (n_points is None): n_points = round((lats.shape[0] - 1) / 5.12) # Iterate though longitude ranges iteration_lons = np.linspace(min_skymap_lon, max_skymap_lon, n_points) x_list = [] y_list = [] for i in range(len(iteration_lons) - 1): # Get lon domain for current iteration through skymap lon_min = iteration_lons[i] lon_max = iteration_lons[i + 1] # Get indices of this longitude slice lon_slice_idx = np.logical_and(lons >= lon_min, lons < lon_max) # Get index of pixel nearest to target elevation masked_lats = np.where(lon_slice_idx, lats, np.nan) diffs = np.abs(masked_lats - constant_lat) y, x = np.where(diffs == np.nanmin(diffs)) if x.shape == (0, ) or y.shape == (0, ): continue # Add to master lists x_list.append(x[0]) y_list.append(y[0]) if remove_edge_cases: # Remove any points lying on the edge of CCD bounds and return x_list = np.array(x_list) y_list = np.array(y_list) edge_case_idx = np.where(np.logical_and.reduce([x_list > 0, x_list < lats.shape[1] - 1, y_list > 0, y_list < lats.shape[0] - 1])) x_list = x_list[edge_case_idx] y_list = y_list[edge_case_idx] return (x_list, y_list) else: # Convert to arrays, return return (np.array(x_list), np.array(y_list)) # Next handle case of a contour of constant longitude: elif (constant_lon is not None): # First check that the supplied longitude is valid for this skymap if (constant_lon < np.nanmin(lons)) or (constant_lon > np.nanmax(lons)): raise ValueError(f"Longitude {constant_lat} does not coincide with input skymap: magnetic longitude " + f"range at {altitude_km} km is ({np.nanmin(lons), np.nanmax(lons)}).") # Get the latitude bounds of the skymap min_skymap_lat, max_skymap_lat = np.nanmin(lats), np.nanmax(lats) # Determine the step in longitudes for grabbing target lat, based on n_points if (n_points is None): n_points = round((lats.shape[0] - 1) / 5.12) # Iterate though latitude ranges iteration_lats = np.linspace(min_skymap_lat, max_skymap_lat, n_points) x_list = [] y_list = [] for i in range(len(iteration_lats) - 1): # Get lon domain for current iteration through skymap lat_min = iteration_lats[i] lat_max = iteration_lats[i + 1] # Get indices of this longitude slice lat_slice_idx = np.logical_and(lats >= lat_min, lats < lat_max) # Get index of pixel nearest to target elevation masked_lons = np.where(lat_slice_idx, lons, np.nan) diffs = np.abs(masked_lons - constant_lon) y, x = np.where(diffs == np.nanmin(diffs)) if x.shape == (0, ) or y.shape == (0, ): continue # Add to master lists x_list.append(x[0]) y_list.append(y[0]) if remove_edge_cases: # Remove any points lying on the edge of CCD bounds and return x_list = np.array(x_list) y_list = np.array(y_list) edge_case_idx = np.where(np.logical_and.reduce([x_list > 0, x_list < lats.shape[1] - 1, y_list > 0, y_list < lats.shape[0] - 1])) x_list = x_list[edge_case_idx] y_list = y_list[edge_case_idx] return (x_list, y_list) else: # Convert to arrays, return return (np.array(x_list), np.array(y_list)) # Finally, handle case of a custom contour elif (contour_lats is not None) and (contour_lons is not None): # Convert lists to ndarrays if necessary if isinstance(contour_lats, list): lats = np.array(contour_lats) if isinstance(contour_lons, list): lons = np.array(contour_lons) # Remove any invalid lat/lon pairs invalid_mask = (contour_lons < np.nanmin(lons)) | (contour_lons > np.nanmax(lons)) | (contour_lats < np.nanmin(lats)) | (contour_lats > np.nanmax(lats)) # Filter out invalid contour points valid_contour_lats = contour_lats[~invalid_mask] valid_contour_lons = contour_lons[~invalid_mask] # Check if any lat/lons are actually valid for skymap invalid_lats_mask = (contour_lats < np.nanmin(lats)) | (contour_lats > np.nanmax(lats)) invalid_lons_mask = (contour_lons < np.nanmin(lons)) | (contour_lons > np.nanmax(lons)) if contour_lats[~invalid_lats_mask].shape == (0, ): raise ValueError(f"Magnetic coordinates provided are outside this skymap's valid range of {(np.nanmin(lats),np.nanmax(lats))}.") if contour_lons[~invalid_lons_mask].shape == (0, ): raise ValueError(f"Magnetic longitudes provided are outside this skymap's valid range of {(np.nanmin(lons),np.nanmax(lons))}.") # Get the latitude bounds of the skymap # Iterate through each target point x_list = [] y_list = [] for target_lat, target_lon in zip(valid_contour_lats, valid_contour_lons): # Make sure lat/lon falls within skymap if target_lat < np.nanmin(lats) or target_lat > np.nanmax(lats): raise ValueError(f"Magnetic latitude {target_lat} is outside this skymap's valid range of {(np.nanmin(lats),np.nanmax(lats))}.") if target_lon < np.nanmin(lons) or target_lon > np.nanmax(lons): raise ValueError(f"Magnetic longitude {target_lon} is outside this skymap's valid range of {(np.nanmin(lons),np.nanmax(lons))}.") # Compute haversine distance between all points in skymap haversine_diff = __haversine_distances(target_lat, target_lon, lats, lons) # Obtain the skymap indices of the nearest point nan_mask = np.isnan(haversine_diff) masked_data = np.ma.masked_array(haversine_diff, mask=nan_mask) nearest_indices = np.unravel_index(np.ma.argmin(masked_data), haversine_diff.shape) # Convert indices to CCD Coordinates y_loc = nearest_indices[0] - 1 if y_loc < 0: y_loc = 0 x_loc = nearest_indices[1] - 1 if x_loc < 0: x_loc = 0 x_list.append(x_loc) y_list.append(y_loc) if remove_edge_cases: # Remove any points lying on the edge of CCD bounds and return x_list = np.array(x_list) y_list = np.array(y_list) edge_case_idx = np.where(np.logical_and.reduce([x_list > 0, x_list < lats.shape[1] - 1, y_list > 0, y_list < lats.shape[0] - 1])) x_list = x_list[edge_case_idx] y_list = y_list[edge_case_idx] return (x_list, y_list) else: # Convert to arrays, return return (np.array(x_list), np.array(y_list)) else: # This shouldn't occur, but typing claims there is a missed case somewhere that could not be identified... raise ValueError("Something went wrong. Please verify your inputs are in expected format.")