Source code for ADCS.helpers.cholesky_update

r"""In-tree rank-1 Cholesky update and downdate.

Replaces the external ``choldate`` package, which has no PyPI release, must be
installed from git with ``--no-build-isolation``, and therefore made the whole
package impossible to ``pip install`` cleanly.

Convention (identical to ``choldate``): ``R`` is **upper triangular** with

.. math::

    A = R^\top R

``cholupdate(R, x)`` overwrites ``R`` in place with the factor of
:math:`A + x x^\top`; ``choldowndate(R, x)`` with the factor of
:math:`A - x x^\top`. Both use ``x`` as scratch space, so a copy is taken
internally and the caller's array is left untouched (``choldate`` clobbers it).

The algorithm is the standard LINPACK sequence of Givens rotations: one
rotation per row, each annihilating a component of ``x`` into the factor. It is
:math:`O(n^2)` and allocation-free.

**One deliberate difference from choldate.** On a downdate whose result is not
positive definite, ``choldate`` neither raises nor returns NaN -- it silently
computes :math:`\sqrt{|r^2|}` and returns a plausible-looking but wrong factor.
This implementation writes NaN instead, which is what the SRUAKF's existing
``np.any(np.isnan(...))`` guard was already written to catch.
"""

__all__ = ["cholupdate", "choldowndate"]

import numpy as np
from numba import njit


@njit(cache=True)
def _cholupdate_impl(R: np.ndarray, x: np.ndarray) -> None:
    n = R.shape[0]
    for k in range(n):
        Rkk = R[k, k]
        xk = x[k]
        r = np.sqrt(Rkk * Rkk + xk * xk)
        if Rkk == 0.0:
            # Degenerate factor: the rotation is undefined. Propagate NaN
            # rather than dividing by zero silently.
            for i in range(k, n):
                R[k, i] = np.nan
            return
        c = r / Rkk
        s = xk / Rkk
        R[k, k] = r
        for i in range(k + 1, n):
            R[k, i] = (R[k, i] + s * x[i]) / c
            x[i] = c * x[i] - s * R[k, i]


@njit(cache=True)
def _choldowndate_impl(R: np.ndarray, x: np.ndarray) -> None:
    n = R.shape[0]
    for k in range(n):
        Rkk = R[k, k]
        xk = x[k]
        r2 = Rkk * Rkk - xk * xk
        if Rkk == 0.0 or r2 <= 0.0:
            # A - x x^T is not positive definite; there is no real factor.
            # Fill with NaN so callers' finite-checks trip instead of
            # silently accepting a wrong factor.
            for i in range(k, n):
                for j in range(n):
                    R[i, j] = np.nan
            return
        r = np.sqrt(r2)
        c = r / Rkk
        s = xk / Rkk
        R[k, k] = r
        for i in range(k + 1, n):
            R[k, i] = (R[k, i] - s * x[i]) / c
            x[i] = c * x[i] - s * R[k, i]


[docs] def cholupdate(R: np.ndarray, x: np.ndarray) -> None: r"""Rank-1 update: overwrite ``R`` with the factor of :math:`A + x x^\top`. :param R: Upper-triangular Cholesky factor, modified **in place**. :type R: numpy.ndarray :param x: Update vector. Not modified (a working copy is taken). :type x: numpy.ndarray :return: None :rtype: None """ _cholupdate_impl(R, np.ascontiguousarray(x, dtype=np.float64).copy())
[docs] def choldowndate(R: np.ndarray, x: np.ndarray) -> None: r"""Rank-1 downdate: overwrite ``R`` with the factor of :math:`A - x x^\top`. If the downdated matrix is not positive definite, ``R`` is filled with NaN. :param R: Upper-triangular Cholesky factor, modified **in place**. :type R: numpy.ndarray :param x: Downdate vector. Not modified (a working copy is taken). :type x: numpy.ndarray :return: None :rtype: None """ _choldowndate_impl(R, np.ascontiguousarray(x, dtype=np.float64).copy())