canns 0.13.1__py3-none-any.whl → 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. canns/analyzer/data/__init__.py +5 -1
  2. canns/analyzer/data/asa/__init__.py +27 -12
  3. canns/analyzer/data/asa/cohospace.py +336 -10
  4. canns/analyzer/data/asa/config.py +3 -0
  5. canns/analyzer/data/asa/embedding.py +48 -45
  6. canns/analyzer/data/asa/path.py +104 -2
  7. canns/analyzer/data/asa/plotting.py +88 -19
  8. canns/analyzer/data/asa/tda.py +11 -4
  9. canns/analyzer/data/cell_classification/__init__.py +97 -0
  10. canns/analyzer/data/cell_classification/core/__init__.py +26 -0
  11. canns/analyzer/data/cell_classification/core/grid_cells.py +633 -0
  12. canns/analyzer/data/cell_classification/core/grid_modules_leiden.py +288 -0
  13. canns/analyzer/data/cell_classification/core/head_direction.py +347 -0
  14. canns/analyzer/data/cell_classification/core/spatial_analysis.py +431 -0
  15. canns/analyzer/data/cell_classification/io/__init__.py +5 -0
  16. canns/analyzer/data/cell_classification/io/matlab_loader.py +417 -0
  17. canns/analyzer/data/cell_classification/utils/__init__.py +39 -0
  18. canns/analyzer/data/cell_classification/utils/circular_stats.py +383 -0
  19. canns/analyzer/data/cell_classification/utils/correlation.py +318 -0
  20. canns/analyzer/data/cell_classification/utils/geometry.py +442 -0
  21. canns/analyzer/data/cell_classification/utils/image_processing.py +416 -0
  22. canns/analyzer/data/cell_classification/visualization/__init__.py +19 -0
  23. canns/analyzer/data/cell_classification/visualization/grid_plots.py +292 -0
  24. canns/analyzer/data/cell_classification/visualization/hd_plots.py +200 -0
  25. canns/analyzer/metrics/__init__.py +2 -1
  26. canns/analyzer/visualization/core/config.py +46 -4
  27. canns/data/__init__.py +6 -1
  28. canns/data/datasets.py +154 -1
  29. canns/data/loaders.py +37 -0
  30. canns/pipeline/__init__.py +13 -9
  31. canns/pipeline/__main__.py +6 -0
  32. canns/pipeline/asa/runner.py +105 -41
  33. canns/pipeline/asa_gui/__init__.py +68 -0
  34. canns/pipeline/asa_gui/__main__.py +6 -0
  35. canns/pipeline/asa_gui/analysis_modes/__init__.py +42 -0
  36. canns/pipeline/asa_gui/analysis_modes/base.py +39 -0
  37. canns/pipeline/asa_gui/analysis_modes/batch_mode.py +21 -0
  38. canns/pipeline/asa_gui/analysis_modes/cohomap_mode.py +56 -0
  39. canns/pipeline/asa_gui/analysis_modes/cohospace_mode.py +194 -0
  40. canns/pipeline/asa_gui/analysis_modes/decode_mode.py +52 -0
  41. canns/pipeline/asa_gui/analysis_modes/fr_mode.py +81 -0
  42. canns/pipeline/asa_gui/analysis_modes/frm_mode.py +92 -0
  43. canns/pipeline/asa_gui/analysis_modes/gridscore_mode.py +123 -0
  44. canns/pipeline/asa_gui/analysis_modes/pathcompare_mode.py +199 -0
  45. canns/pipeline/asa_gui/analysis_modes/tda_mode.py +112 -0
  46. canns/pipeline/asa_gui/app.py +29 -0
  47. canns/pipeline/asa_gui/controllers/__init__.py +6 -0
  48. canns/pipeline/asa_gui/controllers/analysis_controller.py +59 -0
  49. canns/pipeline/asa_gui/controllers/preprocess_controller.py +89 -0
  50. canns/pipeline/asa_gui/core/__init__.py +15 -0
  51. canns/pipeline/asa_gui/core/cache.py +14 -0
  52. canns/pipeline/asa_gui/core/runner.py +1936 -0
  53. canns/pipeline/asa_gui/core/state.py +324 -0
  54. canns/pipeline/asa_gui/core/worker.py +260 -0
  55. canns/pipeline/asa_gui/main_window.py +184 -0
  56. canns/pipeline/asa_gui/models/__init__.py +7 -0
  57. canns/pipeline/asa_gui/models/config.py +14 -0
  58. canns/pipeline/asa_gui/models/job.py +31 -0
  59. canns/pipeline/asa_gui/models/presets.py +21 -0
  60. canns/pipeline/asa_gui/resources/__init__.py +16 -0
  61. canns/pipeline/asa_gui/resources/dark.qss +167 -0
  62. canns/pipeline/asa_gui/resources/light.qss +163 -0
  63. canns/pipeline/asa_gui/resources/styles.qss +130 -0
  64. canns/pipeline/asa_gui/utils/__init__.py +1 -0
  65. canns/pipeline/asa_gui/utils/formatters.py +15 -0
  66. canns/pipeline/asa_gui/utils/io_adapters.py +40 -0
  67. canns/pipeline/asa_gui/utils/validators.py +41 -0
  68. canns/pipeline/asa_gui/views/__init__.py +1 -0
  69. canns/pipeline/asa_gui/views/help_content.py +171 -0
  70. canns/pipeline/asa_gui/views/pages/__init__.py +6 -0
  71. canns/pipeline/asa_gui/views/pages/analysis_page.py +565 -0
  72. canns/pipeline/asa_gui/views/pages/preprocess_page.py +492 -0
  73. canns/pipeline/asa_gui/views/panels/__init__.py +1 -0
  74. canns/pipeline/asa_gui/views/widgets/__init__.py +21 -0
  75. canns/pipeline/asa_gui/views/widgets/artifacts_tab.py +44 -0
  76. canns/pipeline/asa_gui/views/widgets/drop_zone.py +80 -0
  77. canns/pipeline/asa_gui/views/widgets/file_list.py +27 -0
  78. canns/pipeline/asa_gui/views/widgets/gridscore_tab.py +308 -0
  79. canns/pipeline/asa_gui/views/widgets/help_dialog.py +27 -0
  80. canns/pipeline/asa_gui/views/widgets/image_tab.py +50 -0
  81. canns/pipeline/asa_gui/views/widgets/image_viewer.py +97 -0
  82. canns/pipeline/asa_gui/views/widgets/log_box.py +16 -0
  83. canns/pipeline/asa_gui/views/widgets/pathcompare_tab.py +200 -0
  84. canns/pipeline/asa_gui/views/widgets/popup_combo.py +25 -0
  85. canns/pipeline/gallery/__init__.py +15 -5
  86. canns/pipeline/gallery/__main__.py +11 -0
  87. canns/pipeline/gallery/app.py +705 -0
  88. canns/pipeline/gallery/runner.py +790 -0
  89. canns/pipeline/gallery/state.py +51 -0
  90. canns/pipeline/gallery/styles.tcss +123 -0
  91. canns/pipeline/launcher.py +81 -0
  92. {canns-0.13.1.dist-info → canns-0.14.0.dist-info}/METADATA +11 -1
  93. canns-0.14.0.dist-info/RECORD +163 -0
  94. canns-0.14.0.dist-info/entry_points.txt +5 -0
  95. canns/pipeline/_base.py +0 -50
  96. canns-0.13.1.dist-info/RECORD +0 -89
  97. canns-0.13.1.dist-info/entry_points.txt +0 -3
  98. {canns-0.13.1.dist-info → canns-0.14.0.dist-info}/WHEEL +0 -0
  99. {canns-0.13.1.dist-info → canns-0.14.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,442 @@
1
+ """
2
+ Geometry Utilities
3
+
4
+ Functions for geometric calculations including ellipse fitting,
5
+ distance computations, and polygon operations.
6
+ """
7
+
8
+ import numpy as np
9
+
10
+
11
+ def fit_ellipse(x: np.ndarray, y: np.ndarray) -> np.ndarray:
12
+ """
13
+ Least-squares fit of ellipse to 2D points.
14
+
15
+ Implements the Direct Least Squares Fitting algorithm by Fitzgibbon et al. (1999).
16
+ This is a robust method that includes scaling to reduce roundoff error and
17
+ returns geometric parameters rather than quadratic form coefficients.
18
+
19
+ Parameters
20
+ ----------
21
+ x : np.ndarray
22
+ X coordinates of points (1D array)
23
+ y : np.ndarray
24
+ Y coordinates of points (1D array)
25
+
26
+ Returns
27
+ -------
28
+ params : np.ndarray
29
+ Array of shape (5,) containing:
30
+ [center_x, center_y, radius_x, radius_y, theta_radians]
31
+ where theta is the orientation angle of the major axis
32
+
33
+ Examples
34
+ --------
35
+ >>> # Generate points on an ellipse
36
+ >>> t = np.linspace(0, 2*np.pi, 100)
37
+ >>> cx, cy = 5, 3 # center
38
+ >>> rx, ry = 4, 2 # radii
39
+ >>> angle = np.pi/4 # rotation
40
+ >>> x = cx + rx * np.cos(t) * np.cos(angle) - ry * np.sin(t) * np.sin(angle)
41
+ >>> y = cy + rx * np.cos(t) * np.sin(angle) + ry * np.sin(t) * np.cos(angle)
42
+ >>> params = fit_ellipse(x, y)
43
+ >>> print(f"Fitted center: ({params[0]:.2f}, {params[1]:.2f})")
44
+ >>> print(f"Fitted radii: ({params[2]:.2f}, {params[3]:.2f})")
45
+
46
+ Notes
47
+ -----
48
+ Based on fitEllipse.m from the MATLAB codebase.
49
+
50
+ References
51
+ ----------
52
+ Fitzgibbon, A.W., Pilu, M., and Fisher, R.B. (1999).
53
+ "Direct least-squares fitting of ellipses". IEEE T-PAMI, 21(5):476-480.
54
+ http://research.microsoft.com/en-us/um/people/awf/ellipse/
55
+ """
56
+ x = np.asarray(x, dtype=float)
57
+ y = np.asarray(y, dtype=float)
58
+
59
+ if len(x) < 5 or len(y) < 5:
60
+ raise ValueError("Need at least 5 points to fit an ellipse")
61
+
62
+ # Normalize data to reduce roundoff error
63
+ mx = np.mean(x)
64
+ my = np.mean(y)
65
+ sx = (np.max(x) - np.min(x)) / 2
66
+ sy = (np.max(y) - np.min(y)) / 2
67
+
68
+ if sx == 0 or sy == 0:
69
+ raise ValueError("Points must have non-zero extent in both dimensions")
70
+
71
+ x_norm = (x - mx) / sx
72
+ y_norm = (y - my) / sy
73
+
74
+ # Force to column vectors
75
+ x_norm = x_norm.ravel()
76
+ y_norm = y_norm.ravel()
77
+
78
+ # Build design matrix
79
+ # D = [x^2, xy, y^2, x, y, 1]
80
+ D = np.column_stack(
81
+ [x_norm**2, x_norm * y_norm, y_norm**2, x_norm, y_norm, np.ones_like(x_norm)]
82
+ )
83
+
84
+ # Build scatter matrix
85
+ S = D.T @ D
86
+
87
+ # Build 6x6 constraint matrix for ellipse
88
+ # This constrains the solution to be an ellipse (not hyperbola or parabola)
89
+ C = np.zeros((6, 6))
90
+ C[0, 2] = -2
91
+ C[1, 1] = 1
92
+ C[2, 0] = -2
93
+
94
+ # Solve the generalized eigensystem using the stable method
95
+ # Break into blocks (as in the "new way" from the MATLAB code)
96
+ tmpA = S[:3, :3]
97
+ tmpB = S[:3, 3:6]
98
+ tmpC = S[3:6, 3:6]
99
+ tmpD = C[:3, :3]
100
+
101
+ # Solve the reduced eigensystem
102
+ try:
103
+ tmpE = np.linalg.inv(tmpC) @ tmpB.T
104
+ tmpM = np.linalg.inv(tmpD) @ (tmpA - tmpB @ tmpE)
105
+
106
+ # Find eigenvalues and eigenvectors
107
+ eigenvalues, eigenvectors = np.linalg.eig(tmpM)
108
+
109
+ # Find the positive eigenvalue (since det(tmpD) < 0)
110
+ # Look for small positive or negative eigenvalues near zero
111
+ idx = np.where((np.real(eigenvalues) < 1e-8) & ~np.isinf(eigenvalues))[0]
112
+
113
+ if len(idx) == 0:
114
+ # Fallback: take the smallest positive eigenvalue
115
+ idx = np.argmin(np.abs(eigenvalues))
116
+ else:
117
+ idx = idx[0]
118
+
119
+ # Extract eigenvector
120
+ A_top = np.real(eigenvectors[:, idx])
121
+
122
+ # Recover the bottom half
123
+ A_bottom = -tmpE @ A_top
124
+ A = np.concatenate([A_top, A_bottom])
125
+
126
+ except np.linalg.LinAlgError as err:
127
+ raise ValueError("Failed to fit ellipse: singular matrix encountered") from err
128
+
129
+ # Unnormalize the coefficients
130
+ par = np.array(
131
+ [
132
+ A[0] * sy * sy,
133
+ A[1] * sx * sy,
134
+ A[2] * sx * sx,
135
+ -2 * A[0] * sy * sy * mx - A[1] * sx * sy * my + A[3] * sx * sy * sy,
136
+ -A[1] * sx * sy * mx - 2 * A[2] * sx * sx * my + A[4] * sx * sx * sy,
137
+ A[0] * sy * sy * mx * mx
138
+ + A[1] * sx * sy * mx * my
139
+ + A[2] * sx * sx * my * my
140
+ - A[3] * sx * sy * sy * mx
141
+ - A[4] * sx * sx * sy * my
142
+ + A[5] * sx * sx * sy * sy,
143
+ ]
144
+ )
145
+
146
+ # Convert quadratic form to geometric parameters
147
+ theta_rad = 0.5 * np.arctan2(par[1], par[0] - par[2])
148
+ cost = np.cos(theta_rad)
149
+ sint = np.sin(theta_rad)
150
+ sin_squared = sint**2
151
+ cos_squared = cost**2
152
+ cos_sin = sint * cost
153
+
154
+ Ao = par[5]
155
+ Au = par[3] * cost + par[4] * sint
156
+ Av = -par[3] * sint + par[4] * cost
157
+ Auu = par[0] * cos_squared + par[2] * sin_squared + par[1] * cos_sin
158
+ Avv = par[0] * sin_squared + par[2] * cos_squared - par[1] * cos_sin
159
+
160
+ # Center in rotated coordinates
161
+ tuCentre = -Au / (2 * Auu)
162
+ tvCentre = -Av / (2 * Avv)
163
+ wCentre = Ao - Auu * tuCentre**2 - Avv * tvCentre**2
164
+
165
+ # Transform back to original coordinates
166
+ uCentre = tuCentre * cost - tvCentre * sint
167
+ vCentre = tuCentre * sint + tvCentre * cost
168
+
169
+ # Radii
170
+ Ru = -wCentre / Auu
171
+ Rv = -wCentre / Avv
172
+
173
+ Ru = np.sqrt(np.abs(Ru)) * np.sign(Ru)
174
+ Rv = np.sqrt(np.abs(Rv)) * np.sign(Rv)
175
+
176
+ # Return geometric parameters
177
+ result = np.array([uCentre, vCentre, Ru, Rv, theta_rad])
178
+
179
+ return result
180
+
181
+
182
+ def squared_distance(X: np.ndarray, Y: np.ndarray) -> np.ndarray:
183
+ """
184
+ Compute squared Euclidean distance matrix between two sets of points.
185
+
186
+ Efficiently computes all pairwise squared distances between points in X and Y
187
+ using the identity: ||x - y||^2 = ||x||^2 + ||y||^2 - 2*x·y
188
+
189
+ Parameters
190
+ ----------
191
+ X : np.ndarray
192
+ First set of points, shape (d, n) where d is dimension, n is number of points
193
+ Y : np.ndarray
194
+ Second set of points, shape (d, m) where d is dimension, m is number of points
195
+
196
+ Returns
197
+ -------
198
+ D : np.ndarray
199
+ Squared distance matrix, shape (n, m)
200
+ D[i, j] = ||X[:, i] - Y[:, j]||^2
201
+
202
+ Examples
203
+ --------
204
+ >>> # 2D points
205
+ >>> X = np.array([[0, 1, 2], [0, 0, 0]]) # 3 points along x-axis
206
+ >>> Y = np.array([[0, 0], [1, 2]]) # 2 points along y-axis
207
+ >>> D = squared_distance(X, Y)
208
+ >>> print(D) # Distances from X points to Y points
209
+
210
+ Notes
211
+ -----
212
+ Based on sqDistance inline function from gridnessScore.m and findCentreRadius.m.
213
+ Uses bsxfun-style broadcasting for efficiency.
214
+ """
215
+ X = np.atleast_2d(X)
216
+ Y = np.atleast_2d(Y)
217
+
218
+ # ||x||^2 for each column of X
219
+ X_norm_sq = np.sum(X**2, axis=0, keepdims=True) # Shape: (1, n)
220
+
221
+ # ||y||^2 for each column of Y
222
+ Y_norm_sq = np.sum(Y**2, axis=0, keepdims=True) # Shape: (1, m)
223
+
224
+ # Compute: ||x||^2 + ||y||^2 - 2*x·y using broadcasting
225
+ # X_norm_sq.T: (n, 1)
226
+ # Y_norm_sq: (1, m)
227
+ # X.T @ Y: (n, m)
228
+ D = X_norm_sq.T + Y_norm_sq - 2 * (X.T @ Y)
229
+
230
+ # Ensure non-negative (due to floating point errors)
231
+ D = np.maximum(D, 0)
232
+
233
+ return D
234
+
235
+
236
+ def polyarea(x: np.ndarray, y: np.ndarray) -> float:
237
+ """
238
+ Compute area of a polygon using the shoelace formula.
239
+
240
+ Parameters
241
+ ----------
242
+ x : np.ndarray
243
+ X coordinates of polygon vertices
244
+ y : np.ndarray
245
+ Y coordinates of polygon vertices
246
+
247
+ Returns
248
+ -------
249
+ area : float
250
+ Area of the polygon (always positive)
251
+
252
+ Examples
253
+ --------
254
+ >>> # Unit square
255
+ >>> x = np.array([0, 1, 1, 0])
256
+ >>> y = np.array([0, 0, 1, 1])
257
+ >>> area = polyarea(x, y)
258
+ >>> print(f"Area: {area}") # Should be 1.0
259
+
260
+ >>> # Triangle
261
+ >>> x = np.array([0, 1, 0.5])
262
+ >>> y = np.array([0, 0, 1])
263
+ >>> area = polyarea(x, y)
264
+ >>> print(f"Area: {area}") # Should be 0.5
265
+
266
+ Notes
267
+ -----
268
+ Based on MATLAB's polyarea function using the shoelace formula.
269
+ The polygon can be specified in either clockwise or counter-clockwise order.
270
+ """
271
+ x = np.asarray(x).ravel()
272
+ y = np.asarray(y).ravel()
273
+
274
+ if len(x) != len(y):
275
+ raise ValueError("x and y must have the same length")
276
+
277
+ if len(x) < 3:
278
+ return 0.0
279
+
280
+ # Shoelace formula: A = 0.5 * |sum(x_i * y_{i+1} - x_{i+1} * y_i)|
281
+ # Use np.roll to get next elements cyclically
282
+ area = 0.5 * np.abs(np.sum(x * np.roll(y, -1)) - np.sum(np.roll(x, -1) * y))
283
+
284
+ return float(area)
285
+
286
+
287
+ def wrap_to_pi(angles: np.ndarray) -> np.ndarray:
288
+ """
289
+ Wrap angles to the range [-π, π].
290
+
291
+ Parameters
292
+ ----------
293
+ angles : np.ndarray
294
+ Angles in radians
295
+
296
+ Returns
297
+ -------
298
+ wrapped : np.ndarray
299
+ Angles wrapped to [-π, π]
300
+
301
+ Examples
302
+ --------
303
+ >>> angles = np.array([0, np.pi, -np.pi, 3*np.pi, -3*np.pi])
304
+ >>> wrapped = wrap_to_pi(angles)
305
+ >>> print(wrapped) # All in [-π, π]
306
+
307
+ Notes
308
+ -----
309
+ Equivalent to MATLAB's wrapToPi function.
310
+ """
311
+ return np.arctan2(np.sin(angles), np.cos(angles))
312
+
313
+
314
+ def cart2pol(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
315
+ """
316
+ Transform Cartesian coordinates to polar coordinates.
317
+
318
+ Parameters
319
+ ----------
320
+ x : np.ndarray
321
+ X coordinates
322
+ y : np.ndarray
323
+ Y coordinates
324
+
325
+ Returns
326
+ -------
327
+ theta : np.ndarray
328
+ Angle in radians, range [-π, π]
329
+ rho : np.ndarray
330
+ Radius (distance from origin)
331
+
332
+ Examples
333
+ --------
334
+ >>> x = np.array([1, 0, -1])
335
+ >>> y = np.array([0, 1, 0])
336
+ >>> theta, rho = cart2pol(x, y)
337
+ >>> print(theta) # [0, π/2, π]
338
+ >>> print(rho) # [1, 1, 1]
339
+
340
+ Notes
341
+ -----
342
+ Equivalent to MATLAB's cart2pol function.
343
+ """
344
+ theta = np.arctan2(y, x)
345
+ rho = np.sqrt(x**2 + y**2)
346
+ return theta, rho
347
+
348
+
349
+ def pol2cart(theta: np.ndarray, rho: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
350
+ """
351
+ Transform polar coordinates to Cartesian coordinates.
352
+
353
+ Parameters
354
+ ----------
355
+ theta : np.ndarray
356
+ Angle in radians
357
+ rho : np.ndarray
358
+ Radius (distance from origin)
359
+
360
+ Returns
361
+ -------
362
+ x : np.ndarray
363
+ X coordinates
364
+ y : np.ndarray
365
+ Y coordinates
366
+
367
+ Examples
368
+ --------
369
+ >>> theta = np.array([0, np.pi/2, np.pi])
370
+ >>> rho = np.array([1, 1, 1])
371
+ >>> x, y = pol2cart(theta, rho)
372
+ >>> print(x) # [1, 0, -1]
373
+ >>> print(y) # [0, 1, 0]
374
+
375
+ Notes
376
+ -----
377
+ Equivalent to MATLAB's pol2cart function.
378
+ """
379
+ x = rho * np.cos(theta)
380
+ y = rho * np.sin(theta)
381
+ return x, y
382
+
383
+
384
+ if __name__ == "__main__":
385
+ # Simple tests
386
+ print("Testing geometry functions...")
387
+
388
+ # Test 1: Fit ellipse
389
+ print("\nTest 1 - Ellipse fitting:")
390
+ t = np.linspace(0, 2 * np.pi, 100)
391
+ cx, cy = 5, 3 # center
392
+ rx, ry = 4, 2 # radii
393
+ angle = np.pi / 6 # rotation
394
+
395
+ # Generate points on ellipse
396
+ x = cx + rx * np.cos(t) * np.cos(angle) - ry * np.sin(t) * np.sin(angle)
397
+ y = cy + rx * np.cos(t) * np.sin(angle) + ry * np.sin(t) * np.cos(angle)
398
+
399
+ params = fit_ellipse(x, y)
400
+ print(f" True center: ({cx}, {cy})")
401
+ print(f" Fitted center: ({params[0]:.2f}, {params[1]:.2f})")
402
+ print(f" True radii: ({rx}, {ry})")
403
+ print(f" Fitted radii: ({abs(params[2]):.2f}, {abs(params[3]):.2f})")
404
+ print(f" True angle: {np.rad2deg(angle):.2f}°")
405
+ print(f" Fitted angle: {np.rad2deg(params[4]):.2f}°")
406
+
407
+ # Test 2: Squared distance
408
+ print("\nTest 2 - Squared distance:")
409
+ X = np.array([[0, 1, 2], [0, 0, 0]]) # 3 points along x-axis
410
+ Y = np.array([[0, 0], [1, 2]]) # 2 points along y-axis
411
+ D = squared_distance(X, Y)
412
+ print(f" X points: {X.T}")
413
+ print(f" Y points: {Y.T}")
414
+ print(f" Squared distances:\n{D}")
415
+
416
+ # Test 3: Polygon area
417
+ print("\nTest 3 - Polygon area:")
418
+ # Unit square
419
+ x = np.array([0, 1, 1, 0])
420
+ y = np.array([0, 0, 1, 1])
421
+ area = polyarea(x, y)
422
+ print(f" Unit square area: {area:.3f} (should be 1.0)")
423
+
424
+ # Triangle
425
+ x = np.array([0, 1, 0.5])
426
+ y = np.array([0, 0, 1])
427
+ area = polyarea(x, y)
428
+ print(f" Triangle area: {area:.3f} (should be 0.5)")
429
+
430
+ # Test 4: Coordinate transformations
431
+ print("\nTest 4 - Coordinate transformations:")
432
+ x = np.array([1, 0, -1, 0])
433
+ y = np.array([0, 1, 0, -1])
434
+ theta, rho = cart2pol(x, y)
435
+ print(f" Cartesian: {np.column_stack([x, y])}")
436
+ print(f" Polar (θ, ρ): {np.column_stack([np.rad2deg(theta), rho])}")
437
+
438
+ x2, y2 = pol2cart(theta, rho)
439
+ print(f" Back to Cartesian: {np.column_stack([x2, y2])}")
440
+ print(f" Error: {np.max(np.abs(np.column_stack([x - x2, y - y2]))):.10f}")
441
+
442
+ print("\nAll tests completed!")