Skip to content

Commit cc3bd58

Browse files
authored
Merge pull request #67 from MunchLab/radial-ect
add radial plotting
2 parents 917f338 + 8873aec commit cc3bd58

2 files changed

Lines changed: 164 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ect"
3-
version = "1.2.0"
3+
version = "1.2.1"
44
authors = [
55
{ name="Liz Munch", email="muncheli@msu.edu" },
66
]

src/ect/results.py

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,15 @@ def __array_finalize__(self, obj):
2727
self.directions = getattr(obj, "directions", None)
2828
self.thresholds = getattr(obj, "thresholds", None)
2929

30-
def plot(self, ax=None):
31-
"""Plot ECT matrix with proper handling for both 2D and 3D"""
30+
def plot(self, ax=None, *, radial=False, **kwargs):
31+
"""Plot ECT matrix with proper handling for both 2D and 3D.
32+
33+
Set radial=True to render a polar visualization (2D only). Any extra
34+
keyword arguments are forwarded to the radial renderer.
35+
"""
36+
if radial:
37+
return self._plot_radial(ax=ax, **kwargs)
38+
3239
ax = ax or plt.gca()
3340

3441
if self.thresholds is None:
@@ -107,6 +114,46 @@ def smooth(self):
107114
# create new ECTResult with float type
108115
return ECTResult(sect.astype(np.float64), self.directions, self.thresholds)
109116

117+
# Internal plotting utilities
118+
def _ensure_2d(self):
119+
if self.directions is None or self.directions.dim != 2:
120+
raise ValueError("This visualization is only supported for 2D ECT results")
121+
122+
def _theta_threshold_mesh(self):
123+
thetas = self.directions.thetas
124+
thresholds = self.thresholds
125+
THETA, R = np.meshgrid(thetas, thresholds)
126+
return THETA, R
127+
128+
def _configure_polar_axes(
129+
self, ax, rmin=0.0, rmax=None, theta_zero="N", theta_dir=-1
130+
):
131+
ax.set_theta_zero_location(theta_zero)
132+
ax.set_theta_direction(theta_dir)
133+
if rmax is None:
134+
rmax = float(np.max(self.thresholds))
135+
ax.set_ylim(float(rmin), float(rmax))
136+
return ax
137+
138+
def _scale_overlay_radii(self, points, rmin=0.0, rmax=None, fit_to_thresholds=True):
139+
x = points[:, 0]
140+
y = points[:, 1]
141+
r = np.sqrt(x**2 + y**2)
142+
theta = np.arctan2(y, x)
143+
144+
if rmax is None:
145+
rmax = float(np.max(self.thresholds))
146+
147+
if not fit_to_thresholds:
148+
return theta, r
149+
150+
max_r_points = float(np.max(r)) if r.size else 0.0
151+
if max_r_points > 0.0:
152+
scaled_r = (r / max_r_points) * (rmax - float(rmin)) + float(rmin)
153+
else:
154+
scaled_r = r
155+
return theta, scaled_r
156+
110157
def _plot_ecc(self, theta):
111158
"""Plot the Euler Characteristic Curve for a specific direction"""
112159
plt.step(self.thresholds, self.T, label="ECC")
@@ -115,6 +162,120 @@ def _plot_ecc(self, theta):
115162
plt.xlabel("$a$")
116163
plt.ylabel(r"$\chi(K_a)$")
117164

165+
def _plot_radial(
166+
self,
167+
ax=None,
168+
title=None,
169+
cmap="viridis",
170+
*,
171+
rmin=0.0,
172+
rmax=None,
173+
colorbar=True,
174+
overlay=None,
175+
overlay_kwargs=None,
176+
**kwargs,
177+
):
178+
"""
179+
Plot ECT matrix in polar coordinates (radial plot).
180+
181+
Args:
182+
ax: matplotlib axes object. If None, creates a new polar subplot
183+
title: optional string for plot title
184+
cmap: colormap for the plot (default: 'viridis')
185+
rmin: minimum radius for the plot (default: 0.0)
186+
rmax: maximum radius for the plot (default: None)
187+
colorbar: whether to show the colorbar (default: True)
188+
overlay: points to overlay on the plot (default: None)
189+
190+
**kwargs: additional keyword arguments passed to pcolormesh
191+
192+
Returns:
193+
matplotlib.axes.Axes: The axes object used for plotting
194+
"""
195+
self._ensure_2d()
196+
197+
if ax is None:
198+
fig, ax = plt.subplots(
199+
subplot_kw=dict(projection="polar"), figsize=(10, 10)
200+
)
201+
202+
THETA, R = self._theta_threshold_mesh()
203+
204+
im = ax.pcolormesh(THETA, R, self.T, cmap=cmap, **kwargs)
205+
206+
self._configure_polar_axes(ax, rmin=rmin, rmax=rmax)
207+
208+
if title:
209+
ax.set_title(title)
210+
211+
if colorbar:
212+
plt.colorbar(im, ax=ax, label="ECT Value")
213+
214+
if overlay is not None:
215+
overlay_kwargs = overlay_kwargs or {}
216+
theta, scaled_r = self._scale_overlay_radii(
217+
overlay, rmin=rmin, rmax=rmax, fit_to_thresholds=True
218+
)
219+
ax.plot(
220+
theta,
221+
scaled_r,
222+
"-",
223+
color=overlay_kwargs.get("color", "black"),
224+
linewidth=overlay_kwargs.get("linewidth", 2),
225+
alpha=overlay_kwargs.get("alpha", 0.5),
226+
)
227+
228+
return ax
229+
230+
def _overlay_points(
231+
self,
232+
points,
233+
ax=None,
234+
color="black",
235+
linewidth=2,
236+
alpha=0.5,
237+
*,
238+
rmin=0.0,
239+
rmax=None,
240+
fit_to_thresholds=True,
241+
**kwargs,
242+
):
243+
"""
244+
Overlay original points on a radial ECT plot.
245+
246+
Args:
247+
points: numpy array of shape (N, 2) containing the original points
248+
ax: matplotlib polar axes object. If None, uses current axes
249+
color: color for the overlay line (default: 'white')
250+
linewidth: line width for the overlay (default: 2)
251+
alpha: transparency for the overlay (default: 0.5)
252+
**kwargs: additional keyword arguments passed to plot
253+
254+
Returns:
255+
matplotlib.axes.Axes: The axes object used for plotting
256+
"""
257+
if ax is None:
258+
ax = plt.gca()
259+
260+
if not hasattr(ax, "name") or ax.name != "polar":
261+
raise ValueError("overlay_points requires a polar axes object")
262+
263+
theta, scaled_r = self._scale_overlay_radii(
264+
points, rmin=rmin, rmax=rmax, fit_to_thresholds=fit_to_thresholds
265+
)
266+
267+
ax.plot(
268+
theta,
269+
scaled_r,
270+
"-",
271+
color=color,
272+
linewidth=linewidth,
273+
alpha=alpha,
274+
**kwargs,
275+
)
276+
277+
return ax
278+
118279
def dist(
119280
self,
120281
other: Union["ECTResult", List["ECTResult"]],

0 commit comments

Comments
 (0)