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