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

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############################################################################## 

10 

11"""Easy-to-use plotting utilities based on `Bokeh <http://bokeh.pydata.org>`_.""" 

12 

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 

23 

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'] 

27 

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 

39 

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 

49 

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 

52 

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) 

61 

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 

79 

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 

84 

85def _process_canvas(figures: List[Any]) -> None: 

86 """Replace non-interactive Bokeh canvases with static images in Jupyter notebooks. 

87  

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 

93 

94 if _disable_js or (not figures and _using_js): 

95 return 

96 

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] 

99 

100 if not disable_indices and not _using_js: 

101 return 

102 

103 _using_js = True 

104 

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 """ 

123 

124 import IPython.display as _ipyd 

125 _ipyd.display(_ipyd.Javascript(js_code)) 

126 

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) 

136 

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) 

147 

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 

156 

157def theme(name: str) -> None: 

158 """Set color theme. 

159 

160 Parameters 

161 ---------- 

162 name : str 

163 Name of theme 

164 

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 

177 

178def figsize(x: int, y: int) -> None: 

179 """Set the default figure size in pixels. 

180 

181 Parameters 

182 ---------- 

183 x : int 

184 Figure width 

185 y : int 

186 Figure height 

187 """ 

188 global _figsize 

189 _figsize = (x, y) 

190 

191def interactive(b: bool) -> None: 

192 """Set default interactivity for plots. 

193 

194 Parameters 

195 ---------- 

196 b : bool 

197 True to enable interactivity, False to disable it 

198 """ 

199 global _interactive 

200 _interactive = b 

201 

202def enable_javascript(b: bool) -> None: 

203 """Enable/disable Javascript. 

204 

205 Parameters 

206 ---------- 

207 b : bool 

208 True to use Javascript, False to avoid use of Javascript 

209 

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 

219 

220def use_static_images(b: bool = True) -> None: 

221 """Use static images instead of dynamic HTML/Javascript in Jupyter notebook. 

222 

223 Parameters 

224 ---------- 

225 b : bool, default=True 

226 True to use static images, False to use HTML/Javascript 

227 

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. 

233 

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 

246 

247def hold(enable: bool = True) -> Optional[bool]: 

248 """Combine multiple plots into one. 

249 

250 Parameters 

251 ---------- 

252 enable : bool, default=True 

253 True to hold plot, False to release hold 

254 

255 Returns 

256 ------- 

257 bool or None 

258 Old state of hold if enable is True 

259 

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 

270 

271class figure: 

272 """Create a new figure, and optionally automatically display it. 

273 

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 

296 

297 Notes 

298 ----- 

299 This function can be used in standalone mode to create a figure: 

300 

301 >>> import arlpy.plot 

302 >>> arlpy.plot.figure(title='Demo 1', width=500) 

303 >>> arlpy.plot.plot([0,10], [0,10]) 

304 

305 Or it can be used as a context manager to create, hold and display a figure: 

306 

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') 

311 

312 It can even be used as a context manager to work with Bokeh functions directly: 

313 

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 """ 

319 

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) 

323 

324 def __enter__(self): 

325 global _hold 

326 _hold = True 

327 return _figure 

328 

329 def __exit__(self, *args): 

330 global _hold, _figure 

331 _hold = False 

332 _show(_figure) 

333 _figure = None 

334 

335class many_figures: 

336 """Create a grid of many figures. 

337 

338 Parameters 

339 ---------- 

340 figsize : tuple of int, optional 

341 Default size of figure in grid as (width, height) 

342 

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 """ 

353 

354 def __init__(self, figsize: Optional[Tuple[int, int]] = None): 

355 self.figsize = figsize 

356 self.old_figsize: Optional[Tuple[int, int]] = None 

357 

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 

364 

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) 

371 

372 if _static_images: 

373 _show_static_images(gridplot) 

374 else: 

375 _process_canvas([]) 

376 _bplt.show(gridplot) 

377 _process_canvas(all_figures) 

378 

379 _figures = None 

380 _figsize = self.old_figsize 

381 

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([]) 

387 

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) 

393 

394def gcf() -> Any: 

395 """Get the current figure. 

396 

397 :returns: handle to the current figure 

398 """ 

399 return _figure 

400 

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. 

427 

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 

452 

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) 

471 

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]) 

476 

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}' 

485 

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) 

493 

494 pooling_funcs = { 

495 'max': _np.amax, 

496 'min': _np.amin, 

497 'mean': _np.mean, 

498 'median': _np.median 

499 } 

500 

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] 

507 

508 # Ensure x and y have the same length 

509 if len(x) > len(y): 

510 x = x[:len(y)] 

511 

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 

526 

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. 

529 

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 

548 

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 

567 

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 } 

579 

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 

592 

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. 

595 

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 

613 

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 

639 

640def vlines(x: Any, color: str = 'gray', style: str = 'dashed', thickness: int = 1, hold: bool = False) -> None: 

641 """Draw vertical lines on a plot. 

642 

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 

648 

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 

662 

663def hlines(y: Any, color: str = 'gray', style: str = 'dashed', thickness: int = 1, hold: bool = False) -> None: 

664 """Draw horizontal lines on a plot. 

665 

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 

671 

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 

685 

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. 

688 

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 

695 

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 

707 

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. 

710 

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 

718 

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 

730 

731def color(n: int) -> str: 

732 """Get a numbered color to cycle over a set of colors. 

733 

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)] 

742 

743def set_colors(c: List[str]) -> None: 

744 """Provide a list of named colors to cycle over. 

745 

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 

753 

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. 

756 

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 

773 

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) 

780 

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) 

785 

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) 

789 

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. 

792 

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 

814 

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) 

821 

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) 

827 

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) 

832 

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. 

835 

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 

851 

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) 

873 

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. 

876 

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`. 

880 

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 

899 

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)