Module pyaurorax.tools.mosaic

Prepare data and create mosaics.

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.
"""
Prepare data and create mosaics.
"""

from ._prep_skymaps import prep_skymaps
from ._prep_images import prep_images
from ._create import create

__all__ = [
    "prep_skymaps",
    "prep_images",
    "create",
]

Functions

def create(prepped_data: Union[MosaicData, List[MosaicData]], prepped_skymap: Union[MosaicSkymap, List[MosaicSkymap]], timestamp: datetime.datetime, cartopy_projection: cartopy.crs.Projection, min_elevation: Union[int, List[int]] = 5, colormap: Union[str, List[str], ForwardRef(None)] = None, image_intensity_scales: Union[List, Dict, ForwardRef(None)] = None) ‑> Mosaic

Create a mosaic object.

Args

prepped_data : MosaicData
The prepared mosaic data. Generated from a prior prep_images() function call.
prepped_skymap : MosaicSkymap
The prepared skymap data. Generated from a prior prep_skymaps() function call.
timestamp : datetime.datetime
The timestamp to generate a mosaic for. Must be within the range of timestamps for which image data was prepped and provided.
cartopy_projection : cartopy.crs.Projection
The cartopy projection to use when creating the mosaic.
min_elevation : int
The minimum elevation cutoff when projecting images on the map, in degrees. Default is 5.
cbar_colorcmap : str

The matplotlib colormap to use for the rendered image data. Default is gray.

Commonly used colormaps are:

  • REGO: gist_heat
  • THEMIS ASI: gray
  • TREx Blue: Blues_r
  • TREx NIR: gray
  • TREx RGB: None

A list of all available colormaps can be found on the matplotlib documentation.

image_intensity_scaled : List or Dict

Ranges for scaling images. Either a a list with 2 elements which will scale all sites with the same range, or as a dictionary which can be used for scaling each site differently.

Example of uniform scaling across all sites: image_intensity_scales = [2000, 8000]

Example of scaling each site differently: image_intensity_scales = {"fsmi": [1000, 10000], "gill": [2000, 8000]}

Returns

The generated Mosaic object.

Raises

ValueError
issues with supplied parameters.
Expand source code
def create(prepped_data: Union[MosaicData, List[MosaicData]],
           prepped_skymap: Union[MosaicSkymap, List[MosaicSkymap]],
           timestamp: datetime.datetime,
           cartopy_projection: cartopy.crs.Projection,
           min_elevation: Union[int, List[int]] = 5,
           colormap: Optional[Union[str, List[str]]] = None,
           image_intensity_scales: Optional[Union[List, Dict]] = None) -> Mosaic:
    """
    Create a mosaic object.

    Args:
        prepped_data (pyaurorax.tools.MosaicData): 
            The prepared mosaic data. Generated from a prior `prep_images()` function call.

        prepped_skymap (pyaurorax.tools.MosaicSkymap): 
            The prepared skymap data. Generated from a prior `prep_skymaps()` function call.

        timestamp (datetime.datetime): 
            The timestamp to generate a mosaic for. Must be within the range of timestamps
            for which image data was prepped and provided.

        cartopy_projection (cartopy.crs.Projection): 
            The cartopy projection to use when creating the mosaic.

        min_elevation (int): 
            The minimum elevation cutoff when projecting images on the map, in degrees. Default is `5`.

        cbar_colorcmap (str): 
            The matplotlib colormap to use for the rendered image data. Default is `gray`.

            Commonly used colormaps are:

            - REGO: `gist_heat`
            - THEMIS ASI: `gray`
            - TREx Blue: `Blues_r`
            - TREx NIR: `gray`
            - TREx RGB: `None`

            A list of all available colormaps can be found on the 
            [matplotlib documentation](https://matplotlib.org/stable/gallery/color/colormap_reference.html).

        image_intensity_scaled (List or Dict): 
            Ranges for scaling images. Either a a list with 2 elements which will scale all sites with 
            the same range, or as a dictionary which can be used for scaling each site differently. 

            Example of uniform scaling across all sites: 
            `image_intensity_scales = [2000, 8000]`

            Example of scaling each site differently:
            `image_intensity_scales = {"fsmi": [1000, 10000], "gill": [2000, 8000]}`

    Returns:
        The generated `pyaurorax.tools.Mosaic` object.

    Raises:
        ValueError: issues with supplied parameters.
    """
    # init coordinates transformer
    #
    # To convert from geodetic coordinates onto the map projection, we use pyproj instead
    # of cartopy's native transformations. This is an optimization.
    pyproj_src_proj = pyproj.CRS.from_user_input(cartopy.crs.Geodetic())
    pyproj_des_proj = pyproj.CRS.from_user_input(cartopy_projection)
    transformer = pyproj.Transformer.from_crs(pyproj_src_proj, pyproj_des_proj, always_xy=True)

    # Convert data, skymaps, colormap indicators to lists for iteration purposed
    if not isinstance(prepped_data, list):
        prepped_data = [prepped_data]
    if not isinstance(prepped_skymap, list):
        prepped_skymap = [prepped_skymap]
    if not isinstance(colormap, list):
        if colormap is None:
            colormap = []
            for _ in range(len(prepped_data)):
                colormap.append('gray')
        else:
            colormap = [colormap]
    if not isinstance(min_elevation, list):
        tmp = []
        for _ in range(len(prepped_data)):
            tmp.append(min_elevation)
        min_elevation = tmp

    # Make sure all lists are same length
    if (len(prepped_data) != len(prepped_skymap)):
        raise ValueError("When passing lists of prepped data and prepped skymap, they must be of the same length.")
    if (len(prepped_data) != len(colormap)) or (len(prepped_skymap) != len(colormap)):
        raise ValueError("List of colormaps must have same length as lists of prepped data and prepped skymaps.")

    # Itarate through each set of prepped data, prepped skymap
    img_poly_list = []
    for mosaic_data_idx in range(len(prepped_data)):
        data = prepped_data[mosaic_data_idx]
        skymap = prepped_skymap[mosaic_data_idx]
        iter_cmap = colormap[mosaic_data_idx]
        min_el = min_elevation[mosaic_data_idx]

        # get sites
        site_list = data.site_uid_list

        # set image intensity scales
        if (image_intensity_scales is None):
            # defaults to scaling all sites between 0-20000
            image_intensity_scales = {}
            for site_uid in skymap.site_uid_list:
                image_intensity_scales[site_uid] = [__DEFAULT_SCALE_MIN, __DEFAULT_SCALE_MAX]
        elif (isinstance(image_intensity_scales, list) is True):
            image_intensity_scales_dict = {}
            for site_uid in site_list:
                image_intensity_scales_dict[site_uid] = image_intensity_scales
            image_intensity_scales = image_intensity_scales_dict
        elif (isinstance(image_intensity_scales, dict) is True):
            # no action needed
            pass
        else:
            raise ValueError("Invalid image_intensity_scales format. Please refer to the documentation for this function.")

        # We need a numpy array of the sites requested, that will be used to make sure any sites
        # that don't have data for the requested frame are not plotted. Also empty dict for images..
        site_list_arr = np.array(site_list)
        # all_images = np.zeros([len(site_list), width * height, __N_CHANNELS], dtype=np.int32)
        all_images = {}

        # Grab the elevation, and filling lats/lons
        elev = skymap.elevation
        polyfill_lon = skymap.polyfill_lon
        polyfill_lat = skymap.polyfill_lat

        # Now we begin to fill in the above arrays, one site at a time. Before doing so
        # we need lists to keep track of which sites actually have data for this frame.
        sites_with_data = []
        sites_with_data_idx = []

        # Determine the frame index of data the corresponds to the requested timestamp
        minimum_timestamp = (np.array(data.timestamps))[np.argmin(np.array(data.timestamps))]
        maximum_timestamp = (np.array(data.timestamps))[np.argmax(np.array(data.timestamps))]
        if timestamp < minimum_timestamp or timestamp > maximum_timestamp:
            raise ValueError("Could not create mosaic for timestamp" + timestamp.strftime("%Y/%m/%d %H:%M:%S") +
                             " as image data was only supplied for the timestamp range: " + minimum_timestamp.strftime("%Y/%m/%d %H:%M:%S") + " to " +
                             maximum_timestamp.strftime("%Y/%m/%d %H:%M:%S"))

        # Get the frame index of the timestamp closest to desired mosaic frame
        frame_idx = np.argmin(np.abs(np.array(data.timestamps) - timestamp))

        # We also define a list that will hold all unique timestamps pulled from each
        # frame's metadata. This should be of length 1, and we can check that to make
        # sure all images being plotted correspond to the same time.
        unique_timestamps = []
        n_channels_dict = {}
        for site in site_list:

            # set image dimensions
            height = data.images_dimensions[site][0]
            width = data.images_dimensions[site][1]

            # Grab the timestamp for this frame/site
            meta_timestamp = data.timestamps[frame_idx]

            # Determine whether current image is single or multi-channel, and add to dictionary for reference
            if len(data.images[site].shape) == 4:
                n_channels = data.images[site].shape[2]
            else:
                n_channels = 1
            n_channels_dict[site] = n_channels

            # Now, obtain the frame of interest, for this site, from the image data and flatten it
            if n_channels == 1:
                img = data.images[site][:, :, frame_idx]
                flattened_img = np.reshape(img, (width * height))
            else:
                img = data.images[site][:, :, :, frame_idx]
                flattened_img = np.reshape(img, (width * height, n_channels))

            tmp = flattened_img

            if (np.sum(tmp) == 0.0):
                # If it's sum is zero, we know there is no data so we can simply continue.
                continue

            # Scale this site's data based on previously defined scaling bounds
            tmp = scale_intensity(
                tmp,
                min=image_intensity_scales[site][0],  # type: ignore
                max=image_intensity_scales[site][1],  # type: ignore
                top=255,
                memory_saver=False,
            )

            # Add the timestamp to tracking list if it's unique
            if meta_timestamp not in unique_timestamps:
                unique_timestamps.append(meta_timestamp)

            # Append sites to respective lists, and add image data to master list
            sites_with_data.append(site)
            sites_with_data_idx.append(np.where(site_list_arr == site)[0][0])
            all_images[site] = tmp.astype(np.int32)

        # This checks to make sure all images have the same timestamps
        if len(unique_timestamps) != 1:
            raise Exception("Error: Images have different timestamps.")

        # Create empty lists for tracking the pixel polygons and their values
        lon_list = []
        lat_list = []
        cmap_vals = []

        # Set up elevation increment for plotting. We start at the min elevation
        # and plot groups of elevations until reaching 90 deg.
        elev_delta = 0.1
        el = min_el

        # Iterate through all elevation ranges
        while el < 90:

            # Only iterate through the sites that actually have data
            for site_id, site_idx in zip(sites_with_data, sites_with_data_idx):
                # Get this sites number of channels
                n_channels = n_channels_dict[site_id]

                # Get all pixels within current elevation threshold
                el_idx = np.nonzero(np.logical_and(elev[site_idx] > el, elev[site_idx] <= el + elev_delta))[0]
                if len(el_idx) == 0:
                    continue

                # Grab this level's filling lat/lons
                el_lvl_fill_lats = polyfill_lat[site_idx][:, el_idx]
                el_lvl_fill_lons = polyfill_lon[site_idx][:, el_idx]

                # Grab this level's data values
                if n_channels == 1:
                    el_lvl_cmap_vals = all_images[site_id][el_idx]
                else:
                    el_lvl_cmap_vals = all_images[site_id][el_idx, :]

                # # Mask any nans that may have slipped through - done as a precaution
                nan_mask = ~np.isnan(el_lvl_fill_lats).any(axis=0) & ~np.isnan(el_lvl_fill_lons).any(axis=0)

                el_lvl_fill_lats = el_lvl_fill_lats[:, nan_mask]
                el_lvl_fill_lons = el_lvl_fill_lons[:, nan_mask]
                if n_channels == 1:
                    el_lvl_cmap_vals = el_lvl_cmap_vals[nan_mask]
                else:
                    el_lvl_cmap_vals = el_lvl_cmap_vals[nan_mask, :]

                # Convert pixel values to a normalized float
                el_lvl_colors = el_lvl_cmap_vals.astype(np.float32) / 255.0

                # Append polygon lat/lons and values to master lists
                if n_channels == 1:
                    # print(1, len(el_lvl_fill_lats), len(el_lvl_colors))
                    cmap = plt.get_cmap(iter_cmap)
                    for k in range(len(el_lvl_fill_lats[0, :])):
                        lon_list.append(el_lvl_fill_lons[:, k])
                        lat_list.append(el_lvl_fill_lats[:, k])
                        cmap_vals.append(cmap(el_lvl_colors[k]))
                else:
                    for k in range(len(el_lvl_fill_lats[0, :])):
                        lon_list.append(el_lvl_fill_lons[:, k])
                        lat_list.append(el_lvl_fill_lats[:, k])
                        cmap_vals.append(el_lvl_colors[k, :])

            el += elev_delta

        # Use our transformer object to convert the lat/lon polygons into projection coordinates.
        lons, lats = transformer.transform(np.array(lon_list), np.array(lat_list))

        # Format polygons for creation of PolyCollection object
        lonlat_polygons = np.empty((lons.shape[0], 5, 2))
        lonlat_polygons[:, :, 0] = lons
        lonlat_polygons[:, :, 1] = lats

        # generate a PolyCollection object, containing all of the Polygons shaded with
        # their corresponding RGB value

        img_data_poly = matplotlib.collections.PolyCollection(
            lonlat_polygons,  # type: ignore
            facecolors=cmap_vals,
            array=None,
            clim=[0.0, 1.0],
            edgecolors="face",
        )

        img_poly_list.append(img_data_poly)

    # cast into mosaic object
    if len(img_poly_list) == 1:
        mosaic = Mosaic(polygon_data=img_poly_list[0], cartopy_projection=cartopy_projection)
    else:
        mosaic = Mosaic(polygon_data=img_poly_list, cartopy_projection=cartopy_projection)

    # return
    return mosaic
def prep_images(image_list: List[pyucalgarysrs.data.classes.Data], data_attribute: Literal['data', 'calibrated_data'] = 'data') ‑> MosaicData

Prepare the image data for use in a mosaic.

Args

image_list : List[Data]
List of image data. Each element of the list is the data for each site.
data_attribute : str
The data attribute to use when prepping the images. Either data or calibrated_data. Default is data.

Returns

The prepared data, as a MosaicData object.

Raises

ValueError
issues encountered with supplied paramters.
Expand source code
def prep_images(image_list: List[Data], data_attribute: Literal["data", "calibrated_data"] = "data") -> MosaicData:
    """
    Prepare the image data for use in a mosaic.

    Args:
        image_list (List[pyaurorax.data.ucalgary.Data]): 
            List of image data. Each element of the list is the data for each site.
        
        data_attribute (str): 
            The data attribute to use when prepping the images. Either `data` or `calibrated_data`. 
            Default is `data`.

    Returns:
        The prepared data, as a `pyaurorax.tools.MosaicData` object.

    Raises:
        ValueError: issues encountered with supplied paramters.
    """
    # set image dimensions and number of sites
    if (data_attribute == "data"):
        # check that the timestamp and calibrated data match in size
        for i in range(0, len(image_list)):
            if (image_list[i].data.shape[-1] != len(image_list[i].timestamp)):
                raise ValueError(("Number of frames does not match number of timestamp records. There are %d timestamp " +
                                  "records, and %d images for list index %d") % (
                                      len(image_list[i].timestamp),
                                      image_list[i].data.shape[-1],
                                      i,
                                  ))

        # set height and width
        height = image_list[0].data.shape[0]
        width = image_list[0].data.shape[1]
    elif (data_attribute == "calibrated_data"):
        # check that the timestamp and calibrated data match in size
        for i in range(0, len(image_list)):
            if (image_list[i].calibrated_data.shape[-1] != len(image_list[i].timestamp)):
                raise ValueError(("Number of frames does not match number of timestamp records. There are %d timestamp " +
                                  "records, and %d images for list index %d") % (
                                      len(image_list[i].timestamp),
                                      image_list[i].calibrated_data.shape[-1],
                                      i,
                                  ))
    else:
        raise ValueError("Invalid 'data_attribute' parameter. Must be either 'data' or 'calibrated_data'.")

    # determine the number of expected frames
    #
    # NOTE: this is done to ensure that the eventual image arrays are all the
    # same size, and we adequately account for dropped frames.
    #
    # Steps:
    #   1) finding the over-arching start and end times of data across all sites
    #   2) determine the cadence using the timestamps
    #   3) determine the number of expected frames using the cadence, start and end
    #
    start_dt = image_list[0].timestamp[0]
    end_dt = image_list[0].timestamp[-1]
    for site_data in image_list:
        this_start_dt = site_data.timestamp[0]
        this_end_dt = site_data.timestamp[-1]
        if (this_start_dt < start_dt):
            start_dt = this_start_dt
        if (this_end_dt > end_dt):
            end_dt = this_end_dt
    cadence = __determine_cadence(image_list[0].timestamp)
    curr_dt = start_dt.replace(microsecond=0)
    expected_num_frames = 0
    expected_timestamps = []
    while (curr_dt <= end_dt):
        expected_timestamps.append(curr_dt)
        expected_num_frames += 1
        curr_dt += datetime.timedelta(seconds=cadence)

    # for each site
    site_uid_list = []
    images_dict = {}
    for site_image_data in image_list:

        if (data_attribute == "data"):
            site_data = site_image_data.data
        elif (data_attribute == "calibrated_data"):
            site_data = site_image_data.calibrated_data

        # set image dimensions
        height = site_data.shape[0]
        width = site_data.shape[1]

        # Determine number of channels of image data
        if len(site_data.shape) == 4:
            n_channels = site_data.shape[2]
        else:
            n_channels = 1

        # add to site uid list - must use try as metadata differs between networks
        try:
            site_uid = site_image_data.metadata[0]["site_unique_id"]
        except KeyError:
            try:
                site_uid = site_image_data.metadata[0]["Site unique ID"]
            except KeyError as e:
                raise KeyError("Unable to find site UID in Metadata") from e

        # We don't attempt to handle the same site being passed in for multiple networks
        if site_uid in images_dict.keys():
            warnings.warn(
                "Same site between differing networks detected. Omitting additional '%s' data" % (site_uid),
                stacklevel=1,
            )
            continue
        site_uid_list.append(site_uid)

        # initialize this site's data destination variables
        images_dict[site_uid] = np.squeeze(np.full((height, width, n_channels, expected_num_frames), np.nan))

        # use binary search to find the index in the data corresponding to each
        # expected timestamp (we assume it is already sorted)
        for i in range(0, len(expected_timestamps)):
            searching_dt = expected_timestamps[i]
            found_idx = None
            low = 0
            high = len(site_image_data.timestamp) - 1
            while (low <= high):
                mid = low + (high - low) // 2
                this_ts = site_image_data.timestamp[mid].replace(microsecond=0)

                if (this_ts == searching_dt):
                    found_idx = mid
                    break
                elif (this_ts > searching_dt):
                    high = mid - 1
                else:
                    low = mid + 1

            if (found_idx is None):
                # didn't find the timestamp, just move on because there will be no data
                # for this timestamp
                continue
            else:
                # found data for this timestamp
                if n_channels != 1:
                    images_dict[site_uid][:, :, :, i] = site_data[:, :, :, found_idx]
                else:
                    images_dict[site_uid][:, :, i] = site_data[:, :, found_idx]

    dimensions_dict = {}
    for site_uid, image in images_dict.items():
        dimensions_dict[site_uid] = (image.shape[0], image.shape[1])

    # cast into object
    prepped_data = MosaicData(
        site_uid_list=site_uid_list,
        timestamps=expected_timestamps,
        images=images_dict,
        images_dimensions=dimensions_dict,
    )

    # return
    return prepped_data
def prep_skymaps(skymaps: List[pyucalgarysrs.data.classes.Skymap], height_km: int, site_uid_order: Optional[List[str]] = None, progress_bar_disable: bool = False, n_parallel: int = 1) ‑> MosaicSkymap

Prepare skymap data for use by the mosaic routine. This is not time-dependent, so it would only need to be done once.

Allows for plotting multiple images on a map, masking the boundaries between images by elevation angle.

Args

skymaps : List[Skymap]
The skymaps to prep.
height_km : int
The altitude to utilize, in kilometers.
site_uid_order : List[str]
The site list order. The order of this list is not important for plotting, but must be consistent with the order of the skymaps parameter.
progress_bar_disable : bool
Disable the progress bar. Defaults to False.
n_parallel : int
Number of skymaps to prepare in parallel using multiprocessing. Default is 1.

Returns

The prepared skymap data as a MosaicSkymap object.

Raises

ValueError
issues encountered with supplied parameters.
Expand source code
def prep_skymaps(skymaps: List[Skymap],
                 height_km: int,
                 site_uid_order: Optional[List[str]] = None,
                 progress_bar_disable: bool = False,
                 n_parallel: int = 1) -> MosaicSkymap:
    """
    Prepare skymap data for use by the mosaic routine. This is not time-dependent, so it 
    would only need to be done once.

    Allows for plotting multiple images on a map, masking the boundaries between 
    images by elevation angle.

    Args:
        skymaps (List[pyaurorax.data.ucalgary.Skymap]): 
            The skymaps to prep.
        
        height_km (int): 
            The altitude to utilize, in kilometers.
        
        site_uid_order (List[str]): 
            The site list order. The order of this list is not important for plotting, but must be
            consistent with the order of the `skymaps` parameter.
        
        progress_bar_disable (bool): 
            Disable the progress bar. Defaults to `False`.

        n_parallel (int): 
            Number of skymaps to prepare in parallel using multiprocessing. Default is `1`. 

    Returns:
        The prepared skymap data as a `pyaurorax.tools.MosaicSkymap` object.
        
    Raises:
        ValueError: issues encountered with supplied parameters.
    """
    # reorder the skymap list based on the site_uid_list supplied
    skymaps_sorted = []
    site_uid_list = []
    if (site_uid_order is not None):
        # need to specifically order the skymaps
        #
        # NOTE: can do an optimization here, but with only a few items
        # ever in this list, it doesn't matter. A O(N)^2 routine is
        # good enough.
        for site_uid in site_uid_order:
            for skymap in skymaps:
                if (site_uid == skymap.site_uid):
                    skymaps_sorted.append(skymap)
                    site_uid_list.append(site_uid)
        if (len(skymaps_sorted) != len(skymaps)):
            raise ValueError("Number of items in supplied skymaps and site_uid_order lists do not match, or " +
                             "some site_uids specified in the order were not found. Unable to flatten skymaps due to this mismatch.")
    else:
        site_uid_list = [x.site_uid for x in skymaps]
        skymaps_sorted = skymaps

    # define empty numpy arrays for the lats, lons, and elevation angles of all of
    # the sites. Also define numpy arrays for 'filling' the pixel coordinates, which
    # will contain polygons vertices in lat/lon.
    elevation = [np.zeros((skymap.full_azimuth.shape[0] * skymap.full_azimuth.shape[1])) for skymap in skymaps_sorted]
    polyfill_lat = [np.zeros((5, skymap.full_azimuth.shape[0] * skymap.full_azimuth.shape[1])) for skymap in skymaps_sorted]
    polyfill_lon = [np.zeros((5, skymap.full_azimuth.shape[0] * skymap.full_azimuth.shape[1])) for skymap in skymaps_sorted]

    # set up processing objects
    processing_dicts = []
    for i in range(0, len(skymaps_sorted)):
        processing_dicts.append({
            "skymap": skymaps_sorted[i],
            "height_km": height_km,
            "i": i,
        })

    if (n_parallel == 1):
        # don't do anything special, just a basic loop
        if (progress_bar_disable is True):
            # no progress bar
            for processing_dict in processing_dicts:
                results_dict = __flatten_skymap(processing_dict)
                elevation[results_dict["i"]] = results_dict["elevation"]
                polyfill_lon[results_dict["i"]] = results_dict["polyfill_lon"]
                polyfill_lat[results_dict["i"]] = results_dict["polyfill_lat"]
        else:
            # with progress bar
            for processing_dict in tqdm(processing_dicts, desc="Preparing skymaps: ", unit="skymap"):
                results_dict = __flatten_skymap(processing_dict)
                elevation[results_dict["i"]] = results_dict["elevation"]
                polyfill_lon[results_dict["i"]] = results_dict["polyfill_lon"]
                polyfill_lat[results_dict["i"]] = results_dict["polyfill_lat"]
    else:
        # multiple workers, do it in a multiprocessing loop
        if (progress_bar_disable is True):
            with ProcessPoolExecutor(max_workers=n_parallel) as executor:
                for results_dict in executor.map(__flatten_skymap, processing_dicts):
                    elevation[results_dict["i"]] = results_dict["elevation"]
                    polyfill_lon[results_dict["i"]] = results_dict["polyfill_lon"]
                    polyfill_lat[results_dict["i"]] = results_dict["polyfill_lat"]
        else:
            results_dicts = tqdm_process_map(
                __flatten_skymap,
                processing_dicts,
                max_workers=n_parallel,
                chunksize=1,
                desc="Preparing skymaps: ",
                unit="skymap",
                tqdm_class=tqdm,
            )
            for results_dict in results_dicts:
                elevation[results_dict["i"]] = results_dict["elevation"]
                polyfill_lon[results_dict["i"]] = results_dict["polyfill_lon"]
                polyfill_lat[results_dict["i"]] = results_dict["polyfill_lat"]

    # cast data into object
    flattened_skymap = MosaicSkymap(
        elevation=elevation,
        polyfill_lat=polyfill_lat,
        polyfill_lon=polyfill_lon,
        site_uid_list=site_uid_list,
    )

    # return
    return flattened_skymap