Coverage for pygeodesy/points.py: 93%
530 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-25 13:15 -0400
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-25 13:15 -0400
2# -*- coding: utf-8 -*-
4u'''Utilities for point lists, tuples, etc.
6Functions to handle collections and sequences of C{LatLon} points
7specified as 2-d U{NumPy<https://www.NumPy.org>}, C{arrays} or tuples
8as C{LatLon} or as C{pseudo-x/-y} pairs.
10C{NumPy} arrays are assumed to contain rows of points with a lat-, a
11longitude -and possibly other- values in different columns. While
12iterating over the array rows, create an instance of a given C{LatLon}
13class "on-the-fly" for each row with the row's lat- and longitude.
15The original C{NumPy} array is read-accessed only and never duplicated,
16except to return a I{subset} of the original array.
18For example, to process a C{NumPy} array, wrap the array by instantiating
19class L{Numpy2LatLon} and specifying the column index for the lat- and
20longitude in each row. Then, pass the L{Numpy2LatLon} instance to any
21L{pygeodesy} function or method accepting a I{points} argument.
23Similarly, class L{Tuple2LatLon} is used to instantiate a C{LatLon} from
24each 2+tuple in a sequence of such 2+tuples using the C{ilat} lat- and
25C{ilon} longitude index in each 2+tuple.
26'''
28from pygeodesy.basics import isclass, isint, isscalar, issequence, \
29 _xdup, issubclassof, _Sequence, _xcopy, \
30 _xinstanceof, typename
31from pygeodesy.constants import EPS, EPS1, PI_2, R_M, isnear0, isnear1, \
32 _umod_360, _0_0, _0_5, _1_0, _2_0, _6_0, \
33 _90_0, _N_90_0, _180_0, _360_0
34# from pygeodesy.datums import _spherical_datum # from .formy
35from pygeodesy.dms import F_D, parseDMS
36from pygeodesy.errors import CrossError, crosserrors, _IndexError, \
37 _IsnotError, _TypeError, _ValueError, \
38 _xattr, _xkwds, _xkwds_item2, _xkwds_pop2
39from pygeodesy.fmath import favg, fdot, hypot, Fsum, fsum
40# from pygeodesy.fsums import Fsum, fsum # from .fmath
41from pygeodesy.formy import _bearingTo2, equirectangular4, _spherical_datum
42# from pygeodesy.internals import typename # from .basics
43from pygeodesy.interns import NN, _colinear_, _COMMASPACE_, _composite_, \
44 _DEQUALSPACED_, _ELLIPSIS_, _EW_, _immutable_, \
45 _near_, _no_, _NS_, _point_, _SPACE_, _UNDER_, \
46 _valid_ # _lat_, _lon_
47from pygeodesy.iters import LatLon2PsxyIter, PointsIter, points2
48from pygeodesy.latlonBase import LatLonBase, _latlonheight3, \
49 _ALL_DOCS, _ALL_LAZY, _MODS
50# from pygeodesy.lazily import _ALL_DOCS, _ALL_LAZY, _ALL_MODS as _MODS
51from pygeodesy.named import classname, _NamedTuple, nameof, \
52 notImplemented, notOverloaded
53from pygeodesy.namedTuples import Bounds2Tuple, Bounds4Tuple, LatLon2Tuple, \
54 NearestOn3Tuple, NearestOn5Tuple, \
55 Point3Tuple, Vector3Tuple, \
56 PhiLam2Tuple # PYCHOK shared
57from pygeodesy.props import Property_RO, property_doc_, property_RO
58from pygeodesy.streprs import Fmt, instr
59from pygeodesy.units import Number_, Radius, Scalar, Scalar_
60from pygeodesy.utily import atan2b, degrees90, degrees180, degrees2m, \
61 unroll180, _unrollon, unrollPI, _Wrap, wrap180
63from math import cos, fabs, fmod as _fmod, radians, sin
65__all__ = _ALL_LAZY.points
66__version__ = '25.04.18'
68_ilat_ = 'ilat'
69_ilon_ = 'ilon'
70_ncols_ = 'ncols'
71_nrows_ = 'nrows'
74class LatLon_(LatLonBase): # XXX in heights._HeightBase.height
75 '''Low-overhead C{LatLon} class, mainly for L{Numpy2LatLon} and L{Tuple2LatLon}.
76 '''
77 # __slots__ efficiency is voided if the __slots__ class attribute is
78 # used in a subclass of a class with the traditional __dict__, @see
79 # <https://docs.Python.org/2/reference/datamodel.html#slots> plus ...
80 #
81 # __slots__ must be repeated in sub-classes, @see Luciano Ramalho,
82 # "Fluent Python", O'Reilly, 2016 p. 276+ "Problems with __slots__",
83 # 2nd Ed, 2022 p. 390 "Summarizing the Issues with __slots__".
84 #
85 # __slots__ = (_lat_, _lon_, _height_, _datum_, _name_)
86 # Property_RO = property_RO # no __dict__ with __slots__!
87 #
88 # In addition, both size and overhead have shrunk in recent Python:
89 #
90 # sys.getsizeof(LatLon_(1, 2)) is 72-88 I{with} __slots__, but
91 # only 48-56 bytes I{without in Python 2.7.18+ and Python 3+}.
92 #
93 # python3 -m timeit -s "from pygeodesy... import LatLonBase as LL" "LL(0, 0)" 2.14 usec
94 # python3 -m timeit -s "from pygeodesy import LatLon_" "LatLon_(0, 0)" 216 nsec
96 def __init__(self, latlonh, lon=None, height=0, wrap=False, datum=None, **name):
97 '''New L{LatLon_}.
99 @note: The lat- and longitude values are taken I{as-given,
100 un-clipped and un-validated}.
102 @see: L{latlonBase.LatLonBase} for further details.
103 '''
104 if name:
105 self.name = name
107 if lon is None: # PYCHOK no cover
108 lat, lon, height = _latlonheight3(latlonh, height, wrap)
109 elif wrap: # PYCHOK no cover
110 lat, lon = _Wrap.latlonDMS2(latlonh, lon)
111 else: # must be latNS, lonEW
112 try:
113 lat, lon = float(latlonh), float(lon)
114 except (TypeError, ValueError):
115 lat = parseDMS(latlonh, suffix=_NS_)
116 lon = parseDMS(lon, suffix=_EW_)
118 # get the minimal __dict__, see _isLatLon_ below
119 self._lat = lat # un-clipped and ...
120 self._lon = lon # ... un-validated
121 self._datum = None if datum is None else \
122 _spherical_datum(datum, name=self.name)
123 self._height = height
125 def __eq__(self, other):
126 return isinstance(other, LatLon_) and \
127 other.lat == self.lat and \
128 other.lon == self.lon
130 def __ne__(self, other):
131 return not self.__eq__(other)
133 @Property_RO
134 def datum(self):
135 '''Get the C{datum} (L{Datum}) or C{None}.
136 '''
137 return self._datum
139 def intermediateTo(self, other, fraction, height=None, wrap=False):
140 '''Locate the point at a given fraction, I{linearly} between
141 (or along) this and an other point.
143 @arg other: The other point (C{LatLon}).
144 @arg fraction: Fraction between both points (C{float},
145 0.0 for this and 1.0 for the other point).
146 @kwarg height: Optional height (C{meter}), overriding the
147 intermediate height.
148 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll
149 the B{C{other}} point (C{bool}).
151 @return: Intermediate point (same C{LatLon} class).
153 @raise TypeError: Incompatible B{C{other}} C{type}.
154 '''
155 f = Scalar(fraction=fraction)
156 if isnear0(f):
157 r = self
158 else:
159 r = self.others(other)
160 if wrap or not isnear1(f):
161 _, lat, lon = _Wrap.latlon3(self.lon, r.lat, r.lon, wrap)
162 lat = favg(self.lat, lat, f=f)
163 lon = favg(self.lon, lon, f=f)
164 h = height if height is not None else \
165 favg(self.height, r.height, f=f)
166 # = self._havg(r, f=f, h=height)
167 r = self.classof(lat, lon, height=h, datum=r.datum,
168 name=typename(r.intermediateTo))
169 return r
171 def toRepr(self, **kwds):
172 '''This L{LatLon_} as a string "class(<degrees>, ...)",
173 ignoring keyword argument C{B{std}=N/A}.
175 @see: L{latlonBase.LatLonBase.toRepr} for further details.
176 '''
177 _, kwds = _xkwds_pop2(kwds, std=NotImplemented)
178 return LatLonBase.toRepr(self, **kwds)
180 def toStr(self, form=F_D, joined=_COMMASPACE_, **m_prec_sep_s_D_M_S): # PYCHOK expected
181 '''Convert this point to a "lat, lon[, height][, name][, ...]"
182 string, formatted in the given C{B{form}at}.
184 @see: L{latlonBase.LatLonBase.toStr} for further details.
185 '''
186 t = LatLonBase.toStr(self, form=form, joined=NN,
187 **_xkwds(m_prec_sep_s_D_M_S, m=NN))
188 if self.name:
189 t += (repr(self.name),)
190 return joined.join(t) if joined else t
193def _isLatLon(inst):
194 '''(INTERNAL) Check a C{LatLon} or C{LatLon_} instance.
195 '''
196 return isinstance(inst, (LatLon_, _MODS.latlonBase.LatLonBase))
199def _isLatLon_(LL):
200 '''(INTERNAL) Check a (sub-)class of C{LatLon_}.
201 '''
202 return issubclassof(LL, LatLon_) or (isclass(LL) and
203 all(hasattr(LL, _) for _ in LatLon_(0, 0).__dict__.keys()))
206class _Basequence(_Sequence): # immutable, on purpose
207 '''(INTERNAL) Base class.
208 '''
209 _array = []
210 _epsilon = EPS
211 _itemname = _point_
213 def _contains(self, point):
214 '''(INTERNAL) Check for a matching point.
215 '''
216 return any(self._findall(point, ()))
218 def copy(self, deep=False): # PYCHOK no cover
219 '''Make a shallow or deep copy of this instance.
221 @kwarg deep: If C{True}, make a deep, otherwise a
222 shallow copy (C{bool}).
224 @return: The copy (C{This class}).
225 '''
226 return _xcopy(self, deep=deep)
228 def _count(self, point):
229 '''(INTERNAL) Count the number of matching points.
230 '''
231 return sum(1 for _ in self._findall(point, ())) # NOT len()!
233 def dup(self, **items): # PYCHOK no cover
234 '''Duplicate this instance, I{without replacing items}.
236 @kwarg items: No attributes (I{not allowed}).
238 @return: The duplicate (C{This class}).
240 @raise TypeError: Any B{C{items}} invalid.
241 '''
242 if items:
243 t = _SPACE_(classname(self), _immutable_)
244 raise _TypeError(txt=t, this=self, **items)
245 return _xdup(self)
247 @property_doc_(''' the equality tolerance (C{float}).''')
248 def epsilon(self):
249 '''Get the tolerance for equality tests (C{float}).
250 '''
251 return self._epsilon
253 @epsilon.setter # PYCHOK setter!
254 def epsilon(self, tol):
255 '''Set the tolerance for equality tests (C{scalar}).
257 @raise UnitError: Non-scalar or invalid B{C{tol}}.
258 '''
259 self._epsilon = Scalar_(tolerance=tol)
261 def _find(self, point, start_end):
262 '''(INTERNAL) Find the first matching point index.
263 '''
264 for i in self._findall(point, start_end):
265 return i
266 return -1
268 def _findall(self, point, start_end): # PYCHOK no cover
269 '''(INTERNAL) I{Must be implemented/overloaded}.'''
270 notImplemented(self, point, start_end)
272 def _getitem(self, index):
273 '''(INTERNAL) Return point [index] or return a slice.
274 '''
275 # Luciano Ramalho, "Fluent Python", O'Reilly, 2016 p. 290+, 2022 p. 405+
276 if isinstance(index, slice):
277 # XXX an numpy.[nd]array slice is a view, not a copy
278 return self.__class__(self._array[index], **self._slicekwds())
279 else:
280 return self.point(self._array[index])
282 def _index(self, point, start_end):
283 '''(INTERNAL) Find the first matching point index.
284 '''
285 for i in self._findall(point, start_end):
286 return i
287 raise _IndexError(self._itemname, point, txt_not_='found')
289 @property_RO
290 def isNumpy2(self): # PYCHOK no cover
291 '''Is this a Numpy2 wrapper?
292 '''
293 return False # isinstance(self, (Numpy2LatLon, ...))
295 @property_RO
296 def isPoints2(self): # PYCHOK no cover
297 '''Is this a LatLon2 wrapper/converter?
298 '''
299 return False # isinstance(self, (LatLon2psxy, ...))
301 @property_RO
302 def isTuple2(self): # PYCHOK no cover
303 '''Is this a Tuple2 wrapper?
304 '''
305 return False # isinstance(self, (Tuple2LatLon, ...))
307 def _iter(self):
308 '''(INTERNAL) Yield all points.
309 '''
310 _array, _point = self._array, self.point
311 for i in range(len(self)):
312 yield _point(_array[i])
314 def point(self, *attrs): # PYCHOK no cover
315 '''I{Must be overloaded}.'''
316 notOverloaded(self, *attrs)
318 def _range(self, start=None, end=None, step=1):
319 '''(INTERNAL) Return the range.
320 '''
321 if step > 0:
322 if start is None:
323 start = 0
324 if end is None:
325 end = len(self)
326 elif step < 0:
327 if start is None:
328 start = len(self) - 1
329 if end is None:
330 end = -1
331 else:
332 raise _ValueError(step=step)
333 return range(start, end, step)
335 def _repr(self):
336 '''(INTERNAL) Return a string representation.
337 '''
338 # XXX use Python 3+ reprlib.repr
339 t = repr(self._array[:1]) # only first row
340 t = _SPACE_(t[:-1], _ELLIPSIS_, Fmt.SQUARE(t[-1:], len(self)))
341 t = _SPACE_.join(t.split()) # coalesce spaces
342 return instr(self, t, **self._slicekwds())
344 def _reversed(self): # PYCHOK false
345 '''(INTERNAL) Yield all points in reverse order.
346 '''
347 _array, point = self._array, self.point
348 for i in range(len(self) - 1, -1, -1):
349 yield point(_array[i])
351 def _rfind(self, point, start_end):
352 '''(INTERNAL) Find the last matching point index.
353 '''
354 def _r3(start=None, end=None, step=-1):
355 return (start, end, step) # PYCHOK returns
357 for i in self._findall(point, _r3(*start_end)):
358 return i
359 return -1
361 def _slicekwds(self): # PYCHOK no cover
362 '''(INTERNAL) I{Should be overloaded}.
363 '''
364 return {}
367class _Array2LatLon(_Basequence): # immutable, on purpose
368 '''(INTERNAL) Base class for Numpy2LatLon or Tuple2LatLon.
369 '''
370 _array = ()
371 _ilat = 0 # row column index
372 _ilon = 0 # row column index
373 _LatLon = LatLon_ # default
374 _shape = ()
376 def __init__(self, array, ilat=0, ilon=1, LatLon=None, shape=()):
377 '''Handle a C{NumPy} or C{Tuple} array as a sequence of C{LatLon} points.
378 '''
379 ais = (_ilat_, ilat), (_ilon_, ilon)
381 if len(shape) != 2 or shape[0] < 1 or shape[1] < len(ais):
382 raise _IndexError('array.shape', shape)
384 self._array = array
385 self._shape = Shape2Tuple(shape) # *shape
387 if LatLon: # check the point class
388 if not _isLatLon_(LatLon):
389 raise _IsnotError(_valid_, LatLon=LatLon)
390 self._LatLon = LatLon
392 # check the attr indices
393 for n, (ai, i) in enumerate(ais):
394 if not isint(i):
395 raise _IsnotError(int, **{ai: i})
396 i = int(i)
397 if not 0 <= i < shape[1]:
398 raise _ValueError(ai, i)
399 for aj, j in ais[:n]:
400 if int(j) == i:
401 raise _ValueError(_DEQUALSPACED_(ai, aj, i))
402 setattr(self, NN(_UNDER_, ai), i)
404 def __contains__(self, latlon):
405 '''Check for a specific lat-/longitude.
407 @arg latlon: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
408 C{(lat, lon)}).
410 @return: C{True} if B{C{latlon}} is present, C{False} otherwise.
412 @raise TypeError: Invalid B{C{latlon}}.
413 '''
414 return self._contains(latlon)
416 def __getitem__(self, index):
417 '''Return row[index] as C{LatLon} or return a L{Numpy2LatLon} slice.
418 '''
419 return self._getitem(index)
421 def __iter__(self):
422 '''Yield rows as C{LatLon}.
423 '''
424 return self._iter()
426 def __len__(self):
427 '''Return the number of rows.
428 '''
429 return self._shape[0]
431 def __repr__(self):
432 '''Return a string representation.
433 '''
434 return self._repr()
436 def __reversed__(self): # PYCHOK false
437 '''Yield rows as C{LatLon} in reverse order.
438 '''
439 return self._reversed()
441 __str__ = __repr__
443 def count(self, latlon):
444 '''Count the number of rows with a specific lat-/longitude.
446 @arg latlon: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
447 C{(lat, lon)}).
449 @return: Count (C{int}).
451 @raise TypeError: Invalid B{C{latlon}}.
452 '''
453 return self._count(latlon)
455 def find(self, latlon, *start_end):
456 '''Find the first row with a specific lat-/longitude.
458 @arg latlon: Point (C{LatLon}) or 2-tuple (lat, lon).
459 @arg start_end: Optional C{[start[, end]]} index (integers).
461 @return: Index or -1 if not found (C{int}).
463 @raise TypeError: Invalid B{C{latlon}}.
464 '''
465 return self._find(latlon, start_end)
467 def _findall(self, latlon, start_end):
468 '''(INTERNAL) Yield indices of all matching rows.
469 '''
470 try:
471 lat, lon = latlon.lat, latlon.lon
472 except AttributeError:
473 try:
474 lat, lon = latlon
475 except (TypeError, ValueError):
476 raise _IsnotError(_valid_, latlon=latlon)
478 _ilat, _ilon = self._ilat, self._ilon
479 _array, _eps = self._array, self._epsilon
480 for i in self._range(*start_end):
481 row = _array[i]
482 if fabs(row[_ilat] - lat) <= _eps and \
483 fabs(row[_ilon] - lon) <= _eps:
484 yield i
486 def findall(self, latlon, *start_end):
487 '''Yield indices of all rows with a specific lat-/longitude.
489 @arg latlon: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
490 C{(lat, lon)}).
491 @arg start_end: Optional C{[start[, end]]} index (C{int}).
493 @return: Indices (C{iterable}).
495 @raise TypeError: Invalid B{C{latlon}}.
496 '''
497 return self._findall(latlon, start_end)
499 def index(self, latlon, *start_end): # PYCHOK Python 2- issue
500 '''Find index of the first row with a specific lat-/longitude.
502 @arg latlon: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
503 C{(lat, lon)}).
504 @arg start_end: Optional C{[start[, end]]} index (C{int}).
506 @return: Index (C{int}).
508 @raise IndexError: Point not found.
510 @raise TypeError: Invalid B{C{latlon}}.
511 '''
512 return self._index(latlon, start_end)
514 @Property_RO
515 def ilat(self):
516 '''Get the latitudes column index (C{int}).
517 '''
518 return self._ilat
520 @Property_RO
521 def ilon(self):
522 '''Get the longitudes column index (C{int}).
523 '''
524 return self._ilon
526# next = __iter__
528 def point(self, row): # PYCHOK *attrs
529 '''Instantiate a point C{LatLon}.
531 @arg row: Array row (numpy.array).
533 @return: Point (C{LatLon}).
534 '''
535 return self._LatLon(row[self._ilat], row[self._ilon])
537 def rfind(self, latlon, *start_end):
538 '''Find the last row with a specific lat-/longitude.
540 @arg latlon: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
541 C{(lat, lon)}).
542 @arg start_end: Optional C{[start[, end]]} index (C{int}).
544 @note: Keyword order, first stop, then start.
546 @return: Index or -1 if not found (C{int}).
548 @raise TypeError: Invalid B{C{latlon}}.
549 '''
550 return self._rfind(latlon, start_end)
552 def _slicekwds(self):
553 '''(INTERNAL) Slice kwds.
554 '''
555 return dict(ilat=self._ilat, ilon=self._ilon)
557 @Property_RO
558 def shape(self):
559 '''Get the shape of the C{NumPy} array or the C{Tuples} as
560 L{Shape2Tuple}C{(nrows, ncols)}.
561 '''
562 return self._shape
564 def _subset(self, indices): # PYCHOK no cover
565 '''(INTERNAL) I{Must be implemented/overloaded}.'''
566 notImplemented(self, indices)
568 def subset(self, indices):
569 '''Return a subset of the C{NumPy} array.
571 @arg indices: Row indices (C{range} or C{int}[]).
573 @note: A C{subset} is different from a C{slice} in 2 ways:
574 (a) the C{subset} is typically specified as a list of
575 (un-)ordered indices and (b) the C{subset} allocates
576 a new, separate C{NumPy} array while a C{slice} is
577 just an other C{view} of the original C{NumPy} array.
579 @return: Sub-array (C{numpy.array}).
581 @raise IndexError: Out-of-range B{C{indices}} value.
583 @raise TypeError: If B{C{indices}} is not a C{range}
584 nor an C{int}[].
585 '''
586 if not issequence(indices, tuple): # NO tuple, only list
587 # and range work properly to get Numpy array sub-sets
588 raise _IsnotError(_valid_, indices=type(indices))
590 n = len(self)
591 for i, v in enumerate(indices):
592 if not isint(v):
593 raise _TypeError(Fmt.SQUARE(indices=i), v)
594 elif not 0 <= v < n:
595 raise _IndexError(Fmt.SQUARE(indices=i), v)
597 return self._subset(indices)
600class LatLon2psxy(_Basequence):
601 '''Wrapper for C{LatLon} points as "on-the-fly" pseudo-xy coordinates.
602 '''
603 _closed = False
604 _len = 0
605 _deg2m = None # default, keep degrees
606 _radius = None
607 _wrap = True
609 def __init__(self, latlons, closed=False, radius=None, wrap=True):
610 '''Handle C{LatLon} points as pseudo-xy coordinates.
612 @note: The C{LatLon} latitude is considered the I{pseudo-y}
613 and longitude the I{pseudo-x} coordinate, likewise
614 for L{LatLon2Tuple}. However, 2-tuples C{(x, y)} are
615 considered as I{(longitude, latitude)}.
617 @arg latlons: Points C{list}, C{sequence}, C{set}, C{tuple},
618 etc. (C{LatLon[]}).
619 @kwarg closed: Optionally, close the polygon (C{bool}).
620 @kwarg radius: Mean earth radius (C{meter}).
621 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll
622 the B{C{latlons}} points (C{bool}).
624 @raise PointsError: Insufficient number of B{C{latlons}}.
626 @raise TypeError: Some B{C{points}} are not B{C{base}}.
627 '''
628 self._closed = closed
629 self._len, self._array = points2(latlons, closed=closed)
630 if radius:
631 self._radius = r = Radius(radius)
632 self._deg2m = degrees2m(_1_0, r)
633 if not wrap:
634 self._wrap = False
636 def __contains__(self, xy):
637 '''Check for a matching point.
639 @arg xy: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
640 C{(x, y)}) in (C{degrees}.
642 @return: C{True} if B{C{xy}} is present, C{False} otherwise.
644 @raise TypeError: Invalid B{C{xy}}.
645 '''
646 return self._contains(xy)
648 def __getitem__(self, index):
649 '''Return the pseudo-xy or return a L{LatLon2psxy} slice.
650 '''
651 return self._getitem(index)
653 def __iter__(self):
654 '''Yield all pseudo-xy's.
655 '''
656 return self._iter()
658 def __len__(self):
659 '''Return the number of pseudo-xy's.
660 '''
661 return self._len
663 def __repr__(self):
664 '''Return a string representation.
665 '''
666 return self._repr()
668 def __reversed__(self): # PYCHOK false
669 '''Yield all pseudo-xy's in reverse order.
670 '''
671 return self._reversed()
673 __str__ = __repr__
675 def count(self, xy):
676 '''Count the number of matching points.
678 @arg xy: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
679 C{(x, y)}) in (C{degrees}.
681 @return: Count (C{int}).
683 @raise TypeError: Invalid B{C{xy}}.
684 '''
685 return self._count(xy)
687 def find(self, xy, *start_end):
688 '''Find the first matching point.
690 @arg xy: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
691 C{(x, y)}) in (C{degrees}.
692 @arg start_end: Optional C{[start[, end]]} index (C{int}).
694 @return: Index or -1 if not found (C{int}).
696 @raise TypeError: Invalid B{C{xy}}.
697 '''
698 return self._find(xy, start_end)
700 def _findall(self, xy, start_end):
701 '''(INTERNAL) Yield indices of all matching points.
702 '''
703 try:
704 x, y = xy.lon, xy.lat
706 def _x_y_ll3(ll): # match LatLon
707 return ll.lon, ll.lat, ll
709 except AttributeError:
710 try:
711 x, y = xy[:2]
712 except (IndexError, TypeError, ValueError):
713 raise _IsnotError(_valid_, xy=xy)
715 _x_y_ll3 = self.point # PYCHOK expected
717 _array, _eps = self._array, self._epsilon
718 for i in self._range(*start_end):
719 xi, yi, _ = _x_y_ll3(_array[i])
720 if fabs(xi - x) <= _eps and \
721 fabs(yi - y) <= _eps:
722 yield i
724 def findall(self, xy, *start_end):
725 '''Yield indices of all matching points.
727 @arg xy: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
728 C{(x, y)}) in (C{degrees}.
729 @arg start_end: Optional C{[start[, end]]} index (C{int}).
731 @return: Indices (C{iterator}).
733 @raise TypeError: Invalid B{C{xy}}.
734 '''
735 return self._findall(xy, start_end)
737 def index(self, xy, *start_end): # PYCHOK Python 2- issue
738 '''Find the first matching point.
740 @arg xy: Point (C{LatLon}) or 2-tuple (x, y) in (C{degrees}).
741 @arg start_end: Optional C{[start[, end]]} index (C{int}).
743 @return: Index (C{int}).
745 @raise IndexError: Point not found.
747 @raise TypeError: Invalid B{C{xy}}.
748 '''
749 return self._index(xy, start_end)
751 @property_RO
752 def isPoints2(self):
753 '''Is this a LatLon2 wrapper/converter?
754 '''
755 return True # isinstance(self, (LatLon2psxy, ...))
757 def point(self, ll): # PYCHOK *attrs
758 '''Create a pseudo-xy.
760 @arg ll: Point (C{LatLon}).
762 @return: An L{Point3Tuple}C{(x, y, ll)}.
763 '''
764 x, y = ll.lon, ll.lat # note, x, y = lon, lat
765 if self._wrap:
766 y, x = _Wrap.latlon(y, x)
767 d = self._deg2m
768 if d: # convert degrees to meter (or radians)
769 x *= d
770 y *= d
771 return Point3Tuple(x, y, ll)
773 def rfind(self, xy, *start_end):
774 '''Find the last matching point.
776 @arg xy: Point (C{LatLon}, L{LatLon2Tuple} or 2-tuple
777 C{(x, y)}) in (C{degrees}.
778 @arg start_end: Optional C{[start[, end]]} index (C{int}).
780 @return: Index or -1 if not found (C{int}).
782 @raise TypeError: Invalid B{C{xy}}.
783 '''
784 return self._rfind(xy, start_end)
786 def _slicekwds(self):
787 '''(INTERNAL) Slice kwds.
788 '''
789 return dict(closed=self._closed, radius=self._radius, wrap=self._wrap)
792class Numpy2LatLon(_Array2LatLon): # immutable, on purpose
793 '''Wrapper for C{NumPy} arrays as "on-the-fly" C{LatLon} points.
794 '''
795 def __init__(self, array, ilat=0, ilon=1, LatLon=None):
796 '''Handle a C{NumPy} array as a sequence of C{LatLon} points.
798 @arg array: C{NumPy} array (C{numpy.array}).
799 @kwarg ilat: Optional index of the latitudes column (C{int}).
800 @kwarg ilon: Optional index of the longitudes column (C{int}).
801 @kwarg LatLon: Optional C{LatLon} class to use (L{LatLon_}).
803 @raise IndexError: If B{C{array.shape}} is not (1+, 2+).
805 @raise TypeError: If B{C{array}} is not a C{NumPy} array or
806 C{LatLon} is not a class with C{lat}
807 and C{lon} attributes.
809 @raise ValueError: If the B{C{ilat}} and/or B{C{ilon}} values
810 are the same or out of range.
812 @example:
814 >>> type(array)
815 <type 'numpy.ndarray'> # <class ...> in Python 3+
816 >>> points = Numpy2LatLon(array, lat=0, lon=1)
817 >>> simply = simplifyRDP(points, ...)
818 >>> type(simply)
819 <type 'numpy.ndarray'> # <class ...> in Python 3+
820 >>> sliced = points[1:-1]
821 >>> type(sliced)
822 <class '...Numpy2LatLon'>
823 '''
824 try: # get shape and check some other numpy.array attrs
825 s, _, _ = array.shape, array.nbytes, array.ndim # PYCHOK expected
826 except AttributeError:
827 raise _IsnotError('NumPy', array=type(array))
829 _Array2LatLon.__init__(self, array, ilat=ilat, ilon=ilon,
830 LatLon=LatLon, shape=s)
832 @property_RO
833 def isNumpy2(self):
834 '''Is this a Numpy2 wrapper?
835 '''
836 return True # isinstance(self, (Numpy2LatLon, ...))
838 def _subset(self, indices):
839 return self._array[indices] # NumPy special
842class Shape2Tuple(_NamedTuple):
843 '''2-Tuple C{(nrows, ncols)}, the number of rows and columns,
844 both C{int}.
845 '''
846 _Names_ = (_nrows_, _ncols_)
847 _Units_ = ( Number_, Number_)
850class Tuple2LatLon(_Array2LatLon):
851 '''Wrapper for tuple sequences as "on-the-fly" C{LatLon} points.
852 '''
853 def __init__(self, tuples, ilat=0, ilon=1, LatLon=None):
854 '''Handle a list of tuples, each containing a lat- and longitude
855 and perhaps other values as a sequence of C{LatLon} points.
857 @arg tuples: The C{list}, C{tuple} or C{sequence} of tuples (C{tuple}[]).
858 @kwarg ilat: Optional index of the latitudes value (C{int}).
859 @kwarg ilon: Optional index of the longitudes value (C{int}).
860 @kwarg LatLon: Optional C{LatLon} class to use (L{LatLon_}).
862 @raise IndexError: If C{(len(B{tuples}), min(len(t) for t
863 in B{tuples}))} is not (1+, 2+).
865 @raise TypeError: If B{C{tuples}} is not a C{list}, C{tuple}
866 or C{sequence} or if B{C{LatLon}} is not a
867 C{LatLon} with C{lat}, C{lon} and C{name}
868 attributes.
870 @raise ValueError: If the B{C{ilat}} and/or B{C{ilon}} values
871 are the same or out of range.
873 @example:
875 >>> tuples = [(0, 1), (2, 3), (4, 5)]
876 >>> type(tuples)
877 <type 'list'> # <class ...> in Python 3+
878 >>> points = Tuple2LatLon(tuples, lat=0, lon=1)
879 >>> simply = simplifyRW(points, 0.5, ...)
880 >>> type(simply)
881 <type 'list'> # <class ...> in Python 3+
882 >>> simply
883 [(0, 1), (4, 5)]
884 >>> sliced = points[1:-1]
885 >>> type(sliced)
886 <class '...Tuple2LatLon'>
887 >>> sliced
888 ...Tuple2LatLon([(2, 3), ...][1], ilat=0, ilon=1)
890 >>> closest, _ = nearestOn2(LatLon_(2, 1), points, adjust=False)
891 >>> closest
892 LatLon_(lat=1.0, lon=2.0)
894 >>> closest, _ = nearestOn2(LatLon_(3, 2), points)
895 >>> closest
896 LatLon_(lat=2.001162, lon=3.001162)
897 '''
898 _xinstanceof(list, tuple, tuples=tuples)
899 s = len(tuples), min(len(_) for _ in tuples)
900 _Array2LatLon.__init__(self, tuples, ilat=ilat, ilon=ilon,
901 LatLon=LatLon, shape=s)
903 @property_RO
904 def isTuple2(self):
905 '''Is this a Tuple2 wrapper?
906 '''
907 return True # isinstance(self, (Tuple2LatLon, ...))
909 def _subset(self, indices):
910 return type(self._array)(self._array[i] for i in indices)
913def _area2(points, adjust, wrap):
914 '''(INTERNAL) Approximate the area in radians squared, I{signed}.
915 '''
916 if adjust:
917 # approximate trapezoid by a rectangle, adjusting
918 # the top width by the cosine of the latitudinal
919 # average and bottom width by some fudge factor
920 def _adjust(w, h):
921 c = cos(h) if fabs(h) < PI_2 else _0_0
922 return w * h * (c + 1.2876) * _0_5
923 else:
924 def _adjust(w, h): # PYCHOK expected
925 return w * h
927 # setting radius=1 converts degrees to radians
928 Ps = LatLon2PsxyIter(points, loop=1, radius=_1_0, wrap=wrap)
929 x1, y1, ll = Ps[0]
930 pts = [ll] # for _areaError
932 A2 = Fsum() # trapezoidal area in radians**2
933 for p in Ps.iterate(closed=True):
934 x2, y2, ll = p
935 if len(pts) < 4:
936 pts.append(ll)
937 w, x2 = unrollPI(x1, x2, wrap=wrap and not Ps.looped)
938 A2 += _adjust(w, (y2 + y1) * _0_5)
939 x1, y1 = x2, y2
941 return A2.fsum(), tuple(pts)
944def _areaError(pts, near_=NN): # in .ellipsoidalKarney
945 '''(INTERNAL) Area issue.
946 '''
947 t = _ELLIPSIS_(pts[:3], NN)
948 return _ValueError(NN(near_, 'zero or polar area'), txt=t)
951def areaOf(points, adjust=True, radius=R_M, wrap=True):
952 '''Approximate the area of a polygon or composite.
954 @arg points: The polygon points or clips (C{LatLon}[],
955 L{BooleanFHP} or L{BooleanGH}).
956 @kwarg adjust: Adjust the wrapped, unrolled longitudinal delta
957 by the cosine of the mean latitude (C{bool}).
958 @kwarg radius: Mean earth radius (C{meter}) or C{None}.
959 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll
960 the B{C{points}} (C{bool}).
962 @return: Approximate area (I{square} C{meter}, same units as
963 B{C{radius}} or C{radians} I{squared} if C{B{radius}
964 is None}).
966 @raise PointsError: Insufficient number of B{C{points}}
968 @raise TypeError: Some B{C{points}} are not C{LatLon}.
970 @raise ValueError: Invalid B{C{radius}}.
972 @note: This area approximation has limited accuracy and is
973 ill-suited for regions exceeding several hundred Km
974 or Miles or with near-polar latitudes.
976 @see: L{sphericalNvector.areaOf}, L{sphericalTrigonometry.areaOf},
977 L{ellipsoidalExact.areaOf} and L{ellipsoidalKarney.areaOf}.
978 '''
979 if _MODS.booleans.isBoolean(points):
980 a = points._sum1(areaOf, adjust=adjust, radius=None, wrap=wrap)
981 else:
982 a, _ = _area2(points, adjust, wrap)
983 return fabs(a if radius is None else (Radius(radius)**2 * a))
986def boundsOf(points, wrap=False, LatLon=None): # was=True
987 '''Determine the bottom-left SW and top-right NE corners of a
988 path or polygon.
990 @arg points: The path or polygon points (C{LatLon}[]).
991 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll
992 the B{C{points}} (C{bool}).
993 @kwarg LatLon: Optional class to return the C{bounds}
994 corners (C{LatLon}) or C{None}.
996 @return: A L{Bounds2Tuple}C{(latlonSW, latlonNE)}, each
997 a B{C{LatLon}} or if C{B{LatLon} is None}, a
998 L{Bounds4Tuple}C{(latS, lonW, latN, lonE)}.
1000 @raise PointsError: Insufficient number of B{C{points}}
1002 @raise TypeError: Some B{C{points}} are not C{LatLon}.
1004 @see: Function L{quadOf}.
1005 '''
1006 Ps = LatLon2PsxyIter(points, loop=1, wrap=wrap)
1007 w, s, _ = e, n, _ = Ps[0]
1009 v = w
1010 for x, y, _ in Ps.iterate(closed=False): # [1:]
1011 if wrap:
1012 _, x = unroll180(v, x, wrap=True)
1013 v = x
1015 if w > x:
1016 w = x
1017 elif e < x:
1018 e = x
1020 if s > y:
1021 s = y
1022 elif n < y:
1023 n = y
1025 return Bounds4Tuple(s, w, n, e) if LatLon is None else \
1026 Bounds2Tuple(LatLon(s, w), LatLon(n, e)) # PYCHOK inconsistent
1029def centroidOf(points, wrap=False, LatLon=None): # was=True
1030 '''Determine the centroid of a polygon.
1032 @arg points: The polygon points (C{LatLon}[]).
1033 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll the
1034 B{C{points}} (C{bool}).
1035 @kwarg LatLon: Optional class to return the centroid (C{LatLon})
1036 or C{None}.
1038 @return: Centroid (B{C{LatLon}}) or a L{LatLon2Tuple}C{(lat, lon)}
1039 if C{B{LatLon} is None}.
1041 @raise PointsError: Insufficient number of B{C{points}}
1043 @raise TypeError: Some B{C{points}} are not C{LatLon}.
1045 @raise ValueError: The B{C{points}} enclose a pole or
1046 near-zero area.
1048 @see: U{Centroid<https://WikiPedia.org/wiki/Centroid#Of_a_polygon>} and
1049 Paul Bourke's U{Calculating The Area And Centroid Of A Polygon
1050 <https://www.SEAS.UPenn.edu/~ese502/lab-content/extra_materials/
1051 Polygon%20Area%20and%20Centroid.pdf>}, 1988.
1052 '''
1053 A, X, Y = Fsum(), Fsum(), Fsum()
1055 # setting radius=1 converts degrees to radians
1056 Ps = LatLon2PsxyIter(points, loop=1, radius=_1_0, wrap=wrap)
1057 x1, y1, ll = Ps[0]
1058 pts = [ll] # for _areaError
1059 for p in Ps.iterate(closed=True):
1060 x2, y2, ll = p
1061 if len(pts) < 4:
1062 pts.append(ll)
1063 if wrap and not Ps.looped:
1064 _, x2 = unrollPI(x1, x2, wrap=True)
1065 t = x1 * y2 - x2 * y1
1066 A += t
1067 X += t * (x1 + x2)
1068 Y += t * (y1 + y2)
1069 # XXX more elaborately:
1070 # t1, t2 = x1 * y2, -(x2 * y1)
1071 # A.fadd_(t1, t2)
1072 # X.fadd_(t1 * x1, t1 * x2, t2 * x1, t2 * x2)
1073 # Y.fadd_(t1 * y1, t1 * y2, t2 * y1, t2 * y2)
1074 x1, y1 = x2, y2
1076 a = A.fmul(_6_0).fover(_2_0)
1077 if isnear0(a):
1078 raise _areaError(pts, near_=_near_)
1079 y, x = degrees90(Y.fover(a)), degrees180(X.fover(a))
1080 return LatLon2Tuple(y, x) if LatLon is None else LatLon(y, x)
1083def _distanceTo(Error, **name_points): # .frechet, .hausdorff, .heights
1084 '''(INTERNAL) Check all callable C{distanceTo} methods.
1085 '''
1086 name, ps = _xkwds_item2(name_points)
1087 for i, p in enumerate(ps):
1088 if not callable(_xattr(p, distanceTo=None)):
1089 n = typename(_distanceTo)[1:]
1090 t = _SPACE_(_no_, typename(callable), n)
1091 raise Error(Fmt.SQUARE(name, i), p, txt=t)
1092 return ps
1095def fractional(points, fi, j=None, wrap=None, LatLon=None, Vector=None, **kwds):
1096 '''Return the point at a given I{fractional} index.
1098 @arg points: The points (C{LatLon}[], L{Numpy2LatLon}[],
1099 L{Tuple2LatLon}[], C{Cartesian}[], C{Vector3d}[],
1100 L{Vector3Tuple}[]).
1101 @arg fi: The fractional index (L{FIx}, C{float} or C{int}).
1102 @kwarg j: Optionally, index of the other point (C{int}).
1103 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll the
1104 B{{points}} (C{bool}) or C{None} for a backward
1105 compatible L{LatLon2Tuple} or B{C{LatLon}} with
1106 averaged lat- and longitudes. Use C{True} or
1107 C{False} to get the I{fractional} point computed
1108 by method C{B{points}[fi].intermediateTo}.
1109 @kwarg LatLon: Optional class to return the I{intermediate},
1110 I{fractional} point (C{LatLon}) or C{None}.
1111 @kwarg Vector: Optional class to return the I{intermediate},
1112 I{fractional} point (C{Cartesian}, C{Vector3d})
1113 or C{None}.
1114 @kwarg kwds: Optional, additional B{C{LatLon}} I{or} B{C{Vector}}
1115 keyword arguments, ignored if both C{B{LatLon}} and
1116 C{B{Vector}} are C{None}.
1118 @return: A L{LatLon2Tuple}C{(lat, lon)} if B{C{wrap}}, B{C{LatLon}}
1119 and B{C{Vector}} all are C{None}, the defaults.
1121 An instance of B{C{LatLon}} if not C{None} I{or} an instance
1122 of B{C{Vector}} if not C{None}.
1124 Otherwise with B{C{wrap}} either C{True} or C{False} and
1125 B{C{LatLon}} and B{C{Vector}} both C{None}, an instance of
1126 B{C{points}}' (sub-)class C{intermediateTo} I{fractional}.
1128 Summarized as follows:
1130 >>> wrap | LatLon | Vector | returned type/value
1131 # -------+--------+--------+--------------+------
1132 # | | | LatLon2Tuple | favg
1133 # None | None | None | or** |
1134 # | | | Vector3Tuple | favg
1135 # None | LatLon | None | LatLon | favg
1136 # None | None | Vector | Vector | favg
1137 # -------+--------+--------+--------------+------
1138 # True | None | None | points' | .iTo
1139 # True | LatLon | None | LatLon | .iTo
1140 # True | None | Vector | Vector | .iTo
1141 # -------+--------+--------+--------------+------
1142 # False | None | None | points' | .iTo
1143 # False | LatLon | None | LatLon | .iTo
1144 # False | None | Vector | Vector | .iTo
1145 # _____
1146 # favg) averaged lat, lon or x, y, z values
1147 # .iTo) value from points[fi].intermediateTo
1148 # **) depends on base class of points[fi]
1150 @raise IndexError: Fractional index B{C{fi}} invalid or B{C{points}}
1151 not subscriptable or not closed.
1153 @raise TypeError: Invalid B{C{LatLon}}, B{C{Vector}} or B{C{kwds}}
1154 argument.
1156 @see: Class L{FIx} and method L{FIx.fractional}.
1157 '''
1158 if LatLon and Vector: # PYCHOK no cover
1159 kwds = _xkwds(kwds, fi=fi, LatLon=LatLon, Vector=Vector)
1160 raise _TypeError(txt__=fractional, **kwds)
1161 w = wrap if LatLon else False # intermediateTo
1162 try:
1163 if (not isscalar(fi)) or fi < 0:
1164 raise IndexError
1165 n = _xattr(fi, fin=0)
1166 p = _fractional(points, fi, j, fin=n, wrap=w) # see .units.FIx
1167 if LatLon:
1168 p = LatLon(p.lat, p.lon, **kwds)
1169 elif Vector:
1170 p = Vector(p.x, p.y, p.z, **kwds)
1171 except (IndexError, TypeError):
1172 raise _IndexError(fi=fi, points=points, wrap=w, txt__=fractional)
1173 return p
1176def _fractional(points, fi, j, fin=None, wrap=None, dup=False): # in .frechet.py
1177 '''(INTERNAL) Compute point at L{fractional} index C{fi} and C{j}.
1178 '''
1179 i = int(fi)
1180 p = points[i]
1181 r = fi - float(i)
1182 if r > EPS: # EPS0?
1183 if j is None: # in .frechet.py
1184 j = i + 1
1185 if fin:
1186 j %= fin
1187 q = points[j]
1188 if r >= EPS1: # PYCHOK no cover
1189 p = q
1190 elif wrap is not None: # isbool(wrap)
1191 p = p.intermediateTo(q, r, wrap=wrap)
1192 elif _isLatLon(p): # backward compatible default
1193 t = LatLon2Tuple(favg(p.lat, q.lat, f=r),
1194 favg(p.lon, q.lon, f=r),
1195 name__=fractional)
1196 p = p.dup(lat=t.lat, lon=t.lon, name=t.name) if dup else t # PYCHOK lat, lon
1197 else: # assume p and q are cartesian or vectorial
1198 z = p.z if p.z is q.z else favg(p.z, q.z, f=r)
1199 p = Vector3Tuple(favg(p.x, q.x, f=r),
1200 favg(p.y, q.y, f=r), z,
1201 name__=fractional)
1202 return p
1205def isclockwise(points, adjust=False, wrap=True):
1206 '''Determine the direction of a path or polygon.
1208 @arg points: The path or polygon points (C{LatLon}[]).
1209 @kwarg adjust: Adjust the wrapped, unrolled longitudinal delta
1210 by the cosine of the mean latitude (C{bool}).
1211 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll the
1212 B{C{points}} (C{bool}).
1214 @return: C{True} if B{C{points}} are clockwise, C{False} otherwise.
1216 @raise PointsError: Insufficient number of B{C{points}}
1218 @raise TypeError: Some B{C{points}} are not C{LatLon}.
1220 @raise ValueError: The B{C{points}} enclose a pole or zero area.
1221 '''
1222 a, pts = _area2(points, adjust, wrap)
1223 if a > 0: # opposite of ellipsoidalExact and -Karney
1224 return True
1225 elif a < 0:
1226 return False
1227 # <https://blog.Element84.com/determining-if-a-spherical-polygon-contains-a-pole.html>
1228 raise _areaError(pts)
1231def isconvex(points, adjust=False, wrap=False): # was=True
1232 '''Determine whether a polygon is convex.
1234 @arg points: The polygon points (C{LatLon}[]).
1235 @kwarg adjust: Adjust the wrapped, unrolled longitudinal delta
1236 by the cosine of the mean latitude (C{bool}).
1237 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll the
1238 B{C{points}} (C{bool}).
1240 @return: C{True} if B{C{points}} are convex, C{False} otherwise.
1242 @raise CrossError: Some B{C{points}} are colinear.
1244 @raise PointsError: Insufficient number of B{C{points}}
1246 @raise TypeError: Some B{C{points}} are not C{LatLon}.
1247 '''
1248 return bool(isconvex_(points, adjust=adjust, wrap=wrap))
1251def isconvex_(points, adjust=False, wrap=False): # was=True
1252 '''Determine whether a polygon is convex I{and clockwise}.
1254 @arg points: The polygon points (C{LatLon}[]).
1255 @kwarg adjust: Adjust the wrapped, unrolled longitudinal delta
1256 by the cosine of the mean latitude (C{bool}).
1257 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll the
1258 B{C{points}} (C{bool}).
1260 @return: C{+1} if B{C{points}} are convex clockwise, C{-1} for
1261 convex counter-clockwise B{C{points}}, C{0} otherwise.
1263 @raise CrossError: Some B{C{points}} are colinear.
1265 @raise PointsError: Insufficient number of B{C{points}}
1267 @raise TypeError: Some B{C{points}} are not C{LatLon}.
1268 '''
1269 if adjust:
1270 def _unroll2(x1, x2, w, y1, y2):
1271 x21, x2 = unroll180(x1, x2, wrap=w)
1272 y = radians(y1 + y2) * _0_5
1273 x21 *= cos(y) if fabs(y) < PI_2 else _0_0
1274 return x21, x2
1275 else:
1276 def _unroll2(x1, x2, w, *unused): # PYCHOK expected
1277 return unroll180(x1, x2, wrap=w)
1279 c, s = crosserrors(), 0
1281 Ps = LatLon2PsxyIter(points, loop=2, wrap=wrap)
1282 x1, y1, _ = Ps[0]
1283 x2, y2, _ = Ps[1]
1285 x21, x2 = _unroll2(x1, x2, False, y1, y2)
1286 for i, p in Ps.enumerate(closed=True):
1287 x3, y3, ll = p
1288 x32, x3 = _unroll2(x2, x3, bool(wrap and not Ps.looped), y2, y3)
1290 # get the sign of the distance from point
1291 # x3, y3 to the line from x1, y1 to x2, y2
1292 # <https://WikiPedia.org/wiki/Distance_from_a_point_to_a_line>
1293 s3 = fdot((x3, y3, x1, y1), y2 - y1, -x21, -y2, x2)
1294 if s3 > 0: # x3, y3 on the right
1295 if s < 0: # non-convex
1296 return 0
1297 s = +1
1299 elif s3 < 0: # x3, y3 on the left
1300 if s > 0: # non-convex
1301 return 0
1302 s = -1
1304 elif c and fdot((x32, y1 - y2), y3 - y2, -x21) < 0: # PYCHOK no cover
1305 # colinear u-turn: x3, y3 not on the
1306 # opposite side of x2, y2 as x1, y1
1307 t = Fmt.SQUARE(points=i)
1308 raise CrossError(t, ll, txt=_colinear_)
1310 x1, y1, x2, y2, x21 = x2, y2, x3, y3, x32
1312 return s # all points on the same side
1315def isenclosedBy(point, points, wrap=False): # MCCABE 15
1316 '''Determine whether a point is enclosed by a polygon or composite.
1318 @arg point: The point (C{LatLon} or 2-tuple C{(lat, lon)}).
1319 @arg points: The polygon points or clips (C{LatLon}[], L{BooleanFHP}
1320 or L{BooleanGH}).
1321 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll the
1322 B{C{points}} (C{bool}).
1324 @return: C{True} if the B{C{point}} is inside the polygon or
1325 composite, C{False} otherwise.
1327 @raise PointsError: Insufficient number of B{C{points}}
1329 @raise TypeError: Some B{C{points}} are not C{LatLon}.
1331 @raise ValueError: Invalid B{C{point}}, lat- or longitude.
1333 @see: Functions L{pygeodesy.isconvex} and L{pygeodesy.ispolar} especially
1334 if the B{C{points}} may enclose a pole or wrap around the earth
1335 I{longitudinally}, methods L{sphericalNvector.LatLon.isenclosedBy},
1336 L{sphericalTrigonometry.LatLon.isenclosedBy} and U{MultiDop
1337 GeogContainPt<https://GitHub.com/NASA/MultiDop>} (U{Shapiro et.al. 2009,
1338 JTECH<https://Journals.AMetSoc.org/doi/abs/10.1175/2009JTECHA1256.1>}
1339 and U{Potvin et al. 2012, JTECH <https://Journals.AMetSoc.org/doi/abs/
1340 10.1175/JTECH-D-11-00019.1>}).
1341 '''
1342 try:
1343 y0, x0 = point.lat, point.lon
1344 except AttributeError:
1345 try:
1346 y0, x0 = map(float, point[:2])
1347 except (IndexError, TypeError, ValueError) as x:
1348 raise _ValueError(point=point, cause=x)
1350 if wrap:
1351 y0, x0 = _Wrap.latlon(y0, x0)
1353 def _dxy3(x, x2, y2, Ps):
1354 dx, x2 = unroll180(x, x2, wrap=not Ps.looped)
1355 return dx, x2, y2
1357 else:
1358 x0 = _fmod(x0, _360_0) # not x0 % 360!
1359 x0_180_ = x0 - _180_0
1360 x0_180 = x0 + _180_0
1362 def _dxy3(x1, x, y, unused): # PYCHOK expected
1363 x = _umod_360(float(x))
1364 if x < x0_180_:
1365 x += _360_0
1366 elif x >= x0_180:
1367 x -= _360_0
1368 return (x - x1), x, y
1370 if _MODS.booleans.isBoolean(points):
1371 return points._encloses(y0, x0, wrap=wrap)
1373 Ps = LatLon2PsxyIter(points, loop=1, wrap=wrap)
1374 p = Ps[0]
1375 e = m = False
1376 S = Fsum()
1378 _, x1, y1 = _dxy3(x0, p.x, p.y, False)
1379 for p in Ps.iterate(closed=True):
1380 dx, x2, y2 = _dxy3(x1, p.x, p.y, Ps)
1381 # ignore duplicate and near-duplicate pts
1382 if fabs(dx) > EPS or fabs(y2 - y1) > EPS:
1383 # determine if polygon edge (x1, y1)..(x2, y2) straddles
1384 # point (lat, lon) or is on boundary, but do not count
1385 # edges on boundary as more than one crossing
1386 if fabs(dx) < 180 and (x1 < x0 <= x2 or x2 < x0 <= x1):
1387 m = not m
1388 dy = (x0 - x1) * (y2 - y1) - (y0 - y1) * dx
1389 if (dy > 0 and dx >= 0) or (dy < 0 and dx <= 0):
1390 e = not e
1392 S += sin(radians(y2))
1393 x1, y1 = x2, y2
1395 # An odd number of meridian crossings means, the polygon
1396 # contains a pole. Assume it is the pole on the hemisphere
1397 # containing the polygon mean point and if the polygon does
1398 # contain the North Pole, flip the result.
1399 if m and S.fsum() > 0:
1400 e = not e
1401 return e
1404def ispolar(points, wrap=False):
1405 '''Check whether a polygon encloses a pole.
1407 @arg points: The polygon points (C{LatLon}[]).
1408 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll
1409 the B{C{points}} (C{bool}).
1411 @return: C{True} if the polygon encloses a pole, C{False}
1412 otherwise.
1414 @raise PointsError: Insufficient number of B{C{points}}
1416 @raise TypeError: Some B{C{points}} are not C{LatLon} or don't
1417 have C{bearingTo2}, C{initialBearingTo}
1418 and C{finalBearingTo} methods.
1419 '''
1420 def _cds(ps, w): # iterate over course deltas
1421 Ps = PointsIter(ps, loop=2, wrap=w)
1422 p2, p1 = Ps[0:2]
1423 b1, _ = _bearingTo2(p2, p1, wrap=False)
1424 for p2 in Ps.iterate(closed=True):
1425 if not p2.isequalTo(p1, EPS):
1426 if w and not Ps.looped:
1427 p2 = _unrollon(p1, p2)
1428 b, b2 = _bearingTo2(p1, p2, wrap=False)
1429 yield wrap180(b - b1) # (b - b1 + 540) % 360 - 180
1430 yield wrap180(b2 - b) # (b2 - b + 540) % 360 - 180
1431 p1, b1 = p2, b2
1433 # summation of course deltas around pole is 0° rather than normally ±360°
1434 # <https://blog.Element84.com/determining-if-a-spherical-polygon-contains-a-pole.html>
1435 s = fsum(_cds(points, wrap))
1436 # XXX fix (intermittant) edge crossing pole - eg (85,90), (85,0), (85,-90)
1437 return fabs(s) < 90 # "zero-ish"
1440def luneOf(lon1, lon2, closed=False, LatLon=LatLon_, **LatLon_kwds):
1441 '''Generate an ellipsoidal or spherical U{lune
1442 <https://WikiPedia.org/wiki/Spherical_lune>}-shaped path or polygon.
1444 @arg lon1: Left longitude (C{degrees90}).
1445 @arg lon2: Right longitude (C{degrees90}).
1446 @kwarg closed: Optionally, close the path (C{bool}).
1447 @kwarg LatLon: Class to use (L{LatLon_}).
1448 @kwarg LatLon_kwds: Optional, additional B{C{LatLon}}
1449 keyword arguments.
1451 @return: A tuple of 4 or 5 B{C{LatLon}} instances outlining
1452 the lune shape.
1454 @see: U{Latitude-longitude quadrangle
1455 <https://www.MathWorks.com/help/map/ref/areaquad.html>}.
1456 '''
1457 t = (LatLon( _0_0, lon1, **LatLon_kwds),
1458 LatLon( _90_0, lon1, **LatLon_kwds),
1459 LatLon( _0_0, lon2, **LatLon_kwds),
1460 LatLon(_N_90_0, lon2, **LatLon_kwds))
1461 if closed:
1462 t += t[:1]
1463 return t
1466def nearestOn5(point, points, closed=False, wrap=False, adjust=True,
1467 limit=9, **LatLon_and_kwds):
1468 '''Locate the point on a path or polygon closest to a reference point.
1470 The closest point on each polygon edge is either the nearest of that
1471 edge's end points or a point in between.
1473 @arg point: The reference point (C{LatLon}).
1474 @arg points: The path or polygon points (C{LatLon}[]).
1475 @kwarg closed: Optionally, close the path or polygon (C{bool}).
1476 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll the
1477 B{C{points}} (C{bool}).
1478 @kwarg adjust: See function L{pygeodesy.equirectangular4} (C{bool}).
1479 @kwarg limit: See function L{pygeodesy.equirectangular4} (C{degrees}),
1480 default C{9 degrees} is about C{1,000 Kmeter} (for mean
1481 spherical earth radius L{R_KM}).
1482 @kwarg LatLon_and_kwds: Optional, C{B{LatLon}=None} class to use for
1483 the closest point and additional B{C{LatLon}} keyword
1484 arguments, ignored if C{B{LatLon} is None} or not given.
1486 @return: A L{NearestOn3Tuple}C{(closest, distance, angle)} with the
1487 {closest} point (B{C{LatLon}}) or if C{B{LatLon} is None},
1488 a L{NearestOn5Tuple}C{(lat, lon, distance, angle, height)}.
1489 The C{distance} is the L{pygeodesy.equirectangular} distance
1490 between the C{closest} and reference B{C{point}} in C{degrees}.
1491 The C{angle} from the B{C{point}} to the C{closest} is in
1492 compass C{degrees}, like function L{pygeodesy.compassAngle}.
1494 @raise LimitError: Lat- and/or longitudinal delta exceeds the B{C{limit}},
1495 see function L{pygeodesy.equirectangular4}.
1497 @raise PointsError: Insufficient number of B{C{points}}
1499 @raise TypeError: Some B{C{points}} are not C{LatLon}.
1501 @note: Distances are I{approximated} by function L{pygeodesy.equirectangular4}.
1502 For more accuracy use one of the C{LatLon.nearestOn6} methods.
1504 @see: Function L{pygeodesy.degrees2m}.
1505 '''
1506 def _d2yx4(p2, p1, u, alw):
1507 # w = wrap if (i < (n - 1) or not closed) else False
1508 # equirectangular4 returns a Distance4Tuple(distance
1509 # in degrees squared, delta lat, delta lon, p2.lon
1510 # unroll/wrap'd); the previous p2.lon unroll/wrap'd
1511 # is also applied to the next edge's p1.lon
1512 return equirectangular4(p1.lat, p1.lon + u,
1513 p2.lat, p2.lon, **alw)
1515 def _h(p): # get height or default 0
1516 return _xattr(p, height=0) or 0
1518 # 3-D version used in .vector3d._nearestOn2
1519 #
1520 # point (x, y) on axis rotated ccw by angle a:
1521 # x' = x * cos(a) + y * sin(a)
1522 # y' = y * cos(a) - x * sin(a)
1523 #
1524 # distance (w) along and (h) perpendicular to
1525 # a line thru point (dx, dy) and the origin:
1526 # d = hypot(dx, dy)
1527 # w = (x * dx + y * dy) / d
1528 # h = (y * dx - x * dy) / d
1529 #
1530 # closest point on that line thru (dx, dy):
1531 # xc = dx * w / d
1532 # yc = dy * w / d
1533 # or
1534 # xc = dx * f
1535 # yc = dy * f
1536 # with
1537 # f = w / d
1538 # or
1539 # f = (y * dy + x * dx) / hypot2(dx, dy)
1540 #
1541 # i.e. no need for sqrt or hypot
1543 Ps = PointsIter(points, loop=1, wrap=wrap)
1544 p1 = c = Ps[0]
1545 u1 = u = _0_0
1546 kw = dict(adjust=adjust, limit=limit, wrap=False)
1547 d, dy, dx, _ = _d2yx4(p1, point, u1, kw)
1548 for p2 in Ps.iterate(closed=closed):
1549 # iff wrapped, unroll lon1 (actually previous
1550 # lon2) like function unroll180/-PI would've
1551 if wrap:
1552 kw.update(wrap=not (closed and Ps.looped))
1553 d21, y21, x21, u2 = _d2yx4(p2, p1, u1, kw)
1554 if d21 > EPS:
1555 # distance point to p1, y01 and x01 negated
1556 d2, y01, x01, _ = _d2yx4(point, p1, u1, kw)
1557 if d2 > EPS:
1558 w2 = y01 * y21 + x01 * x21
1559 if w2 > 0:
1560 if w2 < d21:
1561 # closest is between p1 and p2, use
1562 # original delta's, not y21 and x21
1563 f = w2 / d21
1564 p1 = LatLon_(favg(p1.lat, p2.lat, f=f),
1565 favg(p1.lon, p2.lon + u2, f=f),
1566 height=favg(_h(p1), _h(p2), f=f))
1567 u1 = _0_0
1568 else: # p2 is closest
1569 p1, u1 = p2, u2
1570 d2, y01, x01, _ = _d2yx4(point, p1, u1, kw)
1571 if d2 < d: # p1 is closer, y01 and x01 negated
1572 c, u, d, dy, dx = p1, u1, d2, -y01, -x01
1573 p1, u1 = p2, u2
1575 a = atan2b(dx, dy) # azimuth
1576 d = hypot( dx, dy)
1577 h = _h(c)
1578 n = nameof(point) or typename(nearestOn5)
1579 if LatLon_and_kwds:
1580 LL, kwds = _xkwds_pop2(LatLon_and_kwds, LatLon=None)
1581 if LL is not None:
1582 r = LL(c.lat, c.lon + u, **_xkwds(kwds, height=h, name=n))
1583 return NearestOn3Tuple(r, d, a, name=n)
1584 return NearestOn5Tuple(c.lat, c.lon + u, d, a, h, name=n) # PYCHOK expected
1587def perimeterOf(points, closed=False, adjust=True, radius=R_M, wrap=True):
1588 '''I{Approximate} the perimeter of a path, polygon. or composite.
1590 @arg points: The path or polygon points or clips (C{LatLon}[],
1591 L{BooleanFHP} or L{BooleanGH}).
1592 @kwarg closed: Optionally, close the path or polygon (C{bool}).
1593 @kwarg adjust: Adjust the wrapped, unrolled longitudinal delta
1594 by the cosine of the mean latitude (C{bool}).
1595 @kwarg radius: Mean earth radius (C{meter}).
1596 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll the
1597 B{C{points}} (C{bool}).
1599 @return: Approximate perimeter (C{meter}, same units as
1600 B{C{radius}}).
1602 @raise PointsError: Insufficient number of B{C{points}}
1604 @raise TypeError: Some B{C{points}} are not C{LatLon}.
1606 @raise ValueError: Invalid B{C{radius}} or C{B{closed}=False} with
1607 C{B{points}} a composite.
1609 @note: This perimeter is based on the L{pygeodesy.equirectangular4}
1610 distance approximation and is ill-suited for regions exceeding
1611 several hundred Km or Miles or with near-polar latitudes.
1613 @see: Functions L{sphericalTrigonometry.perimeterOf} and
1614 L{ellipsoidalKarney.perimeterOf}.
1615 '''
1616 def _degs(ps, c, a, w): # angular edge lengths in degrees
1617 Ps = LatLon2PsxyIter(ps, loop=1) # wrap=w
1618 p1, u = Ps[0], _0_0 # previous x2's unroll/wrap
1619 for p2 in Ps.iterate(closed=c):
1620 if w and c:
1621 w = not Ps.looped
1622 # apply previous x2's unroll/wrap'd to new x1
1623 _, dy, dx, u = equirectangular4(p1.y, p1.x + u,
1624 p2.y, p2.x,
1625 adjust=a, limit=None,
1626 wrap=w) # PYCHOK non-seq
1627 yield hypot(dx, dy)
1628 p1 = p2
1630 if _MODS.booleans.isBoolean(points):
1631 if not closed:
1632 notImplemented(None, closed=closed, points=_composite_)
1633 d = points._sum1(perimeterOf, closed=True, adjust=adjust,
1634 radius=radius, wrap=wrap)
1635 else:
1636 d = fsum(_degs(points, closed, adjust, wrap))
1637 return degrees2m(d, radius=radius)
1640def quadOf(latS, lonW, latN, lonE, closed=False, LatLon=LatLon_, **LatLon_kwds):
1641 '''Generate a quadrilateral path or polygon from two points.
1643 @arg latS: Souther-nmost latitude (C{degrees90}).
1644 @arg lonW: Western-most longitude (C{degrees180}).
1645 @arg latN: Norther-nmost latitude (C{degrees90}).
1646 @arg lonE: Eastern-most longitude (C{degrees180}).
1647 @kwarg closed: Optionally, close the path (C{bool}).
1648 @kwarg LatLon: Class to use (L{LatLon_}).
1649 @kwarg LatLon_kwds: Optional, additional B{C{LatLon}}
1650 keyword arguments.
1652 @return: Return a tuple of 4 or 5 B{C{LatLon}} instances
1653 outlining the quadrilateral.
1655 @see: Function L{boundsOf}.
1656 '''
1657 t = (LatLon(latS, lonW, **LatLon_kwds),
1658 LatLon(latN, lonW, **LatLon_kwds),
1659 LatLon(latN, lonE, **LatLon_kwds),
1660 LatLon(latS, lonE, **LatLon_kwds))
1661 if closed:
1662 t += t[:1]
1663 return t
1666__all__ += _ALL_DOCS(_Array2LatLon, _Basequence)
1668# **) MIT License
1669#
1670# Copyright (C) 2016-2025 -- mrJean1 at Gmail -- All Rights Reserved.
1671#
1672# Permission is hereby granted, free of charge, to any person obtaining a
1673# copy of this software and associated documentation files (the "Software"),
1674# to deal in the Software without restriction, including without limitation
1675# the rights to use, copy, modify, merge, publish, distribute, sublicense,
1676# and/or sell copies of the Software, and to permit persons to whom the
1677# Software is furnished to do so, subject to the following conditions:
1678#
1679# The above copyright notice and this permission notice shall be included
1680# in all copies or substantial portions of the Software.
1681#
1682# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
1683# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1684# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
1685# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
1686# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
1687# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
1688# OTHER DEALINGS IN THE SOFTWARE.