asp_plot.utils
==============

.. py:module:: asp_plot.utils


Attributes
----------

.. autoapisummary::

   asp_plot.utils.logger


Classes
-------

.. autoapisummary::

   asp_plot.utils.ColorBar
   asp_plot.utils.Plotter
   asp_plot.utils.Raster


Functions
---------

.. autoapisummary::

   asp_plot.utils.add_copyright_overlay
   asp_plot.utils.detect_planetary_body
   asp_plot.utils.detect_vantor_satellite
   asp_plot.utils.get_acquisition_dates
   asp_plot.utils.get_planetary_bounds
   asp_plot.utils.get_utm_epsg
   asp_plot.utils.get_xml_tag
   asp_plot.utils.glob_file
   asp_plot.utils.run_subprocess_command
   asp_plot.utils.save_figure
   asp_plot.utils.show_existing_figure


Module Contents
---------------

.. py:class:: ColorBar(perc_range=(2, 98), symm=False)

   Utility class for managing colorbar limits and normalization.

   This class handles color scaling for data visualization, including
   percentile-based limits, symmetric color mapping, and logarithmic normalization.

   .. attribute:: perc_range

      Percentile range (min, max) for color limits, default is (2, 98)

      :type: tuple

   .. attribute:: symm

      Whether to use symmetric color limits, default is False

      :type: bool

   .. attribute:: clim

      Current color limits (min, max), None until calculated

      :type: tuple or None

   .. rubric:: Examples

   >>> cb = ColorBar(perc_range=(5, 95), symm=True)
   >>> clim = cb.get_clim(data)
   >>> norm = cb.get_norm(lognorm=False)
   >>> im = ax.imshow(data, norm=norm)
   >>> plt.colorbar(im, extend=cb.get_cbar_extend(data))


   .. py:method:: find_common_clim(inputs)

      Find common color limits across multiple inputs.

      :param inputs: List of input data arrays
      :type inputs: list

      :returns: Common color limits (min, max)
      :rtype: tuple



   .. py:method:: get_cbar_extend(input, clim=None)

      Determine colorbar extension mode based on data and limits.

      :param input: Input data array
      :type input: array_like
      :param clim: Color limits (min, max), if None uses current or calculated limits
      :type clim: tuple, optional

      :returns: Colorbar extension mode: 'neither', 'min', 'max', or 'both'
      :rtype: str



   .. py:method:: get_clim(input)

      Calculate color limits based on data percentiles.

      :param input: Input data array (can be masked)
      :type input: array_like

      :returns: Color limits (min, max)
      :rtype: tuple



   .. py:method:: get_norm(lognorm=False)

      Get normalization for colormap.

      :param lognorm: Whether to use logarithmic normalization, default is False
      :type lognorm: bool, optional

      :returns: Normalization object for matplotlib colormaps
      :rtype: matplotlib.colors.Normalize

      .. rubric:: Notes

      Requires the clim attribute to be set (by calling get_clim or find_common_clim)



   .. py:method:: symm_clim()

      Make color limits symmetric around zero.

      :returns: Symmetric color limits (-max, max)
      :rtype: tuple



   .. py:attribute:: clim
      :value: None



   .. py:attribute:: perc_range
      :value: (2, 98)



   .. py:attribute:: symm
      :value: False



.. py:class:: Plotter(clim_perc=(2, 98), lognorm=False, title=None)

   Base class for plotting array and vector data.

   This class provides common plotting functionality, including color management,
   colorbar customization, and basemap addition.

   .. attribute:: clim_perc

      Percentile range for color limits, default is (2, 98)

      :type: tuple

   .. attribute:: lognorm

      Whether to use logarithmic color normalization, default is False

      :type: bool

   .. attribute:: title

      Plot title, default is None

      :type: str or None

   .. attribute:: cb

      ColorBar instance for managing color scaling

      :type: ColorBar

   .. rubric:: Examples

   >>> plotter = Plotter(clim_perc=(5, 95), title="My Plot")
   >>> fig, ax = plt.subplots()
   >>> plotter.plot_array(ax, data, cmap="viridis", cbar_label="Elevation (m)")


   .. py:method:: plot_array(ax, array, clim=None, cmap='inferno', add_cbar=True, cbar_label=None, alpha=1)

      Plot a 2D array on the given axes.

      :param ax: Axes to plot on
      :type ax: matplotlib.axes.Axes
      :param array: 2D array to plot
      :type array: array_like
      :param clim: Color limits (min, max), default is None (auto-calculated)
      :type clim: tuple, optional
      :param cmap: Colormap to use, default is "inferno"
      :type cmap: str or matplotlib.colors.Colormap, optional
      :param add_cbar: Whether to add a colorbar, default is True
      :type add_cbar: bool, optional
      :param cbar_label: Label for the colorbar, default is None
      :type cbar_label: str, optional
      :param alpha: Transparency (0-1), default is 1
      :type alpha: float, optional

      :returns: The plotted image
      :rtype: matplotlib.image.AxesImage

      .. rubric:: Notes

      If clim is None, color limits are calculated using the ColorBar instance.



   .. py:method:: plot_geodataframe(ax, gdf, column_name, clim=None, cmap='inferno', cbar_label=None, **ctx_kwargs)

      Plot a GeoDataFrame with color mapping and optional basemap.

      :param ax: Axes to plot on
      :type ax: matplotlib.axes.Axes
      :param gdf: GeoDataFrame to plot
      :type gdf: geopandas.GeoDataFrame
      :param column_name: Column name to use for color mapping
      :type column_name: str
      :param clim: Color limits (min, max), default is None (auto-calculated)
      :type clim: tuple, optional
      :param cmap: Colormap to use, default is "inferno"
      :type cmap: str or matplotlib.colors.Colormap, optional
      :param cbar_label: Label for the colorbar, default is None
      :type cbar_label: str, optional
      :param \*\*ctx_kwargs: Additional keyword arguments for contextily.add_basemap

      :returns: The axes with the plot
      :rtype: matplotlib.axes.Axes

      .. rubric:: Notes

      If ctx_kwargs are provided, adds a basemap using contextily.



   .. py:attribute:: cb


   .. py:attribute:: clim_perc
      :value: (2, 98)



   .. py:attribute:: lognorm
      :value: False



   .. py:attribute:: title
      :value: None



.. py:class:: Raster(fn, downsample=1)

   Utility class for raster data handling and processing.

   This class provides convenient methods for reading, processing,
   and analyzing raster data using rasterio and GDAL.

   .. attribute:: fn

      Path to the raster file

      :type: str

   .. attribute:: ds

      Open rasterio dataset

      :type: rasterio.DatasetReader

   .. attribute:: downsample

      Downsampling factor for reading data

      :type: int or float

   .. attribute:: data

      Cached raster data (loaded on demand)

      :type: numpy.ma.MaskedArray or None

   .. attribute:: transform

      Affine transform for the raster (adjusted if downsampled)

      :type: affine.Affine

   .. rubric:: Examples

   >>> raster = Raster("path/to/dem.tif")
   >>> data = raster.read_array()
   >>> hillshade = raster.hillshade()
   >>> epsg = raster.get_epsg_code()
   >>> gsd = raster.get_gsd()
   >>> # Downsample for faster plotting
   >>> raster_ds = Raster("path/to/dem.tif", downsample=10)
   >>> raster_ds.plot(ax=ax, cmap="viridis")


   .. py:method:: compute_difference(second_fn, save=False)

      Compute the difference between this raster and another.

      :param second_fn: Path to the second raster file
      :type second_fn: str
      :param save: Whether to save the difference raster to disk, default is False
      :type save: bool, optional

      :returns: Difference array (second_raster - this_raster)
      :rtype: numpy.ndarray

      .. rubric:: Notes

      Aligns rasters to the grid of the second raster before differencing.
      If save=True, saves the difference raster with "_diff.tif" suffix.



   .. py:method:: get_bounds(latlon=True, json_format=True)

      Get the geographic bounds of the raster.

      :param latlon: Whether to return bounds in latitude/longitude, default is True
      :type latlon: bool, optional
      :param json_format: Whether to return bounds in GeoJSON-like format, default is True
      :type json_format: bool, optional

      :returns: If json_format=True: list of corner coordinates as dictionaries
                If json_format=False: tuple of (min_x, min_y, max_x, max_y)
      :rtype: list or tuple



   .. py:method:: get_epsg_code()

      Get the EPSG code for the raster's coordinate reference system.

      :returns: EPSG code
      :rtype: int

      .. rubric:: Notes

      If the CRS has no exact EPSG match (e.g. a compound or 3D-promoted
      CRS such as "EPSG:32610+EPSG:4979"), falls back to the EPSG code
      of the horizontal (2D) component.



   .. py:method:: get_gsd()

      Get the ground sample distance (resolution) of the raster.

      :returns: Ground sample distance (pixel size) in raster units
      :rtype: float



   .. py:method:: get_ndv()

      Get the no-data value for the raster.

      :returns: No-data value
      :rtype: float or int

      .. rubric:: Notes

      If no-data value is not defined in the raster metadata,
      tries to infer it from the first pixel value.



   .. py:method:: get_utm_epsg_code()

      Estimate the appropriate UTM EPSG code for this raster's location.

      Uses the raster's center coordinates to determine the correct
      UTM zone via the PROJ database. Works regardless of the raster's
      current CRS (geographic or projected).

      :returns: UTM EPSG code (e.g., 32616 for UTM Zone 16N)
      :rtype: int



   .. py:method:: hillshade()

      Generate a hillshade from the raster.

      :returns: Hillshade array
      :rtype: numpy.ma.MaskedArray

      .. rubric:: Notes

      First checks if a hillshade file already exists with "_hs.tif" suffix.
      If not, generates the hillshade using GDAL.



   .. py:method:: load_and_diff_rasters(first_fn, second_fn)
      :staticmethod:


      Load two rasters, align them, and compute their difference.

      :param first_fn: Path to the first raster file
      :type first_fn: str
      :param second_fn: Path to the second raster file (used as reference grid)
      :type second_fn: str

      :returns: (difference array (second_raster - first_raster), transform, CRS, nodata)
      :rtype: tuple

      .. rubric:: Notes

      The first raster is reprojected and resampled to match the second raster's
      grid before differencing. Both rasters are cropped to their intersection first
      (matching geoutils behavior). Uses rioxarray for efficient reprojection.



   .. py:method:: read_array(b=1, extent=False)

      Read raster data as a numpy masked array.

      :param b: Band number to read, default is 1
      :type b: int, optional
      :param extent: Whether to return extent information for plotting, default is False
      :type extent: bool, optional

      :returns: If extent=False: masked array of raster data
                If extent=True: tuple of (masked array, extent)
      :rtype: numpy.ma.MaskedArray or tuple

      .. rubric:: Notes

      No-data values are properly masked, and invalid values are fixed.
      If downsample > 1, reads a downsampled version of the raster.



   .. py:method:: read_raster_subset(bbox, b=1)

      Read a subset of raster data defined by a bounding box.

      :param bbox: Bounding box in the format (ul_x, lr_y, lr_x, ul_y)
                   (upper-left x, lower-right y, lower-right x, upper-left y)
      :type bbox: tuple
      :param b: Band number to read, default is 1
      :type b: int, optional

      :returns: Masked array of subset raster data with nodata values masked
      :rtype: numpy.ma.MaskedArray

      .. rubric:: Notes

      No-data values are properly masked, similar to read_array()



   .. py:method:: save_raster(data, output_fn, reference_fn, dtype=None, nodata=None)
      :staticmethod:


      Save a numpy array as a GeoTIFF using a reference raster's profile.

      :param data: Data array to save
      :type data: numpy.ndarray
      :param output_fn: Output file path
      :type output_fn: str
      :param reference_fn: Reference raster file to copy metadata from
      :type reference_fn: str
      :param dtype: Data type for output raster, default is None (uses float32)
      :type dtype: numpy.dtype or str, optional
      :param nodata: No-data value for output raster, default is None (uses reference nodata)
      :type nodata: int or float, optional

      :rtype: None

      .. rubric:: Notes

      Copies CRS, transform, and other metadata from the reference raster.
      Useful for saving processed data that should align with an existing raster.

      .. rubric:: Examples

      >>> diff = raster1.data - raster2.data
      >>> Raster.save_raster(diff, "difference.tif", reference_fn="raster2.tif")



   .. py:property:: data

      Lazy-loaded raster data.

      :returns: Raster data (loaded on first access)
      :rtype: numpy.ma.MaskedArray


   .. py:attribute:: downsample
      :value: 1



   .. py:attribute:: fn


   .. py:property:: transform

      Get the affine transform for the raster.

      :returns: Affine transform (adjusted for downsampling if applicable)
                or None if the transform is identity (not georeferenced)
      :rtype: affine.Affine


.. py:function:: add_copyright_overlay(ax)

   Add Vantor copyright text overlay to the bottom-right of a matplotlib axes.

   :param ax: The axes to add the copyright overlay to.
   :type ax: matplotlib.axes.Axes


.. py:function:: detect_planetary_body(dem_fn)

   Detect planetary body from DEM CRS WKT.

   Inspects the DATUM/ELLIPSOID fields of the DEM's CRS WKT string to
   determine the planetary body.  ASP's ``point2dem`` consistently encodes
   the body name in these fields regardless of projection type.

   :param dem_fn: Path to the DEM file.
   :type dem_fn: str

   :returns: One of ``"earth"``, ``"moon"``, or ``"mars"``.
   :rtype: str


.. py:function:: detect_vantor_satellite(directory)

   Check if XML files in directory indicate a Vantor (WorldView) satellite.

   :param directory: Path to directory containing XML camera model files.
   :type directory: str

   :returns: True if any XML file contains a WorldView SATID (WV01, WV02, WV03, etc.).
   :rtype: bool


.. py:function:: get_acquisition_dates(directory, extra_dirs=None)

   Extract scene acquisition date(s) from metadata in a processing directory.

   Looks for WorldView/Maxar-style XML camera files (using the ``FIRSTLINETIME``
   tag) and ASTER L1A file or directory names (which encode the capture date in
   the filename: ``AST_L1A_<prodcode><MMDDYYYY><HHMMSS>_...``). Returns a sorted,
   deduplicated list of date strings. An empty list is returned if nothing is
   found.

   :param directory: Top-level ASP processing directory to search (non-recursive for XMLs,
                     recursive for ASTER L1A filenames).
   :type directory: str
   :param extra_dirs: Additional directories to search non-recursively for XML files (e.g. a
                      stereo or bundle-adjust subdirectory).
   :type extra_dirs: list of str, optional

   :returns: Acquisition datetime strings formatted as ``YYYY-MM-DD HH:MM:SS UTC``.
   :rtype: list of str


.. py:function:: get_planetary_bounds(dem_fn, body=None)

   Get DEM bounds in planetocentric lon/lat with 0-360 east-positive longitude.

   Reprojects the DEM's native-CRS bounding box into the body's geographic
   coordinate system, which is the format required by the ODE GDS REST API.

   :param dem_fn: Path to the DEM file.
   :type dem_fn: str
   :param body: Planetary body (``"earth"``, ``"moon"``, ``"mars"``).  If None,
                auto-detected from the DEM CRS via :func:`detect_planetary_body`.
   :type body: str or None, optional

   :returns: Dictionary with keys ``westernlon``, ``easternlon``, ``minlat``,
             ``maxlat`` in planetocentric coordinates with east-positive 0-360
             longitude.
   :rtype: dict


.. py:function:: get_utm_epsg(lon, lat)

   Get the UTM EPSG code for a given longitude and latitude.

   Uses the PROJ database to determine the correct UTM zone.

   :param lon: Longitude in degrees
   :type lon: float
   :param lat: Latitude in degrees
   :type lat: float

   :returns: UTM EPSG code (e.g., 32616 for UTM Zone 16N)
   :rtype: int


.. py:function:: get_xml_tag(xml, tag, all=False)

   Extract value(s) from XML tag(s).

   Parses an XML file and extracts the content of specified tag(s).

   :param xml: Path to XML file
   :type xml: str
   :param tag: XML tag to extract
   :type tag: str
   :param all: If True, find all occurrences of the tag; if False, find first occurrence
               Default is False
   :type all: bool, optional

   :returns: If all=False: string content of the first matching tag
             If all=True: list of string contents for all matching tags
   :rtype: str or list

   :raises ValueError: If the tag is not found in the XML file

   .. rubric:: Examples

   >>> satid = get_xml_tag("path/to/file.xml", "SATID")
   >>> ephemeris = get_xml_tag("path/to/file.xml", "EPHEMLIST", all=True)


.. py:function:: glob_file(directory, *patterns, all_files=False)

   Find files matching pattern(s) in a directory.

   Searches a directory for files matching one or more glob patterns.
   By default, returns the first matching file found. If all_files is True,
   returns all matching files.

   :param directory: Directory to search in
   :type directory: str
   :param \*patterns: One or more glob patterns (e.g., "*.tif", "*DEM.tif")
   :type \*patterns: str
   :param all_files: If True, return all matching files; if False, return only the first match.
                     Default is False.
   :type all_files: bool, optional

   :returns: If all_files is False, returns the first matching file path or None if no matches
             If all_files is True, returns a list of matching file paths or None if no matches
   :rtype: str or list or None

   .. rubric:: Examples

   >>> first_tif = glob_file("/path/to/dir", "*.tif")
   >>> all_tifs = glob_file("/path/to/dir", "*.tif", all_files=True)
   >>> dem_file = glob_file("/path/to/dir", "*-DEM.tif", "*_dem.tif")


.. py:function:: run_subprocess_command(command)

   Run a subprocess command and stream output.

   Executes a shell command using subprocess, streaming its
   output in real time to the console.

   :param command: Command to execute as a list of arguments or a single string
   :type command: list or str

   :returns: Return code from the command (0 for success)
   :rtype: int

   .. rubric:: Notes

   This function prints command output in real time and indicates
   whether command execution was successful.

   .. rubric:: Examples

   >>> run_subprocess_command(["ls", "-la"])
   >>> run_subprocess_command("dg_mosaic --skip-tif-gen --output-prefix output_file input_files")


.. py:function:: save_figure(fig, save_dir=None, fig_fn=None, dpi=None)

   Save a matplotlib figure to a file.

   Saves a figure to the specified directory with the given filename.
   Creates the directory if it doesn't exist.

   :param fig: Figure object to save
   :type fig: matplotlib.figure.Figure
   :param save_dir: Directory to save the figure in
   :type save_dir: str, optional
   :param fig_fn: Filename for the saved figure
   :type fig_fn: str, optional
   :param dpi: Resolution in dots per inch. Default is None, which uses the
               figure's own DPI setting.
   :type dpi: int or None, optional

   :rtype: None

   :raises ValueError: If save_dir or fig_fn is not provided

   .. rubric:: Examples

   >>> fig, ax = plt.subplots()
   >>> ax.plot([1, 2, 3], [1, 2, 3])
   >>> save_figure(fig, save_dir='plots', fig_fn='line_plot.png')


.. py:function:: show_existing_figure(filename)

   Display an existing figure from a file.

   Loads and displays an image file using matplotlib.

   :param filename: Path to the image file
   :type filename: str

   :returns: Displays the figure in the current matplotlib figure
   :rtype: None

   .. rubric:: Notes

   If the file does not exist, a message is printed and no figure is displayed.


.. py:data:: logger

