Coverage for pygeodesy/geodesicx/gxarea.py: 95%
213 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-05-29 12:40 -0400
« prev ^ index » next coverage.py v7.6.1, created at 2025-05-29 12:40 -0400
1# -*- coding: utf-8 -*-
3u'''Slightly enhanced versions of classes U{PolygonArea
4<https://GeographicLib.SourceForge.io/1.52/python/code.html#
5module-geographiclib.polygonarea>} and C{Accumulator} from
6I{Karney}'s Python U{geographiclib
7<https://GeographicLib.SourceForge.io/1.52/python/index.html>}.
9Class L{GeodesicAreaExact} is intended to work with instances
10of class L{GeodesicExact} and of I{wrapped} class C{Geodesic},
11see module L{pygeodesy.karney}.
13Copyright (C) U{Charles Karney<mailto:Karney@Alum.MIT.edu>} (2008-2024)
14and licensed under the MIT/X11 License. For more information, see the
15U{GeographicLib<https://GeographicLib.SourceForge.io>} documentation.
16'''
17# make sure int/int division yields float quotient
18from __future__ import division as _; del _ # noqa: E702 ;
20from pygeodesy.basics import _copysign, isodd, unsigned0
21from pygeodesy.constants import NAN, _0_0, _0_5, _720_0
22from pygeodesy.internals import printf, typename
23# from pygeodesy.interns import _COMMASPACE_ # from .lazily
24from pygeodesy.karney import Area3Tuple, _diff182, GeodesicError, \
25 _norm180, _remainder, _sum3
26from pygeodesy.lazily import _ALL_DOCS, _COMMASPACE_
27from pygeodesy.named import ADict, callername, _NamedBase, pairs
28from pygeodesy.props import Property, Property_RO, property_RO
29# from pygeodesy.streprs import pairs # from .named
31from math import fmod as _fmod
33__all__ = ()
34__version__ = '25.05.28'
37class GeodesicAreaExact(_NamedBase):
38 '''Area and perimeter of a geodesic polygon, an enhanced version of I{Karney}'s
39 Python class U{PolygonArea<https://GeographicLib.SourceForge.io/html/python/
40 code.html#module-geographiclib.polygonarea>} using the more accurate surface area.
42 @note: The name of this class C{*Exact} is a misnomer, see I{Karney}'s comments at
43 C++ attribute U{GeodesicExact._c2<https://GeographicLib.SourceForge.io/C++/doc/
44 GeodesicExact_8cpp_source.html>}.
45 '''
46 _Area = None
47 _g_gX = None # Exact or not
48 _lat0 = _lon0 = \
49 _lat1 = _lon1 = NAN
50 _mask = 0
51 _num = 0
52 _Peri = None
53 _verbose = False
54 _xings = 0
56 def __init__(self, geodesic, polyline=False, **name):
57 '''New L{GeodesicAreaExact} instance.
59 @arg geodesic: A geodesic (L{GeodesicExact}, I{wrapped}
60 C{Geodesic} or L{GeodesicSolve}).
61 @kwarg polyline: If C{True}, compute the perimeter only,
62 otherwise area and perimeter (C{bool}).
63 @kwarg name: Optional C{B{name}=NN} (C{str}).
65 @raise GeodesicError: Invalid B{C{geodesic}}.
66 '''
67 try: # results returned as L{GDict}
68 if not (callable(geodesic._GDictDirect) and
69 callable(geodesic._GDictInverse)):
70 raise TypeError()
71 except (AttributeError, TypeError):
72 raise GeodesicError(geodesic=geodesic)
74 self._g_gX = g = geodesic
75 # use the class-level Caps since the values
76 # differ between GeodesicExact and Geodesic
77 self._mask = g.DISTANCE | g.LATITUDE | g.LONGITUDE
78 self._Peri = _Accumulator(name='_Peri')
79 if not polyline: # perimeter and area
80 self._mask |= g.AREA | g.LONG_UNROLL
81 self._Area = _Accumulator(name='_Area')
82 if g.debug: # PYCHOK no cover
83 self.verbose = True # debug as verbosity
84 if name:
85 self.name = name
87 def AddEdge(self, azi, s):
88 '''Add another polygon edge.
90 @arg azi: Azimuth at the current point (compass
91 C{degrees360}).
92 @arg s: Length of the edge (C{meter}).
93 '''
94 if self.num < 1:
95 raise GeodesicError(num=self.num)
96 r = self._Direct(azi, s)
97 p = self._Peri.Add(s)
98 if self._Area:
99 a = self._Area.Add(r.S12)
100 self._xings += r.xing
101 else:
102 a = NAN
103 self._lat1 = r.lat2
104 self._lon1 = r.lon2
105 self._num += 1
106 if self.verbose: # PYCHOK no cover
107 self._print(self.num, p, a, r, lat1=r.lat2, lon1=r.lon2,
108 azi=azi, s=s)
109 return self.num
111 def AddPoint(self, lat, lon):
112 '''Add another polygon point.
114 @arg lat: Latitude of the point (C{degrees}).
115 @arg lon: Longitude of the point (C{degrees}).
116 '''
117 if self.num > 0:
118 r = self._Inverse(self.lat1, self.lon1, lat, lon)
119 s = r.s12
120 p = self._Peri.Add(s)
121 if self._Area:
122 a = self._Area.Add(r.S12)
123 self._xings += r.xing
124 else:
125 a = NAN
126 else:
127 self._lat0 = lat
128 self._lon0 = lon
129 a = p = s = _0_0
130 r = None
131 self._lat1 = lat
132 self._lon1 = lon
133 self._num += 1
134 if self.verbose: # PYCHOK no cover
135 self._print(self.num, p, a, r, lat1=lat, lon1=lon, s=s)
136 return self.num
138 @Property_RO
139 def area0x(self):
140 '''Get the ellipsoid's surface area (C{meter} I{squared}), more accurate
141 for very I{oblate} ellipsoids.
142 '''
143 return self.ellipsoid.areax # not .area!
145 area0 = area0x # for C{geographiclib} compatibility
147 def Compute(self, reverse=False, sign=True, polar=False):
148 '''Compute the accumulated perimeter and area.
150 @kwarg reverse: If C{True}, clockwise traversal counts as a positive area instead
151 of counter-clockwise (C{bool}).
152 @kwarg sign: If C{True}, return a signed result for the area if the polygon is
153 traversed in the "wrong" direction instead of returning the area for
154 the rest of the earth.
155 @kwarg polar: Use C{B{polar}=True} if the polygon encloses a pole (C{bool}), see
156 function L{ispolar<pygeodesy.points.ispolar>} and U{area of a polygon
157 enclosing a pole<https://GeographicLib.SourceForge.io/C++/doc/
158 classGeographicLib_1_1GeodesicExact.html#a3d7a9155e838a09a48dc14d0c3fac525>}.
160 @return: L{Area3Tuple}C{(number, perimeter, area)} with the number of points, the
161 perimeter in C{meter} and the (signed) area in C{meter**2}. The perimeter
162 includes the length of a final edge, connecting the current to the initial
163 point, if this polygon was initialized with C{polyline=False}. For perimeter
164 only, i.e. C{polyline=True}, area is C{NAN}.
166 @note: Arbitrarily complex polygons are allowed. In the case of self-intersecting
167 polygons, the area is accumulated "algebraically". E.g., the areas of both
168 loops in a I{figure-8} polygon will partially cancel.
170 @note: More points and edges can be added after this call.
171 '''
172 r, n = None, self.num
173 if n < 2:
174 p = _0_0
175 a = NAN if self.polyline else p
176 elif self._Area:
177 r = self._Inverse(self.lat1, self.lon1, self.lat0, self.lon0)
178 a = self._reduced(r.S12, r.xing, n, reverse=reverse, sign=sign, polar=polar)
179 p = self._Peri.Sum(r.s12)
180 else:
181 p = self._Peri.Sum()
182 a = NAN
183 if self.verbose: # PYCHOK no cover
184 self._print(n, p, a, r, lat0=self.lat0, lon0=self.lon0)
185 return Area3Tuple(n, p, a)
187 def _Direct(self, azi, s):
188 '''(INTERNAL) Edge helper.
189 '''
190 lon1 = self.lon1
191 r = self._g_gX._GDictDirect(self.lat1, lon1, azi, False, s, self._mask)
192 if self._Area: # aka transitDirect
193 # Count crossings of prime meridian exactly as
194 # int(ceil(lon2 / 360)) - int(ceil(lon1 / 360))
195 # Since we only need the parity of the result we
196 # can use std::remquo but this is buggy with g++
197 # 4.8.3 and requires C++11. So instead we do:
198 lon1 = _fmod( lon1, _720_0) # r.lon1
199 lon2 = _fmod(r.lon2, _720_0)
200 # int(True) == 1, int(False) == 0
201 r.set_(xing=int(lon2 > 360 or -360 < lon2 <= 0) -
202 int(lon1 > 360 or -360 < lon1 <= 0))
203 return r
205 @Property_RO
206 def ellipsoid(self):
207 '''Get this area's ellipsoid (C{Ellipsoid[2]}).
208 '''
209 return self._g_gX.ellipsoid
211 @Property_RO
212 def geodesic(self):
213 '''Get this area's geodesic object (C{Geodesic[Exact]}).
214 '''
215 return self._g_gX
217 earth = geodesic # for C{geographiclib} compatibility
219 def _Inverse(self, lat1, lon1, lat2, lon2):
220 '''(INTERNAL) Point helper.
221 '''
222 r = self._g_gX._GDictInverse(lat1, lon1, lat2, lon2, self._mask)
223 if self._Area: # aka transit
224 # count crossings of prime meridian as +1 or -1
225 # if in east or west direction, otherwise 0
226 lon1 = _norm180(lon1)
227 lon2 = _norm180(lon2)
228 lon12, _ = _diff182(lon1, lon2)
229 r.set_(xing=int(lon12 > 0 and lon1 <= 0 and lon2 > 0) or
230 -int(lon12 < 0 and lon2 <= 0 and lon1 > 0))
231 return r
233 @property_RO
234 def lat0(self):
235 '''Get the first point's latitude (C{degrees}).
236 '''
237 return self._lat0
239 @property_RO
240 def lat1(self):
241 '''Get the most recent point's latitude (C{degrees}).
242 '''
243 return self._lat1
245 @property_RO
246 def lon0(self):
247 '''Get the first point's longitude (C{degrees}).
248 '''
249 return self._lon0
251 @property_RO
252 def lon1(self):
253 '''Get the most recent point's longitude (C{degrees}).
254 '''
255 return self._lon1
257 @property_RO
258 def num(self):
259 '''Get the current number of points (C{int}).
260 '''
261 return self._num
263 @Property_RO
264 def polyline(self):
265 '''Is this perimeter only (C{bool}), area NAN?
266 '''
267 return self._Area is None
269 def _print(self, n, p, a, r, **kwds): # PYCHOK no cover
270 '''(INTERNAL) Print a verbose line.
271 '''
272 d = ADict(p=p, s12=r.s12 if r else NAN, **kwds)
273 if self._Area:
274 d.set_(a=a, S12=r.S12 if r else NAN)
275 t = _COMMASPACE_.join(pairs(d, prec=10))
276 printf('%s %s: %s (%s)', self.named2, n, t, callername(up=2))
278 def _reduced(self, S12, xing, n, reverse=False, sign=True, polar=False):
279 '''(INTERNAL) Accumulate and reduce area to allowed range.
280 '''
281 a0 = self.area0x
282 A = _Accumulator(self._Area)
283 _ = A.Add(S12)
284 a = A.Remainder(a0) # clockwise
285 if isodd(self._xings + xing):
286 a = A.Add((a0 if a < 0 else -a0) * _0_5)
287 if not reverse:
288 a = A.Negate() # counter-clockwise
289 # (-area0x/2, area0x/2] if sign else [0, area0x)
290 a0_ = a0 if sign else (a0 * _0_5)
291 if a > a0_:
292 a = A.Add(-a0)
293 elif a <= -a0_:
294 a = A.Add( a0)
295 if polar: # see .geodesicw._gwrapped.Geodesic.Area
296 a = A.Add(_copysign(a0 * _0_5 * n, a)) # - if reverse or sign?
297 return unsigned0(a)
299 def Reset(self):
300 '''Reset this polygon to empty.
301 '''
302 if self._Area:
303 self._Area.Reset()
304 self._Peri.Reset()
305 self._lat0 = self._lon0 = \
306 self._lat1 = self._lon1 = NAN
307 self._num = self._xings = n = 0
308 if self.verbose: # PYCHOK no cover
309 printf('%s %s: (%s)', self.named2, n, typename(self.Reset))
310 return n
312 Clear = Reset
314 def TestEdge(self, azi, s, **reverse_sign_polar):
315 '''Compute the properties for a tentative, additional edge
317 @arg azi: Azimuth at the current the point (compass C{degrees}).
318 @arg s: Length of the edge (C{meter}).
319 @kwarg reverse_sign_polar: Optional C{B{reverse}=False}, C{B{sign}=True} and
320 C{B{polar}=False} keyword arguments, see method L{Compute}.
322 @return: L{Area3Tuple}C{(number, perimeter, area)}.
324 @raise GeodesicError: No points.
325 '''
326 r, n = None, self.num + 1
327 if n < 2: # raise GeodesicError(num=self.num)
328 a = p = NAN # like .test_Planimeter19
329 else:
330 p = self._Peri.Sum(s)
331 if self.polyline:
332 a = NAN
333 else:
334 d = self._Direct(azi, s)
335 r = self._Inverse(d.lat2, d.lon2, self.lat0, self.lon0)
336 a = self._reduced(d.S12 + r.S12, d.xing + r.xing, n, **reverse_sign_polar)
337 p += r.s12
338 if self.verbose: # PYCHOK no cover
339 self._print(n, p, a, r, azi=azi, s=s)
340 return Area3Tuple(n, p, a)
342 def TestPoint(self, lat, lon, **reverse_sign_polar):
343 '''Compute the properties for a tentative, additional vertex
345 @arg lat: Latitude of the point (C{degrees}).
346 @arg lon: Longitude of the point (C{degrees}).
347 @kwarg reverse_sign_polar: Optional C{B{reverse}=False}, C{B{sign}=True} and
348 C{B{polar}=False} keyword arguments, see method L{Compute}.
350 @return: L{Area3Tuple}C{(number, perimeter, area)}.
351 '''
352 r, n = None, self.num + 1
353 if n < 2:
354 p = _0_0
355 a = NAN if self.polyline else p
356 else:
357 i = self._Inverse(self.lat1, self.lon1, lat, lon)
358 p = self._Peri.Sum(i.s12)
359 if self._Area:
360 r = self._Inverse(lat, lon, self.lat0, self.lon0)
361 a = self._reduced(i.S12 + r.S12, i.xing + r.xing, n, **reverse_sign_polar)
362 p += r.s12
363 else:
364 a = NAN
365 if self.verbose: # PYCHOK no cover
366 self._print(n, p, a, r, lat=lat, lon=lon)
367 return Area3Tuple(n, p, a)
369 def toStr(self, prec=6, sep=_COMMASPACE_, **unused): # PYCHOK signature
370 '''Return this C{GeodesicExactArea} as string.
372 @kwarg prec: The C{float} precision, number of decimal digits (0..9).
373 Trailing zero decimals are stripped for B{C{prec}} values
374 of 1 and above, but kept for negative B{C{prec}} values.
375 @kwarg sep: Separator to join (C{str}).
377 @return: Area items (C{str}).
378 '''
379 n, p, a = self.Compute()
380 d = dict(geodesic=self.geodesic, num=n, area=a,
381 perimeter=p, polyline=self.polyline)
382 return sep.join(pairs(d, prec=prec))
384 @Property
385 def verbose(self):
386 '''Get the C{verbose} option (C{bool}).
387 '''
388 return self._verbose
390 @verbose.setter # PYCHOK setter!
391 def verbose(self, verbose): # PYCHOK no cover
392 '''Set the C{verbose} option (C{bool}) to print
393 a message after each method invokation.
394 '''
395 self._verbose = bool(verbose)
398class PolygonArea(GeodesicAreaExact):
399 '''For C{geographiclib} compatibility, sub-class of L{GeodesicAreaExact}.
400 '''
401 def __init__(self, earth, polyline=False, **name):
402 '''New L{PolygonArea} instance.
404 @arg earth: A geodesic (L{GeodesicExact}, I{wrapped}
405 C{Geodesic} or L{GeodesicSolve}).
406 @kwarg polyline: If C{True}, compute the perimeter only, otherwise
407 perimeter and area (C{bool}).
408 @kwarg name: Optional C{B{name}=NN} (C{str}).
410 @raise GeodesicError: Invalid B{C{earth}}.
411 '''
412 GeodesicAreaExact.__init__(self, earth, polyline=polyline, **name)
415class _Accumulator(_NamedBase):
416 '''Like C{math.fsum}, but allowing a running sum.
418 Original from I{Karney}'s U{geographiclib
419 <https://PyPI.org/project/geographiclib>}C{.accumulator},
420 enhanced to return the current sum by most methods.
421 '''
422 _n = 0 # len()
423 _s = _t = _0_0
425 def __init__(self, y=0, **name):
426 '''New L{_Accumulator}.
428 @kwarg y: Initial value (C{scalar}).
429 @kwarg name: Optional C{B{name}=NN} (C{str}).
430 '''
431 if isinstance(y, _Accumulator):
432 self._s, self._t, self._n = y._s, y._t, 1
433 elif y:
434 self._s, self._n = float(y), 1
435 if name:
436 self.name = name
438 def Add(self, y):
439 '''Add a value.
441 @return: Current C{sum}.
442 '''
443 self._n += 1
444 self._s, self._t, _ = _sum3(self._s, self._t, y)
445 return self._s # current .Sum()
447 def Negate(self):
448 '''Negate sum.
450 @return: Current C{sum}.
451 '''
452 self._s = s = -self._s
453 self._t = -self._t
454 return s # current .Sum()
456 @property_RO
457 def num(self):
458 '''Get the current number of C{Add}itions (C{int}).
459 '''
460 return self._n
462 def Remainder(self, y):
463 '''Remainder on division by B{C{y}}.
465 @return: Remainder of C{sum} / B{C{y}}.
466 '''
467 self._s = _remainder(self._s, y)
468# self._t = _remainder(self._t, y)
469 self._n = -1
470 return self.Add(_0_0)
472 def Reset(self, y=0):
473 '''Set value from argument.
474 '''
475 self._n, self._s, self._t = 0, float(y), _0_0
477 Set = Reset
479 def Sum(self, y=0):
480 '''Return C{sum + B{y}}.
482 @note: B{C{y}} is included in the returned
483 result, but I{not} accumulated.
484 '''
485 if y:
486 s = _Accumulator(self, name='_Sum')
487 s.Add(y)
488 else:
489 s = self
490 return s._s
492 def toStr(self, prec=6, sep=_COMMASPACE_, **unused): # PYCHOK signature
493 '''Return this C{_Accumulator} as string.
495 @kwarg prec: The C{float} precision, number of decimal digits (0..9).
496 Trailing zero decimals are stripped for B{C{prec}} values
497 of 1 and above, but kept for negative B{C{prec}} values.
498 @kwarg sep: Separator to join (C{str}).
500 @return: Accumulator (C{str}).
501 '''
502 d = dict(n=self.num, s=self._s, t=self._t)
503 return sep.join(pairs(d, prec=prec))
506__all__ += _ALL_DOCS(GeodesicAreaExact, PolygonArea)
508# **) MIT License
509#
510# Copyright (C) 2016-2025 -- mrJean1 at Gmail -- All Rights Reserved.
511#
512# Permission is hereby granted, free of charge, to any person obtaining a
513# copy of this software and associated documentation files (the "Software"),
514# to deal in the Software without restriction, including without limitation
515# the rights to use, copy, modify, merge, publish, distribute, sublicense,
516# and/or sell copies of the Software, and to permit persons to whom the
517# Software is furnished to do so, subject to the following conditions:
518#
519# The above copyright notice and this permission notice shall be included
520# in all copies or substantial portions of the Software.
521#
522# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
523# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
524# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
525# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
526# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
527# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
528# OTHER DEALINGS IN THE SOFTWARE.