Coverage for pygeodesy / hom.py: 99%
382 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-10-21 16:43 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-10-21 16:43 -0400
2# -*- coding: utf-8 -*-
4u'''A pure Python implementation of 2 ellipsoidal and 3 spherical variants of I{Hotine}'s
5U{Oblique Mercator<https://WikiPedia.org/wiki/Oblique_Mercator_projection>} projection from
6U{Snyder Chapter 9<>https://pubs.USGS.gov/pp/1395/report.pdf>} page 66ff, on-line U{Neacsu
7<https://Neacsu.net/geodesy/snyder/3-cylindrical/sect_9>} and U{IOGP section 3.2.4
8<https://www.IOGP.org/wp-content/uploads/2019/09/373-07-02.pdf>} page 59ff.
10@see: U{Oblique Mercator Usage<https://Neacsu.net/geodesy/snyder/3-cylindrical/sect_9/#usage>}.
11'''
12# make sure int/int division yields float quotient
13from __future__ import division as _; del _ # noqa: E702 ;
15from pygeodesy.basics import _copysign, _ALL_LAZY, typename
16from pygeodesy.constants import INF, NAN, PI_2, PI_4, R_M, _0_0, _0_5, _1_0, _2_0, \
17 _90_0, _isfinite, isnear0, _1_over, _over, \
18 _EPS2e4 as _TOL
19from pygeodesy.datums import _ellipsoidal_datum, _WGS84
20from pygeodesy.errors import _ValueError, _xkwds
21from pygeodesy.fmath import fdot, fdot_
22# from pygeodesy.internals import typename # from .basics
23from pygeodesy.interns import _C_, _COMMA_, _COMMASPACE_, _lat_, _lon_, _scale_, \
24 _x_, _y_, _0_, _1_, _2_
25# from pygeodesy.lazily import _ALL_LAZY # from .basics
26from pygeodesy.named import _NamedBase, _NamedTuple, _namethod, _Pass
27from pygeodesy.namedTuples import LatLon2Tuple, Vector2Tuple
28from pygeodesy.props import Property_RO, property_doc_, property_RO
29from pygeodesy.streprs import Fmt, pairs, unstr
30from pygeodesy.units import Azimuth, Degrees, Int, Meter, Lamd, Lat, _LatLon2, \
31 Lon, Phid, Radius, Scalar
32from pygeodesy.utily import asin1, atan1, atan2, sincos2, sincos2_, wrapPI as _PI
34from math import cos, cosh, degrees, e as _base_e, fabs, log, radians, \
35 sin, sinh, sqrt, tan, tanh
37__all__ = _ALL_LAZY.hom
38__version__ = '26.10.21'
41class _HotineObliqueBase(_NamedBase):
42 '''(INTERNAL) Base class for all C{HotineOblique*} variants.
43 '''
44 _a0 = _b0 = _0_0 # origin (phi0, lam0)
45 _ap = _bp = _0_0 # pole (phip, lamp)
46 _args = () # __init__ args
47 _g0 = _zC = NAN # rectified-skew, azimuth
48 _k0 = _1_0 # central scale factor
49 _lat0 = _0_0 # origin latitude (degrees) NAN
50 _x0 = _y0 = _0_0 # false east-, northing
52 def __init__(self, unused, k0=_1_0, lat0=None,
53 x0=_0_0, y0=_0_0, **name):
54 if name:
55 self.name = name
56 if k0 != 1:
57 self._k0 = Scalar(k0=k0)
58# self._Rk0 = self.R * self.k0
59 if lat0 is not None:
60 self._lat0 = Lat(lat0=lat0)
61 self._a0 = Phid(self._lat0)
62 sa0, ca0 = self._sca0
63 if isnear0(sa0) or isnear0(ca0):
64 raise self._Error(lat0=self._lat0)
65 if x0:
66 self._x0 = Meter(x0=x0)
67 if y0:
68 self._y0 = Meter(y0=y0)
70 @Property_RO
71 def _args_kwds(self):
72 kwds = self._kwds()
73 if self.name:
74 kwds = _xkwds(kwds, name=self.name)
75 return unstr(None, *self._args, **kwds)
77 @Property_RO
78 def azimuthC(self):
79 '''Get the center line azimuth (C{degrees180}) or C{NAN}.
80 '''
81 z = self._zC
82 if _isfinite(z):
83 z = Azimuth(azimuth=degrees(z), _180=True) # Snyder page 275, 𝛼c
84 return z
86 def _Error(self, **kwds):
87 return HotineError(txt=str(self), **kwds)
89 @Property_RO
90 def gamma0(self):
91 '''Get the rectified-skew angle (C{degrees}) or C{NAN}.
92 '''
93 g = self._g0
94 if _isfinite(g):
95 g = Degrees(gamma0=degrees(g))
96 return g
98 @property_RO
99 def k0(self):
100 '''Get the scale factor at origin (C{scalar}).
101 '''
102 return self._k0
104 def _kwds(self, **kwds):
105 if self.k0 != 1:
106 kwds.update(k0=self.k0)
107 if self._lat0:
108 kwds.update(lat0=self._lat0)
109 if self._x0:
110 kwds.update(x0=self._x0)
111 if self._y0:
112 kwds.update(y0=self._y0)
113 return kwds
115 @property_RO
116 def latlon0(self):
117 '''Get the origin, other pole (L{LatLon2Tuple}).
118 '''
119 return LatLon2Tuple(self._lat0, self._lon0, name=_0_)
121 @Property_RO
122 def _lon0(self):
123 return degrees(self._b0)
125 def _philam2(self, lat_latlon, lon):
126 lat, lon = \
127 self._args = _LatLon2(lat_latlon, Error=HotineError) if lon is None else \
128 ( Lat(lat_latlon), Lon(lon))
129 return Phid(lat), Lamd(lon)
131 def _philam4(self, latlon1, latlon2):
132 a1, b1 = self._philam2(latlon1, None)
133 t = self._args
134 a2, b2 = self._philam2(latlon2, None)
135 self._args = t, self._args
136 return a1, b1, a2, b2
138 def _philamalp3(self, latlon, lon, azimuth):
139 a, b = self._philam2(latlon, lon)
140 z = Azimuth(azimuth) # == % 360
141 self._args += z,
142 self._zC = z = radians(z)
143 self._b0 = b # 𝜆C
144 self._a0 = a # 𝜑C
145 return a, b, z
147 @Property_RO
148 def _sca0(self): # 𝜑0
149 return sincos2(self._a0)
151 @Property_RO
152 def _scb0(self): # 𝜆0
153 return sincos2(self._b0)
155 @Property_RO
156 def _scap(self): # 𝜑p
157 return sincos2(self._ap)
159 @Property_RO
160 def _scbp(self): # 𝜆p
161 return sincos2(self._bp)
163 @Property_RO
164 def _scg0(self): # 𝛾0
165 return sincos2(self._g0)
167 @Property_RO
168 def _sczC(self): # 𝛼C or 𝛽C
169 return sincos2(self._zC)
171 def toStr(self, **kwds):
172 '''Get this C{HotineOblique*} instance as C{str}.
173 '''
174 t = self._args_kwds
175 return _COMMASPACE_(t, *pairs(kwds))
177 @property_RO
178 def xy0(self):
179 '''Get the false east- and northing (L{Vector2Tuple}, C{meter}).
180 '''
181 return Vector2Tuple(self._x0, self._y0, name=_0_)
184class _HotineOblique123(_HotineObliqueBase):
185 '''(INTERNAL) Spherical C{HotineOblique1}, C{-2} and C{-3} base class.
186 '''
187 _Rk0 = \
188 _radius = R_M
190 def __init__(self, ap, bp, radius, **k0_lat0_x0_y0_name):
191 self._ap = ap
192 self._bp = bp = _PI(bp)
193 self._b0 = _PI(bp + PI_2)
195 _HotineObliqueBase.__init__(self, None, **k0_lat0_x0_y0_name)
197 if radius != _HotineOblique123._radius or \
198 self.k0 != _HotineObliqueBase._k0:
199 self._radius = R = Radius(radius)
200 self._Rk0 = R * self.k0
202 def forward(self, lat_latlon, lon=None, **name):
203 '''Project a geodetic lat- and longitude to Hotine east- and northing.
205 @arg lat_latlon: Latitude of the point (scalar C{degrees90} or C{LatLon},
206 2-tuple C{(lat, lon)} or comma-separated I{"latDMS, lonDMS"} C{str}).
207 @arg lon: Longitude of the point (C{degrees180}), required if B{C{lat_latlon}}
208 is scalar, ignored otherwise.
209 @kwarg name: Optional C{B{name}=NN} (C{str}).
211 @return: L{Hotine8Tuple}C{(x, y, u, v, lat, lon, scale, hom)} with falsed
212 east- and northing C{x} and C{y} and "unrectified" C{u} and C{v}
213 in C{meter}, geodetic C{lat}- and C{lon}gitude in degrees, C{scale}
214 factor (aka C{k}) and this C{HotineOblique*} Mercator instance C{hom}.
215 '''
216 lat, lon = _LatLon2(lat_latlon, lon, HotineError)
218 d = Lamd(lon) - self._b0
219 sa, ca, sd, cd = sincos2_(Phid(lat), d)
220 sp, cp = self._scap # phi-p
222 A = fdot_(sp, sa, -cp, ca * sd)
223 u = fdot_(cp, sa, sp, ca * sd) # / ca
224 u = atan2( u, ca * cd) * self._Rk0
225 v = log(_d_s(_1_0, -A)) * self._Rk0 * _0_5
227 k = _1_0 - A**2
228 k = (self.k0 / sqrt(k)) if k > 0 else NAN
229 x = u + self._x0
230 y = v + self._y0
231 return Hotine8Tuple(x, y, u, v, lat, lon, k, self,
232 name=_namethod(self.forward, **name))
234 def _kwds(self, **unused):
235 return _HotineObliqueBase._kwds(self, radius=self.radius)
237 @Property_RO
238 def latlonP(self):
239 '''Get the main pole (L{LatLon2Tuple}).
240 '''
241 return LatLon2Tuple(degrees(self._ap), degrees(self._bp), name='P')
243 @property_RO
244 def radius(self):
245 '''Get the the earth radius (C{meter}, conventionally).
246 '''
247 return self._radius
249 def reverse(self, x, y, **name):
250 '''Project an Hotine east- and northing to geodetic lat- and longitude.
252 @arg x: Easting of the point (C{meter}).
253 @arg y: Northing of the point (C{meter}).
254 @kwarg name: Optional C{B{name}=NN} (C{str}).
256 @return: L{Hotine8Tuple}C{(x, y, u, v, lat, lon, scale, hom)} with geodetic
257 C{lat}- and C{lon}gitude in degrees, C{scale} factor (aka C{k}),
258 falsed east- and northing C{x} and C{y} and "unrectified" C(u) and
259 C{v} in C{meter} and this C{HotineOblique*} Mercator instance C{hom}.
260 '''
261 t = _1_over(self._Rk0)
262 u = x - self._x0
263 v = y - self._y0
264 e = u * t
265 n = v * t
267 sx, cx = sincos2(e)
268 sy, cy, ty = sinh(n), cosh(n), tanh(n)
269 sp, cp = self._scap
271 a = degrees(asin1(fdot_(sp, ty, cp, sx / cy))) if cy else INF
272 b = _PI( atan2(fdot_(sp, sx, -cp, sy), cx) + self._b0)
273 return Hotine8Tuple(x, y, u, v, a, degrees(b), self.k0, self,
274 name=_namethod(self.reverse, **name))
277class HotineOblique1(_HotineOblique123):
278 '''Spherical variant 1: Hotine Oblique Mercator projection given
279 C{two points}.
280 '''
282 def __init__(self, latlon1, latlon2, radius=R_M, **k0_lat0_x0_y0_name):
283 '''New I{spherical} L{HotineOblique1} Mercator, variant 1.
285 @arg latlon1: Center line point (C{LatLon}, C{2-tuple (lat, lon)} or
286 a comma-separated I{"latDMS, lonDMS"} C{str}).
287 @arg latlon2: Other center line point (C{LatLon}, C{2-tuple (lat, lon)}
288 or a comma separated I{"latDMS, lonDMS"} C{str}).
289 @kwarg radius: Earth radius (C{meter}, conventionally).
290 @kwarg k0_lat0_x0_y0_name: Central scale factor C{B{k0}=1.0} (C{scalar}), origin
291 latitude C{B{lat0}=0.0} (C{degrees}), false easting C{B{x0}=0.0} and
292 false northing C{B{y0}=0.0} (C{meter}) and C{B{name}=NN} (C{str}).
293 '''
294 a1, b1, a2, b2 = self._philam4(latlon1, latlon2)
296 sa1, ca1, sb1, cb1 = sincos2_(a1, b1)
297 sa2, ca2, sb2, cb2 = sincos2_(a2, b2)
298 ca1_sa2 = ca1 * sa2
299 sa1_ca2 = sa1 * ca2
300 bp = atan2(fdot_(ca1_sa2, cb1, -sa1_ca2, cb2),
301 fdot_(sa1_ca2, sb2, -ca1_sa2, sb1))
302 ap = atan1(-cos(bp - b1), tan(a1))
303 _HotineOblique123.__init__(self, ap, bp, radius, **k0_lat0_x0_y0_name)
305 @property_RO
306 def latlon1(self):
307 '''Get the 1st center line point (L{LatLon2Tuple}).
308 '''
309 return LatLon2Tuple(*self._args[0], name=_1_)
311 @property_RO
312 def latlon2(self):
313 '''Get the 2nd center line point (L{LatLon2Tuple}).
314 '''
315 return LatLon2Tuple(*self._args[1], name=_2_)
318class HotineOblique2(_HotineOblique123):
319 '''Spherical variant 2: Hotine Oblique Mercator projection given the C{central point}
320 and C{azimuth}.
321 '''
323 def __init__(self, lat_latlon, lon=None, azimuth=_0_0, radius=R_M, **k0_lat0_x0_y0_name):
324 '''New spherical L{HotineOblique2} Mercator, variant 2.
326 @arg lat_latlon: Center latitude (scalar C{degrees90} or any C{LatLon}, 2-tuple
327 C{(lat, lon)} or a comma-separated I{"latDMS, lonDMS"} C{str}).
328 @arg lon: Center longitude (C{degrees180}), required if B{C{lat_latlon}} is scalar,
329 =ignored otherwise.
330 @kwarg azimuth: Azimuth of the center line (C{degrees360} from North).
331 @kwarg radius: Earth radius (C{meter}, conventionally).
332 @kwarg k0_lat0_x0_y0_name: Central scale factor C{B{k0}=1.0} (C{scalar}), origin
333 latitude C{B{lat0}=0.0} (C{degrees}), false easting C{B{x0}=0.0} and
334 false northing C{B{y0}=0.0} (C{meter}) and C{B{name}=NN} (C{str}).
335 '''
336 aC, bp, zC = self._philamalp3(lat_latlon, lon, azimuth)
337 sa, ca, sz, cz = sincos2_(aC, zC) # == ._sca0, ._sczC
339 ap = asin1( sz * ca)
340 bp += atan2(-cz, -sa * sz) # PYLINT E1130
341 _HotineOblique123.__init__(self, ap, bp, radius, **k0_lat0_x0_y0_name)
343 @property_RO
344 def latlonC(self):
345 '''Get the center point (L{LatLon2Tuple}).
346 '''
347 return LatLon2Tuple(*self._args[:2], name=_C_)
350class HotineOblique3(_HotineOblique123):
351 '''Spherical variant 3: Hotine Oblique Mercator projection given the C{main pole}.
352 '''
354 def __init__(self, lat_latlon, lon=None, radius=R_M, **k0_lat0_x0_y0_name):
355 '''New spherical L{HotineOblique3} Mercator, variant 3.
357 @arg lat_latlon: Main pole latitude (scalar C{degrees90} or any C{LatLon},
358 2-tuple C{(lat, lon)} or a comma-separated I{"latDMS, lonDMS"} C{str}).
359 @arg lon: Main pole longitude (C{degrees180}), required if B{C{lat_latlon}} is
360 scalar, ignored otherwise.
361 @kwarg radius: Earth radius (C{meter}, conventionally).
362 @kwarg k0_lat0_x0_y0_name: Central scale factor C{B{k0}=1.0} (C{scalar}), origin
363 latitude C{B{lat0}=0.0} (C{degrees}), false easting C{B{x0}=0.0} and
364 false northing C{B{y0}=0.0} (C{meter}) and C{B{name}=NN} (C{str}).
365 '''
366 ap, bp = self._philam2(lat_latlon, lon)
367 _HotineOblique123.__init__(self, ap, bp, radius, **k0_lat0_x0_y0_name)
370class _HotineObliqueAB(_HotineObliqueBase):
371 '''(INTERNAL) Ellipsoidal C{HotineObliqueA} and C{-B} base class.
372 '''
373 _ABEG_BuCz = () # 7-tuple
374 _earth = _WGS84
375 _xyuvIOGP = False
377 def __init__(self, earth, **k0_lat0_x0_y0_name):
379 if earth is not _HotineObliqueAB._earth:
380 self._earth = _ellipsoidal_datum(earth)
382 _HotineObliqueBase.__init__(self, None, **k0_lat0_x0_y0_name)
384 @Property_RO
385 def _ABD21EFt0(self): # 7-tuple
386 sa0, ca0 = self._sca0
387 a0 = self._a0
388 E = self.ellipsoid
389 e2 = E.e2not
390 r = _1_0 - e2
391 B = ca0**4 * e2 / r
392 B = sqrt(_1_0 + B)
393 s = _1_0 - sa0**2 * e2
394 r = sqrt(r) * B
395 A = r * E.a * self.k0 / s
396 D = F = r / (sqrt(s) * ca0)
397 D2 = D**2
398 if D2 > 1:
399 D21 = sqrt(D2 - _1_0)
400 F += _copysign(D21, a0) # Snyder F, NOT!
401 else: # PYCHOK no cover
402 D21 = _0_0
403 t0 = _tesB(E.e, sa0, 1, a0) # 9-13, not 9-13a!
404 E = pow(t0, B) * F # Snyder E, 9-15 & 9-36 IOGP H
405 return A, B, D, D2, D21, E, F, t0
407 @property_RO
408 def ABmap(self):
409 '''Get 14 map parameters C{(A, B, D, D2, E, G, uC, zC,
410 a0, b0, g0, t0, x0, y0)} (L{HotineAB14Tuple}).
411 '''
412 A, B, D, D2, _, E, _, t0 = self._ABD21EFt0
413 _, _, _, G, _, uC, _ = self._ABEG_BuCz # PYLINT W0632
414 return HotineAB14Tuple(A, B, D, D2, E, G, uC, self._zC,
415 self._a0, self._b0, self._g0, t0,
416 self._x0, self._y0, name=typename(self))
418# def _debug(self, where, locals__, **kwds):
419# d = dict(a0=self._a0, b0=self._b0, latlon0=self.latlon0,
420# g0=self._g0, k0=self.k0, zC=self._zC)
421# d.update(locals__)
422# d.update(kwds)
423# n = self.methodname(where)
424# s = _COMMA_ + _NL_ + _SPACE_ * (len(n) + 1)
425# print(n, s.join(pairs(d)))
427 @property_RO
428 def earth(self):
429 '''Get the earth model (L{Datum}).
430 '''
431 return self._earth
433 @Property_RO
434 def ellipsoid(self):
435 '''Get the earth' ellipsoid (L{Ellipsoid}).
436 '''
437 return self.earth.ellipsoid
439 def forward(self, lat_latlon, lon=None, **name):
440 '''Project a geodetic lat- and longitude to Hotine east- and northing.
442 @arg lat_latlon: Latitude of the point (scalar C{degrees90} or C{LatLon},
443 2-tuple C{(lat, lon)} or comma-separated I{"latDMS, lonDMS"} C{str}).
444 @arg lon: Longitude of the point (C{degrees180}), required if B{C{lat_latlon}}
445 is scalar, ignored otherwise.
446 @kwarg name: Optional C{B{name}=NN} (C{str}).
448 @return: L{Hotine8Tuple}C{(x, y, u, v, lat, lon, scale, hom)} with falsed east-
449 and northing C{x} and C{y} and "unrectified" C{u} and C{v} in C{meter},
450 geodetic C{lat}- and C{lon}gitude in degrees, C{scale} factor (aka
451 C{k}) and this C{HotineOblique*} Mercator instance C{hom}.
452 '''
453 lat, lon = _LatLon2(lat_latlon, lon, HotineError)
455 A, B, Q, _, A_B, uC, zCE = self._ABEG_BuCz # PYLINT W0632
457 a = Phid(lat)
458 b = Lamd(lon)
459 Bd = _PI(b - self._b0) * B
460 E = self.ellipsoid
461 sg0, cg0 = self._scg0
463 sa, ca, V, cBd = sincos2_(a, Bd) # phi
464 if ca: # PYLINT W0125
465 Q *= _tesB(E.e, sa, -B, a) # PYLINT E1120 Snyder 9-25 ...
466 S, T = _ST_Q(Q)
467 U = _over(fdot_(S, sg0, -V, cg0), T) # PYLINT E1130
469 v = _d_s(_1_0, U)
470 if _isfinite(v) and v > 0: # U +/-1
471 v = log(v) * A_B * _0_5 # ... Snyder 9-30
472 elif ca: # PYLINT W0125
473 pass # 0.0 or INF
474 else: # PYCHOK no cover lat +/-90
475 v = _copysign(self._g0 * _0_5, -sa) # PYLINT E1130
476 v = log(tan(PI_4 + v)) * A_B
478 if cBd: # PYLINT W0125
479 u = fdot_(S, cg0, V, sg0)
480 u = atan2(u, cBd) * A_B # Snyder 9-31 XXX atan1
481 else: # PYCHOK no cover
482 u = A * Bd # Note after 9-31
484 if uC or zCE: # variant B, IOGP page 62
485 u -= (uC if b < self._b0 else # ABS(uC) * SIGN(a0) * SIGN(b0 - b)
486 (-uC if b > self._b0 else u)) if zCE else uC
487 else: # PYCHOK no cover
488 u = a * A_B # lat +/-90 idem
489 v = _0_0
491 x = _1_0 - sa**2 * E.e2not
492 if x > 0:
493 k = E.a * ca * cBd # Snyder 9-32
494 k = Scalar(k=cos(u / A_B) * sqrt(x) * A / k) if k and A_B else NAN
495 else: # PYCHOK no cover
496 k = NAN if x < 0 else _0_0
498 s, c = self._scg0 if self.xyuvIOGP else self._sczC # PYLINT W0125
499 x = fdot_(v, c, u, s, start=self._x0)
500 y = fdot_(u, c, -v, s, start=self._y0)
502# self._debug(self.forward, locals(), t=_tesB(E.e, sa, 1, a))
503 return Hotine8Tuple(x, y, u, v, lat, lon, k, self,
504 name=_namethod(self.forward, **name))
506 def _kwds(self, **unused):
507 return _HotineObliqueBase._kwds(self, earth=self.ellipsoid)
509 def reverse(self, x, y, triPhi=0, **name):
510 '''Project an Hotine east- and northing to geodetic lat- and longitude.
512 @arg x: Easting of the point (C{meter}).
513 @arg y: Northing of the point (C{meter}).
514 @kwarg triPhi: Number of C{phi} iterations (C{int}), set C{B{triPhi}=0}
515 to use the Horner-like C{e2-sin(X2)} series.
517 @kwarg name: Optional C{B{name}=NN} (C{str}).
519 @return: L{Hotine8Tuple}C{(x, y, u, v, lat, lon, scale, hom)} with geodetic
520 C{lat}- and C{lon}gitude in degrees, C{scale} factor (aka C{k}),
521 falsed east- and northing C{x} and C{y} and "unrectified" C{u} and
522 C{v} in C{meter} and this C{HotineOblique*} Mercator instance C{hom}.
523 '''
524 _, B, E, _, A_B, uC, _ = self._ABEG_BuCz # PYLINT W0632
526 e, n = (x - self._x0), (y - self._y0)
527 s, c = self._scg0 if self.xyuvIOGP else self._sczC # PYLINT W0125
528 v, u = (fdot_(e, c, -n, s), # 9-40
529 fdot_(n, c, e, s)) # 9-41
530 u += uC # IOGP uC
531 V, cBu_A = sincos2(_over(u, A_B))
532 sg0, cg0 = self._scg0
534 Q = pow(_base_e, _over(-v, A_B))
535 S, T = _ST_Q(Q)
536 U = _over(fdot_(V, cg0, S, sg0), T)
537 t, i = _d_s(_1_0, -U), None
538 if _isfinite(t) and t > 0:
539 t = _over(E, sqrt(t))
540 t = pow(t, _1_over(B))
541 X = _phit(t) # Snyder chi, 7-13
542 n = Int(triPhi=triPhi)
543 e = self.ellipsoid.e
544 if e and n > 0:
545 e_2 = e * _0_5
546 for i in range(n): # 4..5
547 a = _d_s(_1_0, sin(X) * e)
548 a = _phit(pow(a, e_2) * t) # 7-9
549 d = fabs(a - X)
550 if d < _TOL:
551 i += 1
552 break
553 X = a
554 else: # PYCHOK no cover
555 t = unstr(self.reverse, x, y, triPhi=n)
556 e = Fmt.no_convergence(d, _TOL)
557 raise HotineError(e, txt=t)
559 else: # Horner-like e2-sin(X2) series, 3-5
560 sX4 = [sin(X * _) for _ in range(2, 9, 2)]
561 a = fdot(self._Xe5, X, *sX4)
563 b = fdot_(S, cg0, -V, sg0)
564 b = atan2(b, cBu_A)
565 b = _PI(self._b0 - _over(b, B))
567 lat, lon = degrees(a), degrees(b)
568 else: # PYCHOK no cover a = +/-PI_2, b = self._b0
569 lat, lon = _copysign(_90_0, U), self._lon0
571# self._debug(self.reverse, locals())
572 return Hotine8Tuple(x, y, u, v, lat, lon, self.k0, self, iteration=i,
573 name=_namethod(self.reverse, **name))
575 @Property_RO
576 def _Xe5(self): # Snyder page 75, 3-5
577 e2 = self.ellipsoid.e2not
578 e4 = e2 * e2
579 e6 = e4 * e2
580 return (_1_0, e2 * (_0_5 + e2 * (5 / 24 + e2 * ( 1 / 12 + e2 * ( 13 / 360)))),
581 e4 * ( 7 / 48 + e2 * (29 / 240 + e2 * ( 811 / 11520))),
582 e6 * ( 7 / 120 + e2 * ( 81 / 1120)),
583 e6 * e2 * (4279 / 161280))
585 @property_doc_(' the IOGP I{xy-uv} C{gamma0} transform instead of C{azimuthC} (C{bool})')
586 def xyuvIOGP(self):
587 return self._xyuvIOGP
589 @xyuvIOGP.setter # PYCHOK setter!
590 def xyuvIOGP(self, IOGP):
591 self._xyuvIOGP = bool(IOGP)
594class HotineObliqueA(_HotineObliqueAB):
595 '''Ellipsoidal variant A: Hotine Oblique Mercator projection given C{two points}.
596 '''
598 def __init__(self, latlon1, latlon2, earth=_WGS84, **k0_lat0_x0_y0_name):
599 '''New ellipsoidal L{HotineObliqueA} Mercator, variant A.
601 @arg latlon1: Center line point (C{LatLon}, 2-tuple C{(lat, lon)} or
602 comma-separated I{"latDMS, lonDMS"} C{str}).
603 @arg latlon2: Other center line point (C{LatLon}, 2-tuple C{(lat, lon)} or
604 comma-separated I{"latDMS, lonDMS"} C{str}).
605 @kwarg earth: Earth model (L{Datum}, L{Ellipsoid}, L{Ellipsoid2} or L{a_f2Tuple}).
606 @kwarg k0_lat0_x0_y0_name: Central scale factor C{B{k0}=1.0} (C{scalar}), origin
607 latitude C{B{lat0}=0.0} (C{degrees}), false easting C{B{x0}=0.0} and
608 false northing C{B{y0}=0.0} (C{meter}) and C{B{name}=NN} (C{str}).
609 '''
610 a1, b1, a2, b2 = self._philam4(latlon1, latlon2)
611 _HotineObliqueAB.__init__(self, earth, **k0_lat0_x0_y0_name)
613 (lat1, _), (lat2, _) = self._args
614 if isnear0(a1 - a2): # phi1 == phi2
615 raise self._Error(lat1=lat1, lat2=lat2)
616 sa1, ca1, sa2, ca2 = sincos2_(a1, a2)
617 if isnear0(sa1) or isnear0(ca1): # phi1 +/-PI_2 or 0
618 raise self._Error(lat1=lat1)
619 elif isnear0(ca2 + PI_2): # phi2 == -PI_2
620 raise self._Error(lat2=lat2)
622 A, B, D, _, _, E, _, _ = self._ABD21EFt0
623 e = self.ellipsoid.e
624 H = _tesB(e, sa1, B, a1) # Snyder H
625 L = _tesB(e, sa2, B, a2)
626 G = (_over(E, H) - _over(H, E)) * _0_5
627 J = _d_s(E**2, L * H)
628 P = _d_s(L, H)
630 b0 = atan1(tan(B * _PI(b1 - b2) * _0_5) * J, P) # atan2?
631 b0 = (b1 + b2) * _0_5 - _over(b0, B)
632 g0 = atan1(sin(B * _PI(b1 - b0)), G) # atan2?
633 zC = asin1(sin(g0) * D)
634 uC = _0_0
636 self._b0, self._g0, self._zC = b0, g0, zC
637 self._ABEG_BuCz = A, B, E, G, _over(A, B), uC, False
638# self._debug(HotineObliqueA.__init__, locals(), gamma0=self.gamma0, zCd=self.azimuth)
640 @property_RO
641 def latlon1(self):
642 '''Get the 1st center line point (L{LatLon2Tuple}).
643 '''
644 return LatLon2Tuple(*self._args[0], name=_1_)
646 @property_RO
647 def latlon2(self):
648 '''Get the 2nd center line point (L{LatLon2Tuple}).
649 '''
650 return LatLon2Tuple(*self._args[1], name=_2_)
653class HotineObliqueB(_HotineObliqueAB):
654 '''Ellipsoidal variant B: Hotine Oblique Mercator projection given the C{central point}
655 and C{azimuth}.
656 '''
658 def __init__(self, lat_latlon, lon=None, azimuth=_0_0, earth=_WGS84, **k0_lat0_x0_y0_name):
659 '''New ellipsoidal L{HotineObliqueB} Mercator, variant B.
661 @arg lat_latlon: Center latitude (scalar C{degrees90} or C{LatLon}, 2-tuple
662 C{(lat, lon)} or comma-separated I{"latDMS, lonDMS"} C{str}).
663 @arg lon: Center longitude (C{degrees180}), required if B{C{lat_latlon}} is scalar,
664 ignored otherwise.
665 @kwarg azimuth: Azimuth of the center line (C{degrees360} from North).
666 @kwarg earth: Earth model (L{Datum}, L{Ellipsoid}, L{Ellipsoid2} or L{a_f2Tuple}).
667 @kwarg k0_lat0_x0_y0_name: Central scale factor C{B{k0}=1.0} (C{scalar}), origin
668 latitude C{B{lat0}=0.0} (C{degrees}), false easting C{B{x0}=0.0} and
669 false northing C{B{y0}=0.0} (C{meter}) and C{B{name}=NN} (C{str}).
670 '''
671 aC, bC, zC = self._philamalp3(lat_latlon, lon, azimuth)
672 _HotineObliqueAB.__init__(self, earth, **k0_lat0_x0_y0_name)
673 szC, czC = self._sczC
675 A, B, D, _, D21, E, F, _ = self._ABD21EFt0
676 A_B = _over(A, B)
677 G = (F - _1_over(F)) * _0_5
679 g0 = asin1(_over(szC, D)) # == IOGP
680 b0 = bC - _over(asin1(tan(g0) * G), B) # == IOGP
682 zCE = isnear0(zC - PI_2) # IOGP page 61, zC == 90?
683 uC = (A * (bC - b0)) if zCE else \
684 (atan2(D21, czC) * A_B)
685 uC = _copysign(uC, aC) # == ABS(uC) * SIGN(𝜑C)
687 self._b0, self._g0 = _PI(b0), g0
689 self._ABEG_BuCz = A, B, E, G, A_B, uC, zCE
690# self._debug(HotineObliqueB.__init__, locals(), gamma0=self.gamma0)
692 @property_RO
693 def latlonC(self):
694 '''Get the center point (L{LatLon2Tuple}).
695 '''
696 return LatLon2Tuple(*self._args[:2], name=_C_)
699class HotineError(_ValueError):
700 '''Error raised for C{HotineOblique*} issues.
701 '''
702 pass # PYLINT W0107
705class Hotine8Tuple(_NamedTuple):
706 '''8-Tuple C{(x, y, u, v, lat, lon, scale, hom)} with (falsed) east- and northing C{x}
707 and C{y} and "unrectified" C{u} and C{v} in C{meter}, geodetic C{lat}- and C{lon}gitude
708 in degrees, C{scale} factor (aka C{k}) and C{HotineOblique*} Mercator instance C{hom}.
710 @note: Except C{hom}, any attibute may be C{NAN} or non-finite for singulaties.
711 '''
712 _Names_ = (_x_, _y_, 'u', 'v', _lat_, _lon_, _scale_, 'hom')
713 _Units_ = ( Meter, Meter, Meter, Meter, Lat, Lon, _Pass, _Pass)
716class HotineAB14Tuple(_NamedTuple):
717 '''14-Tuple C{(A, B, D, D2, E, G, uC, zC, a0, b0, g0, t0, x0, y0)} of ellipsoidal map
718 parameters I{only} for variants L{HotineObliqueA} and L{HotineObliqueB} with
719 angles C{zC}, C{a0}, C{b0} and C{g0} in C{radians}.
720 '''
721 _Names_ = tuple('A,B,D,D2,E,G,uC,zC,a0,b0,g0,t0,x0,y0'.split(_COMMA_))
722 _Units_ = (Scalar,) * 7 + (_Pass,) * 4 + (Scalar,) + (Meter,) * 2
725def _d_s(a, b): # diff / sum
726 return _over(a - b, a + b)
729def _phit(t): # Snyder page 75, X 7-13
730 return PI_2 - atan1(t) * _2_0 # 7-13
733def _ST_Q(Q):
734 _Q = _1_over(Q)
735 S = (Q - _Q) * _0_5
736 T = (Q + _Q) * _0_5
737 return S, T
740def _tesB(e, s, B, *a): # t(e, s) power B!
741 if a and B: # ϕ, Snyder page 71, 9-13
742 t = _d_s(_1_0, e * s)
743 t = pow( t, -e * _0_5)
744 t *= tan(_0_5 * (PI_2 - a[0]))
745 t = pow( t, B)
746# elif s and B: # Snyder page 71, 9-13a
747# t = _d_s(_1_0, -s * e)
748# t = pow(t, e)
749# t *= _d_s(_1_0, s)
750# t = pow(t, B * _0_5) # == pow(sqrt(t), B)
751 else: # PYCHOK no cover
752 t = _1_0
753 return t
756# **) MIT License
757#
758# Copyright (C) 2026-2026 -- mrJean1 at Gmail -- All Rights Reserved.
759#
760# Permission is hereby granted, free of charge, to any person obtaining a
761# copy of this software and associated documentation files (the "Software"),
762# to deal in the Software without restriction, including without limitation
763# the rights to use, copy, modify, merge, publish, distribute, sublicense,
764# and/or sell copies of the Software, and to permit persons to whom the
765# Software is furnished to do so, subject to the following conditions:
766#
767# The above copyright notice and this permission notice shall be included
768# in all copies or substantial portions of the Software.
769#
770# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
771# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
772# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
773# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
774# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
775# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
776# OTHER DEALINGS IN THE SOFTWARE.