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