Coverage for python/bellhop/plotutils.py: 49%
334 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 12:04 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-22 12:04 +0000
1##############################################################################
2#
3# Copyright (c) 2025-, Will Robertson
4# Copyright (c) 2018-2025, Mandar Chitre
5#
6# This file was originally part of arlpy, released under Simplified BSD License.
7# It has been relicensed in this repository to be compatible with the Bellhop licence (GPL).
8#
9##############################################################################
11"""Easy-to-use plotting utilities based on `Bokeh <http://bokeh.pydata.org>`_."""
13from typing import Any, Optional, List, Tuple, Union
14import numpy as _np
15import os as _os
16import warnings as _warnings
17from tempfile import mkstemp as _mkstemp
18import bokeh.plotting as _bplt
19import bokeh.models as _bmodels
20import bokeh.resources as _bres
21import bokeh.io as _bio
22import scipy.signal as _sig
24light_palette = ['mediumblue', 'crimson', 'forestgreen', 'gold', 'darkmagenta', 'olive', 'palevioletred', 'yellowgreen',
25 'deepskyblue', 'dimgray', 'indianred', 'mediumaquamarine', 'orange', 'saddlebrown', 'teal', 'mediumorchid']
26dark_palette = ['lightskyblue', 'red', 'limegreen', 'salmon', 'magenta', 'forestgreen', 'silver', 'teal']
28_figure = None
29_figures = None
30_hold = False
31_figsize = (600, 400)
32_color = 0
33_notebook = False
34_disable_js = False
35_using_js = False
36_interactive = True
37_static_images = False
38_colors = light_palette
40# Detect Jupyter notebook environment
41try:
42 from IPython import get_ipython
43 ipython = get_ipython()
44 _notebook = ipython is not None and "ZMQInteractiveShell" in ipython.__class__.__name__
45 if _notebook:
46 _bplt.output_notebook(resources=_bres.INLINE, hide_banner=True)
47except (ImportError, AttributeError):
48 _notebook = False
50def _new_figure(title: Optional[str], width: Optional[int], height: Optional[int], xlabel: Optional[str], ylabel: Optional[str], xlim: Optional[Tuple[float, float]], ylim: Optional[Tuple[float, float]], xtype: Optional[str], ytype: Optional[str], interactive: Optional[bool]) -> Any:
51 global _color, _figure
53 width = width or _figsize[0]
54 height = height or _figsize[1]
55 _color = 0
56 tools: Union[List[str], str] = []
57 interactive = interactive or _interactive
58 if interactive:
59 tools = 'pan,box_zoom,wheel_zoom,reset,save'
60 args = dict(title=title, width=width, height=height, x_range=xlim, y_range=ylim, x_axis_label=xlabel, y_axis_label=ylabel, x_axis_type=xtype, y_axis_type=ytype, tools=tools)
62 if _figure is not None:
63 f = _figure
64 if title:
65 f.title.text = title
66 if width:
67 f.width = width
68 if height:
69 f.height = height
70 if xlabel and f.xaxis:
71 f.xaxis[0].axis_label = xlabel
72 if ylabel and f.yaxis:
73 f.yaxis[0].axis_label = ylabel
74 if xlim and hasattr(f, "x_range"):
75 f.x_range.start, f.x_range.end = xlim
76 if ylim and hasattr(f, "y_range"):
77 f.y_range.start, f.y_range.end = ylim
78 return f
80 f = _bplt.figure(**{k:v for (k,v) in args.items() if v is not None})
81 f.toolbar.logo = None
82 _figure = f
83 return f
85def _process_canvas(figures: List[Any]) -> None:
86 """Replace non-interactive Bokeh canvases with static images in Jupyter notebooks.
88 This optimization converts non-interactive plots to static images to reduce
89 JavaScript overhead in notebooks. Only runs if JavaScript is enabled and there
90 are figures without interactive tools.
91 """
92 global _using_js
94 if _disable_js or (not figures and _using_js):
95 return
97 # Find indices of non-interactive figures
98 disable_indices = [i + 1 for i, f in enumerate(figures) if f is not None and not f.tools]
100 if not disable_indices and not _using_js:
101 return
103 _using_js = True
105 # JavaScript to convert non-interactive canvases to static images
106 js_code = f"""
107 var disable = {disable_indices};
108 var clist = document.getElementsByClassName('bk-canvas');
109 var j = 0;
110 for (var i = 0; i < clist.length; i++) {{
111 if (clist[i].id == '') {{
112 j++;
113 clist[i].id = 'bkc-' + String(i) + '-' + String(+new Date());
114 if (disable.indexOf(j) >= 0) {{
115 var png = clist[i].toDataURL();
116 var img = document.createElement('img');
117 img.src = png;
118 clist[i].parentNode.replaceChild(img, clist[i]);
119 }}
120 }}
121 }}
122 """
124 import IPython.display as _ipyd
125 _ipyd.display(_ipyd.Javascript(js_code))
127def _show_static_images(f: Any) -> None:
128 fh, fname = _mkstemp(suffix='.png')
129 _os.close(fh)
130 with _warnings.catch_warnings(): # to avoid displaying deprecation warning
131 _warnings.simplefilter('ignore') # from bokeh 0.12.16
132 _bio.export_png(f, fname)
133 import IPython.display as _ipyd
134 _ipyd.display(_ipyd.Image(filename=fname, embed=True))
135 _os.unlink(fname)
137def _show(f: Any) -> None:
138 if _figures is None:
139 if _static_images:
140 _show_static_images(f)
141 else:
142 _process_canvas([])
143 _bplt.show(f)
144 _process_canvas([f])
145 else:
146 _figures[-1].append(f)
148def _hold_enable(enable: bool) -> bool:
149 global _hold, _figure
150 ohold = _hold
151 _hold = enable
152 if not _hold and _figure is not None:
153 _show(_figure)
154 _figure = None
155 return ohold
157def theme(name: str) -> None:
158 """Set color theme.
160 Parameters
161 ----------
162 name : str
163 Name of theme
165 Examples
166 --------
167 >>> import arlpy.plot
168 >>> arlpy.plot.theme('dark')
169 """
170 if name == 'dark':
171 name = 'dark_minimal'
172 set_colors(dark_palette)
173 elif name == 'light':
174 name = 'light_minimal'
175 set_colors(light_palette)
176 _bio.curdoc().theme = name
178def figsize(x: int, y: int) -> None:
179 """Set the default figure size in pixels.
181 Parameters
182 ----------
183 x : int
184 Figure width
185 y : int
186 Figure height
187 """
188 global _figsize
189 _figsize = (x, y)
191def interactive(b: bool) -> None:
192 """Set default interactivity for plots.
194 Parameters
195 ----------
196 b : bool
197 True to enable interactivity, False to disable it
198 """
199 global _interactive
200 _interactive = b
202def enable_javascript(b: bool) -> None:
203 """Enable/disable Javascript.
205 Parameters
206 ----------
207 b : bool
208 True to use Javascript, False to avoid use of Javascript
210 Notes
211 -----
212 Jupyterlab does not support Javascript output. To avoid error messages,
213 Javascript can be disabled using this call. This removes an optimization
214 to replace non-interactive plots with static images, but other than that
215 does not affect functionality.
216 """
217 global _disable_js
218 _disable_js = not b
220def use_static_images(b: bool = True) -> None:
221 """Use static images instead of dynamic HTML/Javascript in Jupyter notebook.
223 Parameters
224 ----------
225 b : bool, default=True
226 True to use static images, False to use HTML/Javascript
228 Notes
229 -----
230 Static images are useful when the notebook is to be exported as a markdown,
231 LaTeX or PDF document, since dynamic HTML/Javascript is not rendered in these
232 formats. When static images are used, all interactive functionality is disabled.
234 To use static images, you must have the following packages installed:
235 selenium, pillow, phantomjs.
236 """
237 global _static_images, _interactive
238 if not b:
239 _static_images = False
240 return
241 if not _notebook:
242 _warnings.warn('Not running in a Jupyter notebook, static png support disabled')
243 return
244 _interactive = False
245 _static_images = True
247def hold(enable: bool = True) -> Optional[bool]:
248 """Combine multiple plots into one.
250 Parameters
251 ----------
252 enable : bool, default=True
253 True to hold plot, False to release hold
255 Returns
256 -------
257 bool or None
258 Old state of hold if enable is True
260 Examples
261 --------
262 >>> import arlpy.plot
263 >>> oh = arlpy.plot.hold()
264 >>> arlpy.plot.plot([0,10], [0,10], color='blue', legend='A')
265 >>> arlpy.plot.plot([10,0], [0,10], marker='o', color='green', legend='B')
266 >>> arlpy.plot.hold(oh)
267 """
268 rv = _hold_enable(enable)
269 return rv if enable else None
271class figure:
272 """Create a new figure, and optionally automatically display it.
274 Parameters
275 ----------
276 title : str, optional
277 Figure title
278 xlabel : str, optional
279 X-axis label
280 ylabel : str, optional
281 Y-axis label
282 xlim : tuple of float, optional
283 X-axis limits (min, max)
284 ylim : tuple of float, optional
285 Y-axis limits (min, max)
286 xtype : str, default='auto'
287 X-axis type ('auto', 'linear', 'log', etc)
288 ytype : str, default='auto'
289 Y-axis type ('auto', 'linear', 'log', etc)
290 width : int, optional
291 Figure width in pixels
292 height : int, optional
293 Figure height in pixels
294 interactive : bool, optional
295 Enable interactive tools (pan, zoom, etc) for plot
297 Notes
298 -----
299 This function can be used in standalone mode to create a figure:
301 >>> import arlpy.plot
302 >>> arlpy.plot.figure(title='Demo 1', width=500)
303 >>> arlpy.plot.plot([0,10], [0,10])
305 Or it can be used as a context manager to create, hold and display a figure:
307 >>> import arlpy.plot
308 >>> with arlpy.plot.figure(title='Demo 2', width=500):
309 >>> arlpy.plot.plot([0,10], [0,10], color='blue', legend='A')
310 >>> arlpy.plot.plot([10,0], [0,10], marker='o', color='green', legend='B')
312 It can even be used as a context manager to work with Bokeh functions directly:
314 >>> import arlpy.plot
315 >>> with arlpy.plot.figure(title='Demo 3', width=500) as f:
316 >>> f.line([0,10], [0,10], line_color='blue')
317 >>> f.square([3,7], [4,5], line_color='green', fill_color='yellow', size=10)
318 """
320 def __init__(self, title=None, xlabel=None, ylabel=None, xlim=None, ylim=None, xtype='auto', ytype='auto', width=None, height=None, interactive=None):
321 global _figure
322 _figure = _new_figure(title, width, height, xlabel, ylabel, xlim, ylim, xtype, ytype, interactive)
324 def __enter__(self):
325 global _hold
326 _hold = True
327 return _figure
329 def __exit__(self, *args):
330 global _hold, _figure
331 _hold = False
332 _show(_figure)
333 _figure = None
335class many_figures:
336 """Create a grid of many figures.
338 Parameters
339 ----------
340 figsize : tuple of int, optional
341 Default size of figure in grid as (width, height)
343 Examples
344 --------
345 >>> import arlpy.plot
346 >>> with arlpy.plot.many_figures(figsize=(300,200)):
347 >>> arlpy.plot.plot([0,10], [0,10])
348 >>> arlpy.plot.plot([0,10], [0,10])
349 >>> arlpy.plot.next_row()
350 >>> arlpy.plot.next_column()
351 >>> arlpy.plot.plot([0,10], [0,10])
352 """
354 def __init__(self, figsize: Optional[Tuple[int, int]] = None):
355 self.figsize = figsize
356 self.old_figsize: Optional[Tuple[int, int]] = None
358 def __enter__(self) -> None:
359 global _figures, _figsize
360 _figures = [[]]
361 self.old_figsize = _figsize
362 if self.figsize is not None:
363 _figsize = self.figsize
365 def __exit__(self, *args: Any) -> None:
366 global _figures, _figsize
367 if _figures and (len(_figures) > 1 or _figures[0]):
368 # Flatten nested list to get all figures
369 all_figures = [fig for row in _figures for fig in row if fig is not None]
370 gridplot = _bplt.gridplot(_figures, merge_tools=False)
372 if _static_images:
373 _show_static_images(gridplot)
374 else:
375 _process_canvas([])
376 _bplt.show(gridplot)
377 _process_canvas(all_figures)
379 _figures = None
380 _figsize = self.old_figsize
382def next_row() -> None:
383 """Move to the next row in a grid of many figures."""
384 global _figures
385 if _figures is not None:
386 _figures.append([])
388def next_column() -> None:
389 """Move to the next column in a grid of many figures."""
390 global _figures
391 if _figures is not None:
392 _figures[-1].append(None)
394def gcf() -> Any:
395 """Get the current figure.
397 :returns: handle to the current figure
398 """
399 return _figure
401def plot(x: Any,
402 y: Any = None,
403 fs: Optional[float] = None,
404 maxpts: int = 10000,
405 pooling: Optional[str] = None,
406 color: Optional[str] = None,
407 style: str = 'solid',
408 thickness: int = 1,
409 marker: Optional[str] = None,
410 filled: bool = False,
411 size: int = 6,
412 mskip: int = 0,
413 title: Optional[str] = None,
414 xlabel: Optional[str] = None,
415 ylabel: Optional[str] = None,
416 xlim: Optional[Tuple[float, float]] = None,
417 ylim: Optional[Tuple[float, float]] = None,
418 xtype: str = 'auto',
419 ytype: str = 'auto',
420 width: Optional[int] = None,
421 height: Optional[int] = None,
422 legend: Optional[str] = None,
423 interactive: Optional[bool] = None,
424 hold: bool = False,
425 ) -> None:
426 """Plot a line graph or time series.
428 :param x: x data or time series data (if y is None)
429 :param y: y data or None (if time series)
430 :param fs: sampling rate for time series data
431 :param maxpts: maximum number of points to plot (downsampled if more points provided)
432 :param pooling: pooling for downsampling (None, 'max', 'min', 'mean', 'median')
433 :param color: line color (see `Bokeh colors <https://bokeh.pydata.org/en/latest/docs/reference/colors.html>`_)
434 :param style: line style ('solid', 'dashed', 'dotted', 'dotdash', 'dashdot', None)
435 :param thickness: line width in pixels
436 :param marker: point markers ('.', 'o', 's', '*', 'x', '+', 'd', '^')
437 :param filled: filled markers or outlined ones
438 :param size: marker size
439 :param mskip: number of points to skip marking (to avoid too many markers)
440 :param title: figure title
441 :param xlabel: x-axis label
442 :param ylabel: y-axis label
443 :param xlim: x-axis limits (min, max)
444 :param ylim: y-axis limits (min, max)
445 :param xtype: x-axis type ('auto', 'linear', 'log', etc)
446 :param ytype: y-axis type ('auto', 'linear', 'log', etc)
447 :param width: figure width in pixels
448 :param height: figure height in pixels
449 :param legend: legend text
450 :param interactive: enable interactive tools (pan, zoom, etc) for plot
451 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
453 >>> import arlpy.plot
454 >>> import numpy as np
455 >>> arlpy.plot.plot([0,10], [1,-1], color='blue', marker='o', filled=True, legend='A', hold=True)
456 >>> arlpy.plot.plot(np.random.normal(size=1000), fs=100, color='green', legend='B')
457 """
458 global _figure, _color
459 x = _np.asarray(x, dtype=_np.float64)
460 if y is None:
461 y = x
462 x = _np.arange(x.size)
463 if fs is not None:
464 x = x/fs
465 if xlabel is None:
466 xlabel = 'Time (s)'
467 if xlim is None:
468 xlim = (x[0], x[-1])
469 else:
470 y = _np.asarray(y, dtype=_np.float64)
472 if x.ndim == 0: # 0-dimensional array (scalar)
473 x = _np.array([x])
474 if y.ndim == 0: # 0-dimensional array (scalar)
475 y = _np.array([y])
477 _figure = _new_figure(title, width, height, xlabel, ylabel, xlim, ylim, xtype, ytype, interactive)
478 if color is None:
479 color = _colors[_color % len(_colors)]
480 _color += 1
481 if x.size > maxpts:
482 n = int(_np.ceil(x.size / maxpts))
483 x = x[::n]
484 desc = f'Downsampled by {n}'
486 # Apply pooling to reduce data
487 if pooling is None:
488 y = y[::n]
489 else:
490 # Trim data to fit evenly into bins
491 trimmed_size = n * (y.size // n)
492 y_trimmed = y[:trimmed_size].reshape(-1, n)
494 pooling_funcs = {
495 'max': _np.amax,
496 'min': _np.amin,
497 'mean': _np.mean,
498 'median': _np.median
499 }
501 if pooling in pooling_funcs:
502 y = pooling_funcs[pooling](y_trimmed, axis=1)
503 desc += f', {pooling} pooled'
504 else:
505 _warnings.warn(f'Unknown pooling: {pooling}')
506 y = y[::n]
508 # Ensure x and y have the same length
509 if len(x) > len(y):
510 x = x[:len(y)]
512 _figure.add_layout(_bmodels.Label(
513 x=5, y=5, x_units='screen', y_units='screen',
514 text=desc, text_font_size="8pt", text_alpha=0.5
515 ))
516 if style is not None:
517 if legend is None:
518 _figure.line(x, y, line_color=color, line_dash=style, line_width=thickness)
519 else:
520 _figure.line(x, y, line_color=color, line_dash=style, line_width=thickness, legend_label=legend)
521 if marker is not None:
522 scatter(x[::(mskip+1)], y[::(mskip+1)], marker=marker, filled=filled, size=size, color=color, legend=legend, hold=True)
523 if not hold and not _hold:
524 _show(_figure)
525 _figure = None
527def scatter(x: Any, y: Any, marker: str = '.', filled: bool = False, size: int = 6, color: Optional[str] = None, title: Optional[str] = None, xlabel: Optional[str] = None, ylabel: Optional[str] = None, xlim: Optional[Tuple[float, float]] = None, ylim: Optional[Tuple[float, float]] = None, xtype: str = 'auto', ytype: str = 'auto', width: Optional[int] = None, height: Optional[int] = None, legend: Optional[str] = None, hold: bool = False, interactive: Optional[bool] = None) -> None:
528 """Plot a scatter plot.
530 :param x: x data
531 :param y: y data
532 :param color: marker color (see `Bokeh colors`_)
533 :param marker: point markers ('.', 'o', 's', '*', 'x', '+', 'd', '^')
534 :param filled: filled markers or outlined ones
535 :param size: marker size
536 :param title: figure title
537 :param xlabel: x-axis label
538 :param ylabel: y-axis label
539 :param xlim: x-axis limits (min, max)
540 :param ylim: y-axis limits (min, max)
541 :param xtype: x-axis type ('auto', 'linear', 'log', etc)
542 :param ytype: y-axis type ('auto', 'linear', 'log', etc)
543 :param width: figure width in pixels
544 :param height: figure height in pixels
545 :param legend: legend text
546 :param interactive: enable interactive tools (pan, zoom, etc) for plot
547 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
549 >>> import arlpy.plot
550 >>> import numpy as np
551 >>> arlpy.plot.scatter(np.random.normal(size=100), np.random.normal(size=100), color='blue', marker='o')
552 """
553 global _figure, _color
554 if _figure is None:
555 _figure = _new_figure(title, width, height, xlabel, ylabel, xlim, ylim, xtype, ytype, interactive)
556 x = _np.asarray(x, dtype=_np.float64)
557 y = _np.asarray(y, dtype=_np.float64)
558 if color is None:
559 color = _colors[_color % len(_colors)]
560 _color += 1
561 # Build kwargs for marker rendering
562 kwargs = {'size': size, 'line_color': color}
563 if filled:
564 kwargs['fill_color'] = color
565 if legend is not None:
566 kwargs['legend_label'] = legend
568 # Map marker types to Bokeh scatter marker names (using modern Bokeh 3.4+ API)
569 marker_map = {
570 '.': 'circle',
571 'o': 'circle',
572 's': 'square',
573 '*': 'star',
574 'x': 'x',
575 '+': 'cross',
576 'd': 'diamond',
577 '^': 'triangle',
578 }
580 if marker in marker_map:
581 bokeh_marker = marker_map[marker]
582 # Small dots use smaller size and always filled
583 if marker == '.':
584 _figure.scatter(x, y, marker=bokeh_marker, **{**kwargs, 'size': size/2, 'fill_color': color})
585 else:
586 _figure.scatter(x, y, marker=bokeh_marker, **kwargs)
587 elif marker is not None:
588 _warnings.warn(f'Bad marker type: {marker}')
589 if not hold and not _hold:
590 _show(_figure)
591 _figure = None
593def image(img: Any, x: Optional[Any] = None, y: Optional[Any] = None, colormap: str = 'Plasma256', clim: Optional[Tuple[float, float]] = None, clabel: Optional[str] = None, title: Optional[str] = None, xlabel: Optional[str] = None, ylabel: Optional[str] = None, xlim: Optional[Tuple[float, float]] = None, ylim: Optional[Tuple[float, float]] = None, xtype: str = 'auto', ytype: str = 'auto', width: Optional[int] = None, height: Optional[int] = None, hold: bool = False, interactive: Optional[bool] = None) -> None:
594 """Plot a heatmap of 2D scalar data.
596 :param img: 2D image data
597 :param x: x-axis range for image data (min, max)
598 :param y: y-axis range for image data (min, max)
599 :param colormap: named color palette or Bokeh ColorMapper (see `Bokeh palettes <https://bokeh.pydata.org/en/latest/docs/reference/palettes.html>`_)
600 :param clim: color axis limits (min, max)
601 :param clabel: color axis label
602 :param title: figure title
603 :param xlabel: x-axis label
604 :param ylabel: y-axis label
605 :param xlim: x-axis limits (min, max)
606 :param ylim: y-axis limits (min, max)
607 :param xtype: x-axis type ('auto', 'linear', 'log', etc)
608 :param ytype: y-axis type ('auto', 'linear', 'log', etc)
609 :param width: figure width in pixels
610 :param height: figure height in pixels
611 :param interactive: enable interactive tools (pan, zoom, etc) for plot
612 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
614 >>> import arlpy.plot
615 >>> import numpy as np
616 >>> arlpy.plot.image(np.random.normal(size=(100,100)), colormap='Inferno256')
617 """
618 global _figure
619 if x is None:
620 x = (0, img.shape[1]-1)
621 if y is None:
622 y = (0, img.shape[0]-1)
623 if xlim is None:
624 xlim = x
625 if ylim is None:
626 ylim = y
627 if _figure is None:
628 _figure = _new_figure(title, width, height, xlabel, ylabel, xlim, ylim, xtype, ytype, interactive)
629 if clim is None:
630 clim = [_np.amin(img), _np.amax(img)]
631 if not isinstance(colormap, _bmodels.ColorMapper):
632 colormap = _bmodels.LinearColorMapper(palette=colormap, low=clim[0], high=clim[1])
633 _figure.image([img], x=x[0], y=y[0], dw=x[-1]-x[0], dh=y[-1]-y[0], color_mapper=colormap)
634 cbar = _bmodels.ColorBar(color_mapper=colormap, location=(0,0), title=clabel)
635 _figure.add_layout(cbar, 'right')
636 if not hold and not _hold:
637 _show(_figure)
638 _figure = None
640def vlines(x: Any, color: str = 'gray', style: str = 'dashed', thickness: int = 1, hold: bool = False) -> None:
641 """Draw vertical lines on a plot.
643 :param x: x location of lines
644 :param color: line color (see `Bokeh colors`_)
645 :param style: line style ('solid', 'dashed', 'dotted', 'dotdash', 'dashdot')
646 :param thickness: line width in pixels
647 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
649 >>> import arlpy.plot
650 >>> arlpy.plot.plot([0, 20], [0, 10], hold=True)
651 >>> arlpy.plot.vlines([7, 12])
652 """
653 global _figure
654 if _figure is None:
655 return
656 x = _np.asarray(x, dtype=_np.float64)
657 for j in range(x.size):
658 _figure.add_layout(_bmodels.Span(location=x[j], dimension='height', line_color=color, line_dash=style, line_width=thickness))
659 if not hold and not _hold:
660 _show(_figure)
661 _figure = None
663def hlines(y: Any, color: str = 'gray', style: str = 'dashed', thickness: int = 1, hold: bool = False) -> None:
664 """Draw horizontal lines on a plot.
666 :param y: y location of lines
667 :param color: line color (see `Bokeh colors`_)
668 :param style: line style ('solid', 'dashed', 'dotted', 'dotdash', 'dashdot')
669 :param thickness: line width in pixels
670 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
672 >>> import arlpy.plot
673 >>> arlpy.plot.plot([0, 20], [0, 10], hold=True)
674 >>> arlpy.plot.hlines(3, color='red', style='dotted')
675 """
676 global _figure
677 if _figure is None:
678 return
679 y = _np.asarray(y, dtype=_np.float64)
680 for j in range(y.size):
681 _figure.add_layout(_bmodels.Span(location=y[j], dimension='width', line_color=color, line_dash=style, line_width=thickness))
682 if not hold and not _hold:
683 _show(_figure)
684 _figure = None
686def text(x: float, y: float, s: str, color: str = 'gray', size: str = '8pt', hold: bool = False) -> None:
687 """Add text annotation to a plot.
689 :param x: x location of left of text
690 :param y: y location of bottom of text
691 :param s: text to add
692 :param color: text color (see `Bokeh colors`_)
693 :param size: text size (e.g. '12pt', '3em')
694 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
696 >>> import arlpy.plot
697 >>> arlpy.plot.plot([0, 20], [0, 10], hold=True)
698 >>> arlpy.plot.text(7, 3, 'demo', color='orange')
699 """
700 global _figure
701 if _figure is None:
702 return
703 _figure.add_layout(_bmodels.Label(x=x, y=y, text=s, text_font_size=size, text_color=color))
704 if not hold and not _hold:
705 _show(_figure)
706 _figure = None
708def box(left: Optional[float] = None, right: Optional[float] = None, top: Optional[float] = None, bottom: Optional[float] = None, color: str = 'yellow', alpha: float = 0.1, hold: bool = False) -> None:
709 """Add a highlight box to a plot.
711 :param left: x location of left of box
712 :param right: x location of right of box
713 :param top: y location of top of box
714 :param bottom: y location of bottom of box
715 :param color: text color (see `Bokeh colors`_)
716 :param alpha: transparency (0-1)
717 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
719 >>> import arlpy.plot
720 >>> arlpy.plot.plot([0, 20], [0, 10], hold=True)
721 >>> arlpy.plot.box(left=5, right=10, top=8)
722 """
723 global _figure
724 if _figure is None:
725 return
726 _figure.add_layout(_bmodels.BoxAnnotation(left=left, right=right, top=top, bottom=bottom, fill_color=color, fill_alpha=alpha))
727 if not hold and not _hold:
728 _show(_figure)
729 _figure = None
731def color(n: int) -> str:
732 """Get a numbered color to cycle over a set of colors.
734 >>> import arlpy.plot
735 >>> arlpy.plot.color(0)
736 'blue'
737 >>> arlpy.plot.color(1)
738 'red'
739 >>> arlpy.plot.plot([0, 20], [0, 10], color=arlpy.plot.color(3))
740 """
741 return _colors[n % len(_colors)]
743def set_colors(c: List[str]) -> None:
744 """Provide a list of named colors to cycle over.
746 >>> import arlpy.plot
747 >>> arlpy.plot.set_colors(['red', 'blue', 'green', 'black'])
748 >>> arlpy.plot.color(2)
749 'green'
750 """
751 global _colors
752 _colors = c
754def specgram(x: Any, fs: float = 2, nfft: Optional[int] = None, noverlap: Optional[int] = None, colormap: str = 'Plasma256', clim: Optional[Tuple[float, float]] = None, clabel: str = 'dB', title: Optional[str] = None, xlabel: str = 'Time (s)', ylabel: str = 'Frequency (Hz)', xlim: Optional[Tuple[float, float]] = None, ylim: Optional[Tuple[float, float]] = None, width: Optional[int] = None, height: Optional[int] = None, hold: bool = False, interactive: Optional[bool] = None) -> None:
755 """Plot spectrogram of a given time series signal.
757 :param x: time series signal
758 :param fs: sampling rate
759 :param nfft: FFT size (see `scipy.signal.spectrogram <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.spectrogram.html>`_)
760 :param noverlap: overlap size (see `scipy.signal.spectrogram`_)
761 :param colormap: named color palette or Bokeh ColorMapper (see `Bokeh palettes`_)
762 :param clim: color axis limits (min, max), or dynamic range with respect to maximum
763 :param clabel: color axis label
764 :param title: figure title
765 :param xlabel: x-axis label
766 :param ylabel: y-axis label
767 :param xlim: x-axis limits (min, max)
768 :param ylim: y-axis limits (min, max)
769 :param width: figure width in pixels
770 :param height: figure height in pixels
771 :param interactive: enable interactive tools (pan, zoom, etc) for plot
772 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
774 >>> import arlpy.plot
775 >>> import numpy as np
776 >>> arlpy.plot.specgram(np.random.normal(size=(10000)), fs=10000, clim=30)
777 """
778 f, t, Sxx = _sig.spectrogram(x, fs=fs, nperseg=nfft, noverlap=noverlap)
779 Sxx = 10 * _np.log10(Sxx + _np.finfo(float).eps)
781 # Convert scalar clim to range (for dynamic range specification)
782 if isinstance(clim, (int, float)):
783 max_val = _np.max(Sxx)
784 clim = (max_val - clim, max_val)
786 image(Sxx, x=(t[0], t[-1]), y=(f[0], f[-1]), title=title, colormap=colormap,
787 clim=clim, clabel=clabel, xlabel=xlabel, ylabel=ylabel, xlim=xlim,
788 ylim=ylim, width=width, height=height, hold=hold, interactive=interactive)
790def psd(x: Any, fs: float = 2, nfft: int = 512, noverlap: Optional[int] = None, window: str = 'hann', color: Optional[str] = None, style: str = 'solid', thickness: int = 1, marker: Optional[str] = None, filled: bool = False, size: int = 6, title: Optional[str] = None, xlabel: str = 'Frequency (Hz)', ylabel: str = 'Power spectral density (dB/Hz)', xlim: Optional[Tuple[float, float]] = None, ylim: Optional[Tuple[float, float]] = None, width: Optional[int] = None, height: Optional[int] = None, legend: Optional[str] = None, hold: bool = False, interactive: Optional[bool] = None) -> None:
791 """Plot power spectral density of a given time series signal.
793 :param x: time series signal
794 :param fs: sampling rate
795 :param nfft: segment size (see `scipy.signal.welch <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.welch.html>`_)
796 :param noverlap: overlap size (see `scipy.signal.welch`_)
797 :param window: window to use (see `scipy.signal.welch`_)
798 :param color: line color (see `Bokeh colors`_)
799 :param style: line style ('solid', 'dashed', 'dotted', 'dotdash', 'dashdot')
800 :param thickness: line width in pixels
801 :param marker: point markers ('.', 'o', 's', '*', 'x', '+', 'd', '^')
802 :param filled: filled markers or outlined ones
803 :param size: marker size
804 :param title: figure title
805 :param xlabel: x-axis label
806 :param ylabel: y-axis label
807 :param xlim: x-axis limits (min, max)
808 :param ylim: y-axis limits (min, max)
809 :param width: figure width in pixels
810 :param height: figure height in pixels
811 :param legend: legend text
812 :param interactive: enable interactive tools (pan, zoom, etc) for plot
813 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
815 >>> import arlpy.plot
816 >>> import numpy as np
817 >>> arlpy.plot.psd(np.random.normal(size=(10000)), fs=10000)
818 """
819 f, Pxx = _sig.welch(x, fs=fs, nperseg=nfft, noverlap=noverlap, window=window)
820 Pxx = 10 * _np.log10(Pxx + _np.finfo(float).eps)
822 # Set default axis limits if not specified
823 xlim = xlim or (0, fs / 2)
824 if ylim is None:
825 max_pxx = _np.max(Pxx)
826 ylim = (max_pxx - 50, max_pxx + 10)
828 plot(f, Pxx, color=color, style=style, thickness=thickness, marker=marker,
829 filled=filled, size=size, title=title, xlabel=xlabel, ylabel=ylabel,
830 xlim=xlim, ylim=ylim, maxpts=len(f), width=width, height=height,
831 hold=hold, legend=legend, interactive=interactive)
833def iqplot(data: Any, marker: str = '.', color: Optional[str] = None, labels: Optional[Any] = None, filled: bool = False, size: Optional[int] = None, title: Optional[str] = None, xlabel: Optional[str] = None, ylabel: Optional[str] = None, xlim: List[float] = [-2, 2], ylim: List[float] = [-2, 2], width: Optional[int] = None, height: Optional[int] = None, hold: bool = False, interactive: Optional[bool] = None) -> None:
834 """Plot signal points.
836 :param data: complex baseband signal points
837 :param marker: point markers ('.', 'o', 's', '*', 'x', '+', 'd', '^')
838 :param color: marker/text color (see `Bokeh colors`_)
839 :param labels: label for each signal point, or True to auto-generate labels
840 :param filled: filled markers or outlined ones
841 :param size: marker/text size (e.g. 5, '8pt')
842 :param title: figure title
843 :param xlabel: x-axis label
844 :param ylabel: y-axis label
845 :param xlim: x-axis limits (min, max)
846 :param ylim: y-axis limits (min, max)
847 :param width: figure width in pixels
848 :param height: figure height in pixels
849 :param interactive: enable interactive tools (pan, zoom, etc) for plot
850 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
852 >>> import arlpy
853 >>> import arlpy.plot
854 >>> arlpy.plot.iqplot(arlpy.comms.psk(8))
855 >>> arlpy.plot.iqplot(arlpy.comms.qam(16), color='red', marker='x')
856 >>> arlpy.plot.iqplot(arlpy.comms.psk(4), labels=['00', '01', '11', '10'])
857 """
858 data = _np.asarray(data, dtype=_np.complex128)
859 if not _hold:
860 figure(title=title, xlabel=xlabel, ylabel=ylabel, xlim=xlim, ylim=ylim, width=width, height=height, interactive=interactive)
861 if labels is None:
862 if size is None:
863 size = 5
864 scatter(data.real, data.imag, marker=marker, filled=filled, color=color, size=size, hold=hold)
865 else:
866 if labels:
867 labels = range(len(data))
868 if color is None:
869 color = 'black'
870 plot([0], [0], hold=True)
871 for i in range(len(data)):
872 text(data[i].real, data[i].imag, str(labels[i]), color=color, size=size, hold=True if i < len(data)-1 else hold)
874def freqz(b: Any, a: Any = 1, fs: float = 2.0, worN: Optional[int] = None, whole: bool = False, degrees: bool = True, style: str = 'solid', thickness: int = 1, title: Optional[str] = None, xlabel: str = 'Frequency (Hz)', xlim: Optional[Tuple[float, float]] = None, ylim: Optional[Tuple[float, float]] = None, width: Optional[int] = None, height: Optional[int] = None, hold: bool = False, interactive: Optional[bool] = None) -> None:
875 """Plot frequency response of a filter.
877 This is a convenience function to plot frequency response, and internally uses
878 :func:`scipy.signal.freqz` to estimate the response. For further details, see the
879 documentation for :func:`scipy.signal.freqz`.
881 :param b: numerator of a linear filter
882 :param a: denominator of a linear filter
883 :param fs: sampling rate in Hz (optional, normalized frequency if not specified)
884 :param worN: see :func:`scipy.signal.freqz`
885 :param whole: see :func:`scipy.signal.freqz`
886 :param degrees: True to display phase in degrees, False for radians
887 :param style: line style ('solid', 'dashed', 'dotted', 'dotdash', 'dashdot')
888 :param thickness: line width in pixels
889 :param title: figure title
890 :param xlabel: x-axis label
891 :param ylabel1: y-axis label for magnitude
892 :param ylabel2: y-axis label for phase
893 :param xlim: x-axis limits (min, max)
894 :param ylim: y-axis limits (min, max)
895 :param width: figure width in pixels
896 :param height: figure height in pixels
897 :param interactive: enable interactive tools (pan, zoom, etc) for plot
898 :param hold: if set to True, output is not plotted immediately, but combined with the next plot
900 >>> import arlpy
901 >>> arlpy.plot.freqz([1,1,1,1,1], fs=120000);
902 """
903 w, h = _sig.freqz(b, a, worN, whole)
904 Hxx = 20*_np.log10(abs(h)+_np.finfo(float).eps)
905 f = w*fs/(2*_np.pi)
906 if xlim is None:
907 xlim = (0, fs/2)
908 if ylim is None:
909 ylim = (_np.max(Hxx)-50, _np.max(Hxx)+10)
910 figure(title=title, xlabel=xlabel, ylabel='Amplitude (dB)', xlim=xlim, ylim=ylim, width=width, height=height, interactive=interactive)
911 _hold_enable(True)
912 plot(f, Hxx, color=color(0), style=style, thickness=thickness, legend='Magnitude')
913 fig = gcf()
914 units = 180/_np.pi if degrees else 1
915 fig.extra_y_ranges = {'phase': _bmodels.Range1d(start=-_np.pi*units, end=_np.pi*units)}
916 fig.add_layout(_bmodels.LinearAxis(y_range_name='phase', axis_label='Phase (degrees)' if degrees else 'Phase (radians)'), 'right')
917 phase = _np.angle(h)*units
918 fig.line(f, phase, line_color=color(1), line_dash=style, line_width=thickness, legend_label='Phase', y_range_name='phase')
919 _hold_enable(hold)