#!/usr/local/bin/python
# coding: iso-8859-1
# Copyright © 2008 by Amos Newcombe
# 
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License along with this program.  If not, see <http://www.gnu.org/licenses/>.

'''A library class for angles.

You can specify either radians or degrees for an angle, and the constructor
calculates the other and saves them both.

Trig functions are methods in the angle class; the major inverse trig functions
are Python functions that return angles. Both 1- and 2-argument versions are
included for arctan.

>>> arcsin(.866)
angle(rad=1.047147, deg=59d59'49.521")
>>> arctan2(1, 0)
angle(rad=1.570796, deg=90d0'0.000")
'''

from math import pi, degrees, radians, ldexp, modf, sin, cos, tan, asin, acos, atan, atan2

class angle:
	'''A class to deal with angles in their various units.
	
	The constructor expects either a radian value (rad=x), a decimal degree value
	(deg=x), or a degree-minute-second tuple (deg=(d, m, s), but it can have more or
	fewer components). If the argument name is missing it defaults to radians,
	unless the value is a tuple, when it defaults to degrees.
	
	>>> angle(3.14)
	angle(rad=3.140000, deg=179d54'31.492")
	>>> angle(deg=(23, 56, 4, 6))
	angle(rad=0.417735, deg=23d56'4.100")
	>>> angle((30,))
	angle(rad=0.523599, deg=30d0'0.000")
	
	You can add and subtract angles with each other; you can multiply an angle by a
	scalar and vice versa; you can divide to angles to get a scalar, and divide an
	angle by a scalar to get an angle. You can't divide a scalar by an angle.

	>>> angle(rad=pi) - angle(deg=90)
	angle(rad=1.570796, deg=90d0'0.000")
	>>> 1/angle(pi)
	Traceback (most recent call last):
	...
	TypeError: unsupported operand type(s) for /: 'int' and 'instance'
	
	Each trigonometric function has its own method.
	'''
	
	# ¤ Creation
	
	def __init__(self, rad=None, deg=None):
		'''Initialize an angle with a value in either radians (default) or degrees.
		
		You can specify both, but the values must be compatible (to about 10 decimal
		places in radians) or you will raise a 'ConflictingAngle' ValueError. See
		SettleConflict() for details. You can also specify neither, but such an instance
		will raise an 'Uninitialized' ValueError on any call to getDeg() or getRad().

		>>> angle(pi/6, 30)
		angle(rad=0.523599, deg=29d59'60.000")
		>>> angle(1.571, 90)
		Traceback (most recent call last):
		...
		ConflictingAngle: rad = 1.571, deg = 90.0
		>>> angle() + angle()
		Traceback (most recent call last):
		...
		UninitializedAngle
		'''
		if deg != None:
			if isinstance(deg, tuple): deg = Reduce(deg)
			else                     : deg = float(deg)
		if rad != None:
			if deg == None and isinstance(rad, tuple):
				deg = Reduce(rad)
				rad = None
			else:
				rad = float(rad)
		if (deg != None) and (rad != None):
			deg, rad = self.SettleConflict(deg, rad)
		self.setDeg(deg)
		self.setRad(rad)
	
	def SettleConflict(self, deg, rad):
		xDiff = rad - (deg * pi / 180.)
		if abs(xDiff) < ConflictingAngle.xThreshhold: # OK, they're practically the same,
			rad = (rad + radians(deg)) / 2.               # so use the average.
			deg = degrees(rad)
		else:                                         # Nope, they're different,
			raise ConflictingAngle, (rad, deg)            # so bitch and moan.
		return deg, rad
	
	def setDeg(self, deg): self.deg = deg
	def getDeg(self):
		if self.deg == None:
			if self.rad == None: raise UninitializedAngle
			else               : self.deg = degrees(self.rad)
		return self.deg
	
	def setRad(self, rad): self.rad = rad
	def getRad(self):
		if self.rad == None:
			if self.deg == None: raise UninitializedAngle
			else               : self.rad = radians(self.deg)
		return self.rad
	
	# ¤ Description
	
	def __repr__(self):
		return '%s(rad=%f, deg=%s)' % (self.__class__.__name__, self.getRad(), self.ToDms())
	
	def ToDms(self):
		tpn = Expand(self.getDeg(), 2) # tpn of length 3
		return '%dd%d\'%.3f"' % tpn
	
	# ¤ Arithmetic
	
	# add and subtract angles from each other
	
	def __add__(self, other): return angle(rad=self.getRad() + other.getRad())
	def __sub__(self, other): return angle(rad=self.getRad() - other.getRad())
	
	# reversals should never be called
# 	def __radd__(self, other): return other + self
# 	def __rsub__(self, other): return other - self
	
	def __iadd__(self, other): self.setRad(self.getRad() + other.getRad())
	def __isub__(self, other): self.setRad(self.getRad() - other.getRad())
	
	# multiply angles by scalars
	
	def __mul__ (self, other): return angle(rad=self.getRad() * other)
	def __rmul__(self, other): return angle(rad=self.getRad() * other)
	
	def __imul__(self, other): self.setRad(self.getRad() * other)
	
	# divide angles by angles to return a scalar, or by scalars to return an angle
	# but don't divide a scalar by an angle
	
	def __div__(self, other):
		if isinstance(other, angle): return angle(self.getRad() / other.getRad())
		else                       : return angle(self.getRad() / other)
	
	def __idiv__(self, other): self.setRad(self.getRad() / other)
	
	def __truediv__(self, other): return self.__div__(other)
	def __itruediv__(self, other): self.__idev__(other)
	
	def __floordiv__(self, other): return angle(self.getRad() // other)
	def __ifloordiv__(self, other): self.setRad(self.getRad() // other)
	
	def __mod__(self, other):
		if isinstance(other, angle): return angle(self.getRad() % other.getRad())
		else                       : return angle(self.getRad() % other)
	
	def __imod__(self, other): self.setRad(self.getRad() % other)
	
	# unary operations
	
	def __neg__(self): return angle(rad = - self.getRad())
	def __pos__(self): return angle(rad = + self.getRad())
	
	def __float__(self): return self.getRad()
	
	# ¤ Trigonometry
	
	def cos(self): return cos(self.getRad())
	def sin(self): return sin(self.getRad())
	def tan(self): return tan(self.getRad())
	
	def sec(self): return 1. / self.cos()
	def csc(self): return 1. / self.sin()
	def cot(self): return 1. / self.tan()

def arcsin(x): return angle(rad=asin(x))
def arccos(x): return angle(rad=acos(x))
def arctan(x): return angle(rad=atan(x))
def arctan2(y, x): return angle(rad=atan2(y, x))

def Expand(x, cLevel=2, nBase=60.):
	'''Expand a number into a base-60 (default) tuple.
	
	The first component can be any integer; subsequent ones are base 60.
	Parameter cLevel counts the accuracy of the representation: it defaults to
	2, which gives degrees, minutes and seconds, but it can be more or less.
	Parameter nBase chooses the base.
	'''
	l = []
	while 0 < cLevel:
		xF, x = modf(x)
		l.append(int(x))
		x = xF * nBase
		cLevel -= 1
	l.append(x)
	return tuple(l)

def Reduce(tpn, nBase=60.):
	'''Reduce a base-60 (default) tuple to a single number.
	
	This is the inverse of Expand().
	'''
	xSum, xFactor = 0., 1.
	for n in tpn:
		xSum    += n / xFactor
		xFactor *= nBase
	return xSum

class ConflictingAngle(ValueError):
	
	xThreshhold = ldexp(1., -32) # 2**-32 ~= 2.3e-10
	
	def __init__(self, rad, deg): self.sMsg = 'rad = %s, deg = %s' % (rad, deg)
	def  __str__(self): return self.sMsg

class UninitializedAngle(ValueError): pass

if __name__ == '__main__':
	
	# Unit tests
	import doctest
	print '%d tests failed out of %d' % doctest.testmod()
	
	# Command line tool
	from commander.commander import evaluator
	import sys
	
	class anglecommander(evaluator):
		
		# ¤ Metadata
		
		sVersion = '%prog 2008.09.04'
		
		# ¤ Creation
		
		def options(self):
			'populate the option parser (self.optParser) with options'
			evaluator.options(self)
			self.optParser.add_option('-r', '--rad',
				action='append_const', const='r', dest='lchPut',
				help='output radians'
			)
			self.optParser.add_option('-d', '--deg',
				action='append_const', const='d', dest='lchPut',
				help='output decimal degrees'
			)
			self.optParser.add_option('-b', '--b60',
				action='append_const', const='b', dest='lchPut',
				help='output degrees, minutes, seconds in base 60'
			)
		
		def function(self, sArg):
			ang = ParseAngle(sArg)
			for ch in self.opt.lchPut:
				if ch == 'r': print ang.rad
				if ch == 'd': print ang.deg
				if ch == 'b': print ang.ToDms()
	
	anglecommander(sys.argv).main()
