Coverage for pygeodesy/geodesicx/gxarea.py: 95%

213 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2025-09-09 13:03 -0400

1# -*- coding: utf-8 -*- 

2 

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>}. 

8 

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}. 

12 

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 ; 

19 

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 

30 

31from math import fmod as _fmod 

32 

33__all__ = () 

34__version__ = '25.06.04' 

35 

36 

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. 

41 

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 

55 

56 def __init__(self, geodesic, polyline=False, **name): 

57 '''New L{GeodesicAreaExact} instance. 

58 

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}). 

64 

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) 

73 

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 

86 

87 def AddEdge(self, azi, s): 

88 '''Add another polygon edge. 

89 

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 

110 

111 def AddPoint(self, lat, lon): 

112 '''Add another polygon point. 

113 

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 

137 

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! 

144 

145 area0 = area0x # for C{geographiclib} compatibility 

146 

147 def Compute(self, reverse=False, sign=True, polar=False): 

148 '''Compute the accumulated perimeter and area. 

149 

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>}. 

159 

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}. 

165 

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. 

169 

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 n > 0 and 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) 

186 

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 

204 

205 @Property_RO 

206 def ellipsoid(self): 

207 '''Get this area's ellipsoid (C{Ellipsoid[2]}). 

208 ''' 

209 return self._g_gX.ellipsoid 

210 

211 @Property_RO 

212 def geodesic(self): 

213 '''Get this area's geodesic object (C{Geodesic[Exact]}). 

214 ''' 

215 return self._g_gX 

216 

217 earth = geodesic # for C{geographiclib} compatibility 

218 

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 

232 

233 @property_RO 

234 def lat0(self): 

235 '''Get the first point's latitude (C{degrees}). 

236 ''' 

237 return self._lat0 

238 

239 @property_RO 

240 def lat1(self): 

241 '''Get the most recent point's latitude (C{degrees}). 

242 ''' 

243 return self._lat1 

244 

245 @property_RO 

246 def lon0(self): 

247 '''Get the first point's longitude (C{degrees}). 

248 ''' 

249 return self._lon0 

250 

251 @property_RO 

252 def lon1(self): 

253 '''Get the most recent point's longitude (C{degrees}). 

254 ''' 

255 return self._lon1 

256 

257 @property_RO 

258 def num(self): 

259 '''Get the current number of points (C{int}). 

260 ''' 

261 return self._num 

262 

263 @Property_RO 

264 def polyline(self): 

265 '''Is this perimeter only (C{bool}), area NAN? 

266 ''' 

267 return self._Area is None 

268 

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)) 

277 

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) 

298 

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 

311 

312 Clear = Reset 

313 

314 def TestEdge(self, azi, s, **reverse_sign_polar): 

315 '''Compute the properties for a tentative, additional edge 

316 

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}. 

321 

322 @return: L{Area3Tuple}C{(number, perimeter, area)}, with C{perimeter} and 

323 C{area} both C{NAN} for insuffcient C{number} of points. 

324 ''' 

325 r, n = None, self.num + 1 

326 if n < 2: # raise GeodesicError(num=self.num) 

327 a = p = NAN # like .test_Planimeter19 

328 else: 

329 p = self._Peri.Sum(s) 

330 if self.polyline: 

331 a = NAN 

332 else: 

333 d = self._Direct(azi, s) 

334 r = self._Inverse(d.lat2, d.lon2, self.lat0, self.lon0) 

335 a = self._reduced(d.S12 + r.S12, d.xing + r.xing, n, **reverse_sign_polar) 

336 p += r.s12 

337 if self.verbose: # PYCHOK no cover 

338 self._print(n, p, a, r, azi=azi, s=s) 

339 return Area3Tuple(n, p, a) 

340 

341 def TestPoint(self, lat, lon, **reverse_sign_polar): 

342 '''Compute the properties for a tentative, additional vertex 

343 

344 @arg lat: Latitude of the point (C{degrees}). 

345 @arg lon: Longitude of the point (C{degrees}). 

346 @kwarg reverse_sign_polar: Optional C{B{reverse}=False}, C{B{sign}=True} and 

347 C{B{polar}=False} keyword arguments, see method L{Compute}. 

348 

349 @return: L{Area3Tuple}C{(number, perimeter, area)}. 

350 ''' 

351 r, n = None, self.num + 1 

352 if n < 2: 

353 p = _0_0 

354 a = NAN if self.polyline else p 

355 else: 

356 i = self._Inverse(self.lat1, self.lon1, lat, lon) 

357 p = self._Peri.Sum(i.s12) 

358 if self._Area: 

359 r = self._Inverse(lat, lon, self.lat0, self.lon0) 

360 a = self._reduced(i.S12 + r.S12, i.xing + r.xing, n, **reverse_sign_polar) 

361 p += r.s12 

362 else: 

363 a = NAN 

364 if self.verbose: # PYCHOK no cover 

365 self._print(n, p, a, r, lat=lat, lon=lon) 

366 return Area3Tuple(n, p, a) 

367 

368 def toStr(self, prec=6, sep=_COMMASPACE_, **unused): # PYCHOK signature 

369 '''Return this C{GeodesicExactArea} as string. 

370 

371 @kwarg prec: The C{float} precision, number of decimal digits (0..9). 

372 Trailing zero decimals are stripped for B{C{prec}} values 

373 of 1 and above, but kept for negative B{C{prec}} values. 

374 @kwarg sep: Separator to join (C{str}). 

375 

376 @return: Area items (C{str}). 

377 ''' 

378 n, p, a = self.Compute() 

379 d = dict(geodesic=self.geodesic, num=n, area=a, 

380 perimeter=p, polyline=self.polyline) 

381 return sep.join(pairs(d, prec=prec)) 

382 

383 @Property 

384 def verbose(self): 

385 '''Get the C{verbose} option (C{bool}). 

386 ''' 

387 return self._verbose 

388 

389 @verbose.setter # PYCHOK setter! 

390 def verbose(self, verbose): # PYCHOK no cover 

391 '''Set the C{verbose} option (C{bool}) to print 

392 a message after each method invokation. 

393 ''' 

394 self._verbose = bool(verbose) 

395 

396 

397class PolygonArea(GeodesicAreaExact): 

398 '''For C{geographiclib} compatibility, sub-class of L{GeodesicAreaExact}. 

399 ''' 

400 def __init__(self, earth, polyline=False, **name): 

401 '''New L{PolygonArea} instance. 

402 

403 @arg earth: A geodesic (L{GeodesicExact}, I{wrapped} 

404 C{Geodesic} or L{GeodesicSolve}). 

405 @kwarg polyline: If C{True}, compute the perimeter only, otherwise 

406 perimeter and area (C{bool}). 

407 @kwarg name: Optional C{B{name}=NN} (C{str}). 

408 

409 @raise GeodesicError: Invalid B{C{earth}}. 

410 ''' 

411 GeodesicAreaExact.__init__(self, earth, polyline=polyline, **name) 

412 

413 

414class _Accumulator(_NamedBase): 

415 '''Like C{math.fsum}, but allowing a running sum. 

416 

417 Original from I{Karney}'s U{geographiclib 

418 <https://PyPI.org/project/geographiclib>}C{.accumulator}, 

419 enhanced to return the current sum by most methods. 

420 ''' 

421 _n = 0 # len() 

422 _s = _t = _0_0 

423 

424 def __init__(self, y=0, **name): 

425 '''New L{_Accumulator}. 

426 

427 @kwarg y: Initial value (C{scalar}). 

428 @kwarg name: Optional C{B{name}=NN} (C{str}). 

429 ''' 

430 if isinstance(y, _Accumulator): 

431 self._s, self._t, self._n = y._s, y._t, 1 

432 elif y: 

433 self._s, self._n = float(y), 1 

434 if name: 

435 self.name = name 

436 

437 def Add(self, y): 

438 '''Add a value. 

439 

440 @return: Current C{sum}. 

441 ''' 

442 self._n += 1 

443 self._s, self._t, _ = _sum3(self._s, self._t, y) 

444 return self._s # current .Sum() 

445 

446 def Negate(self): 

447 '''Negate sum. 

448 

449 @return: Current C{sum}. 

450 ''' 

451 self._s = s = -self._s 

452 self._t = -self._t 

453 return s # current .Sum() 

454 

455 @property_RO 

456 def num(self): 

457 '''Get the current number of C{Add}itions (C{int}). 

458 ''' 

459 return self._n 

460 

461 def Remainder(self, y): 

462 '''Remainder on division by B{C{y}}. 

463 

464 @return: Remainder of C{sum} / B{C{y}}. 

465 ''' 

466 self._s = _remainder(self._s, y) 

467# self._t = _remainder(self._t, y) 

468 self._n = -1 

469 return self.Add(_0_0) 

470 

471 def Reset(self, y=0): 

472 '''Set value from argument. 

473 ''' 

474 self._n, self._s, self._t = 0, float(y), _0_0 

475 

476 Set = Reset 

477 

478 def Sum(self, y=0): 

479 '''Return C{sum + B{y}}. 

480 

481 @note: B{C{y}} is included in the returned 

482 result, but I{not} accumulated. 

483 ''' 

484 if y: 

485 s = _Accumulator(self, name='_Sum') 

486 s.Add(y) 

487 else: 

488 s = self 

489 return s._s 

490 

491 def toStr(self, prec=6, sep=_COMMASPACE_, **unused): # PYCHOK signature 

492 '''Return this C{_Accumulator} as string. 

493 

494 @kwarg prec: The C{float} precision, number of decimal digits (0..9). 

495 Trailing zero decimals are stripped for B{C{prec}} values 

496 of 1 and above, but kept for negative B{C{prec}} values. 

497 @kwarg sep: Separator to join (C{str}). 

498 

499 @return: Accumulator (C{str}). 

500 ''' 

501 d = dict(n=self.num, s=self._s, t=self._t) 

502 return sep.join(pairs(d, prec=prec)) 

503 

504 

505__all__ += _ALL_DOCS(GeodesicAreaExact, PolygonArea) 

506 

507# **) MIT License 

508# 

509# Copyright (C) 2016-2025 -- mrJean1 at Gmail -- All Rights Reserved. 

510# 

511# Permission is hereby granted, free of charge, to any person obtaining a 

512# copy of this software and associated documentation files (the "Software"), 

513# to deal in the Software without restriction, including without limitation 

514# the rights to use, copy, modify, merge, publish, distribute, sublicense, 

515# and/or sell copies of the Software, and to permit persons to whom the 

516# Software is furnished to do so, subject to the following conditions: 

517# 

518# The above copyright notice and this permission notice shall be included 

519# in all copies or substantial portions of the Software. 

520# 

521# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 

522# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 

523# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 

524# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 

525# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 

526# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 

527# OTHER DEALINGS IN THE SOFTWARE.