Coverage for pygeodesy/rhumb/bases.py: 94%

373 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2025-04-25 13:15 -0400

1 

2# -*- coding: utf-8 -*- 

3 

4u'''(INTERNAL) base classes C{RhumbBase} and C{RhumbLineBase}, pure Python version of I{Karney}'s 

5C++ classes U{Rhumb<https://GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1Rhumb.html>} 

6and U{RhumbLine<https://GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1RhumbLine.html>} 

7from I{GeographicLib versions 2.0} and I{2.2} and I{Karney}'s C++ example U{Rhumb intersect 

8<https://SourceForge.net/p/geographiclib/discussion/1026620/thread/2ddc295e/>}. 

9 

10Class L{RhumbLineBase} has been enhanced with methods C{Intersecant2}, C{Intersection} and C{PlumbTo} 

11to iteratively find the intersection of a rhumb line and a circle or an other rhumb line, respectively 

12a perpendicular geodesic or other rhumb line. 

13 

14For more details, see the C++ U{GeographicLib<https://GeographicLib.SourceForge.io/C++/doc/index.html>} 

15documentation, especially the U{Class List<https://GeographicLib.SourceForge.io/C++/doc/annotated.html>}, 

16the background information on U{Rhumb lines<https://GeographicLib.SourceForge.io/C++/doc/rhumb.html>}, 

17the utily U{RhumbSolve<https://GeographicLib.SourceForge.io/C++/doc/RhumbSolve.1.html>} and U{Online 

18rhumb line calculations<https://GeographicLib.SourceForge.io/cgi-bin/RhumbSolve>}. 

19 

20Copyright (C) U{Charles Karney<mailto:Karney@Alum.MIT.edu>} (2014-2024) and licensed under the MIT/X11 

21License. For more information, see the U{GeographicLib<https://GeographicLib.SourceForge.io>} documentation. 

22''' 

23# make sure int/int division yields float quotient 

24from __future__ import division as _; del _ # PYCHOK semicolon 

25 

26from pygeodesy.basics import _copysign, itemsorted, unsigned0, _xinstanceof 

27from pygeodesy.constants import EPS, EPS0, EPS1, INT0, NAN, _over, \ 

28 _EPSqrt as _TOL, _0_0, _0_01, _1_0, _90_0 

29from pygeodesy.datums import Datum, _earth_datum, _spherical_datum, _WGS84 

30from pygeodesy.errors import IntersectionError, RhumbError, _xdatum, \ 

31 _xkwds, _xkwds_pop2, _Xorder 

32# from pygeodesy.etm import ExactTransverseMercator # _MODS 

33from pygeodesy.fmath import euclid, favg, sqrt_a, Fsum 

34# from pygeodesy.formy import opposing # _MODS 

35# from pygeodesy.fsums import Fsum # from .fmath 

36from pygeodesy.internals import typename, _under 

37from pygeodesy.interns import NN, _coincident_, _COMMASPACE_, _Dash, \ 

38 _DMAIN_, _parallel_, _too_ 

39from pygeodesy.karney import _atan2d, Caps, _CapsBase, _diff182, _fix90, \ 

40 _norm180, GDict 

41# from pygeodesy.ktm import KTransverseMercator, _AlpCoeffs # _MODS 

42from pygeodesy.lazily import _ALL_DOCS, _ALL_MODS as _MODS 

43from pygeodesy.namedTuples import Distance2Tuple, LatLon2Tuple 

44from pygeodesy.props import deprecated_method, Property, Property_RO, \ 

45 property_RO, _update_all 

46from pygeodesy.streprs import Fmt, pairs 

47from pygeodesy.units import Float_, Lat, Lon, Meter, Radius_, Int # PYCHOK shared 

48from pygeodesy.utily import acos1, _azireversed, _loneg, sincos2d, sincos2d_, \ 

49 _unrollon, _Wrap 

50from pygeodesy.vector3d import _intersect3d3, Vector3d # in .Intersection below 

51 

52from math import cos, fabs 

53 

54__all__ = () 

55__version__ = '25.04.14' 

56 

57_anti_ = _Dash('anti') 

58_rls = [] # instances of C{RbumbLine...} to be updated 

59_TRIPS = 129 # .Intersection, .PlumbTo, 19+ 

60 

61 

62class _Lat(Lat): 

63 '''(INTERNAL) Latitude B{C{lat}}. 

64 ''' 

65 def __init__(self, *lat, **Error_name): 

66 kwds = _xkwds(Error_name, clip=0, Error=RhumbError) 

67 Lat.__new__(_Lat, *lat, **kwds) 

68 

69 

70class _Lon(Lon): 

71 '''(INTERNAL) Longitude B{C{lon}}. 

72 ''' 

73 def __init__(self, *lon, **Error_name): 

74 kwds = _xkwds(Error_name, clip=0, Error=RhumbError) 

75 Lon.__new__(_Lon, *lon, **kwds) 

76 

77 

78def _update_all_rls(r): 

79 '''(INTERNAL) Zap cached/memoized C{Property[_RO]}s 

80 of any C{RhumbLine} instances tied to the given 

81 C{Rhumb} instance B{C{r}}. 

82 ''' 

83 # _xinstanceof(_MODS.rhumb.aux_.RhumbAux, _MODS.rhumb.ekx.Rhumb, r=r) 

84 _update_all(r) 

85 for rl in _rls: # PYCHOK use weakref? 

86 if rl._rhumb is r: 

87 _update_all(rl) 

88 

89 

90class RhumbBase(_CapsBase): 

91 '''(INTERNAL) Base class for C{rhumb.aux_.RhumbAux} and C{rhumb.ekx.Rhumb}. 

92 ''' 

93 _datum = _WGS84 

94 _exact = True 

95 _f_max = _0_01 

96 _mTM = 6 # see .TMorder 

97 

98 def __init__(self, a_earth, f, exact, TMorder_name): 

99 '''New C{RhumbAux} or C{Rhumb}. 

100 ''' 

101 if TMorder_name: 

102 M = self._mTM 

103 m, name = _xkwds_pop2(TMorder_name, TMorder=M) 

104 if m != M: 

105 self.TMorder = m 

106 else: 

107 name = {} 

108 _earth_datum(self, a_earth, f=f, **name) 

109 if not exact: 

110 self.exact = False 

111 if name: 

112 self.name = name 

113 

114 @Property_RO 

115 def a(self): 

116 '''Get the C{ellipsoid}'s equatorial radius, semi-axis (C{meter}). 

117 ''' 

118 return self.ellipsoid.a 

119 

120 equatoradius = a 

121 

122 def ArcDirect(self, lat1, lon1, azi12, a12, outmask=Caps.LATITUDE_LONGITUDE): 

123 '''Solve the I{direct rhumb} problem, optionally with area. 

124 

125 @arg lat1: Latitude of the first point (C{degrees90}). 

126 @arg lon1: Longitude of the first point (C{degrees180}). 

127 @arg azi12: Azimuth of the rhumb line (compass C{degrees}). 

128 @arg a12: Angle along the rhumb line from the given to the 

129 destination point (C{degrees}), can be negative. 

130 

131 @return: L{GDict} with 2 up to 8 items C{lat2, lon2, a12, S12, 

132 lat1, lon1, azi12, s12} with the destination point's 

133 latitude C{lat2} and longitude C{lon2} in C{degrees}, 

134 the rhumb angle C{a12} in C{degrees} and area C{S12} 

135 under the rhumb line in C{meter} I{squared}. 

136 

137 @raise ImportError: Package C{numpy} not found or not installed, 

138 only required for area C{S12} when C{B{exact} 

139 is True} and L{RhumbAux}. 

140 

141 @note: If B{C{a12}} is large enough that the rhumb line crosses 

142 a pole, the longitude of the second point is indeterminate 

143 and C{NAN} is returned for C{lon2} and area C{S12}. 

144 

145 @note: If the given point is a pole, the cosine of its latitude is 

146 taken to be C{sqrt(L{EPS})}. This position is extremely 

147 close to the actual pole and allows the calculation to be 

148 carried out in finite terms. 

149 ''' 

150 s12 = a12 * self._mpd 

151 return self._DirectRhumb(lat1, lon1, azi12, a12, s12, outmask) 

152 

153 @Property_RO 

154 def b(self): 

155 '''Get the C{ellipsoid}'s polar radius, semi-axis (C{meter}). 

156 ''' 

157 return self.ellipsoid.b 

158 

159 polaradius = b 

160 

161 @property 

162 def datum(self): 

163 '''Get this rhumb's datum (L{Datum}). 

164 ''' 

165 return self._datum 

166 

167 @datum.setter # PYCHOK setter! 

168 def datum(self, datum): 

169 '''Set this rhumb's datum (L{Datum}). 

170 

171 @raise RhumbError: If C{abs(B{f}} exceeds non-zero C{f_max} and C{exact=False}. 

172 ''' 

173 _xinstanceof(Datum, datum=datum) 

174 if self._datum != datum: 

175 self._exactest(self.exact, datum.ellipsoid, self.f_max) 

176 _update_all_rls(self) 

177 self._datum = datum 

178 

179 def _Direct(self, ll1, azi12, s12, **outmask): 

180 '''(INTERNAL) Short-cut version, see .latlonBase.rhumb.... 

181 ''' 

182 return self.Direct(ll1.lat, ll1.lon, azi12, s12, **outmask) 

183 

184 def Direct(self, lat1, lon1, azi12, s12, outmask=Caps.LATITUDE_LONGITUDE): 

185 '''Solve the I{direct rhumb} problem, optionally with area. 

186 

187 @arg lat1: Latitude of the first point (C{degrees90}). 

188 @arg lon1: Longitude of the first point (C{degrees180}). 

189 @arg azi12: Azimuth of the rhumb line (compass C{degrees}). 

190 @arg s12: Distance along the rhumb line from the given to 

191 the destination point (C{meter}), can be negative. 

192 

193 @return: L{GDict} with 2 up to 8 items C{lat2, lon2, a12, S12, 

194 lat1, lon1, azi12, s12} with the destination point's 

195 latitude C{lat2} and longitude C{lon2} in C{degrees}, 

196 the rhumb angle C{a12} in C{degrees} and area C{S12} 

197 under the rhumb line in C{meter} I{squared}. 

198 

199 @raise ImportError: Package C{numpy} not found or not installed, 

200 only required for area C{S12} when C{B{exact} 

201 is True} and L{RhumbAux}. 

202 

203 @note: If B{C{s12}} is large enough that the rhumb line crosses 

204 a pole, the longitude of the second point is indeterminate 

205 and C{NAN} is returned for C{lon2} and area C{S12}. 

206 

207 @note: If the given point is a pole, the cosine of its latitude is 

208 taken to be C{sqrt(L{EPS})}. This position is extremely 

209 close to the actual pole and allows the calculation to be 

210 carried out in finite terms. 

211 ''' 

212 a12 = _over(s12, self._mpd) 

213 return self._DirectRhumb(lat1, lon1, azi12, a12, s12, outmask) 

214 

215 def Direct8(self, lat1, lon1, azi12, s12, outmask=Caps.LATITUDE_LONGITUDE_AREA): 

216 '''Like method L{Rhumb.Direct} but returning a L{Rhumb8Tuple} with area C{S12}. 

217 ''' 

218 return self.Direct(lat1, lon1, azi12, s12, outmask=outmask).toRhumb8Tuple() 

219 

220 def _DirectLine(self, ll1, azi12, **caps_name): 

221 '''(INTERNAL) Short-cut version, see .latlonBase. 

222 ''' 

223 return self.DirectLine(ll1.lat, ll1.lon, azi12, **caps_name) 

224 

225 def DirectLine(self, lat1, lon1, azi12, **caps_name): 

226 '''Define a C{RhumbLine} in terms of the I{direct} rhumb 

227 problem to compute several points on a single rhumb line. 

228 

229 @arg lat1: Latitude of the first point (C{degrees90}). 

230 @arg lon1: Longitude of the first point (C{degrees180}). 

231 @arg azi12: Azimuth of the rhumb line (compass C{degrees}). 

232 @kwarg caps_name: Optional keyword arguments C{B{name}=NN} and 

233 C{B{caps}=Caps.STANDARD}, a bit-or'ed combination of 

234 L{Caps<pygeodesy.karney.Caps>} values specifying the 

235 required capabilities. Include C{Caps.LINE_OFF} if 

236 updates to the B{C{rhumb}} should I{not be reflected} 

237 in this rhumb line. 

238 

239 @return: A C{RhumbLine...} instance and invoke its method 

240 C{.Position} to compute each point. 

241 

242 @note: Updates to this rhumb are reflected in the returned 

243 rhumb line, unless C{B{caps} |= Caps.LINE_OFF}. 

244 ''' 

245 return self._RhumbLine(self, lat1, lon1, azi12, **caps_name) 

246 

247 Line = DirectLine # synonyms 

248 

249 def _DirectRhumb(self, lat1, lon1, azi12, a12, s12, outmask): 

250 '''(INTERNAL) See methods C{.ArcDirect} and C{.Direct}. 

251 ''' 

252 rl = self._RhumbLine(self, lat1, lon1, azi12, caps=Caps.LINE_OFF, 

253 name=self.name) 

254 return rl._Position(a12, s12, outmask | self._debug) # lat2, lon2, S12 

255 

256 @Property 

257 def ellipsoid(self): 

258 '''Get this rhumb's ellipsoid (L{Ellipsoid}). 

259 ''' 

260 return self.datum.ellipsoid 

261 

262 @ellipsoid.setter # PYCHOK setter! 

263 def ellipsoid(self, a_earth_f): 

264 '''Set this rhumb's ellipsoid (L{Ellipsoid}, L{Ellipsoid2}, L{Datum} or 

265 L{a_f2Tuple}) or (equatorial) radius and flattening (2-tuple C{(a, f)}). 

266 

267 @raise RhumbError: If C{abs(B{f}} exceeds non-zero C{f_max} and C{exact=False}. 

268 ''' 

269 self.datum = _spherical_datum(a_earth_f, Error=RhumbError) 

270 

271 @Property 

272 def exact(self): 

273 '''Get the I{exact} option (C{bool}). 

274 ''' 

275 return self._exact 

276 

277 @exact.setter # PYCHOK setter! 

278 def exact(self, exact): 

279 '''Set the I{exact} option (C{bool}). If C{True}, use I{exact} rhumb 

280 expressions, otherwise a series expansion (accurate for oblate or 

281 prolate ellipsoids with C{abs(flattening)} below C{f_max}. 

282 

283 @raise RhumbError: If C{B{exact}=False} and C{abs(flattening}) 

284 exceeds non-zero C{f_max}. 

285 

286 @see: Option U{B{-s}<https://GeographicLib.SourceForge.io/C++/doc/RhumbSolve.1.html>} 

287 and U{ACCURACY<https://GeographicLib.SourceForge.io/C++/doc/RhumbSolve.1.html#ACCURACY>}. 

288 ''' 

289 x = bool(exact) 

290 if self._exact != x: 

291 self._exactest(x, self.ellipsoid, self.f_max) 

292 _update_all_rls(self) 

293 self._exact = x 

294 

295 def _exactest(self, exact, ellipsoid, f_max): 

296 # Helper for property setters C{ellipsoid}, C{exact} and C{f_max} 

297 if fabs(ellipsoid.f) > f_max > 0 and not exact: 

298 raise RhumbError(exact=exact, f=ellipsoid.f, f_max=f_max) 

299 

300 @Property_RO 

301 def f(self): 

302 '''Get the C{ellipsoid}'s flattening (C{float}). 

303 ''' 

304 return self.ellipsoid.f 

305 

306 flattening = f 

307 

308 @property 

309 def f_max(self): 

310 '''Get the I{max.} flattening (C{float}). 

311 ''' 

312 return self._f_max 

313 

314 @f_max.setter # PYCHOK setter! 

315 def f_max(self, f_max): # PYCHOK no cover 

316 '''Set the I{max.} flattening, not to exceed (C{float}). 

317 

318 @raise RhumbError: If C{exact=False} and C{abs(flattening}) 

319 exceeds non-zero C{f_max}. 

320 ''' 

321 f = Float_(f_max=f_max, low=_0_0, high=EPS1) 

322 if self._f_max != f: 

323 self._exactest(self.exact, self.ellipsoid, f) 

324 self._f_max = f 

325 

326 def _Inverse(self, ll1, ll2, wrap, **outmask): 

327 '''(INTERNAL) Short-cut version, see .latlonBase.rhumb.... 

328 ''' 

329 if wrap: 

330 ll2 = _unrollon(ll1, _Wrap.point(ll2)) 

331 return self.Inverse(ll1.lat, ll1.lon, ll2.lat, ll2.lon, **outmask) 

332 

333 def Inverse(self, lat1, lon1, lat2, lon2, outmask=Caps.AZIMUTH_DISTANCE): 

334 '''Solve the I{inverse rhumb} problem. 

335 

336 @arg lat1: Latitude of the first point (C{degrees90}). 

337 @arg lon1: Longitude of the first point (C{degrees180}). 

338 @arg lat2: Latitude of the second point (C{degrees90}). 

339 @arg lon2: Longitude of the second point (C{degrees180}). 

340 

341 @return: L{GDict} with 4 to 9 items C{lat1, lon1, lat2, lon2, 

342 azi12, azi21, s12, a12, S12}, the rhumb line's azimuth 

343 C{azi12} and I{reverse} azimuth C{azi21}, both in 

344 compass C{degrees} between C{-180} and C{+180}, the 

345 rhumb distance C{s12} and rhumb angle C{a12} between 

346 both points in C{meter} respectively C{degrees} and 

347 the area C{S12} under the rhumb line in C{meter} 

348 I{squared}. 

349 

350 @raise ImportError: Package C{numpy} not found or not installed, 

351 only required for L{RhumbAux} area C{S12} 

352 when C{B{exact} is True}. 

353 

354 @note: The shortest rhumb line is found. If the end points are 

355 on opposite meridians, there are two shortest rhumb lines 

356 and the East-going one is chosen. 

357 

358 @note: If either point is a pole, the cosine of its latitude is 

359 taken to be C{sqrt(L{EPS})}. This position is extremely 

360 close to the actual pole and allows the calculation to be 

361 carried out in finite terms. 

362 ''' 

363 r = GDict(lat1=lat1, lon1=lon1, lat2=lat2, lon2=lon2, name=self.name) 

364 Cs = Caps 

365 if (outmask & Cs.AZIMUTH_DISTANCE_AREA): 

366 lon12, _ = _diff182(lon1, lon2, K_2_0=True) 

367 y, x, s1, s2 = self._Inverse4(lon12, r, outmask) 

368 if (outmask & Cs.AZIMUTH): 

369 z = _atan2d(y, x) 

370 r.set_(azi12=z, azi21=_azireversed(z)) 

371 if (outmask & Cs.AREA): 

372 S12 = self._S12d(s1, s2, lon12) 

373 r.set_(S12=unsigned0(S12)) # like .gx 

374 return r 

375 

376 def _Inverse4(self, lon12, r, outmask): # PYCHOK no cover 

377 '''(INTERNAL) I{Must be overloaded}.''' 

378 self._notOverloaded(lon12, r, Caps.toStr(outmask)) # underOK=True 

379 

380 def Inverse8(self, lat1, lon1, azi12, s12, outmask=Caps.AZIMUTH_DISTANCE_AREA): 

381 '''Like method L{Rhumb.Inverse} but returning a L{Rhumb8Tuple} with area C{S12}. 

382 ''' 

383 return self.Inverse(lat1, lon1, azi12, s12, outmask=outmask).toRhumb8Tuple() 

384 

385 def _InverseLine(self, ll1, ll2, wrap, **caps_name): 

386 '''(INTERNAL) Short-cut version, see .latlonBase. 

387 ''' 

388 if wrap: 

389 ll2 = _unrollon(ll1, _Wrap.point(ll2)) 

390 return self.InverseLine(ll1.lat, ll1.lon, ll2.lat, ll2.lon, **caps_name) 

391 

392 def InverseLine(self, lat1, lon1, lat2, lon2, **caps_name): 

393 '''Define a C{RhumbLine} in terms of the I{inverse} rhumb problem. 

394 

395 @arg lat1: Latitude of the first point (C{degrees90}). 

396 @arg lon1: Longitude of the first point (C{degrees180}). 

397 @arg lat2: Latitude of the second point (C{degrees90}). 

398 @arg lon2: Longitude of the second point (C{degrees180}). 

399 @kwarg caps_name: Optional keyword arguments C{B{name}=NN} and 

400 C{B{caps}=Caps.STANDARD}, a bit-or'ed combination of 

401 L{Caps<pygeodesy.karney.Caps>} values specifying the 

402 required capabilities. Include C{Caps.LINE_OFF} if 

403 updates to the B{C{rhumb}} should I{not be reflected} 

404 in this rhumb line. 

405 

406 @return: A C{RhumbLine...} instance and invoke its method 

407 C{ArcPosition} or C{Position} to compute points. 

408 

409 @note: Updates to this rhumb are reflected in the returned 

410 rhumb line, unless C{B{caps} |= Caps.LINE_OFF}. 

411 ''' 

412 r = self.Inverse(lat1, lon1, lat2, lon2, outmask=Caps.AZIMUTH) 

413 return self._RhumbLine(self, lat1, lon1, r.azi12, **caps_name) 

414 

415 @Property_RO 

416 def _mpd(self): # PYCHOK no cover 

417 '''(INTERNAL) I{Must be overloaded}.''' 

418 _MODS.named.notOverloaded(self) 

419 

420 @property_RO 

421 def RAorder(self): 

422 '''Get the I{Rhumb Area} order, C{None} always. 

423 ''' 

424 return None 

425 

426 @property_RO 

427 def _RhumbLine(self): # PYCHOK no cover 

428 '''(INTERNAL) I{Must be overloaded}.''' 

429 self._notOverloaded(underOK=True) 

430 

431 def _S12d(self, s1, s2, lon): # PYCHOK no cover 

432 '''(INTERNAL) I{Must be overloaded}.''' 

433 self._notOverloaded(s1, s2, lon) # underOK=True 

434 

435 @Property 

436 def TMorder(self): 

437 '''Get the L{KTransverseMercator} order (C{int}, 4, 5, 6, 7 or 8). 

438 ''' 

439 return self._mTM 

440 

441 @TMorder.setter # PYCHOK setter! 

442 def TMorder(self, order): 

443 '''Set the L{KTransverseMercator} order (C{int}, 4, 5, 6, 7 or 8). 

444 

445 @note: Setting C{TMorder} turns property C{exact} off, but only 

446 for L{Rhumb} instances. 

447 ''' 

448 m = _Xorder(_MODS.ktm._AlpCoeffs, RhumbError, TMorder=order) 

449 if self._mTM != m: 

450 _update_all_rls(self) 

451 self._mTM = m 

452 if self.exact and isinstance(self, _MODS.rhumb.ekx.Rhumb): 

453 self.exact = False 

454 

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

456 '''Return this C{Rhumb} as string. 

457 

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

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

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

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

462 

463 @return: Tuple items (C{str}). 

464 ''' 

465 d = dict(ellipsoid=self.ellipsoid, RAorder=self.RAorder, 

466 exact=self.exact, TMorder=self.TMorder) 

467 return sep.join(pairs(itemsorted(d, asorted=False), prec=prec)) 

468 

469 

470class RhumbLineBase(_CapsBase): 

471 '''(INTERNAL) Base class for C{rhumb.aux_.RhumbLineAux} and C{rhumb.ekx.RhumbLine}. 

472 ''' 

473 _azi12 = _0_0 

474 _calp = _1_0 

475# _caps = \ 

476# _debug = 0 

477# _lat1 = \ 

478# _lon1 = \ 

479# _lon12 = _0_0 

480 _Rhumb = RhumbBase # compatible C{Rhumb} class 

481 _rhumb = None # C{Rhumb} instance 

482 _salp = \ 

483 _talp = _0_0 

484 

485 def __init__(self, rhumb, lat1, lon1, azi12, caps=Caps.STANDARD, name=NN): 

486 '''New C{RhumbLine} or C{RhumbLineAux}. 

487 ''' 

488 _xinstanceof(self._Rhumb, rhumb=rhumb) 

489 

490 self._lat1 = _Lat(lat1=_fix90(lat1)) 

491 self._lon1 = _Lon(lon1= lon1) 

492 self._lon12 = _norm180(self._lon1) 

493 if azi12: # non-zero, non-None 

494 self.azi12 = _norm180(azi12) 

495 

496 n = name or rhumb.name 

497 if n: 

498 self.name=n 

499 

500 self._caps = caps 

501 self._debug |= (caps | rhumb._debug) & Caps._DEBUG_DIRECT_LINE 

502 if (caps & Caps.LINE_OFF): # copy to avoid updates 

503 self._rhumb = rhumb.copy(deep=False, name=_under(rhumb.name)) 

504 else: 

505 self._rhumb = rhumb 

506 _rls.append(self) 

507 

508 def __del__(self): # XXX use weakref? 

509 if _rls: # may be empty or None 

510 try: # PYCHOK no cover 

511 _rls.remove(self) 

512 except (TypeError, ValueError): 

513 pass 

514 self._rhumb = None 

515 # _update_all(self) # throws TypeError during Python 2 cleanup 

516 

517 def ArcPosition(self, a12, outmask=Caps.LATITUDE_LONGITUDE): 

518 '''Compute a point at a given angular distance on this rhumb line. 

519 

520 @arg a12: The angle along this rhumb line from its origin to the 

521 point (C{degrees}), can be negative. 

522 @kwarg outmask: Bit-or'ed combination of L{Caps<pygeodesy.karney.Caps>} 

523 values specifying the quantities to be returned. 

524 

525 @return: L{GDict} with 4 to 8 items C{azi12, a12, s12, S12, lat2, 

526 lon2, lat1, lon1} with latitude C{lat2} and longitude 

527 C{lon2} of the point in C{degrees}, the rhumb distance 

528 C{s12} in C{meter} from the start point of and the area 

529 C{S12} under this rhumb line in C{meter} I{squared}. 

530 

531 @raise ImportError: Package C{numpy} not found or not installed, 

532 only required for L{RhumbLineAux} area C{S12} 

533 when C{B{exact} is True}. 

534 

535 @note: If B{C{a12}} is large enough that the rhumb line crosses a 

536 pole, the longitude of the second point is indeterminate and 

537 C{NAN} is returned for C{lon2} and area C{S12}. 

538 

539 If the first point is a pole, the cosine of its latitude is 

540 taken to be C{sqrt(L{EPS})}. This position is extremely 

541 close to the actual pole and allows the calculation to be 

542 carried out in finite terms. 

543 ''' 

544 return self._Position(a12, self.degrees2m(a12), outmask) 

545 

546 @Property 

547 def azi12(self): 

548 '''Get this rhumb line's I{azimuth} (compass C{degrees}). 

549 ''' 

550 return self._azi12 

551 

552 @azi12.setter # PYCHOK setter! 

553 def azi12(self, azi12): 

554 '''Set this rhumb line's I{azimuth} (compass C{degrees}). 

555 ''' 

556 z = _norm180(azi12) 

557 if self._azi12 != z: 

558 if self._rhumb: 

559 _update_all(self) 

560 self._azi12 = z 

561 self._salp, self._calp = t = sincos2d(z) # no NEG0 

562 self._talp = _over(*t) 

563 

564 @property_RO 

565 def azi12_sincos2(self): # PYCHOK no cover 

566 '''Get the sine and cosine of this rhumb line's I{azimuth} (2-tuple C{(sin, cos)}). 

567 ''' 

568 return self._scalp, self._calp 

569 

570 @property_RO 

571 def datum(self): 

572 '''Get this rhumb line's datum (L{Datum}). 

573 ''' 

574 return self.rhumb.datum 

575 

576 def degrees2m(self, angle): 

577 '''Convert an angular distance along this rhumb line to C{meter}. 

578 

579 @arg angle: Angular distance (C{degrees}). 

580 

581 @return: Distance (C{meter}). 

582 ''' 

583 return float(angle) * self.rhumb._mpd 

584 

585 @deprecated_method 

586 def distance2(self, lat, lon): # PYCHOK no cover 

587 '''DEPRECATED on 23.09.23, use method L{RhumbLineAux.Inverse} or L{RhumbLine.Inverse}. 

588 

589 @return: A L{Distance2Tuple}C{(distance, initial)} with the C{distance} 

590 in C{meter} and C{initial} bearing (azimuth) in C{degrees}. 

591 ''' 

592 r = self.Inverse(lat, lon) 

593 return Distance2Tuple(r.s12, r.azi12) 

594 

595 @property_RO 

596 def ellipsoid(self): 

597 '''Get this rhumb line's ellipsoid (L{Ellipsoid}). 

598 ''' 

599 return self.rhumb.ellipsoid 

600 

601 @property_RO 

602 def exact(self): 

603 '''Get this rhumb line's I{exact} option (C{bool}). 

604 ''' 

605 return self.rhumb.exact 

606 

607 def Intersecant2(self, lat0, lon0, radius, napier=True, **tol_eps): 

608 '''Compute the intersection(s) of this rhumb line and a circle. 

609 

610 @arg lat0: Latitude of the circle center (C{degrees}). 

611 @arg lon0: Longitude of the circle center (C{degrees}). 

612 @arg radius: Radius of the circle (C{meter}, conventionally). 

613 @kwarg napier: If C{True}, apply I{Napier}'s spherical triangle 

614 instead of planar trigonometry (C{bool}). 

615 @kwarg tol_eps: Optional keyword arguments, see method 

616 method L{Intersection} for further details. 

617 

618 @return: 2-Tuple C{(P, Q)} with both intersections (representing 

619 a rhumb chord), each a L{GDict} from method L{Intersection} 

620 extended to 18 items by C{lat3, lon3, azi03, a03, s03} 

621 with azimuth C{azi03} of, distance C{a03} in C{degrees} 

622 and C{s03} in C{meter} along the rhumb line from the circle 

623 C{lat0, lon0} to the chord center C{lat3, lon3}. If this 

624 rhumb line is tangential to the circle, both points 

625 are the same L{GDict} instance with distances C{s02} and 

626 C{s03} near-equal to the B{C{radius}}. 

627 

628 @raise IntersectionError: The circle and this rhumb line 

629 do not intersect. 

630 

631 @raise UnitError: Invalid B{C{radius}}. 

632 ''' 

633 r = Radius_(radius) 

634 p = q = self.PlumbTo(lat0, lon0, exact=None, **tol_eps) 

635 a = q.s02 

636 t = dict(lat3=q.lat2, lon3=q.lon2, azi03=q.azi02, a03=q.a02, s03=a) 

637 if a < r: 

638 t.update(iteration=q.iteration, lat0=q.lat1, lon0=q.lon1, # or lat0, lon0 

639 name=typename(self.Intersecant2, self.name)) 

640 if fabs(a) < EPS0: # coincident centers 

641 d, h = _0_0, r 

642 else: 

643 d = q.s12 

644 if napier: # Napier rule (R1) cos(b) = cos(c) / cos(a) 

645 # <https://WikiPedia.org/wiki/Spherical_trigonometry> 

646 m = self.rhumb._mpr 

647 h = (acos1(cos(r / m) / cos(a / m)) * m) if m else _0_0 

648 else: 

649 h = _copysign(sqrt_a(r, a), a) 

650 p = q = self.Position(d + h).set_(**t) 

651 if h: 

652 q = self.Position(d - h).set_(**t) 

653 elif a > r: 

654 t = _too_(Fmt.distant(a)) 

655 raise IntersectionError(self, lat0, lon0, radius, 

656 txt=t, **tol_eps) 

657 else: # tangential 

658 q.set_(**t) # == p.set(_**t) 

659 return p, q 

660 

661 @deprecated_method 

662 def intersection2(self, other, **tol_eps): # PYCHOK no cover 

663 '''DEPRECATED on 23.10.10, use method L{Intersection}.''' 

664 p = self.Intersection(other, **tol_eps) 

665 r = LatLon2Tuple(p.lat2, p.lon2, name=typename(self.intersection2)) 

666 r._iteration = p.iteration 

667 return r 

668 

669 def Intersection(self, other, tol=_TOL, **eps): 

670 '''I{Iteratively} find the intersection of this and an other rhumb line. 

671 

672 @arg other: The other rhumb line (C{RhumbLine}). 

673 @kwarg tol: Tolerance for longitudinal convergence and parallel 

674 error (C{degrees}). 

675 @kwarg eps: Tolerance for L{pygeodesy.intersection3d3} (C{EPS}). 

676 

677 @return: The intersection point, a L{Position}-like L{GDict} with 

678 13 items C{lat1, lon1, azi12, a12, s12, lat2, lon2, lat0, 

679 lon0, azi02, a02, s02, at} with the rhumb angle C{a02} 

680 and rhumb distance C{s02} between the start point C{lat0, 

681 lon0} of the B{C{other}} rhumb line and the intersection 

682 C{lat2, lon2}, the azimuth C{azi02} of the B{C{other}} 

683 rhumb line and the angle C{at} between both rhumb lines. 

684 See method L{Position} for further details. 

685 

686 @raise IntersectionError: No convergence for this B{C{tol}} or 

687 no intersection for an other reason. 

688 

689 @see: Methods C{distance2} and C{PlumbTo} and function 

690 L{pygeodesy.intersection3d3}. 

691 

692 @note: Each iteration involves a round trip to this rhumb line's 

693 L{ExactTransverseMercator} or L{KTransverseMercator} 

694 projection and function L{pygeodesy.intersection3d3} in 

695 that domain. 

696 ''' 

697 _xinstanceof(RhumbLineBase, other=other) 

698 _xdatum(self.rhumb, other.rhumb, Error=RhumbError) 

699 try: 

700 if self.others(other) is self: 

701 raise ValueError(_coincident_) 

702 # make invariants and globals locals 

703 _s_3d, s_az = self._xTM3d, self.azi12 

704 _o_3d, o_az = other._xTM3d, other.azi12 

705 p = _MODS.formy.opposing(s_az, o_az, margin=tol) 

706 if p is not None: # == isbool(p) 

707 raise ValueError(_anti_(_parallel_) if p else _parallel_) 

708 _diff = euclid # approximate length 

709 _i3d3 = _intersect3d3 # NOT .vector3d.intersection3d3 

710 _LL2T = LatLon2Tuple 

711 _xTMr = self.xTM.reverse # ellipsoidal or spherical 

712 # use halfway point as initial estimate 

713 p = _LL2T(favg(self.lat1, other.lat1), 

714 favg(self.lon1, other.lon1)) 

715 for i in range(1, _TRIPS): 

716 v = _i3d3(_s_3d(p), s_az, # point + bearing 

717 _o_3d(p), o_az, useZ=False, **eps)[0] 

718 t = _xTMr(v.x, v.y, lon0=p.lon) # PYCHOK Reverse4Tuple 

719 d = _diff(t.lon - p.lon, t.lat) # PYCHOK t.lat + p.lat - p.lat 

720 p = _LL2T(t.lat + p.lat, t.lon) # PYCHOK t.lon + p.lon = lon0 

721 if d < tol: # 19+ trips 

722 break 

723 else: 

724 raise ValueError(Fmt.no_convergence(d, tol)) 

725 

726 P = GDict(lat1=self.lat1, lat2=p.lat, lat0=other.lat1, 

727 lon1=self.lon1, lon2=p.lon, lon0=other.lon1, 

728 name=typename(self.Intersection, self.name)) 

729 r = self.Inverse(p.lat, p.lon, outmask=Caps.DISTANCE) 

730 t = other.Inverse(p.lat, p.lon, outmask=Caps.DISTANCE) 

731 P.set_(azi12= self.azi12, a12=r.a12, s12=r.s12, 

732 azi02=other.azi12, a02=t.a12, s02=t.s12, 

733 at=other.azi12 - self.azi12, iteration=i) 

734 except Exception as x: 

735 raise IntersectionError(self, other, tol=tol, 

736 eps=eps, cause=x) 

737 return P 

738 

739 def Inverse(self, lat2, lon2, wrap=False, **outmask): 

740 '''Return the rhumb angle, distance, azimuth, I{reverse} azimuth, etc. of 

741 a rhumb line between the given point and this rhumb line's start point. 

742 

743 @arg lat2: Latitude of the point (C{degrees}). 

744 @arg lon2: Longitude of the points (C{degrees}). 

745 @kwarg wrap: If C{True}, wrap or I{normalize} and unroll B{C{lat2}} 

746 and B{C{lon2}} (C{bool}). 

747 

748 @return: L{GDict} with 8 items C{a12, s12, azi12, azi21, lat1, lon1, 

749 lat2, lon2}, the rhumb angle C{a12} and rhumb distance C{s12} 

750 between both points in C{degrees} respectively C{meter}, the 

751 rhumb line's azimuth C{azi12} and I{reverse} azimuth C{azi21} 

752 both in compass C{degrees} between C{-180} and C{+180}. 

753 ''' 

754 if wrap: 

755 _, lat2, lon2 = _Wrap.latlon3(self.lon1, _fix90(lat2), lon2, wrap) 

756 r = self.rhumb.Inverse(self.lat1, self.lon1, lat2, lon2, **outmask) 

757 return r 

758 

759 @Property_RO 

760 def isLoxodrome(self): 

761 '''Is this rhumb line a meridional (C{None}), a parallel 

762 (C{False}) or a C{True} loxodrome? 

763 

764 @see: I{Osborne's} U{2.5 Rumb lines and loxodromes 

765 <https://Zenodo.org/record/35392>}, page 37. 

766 ''' 

767 return bool(self._salp) if self._calp else None 

768 

769 @Property_RO 

770 def lat1(self): 

771 '''Get this rhumb line's latitude (C{degrees90}). 

772 ''' 

773 return self._lat1 

774 

775 @Property_RO 

776 def lon1(self): 

777 '''Get this rhumb line's longitude (C{degrees180}). 

778 ''' 

779 return self._lon1 

780 

781 @Property_RO 

782 def latlon1(self): 

783 '''Get this rhumb line's lat- and longitude (L{LatLon2Tuple}C{(lat, lon)}). 

784 ''' 

785 return LatLon2Tuple(self.lat1, self.lon1) 

786 

787 def m2degrees(self, distance): 

788 '''Convert a distance along this rhumb line to an angular distance. 

789 

790 @arg distance: Distance (C{meter}). 

791 

792 @return: Angular distance (C{degrees}). 

793 ''' 

794 return _over(float(distance), self.rhumb._mpd) 

795 

796 @property_RO 

797 def _mu1(self): # PYCHOK no cover 

798 '''(INTERNAL) I{Must be overloaded}.''' 

799 self._notOverloaded(underOK=True) 

800 

801 def _mu2lat(self, mu2): # PYCHOK no cover 

802 '''(INTERNAL) I{Must be overloaded}.''' 

803 self._notOverloaded(mu2) # underOK=True 

804 

805 @deprecated_method 

806 def nearestOn4(self, lat0, lon0, **exact_eps_est_tol): # PYCHOK no cover 

807 '''DEPRECATED on 23.10.10, use method L{PlumbTo}.''' 

808 P = self.PlumbTo(lat0, lon0, **exact_eps_est_tol) 

809 r = _MODS.deprecated.classes.NearestOn4Tuple(P.lat2, P.lon2, P.s12, P.azi02, 

810 name=typename(self.nearestOn4)) 

811 r._iteration = P.iteration 

812 return r 

813 

814 @deprecated_method 

815 def NearestOn(self, lat0, lon0, **exact_eps_est_tol): # PYCHOK no cover 

816 '''DEPRECATED on 23.10.30, use method L{PlumbTo}.''' 

817 return self.PlumbTo(lat0, lon0, **exact_eps_est_tol) 

818 

819 def PlumbTo(self, lat0, lon0, exact=None, eps=EPS, est=None, tol=_TOL): 

820 '''Compute the I{perpendicular} intersection of this rhumb line with a geodesic 

821 from the given point (transcoded from I{Karney}'s C++ U{rhumb-intercept 

822 <https://SourceForge.net/p/geographiclib/discussion/1026620/thread/2ddc295e/>}). 

823 

824 @arg lat0: Latitude of the point on the geodesic (C{degrees}). 

825 @arg lon0: Longitude of the point on the geodesic (C{degrees}). 

826 @kwarg exact: If C{None}, use a rhumb line perpendicular to this rhumb line, 

827 otherwise use an I{exact} C{Geodesic...} from the given point 

828 perpendicular to this rhumb line (C{bool} or C{Geodesic...}), 

829 see method L{geodesic_<pygeodesy.Ellipsoid.geodesic_>}. 

830 @kwarg eps: Optional tolerance (C{EPS}), used only if C{B{exact} is None}, 

831 see function L{intersection3d3<pygeodesy.intersection3d3>}. 

832 @kwarg est: Optionally, an initial estimate for the distance C{s12} of the 

833 intersection I{along} this rhumb line (C{meter}), used only if 

834 C{B{exact} is not None}. 

835 @kwarg tol: Longitudinal convergence tolerance (C{degrees}) or distance 

836 tolerance (C(meter)) when C{B{exact} is None}, respectively 

837 C{not None}. 

838 

839 @return: The intersection point on this rhumb line, a L{GDict} from method 

840 L{Intersection} if B{C{exact}=None}. If C{B{exact} is not None}, 

841 a L{Position}-like L{GDict} of 13 items C{azi12, a12, s12, lat2, 

842 lat1, lat0, lon2, lon1, lon0, azi0, a02, s02, at} with distance 

843 C{a02} in C{degrees} and C{s02} in C{meter} between the given point 

844 C{lat0, lon0} and the intersection C{lat2, lon2}, geodesic azimuth 

845 C{azi0} at the given point and the (perpendicular) angle C{at} 

846 between the geodesic and this rhumb line at the intersection. The 

847 I{geodesic} azimuth at the intersection is C{(at + azi12)}. See 

848 method L{Position} for further details. 

849 

850 @raise ImportError: I{Karney}'s U{geographiclib 

851 <https://PyPI.org/project/geographiclib>} 

852 package not found or not installed. 

853 

854 @raise IntersectionError: No convergence for this B{C{eps}} or B{C{tol}} or 

855 no intersection for some other reason. 

856 

857 @see: Methods C{distance2}, C{Intersecant2} and C{Intersection} and function 

858 L{intersection3d3<pygeodesy.intersection3d3>}. 

859 ''' 

860 Cs, tol = Caps, Float_(tol=tol, low=EPS, high=None) 

861 

862# def _over(p, q): # see @note at method C{.Position} 

863# if p: 

864# p = (p / (q or _copysign(tol, q))) if isfinite(q) else NAN 

865# return p 

866 

867 if exact is None: 

868 z = _norm180(self.azi12 + _90_0) # perpendicular azimuth 

869 rl = RhumbLineBase(self.rhumb, lat0, lon0, z, caps=Cs.LINE_OFF) 

870 P = self.Intersection(rl, tol=tol, eps=eps) 

871 

872 else: # C{rhumb-intercept} 

873 E = self.ellipsoid 

874 _gI = E.geodesic_(exact=exact).Inverse 

875 gm = Cs.STANDARD | Cs._REDUCEDLENGTH_GEODESICSCALE # ^ Cs.DISTANCE_IN 

876 if est is None: # get an estimate from the "perpendicular" geodesic 

877 r = _gI(self.lat1, self.lon1, lat0, lon0, outmask=Cs.AZIMUTH_DISTANCE) 

878 d, _ = _diff182(r.azi2, self.azi12, K_2_0=True) 

879 _, s12 = sincos2d(d) 

880 s12 *= r.s12 # signed 

881 else: 

882 s12 = Meter(est=est) 

883 try: 

884 _abs = fabs 

885 _d2 = _diff182 

886 _ErT = E.rocPrimeVertical # aka rocTransverse 

887 _ovr = _over 

888 _S12 = Fsum(s12).fsum2f_ 

889 _scd = sincos2d_ 

890 for i in range(1, _TRIPS): # 9+, suffix 1 == C++ 2, 2 == C++ 3 

891 P = self.Position(s12) # outmask=Cs.LATITUDE_LONGITUDE 

892 r = _gI(lat0, lon0, P.lat2, P.lon2, outmask=gm) 

893 d, _ = _d2(self.azi12, r.azi2, K_2_0=True) 

894 s, c, s2, c2 = _scd(d, r.lat2) 

895 c2 *= _ErT(r.lat2) 

896 s *= _ovr(s2 * self._salp, c2) - _ovr(s * r.M21, r.m12) 

897 s12, t = _S12(c / s) # XXX _ovr? 

898 if _abs(t) < tol: # or _abs(c) < EPS 

899 break 

900 P.set_(azi0=r.azi1, a02=r.a12, s02=r.s12, # azi2=r.azi2, 

901 lat0=lat0, lon0=lon0, iteration=i, at=r.azi2 - self.azi12, 

902 name=typename(self.PlumbTo, self.name)) 

903 except Exception as x: # Fsum(NAN) Value-, ZeroDivisionError 

904 raise IntersectionError(lat0=lat0, lon0=lon0, tol=tol, exact=exact, 

905 eps=eps, est=est, iteration=i, cause=x) 

906 

907 return P 

908 

909 def Position(self, s12, outmask=Caps.LATITUDE_LONGITUDE): 

910 '''Compute a point at a given distance on this rhumb line. 

911 

912 @arg s12: The distance along this rhumb line from its origin to the point 

913 (C{meters}), can be negative. 

914 @kwarg outmask: Bit-or'ed combination of L{Caps<pygeodesy.karney.Caps>} 

915 values specifying the quantities to be returned. 

916 

917 @return: L{GDict} with 4 to 8 items C{azi12, a12, s12, S12, lat2, lat1, 

918 lon2, lon1} with latitude C{lat2} and longitude C{lon2} of the 

919 point in C{degrees}, the rhumb angle C{a12} in C{degrees} from 

920 the start point of and the area C{S12} under this rhumb line 

921 in C{meter} I{squared}. 

922 

923 @raise ImportError: Package C{numpy} not found or not installed, required 

924 only for L{RhumbLineAux} area C{S12} when C{B{exact} 

925 is True}. 

926 

927 @note: If B{C{s12}} is large enough that the rhumb line crosses a pole, the 

928 longitude of the second point is indeterminate and C{NAN} is returned 

929 for C{lon2} and area C{S12}. 

930 

931 If the first point is a pole, the cosine of its latitude is taken to 

932 be C{sqrt(L{EPS})}. This position is extremely close to the actual 

933 pole and allows the calculation to be carried out in finite terms. 

934 ''' 

935 return self._Position(self.m2degrees(s12), s12, outmask) 

936 

937 def _Position(self, a12, s12, outmask): 

938 '''(INTERNAL) C{Arc-/Position} helper. 

939 ''' 

940 r = GDict(azi12=self.azi12, a12=a12, s12=s12, name=self.name) 

941 Cs = Caps 

942 if (outmask & Cs.LATITUDE_LONGITUDE_AREA): 

943 if a12 or s12: 

944 mu12 = self._calp * a12 

945 mu2 = self._mu1 + mu12 

946 if fabs(mu2) > 90: # past pole 

947 mu2 = _norm180(mu2) # reduce to [-180, 180) 

948 if fabs(mu2) > 90: # point on anti-meridian 

949 mu2 = _norm180(_loneg(mu2)) 

950 lat2 = self._mu2lat(mu2) 

951 lon2 = S12 = NAN 

952 else: 

953 lat2, lon2, S1, S2 = self._Position4(a12, mu2, s12, mu12) 

954 if (outmask & Cs.AREA): 

955 S12 = self.rhumb._S12d(S1, S2, lon2) 

956 S12 = unsigned0(S12) # like .gx 

957# else: 

958# S12 = None # unused 

959 if (outmask & Cs.LONGITUDE): 

960 if (outmask & Cs.LONG_UNROLL): 

961 lon2 += self.lon1 

962 else: 

963 lon2 = _norm180(self._lon12 + lon2) 

964 else: # coincident 

965 lat2, lon2 = self.latlon1 

966 S12 = _0_0 

967 

968 if (outmask & Cs.AREA): 

969 r.set_(S12=S12) 

970 if (outmask & Cs.LATITUDE): 

971 r.set_(lat2=lat2, lat1=self.lat1) 

972 if (outmask & Cs.LONGITUDE): 

973 r.set_(lon2=lon2, lon1=self.lon1) 

974 return r 

975 

976 def _Position4(self, a12, mu2, s12, mu12): # PYCHOK no cover 

977 '''(INTERNAL) I{Must be overloaded}.''' 

978 self._notOverloaded(a12, s12, mu2, mu12) # underOK=True 

979 

980 @Property_RO 

981 def rhumb(self): 

982 '''Get this rhumb line's rhumb (L{RhumbAux} or L{Rhumb}). 

983 ''' 

984 return self._rhumb 

985 

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

987 '''Return this C{RhumbLine} as string. 

988 

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

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

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

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

993 

994 @return: C{RhumbLine} (C{str}). 

995 ''' 

996 d = dict(rhumb=self.rhumb, lat1=self.lat1, lon1=self.lon1, 

997 azi12=self.azi12, exact=self.exact, 

998 TMorder=self.TMorder, xTM=self.xTM) 

999 return sep.join(pairs(itemsorted(d, asorted=False), prec=prec)) 

1000 

1001 @property_RO 

1002 def TMorder(self): 

1003 '''Get this rhumb line's I{Transverse Mercator} order (C{int}, 4, 5, 6, 7 or 8). 

1004 ''' 

1005 return self.rhumb.TMorder 

1006 

1007 @Property_RO 

1008 def xTM(self): 

1009 '''Get this rhumb line's I{Transverse Mercator} projection (L{ExactTransverseMercator} 

1010 if I{exact} and I{ellipsoidal}, otherwise L{KTransverseMercator} for C{TMorder}). 

1011 ''' 

1012 E = self.ellipsoid 

1013 # ExactTransverseMercator doesn't handle spherical earth models 

1014 return _MODS.etm.ExactTransverseMercator(E) if self.exact and E.isEllipsoidal else \ 

1015 _MODS.ktm.KTransverseMercator(E, TMorder=self.TMorder) 

1016 

1017 def _xTM3d(self, latlon0, z=INT0, V3d=Vector3d): 

1018 '''(INTERNAL) C{xTM.forward} this C{latlon1} to C{V3d} with B{C{latlon0}} 

1019 as current intersection estimate and central meridian. 

1020 ''' 

1021 t = self.xTM.forward(self.lat1 - latlon0.lat, self.lon1, lon0=latlon0.lon) 

1022 return V3d(t.easting, t.northing, z) 

1023 

1024 

1025class _PseudoRhumbLine(RhumbLineBase): 

1026 '''(INTERNAL) Pseudo-rhumb line for a geodesic (line), see C{geodesicw._PlumbTo}. 

1027 ''' 

1028 def __init__(self, gl, name=NN): 

1029 R = RhumbBase(gl.geodesic.ellipsoid, None, True, name) 

1030 RhumbLineBase.__init__(self, R, gl.lat1, gl.lon1, 0, caps=Caps.LINE_OFF) 

1031 self._azi1 = self.azi12 = gl.azi1 

1032 self._gl = gl 

1033 self._gD = gl.geodesic.Direct 

1034 

1035 def PlumbTo(self, lat0, lon0, **exact_eps_est_tol): # PYCHOK signature 

1036 P = RhumbLineBase.PlumbTo(self, lat0, lon0, **exact_eps_est_tol) 

1037 z, P = _xkwds_pop2(P, azi12=None) 

1038 P.set_(azi1=self._gl.azi1, azi2=z) 

1039 return P # geodesic L{Position} 

1040 

1041 def Position(self, s12, **unused): # PYCHOK signature 

1042 r = self._gD(self.lat1, self.lon1, self._azi1, s12) 

1043 self._azi1 = r.azi1 

1044 self.azi12 = z = r.azi2 

1045 self._salp, _ = sincos2d(z) 

1046 return r.set_(azi12=z) 

1047 

1048 

1049__all__ += _ALL_DOCS(RhumbBase, RhumbLineBase) 

1050 

1051if __name__ == _DMAIN_: 

1052 

1053 from pygeodesy import printf, Rhumb as Rh, RhumbAux as Ah 

1054 from pygeodesy.basics import _zip 

1055 from pygeodesy.ellipsoids import _EWGS84 

1056 

1057 Al = Ah(_EWGS84).Line(30, 0, 45) 

1058 Rl = Rh(_EWGS84).Line(30, 0, 45) 

1059 

1060 for i in range(1, 10): 

1061 s = .5e6 + 1e6 / i 

1062 a = Al.Position(s).lon2 

1063 r = Rl.Position(s).lon2 

1064 e = (fabs(a - r) / a) if a else 0 

1065 printf('# Position.lon2 %.14f vs %.14f, diff %g', r, a, e) 

1066 

1067 for exact in (None, False, True): 

1068 for est in (None, 1e6): 

1069 a = Al.PlumbTo(60, 0, exact=exact, est=est) 

1070 r = Rl.PlumbTo(60, 0, exact=exact, est=est) 

1071 printf('# %s, iteration=%s, exact=%s, est=%s\n# %s, iteration=%s', 

1072 a.toRepr(), a.iteration, exact, est, 

1073 r.toRepr(), r.iteration, nl=1) 

1074 

1075 NE_=(71.688899882813, 0.2555198244234, 44095641862956.11) 

1076 LHR=(77.7683897102557, 5771083.38332803, 37395209100030.39) 

1077 NRT=(-92.38888798169965, 12782581.067684170, -63760642939072.50) 

1078 

1079 def _ref(fmt, r3, x3): 

1080 e3 = [] 

1081 for r, x in _zip(r3, x3): # strict=True 

1082 e = fabs(r - x) / fabs(x) 

1083 e3.append('%.g' % (e,)) 

1084 printf((fmt % r3) + ', rel errors: ' + ', '.join(e3)) 

1085 

1086 for R in (Ah, Rh): # <https://GeographicLib.SourceForge.io/cgi-bin/RhumbSolve -p 9> version 2.2 

1087 rh = R(exact=True) # WGS84 default 

1088 printf('# %r', rh, nl=1) 

1089 r = rh.Direct8(40.6, -73.8, 51, 5.5e6) # from JFK about NE 

1090 _ref('# JFK NE lat2=%.12f, lon2=%.12f, S12=%.1f', (r.lat2, r.lon2, r.S12), NE_) 

1091 r = rh.Inverse8(40.6, -73.8, 51.6, -0.5) # JFK to LHR 

1092 _ref('# JFK-LHR azi12=%.12f, s12=%.3f S12=%.1f', (r.azi12, r.s12, r.S12), LHR) 

1093 r = rh.Inverse8(40.6, -73.8, 35.8, 140.3) # JFK to Tokyo Narita 

1094 _ref('# JFK-NRT azi12=%.12f, s12=%.3f S12=%.1f', (r.azi12, r.s12, r.S12), NRT) 

1095 

1096# % python3.10 -m pygeodesy3.rhumb.Bases 

1097 

1098# Position.lon2 11.61455846901637 vs 11.61455846901637, diff 3.05885e-16 

1099# Position.lon2 7.58982302826842 vs 7.58982302826842, diff 2.34045e-16 

1100# Position.lon2 6.28526067416369 vs 6.28526067416369, diff 2.82623e-16 

1101# Position.lon2 5.63938995325146 vs 5.63938995325146, diff 1.57495e-16 

1102# Position.lon2 5.25385527435707 vs 5.25385527435707, diff 0 

1103# Position.lon2 4.99764604290380 vs 4.99764604290380, diff 8.88597e-16 

1104# Position.lon2 4.81503363740473 vs 4.81503363740473, diff 1.84459e-16 

1105# Position.lon2 4.67828821748836 vs 4.67828821748835, diff 5.69553e-16 

1106# Position.lon2 4.57205667906283 vs 4.57205667906283, diff 5.82787e-16 

1107 

1108# Intersection(a02=17.798332, a12=19.521356, at=90.0, azi02=135.0, azi12=45.0, lat0=60.0, lat1=30.0, lat2=45.0, lon0=0.0, lon1=0.0, lon2=15.830286, name='Intersection', s02=1977981.142985, s12=2169465.957531), iteration=9, exact=None, est=None 

1109# Intersection(a02=17.798332, a12=19.521356, at=90.0, azi02=135.0, azi12=45.0, lat0=60.0, lat1=30.0, lat2=45.0, lon0=0.0, lon1=0.0, lon2=15.830286, name='Intersection', s02=1977981.142985, s12=2169465.957531), iteration=9 

1110 

1111# Intersection(a02=17.798332, a12=19.521356, at=90.0, azi02=135.0, azi12=45.0, lat0=60.0, lat1=30.0, lat2=45.0, lon0=0.0, lon1=0.0, lon2=15.830286, name='Intersection', s02=1977981.142985, s12=2169465.957531), iteration=9, exact=None, est=1000000.0 

1112# Intersection(a02=17.798332, a12=19.521356, at=90.0, azi02=135.0, azi12=45.0, lat0=60.0, lat1=30.0, lat2=45.0, lon0=0.0, lon1=0.0, lon2=15.830286, name='Intersection', s02=1977981.142985, s12=2169465.957531), iteration=9 

1113 

1114# PlumbTo(a02=17.967658, a12=27.74256, at=90.0, azi0=113.73626, azi12=45.0, lat0=60, lat1=30.0, lat2=49.634582, lon0=0, lon1=0.0, lon2=25.767876, name='PlumbTo', s02=1997960.116871, s12=3083112.636236), iteration=5, exact=False, est=None 

1115# PlumbTo(a02=17.967658, a12=27.74256, at=90.0, azi0=113.73626, azi12=45.0, lat0=60, lat1=30.0, lat2=49.634582, lon0=0, lon1=0.0, lon2=25.767876, name='PlumbTo', s02=1997960.116871, s12=3083112.636236), iteration=5 

1116 

1117# PlumbTo(a02=17.967658, a12=27.74256, at=90.0, azi0=113.73626, azi12=45.0, lat0=60, lat1=30.0, lat2=49.634582, lon0=0, lon1=0.0, lon2=25.767876, name='PlumbTo', s02=1997960.116871, s12=3083112.636236), iteration=7, exact=False, est=1000000.0 

1118# PlumbTo(a02=17.967658, a12=27.74256, at=90.0, azi0=113.73626, azi12=45.0, lat0=60, lat1=30.0, lat2=49.634582, lon0=0, lon1=0.0, lon2=25.767876, name='PlumbTo', s02=1997960.116871, s12=3083112.636236), iteration=7 

1119 

1120# PlumbTo(a02=17.967658, a12=27.74256, at=90.0, azi0=113.73626, azi12=45.0, lat0=60, lat1=30.0, lat2=49.634582, lon0=0, lon1=0.0, lon2=25.767876, name='PlumbTo', s02=1997960.116871, s12=3083112.636236), iteration=5, exact=True, est=None 

1121# PlumbTo(a02=17.967658, a12=27.74256, at=90.0, azi0=113.73626, azi12=45.0, lat0=60, lat1=30.0, lat2=49.634582, lon0=0, lon1=0.0, lon2=25.767876, name='PlumbTo', s02=1997960.116871, s12=3083112.636236), iteration=5 

1122 

1123# PlumbTo(a02=17.967658, a12=27.74256, at=90.0, azi0=113.73626, azi12=45.0, lat0=60, lat1=30.0, lat2=49.634582, lon0=0, lon1=0.0, lon2=25.767876, name='PlumbTo', s02=1997960.116871, s12=3083112.636236), iteration=7, exact=True, est=1000000.0 

1124# PlumbTo(a02=17.967658, a12=27.74256, at=90.0, azi0=113.73626, azi12=45.0, lat0=60, lat1=30.0, lat2=49.634582, lon0=0, lon1=0.0, lon2=25.767876, name='PlumbTo', s02=1997960.116871, s12=3083112.636236), iteration=7 

1125 

1126# RhumbAux(RAorder=None, TMorder=6, ellipsoid=Ellipsoid(name='WGS84', a=6378137, b=6356752.31424518, f_=298.25722356, f=0.00335281, f2=0.00336409, n=0.00167922, e=0.08181919, e2=0.00669438, e21=0.99330562, e22=0.0067395, e32=0.00335843, A=6367449.14582341, L=10001965.72931272, R1=6371008.77141506, R2=6371007.18091847, R3=6371000.79000916, Rbiaxial=6367453.63451633, Rtriaxial=6372797.5559594), exact=True) 

1127# JFK NE lat2=71.688899882813, lon2=0.255519824423, S12=44095641862956.1, rel errors: 4e-16, 2e-13, 4e-16 

1128# JFK-LHR azi12=77.768389710256, s12=5771083.383 S12=37395209100030.4, rel errors: 5e-16, 3e-16, 8e-16 

1129# JFK-NRT azi12=-92.388887981700, s12=12782581.068 S12=-63760642939072.5, rel errors: 0, 1e-16, 7e-16 

1130 

1131# Rhumb(RAorder=6, TMorder=6, ellipsoid=Ellipsoid(name='WGS84', a=6378137, b=6356752.31424518, f_=298.25722356, f=0.00335281, f2=0.00336409, n=0.00167922, e=0.08181919, e2=0.00669438, e21=0.99330562, e22=0.0067395, e32=0.00335843, A=6367449.14582341, L=10001965.72931272, R1=6371008.77141506, R2=6371007.18091847, R3=6371000.79000916, Rbiaxial=6367453.63451633, Rtriaxial=6372797.5559594), exact=True) 

1132# JFK NE lat2=71.688899882813, lon2=0.255519824423, S12=44095641862956.1, rel errors: 2e-16, 1e-13, 5e-16 

1133# JFK-LHR azi12=77.768389710256, s12=5771083.383 S12=37395209100030.4, rel errors: 4e-16, 3e-16, 6e-16 

1134# JFK-NRT azi12=-92.388887981700, s12=12782581.068 S12=-63760642939072.5, rel errors: 0, 1e-16, 1e-16 

1135 

1136# **) MIT License 

1137# 

1138# Copyright (C) 2022-2025 -- mrJean1 at Gmail -- All Rights Reserved. 

1139# 

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

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

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

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

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

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

1146# 

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

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

1149# 

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

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

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

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

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

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

1156# OTHER DEALINGS IN THE SOFTWARE.