Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 3c6efe2

Browse files
committed
Improve handling of degenerate jacobians in non-rectilinear grids.
grid_helper_curvelinear and floating_axes have code to specifically handle the case where the transform from rectlinear to non-rectilinear axes has null derivatives in one of the directions, inferring the angle of the jacobian from the derivative in the other direction. (This angle defines the rotation applied to axis labels, ticks, and tick labels.) This approach, however, is insufficient if the derivatives in both directions are zero. A classical example is e.g. the ``exp(-1/x**2)`` transform, for which all derivatives are zero. To handle this case more robustly (and also to better encapsulate the angle calculation, which is currently repeated at a few places), instead, one can increase the step size of the numerical differentiation until the gradient becomes nonzero. This amounts to moving along the corresponding gridline until one actually leaves the position of the tick, and thus is indeed a justifiable approach to compute the tick rotation. Full examples: import numpy as np from matplotlib import pyplot as plt from matplotlib.projections.polar import PolarTransform from matplotlib.transforms import Affine2D from mpl_toolkits.axisartist import ( angle_helper, GridHelperCurveLinear, HostAxes) import mpl_toolkits.axisartist.floating_axes as floating_axes # def tr(x, y): return x - y, x + y # def inv_tr(u, v): return (u + v) / 2, (v - u) / 2 @np.errstate(divide="ignore") # at x=0, exp(-1/x**2)=0; div-by-zero can be ignored. def tr(x, y): return np.exp(-x**-2) - np.exp(-y**-2), np.exp(-x**-2) + np.exp(-y**-2) def inv_tr(u, v): return (-np.log((u+v)/2))**(1/2), (-np.log((v-u)/2))**(1/2) plt.subplot( 121, axes_class=floating_axes.FloatingAxes, grid_helper=floating_axes.GridHelperCurveLinear( (tr, inv_tr), extremes=(0, 10, 0, 10))) ax = plt.subplot( 122, axes_class=HostAxes, grid_helper=GridHelperCurveLinear( Affine2D().scale(np.pi / 180, 1) + PolarTransform() + Affine2D().scale(2, 1), extreme_finder=angle_helper.ExtremeFinderCycle( 20, 20, lon_cycle=360, lat_cycle=None, lon_minmax=None, lat_minmax=(0, np.inf), ), grid_locator1=angle_helper.LocatorDMS(12), tick_formatter1=angle_helper.FormatterDMS(), ), aspect=1, xlim=(-5, 12), ylim=(-5, 10)) ax.axis["lat"] = axis = ax.new_floating_axis(0, 40) axis.label.set_text(r"$\theta = 40^{\circ}$") axis.label.set_visible(True) ax.grid(True) plt.show()
1 parent 07002c2 commit 3c6efe2

File tree

3 files changed

+101
-41
lines changed

3 files changed

+101
-41
lines changed

lib/mpl_toolkits/axisartist/floating_axes.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,25 +71,19 @@ def trf_xy(x, y):
7171

7272
if self.nth_coord == 0:
7373
mask = (ymin <= yy0) & (yy0 <= ymax)
74-
(xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = \
75-
grid_helper_curvelinear._value_and_jacobian(
74+
(xx1, yy1), angle_normal, angle_tangent = \
75+
grid_helper_curvelinear._value_and_jac_angle(
7676
trf_xy, self.value, yy0[mask], (xmin, xmax), (ymin, ymax))
7777
labels = self._grid_info["lat_labels"]
7878

7979
elif self.nth_coord == 1:
8080
mask = (xmin <= xx0) & (xx0 <= xmax)
81-
(xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = \
82-
grid_helper_curvelinear._value_and_jacobian(
81+
(xx1, yy1), angle_tangent, angle_normal = \
82+
grid_helper_curvelinear._value_and_jac_angle(
8383
trf_xy, xx0[mask], self.value, (xmin, xmax), (ymin, ymax))
8484
labels = self._grid_info["lon_labels"]
8585

8686
labels = [l for l, m in zip(labels, mask) if m]
87-
88-
angle_normal = np.arctan2(dyy1, dxx1)
89-
angle_tangent = np.arctan2(dyy2, dxx2)
90-
mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal
91-
angle_normal[mm] = angle_tangent[mm] + np.pi / 2
92-
9387
tick_to_axes = self.get_tick_transform(axes) - axes.transAxes
9488
in_01 = functools.partial(
9589
mpl.transforms._interval_contains_close, (0, 1))

lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,74 @@
1616
from .grid_finder import GridFinder
1717

1818

19-
def _value_and_jacobian(func, xs, ys, xlims, ylims):
19+
def _value_and_jac_angle(func, xs, ys, xlim, ylim):
2020
"""
21-
Compute *func* and its derivatives along x and y at positions *xs*, *ys*,
22-
while ensuring that finite difference calculations don't try to evaluate
23-
values outside of *xlims*, *ylims*.
21+
Parameters
22+
----------
23+
func : callable
24+
A function that transforms the coordinates of a point (x, y) to a new coordinate
25+
system (u, v), and which can also take x and y as arrays of shape *shape* and
26+
returns (u, v) as a ``(2, shape)`` array.
27+
xs, ys : array-likes
28+
Points where *func* and its derivatives will be evaluated.
29+
xlim, ylim : pairs of floats
30+
(min, max) beyond which *func* should not be evaluated.
31+
32+
Returns
33+
-------
34+
val
35+
Value of *func* at each point of ``(xs, ys)``.
36+
thetas_dx
37+
Angles (in radians) defined by the (u, v) components of the numerically
38+
differentiated df/dx vector, at each point of ``(xs, ys)``. If needed, the
39+
differentiation step size is increased until at least one component of df/dx
40+
is nonzero, under the constraint of not going out of the *xlims*, *ylims*
41+
bounds. If the gridline at a point is actually null (and the angle is thus not
42+
well defined), the derivatives are evaluated after taking a small step along y;
43+
this ensures e.g. that the tick at r=0 on a radial axis of a polar plot is
44+
parallel with the ticks at r!=0.
45+
thetas_dy
46+
Like *thetas_dx*, but for df/dy.
2447
"""
25-
eps = np.finfo(float).eps ** (1/2) # see e.g. scipy.optimize.approx_fprime
48+
49+
shape = np.broadcast_shapes(np.shape(xs), np.shape(ys))
2650
val = func(xs, ys)
27-
# Take the finite difference step in the direction where the bound is the
28-
# furthest; the step size is min of epsilon and distance to that bound.
29-
xlo, xhi = sorted(xlims)
30-
dxlo = xs - xlo
31-
dxhi = xhi - xs
32-
xeps = (np.take([-1, 1], dxhi >= dxlo)
33-
* np.minimum(eps, np.maximum(dxlo, dxhi)))
34-
val_dx = func(xs + xeps, ys)
35-
ylo, yhi = sorted(ylims)
36-
dylo = ys - ylo
37-
dyhi = yhi - ys
38-
yeps = (np.take([-1, 1], dyhi >= dylo)
39-
* np.minimum(eps, np.maximum(dylo, dyhi)))
40-
val_dy = func(xs, ys + yeps)
41-
return (val, (val_dx - val) / xeps, (val_dy - val) / yeps)
51+
52+
eps0 = np.finfo(float).eps ** (1/2) # cf. scipy.optimize.approx_fprime
53+
# Take finite difference steps towards the furthest bound; the step size will be the
54+
# min of epsilon and the distance to that bound.
55+
xlo, xhi = sorted(xlim)
56+
xdlo = xs - xlo
57+
xdhi = xhi - xs
58+
xeps_max = np.maximum(xdlo, xdhi)
59+
xeps0 = np.where(xdhi >= xdlo, 1, -1) * np.minimum(eps0, xeps_max)
60+
ylo, yhi = sorted(ylim)
61+
ydlo = ys - ylo
62+
ydhi = yhi - ys
63+
yeps_max = np.maximum(ydlo, ydhi)
64+
yeps0 = np.where(ydhi >= ydlo, 1, -1) * np.minimum(eps0, yeps_max)
65+
66+
def calc_thetas(dfunc, ps, eps0, eps_max, eps_q):
67+
thetas_dp = np.full(shape, np.nan)
68+
missing = np.full(shape, True)
69+
eps_p = eps0
70+
for it, eps_q in enumerate([0, eps_q]):
71+
while missing.any() and (abs(eps_p) < eps_max).any():
72+
if it == 0 and (eps_p > 1).any():
73+
break # Degenerate derivative, move a bit along the other coord.
74+
eps_p = np.minimum(eps_p, eps_max)
75+
df_x, df_y = (dfunc(eps_p, eps_q) - dfunc(0, eps_q)) / eps_p
76+
good = missing & ((df_x != 0) | (df_y != 0))
77+
thetas_dp[good] = np.arctan2(df_y, df_x)[good]
78+
missing &= ~good
79+
eps_p *= 2
80+
return thetas_dp
81+
82+
thetas_dx = calc_thetas(lambda eps_p, eps_q: func(xs + eps_p, ys + eps_q),
83+
xs, xeps0, xeps_max, yeps0)
84+
thetas_dy = calc_thetas(lambda eps_p, eps_q: func(xs + eps_q, ys + eps_p),
85+
ys, yeps0, yeps_max, xeps0)
86+
return (val, thetas_dx, thetas_dy)
4287

4388

4489
class FixedAxisArtistHelper(_FixedAxisArtistHelperBase):
@@ -167,12 +212,11 @@ def trf_xy(x, y):
167212
elif self.nth_coord == 1:
168213
xx0 = (xmin + xmax) / 2
169214
yy0 = self.value
170-
xy1, dxy1_dx, dxy1_dy = _value_and_jacobian(
215+
xy1, angle_dx, angle_dy = _value_and_jac_angle(
171216
trf_xy, xx0, yy0, (xmin, xmax), (ymin, ymax))
172217
p = axes.transAxes.inverted().transform(xy1)
173218
if 0 <= p[0] <= 1 and 0 <= p[1] <= 1:
174-
d = [dxy1_dy, dxy1_dx][self.nth_coord]
175-
return xy1, np.rad2deg(np.arctan2(*d[::-1]))
219+
return xy1, np.rad2deg([angle_dy, angle_dx][self.nth_coord])
176220
else:
177221
return None, None
178222

@@ -197,23 +241,17 @@ def trf_xy(x, y):
197241
# find angles
198242
if self.nth_coord == 0:
199243
mask = (e0 <= yy0) & (yy0 <= e1)
200-
(xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = _value_and_jacobian(
244+
(xx1, yy1), angle_normal, angle_tangent = _value_and_jac_angle(
201245
trf_xy, self.value, yy0[mask], (-np.inf, np.inf), (e0, e1))
202246
labels = self._grid_info["lat_labels"]
203247

204248
elif self.nth_coord == 1:
205249
mask = (e0 <= xx0) & (xx0 <= e1)
206-
(xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = _value_and_jacobian(
250+
(xx1, yy1), angle_tangent, angle_normal = _value_and_jac_angle(
207251
trf_xy, xx0[mask], self.value, (-np.inf, np.inf), (e0, e1))
208252
labels = self._grid_info["lon_labels"]
209253

210254
labels = [l for l, m in zip(labels, mask) if m]
211-
212-
angle_normal = np.arctan2(dyy1, dxx1)
213-
angle_tangent = np.arctan2(dyy2, dxx2)
214-
mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal
215-
angle_normal[mm] = angle_tangent[mm] + np.pi / 2
216-
217255
tick_to_axes = self.get_tick_transform(axes) - axes.transAxes
218256
in_01 = functools.partial(
219257
mpl.transforms._interval_contains_close, (0, 1))

lib/mpl_toolkits/axisartist/tests/test_floating_axes.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import numpy as np
22

3+
import pytest
4+
35
import matplotlib.pyplot as plt
46
import matplotlib.projections as mprojections
57
import matplotlib.transforms as mtransforms
@@ -113,3 +115,29 @@ def test_axis_direction():
113115
ax.axis['y'] = ax.new_floating_axis(nth_coord=1, value=0,
114116
axis_direction='left')
115117
assert ax.axis['y']._axis_direction == 'left'
118+
119+
120+
def test_transform_with_zero_derivatives():
121+
# The transform is really a 45° rotation
122+
# tr(x, y) = x-y, x+y; inv_tr(u, v) = (u+v)/2, (u-v)/2
123+
# with an additional x->exp(-x**-2) on each coordinate.
124+
# Therefore all ticks should be at +/-45°, even the one at zero where the
125+
# transform derivatives are zero.
126+
127+
# at x=0, exp(-x**-2)=0; div-by-zero can be ignored.
128+
@np.errstate(divide="ignore")
129+
def tr(x, y):
130+
return np.exp(-x**-2) - np.exp(-y**-2), np.exp(-x**-2) + np.exp(-y**-2)
131+
132+
def inv_tr(u, v):
133+
return (-np.log((u+v)/2))**(1/2), (-np.log((v-u)/2))**(1/2)
134+
135+
fig = plt.figure()
136+
ax = fig.add_subplot(
137+
axes_class=FloatingAxes, grid_helper=GridHelperCurveLinear(
138+
(tr, inv_tr), extremes=(0, 10, 0, 10)))
139+
fig.canvas.draw()
140+
141+
for k in ax.axis:
142+
for l, a in ax.axis[k].major_ticks.locs_angles:
143+
assert a % 90 == pytest.approx(45, abs=1e-3)

0 commit comments

Comments
 (0)