Coverage for pygeodesy / angles.py: 95%
494 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-08 14:37 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-08 14:37 -0400
2# -*- coding: utf-8 -*-
4u'''Classes L{Ang}, L{Deg}, L{Rad} and L{Lambertian} accurately representing an angle
5as a 3-tuple C{(sine, cosine, turns)}, with C{turns} the number of full turns.
7Transcoded to pure Python from I{Karney}'s GeographicLib 2.7 C++ class U{AngleT
8<https://GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1AngleT.html>}.
10Copyright (C) U{Charles Karney <mailto:Karney@Alum.MIT.edu>} (2024-2025) and licensed
11under the MIT/X11 License. For more information, see the U{GeographicLib 2.7
12<https://GeographicLib.SourceForge.io/>} documentation.
13'''
14# make sure int/int division yields float quotient, see .basics
15from __future__ import division as _; del _ # noqa: E702 ;
17from pygeodesy.basics import _copysign, map1, signBit, _signOf
18from pygeodesy.constants import EPS, EPS0, NAN, PI2, _0_0, _N_0_0, \
19 _0_25, _1_0, _N_1_0, _4_0, _360_0, \
20 _copysign_0_0, _copysign_1_0, \
21 _flipsign, float_, _isfinite, \
22 _over, _pos_self, remainder
23from pygeodesy.errors import _xkwds, _xkwds_get, _xkwds_pop2
24from pygeodesy.fmath import hypot, _ALL_LAZY, _MODS
25# from pygeodesy.interns import NN, _COMMASPACE_ # from .streprs
26# from pygeodesy.lazily import _ALL_LAZY, _ALL_MODS as _MODS # from .fmath
27from pygeodesy.named import _Named, _NamedTuple, _Pass
28from pygeodesy.props import Property_RO, property_doc_, property_RO, \
29 _allPropertiesOf_n, _update_all
30from pygeodesy.streprs import Fmt, fstr, unstr, NN, _COMMASPACE_
31from pygeodesy.units import Degrees, _isDegrees, _isRadians, Radians
32from pygeodesy.utily import atan2, atan2d, sincos2, sincos2d, SinCos2
34from math import asinh, ceil as _ceil, fabs, floor as _floor, \
35 isinf, isnan, sinh
37__all__ = _ALL_LAZY.angles
38__version__ = '26.08.06'
40_EPS03 = EPS / (1 << 20)
41# _HD = _180_0
42# _QD = _90_0
43# _TD = _360_0
44# _DM = _SM = _60_0
45# _DS = _3600_0
46_ZRND = _1_0 / 1024
48_CARDINAL2 = {-2: (_N_0_0, _N_1_0),
49 -1: (_N_1_0, _0_0),
50 1: ( _1_0, _0_0),
51 2: ( _0_0, _N_1_0)}.get
54def _fint(f):
55 # float of C{int(f)} preserving signed C{0}.
56 i = int(f)
57 return float_(i) if i else _copysign_0_0(f)
60def _ncardinal(s, c, n):
61 if n:
62 n *= _4_0
63 i = (1 if (-c) < fabs(s) else 2) if signBit(c) else \
64 (1 if c < fabs(s) else 0)
65 if i:
66 n += _copysign(i, s)
67 return n
70def _normalize2(s, c):
71 h = hypot(s, c)
72 if _isfinite(h):
73 sc = ((s / h), (c / h)) if h else (
74 # If y is +/-0 and x = -0, +/-pi is returned,
75 # or y is +/-0 and x = +0, +/-0 is returned,
76 # so, retain the sign of s = +/-0
77 _orthogonal2(False, s, c))
78 elif isnan(h) or (isinf(s) and isinf(c)):
79 sc = NAN, NAN
80 else:
81 sc = _orthogonal2(isinf(s), s, c)
82 return sc
85def _orthogonal2(pred, s, c):
86 return (_copysign_1_0(s), _copysign_0_0(c)) if pred else \
87 (_copysign_0_0(s), _copysign_1_0(c))
90def _other(x, unit=Radians, **unused):
91 # get C{x} as C{Ang} from C{Degrees}, C{Radians} or C{Lambertian}
92 return Ang.fromLambertian(x) if unit is Lambertian else (
93 Ang.fromRadians(x) if _isRadians(x, iscalar=unit is Radians) else (
94 Ang.fromDegrees(x) if _isDegrees(x, iscalar=unit is Degrees) else
95 _raiseError(unit, x))) # PYCHOK indent
98def _raiseError(unit, arg, **kwds):
99 raise TypeError(unstr(unit, arg, **kwds))
102def _rnd(x):
103 w = _ZRND - fabs(x)
104 if w > 0:
105 x = _copysign(_ZRND - w, x)
106 return x
109def _scnu4(s, c, n, unit=Radians, **unused): # unit=Ang._unit
110 s, c, n = map1(float, s, c, n)
111 return _normalize2(s, c) + (n, unit)
114class Ang(_Named):
115 '''An accurate representation of angles, as 3-tuple C{(s, c, n)}.
117 This class represents an angle via its sine C{s}, cosine C{c} and
118 the number of full turns C{n}. The angle is then C{atan2(s, c) +
119 n * PI2}. This representation offers several advantages:
121 - cardinal directions (multiples of 90 degrees) are exactly represented
122 (a benefit shared by representing angles as degrees)
124 - angles very close to any cardinal direction are accurately represented
126 - there's no loss of precision with large angles (outside the "normal"
127 range [-180, +180])
129 - various operations, such as adding a multiple of 90 degrees to an
130 angle are performed exactly.
132 @note: B{C{n}} is a C{float}, this allows it to be NAN, INF or NINF.
133 '''
134 _unit = Radians # see _scnu4
136 def __init__(self, s_ang=0, c=None, n=0, normal=True, **unit_name):
137 '''New L{Ang}.
139 @kwarg s_ang: A previous L{Ang}, C{Degrees}, C{Radians} if C{B{c}
140 is None}, otherwise the sine component (C{float}).
141 @kwarg c: The cosine component (C{float}) iff C{not None}.
142 @kwarg n: The number of L{PI2} turns (C{float}).
143 @kwarg normal: If C{True}, B{C{s}} and B{C{c}} are normalized, i.e.
144 on the unit circle (C{boo}).
145 @kwarg unit_name: Type C{B{unit}=}L{Radians} or L{Degrees} of scalar
146 scalar values (L{Degrees} or L{Radians}).
148 @note: Either B{C{s}} or B{C{c}} can be INF or NINF, but not both.
150 @note: By default, the point B{C{(s, c)}} is scaled to lie on the
151 unit circle.
152 '''
153 s, c, n, u = s_ang.scnu4 if isAng(s_ang) else (
154 _other(s_ang, **unit_name).scnu4 if c is None else
155 _scnu4(s_ang, c, n, **unit_name))
156 if unit_name: # Error=... from _NamedTuple.toUnits()
157 u = _xkwds_get(unit_name, unit=u)
158 name = _xkwds_get(unit_name, name=NN)
159 if name:
160 self.name = name
161 self._n = _fint(n)
162 self._s, self._c = (s, c) if normal else _normalize2(s, c)
163 self.unit = u
165 def __abs__(self):
166 s, _ = self._float2()
167 return self._float1(fabs(s))
169 def __add__(self, other):
170 return self.copy().__iadd__(other)
172 def __bool__(self): # PYCHOK Python 3+
173 s, c, n = self.scn3
174 return bool(s or c or n)
176# def __call__(self, *args, **kwds): # PYCHOK no cover
177# return self._NotImplemented(*args, **kwds)
179 def __ceil__(self): # PYCHOK not special in Python 2-
180 s, _ = self._float2()
181 return self._float1(_ceil(s))
183 def __cmp__(self, other): # PYCHOK no cover
184 s, r = self._float2(other)
185 return _signOf(s, r) # -1, 0, +1
187 def __divmod__(self, other):
188 s, r = self._float2(other)
189 q, r = divmod(s, r)
190 return q, self._float1(r)
192 def __eq__(self, other):
193 s, r = self._float2(other)
194 return fabs(s - r) < EPS0
196 def __float__(self):
197 u = self.unit
198 return self.radians if u is Radians else (
199 self.degrees if u is Degrees else (
200 self.lambertian if u is Lambertian else
201 _raiseError(float, u))) # PYCHOK indent
203 def __floor__(self): # PYCHOK not special in Python 2-
204 s, _ = self._float2()
205 return self._float1(_floor(s))
207 def __floordiv__(self, other):
208 return self.copy().__ifloordiv__(other)
210# def __format__(self, *other): # PYCHOK no cover
211# return self._NotImplemented(self, *other)
213 def __ge__(self, other):
214 s, r = self._float2(other)
215 return s >= r
217 def __gt__(self, other):
218 s, r = self._float2(other)
219 return s > r
221 def __hash__(self): # PYCHOK no cover
222 # @see: U{Notes for type implementors<https://docs.Python.org/
223 # 3/library/numbers.html#numbers.Rational>}
224 return hash(self.scn3) # tuple.__hash__()
226 def __iadd__(self, other):
227 p = self._other(other)
228 q = p.ncardinal + self.ncardinal
229 s, c, n = self.scn3
230 s, c = _normalize2(s * p.c + c * p.s,
231 c * p.c - s * p.s)
232 q -= _ncardinal(s, c, n)
233 n = _fint(q * _0_25) + p.n
234 if n:
235 self._n += n
236 self._s = s
237 self._c = c
238 _update_all(self)
239 return self._update(s, c)
241 def __ifloordiv__(self, other):
242 s, r = self._float2(other)
243 return self._ifloat(s // r)
245 def __imatmul__(self, other): # PYCHOK no cover
246 return self._notImplemented()
248 def __imod__(self, other):
249 s, r = self._float2(other)
250 return self._ifloat(s % r)
252 def __imul__(self, other):
253 s, r = self._float2(other)
254 return self._ifloat(s * r)
256 def __int__(self):
257 s, _ = self._float2(0)
258 return int(s)
260 def __invert__(self): # PYCHOK no cover
261 # Luciano Ramalho, "Fluent Python", O'Reilly, 2nd Ed, 2022 p. 567
262 return self._notImplemented()
264 def __ipow__(self, other, *mod): # PYCHOK 2 vs 3 args
265 s, r = self._float2(other)
266 return self._ifloat(pow(s, r, *mod))
268 def __isub__(self, other):
269 return self.__iadd__(-other)
271# def __iter__(self):
272# '''
273# return self._NotImplemented()
275 def __itruediv__(self, other):
276 s, r = self._float2(other)
277 return self._ifloat(s / r)
279 def __le__(self, other):
280 s, r = self._float2(other)
281 return s <= r
283 def __lt__(self, other):
284 s, r = self._float2(other)
285 return s < r
287 def __matmul__(self, other): # PYCHOK no cover
288 return self._notImplemented(other)
290 def __mod__(self, other):
291 s, r = self._float2(other)
292 return self._float1(s % r)
294 def __mul__(self, other):
295 return self.copy().__imul__(other)
297 def __ne__(self, other):
298 return not self.__eq__(other)
300 def __neg__(self):
301 s, c, n = self.scn3
302 s, n = _flipsign(s), _flipsign(n)
303 return self._Ang(s, c, n) # normal=True
305 def __pos__(self):
306 return self if _pos_self else self.copy()
308 def __pow__(self, other, *mod): # PYCHOK 2 vs 3 args
309 return self.copy().__ipow__(other, *mod)
311 def __radd__(self, other):
312 return self._other(other) + self
314 def __rdivmod__(self, other):
315 return divmod(self._other(other), self)
317 def __repr__(self):
318 return self.toRepr()
320 def __rfloordiv__(self, other):
321 return self._other(other) // self
323 def __rmatmul__(self, other): # PYCHOK no cover
324 return self._notImplemented(self, other)
326 def __rmod__(self, other):
327 return self._other(other) % self
329 def __rmul__(self, other):
330 return self._other(other) * self
332 def __round__(self, *ndigits): # PYCHOK Python 3+
333 return self.round(*ndigits)
335 def __rpow__(self, other, *mod):
336 return pow(self._other(other), self, *mod)
338 def __rsub__(self, other):
339 return self._other(other) - self
341 def __rtruediv__(self, other):
342 return self._other(other) / self
344 def __str__(self):
345 return self.toStr(0) # ignore turns
347 def __sub__(self, other):
348 return self.copy().__isub__(other)
350 def __truediv__(self, other):
351 return self.copy().__itruediv__(other)
353 __trunc__ = __int__
355 if _MODS.sys_version_info2 < (3, 0): # PYCHOK no cover
356 # <https://docs.Python.org/2/library/operator.html#mapping-operators-to-functions>
357 __div__ = __truediv__
358 __idiv__ = __itruediv__
359 __long__ = __int__
360 __nonzero__ = __bool__
361 __rdiv__ = __rtruediv__
363 def _Ang(self, s, *cn, **normal_unit_name):
364 # return an C{Ang} like C{self}
365 return Ang(s, *cn, **self._kwds(normal_unit_name))
367 def base(self, *center):
368 '''Return this C{Angle}'s base, optionally centered.
369 '''
370 r = self.copy()
371 if center:
372 c = self._other(center[0])
373 b = self - c
374 b = b.base()
375 b += c
376 r.n0 = b.n0
377 else:
378 r.n = 0
379 return r
381 @property_RO
382 def c(self):
383 '''Get the cosine of this C{Angle} (C{float}).
384 '''
385 return self._c
387 @staticmethod
388 def cardinal(q=0, **unit_name):
389 '''A cardinal direction.
391 @kwarg q: The number of I{quarter} turns (C{scalar}).
393 @return: An C{Ang} equivalent to B{C{q}} quarter turns.
395 @note: B{C{q}} is truncated to an integer and signed
396 C{0} is distinguished. C{Ang.NAN} is returned
397 if B{C{q}} is not finite.
398 '''
399 if _isfinite(q):
400 if q:
401 q = _fint(q)
402 i = int(remainder(q, _4_0)) # i is in [-2, 2]
403 n = _fint((q - i) * _0_25)
404 s, c = _CARDINAL2(i, ((_0_0 if q else q), _1_0))
405 t = s is not q
406 else:
407 s, c, n, t = _copysign_0_0(q), 1, 0, True
408 r = Ang(s, c, n, normal=t, **unit_name)
409 else:
410 r = Ang.NAN(**unit_name)
411 return r
413 def copy(self, **unit_name): # PYCHOK signature
414 '''Return a copy of this C{Ang}.
415 '''
416 return self._Ang(self, **self._kwds(unit_name))
418 @Property_RO
419 def degrees(self):
420 '''Get this C{Ang} in C{degrees}.
421 '''
422 d = self.degrees0
423 if self.n:
424 d += self.n * _360_0
425 return d # XXX Degrees(d, self.name)
427 @Property_RO
428 def degrees0(self):
429 '''Get this C{Ang} in C{degrees} ignoring the turns.
430 '''
431 return atan2d(*self.sc2) # XXX Degrees(d, self.name)
433 divmod = __divmod__
435 @staticmethod
436 def EPS0(**unit_name):
437 '''Get a tiny C{Ang}.
439 @note: This allows angles extremely close to the cardinal
440 directions to be generated. The C{.round} method
441 will flush this angle to C{0}.
442 '''
443 return Ang(_EPS03, 1, **unit_name)
445 @staticmethod
446 def _flip(bet, omg, alp=None):
447 '''(INTERNAL) Reflect C{bet}, C{omg} and C{alp} inplace.
448 ''' # Ellipsoid3.Flip
449 bet.reflect(flipc=True)
450 omg.reflect(flips=True)
451 if alp:
452 alp.reflect(flips=True, flipc=True)
454 def flipsign(self, mul=-1, **name):
455 '''Copy this C{Ang} with sign flipped.
456 '''
457 r = (-self) if signBit(mul) else self
458 return self._Ang(r, **name) if name else r
460 def _float1(self, f, **name):
461 # return C{f} as C{Ang} in this C{unit}
462 return _Ang_from[self.unit](f, **name)
464 def _float2(self, other=None):
465 # get self and C{other} as floats
466 r = other if other is None or isinstance(other, int) else \
467 float(_Ang_from[self.unit](other))
468 return float(self), r
470 def _ifloat(self, f): # PYCHOK expected
471 # set self to C{f} degrees or radians
472 scn = self._float1(f).scn3
473 self._s, self._c, self._n = scn
474 return self._update()
476 @staticmethod
477 def fromDegrees(deg, **unit_name):
478 '''Get an C{Ang} from degrees.
479 '''
480 if isAng(deg):
481 s, c, n = deg.scn3
482 d = deg.degrees0
483 elif _isDegrees(deg, iscalar=True):
484 s, c = sincos2d(deg)
485 d = atan2d(s, c)
486 n = round((deg - d) / _360_0)
487 else:
488 _raiseError(Ang.fromDegrees, deg, **unit_name)
489 a = Ang(s, c, n, **_xkwds(unit_name, unit=Degrees))
490 a.__dict__.update(degrees0=d) # Property_RO
491 return a
493 @staticmethod
494 def fromLambertian(psi, **unit_name):
495 '''Get an C{Ang} from C{lamberterian} radians.
496 '''
497 s = psi.lambertian if isAng(psi) else sinh(psi)
498 return Ang(s, 1, normal=False, **_xkwds(unit_name, unit=Lambertian))
500 @staticmethod
501 def fromRadians(rad, **unit_name):
502 '''Get an C{Ang} from radians.
503 '''
504 if isAng(rad):
505 s, c, n = rad.scn3
506 r = rad.radians0
507 elif _isRadians(rad, iscalar=True):
508 s, c = sincos2(rad)
509 r = atan2(s, c)
510 n = round((rad - r) / PI2)
511 else:
512 _raiseError(Ang.fromRadians, rad, **unit_name)
513 a = Ang(s, c, n, **_xkwds(unit_name, unit=Radians))
514 a.__dict__.update(radians0=r) # Property_RO
515 return a
517 @staticmethod
518 def fromScalar(ang, **unit_name):
519 '''Get an C{Ang} from C{Degrees}, C{Radians} or another C{Ang}.
520 '''
521 if isAng(ang):
522 r = Ang(ang, **_xkwds(unit_name, unit=ang.unit))
523 else:
524 u = _xkwds_get(unit_name, unit=None)
525 if u is Lambertian:
526 r = Ang.fromLambertian(ang, **unit_name)
527 elif _isDegrees(ang, iscalar=u is Degrees):
528 r = Ang.fromDegrees(ang, **unit_name)
529 elif _isRadians(ang, iscalar=u is Radians):
530 r = Ang.fromRadians(ang, **unit_name)
531 else:
532 _raiseError(Ang.fromScalar, ang, **unit_name)
533 return r
535 def is_integer(self, *n):
536 '''Is this C{Ang}'s degrees C{integer}? (C{bool}).
537 '''
538 return self.toDegrees(*n).is_integer()
540 def isnear0(self, eps0=EPS0): # aka zerop
541 '''Is this C{Ang} near C{0} within a tolerance?
542 '''
543 s, c, n = self.scn3
544 return bool(n == 0 and c > 0 and fabs(s) <= eps0)
546 def _kwds(self, kwds, **dflt):
547 return _xkwds(kwds, **_xkwds(dflt, unit=self.unit,
548 name=self.name))
550 @Property_RO
551 def lambertian(self):
552 '''Get this C{Ang}'s Lambertian, C{asinh(tan(radians))}.
553 '''
554 return asinh(self.t) # XXX Lambertian(self.t)
556 def mod(self, mul=_1_0, **unit_name):
557 '''Return the I{reduced latitude} C{atan(B{mul} *
558 tan(B{this}))} as an C{Ang}.
560 @arg mul: Factor (C{scalar}, positive).
562 @note: The quadrant of the result tracks that of
563 this C{Ang} through multiples turns.
564 '''
565 kwds = self._kwds(unit_name)
566 if signBit(mul):
567 r = self._Ang(Ang.NAN(), **kwds)
568 else:
569 s, c, n = self.scn3
570 if mul > 1:
571 c = c / mul # /= chokes PyChecker
572 else: # mul <= 1
573 s *= mul
574 r = self._Ang(s, c, n, normal=False, **kwds)
575 return r
577 @staticmethod
578 def N(**unit_name):
579 '''Get North C{Ang}.
580 '''
581 return Ang(0, 1, **unit_name)
583 @property
584 def n(self):
585 '''Return the number of turns (C{float}) or C{0.0}.
586 '''
587 return self._n or _0_0
589 @n.setter # PYCHOK setter!
590 def n(self, n):
591 self._n_0(_fint(n))
593 def _n_0(self, n):
594 '''(INTERNAL) Set C{n} or C{n0}.
595 '''
596 if self._n != n:
597 self._n, n = n, self._n
598 self._update()
599 return n
601 @property
602 def n0(self):
603 '''Return the number of turns, treating C{-180} as C{180 - 1 turn} (C{float}).
604 '''
605 return (self.n - self._n01) or _0_0
607 @n0.setter # PYCHOK setter!
608 def n0(self, n):
609 self._n_0(_fint(n) + self._n01)
611 @Property_RO
612 def _n01(self):
613 s, c = self.sc2
614 return int(c < 0 and s == 0 and signBit(s))
616 @staticmethod
617 def NAN(**unit_name):
618 '''Get an invalid C{Ang}.
619 '''
620 return Ang(NAN, NAN, **unit_name)
622 @Property_RO
623 def ncardinal(self):
624 '''Get the nearest cardinal direction (C{float_int}).
626 @note: This is the reverse of C{cardinal}.
627 '''
628 return _ncardinal(*self.scn3)
630 def nearest(self, ind=0, **name):
631 '''Return the closest cardinal direction (C{Ang}).
633 @arg ind: An indicator, if C{B{ind}=0} the closest cardinal
634 direction, otherwise, if B{C{ind}} is even, the
635 closest even (N/S) cardinal direction or if B{C{ind}}
636 is odd, the closest odd (E/W) cardinal direction.
637 '''
638 s, c, n = self.scn3
639 p = (ind == 0 and fabs(s) > fabs(c)) or (ind & 1)
640 s, c = _orthogonal2(p, s, c)
641 return self._Ang(s, c, n, **self._kwds(name))
643 @staticmethod
644 def _norm(bet, omg, alp=None, alt=False):
645 '''(INTERNAL) Put C{bet}, C{ong} and C{alp} in range.
646 ''' # Ellipsoid3.AngNorm
647 flip = signBit(omg.s if alt else bet.c)
648 if flip:
649 Ang._flip(bet, omg, alp)
650 return flip
652 def normalize(self, *n):
653 '''Re-normalize this C{Ang}, optionally replacing turns.
654 '''
655 sc = _normalize2(*self.sc2)
656 if n:
657 self.n, n = n[0], self.n
658 if self.n != n: # updated
659 self._s, self._c = sc
660 return self
661 return self._update(*sc)
663 def _other(self, other):
664 # get C{other} as C{Ang} from C{unit}
665 return other if isAng(other) else _other(other, self.unit)
667 pow = __pow__
669 @Property_RO
670 def _quadrant(self):
671 s, c = map(int, map(signBit, self.sc2))
672 return s + s + (c ^ s)
674 @property_doc_("this C{Ang}'s quadrant (C{int} 0..3)")
675 def quadrant(self):
676 return self._quadrant
678 @quadrant.setter # PYCHOK setter!
679 def quadrant(self, quadrant):
680 s, c = map(fabs, self.sc2)
681 q = int(quadrant)
682 if (q & 2):
683 s = -s # _copysign(self.s, -1 if (q & 2) else 1)
684 if (((q >> 1) ^ q) & 1):
685 c = -c # _copysign(self.c, -1 if (((q >> 1) ^ q) & 1) else 1)
686 self._update(s, c)
688 @Property_RO
689 def radians(self):
690 '''Get this C{Ang} in C{radians}.
691 '''
692 r = self.radians0
693 if self.n:
694 r += self.n * PI2
695 return r # XXX Radians(r, self.name)
697 @Property_RO
698 def radians0(self):
699 '''Get this C{Ang} in C{radians} ignoring the turns.
700 '''
701 return atan2(*self.sc2) # XXX Radians(r, self.name)
703 def reflect(self, flips=False, flipc=False, swapsc=False):
704 '''Reflect this C{Ang} in various ways.
706 @kwarg flips: Flip the sign of C{s}.
707 @kwarg flipc: Flip the sign of C{c}.
708 @kwarg swapsc: Swap C{s} and C{c}.
710 @note: The operations are carried out in the order
711 of the arguments.
712 '''
713 s, c = self.sc2
714 if flips:
715 s = -s
716 if flipc:
717 c = -c
718 if swapsc:
719 s, c = c, s
720 return self._update(s, c)
722 def round(self, *ndigits, **name):
723 '''Return this C{Ang}, optionally rounded to C{ndigits} (C{Ang}).
724 '''
725 s, c, n = self.scn3
726 if ndigits:
727 s = round(s, *ndigits)
728 c = round(c, *ndigits)
729 else:
730 s, c = map1(_rnd, s, c)
731 return self._Ang(s, c, n, **self._kwds(name))
733 @property_RO
734 def s(self):
735 '''Get the sine of this C{Ang} (C{float}).
736 '''
737 return self._s
739 @property_RO
740 def sc2(self):
741 '''Get the 2-tuple C{(s, c)}.
742 '''
743 return self.s, self.c
745 @Property_RO
746 def scn3(self):
747 '''Get the 3-tuple C{(s, c, n)}.
748 '''
749 return self.s, self.c, self.n
751 @property_RO
752 def scnu4(self):
753 '''Get the 4-tuple C{(s, c, n, unit)}.
754 '''
755 return self.s, self.c, self.n, self.unit
757 def shift(self, q=0, **unit_name):
758 '''Shift this C{Ang} by C{q} I{quarter} turns (C{scalar}).
759 '''
760 kwds = self._kwds(unit_name)
761 if _isfinite(q):
762 s = self.copy(**kwds)
763 if q:
764 s -= Ang.cardinal(q)
765 else:
766 s = Ang.NAN(**kwds)
767 return s
769 def signOf(self, *n):
770 '''Determine this C{Ang}'s sign, optionally replacing the turns.
772 @return: The sign (C{int}, -1, 0 or +1).
773 '''
774 return _signOf(self.toDegrees(*n), 0)
776 @Property_RO
777 def t(self):
778 '''Get the tangent of this C{Ang} (C{float}).
779 '''
780 return _over(*self.sc2)
782 def toDegrees(self, *n):
783 '''Return this C{Ang} as C{Degrees}, optionally replacing the turns.
784 '''
785 if n:
786 d = self.degrees0
787 n = float(n[0])
788 if n:
789 d += n * _360_0
790 else:
791 d = self.degrees
792 return Degrees(d, self.name)
794 def toLambertian(self, **name):
795 '''Return this C{Ang} as L{Lambertian}.
796 '''
797 name = _xkwds(name, name=self.name)
798 return Lambertian(self.lambertian, **name)
800 def toRadians(self, *n):
801 '''Return this C{Ang} as C{Radians}, optionally replacing the turns.
802 '''
803 if n:
804 r = self.radians0
805 n = float(n[0])
806 if n:
807 r += n * PI2
808 else:
809 r = self.radians
810 return Radians(r, self.name)
812 def toRepr(self, *n, **prec_fmt): # PYCHOK signature
813 '''Return this C{Ang} as C{"<name>(<value>)"} with/out turns (C{str}).
814 '''
815 return self.toUnit(*n).toRepr(**prec_fmt)
817 def toStr(self, *n, **prec_fmt): # PYCHOK signature
818 '''Return this C{Ang} as C{"<value>"} with/out turns (C{str}).
819 '''
820 return self.toUnit(*n).toStr(**prec_fmt)
822 def toTuple(self, **prec_fmt_sep):
823 '''Return string C{"(s, c, n)"} or tuple C{('s', 'c', 'n')} if C{sep is None}.
824 '''
825 return fstr(self.scn3, **prec_fmt_sep)
827 def toUnit(self, *n):
828 '''Return this C{Ang} as C{self.unit}s, optionally replacing the turns.
829 '''
830 u = self.unit
831 return self.toRadians(*n) if u is Radians else (
832 self.toDegrees(*n) if u is Degrees else (
833 self.toLambertian() if u is Lambertian else
834 _raiseError(self.toUnit, u))) # PYCHOK indent
836 @property_doc_(' the scalar unit to L{Degrees} or L{Radians}')
837 def unit(self):
838 return self._unit
840 @unit.setter # PYCHOK setter!
841 def unit(self, unit):
842 if unit not in _Ang_types: # PYCHOK no cover
843 _raiseError(Ang.unit, unit)
844 if self._unit != unit:
845 self._unit = unit
847 def _update(self, *sc):
848 if sc:
849 if sc == self.sc2:
850 return self
851 self._s, self._c = sc
852 _update_all(self)
853 return self
855_allPropertiesOf_n(14, Ang) # PYCHOK assert
858class _Ang3Tuple(_NamedTuple):
859 '''(INTERNAL) Methods C{.toDegrees}, C{.toLambertian}, C{.toRadians} and C{.toUnit}.
860 '''
861 _Names_ = (Ang.__name__,) * 3 # needed for ...
862 _Units_ = Ang, Ang, _Pass # ...testNamedTuples
864 def toDegrees(self, *n, **fmt_prec_sep):
865 '''Change any C{Ang} to C{unit Degrees} or to C{Degrees.toStr} if any B{C{fmt_prec_sep}}.
866 '''
867 t = self.toUnit(Degrees, *n)
868 if fmt_prec_sep: # see C{Degrees.toStr}
869 sep, fmt_prec = _xkwds_pop2(fmt_prec_sep, sep=_COMMASPACE_)
870 s = self.toStr(sep=None) if sep else self
871 t = (a.toStr(**fmt_prec) if isAng(a) else s for a, s in zip(t, s))
872 t = Fmt.PAREN(sep.join(t)) if sep else tuple(t)
873 return t
875 def toLambertian(self):
876 '''Change any C{Ang} to C{unit Lambertian}.
877 '''
878 return self.toUnit(Lambertian)
880 def toRadians(self, *n):
881 '''Change any C{Ang} to C{unit Radians}.
882 '''
883 return self.toUnit(Radians, *n)
885 def toUnit(self, unit, *n):
886 '''Change any C{Ang} to C{unit}, optional name C{n}.
887 '''
888 for a in self:
889 if isAng(a): # and a.unit is not unit:
890 a.unit = unit
891 if n:
892 a.n = n[0]
893 return self
896class Lambertian(Radians):
897 '''A C{Lambertian} in C{radians}.
898 '''
899 def __new__(cls, *args, **kwds):
900 return Radians.__new__(cls, *args, **_xkwds(kwds, name='psi'))
903_Ang_from = {Radians: Ang.fromRadians,
904 Degrees: Ang.fromDegrees,
905 Lambertian: Ang.fromLambertian}
906_Ang_types = tuple(_Ang_from.keys()) # PYCHOK used!
909def Ang_(s, c=None, n=1, **unit_name):
910 '''(INTERNAL) New, non-normal C{Ang}.
911 '''
912 return Ang(s, c, n, **_xkwds(unit_name, normal=False))
915def Deg(deg, **name):
916 '''Return an L{Ang} from C{deg} degrees or an other L{Ang}.
917 '''
918 return Ang(deg, unit=Degrees, **name)
921def isAng(ang):
922 '''Is C{ang} an L{Ang} instance?
923 '''
924 return isinstance(ang, Ang)
927def Rad(rad, **name):
928 '''Return an L{Ang} from C{rad} radians or an other L{Ang}.
929 '''
930 return Ang(rad, unit=Radians, **name)
933def _SinCos2(ang, *unit):
934 '''Get C{sin} and C{cos} of an L{Ang}, any I{typed} C{ang}le
935 or C{unit} if C{ang}le is scalar.
937 @see: Function L{SinCos2<pygeodesy.utily.SinCos2>}.
938 '''
939 return ang.sc2 if isAng(ang) else SinCos2(ang, *unit)
941# **) MIT License
942#
943# Copyright (C) 2025-2026 -- mrJean1 at Gmail -- All Rights Reserved.
944#
945# Permission is hereby granted, free of charge, to any person obtaining a
946# copy of this software and associated documentation files (the "Software"),
947# to deal in the Software without restriction, including without limitation
948# the rights to use, copy, modify, merge, publish, distribute, sublicense,
949# and/or sell copies of the Software, and to permit persons to whom the
950# Software is furnished to do so, subject to the following conditions:
951#
952# The above copyright notice and this permission notice shall be included
953# in all copies or substantial portions of the Software.
954#
955# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
956# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
957# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
958# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
959# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
960# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
961# OTHER DEALINGS IN THE SOFTWARE.