Source code for test_gravCoeffOps

# ISC License
#
# Copyright (c) 2026, Autonomous Vehicle Systems Lab, University of Colorado at Boulder
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
"""Unit tests for gravity and polyhedral coefficient loading helpers.

The tests exercise :mod:`gravCoeffOps` behavior through the installed
``gravityEffector`` bindings.
"""

import csv
from pathlib import Path

import numpy as np
import pytest

from Basilisk.simulation import gravityEffector

GGM03S_PATH = Path(__file__).resolve().with_name("GGM03S.txt")

loadGravFromFileToList = gravityEffector.loadGravFromFileToList
loadPolyFromFileToList = gravityEffector.loadPolyFromFileToList


def _load_reference_coefficients(file_path: Path, max_degree: int):
    """Load a deterministic reference coefficient table from ``GGM03S`` data.

    :param file_path: Path to the gravity coefficient CSV file.
    :param max_degree: Highest spherical-harmonic degree to load.
    :return: Tuple ``(c_ref, s_ref, mu, rad_equator, max_degree_file, max_order_file)``.
    """
    with file_path.open("r", newline="") as grav_file:
        grav_reader = csv.reader(grav_file, delimiter=",")
        first_row = next(grav_reader)
        rad_equator = float(first_row[0])
        mu = float(first_row[1])
        max_degree_file = int(first_row[3])
        max_order_file = int(first_row[4])

        c_ref = [[0.0] * (degree + 1) for degree in range(max_degree + 1)]
        s_ref = [[0.0] * (degree + 1) for degree in range(max_degree + 1)]

        for grav_row in grav_reader:
            degree = int(grav_row[0])
            order = int(grav_row[1])
            if degree > max_degree:
                break
            c_ref[degree][order] = float(grav_row[2])
            s_ref[degree][order] = float(grav_row[3])

    return c_ref, s_ref, mu, rad_equator, max_degree_file, max_order_file


def _load_header_and_rows(file_path: Path, max_degree: int):
    """Load the header and all coefficient rows up to ``max_degree``.

    :param file_path: Path to the gravity coefficient CSV file.
    :param max_degree: Highest degree to include.
    :return: Tuple ``(header, rows)`` with the original CSV rows.
    """
    with file_path.open("r", newline="") as grav_file:
        grav_reader = csv.reader(grav_file, delimiter=",")
        header = next(grav_reader)
        rows = []
        for grav_row in grav_reader:
            if int(grav_row[0]) > max_degree:
                break
            rows.append(grav_row)

    return header, rows


def _write_temp_gravity_file(tmp_path: Path, header, rows):
    """Write a temporary gravity CSV file used by mutation-style tests.

    :param tmp_path: ``pytest`` temporary directory fixture.
    :param header: CSV header row.
    :param rows: Data rows to write.
    :return: Path to the written temporary CSV file.
    """
    temp_gravity_file = tmp_path / "temporaryGravityFile.csv"
    with temp_gravity_file.open("w", newline="") as grav_file:
        grav_writer = csv.writer(grav_file)
        grav_writer.writerow(header)
        grav_writer.writerows(rows)

    return temp_gravity_file


[docs] def test_load_grav_from_file_to_list_respects_max_degree(): """Verify spherical-harmonic loading is truncated to the requested degree.""" max_degree = 8 c_list, s_list, _, _ = loadGravFromFileToList(str(GGM03S_PATH), maxDeg=max_degree) assert len(c_list) == max_degree + 1 assert len(s_list) == max_degree + 1 for degree in range(max_degree + 1): assert len(c_list[degree]) == degree + 1 assert len(s_list[degree]) == degree + 1
[docs] def test_load_grav_from_file_to_list_includes_requested_last_degree(): """Verify coefficients for the highest requested degree are loaded.""" max_degree = 20 c_ref, s_ref, mu_ref, rad_ref, _, _ = _load_reference_coefficients( GGM03S_PATH, max_degree ) c_list, s_list, mu, rad_equator = loadGravFromFileToList( str(GGM03S_PATH), maxDeg=max_degree ) assert mu == mu_ref assert rad_equator == rad_ref for degree in range(max_degree + 1): np.testing.assert_allclose(c_list[degree], c_ref[degree], rtol=0.0, atol=0.0) np.testing.assert_allclose(s_list[degree], s_ref[degree], rtol=0.0, atol=0.0)
[docs] def test_load_grav_from_file_to_list_rejects_degree_above_file_limit(): """Verify an out-of-range degree request raises a clear ValueError.""" _, _, _, _, max_degree_file, max_order_file = _load_reference_coefficients( GGM03S_PATH, max_degree=0 ) requested_degree = min(max_degree_file, max_order_file) + 1 with pytest.raises(ValueError, match="maximum degree/order"): loadGravFromFileToList(str(GGM03S_PATH), maxDeg=requested_degree)
[docs] def test_load_grav_from_file_to_list_rejects_negative_degree(): """Verify negative requested degree raises a clear ValueError.""" with pytest.raises(ValueError, match="must be non-negative"): loadGravFromFileToList(str(GGM03S_PATH), maxDeg=-1)
[docs] def test_load_grav_from_file_to_list_uses_row_order_column(tmp_path): """Verify rows are placed by explicit order value, even if row order is shuffled.""" header, rows = _load_header_and_rows(GGM03S_PATH, max_degree=2) # Build a stable lookup by (degree, order), then intentionally shuffle # only the degree-2 rows to verify assignment is by column values, not # by row position within the file. rows_by_index = {(int(row[0]), int(row[1])): row for row in rows} shuffled_rows = [ rows_by_index[(0, 0)], rows_by_index[(1, 0)], rows_by_index[(1, 1)], rows_by_index[(2, 0)], rows_by_index[(2, 2)], rows_by_index[(2, 1)], ] temp_file = _write_temp_gravity_file(tmp_path, header, shuffled_rows) c_ref, s_ref, _, _, _, _ = _load_reference_coefficients(GGM03S_PATH, max_degree=2) c_list, s_list, _, _ = loadGravFromFileToList(str(temp_file), maxDeg=2) for degree in range(3): np.testing.assert_allclose(c_list[degree], c_ref[degree], rtol=0.0, atol=0.0) np.testing.assert_allclose(s_list[degree], s_ref[degree], rtol=0.0, atol=0.0)
[docs] def test_load_grav_from_file_to_list_preserves_zero_for_missing_order(tmp_path): """Verify missing coefficients remain zero at their degree/order index.""" header, rows = _load_header_and_rows(GGM03S_PATH, max_degree=2) # Omit degree=2, order=1 on purpose and verify the parser keeps that # coefficient at the initialized zero value. rows_by_index = {(int(row[0]), int(row[1])): row for row in rows} missing_order_rows = [ rows_by_index[(0, 0)], rows_by_index[(1, 0)], rows_by_index[(1, 1)], rows_by_index[(2, 0)], rows_by_index[(2, 2)], ] temp_file = _write_temp_gravity_file(tmp_path, header, missing_order_rows) c_ref, s_ref, _, _, _, _ = _load_reference_coefficients(GGM03S_PATH, max_degree=2) c_list, s_list, _, _ = loadGravFromFileToList(str(temp_file), maxDeg=2) assert c_list[2][1] == 0.0 assert s_list[2][1] == 0.0 assert c_list[2][2] == c_ref[2][2] assert s_list[2][2] == s_ref[2][2]
[docs] def test_load_grav_from_file_to_list_rejects_degree_regression(tmp_path): """Verify decreasing degree rows are rejected.""" header, rows = _load_header_and_rows(GGM03S_PATH, max_degree=2) # Re-introduce a degree-1 row after a degree-2 row to violate # non-decreasing-degree ordering. rows_by_index = {(int(row[0]), int(row[1])): row for row in rows} regressed_rows = [ rows_by_index[(0, 0)], rows_by_index[(1, 0)], rows_by_index[(2, 0)], rows_by_index[(1, 1)], ] temp_file = _write_temp_gravity_file(tmp_path, header, regressed_rows) with pytest.raises(ValueError, match="non-decreasing degree"): loadGravFromFileToList(str(temp_file), maxDeg=2)
[docs] def test_load_poly_from_file_to_list_rejects_empty_tab_file(tmp_path): """Verify empty .tab polyhedral files are rejected with a clear error.""" empty_tab_file = tmp_path / "emptyShape.tab" empty_tab_file.write_text("") with pytest.raises(ValueError, match="polyhedral file is empty"): loadPolyFromFileToList(str(empty_tab_file))