| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -825,6 +825,15 @@ def limit_range_for_scale(self, vmin, vmax): | |||
| 825 | 825 | """ | |
| 826 | 826 | return self._scale.limit_range_for_scale(vmin, vmax, self.get_minpos()) | |
| 827 | 827 | ||
| 828 | + def _nan_out_of_scale_range(self, data): | ||
| 829 | + """ | ||
| 830 | + Return *data* with values that are out of range for this axis's scale | ||
| 831 | + replaced by NaN. E.g. ``<=0`` on a log axis. | ||
| 832 | + """ | ||
| 833 | + data = np.asanyarray(data, dtype=float) | ||
| 834 | + valid = self._scale.val_in_range(data) | ||
| 835 | + return data if np.all(valid) else np.where(valid, data, np.nan) | ||
| 836 | + | ||
| 828 | 837 | def _get_autoscale_on(self): | |
| 829 | 838 | """Return whether this Axis is autoscaled.""" | |
| 830 | 839 | return self._autoscale_on | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -30,7 +30,6 @@ | |||
| 30 | 30 | """ # noqa: E501 | |
| 31 | 31 | ||
| 32 | 32 | import inspect | |
| 33 | - import math | ||
| 34 | 33 | import textwrap | |
| 35 | 34 | from functools import wraps | |
| 36 | 35 | ||
@@ -119,17 +118,20 @@ def val_in_range(self, val): | |||
| 119 | 118 | """ | |
| 120 | 119 | Return whether the value(s) are within the valid range for this scale. | |
| 121 | 120 | ||
| 122 | - This method is a generic implementation. Subclasses may implement more | ||
| 123 | - efficient solutions for their domain. | ||
| 124 | - """ | ||
| 125 | - try: | ||
| 126 | - if not math.isfinite(val): | ||
| 127 | - return False | ||
| 121 | + Accepts a scalar or array-like ``val``. For a scalar, returns a | ||
| 122 | + Python ``bool``. For an array, returns a bool ndarray of the same | ||
| 123 | + shape. This is a generic implementation, and subclasses may implement | ||
| 124 | + more efficient solutions for their domain. | ||
| 125 | + """ | ||
| 126 | + arr = np.asarray(val) | ||
| 127 | + with np.errstate(invalid='ignore'): | ||
| 128 | + try: | ||
| 129 | + vmin, vmax = self.limit_range_for_scale(arr, arr, minpos=1e-300) | ||
| 130 | + except (TypeError, ValueError): | ||
| 131 | + result = np.zeros(arr.shape, dtype=bool) | ||
| 128 | 132 | else: | |
| 129 | - vmin, vmax = self.limit_range_for_scale(val, val, minpos=1e-300) | ||
| 130 | - return vmin == val and vmax == val | ||
| 131 | - except (TypeError, ValueError): | ||
| 132 | - return False | ||
| 133 | + result = np.isfinite(arr) & (vmin == arr) & (vmax == arr) | ||
| 134 | + return bool(result) if arr.ndim == 0 else result | ||
| 133 | 135 | ||
| 134 | 136 | ||
| 135 | 137 | def _make_axis_parameter_optional(init_func): | |
@@ -219,11 +221,13 @@ def get_transform(self): | |||
| 219 | 221 | ||
| 220 | 222 | def val_in_range(self, val): | |
| 221 | 223 | """ | |
| 222 | - Return whether the value is within the valid range for this scale. | ||
| 224 | + Return whether the value(s) are within the valid range for this scale. | ||
| 223 | 225 | ||
| 224 | 226 | This is True for all values, except +-inf and NaN. | |
| 225 | 227 | """ | |
| 226 | - return math.isfinite(val) | ||
| 228 | + arr = np.asarray(val) | ||
| 229 | + result = np.isfinite(arr) | ||
| 230 | + return bool(result) if arr.ndim == 0 else result | ||
| 227 | 231 | ||
| 228 | 232 | ||
| 229 | 233 | class FuncTransform(Transform): | |
@@ -431,11 +435,14 @@ def limit_range_for_scale(self, vmin, vmax, minpos): | |||
| 431 | 435 | ||
| 432 | 436 | def val_in_range(self, val): | |
| 433 | 437 | """ | |
| 434 | - Return whether the value is within the valid range for this scale. | ||
| 438 | + Return whether the value(s) are within the valid range for this scale. | ||
| 435 | 439 | ||
| 436 | 440 | This is True for value(s) > 0 except +inf and NaN. | |
| 437 | 441 | """ | |
| 438 | - return math.isfinite(val) and val > 0 | ||
| 442 | + arr = np.asarray(val) | ||
| 443 | + with np.errstate(invalid='ignore'): | ||
| 444 | + result = np.isfinite(arr) & (arr > 0) | ||
| 445 | + return bool(result) if arr.ndim == 0 else result | ||
| 439 | 446 | ||
| 440 | 447 | ||
| 441 | 448 | class FuncScaleLog(LogScale): | |
@@ -625,11 +632,13 @@ def get_transform(self): | |||
| 625 | 632 | ||
| 626 | 633 | def val_in_range(self, val): | |
| 627 | 634 | """ | |
| 628 | - Return whether the value is within the valid range for this scale. | ||
| 635 | + Return whether the value(s) are within the valid range for this scale. | ||
| 629 | 636 | ||
| 630 | 637 | This is True for all values, except +-inf and NaN. | |
| 631 | 638 | """ | |
| 632 | - return math.isfinite(val) | ||
| 639 | + arr = np.asarray(val) | ||
| 640 | + result = np.isfinite(arr) | ||
| 641 | + return bool(result) if arr.ndim == 0 else result | ||
| 633 | 642 | ||
| 634 | 643 | ||
| 635 | 644 | class AsinhTransform(Transform): | |
@@ -759,11 +768,13 @@ def set_default_locators_and_formatters(self, axis): | |||
| 759 | 768 | ||
| 760 | 769 | def val_in_range(self, val): | |
| 761 | 770 | """ | |
| 762 | - Return whether the value is within the valid range for this scale. | ||
| 771 | + Return whether the value(s) are within the valid range for this scale. | ||
| 763 | 772 | ||
| 764 | 773 | This is True for all values, except +-inf and NaN. | |
| 765 | 774 | """ | |
| 766 | - return math.isfinite(val) | ||
| 775 | + arr = np.asarray(val) | ||
| 776 | + result = np.isfinite(arr) | ||
| 777 | + return bool(result) if arr.ndim == 0 else result | ||
| 767 | 778 | ||
| 768 | 779 | ||
| 769 | 780 | class LogitTransform(Transform): | |
@@ -880,11 +891,14 @@ def limit_range_for_scale(self, vmin, vmax, minpos): | |||
| 880 | 891 | ||
| 881 | 892 | def val_in_range(self, val): | |
| 882 | 893 | """ | |
| 883 | - Return whether the value is within the valid range for this scale. | ||
| 894 | + Return whether the value(s) are within the valid range for this scale. | ||
| 884 | 895 | ||
| 885 | 896 | This is True for value(s) which are between 0 and 1 (excluded). | |
| 886 | 897 | """ | |
| 887 | - return 0 < val < 1 | ||
| 898 | + arr = np.asarray(val) | ||
| 899 | + with np.errstate(invalid='ignore'): | ||
| 900 | + result = (0 < arr) & (arr < 1) | ||
| 901 | + return bool(result) if arr.ndim == 0 else result | ||
| 888 | 902 | ||
| 889 | 903 | ||
| 890 | 904 | _scale_mapping = { | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -477,3 +477,23 @@ def test_val_in_range_base_fallback(): | |||
| 477 | 477 | assert s.val_in_range(np.nan) is False | |
| 478 | 478 | assert s.val_in_range(np.inf) is False | |
| 479 | 479 | assert s.val_in_range(-np.inf) is False | |
| 480 | + | ||
| 481 | + | ||
| 482 | + def test_val_in_range_array(): | ||
| 483 | + # Vectorized: scalar in -> scalar bool, array in -> bool array. | ||
| 484 | + arr = np.array([0.5, -1.0, 0.0, np.nan, np.inf, 0.25]) | ||
| 485 | + cases = { | ||
| 486 | + 'linear': [True, True, True, False, False, True], | ||
| 487 | + 'log': [True, False, False, False, False, True], | ||
| 488 | + 'symlog': [True, True, True, False, False, True], | ||
| 489 | + 'asinh': [True, True, True, False, False, True], | ||
| 490 | + 'logit': [True, False, False, False, False, True], | ||
| 491 | + } | ||
| 492 | + for name, expected in cases.items(): | ||
| 493 | + s = mscale._scale_mapping[name](axis=None) | ||
| 494 | + np.testing.assert_array_equal(s.val_in_range(arr), expected) | ||
| 495 | + | ||
| 496 | + # 2D shape is preserved. | ||
| 497 | + out = mscale._scale_mapping['log'](axis=None).val_in_range( | ||
| 498 | + np.array([[1.0, -1.0], [0.5, np.nan]])) | ||
| 499 | + np.testing.assert_array_equal(out, [[True, False], [True, False]]) | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -97,6 +97,30 @@ def _viewlim_mask(xs, ys, zs, axes): | |||
| 97 | 97 | return mask | |
| 98 | 98 | ||
| 99 | 99 | ||
| 100 | + def _scale_invalid_mask(xs, ys, zs, axes): | ||
| 101 | + """ | ||
| 102 | + Return the mask of points whose coordinates are invalid for the axis | ||
| 103 | + scale they live on (e.g. <=0 on a log axis). | ||
| 104 | + | ||
| 105 | + Parameters | ||
| 106 | + ---------- | ||
| 107 | + xs, ys, zs : array-like | ||
| 108 | + The points to check, in data coordinates. | ||
| 109 | + axes : Axes3D | ||
| 110 | + The axes whose scales are queried. | ||
| 111 | + | ||
| 112 | + Returns | ||
| 113 | + ------- | ||
| 114 | + mask : np.ndarray | ||
| 115 | + Boolean array, ``True`` where any of x/y/z is out of its scale's | ||
| 116 | + valid domain. | ||
| 117 | + """ | ||
| 118 | + return np.logical_or.reduce(( | ||
| 119 | + np.logical_not(axes.xaxis._scale.val_in_range(xs)), | ||
| 120 | + np.logical_not(axes.yaxis._scale.val_in_range(ys)), | ||
| 121 | + np.logical_not(axes.zaxis._scale.val_in_range(zs)))) | ||
| 122 | + | ||
| 123 | + | ||
| 100 | 124 | class Text3D(mtext.Text): | |
| 101 | 125 | """ | |
| 102 | 126 | Text object with 3D position and direction. | |
@@ -191,8 +215,10 @@ def set_3d_properties(self, z=0, zdir='z', axlim_clip=False): | |||
| 191 | 215 | ||
| 192 | 216 | @artist.allow_rasterization | |
| 193 | 217 | def draw(self, renderer): | |
| 218 | + mask = _scale_invalid_mask(self._x, self._y, self._z, self.axes) | ||
| 194 | 219 | if self._axlim_clip: | |
| 195 | - mask = _viewlim_mask(self._x, self._y, self._z, self.axes) | ||
| 220 | + mask |= _viewlim_mask(self._x, self._y, self._z, self.axes) | ||
| 221 | + if np.any(mask): | ||
| 196 | 222 | pos3d = np.ma.array([self._x, self._y, self._z], | |
| 197 | 223 | mask=mask, dtype=float).filled(np.nan) | |
| 198 | 224 | else: | |
@@ -328,9 +354,12 @@ def get_data_3d(self): | |||
| 328 | 354 | ||
| 329 | 355 | @artist.allow_rasterization | |
| 330 | 356 | def draw(self, renderer): | |
| 357 | + scale_mask = _scale_invalid_mask(*self._verts3d, self.axes) | ||
| 331 | 358 | if self._axlim_clip: | |
| 359 | + scale_mask |= _viewlim_mask(*self._verts3d, self.axes) | ||
| 360 | + if np.any(scale_mask): | ||
| 332 | 361 | mask = np.broadcast_to( | |
| 333 | - _viewlim_mask(*self._verts3d, self.axes), | ||
| 362 | + scale_mask, | ||
| 334 | 363 | (len(self._verts3d), *self._verts3d[0].shape) | |
| 335 | 364 | ) | |
| 336 | 365 | xs3d, ys3d, zs3d = np.ma.array(self._verts3d, | |
@@ -424,10 +453,13 @@ class Collection3D(Collection): | |||
| 424 | 453 | def do_3d_projection(self): | |
| 425 | 454 | """Project the points according to renderer matrix.""" | |
| 426 | 455 | vs_list = [vs for vs, _ in self._3dverts_codes] | |
| 456 | + masks = [_scale_invalid_mask(*vs.T, self.axes) for vs in vs_list] | ||
| 427 | 457 | if self._axlim_clip: | |
| 428 | - vs_list = [np.ma.array(vs, mask=np.broadcast_to( | ||
| 429 | - _viewlim_mask(*vs.T, self.axes), vs.shape)) | ||
| 430 | - for vs in vs_list] | ||
| 458 | + masks = [m | _viewlim_mask(*vs.T, self.axes) | ||
| 459 | + for m, vs in zip(masks, vs_list)] | ||
| 460 | + vs_list = [np.ma.array(vs, mask=np.broadcast_to(m, vs.shape)) | ||
| 461 | + if np.any(m) else vs | ||
| 462 | + for vs, m in zip(vs_list, masks)] | ||
| 431 | 463 | xyzs_list = [proj3d._scale_proj_transform( | |
| 432 | 464 | vs[:, 0], vs[:, 1], vs[:, 2], self.axes) for vs in vs_list] | |
| 433 | 465 | self._paths = [mpath.Path(np.ma.column_stack([xs, ys]), cs) | |
@@ -520,6 +552,14 @@ def do_3d_projection(self): | |||
| 520 | 552 | if np.ma.isMA(segments) and segments.mask is not np.ma.nomask: | |
| 521 | 553 | mask = segments.mask | |
| 522 | 554 | ||
| 555 | + scale_mask = _scale_invalid_mask(segments[..., 0], | ||
| 556 | + segments[..., 1], | ||
| 557 | + segments[..., 2], | ||
| 558 | + self.axes) | ||
| 559 | + if np.any(scale_mask): | ||
| 560 | + mask |= np.broadcast_to(scale_mask[..., np.newaxis], | ||
| 561 | + (*scale_mask.shape, 3)) | ||
| 562 | + | ||
| 523 | 563 | if self._axlim_clip: | |
| 524 | 564 | viewlim_mask = _viewlim_mask(segments[..., 0], | |
| 525 | 565 | segments[..., 1], | |
@@ -612,12 +652,15 @@ def get_path(self): | |||
| 612 | 652 | ||
| 613 | 653 | def do_3d_projection(self): | |
| 614 | 654 | s = self._segment3d | |
| 655 | + xs0, ys0, zs0 = zip(*s) | ||
| 656 | + mask = _scale_invalid_mask(xs0, ys0, zs0, self.axes) | ||
| 615 | 657 | if self._axlim_clip: | |
| 616 | - mask = _viewlim_mask(*zip(*s), self.axes) | ||
| 658 | + mask |= _viewlim_mask(xs0, ys0, zs0, self.axes) | ||
| 659 | + if np.any(mask): | ||
| 617 | 660 | xs, ys, zs = np.ma.array(zip(*s), | |
| 618 | 661 | dtype=float, mask=mask).filled(np.nan) | |
| 619 | 662 | else: | |
| 620 | - xs, ys, zs = zip(*s) | ||
| 663 | + xs, ys, zs = xs0, ys0, zs0 | ||
| 621 | 664 | vxs, vys, vzs, vis = proj3d._scale_proj_transform_clip(xs, ys, zs, self.axes) | |
| 622 | 665 | self._path2d = mpath.Path(np.ma.column_stack([vxs, vys])) | |
| 623 | 666 | return min(vzs) | |
@@ -672,12 +715,15 @@ def set_3d_properties(self, path, zs=0, zdir='z', axlim_clip=False): | |||
| 672 | 715 | ||
| 673 | 716 | def do_3d_projection(self): | |
| 674 | 717 | s = self._segment3d | |
| 718 | + xs0, ys0, zs0 = zip(*s) | ||
| 719 | + mask = _scale_invalid_mask(xs0, ys0, zs0, self.axes) | ||
| 675 | 720 | if self._axlim_clip: | |
| 676 | - mask = _viewlim_mask(*zip(*s), self.axes) | ||
| 721 | + mask |= _viewlim_mask(xs0, ys0, zs0, self.axes) | ||
| 722 | + if np.any(mask): | ||
| 677 | 723 | xs, ys, zs = np.ma.array(zip(*s), | |
| 678 | 724 | dtype=float, mask=mask).filled(np.nan) | |
| 679 | 725 | else: | |
| 680 | - xs, ys, zs = zip(*s) | ||
| 726 | + xs, ys, zs = xs0, ys0, zs0 | ||
| 681 | 727 | vxs, vys, vzs, vis = proj3d._scale_proj_transform_clip(xs, ys, zs, self.axes) | |
| 682 | 728 | self._path2d = mpath.Path(np.ma.column_stack([vxs, vys]), self._code3d) | |
| 683 | 729 | return min(vzs) | |
@@ -816,8 +862,10 @@ def set_3d_properties(self, zs, zdir, axlim_clip=False): | |||
| 816 | 862 | self.stale = True | |
| 817 | 863 | ||
| 818 | 864 | def do_3d_projection(self): | |
| 865 | + mask = _scale_invalid_mask(*self._offsets3d, self.axes) | ||
| 819 | 866 | if self._axlim_clip: | |
| 820 | - mask = _viewlim_mask(*self._offsets3d, self.axes) | ||
| 867 | + mask |= _viewlim_mask(*self._offsets3d, self.axes) | ||
| 868 | + if np.any(mask): | ||
| 821 | 869 | xs, ys, zs = np.ma.array(self._offsets3d, mask=mask) | |
| 822 | 870 | else: | |
| 823 | 871 | xs, ys, zs = self._offsets3d | |
@@ -1038,8 +1086,10 @@ def do_3d_projection(self): | |||
| 1038 | 1086 | for xyz in self._offsets3d: | |
| 1039 | 1087 | if np.ma.isMA(xyz): | |
| 1040 | 1088 | mask = mask | xyz.mask | |
| 1089 | + mask = mask | _scale_invalid_mask(*self._offsets3d, self.axes) | ||
| 1041 | 1090 | if self._axlim_clip: | |
| 1042 | 1091 | mask = mask | _viewlim_mask(*self._offsets3d, self.axes) | |
| 1092 | + if np.any(mask): | ||
| 1043 | 1093 | mask = np.broadcast_to(mask, | |
| 1044 | 1094 | (len(self._offsets3d), *self._offsets3d[0].shape)) | |
| 1045 | 1095 | xyzs = np.ma.array(self._offsets3d, mask=mask) | |
@@ -1377,9 +1427,11 @@ def do_3d_projection(self): | |||
| 1377 | 1427 | if self._edge_is_mapped: | |
| 1378 | 1428 | self._edgecolor3d = self._edgecolors | |
| 1379 | 1429 | ||
| 1380 | - needs_masking = np.any(self._invalid_vertices) | ||
| 1381 | 1430 | num_faces = len(self._faces) | |
| 1382 | - mask = self._invalid_vertices | ||
| 1431 | + mask = self._invalid_vertices | _scale_invalid_mask( | ||
| 1432 | + self._faces[..., 0], self._faces[..., 1], | ||
| 1433 | + self._faces[..., 2], self.axes) | ||
| 1434 | + needs_masking = np.any(mask) | ||
| 1383 | 1435 | ||
| 1384 | 1436 | # Some faces might contain masked vertices, so we want to ignore any | |
| 1385 | 1437 | # errors that those might cause | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -640,13 +640,16 @@ def autoscale(self, enable=True, axis='both', tight=None): | |||
| 640 | 640 | def auto_scale_xyz(self, X, Y, Z=None, had_data=None): | |
| 641 | 641 | # This updates the bounding boxes as to keep a record as to what the | |
| 642 | 642 | # minimum sized rectangular volume holds the data. | |
| 643 | + X = self.xaxis._nan_out_of_scale_range(X) | ||
| 644 | + Y = self.yaxis._nan_out_of_scale_range(Y) | ||
| 643 | 645 | if np.shape(X) == np.shape(Y): | |
| 644 | 646 | self.xy_dataLim.update_from_data_xy( | |
| 645 | 647 | np.column_stack([np.ravel(X), np.ravel(Y)]), not had_data) | |
| 646 | 648 | else: | |
| 647 | 649 | self.xy_dataLim.update_from_data_x(X, not had_data) | |
| 648 | 650 | self.xy_dataLim.update_from_data_y(Y, not had_data) | |
| 649 | 651 | if Z is not None: | |
| 652 | + Z = self.zaxis._nan_out_of_scale_range(Z) | ||
| 650 | 653 | self.zz_dataLim.update_from_data_x(Z, not had_data) | |
| 651 | 654 | # Let autoscale_view figure out how to use this data. | |
| 652 | 655 | self.autoscale_view() | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -3190,3 +3190,17 @@ def test_scale3d_calc_coord(): | |||
| 3190 | 3190 | # Pane coordinate should match axis limit (y-pane at max) | |
| 3191 | 3191 | assert pane_idx == 1 | |
| 3192 | 3192 | assert point[pane_idx] == pytest.approx(ax.get_ylim()[1]) | |
| 3193 | + | ||
| 3194 | + | ||
| 3195 | + def test_plot_surface_log_scale_invalid_values(): | ||
| 3196 | + """Ensure non-positive Z values on a log z-axis does not corrupt zlim.""" | ||
| 3197 | + fig = plt.figure() | ||
| 3198 | + ax = fig.add_subplot(projection='3d') | ||
| 3199 | + ax.set_zscale('log') | ||
| 3200 | + X, Y = np.meshgrid(np.linspace(1, 3, 4), np.linspace(1, 3, 4)) | ||
| 3201 | + Z = X * Y - 4 # half the entries are <= 0, invalid for a log scale | ||
| 3202 | + ax.plot_surface(X, Y, Z) | ||
| 3203 | + fig.canvas.draw() | ||
| 3204 | + | ||
| 3205 | + zmin, zmax = ax.get_zlim() | ||
| 3206 | + assert 1e-3 < zmin < zmax < 1e3, f"zlim corrupted: {(zmin, zmax)}" | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments