66from typing import Literal
77
88from pygmt ._typing import PathLike , TableLike
9- from pygmt .alias import Alias , AliasSystem
9+ from pygmt .alias import Alias , AliasSystem , _to_string
1010from pygmt .clib import Session
11- from pygmt .exceptions import GMTParameterError
11+ from pygmt .exceptions import GMTParameterError , GMTTypeError
1212from pygmt .helpers import (
1313 build_arg_list ,
14+ data_kind ,
1415 deprecate_parameter ,
1516 fmt_docstring ,
17+ is_nonstr_iter ,
1618 kwargs_to_strings ,
1719 use_alias ,
1820)
@@ -70,13 +72,122 @@ def _alias_option_D( # ruff: ignore[invalid-function-name]
7072 ]
7173
7274
75+ def _alias_option_N ( # ruff: ignore[invalid-function-name]
76+ distribution = None , distribution_pen = None
77+ ):
78+ """
79+ Helper function to create the alias for the -N option.
80+
81+ The ``-N`` option may be repeated to draw several distribution curves, so
82+ ``distribution`` also accepts a sequence of modes. ``distribution_pen`` is either a
83+ single pen, used for every curve, or one pen per curve.
84+
85+ Examples
86+ --------
87+ >>> def parse(**kwargs):
88+ ... return build_arg_list(AliasSystem(N=_alias_option_N(**kwargs)))
89+ >>> parse()
90+ []
91+ >>> # A single curve
92+ >>> parse(distribution="mean")
93+ ['-N0']
94+ >>> parse(distribution="median", distribution_pen="1p,blue")
95+ ['-N1+p1p,blue']
96+
97+ >>> # Multiple curves
98+ >>> parse(distribution=["mean", "lms"])
99+ ['-N0', '-N2']
100+
101+ >>> # A single pen is used for every curve
102+ >>> parse(distribution=["mean", "lms"], distribution_pen="1p,red")
103+ ['-N0+p1p,red', '-N2+p1p,red']
104+
105+ >>> # Several curves, each with its own pen
106+ >>> parse(
107+ ... distribution=["mean", "median", "lms"],
108+ ... distribution_pen=["1p,red", "1p,blue", "1p,green"],
109+ ... )
110+ ['-N0+p1p,red', '-N1+p1p,blue', '-N2+p1p,green']
111+
112+ >>> # A pen alone without distribution is ignored.
113+ >>> parse(distribution_pen="1p,red")
114+ []
115+
116+ >>> # Backward compatibility: the legacy syntax combines the mode and the pen into
117+ >>> # a single string, and is passed through as is.
118+ >>> parse(distribution="0+p1p,blue")
119+ ['-N0+p1p,blue']
120+ >>> parse(distribution=["0+p1p,blue", "1+p1p,red"])
121+ ['-N0+p1p,blue', '-N1+p1p,red']
122+ >>> parse(distribution="+p1p,blue")
123+ ['-N+p1p,blue']
124+ >>> parse(distribution="1")
125+ ['-N1']
126+ >>> parse(distribution=True)
127+ ['-N']
128+
129+ >>> # But the legacy syntax cannot be mixed with 'distribution_pen'.
130+ >>> parse(distribution="0+p1p,blue", distribution_pen="1p,red")
131+ Traceback (most recent call last):
132+ ...
133+ pygmt.exceptions.GMTParameterError: Conflicting parameters: 'distribution_pen' ...
134+
135+ >>> parse(distribution="invalid")
136+ Traceback (most recent call last):
137+ ...
138+ pygmt.exceptions.GMTValueError: Invalid value for parameter 'distribution': ...
139+
140+ >>> parse(distribution=["mean", "lms"], distribution_pen=["1p,red"])
141+ Traceback (most recent call last):
142+ ...
143+ pygmt.exceptions.GMTParameterError: 'distribution_pen' must be a single pen or ...
144+ """
145+ # Do nothing if distribution is not specified. Ignoring distribution_pen.
146+ if distribution is None :
147+ return Alias (None , name = "distribution" )
148+
149+ modes = distribution if is_nonstr_iter (distribution ) else [distribution ]
150+ # The legacy syntax gives the mode and the pen as a single string (e.g., "1+p1p,red"
151+ # or "+p1p,red"), or the mode as a string (e.g. "1"). Pass it as is.
152+ if any (isinstance (mode , str ) and ("+" in mode or mode .isdigit ()) for mode in modes ):
153+ if distribution_pen is not None :
154+ raise GMTParameterError (
155+ conflicts_with = ("distribution_pen" , ["distribution" ]),
156+ reason = "'distribution' is using the legacy syntax." ,
157+ )
158+ return Alias (distribution , name = "distribution" )
159+
160+ pens = (
161+ distribution_pen
162+ if is_nonstr_iter (distribution_pen )
163+ else [distribution_pen ] * len (modes )
164+ )
165+ if len (pens ) != len (modes ):
166+ raise GMTParameterError (
167+ reason = (
168+ "'distribution_pen' must be a single pen or one pen per curve, but "
169+ f"got { len (pens )} pen(s) for { len (modes )} curve(s)."
170+ )
171+ )
172+
173+ values = []
174+ for mode , pen in zip (modes , pens , strict = True ):
175+ _mode = _to_string (
176+ mode , mapping = {"mean" : 0 , "median" : 1 , "lms" : 2 }, name = "distribution"
177+ )
178+ _pen = _to_string (pen , prefix = "+p" , name = "distribution_pen" )
179+ if _mode is None : # e.g. distribution=False means no curve at all.
180+ continue
181+ values .append (_mode if _pen is None else f"{ _mode } { _pen } " )
182+ return Alias (values , name = "distribution" )
183+
184+
73185@fmt_docstring
74186# TODO(PyGMT>=0.22.0): Remove the deprecated "extreme" parameter.
75187# TODO(PyGMT>=0.22.0): Remove the deprecated "annotate" parameter.
76188@deprecate_parameter ("extreme" , "out_range" , "0.20.0" , remove_version = "0.22.0" )
77189@deprecate_parameter ("annotate" , "annot" , "0.20.0" , remove_version = "0.22.0" )
78190@use_alias (
79- N = "distribution" ,
80191 T = "series" ,
81192 Z = "histtype" ,
82193 b = "binary" ,
@@ -90,6 +201,7 @@ def _alias_option_D( # ruff: ignore[invalid-function-name]
90201def histogram (
91202 self ,
92203 data : PathLike | TableLike ,
204+ weights : bool | Sequence [float ] = False ,
93205 bar_width : float | str | None = None ,
94206 bar_offset : float | str | None = None ,
95207 cmap : str | bool = False ,
@@ -105,6 +217,10 @@ def histogram(
105217 out_range : Literal ["first" , "last" , "both" ] | None = None ,
106218 stairs : bool = False ,
107219 cumulative : bool | Literal ["reverse" ] = False ,
220+ distribution : Literal ["mean" , "median" , "lms" ]
221+ | Sequence [Literal ["mean" , "median" , "lms" ]]
222+ | None = None ,
223+ distribution_pen : str | Sequence [str ] | None = None ,
108224 projection : str | None = None ,
109225 region : Sequence [float | str ] | str | None = None ,
110226 frame : Frame | Axis | Literal ["none" ] | str | Sequence [str ] | bool = False ,
@@ -131,6 +247,7 @@ def histogram(
131247 - G = fill
132248 - J = projection
133249 - L = out_range
250+ - N = distribution, **+p**: distribution_pen
134251 - Q = cumulative
135252 - R = region
136253 - S = stairs
@@ -146,6 +263,14 @@ def histogram(
146263 data
147264 Pass in either a file name to an ASCII data table, a Python list, a 2-D
148265 $table_classes.
266+ weights
267+ Use weighted counts instead of pure counts [Default is ``False``, i.e., pure
268+ counts are used]. It can be:
269+
270+ - ``True``: Weights are provided in the second column of ``data``, if ``data``
271+ is a file name or a 2-D sequence.
272+ - A 1-D array of weights, one per data point, requiring that ``data`` is a 1-D
273+ sequence of values.
149274 $cmap
150275 pen
151276 Draw bar outline (or stair-case curve) using the specified pen thickness
@@ -177,15 +302,6 @@ def histogram(
177302 center
178303 Center bin on each value specified via ``series`` [Default uses the values to
179304 define the left edge of each bin].
180- distribution : bool, float, or str
181- [*mode*][**+p**\ *pen*].
182- Draw the equivalent normal distribution; append desired
183- *pen* [Default is ``"0.25p,black,solid"``].
184- The *mode* selects which central location and scale to use:
185-
186- * 0 = mean and standard deviation [Default];
187- * 1 = median and L1 scale (1.4826 \* median absolute deviation; MAD);
188- * 2 = LMS (least median of squares) mode and scale.
189305 out_range
190306 Handle values that fall outside the range set by ``series``. By default, these
191307 values are ignored. Valid values are:
@@ -207,8 +323,9 @@ def histogram(
207323 [*min*\ /*max*\ /]\ *inc*\ [**+n**\ ].
208324 Set the interval for the width of each bar in the histogram.
209325 histtype : int or str
210- [*type*][**+w**].
211- Choose between 6 types of histograms:
326+ [*type*].
327+ By default, pure counts are determined. To get weighted counts, use the
328+ ``weights`` parameter. Choose between 6 types of histograms:
212329
213330 * 0 = counts [Default]
214331 * 1 = frequency_percent
@@ -217,8 +334,23 @@ def histogram(
217334 * 4 = log10 (1.0 + count)
218335 * 5 = log10 (1.0 + frequency_percent).
219336
220- To use weights provided as a second data column instead of pure counts,
221- append **+w**.
337+ distribution
338+ Draw the equivalent normal distribution. Select which central location and scale
339+ to use:
340+
341+ - ``"mean"``: mean and standard deviation
342+ - ``"median"``: median and L1 scale (1.4826 \* median absolute deviation)
343+ - ``"lms"``: least median of squares (LMS) mode and scale
344+
345+ Pass a sequence of modes to draw several curves at once, e.g.,
346+ ``["mean", "lms"]``.
347+
348+ **Note**: If ``wrap`` is used, only ``"mean"`` is available and the circular
349+ von Mises distribution is determined instead.
350+ distribution_pen
351+ Pen used to draw the distribution curve [Default is ``"0.25p,black,solid"``].
352+ Pass a sequence of pens to use a different pen for each curve; a single pen is
353+ used for all of them. If ``distribution`` is not set, this parameter is ignored.
222354 $projection
223355 $region
224356 $frame
@@ -251,6 +383,16 @@ def histogram(
251383 required = "bar_width" , reason = "Required when 'bar_offset' is set."
252384 )
253385
386+ # weights can be given as a 1-D array, or as a boolean to indicate that the second
387+ # column of data contains weights. If weights is an array, then data must be a 1-D
388+ # sequence of values.
389+ _weight_is_array = is_nonstr_iter (weights )
390+ if data_kind (data ) == "file" and _weight_is_array :
391+ raise GMTTypeError (
392+ type (weights ),
393+ reason = "'weights' must be boolean when 'data' is a file name." ,
394+ )
395+
254396 aliasdict = AliasSystem (
255397 A = Alias (horizontal , name = "horizontal" ),
256398 C = Alias (cmap , name = "cmap" ),
@@ -272,6 +414,7 @@ def histogram(
272414 name = "out_range" ,
273415 mapping = {"first" : "l" , "last" : "h" , "both" : "b" },
274416 ),
417+ N = _alias_option_N (distribution , distribution_pen ),
275418 Q = Alias (cumulative , name = "cumulative" , mapping = {"reverse" : "r" }),
276419 S = Alias (stairs , name = "stairs" ),
277420 W = Alias (pen , name = "pen" ),
@@ -286,10 +429,13 @@ def histogram(
286429 t = transparency ,
287430 )
288431 aliasdict .merge (kwargs )
432+ if weights is not False :
433+ aliasdict ["Z" ] = f"{ aliasdict .get ('Z' , '' )} +w"
289434
290435 self ._activate_figure ()
291436 with Session () as lib :
292- with lib .virtualfile_in (check_kind = "vector" , data = data ) as vintbl :
437+ vfargs = {"x" : data , "y" : weights } if _weight_is_array else {"data" : data }
438+ with lib .virtualfile_in (check_kind = "vector" , ** vfargs ) as vintbl :
293439 lib .call_module (
294440 module = "histogram" , args = build_arg_list (aliasdict , infile = vintbl )
295441 )
0 commit comments