nadata 0.1.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 (86) hide show
  1. nadata/__init__.py +15 -0
  2. nadata/api.py +894 -0
  3. nadata/cli.py +445 -0
  4. nadata/console.py +486 -0
  5. nadata/core/__init__.py +0 -0
  6. nadata/core/database/__init__.py +38 -0
  7. nadata/core/database/builtins.py +87 -0
  8. nadata/core/database/database.py +958 -0
  9. nadata/core/database/dumper.py +285 -0
  10. nadata/core/database/entity_id.py +142 -0
  11. nadata/core/database/instance.py +706 -0
  12. nadata/core/database/network_index.py +1142 -0
  13. nadata/core/database/page_manager.py +175 -0
  14. nadata/core/database/persistence/__init__.py +28 -0
  15. nadata/core/database/persistence/binary_cells.py +1141 -0
  16. nadata/core/database/persistence/compaction.py +97 -0
  17. nadata/core/database/persistence/dumper.py +798 -0
  18. nadata/core/database/persistence/lazy_instances.py +1537 -0
  19. nadata/core/database/persistence/loader.py +424 -0
  20. nadata/core/database/persistence/record_store.py +228 -0
  21. nadata/core/database/persistence/serialization.py +605 -0
  22. nadata/core/database/query_engine.py +222 -0
  23. nadata/core/database/queryset.py +508 -0
  24. nadata/core/database/reconciliation.py +287 -0
  25. nadata/core/database/slotted_page.py +190 -0
  26. nadata/core/database/spatial_index.py +1080 -0
  27. nadata/core/expressions/__init__.py +44 -0
  28. nadata/core/expressions/autograd.py +2909 -0
  29. nadata/core/expressions/builtins.py +4673 -0
  30. nadata/core/expressions/expression.py +2658 -0
  31. nadata/core/expressions/io_builtins.py +972 -0
  32. nadata/core/expressions/scope.py +336 -0
  33. nadata/core/expressions/statement.py +953 -0
  34. nadata/core/struct/__init__.py +14 -0
  35. nadata/core/struct/struct.py +218 -0
  36. nadata/core/struct/struct_attribute.py +100 -0
  37. nadata/core/struct/struct_method.py +324 -0
  38. nadata/core/types/__init__.py +33 -0
  39. nadata/core/types/bits/__init__.py +0 -0
  40. nadata/core/types/bits/bits.py +686 -0
  41. nadata/core/types/home_types/__init__.py +19 -0
  42. nadata/core/types/home_types/color.py +102 -0
  43. nadata/core/types/home_types/coordinate.py +1008 -0
  44. nadata/core/types/home_types/datetime.py +362 -0
  45. nadata/core/types/home_types/geogrid.py +92 -0
  46. nadata/core/types/home_types/geometry.py +1223 -0
  47. nadata/core/types/home_types/graph.py +0 -0
  48. nadata/core/types/home_types/planet.py +359 -0
  49. nadata/core/types/home_types/sea_level.py +98 -0
  50. nadata/core/types/home_types/vec.py +0 -0
  51. nadata/core/types/types.py +1345 -0
  52. nadata/core/values/__init__.py +14 -0
  53. nadata/core/values/values.py +1568 -0
  54. nadata/parser/__init__.py +0 -0
  55. nadata/parser/expressions/__init__.py +6 -0
  56. nadata/parser/expressions/dependencies.py +318 -0
  57. nadata/parser/expressions/expression_parser.py +993 -0
  58. nadata/parser/expressions/splitter.py +98 -0
  59. nadata/parser/expressions/statement_parser.py +1425 -0
  60. nadata/parser/expressions/tokenizer.py +402 -0
  61. nadata/parser/expressions/type_parser.py +187 -0
  62. nadata/parser/nadata_loader/__init__.py +8 -0
  63. nadata/parser/nadata_loader/loader.py +951 -0
  64. nadata/parser/nastruct_parser/__init__.py +0 -0
  65. nadata/parser/nastruct_parser/declaration.py +105 -0
  66. nadata/parser/nastruct_parser/imports.py +164 -0
  67. nadata/parser/nastruct_parser/nastruct_parser.py +209 -0
  68. nadata/parser/nastruct_parser/parse_attributes.py +149 -0
  69. nadata/parser/nastruct_parser/parse_callables.py +436 -0
  70. nadata/parser/nastruct_parser/parse_expression.py +59 -0
  71. nadata/parser/nastruct_parser/parse_structs.py +206 -0
  72. nadata/parser/nastruct_parser/parser_state.py +84 -0
  73. nadata/parser/nastruct_parser/resolver.py +1412 -0
  74. nadata/parser/nastruct_parser/struct_body_state.py +50 -0
  75. nadata/parser/nastruct_parser/tmp_structs.py +156 -0
  76. nadata/parser/utils/__init__.py +20 -0
  77. nadata/parser/utils/brackets.py +252 -0
  78. nadata/parser/utils/comments_remove.py +149 -0
  79. nadata/parser/utils/identifiers.py +121 -0
  80. nadata/session.py +868 -0
  81. nadata-0.1.0.dist-info/METADATA +191 -0
  82. nadata-0.1.0.dist-info/RECORD +86 -0
  83. nadata-0.1.0.dist-info/WHEEL +5 -0
  84. nadata-0.1.0.dist-info/entry_points.txt +2 -0
  85. nadata-0.1.0.dist-info/licenses/LICENSE +21 -0
  86. nadata-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1223 @@
1
+ """
2
+ Geometry primitives for Nadata.
3
+
4
+ Provides the Polyline and Polygon value types over Coordinates, with
5
+ the measurement and predicate layer of the map plan (Phase B):
6
+
7
+ - Polyline: geodesic length, point lookup by distance or fraction,
8
+ distance to the path.
9
+ - Polygon: spherical-excess area, geodesic perimeter, vertex centroid,
10
+ antimeridian-safe point-in-polygon (planar even-odd test on an
11
+ unwrapped equirectangular projection), distance to the boundary, and
12
+ the inradius / circumradius fast-path radii used by the Phase C
13
+ spatial index.
14
+
15
+ Sphere math is closed-form; spheroid math uses Vincenty per edge and
16
+ sphere formulas on authalic / quadratic-mean radii (documented
17
+ approximations). Shape constructor sugar (circle, ellipse, regular
18
+ polygon) produces Polygons.
19
+
20
+ Free functions in this module receive raw payloads from the evaluator
21
+ (home_types module convention); method-style members on the types are
22
+ the primary API and delegate to the same logic.
23
+ """
24
+
25
+ # Import Modules
26
+ from __future__ import annotations
27
+
28
+ from typing import Any, Optional
29
+ import math
30
+
31
+ from nadata.core.types.home_types.coordinate import (
32
+ Coordinate,
33
+ _from_unit_vector,
34
+ _to_unit_vector,
35
+ normalize_lon,
36
+ wrap_lon_delta,
37
+ )
38
+ from nadata.core.types.home_types.planet import Planet, get_current_planet
39
+
40
+
41
+
42
+ def _require_coordinate_list(name: str, vertices: Any) -> list[Coordinate]:
43
+ """
44
+ Validates a vertex list argument.
45
+
46
+ Args:
47
+ name (str): Argument name for error messages.
48
+ vertices (Any): Candidate vertex list.
49
+
50
+ Returns:
51
+ list[Coordinate]: The validated vertex list.
52
+
53
+ Raises:
54
+ TypeError: If the argument is not a list of Coordinates.
55
+ """
56
+
57
+ if isinstance(vertices, tuple):
58
+ vertices = list(vertices)
59
+
60
+ if not isinstance(vertices, list):
61
+ raise TypeError(
62
+ f"{name} must be a list of Coordinate, got '{type(vertices).__name__}'"
63
+ )
64
+
65
+ for index, vertex in enumerate(vertices):
66
+ if not isinstance(vertex, Coordinate):
67
+ raise TypeError(
68
+ f"{name}[{index}] must be a Coordinate, "
69
+ f"got '{type(vertex).__name__}'"
70
+ )
71
+
72
+ return vertices
73
+
74
+
75
+ def _unit_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
76
+ """
77
+ Computes the cross product of two unit vectors.
78
+
79
+ Args:
80
+ a (tuple[float, float, float]): First unit vector.
81
+ b (tuple[float, float, float]): Second unit vector.
82
+
83
+ Returns:
84
+ tuple[float, float, float]: The cross product.
85
+ """
86
+
87
+ return (
88
+ a[1] * b[2] - a[2] * b[1],
89
+ a[2] * b[0] - a[0] * b[2],
90
+ a[0] * b[1] - a[1] * b[0],
91
+ )
92
+
93
+
94
+ def _unit_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
95
+ """
96
+ Computes the dot product of two vectors.
97
+
98
+ Args:
99
+ a (tuple[float, float, float]): First vector.
100
+ b (tuple[float, float, float]): Second vector.
101
+
102
+ Returns:
103
+ float: The dot product.
104
+ """
105
+
106
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
107
+
108
+
109
+ def _central_angle(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
110
+ """
111
+ Computes the angle between two unit vectors.
112
+
113
+ Args:
114
+ a (tuple[float, float, float]): First unit vector.
115
+ b (tuple[float, float, float]): Second unit vector.
116
+
117
+ Returns:
118
+ float: Angle in radians, in [0, pi].
119
+ """
120
+
121
+ cross: tuple[float, float, float] = _unit_cross(a=a, b=b)
122
+
123
+ return math.atan2(
124
+ math.sqrt(cross[0] ** 2 + cross[1] ** 2 + cross[2] ** 2),
125
+ _unit_dot(a=a, b=b),
126
+ )
127
+
128
+
129
+ def _resolve_planet(planet: Optional[Planet]) -> Planet:
130
+ """
131
+ Resolves the effective planet for a computation.
132
+
133
+ Args:
134
+ planet (Optional[Planet]): Explicit override, or None to use
135
+ the session current planet.
136
+
137
+ Returns:
138
+ Planet: The planet to compute against.
139
+ """
140
+
141
+ return planet if planet is not None else get_current_planet()
142
+
143
+
144
+ def _unwrapped_lons(vertices: list[Coordinate]) -> list[float]:
145
+ """
146
+ Unwraps vertex longitudes into a contiguous sequence (consecutive
147
+ deltas within 180 degrees), so polygons crossing the antimeridian
148
+ keep their true shape.
149
+
150
+ Args:
151
+ vertices (list[Coordinate]): The polygon or polyline vertices.
152
+
153
+ Returns:
154
+ list[float]: Contiguous longitudes in degrees.
155
+ """
156
+
157
+ if not vertices:
158
+ return []
159
+
160
+ lons: list[float] = [vertices[0].lon]
161
+
162
+ for vertex in vertices[1:]:
163
+ lons.append(lons[-1] + wrap_lon_delta(lon_delta_deg=vertex.lon - lons[-1]))
164
+
165
+ return lons
166
+
167
+
168
+ def _edge_angle(a: Coordinate, b: Coordinate) -> float:
169
+ """
170
+ Computes the angular separation of two coordinates on the unit
171
+ sphere (their geodetic directions treated as unit vectors).
172
+
173
+ Args:
174
+ a (Coordinate): First coordinate.
175
+ b (Coordinate): Second coordinate.
176
+
177
+ Returns:
178
+ float: Angle in radians.
179
+ """
180
+
181
+ return _central_angle(
182
+ a=_to_unit_vector(lat_deg=a.lat, lon_deg=a.lon),
183
+ b=_to_unit_vector(lat_deg=b.lat, lon_deg=b.lon),
184
+ )
185
+
186
+
187
+ def _edge_length(a: Coordinate, b: Coordinate, planet: Planet) -> float:
188
+ """
189
+ Computes the geodesic length of one edge.
190
+
191
+ Args:
192
+ a (Coordinate): First endpoint.
193
+ b (Coordinate): Second endpoint.
194
+ planet (Planet): Effective planet.
195
+
196
+ Returns:
197
+ float: Edge length in meters.
198
+ """
199
+
200
+ if planet.is_sphere:
201
+ return _edge_angle(a=a, b=b) * planet.equatorial_radius
202
+
203
+ return a.distance(other=b, planet=planet)
204
+
205
+
206
+ def _segment_distance(
207
+ point: Coordinate,
208
+ a: Coordinate,
209
+ b: Coordinate,
210
+ ) -> float:
211
+ """
212
+ Computes the angular distance from a point to the minor great-
213
+ circle arc between two coordinates, on the unit sphere.
214
+
215
+ Args:
216
+ point (Coordinate): The query point.
217
+ a (Coordinate): Segment start.
218
+ b (Coordinate): Segment end.
219
+
220
+ Returns:
221
+ float: Angular distance in radians.
222
+ """
223
+
224
+ p: tuple[float, float, float] = _to_unit_vector(lat_deg=point.lat, lon_deg=point.lon)
225
+ u: tuple[float, float, float] = _to_unit_vector(lat_deg=a.lat, lon_deg=a.lon)
226
+ v: tuple[float, float, float] = _to_unit_vector(lat_deg=b.lat, lon_deg=b.lon)
227
+
228
+ # Fall back to endpoints for degenerate or antipodal segments
229
+ angle_ab: float = _central_angle(a=u, b=v)
230
+
231
+ if angle_ab < 1e-15 or abs(angle_ab - math.pi) < 1e-9:
232
+ return min(
233
+ _central_angle(a=p, b=u),
234
+ _central_angle(a=p, b=v),
235
+ )
236
+
237
+ normal: tuple[float, float, float] = _unit_cross(a=u, b=v)
238
+ normal_norm: float = math.sqrt(_unit_dot(a=normal, b=normal))
239
+ normal = (normal[0] / normal_norm, normal[1] / normal_norm, normal[2] / normal_norm)
240
+
241
+ # Project the point onto the great-circle plane
242
+ dot_pn: float = _unit_dot(a=p, b=normal)
243
+ projected: tuple[float, float, float] = (
244
+ p[0] - dot_pn * normal[0],
245
+ p[1] - dot_pn * normal[1],
246
+ p[2] - dot_pn * normal[2],
247
+ )
248
+
249
+ projected_norm: float = math.sqrt(_unit_dot(a=projected, b=projected))
250
+
251
+ if projected_norm < 1e-15:
252
+ # The point sits on a pole of the great circle: nearest endpoint
253
+ return min(
254
+ _central_angle(a=p, b=u),
255
+ _central_angle(a=p, b=v),
256
+ )
257
+
258
+ projected = (
259
+ projected[0] / projected_norm,
260
+ projected[1] / projected_norm,
261
+ projected[2] / projected_norm,
262
+ )
263
+
264
+ # Inside the minor arc when closer to both endpoints than they are
265
+ # to each other
266
+ angle_ap: float = _central_angle(a=u, b=projected)
267
+ angle_bp: float = _central_angle(a=v, b=projected)
268
+
269
+ if angle_ap <= angle_ab + 1e-12 and angle_bp <= angle_ab + 1e-12:
270
+ return _central_angle(a=p, b=projected)
271
+
272
+ return min(
273
+ _central_angle(a=p, b=u),
274
+ _central_angle(a=p, b=v),
275
+ )
276
+
277
+
278
+ def _segment_distance_meters(
279
+ point: Coordinate,
280
+ a: Coordinate,
281
+ b: Coordinate,
282
+ planet: Planet,
283
+ ) -> float:
284
+ """
285
+ Computes the distance from a point to a boundary segment.
286
+
287
+ Args:
288
+ point (Coordinate): The query point.
289
+ a (Coordinate): Segment start.
290
+ b (Coordinate): Segment end.
291
+ planet (Planet): Effective planet.
292
+
293
+ Returns:
294
+ float: Distance in meters (spheroid: sphere approximation on
295
+ the quadratic mean radius).
296
+ """
297
+
298
+ radius: float = (
299
+ planet.equatorial_radius
300
+ if planet.is_sphere
301
+ else planet.quadratic_mean_radius
302
+ )
303
+
304
+ return _segment_distance(point=point, a=a, b=b) * radius
305
+
306
+
307
+ def _point_in_ring(lon: float, lat: float, lons: list[float], lats: list[float]) -> bool:
308
+ """
309
+ Planar even-odd point-in-ring test on the equirectangular plane.
310
+
311
+ Args:
312
+ lon (float): Query longitude (already translated).
313
+ lat (float): Query latitude.
314
+ lons (list[float]): Ring longitudes (contiguous unwrap).
315
+ lats (list[float]): Ring latitudes.
316
+
317
+ Returns:
318
+ bool: True when the point is inside the ring.
319
+ """
320
+
321
+ inside: bool = False
322
+ count: int = len(lons)
323
+ j: int = count - 1
324
+
325
+ for i in range(count):
326
+ yi: float = lats[i]
327
+ yj: float = lats[j]
328
+
329
+ if (yi > lat) != (yj > lat):
330
+ x_intersect: float = (
331
+ (lons[j] - lons[i]) * (lat - yi) / (yj - yi) + lons[i]
332
+ )
333
+
334
+ if lon < x_intersect:
335
+ inside = not inside
336
+
337
+ j = i
338
+
339
+ return inside
340
+
341
+
342
+
343
+ class Polyline:
344
+ """
345
+ A path over the planet: an ordered sequence of Coordinates.
346
+
347
+ Attributes:
348
+ vertices (list[Coordinate]): The path vertices.
349
+ """
350
+
351
+ def __init__(self, vertices: Any) -> None:
352
+ """
353
+ Initializes the Polyline instance.
354
+
355
+ Args:
356
+ vertices (Any): The path vertices (list of Coordinate).
357
+
358
+ Raises:
359
+ TypeError: If vertices is not a list of Coordinates.
360
+ """
361
+
362
+ self.vertices: list[Coordinate] = _require_coordinate_list(
363
+ name="Polyline vertices", vertices=vertices
364
+ )
365
+
366
+ def _planet(self, planet: Optional[Planet]) -> Planet:
367
+ """
368
+ Resolves the effective planet.
369
+
370
+ Args:
371
+ planet (Optional[Planet]): Explicit override.
372
+
373
+ Returns:
374
+ Planet: The planet to compute against.
375
+ """
376
+
377
+ return _resolve_planet(planet=planet)
378
+
379
+ def num_vertices(self) -> int:
380
+ """
381
+ Returns the vertex count.
382
+
383
+ Returns:
384
+ int: Number of vertices.
385
+ """
386
+
387
+ return len(self.vertices)
388
+
389
+ def vertex(self, index: int) -> Coordinate:
390
+ """
391
+ Returns the vertex at an index (negative indices count from
392
+ the end).
393
+
394
+ Args:
395
+ index (int): Vertex index.
396
+
397
+ Returns:
398
+ Coordinate: The vertex.
399
+
400
+ Raises:
401
+ IndexError: If the index is out of range.
402
+ """
403
+
404
+ if not isinstance(index, int) or isinstance(index, bool):
405
+ raise TypeError(f"Vertex index must be an integer, got '{type(index).__name__}'")
406
+
407
+ return self.vertices[index]
408
+
409
+ def length(self, planet: Optional[Planet] = None) -> float:
410
+ """
411
+ Computes the geodesic length along the path.
412
+
413
+ Args:
414
+ planet (Optional[Planet], optional): Explicit planet
415
+ override. Defaults to None.
416
+ center (Optional[Coordinate], optional): Explicit anchor
417
+ override. Defaults to the centroid.
418
+
419
+ Returns:
420
+ float: Length in meters.
421
+ """
422
+
423
+ resolved: Planet = self._planet(planet=planet)
424
+
425
+ total: float = 0.0
426
+
427
+ for i in range(len(self.vertices) - 1):
428
+ total += _edge_length(
429
+ a=self.vertices[i],
430
+ b=self.vertices[i + 1],
431
+ planet=resolved,
432
+ )
433
+
434
+ return total
435
+
436
+ def point_at_distance(
437
+ self,
438
+ distance_m: float,
439
+ planet: Optional[Planet] = None,
440
+ ) -> Coordinate:
441
+ """
442
+ Returns the point at a along-path distance (walking the
443
+ geodesic edges).
444
+
445
+ Args:
446
+ distance_m (float): Distance along the path in meters.
447
+ planet (Optional[Planet], optional): Explicit planet
448
+ override. Defaults to None.
449
+
450
+ Returns:
451
+ Coordinate: The point at the distance.
452
+
453
+ Raises:
454
+ ValueError: If the distance is negative or beyond the path
455
+ length, or the path is empty.
456
+ """
457
+
458
+ resolved: Planet = self._planet(planet=planet)
459
+
460
+ if not self.vertices:
461
+ raise ValueError("Cannot locate a point on an empty Polyline")
462
+
463
+ if not isinstance(distance_m, (int, float)) or isinstance(distance_m, bool):
464
+ raise TypeError(
465
+ f"Distance must be a number, got '{type(distance_m).__name__}'"
466
+ )
467
+
468
+ remaining: float = float(distance_m)
469
+
470
+ if remaining < 0.0:
471
+ raise ValueError(f"Distance must be non-negative, got {remaining}")
472
+
473
+ if remaining > self.length(planet=planet):
474
+ raise ValueError(
475
+ f"Distance {distance_m} exceeds the path length "
476
+ f"{self.length(planet=planet)}"
477
+ )
478
+
479
+ last_index: int = len(self.vertices) - 2
480
+
481
+ for i in range(len(self.vertices) - 1):
482
+ edge: float = _edge_length(
483
+ a=self.vertices[i], b=self.vertices[i + 1], planet=resolved
484
+ )
485
+
486
+ if remaining <= edge or i == last_index:
487
+ # Clamp absorbs float drift at the path end
488
+ fraction: float = 0.0 if edge == 0.0 else min(
489
+ 1.0, remaining / edge
490
+ )
491
+
492
+ return self.vertices[i].interpolate(
493
+ other=self.vertices[i + 1], t=fraction, planet=planet
494
+ )
495
+
496
+ remaining -= edge
497
+
498
+ raise ValueError("Cannot locate a point on an empty Polyline")
499
+
500
+ def point_at(self, t: float, planet: Optional[Planet] = None) -> Coordinate:
501
+ """
502
+ Returns the point at a fraction of the total path length.
503
+
504
+ Args:
505
+ t (float): Fraction in [0, 1].
506
+ planet (Optional[Planet], optional): Explicit planet
507
+ override. Defaults to None.
508
+
509
+ Returns:
510
+ Coordinate: The point at the fraction.
511
+
512
+ Raises:
513
+ ValueError: If t is outside [0, 1] or the path is empty.
514
+ """
515
+
516
+ if not isinstance(t, (int, float)) or isinstance(t, bool):
517
+ raise TypeError(f"t must be a number, got '{type(t).__name__}'")
518
+
519
+ if not 0.0 <= float(t) <= 1.0:
520
+ raise ValueError(f"t must be in [0, 1], got {t}")
521
+
522
+ return self.point_at_distance(
523
+ distance_m=float(t) * self.length(planet=planet), planet=planet
524
+ )
525
+
526
+ def distance_to(self, point: Coordinate, planet: Optional[Planet] = None) -> float:
527
+ """
528
+ Computes the distance from a coordinate to the nearest point
529
+ of the path.
530
+
531
+ Args:
532
+ point (Coordinate): The query coordinate.
533
+ planet (Optional[Planet], optional): Explicit planet
534
+ override. Defaults to None.
535
+
536
+ Returns:
537
+ float: Distance in meters.
538
+
539
+ Raises:
540
+ ValueError: If the path is empty.
541
+ """
542
+
543
+ resolved: Planet = self._planet(planet=planet)
544
+
545
+ if not self.vertices:
546
+ raise ValueError("Cannot measure distance to an empty Polyline")
547
+
548
+ best: Optional[float] = None
549
+
550
+ for i in range(len(self.vertices) - 1):
551
+ candidate: float = _segment_distance_meters(
552
+ point=point,
553
+ a=self.vertices[i],
554
+ b=self.vertices[i + 1],
555
+ planet=resolved,
556
+ )
557
+
558
+ if best is None or candidate < best:
559
+ best = candidate
560
+
561
+ # A single-vertex path measures the distance to the vertex
562
+ if best is None:
563
+ best = _segment_distance_meters(
564
+ point=point,
565
+ a=self.vertices[0],
566
+ b=self.vertices[0],
567
+ planet=resolved,
568
+ )
569
+
570
+ return best
571
+
572
+ def __eq__(self, other: Any) -> bool:
573
+ """
574
+ Checks equality with another Polyline.
575
+
576
+ Args:
577
+ other (Any): Object to compare with.
578
+
579
+ Returns:
580
+ bool: True when the vertex lists match.
581
+ """
582
+
583
+ if not isinstance(other, Polyline):
584
+ return False
585
+
586
+ return self.vertices == other.vertices
587
+
588
+ def __repr__(self) -> str:
589
+ """
590
+ Returns string representation of the Polyline.
591
+
592
+ Returns:
593
+ str: Polyline constructor string.
594
+ """
595
+
596
+ return f"Polyline({self.vertices!r})"
597
+
598
+
599
+ class Polygon:
600
+ """
601
+ A closed ring over the planet: an ordered sequence of Coordinates
602
+ (the closing edge is implicit). A duplicate closing vertex is
603
+ stripped on construction.
604
+
605
+ Attributes:
606
+ vertices (list[Coordinate]): The ring vertices.
607
+ """
608
+
609
+ def __init__(self, vertices: Any) -> None:
610
+ """
611
+ Initializes the Polygon instance.
612
+
613
+ Args:
614
+ vertices (Any): The ring vertices (list of Coordinate).
615
+
616
+ Raises:
617
+ TypeError: If vertices is not a list of Coordinates.
618
+ ValueError: If the vertex count is 1 or 2.
619
+ """
620
+
621
+ ring: list[Coordinate] = _require_coordinate_list(
622
+ name="Polygon vertices", vertices=vertices
623
+ )
624
+
625
+ # Strip an explicitly closed ring
626
+ if len(ring) >= 2 and ring[0] == ring[-1]:
627
+ ring = ring[:-1]
628
+
629
+ if 0 < len(ring) < 3:
630
+ raise ValueError(
631
+ f"A polygon needs 0 (empty) or at least 3 vertices, got {len(ring)}"
632
+ )
633
+
634
+ self.vertices: list[Coordinate] = ring
635
+
636
+ def _planet(self, planet: Optional[Planet]) -> Planet:
637
+ """
638
+ Resolves the effective planet.
639
+
640
+ Args:
641
+ planet (Optional[Planet]): Explicit override.
642
+
643
+ Returns:
644
+ Planet: The planet to compute against.
645
+ """
646
+
647
+ return _resolve_planet(planet=planet)
648
+
649
+ def num_vertices(self) -> int:
650
+ """
651
+ Returns the vertex count.
652
+
653
+ Returns:
654
+ int: Number of vertices.
655
+ """
656
+
657
+ return len(self.vertices)
658
+
659
+ def vertex(self, index: int) -> Coordinate:
660
+ """
661
+ Returns the vertex at an index (negative indices count from
662
+ the end).
663
+
664
+ Args:
665
+ index (int): Vertex index.
666
+
667
+ Returns:
668
+ Coordinate: The vertex.
669
+
670
+ Raises:
671
+ IndexError: If the index is out of range.
672
+ """
673
+
674
+ if not isinstance(index, int) or isinstance(index, bool):
675
+ raise TypeError(f"Vertex index must be an integer, got '{type(index).__name__}'")
676
+
677
+ return self.vertices[index]
678
+
679
+ def area(self, planet: Optional[Planet] = None) -> float:
680
+ """
681
+ Computes the enclosed area.
682
+
683
+ Sphere: exact spherical excess (Chamberlain & Duquette).
684
+ Spheroid: the same formula on the authalic (equal-area)
685
+ radius -- an approximation (see the map plan, open points).
686
+
687
+ Args:
688
+ planet (Optional[Planet], optional): Explicit planet
689
+ override. Defaults to None.
690
+
691
+ Returns:
692
+ float: Area in square meters (0 for the empty polygon).
693
+ """
694
+
695
+ resolved: Planet = self._planet(planet=planet)
696
+
697
+ if len(self.vertices) < 3:
698
+ return 0.0
699
+
700
+ lons: list[float] = _unwrapped_lons(vertices=self.vertices)
701
+
702
+ total: float = 0.0
703
+
704
+ for i in range(len(self.vertices)):
705
+ j: int = (i + 1) % len(self.vertices)
706
+
707
+ lat_i: float = math.radians(self.vertices[i].lat)
708
+ lat_j: float = math.radians(self.vertices[j].lat)
709
+
710
+ total += math.radians(lons[j] - lons[i]) * (
711
+ 2.0 + math.sin(lat_i) + math.sin(lat_j)
712
+ )
713
+
714
+ return abs(total) * resolved.authalic_radius**2 / 2.0
715
+
716
+ def perimeter(self, planet: Optional[Planet] = None) -> float:
717
+ """
718
+ Computes the geodesic length of the closed boundary.
719
+
720
+ Args:
721
+ planet (Optional[Planet], optional): Explicit planet
722
+ override. Defaults to None.
723
+
724
+ Returns:
725
+ float: Perimeter in meters.
726
+ """
727
+
728
+ resolved: Planet = self._planet(planet=planet)
729
+
730
+ if len(self.vertices) < 3:
731
+ return 0.0
732
+
733
+ total: float = 0.0
734
+
735
+ for i in range(len(self.vertices)):
736
+ total += _edge_length(
737
+ a=self.vertices[i],
738
+ b=self.vertices[(i + 1) % len(self.vertices)],
739
+ planet=resolved,
740
+ )
741
+
742
+ return total
743
+
744
+ def centroid(self, planet: Optional[Planet] = None) -> Coordinate:
745
+ """
746
+ Returns the vertex centroid: the normalized mean of the vertex
747
+ direction vectors, at surface radius (a fast-path anchor, not
748
+ an area centroid).
749
+
750
+ Args:
751
+ planet (Optional[Planet], optional): Explicit planet
752
+ override. Defaults to None.
753
+
754
+ Returns:
755
+ Coordinate: The centroid coordinate.
756
+
757
+ Raises:
758
+ ValueError: If the polygon is empty or the vertices cancel
759
+ out (e.g. antipodal halves).
760
+ """
761
+
762
+ resolved: Planet = self._planet(planet=planet)
763
+
764
+ if not self.vertices:
765
+ raise ValueError("Cannot compute the centroid of an empty Polygon")
766
+
767
+ x: float = 0.0
768
+ y: float = 0.0
769
+ z: float = 0.0
770
+
771
+ for vertex in self.vertices:
772
+ vx, vy, vz = _to_unit_vector(lat_deg=vertex.lat, lon_deg=vertex.lon)
773
+
774
+ x += vx
775
+ y += vy
776
+ z += vz
777
+
778
+ norm: float = math.sqrt(x * x + y * y + z * z)
779
+
780
+ if norm < 1e-15:
781
+ raise ValueError("Polygon vertices cancel out; centroid is undefined")
782
+
783
+ lat: float
784
+ lon: float
785
+ lat, lon = _from_unit_vector(vector=(x / norm, y / norm, z / norm))
786
+
787
+ return Coordinate(
788
+ lat=lat, lon=lon, radius=resolved.surface_radius(lat_deg=lat)
789
+ )
790
+
791
+ def contains(self, point: Coordinate, planet: Optional[Planet] = None) -> bool:
792
+ """
793
+ Tests whether a coordinate lies inside the ring.
794
+
795
+ Uses a planar even-odd test on an unwrapped equirectangular
796
+ projection, probing the point at longitudes shifted by -360,
797
+ 0, and +360 so antimeridian-crossing rings test correctly.
798
+ Region-sized polygons are exact for practical purposes; polar
799
+ cap shapes are an approximation (map plan, open points).
800
+
801
+ Args:
802
+ point (Coordinate): The query coordinate.
803
+ planet (Optional[Planet], optional): Unused; accepted for
804
+ API symmetry. Defaults to None.
805
+
806
+ Returns:
807
+ bool: True when the point is inside (boundary is inside).
808
+
809
+ Raises:
810
+ ValueError: If the polygon is empty.
811
+ """
812
+
813
+ if not self.vertices:
814
+ raise ValueError("An empty Polygon contains nothing")
815
+
816
+ lons: list[float] = _unwrapped_lons(vertices=self.vertices)
817
+ lats: list[float] = [vertex.lat for vertex in self.vertices]
818
+
819
+ for shift in (-360.0, 0.0, 360.0):
820
+ if _point_in_ring(
821
+ lon=point.lon + shift, lat=point.lat, lons=lons, lats=lats
822
+ ):
823
+ return True
824
+
825
+ return False
826
+
827
+ def distance_to(self, point: Coordinate, planet: Optional[Planet] = None) -> float:
828
+ """
829
+ Computes the distance from a coordinate to the polygon (0 when
830
+ the point is inside).
831
+
832
+ Args:
833
+ point (Coordinate): The query coordinate.
834
+ planet (Optional[Planet], optional): Explicit planet
835
+ override. Defaults to None.
836
+
837
+ Returns:
838
+ float: Distance in meters.
839
+
840
+ Raises:
841
+ ValueError: If the polygon is empty.
842
+ """
843
+
844
+ resolved: Planet = self._planet(planet=planet)
845
+
846
+ if not self.vertices:
847
+ raise ValueError("Cannot measure distance to an empty Polygon")
848
+
849
+ if self.contains(point=point):
850
+ return 0.0
851
+
852
+ best: Optional[float] = None
853
+ count: int = len(self.vertices)
854
+
855
+ for i in range(count):
856
+ candidate: float = _segment_distance_meters(
857
+ point=point,
858
+ a=self.vertices[i],
859
+ b=self.vertices[(i + 1) % count],
860
+ planet=resolved,
861
+ )
862
+
863
+ if best is None or candidate < best:
864
+ best = candidate
865
+
866
+ assert best is not None
867
+
868
+ return best
869
+
870
+ def circumradius(
871
+ self,
872
+ planet: Optional[Planet] = None,
873
+ center: Optional[Coordinate] = None,
874
+ ) -> float:
875
+ """
876
+ Returns the geodesic distance from the centroid (or an
877
+ explicit anchor) to the farthest vertex. Points farther than
878
+ this from the centroid are definitely outside (Phase C fast
879
+ path).
880
+
881
+ Args:
882
+ planet (Optional[Planet], optional): Explicit planet
883
+ override. Defaults to None.
884
+ center (Optional[Coordinate], optional): Explicit anchor
885
+ override. Defaults to the centroid.
886
+
887
+ Returns:
888
+ float: Circumradius in meters.
889
+
890
+ Raises:
891
+ ValueError: If the polygon is empty.
892
+ """
893
+
894
+ resolved: Planet = self._planet(planet=planet)
895
+
896
+ if not self.vertices:
897
+ raise ValueError("An empty Polygon has no circumradius")
898
+
899
+ anchor: Coordinate = (
900
+ center if center is not None else self.centroid(planet=planet)
901
+ )
902
+
903
+ return max(
904
+ anchor.distance(other=vertex, planet=resolved)
905
+ for vertex in self.vertices
906
+ )
907
+
908
+ def inradius(
909
+ self,
910
+ planet: Optional[Planet] = None,
911
+ center: Optional[Coordinate] = None,
912
+ ) -> float:
913
+ """
914
+ Returns the distance from the centroid to the nearest boundary
915
+ point. Points closer than this to the centroid are definitely
916
+ inside (Phase C fast path). 0 when the centroid falls outside
917
+ the ring (pathological shapes).
918
+
919
+ Args:
920
+ planet (Optional[Planet], optional): Explicit planet
921
+ override. Defaults to None.
922
+
923
+ Returns:
924
+ float: Inradius in meters.
925
+
926
+ Raises:
927
+ ValueError: If the polygon is empty.
928
+ """
929
+
930
+ resolved: Planet = self._planet(planet=planet)
931
+
932
+ if not self.vertices:
933
+ raise ValueError("An empty Polygon has no inradius")
934
+
935
+ anchor: Coordinate = (
936
+ center if center is not None else self.centroid(planet=planet)
937
+ )
938
+
939
+ if not self.contains(point=anchor):
940
+ return 0.0
941
+
942
+ best: Optional[float] = None
943
+ count: int = len(self.vertices)
944
+
945
+ for i in range(count):
946
+ candidate: float = _segment_distance_meters(
947
+ point=anchor,
948
+ a=self.vertices[i],
949
+ b=self.vertices[(i + 1) % count],
950
+ planet=resolved,
951
+ )
952
+
953
+ if best is None or candidate < best:
954
+ best = candidate
955
+
956
+ assert best is not None
957
+
958
+ return best
959
+
960
+ def __eq__(self, other: Any) -> bool:
961
+ """
962
+ Checks equality with another Polygon.
963
+
964
+ Args:
965
+ other (Any): Object to compare with.
966
+
967
+ Returns:
968
+ bool: True when the vertex lists match.
969
+ """
970
+
971
+ if not isinstance(other, Polygon):
972
+ return False
973
+
974
+ return self.vertices == other.vertices
975
+
976
+ def __repr__(self) -> str:
977
+ """
978
+ Returns string representation of the Polygon.
979
+
980
+ Returns:
981
+ str: Polygon constructor string.
982
+ """
983
+
984
+ return f"Polygon({self.vertices!r})"
985
+
986
+
987
+ # Shape constructor sugar
988
+
989
+
990
+ def circle(
991
+ center: Coordinate,
992
+ radius_m: float,
993
+ segments: int = 64,
994
+ planet: Optional[Planet] = None,
995
+ ) -> Polygon:
996
+ """
997
+ Builds a geodesic circle: all vertices at a surface distance from
998
+ the center.
999
+
1000
+ Args:
1001
+ center (Coordinate): The circle center.
1002
+ radius_m (float): Radius in meters.
1003
+ segments (int, optional): Vertex count. Defaults to 64.
1004
+ planet (Optional[Planet], optional): Explicit planet override.
1005
+
1006
+ Returns:
1007
+ Polygon: The circle polygon.
1008
+
1009
+ Raises:
1010
+ TypeError: If segments is not an integer.
1011
+ ValueError: If radius is not positive or segments < 3.
1012
+ """
1013
+
1014
+ if isinstance(segments, bool) or not isinstance(segments, int):
1015
+ raise TypeError(f"Segments must be an integer, got '{type(segments).__name__}'")
1016
+
1017
+ if segments < 3:
1018
+ raise ValueError(f"A circle needs at least 3 segments, got {segments}")
1019
+
1020
+ vertices: list[Coordinate] = [
1021
+ center.destination(
1022
+ bearing_deg=360.0 * i / segments,
1023
+ distance_m=radius_m,
1024
+ planet=planet,
1025
+ )
1026
+ for i in range(segments)
1027
+ ]
1028
+
1029
+ return Polygon(vertices=vertices)
1030
+
1031
+
1032
+ def ellipse(
1033
+ center: Coordinate,
1034
+ radius_a_m: float,
1035
+ radius_b_m: float,
1036
+ bearing_deg: float = 0.0,
1037
+ segments: int = 64,
1038
+ planet: Optional[Planet] = None,
1039
+ ) -> Polygon:
1040
+ """
1041
+ Builds a parametric ellipse: the vertex at angle t sits at polar
1042
+ radius r(t) = (ra * rb) / sqrt((rb cos t)^2 + (ra sin t)^2) from
1043
+ the center, oriented along the bearing.
1044
+
1045
+ Args:
1046
+ center (Coordinate): The ellipse center.
1047
+ radius_a_m (float): Semi-axis along the bearing, in meters.
1048
+ radius_b_m (float): Semi-axis perpendicular, in meters.
1049
+ bearing_deg (float, optional): Orientation of the a axis.
1050
+ Defaults to 0.0.
1051
+ segments (int, optional): Vertex count. Defaults to 64.
1052
+ planet (Optional[Planet], optional): Explicit planet override.
1053
+
1054
+ Returns:
1055
+ Polygon: The ellipse polygon.
1056
+
1057
+ Raises:
1058
+ TypeError: If segments is not an integer.
1059
+ ValueError: If a radius is not positive or segments < 3.
1060
+ """
1061
+
1062
+ if isinstance(segments, bool) or not isinstance(segments, int):
1063
+ raise TypeError(f"Segments must be an integer, got '{type(segments).__name__}'")
1064
+
1065
+ if segments < 3:
1066
+ raise ValueError(f"An ellipse needs at least 3 segments, got {segments}")
1067
+
1068
+ if radius_a_m <= 0.0 or radius_b_m <= 0.0:
1069
+ raise ValueError(
1070
+ f"Semi-axes must be positive, got {radius_a_m} and {radius_b_m}"
1071
+ )
1072
+
1073
+ vertices: list[Coordinate] = []
1074
+
1075
+ for i in range(segments):
1076
+ theta: float = 2.0 * math.pi * i / segments
1077
+ cos_t: float = math.cos(theta)
1078
+ sin_t: float = math.sin(theta)
1079
+
1080
+ polar_radius: float = (radius_a_m * radius_b_m) / math.sqrt(
1081
+ (radius_b_m * cos_t) ** 2 + (radius_a_m * sin_t) ** 2
1082
+ )
1083
+
1084
+ vertices.append(
1085
+ center.destination(
1086
+ bearing_deg=bearing_deg + math.degrees(theta),
1087
+ distance_m=polar_radius,
1088
+ planet=planet,
1089
+ )
1090
+ )
1091
+
1092
+ return Polygon(vertices=vertices)
1093
+
1094
+
1095
+ def regular_polygon(
1096
+ center: Coordinate,
1097
+ radius_m: float,
1098
+ sides: int,
1099
+ rotation_deg: float = 0.0,
1100
+ planet: Optional[Planet] = None,
1101
+ ) -> Polygon:
1102
+ """
1103
+ Builds a regular polygon: sides vertices on a geodesic circle,
1104
+ rotated by an initial bearing.
1105
+
1106
+ Args:
1107
+ center (Coordinate): The polygon center.
1108
+ radius_m (float): Circumradius in meters.
1109
+ sides (int): Vertex count (at least 3).
1110
+ rotation_deg (float, optional): Bearing of the first vertex.
1111
+ Defaults to 0.0.
1112
+ planet (Optional[Planet], optional): Explicit planet override.
1113
+
1114
+ Returns:
1115
+ Polygon: The regular polygon.
1116
+
1117
+ Raises:
1118
+ TypeError: If sides is not an integer.
1119
+ ValueError: If sides < 3 or radius is not positive.
1120
+ """
1121
+
1122
+ if isinstance(sides, bool) or not isinstance(sides, int):
1123
+ raise TypeError(f"Sides must be an integer, got '{type(sides).__name__}'")
1124
+
1125
+ if sides < 3:
1126
+ raise ValueError(f"A regular polygon needs at least 3 sides, got {sides}")
1127
+
1128
+ vertices: list[Coordinate] = [
1129
+ center.destination(
1130
+ bearing_deg=rotation_deg + 360.0 * i / sides,
1131
+ distance_m=radius_m,
1132
+ planet=planet,
1133
+ )
1134
+ for i in range(sides)
1135
+ ]
1136
+
1137
+ return Polygon(vertices=vertices)
1138
+
1139
+
1140
+ # Free-function twins of the member API
1141
+
1142
+
1143
+ def area(polygon: Polygon, planet: Optional[Planet] = None) -> float:
1144
+ """
1145
+ Enclosed area of a polygon, in square meters.
1146
+
1147
+ Args:
1148
+ polygon (Polygon): The polygon.
1149
+ planet (Optional[Planet], optional): Explicit planet override.
1150
+
1151
+ Returns:
1152
+ float: Area in square meters.
1153
+ """
1154
+
1155
+ return polygon.area(planet=planet)
1156
+
1157
+
1158
+ def perimeter(polygon: Polygon, planet: Optional[Planet] = None) -> float:
1159
+ """
1160
+ Geodesic boundary length of a polygon, in meters.
1161
+
1162
+ Args:
1163
+ polygon (Polygon): The polygon.
1164
+ planet (Optional[Planet], optional): Explicit planet override.
1165
+
1166
+ Returns:
1167
+ float: Perimeter in meters.
1168
+ """
1169
+
1170
+ return polygon.perimeter(planet=planet)
1171
+
1172
+
1173
+ def contains(
1174
+ polygon: Polygon,
1175
+ point: Coordinate,
1176
+ planet: Optional[Planet] = None,
1177
+ ) -> bool:
1178
+ """
1179
+ Tests whether a coordinate lies inside a polygon.
1180
+
1181
+ Args:
1182
+ polygon (Polygon): The polygon.
1183
+ point (Coordinate): The query coordinate.
1184
+ planet (Optional[Planet], optional): Unused; API symmetry.
1185
+
1186
+ Returns:
1187
+ bool: True when the point is inside.
1188
+ """
1189
+
1190
+ return polygon.contains(point=point, planet=planet)
1191
+
1192
+
1193
+ def polyline_length(
1194
+ polyline: Polyline,
1195
+ planet: Optional[Planet] = None,
1196
+ ) -> float:
1197
+ """
1198
+ Geodesic length of a polyline, in meters.
1199
+
1200
+ Args:
1201
+ polyline (Polyline): The polyline.
1202
+ planet (Optional[Planet], optional): Explicit planet override.
1203
+
1204
+ Returns:
1205
+ float: Length in meters.
1206
+ """
1207
+
1208
+ return polyline.length(planet=planet)
1209
+
1210
+
1211
+ def normalize_ring(vertices: Any) -> "Polygon":
1212
+ """
1213
+ Builds a Polygon from a vertex ring, stripping any explicit
1214
+ closing vertex.
1215
+
1216
+ Args:
1217
+ vertices (Any): The ring vertices (list of Coordinate).
1218
+
1219
+ Returns:
1220
+ Polygon: The normalized polygon.
1221
+ """
1222
+
1223
+ return Polygon(vertices=vertices)