Source code for pygmt.src.histogram

"""
Histogram - Calculate and plot histograms.
"""

from collections.abc import Sequence
from typing import Literal

from pygmt._typing import PathLike, TableLike
from pygmt.alias import Alias, AliasSystem, _to_string
from pygmt.clib import Session
from pygmt.exceptions import GMTParameterError, GMTTypeError
from pygmt.helpers import (
    build_arg_list,
    data_kind,
    deprecate_parameter,
    fmt_docstring,
    is_nonstr_iter,
    kwargs_to_strings,
    use_alias,
)
from pygmt.params import Axis, Frame, Pattern

__doctest_skip__ = ["histogram"]


def _alias_option_D(  # ruff: ignore[invalid-function-name]
    annot: bool = False,
    annot_position: Literal["start", "end"] = "end",
    annot_font: str | None = None,
    annot_offset: float | str | None = None,
    annot_orientation: Literal["horizontal", "vertical"] = "horizontal",
):
    """
    Helper function to build the -D option for the histogram module.

    >>> def parse(**kwargs):
    ...     return AliasSystem(D=_alias_option_D(**kwargs)).get("D")

    >>> parse(annot=False)
    >>> parse(annot=True)
    ''
    >>> parse(
    ...     annot=True,
    ...     annot_position="start",
    ...     annot_font="12p,Helvetica-Bold",
    ...     annot_offset="6p",
    ...     annot_orientation="vertical",
    ... )
    '+b+f12p,Helvetica-Bold+o6p+r'

    >>> # annot_* parameters are ignored if annot is not set
    >>> parse(annot_position="start")
    """
    # Ignore any annot_* parameter if annot is not set.
    if not annot:
        return Alias(False, name="annot")

    # If annot is set, return a list of Alias objects for the annot_* parameters.
    return [
        Alias(
            annot_position,
            name="annot_position",
            mapping={"start": "+b", "end": ""},
        ),
        Alias(annot_font, name="annot_font", prefix="+f"),
        Alias(annot_offset, name="annot_offset", prefix="+o"),
        Alias(
            annot_orientation,
            name="annot_orientation",
            mapping={"horizontal": "", "vertical": "+r"},
        ),
    ]


def _alias_option_N(  # ruff: ignore[invalid-function-name]
    distribution=None, distribution_pen=None
):
    """
    Helper function to create the alias for the -N option.

    The ``-N`` option may be repeated to draw several distribution curves, so
    ``distribution`` also accepts a sequence of modes. ``distribution_pen`` is either a
    single pen, used for every curve, or one pen per curve.

    Examples
    --------
    >>> def parse(**kwargs):
    ...     return build_arg_list(AliasSystem(N=_alias_option_N(**kwargs)))
    >>> parse()
    []
    >>> # A single curve
    >>> parse(distribution="mean")
    ['-N0']
    >>> parse(distribution="median", distribution_pen="1p,blue")
    ['-N1+p1p,blue']

    >>> # Multiple curves
    >>> parse(distribution=["mean", "lms"])
    ['-N0', '-N2']

    >>> # A single pen is used for every curve
    >>> parse(distribution=["mean", "lms"], distribution_pen="1p,red")
    ['-N0+p1p,red', '-N2+p1p,red']

    >>> # Several curves, each with its own pen
    >>> parse(
    ...     distribution=["mean", "median", "lms"],
    ...     distribution_pen=["1p,red", "1p,blue", "1p,green"],
    ... )
    ['-N0+p1p,red', '-N1+p1p,blue', '-N2+p1p,green']

    >>> # A pen alone without distribution is ignored.
    >>> parse(distribution_pen="1p,red")
    []

    >>> # Backward compatibility: the legacy syntax combines the mode and the pen into
    >>> # a single string, and is passed through as is.
    >>> parse(distribution="0+p1p,blue")
    ['-N0+p1p,blue']
    >>> parse(distribution=["0+p1p,blue", "1+p1p,red"])
    ['-N0+p1p,blue', '-N1+p1p,red']
    >>> parse(distribution="+p1p,blue")
    ['-N+p1p,blue']
    >>> parse(distribution="1")
    ['-N1']
    >>> parse(distribution=True)
    ['-N']

    >>> # But the legacy syntax cannot be mixed with 'distribution_pen'.
    >>> parse(distribution="0+p1p,blue", distribution_pen="1p,red")
    Traceback (most recent call last):
        ...
    pygmt.exceptions.GMTParameterError: Conflicting parameters: 'distribution_pen' ...

    >>> parse(distribution="invalid")
    Traceback (most recent call last):
        ...
    pygmt.exceptions.GMTValueError: Invalid value for parameter 'distribution': ...

    >>> parse(distribution=["mean", "lms"], distribution_pen=["1p,red"])
    Traceback (most recent call last):
        ...
    pygmt.exceptions.GMTParameterError: 'distribution_pen' must be a single pen or ...
    """
    # Do nothing if distribution is not specified. Ignoring distribution_pen.
    if distribution is None:
        return Alias(None, name="distribution")

    modes = distribution if is_nonstr_iter(distribution) else [distribution]
    # The legacy syntax gives the mode and the pen as a single string (e.g., "1+p1p,red"
    # or "+p1p,red"), or the mode as a string (e.g. "1"). Pass it as is.
    if any(isinstance(mode, str) and ("+" in mode or mode.isdigit()) for mode in modes):
        if distribution_pen is not None:
            raise GMTParameterError(
                conflicts_with=("distribution_pen", ["distribution"]),
                reason="'distribution' is using the legacy syntax.",
            )
        return Alias(distribution, name="distribution")

    pens = (
        distribution_pen
        if is_nonstr_iter(distribution_pen)
        else [distribution_pen] * len(modes)
    )
    if len(pens) != len(modes):
        raise GMTParameterError(
            reason=(
                "'distribution_pen' must be a single pen or one pen per curve, but "
                f"got {len(pens)} pen(s) for {len(modes)} curve(s)."
            )
        )

    values = []
    for mode, pen in zip(modes, pens, strict=True):
        _mode = _to_string(
            mode, mapping={"mean": 0, "median": 1, "lms": 2}, name="distribution"
        )
        _pen = _to_string(pen, prefix="+p", name="distribution_pen")
        if _mode is None:  # e.g. distribution=False means no curve at all.
            continue
        values.append(_mode if _pen is None else f"{_mode}{_pen}")
    return Alias(values, name="distribution")


@fmt_docstring
# TODO(PyGMT>=0.22.0): Remove the deprecated "extreme" parameter.
# TODO(PyGMT>=0.22.0): Remove the deprecated "annotate" parameter.
@deprecate_parameter("extreme", "out_range", "0.20.0", remove_version="0.22.0")
@deprecate_parameter("annotate", "annot", "0.20.0", remove_version="0.22.0")
@use_alias(
    T="series",
    Z="histtype",
    b="binary",
    d="nodata",
    e="find",
    h="header",
    l="label",
    w="wrap",
)
@kwargs_to_strings(T="sequence")
def histogram(
    self,
    data: PathLike | TableLike,
    weights: bool | Sequence[float] = False,
    bar_width: float | str | None = None,
    bar_offset: float | str | None = None,
    cmap: str | bool = False,
    pen: str | None = None,
    fill: str | Pattern | None = None,
    annot: bool = False,
    annot_position: Literal["start", "end"] = "end",
    annot_font: str | None = None,
    annot_offset: float | str | None = None,
    annot_orientation: Literal["horizontal", "vertical"] = "horizontal",
    horizontal: bool = False,
    center: bool = False,
    out_range: Literal["first", "last", "both"] | None = None,
    stairs: bool = False,
    cumulative: bool | Literal["reverse"] = False,
    distribution: Literal["mean", "median", "lms"]
    | Sequence[Literal["mean", "median", "lms"]]
    | None = None,
    distribution_pen: str | Sequence[str] | None = None,
    projection: str | None = None,
    region: Sequence[float | str] | str | None = None,
    frame: Frame | Axis | Literal["none"] | str | Sequence[str] | bool = False,
    verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"]
    | bool = False,
    panel: int | Sequence[int] | bool = False,
    incols: int | str | Sequence[int | str] | None = None,
    perspective: float | Sequence[float] | str | bool = False,
    transparency: float | None = None,
    **kwargs,
):
    r"""
    Calculate and plot histograms.

    Full GMT docs at :gmt-docs:`histogram.html`.

    $aliases
       - A = horizontal
       - B = frame
       - C = cmap
       - D = annot, **+b**: annot_position, **+f**: annot_font, **+o**: annot_offset,
         **+r**: annot_orientation
       - E = bar_width, **+o**: bar_offset
       - G = fill
       - J = projection
       - L = out_range
       - N = distribution, **+p**: distribution_pen
       - Q = cumulative
       - R = region
       - S = stairs
       - V = verbose
       - W = pen
       - c = panel
       - i = incols
       - p = perspective
       - t = transparency

    Parameters
    ----------
    data
        Pass in either a file name to an ASCII data table, a Python list, a 2-D
        $table_classes.
    weights
        Use weighted counts instead of pure counts [Default is ``False``, i.e., pure
        counts are used]. It can be:

        - ``True``: Weights are provided in the second column of ``data``, if ``data``
          is a file name or a 2-D sequence.
        - A 1-D array of weights, one per data point, requiring that ``data`` is a 1-D
          sequence of values.
    $cmap
    pen
        Draw bar outline (or stair-case curve) using the specified pen thickness
        [Default is no outline].
    fill
         Set color or pattern for filling bars [Default is no fill].
    annot
        If ``True``, annotate each bar with the value it represents. The remaining
        ``annot_*`` parameters control how the annotations look and are ignored if
        ``annot`` is not set.
    annot_position
        Position of the annotations relative to the bars. Valid values are ``"start"``
        and ``"end"``.
    annot_font
        Font of the annotations [Default is :gmt-term:`FONT_ANNOT_PRIMARY`].
    annot_offset
        Offset between a bar and its annotation, with an optional
        :ref:`dimension unit <dimension-units>` [Default is ``"6p"``].
    annot_orientation
        Orientation of the annotations. Valid values are ``"horizontal"`` and
        ``"vertical"``.
    bar_width
        Use an alternative histogram bar width than the default set via ``series``. Give
        either an alternative width in data units, or the user may append a
        :ref:`dimension unit <dimension-units>` for a fixed dimension instead.
    bar_offset
        Shift all bars along the axis by a constant value. It may be given in data units
        of plot dimension units by appending the relevant unit. Requires ``bar_width``.
    center
        Center bin on each value specified via ``series`` [Default uses the values to
        define the left edge of each bin].
    out_range
        Handle values that fall outside the range set by ``series``. By default, these
        values are ignored. Valid values are:

        - ``"first"``: only include values below first bin into the first bin
        - ``"last"``: only include values above the last bin into that last bin
        - ``"both"``: include values into the first or last bins
    cumulative
        Pass ``True`` to draw a cumulative histogram, or set it to ``"reverse"`` to draw
        a reverse cumulative histogram instead.
    stairs
        Draw a stairs-step diagram which does not include the internal bars of the
        default histogram.
    horizontal
        Plot the histogram horizontally from x = 0 [Default is vertically from y = 0].
        The plot dimensions remain the same, but the two axes are flipped, i.e., the
        x-axis is plotted vertically and the y-axis is plotted horizontally.
    series : int, str, or list
        [*min*\ /*max*\ /]\ *inc*\ [**+n**\ ].
        Set the interval for the width of each bar in the histogram.
    histtype : int or str
        [*type*].
        By default, pure counts are determined. To get weighted counts, use the
        ``weights`` parameter. Choose between 6 types of histograms:

        * 0 = counts [Default]
        * 1 = frequency_percent
        * 2 = log (1.0 + count)
        * 3 = log (1.0 + frequency_percent)
        * 4 = log10 (1.0 + count)
        * 5 = log10 (1.0 + frequency_percent).

    distribution
        Draw the equivalent normal distribution. Select which central location and scale
        to use:

        - ``"mean"``: mean and standard deviation
        - ``"median"``: median and L1 scale (1.4826 \* median absolute deviation)
        - ``"lms"``: least median of squares (LMS) mode and scale

        Pass a sequence of modes to draw several curves at once, e.g.,
        ``["mean", "lms"]``.

        **Note**: If ``wrap`` is used, only ``"mean"`` is available and the circular
        von Mises distribution is determined instead.
    distribution_pen
        Pen used to draw the distribution curve [Default is ``"0.25p,black,solid"``].
        Pass a sequence of pens to use a different pen for each curve; a single pen is
        used for all of them. If ``distribution`` is not set, this parameter is ignored.
    $projection
    $region
    $frame
    $verbose
    $binary
    $panel
    $nodata
    $find
    $header
    $incols
    $label
    $perspective
    $transparency
    $wrap

    Examples
    --------

    >>> import numpy as np
    >>> import pygmt
    >>> # Generate random data from a normal distribution
    >>> rng = np.random.default_rng(seed=100)
    >>> data = rng.normal(loc=100, scale=25, size=1024)
    >>> fig = pygmt.Figure()
    >>> fig.histogram(data=data, frame=True, series=5, fill="red3", pen="1p")
    >>> fig.show()
    """
    if bar_offset is not None and bar_width is None:
        raise GMTParameterError(
            required="bar_width", reason="Required when 'bar_offset' is set."
        )

    # weights can be given as a 1-D array, or as a boolean to indicate that the second
    # column of data contains weights. If weights is an array, then data must be a 1-D
    # sequence of values.
    _weight_is_array = is_nonstr_iter(weights)
    if data_kind(data) == "file" and _weight_is_array:
        raise GMTTypeError(
            type(weights),
            reason="'weights' must be boolean when 'data' is a file name.",
        )

    aliasdict = AliasSystem(
        A=Alias(horizontal, name="horizontal"),
        C=Alias(cmap, name="cmap"),
        D=_alias_option_D(
            annot=annot,
            annot_position=annot_position,
            annot_font=annot_font,
            annot_offset=annot_offset,
            annot_orientation=annot_orientation,
        ),
        E=[
            Alias(bar_width, name="bar_width"),
            Alias(bar_offset, name="bar_offset", prefix="+o"),
        ],
        F=Alias(center, name="center"),
        G=Alias(fill, name="fill"),
        L=Alias(
            out_range,
            name="out_range",
            mapping={"first": "l", "last": "h", "both": "b"},
        ),
        N=_alias_option_N(distribution, distribution_pen),
        Q=Alias(cumulative, name="cumulative", mapping={"reverse": "r"}),
        S=Alias(stairs, name="stairs"),
        W=Alias(pen, name="pen"),
    ).add_common(
        B=frame,
        J=projection,
        R=region,
        V=verbose,
        c=panel,
        i=incols,
        p=perspective,
        t=transparency,
    )
    aliasdict.merge(kwargs)
    if weights is not False:
        aliasdict["Z"] = f"{aliasdict.get('Z', '')}+w"

    self._activate_figure()
    with Session() as lib:
        vfargs = {"x": data, "y": weights} if _weight_is_array else {"data": data}
        with lib.virtualfile_in(check_kind="vector", **vfargs) as vintbl:
            lib.call_module(
                module="histogram", args=build_arg_list(aliasdict, infile=vintbl)
            )