from dataclasses import dataclass
import numpy as np
from giant.stellar_opnav.estimators.attitude_estimator import AttitudeEstimator, AttitudeEstimatorOptions
from giant.rotations import Rotation
from giant._typing import DOUBLE_ARRAY
[docs]
@dataclass
class ESOQ2Options(AttitudeEstimatorOptions):
"""
Options for the ESOQ2 attitude estimator.
:param n_iter: Number of iterations for lambda computation (default=10, use 0 for lam=1)
"""
n_iter: int = 10
"""
Number of iterations for lambda computation in ESOQ2 algorithm.
"""
[docs]
class ESOQ2(AttitudeEstimator, ESOQ2Options):
"""
Implements the ESOQ2 (Second Estimator of the Optimal Quaternion) solution to Wahba's problem.
This is a faster technique than Davenport's Q-Method solution but slightly less accurate.
This class is relatively easy to use. After you initialize the class, simply specify the
`target_frame_directions` unit vectors (:math:`\textbf{a}_i` from the :mod:`~.stellar_opnav.estimators`
documentation) as a 3xn array of vectors (each column is a vector) and the `base_frame_directions` unit
vectors (:math:`\textbf{b}_i` from the :mod:`~.stellar_opnav.estimators` documentation) as a 3xn array of
vectors (each column is a vector) in the call to :meth:`.estimate`. Here the `target_frame_directions`
unit vectors are expressed in the end frame (the frame you want to rotate to) and the
`base_frame_directions` unit vectors are expressed in the starting frame (the frame you want to rotate
from). Each column of `target_frame_directions` and `base_frame_directions` should
correspond to each other as a pair (i.e. column 1 in `target_frame_directions` is paired with column ` in
`base_frame_directions`.
Optionally, you can set the :attr:`weighted_estimation` value to `True` and then provide the
`weights` input to :meth:`estimate` to specify whether to use weighted estimation or not, and what weights to
use if you are using weighted estimation. The `weights` input should be a length n array of the weights to
apply to each unit vector pair.
The :meth:`estimate` method can be called to compute the attitude quaternion that best aligns the two frames.
When the :meth:`estimate` method completes, the solved for rotation is returned as an :class:`.Rotation` object.
In addition, the formal post fit covariance matrix of the estimate can be found in the :attr:`post_fit_covariance`
attribute. Note that as with all attitude quaternions, the post fit covariance matrix will be rank deficient
since there are only 3 true degrees of freedom.
"""
def __init__(self, options: ESOQ2Options | None = None) -> None:
"""
:param options: the options dataclass to use to configure this class
"""
super().__init__(ESOQ2Options, options=options)
self._attitude_prof_mat = None
"""
The attitude profile matrix.
This is used internally and is only set after a call to :meth:`estimate`. It is stored for reuse in the
computation of the post fit covariance matrix.
"""
self._solved_rotation: Rotation | None = None
"""
the solved for rotation from the base to the target frame.
This gets computed in the call to estimate. Users shouldn't use it, rely on the retunr from estimate instead.
"""
[docs]
def estimate(self, target_frame_directions: DOUBLE_ARRAY, base_frame_directions: DOUBLE_ARRAY, weights: DOUBLE_ARRAY | None = None) -> Rotation:
"""
This function uses the ESOQ2 algorithm to determine the optimal attitude quaternion
given sets of observed and reference unit vectors. It is based on Daniele Mortari's work.
:param target_frame_directions: Matrix of observed unit vectors (3xN)
:param base_frame_directions: Matrix of reference unit vectors (3xN)
:param weights: Vector of weights for each observation (N,) or None
:param n_iter: Number of iterations for lambda computation (default=5, use 0 for lam=1)
:returns: Near optimal attitude quaternion
.. Note::
This implementation is based on "Second Estimator of the Optimal Quaternion" by Daniele Mortari,
Journal of Guidance, Control, and Dynamics, Vol.23, No. 5, Sep-Oct 2000, pg 885-888.
"""
# Second Estimator of the Optimal Quaternion (Daniele Mortari, 01/05/2001)
# Function takes two matrices of vectors and determines the optimal quaternion
# in a least squares sense
if self.weighted_estimation:
assert weights is not None, "Weights miust not be None if doing weighted estimation"
target_frame_directions = weights.squeeze() * target_frame_directions
bmat = self.attitude_profile_matrix(base_frame_directions, target_frame_directions)
self._attitude_prof_mat = bmat.copy()
# Optimal 180 deg rotation
b_trace = np.trace(bmat)
b_diag = np.array([bmat[0,0], bmat[1,1], bmat[2,2], b_trace])
irot = np.argmin(b_diag)
b_min = b_diag[irot]
if irot < 3:
for i in range(3):
if i != irot:
bmat[:, i] *= -1.0
b_trace = 2.0 * b_min - b_trace
# Compute S matrix and z vector
smat = bmat + bmat.T
zvec = np.array([bmat[1,2] - bmat[2,1],
bmat[2,0] - bmat[0,2],
bmat[0,1] - bmat[1,0]])
zvec2 = zvec**2
# Lambda max computation
if self.n_iter == 0:
lam = 1.0
else:
tadj = (smat[1,1] * smat[2,2] - smat[1,2]**2 +
smat[0,0] * smat[2,2] - smat[0,2]**2 +
smat[1,1] * smat[0,0] - smat[0,1]**2)
trB2 = b_trace**2
aa = trB2 - tadj
bb = trB2 + np.sum(zvec2)
szvec = smat @ zvec
c2 = -aa - bb
if target_frame_directions.shape[1] == 2:
u = 2.0 * np.sqrt(aa * bb - np.sum(szvec**2))
lam = (np.sqrt(u - c2) + np.sqrt(-u - c2)) / 2.0
else:
c1 = (-(smat[0,0] * smat[1,1] * smat[2,2] +
2 * smat[0,1] * smat[1,2] * smat[0,2] -
smat[0,0] * smat[1,2]**2 -
smat[1,1] * smat[0,2]**2 -
smat[2,2] * smat[0,1]**2) -
np.dot(zvec, szvec))
c0 = aa * bb - c1 * b_trace - np.sum(szvec**2)
lam = 1.0
for k in range(self.n_iter):
lam2 = lam**2
lam = (lam2 * (3.0 * lam2 + c2) - c0) / (2.0 * lam * (2.0 * lam2 + c2) + c1)
tpl = b_trace + lam
smat[np.diag_indices(3)] -= tpl
tml = b_trace - lam
# Euler axis computation
mvec = np.array([tml * smat[0,0] - zvec2[0],
tml * smat[1,1] - zvec2[1],
tml * smat[2,2] - zvec2[2]])
mxvec = np.array([tml * smat[0,1] - zvec[0] * zvec[1],
tml * smat[0,2] - zvec[0] * zvec[2],
tml * smat[1,2] - zvec[1] * zvec[2]])
evec = np.array([mvec[1] * mvec[2] - mxvec[2]**2,
mvec[0] * mvec[2] - mxvec[1]**2,
mvec[0] * mvec[1] - mxvec[0]**2])
k = np.argmax(np.abs(evec))
if k == 0:
evec[1] = mxvec[1] * mxvec[2] - mxvec[0] * mvec[2]
evec[2] = mxvec[0] * mxvec[2] - mxvec[1] * mvec[1]
elif k == 1:
evec[0] = mxvec[1] * mxvec[2] - mxvec[0] * mvec[2]
evec[2] = mxvec[0] * mxvec[1] - mvec[0] * mxvec[2]
elif k == 2:
evec[0] = mxvec[0] * mxvec[2] - mxvec[1] * mvec[1]
evec[1] = mxvec[0] * mxvec[1] - mvec[0] * mxvec[2]
# Quaternion computation in rotated frame
quat = np.zeros(4)
quat[:3] = tml * evec
quat[3] = -np.dot(zvec, evec)
# Normalize
quat = quat / np.linalg.norm(quat)
# Undo rotation to get quaternion in input frame
if irot == 0:
quat = np.array([quat[3], -quat[2], quat[1], -quat[0]])
elif irot == 1:
quat = np.array([quat[2], quat[3], -quat[0], -quat[1]])
elif irot == 2:
quat = np.array([-quat[1], quat[0], quat[3], -quat[2]])
# irot == 3: no change needed
# Ensure positive scalar part
quat = quat * np.copysign(1.0, quat[3])
self._solved_rotation = Rotation(quat)
return self._solved_rotation.copy()
@property
def post_fit_covariance(self) -> DOUBLE_ARRAY:
"""
This returns the post-fit covariance after calling the ``estimate`` method as a 4x4 numpy array.
This should be only be called after the estimate method has been called, otherwise it raises a ValueError
Note that this uses the same covariance as Davenport's Q-Method solution, which is technically the lower bound.
"""
if self._attitude_prof_mat is None or self._solved_rotation is None:
raise ValueError('estimate method must be called before requesting the post-fit covariance')
# compute rotation matrix times attitude profile matrix transposed
tbt = self._solved_rotation.matrix@self._attitude_prof_mat.T
# compute the fisher information matrix
ftt = np.trace(tbt)*np.eye(3) - (tbt+tbt.T)/2
# form covariance matrix
cov = np.zeros((4, 4), dtype=np.float64)
cov[:3, :3] = np.linalg.inv(ftt)/4
return cov