diff --git a/benchmarks/accuracyComparison/.gitignore b/benchmarks/accuracyComparison/.gitignore new file mode 100644 index 00000000000..ded97eea5fd --- /dev/null +++ b/benchmarks/accuracyComparison/.gitignore @@ -0,0 +1,7 @@ +# Reference ephemerides and their manifests generated by generate_gmat_reference.py and generate_orekit_reference.py, +# and the Earth orientation kernels used by compare_with_basilisk.py +data/gmat_*.csv +data/orekit_*.csv +data/*_manifest.json +data/spice/ +__pycache__/ diff --git a/benchmarks/accuracyComparison/cases.json b/benchmarks/accuracyComparison/cases.json new file mode 100644 index 00000000000..3510112b67d --- /dev/null +++ b/benchmarks/accuracyComparison/cases.json @@ -0,0 +1,253 @@ +{ + "description": "Shared definition of the Basilisk vs GMAT vs Orekit accuracy-comparison cases. All quantities in SI units (m, m/s, kg, s) unless the key says otherwise. Every case is propagated in the Earth-centered inertial ICRF axes (GCRF in Orekit, EarthICRF in GMAT, J2000 of the DE430 SPICE kernels, which are ICRF-aligned, in Basilisk N); the initial states, the attitudes and the outputs are all expressed in that frame.", + "inertial_frame": "ICRF", + "epoch_utc": "2026-01-01T00:00:00.000", + "mu_m3_s2": 398600441500000.0, + "equatorial_radius_m": 6378136.3, + "earth_flattening": 0.0033528, + "gravity_coefficients_file": "data/egm96_normalized_coefficients.csv", + "spacecraft": { + "mass_kg": 1000.0, + "srp_area_m2": 10.0, + "srp_cr": 1.5, + "drag_area_m2": 10.0, + "drag_cd": 2.2 + }, + "srp": { + "solar_flux_w_m2": 1361.0, + "astronomical_unit_km": 149597870.693 + }, + "exponential_atmosphere": { + "density_at_zero_altitude_kg_m3": 2.0e-9, + "scale_height_m": 60000.0 + }, + "density_probe": { + "description": "Geodetic points [latitude deg, longitude deg, altitude m] at which every tool reports its own atmosphere altitude and density, so that the three exponential atmospheres are compared directly before any propagation. The points are expressed in the Earth-fixed frame, where the altitude does not depend on the epoch.", + "points_lat_lon_alt": [ + [0.0, 0.0, 400000.0], + [0.0, 90.0, 200000.0], + [45.0, 30.0, 400000.0], + [45.0, 200.0, 800000.0], + [-60.0, 300.0, 300000.0], + [20.0, 150.0, 500000.0], + [80.0, 45.0, 250000.0], + [89.99, 0.0, 400000.0], + [-89.99, 0.0, 600000.0] + ], + "density_rel_tolerance": 2e-5, + "altitude_tolerance_m": 1.0 + }, + "box": { + "size_m": [1.0, 2.0, 3.0], + "drag_cd": 2.2, + "absorption": 0.6, + "specular_reflection": 0.2, + "diffuse_reflection": 0.2 + }, + "duration_s": 2592000.0, + "sample_period_s": 3600.0, + "cases": { + "leo_twobody": { + "gravity": {"degree": 0, "order": 0}, + "earth_rotation": false, + "basilisk_step_s": 5.0, + "third_bodies": [], + "srp": false, + "drag": false, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "leo_zonal6": { + "gravity": {"degree": 6, "order": 0}, + "earth_rotation": false, + "basilisk_step_s": 5.0, + "third_bodies": [], + "srp": false, + "drag": false, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "gto_zonal6": { + "gravity": {"degree": 6, "order": 0}, + "earth_rotation": false, + "basilisk_step_s": 5.0, + "third_bodies": [], + "srp": false, + "drag": false, + "r0_m": [6572319.308, 0.0, 0.0], + "v0_m_s": [0.0, 9003.3973, 4888.44588] + }, + "leo_egm8x8": { + "gravity": {"degree": 8, "order": 8}, + "earth_rotation": true, + "basilisk_step_s": 5.0, + "third_bodies": [], + "srp": false, + "drag": false, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "leo_sunmoon": { + "gravity": {"degree": 0, "order": 0}, + "earth_rotation": true, + "basilisk_step_s": 5.0, + "third_bodies": ["sun", "moon"], + "srp": false, + "drag": false, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "leo_srp": { + "gravity": {"degree": 0, "order": 0}, + "earth_rotation": true, + "basilisk_step_s": 5.0, + "third_bodies": [], + "srp": true, + "orekit_max_step_s": 10.0, + "drag": false, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "leo_drag": { + "gravity": {"degree": 0, "order": 0}, + "earth_rotation": true, + "basilisk_step_s": 0.25, + "third_bodies": [], + "srp": false, + "drag": true, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "leo_all": { + "gravity": {"degree": 8, "order": 8}, + "earth_rotation": true, + "basilisk_step_s": 0.25, + "third_bodies": ["sun", "moon"], + "srp": true, + "orekit_max_step_s": 10.0, + "drag": true, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "gto_all": { + "gravity": {"degree": 8, "order": 8}, + "earth_rotation": true, + "basilisk_step_s": 5.0, + "third_bodies": ["sun", "moon"], + "srp": true, + "orekit_max_step_s": 10.0, + "drag": false, + "r0_m": [6572319.308, 0.0, 0.0], + "v0_m_s": [0.0, 9003.3973, 4888.44588] + }, + "geo_all": { + "gravity": {"degree": 8, "order": 8}, + "earth_rotation": true, + "basilisk_step_s": 5.0, + "third_bodies": ["sun", "moon"], + "srp": true, + "orekit_max_step_s": 10.0, + "drag": false, + "r0_m": [42164137.0, 0.0, 0.0], + "v0_m_s": [0.0, 3074.193002, 53.660238] + }, + "leo_egm70": { + "description": "EGM96 degree and order 70, rotating Earth, 7 days", + "duration_s": 604800.0, + "gravity": {"degree": 70, "order": 70}, + "earth_rotation": true, + "basilisk_step_s": 5.0, + "third_bodies": [], + "srp": false, + "drag": false, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "sso_all": { + "description": "Sun-synchronous orbit at 700 km, all perturbations, spring equinox epoch", + "epoch_utc": "2026-03-20T00:00:00.000", + "gravity": {"degree": 20, "order": 20}, + "earth_rotation": true, + "basilisk_step_s": 0.25, + "third_bodies": ["sun", "moon"], + "srp": true, + "orekit_max_step_s": 10.0, + "drag": true, + "r0_m": [872147.576, -503534.638, 6998976.713], + "v0_m_s": [-3755.897451, -6505.405213, 0.0] + }, + "sso_drag": { + "description": "Point-mass gravity and exponential-atmosphere drag in the 700 km sun-synchronous orbit", + "epoch_utc": "2026-03-20T00:00:00.000", + "gravity": {"degree": 0, "order": 0}, + "earth_rotation": true, + "basilisk_step_s": 0.25, + "third_bodies": [], + "srp": false, + "drag": true, + "r0_m": [872147.576, -503534.638, 6998976.713], + "v0_m_s": [-3755.897451, -6505.405213, 0.0] + }, + "molniya_all": { + "description": "Molniya orbit, gravity, Sun and Moon, SRP, northern summer epoch", + "epoch_utc": "2026-05-20T00:00:00.000", + "gravity": {"degree": 8, "order": 8}, + "earth_rotation": true, + "basilisk_step_s": 5.0, + "third_bodies": ["sun", "moon"], + "srp": true, + "orekit_max_step_s": 10.0, + "drag": false, + "r0_m": [1546138.996, -2677991.296, -6175136.458], + "v0_m_s": [8678.748104, 5010.677554, 0.0] + }, + "leo_box_fixed": { + "description": "Box spacecraft with a fixed inertial attitude, drag and SRP, 7 days (Orekit reference only)", + "references": ["orekit"], + "body": "box", + "attitude": {"sigma_BN": [0.1, -0.2, 0.3], "omega_BN_B_rad_s": [0.0, 0.0, 0.0]}, + "duration_s": 604800.0, + "gravity": {"degree": 0, "order": 0}, + "earth_rotation": true, + "basilisk_step_s": 0.25, + "third_bodies": [], + "srp": true, + "orekit_max_step_s": 2.0, + "drag": true, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "leo_box_spin": { + "description": "Box spacecraft spinning at about 1.5 deg/s, drag and SRP, 7 days (Orekit reference only)", + "references": ["orekit"], + "body": "box", + "attitude": {"sigma_BN": [0.1, -0.2, 0.3], "omega_BN_B_rad_s": [0.02, 0.01, -0.015]}, + "duration_s": 604800.0, + "gravity": {"degree": 0, "order": 0}, + "earth_rotation": true, + "basilisk_step_s": 0.25, + "third_bodies": [], + "srp": true, + "orekit_max_step_s": 2.0, + "drag": true, + "r0_m": [6778137.0, 0.0, 0.0], + "v0_m_s": [0.0, 4763.307887, 6009.798867] + }, + "geo_box_spin": { + "description": "Box spacecraft spinning at about 1.5 deg/s in GEO, SRP only, 7 days (Orekit reference only)", + "references": ["orekit"], + "body": "box", + "attitude": {"sigma_BN": [0.1, -0.2, 0.3], "omega_BN_B_rad_s": [0.02, 0.01, -0.015]}, + "duration_s": 604800.0, + "gravity": {"degree": 0, "order": 0}, + "earth_rotation": true, + "basilisk_step_s": 5.0, + "third_bodies": [], + "srp": true, + "orekit_max_step_s": 2.0, + "drag": false, + "r0_m": [42164137.0, 0.0, 0.0], + "v0_m_s": [0.0, 3074.193002, 53.660238] + } + } +} diff --git a/benchmarks/accuracyComparison/compare_with_basilisk.py b/benchmarks/accuracyComparison/compare_with_basilisk.py new file mode 100644 index 00000000000..804eea0bdfd --- /dev/null +++ b/benchmarks/accuracyComparison/compare_with_basilisk.py @@ -0,0 +1,519 @@ +# +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. +# + +r""" +Compare Basilisk's orbit propagation with the GMAT and Orekit reference ephemerides. + +This is a rough comparison. The three tools differ in their features and in how a force model is set up, the results +are not regenerated automatically, and they go out of date as the tools are released. + +The cases are defined in ``cases.json``. The reference ephemerides are produced beforehand with +``generate_gmat_reference.py`` and ``generate_orekit_reference.py`` (GMAT and Orekit are needed only for that step) and +are read from ``data/`` or the folder given with ``--data-dir``. Each generator also writes a manifest with the effective +case configuration, the generator options, the frame, the tool version and the checksums of the data; it is validated +before Basilisk is run, so a reference that was generated for another epoch, configuration or frame is rejected. A +reference generated with an alternative configuration (for example ``--oblate-shadow``) is only accepted with +``--orekit-variant`` naming it, and the results then carry that variant. Basilisk is run with the same initial +conditions, inertial frame (ICRF), gravity field, third bodies, solar radiation pressure, and exponential-atmosphere drag +(for a cannonball or a box spacecraft) as the reference tools. The maximum position and velocity differences to each +reference, and between the references, are printed. The methodology and results are described in +:ref:`accuracyComparison`. + +Usage:: + + python compare_with_basilisk.py [--cases leo_all geo_all] [--duration-days 30] [--kernel-dir DIR] + [--data-dir DIR] [--figures-dir DIR] [--orekit-variant NAME] + +The cases with a rotating Earth use the high-precision Earth orientation kernel and the ITRF93 frame association +kernel (``earth_000101_260711_260415.bpc`` and ``earth_assoc_itrf93.tf``), which are not part of Basilisk's +support data. They are searched in ``--kernel-dir``, in ``data/spice`` next to this script, and in the Basilisk +support-data cache. + +The atmosphere altitude is computed above the same ellipsoid as in the other tools. Before it propagates a case with +drag, the script compares the altitude and the density that the three tools compute at the points of ``density_probe`` +in ``cases.json`` (the probe files written by the generators), and stops if the altitude definition or the density +model differ beyond the tolerances given there. +""" + +import argparse +import json +import sys +import tempfile +from pathlib import Path + +import numpy as np +from Basilisk.architecture import bskLogging, messaging +from Basilisk.simulation import (dragDynamicEffector, eclipse, exponentialAtmosphere, facetDragDynamicEffector, + facetSRPDynamicEffector, radiationPressure, spacecraft, svIntegrators, + zeroWindModel) +from Basilisk.utilities import SimulationBaseClass, macros, simIncludeGravBody +from Basilisk.utilities.supportDataTools.dataFetcher import DataFile + +try: + from matplotlib import pyplot as plt +except ImportError: + plt = None + +DATA_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(DATA_DIR)) +from comparisonCommon import (DEFAULT_VARIANT, boxFacets, caseDuration, caseEpoch, caseReferences, checkConsistentProvenance, + checkProbe, loadProbe, loadSpec, manifestPath, requiredProbeTools, validateManifestEntry, + writeBasiliskGravity) + +DEFAULT_KERNELS = (DataFile.EphemerisData.de430, DataFile.EphemerisData.naif0012, + DataFile.EphemerisData.de_403_masses, DataFile.EphemerisData.pck00010) +ITRF_KERNELS = ["earth_000101_260711_260415.bpc", "earth_assoc_itrf93.tf"] +SPICE_FRAMES = {"earth": "ITRF93", "sun": "IAU_SUN", "moon": "IAU_MOON"} +INITIAL_STATE_TOLERANCE = 1.0e-3 # [m, m/s] allowed difference between the first reference sample and the case state +SAMPLE_TIME_TOLERANCE = 1.0e-3 # [s] allowed difference between reference and Basilisk sample times +RKF78_REL_TOL = 1.0e-4 # [-] relative tolerance of the Basilisk RKF78 integrator (the library default) +RKF78_ABS_TOL = 1.0e-8 # [m, m/s, ...] absolute tolerance of the Basilisk RKF78 integrator (the library default) +MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] + + +def loadReference(tool, caseName, spec, dataDir=None, variant=DEFAULT_VARIANT): + """Load a reference ephemeris as an array with columns (t, r_xyz, v_xyz) in SI units, after validating its manifest. + + Args: + tool (str): ``"gmat"`` or ``"orekit"``. + caseName (str): case name in ``cases.json``. + spec (dict): parsed ``cases.json`` content. + dataDir (Path): folder with the reference ephemerides; ``data`` next to this script by default. + variant (str): variant of the reference that is accepted, ``"default"`` unless an alternative configuration + is compared on purpose. + """ + folder = Path(dataDir or DATA_DIR / "data") + path = folder / f"{tool}_{caseName}.csv" + if not path.exists(): + raise FileNotFoundError( + f"The {tool} reference ephemeris {path} does not exist. Generate it with " + f"generate_{tool}_reference.py first (see Reproducing the Results in the accuracy comparison " + f"documentation, ``accuracyComparison``), or give the folder that contains it with --data-dir.") + entry = validateManifestEntry(folder, tool, spec, caseName, path, variant) + data = np.loadtxt(path, delimiter=",", skiprows=1) + if not np.isfinite(data).all(): + raise ValueError(f"The {tool} reference ephemeris {path} contains non-finite values: regenerate it.") + case = spec["cases"][caseName] + initial = np.concatenate([case["r0_m"], case["v0_m_s"]]) # [m, m/s] + if np.abs(data[0, 1:7] - initial).max() > INITIAL_STATE_TOLERANCE or abs(data[0, 0]) > SAMPLE_TIME_TOLERANCE: + raise ValueError(f"The first sample of the {tool} reference ephemeris {path} is not the initial state of case " + f"{caseName} in cases.json: regenerate it.") + return data, entry + + +def validateReference(tool, caseName, reference, bsk): + """Return the part of a reference ephemeris that matches the Basilisk samples, after checking it. + + The reference must have the same columns, at least as many samples as Basilisk, and the same sample times. + Otherwise states of different epochs would be compared and the reported differences would be misleading. + + Args: + tool (str): ``"gmat"`` or ``"orekit"``. + caseName (str): case name in ``cases.json``. + reference (ndarray): reference ephemeris with columns (t, r_xyz, v_xyz). + bsk (ndarray): Basilisk ephemeris with columns (t, r_xyz, v_xyz). + """ + where = f"The {tool} reference ephemeris of case {caseName}" + if reference.ndim != 2 or reference.shape[1] != bsk.shape[1]: + raise ValueError(f"{where} has shape {reference.shape}; {bsk.shape[1]} columns (t, r_xyz, v_xyz) are expected.") + if len(reference) < len(bsk): + raise ValueError(f"{where} has {len(reference)} samples but Basilisk produced {len(bsk)}. Regenerate it with " + f"generate_{tool}_reference.py using the same cases.json, or use a shorter --duration-days.") + reference = reference[:len(bsk)] + timeError = np.abs(reference[:, 0] - bsk[:, 0]).max() # [s] + if timeError > SAMPLE_TIME_TOLERANCE: + raise ValueError(f"The sample times of the {tool} reference ephemeris of case {caseName} differ from the Basilisk " + f"sample times by up to {timeError:.3g} s. Regenerate the reference with the sample period of " + f"cases.json.") + return reference + + +def findKernelDir(kernelDir=None): + """Return the folder that contains the high-precision Earth orientation kernels. + + Args: + kernelDir (Path): folder given by the user, searched first. + """ + candidates = [Path(kernelDir)] if kernelDir else [] + candidates += [DATA_DIR / "data" / "spice", + Path.home() / ".cache" / "bsk_support_data" / "supportData" / "EphemerisData"] + for folder in candidates: + if all((folder / name).exists() for name in ITRF_KERNELS): + return folder + raise FileNotFoundError(f"The Earth orientation kernels {ITRF_KERNELS} were not found in " + f"{[str(d) for d in candidates]}. Use --kernel-dir.") + + +def spiceTime(spec, case): + """Return the case epoch as a SPICE UTC time string. + + Args: + spec (dict): parsed ``cases.json`` content. + case (dict): the case entry. + """ + e = caseEpoch(spec, case) # "YYYY-MM-DDTHH:MM:SS.sss" + return f"{e[0:4]} {MONTHS[int(e[5:7]) - 1]} {e[8:10]} {e[11:]} (UTC)" + + +def configureExponentialAtmosphere(spec, atmo): + """Configure a Basilisk exponential atmosphere with the shared model of ``cases.json``. + + The density at zero altitude and the scale height are passed as given, and the altitude is computed above the + ellipsoid of the Earth. The propagation and the density probe use this same function. + + Args: + spec (dict): parsed ``cases.json`` content. + atmo (ExponentialAtmosphere): the module to configure. + """ + expAtm = spec["exponential_atmosphere"] + atmo.planetRadius = spec["equatorial_radius_m"] # [m] + atmo.setPlanetPolarRadius(spec["equatorial_radius_m"] * (1.0 - spec["earth_flattening"])) # [m] + atmo.baseDensity = expAtm["density_at_zero_altitude_kg_m3"] # [kg/m^3] + atmo.scaleHeight = expAtm["scale_height_m"] # [m] + + +def probeBasilisk(spec, positions): + """Return the density [kg/m^3] of Basilisk's exponential atmosphere at Earth-fixed positions. + + One update of the module with one spacecraft per position and a planet at the origin with identity orientation, so + that the planet-fixed and the inertial frame coincide. + + Args: + spec (dict): parsed ``cases.json`` content. + positions (ndarray): [m] Earth-fixed positions, one row per point. + """ + scSim = SimulationBaseClass.SimBaseClass() + process = scSim.CreateNewProcess("probe") + process.addTask(scSim.CreateNewTask("probeTask", macros.sec2nano(1.0))) + atmo = exponentialAtmosphere.ExponentialAtmosphere() + atmo.ModelTag = "probeAtmosphere" + configureExponentialAtmosphere(spec, atmo) + planetPayload = messaging.SpicePlanetStateMsgPayload() + planetPayload.J20002Pfix = np.eye(3).tolist() # [-] + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload) + atmo.planetPosInMsg.subscribeTo(planetMsg) + messages = [] + for position in positions: + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = [float(c) for c in position] + messages.append(messaging.SCStatesMsg().write(payload)) + atmo.addSpacecraftToModel(messages[-1]) + scSim.AddModelToTask("probeTask", atmo) + recorders = [msg.recorder() for msg in atmo.envOutMsgs] + for recorder in recorders: + scSim.AddModelToTask("probeTask", recorder) + scSim.InitializeSimulation() + scSim.ConfigureStopTime(0) + scSim.ExecuteSimulation() + return np.array([recorder.neutralDensity[-1] for recorder in recorders]) + + +def compareDensityProbe(spec, dataDir=None, tools=("gmat", "orekit")): + """Check that Basilisk and the reference tools evaluate the same altitude and density at the probe points. + + Args: + spec (dict): parsed ``cases.json`` content. + dataDir (Path): folder with the probe files; ``data`` next to this script by default. + tools (tuple): reference tools whose probe is checked; see :func:`comparisonCommon.requiredProbeTools`. + + Raises: + ValueError: if a probe file is missing or stale, or a tolerance of ``density_probe`` is exceeded. + """ + folder = Path(dataDir or DATA_DIR / "data") + print("density probe: altitude error [m] / density difference to Basilisk [-]") + for tool in tools: + rows = loadProbe(folder, tool, spec) + results = checkProbe(spec, tool, rows, probeBasilisk(spec, rows[:, :3])) + print(f" {tool}: max |dh| = {max(abs(r[1]) for r in results):.3e} m, " + f"max |drho/rho| = {max(abs(r[2]) for r in results):.3e}") + + +def propagateBasilisk(spec, case, gravityFile, kernelDir=None, durationSeconds=None): + """Propagate one case with Basilisk and return an array with columns (t, r_xyz, v_xyz). + + Args: + spec (dict): parsed ``cases.json`` content. + case (dict): the case entry. + gravityFile (Path): spherical-harmonics file used when the gravity degree is positive. + kernelDir (Path): folder with the Earth orientation kernels, used by the cases with a rotating Earth. + durationSeconds (float): [s] propagation time; the duration of the case by default. + """ + sc = spec["spacecraft"] + isBox = case.get("body", "cannonball") == "box" + stepSeconds = case["basilisk_step_s"] # [s] + thirdBodies = case["third_bodies"] + needSpice = case["earth_rotation"] or thirdBodies or case["srp"] or case["drag"] + + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("dynamicsProcess") + proc.addTask(scSim.CreateNewTask("dynamicsTask", macros.sec2nano(stepSeconds))) + + scObject = spacecraft.Spacecraft() + scObject.ModelTag = "spacecraft" + integrator = svIntegrators.svIntegratorRKF78(scObject) + integrator.relTol = RKF78_REL_TOL # [-] + integrator.absTol = RKF78_ABS_TOL # [m, m/s, ...] + scObject.setIntegrator(integrator) + scObject.hub.mHub = sc["mass_kg"] # [kg] + scObject.hub.r_CN_NInit = case["r0_m"] + scObject.hub.v_CN_NInit = case["v0_m_s"] + if isBox: + # spherical inertia and facets with the center of pressure at the center of mass: no torque changes the spin + scObject.hub.IHubPntBc_B = [[500.0, 0.0, 0.0], [0.0, 500.0, 0.0], [0.0, 0.0, 500.0]] # [kg*m^2] + scObject.hub.sigma_BNInit = case["attitude"]["sigma_BN"] + scObject.hub.omega_BN_BInit = case["attitude"]["omega_BN_B_rad_s"] # [rad/s] + scSim.AddModelToTask("dynamicsTask", scObject) + + gravFactory = simIncludeGravBody.gravBodyFactory() + earth = gravFactory.createEarth() + earth.mu = spec["mu_m3_s2"] + earth.radEquator = spec["equatorial_radius_m"] + earth.isCentralBody = True + if case["gravity"]["degree"] > 0: + earth.useSphericalHarmonicsGravityModel(str(gravityFile), case["gravity"]["degree"]) + effectiveBodies = [earth] + if thirdBodies: + extra = {"sun": gravFactory.createSun, "moon": gravFactory.createMoon} + effectiveBodies += [extra[b]() for b in thirdBodies] + if case["srp"] and "sun" not in thirdBodies: + gravFactory.createSun() # needed for the SPICE sun state message only + + spiceObject = None + if needSpice: + kernels = list(DEFAULT_KERNELS) + ITRF_KERNELS + folder = findKernelDir(kernelDir) + spiceObject = gravFactory.createSpiceInterface( + path=str(folder) + "/", time=spiceTime(spec, case), epochInMsg=True, spiceKernelFileNames=kernels, + spicePlanetFrames=[SPICE_FRAMES[name] for name in gravFactory.gravBodies]) + spiceObject.zeroBase = "Earth" + scSim.AddModelToTask("dynamicsTask", spiceObject, 100) + else: + # No planet-orientation message is connected on purpose: the gravity effector then uses an identity + # orientation, which keeps the pole along inertial +Z as in GMAT and Orekit. Silence the expected warning. + quietLogger = bskLogging.BSKLogger() + quietLogger.setLogLevel(bskLogging.BSK_ERROR) + scObject.gravField.bskLogger = quietLogger + scObject.gravField.gravBodies = spacecraft.GravBodyVector(effectiveBodies) + + bodyNames = list(gravFactory.gravBodies) + if case["srp"]: + eclipseObject = eclipse.Eclipse() + eclipseObject.setExtrapolateScStateToStepMidpoint(True) # same task rate as the spacecraft + eclipseObject.addSpacecraftToModel(scObject.scStateOutMsg) + eclipseObject.addPlanetToModel(spiceObject.planetStateOutMsgs[bodyNames.index("earth")]) + eclipseObject.sunInMsg.subscribeTo(spiceObject.planetStateOutMsgs[bodyNames.index("sun")]) + scSim.AddModelToTask("dynamicsTask", eclipseObject, 90) + + if isBox: + box = spec["box"] + srp = facetSRPDynamicEffector.FacetSRPDynamicEffector() + srp.ModelTag = "facetSrp" + srp.setNumFacets(6) + srp.setNumArticulatedFacets(0) + for area, normal in boxFacets(spec): + srp.addFacet(area, np.eye(3), normal, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], + box["diffuse_reflection"], box["specular_reflection"]) + else: + srp = radiationPressure.RadiationPressure() # default model is the cannonball model + srp.area = sc["srp_area_m2"] # [m^2] + srp.coefficientReflection = sc["srp_cr"] # [-] + sunMsg = spiceObject.planetStateOutMsgs[bodyNames.index("sun")] + if isBox: + srp.sunInMsg.subscribeTo(sunMsg) + else: + srp.sunEphmInMsg.subscribeTo(sunMsg) + srp.sunEclipseInMsg.subscribeTo(eclipseObject.eclipseOutMsgs[0]) + scObject.addDynamicEffector(srp) + scSim.AddModelToTask("dynamicsTask", srp, 80) + + if case["drag"]: + earthPlanetMsg = spiceObject.planetStateOutMsgs[bodyNames.index("earth")] + atmo = exponentialAtmosphere.ExponentialAtmosphere() + atmo.setExtrapolateScStateToStepMidpoint(True) # same task period as the spacecraft + atmo.ModelTag = "expAtmosphere" + configureExponentialAtmosphere(spec, atmo) + atmo.addSpacecraftToModel(scObject.scStateOutMsg) + atmo.planetPosInMsg.subscribeTo(earthPlanetMsg) + scSim.AddModelToTask("dynamicsTask", atmo, 90) + + wind = zeroWindModel.ZeroWindModel() # atmosphere co-rotating with the planet + wind.ModelTag = "zeroWind" + wind.setExtrapolateScStateToStepMidpoint(True) # same task period as the spacecraft, as for the atmosphere + wind.planetPosInMsg.subscribeTo(earthPlanetMsg) + wind.addSpacecraftToModel(scObject.scStateOutMsg) + scSim.AddModelToTask("dynamicsTask", wind, 85) + + if isBox: + drag = facetDragDynamicEffector.FacetDragDynamicEffector() + drag.ModelTag = "facetDrag" + for area, normal in boxFacets(spec): + drag.addFacet(area, spec["box"]["drag_cd"], normal, [0.0, 0.0, 0.0]) + else: + drag = dragDynamicEffector.DragDynamicEffector() + drag.ModelTag = "drag" + drag.coreParams.projectedArea = sc["drag_area_m2"] # [m^2] + drag.coreParams.dragCoeff = sc["drag_cd"] # [-] + drag.atmoDensInMsg.subscribeTo(atmo.envOutMsgs[0]) + drag.windVelInMsg.subscribeTo(wind.envOutMsgs[0]) + scObject.addDynamicEffector(drag) + scSim.AddModelToTask("dynamicsTask", drag, 80) + + recorder = scObject.scStateOutMsg.recorder(macros.sec2nano(spec["sample_period_s"])) + scSim.AddModelToTask("dynamicsTask", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(durationSeconds or caseDuration(spec, case))) + scSim.ExecuteSimulation() + if spiceObject is not None: + gravFactory.unloadSpiceKernels() + + return np.column_stack([np.array(recorder.times()) * macros.NANO2SEC, + np.array(recorder.r_BN_N), np.array(recorder.v_BN_N)]) + + +def maxErrors(a, b): + """Return the max position [m] and velocity [m/s] difference between two ephemerides.""" + return (np.linalg.norm(a[:, 1:4] - b[:, 1:4], axis=1).max(), + np.linalg.norm(a[:, 4:7] - b[:, 4:7], axis=1).max()) + + +def plotCase(name, bsk, gmat, orekit, variants=None): + """Return a figure with the position differences between the tools versus time. + + Args: + name (str): case name used as the title. + bsk, gmat, orekit (ndarray): ephemerides with columns (t, r_xyz, v_xyz). ``gmat`` is None for the cases that GMAT + does not model. + variants (dict): variant of each reference tool that is not the default one, shown in the title. + """ + def difference(a, b): + return np.maximum(np.linalg.norm(a[:, 1:4] - b[:, 1:4], axis=1), 1e-6) # [m] + + fig = plt.figure(figsize=(6.0, 3.6)) + tDays = bsk[:, 0] / 86400.0 # [days] + if gmat is not None: + plt.semilogy(tDays, difference(bsk, gmat), "-", label="Basilisk - GMAT") + plt.semilogy(tDays, difference(bsk, orekit), "-", label="Basilisk - Orekit") + if gmat is not None: + plt.semilogy(tDays, difference(gmat, orekit), "--", label="GMAT - Orekit") + plt.xlabel("time [days]") + plt.ylabel("position difference [m]") + plt.title(name + "".join(f" ({tool} variant: {v})" for tool, v in (variants or {}).items())) + plt.grid(True) + plt.legend(fontsize="small") + plt.tight_layout() + return fig + + +def run(caseNames=None, durationDays=None, kernelDir=None, figuresDir=None, showPlots=False, dataDir=None, + variants=None): + """Propagate the comparison cases with Basilisk and compare to GMAT and Orekit. + + Returns a dictionary keyed by case name with the maximum position [m] and velocity [m/s] differences and, under + ``variants``, the variant of each reference that is not the default configuration. + + Args: + caseNames (list): subset of cases to run; all cases by default. + durationDays (float): propagation time in days, compared with the start of the reference ephemerides; + by default the full duration of each case. A case shorter than this is not extended. + kernelDir (Path): folder with the Earth orientation kernels. + figuresDir (Path): folder where the position-difference figures are saved as SVG files. + showPlots (bool): show the matplotlib plots. + dataDir (Path): folder with the reference ephemerides; ``data`` next to this script by default. + variants (dict): variant of the reference of each tool that is accepted, e.g. ``{"orekit": "oblate_shadow"}``. + The references must otherwise be the default configuration. + """ + spec = loadSpec() + caseNames = caseNames or list(spec["cases"]) + variants = variants or {} + results = {} + provenance = {} # manifest entries of the references used, by tool and case + probeTools = requiredProbeTools(spec, caseNames) # only the references of the selected drag cases + if probeTools: + compareDensityProbe(spec, dataDir, probeTools) + + # validate every reference, and that they are consistent with each other + for name in caseNames: + for tool in caseReferences(spec["cases"][name]): + provenance.setdefault(tool, {})[name] = loadReference( + tool, name, spec, dataDir, variants.get(tool, DEFAULT_VARIANT))[1] + folder = Path(dataDir or DATA_DIR / "data") + for tool, entries in provenance.items(): + if tool in probeTools: + entries = {**entries, "density_probe": json.loads(manifestPath(folder, tool).read_text())["density_probe"]} + checkConsistentProvenance(folder, tool, entries) + + with tempfile.TemporaryDirectory() as tmp: + for name in caseNames: + case = spec["cases"][name] + duration = caseDuration(spec, case) # [s] + if durationDays is not None: + duration = min(duration, durationDays * 86400.0) # [s] + loaded = {tool: loadReference(tool, name, spec, dataDir, variants.get(tool, DEFAULT_VARIANT))[0] + for tool in caseReferences(case)} + gravityFile = Path(tmp) / f"{name}.txt" + if case["gravity"]["degree"] > 0: + writeBasiliskGravity(gravityFile, spec, case["gravity"]["degree"], case["gravity"]["order"]) + bsk = propagateBasilisk(spec, case, gravityFile, kernelDir, duration) + references = {tool: validateReference(tool, name, ref, bsk) for tool, ref in loaded.items()} + gmat, orekit = references.get("gmat"), references["orekit"] + results[name] = {} + if gmat is not None: + results[name]["bsk_vs_gmat"] = maxErrors(bsk, gmat) + results[name]["bsk_vs_orekit"] = maxErrors(bsk, orekit) + if gmat is not None: + results[name]["gmat_vs_orekit"] = maxErrors(gmat, orekit) + caseVariants = {tool: v for tool, v in variants.items() if tool in references and v != DEFAULT_VARIANT} + results[name]["variants"] = caseVariants + print(f"{name}: max |dr| [m] (|dv| [m/s]) " + + ", ".join(f"{k}={v[0]:.3e} ({v[1]:.2e})" for k, v in results[name].items() if k != "variants") + + "".join(f"; {tool} reference variant: {v}" for tool, v in caseVariants.items())) + + if plt is not None and (figuresDir or showPlots): + fig = plotCase(name, bsk, gmat, orekit, caseVariants) + if figuresDir: + Path(figuresDir).mkdir(parents=True, exist_ok=True) + suffix = "".join(f"_{tool}-{v}" for tool, v in caseVariants.items()) + fig.savefig(Path(figuresDir) / f"accuracyComparison_{name}{suffix}.svg") + if not showPlots: + plt.close(fig) + + for tool, entries in provenance.items(): + print(f"{tool} references: {next(iter(entries.values()))['tool_version']}") + if plt is not None and showPlots: + plt.show() + return results + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument("--cases", nargs="*", help="case names to run (default: all)") + parser.add_argument("--duration-days", type=float, + help="maximum propagation time in days (default: the full duration of each case)") + parser.add_argument("--kernel-dir", type=Path, help="folder with the high-precision Earth orientation kernels") + parser.add_argument("--data-dir", type=Path, help="folder with the GMAT and Orekit reference ephemerides " + "(default: data next to this script)") + parser.add_argument("--figures-dir", type=Path, help="folder where the SVG figures are saved") + for tool in ("gmat", "orekit"): + parser.add_argument(f"--{tool}-variant", default=DEFAULT_VARIANT, + help=f"variant of the {tool} reference to accept: an alternative configuration such as " + "oblate_shadow is rejected unless it is named here (default: %(default)s)") + parser.add_argument("--show-plots", action="store_true", help="show the plots") + args = parser.parse_args() + run(args.cases, args.duration_days, args.kernel_dir, args.figures_dir, args.show_plots, args.data_dir, + {"gmat": args.gmat_variant, "orekit": args.orekit_variant}) diff --git a/benchmarks/accuracyComparison/comparisonCommon.py b/benchmarks/accuracyComparison/comparisonCommon.py new file mode 100644 index 00000000000..54dd1e64f23 --- /dev/null +++ b/benchmarks/accuracyComparison/comparisonCommon.py @@ -0,0 +1,637 @@ +# +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. +# + +r""" +Shared helpers for the Basilisk vs GMAT vs Orekit accuracy comparison. + +The case definitions live in ``cases.json`` and the Earth gravity coefficients in +``data/egm96_normalized_coefficients.csv``. This module loads them and writes the +gravity-field files in the formats read by GMAT (``.cof``), Orekit (ICGEM ``.gfc``) +and Basilisk (text), so that all three tools use identical coefficients. +""" + +import ast +import csv +import datetime +import hashlib +import json +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +TOOLS = ("gmat", "orekit") + + +def loadSpec(): + """Return the parsed ``cases.json``.""" + return json.loads((HERE / "cases.json").read_text()) + + +def caseEpoch(spec, case): + """Return the UTC epoch of a case, ``"YYYY-MM-DDTHH:MM:SS.sss"``; cases may override the global epoch. + + Args: + spec (dict): parsed ``cases.json`` content. + case (dict): the case entry. + """ + return case.get("epoch_utc", spec["epoch_utc"]) + + +def caseDuration(spec, case): + """Return the propagation time of a case in seconds [s]; cases may override the global duration. + + Args: + spec (dict): parsed ``cases.json`` content. + case (dict): the case entry. + """ + return case.get("duration_s", spec["duration_s"]) + + +def caseReferences(case): + """Return the tools that provide a reference ephemeris for a case, ``("gmat", "orekit")`` by default. + + Args: + case (dict): the case entry. + """ + return tuple(case.get("references", TOOLS)) + + +def mrp2dcm(sigma): + """Return the direction cosine matrix [BN] of the modified Rodrigues parameters ``sigma_BN``. + + Args: + sigma (list): MRP set of the body frame B relative to the inertial frame N. + """ + s = np.asarray(sigma, dtype=float) + s2 = s @ s + tilde = np.array([[0.0, -s[2], s[1]], [s[2], 0.0, -s[0]], [-s[1], s[0], 0.0]]) + return np.eye(3) + (8.0 * tilde @ tilde - 4.0 * (1.0 - s2) * tilde) / (1.0 + s2) ** 2 + + +def boxFacets(spec): + """Return the six facets of the box spacecraft as ``(area [m^2], unit normal in the body frame)`` tuples. + + Args: + spec (dict): parsed ``cases.json`` content. + """ + lx, ly, lz = spec["box"]["size_m"] # [m] + areas = {0: ly * lz, 1: lx * lz, 2: lx * ly} # [m^2] by the axis of the facet normal + facets = [] + for axis in range(3): + for sign in (1.0, -1.0): + normal = np.zeros(3) + normal[axis] = sign + facets.append((areas[axis], normal)) + return facets + + +MANIFEST_VERSION = 1 # [-] version of the reference manifest format +DEFAULT_VARIANT = "default" +NON_REFERENCE_KEYS = ("basilisk_step_s", "references") # case keys that do not affect the reference ephemerides + + +def effectiveCase(spec, name): + """Return the effective configuration of a case: the global settings merged with the case entry. + + The global ``description`` is left out because it is prose, the ``density_probe`` because it has its own manifest + (:func:`probeFingerprint`), and so are the case keys that do not change a reference + (:data:`NON_REFERENCE_KEYS`: the Basilisk time step and the list of tools), so that changing them does not invalidate + the references. The epoch and the duration are resolved, so that a + change of the global value is seen by every case that uses it. + + Args: + spec (dict): parsed ``cases.json`` content. + name (str): case name. + """ + # the Basilisk task period and the list of tools with a reference do not influence the reference ephemerides + case = {k: v for k, v in spec["cases"][name].items() if k not in NON_REFERENCE_KEYS} + settings = {k: v for k, v in spec.items() if k not in ("cases", "description", "density_probe")} + return {"settings": settings, "case": case, "epoch_utc": caseEpoch(spec, spec["cases"][name]), + "duration_s": caseDuration(spec, spec["cases"][name])} + + +def caseFingerprint(spec, name): + """Return the SHA-256 hash of the canonical JSON of the effective configuration of a case. + + Args: + spec (dict): parsed ``cases.json`` content. + name (str): case name. + """ + canonical = json.dumps(effectiveCase(spec, name), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def fileSha256(path): + """Return the SHA-256 hash of a file, or of the files of a folder in sorted order. + + Args: + path (Path): file or folder. + """ + digest = hashlib.sha256() + path = Path(path) + files = sorted(p for p in path.rglob("*") if p.is_file()) if path.is_dir() else [path] + for item in files: + if path.is_dir(): + digest.update(str(item.relative_to(path)).encode()) + with open(item, "rb") as f: + for block in iter(lambda: f.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() + + +def gravityCoefficientsRecord(spec): + """Return the identifier and SHA-256 hash of the gravity coefficient file that every tool reads. + + Args: + spec (dict): parsed ``cases.json`` content. + """ + return {"id": spec["gravity_coefficients_file"], "sha256": fileSha256(HERE / spec["gravity_coefficients_file"])} + + +GENERATOR_INPUT_FUNCTIONS = ("mrp2dcm", "geodeticToEcef", "probePoints", "loadCoefficients", "writeGmatCof", + "writeGmatExponentialAtmosphere", "writeOrekitGfc") # shared code that builds tool inputs +REQUIRED_EXTERNAL_DATA = {"gmat": ("gravity_coefficients", "de405_file", "eop_file", "leap_secs_file"), + "orekit": ("gravity_coefficients", "orekit_data", "orekit_jar", "hipparchus_jar")} + + +def _executableLines(source, node): + """Return the lines of ``node`` without docstrings, comments and blank lines, whitespace-normalized.""" + lines = source.splitlines() + skip = set() + for item in ast.walk(node): + if isinstance(item, (ast.Module, ast.FunctionDef, ast.ClassDef)) and ast.get_docstring(item, clean=False): + doc = item.body[0] + skip.update(range(doc.lineno, doc.end_lineno + 1)) + first, last = (1, len(lines)) if isinstance(node, ast.Module) else (node.lineno, node.end_lineno) + kept = [] + for number in range(first, last + 1): + if number not in skip: + code = " ".join(lines[number - 1].split("#")[0].split()) + if code: + kept.append(code) + return kept + + +def generatorFingerprint(tool): + """Return the SHA-256 hash of the code that defines what a generator feeds to its tool. + + It covers ``generate__reference.py`` and the functions of this module in :data:`GENERATOR_INPUT_FUNCTIONS`, + ignoring docstrings, comments and blank lines, so that a change of a hard-coded constant, of the integrator settings + or of a force model invalidates the references while documentation edits do not. + + Args: + tool (str): ``"gmat"`` or ``"orekit"``. + """ + digest = hashlib.sha256() + script = Path(__file__).resolve().parent / f"generate_{tool}_reference.py" # not HERE, which tests redirect + source = script.read_text() + digest.update("\n".join(_executableLines(source, ast.parse(source))).encode()) + common = Path(__file__).read_text() + tree = ast.parse(common) + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name in GENERATOR_INPUT_FUNCTIONS: + digest.update("\n".join(_executableLines(common, node)).encode()) + return digest.hexdigest() + + +def requireIcrf(spec, tool): + """Raise if ``cases.json`` asks for an inertial frame other than the ICRF axes that the generators implement. + + Args: + spec (dict): parsed ``cases.json`` content. + tool (str): ``"gmat"`` or ``"orekit"``. + """ + if spec["inertial_frame"] != "ICRF": + raise ValueError(f"The {tool} generator propagates and reports in the ICRF axes only, but cases.json asks for " + f"{spec['inertial_frame']}.") + + +def requiredProbeTools(spec, caseNames): + """Return the tools whose density probe is needed by the selected cases: the references of the drag cases. + + Args: + spec (dict): parsed ``cases.json`` content. + caseNames (list): names of the selected cases. + """ + needed = {tool for name in caseNames if spec["cases"][name]["drag"] for tool in caseReferences(spec["cases"][name])} + return tuple(tool for tool in TOOLS if tool in needed) + + +def checkConsistentProvenance(dataDir, tool, entries): + """Raise if the references used together were generated with different tool builds, data or generator code. + + Cases regenerated one at a time with another GMAT build, Orekit data set or generator revision would otherwise + be mixed silently. + + Args: + dataDir (Path): folder holding the reference ephemerides. + tool (str): ``"gmat"`` or ``"orekit"``. + entries (dict): manifest entries (and optionally ``"density_probe"``) keyed by name. + """ + def comparable(entry, key): + value = entry.get(key) + if key == "generator_options" and isinstance(value, dict) and not value.get("max_step_overridden"): + # The default maximum step is set per case by cases.json (and so is covered by the config hash) + value = {k: v for k, v in value.items() if k != "max_step_s"} + return value + + keys = ("tool_version", "generator_sha256", "external_data", "generator_options") + names = list(entries) + for name in names[1:]: + for key in keys: + if comparable(entries[name], key) != comparable(entries[names[0]], key) and not ( + key in ("external_data", "generator_options") and name == "density_probe"): + raise ValueError(f"The {tool} references in {manifestPath(dataDir, tool)} were generated with different " + f"{key} ({names[0]} vs {name}): regenerate all the cases used together.") + + +def manifestPath(dataDir, tool): + """Return the path of the reference manifest of a tool. + + Args: + dataDir (Path): folder holding the reference ephemerides. + tool (str): ``"gmat"`` or ``"orekit"``. + """ + return Path(dataDir) / f"{tool}_manifest.json" + + +def writeManifestEntry(dataDir, tool, spec, name, csvPath, frame, toolVersion, generatorOptions, externalData, + variant=DEFAULT_VARIANT): + """Record the provenance of a reference ephemeris in the manifest of its tool, merging with the other cases. + + Args: + dataDir (Path): folder holding the reference ephemerides. + tool (str): ``"gmat"`` or ``"orekit"``. + spec (dict): parsed ``cases.json`` content. + name (str): case name. + csvPath (Path): the reference ephemeris written for the case. + frame (str): inertial frame of the ephemeris. + toolVersion (str): version of the tool that generated the ephemeris. + generatorOptions (dict): generator options and integrator settings that affect the physics. + externalData (dict): identifier and SHA-256 hash of each external data set used, keyed by name. + variant (str): ``"default"`` or the name of an intentional alternative configuration. + """ + path = manifestPath(dataDir, tool) + manifest = json.loads(path.read_text()) if path.exists() else {"version": MANIFEST_VERSION, "cases": {}} + manifest["cases"][name] = { + "config_hash": caseFingerprint(spec, name), + "config": effectiveCase(spec, name), + "frame": frame, + "tool": tool, + "tool_version": toolVersion, + "generator_options": generatorOptions, + "generator_sha256": generatorFingerprint(tool), + "external_data": externalData, + "variant": variant, + "csv_sha256": fileSha256(csvPath), + "generated_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), + } + path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + + +def validateManifestEntry(dataDir, tool, spec, name, csvPath, allowedVariant=DEFAULT_VARIANT): + """Check that a reference ephemeris was generated for the current definition of a case. + + The manifest of the tool must hold an entry of the case whose configuration hash, inertial frame and ephemeris + checksum match the current ``cases.json`` and the file on disk, and whose recorded gravity coefficient checksum + matches the coefficient file read. A reference generated with an alternative + configuration (e.g. ``--oblate-shadow``) is rejected unless that variant is explicitly allowed. + + Args: + dataDir (Path): folder holding the reference ephemerides. + tool (str): ``"gmat"`` or ``"orekit"``. + spec (dict): parsed ``cases.json`` content. + name (str): case name. + csvPath (Path): the reference ephemeris of the case. + allowedVariant (str): variant of the reference that is accepted. + + Returns: + dict: the manifest entry of the case. + + Raises: + ValueError: naming the manifest field that does not match. + """ + path = manifestPath(dataDir, tool) + if not path.exists(): + raise ValueError(f"No manifest {path}: regenerate the {tool} references with the generator script.") + manifest = json.loads(path.read_text()) + where = f"{tool} reference of case {name}" + if manifest.get("version") != MANIFEST_VERSION: + raise ValueError(f"{path} has format version {manifest.get('version')} (version), expected {MANIFEST_VERSION}: " + "regenerate the references.") + entry = manifest.get("cases", {}).get(name) + if entry is None: + raise ValueError(f"{where} has no entry in {path}: regenerate it.") + if entry.get("tool") != tool: + raise ValueError(f"{where} was generated by {entry.get('tool')} (tool), not {tool}: the manifest was mixed up.") + if entry["config_hash"] != caseFingerprint(spec, name): + raise ValueError(f"{where} was generated for a different case configuration (config_hash, e.g. epoch, " + "initial state or force models changed in cases.json): regenerate it.") + if hashlib.sha256(json.dumps(entry["config"], sort_keys=True, separators=(",", ":")).encode()).hexdigest() \ + != entry["config_hash"]: + raise ValueError(f"{where} has a config that does not match its config_hash: the manifest was edited by hand.") + if entry["frame"] != spec["inertial_frame"]: + raise ValueError(f"{where} is expressed in {entry['frame']} (frame), but the comparison uses " + f"{spec['inertial_frame']}: regenerate it.") + if entry["variant"] != allowedVariant: + raise ValueError(f"{where} was generated as variant '{entry['variant']}' (variant), but the comparison " + f"expects variant '{allowedVariant}'. Alternative configurations such as --oblate-shadow are " + f"only accepted knowingly: pass --{tool}-variant {entry['variant']} to compare against it, " + "or regenerate the default reference.") + if entry["csv_sha256"] != fileSha256(csvPath): + raise ValueError(f"{csvPath} does not match the checksum recorded in the manifest (csv_sha256): the file was " + "modified or overwritten after it was generated.") + if entry.get("generator_sha256") != generatorFingerprint(tool): + raise ValueError(f"{where} was generated by another version of generate_{tool}_reference.py or of the shared " + "input writers (generator_sha256): the integrator, force model or constants may differ. " + "Regenerate it.") + if not entry.get("tool_version") or "unknown" in entry["tool_version"]: + raise ValueError(f"{where} records no tool build (tool_version '{entry.get('tool_version')}'): regenerate it.") + external = entry.get("external_data", {}) + missing = [key for key in REQUIRED_EXTERNAL_DATA[tool] if key not in external] + if missing: + raise ValueError(f"{where} records no checksum of {missing} (external_data): regenerate it.") + current = gravityCoefficientsRecord(spec) + if external["gravity_coefficients"]["sha256"] != current["sha256"]: + raise ValueError(f"{where} was generated with a different gravity coefficient file than the one Basilisk " + f"reads (external_data.gravity_coefficients.sha256 of {current['id']}): the coefficients " + "changed without a change of the file name or of cases.json. Regenerate the reference.") + return entry + + +def loadCoefficients(spec): + """Return the fully normalized Earth coefficients as ``{(n, m): (C, S)}``. + + Args: + spec (dict): parsed ``cases.json`` content. + """ + table = {} + with open(HERE / spec["gravity_coefficients_file"], newline="") as f: + for row in csv.DictReader(f): + table[(int(row["n"]), int(row["m"]))] = (float(row["C_norm"]), float(row["S_norm"])) + return table + + +def writeGmatCof(path, spec, degree, order): + """Write a GMAT ``.cof`` gravity file. + + Args: + path (Path): output file path. + spec (dict): parsed ``cases.json`` content. + degree (int): maximum degree to write. + order (int): maximum order to write. + """ + table = loadCoefficients(spec) + lines = [ + "COMMENT Earth field for the Basilisk accuracy comparison", + f"POTFIELD{degree:3d}{degree:3d}{1:3d}" + f"{spec['mu_m3_s2']:21.14E}{spec['equatorial_radius_m']:21.14E}{1.0:21.14E}", + ] + for n in range(2, degree + 1): + for m in range(n + 1): + c, s = table[(n, m)] if m <= order else (0.0, 0.0) + line = f"RECOEF{n:5d}{m:3d}{c:24.14E}" + lines.append(line + f"{s: .14E}" if m else line) + path.write_text("\n".join(lines) + "\n") + + +def writeGmatExponentialAtmosphere(path, spec): + """Write the GMAT exponential atmosphere table of the single-scale density model shared by all tools. + + GMAT's table has one band per row: bottom altitude [km], density at that altitude [kg/m^3] and scale height [km]. + A single band from 0 km is the model :math:`\\rho = \\rho_0 \\exp(-h/H)` given by the density at zero altitude and + the scale height of ``cases.json``, which every tool receives without conversion. + + Args: + path (Path): output file path. + spec (dict): parsed ``cases.json`` content. + """ + atm = spec["exponential_atmosphere"] + path.write_text(f"0.0, {float(atm['density_at_zero_altitude_kg_m3'])!r}, {atm['scale_height_m'] / 1000.0!r}\n") + + +def writeOrekitGfc(path, spec, degree, order): + """Write an ICGEM ``.gfc`` gravity file for Orekit. + + Args: + path (Path): output file path. + spec (dict): parsed ``cases.json`` content. + degree (int): maximum degree to write. + order (int): maximum order to write. + """ + table = loadCoefficients(spec) + lines = [ + "product_type gravity_field", + "modelname COMPARISON", + f"earth_gravity_constant {spec['mu_m3_s2']:.14E}", + f"radius {spec['equatorial_radius_m']:.14E}", + f"max_degree {degree}", + "errors no", + "norm fully_normalized", + "tide_system unknown", + "end_of_head", + ] + for n in range(2, degree + 1): + for m in range(min(n, order) + 1): + c, s = table[(n, m)] + lines.append(f"gfc {n:3d} {m:3d} {c:.14E} {s:.14E}") + path.write_text("\n".join(lines) + "\n") + + +def writeBasiliskGravity(path, spec, degree, order): + """Write the gravity file in the Basilisk spherical-harmonics text format. + + Args: + path (Path): output file path. + spec (dict): parsed ``cases.json`` content. + degree (int): maximum degree to write. + order (int): maximum order to write. + """ + table = loadCoefficients(spec) + from Basilisk.utilities import orbitalMotion # only the Basilisk comparison writes this file + omegaEarth = orbitalMotion.OMEGA_EARTH # [rad/s] required by the file format, unused here + lines = [f"{spec['equatorial_radius_m']:.10E}, {spec['mu_m3_s2']:.10E}, {omegaEarth:.7E}, " + f"{degree}, {degree}, 1, 0.0, 0.0"] + for n in range(degree + 1): + for m in range(n + 1): + if n == 0: + c, s = 1.0, 0.0 + elif n >= 2 and m <= order: + c, s = table[(n, m)] + else: + c, s = 0.0, 0.0 + lines.append(f"{n}, {m}, {c:.12E}, {s:.12E}, 0.0, 0.0") + path.write_text("\n".join(lines) + "\n") + + +PROBE_COLUMNS = ["x_m", "y_m", "z_m", "altitude_m", "density_kg_m3"] # columns of the density probe files + + +def geodeticToEcef(spec, latDeg, lonDeg, altitude): + """Return the Earth-fixed position [m] of a geodetic point above the ellipsoid of ``cases.json``. + + Args: + spec (dict): parsed ``cases.json`` content. + latDeg (float): [deg] geodetic latitude. + lonDeg (float): [deg] longitude. + altitude (float): [m] altitude above the ellipsoid. + """ + a = spec["equatorial_radius_m"] # [m] + f = spec["earth_flattening"] # [-] + e2 = f * (2.0 - f) # [-] + lat, lon = np.radians(latDeg), np.radians(lonDeg) # [rad] + n = a / np.sqrt(1.0 - e2 * np.sin(lat) ** 2) # [m] + return np.array([(n + altitude) * np.cos(lat) * np.cos(lon), + (n + altitude) * np.cos(lat) * np.sin(lon), + (n * (1.0 - e2) + altitude) * np.sin(lat)]) + + +def probePoints(spec): + """Return the density probe points as an array of rows ``(x, y, z [m], nominal altitude [m])``, Earth-fixed. + + Args: + spec (dict): parsed ``cases.json`` content. + """ + return np.array([[*geodeticToEcef(spec, lat, lon, alt), alt] + for lat, lon, alt in spec["density_probe"]["points_lat_lon_alt"]]) + + +def probeFingerprint(spec): + """Return the SHA-256 hash of everything that defines the density probe of a tool. + + Args: + spec (dict): parsed ``cases.json`` content. + """ + canonical = json.dumps({"probe": spec["density_probe"]["points_lat_lon_alt"], + "atmosphere": spec["exponential_atmosphere"], + "equatorial_radius_m": spec["equatorial_radius_m"], + "earth_flattening": spec["earth_flattening"]}, + sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def probePath(dataDir, tool): + """Return the path of the density probe file of a tool. + + Args: + dataDir (Path): folder holding the reference files. + tool (str): ``"gmat"`` or ``"orekit"``. + """ + return Path(dataDir) / f"{tool}_density_probe.csv" + + +def writeProbe(dataDir, tool, spec, rows, toolVersion, generatorOptions): + """Write the density probe of a tool and record its provenance in the manifest of the tool. + + Args: + dataDir (Path): folder holding the reference files. + tool (str): ``"gmat"`` or ``"orekit"``. + spec (dict): parsed ``cases.json`` content. + rows (list): one ``(x, y, z [m], altitude [m], density [kg/m^3])`` row per probe point, in the order of + ``points_lat_lon_alt``, with the position and the altitude as reported by the tool. + toolVersion (str): version of the tool. + generatorOptions (dict): generator options that affect the evaluation. + """ + path = probePath(dataDir, tool) + with open(path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(PROBE_COLUMNS) + for row in rows: + writer.writerow([f"{v:.12e}" for v in row]) + manifestFile = manifestPath(dataDir, tool) + manifest = json.loads(manifestFile.read_text()) if manifestFile.exists() else {"version": MANIFEST_VERSION, + "cases": {}} + manifest["density_probe"] = {"probe_hash": probeFingerprint(spec), "tool": tool, "tool_version": toolVersion, + "generator_options": generatorOptions, "generator_sha256": generatorFingerprint(tool), "csv_sha256": fileSha256(path), + "generated_utc": datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds")} + manifestFile.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + + +def loadProbe(dataDir, tool, spec): + """Return the density probe of a tool as an array of rows ``PROBE_COLUMNS``, after validating its manifest. + + Args: + dataDir (Path): folder holding the reference files. + tool (str): ``"gmat"`` or ``"orekit"``. + spec (dict): parsed ``cases.json`` content. + + Raises: + ValueError: naming the manifest field that does not match, or if the file has the wrong number of points. + """ + path = probePath(dataDir, tool) + manifestFile = manifestPath(dataDir, tool) + where = f"{tool} density probe {path}" + if not path.exists(): + raise ValueError(f"The {where} does not exist: regenerate the {tool} references with the generator script.") + manifest = json.loads(manifestFile.read_text()) if manifestFile.exists() else {} + entry = manifest.get("density_probe") + if entry is None: + raise ValueError(f"The {where} has no density_probe entry in {manifestFile}: regenerate it.") + if manifest.get("version") != MANIFEST_VERSION or entry.get("tool") != tool: + raise ValueError(f"The {where} has another manifest format version or tool (version, tool): regenerate it.") + if entry.get("generator_sha256") != generatorFingerprint(tool): + raise ValueError(f"The {where} was generated by another version of generate_{tool}_reference.py " + "(generator_sha256): regenerate it.") + if not entry.get("tool_version") or "unknown" in entry["tool_version"]: + raise ValueError(f"The {where} records no tool build (tool_version): regenerate it.") + if entry["probe_hash"] != probeFingerprint(spec): + raise ValueError(f"The {where} was generated for a different probe or atmosphere (probe_hash): regenerate it.") + if entry["csv_sha256"] != fileSha256(path): + raise ValueError(f"The {where} does not match the checksum recorded in the manifest (csv_sha256).") + rows = np.loadtxt(path, delimiter=",", skiprows=1, ndmin=2) + if rows.shape != (len(spec["density_probe"]["points_lat_lon_alt"]), len(PROBE_COLUMNS)): + raise ValueError(f"The {where} has shape {rows.shape}, which does not match the probe points of cases.json.") + return rows + + +def checkProbe(spec, tool, rows, bskDensity): + """Compare the density and altitude of a tool at the probe points with Basilisk and the nominal altitude. + + The tool reports the position it evaluated and the altitude and density it computed there. Basilisk evaluates its + atmosphere at the same positions. The altitude of the tool must agree with the geodetic altitude of the point + (altitude definition) and the densities must agree (model). + + Args: + spec (dict): parsed ``cases.json`` content. + tool (str): ``"gmat"`` or ``"orekit"``. + rows (ndarray): the probe of the tool, columns ``PROBE_COLUMNS``. + bskDensity (ndarray): [kg/m^3] density of Basilisk at the positions of ``rows``. + + Returns: + list: one ``(nominal altitude [m], altitude error [m], relative density difference [-])`` tuple per point. + + Raises: + ValueError: naming the tool and the first point that exceeds a tolerance. + """ + probe = spec["density_probe"] + nominal = probePoints(spec)[:, 3] # [m] + results = [] + for k, (row, h0, rhoBsk) in enumerate(zip(rows, nominal, bskDensity)): + altitudeError = row[3] - h0 # [m] + densityError = (rhoBsk - row[4]) / row[4] # [-] + results.append((h0, altitudeError, densityError)) + point = probe["points_lat_lon_alt"][k] + if abs(altitudeError) > probe["altitude_tolerance_m"]: + raise ValueError(f"{tool} reports an altitude {altitudeError:+.3g} m different from the geodetic altitude " + f"at probe point {k} {point}: the altitude definition differs.") + if abs(densityError) > probe["density_rel_tolerance"]: + raise ValueError(f"Basilisk and {tool} differ in density by {densityError:+.3e} (relative) at probe point " + f"{k} {point}: the atmosphere models are not equivalent.") + return results diff --git a/benchmarks/accuracyComparison/data/egm96_normalized_coefficients.csv b/benchmarks/accuracyComparison/data/egm96_normalized_coefficients.csv new file mode 100644 index 00000000000..41708f2bc33 --- /dev/null +++ b/benchmarks/accuracyComparison/data/egm96_normalized_coefficients.csv @@ -0,0 +1,2554 @@ +n,m,C_norm,S_norm +2,0,-4.841653717360E-04,0.000000000000E+00 +2,1,-1.869876359550E-10,1.195280120310E-09 +2,2,2.439143523980E-06,-1.400166836540E-06 +3,0,9.572541737920E-07,0.000000000000E+00 +3,1,2.029988821840E-06,2.485131587160E-07 +3,2,9.046277686050E-07,-6.190259442050E-07 +3,3,7.210726570570E-07,1.414356269580E-06 +4,0,5.398738637890E-07,0.000000000000E+00 +4,1,-5.363216169710E-07,-4.734402658530E-07 +4,2,3.506941057850E-07,6.626715725400E-07 +4,3,9.907718038290E-07,-2.009283691770E-07 +4,4,-1.885608027350E-07,3.088531693330E-07 +5,0,6.853234756300E-08,0.000000000000E+00 +5,1,-6.210121285280E-08,-9.442261275250E-08 +5,2,6.524382976120E-07,-3.233496126680E-07 +5,3,-4.519554060710E-07,-2.148471906240E-07 +5,4,-2.953016476540E-07,4.966588767690E-08 +5,5,1.749719832030E-07,-6.693842782190E-07 +6,0,-1.499579947140E-07,0.000000000000E+00 +6,1,-7.608793849470E-08,2.628905455010E-08 +6,2,4.817324428320E-08,-3.737282013470E-07 +6,3,5.717309905160E-08,9.026945171630E-09 +6,4,-8.621426601090E-08,-4.714081542670E-07 +6,5,-2.671333254900E-07,-5.364884324830E-07 +6,6,9.676161210920E-09,-2.371920069350E-07 +7,0,9.097893714500E-08,0.000000000000E+00 +7,1,2.798729104880E-07,9.543369118670E-08 +7,2,3.297438164880E-07,9.306675960420E-08 +7,3,2.503986577060E-07,-2.171986087380E-07 +7,4,-2.751143552570E-07,-1.238003923230E-07 +7,5,1.937655072430E-09,1.773777198720E-08 +7,6,-3.588568606450E-07,1.517898177390E-07 +7,7,1.091851480450E-09,2.444157079930E-08 +8,0,4.967116673240E-08,0.000000000000E+00 +8,1,2.334220478930E-08,5.900604934110E-08 +8,2,8.029787226150E-08,6.541754258590E-08 +8,3,-1.918777570090E-08,-8.634544450210E-08 +8,4,-2.446001054710E-07,7.002330169340E-08 +8,5,-2.553524030370E-08,8.914621647880E-08 +8,6,-6.573616109610E-08,3.092384618070E-07 +8,7,6.728115800720E-08,7.474404736330E-08 +8,8,-1.240924930160E-07,1.205331656030E-07 +9,0,2.767143008530E-08,0.000000000000E+00 +9,1,1.433875027490E-07,2.168349476180E-08 +9,2,2.222883185640E-08,-3.221966471160E-08 +9,3,-1.608115021430E-07,-7.422874094620E-08 +9,4,-9.001792253360E-09,1.946667794750E-08 +9,5,-1.661650929240E-08,-5.411131914830E-08 +9,6,6.269419382480E-08,2.229035259450E-07 +9,7,-1.183663234750E-07,-9.651526678860E-08 +9,8,1.884360227940E-07,-3.085662204210E-09 +9,9,-4.774753861320E-08,9.664128477140E-08 +10,0,5.262224885690E-08,0.000000000000E+00 +10,1,8.351157756520E-08,-1.313143317960E-07 +10,2,-9.424138820810E-08,-5.157916573900E-08 +10,3,-6.898950481760E-09,-1.537688286940E-07 +10,4,-8.407645497160E-08,-7.928062553310E-08 +10,5,-4.933959381850E-08,-5.053702218970E-08 +10,6,-3.758852365980E-08,-7.956670538720E-08 +10,7,8.114605409250E-09,-3.366296413140E-09 +10,8,4.049279816940E-08,-9.187059759220E-08 +10,9,1.254913349390E-07,-3.765162223920E-08 +10,10,1.005386344090E-07,-2.401484495200E-08 +11,0,-5.096137075220E-08,0.000000000000E+00 +11,1,1.516872099330E-08,-2.686041461660E-08 +11,2,1.863097498780E-08,-9.906938620470E-08 +11,3,-3.098712398540E-08,-1.481318042600E-07 +11,4,-3.895802050510E-08,-6.366665119800E-08 +11,5,3.778480294520E-08,4.947362381690E-08 +11,6,-1.186765923950E-09,3.447695845930E-08 +11,7,4.115651880740E-09,-8.982528089770E-08 +11,8,-5.984108413000E-09,2.439896122370E-08 +11,9,-3.142310727230E-08,4.177318298290E-08 +11,10,-5.218826819270E-08,-1.833645617880E-08 +11,11,4.603444487460E-08,-6.966623081850E-08 +12,0,3.772526365580E-08,0.000000000000E+00 +12,1,-5.406549778360E-08,-4.356757489790E-08 +12,2,1.429796422530E-08,3.209759376190E-08 +12,3,3.939958764030E-08,2.442648635050E-08 +12,4,-6.869081279340E-08,4.150811090110E-09 +12,5,3.094111287300E-08,7.825362790330E-09 +12,6,3.415232752080E-09,3.917654844490E-08 +12,7,-1.869099585870E-08,3.561318493820E-08 +12,8,-2.537693988650E-08,1.693610246290E-08 +12,9,4.228806306620E-08,2.526925983010E-08 +12,10,-6.176196549020E-09,3.083757942120E-08 +12,11,1.125029941220E-08,-6.379465015580E-09 +12,12,-2.495326073900E-09,-1.117806019000E-08 +13,0,4.229822064130E-08,0.000000000000E+00 +13,1,-5.135696991240E-08,3.905103866850E-08 +13,2,5.592176670990E-08,-6.273375653810E-08 +13,3,-2.193609279450E-08,9.748293622370E-08 +13,4,-3.137625996660E-09,-1.196278744920E-08 +13,5,5.900493949050E-08,6.649759580360E-08 +13,6,-3.590380730750E-08,-6.572806136860E-09 +13,7,2.530021470870E-09,-6.214708223310E-09 +13,8,-9.831508226950E-09,-1.047402228250E-08 +13,9,2.473257717910E-08,4.528703699360E-08 +13,10,4.103246539300E-08,-3.681210294800E-08 +13,11,-4.438696773990E-08,-4.765078042880E-09 +13,12,-3.126222002220E-08,8.784058092670E-08 +13,13,-6.127595531990E-08,6.852614885940E-08 +14,0,-2.427865029210E-08,0.000000000000E+00 +14,1,-1.869686163810E-08,2.947475422490E-08 +14,2,-3.677893795020E-08,-5.167793920550E-09 +14,3,3.588750973330E-08,2.046188278330E-08 +14,4,1.838656177920E-09,-2.267806135660E-08 +14,5,2.873442735420E-08,-1.638822497280E-08 +14,6,-1.948104855740E-08,2.478312727810E-09 +14,7,3.750038394150E-08,-4.172913194290E-09 +14,8,-3.509464858650E-08,-1.535152652030E-08 +14,9,3.202849393410E-08,2.888049220640E-08 +14,10,3.903291800080E-08,-1.443084524690E-09 +14,11,1.539705165020E-08,-3.905481732450E-08 +14,12,8.408291638690E-09,-3.113271891170E-08 +14,13,3.221470439640E-08,4.518972249600E-08 +14,14,-5.189807943090E-08,-4.815066367480E-09 +15,0,1.479100687080E-09,0.000000000000E+00 +15,1,1.008172681770E-08,1.097730663240E-08 +15,2,-2.139426737750E-08,-3.089148757770E-08 +15,3,5.213929290410E-08,1.728929261030E-08 +15,4,-4.081500840780E-08,6.501747077940E-09 +15,5,1.249357231080E-08,8.083755639960E-09 +15,6,3.312116438960E-08,-3.682460043040E-08 +15,7,5.962106992590E-08,5.318411718790E-09 +15,8,-3.224286914980E-08,2.215235795870E-08 +15,9,1.287882680850E-08,3.756298208290E-08 +15,10,1.046887225210E-08,1.472221470150E-08 +15,11,-1.116750619340E-09,1.809961984320E-08 +15,12,-3.239621344150E-08,1.552431047460E-08 +15,13,-2.839330191170E-08,-4.220667911030E-09 +15,14,5.191688593300E-09,-2.437527396660E-08 +15,15,-1.909305383220E-08,-4.711394215580E-09 +16,0,-3.153229867220E-09,0.000000000000E+00 +16,1,2.583608562310E-08,3.254475608590E-08 +16,2,-2.336714045120E-08,2.887993634390E-08 +16,3,-3.360194293910E-08,-2.204189880100E-08 +16,4,4.023162843140E-08,4.838377169090E-08 +16,5,-1.295019392450E-08,-3.194585781290E-09 +16,6,1.402392523230E-08,-3.507602083030E-08 +16,7,-7.084126351360E-09,-8.815815611310E-09 +16,8,-2.090188680940E-08,5.005273905300E-09 +16,9,-2.185887206430E-08,-3.950124199940E-08 +16,10,-1.175299008140E-08,1.142115829610E-08 +16,11,1.875740425920E-08,-3.031619199250E-09 +16,12,1.954001940380E-08,6.669835740710E-09 +16,13,1.381963695760E-08,1.027784995080E-09 +16,14,-1.931821688560E-08,-3.861748937760E-08 +16,15,-1.451490601420E-08,-3.274430787390E-08 +16,16,-3.796717107460E-08,3.021553726550E-09 +17,0,1.976050663950E-08,0.000000000000E+00 +17,1,-2.541775751180E-08,-3.066305296890E-08 +17,2,-1.959886567210E-08,6.492658934100E-09 +17,3,5.641230662240E-09,6.783270955290E-09 +17,4,7.074570756370E-09,2.494376008340E-08 +17,5,-1.549870060520E-08,6.600215518510E-09 +17,6,-1.181940128470E-08,-2.897709751770E-08 +17,7,2.421497023810E-08,-4.222229736970E-09 +17,8,3.884420975590E-08,3.589040959430E-09 +17,9,3.813564932310E-09,-2.814669437140E-08 +17,10,-3.882160855420E-09,1.813281765080E-08 +17,11,-1.573566003630E-08,1.065606494040E-08 +17,12,2.880130106550E-08,2.034501360840E-08 +17,13,1.655034257310E-08,2.046675314350E-08 +17,14,-1.419838726490E-08,1.149480252440E-08 +17,15,5.421003616570E-09,5.326103698110E-09 +17,16,-3.019922050430E-08,3.653319185310E-09 +17,17,-3.430868560410E-08,-1.985234553810E-08 +18,0,5.086910383320E-09,0.000000000000E+00 +18,1,7.210984496490E-09,-3.887144730130E-08 +18,2,1.406317712050E-08,1.000933962530E-08 +18,3,-5.072325208730E-09,-4.908659313350E-09 +18,4,5.487593082170E-08,-1.352671177200E-09 +18,5,5.487104855550E-09,2.643386294590E-08 +18,6,1.465707552710E-08,-1.364380199510E-08 +18,7,6.758123284170E-09,6.885774942350E-09 +18,8,3.076198451440E-08,4.178277341070E-09 +18,9,-1.884706018800E-08,3.683027369530E-08 +18,10,5.275353589340E-09,-4.660915358810E-09 +18,11,-7.296285189600E-09,1.952152080200E-09 +18,12,-2.974494124220E-08,-1.644978783950E-08 +18,13,-6.279197171520E-09,-3.483839399380E-08 +18,14,-8.156053364100E-09,-1.286365850270E-08 +18,15,-4.050034128790E-08,-2.026849980210E-08 +18,16,1.041410420280E-08,6.614688176240E-09 +18,17,3.587715868410E-09,4.480655875640E-09 +18,18,3.123519537170E-09,-1.099060325430E-08 +19,0,-3.257809653940E-09,0.000000000000E+00 +19,1,-7.599038853190E-09,1.268354726050E-09 +19,2,3.535415286550E-08,-1.313463035140E-09 +19,3,-9.741036073090E-09,1.506622590430E-09 +19,4,1.570390090570E-08,-7.616773838110E-09 +19,5,1.096292133790E-08,2.831721764380E-08 +19,6,-4.087451786580E-09,1.862194307190E-08 +19,7,4.782753370440E-09,-7.172834559000E-09 +19,8,2.949083642800E-08,-9.930370028830E-09 +19,9,3.079614271590E-09,6.941104772140E-09 +19,10,-3.384150690430E-08,-7.379817671360E-09 +19,11,1.604436529160E-08,9.966734534830E-09 +19,12,-2.471065815810E-09,9.168523106420E-09 +19,13,-7.447173799800E-09,-2.825844667420E-08 +19,14,-4.705025892150E-09,-1.295266979830E-08 +19,15,-1.765805497710E-08,-1.403509900390E-08 +19,16,-2.169500961880E-08,-7.245347215670E-09 +19,17,2.904449360790E-08,-1.534565310700E-08 +19,18,3.483821995930E-08,-9.541463449170E-09 +19,19,-2.573493494300E-09,4.831518223630E-09 +20,0,2.223846106510E-08,0.000000000000E+00 +20,1,5.163031252180E-09,6.696267269660E-09 +20,2,1.988311282380E-08,1.751838432570E-08 +20,3,-3.626014367850E-09,3.795907241410E-08 +20,4,2.422381186520E-09,-2.110576118740E-08 +20,5,-1.070425625640E-08,-7.718600831690E-09 +20,6,1.104748375700E-08,-2.177203658980E-09 +20,7,-2.100902827280E-08,-2.234915039690E-11 +20,8,4.424191856370E-09,1.830358045930E-09 +20,9,1.788462169420E-08,-6.639408653580E-09 +20,10,-3.253949199880E-08,-5.123088736210E-09 +20,11,1.389927076970E-08,-1.877064549420E-08 +20,12,-6.357506007500E-09,1.802608531030E-08 +20,13,2.752227259970E-08,6.908870775880E-09 +20,14,1.158411694050E-08,-1.431761601430E-08 +20,15,-2.601307442910E-08,-7.843796724130E-10 +20,16,-1.241371471180E-08,-2.775004436280E-10 +20,17,4.369096679600E-09,-1.374204461980E-08 +20,18,1.518428830220E-08,-8.084299031420E-10 +20,19,-3.149420028520E-09,1.065052022450E-08 +20,20,4.014483279680E-09,-1.204506447850E-08 +21,0,5.878202525750E-09,0.000000000000E+00 +21,1,-1.610006701410E-08,2.843594007910E-08 +21,2,-6.544604825580E-09,3.784748685080E-09 +21,3,1.954919952600E-08,2.262869637160E-08 +21,4,-5.766043392390E-09,1.944937826310E-08 +21,5,2.588563030160E-09,1.708503686690E-09 +21,6,-1.401688105890E-08,-2.738148263810E-12 +21,7,-8.643571684750E-09,4.426122771190E-09 +21,8,-1.704772782370E-08,1.507111926300E-09 +21,9,1.644890623940E-08,8.301131963650E-09 +21,10,-1.099289764090E-08,-1.469137946840E-09 +21,11,6.993003642140E-09,-3.535905651240E-08 +21,12,-3.193001095940E-09,1.457869179470E-08 +21,13,-1.898545245900E-08,1.405147914360E-08 +21,14,2.035807856740E-08,7.557724628400E-09 +21,15,1.755302202780E-08,1.045338868320E-08 +21,16,7.869691093670E-09,-6.560897152790E-09 +21,17,-6.994844899810E-09,-7.360649011470E-09 +21,18,2.596432915210E-08,-1.115608061300E-08 +21,19,-2.737416364100E-08,1.639581900520E-08 +21,20,-2.686824735840E-08,1.620860571680E-08 +21,21,8.303748739320E-09,-3.755461217420E-09 +22,0,-1.137351242590E-08,0.000000000000E+00 +22,1,1.623098656790E-08,-3.773034751530E-09 +22,2,-2.640902613870E-08,-2.108324024280E-09 +22,3,1.165800165400E-08,1.067646172220E-08 +22,4,-2.709791414510E-09,1.749808205650E-08 +22,5,-1.864526250100E-09,7.447181664760E-10 +22,6,9.643907044060E-09,-6.373167439080E-09 +22,7,1.597159817950E-08,4.396009429930E-09 +22,8,-2.351574269980E-08,4.836736950860E-09 +22,9,8.294357967370E-09,8.733821599860E-09 +22,10,6.007040377010E-09,2.218541211090E-08 +22,11,-4.960783015390E-09,-1.788226724740E-08 +22,12,2.135023154630E-09,-7.961205225030E-09 +22,13,-1.726318439790E-08,1.970268968920E-08 +22,14,1.092971330180E-08,8.252809053010E-09 +22,15,2.584108406290E-08,4.601729983180E-09 +22,16,1.412585589210E-10,-7.182380053000E-09 +22,17,8.892940968460E-09,-1.456183482460E-08 +22,18,1.050474474640E-08,-1.642712754810E-08 +22,19,1.413055091240E-08,-3.845371685990E-09 +22,20,-1.676176554410E-08,1.995615133210E-08 +22,21,-2.509487564550E-08,2.361513461330E-08 +22,22,-9.595966948090E-09,2.498614138830E-09 +23,0,-2.262010750820E-08,0.000000000000E+00 +23,1,1.108702397580E-08,1.613791515300E-08 +23,2,-1.351910277790E-08,-5.014117148520E-09 +23,3,-2.451280114450E-08,-1.605704389980E-08 +23,4,-2.398878745580E-08,7.315363622890E-09 +23,5,7.996366241460E-10,-1.614497414100E-10 +23,6,-1.260827813090E-08,1.613081556320E-08 +23,7,-8.041321337620E-09,-1.116471974940E-09 +23,8,7.537853264690E-09,-3.296799252200E-10 +23,9,2.550532549500E-09,-1.280715255480E-08 +23,10,1.651679291340E-08,-1.852396208530E-09 +23,11,9.426568227250E-09,1.523861815830E-08 +23,12,1.636326255350E-08,-1.240983278240E-08 +23,13,-1.151078328080E-08,-4.842791716270E-09 +23,14,6.753216022060E-09,-1.828999622120E-09 +23,15,1.868980428600E-08,-3.605237544810E-09 +23,16,6.138401218640E-09,1.103627072660E-08 +23,17,-5.537210239100E-09,-1.284590604600E-08 +23,18,8.433612638130E-09,-1.491159216050E-08 +23,19,-5.208482283420E-09,1.077895939430E-08 +23,20,8.604343968370E-09,-5.346416393720E-09 +23,21,1.545781898670E-08,1.153333253580E-08 +23,22,-1.784172064710E-08,4.330923489030E-09 +23,23,2.853939801110E-09,-1.132329459700E-08 +24,0,7.636573864110E-10,0.000000000000E+00 +24,1,-3.149436814270E-09,-1.771911903960E-09 +24,2,1.385955720930E-09,1.711040664000E-08 +24,3,-4.764069135280E-09,-9.423293781250E-09 +24,4,6.051080363410E-09,5.497699101910E-09 +24,5,-7.294790474800E-09,-2.138264905040E-08 +24,6,4.542103675350E-09,1.855966653180E-09 +24,7,-6.142444892980E-09,4.700816679510E-09 +24,8,1.548224444250E-08,-4.344720977870E-09 +24,9,-9.766234257970E-09,-1.627551376200E-08 +24,10,1.089346289740E-08,2.091687836080E-08 +24,11,1.452807753370E-08,1.873980187970E-08 +24,12,1.189703107170E-08,-6.229330981500E-09 +24,13,-2.896766730580E-09,3.132512950240E-09 +24,14,-2.000065586030E-08,-1.872496368210E-09 +24,15,6.103963506980E-09,-1.589576805630E-08 +24,16,8.887507533750E-09,2.964927033520E-09 +24,17,-1.196299646110E-08,-5.820745939550E-09 +24,18,-6.526306415550E-10,-1.013323558370E-08 +24,19,-4.388965502640E-09,-8.145525699770E-09 +24,20,-5.175519818510E-09,8.903549423780E-09 +24,21,6.034367550460E-09,1.401160907410E-08 +24,22,3.936402830550E-09,-4.283276557540E-09 +24,23,-6.142834795500E-09,-8.692679021000E-09 +24,24,1.239039213090E-08,-3.750592869590E-09 +25,0,3.213092081150E-09,0.000000000000E+00 +25,1,6.896492085670E-09,-7.995518294000E-09 +25,2,2.194981391730E-08,9.013702491110E-09 +25,3,-1.177749315870E-08,-1.267190243920E-08 +25,4,9.425436289200E-09,6.849371993110E-10 +25,5,-1.004974873390E-08,-9.221223996700E-10 +25,6,1.668328716540E-08,4.305835761990E-10 +25,7,7.714266816710E-09,-4.117032904250E-09 +25,8,3.156519441500E-09,-7.819602176690E-10 +25,9,-2.993853505150E-08,2.126954731990E-08 +25,10,8.819318180340E-09,-4.180415861660E-09 +25,11,1.234014856800E-09,1.080691281230E-08 +25,12,-7.651467867550E-09,1.174737428600E-08 +25,13,8.323081271580E-09,-1.130726046260E-08 +25,14,-1.970421247940E-08,6.531834886350E-09 +25,15,-4.357320529850E-09,-7.351472275730E-09 +25,16,9.182395484550E-10,-1.281248885920E-08 +25,17,-1.521765353790E-08,-3.212803979240E-09 +25,18,1.219015342450E-09,-1.490404832590E-08 +25,19,7.775891117570E-09,9.925187719410E-09 +25,20,-7.508566706720E-09,-5.628261553050E-10 +25,21,1.072328406800E-08,8.160901743810E-09 +25,22,-1.399022359290E-08,3.585461983240E-09 +25,23,8.402708536550E-09,-1.233384079610E-08 +25,24,4.124471345690E-09,-8.307164653170E-09 +25,25,1.074843667670E-08,4.723699139840E-09 +26,0,5.058336354140E-09,0.000000000000E+00 +26,1,-1.547561779650E-09,-7.700127888710E-09 +26,2,-3.587298768360E-09,1.144841111820E-08 +26,3,1.405056712670E-08,4.309055342940E-09 +26,4,1.905487092160E-08,-1.941611796580E-08 +26,5,1.071900254080E-08,9.089528518130E-09 +26,6,1.131169094060E-08,-9.343933844490E-09 +26,7,-1.562282956000E-09,4.811683024770E-09 +26,8,3.949201463170E-09,1.153405253000E-09 +26,9,-1.203714336380E-08,4.751770581340E-10 +26,10,-1.412461243340E-08,-6.452172472940E-09 +26,11,-5.203858576490E-09,2.124433404070E-09 +26,12,-1.750711764840E-08,2.019749719380E-09 +26,13,-3.357088352450E-11,1.504740916860E-09 +26,14,7.963850514920E-09,7.847040688350E-09 +26,15,-1.323887810890E-08,8.039600914420E-09 +26,16,1.290932262530E-09,-6.114344557060E-09 +26,17,-1.244941575640E-08,7.807748456400E-09 +26,18,-1.303174244590E-08,4.999891625700E-09 +26,19,-2.058074645950E-09,3.543961354380E-09 +26,20,6.559521440180E-09,-1.168780411800E-08 +26,21,-8.700388684540E-09,1.682222575640E-09 +26,22,1.015804520490E-08,7.543585315760E-09 +26,23,1.241050574360E-09,1.085800889350E-08 +26,24,8.586203519670E-09,1.482885100990E-08 +26,25,3.934415788730E-09,-5.977924158060E-10 +26,26,3.931797495680E-10,1.938949977720E-09 +27,0,2.771763223600E-09,0.000000000000E+00 +27,1,2.489829094520E-09,3.773784553570E-09 +27,2,1.452701464530E-09,5.031132680260E-10 +27,3,-3.623068128560E-10,1.088457625000E-08 +27,4,-5.991915371570E-10,9.405176812330E-09 +27,5,1.676905608880E-08,1.383385872090E-08 +27,6,3.642659898030E-09,6.130328077440E-09 +27,7,-1.234592660090E-08,-3.865140759520E-09 +27,8,-6.104076448200E-09,-8.995044715810E-09 +27,9,3.401131570780E-09,1.109929386650E-08 +27,10,-1.331588931870E-08,1.728322799150E-10 +27,11,1.983228081070E-09,-9.690542544260E-09 +27,12,-1.136954130440E-08,1.900729437810E-09 +27,13,-4.972247812720E-09,-4.145215599960E-09 +27,14,1.550339570880E-08,1.188212896900E-08 +27,15,-1.800573261960E-09,1.176369862200E-09 +27,16,2.757299528900E-09,2.787702691940E-09 +27,17,3.792815717630E-09,3.149831010490E-10 +27,18,-2.871440717150E-09,7.441905587180E-09 +27,19,-3.265186147070E-10,-2.932435004550E-09 +27,20,-8.551825618460E-10,3.476172081150E-09 +27,21,4.868770309830E-09,-7.087252835400E-09 +27,22,-5.743321000840E-09,2.900566873840E-09 +27,23,-5.410334709410E-09,-1.104524336550E-08 +27,24,4.169518859330E-10,-1.800381863070E-09 +27,25,1.228154702120E-08,5.624251372850E-09 +27,26,-6.594980751640E-09,-2.228384186390E-09 +27,27,7.600673810590E-09,6.923874189200E-10 +28,0,-9.103763758630E-09,0.000000000000E+00 +28,1,-5.554849935870E-09,7.933001925800E-09 +28,2,-1.518913121100E-08,-7.979570890120E-09 +28,3,2.531825422400E-09,1.113730493920E-08 +28,4,-1.992127521260E-09,1.250547047040E-08 +28,5,1.088718757020E-08,-4.225738269890E-09 +28,6,-5.221943160320E-09,1.326565097090E-08 +28,7,-7.055888637460E-10,5.127409977110E-09 +28,8,-4.237049763290E-09,-3.325844745530E-09 +28,9,1.138424618590E-08,-1.041630108110E-08 +28,10,-9.228678850820E-09,8.178518515930E-09 +28,11,-2.980973425700E-09,-1.459445389490E-09 +28,12,-4.834718632560E-10,9.649518450270E-09 +28,13,1.649939749570E-09,6.638037686890E-09 +28,14,-8.233348286190E-09,-1.269394922430E-08 +28,15,-1.227747981870E-08,-1.975373662620E-09 +28,16,-3.572806907090E-09,-1.358900447660E-08 +28,17,1.337426281840E-08,-4.723742263190E-09 +28,18,5.625323227480E-09,-3.872307273280E-09 +28,19,5.771047096350E-09,2.350117342920E-08 +28,20,-1.159221895210E-09,6.629399406620E-09 +28,21,6.631543443750E-09,6.332012112230E-09 +28,22,-1.942314516620E-09,-7.337252631070E-09 +28,23,6.201581651020E-09,2.612024376820E-09 +28,24,1.111862706210E-08,-1.356063787690E-08 +28,25,7.294958961490E-09,-1.760414770310E-08 +28,26,1.230849922590E-08,3.892518439390E-09 +28,27,-8.119712067240E-09,1.302792285500E-09 +28,28,6.987258783200E-09,6.805261679790E-09 +29,0,-4.974064394730E-09,0.000000000000E+00 +29,1,4.989790845850E-09,-9.825124611890E-09 +29,2,-3.121197546210E-09,-2.634334876760E-09 +29,3,1.825181204540E-09,-1.057699777510E-08 +29,4,-2.427863149950E-08,2.261107586220E-09 +29,5,-6.811030636700E-09,6.012425558170E-09 +29,6,1.195928792110E-08,9.702006957400E-09 +29,7,-5.911009342090E-09,-2.145997887340E-09 +29,8,-1.694672355500E-08,1.111602768390E-08 +29,9,-1.293711616900E-09,1.417935732260E-09 +29,10,1.371846247980E-08,1.795434861670E-09 +29,11,-5.962728858760E-09,6.333501809460E-09 +29,12,-4.562789103570E-10,-5.012220088980E-09 +29,13,-1.090959230490E-09,-2.341790143890E-09 +29,14,-3.237189651140E-09,-4.583063250340E-09 +29,15,-9.573597494060E-09,-6.775467258080E-09 +29,16,1.374500634960E-09,-1.486452665400E-08 +29,17,-1.576624155010E-09,-3.925066994340E-09 +29,18,-3.675978408650E-09,-2.585495752940E-09 +29,19,-6.300461435330E-09,5.868407082960E-09 +29,20,-7.964463315310E-09,5.742399831270E-09 +29,21,-9.872643028600E-09,-5.517006015960E-09 +29,22,1.155748360580E-08,-1.476633008540E-09 +29,23,-1.845767178990E-09,2.635467635160E-09 +29,24,3.421996681190E-10,-2.382305811930E-09 +29,25,5.858640383290E-09,8.683339585430E-09 +29,26,7.870398353570E-09,-6.922329809210E-09 +29,27,-7.983133008410E-09,-1.019032140910E-09 +29,28,9.733555375260E-09,-5.712939586010E-09 +29,29,1.282248437670E-08,-5.015484804820E-09 +30,0,6.028820847590E-09,0.000000000000E+00 +30,1,-5.575566155960E-10,1.242852756020E-09 +30,2,-1.037064476900E-08,-2.618023224440E-09 +30,3,2.146923006030E-09,-1.364641885010E-08 +30,4,-4.550904334730E-10,-3.911172135050E-09 +30,5,-4.369739774460E-09,-5.355589749830E-09 +30,6,3.284512858150E-10,3.178082339810E-09 +30,7,4.049232203090E-09,1.839624587790E-09 +30,8,2.549528652360E-09,4.620582818540E-09 +30,9,-7.325925111280E-09,-9.727781742400E-09 +30,10,4.276094845550E-09,-4.108649618140E-09 +30,11,-1.040430052270E-08,1.075814576510E-08 +30,12,1.716222953020E-08,-1.084567755560E-08 +30,13,1.421735870560E-08,2.968062263520E-09 +30,14,5.115058608340E-09,8.072888112570E-09 +30,15,2.105121468460E-10,-1.045411238360E-09 +30,16,-1.089219204570E-08,4.352540635330E-09 +30,17,-6.143824362710E-09,-6.031409385750E-09 +30,18,-1.111492650900E-08,-7.655219579760E-09 +30,19,-1.296739843300E-08,2.420056696940E-09 +30,20,-4.892611720330E-09,1.276556844220E-08 +30,21,-1.062847378100E-08,-5.975375874120E-09 +30,22,-4.837632400010E-09,-9.377201111560E-09 +30,23,5.741138854300E-09,-1.037560822220E-08 +30,24,-2.352380207890E-09,-2.759093396200E-09 +30,25,3.044264048560E-09,-1.548533892290E-08 +30,26,1.221497876230E-09,1.240695516530E-08 +30,27,-7.950638448630E-09,1.275294315930E-08 +30,28,-5.471208002890E-09,-7.960062935130E-09 +30,29,4.159229542400E-09,1.894891044170E-09 +30,30,2.647940180060E-09,8.129947551780E-09 +31,0,7.331000893180E-09,0.000000000000E+00 +31,1,6.111693767340E-09,-1.607745408440E-08 +31,2,7.496251061230E-09,6.377763224440E-09 +31,3,-8.899209661890E-09,-7.655029441600E-09 +31,4,1.225555807230E-08,-4.944664365750E-09 +31,5,-8.712790640450E-09,3.083257473790E-09 +31,6,-1.688908035850E-09,1.370362152700E-09 +31,7,-2.719961335360E-09,-6.886251216800E-10 +31,8,-7.502603553540E-10,2.281027242390E-09 +31,9,-6.558404032720E-10,5.241790026170E-09 +31,10,3.991616750270E-09,-4.735002021320E-09 +31,11,6.935068927770E-10,2.086680688810E-08 +31,12,5.528754098400E-10,4.520421670680E-09 +31,13,9.403894235620E-09,4.668407857300E-09 +31,14,-7.886507711670E-09,3.519524601470E-09 +31,15,4.299547761320E-09,-2.808706843940E-09 +31,16,-7.194302611730E-09,6.118050499790E-09 +31,17,-2.538211689580E-09,6.830082167220E-09 +31,18,-6.020993219960E-10,-2.041872869050E-09 +31,19,2.890864823010E-09,4.439767916090E-09 +31,20,-1.757321939140E-09,5.640819545580E-09 +31,21,-9.671436692080E-09,7.093574080270E-09 +31,22,-9.053120125200E-09,-1.183084174660E-08 +31,23,8.322343538980E-09,4.517745725550E-09 +31,24,-2.815650643660E-09,-3.343695137680E-09 +31,25,-1.645742681690E-08,-2.204609089710E-09 +31,26,-1.266530703560E-08,1.591893989910E-09 +31,27,-1.349533058270E-09,1.075076500190E-08 +31,28,1.042269184110E-08,2.807222949100E-09 +31,29,-1.581268810300E-09,-2.182475106720E-09 +31,30,-9.474167220010E-10,-7.780775256560E-09 +31,31,-8.591934527150E-09,-1.852003164830E-09 +32,0,-2.339662880320E-09,0.000000000000E+00 +32,1,-1.692104860760E-09,1.277604679760E-09 +32,2,1.139996626630E-08,-3.356091279160E-09 +32,3,-1.444433154000E-10,4.054248309410E-09 +32,4,8.563678291120E-10,-6.754224761070E-09 +32,5,8.607762053330E-09,1.825722796460E-09 +32,6,-1.004025686720E-08,-7.630561763400E-09 +32,7,1.370586132780E-09,2.754653470350E-09 +32,8,1.196535319080E-08,4.910182125480E-09 +32,9,7.332252213000E-09,7.189715910520E-10 +32,10,9.121335063790E-11,-5.706809274950E-09 +32,11,-5.420437421270E-09,7.583606425000E-09 +32,12,-1.702890592140E-08,1.408081686230E-08 +32,13,4.021868220270E-09,5.349364919640E-09 +32,14,-5.444203344370E-09,2.204106943160E-09 +32,15,5.165802082800E-09,-8.747275317410E-09 +32,16,4.148670612940E-09,4.272704200040E-09 +32,17,-6.468577789060E-09,1.019164862150E-08 +32,18,1.272863451170E-08,-1.121368880890E-09 +32,19,7.551895369230E-10,-2.775465307300E-09 +32,20,3.816105644200E-09,3.195348556530E-10 +32,21,-2.332629967710E-09,1.164116502510E-08 +32,22,-1.208806787620E-08,-2.726917932320E-09 +32,23,8.186821221430E-09,-2.335497127220E-09 +32,24,-3.550363156670E-09,6.548347638610E-10 +32,25,-1.893749925030E-08,-6.434295328480E-09 +32,26,5.225355314920E-09,-3.688562212410E-09 +32,27,-4.537400852140E-09,-6.680755601110E-09 +32,28,1.653041745000E-09,-5.731303407720E-09 +32,29,4.327681929650E-09,2.881798899340E-09 +32,30,-6.748058662940E-09,1.393462685460E-09 +32,31,-6.267402517660E-09,-2.184756081710E-10 +32,32,3.397566033100E-09,1.426461651550E-09 +33,0,-3.493571794980E-09,0.000000000000E+00 +33,1,-1.396429134450E-09,-2.163917608110E-09 +33,2,-7.487741948960E-09,-5.018720815200E-10 +33,3,-1.996619557930E-09,7.093041026800E-09 +33,4,-4.270199819000E-09,2.274266566980E-09 +33,5,2.377847297290E-10,3.744391694510E-09 +33,6,1.226030399210E-09,-2.873283008360E-09 +33,7,-6.112150860760E-09,2.493833663160E-09 +33,8,-8.231444050570E-10,1.449155554070E-08 +33,9,5.050973920330E-09,7.405174690200E-09 +33,10,-2.397099233170E-09,1.070229067580E-09 +33,11,2.433888364430E-09,-8.670718134870E-09 +33,12,-2.335105323290E-09,8.943506989100E-09 +33,13,2.604153819300E-09,3.138057509810E-09 +33,14,4.929596623020E-09,5.712045506170E-09 +33,15,-4.641453033960E-09,-3.478353023250E-09 +33,16,7.395305175710E-09,6.286131892830E-09 +33,17,-5.730645905510E-09,1.287791149270E-08 +33,18,-9.742859335620E-09,-1.895981245920E-09 +33,19,8.524473311560E-09,2.075617172460E-09 +33,20,-3.326275003090E-09,-7.776899990530E-09 +33,21,9.387616723870E-10,8.177875986740E-10 +33,22,-1.054399408750E-08,-1.561902273920E-08 +33,23,1.158962503140E-10,-1.013563507670E-08 +33,24,1.114160745270E-08,-8.571537764840E-09 +33,25,5.247305323750E-09,-1.049416565370E-08 +33,26,1.095900055960E-08,4.540414402500E-09 +33,27,-1.327729081470E-09,1.261541619420E-09 +33,28,1.759433814210E-09,-1.020603464150E-09 +33,29,-1.630751286330E-08,5.721913288910E-09 +33,30,-1.569770642770E-09,-1.845794022640E-08 +33,31,4.694818688530E-09,1.022900500280E-09 +33,32,6.567759190220E-09,-4.397119133980E-09 +33,33,-1.520438503030E-09,8.312630045290E-09 +34,0,-9.088333404470E-09,0.000000000000E+00 +34,1,-2.768897950470E-09,6.389189702100E-09 +34,2,6.768819065400E-09,5.300821186960E-09 +34,3,1.254296697860E-08,8.116196698340E-09 +34,4,-8.300054175040E-09,1.195868702720E-09 +34,5,-3.881316856380E-09,3.549634499770E-09 +34,6,4.840937095790E-10,7.629754802930E-09 +34,7,2.751257932390E-09,-6.562635731630E-09 +34,8,-9.834468075920E-09,4.687514780210E-09 +34,9,1.530424946640E-09,2.101656978290E-09 +34,10,-7.526332423890E-09,1.465442297810E-09 +34,11,-3.820434315060E-09,-1.078297355990E-09 +34,12,1.426293622620E-08,-4.600636429680E-09 +34,13,-3.562409842550E-09,1.033295230960E-09 +34,14,-2.501876643920E-09,9.646869082410E-09 +34,15,3.759398041570E-10,6.262862497700E-09 +34,16,-1.458740427130E-09,-1.493809290800E-09 +34,17,-4.737475705120E-09,3.936988293890E-09 +34,18,-1.474887013450E-08,-5.381979988170E-09 +34,19,-3.598375688970E-09,7.153020155830E-09 +34,20,3.644668596550E-09,-1.018241473460E-08 +34,21,-9.819802970660E-10,-7.421664565480E-09 +34,22,-3.181522154060E-09,3.366201750350E-09 +34,23,-1.129731205700E-09,-1.189819021720E-08 +34,24,8.780790449540E-09,4.204361580370E-09 +34,25,8.410971702480E-09,-9.863008152660E-09 +34,26,3.999643842310E-09,-1.293600146910E-08 +34,27,1.315661962080E-08,-3.911378364090E-09 +34,28,-1.653206047130E-10,-2.003706538580E-08 +34,29,7.081516766810E-09,-4.315635741130E-09 +34,30,-2.056660356770E-08,-5.869489469520E-10 +34,31,-4.574112681110E-09,-1.608527801250E-09 +34,32,9.140335934740E-09,2.316451382640E-09 +34,33,1.376179379670E-08,4.354719864600E-09 +34,34,-8.540119981550E-09,1.653645990230E-09 +35,0,8.604431584920E-09,0.000000000000E+00 +35,1,-1.076311761680E-08,-1.035762882190E-08 +35,2,-1.481667498070E-08,7.473168452230E-09 +35,3,1.886239003050E-09,3.499676794650E-09 +35,4,-2.823385231080E-09,9.206749379210E-09 +35,5,-7.236884434160E-09,-1.154787961460E-08 +35,6,3.287083204360E-09,7.901422644830E-09 +35,7,-3.458298263670E-09,4.713868397160E-09 +35,8,4.159112286860E-09,9.214869654230E-09 +35,9,-7.835845930220E-10,-1.087807005950E-09 +35,10,-2.630781245960E-09,1.144376698250E-08 +35,11,3.113528421900E-09,-3.115089421420E-09 +35,12,8.104321659030E-09,-6.432339567800E-09 +35,13,-1.608703809880E-09,3.028529254420E-09 +35,14,-7.165111869470E-09,-7.027370469170E-09 +35,15,-1.536905641230E-08,8.759849247170E-09 +35,16,-6.897720477030E-09,-7.368270475840E-09 +35,17,7.037558990270E-10,-8.829204857730E-09 +35,18,-5.552476614980E-09,-1.147104779590E-08 +35,19,-1.071124992730E-09,-3.418541194120E-09 +35,20,9.927023058370E-10,-1.135737452080E-10 +35,21,1.293337856630E-08,-8.176577953860E-10 +35,22,7.514794775950E-09,5.722993090800E-09 +35,23,-8.163912422160E-09,-2.224426125320E-09 +35,24,2.784350905170E-09,6.384996071760E-09 +35,25,7.168589341560E-09,1.997811036450E-09 +35,26,-4.703002323050E-09,4.614889431080E-09 +35,27,1.096020890940E-08,-1.338126357960E-08 +35,28,7.881594607160E-09,-1.536730248390E-08 +35,29,7.707868107660E-09,3.401407546690E-09 +35,30,-4.051928399300E-09,2.873706162240E-09 +35,31,7.841402043150E-09,4.041248078800E-09 +35,32,-3.162679017770E-09,-7.418580642210E-09 +35,33,5.860963396600E-09,-3.077393909050E-09 +35,34,-1.216320996740E-09,2.667174009380E-09 +35,35,-5.878657294100E-09,-5.012306380020E-09 +36,0,-4.025906042430E-09,0.000000000000E+00 +36,1,-1.133866863860E-09,5.149826532830E-09 +36,2,-4.315759014480E-09,-3.402110316550E-09 +36,3,7.004092804440E-11,-1.588956729210E-08 +36,4,3.009611299350E-09,1.389172185380E-09 +36,5,-7.422615355130E-09,1.403378601900E-09 +36,6,1.085460245680E-08,-3.163119432260E-09 +36,7,1.708138061470E-09,6.176802101540E-09 +36,8,3.449393602460E-09,-5.037678578610E-09 +36,9,2.921922194930E-09,-3.740281137080E-10 +36,10,4.231196817030E-09,6.835031437880E-09 +36,11,-4.100392326420E-09,4.751182944750E-09 +36,12,4.872049628370E-10,-9.845877146750E-09 +36,13,-6.154169635070E-09,8.031811355600E-09 +36,14,-1.041416827640E-08,-5.942035747620E-09 +36,15,9.548924090440E-10,3.333105741720E-09 +36,16,1.255059135980E-09,-1.605694061160E-10 +36,17,4.950661860340E-09,-8.653140224770E-09 +36,18,1.771842020150E-09,4.460334007700E-09 +36,19,-5.251492175650E-09,-6.653194861150E-09 +36,20,-6.037933469560E-09,3.526276605970E-09 +36,21,1.069089247300E-08,-5.679489150260E-09 +36,22,3.213561300340E-09,1.612341214610E-09 +36,23,-3.611601995010E-10,2.748919170690E-09 +36,24,2.106628699870E-09,-4.245149987560E-09 +36,25,4.349792921400E-09,1.560714734600E-08 +36,26,3.687625670310E-09,9.371751137140E-09 +36,27,-7.912294643620E-09,8.829968106300E-09 +36,28,2.226379768240E-09,-4.343726174050E-09 +36,29,1.845116758390E-09,2.073447183400E-10 +36,30,-1.004115159550E-08,6.054132936080E-09 +36,31,-8.390844422980E-09,-5.540474455980E-09 +36,32,1.256542071090E-08,2.304762356250E-09 +36,33,3.899576066370E-09,-3.503408568930E-09 +36,34,-9.086932826630E-09,4.357769767150E-09 +36,35,-1.388125032720E-10,-1.255272910760E-08 +36,36,4.601464657200E-09,-5.942453363140E-09 +37,0,-6.268779579280E-09,0.000000000000E+00 +37,1,-5.443219376730E-09,-1.119720406280E-09 +37,2,-4.222469929540E-09,-1.376536864950E-08 +37,3,-5.071907736520E-09,-7.096327636650E-10 +37,4,5.635434160510E-09,4.452261389390E-10 +37,5,-9.743780120770E-09,-5.571059001190E-10 +37,6,-2.073674534690E-09,7.873278010010E-09 +37,7,6.605991615870E-09,8.678772716070E-09 +37,8,-2.132585258950E-09,-2.149230781100E-09 +37,9,2.240916004420E-09,-1.868363282960E-09 +37,10,2.041577442290E-09,2.398260468600E-09 +37,11,1.134972338050E-09,3.686933175180E-09 +37,12,-3.260210939740E-09,-5.251229260970E-09 +37,13,-1.538507953930E-10,-8.314457133140E-09 +37,14,-2.550365811840E-09,-1.124263754940E-09 +37,15,1.003907760440E-08,-9.425420106090E-10 +37,16,3.493685606290E-09,1.339186703030E-08 +37,17,5.534148403360E-09,-4.228162239100E-09 +37,18,1.733161724150E-09,3.805320447350E-09 +37,19,-6.747707951350E-09,-5.734985878550E-10 +37,20,-7.216807582060E-09,-1.011040231090E-09 +37,21,1.475797319620E-09,-1.508076629820E-09 +37,22,6.495381648300E-09,2.886599048200E-10 +37,23,-5.056038003860E-10,4.159919427060E-09 +37,24,-8.281582396210E-09,-8.731897549300E-09 +37,25,6.664338214560E-09,-1.984853299130E-09 +37,26,4.876674057850E-09,1.048526392700E-08 +37,27,-5.535078360780E-09,4.400415593260E-09 +37,28,1.271113940200E-08,3.985262991080E-09 +37,29,4.693466435210E-09,6.800436949060E-09 +37,30,-8.950698498520E-09,1.257788871760E-08 +37,31,3.754702168640E-09,-9.242051298660E-09 +37,32,-3.692487507400E-11,3.997218271790E-09 +37,33,1.818750905500E-09,-1.664976141640E-08 +37,34,2.607622759230E-09,5.187930363020E-10 +37,35,-1.019692950050E-08,-8.481655572150E-09 +37,36,-3.573775148320E-09,-4.800195366380E-09 +37,37,6.004019892180E-09,-4.341380833630E-09 +38,0,-2.502155639040E-09,0.000000000000E+00 +38,1,3.094662651870E-09,4.699763678460E-09 +38,2,8.191906424990E-09,-2.921687425720E-10 +38,3,-2.526476308950E-09,-9.330256089070E-10 +38,4,5.073600179200E-09,-4.184738965870E-09 +38,5,-9.250373236660E-09,7.386333764890E-09 +38,6,-1.400488152730E-08,3.141761717590E-09 +38,7,1.545010491890E-09,-2.780478418510E-09 +38,8,3.140198109060E-09,4.333151960980E-09 +38,9,4.654738226860E-09,-7.837513001430E-10 +38,10,-1.772028658400E-09,-6.303690005900E-09 +38,11,2.854777475340E-11,8.944703235020E-09 +38,12,-3.613171465900E-09,-6.757007708850E-09 +38,13,1.490049248560E-09,-1.047099702290E-08 +38,14,-6.893574454220E-09,3.947575018290E-09 +38,15,2.720243341190E-09,-3.294539266340E-09 +38,16,-5.384177499490E-09,9.758742652300E-09 +38,17,3.894283584560E-09,2.014238431720E-09 +38,18,1.005388051450E-08,-2.616473379750E-09 +38,19,9.928810445110E-10,-6.150519643310E-10 +38,20,4.031035606580E-09,-2.094993484110E-09 +38,21,-1.624230779720E-09,3.927379974940E-09 +38,22,1.027062331440E-09,7.869694151610E-09 +38,23,-1.779555379880E-09,5.417378928760E-09 +38,24,-1.277054508940E-08,1.145838932030E-09 +38,25,-2.699177856990E-09,-2.264212407910E-09 +38,26,-2.735181743870E-09,6.159985950510E-09 +38,27,-1.921933035600E-09,8.739777785440E-09 +38,28,-4.821977245670E-09,-5.189260254630E-09 +38,29,6.334897718690E-09,3.271183779400E-09 +38,30,-1.281435861750E-10,2.169564192470E-09 +38,31,3.876636131040E-09,-7.682541381980E-09 +38,32,8.287519810490E-09,1.786935151510E-09 +38,33,2.523858045630E-09,1.425588251170E-08 +38,34,-9.552997027930E-09,3.282741761800E-09 +38,35,4.809250139500E-09,2.338354917070E-09 +38,36,1.150903811320E-09,-6.842422682140E-10 +38,37,-3.409785315590E-09,2.069942071670E-09 +38,38,3.063492324330E-09,-2.138878504770E-09 +39,0,1.740806835530E-09,0.000000000000E+00 +39,1,-2.570531356120E-09,5.384836810010E-09 +39,2,2.772283607290E-09,7.043582596140E-09 +39,3,-3.323541187700E-09,6.241161164030E-09 +39,4,-5.515359945290E-09,-4.913426547950E-09 +39,5,5.470403754390E-09,1.660106983510E-09 +39,6,-4.459099279310E-09,2.797335827820E-09 +39,7,1.750013392010E-09,-1.650331238450E-09 +39,8,2.311456219420E-09,1.154033933960E-08 +39,9,7.003496111110E-09,3.740305620900E-09 +39,10,6.197134244330E-10,-2.785025024270E-10 +39,11,1.487935754240E-08,-2.398015298130E-09 +39,12,-4.067743386850E-09,8.939576380620E-09 +39,13,-1.952373513880E-09,-7.719371806180E-09 +39,14,-3.652163285340E-09,2.787683204870E-09 +39,15,-5.492368168010E-09,-3.977213472610E-10 +39,16,1.452068355280E-09,-1.854948797380E-09 +39,17,-8.468392835930E-10,-1.469560965730E-09 +39,18,2.712758959760E-09,-8.675873172740E-10 +39,19,5.112403305830E-09,6.247279535700E-09 +39,20,-6.462821654040E-11,-9.928827738910E-09 +39,21,-5.160941937730E-09,1.942356880590E-09 +39,22,-6.712436234870E-09,-8.061254976640E-10 +39,23,-3.229212275350E-09,5.927011413370E-09 +39,24,-1.018823220240E-08,7.750276441120E-09 +39,25,-5.128413416300E-09,-6.300127897740E-09 +39,26,-3.824525688110E-09,8.658643001940E-09 +39,27,-8.225258523680E-09,-2.012886387840E-09 +39,28,-2.823781541450E-09,-1.066366075760E-08 +39,29,-2.165152680740E-10,-3.064116486570E-09 +39,30,4.390997973190E-09,-1.051442743620E-08 +39,31,1.435791389690E-09,-1.131550019820E-08 +39,32,3.096320413120E-09,5.556561995280E-09 +39,33,-5.554414161430E-09,3.562224569710E-09 +39,34,-1.986883495660E-09,3.366497880260E-09 +39,35,-1.360246446290E-08,2.222810049590E-09 +39,36,3.805309103260E-09,-3.453004765330E-09 +39,37,1.323240296530E-09,-4.537044707660E-09 +39,38,-1.499883602220E-09,6.447102281960E-09 +39,39,-8.752845336010E-10,1.324727701800E-09 +40,0,-6.697129416050E-09,0.000000000000E+00 +40,1,2.981192061290E-09,-1.676716023210E-10 +40,2,-5.512298382040E-10,-1.111209073820E-09 +40,3,1.009670338740E-10,-2.066450392780E-09 +40,4,5.673714957280E-09,-7.218195871590E-09 +40,5,1.342709998960E-08,-1.890359125010E-10 +40,6,-3.484362457790E-10,4.260228562960E-09 +40,7,-2.474506957370E-09,2.388701127780E-09 +40,8,6.807945656320E-09,3.820712344230E-09 +40,9,-6.904901659580E-10,1.970151966060E-09 +40,10,-5.248062556040E-09,3.062982145830E-09 +40,11,3.476778078710E-09,-4.166595629490E-09 +40,12,6.546195732800E-09,1.948840888890E-10 +40,13,-2.491105930730E-09,-4.298035504420E-09 +40,14,-5.319900943420E-10,9.008893216680E-10 +40,15,-4.954746349550E-09,-5.736053106550E-10 +40,16,-8.089694660660E-10,-3.323484952900E-09 +40,17,1.130766850920E-09,1.874483170140E-09 +40,18,-4.418793772590E-10,7.153967593850E-10 +40,19,-2.310757780030E-09,7.406777148670E-10 +40,20,-7.899368997840E-09,6.984342900800E-09 +40,21,-4.414751030690E-09,-6.440321740410E-10 +40,22,-1.471379461970E-08,-1.572040385210E-08 +40,23,-6.255647221880E-10,-1.232584492680E-08 +40,24,4.042067407730E-09,3.832949653190E-09 +40,25,4.063005554300E-10,-4.168036303190E-09 +40,26,6.773640700240E-09,-4.632966220410E-09 +40,27,-1.969350678260E-09,1.677887419270E-09 +40,28,2.023035360540E-09,7.027449889570E-09 +40,29,2.445430581630E-09,2.153886130160E-09 +40,30,1.111634523520E-10,1.060540766620E-09 +40,31,-6.079859901060E-09,-1.406991976280E-09 +40,32,-2.425362386930E-09,-5.773921789630E-09 +40,33,-1.509365073520E-09,-9.128313299290E-10 +40,34,6.782766459790E-10,9.662977737640E-10 +40,35,7.622620768750E-09,-8.420371387780E-09 +40,36,4.763069213080E-09,5.099336686090E-09 +40,37,-5.931995110850E-09,1.637826127180E-09 +40,38,4.436815841830E-10,4.452304707790E-09 +40,39,6.731381584520E-09,3.182141043800E-09 +40,40,-1.122280650520E-09,-2.258856180940E-10 +41,0,-4.135254374990E-09,0.000000000000E+00 +41,1,-4.902746800150E-09,-4.134214654800E-09 +41,2,2.890523094740E-09,1.770802625810E-09 +41,3,5.208478798100E-09,2.629101807290E-09 +41,4,-1.912303135970E-09,4.995204255130E-09 +41,5,4.439645101220E-09,-3.405201646390E-09 +41,6,3.373165588390E-09,2.089851881700E-09 +41,7,2.062278313550E-10,3.270198481480E-09 +41,8,-2.151208460290E-09,-4.246456586210E-09 +41,9,-5.914131295560E-10,3.437802052860E-09 +41,10,4.979372647430E-09,1.526296997230E-09 +41,11,5.120731129300E-09,-6.763555559640E-09 +41,12,4.562424385970E-09,4.261456325890E-10 +41,13,-1.903504182200E-09,4.242200101250E-09 +41,14,4.134630161040E-09,-1.225831821090E-09 +41,15,-1.246896856610E-09,3.066814708190E-09 +41,16,-6.347471215260E-10,-4.308651198540E-09 +41,17,-2.068766345500E-09,3.542318376080E-09 +41,18,-2.258482206910E-09,4.389156381810E-09 +41,19,-2.879054093330E-09,-6.985565787230E-10 +41,20,-2.226106329630E-09,-6.843399492150E-10 +41,21,-1.355575985740E-09,-3.869659902170E-09 +41,22,-1.082136593460E-08,-3.189006097620E-09 +41,23,9.058203614270E-10,-1.561879846630E-08 +41,24,6.167781248630E-09,-1.608786244140E-09 +41,25,3.538519840610E-10,3.059843096420E-09 +41,26,7.548488751170E-09,-8.457874537990E-09 +41,27,1.641164338030E-09,-4.055782787720E-10 +41,28,-1.650989511990E-09,-4.696391629830E-09 +41,29,-5.443795858320E-09,6.376582663510E-09 +41,30,2.224024420970E-09,-4.310301853290E-10 +41,31,1.073619985020E-08,1.055560711750E-09 +41,32,-2.672676712940E-09,3.896466044530E-09 +41,33,-3.341207658670E-09,1.053678692660E-08 +41,34,-2.888952643080E-09,4.059117121570E-09 +41,35,-1.375090444160E-08,2.844469680170E-09 +41,36,3.637612043870E-09,-3.624279814390E-09 +41,37,1.807482353080E-09,-1.089077045750E-08 +41,38,-1.058877841610E-08,-5.381928905540E-10 +41,39,-5.238761301590E-09,-3.061018140340E-09 +41,40,4.219713981400E-09,-4.818447849390E-09 +41,41,4.330499948460E-09,7.116011844780E-09 +42,0,-1.333547921150E-09,0.000000000000E+00 +42,1,-2.913397670220E-09,2.067229614400E-09 +42,2,-2.257627403420E-09,-1.913505753260E-09 +42,3,1.004258681320E-09,8.905664237090E-09 +42,4,5.492930491200E-09,1.460330515680E-09 +42,5,-9.140607680470E-09,-4.050254655160E-09 +42,6,2.519091125930E-09,-8.304140013490E-10 +42,7,4.331499033260E-09,-1.818676453190E-09 +42,8,2.314670367200E-09,6.468865579660E-10 +42,9,5.804157665350E-10,2.639248730140E-09 +42,10,5.358615389630E-09,6.872376247740E-09 +42,11,-8.103562632730E-10,2.772880867480E-09 +42,12,5.188339319870E-09,-1.063008811800E-08 +42,13,6.961741315690E-10,5.522400533770E-09 +42,14,-6.834146037370E-09,5.975333445320E-09 +42,15,-1.152658424270E-09,6.353370043640E-09 +42,16,2.713467102420E-09,-2.385026684970E-09 +42,17,-1.798708225290E-09,-4.109817150560E-09 +42,18,-1.314418148820E-08,1.788004821900E-09 +42,19,-2.808864463650E-09,-5.888533920340E-09 +42,20,5.674926203310E-09,6.407916294690E-10 +42,21,2.415054149340E-09,-5.607370905880E-09 +42,22,1.513811901510E-09,-3.402509473940E-09 +42,23,-3.278443358460E-09,-5.636299389860E-09 +42,24,4.822564266820E-09,1.209396129600E-09 +42,25,-4.254478768880E-09,4.343048891310E-09 +42,26,-1.781547127950E-09,-6.254598668530E-09 +42,27,8.060484013240E-09,-2.344734088740E-09 +42,28,-3.522648458340E-09,1.964846529340E-09 +42,29,-6.529767482850E-09,-1.278652818670E-09 +42,30,2.961689154070E-09,1.453455010630E-09 +42,31,5.227719352110E-09,4.842940839940E-09 +42,32,7.009674833970E-09,5.216350054690E-09 +42,33,5.684198706730E-09,5.820922694900E-09 +42,34,2.491337802050E-09,1.115609098020E-08 +42,35,-5.425183928860E-09,-1.144226107010E-09 +42,36,4.430626942750E-09,-5.401592366900E-09 +42,37,-5.905378744180E-09,2.384471056990E-09 +42,38,2.618753283090E-09,-1.316460343180E-08 +42,39,4.311419606050E-09,1.025332507170E-08 +42,40,1.480715239790E-09,-2.900136204390E-09 +42,41,1.462080181060E-09,2.157122946380E-11 +42,42,-7.178961519180E-09,3.057742419430E-09 +43,0,4.800165595710E-09,0.000000000000E+00 +43,1,-2.991635127480E-09,1.405103718920E-09 +43,2,-9.955365204550E-09,-6.782596994730E-10 +43,3,3.169180839900E-09,-1.574981109100E-10 +43,4,1.189355317940E-09,1.813934202910E-09 +43,5,-8.805297833510E-09,7.163576911100E-09 +43,6,5.978940573860E-09,2.945908949490E-10 +43,7,-1.522273928160E-09,4.895801256060E-09 +43,8,5.575028782730E-11,2.798987091980E-10 +43,9,-2.421886947170E-09,-7.090464961800E-09 +43,10,2.876375694290E-10,1.900819151900E-09 +43,11,-3.435182247910E-09,5.953502142030E-09 +43,12,-2.472699241550E-09,1.922109042940E-09 +43,13,1.969661197840E-09,-3.461143433450E-09 +43,14,-5.668188761420E-09,3.256295790610E-09 +43,15,3.547664464360E-09,5.995119817890E-09 +43,16,2.733116248140E-10,2.772290886690E-10 +43,17,3.942329065930E-09,-4.531975505150E-09 +43,18,-3.663292003650E-09,-5.141145710970E-09 +43,19,-4.351660628610E-09,-7.098159952770E-09 +43,20,-6.957619729860E-10,3.540670770720E-10 +43,21,3.899942501340E-09,4.955346244190E-09 +43,22,6.927774549170E-09,-1.966085826590E-09 +43,23,4.670995326260E-10,-6.080557805580E-09 +43,24,6.386141487310E-09,-1.711836503800E-09 +43,25,4.220063948100E-10,3.201738960080E-09 +43,26,-3.497750865620E-09,3.571442919600E-09 +43,27,4.348879284700E-09,8.999898676350E-10 +43,28,-9.577277395740E-10,7.928981927890E-09 +43,29,-1.051477577590E-09,1.148869265590E-09 +43,30,-1.035968394920E-08,-8.234887916430E-09 +43,31,-4.537060354330E-09,-8.685092614570E-10 +43,32,-3.640727610920E-09,5.351435291790E-09 +43,33,6.050067170580E-09,-1.140655597630E-09 +43,34,2.989564721430E-09,4.161613978270E-10 +43,35,-1.288082447270E-09,5.505820281900E-09 +43,36,6.529034317780E-10,-3.629948077870E-09 +43,37,3.173445933620E-09,6.011471515700E-09 +43,38,-5.818958141170E-09,1.748125795710E-10 +43,39,4.963242045100E-09,-2.395468766330E-09 +43,40,1.159857547140E-08,5.031992163330E-11 +43,41,-3.167878868180E-09,3.124734009900E-09 +43,42,-7.440309432750E-09,4.278346909720E-09 +43,43,-2.084751806810E-09,-8.966589736710E-09 +44,0,7.165972711690E-10,0.000000000000E+00 +44,1,4.353595497730E-09,-8.034762022140E-10 +44,2,-3.791207243650E-10,2.366634341500E-09 +44,3,-1.950428792300E-10,-3.501894314830E-09 +44,4,2.638121727220E-09,-2.142411094850E-09 +44,5,1.224841731720E-09,3.339122383120E-09 +44,6,-8.216652903210E-09,3.425708046300E-09 +44,7,2.988516214140E-09,1.046008487460E-08 +44,8,-2.655537325020E-09,3.884521302130E-10 +44,9,-4.012268776260E-10,-6.428380633060E-09 +44,10,-5.278914501160E-09,-6.095998701230E-09 +44,11,-2.170056463380E-09,-1.097912691210E-09 +44,12,-4.439836955210E-09,-2.188994138840E-09 +44,13,2.536624988280E-09,-4.267515043790E-09 +44,14,-5.407746761760E-09,-3.747241285840E-09 +44,15,2.202733272420E-09,-6.384410900110E-09 +44,16,4.565971665110E-09,4.845739152460E-09 +44,17,4.415445570490E-09,1.542024185430E-09 +44,18,5.679065704430E-09,-4.175593381920E-09 +44,19,-6.734474474710E-10,-4.671897916450E-09 +44,20,-4.561573532630E-10,6.303128480840E-10 +44,21,-7.377367881130E-09,1.986637679210E-10 +44,22,6.928759628480E-09,2.071094995620E-09 +44,23,1.015504087950E-09,9.649453796540E-09 +44,24,3.019221426120E-10,-6.513423277000E-09 +44,25,2.903615796070E-09,1.106613001380E-09 +44,26,-4.478760013530E-09,1.278914120670E-09 +44,27,2.515510118010E-09,-2.770434113930E-09 +44,28,-9.056786327170E-10,5.544946607870E-09 +44,29,-6.988660522270E-09,4.490267487880E-09 +44,30,4.789816050300E-09,1.821431008010E-09 +44,31,-6.346032704530E-10,2.651293352210E-09 +44,32,-4.235846571120E-09,7.981913623800E-10 +44,33,-3.756713929950E-09,2.899542827770E-10 +44,34,-3.345920629570E-09,5.901122773680E-09 +44,35,-7.422091263790E-09,-3.126513334370E-09 +44,36,3.534651033610E-09,-7.507927653140E-09 +44,37,9.777433294930E-09,7.491759461040E-09 +44,38,4.505235620780E-09,-6.077228873640E-09 +44,39,6.827236008260E-09,2.671913541660E-09 +44,40,-3.602297350000E-09,7.436406608180E-09 +44,41,3.059183589460E-09,-2.199759228560E-09 +44,42,-8.047526206110E-10,-7.488158529580E-10 +44,43,2.638036200470E-09,-3.006160634320E-09 +44,44,3.029147832800E-09,-1.616472431030E-09 +45,0,-4.205621941550E-09,0.000000000000E+00 +45,1,4.437904015200E-09,-5.516641322920E-09 +45,2,1.184581457110E-09,-2.844267260420E-09 +45,3,-3.070240060650E-10,-6.083236667790E-09 +45,4,-4.170745725140E-10,1.047954696530E-09 +45,5,3.132516693160E-09,-1.386722727330E-09 +45,6,-5.581278422370E-09,-4.547110735660E-10 +45,7,1.449701243110E-12,2.603160936040E-09 +45,8,-3.549789197180E-09,4.565636621870E-10 +45,9,6.831453002810E-09,-4.648437341350E-09 +45,10,-1.372445115580E-09,-2.433110086830E-09 +45,11,1.148219638630E-09,-2.973877452350E-09 +45,12,-4.820959726200E-09,-2.341534946030E-09 +45,13,-4.916036990640E-09,-2.889449348210E-09 +45,14,2.194735711810E-09,-5.069570473690E-09 +45,15,-3.148927577090E-09,-1.495870860330E-12 +45,16,6.631618501960E-09,4.675521363100E-10 +45,17,2.495971564080E-09,-1.676903345220E-09 +45,18,2.454676373420E-09,-4.288990685290E-09 +45,19,-5.131373941490E-09,-2.213149084100E-09 +45,20,5.560746998360E-09,4.142503812610E-09 +45,21,-3.469094933200E-09,-5.588768329060E-10 +45,22,3.185485821810E-09,5.026943892870E-09 +45,23,2.225202739840E-09,5.158341141680E-09 +45,24,-1.103864309440E-08,4.662949477820E-09 +45,25,8.186396108000E-09,-3.381190946740E-09 +45,26,-2.178297673350E-09,5.904222717350E-09 +45,27,-6.372122757360E-09,3.426836113660E-10 +45,28,5.557680241880E-09,-1.534927420260E-10 +45,29,-7.561518762060E-09,-2.520091435100E-09 +45,30,-1.782043858660E-09,-1.165763541420E-10 +45,31,-1.816470755690E-09,-4.896599640220E-09 +45,32,-2.173184034480E-09,-2.981375386310E-09 +45,33,-1.677874778370E-09,-2.763030954520E-09 +45,34,-2.458685391820E-09,6.100491821420E-09 +45,35,-7.251608771010E-09,7.147950603720E-09 +45,36,-8.315208278690E-09,6.914732317070E-09 +45,37,-5.900759893950E-09,3.865104196110E-09 +45,38,-4.575754576600E-09,3.519948774150E-09 +45,39,-2.352068571360E-10,-8.142449528980E-09 +45,40,4.129591946890E-09,-3.331533501920E-09 +45,41,1.151775018060E-09,1.235631761110E-09 +45,42,-1.479546111490E-09,-1.195731214460E-08 +45,43,4.292084923890E-09,2.416969125300E-09 +45,44,1.168960201120E-08,3.793381579740E-09 +45,45,-2.116114555480E-09,-1.862682833220E-10 +46,0,-2.700124150430E-09,0.000000000000E+00 +46,1,1.803105348130E-09,1.019755297190E-09 +46,2,5.498589506430E-09,1.693861927910E-09 +46,3,-2.172273472920E-09,3.135516204490E-11 +46,4,5.663073662420E-10,-7.335529168110E-09 +46,5,-3.922256736040E-09,-9.879384462920E-09 +46,6,-5.197905878460E-09,-3.665452552750E-09 +46,7,5.492830212300E-09,-7.681959977130E-09 +46,8,2.170330957770E-09,3.177877874430E-09 +46,9,9.962999250610E-09,5.930333507140E-09 +46,10,2.106826655790E-09,-7.493203155460E-10 +46,11,-3.673310032640E-09,-3.298752227530E-09 +46,12,-9.890041466320E-10,-7.702804156690E-10 +46,13,-5.474936538900E-09,-9.897892007760E-10 +46,14,-4.117113854090E-10,2.899684764000E-10 +46,15,-5.646983958690E-09,-2.330491438860E-09 +46,16,2.907415183860E-09,5.436269435210E-09 +46,17,-3.728242137920E-09,-6.683971430950E-10 +46,18,2.546144825970E-09,-5.155773935390E-09 +46,19,-5.093228813030E-10,-2.191427421970E-09 +46,20,-3.260469487700E-09,-4.458928556720E-09 +46,21,-5.443201951640E-09,4.863931781270E-09 +46,22,6.011897179550E-09,1.185393572900E-09 +46,23,2.498135519130E-09,4.722635925980E-09 +46,24,-4.678933595450E-09,-3.017402567720E-10 +46,25,4.118193423440E-09,-7.980767339080E-09 +46,26,3.904452519620E-09,1.091379184410E-08 +46,27,-2.314595722990E-09,-1.312451503090E-10 +46,28,-5.124974826090E-10,-5.282119280640E-09 +46,29,-2.208833125440E-09,-2.107496328390E-09 +46,30,-5.257810513720E-09,-7.850757494980E-09 +46,31,-2.421544878070E-09,8.852823076960E-11 +46,32,-1.700763189650E-09,-3.120349198550E-09 +46,33,1.499412908820E-08,-2.894110084220E-10 +46,34,-1.779255135430E-09,4.038713574360E-09 +46,35,-6.428834443760E-09,9.615612498610E-10 +46,36,-2.627832313850E-10,-2.294874403930E-09 +46,37,-3.729320263050E-09,6.206015394460E-09 +46,38,-6.235879495350E-09,-2.844551586440E-09 +46,39,7.901674445350E-09,-6.707042394940E-10 +46,40,1.178020759780E-09,-1.178817755870E-09 +46,41,-3.705494924600E-10,-5.224046690930E-09 +46,42,9.592919770840E-10,6.553234020520E-09 +46,43,-2.408527417460E-09,1.176164217030E-08 +46,44,2.235745321650E-09,-3.228559043930E-09 +46,45,-1.717837162220E-09,5.668516923850E-09 +46,46,-4.191034724380E-10,-2.965750337630E-09 +47,0,-1.901542086150E-10,0.000000000000E+00 +47,1,-6.272334337540E-09,-1.515175144900E-09 +47,2,4.016915657000E-09,-1.000850452210E-09 +47,3,2.593778565010E-09,2.472310378160E-09 +47,4,-1.044433252440E-09,9.349754623590E-10 +47,5,-4.313475379780E-10,-2.203594026510E-09 +47,6,2.574056159830E-09,-3.416224210120E-09 +47,7,3.527478101430E-10,-3.937288494680E-09 +47,8,5.375245913610E-09,-1.239562493180E-09 +47,9,-1.299872026350E-09,2.955558215230E-09 +47,10,6.407677015950E-09,3.688850754230E-09 +47,11,1.235199258440E-09,-4.722116071050E-09 +47,12,1.043809486190E-08,2.754226903460E-09 +47,13,-2.710063121780E-09,-3.335604904630E-09 +47,14,1.075983587290E-09,4.791887522490E-10 +47,15,-1.851793146520E-09,1.007419675680E-09 +47,16,-2.041227548130E-09,-7.748242548250E-10 +47,17,-1.850943898690E-09,4.705348527330E-09 +47,18,-1.756644129770E-09,7.516340261470E-09 +47,19,2.579586301990E-09,3.902038584360E-09 +47,20,-1.113336663180E-08,1.969396282900E-10 +47,21,-5.995681527040E-09,1.111359847690E-10 +47,22,-7.566271764250E-09,-9.652171414620E-10 +47,23,3.096654061260E-09,9.949282947140E-10 +47,24,-1.172859787760E-09,-1.132243637800E-09 +47,25,-2.645424355070E-09,-9.918866856290E-09 +47,26,8.397187801200E-09,2.930250503890E-10 +47,27,-6.105147074180E-09,-2.631903974260E-09 +47,28,1.670580132970E-09,-6.845292476990E-09 +47,29,5.866532540120E-09,1.399194706360E-11 +47,30,-2.420547557340E-09,2.459038934900E-09 +47,31,5.197702687790E-10,9.517144246490E-10 +47,32,-1.600202227920E-09,-1.571895680680E-10 +47,33,-4.662702949620E-09,2.693939609510E-09 +47,34,1.864385588010E-09,3.687458850930E-09 +47,35,-9.092574441930E-09,1.086839312870E-09 +47,36,7.408295179630E-09,-4.346910605900E-09 +47,37,8.227696592810E-09,1.656955416420E-09 +47,38,6.478279865870E-11,1.369445951560E-09 +47,39,-1.854496469250E-09,7.669875550100E-09 +47,40,-8.501736200870E-09,7.891720315190E-09 +47,41,-3.689359852300E-09,4.900976730060E-09 +47,42,-1.590398735650E-09,-5.524460476410E-09 +47,43,2.238248828710E-09,2.734102831120E-09 +47,44,-3.413121915720E-09,1.043472019470E-08 +47,45,6.210338251890E-09,2.302636088370E-09 +47,46,-2.586025579960E-10,-4.329609435370E-09 +47,47,3.288787602110E-09,-5.014408872170E-09 +48,0,3.576811781080E-09,0.000000000000E+00 +48,1,1.149686994350E-09,3.026570132830E-09 +48,2,4.520343854360E-09,3.633930173490E-10 +48,3,-1.229275842350E-09,2.579793024870E-09 +48,4,4.093419595660E-10,-1.220805347810E-09 +48,5,5.436634557990E-09,-2.356053804810E-09 +48,6,4.749040206010E-09,7.848437217790E-09 +48,7,-3.989788733220E-10,2.481632621760E-09 +48,8,2.558451118260E-09,5.473963718920E-09 +48,9,2.241306783480E-10,3.987951872380E-09 +48,10,-3.531582996060E-09,3.044708023320E-09 +48,11,1.381797076390E-09,1.073202517230E-09 +48,12,9.197944995490E-11,-3.831024245600E-09 +48,13,2.844454384600E-09,1.676449438990E-09 +48,14,-4.174152398840E-10,-5.208905172190E-10 +48,15,4.621822065930E-09,1.745411450740E-09 +48,16,8.553109058640E-10,9.641781796100E-10 +48,17,1.038824890530E-09,3.586122725010E-09 +48,18,-6.518334564140E-10,1.746022377690E-09 +48,19,-2.274283150470E-09,4.719782771110E-09 +48,20,-5.258959022630E-09,5.059470180140E-09 +48,21,4.199505646940E-10,-3.373949292430E-09 +48,22,-7.467394937560E-09,2.786887113580E-09 +48,23,-2.068701633980E-09,-2.406987793840E-09 +48,24,-4.284791490160E-09,-9.292227918240E-10 +48,25,-3.138163674880E-09,3.538600165270E-10 +48,26,6.753170508520E-10,-5.813654146940E-09 +48,27,-8.142622998530E-09,4.753006835140E-09 +48,28,6.191172112590E-10,-7.450178269020E-09 +48,29,2.082708249320E-09,-6.707256708290E-09 +48,30,-1.210146529610E-09,-1.359525806740E-09 +48,31,2.189262394800E-10,-3.845883145830E-09 +48,32,1.754932808570E-09,-1.717345016430E-09 +48,33,2.030341266640E-09,-1.192091994380E-10 +48,34,7.363414922060E-10,7.248808414100E-09 +48,35,-5.460034316940E-09,1.263208848990E-09 +48,36,-4.578230415680E-09,-6.448502012820E-10 +48,37,-1.778679170250E-09,-4.543049544850E-09 +48,38,-8.735473573200E-09,-1.031935132010E-09 +48,39,5.122260065570E-09,-8.695379946670E-09 +48,40,1.433852396990E-09,4.950291643610E-09 +48,41,-2.360899362380E-09,-9.763626932710E-09 +48,42,1.918747689180E-09,2.845479876110E-09 +48,43,4.869402161970E-09,5.838629295440E-09 +48,44,6.538590551770E-10,-1.208656126830E-10 +48,45,5.628631593190E-09,4.735073390150E-09 +48,46,-3.689180925520E-09,8.754448556250E-09 +48,47,3.191618307660E-09,4.529547266630E-09 +48,48,6.095745980270E-09,-1.460026110390E-09 +49,0,-1.323812517480E-10,0.000000000000E+00 +49,1,6.470682776270E-09,-2.378961410410E-10 +49,2,1.302004275460E-10,3.004564435470E-09 +49,3,1.437482185980E-09,1.446703099780E-09 +49,4,-3.923295687440E-10,9.703880354480E-09 +49,5,2.554294602330E-09,-1.477680220730E-10 +49,6,-2.113766645530E-09,3.438325999430E-09 +49,7,3.153300408860E-09,3.419435349570E-09 +49,8,-1.066749143620E-09,3.726697496380E-09 +49,9,-8.154887167340E-10,5.553817123900E-09 +49,10,-4.966785601710E-09,-3.871683850690E-10 +49,11,4.840622169880E-09,2.237906176730E-09 +49,12,-3.203374077620E-09,-2.027375374750E-09 +49,13,2.498127250430E-09,3.727621882280E-09 +49,14,6.343490285680E-10,-1.581675935640E-09 +49,15,-1.991987628090E-09,1.127742264700E-09 +49,16,-1.233364004790E-09,-5.944565408060E-09 +49,17,-2.656997020320E-09,-6.102481646900E-10 +49,18,-1.314021733230E-09,-2.437825192310E-09 +49,19,-2.296883430830E-09,-4.941805213170E-10 +49,20,4.668713948080E-09,3.071276321850E-10 +49,21,-2.089893465550E-09,-5.884345241950E-09 +49,22,1.911352160960E-10,4.434912813020E-09 +49,23,2.998712730750E-09,-1.063791057930E-09 +49,24,4.359960326200E-09,1.426788624730E-09 +49,25,-4.601568267400E-09,5.385031413990E-09 +49,26,-7.848455590490E-09,-9.769334116440E-10 +49,27,-3.043182991430E-09,2.748722321950E-09 +49,28,-3.230177537590E-09,-1.122847465330E-08 +49,29,6.641061651010E-10,-5.716706637060E-10 +49,30,2.481268704690E-09,2.858958321020E-09 +49,31,-2.486891239250E-10,-6.183842848750E-09 +49,32,1.373363187110E-09,-5.666676531450E-09 +49,33,1.510785316600E-09,-1.852757424700E-09 +49,34,4.486217003390E-09,-5.354470384220E-10 +49,35,2.841359546950E-09,5.536051024880E-09 +49,36,-4.734609429360E-09,5.778006535130E-10 +49,37,-1.003753463120E-09,9.278828413400E-10 +49,38,3.102966572670E-09,-2.412497894310E-09 +49,39,2.828358859020E-09,2.262143453330E-09 +49,40,-1.768942083280E-09,1.905250311420E-09 +49,41,-1.788697411960E-09,-1.474820174510E-09 +49,42,-2.887968795060E-09,-4.840586743220E-10 +49,43,5.045570108980E-09,-6.878565260430E-09 +49,44,5.934831742780E-09,8.105274677190E-09 +49,45,8.782940125860E-10,-1.546287994600E-09 +49,46,3.322231418120E-09,1.320616091670E-09 +49,47,2.195465587390E-09,4.455773229060E-10 +49,48,-3.199346453340E-09,5.297966111630E-10 +49,49,3.532984580120E-09,1.155176600730E-09 +50,0,-3.761271550800E-09,0.000000000000E+00 +50,1,4.238883046090E-09,-1.502279550460E-09 +50,2,-8.616446855780E-09,-3.183405458640E-09 +50,3,6.548148488300E-10,-1.065541117400E-09 +50,4,-8.488785640010E-09,4.072135778900E-10 +50,5,-7.589453583230E-10,-2.794746264710E-09 +50,6,-8.059476268040E-11,1.796612314970E-09 +50,7,2.141835390230E-09,4.518097438180E-09 +50,8,-2.725376702080E-09,1.148292529290E-09 +50,9,-1.445098911840E-09,2.534728033090E-09 +50,10,-4.897999108120E-09,-3.576986333820E-10 +50,11,-2.947346010710E-09,1.947867087360E-09 +50,12,-4.053825871980E-09,5.023916574000E-09 +50,13,-6.355135822560E-10,1.397052560300E-09 +50,14,-3.798168277260E-09,4.204464794920E-09 +50,15,-3.406931426350E-09,-1.765257310320E-09 +50,16,-2.125799204330E-09,-4.978509235830E-09 +50,17,1.694170347620E-10,-3.844711977210E-09 +50,18,6.368852327860E-10,-4.498810116400E-09 +50,19,6.433919474670E-10,3.155122823120E-11 +50,20,2.085586854880E-09,-2.371940178150E-09 +50,21,-6.275462360580E-10,-8.819139169520E-10 +50,22,2.617491418610E-09,-4.960326558710E-10 +50,23,-1.673529548240E-09,-5.583517150400E-09 +50,24,1.042632886410E-08,5.326454095530E-11 +50,25,4.582486969560E-09,3.397692167230E-09 +50,26,-8.487290604600E-09,-1.810832497910E-09 +50,27,5.476699560080E-09,-2.516965107970E-09 +50,28,-7.446174588530E-10,5.586092051140E-09 +50,29,6.060260894590E-09,2.809475665720E-09 +50,30,4.319695945520E-09,6.413939135950E-09 +50,31,-2.846204362830E-09,5.888021893620E-09 +50,32,-1.624979227340E-09,2.056468224120E-09 +50,33,-4.605251590160E-09,-2.821705225470E-09 +50,34,-8.466469349690E-10,-2.729233571210E-09 +50,35,1.191404477080E-09,2.067435194140E-11 +50,36,2.136339701670E-11,4.508693605700E-11 +50,37,-7.338350115310E-10,-1.412877004580E-09 +50,38,-1.024100628370E-09,-5.635531415390E-09 +50,39,-5.295409121710E-09,9.194589222150E-09 +50,40,2.874864871600E-09,6.341287617840E-09 +50,41,-6.358722700840E-09,-4.966473872890E-09 +50,42,5.699643458350E-09,-4.096643871800E-09 +50,43,-5.256709539230E-10,-2.397434929020E-09 +50,44,8.107544400670E-11,-1.045606221100E-09 +50,45,-3.149260976460E-09,5.121743593230E-09 +50,46,-4.253527768420E-09,1.833109791540E-09 +50,47,-5.603484471990E-09,-1.126563901450E-08 +50,48,9.533090707290E-10,-3.027401352700E-10 +50,49,2.270561304910E-09,-4.598092885840E-09 +50,50,5.438521252840E-09,1.480274320950E-09 +51,0,-4.788529061520E-09,0.000000000000E+00 +51,1,1.654488792030E-09,3.094969001700E-09 +51,2,-6.911632305110E-09,-1.024241451950E-09 +51,3,-4.450729905480E-09,-7.397839007000E-09 +51,4,1.078620938600E-09,2.383501415090E-09 +51,5,-2.992818938910E-09,-3.756487287740E-09 +51,6,9.696836253820E-10,-3.248212420440E-09 +51,7,-1.336310313150E-09,3.130439521530E-09 +51,8,1.400761325690E-09,4.317558708830E-09 +51,9,-9.806044704730E-10,-3.701458194070E-09 +51,10,3.784363680640E-09,-2.652668247170E-09 +51,11,-3.740592243950E-09,2.016157155090E-10 +51,12,-4.460974947830E-09,1.950462746710E-09 +51,13,-1.006471889030E-08,-9.354838711770E-10 +51,14,1.596626828250E-09,3.099980256590E-10 +51,15,8.693250903230E-10,-7.132423869700E-10 +51,16,-1.554291207060E-09,-1.718100084940E-09 +51,17,3.683429058780E-09,-7.859572376940E-10 +51,18,1.468134444570E-09,-7.167786101110E-10 +51,19,7.804087551570E-10,-2.377426180110E-10 +51,20,5.476406501810E-10,3.142605121030E-11 +51,21,1.572137888840E-09,9.439816488870E-10 +51,22,-2.932015984370E-09,-2.687668464180E-09 +51,23,-2.266961866800E-09,2.443369064410E-09 +51,24,3.599801079110E-09,-6.255451756510E-09 +51,25,6.791694236840E-09,-9.238945915960E-10 +51,26,-1.299127791580E-09,-3.694755691020E-09 +51,27,4.835227319780E-09,-8.564765733200E-09 +51,28,3.584413133970E-09,6.675464789300E-09 +51,29,-2.421832220580E-09,1.475177384610E-09 +51,30,1.725579286990E-09,3.323034266090E-09 +51,31,3.694987511020E-11,5.064141929210E-09 +51,32,2.448323820290E-09,2.150165976410E-09 +51,33,-2.609515609560E-09,1.524678191830E-09 +51,34,-4.457449303660E-09,-8.638065333720E-10 +51,35,3.692352784470E-09,2.351906160000E-09 +51,36,-6.299005050720E-09,3.096235280580E-09 +51,37,-2.424004385740E-09,-5.677256780090E-09 +51,38,1.937545310910E-09,-3.787499552990E-09 +51,39,5.183520790080E-09,7.194336430600E-10 +51,40,-3.754862401280E-09,-3.935940991850E-10 +51,41,1.597507361220E-09,1.843475656820E-09 +51,42,1.085321039060E-10,3.381877264650E-09 +51,43,-4.704173188610E-09,2.056205292020E-09 +51,44,5.840394764860E-11,1.787294030190E-10 +51,45,-9.271586993750E-09,-6.784455665040E-09 +51,46,4.432591672660E-10,-2.461576926830E-10 +51,47,3.988506040580E-09,-7.601353549590E-10 +51,48,3.825587465740E-09,7.302106502010E-10 +51,49,-1.816389493240E-09,-2.533188162350E-09 +51,50,-1.726443518410E-09,6.316935152470E-09 +51,51,-5.828724526020E-10,3.545953414910E-09 +52,0,8.767984150380E-10,0.000000000000E+00 +52,1,-1.381495046120E-09,-5.661410320870E-09 +52,2,3.483643263500E-09,5.286356143690E-10 +52,3,1.046391123650E-09,-1.522856421500E-09 +52,4,3.568822484680E-09,1.394217080450E-09 +52,5,-1.638430592690E-10,-1.826400649870E-09 +52,6,-5.632359394560E-09,-9.111109114490E-10 +52,7,-4.134162233160E-09,-1.980308573540E-09 +52,8,1.080896126530E-09,-1.050652675470E-09 +52,9,-2.253092574960E-09,-4.902742732660E-09 +52,10,4.916567708180E-10,-2.317909033510E-09 +52,11,8.496231629480E-10,8.981706345270E-10 +52,12,-6.917304854780E-10,-3.965966613600E-09 +52,13,-2.804168621250E-09,4.123104664040E-09 +52,14,2.971399004640E-09,-8.194058572640E-10 +52,15,3.068395983660E-09,1.731707301030E-09 +52,16,1.077005610940E-09,2.150167687560E-09 +52,17,5.619291475210E-10,8.104853217700E-10 +52,18,6.757412599980E-11,-1.073355462250E-10 +52,19,-1.731380457740E-10,2.008721224990E-09 +52,20,2.516375938840E-09,-2.892732961910E-09 +52,21,9.371809639140E-10,7.746533523560E-10 +52,22,-2.895253845440E-09,3.709719025180E-09 +52,23,4.801220487910E-09,1.557556709030E-09 +52,24,-2.088574635290E-10,3.494435605990E-10 +52,25,2.730166058380E-09,-1.935099144650E-09 +52,26,-6.189339704000E-09,-3.000140014630E-09 +52,27,1.899148275840E-10,-6.277448937800E-09 +52,28,2.340147635140E-09,1.680413046910E-09 +52,29,-6.151234637550E-09,-3.676696899580E-09 +52,30,-1.958035067170E-09,4.348241405200E-10 +52,31,2.004378072280E-09,-2.041312837770E-09 +52,32,4.089938895350E-09,-5.235246921420E-09 +52,33,7.230796783910E-10,-2.912431585250E-10 +52,34,5.348593011880E-09,-1.196167953110E-09 +52,35,7.269486795270E-09,6.731384953060E-09 +52,36,1.178431477360E-09,5.691570121490E-09 +52,37,-9.486680927260E-09,2.988545321040E-09 +52,38,-1.117390912980E-09,-1.248609235710E-09 +52,39,-1.676626137490E-09,-1.060683504650E-09 +52,40,-1.132361372080E-08,-1.837636171020E-09 +52,41,-2.938838429170E-09,-3.823519122830E-09 +52,42,-7.904176929050E-10,-6.322943887070E-09 +52,43,5.116313948380E-09,-2.526989118530E-09 +52,44,-1.033011113070E-09,-1.714353139650E-10 +52,45,2.302037336250E-09,-1.016491062630E-09 +52,46,3.307137383810E-10,3.383386586190E-09 +52,47,-3.421607628480E-09,5.029832499950E-09 +52,48,2.656081595130E-09,3.390224545640E-09 +52,49,-7.549776828270E-09,5.932845899320E-09 +52,50,-4.299437701930E-09,-1.398407893160E-09 +52,51,-5.413234227730E-09,-2.499754278410E-09 +52,52,-3.367459015880E-09,-4.307243464810E-09 +53,0,7.394145005340E-09,0.000000000000E+00 +53,1,-3.047141211310E-10,8.821589031500E-10 +53,2,7.288386905220E-09,-6.522381211070E-10 +53,3,-2.123993597990E-09,2.240663376710E-09 +53,4,3.587176178390E-09,7.968971024540E-10 +53,5,-6.801476495490E-11,-3.307589221890E-09 +53,6,9.443164711020E-10,5.791332629350E-11 +53,7,1.340647230700E-09,-5.815774281820E-09 +53,8,2.102804192610E-09,-2.758021918710E-10 +53,9,1.420870153250E-09,-2.151462017050E-09 +53,10,1.119431048260E-08,-4.064687619620E-09 +53,11,2.144339300450E-09,-4.149531572370E-11 +53,12,-3.715008881010E-10,-5.006374539030E-09 +53,13,4.101558223090E-09,2.490159496050E-09 +53,14,3.312228630680E-09,-1.270093607240E-10 +53,15,5.725101489350E-09,4.172512951100E-09 +53,16,-7.525003268380E-10,6.044045268630E-09 +53,17,-3.733776567410E-09,1.786870832740E-09 +53,18,4.029963208360E-09,2.465772175200E-09 +53,19,4.049857053300E-09,-1.087558614950E-09 +53,20,-2.213599890720E-09,-1.503775893530E-09 +53,21,-2.213117684400E-09,2.619743589580E-09 +53,22,3.094038788510E-10,5.989023671070E-09 +53,23,3.973563566980E-09,-4.704951020210E-09 +53,24,-5.760881179620E-09,5.751141565250E-09 +53,25,6.114036510360E-10,-6.506816400580E-09 +53,26,-1.667515315360E-09,2.719236962220E-09 +53,27,3.183313770850E-10,-3.168058817770E-09 +53,28,9.656374771570E-10,3.289355020860E-09 +53,29,-6.853961854570E-09,-1.684227976850E-09 +53,30,-5.253711397510E-09,-6.325791679860E-09 +53,31,-3.126179620030E-09,2.642086210230E-09 +53,32,2.645221508160E-09,-1.792701105030E-09 +53,33,-9.828763703740E-10,2.676826502370E-09 +53,34,-4.990205283240E-09,-7.477997608240E-09 +53,35,1.276302624490E-09,-3.827995704330E-09 +53,36,3.348640163330E-09,2.477669267200E-09 +53,37,-2.368805260650E-09,6.849184825410E-10 +53,38,1.812519694690E-09,-2.586867216060E-09 +53,39,-2.229924390700E-09,-2.226262600820E-09 +53,40,-3.537156489480E-10,-1.176708524320E-09 +53,41,7.384449911400E-09,-6.501634793100E-09 +53,42,5.057002113970E-09,2.707699851810E-09 +53,43,-8.693278743840E-10,4.474948356840E-09 +53,44,1.885499921340E-09,-1.929986579570E-10 +53,45,-7.170450198340E-09,2.669723782510E-09 +53,46,-1.883452963160E-10,-5.395993184340E-09 +53,47,-5.398075888850E-10,5.578883824660E-09 +53,48,-3.239479269180E-09,-3.237662749290E-09 +53,49,-1.193687779180E-09,-3.856564303280E-09 +53,50,8.235800148390E-09,1.643982163700E-09 +53,51,2.667438223250E-09,6.242165041490E-10 +53,52,5.945371668500E-09,7.885733506280E-11 +53,53,3.065383395890E-09,5.334196940200E-09 +54,0,2.344454337330E-09,0.000000000000E+00 +54,1,-1.633257501310E-09,2.725797972390E-09 +54,2,6.210659248440E-10,-1.533443340670E-09 +54,3,6.176557266070E-09,1.722318502650E-09 +54,4,-1.537560249380E-09,-5.385032151630E-09 +54,5,1.710251480240E-09,-2.100456940380E-09 +54,6,2.717122901410E-09,-4.006010852240E-09 +54,7,2.493752788820E-09,1.667392054950E-09 +54,8,3.095361232120E-09,-2.209122486160E-09 +54,9,3.945447935800E-09,7.970832900650E-09 +54,10,1.757316953020E-09,-1.113238259200E-09 +54,11,4.871176195230E-09,7.527629123620E-10 +54,12,-6.630466131010E-10,-1.933304784410E-09 +54,13,3.675389376090E-09,3.807577749330E-10 +54,14,-3.499510735820E-09,1.056516088300E-09 +54,15,3.563556896550E-09,-3.873790989330E-09 +54,16,-2.633668199810E-09,1.424920065240E-09 +54,17,-8.337824834790E-10,5.594385601170E-09 +54,18,2.291952537700E-09,-1.084925859170E-09 +54,19,8.146713906850E-09,-1.254453296550E-09 +54,20,-2.009474626880E-09,-5.077034147090E-10 +54,21,-3.550576353530E-09,2.339628117010E-09 +54,22,-2.410814422580E-09,3.478239582580E-09 +54,23,2.717653482940E-10,-1.671419635560E-09 +54,24,2.646774705910E-09,4.419493607030E-09 +54,25,-3.320770699350E-09,-2.975842903550E-09 +54,26,4.164708158370E-09,-2.161719351180E-09 +54,27,-8.851421583750E-11,5.133095298660E-09 +54,28,-1.594407369430E-09,-8.219764284900E-10 +54,29,-2.592462651800E-09,1.488565156040E-09 +54,30,2.049875260600E-09,1.470285511650E-09 +54,31,4.726391132620E-11,4.400694722700E-09 +54,32,-2.033562287500E-09,1.760532764790E-09 +54,33,-5.891516940680E-09,9.870558003760E-10 +54,34,-8.251363567240E-09,-3.757141173360E-09 +54,35,6.098928219680E-10,-8.385145260430E-09 +54,36,2.156565940320E-09,-6.444970843350E-09 +54,37,2.060532741200E-09,1.761984877160E-09 +54,38,-5.482113237180E-10,-7.919549074070E-10 +54,39,7.946290160520E-09,-1.785565453480E-09 +54,40,2.569459210610E-09,7.091464290510E-10 +54,41,5.992413212840E-09,3.538215285900E-09 +54,42,6.090902809840E-09,6.242586540120E-09 +54,43,-2.006582109310E-09,3.845500946720E-09 +54,44,-2.273997904790E-09,3.690305405040E-09 +54,45,-1.723296697340E-09,-5.591336621370E-09 +54,46,1.359435705560E-09,-4.555579367080E-09 +54,47,-2.284662766280E-09,3.059541385160E-09 +54,48,3.702670032520E-09,1.739780759680E-09 +54,49,-1.179392030220E-10,3.698082873610E-10 +54,50,-1.935135769990E-09,-7.573037574700E-10 +54,51,4.819567046210E-09,3.784437278100E-09 +54,52,1.501205263650E-09,-2.151288416980E-09 +54,53,-4.128542441190E-09,2.981089125430E-09 +54,54,-7.554857622120E-09,5.652813156070E-09 +55,0,1.115005566320E-09,0.000000000000E+00 +55,1,-1.608725974070E-09,5.063148670090E-09 +55,2,-2.076754138540E-09,-2.029327449900E-09 +55,3,4.783604868890E-09,3.163382718350E-09 +55,4,-2.805725370250E-10,1.742727439010E-09 +55,5,6.415134242940E-09,4.324920986130E-09 +55,6,-3.014345634760E-09,-1.832702903750E-09 +55,7,2.074893149910E-09,3.433343539790E-09 +55,8,-3.752072830400E-09,3.922551362580E-10 +55,9,2.752630926410E-09,2.041397089810E-09 +55,10,-3.217844801160E-10,3.791201015350E-09 +55,11,1.957370469470E-09,2.689659567840E-09 +55,12,1.194901090980E-09,3.828653210110E-09 +55,13,1.253509795510E-09,-5.995591665540E-10 +55,14,-9.454895192810E-10,6.678586727330E-10 +55,15,-1.866438592660E-09,-5.282791069400E-09 +55,16,-4.140123102470E-09,1.181912558260E-11 +55,17,-1.672627835380E-09,-4.203723305240E-10 +55,18,-1.173782653580E-09,7.187570335120E-09 +55,19,1.158802627980E-09,-4.886721909860E-10 +55,20,-1.091340819550E-09,1.128527041240E-09 +55,21,7.630114909550E-10,-3.071312658130E-09 +55,22,3.675285056610E-09,-2.217087587400E-09 +55,23,1.729041781750E-09,5.657692241070E-09 +55,24,6.475526956160E-09,4.041328433930E-09 +55,25,-4.805034971050E-09,1.148635688000E-09 +55,26,-9.155065784010E-10,-1.575120794020E-09 +55,27,-4.098055391640E-09,3.588603944960E-09 +55,28,7.326412075770E-10,1.688471171200E-10 +55,29,2.345350300560E-09,-5.817133544390E-10 +55,30,4.863906269660E-09,-5.456542340760E-10 +55,31,-6.943497247430E-11,1.458126205790E-09 +55,32,-5.402252565680E-10,1.276464667030E-09 +55,33,2.433342032170E-09,-5.024040699930E-10 +55,34,-1.989517498510E-09,1.011162534670E-09 +55,35,2.851397936120E-09,-3.052826979780E-09 +55,36,1.913803143990E-09,4.988520510770E-09 +55,37,-3.152190703590E-09,5.933312778350E-09 +55,38,-7.560417463260E-09,9.849016936520E-10 +55,39,-1.066894966080E-09,-4.374820484600E-09 +55,40,2.035359528130E-09,-4.663879966520E-09 +55,41,3.938340826510E-09,-2.193875991780E-10 +55,42,2.758342971180E-09,-2.796288030780E-09 +55,43,9.079578994930E-10,8.408680604640E-10 +55,44,8.008178372740E-09,-2.414781283200E-09 +55,45,1.187850379670E-09,4.269531786830E-09 +55,46,-3.067326756970E-09,-2.158108941360E-09 +55,47,3.412009003650E-09,3.414232752140E-10 +55,48,-4.789302281980E-09,-2.059832436040E-09 +55,49,2.790384783520E-09,-1.889840398710E-09 +55,50,-3.410466542560E-09,8.865946891520E-10 +55,51,-3.232035164960E-10,4.716448526810E-09 +55,52,-4.050163269740E-09,-3.011986528420E-09 +55,53,7.269727583440E-10,2.072978075120E-09 +55,54,-5.604826314310E-09,-5.709820393680E-09 +55,55,2.091212654200E-09,-7.070732706610E-09 +56,0,-4.723393891860E-09,0.000000000000E+00 +56,1,3.931875164780E-09,3.377423145750E-09 +56,2,-4.911999596790E-09,4.841334065490E-09 +56,3,-1.123073250000E-09,1.546542102990E-09 +56,4,-1.909603619420E-09,6.066219654300E-09 +56,5,2.391247276790E-09,7.244401389130E-10 +56,6,-7.978255862270E-09,7.309445644630E-10 +56,7,2.124168533870E-09,-8.579761251080E-10 +56,8,-4.722645525860E-09,-3.626881400570E-09 +56,9,4.174066313450E-09,8.892097062790E-10 +56,10,-4.416156487500E-09,1.229516876670E-09 +56,11,2.985675647500E-10,3.119183336190E-09 +56,12,4.222200077610E-10,4.135379259320E-09 +56,13,3.630752036140E-10,5.574274434020E-09 +56,14,-1.200388748680E-09,3.594619660570E-09 +56,15,-2.431931873970E-09,1.507844021280E-09 +56,16,1.763755538520E-09,-3.483526234890E-09 +56,17,-8.272287544120E-09,-2.451097197120E-10 +56,18,-2.966873676920E-09,3.350226498400E-09 +56,19,-1.456959090200E-09,7.526648391740E-10 +56,20,4.956743363780E-09,-1.240936082230E-09 +56,21,4.340101328570E-10,-3.625993951050E-09 +56,22,1.029034808510E-09,3.667684219920E-10 +56,23,2.181402489550E-09,4.022182602740E-09 +56,24,1.486700186950E-09,-2.340223674660E-10 +56,25,-2.029832252260E-09,2.662861669000E-09 +56,26,2.350983962830E-09,-4.809055955770E-09 +56,27,7.698846738580E-10,3.658976099300E-09 +56,28,-3.477068367780E-09,1.213925342230E-09 +56,29,4.584760126090E-09,-3.309890089060E-09 +56,30,-8.905714229540E-10,1.919032090740E-09 +56,31,6.231908570990E-11,-6.006341530140E-09 +56,32,3.367445294720E-09,-2.469521301760E-09 +56,33,6.002585961040E-09,1.838500575560E-09 +56,34,2.386992588270E-09,1.057044453260E-09 +56,35,-1.265066242300E-09,2.043446088360E-10 +56,36,3.364876147800E-09,-1.766344009660E-09 +56,37,2.650073182810E-09,5.477263393790E-09 +56,38,1.341528863070E-09,4.306286848030E-09 +56,39,-1.119972284810E-09,-3.747945674940E-09 +56,40,2.607735034490E-09,3.841457138210E-09 +56,41,-8.414134205680E-10,2.383070789570E-09 +56,42,-4.572789444830E-09,-1.673540311950E-10 +56,43,-7.086793399900E-10,-4.644846690760E-09 +56,44,1.467705671390E-09,2.337348166890E-09 +56,45,4.360362991700E-09,-7.663524881560E-10 +56,46,3.830465283940E-09,-4.645424628720E-09 +56,47,7.601285471620E-09,2.636658948840E-09 +56,48,-1.935611352530E-09,-3.144254237580E-09 +56,49,5.138414468240E-09,6.126185075940E-09 +56,50,-4.242032851540E-09,1.101421495020E-09 +56,51,2.608516175440E-09,-1.119594537340E-09 +56,52,-3.977203369520E-09,-2.003699677400E-09 +56,53,-9.471217167780E-09,-3.550571787820E-10 +56,54,-6.948140165290E-09,-1.691376712010E-09 +56,55,7.988654685520E-11,-3.117352158800E-09 +56,56,1.473999490680E-09,-1.452422150510E-09 +57,0,-3.211701956050E-09,0.000000000000E+00 +57,1,3.213168361110E-09,-2.886677267450E-09 +57,2,-2.693632443430E-09,1.895370698230E-09 +57,3,-5.683904275780E-09,3.991874448280E-09 +57,4,-7.606326539450E-09,-1.493231762870E-09 +57,5,-3.247000655760E-09,1.154219160530E-09 +57,6,6.617021665460E-11,-3.380095954190E-10 +57,7,-4.489890815210E-10,-3.997134453870E-10 +57,8,4.464178395660E-09,9.966429399070E-09 +57,9,4.991674092530E-10,-4.988817172630E-09 +57,10,-1.385241645900E-09,3.449214060190E-09 +57,11,-5.195229552670E-09,-7.544394264660E-09 +57,12,4.373747473780E-09,5.790725940740E-09 +57,13,1.447728874490E-09,-1.986494382940E-09 +57,14,-5.779648352550E-10,6.272156481090E-09 +57,15,3.949553802570E-10,3.678341427270E-09 +57,16,-8.957345595480E-10,-4.285759691940E-09 +57,17,-1.122173691850E-09,-2.150509427310E-10 +57,18,1.487326824370E-09,3.747288908570E-11 +57,19,-4.272025392360E-09,4.145279682360E-09 +57,20,2.976926298910E-09,2.179101651460E-09 +57,21,-2.857913288840E-10,1.292653920750E-10 +57,22,2.725569988820E-09,1.254063616530E-10 +57,23,-2.723735032010E-09,-4.706031698530E-10 +57,24,-1.723212075920E-09,-2.495340663920E-09 +57,25,6.924629855200E-09,-2.151455977590E-09 +57,26,-2.114769496880E-09,-4.863397422900E-09 +57,27,6.700029505880E-10,7.020375884920E-10 +57,28,-3.939979365780E-09,-4.817979382350E-10 +57,29,3.082423946420E-10,-2.082506149450E-09 +57,30,6.386716710010E-09,3.021538347890E-09 +57,31,-7.828703979060E-10,-2.600888245200E-09 +57,32,3.155702408840E-10,-1.377018776450E-09 +57,33,1.013027153010E-09,3.326871303040E-09 +57,34,-2.373788981420E-09,1.660677121610E-09 +57,35,-3.871354531910E-10,-3.900740405790E-09 +57,36,-7.763371124240E-10,-6.195628392820E-09 +57,37,2.358984173630E-09,2.886916018480E-09 +57,38,4.719009416910E-09,3.888456270510E-09 +57,39,1.957712668700E-09,1.393268694420E-10 +57,40,1.632043310660E-09,7.610245875580E-10 +57,41,-1.236086067420E-09,1.483989910730E-09 +57,42,-8.017390320310E-09,1.659250844370E-09 +57,43,-1.947211823100E-09,-6.962239077040E-09 +57,44,1.485454886990E-09,6.084751066300E-10 +57,45,-1.280484249260E-09,3.939794120100E-09 +57,46,-4.368661744920E-09,2.623321573020E-09 +57,47,5.465026666920E-10,-1.492610213030E-09 +57,48,-1.221762182760E-09,4.762522155490E-09 +57,49,5.942101777800E-10,-2.958141489340E-09 +57,50,-1.261760742960E-09,-4.071077763260E-09 +57,51,-2.896914012440E-09,-3.542855783960E-09 +57,52,2.822916287430E-09,-5.936377909650E-10 +57,53,3.268791057160E-09,-1.865595793120E-09 +57,54,-6.215221051160E-09,2.850422033960E-09 +57,55,2.691248692070E-09,8.788402705710E-10 +57,56,8.174722896910E-10,2.437106180390E-09 +57,57,-2.879886972900E-09,-3.274631316060E-09 +58,0,-3.040261671930E-09,0.000000000000E+00 +58,1,-1.084782222380E-09,-1.329280178430E-09 +58,2,9.231884768560E-10,4.806032976680E-09 +58,3,-2.066492603590E-09,-6.957446363990E-09 +58,4,-3.278655978520E-09,5.322785715110E-11 +58,5,2.126752987620E-09,-2.928122524540E-09 +58,6,8.339028282550E-10,5.519745435420E-10 +58,7,-6.678992994630E-09,2.590073043560E-09 +58,8,3.292369543240E-09,5.338809101770E-09 +58,9,-4.611582098220E-09,-6.273550926320E-09 +58,10,2.913270492300E-10,-2.268593594350E-09 +58,11,-2.619425905930E-09,-4.473555411880E-09 +58,12,1.782219473710E-09,-4.773540155800E-10 +58,13,-3.965302333570E-09,-1.329653156960E-09 +58,14,3.489400833260E-09,-2.681000819980E-09 +58,15,1.164881402140E-09,8.907514603460E-10 +58,16,2.573637162050E-09,-3.199054060840E-09 +58,17,4.025381573190E-09,1.856942408030E-09 +58,18,2.820936889210E-09,-1.064053900430E-09 +58,19,1.701260716300E-09,-3.347319195640E-10 +58,20,-2.511877896530E-09,1.427190747980E-09 +58,21,-9.581994339330E-10,4.671512190420E-09 +58,22,3.584776510270E-09,-3.018296810570E-09 +58,23,-2.314508395720E-10,1.936615307960E-09 +58,24,-3.783254450150E-09,-1.698724412510E-09 +58,25,2.560447979180E-09,-2.543786586890E-09 +58,26,-1.277821935380E-09,1.737673710580E-10 +58,27,-3.233543776870E-09,-5.045012819740E-09 +58,28,-2.592271696820E-09,-2.367345857740E-09 +58,29,2.975546669070E-09,-2.856526876350E-09 +58,30,6.045695318770E-09,3.755160243620E-09 +58,31,-3.432480653910E-09,1.462380740610E-09 +58,32,2.374765821070E-09,1.452447771550E-09 +58,33,-7.526071828020E-10,1.053080432320E-09 +58,34,-8.653237538190E-10,2.041043347630E-09 +58,35,-2.770013458490E-09,-5.525381161190E-10 +58,36,-3.325620357830E-09,-2.153765325240E-09 +58,37,3.124714237960E-09,-3.010682856360E-09 +58,38,-5.364422737030E-09,2.082242147730E-09 +58,39,6.476532774980E-10,-1.429101401320E-09 +58,40,1.504605549120E-09,-4.249350396630E-09 +58,41,1.150926015530E-09,3.320512502270E-09 +58,42,9.602978898440E-10,-2.278387955980E-09 +58,43,-5.411126698070E-10,-2.354180311190E-09 +58,44,6.158052101950E-09,1.878284244480E-10 +58,45,-5.208464108110E-10,4.706590850810E-09 +58,46,1.217749081890E-09,2.290755750350E-09 +58,47,6.275762934500E-10,1.426488106670E-12 +58,48,-1.661014374690E-09,-3.675877073990E-09 +58,49,1.867328688520E-09,-5.668984673980E-10 +58,50,-7.426459595210E-11,6.856634598780E-09 +58,51,1.148910508650E-09,-2.258976844410E-09 +58,52,-4.820252031560E-10,5.299422251310E-09 +58,53,-4.874628184050E-09,-2.497832803790E-09 +58,54,-2.513335484710E-09,-6.169851099640E-10 +58,55,7.590189793460E-10,-3.092558619200E-09 +58,56,-1.108672272540E-09,-1.660851359770E-10 +58,57,-2.284106746490E-09,-5.042392436540E-11 +58,58,-5.645383317980E-11,4.013483764920E-10 +59,0,1.840050187380E-09,0.000000000000E+00 +59,1,-4.307306038560E-09,-2.163596865530E-09 +59,2,5.957741551050E-09,1.032500557700E-09 +59,3,-2.794257315300E-09,-5.475276519610E-09 +59,4,3.426092726740E-09,2.165239887090E-09 +59,5,-6.120267563070E-10,-2.626670618890E-09 +59,6,-5.721833067440E-09,-6.182099802880E-10 +59,7,-3.160777421580E-09,1.456995240020E-09 +59,8,3.907200863740E-09,1.317516606830E-10 +59,9,-1.946389150660E-09,-4.243764073300E-10 +59,10,5.385229272530E-09,-5.239770271570E-09 +59,11,3.718279481250E-10,1.199958740030E-09 +59,12,2.521657196820E-09,-3.721361868310E-09 +59,13,-4.899660783750E-09,-7.493610048050E-10 +59,14,4.443968867330E-10,-5.794345234220E-09 +59,15,6.517778077870E-12,-3.703607095630E-09 +59,16,-2.368622635240E-09,-6.797324537680E-10 +59,17,2.944014483510E-09,5.162568227990E-12 +59,18,3.562808022080E-09,2.566298281400E-10 +59,19,-2.252110435140E-10,-3.648925383390E-09 +59,20,-4.065617945160E-09,-1.469776126140E-09 +59,21,4.375766878870E-09,1.440780046410E-10 +59,22,1.070811046060E-09,-3.600559706980E-09 +59,23,4.558622134430E-09,3.441925911050E-09 +59,24,5.469328413640E-10,-4.219324422930E-10 +59,25,1.278423342910E-09,-1.939465854520E-09 +59,26,7.305773962320E-10,9.864977641930E-09 +59,27,-2.206293262410E-09,-1.473624039170E-09 +59,28,6.431234041230E-10,-1.962323234300E-10 +59,29,-3.715445633370E-09,-1.225508889050E-09 +59,30,-2.685652029940E-09,-1.249408907010E-09 +59,31,-2.076136423610E-09,4.007780583670E-09 +59,32,1.217312664430E-09,-1.501789025450E-10 +59,33,-7.843718868520E-10,-5.689987100940E-10 +59,34,2.432089982380E-09,1.362474073120E-10 +59,35,-1.418177473600E-09,-6.533464914690E-10 +59,36,2.023476443180E-10,8.314474530240E-10 +59,37,-9.238956597510E-10,-2.343100183710E-09 +59,38,-1.392819566660E-09,4.650646350600E-09 +59,39,-1.948351354010E-09,1.031506956530E-09 +59,40,-8.436128106760E-09,1.675203063350E-09 +59,41,-1.744130815450E-09,1.619900997030E-09 +59,42,-2.234340783150E-09,-1.919719083700E-09 +59,43,-2.529703629600E-09,-2.477652218130E-09 +59,44,5.719001327810E-09,-3.486625617910E-09 +59,45,6.616430594350E-09,-1.672065118640E-09 +59,46,4.709871995250E-09,1.542268976850E-09 +59,47,-1.953021743790E-10,5.482112957390E-09 +59,48,3.019589642190E-09,6.149970972850E-09 +59,49,-2.069913059960E-09,3.405487634890E-09 +59,50,-4.119244015620E-09,2.659377317900E-09 +59,51,-3.832830850750E-10,-4.998764209930E-10 +59,52,-5.941997462860E-09,-8.617825851810E-10 +59,53,-3.871094426840E-10,-3.442636722920E-09 +59,54,-2.959403072140E-09,-2.234496338710E-09 +59,55,-6.950841570220E-10,1.922582400270E-09 +59,56,3.655647418820E-09,-2.515182187870E-09 +59,57,3.449820104690E-09,-2.315997258310E-09 +59,58,8.168806883020E-10,-2.437437358300E-09 +59,59,2.509554925840E-09,6.956397197440E-10 +60,0,-4.371073308180E-10,0.000000000000E+00 +60,1,1.350379393080E-09,2.273259051030E-10 +60,2,3.541489906610E-09,-2.096180344960E-09 +60,3,2.942526888740E-09,2.823041813140E-11 +60,4,7.139114726260E-09,5.427868359880E-12 +60,5,-2.146486516250E-09,-5.084022065900E-10 +60,6,-4.311030894150E-09,-2.950736602500E-09 +60,7,-1.852583641640E-09,-2.531591331730E-09 +60,8,2.577621384790E-09,-1.129686536890E-09 +60,9,6.225133837180E-10,2.152974217500E-09 +60,10,4.354742153310E-10,-2.636409786130E-09 +60,11,2.652795685490E-09,4.267942835640E-09 +60,12,9.195283556180E-10,-3.157897817710E-09 +60,13,-5.141915992790E-10,-7.464022649620E-10 +60,14,-2.471853934260E-09,-1.371766193520E-09 +60,15,1.096047706990E-09,1.312710738660E-10 +60,16,-3.349111995700E-09,-8.905931271120E-10 +60,17,-4.965884088040E-09,1.638198188210E-09 +60,18,-2.315643192750E-09,-2.654257135220E-09 +60,19,3.584777199710E-09,-2.116525302210E-09 +60,20,-9.652814680370E-10,4.716489086530E-11 +60,21,1.137358426580E-09,-4.654945170330E-09 +60,22,3.192315981140E-09,3.821899124700E-09 +60,23,6.470456042010E-09,-2.668383773700E-09 +60,24,-1.912700195840E-09,1.204856401330E-09 +60,25,1.410912851560E-09,5.699784201030E-10 +60,26,3.699207387480E-09,4.036549823770E-09 +60,27,-4.336689342220E-09,2.005331420940E-09 +60,28,1.403303089990E-09,-1.519018560590E-09 +60,29,2.052084940760E-09,2.939589044080E-09 +60,30,-4.410720855680E-09,-2.592553391130E-09 +60,31,3.954527659950E-09,4.678519455350E-10 +60,32,-6.532225311860E-10,-3.155645927240E-10 +60,33,-4.728270100550E-09,4.022850311600E-09 +60,34,4.590268069360E-09,-2.369282463590E-09 +60,35,1.429718149320E-09,5.618529565810E-10 +60,36,8.716333280240E-10,-4.916776681810E-11 +60,37,-2.900190348080E-09,7.126517140580E-10 +60,38,1.315941194270E-09,-1.326573476220E-11 +60,39,-2.243069481570E-09,-5.365647263010E-09 +60,40,-6.006589987130E-10,9.544698984700E-10 +60,41,-7.893389243930E-10,-1.046862505740E-09 +60,42,1.710920969330E-09,3.104373181490E-09 +60,43,4.435421883280E-09,7.963440586950E-10 +60,44,1.430599918470E-09,-8.085034421740E-10 +60,45,4.139392407640E-09,-3.917762075920E-09 +60,46,1.187139855230E-09,3.488570589510E-10 +60,47,7.794468012940E-10,1.979234649920E-09 +60,48,-4.479064558000E-09,-3.726443608050E-09 +60,49,5.940656681510E-09,-4.463891602560E-09 +60,50,3.567052314680E-09,3.846989602640E-09 +60,51,-2.957895190050E-09,-5.802747406160E-10 +60,52,3.333043514320E-09,5.368710642110E-09 +60,53,-8.901214781070E-10,5.680468028940E-09 +60,54,4.751339117720E-09,-2.710083218770E-09 +60,55,2.388094696720E-09,2.893742294090E-09 +60,56,-1.455251935060E-09,-3.541955252810E-09 +60,57,-1.744219532970E-09,-3.224912394770E-10 +60,58,-1.440854655100E-09,2.316252724700E-09 +60,59,-2.320413112320E-09,1.205854446050E-09 +60,60,4.230680697890E-09,3.929837805450E-10 +61,0,2.918874634290E-09,0.000000000000E+00 +61,1,1.271006405540E-09,1.152780296800E-09 +61,2,1.464810717710E-10,-1.455751306090E-09 +61,3,-3.681917232120E-10,5.888267530230E-09 +61,4,1.875926365200E-10,-2.323391178170E-09 +61,5,5.663112231830E-10,-6.807009704910E-11 +61,6,3.824208018120E-09,-2.253511339260E-09 +61,7,6.601298024780E-10,1.244190344430E-09 +61,8,1.652618668220E-09,-7.321911780600E-10 +61,9,2.319201652640E-09,4.096503299310E-09 +61,10,-4.985757408110E-11,1.152621145400E-09 +61,11,2.144060166670E-09,3.754888370620E-09 +61,12,-7.959057859580E-10,-1.464383121230E-09 +61,13,4.285810197820E-09,-1.476417574880E-09 +61,14,-1.140205367600E-09,1.130455976170E-09 +61,15,2.256029144270E-09,-1.332549778280E-09 +61,16,-2.613066657730E-09,-3.272659308120E-10 +61,17,-1.111548363460E-09,1.546681586020E-09 +61,18,-3.848444073830E-09,-3.715743251940E-09 +61,19,1.147291229880E-09,1.860942452810E-09 +61,20,3.586975011710E-09,1.227113615580E-09 +61,21,-3.307133949590E-09,2.955848254610E-09 +61,22,1.617096397930E-09,2.284928545560E-09 +61,23,-5.370914652960E-09,-7.249864474960E-10 +61,24,3.874310146700E-09,3.479110828210E-09 +61,25,7.391097884720E-10,-2.451960236260E-09 +61,26,1.048408316530E-09,-4.434785173830E-09 +61,27,-1.557730798480E-10,7.967679585320E-10 +61,28,6.101510649930E-09,-1.948803284240E-09 +61,29,-9.311702590350E-10,2.762056455410E-09 +61,30,-8.539962733050E-11,1.009121790400E-09 +61,31,2.825566191580E-09,2.551624186290E-10 +61,32,-7.133009479420E-11,5.349623871810E-09 +61,33,-3.341814831280E-09,-2.528171726630E-13 +61,34,1.536889263590E-09,-2.665727621940E-09 +61,35,-2.277549865390E-09,1.847889364690E-09 +61,36,-1.388315152540E-09,1.437704602880E-09 +61,37,-3.786064441530E-09,-1.183511574170E-09 +61,38,-4.382705496440E-10,-7.024248244440E-09 +61,39,3.419923017320E-09,-2.845318172670E-09 +61,40,3.571866019720E-09,-2.633854671230E-09 +61,41,-2.694380121990E-10,-3.294724681040E-09 +61,42,3.890648941050E-09,3.177102634970E-09 +61,43,2.748825922670E-09,4.366283658780E-09 +61,44,8.977369389720E-10,5.299234356820E-09 +61,45,2.089837663570E-09,2.486612891870E-09 +61,46,-7.729287576810E-10,1.740067084180E-09 +61,47,-4.259873638210E-10,3.771673024660E-09 +61,48,1.502647407710E-10,2.488501105990E-10 +61,49,1.171851403670E-09,-1.057794564090E-09 +61,50,-1.356099668180E-09,2.820021590200E-09 +61,51,-3.866232696530E-10,2.408396620320E-09 +61,52,-4.216872519640E-09,-1.136341017900E-09 +61,53,-2.455368065310E-09,-4.466559078600E-09 +61,54,2.888665580530E-09,4.906453099380E-10 +61,55,5.345431157290E-10,-1.303359237580E-09 +61,56,3.277472355680E-09,2.993297331720E-09 +61,57,5.133636161310E-09,3.186838627900E-09 +61,58,-1.994970161060E-09,6.408968318870E-09 +61,59,-4.407857162370E-09,-2.111438995020E-09 +61,60,-2.834844303910E-09,-3.237562902230E-09 +61,61,-1.629027932790E-09,8.578707215520E-10 +62,0,2.602074812300E-09,0.000000000000E+00 +62,1,1.160176296650E-09,3.377339083360E-09 +62,2,-5.019702226710E-09,4.849979314430E-09 +62,3,5.518348643240E-09,1.517308802020E-09 +62,4,-3.169712012080E-09,-3.187596827810E-10 +62,5,3.752102992440E-09,1.727474287740E-09 +62,6,1.758796205000E-09,-5.991626911560E-09 +62,7,-1.840174115210E-09,2.398548542430E-09 +62,8,5.938053486450E-10,1.469205025010E-09 +62,9,2.630260660550E-09,-1.590051261250E-09 +62,10,-1.862648041070E-09,5.718194263070E-09 +62,11,1.594108013010E-09,2.453648354750E-09 +62,12,3.770589639320E-09,3.121481300270E-10 +62,13,3.256133247260E-09,-2.960459402080E-09 +62,14,1.911332534190E-09,7.366323759170E-09 +62,15,-1.480035768690E-09,-3.957619905800E-11 +62,16,2.083601832800E-09,-3.194141107680E-10 +62,17,-1.235133438360E-10,-2.292747950140E-09 +62,18,-3.654084319330E-10,6.495301110160E-10 +62,19,2.106671825820E-09,6.209779935840E-10 +62,20,2.667831080910E-09,3.552426020250E-09 +62,21,-2.177272376890E-09,1.539004218230E-10 +62,22,1.651818719110E-10,-1.153773453160E-09 +62,23,-3.569004956560E-09,-1.227789015260E-09 +62,24,4.167191900400E-09,1.020759158080E-09 +62,25,-2.077650234780E-09,-6.136225645540E-10 +62,26,-2.248563229530E-09,-2.130759798340E-10 +62,27,5.346679987880E-10,2.154995803500E-09 +62,28,8.285239803080E-10,-9.558170315370E-10 +62,29,1.244386085140E-09,3.560265128910E-09 +62,30,-4.726785743590E-09,-1.640894885420E-09 +62,31,-2.501405165580E-10,-3.403278943390E-09 +62,32,5.865953688100E-10,6.049135359060E-09 +62,33,-3.355180534040E-09,-3.897973381730E-09 +62,34,-6.348921336510E-10,-2.684188748350E-09 +62,35,4.812201886470E-09,-1.105080912100E-09 +62,36,1.988356944010E-10,1.456076816210E-09 +62,37,3.198934443700E-09,2.146780642390E-09 +62,38,-1.920403870900E-09,6.167164065640E-10 +62,39,3.345158125060E-09,4.146416458680E-10 +62,40,-1.975643435140E-09,-3.017960724550E-09 +62,41,-3.462355333180E-09,-2.288018584840E-09 +62,42,3.056984667490E-09,-3.754962024820E-09 +62,43,-1.496762928780E-09,-6.294970168770E-10 +62,44,2.144485245070E-09,1.885235904890E-09 +62,45,-8.344057462350E-10,-3.144739353610E-09 +62,46,-2.932651062120E-10,7.321131487820E-10 +62,47,-1.610970270920E-09,2.360671049810E-09 +62,48,-1.226634226270E-09,2.268638935850E-09 +62,49,-4.356409315020E-09,-1.166155355830E-09 +62,50,-1.633307572310E-10,2.149841104700E-09 +62,51,-4.387267385950E-09,-3.968022764710E-09 +62,52,3.934366104560E-09,-5.096678799580E-09 +62,53,-1.007999503030E-09,-2.809990220620E-09 +62,54,3.341716537990E-09,4.319802908010E-09 +62,55,4.151629555190E-09,2.892172193260E-11 +62,56,-3.153779936590E-09,-4.240810555030E-10 +62,57,1.058659541500E-09,-4.618807788870E-09 +62,58,-1.405696433690E-09,-1.536136080490E-09 +62,59,-5.523141958700E-10,-2.483708563540E-09 +62,60,1.045928882370E-09,1.338023176250E-09 +62,61,4.086442386790E-09,2.257181843210E-09 +62,62,-2.009008222440E-09,1.787845589950E-10 +63,0,-3.113231888160E-09,0.000000000000E+00 +63,1,-2.516861895270E-09,1.623162042560E-09 +63,2,-8.908431926580E-10,6.817182646860E-10 +63,3,-8.390313019160E-10,-2.445489568920E-09 +63,4,-2.100003666890E-09,5.437499425470E-09 +63,5,6.080741220050E-10,4.967999130370E-10 +63,6,1.850639533530E-09,3.981438264690E-09 +63,7,-9.459172485800E-10,2.404728085210E-09 +63,8,-2.899320041120E-09,3.386137926630E-09 +63,9,-1.745724094630E-10,-6.292344744470E-09 +63,10,2.913849086810E-10,1.860556970080E-09 +63,11,2.023389011840E-09,2.834162736530E-09 +63,12,3.222821272380E-10,-2.837187829310E-10 +63,13,1.916554600000E-09,2.919872679640E-10 +63,14,6.856140704570E-09,7.447012986920E-10 +63,15,-2.222920942650E-09,-1.469863582230E-10 +63,16,5.455861355260E-09,2.544710797900E-09 +63,17,8.192427996590E-10,-3.472768452320E-09 +63,18,3.245203346100E-10,9.580393852660E-10 +63,19,-3.493112925230E-09,1.043095151110E-09 +63,20,1.085487023710E-09,-6.206919428330E-10 +63,21,2.260772528410E-09,-1.716755594590E-09 +63,22,-1.956215276640E-09,-3.400257718070E-10 +63,23,1.550668710550E-10,1.227010109250E-09 +63,24,-2.998283060100E-10,-3.158794466200E-09 +63,25,-2.175028920990E-09,4.911241285060E-09 +63,26,-9.763699194000E-10,-2.984194895640E-09 +63,27,4.619148427470E-10,1.151378302700E-09 +63,28,-4.017740851420E-09,2.923619079540E-09 +63,29,1.858919777990E-10,2.084800556610E-09 +63,30,-3.389692513670E-09,-2.273194879810E-09 +63,31,-8.394260596790E-10,-1.646195214550E-09 +63,32,-1.795674629320E-09,2.461768800480E-09 +63,33,-4.567545511130E-09,-5.000394941340E-10 +63,34,1.445036342410E-09,-1.815471808040E-09 +63,35,2.544035856310E-09,7.060249022330E-10 +63,36,5.476624208890E-10,-2.874105095360E-09 +63,37,1.274021918280E-09,4.687069359370E-09 +63,38,3.985678417520E-10,-1.396090377460E-09 +63,39,7.557409402480E-10,1.605216788240E-09 +63,40,8.556321130510E-10,2.570088463410E-09 +63,41,-2.271907949700E-09,-1.011683657320E-09 +63,42,-2.609893708380E-10,3.073749620700E-10 +63,43,-4.594130926190E-09,2.902928312220E-09 +63,44,-6.665328282030E-09,-2.485162122330E-09 +63,45,-2.139181013290E-09,-2.936168635240E-09 +63,46,3.100230357480E-10,-9.932197143000E-10 +63,47,2.232608761200E-09,-1.061999082320E-09 +63,48,5.771037384010E-09,-1.661416621910E-09 +63,49,1.158790774210E-09,2.190396302210E-09 +63,50,8.017459442960E-11,1.391941659770E-09 +63,51,1.331343203560E-09,4.104086621760E-10 +63,52,-1.237296937730E-09,5.296340410350E-09 +63,53,-2.770689147680E-09,-1.023252439320E-10 +63,54,4.008518457630E-09,-3.971392343080E-09 +63,55,-6.752816863880E-09,1.205941340790E-10 +63,56,-6.712579550460E-12,-5.720479292240E-09 +63,57,-1.335086838000E-09,1.477000361590E-09 +63,58,8.436951266980E-10,-1.128680731100E-09 +63,59,1.285682206840E-09,-4.135304947910E-09 +63,60,3.509379970670E-09,-1.102011231370E-09 +63,61,-2.801806367960E-09,4.202793394990E-09 +63,62,-4.675099143540E-09,2.231241143570E-10 +63,63,-6.307713697460E-10,-1.765238767480E-10 +64,0,-3.731696212280E-09,0.000000000000E+00 +64,1,3.763762135480E-09,-5.071800557340E-10 +64,2,-9.248628428740E-10,3.192026779200E-09 +64,3,-1.722704949490E-09,-1.621196960860E-09 +64,4,-3.686680722760E-09,2.418345897040E-09 +64,5,-2.514883109830E-09,-2.944875377510E-09 +64,6,-2.010249298190E-09,-6.397247993610E-10 +64,7,-3.415866066180E-10,6.172629726140E-10 +64,8,-7.847161368100E-10,1.031542450610E-09 +64,9,-2.076897768450E-09,-2.880877645430E-09 +64,10,1.733263399430E-09,-2.833205359950E-10 +64,11,-3.358466038460E-09,-4.903433056140E-09 +64,12,2.544616711800E-09,2.009991673560E-09 +64,13,-4.048701062510E-10,3.491667199260E-10 +64,14,-1.084757720800E-09,-1.340985780780E-09 +64,15,-1.654592863440E-09,-9.735285425990E-10 +64,16,1.714438773120E-09,-1.751190419670E-09 +64,17,-1.420729429050E-09,-9.671441042350E-10 +64,18,2.218830459930E-10,2.574332343170E-09 +64,19,7.619174113620E-10,1.161592595630E-09 +64,20,-3.570245840700E-09,-2.016696681510E-09 +64,21,6.403389418230E-10,-3.454098768650E-10 +64,22,2.197322764280E-09,1.535961197550E-09 +64,23,-3.743397522520E-09,-4.247093713180E-10 +64,24,-3.975952617110E-09,-3.034204832960E-09 +64,25,8.461855746110E-10,6.484126889940E-10 +64,26,-1.142672248170E-09,-1.931501403780E-09 +64,27,-5.164534264200E-10,-2.436563639760E-09 +64,28,-1.232844044050E-09,4.015833621550E-09 +64,29,4.026840088990E-11,-3.007288713280E-09 +64,30,-2.920047969710E-09,1.021505176160E-09 +64,31,2.184740419110E-09,4.486552836740E-10 +64,32,8.759878204850E-10,2.528937553410E-09 +64,33,-1.879123225540E-09,1.716034010090E-10 +64,34,1.796219947550E-09,-1.319014902120E-09 +64,35,-6.336461264990E-09,1.135912452020E-09 +64,36,-9.965823286240E-10,-1.141855296890E-09 +64,37,1.664377207180E-09,1.812187120150E-10 +64,38,1.989633955420E-09,-1.979207023610E-09 +64,39,-9.125460243000E-11,-3.308173981710E-09 +64,40,4.434356187850E-09,1.136074619000E-09 +64,41,6.344567649150E-10,2.433481478640E-11 +64,42,1.710844321220E-09,-8.039509430000E-10 +64,43,8.100313676960E-10,1.445847502750E-09 +64,44,-4.676654583270E-09,2.168481627970E-09 +64,45,-1.187484630910E-09,-1.407306930630E-09 +64,46,2.160174503520E-09,4.726820800650E-10 +64,47,-2.680134748950E-09,3.374311497540E-10 +64,48,1.174263331430E-09,-2.714457391020E-09 +64,49,-2.797143125500E-10,2.201843687060E-09 +64,50,-5.770487128930E-09,-3.461861220460E-09 +64,51,1.431712683920E-10,4.341948172460E-10 +64,52,4.096973453250E-09,-4.740949790410E-09 +64,53,2.870997453700E-09,7.708464746760E-10 +64,54,1.086669219320E-10,-1.574084017260E-09 +64,55,1.826255887060E-09,2.515493443910E-09 +64,56,1.980861499430E-09,3.681234118970E-09 +64,57,-2.455498693170E-09,-4.620088385090E-10 +64,58,-9.882780303100E-11,-3.889703344220E-10 +64,59,-8.205621023210E-09,-3.896344374440E-10 +64,60,-3.571810438530E-10,1.292844135770E-09 +64,61,-4.167731693080E-09,-2.468999902940E-09 +64,62,-1.453139587700E-09,-9.373163458900E-10 +64,63,2.131922091730E-09,-5.201276279730E-09 +64,64,4.734733079730E-09,-1.225807825420E-09 +65,0,-4.497001574170E-10,0.000000000000E+00 +65,1,-2.582520241830E-09,-4.154507126770E-09 +65,2,-2.210427043400E-10,4.833970528480E-10 +65,3,-2.366780181550E-09,3.201830684440E-11 +65,4,5.210016815900E-10,-2.073534020350E-09 +65,5,-3.373185782850E-09,-9.808289521240E-10 +65,6,1.771134176650E-09,2.654589743230E-10 +65,7,-2.801956446820E-09,-3.815249788560E-10 +65,8,9.185083123730E-10,1.468770845730E-09 +65,9,4.092914337930E-10,1.702934309330E-09 +65,10,1.172027859350E-09,-1.827340128290E-09 +65,11,-3.453903998410E-09,-1.304464444720E-09 +65,12,8.780575825950E-11,1.539441731830E-09 +65,13,-8.392533286850E-10,1.378213785290E-09 +65,14,-6.793350414240E-09,-4.102289681570E-09 +65,15,1.046368100140E-09,1.280721265030E-09 +65,16,-2.225009178890E-09,-4.682412345750E-09 +65,17,-1.941765234510E-09,-3.218004977650E-09 +65,18,-1.578113015250E-09,-1.900477219400E-09 +65,19,-3.993143379130E-09,-2.079467126010E-10 +65,20,-3.097339226490E-09,-7.292866769980E-10 +65,21,2.134419924790E-09,-3.954010293350E-10 +65,22,2.782197520050E-09,-1.626609002670E-10 +65,23,9.795858709860E-10,4.182099965800E-09 +65,24,-1.337889624970E-09,1.084831798500E-10 +65,25,6.307774063390E-10,-2.717669130180E-09 +65,26,-3.134745208270E-09,-1.307612746310E-09 +65,27,-5.807083254060E-10,-2.866644071590E-09 +65,28,-5.431373617360E-11,5.123836017230E-10 +65,29,-9.940505371280E-10,-3.270263113370E-09 +65,30,4.885037261530E-09,-3.693376558430E-12 +65,31,1.341695861410E-09,8.231984807500E-11 +65,32,-1.007904177870E-09,1.344269575650E-09 +65,33,1.408943678680E-09,-3.265679431930E-10 +65,34,1.335469380040E-09,-1.885024843120E-09 +65,35,-2.337024677420E-09,3.524193387350E-09 +65,36,-1.713289739120E-09,-2.607533163930E-09 +65,37,4.692360544340E-09,-1.418534671660E-09 +65,38,-1.487174097630E-09,4.600436876180E-09 +65,39,-1.748204875800E-09,9.018730437330E-10 +65,40,-1.066935521290E-09,-1.335610868420E-10 +65,41,1.174770936910E-09,-1.431665806060E-09 +65,42,3.224704510290E-09,-4.069531758110E-09 +65,43,2.192846401340E-09,2.534984153720E-10 +65,44,1.633483527360E-09,1.987828100200E-09 +65,45,-1.740139299310E-09,1.606359448490E-09 +65,46,3.270764531210E-09,1.789404354270E-09 +65,47,8.711901969180E-10,2.436184211660E-09 +65,48,1.147172029550E-09,-6.143737579750E-10 +65,49,-2.821207301130E-10,1.619539796870E-09 +65,50,-5.975958712290E-09,1.059269725660E-09 +65,51,9.292439454760E-10,-2.832146623350E-09 +65,52,-6.929506822270E-10,1.752022659030E-09 +65,53,-4.456014140260E-09,-6.690135610370E-10 +65,54,2.577887993050E-09,1.560432650180E-09 +65,55,-3.085737797210E-09,4.280187015860E-09 +65,56,-1.221626603780E-09,-3.514054241150E-09 +65,57,-6.974774328190E-10,2.808244826070E-10 +65,58,-8.150957771110E-10,-9.491090025640E-10 +65,59,3.016276910470E-09,-2.220141147620E-09 +65,60,5.837589064000E-10,2.101359978590E-09 +65,61,-3.734689050930E-09,2.758805125700E-09 +65,62,9.542286000320E-10,5.207585983020E-10 +65,63,4.627603476500E-11,-3.550025658840E-09 +65,64,1.070040024200E-09,4.064008192660E-09 +65,65,4.397247578790E-10,2.073653366540E-09 +66,0,-2.460800030280E-10,0.000000000000E+00 +66,1,-1.280657465190E-09,-1.574128399040E-09 +66,2,2.347453654390E-09,-8.040931084050E-10 +66,3,1.643725276540E-09,-1.256637194030E-09 +66,4,1.490770044470E-10,-3.673167169150E-09 +66,5,5.591715510780E-09,4.141473903350E-09 +66,6,2.070179853990E-09,-2.551205362950E-09 +66,7,-8.126590193520E-10,2.617080495970E-09 +66,8,-8.196264889060E-10,6.574279032120E-10 +66,9,3.449147628480E-10,4.806655307740E-09 +66,10,-8.923568166870E-10,-4.176603421120E-09 +66,11,-7.347352065630E-10,6.457222722650E-10 +66,12,2.403613836060E-09,-4.392473848680E-09 +66,13,-3.801518141770E-09,2.087973473390E-09 +66,14,-3.715601437270E-09,-1.610130832850E-09 +66,15,2.034561722660E-09,-2.029405274740E-09 +66,16,-2.152999496830E-09,1.076012679690E-10 +66,17,-1.533446216000E-09,-8.016486525890E-10 +66,18,-9.232016407660E-10,-2.279914769570E-09 +66,19,-1.858789070620E-09,-2.028762133550E-09 +66,20,-1.355984881410E-09,-9.497231418180E-10 +66,21,-3.390549960660E-10,-2.286948570600E-09 +66,22,3.050702686610E-09,1.808451908140E-09 +66,23,3.265119970390E-09,2.687008080500E-09 +66,24,-4.226135838130E-10,3.664334281770E-10 +66,25,2.317767690930E-09,-1.266917971540E-09 +66,26,3.155056379980E-09,4.667546329740E-11 +66,27,3.080188109700E-09,-1.808756851910E-09 +66,28,8.888885078140E-10,-2.494070880850E-09 +66,29,-2.838885092330E-09,2.142506734030E-09 +66,30,3.598610884130E-09,-2.191739777040E-09 +66,31,-2.504525498340E-09,5.505897350900E-10 +66,32,-1.228302538300E-09,8.662025052290E-10 +66,33,1.628093193040E-10,-4.053134993640E-09 +66,34,1.016410440490E-10,1.610839070790E-09 +66,35,2.267498212850E-09,-2.590912864610E-10 +66,36,-1.744929448380E-09,-2.948927117250E-09 +66,37,5.372205343970E-10,2.103663241470E-09 +66,38,1.216739408000E-09,3.508065350470E-09 +66,39,-7.472458779630E-10,4.757573620480E-09 +66,40,-2.614881888300E-09,-3.052954895070E-09 +66,41,-1.342630346230E-09,3.510168612510E-09 +66,42,-4.071159809510E-09,-2.753231347140E-09 +66,43,-5.117004825940E-10,-1.958811976890E-09 +66,44,9.281050718600E-10,-4.175784722520E-09 +66,45,7.970090804110E-10,-6.181933191000E-10 +66,46,-3.024693423180E-10,-3.218168338570E-09 +66,47,4.438982742160E-09,-1.290929284670E-09 +66,48,4.323029256770E-09,8.720023150220E-10 +66,49,1.125630306700E-09,3.118534809810E-09 +66,50,-2.907367491250E-09,1.219905396130E-09 +66,51,-1.529772323460E-09,-2.227411470510E-09 +66,52,-1.941031870900E-09,-7.543799901970E-10 +66,53,-2.285317176710E-09,-4.799611928840E-09 +66,54,-9.905752784450E-10,-2.750658491640E-09 +66,55,-9.163590782370E-10,-2.597847181910E-09 +66,56,-1.488223200990E-09,5.379627561370E-10 +66,57,1.811916882880E-09,4.296111838440E-09 +66,58,-1.392164398380E-09,-1.198134554460E-09 +66,59,2.243140872440E-09,3.600679985220E-09 +66,60,-6.917090454740E-10,-3.150250793060E-09 +66,61,4.601702578460E-10,-2.065015208390E-10 +66,62,6.477793032930E-09,1.122385451370E-09 +66,63,1.734114276120E-09,4.577823635960E-09 +66,64,1.789674520060E-09,-1.578555061690E-09 +66,65,-2.454561121190E-09,4.446713352570E-09 +66,66,-3.470540361030E-09,-6.776785495600E-10 +67,0,-9.706009108070E-11,0.000000000000E+00 +67,1,-2.290819761380E-09,1.830690760280E-09 +67,2,-8.937671124790E-11,-4.453189611100E-09 +67,3,4.795288217440E-10,1.416116339190E-10 +67,4,-1.301011180300E-09,1.670873076700E-09 +67,5,1.275492899160E-09,2.835417711970E-09 +67,6,-8.562008694390E-10,2.995654277750E-11 +67,7,-4.166004192720E-09,-1.965622765600E-09 +67,8,2.420992959210E-09,-2.503334480710E-09 +67,9,2.940902793510E-09,3.244011081410E-09 +67,10,1.203345641660E-09,-8.119853617390E-11 +67,11,2.561829811270E-09,6.698567478400E-11 +67,12,-3.081848640880E-09,-1.676411505080E-09 +67,13,3.495951249990E-09,-1.972989912790E-09 +67,14,1.718074490950E-09,7.862381540890E-10 +67,15,1.637120626510E-09,-1.243698337550E-09 +67,16,6.436513372350E-10,1.388517239510E-09 +67,17,-2.129567959610E-09,-1.526729105210E-09 +67,18,-6.478453646480E-10,1.534804097720E-09 +67,19,-5.127940340980E-10,2.531824743840E-10 +67,20,3.208581116880E-09,-2.112727878510E-09 +67,21,-2.179983137990E-09,1.124469741430E-10 +67,22,-3.497425289140E-09,4.598119426420E-09 +67,23,3.409884264400E-09,-6.896242687190E-10 +67,24,2.278996796760E-09,-2.577358588510E-09 +67,25,3.873948084960E-09,5.150925509160E-10 +67,26,3.977180985920E-09,3.436698238370E-10 +67,27,1.551155829140E-09,5.410294556530E-10 +67,28,-1.292357834550E-09,-1.975563442520E-09 +67,29,-6.471883631530E-10,9.871988512280E-10 +67,30,1.251120280100E-09,-1.188815415060E-09 +67,31,-1.216956297880E-09,4.050885104380E-09 +67,32,-1.037238380160E-09,-2.784125704450E-09 +67,33,-1.669173198330E-09,3.902479385730E-10 +67,34,5.533188310880E-10,2.001204829710E-09 +67,35,-2.184153830880E-09,-2.194310980730E-09 +67,36,-2.128869837840E-09,-1.298161630470E-09 +67,37,-3.122149979900E-09,-7.401461276140E-10 +67,38,1.777938832560E-09,6.549743004270E-11 +67,39,-1.018473525210E-09,-5.001070726250E-10 +67,40,-6.002634084460E-10,-2.576627765330E-10 +67,41,-8.372154001840E-11,-2.170711389680E-09 +67,42,-7.139144812630E-10,2.242171836710E-09 +67,43,2.738537663490E-09,-2.312135294480E-09 +67,44,-5.888527675810E-10,4.988692645170E-10 +67,45,-1.683202994580E-09,1.846633415630E-09 +67,46,-3.503793125350E-09,-5.936551843710E-11 +67,47,-1.259361841530E-09,-2.594579615330E-09 +67,48,3.890250315660E-09,-2.925871607800E-09 +67,49,2.484294694180E-10,2.492478399390E-09 +67,50,1.545183677550E-09,-1.351024272250E-09 +67,51,5.420934703580E-09,-2.412510107820E-09 +67,52,5.635896347200E-09,1.182813094220E-09 +67,53,1.142862625950E-09,2.483450776960E-10 +67,54,9.705711141730E-10,-6.735060519920E-10 +67,55,7.947947136220E-11,8.455901673520E-09 +67,56,-4.302483349080E-09,-4.491451261590E-09 +67,57,2.640684009420E-09,-2.330060320250E-09 +67,58,-2.211003582120E-09,-1.774751089400E-09 +67,59,8.510752486460E-10,-3.067744747100E-09 +67,60,3.330626364340E-09,5.435035060500E-10 +67,61,3.248106706490E-09,-2.862300866020E-09 +67,62,4.604754416450E-09,-5.357417880570E-09 +67,63,1.617348059880E-09,6.071722418580E-09 +67,64,2.374349314750E-10,-2.525709734480E-09 +67,65,-9.283287343970E-10,1.643900584760E-10 +67,66,6.550020060290E-10,-9.096002787960E-10 +67,67,-9.501805989530E-10,3.016070885420E-09 +68,0,4.622867674000E-10,0.000000000000E+00 +68,1,2.143896743880E-10,2.898155351290E-09 +68,2,-2.375129026330E-09,-1.436467102500E-09 +68,3,-3.566863398040E-09,1.688848506020E-09 +68,4,1.990117220430E-09,-3.001482605970E-10 +68,5,-4.598465107440E-10,1.639498084770E-10 +68,6,1.199090058940E-09,9.939181447530E-10 +68,7,1.602596213560E-10,-4.620449723940E-09 +68,8,-2.491313389460E-09,1.133586665990E-09 +68,9,1.350117864750E-09,-1.022222666290E-09 +68,10,-9.072826796310E-10,1.989410030670E-09 +68,11,4.551313132950E-09,-2.829333531760E-09 +68,12,1.623865035970E-09,4.798420048240E-10 +68,13,1.301428797720E-09,-2.972955618640E-09 +68,14,-3.999144897760E-10,-6.066133977590E-10 +68,15,3.134321014270E-09,-4.461530952230E-10 +68,16,1.112432921760E-09,-3.495240619650E-09 +68,17,-1.645167178660E-09,-1.949540098340E-10 +68,18,-6.104689519900E-10,1.234621622700E-09 +68,19,5.762693964550E-09,5.904003766810E-10 +68,20,3.725148255910E-09,-1.011230735910E-09 +68,21,-2.121240791260E-09,2.803592265400E-09 +68,22,-2.918514740270E-09,-2.556658891950E-09 +68,23,-2.989268927060E-09,5.033122992960E-10 +68,24,2.805390547640E-09,9.921069525720E-10 +68,25,-5.345627118200E-10,2.828677668000E-09 +68,26,-1.294879518750E-09,7.847130854110E-10 +68,27,-1.455803035130E-09,-1.782513316640E-10 +68,28,8.569580523230E-10,4.749305989930E-12 +68,29,1.856936851030E-09,-2.133528308260E-09 +68,30,8.515864065830E-10,1.222433401750E-09 +68,31,8.097580051050E-10,3.211260591160E-11 +68,32,-3.741512278670E-09,1.382537079450E-09 +68,33,2.955854102060E-09,-5.088675650260E-10 +68,34,-1.151140797100E-09,2.528810068500E-09 +68,35,-2.473594488520E-09,-5.719050707600E-10 +68,36,3.835360510700E-09,2.186828513740E-09 +68,37,-4.444959640190E-09,3.214092355240E-09 +68,38,-2.921195018540E-09,-3.136565869650E-09 +68,39,-2.146456632140E-09,-1.325096362170E-10 +68,40,3.746760754680E-10,-1.154702567230E-09 +68,41,4.497962144960E-09,-5.197795805560E-09 +68,42,-1.683963056520E-09,2.229305549630E-09 +68,43,1.819118595880E-09,-1.470548269980E-09 +68,44,2.283598895780E-09,3.785352301230E-09 +68,45,-1.353484110740E-09,1.928246611800E-09 +68,46,-3.559909779480E-09,-3.040041802150E-10 +68,47,-1.690263181590E-09,-1.230799462400E-09 +68,48,-9.828697684530E-10,-4.996165019790E-10 +68,49,-1.393896348830E-10,2.209981442400E-09 +68,50,-2.953255980760E-09,1.678301471090E-10 +68,51,3.839928020730E-09,-1.953415873250E-09 +68,52,-1.621579934280E-09,7.319588969000E-10 +68,53,-2.184733050670E-09,9.881845352130E-11 +68,54,-1.222794211580E-09,-7.946338239610E-11 +68,55,2.389362651600E-10,-2.977147087230E-09 +68,56,2.517965208390E-09,-5.596085214660E-09 +68,57,3.244164097770E-09,8.578655065960E-10 +68,58,1.675555464320E-09,7.916671148230E-10 +68,59,5.940183932700E-09,4.242384644880E-09 +68,60,-2.009912842870E-09,1.765035698310E-09 +68,61,9.150037577750E-11,5.245230706380E-10 +68,62,3.342982362660E-09,1.490766278720E-09 +68,63,3.513310881480E-10,-4.698385168520E-09 +68,64,-3.593723191900E-09,-3.155146821700E-09 +68,65,2.572304390550E-09,-1.700075638810E-10 +68,66,7.643768118980E-10,-1.196751140410E-09 +68,67,3.975163378110E-09,-1.251485421120E-09 +68,68,-1.242276626610E-09,1.608347229670E-09 +69,0,9.639933537860E-10,0.000000000000E+00 +69,1,1.925266062060E-09,-2.813391321300E-09 +69,2,-2.893582701700E-09,1.761522643360E-09 +69,3,3.342859320410E-09,-2.862825189080E-09 +69,4,-1.693943552650E-09,3.112943109920E-10 +69,5,2.646120493500E-09,2.394010476880E-09 +69,6,1.774821922000E-09,3.014931173930E-09 +69,7,1.444948129350E-09,-5.174193130150E-10 +69,8,-1.325283060160E-09,2.546060902640E-09 +69,9,-1.494040082650E-09,5.899918300240E-10 +69,10,3.320817971980E-10,-2.026586615120E-10 +69,11,-1.432512993070E-09,2.615264394720E-09 +69,12,9.512271120140E-10,1.195907312600E-09 +69,13,3.153562295000E-10,-5.822536352420E-09 +69,14,9.336534382360E-10,1.257808025520E-09 +69,15,4.668834417170E-11,-4.279317367640E-09 +69,16,-2.657639878040E-09,-4.217892087340E-09 +69,17,1.129007144130E-09,-3.705509203530E-09 +69,18,-6.044124384520E-10,-1.453862684460E-09 +69,19,2.465821404190E-09,2.600523021480E-09 +69,20,4.023980950050E-09,-7.759250054430E-10 +69,21,-2.062557523760E-09,3.282418387670E-09 +69,22,-3.671402852110E-09,-1.250909351290E-09 +69,23,-1.362369154240E-09,1.278004896850E-09 +69,24,2.673359703800E-09,2.662806710100E-09 +69,25,-1.607033110150E-09,-9.064177943250E-10 +69,26,-5.273926271570E-09,3.357129992670E-09 +69,27,-2.757826457380E-09,-1.211861220330E-10 +69,28,-5.979676782100E-10,-1.830945365190E-10 +69,29,-7.968564415460E-10,-9.507942576220E-10 +69,30,-8.329138953830E-10,1.395181915740E-09 +69,31,-1.149255764250E-09,-1.330336461080E-09 +69,32,4.530327125190E-10,-5.862785877980E-10 +69,33,4.337905653340E-09,1.672924264700E-10 +69,34,-1.428502473790E-09,1.870047950040E-09 +69,35,-1.996780388550E-09,1.757468893520E-09 +69,36,3.129664852880E-09,1.232587622220E-09 +69,37,-1.545505076140E-09,5.797110887170E-09 +69,38,-3.806143201020E-09,-9.735928925920E-10 +69,39,-6.152759456420E-10,-3.807565905920E-10 +69,40,-7.193092218020E-10,-3.991381035320E-09 +69,41,-1.249770417440E-09,-6.708951650550E-10 +69,42,-2.438327600130E-09,2.046305039840E-09 +69,43,-9.136984874520E-10,4.528833101270E-10 +69,44,1.894698355950E-09,-1.971864706720E-09 +69,45,1.651187180580E-09,6.091007676570E-10 +69,46,-6.483957387900E-10,-1.654501310780E-09 +69,47,1.214040973200E-09,1.655725109600E-09 +69,48,-3.415395857530E-10,9.178171977740E-10 +69,49,2.635752983680E-09,1.405689906240E-09 +69,50,-3.226015307580E-10,-7.630886168800E-10 +69,51,1.206215920650E-09,-1.515793598940E-09 +69,52,-2.246735021880E-09,5.547309523190E-10 +69,53,-3.107208769980E-09,-4.161225367500E-09 +69,54,2.933592573650E-09,-3.224424934570E-09 +69,55,2.931789037830E-09,-2.095394063520E-09 +69,56,-9.531111409640E-10,-1.550976585470E-09 +69,57,2.745033423200E-09,-1.130452163930E-09 +69,58,-3.196565288320E-09,2.197205034110E-09 +69,59,-1.672989310990E-09,-5.010995983660E-09 +69,60,-1.985625284290E-10,-1.298461210330E-09 +69,61,2.550878316440E-09,-4.693510959090E-09 +69,62,-6.223215909800E-10,-2.684621631070E-12 +69,63,2.338737147700E-09,1.972862608500E-09 +69,64,-3.524290157890E-09,2.875207866270E-09 +69,65,1.708121509700E-09,-8.334049698470E-10 +69,66,-8.313417394230E-09,7.931345562260E-10 +69,67,3.441072944010E-10,3.269945618660E-09 +69,68,-8.850300349560E-10,1.665518665040E-09 +69,69,1.704767446060E-09,-2.262357492880E-09 +70,0,-1.714828829240E-09,0.000000000000E+00 +70,1,-3.389289070670E-10,-2.884563311030E-09 +70,2,1.216039767620E-09,1.287050405700E-09 +70,3,1.483361682940E-09,-5.761446835880E-10 +70,4,2.179798539290E-09,2.185138287060E-09 +70,5,3.840145088720E-11,2.847028836310E-09 +70,6,-1.279055636330E-09,2.679402537010E-10 +70,7,-3.086659246050E-10,1.539190811630E-09 +70,8,2.523231978470E-09,-6.701502042560E-10 +70,9,-3.504911490390E-10,-7.661235213110E-10 +70,10,-3.931379577490E-10,-7.779998572610E-10 +70,11,-1.607623497000E-09,3.574369028210E-09 +70,12,7.294567771310E-10,1.813270429280E-09 +70,13,1.529214316590E-09,-9.081410166090E-10 +70,14,3.115449583740E-09,1.757523966270E-10 +70,15,-1.888231891900E-09,1.722577151420E-09 +70,16,-9.291417005590E-10,-6.556539784820E-10 +70,17,5.562361704850E-10,-6.285144359120E-12 +70,18,-3.430632530680E-09,-1.754148558460E-10 +70,19,4.901845670360E-10,-1.461520458150E-09 +70,20,-3.887441964960E-09,4.905155401980E-10 +70,21,1.494733829700E-09,-1.151482230930E-09 +70,22,-1.475520708200E-09,-1.162905908300E-09 +70,23,1.341630296010E-09,-9.370184499680E-10 +70,24,-1.354924958770E-09,2.058075989600E-09 +70,25,-1.183065895650E-09,-7.658446024510E-10 +70,26,-2.370138642260E-09,1.201083159830E-09 +70,27,-1.435298534230E-09,-5.593116418060E-10 +70,28,-2.051684943670E-09,1.470749230100E-09 +70,29,8.771265806600E-10,2.470717753350E-09 +70,30,6.970603346440E-10,1.121877148680E-09 +70,31,-1.864935886300E-09,-1.640325950790E-09 +70,32,-7.244133679480E-10,-3.038487684170E-10 +70,33,-3.682855515170E-10,1.958321776860E-10 +70,34,1.679845848500E-09,7.738712704880E-10 +70,35,-5.134450811640E-09,-3.237743274000E-09 +70,36,1.470218640390E-09,-9.605254813570E-10 +70,37,-2.489932927270E-09,1.717065855160E-09 +70,38,-9.195904342850E-10,-2.168229873220E-09 +70,39,3.771575962810E-09,-2.519123676510E-09 +70,40,-1.895595613150E-09,6.184482392630E-10 +70,41,3.950171200860E-09,1.127524564800E-09 +70,42,-1.036911301620E-09,1.601539397320E-09 +70,43,-2.808324515210E-09,-1.689775171170E-09 +70,44,1.071810188310E-09,1.139966121430E-10 +70,45,-1.564771842710E-09,2.948037718730E-09 +70,46,-2.396455811940E-09,-1.357920296830E-09 +70,47,-2.902318811020E-11,-2.442770592270E-09 +70,48,1.366151523380E-09,-2.970775585520E-09 +70,49,4.656013510100E-10,-5.536651112830E-10 +70,50,5.462376341320E-09,3.199429657240E-10 +70,51,1.395504648980E-09,2.600461106950E-09 +70,52,-2.845376462370E-10,3.671545932910E-09 +70,53,-3.550811821970E-09,4.392075899980E-10 +70,54,2.567761142050E-09,2.343224458270E-09 +70,55,-2.245184401420E-09,2.269095001110E-09 +70,56,2.522004754930E-09,2.487281067370E-09 +70,57,3.195422415400E-10,-1.493523363180E-09 +70,58,-2.901600707990E-09,-2.884708349330E-09 +70,59,5.610088494530E-09,-2.438663947610E-09 +70,60,-8.197560856990E-10,3.662911406340E-09 +70,61,1.099760910830E-09,-1.995017322810E-09 +70,62,1.438840223650E-09,2.201767867160E-09 +70,63,-2.928359437030E-09,1.744271491360E-09 +70,64,-2.069393259060E-09,1.505801742470E-09 +70,65,1.019277424350E-09,1.775623036940E-09 +70,66,-2.017472644360E-09,1.999366255320E-09 +70,67,9.153170437710E-10,-3.613468945850E-10 +70,68,-3.183171226730E-09,1.844470166990E-09 +70,69,-2.203530405520E-09,9.940130691520E-10 +70,70,-4.703751388260E-10,-6.483061378330E-10 diff --git a/benchmarks/accuracyComparison/generate_gmat_reference.py b/benchmarks/accuracyComparison/generate_gmat_reference.py new file mode 100644 index 00000000000..dbc17d3bec6 --- /dev/null +++ b/benchmarks/accuracyComparison/generate_gmat_reference.py @@ -0,0 +1,453 @@ +# +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. +# + +r""" +Generate the GMAT reference ephemerides used by the accuracy comparison. + +This is a maintainer tool and is not run by CI. It reads ``cases.json``, writes one +GMAT script (and gravity file) per case, runs ``GmatConsole``, and stores the propagated +state in ``/gmat_.csv`` (SI units, default ``data/``). + +Usage:: + + python generate_gmat_reference.py /path/to/GMAT/R2026a [case ...] [--output-dir DIR] + +Every case is propagated and reported in the ICRF axes of the DE430 SPICE kernels that Basilisk uses. GMAT's +``MJ2000Eq`` axes differ from them by the frame bias, a rotation of about 20 mas. + +Cases with Earth rotation use GMAT's built-in Earth with its default orientation model and its ``EarthICRF`` coordinate +system. Cases without Earth rotation use a user-defined planet with a constant spin axis, because the orientation of +GMAT's built-in Earth cannot be modified. GMAT's ICRF axes about this planet did not give a gravity pole consistent with +the other tools, so these cases are propagated in the ``TerraMJ2000Eq`` system. +The initial state is rotated from ICRF to MJ2000Eq with the frame bias matrix +:data:`BIAS_ICRF_TO_MJ2000`, the spin axis is the ICRF +Z axis expressed in MJ2000Eq, and the reported states are +rotated back to ICRF. + +The drag uses GMAT's ``Exponential`` atmosphere configured with ``Drag.InputFile`` as a single band from 0 km, which is +the single-scale density of ``cases.json`` (see ``writeGmatExponentialAtmosphere``), which is passed to GMAT without +conversion. The generator also writes ``gmat_density_probe.csv``: the Earth-fixed position, the altitude (``Earth.Altitude``) +and the density (``fm.AtmosDensity``, in kg/km^3, converted to kg/m^3) that GMAT reports at the points of ``density_probe``, which ``compare_with_basilisk.py`` +compares with Basilisk and Orekit before it propagates anything. Cases that GMAT cannot model +(a box spacecraft with attitude-dependent drag) do not list ``gmat`` in their ``references`` and are skipped. + +Next to the ephemerides, ``/gmat_manifest.json`` records for every case the effective configuration and its +hash, the frame, the GMAT release and build date, the checksums of the gravity coefficients and of the GMAT +ephemeris, Earth-orientation, nutation and time files (from ``gmat_startup_file.txt``) and of the ephemeris, and a +``variant`` label, which ``compare_with_basilisk.py`` validates before running Basilisk. +""" + +import argparse +import csv +import math +import re +import subprocess +import tempfile +from pathlib import Path + +import numpy as np + +from comparisonCommon import (HERE, caseDuration, caseEpoch, caseReferences, fileSha256, gravityCoefficientsRecord, + loadSpec, probePoints, requireIcrf, writeGmatCof, writeGmatExponentialAtmosphere, + writeManifestEntry, writeProbe) + +KM = 1000.0 # [m/km] +GMAT_BODY = {"sun": "Sun", "moon": "Luna"} +# Frame bias matrix of the rotation from the ICRF (GCRF) axes to the MJ2000Eq (EME2000) axes of GMAT, at J2000, from +# Orekit's GCRF to EME2000 transform. The columns are the ICRF axes in MJ2000Eq components. +BIAS_ICRF_TO_MJ2000 = np.array([[0.9999999999999942, -7.078279744199198e-08, 8.056217146976134e-08], + [7.078279477857338e-08, 0.9999999999999971, 3.3060414542221364e-08], + [-8.056217380986972e-08, -3.3060408839805523e-08, 0.9999999999999962]]) # [-] +ICRF_POLE_MJ2000 = BIAS_ICRF_TO_MJ2000[:, 2] # [-] ICRF +Z axis in MJ2000Eq components +ICRF_POLE_RA_DEG = math.degrees(math.atan2(ICRF_POLE_MJ2000[1], ICRF_POLE_MJ2000[0])) % 360.0 # [deg] +ICRF_POLE_DEC_DEG = math.degrees(math.asin(ICRF_POLE_MJ2000[2])) # [deg] + + +def gmatPath(path): + """Return a file path with forward slashes, which GMAT accepts on every platform inside its quoted script strings. + + Args: + path (Path): file path. + """ + return str(path).replace("\\", "/") + + +def gmatScript(spec, case, cofPath, reportPath, atmospherePath=None): + """Return the GMAT script text for one case. + + Args: + spec (dict): parsed ``cases.json`` content. + case (dict): the case entry. + cofPath (Path): gravity file (used when the gravity degree is positive or the case has drag). + atmospherePath (Path): exponential atmosphere table, used by the cases with drag. + reportPath (Path): GMAT report file to write. + """ + sc = spec["spacecraft"] + r0 = [x / KM for x in case["r0_m"]] # [km] + v0 = [x / KM for x in case["v0_m_s"]] # [km/s] + degree = case["gravity"]["degree"] + rotating = case["earth_rotation"] + body = "Earth" if rotating else "Terra" + cs = f"{body}ICRF" if rotating else f"{body}MJ2000Eq" # Terra: see the module docstring + if not rotating: + r0 = list(BIAS_ICRF_TO_MJ2000 @ r0) # [km] + v0 = list(BIAS_ICRF_TO_MJ2000 @ v0) # [km/s] + epoch = caseEpoch(spec, case) # "YYYY-MM-DDTHH:MM:SS.sss" + month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", + "Dec"][int(epoch[5:7]) - 1] + gmatEpoch = f"{epoch[8:10]} {month} {epoch[0:4]} {epoch[11:]}" + steps = int(round(caseDuration(spec, case) / spec["sample_period_s"])) + thirdBodies = [GMAT_BODY[b] for b in case["third_bodies"]] + + lines = [] + if not rotating: + lines += [ + # User-defined planet: the built-in Earth orientation cannot be modified in GMAT + "Create Planet Terra;", + "GMAT Terra.CentralBody = 'Sun';", + "GMAT Terra.NAIFId = 399;", + "GMAT Terra.EquatorialRadius = %.12f;" % (spec["equatorial_radius_m"] / KM), + "GMAT Terra.Flattening = %.12f;" % spec["earth_flattening"], + "GMAT Terra.Mu = %.12f;" % (spec["mu_m3_s2"] / KM**3), + "GMAT Terra.RotationDataSource = 'IAUSimplified';", + "GMAT Terra.SpinAxisRAConstant = %.12f;" % ICRF_POLE_RA_DEG, + "GMAT Terra.SpinAxisRARate = 0;", + "GMAT Terra.SpinAxisDECConstant = %.12f;" % ICRF_POLE_DEC_DEG, + "GMAT Terra.SpinAxisDECRate = 0;", + "GMAT Terra.RotationConstant = 190.147;", + "GMAT Terra.RotationRate = 360.9856235;", + "Create CoordinateSystem TerraMJ2000Eq;", + "GMAT TerraMJ2000Eq.Origin = Terra;", + "GMAT TerraMJ2000Eq.Axes = MJ2000Eq;", + ] + lines += [ + "Create Spacecraft sat;", + "sat.DateFormat = UTCGregorian;", + f"sat.Epoch = '{gmatEpoch}';", + f"sat.CoordinateSystem = {cs};", + "sat.DisplayStateType = Cartesian;", + "sat.X = %.12f;" % r0[0], + "sat.Y = %.12f;" % r0[1], + "sat.Z = %.12f;" % r0[2], + "sat.VX = %.12f;" % v0[0], + "sat.VY = %.12f;" % v0[1], + "sat.VZ = %.12f;" % v0[2], + "sat.DryMass = %.6f;" % sc["mass_kg"], + "sat.Cd = %.6f;" % sc["drag_cd"], + "sat.Cr = %.6f;" % sc["srp_cr"], + "sat.DragArea = %.6f;" % sc["drag_area_m2"], + "sat.SRPArea = %.6f;" % sc["srp_area_m2"], + "Create ForceModel fm;", + f"fm.CentralBody = {body};", + ] + if degree > 0 or case["drag"]: # GMAT supports drag only for a gravity-field primary body + lines += [ + f"fm.PrimaryBodies = {{{body}}};", + f"fm.PointMasses = {{{', '.join(thirdBodies)}}};", + f"fm.GravityField.{body}.PotentialFile = '{gmatPath(cofPath)}';", + f"fm.GravityField.{body}.Degree = {degree};", + f"fm.GravityField.{body}.Order = {case['gravity']['order']};", + f"fm.GravityField.{body}.TideModel = 'None';", + ] + else: + lines += ["fm.PrimaryBodies = {};", + f"fm.PointMasses = {{{', '.join([body] + thirdBodies)}}};"] + if case["srp"]: + lines += [ + "fm.SRP = On;", + "fm.SRP.Flux = %.6f;" % spec["srp"]["solar_flux_w_m2"], + "fm.SRP.SRPModel = Spherical;", + "fm.SRP.Nominal_Sun = %.6f;" % spec["srp"]["astronomical_unit_km"], + ] + else: + lines += ["fm.SRP = Off;"] + if case["drag"]: + lines += [ + "fm.Drag.AtmosphereModel = Exponential;", + f"fm.Drag.InputFile = '{gmatPath(atmospherePath)}';", + ] + else: + lines += ["fm.Drag = None;"] + report = "Report rf sat.ElapsedSecs " + " ".join( + f"sat.{cs}.{c}" for c in ("X", "Y", "Z", "VX", "VY", "VZ")) + ";" + lines += [ + "Create Propagator prop;", + "prop.FM = fm;", + "prop.Type = PrinceDormand78;", + "prop.InitialStepSize = 10;", + "prop.Accuracy = 1e-12;", + "prop.MinStep = 1e-6;", + "prop.MaxStep = 300;", + "prop.MaxStepAttempts = 50;", + "Create ReportFile rf;", + f"rf.Filename = '{gmatPath(reportPath)}';", + "rf.Precision = 16;", + "rf.WriteHeaders = false;", + "rf.LeftJustify = On;", + "rf.ZeroFill = Off;", + "rf.FixedWidth = false;", + "rf.Delimiter = ',';", + "rf.WriteReport = true;", + "Create Variable i;", + "BeginMissionSequence;", + ] + lines += [ + report, + f"For i = 1:{steps}", + " Propagate prop(sat) {sat.ElapsedSecs = %.1f};" % spec["sample_period_s"], + " " + report, + "EndFor;", + ] + return "\n".join(lines) + "\n" + + +def gmatProbeScript(spec, cofPath, atmospherePath, reportPath): + """Return the GMAT script that reports the altitude and the density of the exponential atmosphere at the probe points. + + The spacecraft is placed at rest in the Earth-fixed system and propagated for a millisecond with the drag force + model, after which GMAT reports its Earth-fixed position, its altitude above the Earth ellipsoid and the density of + the atmosphere at its position. + + Args: + spec (dict): parsed ``cases.json`` content. + cofPath (Path): gravity file (GMAT needs a gravity field primary body for drag). + atmospherePath (Path): exponential atmosphere table. + reportPath (Path): GMAT report file to write. + """ + epoch = spec["epoch_utc"] # "YYYY-MM-DDTHH:MM:SS.sss" + month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", + "Dec"][int(epoch[5:7]) - 1] + lines = [ + "Create Spacecraft sat;", + "sat.DateFormat = UTCGregorian;", + f"sat.Epoch = '{epoch[8:10]} {month} {epoch[0:4]} {epoch[11:]}';", + "sat.CoordinateSystem = EarthFixed;", + "sat.DisplayStateType = Cartesian;", + "sat.DryMass = %.6f;" % spec["spacecraft"]["mass_kg"], + "sat.DragArea = %.6f;" % spec["spacecraft"]["drag_area_m2"], + "sat.Cd = %.6f;" % spec["spacecraft"]["drag_cd"], + "Create ForceModel fm;", + "fm.CentralBody = Earth;", + "fm.PrimaryBodies = {Earth};", + "fm.PointMasses = {};", + f"fm.GravityField.Earth.PotentialFile = '{gmatPath(cofPath)}';", + "fm.GravityField.Earth.Degree = 2;", + "fm.GravityField.Earth.Order = 0;", + "fm.GravityField.Earth.TideModel = 'None';", + "fm.SRP = Off;", + "fm.Drag.AtmosphereModel = Exponential;", + f"fm.Drag.InputFile = '{gmatPath(atmospherePath)}';", + "Create Propagator prop;", + "prop.FM = fm;", + "prop.Type = PrinceDormand78;", + "prop.InitialStepSize = 0.001;", + "prop.Accuracy = 1e-12;", + "prop.MinStep = 1e-6;", + "prop.MaxStep = 300;", + "Create ReportFile rf;", + f"rf.Filename = '{gmatPath(reportPath)}';", + "rf.Precision = 16;", + "rf.WriteHeaders = false;", + "rf.LeftJustify = On;", + "rf.ZeroFill = Off;", + "rf.FixedWidth = false;", + "rf.Delimiter = ',';", + "rf.WriteReport = true;", + "BeginMissionSequence;", + ] + for x, y, z, _ in probePoints(spec): + lines += [ + "sat.X = %.12f;" % (x / KM), + "sat.Y = %.12f;" % (y / KM), + "sat.Z = %.12f;" % (z / KM), + "sat.VX = 0;", + "sat.VY = 0;", + "sat.VZ = 0;", + "Propagate prop(sat) {sat.ElapsedSecs = 0.001};", + "Report rf sat.EarthFixed.X sat.EarthFixed.Y sat.EarthFixed.Z sat.Earth.Altitude sat.fm.AtmosDensity;", + ] + return "\n".join(lines) + "\n" + + +# gmat_startup_file.txt keys of the ephemeris, Earth-orientation, nutation and time files that GMAT reads +GMAT_DATA_KEYS = ("PLANETARY_SPK_FILE", "DE405_FILE", "DE421_FILE", "DE424_FILE", "EOP_FILE", "NUTATION_COEFF_FILE", + "PLANETARY_PCK_FILE", "EARTH_LATEST_PCK_FILE", "EARTH_PCK_PREDICTED_FILE", "EARTH_PCK_CURRENT_FILE", + "LUNA_PCK_CURRENT_FILE", "LUNA_FRAME_KERNEL_FILE", "LEAP_SECS_FILE", "LSK_FILE") + + +def gmatConsole(gmatRoot): + """Return the ``GmatConsole`` executable of a GMAT installation. + + Args: + gmatRoot (Path): GMAT installation folder. + """ + binDir = gmatRoot / "bin" + windows = binDir / "GmatConsole.exe" + console = windows if windows.is_file() else binDir / "GmatConsole" + if not console.is_file(): + raise RuntimeError(f"No GmatConsole executable in {binDir}. The console application is not part of every " + "GMAT distribution (for example, it is missing in the Windows R2020a and R2022a zips).") + return console + + +def gmatRelease(gmatRoot): + """Return the release (e.g. ``R2026a``) of a GMAT installation, or ``None`` if it cannot be found. + + The release is read from the file names of the Linux libraries (``libGmatBase.so.``) and of the macOS + application (``GMAT-R``). The Windows distribution has no versioned file names, so the ``README.txt`` + at the root of every distribution, which states ``Version `` in its copyright notice, is used last. + + Args: + gmatRoot (Path): GMAT installation folder. + """ + binDir = gmatRoot / "bin" + names = [p.name for p in sorted(binDir.glob("libGmatBase.so.*")) + sorted(binDir.glob("GMAT-R*"))] + release = re.search(r"R\d{4}[a-z]", " ".join(names)) + if release is None: + readme = gmatRoot / "README.txt" + if readme.is_file(): + release = re.search(r"Version\s+(R\d{4}[a-z])", readme.read_text(encoding="utf-8", errors="replace")) + return release.group(1) if release else None + return None + return release.group(0) + + +def gmatVersion(gmatRoot, console): + """Return the release and the build date of a GMAT installation, independent of the name of its folder. + + The release is taken from :func:`gmatRelease`, the build date is printed in the banner of the console. + + Args: + gmatRoot (Path): GMAT installation folder. + console (Path): ``GmatConsole`` executable. + """ + banner = subprocess.run([str(console), "--help"], cwd=console.parent, capture_output=True, text=True, check=False, + stdin=subprocess.DEVNULL, timeout=120).stdout + build = re.search(r"Build Date:\s*(.+)", banner) + release = gmatRelease(gmatRoot) + if build is None or release is None: + raise RuntimeError(f"Cannot determine the GMAT release and build date of {gmatRoot}.") + return f"GMAT {release}, build {build.group(1).strip()}" + + +def gmatExternalData(gmatRoot, spec): + """Return the identifier and SHA-256 hash of the gravity coefficients and of the GMAT ephemeris and + Earth-orientation files listed in ``gmat_startup_file.txt``. + + Args: + gmatRoot (Path): GMAT installation folder. + spec (dict): parsed ``cases.json`` content. + """ + binDir = gmatRoot / "bin" + startup = {} + for line in (binDir / "gmat_startup_file.txt").read_text().splitlines(): + key, sep, value = line.partition("=") + if sep and not line.lstrip().startswith("#"): + startup[key.strip()] = value.strip() + + def resolve(value): + """Expand the ``NAME/`` path variables of the startup file.""" + head, _, tail = value.partition("/") + return resolve(startup[head] + "/" + tail) if head in startup else value + + data = {"gravity_coefficients": gravityCoefficientsRecord(spec)} + for key in GMAT_DATA_KEYS: + if key in startup: + path = (binDir / resolve(startup[key])).resolve() + if path.is_file(): + data[key.lower()] = {"id": path.name, "sha256": fileSha256(path)} + missing = [k for k in ("DE405_FILE", "EOP_FILE", "LEAP_SECS_FILE") if k.lower() not in data] + if missing: + raise RuntimeError(f"GMAT data files {missing} listed in gmat_startup_file.txt were not found.") + return data + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument("gmatRoot", type=Path, help="GMAT installation folder (contains bin/)") + parser.add_argument("cases", nargs="*", help="case names to generate (default: all)") + parser.add_argument("--output-dir", type=Path, default=HERE / "data", + help="folder where the reference ephemerides are written (default: data next to this script)") + args = parser.parse_args() + + spec = loadSpec() + requireIcrf(spec, "gmat") + outDir = args.output_dir + outDir.mkdir(parents=True, exist_ok=True) + console = gmatConsole(args.gmatRoot) + + toolVersion = gmatVersion(args.gmatRoot, console) + externalData = gmatExternalData(args.gmatRoot, spec) + options = {"propagator": "PrinceDormand78", "initial_step_s": 10.0, "accuracy": 1.0e-12, "min_step_s": 1.0e-6, + "max_step_s": 300.0, "srp_model": "Spherical", "third_body_model": "point mass", + "atmosphere": "Exponential, single band from 0 km"} + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + probeCof, probeAtmosphere = tmp / "probe.cof", tmp / "probe_atmosphere.txt" + writeGmatCof(probeCof, spec, 2, 0) + writeGmatExponentialAtmosphere(probeAtmosphere, spec) + probeReport, probeScript = tmp / "probe.txt", tmp / "probe.script" + probeScript.write_text(gmatProbeScript(spec, probeCof, probeAtmosphere, probeReport)) + subprocess.run([str(console), "--run", str(probeScript), "--exit"], cwd=console.parent, check=True) + probeRows = [] + for line in probeReport.read_text().splitlines(): + if line.strip(): + x, y, z, altitude, density = (float(v) for v in line.split(",")) + probeRows.append((x * KM, y * KM, z * KM, altitude * KM, density / KM**3)) # [m] x3, [m], [kg/m^3] + writeProbe(outDir, "gmat", spec, probeRows, toolVersion, + {"atmosphere": "Exponential, single band from 0 km", "probe_step_s": 0.001}) + print(f"density probe: {len(probeRows)} points") + + for name, case in spec["cases"].items(): + if args.cases and name not in args.cases: + continue + if "gmat" not in caseReferences(case): + continue + cof = tmp / f"{name}.cof" + if case["gravity"]["degree"] > 0 or case["drag"]: + writeGmatCof(cof, spec, max(case["gravity"]["degree"], 2), case["gravity"]["order"]) + atmosphere = tmp / f"{name}_atmosphere.txt" + writeGmatExponentialAtmosphere(atmosphere, spec) + report = tmp / f"{name}.txt" + script = tmp / f"{name}.script" + script.write_text(gmatScript(spec, case, cof, report, atmosphere)) + subprocess.run([str(console), "--run", str(script), "--exit"], + cwd=console.parent, check=True) + rows = [[float(v) for v in line.split(",")] + for line in report.read_text().splitlines() if line.strip()] + csvPath = outDir / f"gmat_{name}.csv" + with open(csvPath, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["t_s", "x_m", "y_m", "z_m", "vx_m_s", "vy_m_s", "vz_m_s"]) + for k, row in enumerate(rows): + # GMAT stops at ElapsedSecs within ~1e-6 s of the request. Shift the state + # back to the nominal sample time with its own velocity (first order). + tNominal = k * spec["sample_period_s"] # [s] + dt = row[0] - tNominal # [s] + r = np.array([(x - v * dt) * KM for x, v in zip(row[1:4], row[4:7])]) # [m] + v = np.array(row[4:7]) * KM # [m/s] + if not case["earth_rotation"]: # propagated in MJ2000Eq, reported in ICRF + r, v = BIAS_ICRF_TO_MJ2000.T @ r, BIAS_ICRF_TO_MJ2000.T @ v + writer.writerow([f"{tNominal:.1f}"] + [f"{c:.9f}" for c in r] + [f"{c:.9f}" for c in v]) + writeManifestEntry(outDir, "gmat", spec, name, csvPath, spec["inertial_frame"], toolVersion, options, + externalData) + print(f"{name}: {len(rows)} samples") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/accuracyComparison/generate_orekit_reference.py b/benchmarks/accuracyComparison/generate_orekit_reference.py new file mode 100644 index 00000000000..76475df5616 --- /dev/null +++ b/benchmarks/accuracyComparison/generate_orekit_reference.py @@ -0,0 +1,270 @@ +# +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. +# + +r""" +Generate the Orekit reference ephemerides used by the accuracy comparison. + +This is a maintainer tool and is not run by CI. It reads ``cases.json``, propagates +each case with Orekit's numerical propagator, and stores the state in +``/orekit_.csv`` (SI units, default ``data/``). + +Usage:: + + python generate_orekit_reference.py /path/to/orekit-data.zip [case ...] [--output-dir DIR] + [--oblate-shadow] [--max-step SECONDS] + +It requires the ``orekit_jpype`` package, version 13 or newer, and a Java runtime. Every case is propagated and +written in the GCRF frame, which has the ICRF axes of the DE430 SPICE kernels that Basilisk uses. Cases without +Earth rotation evaluate the gravity field in the GCRF frame, so the Earth pole is along inertial +Z. +Cases with Earth rotation evaluate it in ITRF (IERS 2010 conventions). The orekit-data folder +can be fetched with ``orekit_jpype.pyhelpers.download_orekit_data_curdir()``. + +The radiation pressure uses a conical Earth shadow. By default the occulting body is a sphere of the equatorial radius, as +in Basilisk and GMAT, and ``--oblate-shadow`` uses Orekit's oblate Earth instead. The shadow transitions and the facets +of a box that turn in and out of view make the force non-smooth, which the adaptive integrator resolves only with a small +maximum step; the cases set it with ``orekit_max_step_s`` (``--max-step`` overrides it, default 300 s). + +The drag uses Orekit's ``SimpleExponentialAtmosphere`` with the density at zero altitude and the scale height of the +``exponential_atmosphere`` entry of ``cases.json``, evaluated at the altitude above the oblate Earth. The generator also +writes ``orekit_density_probe.csv``: the altitude and the density that Orekit computes at the points of ``density_probe``, +which ``compare_with_basilisk.py`` compares with Basilisk and GMAT before it propagates anything. + +Next to the ephemerides, ``/orekit_manifest.json`` records for every case the effective configuration and +its hash, the frame, the generator options, the tool version, the checksums of the external data and of the ephemeris, +and a ``variant`` label. A reference generated with ``--oblate-shadow`` or ``--max-step`` is labeled as such, and +``compare_with_basilisk.py`` refuses it unless it is asked for explicitly. + +Cases with ``"body": "box"`` use Orekit's ``BoxAndSolarArraySpacecraft`` without solar array and a ``FixedRate`` attitude, so the +drag and the radiation pressure depend on the attitude. +""" + +import argparse +import csv +import tempfile +from importlib import metadata +from pathlib import Path + +from comparisonCommon import (DEFAULT_VARIANT, HERE, caseDuration, caseEpoch, fileSha256, gravityCoefficientsRecord, + loadSpec, mrp2dcm, probePoints, requireIcrf, writeManifestEntry, writeOrekitGfc, writeProbe) + +SPEED_OF_LIGHT = 299792458.0 # [m/s] +MIN_OREKIT_MAJOR = 13 # [-] oldest Orekit major release with the API used here (e.g. SpacecraftState.withMass) + + +def requireOrekit(version): + """Raise if the installed ``orekit_jpype`` is older than the Orekit release that this generator supports. + + The ``orekit_jpype`` version starts with the version of Orekit that it packages. + + Args: + version (str): version of the installed ``orekit_jpype`` package, for example ``"13.1.8.0"``. + """ + if int(version.split(".")[0]) < MIN_OREKIT_MAJOR: + raise RuntimeError(f"Orekit {MIN_OREKIT_MAJOR} or newer is required, but orekit_jpype {version} is installed.") + + +def jarPath(location, fileClass): + """Return the filesystem path of a jar from the URL of its code source. + + ``URL.getPath()`` keeps the URL escaping and does not give a native Windows path, so the URL is + converted through ``java.io.File``, whose path is then explicitly converted to a Python string. + + Args: + location (java.net.URL): code source location of the jar. + fileClass (type): ``java.io.File`` class. + """ + return Path(str(fileClass(location.toURI()).getPath())) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument("orekitData", type=Path, help="orekit-data.zip or folder") + parser.add_argument("cases", nargs="*", help="case names to generate (default: all)") + parser.add_argument("--output-dir", type=Path, default=HERE / "data", + help="folder where the reference ephemerides are written (default: data next to this script)") + parser.add_argument("--oblate-shadow", action="store_true", + help="use the oblate Earth as the occulting body of the radiation pressure (default: a sphere " + "of the equatorial radius, as in Basilisk and GMAT)") + parser.add_argument("--max-step", type=float, + help="maximum integrator step in seconds, overriding the case value (default: 300 s)") + args = parser.parse_args() + + requireOrekit(metadata.version("orekit_jpype")) + import orekit_jpype # imported here so that the helper functions of this module need no Java + orekit_jpype.initVM() + from orekit_jpype.pyhelpers import setup_orekit_curdir + setup_orekit_curdir(str(args.orekitData)) + + from jpype import JArray, JClass, JDouble + from java.io import File + from org.hipparchus.geometry.euclidean.threed import Vector3D + from org.hipparchus.ode.nonstiff import DormandPrince853Integrator + from org.orekit.bodies import CelestialBodyFactory, OneAxisEllipsoid + from org.orekit.data import DataContext, DirectoryCrawler + from org.orekit.attitudes import Attitude, FixedRate + from org.orekit.forces import BoxAndSolarArraySpacecraft + from org.orekit.forces.drag import DragForce, IsotropicDrag + from org.orekit.forces.gravity import (HolmesFeatherstoneAttractionModel, NewtonianAttraction, + ThirdBodyAttraction) + from org.orekit.forces.gravity.potential import GravityFieldFactory, ICGEMFormatReader + from org.orekit.forces.radiation import IsotropicRadiationSingleCoefficient, SolarRadiationPressure + from org.orekit.frames import FramesFactory + from org.hipparchus.geometry.euclidean.threed import Rotation + from org.orekit.models.earth.atmosphere import SimpleExponentialAtmosphere + from org.orekit.orbits import CartesianOrbit, OrbitType + from org.orekit.propagation import SpacecraftState + from org.orekit.propagation.numerical import NumericalPropagator + from org.orekit.time import AbsoluteDate, TimeScalesFactory + from org.orekit.utils import IERSConventions, PVCoordinates + + spec = loadSpec() + requireIcrf(spec, "orekit") + sc = spec["spacecraft"] + outDir = args.output_dir + outDir.mkdir(parents=True, exist_ok=True) + mu = spec["mu_m3_s2"] # [m^3/s^2] + ae = spec["equatorial_radius_m"] # [m] + gcrf = FramesFactory.getGCRF() # ICRF axes, as the J2000 frame of the DE430 SPICE kernels used by Basilisk + itrf = FramesFactory.getITRF(IERSConventions.IERS_2010, True) + utc = TimeScalesFactory.getUTC() + + def utcDate(e): + """Return the Orekit date of the UTC string ``"YYYY-MM-DDTHH:MM:SS.sss"``.""" + return AbsoluteDate(int(e[0:4]), int(e[5:7]), int(e[8:10]), int(e[11:13]), int(e[14:16]), + float(e[17:]), utc) + + earthShape = OneAxisEllipsoid(ae, spec["earth_flattening"], itrf) + shadowShape = OneAxisEllipsoid(ae, 0.0, itrf) # spherical occulting body, as in Basilisk and GMAT + sun = CelestialBodyFactory.getSun() + bodies = {"sun": sun, "moon": CelestialBodyFactory.getMoon()} + + expAtm = spec["exponential_atmosphere"] + def jarRecord(javaClass): + """Return the file name and SHA-256 hash of the jar that provides a Java class.""" + jar = jarPath(JClass(javaClass).class_.getProtectionDomain().getCodeSource().getLocation(), File) + return {"id": jar.name, "sha256": fileSha256(jar)} + + orekitJar = jarRecord("org.orekit.frames.FramesFactory") + hipparchusJar = jarRecord("org.hipparchus.ode.nonstiff.DormandPrince853Integrator") + toolVersion = (f"Orekit {orekitJar['id']}, Hipparchus {hipparchusJar['id']}, " + f"orekit_jpype {metadata.version('orekit_jpype')}") + externalData = { + "orekit_data": {"id": args.orekitData.name, "sha256": fileSha256(args.orekitData)}, + "orekit_jar": orekitJar, + "hipparchus_jar": hipparchusJar, + "gravity_coefficients": gravityCoefficientsRecord(spec), + } + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + DataContext.getDefault().getDataProvidersManager().addProvider( + DirectoryCrawler(File(str(tmp)))) + + probeEpoch = utcDate(spec["epoch_utc"]) + probeAtmosphere = SimpleExponentialAtmosphere( + earthShape, expAtm["density_at_zero_altitude_kg_m3"], 0.0, expAtm["scale_height_m"]) + probeRows = [] + for x, y, z, _ in probePoints(spec): # Earth-fixed positions + position = Vector3D(float(x), float(y), float(z)) + altitude = earthShape.transform(position, itrf, probeEpoch).getAltitude() # [m] + probeRows.append((x, y, z, altitude, probeAtmosphere.getDensity(probeEpoch, position, itrf))) + writeProbe(outDir, "orekit", spec, probeRows, toolVersion, {"atmosphere": "SimpleExponentialAtmosphere"}) + print(f"density probe: {len(probeRows)} points") + + for name, case in spec["cases"].items(): + if args.cases and name not in args.cases: + continue + degree, order = case["gravity"]["degree"], case["gravity"]["order"] + epoch = utcDate(caseEpoch(spec, case)) + steps = int(round(caseDuration(spec, case) / spec["sample_period_s"])) + box = None + if case.get("body", "cannonball") == "box": + size, coeffs = spec["box"]["size_m"], spec["box"] + box = BoxAndSolarArraySpacecraft( + float(size[0]), float(size[1]), float(size[2]), sun, 0.0, Vector3D(0.0, 1.0, 0.0), + float(coeffs["drag_cd"]), 0.0, float(coeffs["absorption"]), float(coeffs["specular_reflection"])) + gravityFrame = itrf if case["earth_rotation"] else gcrf + + pv = PVCoordinates(Vector3D(*case["r0_m"]), Vector3D(*case["v0_m_s"])) + orbit = CartesianOrbit(pv, gcrf, epoch, mu) + maxStep = args.max_step or case.get("orekit_max_step_s", 300.0) # [s] + integrator = DormandPrince853Integrator(1.0e-3, maxStep, 1.0e-10, 1.0e-13) + integrator.setInitialStepSize(10.0) # [s] + propagator = NumericalPropagator(integrator) + propagator.setOrbitType(OrbitType.CARTESIAN) + + if degree > 0: + gfc = f"{name}.gfc" + writeOrekitGfc(tmp / gfc, spec, degree, order) + GravityFieldFactory.clearPotentialCoefficientsReaders() + GravityFieldFactory.addPotentialCoefficientsReader(ICGEMFormatReader(gfc, True)) + provider = GravityFieldFactory.getNormalizedProvider(degree, order) + propagator.addForceModel(HolmesFeatherstoneAttractionModel(gravityFrame, provider)) + else: + propagator.addForceModel(NewtonianAttraction(mu)) + for bodyName in case["third_bodies"]: + propagator.addForceModel(ThirdBodyAttraction(bodies[bodyName])) + if case["srp"]: + pRef = spec["srp"]["solar_flux_w_m2"] / SPEED_OF_LIGHT # [N/m^2] + dRef = spec["srp"]["astronomical_unit_km"] * 1000.0 # [m] + propagator.addForceModel(SolarRadiationPressure( + dRef, pRef, sun, earthShape if args.oblate_shadow else shadowShape, + box if box is not None else + IsotropicRadiationSingleCoefficient(sc["srp_area_m2"], sc["srp_cr"]))) + if case["drag"]: + atmosphere = SimpleExponentialAtmosphere( + earthShape, expAtm["density_at_zero_altitude_kg_m3"], 0.0, expAtm["scale_height_m"]) + propagator.addForceModel(DragForce( + atmosphere, box if box is not None else IsotropicDrag(sc["drag_area_m2"], sc["drag_cd"]))) + if box is not None: + att = case["attitude"] + dcm = mrp2dcm(att["sigma_BN"]) # [BN], maps inertial components to body components + rotation = Rotation(JArray(JArray(JDouble))([[float(x) for x in row] for row in dcm]), 1.0e-10) + spin = Vector3D(*[float(w) for w in att["omega_BN_B_rad_s"]]) # [rad/s] in the body frame + propagator.setAttitudeProvider(FixedRate(Attitude(epoch, gcrf, rotation, spin, Vector3D.ZERO))) + + state = SpacecraftState(orbit).withMass(sc["mass_kg"]) + propagator.setInitialState(state) + csvPath = outDir / f"orekit_{name}.csv" + with open(csvPath, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["t_s", "x_m", "y_m", "z_m", "vx_m_s", "vy_m_s", "vz_m_s"]) + for k in range(steps + 1): + t = k * spec["sample_period_s"] # [s] + if k > 0: + # restart from the previous sample to avoid re-propagating from t = 0 + propagator.resetInitialState(state) + state = propagator.propagate(epoch.shiftedBy(float(t))) + pvOut = state.getPVCoordinates(gcrf) + p, v = pvOut.getPosition(), pvOut.getVelocity() + writer.writerow([f"{t:.1f}"] + [f"{c:.9f}" for c in + (p.getX(), p.getY(), p.getZ(), v.getX(), v.getY(), v.getZ())]) + options = {"oblate_shadow": args.oblate_shadow, "max_step_s": maxStep, + "max_step_overridden": args.max_step is not None, + "integrator": {"type": "DormandPrince853", "min_step_s": 1.0e-3, + "abs_tolerance_m": 1.0e-10, "rel_tolerance": 1.0e-13, + "initial_step_s": 10.0}} + variants = ((["oblate_shadow"] if args.oblate_shadow else []) + + ([f"max_step_{args.max_step:g}s"] if args.max_step is not None else [])) + writeManifestEntry(outDir, "orekit", spec, name, csvPath, spec["inertial_frame"], toolVersion, options, + externalData, "+".join(variants) if variants else DEFAULT_VARIANT) + print(f"{name}: {steps + 1} samples") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/tests/test_accuracy_comparison_manifest.py b/benchmarks/tests/test_accuracy_comparison_manifest.py new file mode 100644 index 00000000000..bfa8785fc36 --- /dev/null +++ b/benchmarks/tests/test_accuracy_comparison_manifest.py @@ -0,0 +1,375 @@ +# +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. +# + +"""Tests of the reference manifest of the accuracy comparison. They need neither GMAT, Orekit nor Basilisk.""" + +import copy +import sys +from pathlib import Path + +import numpy as np +import pytest + +ACCURACY_DIR = Path(__file__).resolve().parents[1] / "accuracyComparison" +sys.path.insert(0, str(ACCURACY_DIR)) + +import comparisonCommon as common # noqa: E402 + +CASE = "leo_twobody" + + +def _external(spec, tool="orekit"): + """Return external-data records with every identifier that a reference of ``tool`` must carry.""" + return {key: {"id": key, "sha256": "0" * 64} for key in common.REQUIRED_EXTERNAL_DATA[tool]} | { + "gravity_coefficients": common.gravityCoefficientsRecord(spec)} + + +def _write(folder, spec, name=CASE, tool="orekit", version="Orekit test", variant=common.DEFAULT_VARIANT, options=None): + """Write a reference ephemeris and its manifest entry; return the file path.""" + csv = folder / f"{tool}_{name}.csv" + csv.write_text("t_s,x_m,y_m,z_m,vx_m_s,vy_m_s,vz_m_s\n0.0,1,2,3,4,5,6\n") + common.writeManifestEntry(folder, tool, spec, name, csv, spec["inertial_frame"], version, options or {}, + _external(spec, tool), variant) + return csv + + +@pytest.fixture +def reference(tmp_path): + """Write a reference ephemeris with a manifest entry for the current definition of the case.""" + spec = common.loadSpec() + return spec, tmp_path, _write(tmp_path, spec) + + +def test_matching_reference_is_accepted(reference): + """Verify a reference generated for the current case definition passes the validation.""" + spec, folder, csv = reference + entry = common.validateManifestEntry(folder, "orekit", spec, CASE, csv) + assert entry["variant"] == common.DEFAULT_VARIANT + assert entry["frame"] == "ICRF" + + +def test_changed_gravity_coefficients_are_rejected(reference, tmp_path, monkeypatch): + """Verify that changing a coefficient without changing the file name or cases.json invalidates the reference.""" + spec, folder, csv = reference + source = common.HERE / spec["gravity_coefficients_file"] + copy_dir = tmp_path / "current_input" + (copy_dir / source.parent.relative_to(common.HERE)).mkdir(parents=True) + lines = source.read_text().splitlines() + n, m, c20, s20 = lines[1].split(",") # first data row: n = 2, m = 0 + assert (n, m) == ("2", "0") + c20 = repr(float(c20) * 1.01) # [-] 1 % change of the normalized C20 + lines[1] = ",".join((n, m, c20, s20)) + (copy_dir / spec["gravity_coefficients_file"]).write_text("\n".join(lines) + "\n") + monkeypatch.setattr(common, "HERE", copy_dir) # same relative path, different content + with pytest.raises(ValueError, match="gravity_coefficients"): + common.validateManifestEntry(folder, "orekit", spec, CASE, csv) + + +def test_missing_gravity_checksum_is_rejected(tmp_path): + """Verify that a manifest entry without the gravity coefficient checksum is not accepted.""" + spec = common.loadSpec() + csv = tmp_path / f"orekit_{CASE}.csv" + csv.write_text("t_s,x_m\n0.0,1\n") + common.writeManifestEntry(tmp_path, "orekit", spec, CASE, csv, spec["inertial_frame"], "Orekit test", {}, {}) + with pytest.raises(ValueError, match="gravity_coefficients"): + common.validateManifestEntry(tmp_path, "orekit", spec, CASE, csv) + + +def test_changed_epoch_is_rejected(reference): + """Verify that changing the epoch in cases.json is detected although the elapsed sample times are unchanged.""" + spec, folder, csv = reference + changed = copy.deepcopy(spec) + changed["cases"][CASE]["epoch_utc"] = "2026-06-01T00:00:00.000" + with pytest.raises(ValueError, match="config_hash"): + common.validateManifestEntry(folder, "orekit", changed, CASE, csv) + + +def test_changed_global_epoch_is_rejected(reference): + """Verify that a change of the global epoch invalidates the cases that inherit it.""" + spec, folder, csv = reference + changed = copy.deepcopy(spec) + changed["epoch_utc"] = "2026-06-01T00:00:00.000" + with pytest.raises(ValueError, match="config_hash"): + common.validateManifestEntry(folder, "orekit", changed, CASE, csv) + + +def test_basilisk_time_step_does_not_invalidate_the_reference(reference): + """Verify that changing the Basilisk task period, which the references do not depend on, is accepted.""" + spec, folder, csv = reference + changed = copy.deepcopy(spec) + changed["cases"][CASE]["basilisk_step_s"] = 0.5 + changed["cases"][CASE]["references"] = ["orekit"] # the list of tools does not change a reference either + assert common.validateManifestEntry(folder, "orekit", changed, CASE, csv)["variant"] == common.DEFAULT_VARIANT + + +def test_alternative_variant_is_rejected_unless_requested(tmp_path): + """Verify a reference generated with an alternative configuration is not accepted as the default one.""" + spec = common.loadSpec() + csv = _write(tmp_path, spec, variant="oblate_shadow", options={"oblate_shadow": True}) + with pytest.raises(ValueError, match="variant"): + common.validateManifestEntry(tmp_path, "orekit", spec, CASE, csv) + entry = common.validateManifestEntry(tmp_path, "orekit", spec, CASE, csv, "oblate_shadow") + assert entry["variant"] == "oblate_shadow" + + +def test_modified_csv_is_rejected(reference): + """Verify a reference file that was changed after generation does not match its recorded checksum.""" + spec, folder, csv = reference + csv.write_text(csv.read_text() + "3600.0,1,2,3,4,5,6\n") + with pytest.raises(ValueError, match="csv_sha256"): + common.validateManifestEntry(folder, "orekit", spec, CASE, csv) + + +def test_wrong_frame_is_rejected(tmp_path): + """Verify a reference expressed in another inertial frame is rejected.""" + spec = common.loadSpec() + csv = tmp_path / f"orekit_{CASE}.csv" + csv.write_text("t_s,x_m\n0.0,1\n") + common.writeManifestEntry(tmp_path, "orekit", spec, CASE, csv, "EME2000", "Orekit test", {}, _external(spec)) + with pytest.raises(ValueError, match="frame"): + common.validateManifestEntry(tmp_path, "orekit", spec, CASE, csv) + + +def test_missing_manifest_is_rejected(tmp_path): + """Verify a reference without a manifest is rejected.""" + spec = common.loadSpec() + csv = tmp_path / f"orekit_{CASE}.csv" + csv.write_text("t_s,x_m\n0.0,1\n") + with pytest.raises(ValueError, match="manifest"): + common.validateManifestEntry(tmp_path, "orekit", spec, CASE, csv) + + +def _edit(folder, edit): + """Apply ``edit`` to the manifest of the Orekit references in ``folder``.""" + path = common.manifestPath(folder, "orekit") + manifest = common.json.loads(path.read_text()) + edit(manifest) + path.write_text(common.json.dumps(manifest)) + + +def test_missing_external_data_identifiers_are_rejected(tmp_path): + """Verify a reference that records only the gravity file, not the tool data or build, is not accepted.""" + spec = common.loadSpec() + csv = _write(tmp_path, spec) + _edit(tmp_path, lambda m: m["cases"][CASE]["external_data"].pop("orekit_jar")) + with pytest.raises(ValueError, match="orekit_jar"): + common.validateManifestEntry(tmp_path, "orekit", spec, CASE, csv) + + +def test_unknown_tool_version_is_rejected(tmp_path): + """Verify a reference without an identifiable tool build is rejected.""" + spec = common.loadSpec() + csv = _write(tmp_path, spec, version="Orekit unknown, orekit_jpype 13") + with pytest.raises(ValueError, match="tool_version"): + common.validateManifestEntry(tmp_path, "orekit", spec, CASE, csv) + + +def test_changed_generator_code_is_rejected(reference): + """Verify that a reference generated by different generator code (e.g. another constant) is rejected.""" + spec, folder, csv = reference + _edit(folder, lambda m: m["cases"][CASE].update(generator_sha256="0" * 64)) + with pytest.raises(ValueError, match="generator_sha256"): + common.validateManifestEntry(folder, "orekit", spec, CASE, csv) + + +def test_generator_fingerprint_ignores_comments_and_docstrings(): + """Verify documentation edits do not change the generator fingerprint but a changed constant does.""" + source = 'def f():\n """Doc."""\n x = 1.0 # [s] comment\n return x\n' + edited = 'def f():\n """Other doc."""\n\n x = 1.0 # [m] other\n return x\n' + constant = 'def f():\n """Doc."""\n x = 2.0 # [s] comment\n return x\n' + lines = lambda text: common._executableLines(text, common.ast.parse(text)) # noqa: E731 + assert lines(source) == lines(edited) + assert lines(source) != lines(constant) + + +def test_wrong_manifest_version_or_tool_is_rejected(reference): + """Verify a manifest of another format version, or an entry that names another tool, is rejected.""" + spec, folder, csv = reference + _edit(folder, lambda m: m["cases"][CASE].update(tool="gmat")) + with pytest.raises(ValueError, match="tool"): + common.validateManifestEntry(folder, "orekit", spec, CASE, csv) + _edit(folder, lambda m: m.update(version=99)) + with pytest.raises(ValueError, match="version"): + common.validateManifestEntry(folder, "orekit", spec, CASE, csv) + + +def test_hand_edited_config_is_rejected(reference): + """Verify an entry whose recorded configuration no longer matches its hash is rejected.""" + spec, folder, csv = reference + _edit(folder, lambda m: m["cases"][CASE]["config"].update(epoch_utc="2030-01-01T00:00:00.000")) + with pytest.raises(ValueError, match="edited by hand"): + common.validateManifestEntry(folder, "orekit", spec, CASE, csv) + + +def test_mixed_tool_builds_are_rejected(tmp_path): + """Verify references of different tool builds in one manifest are not compared together.""" + spec = common.loadSpec() + _write(tmp_path, spec, "leo_twobody", version="Orekit A") + _write(tmp_path, spec, "leo_zonal6", version="Orekit B") + manifest = common.json.loads(common.manifestPath(tmp_path, "orekit").read_text())["cases"] + with pytest.raises(ValueError, match="tool_version"): + common.checkConsistentProvenance(tmp_path, "orekit", manifest) + + +def test_mixed_external_data_is_rejected(tmp_path): + """Verify references generated with different external data sets are not compared together.""" + spec = common.loadSpec() + _write(tmp_path, spec, "leo_twobody") + _write(tmp_path, spec, "leo_zonal6") + _edit(tmp_path, lambda m: m["cases"]["leo_zonal6"]["external_data"]["orekit_data"].update(sha256="1" * 64)) + manifest = common.json.loads(common.manifestPath(tmp_path, "orekit").read_text())["cases"] + with pytest.raises(ValueError, match="external_data"): + common.checkConsistentProvenance(tmp_path, "orekit", manifest) + + +def test_per_case_default_max_step_is_accepted(tmp_path): + """Verify cases whose default maximum step differs per ``cases.json`` are accepted, an overridden one is not.""" + spec = common.loadSpec() + _write(tmp_path, spec, "leo_twobody") + _write(tmp_path, spec, "leo_zonal6") + _edit(tmp_path, lambda m: m["cases"]["leo_zonal6"]["generator_options"].update(max_step_s=2.0)) + manifest = common.json.loads(common.manifestPath(tmp_path, "orekit").read_text())["cases"] + common.checkConsistentProvenance(tmp_path, "orekit", manifest) + for entry in manifest.values(): + entry["generator_options"]["max_step_overridden"] = True + with pytest.raises(ValueError, match="generator_options"): + common.checkConsistentProvenance(tmp_path, "orekit", manifest) + + +def test_icrf_is_required_by_the_generators(): + """Verify a generator refuses a frame it does not implement instead of labeling its output with it.""" + spec = common.loadSpec() + common.requireIcrf(spec, "orekit") + with pytest.raises(ValueError, match="ICRF"): + common.requireIcrf({**spec, "inertial_frame": "EME2000"}, "gmat") + + +def test_probe_tools_follow_the_selected_drag_cases(): + """Verify only the references of the selected drag cases need a density probe.""" + spec = common.loadSpec() + boxCases = [n for n, c in spec["cases"].items() if c["drag"] and common.caseReferences(c) == ("orekit",)] + assert boxCases, "cases.json has no Orekit-only drag case" + assert common.requiredProbeTools(spec, boxCases) == ("orekit",) + assert common.requiredProbeTools(spec, ["leo_twobody"]) == () + assert common.requiredProbeTools(spec, boxCases + ["leo_drag"]) == ("gmat", "orekit") + + +def test_orekit_only_selection_needs_no_gmat_data(tmp_path): + """Verify the probe comparison of an Orekit-only drag selection runs with no GMAT probe or manifest present.""" + pytest.importorskip("Basilisk") + import compare_with_basilisk as compare + + spec = common.loadSpec() + common.writeProbe(tmp_path, "orekit", spec, _analyticProbe(spec), "Orekit test", {}) + names = [n for n, c in spec["cases"].items() if c["drag"] and common.caseReferences(c) == ("orekit",)] + compare.compareDensityProbe(spec, tmp_path, common.requiredProbeTools(spec, names)) + assert not common.probePath(tmp_path, "gmat").exists() + + +def _analyticProbe(spec, altitudeOffset=0.0, densityScale=1.0): + """Return probe rows (x, y, z, altitude, density) of an exponential atmosphere evaluated at the nominal altitude. + + Args: + spec (dict): parsed ``cases.json`` content. + altitudeOffset (float): [m] error added to the reported altitude, emulating a different altitude definition. + densityScale (float): [-] factor applied to the reported density, emulating a different model. + """ + atm = spec["exponential_atmosphere"] + rows = [] + for x, y, z, altitude in common.probePoints(spec): + density = atm["density_at_zero_altitude_kg_m3"] * np.exp(-altitude / atm["scale_height_m"]) # [kg/m^3] + rows.append((x, y, z, altitude + altitudeOffset, density * densityScale)) + return rows + + +@pytest.fixture +def probe(tmp_path): + """Write the density probe of a tool whose atmosphere equals the shared model.""" + spec = common.loadSpec() + common.writeProbe(tmp_path, "orekit", spec, _analyticProbe(spec), "Orekit test", {}) + return spec, tmp_path + + +def test_matching_probe_is_accepted(probe): + """Verify a probe generated for the current atmosphere loads and agrees with the shared model.""" + spec, folder = probe + rows = common.loadProbe(folder, "orekit", spec) + results = common.checkProbe(spec, "orekit", rows, rows[:, 4]) + assert len(results) == len(spec["density_probe"]["points_lat_lon_alt"]) + assert max(abs(r[1]) for r in results) < 1e-6 + + +def test_changed_atmosphere_invalidates_the_probe(probe): + """Verify that changing the scale height in cases.json is detected by the probe manifest.""" + spec, folder = probe + changed = copy.deepcopy(spec) + changed["exponential_atmosphere"]["scale_height_m"] *= 1.1 + with pytest.raises(ValueError, match="probe_hash"): + common.loadProbe(folder, "orekit", changed) + + +def test_modified_probe_file_is_rejected(probe): + """Verify that editing a probe file after it was generated is detected.""" + spec, folder = probe + path = common.probePath(folder, "orekit") + path.write_text(path.read_text().replace("e+", "e+0", 1)) + with pytest.raises(ValueError, match="csv_sha256"): + common.loadProbe(folder, "orekit", spec) + + +def test_probe_does_not_invalidate_the_case_references(reference): + """Verify that the probe definition is not part of the case fingerprint, so editing it keeps the ephemerides valid.""" + spec, folder, csv = reference + changed = copy.deepcopy(spec) + changed["density_probe"]["density_rel_tolerance"] = 1e-3 + common.validateManifestEntry(folder, "orekit", changed, CASE, csv) + + +def test_different_altitude_definition_is_detected(): + """Verify a tool that reports another altitude (for example a spherical one) is rejected before the density.""" + spec = common.loadSpec() + rows = np.array(_analyticProbe(spec, altitudeOffset=5.0e3)) + with pytest.raises(ValueError, match="altitude definition"): + common.checkProbe(spec, "gmat", rows, rows[:, 4]) + + +def test_different_density_model_is_detected(): + """Verify a density that differs from Basilisk by more than the tolerance is rejected.""" + spec = common.loadSpec() + rows = np.array(_analyticProbe(spec, densityScale=1.0 + 1.0e-3)) + bskDensity = np.array(_analyticProbe(spec))[:, 4] + with pytest.raises(ValueError, match="not equivalent"): + common.checkProbe(spec, "gmat", rows, bskDensity) + + +def test_probe_points_have_the_nominal_geodetic_altitude(): + """Verify the Earth-fixed probe positions are above the ellipsoid by the nominal altitude, also at the poles. + + The altitude is recovered with the fixed-point iteration of the inverse geodetic problem, independent of the + forward conversion that generated the positions.""" + spec = common.loadSpec() + a = spec["equatorial_radius_m"] # [m] + e2 = spec["earth_flattening"] * (2.0 - spec["earth_flattening"]) # [-] + for x, y, z, altitude in common.probePoints(spec): + p = np.hypot(x, y) # [m] + lat = np.arctan2(z, p * (1.0 - e2)) # [rad] + for _ in range(20): + n = a / np.sqrt(1.0 - e2 * np.sin(lat) ** 2) # [m] + h = p * np.cos(lat) + z * np.sin(lat) - a * a / n # [m] altitude, stable at the poles + lat = np.arctan2(z, p * (1.0 - e2 * n / (n + h))) # [rad] + assert h == pytest.approx(altitude, abs=1e-3) diff --git a/benchmarks/tests/test_accuracy_comparison_platform.py b/benchmarks/tests/test_accuracy_comparison_platform.py new file mode 100644 index 00000000000..2dce3ba2572 --- /dev/null +++ b/benchmarks/tests/test_accuracy_comparison_platform.py @@ -0,0 +1,253 @@ +# +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. +# + +"""Fast checks of the GMAT and Orekit reference generators for differences between machines: operating system file +layouts, path separators and escaping, line endings and tool releases. + +The GMAT ``README.txt`` files of several releases are fetched once with Pooch (cached like the Basilisk support data, +see ``BSK_SUPPORT_DATA_CACHE``) and the tests are skipped when they cannot be downloaded. Neither GMAT nor Orekit +is run. +The test of the Orekit jar needs ``orekit_jpype`` and a Java runtime, which the CI runners do not have: it is marked +``ciSkip`` so that the CI runs deselect it (a skip would be an error there) and it skips itself when they are missing. +""" + +import os +import sys +from pathlib import Path + +import pytest + +ACCURACY_DIR = Path(__file__).resolve().parents[1] / "accuracyComparison" +sys.path.insert(0, str(ACCURACY_DIR)) + +import comparisonCommon as common # noqa: E402 +import generate_gmat_reference as gmat # noqa: E402 + +# GMAT releases whose ``README.txt`` states the release in its copyright notice: file name -> SHA-256 +GMAT_READMES = { + "R2020a": "1f0960b306cdb10d36239f105f0b68ec80c90202b7498a1a01bc3a1058491966", + "R2022a": "5ad0bf193191d60fb85ef498d4cea42ef0f9532447b5557dab992149b546e471", + "R2025a": "5d0736ac2b32cad9a667ebb16f7dd0f188fdb823f74c8bf57e12e84b0abbaa5c", + "R2026a": "e3e498dbb5d23560a6ac8c6ead8563056e9b9b1f932fa4ff29ee3742ff8bd655", +} +GMAT_README_URL = "https://sourceforge.net/projects/gmat/files/GMAT/GMAT-{release}/README.txt/download" + +# Files in ``bin/`` of the distribution of each platform (the Windows files are unversioned, the Linux ones are not) +PLATFORM_LAYOUTS = { + "linux": ["GmatConsole", "libGmatBase.so", "libGmatBase.so.{release}"], + "macos": ["GmatConsole", "GMAT-{release}_Beta.app"], + "windows": ["GMAT.exe", "GmatConsole.exe", "libGmatBase.dll", "libGmatUtil.dll"], +} +README_TEXT = b"NASA Docket No. GSC-19468-1, identified as GMAT Version R2026a\n" +BANNER = "General Mission Analysis Tool\nConsole Based Version\nBuild Date: Mar 30 2026 10:09:27\n" + + +@pytest.fixture(scope="module") +def gmatReadmes(): + """Return the paths of the cached GMAT ``README.txt`` files, skipping the tests when they cannot be fetched.""" + pooch = pytest.importorskip("pooch") + cache = os.environ.get("BSK_SUPPORT_DATA_CACHE") + cachePath = Path(cache) if cache else Path(pooch.os_cache("bsk_support_data")) + # Pooch creates the folder with a check-then-create that fails when parallel xdist workers race on a cold cache + (cachePath / "GMAT").mkdir(parents=True, exist_ok=True) + fetcher = pooch.create(path=cachePath, base_url="", + retry_if_failed=2, + registry={f"GMAT/README_{r}.txt": f"sha256:{h}" for r, h in GMAT_READMES.items()}, + urls={f"GMAT/README_{r}.txt": GMAT_README_URL.format(release=r) for r in GMAT_READMES}) + try: + return {r: Path(fetcher.fetch(f"GMAT/README_{r}.txt", downloader=pooch.HTTPDownloader(timeout=30))) + for r in GMAT_READMES} + except Exception as error: # offline, mirror down, or file changed upstream + pytest.skip(f"GMAT README files unavailable: {error}") + + +def _installation(root, platform, release, readmeText): + """Create the file layout of a GMAT distribution of ``platform``; return the console path.""" + binDir = root / "bin" + binDir.mkdir(parents=True) + for name in PLATFORM_LAYOUTS[platform]: + (binDir / name.format(release=release)).touch() + (root / "README.txt").write_bytes(readmeText) + return gmat.gmatConsole(root) + + +@pytest.mark.parametrize("lineEnding", [b"\n", b"\r\n"], ids=["lf", "crlf"]) +@pytest.mark.parametrize("platform", sorted(PLATFORM_LAYOUTS)) +@pytest.mark.parametrize("release", sorted(GMAT_READMES)) +def test_gmat_release_is_detected_for_every_release_platform_and_line_ending( + tmp_path, monkeypatch, gmatReadmes, release, platform, lineEnding): + """Verify the release of each GMAT version is read from the real README for the layout of each platform.""" + readme = gmatReadmes[release].read_bytes().replace(b"\r\n", b"\n").replace(b"\n", lineEnding) + root = tmp_path / "GMAT install" # a folder with a space, as users have + console = _installation(root, platform, release, readme) + assert console.name == ("GmatConsole.exe" if platform == "windows" else "GmatConsole") + assert gmat.gmatRelease(root) == release + monkeypatch.setattr(gmat.subprocess, "run", lambda *a, **k: type("R", (), {"stdout": BANNER})()) + assert gmat.gmatVersion(root, console) == f"GMAT {release}, build Mar 30 2026 10:09:27" + + +def test_gmat_banner_without_build_date_is_rejected(tmp_path, monkeypatch): + """Verify a console banner that has no ``Build Date:`` line is rejected even when the release is known.""" + root = tmp_path / "GMAT install" + console = _installation(root, "windows", "R2026a", README_TEXT) + noDate = "General Mission Analysis Tool\nConsole Based Version\n" + monkeypatch.setattr(gmat.subprocess, "run", lambda *a, **k: type("R", (), {"stdout": noDate})()) + with pytest.raises(RuntimeError, match="build date"): + gmat.gmatVersion(root, console) + + +def test_gmat_without_release_metadata_is_rejected(tmp_path, monkeypatch): + """Verify an installation that states no release is rejected instead of given a made-up version.""" + root = tmp_path / "GMAT install" + console = _installation(root, "windows", "R2026a", README_TEXT) + (root / "README.txt").unlink() + monkeypatch.setattr(gmat.subprocess, "run", lambda *a, **k: type("R", (), {"stdout": BANNER})()) + with pytest.raises(RuntimeError, match="release"): + gmat.gmatVersion(root, console) + + +def test_gmat_distribution_without_console_is_rejected(tmp_path): + """Verify a distribution that ships only the GUI (as the Windows R2020a and R2022a zips do) gives a clear error.""" + (tmp_path / "bin").mkdir() + (tmp_path / "bin" / "GMAT.exe").touch() + with pytest.raises(RuntimeError, match="GmatConsole"): + gmat.gmatConsole(tmp_path) + + +def test_gmat_release_is_never_taken_from_an_example_file_name(tmp_path): + """Verify names such as ``Ex_R2014a_*.script`` in the distribution cannot be mistaken for the release.""" + root = tmp_path / "GMAT" + (root / "bin").mkdir(parents=True) + (root / "bin" / "GmatConsole.exe").touch() + (root / "samples").mkdir() + (root / "samples" / "Ex_R2014a_HighFidelitySRP.script").touch() + assert gmat.gmatRelease(root) is None + + +def test_gmat_external_data_is_found_with_crlf_startup_file_in_folder_with_space(tmp_path): + """Verify the data files of a startup file with Windows line endings and ``NAME/`` variables resolve here.""" + root = tmp_path / "GMAT install" + (root / "bin").mkdir(parents=True) + startup = ["DATA_PATH = ../data/", "PLANETARY_EPHEM_DE_PATH = DATA_PATH/planetary_ephem/de/", + "PLANETARY_COEFF_PATH = DATA_PATH/planetary_coeff/", "TIME_PATH = DATA_PATH/time/", + "DE405_FILE = PLANETARY_EPHEM_DE_PATH/leDE1941.405", "EOP_FILE = PLANETARY_COEFF_PATH/eopc04", + "LEAP_SECS_FILE = TIME_PATH/tai-utc.dat", "# comment"] + (root / "bin" / "gmat_startup_file.txt").write_bytes("\r\n".join(startup).encode()) + for rel in ("planetary_ephem/de/leDE1941.405", "planetary_coeff/eopc04", "time/tai-utc.dat"): + (root / "data" / rel).parent.mkdir(parents=True, exist_ok=True) + (root / "data" / rel).write_text("x") + data = gmat.gmatExternalData(root, common.loadSpec()) + assert {"de405_file", "eop_file", "leap_secs_file"} <= set(data) + assert data["de405_file"]["id"] == "leDE1941.405" + + +def test_gmat_script_paths_of_this_machine_have_no_backslashes_and_resolve(tmp_path): + """Verify the temporary file paths written into a GMAT script point to the same files on this operating system.""" + folder = tmp_path / "temp folder" + folder.mkdir() + cof, report = folder / "case.cof", folder / "case.txt" + cof.write_text("x") + spec = common.loadSpec() + case = next(c for c in (spec["cases"].values() if isinstance(spec["cases"], dict) else spec["cases"]) + if c["gravity"]["degree"] > 0) + script = gmat.gmatScript(spec, case, cof, report, cof) + assert "\\" not in script + written = [line.split("'")[1] for line in script.splitlines() if "PotentialFile" in line] + assert written and all(Path(p).resolve() == cof.resolve() for p in written) + + +class _FakeUrl: + """Stand-in for ``java.net.URL`` with the escaped path of ``getPath()`` and the URI of ``toURI()``.""" + + def __init__(self, uri): + self.uri = uri + + def getPath(self): + return self.uri.split("file:", 1)[1] + + def toURI(self): + return self.uri + + +def _fakeFile(uri): + """Stand-in for ``java.io.File(URI)``: decode the URI into the native path, as Java does.""" + from urllib.parse import unquote + path = unquote(uri.split("file:", 1)[1]) + if path[:3] in ("/C:", "/D:"): # Windows: drop the leading slash and use backslashes + path = path[1:].replace("/", "\\") + return type("File", (), {"getPath": lambda self: path})() + + +@pytest.mark.parametrize("uri, expected", [ + ("file:/home/me/orekit%20env/orekit-13.jar", "/home/me/orekit env/orekit-13.jar"), + ("file:/home/me/orekit/orekit-13.jar", "/home/me/orekit/orekit-13.jar"), + ("file:/C:/Program%20Files/orekit/orekit-13.jar", "C:\\Program Files\\orekit\\orekit-13.jar"), +]) +def test_orekit_jar_path_is_unescaped(uri, expected): + """Verify the jar location becomes a native path string, without URL escaping, for POSIX and Windows paths.""" + import generate_orekit_reference as orekit + + # Path() renders POSIX-style locations with the separators of the running platform + expectedPath = expected if expected[1:2] == ":" else str(Path(expected)) + assert str(orekit.jarPath(_FakeUrl(uri), _fakeFile)) == expectedPath + + +@pytest.mark.ciSkip # deselected in the CI runs: needs orekit_jpype and Java, which the runners do not install +def test_orekit_jar_path_of_this_machine_is_a_native_unescaped_path(tmp_path): + """Verify the jar of the installed Orekit is located, and hashed, from a folder with a space on this machine.""" + import shutil + + orekit_jpype = pytest.importorskip("orekit_jpype") + import jpype + import generate_orekit_reference as orekit + + try: + if not jpype.isJVMStarted(): + orekit_jpype.initVM() + except Exception as error: # no Java runtime + pytest.skip(f"Java VM unavailable: {error}") + jars = [j for j in Path(orekit_jpype.__file__).parent.rglob("orekit-*.jar") + if not j.stem.endswith(("javadoc", "sources"))] + if not jars: + pytest.skip("the installed orekit_jpype carries no orekit jar") + from java.io import File + from java.net import URLClassLoader + + folder = tmp_path / "orekit env" + folder.mkdir() + jar = Path(shutil.copy(jars[0], folder)) + loader = URLClassLoader([File(str(jar)).toURI().toURL()], None) # no parent: the class comes from this copy + location = loader.loadClass("org.orekit.frames.FramesFactory").getProtectionDomain().getCodeSource().getLocation() + path = orekit.jarPath(location, File) + assert "%20" not in str(path) + assert path.resolve() == jar.resolve() + assert len(common.fileSha256(path)) == 64 + assert path.name.startswith("orekit-") # the release that is recorded in the manifest + + +@pytest.mark.parametrize("version, accepted", [("12.2.1.2", False), ("12.0.2.0", False), ("13.0.1.0", True), + ("13.1.8.0", True), ("14.0.0.0", True)]) +def test_orekit_older_than_the_supported_major_release_is_rejected(version, accepted): + """Verify the Orekit generator stops with a clear error before it uses an API that older releases lack.""" + import generate_orekit_reference as orekit + + if accepted: + orekit.requireOrekit(version) + else: + with pytest.raises(RuntimeError, match="Orekit 13 or newer"): + orekit.requireOrekit(version) diff --git a/docs/source/Support/User.rst b/docs/source/Support/User.rst index 8832c00ae85..4c93678e4a8 100644 --- a/docs/source/Support/User.rst +++ b/docs/source/Support/User.rst @@ -11,3 +11,4 @@ The following support documents are for the Basilisk user who is seeking general User/FAQ User/FAQwindows + User/accuracyComparison diff --git a/docs/source/Support/User/accuracyComparison.rst b/docs/source/Support/User/accuracyComparison.rst new file mode 100644 index 00000000000..a75dfb6d474 --- /dev/null +++ b/docs/source/Support/User/accuracyComparison.rst @@ -0,0 +1,419 @@ +.. _accuracyComparison: + +Accuracy Comparison with GMAT and Orekit +======================================== + +This page documents how Basilisk's orbit propagation compares with two widely used +astrodynamics tools, `GMAT `__ and +`Orekit `__. It is intended to help users who need to show +that Basilisk results agree with an established reference before relying on them. + +.. warning:: + + This is a **rough comparison**. The three tools differ in their features and in how each force model is set up, so + the differences are not a ranking of the tools. The page is not generated automatically: the tables and figures were + produced by hand with the tool versions named below, and the page will become out of date as GMAT, Orekit and + Basilisk are released. Run times are deliberately not compared, because they depend on the features each tool + provides and on step sizes that are not equivalent. + +The comparison is reproduced by the scripts in ``benchmarks/accuracyComparison``: +:download:`compare_with_basilisk.py <../../../../benchmarks/accuracyComparison/compare_with_basilisk.py>`, +the reference generators +:download:`generate_gmat_reference.py <../../../../benchmarks/accuracyComparison/generate_gmat_reference.py>` and +:download:`generate_orekit_reference.py <../../../../benchmarks/accuracyComparison/generate_orekit_reference.py>`, +and the case definitions :download:`cases.json <../../../../benchmarks/accuracyComparison/cases.json>`. +The results on this page were produced with Orekit 13.1 and GMAT R2026a, and with Basilisk configured as described in +the tables below: the environment modules use the spacecraft state extrapolated to the middle of the step +(``setExtrapolateScStateToStepMidpoint()``, see :ref:`atmosphereBase`), and :ref:`exponentialAtmosphere` uses the +ellipsoidal altitude (``setPlanetPolarRadius()``). The GMAT and Orekit ephemerides are not part +of the repository: :ref:`accuracyComparisonReproduce` explains how to generate them and run the comparison. Each generated +ephemeris is accompanied by a manifest that records its provenance, which the comparison validates before it runs +Basilisk. The comparison is not part of the automated tests because it needs both tools installed and the run of all cases +takes several minutes. + +Method +------ + +All three tools propagate the same seventeen cases, which are defined once in +``benchmarks/accuracyComparison/cases.json``. In the first ten cases the perturbations are added one at a time, so a +disagreement can be traced to a single effect. The other seven extend the comparison to higher-degree gravity, other orbit +types and epochs, and attitude-dependent drag and radiation pressure. The drag cases use the exponential atmosphere model of each tool (:ref:`exponentialAtmosphere`, Orekit's +``SimpleExponentialAtmosphere`` and GMAT's ``Exponential``), configured with the same single-scale density (see the settings +below). GMAT's model reads its parameters from a table file, which holds one band. + +.. list-table:: + :widths: 22 78 + :header-rows: 1 + + * - Case + - Force model + * - ``leo_twobody`` + - Point-mass gravity, inclined LEO (:math:`a = 6778` km, :math:`i = 51.6^\circ`) + * - ``leo_zonal6`` + - Zonal harmonics :math:`J_2` to :math:`J_6`, same LEO, Earth pole fixed along inertial :math:`+Z` + * - ``gto_zonal6`` + - Same as ``leo_zonal6``, in a GTO (:math:`r_p = 6572` km, :math:`e = 0.73`, :math:`i = 28.5^\circ`) + * - ``leo_egm8x8`` + - EGM96 degree and order 8, rotating Earth + * - ``leo_sunmoon`` + - Point-mass gravity plus Sun and Moon third-body perturbations + * - ``leo_srp`` + - Point-mass gravity plus cannonball solar radiation pressure with Earth shadow + * - ``leo_drag`` + - Point-mass gravity plus exponential-density drag with a co-rotating atmosphere + * - ``leo_all`` + - EGM96 8x8, Sun and Moon, solar radiation pressure, and drag + * - ``gto_all`` + - EGM96 8x8, Sun and Moon, and solar radiation pressure (no drag) + * - ``geo_all`` + - Same as ``gto_all``, in a near-equatorial GEO orbit + * - ``leo_egm70`` + - EGM96 degree and order 70, rotating Earth, same LEO, 7 days + * - ``sso_drag`` + - Point-mass gravity plus drag in a 700 km sun-synchronous orbit (:math:`i = 98.19^\circ`), epoch 2026-03-20 + * - ``sso_all`` + - Same orbit as ``sso_drag`` with EGM96 20x20, Sun and Moon, solar radiation pressure, and drag + * - ``molniya_all`` + - EGM96 8x8, Sun and Moon, and solar radiation pressure in a Molniya orbit (:math:`e = 0.74`, :math:`i = 63.4^\circ`), epoch 2026-05-20 + * - ``leo_box_fixed`` + - Box spacecraft with a fixed inertial attitude, faceted drag and radiation pressure, 7 days (Orekit reference only) + * - ``leo_box_spin`` + - Same box spinning at 1.5 deg/s about a fixed body axis, 7 days (Orekit reference only) + * - ``geo_box_spin`` + - The spinning box in GEO with radiation pressure only, 7 days (Orekit reference only) + +.. list-table:: + :widths: 22 78 + :header-rows: 1 + + * - Item + - Setting + * - Duration, sampling + - 30 days from 2026-01-01 00:00:00 UTC, one sample every hour. The cases that list another epoch or duration above override these values + * - Frame + - Earth-centered inertial ICRF axes: ``GCRF`` in Orekit, ``EarthICRF`` in GMAT, and the ``J2000`` frame of the DE430 SPICE kernels in Basilisk, which is aligned with the ICRF (see the `NAIF explanation of ICRF versus J2000 `__). The initial states, the attitudes and the outputs are expressed in this frame in all tools. Orekit's ``EME2000`` and GMAT's ``MJ2000Eq`` differ from it by the frame bias, a rotation of about 20 mas (several meters per 10\ :sup:`7` m) that would otherwise appear as a difference between the tools + * - Gravity + - :math:`\mu = 3.986004415\times10^{14}` m\ :sup:`3`/s\ :sup:`2`, equatorial radius 6378136.3 m, fully normalized EGM96 coefficients, identical files for all tools + * - Earth orientation + - Zonal-only cases: pole fixed along inertial :math:`+Z`. Rotating cases: ITRF93 in Basilisk (high-precision NAIF Earth kernel and the ITRF93 frame association kernel, requested through ``spicePlanetFrames``; see :ref:`accuracyComparisonReproduce`), ITRF with IERS 2010 conventions in Orekit, GMAT's default Earth orientation + * - Third bodies + - Sun and Moon, from DE430 (Basilisk), DE421 (GMAT) and DE440 (Orekit) + * - Spacecraft + - 1000 kg, :math:`C_r = 1.5` with 10 m\ :sup:`2`, :math:`C_d = 2.2` with 10 m\ :sup:`2`. The box cases use a 1 m x 2 m x 3 m box without solar arrays, :math:`C_d = 2.2`, absorbed, specular and diffuse fractions of 0.6, 0.2 and 0.2, and the center of pressure at the center of mass, so that no torque changes the spin + * - Solar radiation pressure + - Cannonball model, flux 1361 W/m\ :sup:`2` at 1 AU (Basilisk's constant, used in all tools), shadow from the conical Earth model. The occulting body is a sphere of the equatorial radius in all tools (Orekit's default is the oblate Earth, see below) + * - Atmosphere + - Single-scale exponential density :math:`\rho = \rho_0 \exp(-h/H)` with :math:`\rho_0 = 2\times10^{-9}` kg/m\ :sup:`3` at zero altitude and :math:`H = 60` km (``exponential_atmosphere`` in ``cases.json``), in :ref:`exponentialAtmosphere`, Orekit's ``SimpleExponentialAtmosphere`` and GMAT's ``Exponential`` model (configured with ``Drag.InputFile`` as one band from 0 km). The two parameters are passed to each tool as they are, without conversion, and evaluated at the altitude above the ellipsoid of the equatorial radius. The three implementations are compared directly at nine points before any propagation (:ref:`accuracyComparisonDensityProbe`). Each tool uses its own exponential atmosphere model. The model has only two parameters, so all three tools can be given the same density law and the differences come from the propagation and not from the atmosphere + * - Integrators + - Basilisk: RKF78 with the relative tolerance :math:`10^{-4}` and the absolute tolerance :math:`10^{-8}` (the library defaults, set explicitly by the script; the tolerance is applied to the position in m, the velocity in m/s and the other states), task period 5 s (0.25 s for the cases with drag). Orekit: Dormand-Prince 8(5,3), tolerances :math:`10^{-10}` m and :math:`10^{-13}`, maximum step 300 s, 10 s with radiation pressure, and 2 s for the box cases (see below). GMAT: Prince-Dormand 7(8), accuracy :math:`10^{-12}` + +Results +------- + +.. Tables and figures below are produced by hand with compare_with_basilisk.py, see the warning at the top of the page. + Regenerate the references and all numbers together after a change of the frame, the atmosphere or the step extrapolation. + +The table lists the maximum position difference in meters over the propagation time (30 days, except ``leo_egm70`` with +7 days) between each pair of tools. The ``Basilisk - GMAT`` and ``Basilisk - Orekit`` columns are the quantity of +interest. ``GMAT - Orekit`` shows how well the two reference tools agree with each other and +is the natural yardstick for the other columns. + +.. list-table:: + :widths: 28 24 24 24 + :header-rows: 1 + + * - Case + - Basilisk - Orekit [m] + - Basilisk - GMAT [m] + - GMAT - Orekit [m] + * - ``leo_twobody`` + - 0.017 + - 0.27 + - 0.25 + * - ``leo_zonal6`` + - 0.018 + - 0.29 + - 0.27 + * - ``gto_zonal6`` + - 0.23 + - 0.35 + - 0.57 + * - ``leo_egm8x8`` + - 1.1 + - 0.59 + - 0.57 + * - ``leo_sunmoon`` + - 0.017 + - 0.24 + - 0.22 + * - ``leo_srp`` + - 0.52 + - 0.66 + - 1.2 + * - ``gto_all`` + - 0.27 + - 0.44 + - 0.24 + * - ``geo_all`` + - 0.046 + - 0.36 + - 0.32 + * - ``leo_egm70`` + - 0.48 + - 0.33 + - 0.80 + * - ``molniya_all`` + - 7.3 + - 31 + - 39 + +Without drag, all three tools agree at the meter level over a month, except for ``molniya_all`` (see below), and GMAT and +Orekit agree with each other at the same level as they agree with Basilisk. This includes solar radiation pressure with +eclipses (0.5 to 1.2 m). All tools use the same inertial frame (ICRF axes). The Orekit reference is set up like the other two +tools for the radiation pressure: + +- **Occulting body.** The conical shadow uses a sphere of the equatorial radius, as in Basilisk and GMAT. Orekit's default is the + oblate Earth, which changes the result of ``leo_srp`` by 178 m and that of ``gto_all`` by 49 m over 30 days (the Orekit + reference compared with the default one, and equally the difference to Basilisk). ``--oblate-shadow`` selects it, and the reference is then labeled as the variant ``oblate_shadow``, see + :ref:`accuracyComparisonReproduce`. +- **Integrator step.** The shadow entry and exit make the force non-smooth, and the adaptive step control of Orekit needs a + small maximum step to resolve it. The SRP cases use 10 s. With a maximum step of 300 s the difference to Basilisk in ``leo_srp`` + is 152 m, with 60 s it is 12 m, and with 10 s 0.5 m. GMAT agrees with Basilisk with its 300 s maximum step. + +The agreement does not degrade with the gravity degree: with EGM96 degree and order 70 the three tools agree to better than +1 m after 7 days. ``molniya_all`` is the exception: Basilisk is 7.3 m from Orekit and 31 m from GMAT, and GMAT is 39 m from +Orekit. This is not a Basilisk time step effect (7.3, 7.0 and 6.8 m for steps of 5, 2.5 and 1.25 s), and it is not an effect of the +GMAT step either: with a GMAT maximum step of 300 s, 10 s and 2 s the GMAT ephemeris changes by at most 0.6 m and the +difference to Orekit stays between 38.0 and 38.6 m. Its cause has not been isolated. + +The cases without Earth rotation need the same gravity pole in all tools. GMAT's built-in ICRF axes about the user-defined +planet gave a gravity pole that was not consistent with the other tools (up to 1.6 m for ``leo_zonal6`` in a month), so the +generator propagates these cases in GMAT's ``MJ2000Eq`` axes about that planet and rotates the states with the frame bias matrix, +see ``generate_gmat_reference.py``. With this, ``leo_zonal6`` agrees to 0.29 m. + +.. figure:: /_images/accuracyComparison/accuracyComparison_geo_all.svg + :align: center + + Position differences for the GEO case with all perturbations. + +.. figure:: /_images/accuracyComparison/accuracyComparison_leo_srp.svg + :align: center + + Position differences for the LEO case with solar radiation pressure only. The three tools agree to 1.2 m. + +.. figure:: /_images/accuracyComparison/accuracyComparison_molniya_all.svg + :align: center + + Position differences for the Molniya case. GMAT differs from both other tools. + +.. _accuracyComparisonDensityProbe: + +Atmosphere model check +~~~~~~~~~~~~~~~~~~~~~~ + +Giving the three tools the same two parameters does not guarantee that they evaluate the same density, for example if they +define the altitude differently. Before the first propagation with drag, the comparison therefore evaluates the atmosphere of +each tool at nine points (``density_probe`` in ``cases.json``: altitudes from 200 to 800 km, at the equator, at mid-latitudes +and near both poles). At each point the GMAT and Orekit generators report the altitude above the ellipsoid and the density that +their own tool computes (``gmat_density_probe.csv`` and ``orekit_density_probe.csv``), and ``compare_with_basilisk.py`` +evaluates :ref:`exponentialAtmosphere` at the same positions. The comparison stops if + +- the altitude reported by a tool differs from the geodetic altitude of the point by more than ``altitude_tolerance_m`` + (the altitude definition differs, for example a spherical instead of an ellipsoidal one), or +- the density of Basilisk and of a tool differ by more than ``density_rel_tolerance`` (relative; the models differ). + +Orekit and Basilisk agree to better than :math:`10^{-10}` in density at all nine points. GMAT reports an altitude up to +0.64 m lower than the geodetic altitude away from the equator, and its density differs from Basilisk by exactly +:math:`\Delta h/H`, up to :math:`1.1\times10^{-5}`. This is a difference in the altitude definition and not in the atmosphere +model, and it is the reason for the tolerance of :math:`2\times10^{-5}`. At these altitudes it changes the drag of GMAT by about +:math:`10^{-5}` relative. Since the densities agree at these points, a difference in the drag cases below comes from the +propagation and not from the atmosphere. + + +Drag +~~~~ + +The drag cases use the exponential atmosphere model of each tool, configured with the density at zero altitude and the scale +height of the settings table. The comparison therefore does not depend on how each tool sets up an empirical atmosphere +model. GMAT's ``Exponential`` model is configured with a table of one band, which gives the same density law as the other +tools at every altitude. All tools use the same drag law, +cross-section, drag coefficient, mass and co-rotating atmosphere, and the altitude above the ellipsoid (set in +Basilisk with ``setPlanetPolarRadius()``; GMAT's altitude differs by up to 0.64 m, see above). + +.. list-table:: + :widths: 28 24 24 24 + :header-rows: 1 + + * - Case + - Basilisk - Orekit [m] + - Basilisk - GMAT [m] + - GMAT - Orekit [m] + * - ``leo_drag`` + - 17 + - 63 + - 46 + * - ``leo_all`` + - 18 + - 72 + - 53 + * - ``sso_drag`` + - 0.11 + - 0.82 + - 0.70 + * - ``sso_all`` + - 0.24 + - 0.90 + - 1.1 + +In the 700 km cases all three tools agree to about 1 m. In the 400 km cases, where the drag is larger and the orbit decays +faster, Basilisk and Orekit agree to 18 m, but GMAT differs from both by 46 to 72 m although the density law is the same and the density +probe agrees to about :math:`10^{-5}`. The cause of the GMAT difference has not been isolated. It is not the GMAT integrator step: with a +maximum step of 300 s and 10 s the GMAT ephemeris of ``leo_drag`` changes by 0.9 m and the difference to Orekit stays between 45 and 46 m. + +The difference between Basilisk and Orekit is the discretization of the atmosphere and the drag in Basilisk, which are evaluated once per task +period, with the spacecraft state extrapolated to the middle of the period. The error is second order in the period. For +``leo_drag`` over 10 days the maximum position difference to Orekit is 28 m for a 1 s period, 7.1 m for 0.5 s and 1.8 m +for 0.25 s, and for ``leo_all`` over 30 days it is 303 m for 1 s and 18 m for 0.25 s. The cases with drag therefore use a 0.25 s +task period. A larger period needs far fewer steps but gives a proportionally larger difference. In the same way the box cases drop +from 8.4 m and 6.5 m with a 1 s period to 0.53 m and 0.28 m with 0.25 s, and the 700 km cases are at 0.11 m and 0.24 m. + +.. figure:: /_images/accuracyComparison/accuracyComparison_leo_drag.svg + :align: center + + Position differences for the LEO case with drag only, with a 0.25 s Basilisk task period. + +Attitude-dependent drag and radiation pressure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The box cases use :ref:`facetDragDynamicEffector` and :ref:`facetSRPDynamicEffector` in Basilisk and Orekit's +``BoxAndSolarArraySpacecraft`` (without solar array) with a ``FixedRate`` attitude. They are compared with Orekit only: +GMAT's drag model is a cannonball, and its attitude-dependent radiation pressure needs a SPAD file. + +.. list-table:: + :widths: 40 60 + :header-rows: 1 + + * - Case + - Basilisk - Orekit [m] + * - ``leo_box_fixed`` + - 0.53 + * - ``leo_box_spin`` + - 0.28 + * - ``geo_box_spin`` + - 0.0011 + +The facet models and the attitude handling of the two tools agree: the radiation pressure on the spinning box agrees to 1 mm after +7 days in GEO, and the box cases with drag to below 1 m in LEO with a 0.25 s task period. + +The facets that turn in and out of view make the force non-smooth, so the box cases use a 2 s maximum step for Orekit, as +described above for the radiation pressure. + +.. figure:: /_images/accuracyComparison/accuracyComparison_leo_box_spin.svg + :align: center + + Position difference between Basilisk and Orekit for the spinning box in LEO with drag and radiation pressure. + +.. _accuracyComparisonReproduce: + +Reproducing the Results +----------------------- + +All commands are run from ``benchmarks/accuracyComparison`` of the Basilisk repository. + +**1. Prerequisites** + +- **Orekit**: a Java runtime (a JDK 17 was used), the Python package ``orekit_jpype`` 13 or newer + (``pip install orekit-jpype``), and the ``orekit-data.zip`` file. ``orekit_jpype`` finds Java through the + ``JAVA_HOME`` environment variable, which must point to the JDK folder (on Windows, for example + ``C:\Program Files\Eclipse Adoptium\jdk-17.0.20.101-hotspot``). The data file is downloaded into the current folder + with the command below, where the Java virtual machine must be started before the helper is called, or it can be taken + from the `Orekit data repository `__: + + .. code-block:: bash + + python -c "import orekit_jpype as o; o.initVM(); from orekit_jpype.pyhelpers import download_orekit_data_curdir as d; d()" + + Without ``initVM()``, the helper fails with ``Attempt to create Java package 'java' without jvm``, and without + ``JAVA_HOME`` ``initVM()`` fails with ``No JVM shared library file found``. +- **GMAT**: an installation of GMAT R2026a that includes the console application + (``bin/GmatConsole``, or ``bin\GmatConsole.exe`` on Windows), which the Windows R2020a and R2022a zips do not. The + generator runs it from the installation folder. On Windows, download ``gmat-win-R2026a.zip`` from + `SourceForge `__ + and unzip it. The zip has no top-level folder, so extract it into a new folder (for example ``C:\tools\GMAT R2026a``). + The folder to pass to the generator is the one that contains ``bin`` directly. Messages that the Python, MATLAB or + optimizer plug-in libraries did not open are printed by the console and do not affect the generators. +- **Earth orientation kernels** (Basilisk, rotating-Earth cases): the NAIF high-precision Earth PCK + (``earth_000101_260711_260415.bpc`` was used; any ``earth_*.bpc`` that covers all case epochs and their durations, which + are 2026-01-01 to 2026-06-19, works when the name is updated in ``compare_with_basilisk.py``) from the + `NAIF PCK kernels `__, and ``earth_assoc_itrf93.tf`` from the + `NAIF FK kernels `__. Put both in one folder and pass it with + ``--kernel-dir``, or in ``data/spice`` next to the scripts. +- ``numpy`` and, for figures, ``matplotlib``. + +**2. Generate the reference ephemerides** (the ephemerides are written to ``data/``, which is ignored by git, or to the folder given with ``--output-dir``): + +.. code-block:: bash + + python generate_orekit_reference.py /path/to/orekit-data.zip + python generate_gmat_reference.py /path/to/GMAT/R2026a # the folder that contains bin/ + +Both scripts accept case names after the path to generate a subset. They also write ``orekit_manifest.json`` and +``gmat_manifest.json``, which record for every case: + +- the effective case configuration (the global settings merged with the case entry, including the epoch and the duration) and + its SHA-256 hash, +- the inertial frame (ICRF), +- the generator options and integrator settings, +- the version of the tool (the Orekit and ``orekit_jpype`` versions; for GMAT, the release and build date read from + the installation rather than from its folder name), +- the identifiers and checksums of the external data: the gravity coefficients for both tools, the Orekit data + folder or archive for Orekit, and the planetary ephemeris, Earth-orientation, nutation and leap-second files + listed in ``gmat_startup_file.txt`` for GMAT, together with the checksum of the ephemeris file; for Orekit also + the Orekit and Hipparchus jar files, +- a fingerprint of the generator code (the generator script and the shared input writers, ignoring comments and + docstrings), so that a changed constant, integrator setting or force model is detected, +- a ``variant`` label, which is ``default`` unless an alternative configuration was selected. + +Each generator also writes a density probe file (``_density_probe.csv``) whose manifest entry holds the hash of the probe points and of the atmosphere definition. + +``compare_with_basilisk.py`` validates the manifest of every reference before it runs Basilisk. A reference is +rejected, with a message that names the field, if it was generated for another epoch, initial state, force model or +frame, if it was modified after it was generated, if it was generated with different gravity coefficients from the +file that Basilisk reads, if it was generated by other generator code, if it +has no identifiable tool build or no checksum of its external data, if the references used together come from +different tool builds or data sets, if its first sample is not the initial state of the case or it holds non-finite +values, or if it has no manifest. The density probes are required only for the reference tools of the selected cases +with drag, so an Orekit-only selection needs no GMAT data. A reference generated with +``--oblate-shadow`` or ``--max-step`` has the variant ``oblate_shadow`` or ``max_step_s`` and is rejected by a +default comparison. To compare against it on purpose, give the variant with ``--orekit-variant`` (for example +``--orekit-variant oblate_shadow``); the results and the figure file name then state the variant. The generators +overwrite the ephemeris of a case of the same name, so generate the alternative into its own ``--output-dir`` and use +the same folder with ``--data-dir``. The cases without a GMAT reference (``references`` in ``cases.json``) are skipped +by the GMAT generator. ``generate_orekit_reference.py`` also accepts ``--max-step`` (the maximum integrator step, +which ``orekit_max_step_s`` of each case sets by default) and ``--oblate-shadow``. GMAT stops a propagation within +about :math:`10^{-6}` s of the requested time, so the generator shifts each sample back to the nominal time using +the sample's own velocity. + +**3. Run the comparison** + +.. code-block:: bash + + python compare_with_basilisk.py --kernel-dir /path/to/kernels # all cases, using their configured durations + python compare_with_basilisk.py --cases leo_all geo_all --duration-days 5 --kernel-dir /path/to/kernels + python compare_with_basilisk.py --kernel-dir /path/to/kernels --figures-dir figures # also save SVG figures + python compare_with_basilisk.py --cases leo_srp --data-dir oblate --orekit-variant oblate_shadow --kernel-dir /path/to/kernels + +Use ``--data-dir`` if the reference ephemerides are not in ``data/``. ``--duration-days`` limits the duration of every case. The script prints the maximum position and velocity +differences of each case between Basilisk, GMAT, and Orekit, and optionally saves a figure of the position difference versus +time. The all-case run takes about fifteen minutes. + +**4. What to expect** + +The differences should match the tables on this page. Exact values depend on the GMAT, Orekit, and kernel versions, so compare +the level of agreement and not the last digits. + +Limitations +----------- + +The box cases (attitude-dependent drag and radiation pressure) are compared with Orekit only, and use a constant-rate spin without +torque. The 46 to 72 m difference of GMAT in the 400 km drag cases is not explained. The comparison does not compare run times. diff --git a/docs/source/Support/bskKnownIssues.rst b/docs/source/Support/bskKnownIssues.rst index 3cbb4fd9650..4bc72056e78 100644 --- a/docs/source/Support/bskKnownIssues.rst +++ b/docs/source/Support/bskKnownIssues.rst @@ -7,6 +7,33 @@ Basilisk Known Issues Version |release| ----------------- +- The gravity effector advanced the planet orientation of a SPICE-driven planet with a first-order update of the + matrix elements, which is not orthonormal and distorted even a point-mass field by a relative error of order + :math:`(\omega\,\Delta t)^2`. The orientation is now advanced as a rotation about the planet angular velocity. + +- The atmosphere, wind, magnetic field, eclipse, and solar flux modules evaluated the spacecraft state of the previous + step, which made the solar radiation pressure shadow entries and exits a step late. The state can now be extrapolated + to the middle of the step with ``setExtrapolateScStateToStepMidpoint()``, which is disabled by default. + +- The :ref:`msisAtmosphere` module computed the altitude over a sphere and used the mean solar time. The altitude above + an oblate planet is now available with ``setPlanetPolarRadius()``, and the apparent solar time with + ``setUseApparentSolarTime()``. Both are disabled by default. + +- Atmosphere models that are evaluated once per integration step still show a step-size dependent drag error, which is + second order in the step. + +- The :ref:`msisAtmosphere`, :ref:`windBase`, :ref:`magneticFieldBase`, and :ref:`magneticFieldWMM` modules converted + the UTC epoch with ``mktime``, which uses the time zone of the computer. On a computer in a time zone with daylight + saving time, the local solar time was one hour wrong for the part of a simulation on the other side of a daylight + saving transition than the epoch. The epoch is now converted as UTC. + +- The :ref:`msisAtmosphere` module used the daily Ap index and did not use the array of 3-hour Ap values that + NRLMSISE-00 uses in its storm mode, so during a geomagnetic storm the density followed the daily mean from midnight. + The 3-hour history is now available with ``setUseApHistory()``, which is disabled by default. + +- The :ref:`facetSRPDynamicEffector` module used a solar flux of 1368 W/m\ :sup:`2` at 1 AU, while + :ref:`radiationPressure` uses 1361 W/m\ :sup:`2`. Both now use ``SOLAR_FLUX_EARTH``. + - GitHub issue 1598: Compilation database export requires Ninja or a Makefile generator; Xcode and Visual Studio do not support export. Clangd does not automatically discover the generated database in the default ``dist3`` build directory. Configure clangd to use the directory containing ``compile_commands.json``; see :ref:`clangdSetup` diff --git a/docs/source/Support/bskReleaseNotesSnippets/1456-accuracy-comparison.rst b/docs/source/Support/bskReleaseNotesSnippets/1456-accuracy-comparison.rst new file mode 100644 index 00000000000..bfae1850bd5 --- /dev/null +++ b/docs/source/Support/bskReleaseNotesSnippets/1456-accuracy-comparison.rst @@ -0,0 +1,9 @@ +- Added :ref:`accuracyComparison`, a rough comparison of Basilisk orbit propagation against GMAT and Orekit for zonal and tesseral gravity up to degree and order 70, Sun and Moon third-body forces, solar radiation pressure, and exponential-atmosphere drag, in LEO, sun-synchronous, Molniya, GTO, and GEO orbits at several epochs, all in the ICRF axes. The exponential-atmosphere drag is compared with both tools, and the faceted drag and solar radiation pressure of a box spacecraft with a fixed and with a spinning attitude are compared with Orekit. The scripts that generate the GMAT and Orekit reference ephemerides, which also write a manifest with the case configuration, frame, tool versions and data checksums that is validated before the comparison, and that run the comparison, which also compare the altitude and density of the three exponential atmospheres at a set of probe points before any propagation, are in ``benchmarks/accuracyComparison``, and the page explains how to reproduce the results. +- Fixed the gravity effector extrapolating the planet orientation with a first-order update of the matrix elements, which is not orthonormal. The orientation is now advanced as a rotation about the planet angular velocity. Before, a SPICE-driven planet distorted even a point-mass field by a relative error of order :math:`(\omega\,\Delta t)^2` of the time since the planet message was written. The ``J20002Pfix`` and ``J20002Pfix_dot`` state properties of the gravity bodies were also stored transposed after each gravity evaluation, as :math:`[NP]` instead of the documented :math:`[PN]`. They now hold :math:`[PN]` and :math:`[\dot{PN}]` as documented, which matters to code that reads these properties. +- Added ``setExtrapolateScStateToStepMidpoint()`` and ``getExtrapolateScStateToStepMidpoint()`` to :ref:`atmosphereBase` (including :ref:`msisAtmosphere`, :ref:`exponentialAtmosphere`, and :ref:`tabularAtmosphere`), :ref:`windBase`, :ref:`magneticFieldBase`, :ref:`eclipse`, and :ref:`solarFlux`. These modules evaluate the spacecraft state of the previous step because they run before the spacecraft. When the extrapolation is enabled, the spacecraft position is advanced to the middle of the interval the next spacecraft update integrates. This removes a step-size dependent error. The planet (or sun) position and orientation are moved to the same epoch, so a translating planet does not bias the relative position. Stale messages are not extrapolated. The extrapolation is disabled by default and requires that the module is updated with the same task period as the spacecraft. The extrapolation starts once two spacecraft write times show an interval equal to the module update interval, so the first updates are not extrapolated, and during them the planet is moved back to the epoch of the spacecraft state to keep the relative geometry consistent. If the spacecraft is faster or slower than the module, nothing is extrapolated, and a warning is logged when a spacecraft message is found not to have been written at the previous module update. A mismatch is not always detectable, since a message written at the current module update is used as written, but a message that is not the output of the previous module update is never extrapolated. With several spacecraft the extrapolation is applied to all of them and to the planets, or to none. +- Added ``setPlanetPolarRadius()`` and ``getPlanetPolarRadius()`` to :ref:`atmosphereBase` to compute the altitude, and the latitude in :ref:`msisAtmosphere`, above an oblate ellipsoid. The default remains a sphere. +- Added ``setUseApparentSolarTime()`` and ``getUseApparentSolarTime()`` to :ref:`msisAtmosphere` to add the equation of time to the local solar time input of NRLMSISE-00. The default remains the mean solar time. +- Added ``setUseApHistory()`` and ``getUseApHistory()`` to :ref:`msisAtmosphere` to make NRLMSISE-00 use the 3-hour Ap history of the space weather messages (switch 9 set to -1) instead of the daily Ap, which follows a geomagnetic storm more closely. The default remains the daily Ap. +- Fixed ``PCPF2LLA()`` returning an altitude that was wrong for a position exactly at a pole of an oblate planet, where the equatorial component is zero. +- Fixed :ref:`msisAtmosphere`, :ref:`windBase`, :ref:`magneticFieldBase`, and :ref:`magneticFieldWMM` converting the UTC epoch with ``mktime``, which uses the time zone of the computer. On a computer in a time zone with daylight saving time, the local solar time of :ref:`msisAtmosphere` was one hour wrong after a simulation crossed a daylight saving transition. The epoch is now converted as UTC with the new ``architecture/utilities/utcTime.h``. +- Changed :ref:`facetSRPDynamicEffector` to use the solar flux ``SOLAR_FLUX_EARTH`` of 1361 W/m\ :sup:`2` at 1 AU, as :ref:`radiationPressure` does, instead of 1368 W/m\ :sup:`2`. The facet SRP force is 0.5% smaller than before. diff --git a/docs/source/_images/accuracyComparison/accuracyComparison_geo_all.svg b/docs/source/_images/accuracyComparison/accuracyComparison_geo_all.svg new file mode 100644 index 00000000000..91e14bf445b --- /dev/null +++ b/docs/source/_images/accuracyComparison/accuracyComparison_geo_all.svg @@ -0,0 +1,2835 @@ + + + + + + + + 2026-10-07T09:28:04.707150 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_images/accuracyComparison/accuracyComparison_leo_box_spin.svg b/docs/source/_images/accuracyComparison/accuracyComparison_leo_box_spin.svg new file mode 100644 index 00000000000..43c03329edd --- /dev/null +++ b/docs/source/_images/accuracyComparison/accuracyComparison_leo_box_spin.svg @@ -0,0 +1,1560 @@ + + + + + + + + 2026-10-07T09:28:22.119593 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_images/accuracyComparison/accuracyComparison_leo_drag.svg b/docs/source/_images/accuracyComparison/accuracyComparison_leo_drag.svg new file mode 100644 index 00000000000..fbe5b13bda5 --- /dev/null +++ b/docs/source/_images/accuracyComparison/accuracyComparison_leo_drag.svg @@ -0,0 +1,1916 @@ + + + + + + + + 2026-10-07T09:30:46.035392 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_images/accuracyComparison/accuracyComparison_leo_srp.svg b/docs/source/_images/accuracyComparison/accuracyComparison_leo_srp.svg new file mode 100644 index 00000000000..1e31a5f579d --- /dev/null +++ b/docs/source/_images/accuracyComparison/accuracyComparison_leo_srp.svg @@ -0,0 +1,2800 @@ + + + + + + + + 2026-10-07T09:27:29.653934 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/_images/accuracyComparison/accuracyComparison_molniya_all.svg b/docs/source/_images/accuracyComparison/accuracyComparison_molniya_all.svg new file mode 100644 index 00000000000..ff48c97ee4f --- /dev/null +++ b/docs/source/_images/accuracyComparison/accuracyComparison_molniya_all.svg @@ -0,0 +1,3472 @@ + + + + + + + + 2026-10-07T09:28:46.035733 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/index.rst b/docs/source/index.rst index 037b5efccdd..6cb6ea53cae 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -160,6 +160,8 @@ several different (often competing) requirements. Each dynamics module has associated momentum, energy and power validation tests. This ensures the integrity of the validated modules as new simulation capabilities are added. + Orbit propagation accuracy is compared against GMAT and Orekit in + :ref:`accuracyComparison`. - **Monte-Carlo Capability:** The simulation framework is capable of doing bit-for-bit repeatable Monte-Carlo runs. The simulation diff --git a/examples/scenarioDragDeorbit.py b/examples/scenarioDragDeorbit.py index 3078035ab97..199f36c7297 100644 --- a/examples/scenarioDragDeorbit.py +++ b/examples/scenarioDragDeorbit.py @@ -59,6 +59,10 @@ swMsgList.append(messaging.SwDataMsg().write(swMsgData)) atmo.swDataInMsgs[c].subscribeTo(swMsgList[-1]) +By default NRLMSISE-00 uses the daily ap index, the first entry of ``sw_msg``. ``atmo.setUseApHistory(True)`` makes it use the +3-hour ap values instead, which follows a geomagnetic storm more closely. As ``ap`` is constant in this example, both options +give nearly the same density, and the line is left commented out in the script. + The drag model ``DragDynamicEffector()`` is initialized, then model parameters are set. In this example, the projected area ``coreParams.projectedArea`` is set to 10 meters squared and the drag coefficient :math:`C_D` ``coreParams.dragCoeff`` is set to 2.2. @@ -215,6 +219,7 @@ def run(show_plots, initialAlt=250, deorbitAlt=100, model="exponential", useWind elif model == "msis": atmo = msisAtmosphere.MsisAtmosphere() atmo.ModelTag = "MsisAtmo" + # atmo.setUseApHistory(True) # use the 3-hour Ap values instead of the daily Ap, relevant in a geomagnetic storm ap = 8 f107 = 110 diff --git a/examples/scenarioSpiceReconstruction.py b/examples/scenarioSpiceReconstruction.py index 61fad63c6c9..3fe8db81b2f 100644 --- a/examples/scenarioSpiceReconstruction.py +++ b/examples/scenarioSpiceReconstruction.py @@ -69,8 +69,9 @@ does not change the orbit. The reconstruction curve drops into that band after only a few tens of queries, so coarsening the grid further buys nothing. For comparison, the exact-SPICE reference sits at the far right at its full query cost (zero error by definition, on the broken "0" tick), and the -:ref:`spacecraft` + :ref:`gravityEffector` default sits *above* the band: a handful of -reconstruction knots is both cheaper and more accurate than that default behaviour. +:ref:`spacecraft` + :ref:`gravityEffector` default sits inside the band too, at a cost of one Moon +query per step: it is as accurate as the reconstruction, but a handful of +reconstruction knots gives the same orbit for a small fraction of the queries. .. figure:: /_images/Scenarios/scenarioSpiceReconstruction2.svg :align: center @@ -80,8 +81,8 @@ curve, the run with a perfect ephemeris, which grows over the leg and jumps at the flyby), one cheap reconstruction taken from the free zone of the second figure, and the :ref:`spacecraft` default. The shaded region is again the free zone, now time-varying because the integrator error grows. The -reconstruction stays inside that zone for nearly the whole leg on a handful of Moon queries, while -the :ref:`spacecraft` default climbs above it early in the coast and grows to tens of kilometres. +reconstruction stays inside that zone for nearly the whole leg on a handful of Moon queries. The +:ref:`spacecraft` default stays inside it as well, but needs one Moon query per step. .. figure:: /_images/Scenarios/scenarioSpiceReconstruction3.svg :align: center @@ -407,7 +408,7 @@ def plotAccuracyVsCost(sweep, errClassic, integratorFloor, nQExact, nQClassic): byQ[s["nQ"]] = s["err"] nq = np.array(sorted(byQ)) err = np.array([byQ[q] for q in nq]) - top = errClassic * 3.0 + top = max(errClassic, integratorFloor, float(err.max())) * 3.0 plotFloor = 1e-6 # [m] bottom of the log panel; the finest grids are microns from the reference # Broken y-axis: a wide log panel for the error curve, and a thin panel below holding the single @@ -474,7 +475,7 @@ def plotErrorOverTime(tTruth, posTruth, integratorFloor_t, tR, posR, nQGood, label=f"reconstruction, {nQGood} Moon queries") ax.semilogy(tClassic / 3600.0, np.maximum(errClassic_t, 1e-6), color=C_CLASSIC, ls="--", zorder=2, label=f"classic spacecraft + gravityEffector ({nQClassic} queries)") - ax.set_ylim(1e-6, errClassic * 3.0) + ax.set_ylim(1e-6, max(errClassic_t.max(), floor_t.max(), errRecon_t.max()) * 3.0) ax.set_xlabel("time [hr]") ax.set_ylabel("position error vs. exact SPICE [m]") ax.grid(True, which="both", alpha=0.3) diff --git a/src/architecture/utilities/geodeticConversion.cpp b/src/architecture/utilities/geodeticConversion.cpp index ca1be8781cd..07d3fdbb286 100644 --- a/src/architecture/utilities/geodeticConversion.cpp +++ b/src/architecture/utilities/geodeticConversion.cpp @@ -77,7 +77,13 @@ Eigen::Vector3d PCPF2LLA(Eigen::Vector3d pcpfPosition, double planetEqRad, doubl llaPosition[0] = atan2(kappa*pcpfPosition[2], pSingle); double sPhi = sin(llaPosition[0]); double nVal = planetEqRad/sqrt(1.0 - planetEcc2*sPhi*sPhi); - llaPosition[2] = pSingle/(cos(llaPosition[0])) - nVal; + // The altitude p/cos(phi) - N is singular at the poles, where p = 0 and cos(phi) = 0. At latitudes beyond + // 45 degrees use the equivalent expression z/sin(phi) - N(1 - e^2), which is well conditioned there. + if (std::abs(sPhi) > std::abs(cos(llaPosition[0]))) { + llaPosition[2] = pcpfPosition[2] / sPhi - nVal * (1.0 - planetEcc2); + } else { + llaPosition[2] = pSingle / (cos(llaPosition[0])) - nVal; + } llaPosition[1] = atan2(pcpfPosition[1], pcpfPosition[0]); } diff --git a/src/architecture/utilities/stateExtrapolation.h b/src/architecture/utilities/stateExtrapolation.h new file mode 100644 index 00000000000..3e05d272000 --- /dev/null +++ b/src/architecture/utilities/stateExtrapolation.h @@ -0,0 +1,516 @@ +/* + ISC License + + Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + + */ + +#ifndef STATE_EXTRAPOLATION_H +#define STATE_EXTRAPOLATION_H + +#include +#include +#include +#include +#include + +#include +#include + +#include "architecture/msgPayloadDefC/SCStatesMsgPayload.h" +#include "architecture/msgPayloadDefC/SpicePlanetStateMsgPayload.h" +#include "architecture/utilities/bskLogging.h" +#include "architecture/utilities/macroDefinitions.h" + +/*! @brief Angular velocity of a planet-fixed frame, derived once from an orientation and its derivative. + * + * The planet angular velocity is constant while an orientation is extrapolated, so a caller that advances the same + * orientation repeatedly (for example at every integrator stage) computes it once with planetSpin() and reuses it with + * advanceDcm() and advanceDcmDot(). + */ +struct PlanetSpin +{ + Eigen::Matrix3d omegaTilde_N = Eigen::Matrix3d::Zero(); //!< [1/s] `dcm_NPfix_dot * dcm_NPfix^T`, `[omega_N x]` + Eigen::Vector3d omega_N = Eigen::Vector3d::Zero(); //!< [rad/s] inertial angular velocity of the planet + bool isZero = true; //!< true if the orientation derivative is zero +}; + +/*! @brief Extract the inertial angular velocity of a planet-fixed frame from its orientation and derivative. + * + * `dcm_NPfix_dot = [omega_N x] dcm_NPfix`, hence `[omega_N x] = dcm_NPfix_dot * dcm_NPfix^T`. + * + * @param dcm_NPfix [-] [NP] orientation of the inertial frame relative to the planet-fixed frame, which maps + * planet-fixed components to inertial components + * @param dcm_NPfix_dot [1/s] time derivative of dcm_NPfix + * @return the planet spin, with `isZero` set if the derivative is zero + */ +static inline PlanetSpin planetSpin(const Eigen::Matrix3d& dcm_NPfix, const Eigen::Matrix3d& dcm_NPfix_dot) +{ + PlanetSpin spin; + if (dcm_NPfix_dot.isZero()) { + return spin; + } + spin.isZero = false; + spin.omegaTilde_N = dcm_NPfix_dot * dcm_NPfix.transpose(); + spin.omega_N = Eigen::Vector3d(0.5 * (spin.omegaTilde_N(2, 1) - spin.omegaTilde_N(1, 2)), + 0.5 * (spin.omegaTilde_N(0, 2) - spin.omegaTilde_N(2, 0)), + 0.5 * (spin.omegaTilde_N(1, 0) - spin.omegaTilde_N(0, 1))); // [rad/s] + return spin; +} + +/*! @brief Advance a planet-fixed orientation matrix by a time offset with a precomputed planet spin. + * + * The orientation is rotated about the planet angular velocity by `|omega| dt`, with the Rodrigues formula, so the + * result stays orthonormal. No orientation or rate is recomputed, only the rotation for the given time offset. + * + * @param dcm_NPfix [-] [NP] orientation to advance + * @param spin planet spin from planetSpin() of the same orientation and its derivative + * @param dt [s] signed time offset to advance the orientation by + * @return the advanced orientation matrix, or dcm_NPfix if the spin or the offset is zero + */ +static inline Eigen::Matrix3d advanceDcm(const Eigen::Matrix3d& dcm_NPfix, const PlanetSpin& spin, double dt) +{ + if (spin.isZero || dt == 0.0) { + return dcm_NPfix; + } + const double omegaNorm = spin.omega_N.norm(); // [rad/s] + const double rotationAngle = omegaNorm * dt; // [rad] + if (rotationAngle == 0.0) { + return dcm_NPfix; + } + const Eigen::Vector3d axis_N = spin.omega_N / omegaNorm; // [-] + Eigen::Matrix3d axisTilde_N; + axisTilde_N << 0.0, -axis_N[2], axis_N[1], axis_N[2], 0.0, -axis_N[0], -axis_N[1], axis_N[0], 0.0; + const Eigen::Matrix3d rotation = Eigen::Matrix3d::Identity() + std::sin(rotationAngle) * axisTilde_N + + (1.0 - std::cos(rotationAngle)) * axisTilde_N * axisTilde_N; + return rotation * dcm_NPfix; +} + +/*! @brief Advance the time derivative of a planet-fixed orientation with a precomputed planet spin. + * + * With a constant inertial angular velocity, `dcm_NPfix_dot = [omega_N x] dcm_NPfix` holds at every epoch, so the + * derivative at the advanced epoch is `[omega_N x] dcmAdvanced_NPfix`. This assumes a consistent orientation and rate + * pair, for which `dcm_NPfix_dot * dcm_NPfix^T` is skew-symmetric. + * + * @param spin planet spin from planetSpin() + * @param dcmAdvanced_NPfix [-] [NP] orientation after the advance, from advanceDcm() + * @param dcm_NPfix_dot [1/s] time derivative before the advance, returned if the spin is zero + * @return [1/s] the derivative at the advanced epoch + */ +static inline Eigen::Matrix3d advanceDcmDot(const PlanetSpin& spin, const Eigen::Matrix3d& dcmAdvanced_NPfix, const Eigen::Matrix3d& dcm_NPfix_dot) +{ + if (spin.isZero) { + return dcm_NPfix_dot; + } + return spin.omegaTilde_N * dcmAdvanced_NPfix; +} + +/*! @brief Returns true if a spacecraft state message is the output of the previous update of the environment module. + * + * The message-age based extrapolation is only valid if the spacecraft message was written at the previous update of + * the environment module. Then the age of the message is exactly the update interval of the module, and half of the + * age is the middle of that interval. This holds if the module and the spacecraft are updated with the same task + * period. A message written at any other earlier time is either stale (for example a state written once at the start + * of the simulation), or the spacecraft is updated at a different task period than the environment module. + * + * @param currentSimNanos [ns] current simulation time + * @param timeWrittenNanos [ns] time the spacecraft state message was written + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @return true if the message is older than the current time and was written at the previous module update + */ +static inline bool isStepLagMessage(uint64_t currentSimNanos, uint64_t timeWrittenNanos, uint64_t previousUpdateNanos) +{ + return timeWrittenNanos < currentSimNanos && timeWrittenNanos == previousUpdateNanos; +} + +/*! @brief Extrapolate a spacecraft state message to the middle of the interval the next spacecraft update integrates. + * + * Environment modules (atmosphere, wind, magnetic field, eclipse, solar flux) run before the + * spacecraft within a task, so the state they read was written at the end of the previous step. The + * spacecraft update at the current time integrates the interval between that message time and the + * current time, so the best estimate of where the spacecraft is while the environment output acts is the state + * advanced by half of the message age. The position is advanced with the message velocity. + * + * @note The requirement concerns the task period, not the integrator. The environment module must be updated with + * the same task period as the spacecraft, so that the message written by the spacecraft at the previous module + * update is exactly one update interval old. The internal substeps of a variable-step integrator do not matter: + * they still cover the update interval requested of the spacecraft. The state is returned unchanged if the message + * was written at the current time, or if it was not written at the previous update of the environment module. + * The latter is either a stale message (written once at the start of the simulation), or a spacecraft that runs + * faster or slower than the environment module, for which half of the message age is not the middle of the module + * interval. ScStateExtrapolation additionally waits until the spacecraft period has been observed. + * + * @param scState spacecraft state message payload as read from the message + * @param currentSimNanos [ns] current simulation time + * @param timeWrittenNanos [ns] time the spacecraft state message was written + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @return copy of the payload with `r_BN_N` and `r_CN_N` advanced by `v * 0.5 * (currentSimNanos - timeWrittenNanos)` + */ +static inline SCStatesMsgPayload extrapolateScStateToStepMidpoint(const SCStatesMsgPayload& scState, + uint64_t currentSimNanos, + uint64_t timeWrittenNanos, + uint64_t previousUpdateNanos) +{ + SCStatesMsgPayload extrapolated = scState; + if (!isStepLagMessage(currentSimNanos, timeWrittenNanos, previousUpdateNanos)) { + return extrapolated; + } + const double halfAge = 0.5 * diffNanoToSec(currentSimNanos, timeWrittenNanos); // [s] + for (int i = 0; i < 3; i++) { + extrapolated.r_BN_N[i] += scState.v_BN_N[i] * halfAge; + extrapolated.r_CN_N[i] += scState.v_CN_N[i] * halfAge; + } + return extrapolated; +} + +/*! @brief Move a planet state message to the epoch the spacecraft state was extrapolated to. + * + * The relative position of the spacecraft with respect to a planet is only meaningful if both are evaluated at the + * same epoch. The position is advanced with the planet velocity, and the planet-fixed orientation is advanced with + * planetSpin() and advanceDcm(). The offset is signed: it is negative if the planet message is newer than the target epoch. + * + * @param planetState planet state message payload as read from the message + * @param targetNanos [ns] epoch to evaluate the planet at + * @param timeWrittenNanos [ns] time the planet state message was written + * @return copy of the payload with the position and the orientation advanced to the target epoch + * @note An all-zero orientation, as left by a message that only sets position and velocity, is treated as the identity + * like in GravBodyData, so the returned orientation is always a valid rotation matrix. + * @note As in GravBodyData::computeGravityInertial(), the planet is advanced by the time between the message write and + * the target epoch, with no check on the age of the message. A message written once with a non-zero velocity is + * projected forward over the whole simulation. + */ +static inline SpicePlanetStateMsgPayload extrapolatePlanetStateToEpoch(const SpicePlanetStateMsgPayload& planetState, + uint64_t targetNanos, + uint64_t timeWrittenNanos) +{ + SpicePlanetStateMsgPayload extrapolated = planetState; + const double dt = diffNanoToSec(targetNanos, timeWrittenNanos); // [s] + if (dt == 0.0) { + return extrapolated; + } + for (int i = 0; i < 3; i++) { + extrapolated.PositionVector[i] += planetState.VelocityVector[i] * dt; + } + using RowMajorMatrix3d = Eigen::Matrix; + // J20002Pfix is [PN], it maps inertial to planet-fixed components. Its transpose [NP] is advanced. + Eigen::Matrix3d dcm_NPfix = RowMajorMatrix3d(Eigen::Map(&planetState.J20002Pfix[0][0])).transpose(); + if (dcm_NPfix.isZero()) { // position-only planet message that leaves the optional orientation uninitialized + dcm_NPfix = Eigen::Matrix3d::Identity(); + } + const Eigen::Matrix3d dcm_NPfix_dot = RowMajorMatrix3d(Eigen::Map(&planetState.J20002Pfix_dot[0][0])).transpose(); + const PlanetSpin spin = planetSpin(dcm_NPfix, dcm_NPfix_dot); + const Eigen::Matrix3d dcmAdvanced_NPfix = advanceDcm(dcm_NPfix, spin, dt); + Eigen::Map(&extrapolated.J20002Pfix[0][0]) = dcmAdvanced_NPfix.transpose(); + // keep the derivative consistent with the advanced matrix, with the inertial angular velocity unchanged + const Eigen::Matrix3d dcmAdvanced_NPfix_dot = advanceDcmDot(spin, dcmAdvanced_NPfix, dcm_NPfix_dot); + Eigen::Map(&extrapolated.J20002Pfix_dot[0][0]) = dcmAdvanced_NPfix_dot.transpose(); + return extrapolated; +} + +/*! @brief Opt-in switch and rate-mismatch monitor for the spacecraft state extrapolation of an environment module. + * + * The extrapolation is disabled by default, in which case the state message is used as written. When enabled, the + * module calls prepare() once per update with the write times of all its spacecraft state messages. The spacecraft + * states are extrapolated with extrapolateScStateToStepMidpoint() and the planets with + * extrapolatePlanetStateToEpoch() only if all spacecraft messages were written at the previous module update and two + * successive write times were observed with an interval equal to the module update interval. The extrapolation is + * therefore deferred until the spacecraft period is known (the first updates are never extrapolated). If any + * spacecraft state message does not satisfy these conditions, no spacecraft is extrapolated, and a warning is logged + * once if a spacecraft message was not written at the previous module update, which is typically a task period + * mismatch. While no spacecraft state is extrapolated but all were written at the previous module update, the planets + * are moved back to that epoch, so the relative geometry stays consistent during the startup and under a period + * mismatch. A mismatch is not always detectable: a message written at the current module update (for example by a + * faster spacecraft that runs before the module) is used as written and gives no warning. No message that is not the + * output of the previous module update is ever extrapolated. + */ +class ScStateExtrapolation +{ + public: + /*! @brief Enable or disable the extrapolation. + * @param enable true to extrapolate the spacecraft state to the step midpoint + */ + void setEnabled(bool enable) { this->enabled = enable; } + + /*! @brief Returns whether the extrapolation is enabled. + * @return true if the spacecraft state is extrapolated + */ + bool isEnabled() const { return this->enabled; } + + /*! @brief Returns whether the warning that a spacecraft message was not written at the previous module update was + * logged, which is typically a task period mismatch between the module and the spacecraft. + * @return true if the warning was logged since the last reset + */ + bool mismatchDetected() const { return this->warned; } + + /*! @brief Returns an empty reusable buffer for the write times passed to prepare(), which avoids an allocation at + * every module update. + * @return empty vector, valid until the next call + */ + std::vector& writeTimesBuffer() + { + this->timesBuffer.clear(); + return this->timesBuffer; + } + + /*! @brief Clear the observed message history and re-arm the warning. */ + void reset() + { + this->lastWriteNanos.clear(); + this->writeIntervalNanos.clear(); + this->rewritten.clear(); + this->seen.clear(); + this->warned = false; + this->lagConsistent = false; + this->allStepLag = false; + } + + /*! @brief Decide once per module update whether the extrapolation applies to all spacecraft and the planets. + * + * The planet state messages are shared by all spacecraft of a module, so the decision cannot be made per + * spacecraft: a planet moved to the step midpoint is inconsistent with a spacecraft left at the message epoch, and + * the other way around. The extrapolation is therefore applied to every spacecraft and to the planets only if all + * spacecraft state messages were written at the previous module update (see isStepLagMessage()). If one of them + * was not, no spacecraft is extrapolated and a warning is logged once. The planets are still moved to the common + * epoch of the spacecraft messages if all of them were written at the previous module update. + * + * Must be called once per update, before apply() and applyPlanet(). + * @param currentSimNanos [ns] current simulation time + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @param timesWrittenNanos [ns] write time of each spacecraft state message, in the order of the apply() indexes + * @param logger logger of the calling module + */ + void prepare(uint64_t currentSimNanos, + uint64_t previousUpdateNanos, + const std::vector& timesWrittenNanos, + BSKLogger& logger) + { + this->lagConsistent = false; + this->allStepLag = false; + if (!this->enabled) { + return; + } + if (this->lastWriteNanos.size() < timesWrittenNanos.size()) { + this->lastWriteNanos.resize(timesWrittenNanos.size(), 0); + this->writeIntervalNanos.resize(timesWrittenNanos.size(), 0); + this->rewritten.resize(timesWrittenNanos.size(), false); + this->seen.resize(timesWrittenNanos.size(), false); + } + const uint64_t moduleIntervalNanos = currentSimNanos - previousUpdateNanos; // [ns] + bool allLagged = timesWrittenNanos.size() > 0; + bool allStepLagMessages = timesWrittenNanos.size() > 0; + bool mismatch = false; + for (std::size_t index = 0; index < timesWrittenNanos.size(); index++) { + const uint64_t timeWrittenNanos = timesWrittenNanos[index]; + if (!this->seen[index]) { + this->seen[index] = true; + this->lastWriteNanos[index] = timeWrittenNanos; + } else if (timeWrittenNanos != this->lastWriteNanos[index]) { + this->rewritten[index] = true; + this->writeIntervalNanos[index] = timeWrittenNanos - this->lastWriteNanos[index]; + this->lastWriteNanos[index] = timeWrittenNanos; + } + // extrapolate only once two successive write times show that the spacecraft period equals the module + // update interval + const bool periodKnown = this->writeIntervalNanos[index] != 0; + const bool periodDiffers = periodKnown && this->writeIntervalNanos[index] != moduleIntervalNanos; + const bool lagged = periodKnown && !periodDiffers && + isStepLagMessage(currentSimNanos, timeWrittenNanos, previousUpdateNanos); + allLagged = allLagged && lagged; + allStepLagMessages = allStepLagMessages && + isStepLagMessage(currentSimNanos, timeWrittenNanos, previousUpdateNanos); + mismatch = mismatch || + (this->rewritten[index] && timeWrittenNanos < currentSimNanos && (periodDiffers || !lagged)); + } + this->lagConsistent = allLagged; + this->allStepLag = allStepLagMessages; + if (mismatch && !this->warned) { + this->warned = true; + logger.bskLog(BSK_WARNING, + "The spacecraft state extrapolation is enabled, but a spacecraft state message was not " + "written at the previous module update. The module and the spacecraft are not updated with " + "the same task period (spacecraft slower or faster than the module): the spacecraft state " + "is not extrapolated to the step midpoint."); + } + } + + /*! @brief Call prepare() with the write times read from the spacecraft state input messages. + * @tparam Messages range of read functors that provide timeWritten() + * @param currentSimNanos [ns] current simulation time + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @param scStateInMsgs spacecraft state input messages, in the order of the apply() indexes + * @param logger logger of the calling module + */ + template + void prepareFromMessages(uint64_t currentSimNanos, + uint64_t previousUpdateNanos, + Messages& scStateInMsgs, + BSKLogger& logger) + { + std::vector& timesWritten = this->writeTimesBuffer(); // [ns] + for (auto& msg : scStateInMsgs) { + timesWritten.push_back(msg.timeWritten()); + } + this->prepare(currentSimNanos, previousUpdateNanos, timesWritten, logger); + } + + /*! @brief Call prepare() for a module with a single spacecraft state input message. + * @tparam Message read functor that provides timeWritten() + * @param currentSimNanos [ns] current simulation time + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @param scStateInMsg spacecraft state input message + * @param logger logger of the calling module + */ + template + void prepareFromMessage(uint64_t currentSimNanos, + uint64_t previousUpdateNanos, + Message& scStateInMsg, + BSKLogger& logger) + { + std::vector& timesWritten = this->writeTimesBuffer(); // [ns] + timesWritten.push_back(scStateInMsg.timeWritten()); + this->prepare(currentSimNanos, previousUpdateNanos, timesWritten, logger); + } + + /*! @brief Read a spacecraft state input message and apply() the extrapolation to it. + * @tparam Message read functor that returns an SCStatesMsgPayload and provides timeWritten() + * @param scStateInMsg spacecraft state input message + * @param currentSimNanos [ns] current simulation time + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @return the payload, with the position advanced to the step midpoint if the extrapolation applies + */ + template + SCStatesMsgPayload applyMessage(Message& scStateInMsg, + uint64_t currentSimNanos, + uint64_t previousUpdateNanos) const + { + return this->apply(scStateInMsg(), currentSimNanos, scStateInMsg.timeWritten(), previousUpdateNanos); + } + + /*! @brief Read a planet state input message and applyPlanet() the extrapolation to it. + * @tparam Message read functor that returns a SpicePlanetStateMsgPayload and provides timeWritten() + * @param planetInMsg planet state input message + * @param currentSimNanos [ns] current simulation time + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @return the planet payload moved to the epoch of the spacecraft states + */ + template + SpicePlanetStateMsgPayload applyPlanetMessage(Message& planetInMsg, + uint64_t currentSimNanos, + uint64_t previousUpdateNanos) const + { + return this->applyPlanet(planetInMsg(), currentSimNanos, planetInMsg.timeWritten(), previousUpdateNanos); + } + + /*! @brief Return the spacecraft state, extrapolated if prepare() found all spacecraft consistent. + * @param scState spacecraft state message payload as read from the message + * @param currentSimNanos [ns] current simulation time + * @param timeWrittenNanos [ns] time the spacecraft state message was written + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @return the payload, with the position advanced to the step midpoint if the extrapolation applies + */ + SCStatesMsgPayload apply(const SCStatesMsgPayload& scState, + uint64_t currentSimNanos, + uint64_t timeWrittenNanos, + uint64_t previousUpdateNanos) const + { + if (!this->lagConsistent) { + return scState; + } + return extrapolateScStateToStepMidpoint(scState, currentSimNanos, timeWrittenNanos, previousUpdateNanos); + } + + /*! @brief Return the planet state at the epoch the spacecraft states were extrapolated to. + * + * The planet is moved to the middle of the module update interval only if prepare() found the extrapolation + * applicable to all spacecraft, so that the relative geometry of every spacecraft is evaluated at one epoch. + * If all spacecraft messages were written at the previous module update but the extrapolation does not apply (the + * spacecraft period is not yet known, or differs from the module period), the spacecraft states stay at that + * epoch and the planet is moved back to it. Otherwise the planet is returned as written. + * @param planetState planet state message payload as read from the message + * @param currentSimNanos [ns] current simulation time + * @param timeWrittenNanos [ns] time the planet state message was written + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @return the planet payload with the position and the orientation advanced to the step midpoint + * @note prepare() must be called first in every update. + */ + SpicePlanetStateMsgPayload applyPlanet(const SpicePlanetStateMsgPayload& planetState, + uint64_t currentSimNanos, + uint64_t timeWrittenNanos, + uint64_t previousUpdateNanos) const + { + SpicePlanetStateMsgPayload planet = this->movePlanetToEpoch( + planetState, currentSimNanos, timeWrittenNanos, previousUpdateNanos); + // a position-only message leaves the optional orientation at zero: treat it as identity on every path + const double* dcmElements = &planet.J20002Pfix[0][0]; // [-] + if (std::all_of(dcmElements, dcmElements + 9, [](double e) { return e == 0.0; })) { + for (int i = 0; i < 3; i++) { + planet.J20002Pfix[i][i] = 1.0; // [-] + } + } + return planet; + } + + /*! @brief Return the epoch the spacecraft and planet states returned by apply() and applyPlanet() are evaluated at. + * + * Time dependent models (for example the local solar time of an atmosphere model) must use the same epoch as the + * geometry. It is the middle of the module update interval if the extrapolation applies, the previous module update + * if all spacecraft messages were written then, and the current time otherwise. + * @param currentSimNanos [ns] current simulation time + * @param previousUpdateNanos [ns] time of the previous update of the calling environment module + * @return [ns] evaluation epoch + * @note prepare() must be called first in every update. + */ + uint64_t evaluationEpochNanos(uint64_t currentSimNanos, uint64_t previousUpdateNanos) const + { + if (this->lagConsistent) { + return previousUpdateNanos + (currentSimNanos - previousUpdateNanos) / 2; + } + return this->allStepLag ? previousUpdateNanos : currentSimNanos; + } + + private: + bool enabled = false; //!< true if the spacecraft state is extrapolated + bool warned = false; //!< true once the rate mismatch warning was logged + bool lagConsistent = false; //!< true if all spacecraft messages were written at the previous update + //!< and their period is known + bool allStepLag = false; //!< true if all spacecraft messages were written at the previous update + std::vector lastWriteNanos{}; //!< [ns] last observed write time of each state message + std::vector writeIntervalNanos{}; //!< [ns] last observed interval between two write times, 0 if unknown + std::vector rewritten{}; //!< true if the state message was seen with more than one write time + std::vector timesBuffer{}; //!< [ns] reusable buffer for the write times passed to prepare() + std::vector seen{}; //!< true if the state message was observed in a previous update + + //! Move the planet to the epoch selected by prepare(), see applyPlanet(). + SpicePlanetStateMsgPayload movePlanetToEpoch(const SpicePlanetStateMsgPayload& planetState, + uint64_t currentSimNanos, + uint64_t timeWrittenNanos, + uint64_t previousUpdateNanos) const + { + if (!this->lagConsistent) { + if (this->allStepLag) { + // The spacecraft states stay at the message epoch, which is the previous module update for every + // spacecraft, for example while the spacecraft period is not yet known. The planet is moved to that + // epoch, so the relative geometry is still evaluated at one epoch. The offset is signed: negative for a planet + // written at the current update, zero for one written at the previous update. + return extrapolatePlanetStateToEpoch(planetState, previousUpdateNanos, timeWrittenNanos); + } + return planetState; + } + const uint64_t midpointNanos = previousUpdateNanos + (currentSimNanos - previousUpdateNanos) / 2; // [ns] + return extrapolatePlanetStateToEpoch(planetState, midpointNanos, timeWrittenNanos); + } +}; + +#endif diff --git a/src/architecture/utilities/tests/CMakeLists.txt b/src/architecture/utilities/tests/CMakeLists.txt index 55d6ed8c98b..44615c27fd4 100644 --- a/src/architecture/utilities/tests/CMakeLists.txt +++ b/src/architecture/utilities/tests/CMakeLists.txt @@ -40,6 +40,14 @@ add_executable(test_bilinearInterpolation test_bilinearInterpolation.cpp) target_link_libraries(test_bilinearInterpolation GTest::gtest_main) target_link_libraries(test_bilinearInterpolation ArchitectureUtilities) +add_executable(test_stateExtrapolation test_stateExtrapolation.cpp) +target_link_libraries(test_stateExtrapolation GTest::gtest_main) +target_link_libraries(test_stateExtrapolation ArchitectureUtilities) + +add_executable(test_utcTime test_utcTime.cpp) +target_link_libraries(test_utcTime GTest::gtest_main) +target_link_libraries(test_utcTime ArchitectureUtilities) + add_executable(test_bskLogging test_bskLogging.cpp) target_link_libraries(test_bskLogging GTest::gtest_main) target_link_libraries(test_bskLogging ArchitectureUtilities) @@ -54,8 +62,10 @@ gtest_discover_tests(test_orbitalMotion) gtest_discover_tests(test_unitTestComparators) gtest_discover_tests(test_saturate) gtest_discover_tests(test_geodeticConversion) +gtest_discover_tests(test_stateExtrapolation) gtest_discover_tests(test_avsEigenMRP) gtest_discover_tests(test_avsEigenSupport) gtest_discover_tests(test_linearInterpolation) gtest_discover_tests(test_bilinearInterpolation) gtest_discover_tests(test_bskLogging) +gtest_discover_tests(test_utcTime) diff --git a/src/architecture/utilities/tests/test_geodeticConversion.cpp b/src/architecture/utilities/tests/test_geodeticConversion.cpp index 2b3947020cd..fbaff196669 100644 --- a/src/architecture/utilities/tests/test_geodeticConversion.cpp +++ b/src/architecture/utilities/tests/test_geodeticConversion.cpp @@ -80,3 +80,37 @@ EXPECT_LT(std::abs(llaPosition[1] - ans[1]), 0.0001); EXPECT_LT(std::abs(llaPosition[2] - ans[2]), 100); } + +TEST(GeodeticConversion, testPCPF2LLAAtThePoles) +{ + const double planetEqRad = 6378.1363E3; // [m] + const double planetPoRad = 6356.7523E3; // [m] + const double altitude = 400.0E3; // [m] + + Eigen::Vector3d northPole(0.0, 0.0, planetPoRad + altitude); + Eigen::Vector3d ans = PCPF2LLA(northPole, planetEqRad, planetPoRad); + EXPECT_NEAR(ans[0], M_PI_2, 1.0E-12); + EXPECT_NEAR(ans[2], altitude, 1.0E-3); + + Eigen::Vector3d southPole(0.0, 0.0, -(planetPoRad + altitude)); + ans = PCPF2LLA(southPole, planetEqRad, planetPoRad); + EXPECT_NEAR(ans[0], -M_PI_2, 1.0E-12); + EXPECT_NEAR(ans[2], altitude, 1.0E-3); +} + +TEST(GeodeticConversion, testPCPF2LLARoundTripFromEquatorToPole) +{ + const double planetEqRad = 6378.1363E3; // [m] + const double planetPoRad = 6356.7523E3; // [m] + const double longitude = 1.0; // [rad] + + for (double altitude : { -1000.0, 0.0, 400.0E3, 36000.0E3 }) { // [m] + for (double latitudeDeg = -90.0; latitudeDeg <= 90.0; latitudeDeg += 0.25) { // [deg] + Eigen::Vector3d lla(latitudeDeg * M_PI / 180.0, longitude, altitude); + Eigen::Vector3d pcpf = LLA2PCPF(lla, planetEqRad, planetPoRad); + Eigen::Vector3d ans = PCPF2LLA(pcpf, planetEqRad, planetPoRad); + EXPECT_NEAR(ans[0], lla[0], 1.0E-10) << "latitude [deg] " << latitudeDeg; + EXPECT_NEAR(ans[2], lla[2], 1.0E-3) << "latitude [deg] " << latitudeDeg; + } + } +} diff --git a/src/architecture/utilities/tests/test_stateExtrapolation.cpp b/src/architecture/utilities/tests/test_stateExtrapolation.cpp new file mode 100644 index 00000000000..49ef5cbf3cc --- /dev/null +++ b/src/architecture/utilities/tests/test_stateExtrapolation.cpp @@ -0,0 +1,781 @@ +/* + ISC License + + Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + + */ + +#include + +#include +#include + +#include + +#include "architecture/utilities/astroConstants.h" +#include "architecture/utilities/stateExtrapolation.h" + +namespace { +SCStatesMsgPayload +makeState() +{ + SCStatesMsgPayload state{}; + state.r_BN_N[0] = 7.0e6; // [m] + state.v_BN_N[1] = 7.5e3; // [m/s] + state.r_CN_N[0] = 7.0e6; // [m] + state.v_CN_N[1] = 7.5e3; // [m/s] + return state; +} +} // namespace + +TEST(StateExtrapolation, advancesPositionByHalfTheMessageAge) +{ + const uint64_t stepNanos = 10000000000ULL; // [ns] 10 s + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(makeState(), 2 * stepNanos, stepNanos, stepNanos); + + EXPECT_DOUBLE_EQ(out.r_BN_N[0], 7.0e6); + EXPECT_DOUBLE_EQ(out.r_BN_N[1], 7.5e3 * 5.0); // v * age / 2 + EXPECT_DOUBLE_EQ(out.r_CN_N[1], 7.5e3 * 5.0); + EXPECT_DOUBLE_EQ(out.v_BN_N[1], 7.5e3); +} + +TEST(StateExtrapolation, isNoOpWhenMessageWrittenAtCurrentTime) +{ + const SCStatesMsgPayload in = makeState(); + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(in, 5000000000ULL, 5000000000ULL, 0); + + EXPECT_DOUBLE_EQ(out.r_BN_N[1], in.r_BN_N[1]); +} + +TEST(StateExtrapolation, isNoOpForMessageOlderThanThePreviousUpdate) +{ + const SCStatesMsgPayload in = makeState(); + // written at t = 0 and read at t = 30 s by a module that last updated at t = 20 s: stale message + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(in, 30000000000ULL, 0, 20000000000ULL); + + EXPECT_DOUBLE_EQ(out.r_BN_N[1], in.r_BN_N[1]); +} + +TEST(StateExtrapolation, scalesWithTheMessageAge) +{ + const uint64_t secondNanos = 1000000000ULL; // [ns] + for (uint64_t ageSeconds : { 1ULL, 5ULL, 60ULL, 3600ULL }) { + const uint64_t written = 100 * secondNanos; + const SCStatesMsgPayload out = + extrapolateScStateToStepMidpoint(makeState(), written + ageSeconds * secondNanos, written, written); + + EXPECT_DOUBLE_EQ(out.r_BN_N[1], 7.5e3 * 0.5 * static_cast(ageSeconds)) << "age [s] " << ageSeconds; + EXPECT_DOUBLE_EQ(out.r_CN_N[1], 7.5e3 * 0.5 * static_cast(ageSeconds)) << "age [s] " << ageSeconds; + } +} + +TEST(StateExtrapolation, extrapolatesEveryAxisWithItsOwnVelocity) +{ + SCStatesMsgPayload in{}; + for (int i = 0; i < 3; i++) { + in.r_BN_N[i] = 1.0e6 * (i + 1); // [m] + in.v_BN_N[i] = 1.0e3 * (i + 1); // [m/s] + } + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(in, 20000000000ULL, 10000000000ULL, 10000000000ULL); + + for (int i = 0; i < 3; i++) { + EXPECT_DOUBLE_EQ(out.r_BN_N[i], in.r_BN_N[i] + in.v_BN_N[i] * 5.0); + } +} + +TEST(StateExtrapolation, leavesEverythingExceptThePositionsUnchanged) +{ + SCStatesMsgPayload in = makeState(); + in.sigma_BN[2] = 0.25; // [-] + in.omega_BN_B[0] = 1.0e-3; // [rad/s] + in.TotalAccumDV_BN_B[1] = 2.5; // [m/s] + in.MRPSwitchCount = 3; // [-] + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(in, 10000000000ULL, 0, 0); + + EXPECT_DOUBLE_EQ(out.v_BN_N[1], in.v_BN_N[1]); + EXPECT_DOUBLE_EQ(out.v_CN_N[1], in.v_CN_N[1]); + EXPECT_DOUBLE_EQ(out.sigma_BN[2], in.sigma_BN[2]); + EXPECT_DOUBLE_EQ(out.omega_BN_B[0], in.omega_BN_B[0]); + EXPECT_DOUBLE_EQ(out.TotalAccumDV_BN_B[1], in.TotalAccumDV_BN_B[1]); + EXPECT_EQ(out.MRPSwitchCount, in.MRPSwitchCount); + EXPECT_DOUBLE_EQ(out.r_BN_N[0], in.r_BN_N[0]); // zero x velocity +} + +TEST(StateExtrapolation, isNoOpForAMessageOneNanosecondBeforeThePreviousUpdate) +{ + const SCStatesMsgPayload in = makeState(); + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(in, 20000000000ULL, 9999999999ULL, 10000000000ULL); + + EXPECT_DOUBLE_EQ(out.r_BN_N[1], in.r_BN_N[1]); + EXPECT_DOUBLE_EQ(out.r_CN_N[1], in.r_CN_N[1]); +} + +TEST(StateExtrapolation, extrapolatesTheMessageOfTheInitialStateAfterReset) +{ + // after Reset the previous update is the start time, and the first state was written at the start time + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(makeState(), 10000000000ULL, 0, 0); + + EXPECT_DOUBLE_EQ(out.r_BN_N[1], 7.5e3 * 5.0); +} + +TEST(StateExtrapolation, isNoOpForAMessageWrittenAfterTheCurrentTime) +{ + const SCStatesMsgPayload in = makeState(); + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(in, 10000000000ULL, 20000000000ULL, 0); + + EXPECT_DOUBLE_EQ(out.r_BN_N[1], in.r_BN_N[1]); +} + +TEST(StateExtrapolation, isNoOpWithZeroVelocityAtAnyAge) +{ + SCStatesMsgPayload in = makeState(); + in.v_BN_N[1] = 0.0; + in.v_CN_N[1] = 0.0; + const SCStatesMsgPayload out = + extrapolateScStateToStepMidpoint(in, 20000000000ULL, 10000000000ULL, 10000000000ULL); + + EXPECT_DOUBLE_EQ(out.r_BN_N[0], in.r_BN_N[0]); + EXPECT_DOUBLE_EQ(out.r_BN_N[1], in.r_BN_N[1]); +} + +TEST(StateExtrapolation, doesNotModifyItsInput) +{ + const SCStatesMsgPayload in = makeState(); + extrapolateScStateToStepMidpoint(in, 20000000000ULL, 10000000000ULL, 10000000000ULL); + + EXPECT_DOUBLE_EQ(in.r_BN_N[1], 0.0); + EXPECT_DOUBLE_EQ(in.r_CN_N[1], 0.0); +} + +TEST(StateExtrapolation, isNoOpForAMessageNewerThanThePreviousUpdate) +{ + // a spacecraft that runs faster than the module: written at 9 s, the module last updated at 0 s and runs at 10 s + const SCStatesMsgPayload in = makeState(); + const SCStatesMsgPayload out = extrapolateScStateToStepMidpoint(in, 10000000000ULL, 9000000000ULL, 0); + + EXPECT_DOUBLE_EQ(out.r_BN_N[1], in.r_BN_N[1]); + EXPECT_DOUBLE_EQ(out.r_CN_N[1], in.r_CN_N[1]); +} + +namespace { +constexpr uint64_t SECOND_NANOS = 1000000000ULL; // [ns] + +/*! Prepare the extrapolation for one spacecraft message and apply it. */ +SCStatesMsgPayload +applySingle(ScStateExtrapolation& extrapolation, + const SCStatesMsgPayload& in, + uint64_t now, + uint64_t written, + uint64_t previousUpdate, + BSKLogger& logger) +{ + extrapolation.prepare(now, previousUpdate, { written }, logger); + return extrapolation.apply(in, now, written, previousUpdate); +} + +/*! Feed the spacecraft write times seen by a module running at the given period and record which updates were extrapolated. */ +void +runModule(ScStateExtrapolation& extrapolation, + BSKLogger& logger, + uint64_t modulePeriodNanos, + uint64_t scPeriodNanos, + int numUpdates, + std::vector& extrapolated, + bool scRunsFirst = false) +{ + const SCStatesMsgPayload in = makeState(); + uint64_t previousUpdate = 0; // [ns] + for (int k = 0; k < numUpdates; k++) { + const uint64_t now = static_cast(k) * modulePeriodNanos; // [ns] + // by default the spacecraft runs after the module in each task: its newest message is from the last spacecraft + // update before the current time. If it runs first, a spacecraft update at the current time is already visible. + const uint64_t written = scRunsFirst ? (now / scPeriodNanos) * scPeriodNanos + : ((now == 0 ? 0 : now - 1) / scPeriodNanos) * scPeriodNanos; // [ns] + const SCStatesMsgPayload out = applySingle(extrapolation, in, now, written, previousUpdate, logger); + extrapolated.push_back(out.r_BN_N[1] != in.r_BN_N[1]); + previousUpdate = now; + } +} +} // namespace + +TEST(ScStateExtrapolation, matchedTaskPeriodsDoNotWarnAndAreExtrapolated) +{ + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + std::vector extrapolated; + runModule(extrapolation, logger, 10 * SECOND_NANOS, 10 * SECOND_NANOS, 5, extrapolated); + + EXPECT_FALSE(extrapolation.mismatchDetected()); + // the spacecraft period is only known after two write times: the first two updates are not extrapolated + EXPECT_FALSE(extrapolated[0]); + EXPECT_FALSE(extrapolated[1]); + for (std::size_t k = 2; k < extrapolated.size(); k++) { + EXPECT_TRUE(extrapolated[k]) << "update " << k; + } +} + +TEST(ScStateExtrapolation, fasterSpacecraftWarnsAndIsNotExtrapolated) +{ + // spacecraft every 1 s, module every 10 s: messages written at 9, 19 and 29 s are read at 10, 20 and 30 s + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + std::vector extrapolated; + runModule(extrapolation, logger, 10 * SECOND_NANOS, SECOND_NANOS, 4, extrapolated); + + EXPECT_TRUE(extrapolation.mismatchDetected()); + for (std::size_t k = 1; k < extrapolated.size(); k++) { + EXPECT_FALSE(extrapolated[k]) << "update " << k; + } +} + +TEST(ScStateExtrapolation, slowerSpacecraftWarnsAndIsNotExtrapolated) +{ + // spacecraft every 10 s, module every 1 s + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + std::vector extrapolated; + runModule(extrapolation, logger, SECOND_NANOS, 10 * SECOND_NANOS, 25, extrapolated); + + EXPECT_TRUE(extrapolation.mismatchDetected()); + // Nothing is extrapolated before the spacecraft period is known (k = 1), nor after two successive write times show + // a spacecraft interval of 10 s that differs from the 1 s module interval (including the update right after each + // spacecraft write at 21 s, 31 s, ...). + for (std::size_t k = 0; k < extrapolated.size(); k++) { + EXPECT_FALSE(extrapolated[k]) << "update " << k; + } +} + +TEST(ScStateExtrapolation, fasterSpacecraftWrittenAtTheCurrentTimeIsNeitherExtrapolatedNorWarned) +{ + // spacecraft every 1 s running before the module, module every 10 s: the message read at 10, 20, ... s was written + // at the same time, so it needs no extrapolation, and the sampled write times (10 s apart) look like a matched + // task period, so no warning can be given + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + std::vector extrapolated; + runModule(extrapolation, logger, 10 * SECOND_NANOS, SECOND_NANOS, 6, extrapolated, true); + + EXPECT_FALSE(extrapolation.mismatchDetected()); + for (std::size_t k = 0; k < extrapolated.size(); k++) { + EXPECT_FALSE(extrapolated[k]) << "update " << k; + } +} + +TEST(ScStateExtrapolation, slowerSpacecraftWrittenAtTheCurrentTimeIsNeverExtrapolated) +{ + // spacecraft every 20 s running before the module, module every 10 s: messages written at 0, 0, 20, 20, 40, 40 s + // are read at 0, 10, 20, 30, 40, 50 s + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + std::vector extrapolated; + runModule(extrapolation, logger, 10 * SECOND_NANOS, 20 * SECOND_NANOS, 3, extrapolated, true); + + // up to the update at 20 s the message is read at the time it was written, so there is nothing to warn about + EXPECT_FALSE(extrapolation.mismatchDetected()); + + extrapolated.clear(); + ScStateExtrapolation extrapolationLonger; + extrapolationLonger.setEnabled(true); + runModule(extrapolationLonger, logger, 10 * SECOND_NANOS, 20 * SECOND_NANOS, 8, extrapolated, true); + + // the update at 30 s reads the message written at 20 s, and the observed write interval of 20 s differs from the + // 10 s module interval, so the mismatch is detected from there on + EXPECT_TRUE(extrapolationLonger.mismatchDetected()); + for (std::size_t k = 0; k < extrapolated.size(); k++) { + EXPECT_FALSE(extrapolated[k]) << "update " << k; + } +} + +TEST(ScStateExtrapolation, isNotExtrapolatedBeforeTwoWriteTimesAreKnown) +{ + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + const SCStatesMsgPayload in = makeState(); + // first observation of a message written at the previous update: the period is unknown + const SCStatesMsgPayload first = applySingle(extrapolation, in, 20 * SECOND_NANOS, 10 * SECOND_NANOS, 10 * SECOND_NANOS, logger); + EXPECT_DOUBLE_EQ(first.r_BN_N[1], in.r_BN_N[1]); + // second write time observed with an interval equal to the module interval + const SCStatesMsgPayload second = applySingle(extrapolation, in, 30 * SECOND_NANOS, 20 * SECOND_NANOS, 20 * SECOND_NANOS, logger); + EXPECT_NE(second.r_BN_N[1], in.r_BN_N[1]); +} + +TEST(ScStateExtrapolation, staleMessageDoesNotWarn) +{ + // a state written once at the start of the simulation and never rewritten + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + const SCStatesMsgPayload in = makeState(); + for (uint64_t k = 0; k < 5; k++) { + applySingle(extrapolation, in, k * SECOND_NANOS, 0, k == 0 ? 0 : (k - 1) * SECOND_NANOS, logger); + } + + EXPECT_FALSE(extrapolation.mismatchDetected()); +} + +TEST(ScStateExtrapolation, disabledExtrapolationNeverWarnsOrModifiesTheState) +{ + ScStateExtrapolation extrapolation; + BSKLogger logger; + std::vector extrapolated; + runModule(extrapolation, logger, 10 * SECOND_NANOS, SECOND_NANOS, 4, extrapolated); + + EXPECT_FALSE(extrapolation.mismatchDetected()); + for (bool e : extrapolated) { + EXPECT_FALSE(e); + } +} + +TEST(PlanetStateExtrapolation, translatingPlanetKeepsTheSeparationAtEveryUpdateIncludingTheStartup) +{ + // The spacecraft and the planet translate together at 30 km/s, 400 km apart radially. The module runs before the + // spacecraft: at update k it reads the spacecraft written at the previous update and the planet written now. + const double speed = 3.0e4; // [m/s] + const double separation0 = 4.0e5; // [m] + const uint64_t step = 10 * SECOND_NANOS; // [ns] + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + for (uint64_t k = 0; k < 6; k++) { + const uint64_t now = k * step; // [ns] + const uint64_t previous = k == 0 ? 0 : (k - 1) * step; // [ns] + const uint64_t scWritten = previous; // [ns] + SCStatesMsgPayload sc = makeState(); + sc.r_BN_N[0] = separation0 + speed * diffNanoToSec(scWritten, 0); // [m] + sc.v_BN_N[0] = speed; // [m/s] + SpicePlanetStateMsgPayload planet{}; + planet.PositionVector[0] = speed * diffNanoToSec(now, 0); // [m] + planet.VelocityVector[0] = speed; // [m/s] + for (int i = 0; i < 3; i++) { + planet.J20002Pfix[i][i] = 1.0; // [-] + } + extrapolation.prepare(now, previous, { scWritten }, logger); + const SCStatesMsgPayload scOut = extrapolation.apply(sc, now, scWritten, previous); + const SpicePlanetStateMsgPayload planetOut = extrapolation.applyPlanet(planet, now, now, previous); + EXPECT_NEAR(scOut.r_BN_N[0] - planetOut.PositionVector[0], separation0, 1e-6) << "update " << k; + } +} + +TEST(PlanetStateExtrapolation, planetIsNotMovedIfTheSpacecraftStateIsNotExtrapolated) +{ + SpicePlanetStateMsgPayload planet{}; + planet.PositionVector[0] = 1.0e7; // [m] + planet.VelocityVector[0] = 3.0e4; // [m/s] + + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + // message written at 0 s but previous update at 10 s: stale, not extrapolated + applySingle(extrapolation, makeState(), 20 * SECOND_NANOS, 0, 10 * SECOND_NANOS, logger); + const SpicePlanetStateMsgPayload out = + extrapolation.applyPlanet(planet, 20 * SECOND_NANOS, 20 * SECOND_NANOS, 10 * SECOND_NANOS); + + EXPECT_DOUBLE_EQ(out.PositionVector[0], planet.PositionVector[0]); +} + +TEST(PlanetStateExtrapolation, spinningPlanetOrientationStaysOrthonormal) +{ + const double spinRate = OMEGA_EARTH; // [rad/s] Earth rotation rate + SpicePlanetStateMsgPayload planet{}; + planet.J20002Pfix[0][0] = 1.0; // [-] + planet.J20002Pfix[1][1] = 1.0; // [-] + planet.J20002Pfix[2][2] = 1.0; // [-] + // d[PN]/dt = -[omega_P x][PN] with omega along +z: only the 0-1 block is non-zero + planet.J20002Pfix_dot[0][1] = spinRate; // [1/s] + planet.J20002Pfix_dot[1][0] = -spinRate; // [1/s] + + const SpicePlanetStateMsgPayload out = extrapolatePlanetStateToEpoch(planet, 3600 * SECOND_NANOS, 0); + + Eigen::Matrix3d dcm; + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + dcm(i, j) = out.J20002Pfix[i][j]; + } + } + EXPECT_NEAR((dcm * dcm.transpose() - Eigen::Matrix3d::Identity()).norm(), 0.0, 1e-12); + EXPECT_NEAR(dcm(0, 1), std::sin(spinRate * 3600.0), 1e-12); +} + +TEST(PlanetStateExtrapolation, spinningPlanetKeepsTheAngularVelocityConsistent) +{ + const double spinRate = OMEGA_EARTH; // [rad/s] Earth rotation rate + SpicePlanetStateMsgPayload planet{}; + planet.J20002Pfix[0][0] = 1.0; // [-] + planet.J20002Pfix[1][1] = 1.0; // [-] + planet.J20002Pfix[2][2] = 1.0; // [-] + planet.J20002Pfix_dot[0][1] = spinRate; // [1/s] + planet.J20002Pfix_dot[1][0] = -spinRate; // [1/s] + + // angular velocity reconstructed as in WindBase::updatePlanetOmegaFromSpice() + const auto omegaFrom = [](const SpicePlanetStateMsgPayload& p) { + Eigen::Map> C_dot(p.J20002Pfix_dot[0]); + Eigen::Map> C(p.J20002Pfix[0]); + const Eigen::Matrix3d skewP = -C_dot * C.transpose(); + const Eigen::Vector3d omega_P(skewP(2, 1), skewP(0, 2), skewP(1, 0)); + return Eigen::Vector3d(C.transpose() * omega_P); // [rad/s] + }; + + const SpicePlanetStateMsgPayload out = extrapolatePlanetStateToEpoch(planet, 3600 * SECOND_NANOS, 0); + + EXPECT_NEAR((omegaFrom(out) - omegaFrom(planet)).norm(), 0.0, 1e-15); +} + +TEST(PlanetStateExtrapolation, planetIsNotMovedIfTheExtrapolationIsDisabled) +{ + SpicePlanetStateMsgPayload planet{}; + planet.PositionVector[0] = 1.0e7; // [m] + planet.VelocityVector[0] = 3.0e4; // [m/s] + + ScStateExtrapolation extrapolation; + BSKLogger logger; + applySingle(extrapolation, makeState(), 20 * SECOND_NANOS, 10 * SECOND_NANOS, 10 * SECOND_NANOS, logger); + const SpicePlanetStateMsgPayload out = + extrapolation.applyPlanet(planet, 20 * SECOND_NANOS, 20 * SECOND_NANOS, 10 * SECOND_NANOS); + + EXPECT_DOUBLE_EQ(out.PositionVector[0], planet.PositionVector[0]); +} + +TEST(PlanetStateExtrapolation, resetClearsTheExtrapolatedStateOfThePlanet) +{ + SpicePlanetStateMsgPayload planet{}; + planet.PositionVector[0] = 1.0e7; // [m] + planet.VelocityVector[0] = 3.0e4; // [m/s] + + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + const uint64_t step = 10 * SECOND_NANOS; // [ns] + extrapolation.prepare(step, 0, { 0 }, logger); // first write time, the period is known at the next update + applySingle(extrapolation, makeState(), 2 * step, step, step, logger); + const SpicePlanetStateMsgPayload moved = extrapolation.applyPlanet(planet, 2 * step, 2 * step, step); + EXPECT_NE(moved.PositionVector[0], planet.PositionVector[0]); + + extrapolation.reset(); + const SpicePlanetStateMsgPayload out = extrapolation.applyPlanet(planet, 2 * step, 2 * step, step); + + EXPECT_DOUBLE_EQ(out.PositionVector[0], planet.PositionVector[0]); +} + +namespace { +/*! Two spacecraft read by one module at 20 s with the previous update at 10 s. */ +struct TwoSpacecraftResult +{ + SCStatesMsgPayload first; + SCStatesMsgPayload second; + SpicePlanetStateMsgPayload planet; + bool mismatch; +}; + +TwoSpacecraftResult runTwoSpacecraft(uint64_t firstWritten, uint64_t secondWritten, const SpicePlanetStateMsgPayload& planet) +{ + const uint64_t now = 20 * SECOND_NANOS; // [ns] + const uint64_t previous = 10 * SECOND_NANOS; // [ns] + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + // two earlier updates so that both messages are known to be rewritten by their spacecraft + extrapolation.prepare(previous, 0, { 0, 0 }, logger); + extrapolation.prepare(now, previous, { firstWritten, secondWritten }, logger); + const SCStatesMsgPayload in = makeState(); + return { extrapolation.apply(in, now, firstWritten, previous), + extrapolation.apply(in, now, secondWritten, previous), + extrapolation.applyPlanet(planet, now, now, previous), + extrapolation.mismatchDetected() }; +} + +SpicePlanetStateMsgPayload +makeMovingPlanet() +{ + SpicePlanetStateMsgPayload planet{}; + planet.PositionVector[0] = 1.0e7; // [m] + planet.VelocityVector[0] = 3.0e4; // [m/s] + return planet; +} +} // namespace + +TEST(ScStateExtrapolation, allSpacecraftLaggedExtrapolatesBothAndThePlanet) +{ + const SpicePlanetStateMsgPayload planet = makeMovingPlanet(); + const SCStatesMsgPayload in = makeState(); + const TwoSpacecraftResult r = runTwoSpacecraft(10 * SECOND_NANOS, 10 * SECOND_NANOS, planet); + + EXPECT_FALSE(r.mismatch); + EXPECT_NE(r.first.r_BN_N[1], in.r_BN_N[1]); + EXPECT_NE(r.second.r_BN_N[1], in.r_BN_N[1]); + EXPECT_NE(r.planet.PositionVector[0], planet.PositionVector[0]); +} + +TEST(ScStateExtrapolation, oneStaleSpacecraftDisablesTheExtrapolationOfBothAndThePlanet) +{ + // the first spacecraft is lagged, the second one was last written at 5 s (not at the previous update, 10 s) + const SpicePlanetStateMsgPayload planet = makeMovingPlanet(); + const SCStatesMsgPayload in = makeState(); + const TwoSpacecraftResult r = runTwoSpacecraft(10 * SECOND_NANOS, 5 * SECOND_NANOS, planet); + + EXPECT_TRUE(r.mismatch); + EXPECT_DOUBLE_EQ(r.first.r_BN_N[1], in.r_BN_N[1]); + EXPECT_DOUBLE_EQ(r.second.r_BN_N[1], in.r_BN_N[1]); + EXPECT_DOUBLE_EQ(r.planet.PositionVector[0], planet.PositionVector[0]); +} + +TEST(ScStateExtrapolation, theOrderOfTheSpacecraftDoesNotChangeTheDecision) +{ + const SpicePlanetStateMsgPayload planet = makeMovingPlanet(); + const SCStatesMsgPayload in = makeState(); + const TwoSpacecraftResult r = runTwoSpacecraft(5 * SECOND_NANOS, 10 * SECOND_NANOS, planet); + + EXPECT_TRUE(r.mismatch); + EXPECT_DOUBLE_EQ(r.first.r_BN_N[1], in.r_BN_N[1]); + EXPECT_DOUBLE_EQ(r.second.r_BN_N[1], in.r_BN_N[1]); + EXPECT_DOUBLE_EQ(r.planet.PositionVector[0], planet.PositionVector[0]); +} + +TEST(ScStateExtrapolation, noSpacecraftIsNeverExtrapolated) +{ + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + SpicePlanetStateMsgPayload planet{}; + planet.PositionVector[0] = 1.0e9; // [m] + planet.VelocityVector[0] = 1.0e3; // [m/s] + + extrapolation.prepare(20 * SECOND_NANOS, 10 * SECOND_NANOS, {}, logger); + const SpicePlanetStateMsgPayload out = extrapolation.applyPlanet(planet, 20 * SECOND_NANOS, 0, 10 * SECOND_NANOS); + + EXPECT_DOUBLE_EQ(out.PositionVector[0], planet.PositionVector[0]); +} + +TEST(DcmExtrapolation, advancedRateIsTheRotatedRate) +{ + const double spinRate = OMEGA_EARTH; // [rad/s] Earth rotation rate + const double dt = 3600.0; // [s] + // [NP] of a planet spinning about +z, from the identity orientation + Eigen::Matrix3d dcm_NPfix = Eigen::Matrix3d::Identity(); + Eigen::Matrix3d dcm_NPfix_dot = Eigen::Matrix3d::Zero(); + dcm_NPfix_dot(0, 1) = -spinRate; // [1/s] + dcm_NPfix_dot(1, 0) = spinRate; // [1/s] + + const PlanetSpin spin = planetSpin(dcm_NPfix, dcm_NPfix_dot); + const Eigen::Matrix3d advanced = advanceDcm(dcm_NPfix, spin, dt); + const Eigen::Matrix3d advancedDot = advanceDcmDot(spin, advanced, dcm_NPfix_dot); + + // the rate of a rotation about +z at the advanced epoch + EXPECT_NEAR(advancedDot(0, 0), -spinRate * std::sin(spinRate * dt), 1e-15); + EXPECT_NEAR(advancedDot(0, 1), -spinRate * std::cos(spinRate * dt), 1e-15); + EXPECT_NEAR(advancedDot(1, 0), spinRate * std::cos(spinRate * dt), 1e-15); + EXPECT_NEAR(advancedDot(1, 1), -spinRate * std::sin(spinRate * dt), 1e-15); + // a zero rate stays zero + const PlanetSpin zeroSpin = planetSpin(dcm_NPfix, Eigen::Matrix3d::Zero()); + EXPECT_TRUE(advanceDcmDot(zeroSpin, advanced, Eigen::Matrix3d::Zero()).isZero()); +} + +TEST(PlanetStateExtrapolation, positionOnlyPlanetMessageKeepsAValidOrientation) +{ + SpicePlanetStateMsgPayload planet{}; // orientation fields left at their zero value + planet.PositionVector[0] = 1.0e9; // [m] + planet.VelocityVector[0] = 1.0e3; // [m/s] + + const SpicePlanetStateMsgPayload out = extrapolatePlanetStateToEpoch(planet, 10 * SECOND_NANOS, 0); + + EXPECT_DOUBLE_EQ(out.PositionVector[0], 1.0e9 + 1.0e3 * 10.0); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + EXPECT_DOUBLE_EQ(out.J20002Pfix[i][j], i == j ? 1.0 : 0.0); // [-] + } + } +} + +TEST(ScStateExtrapolation, applyPlanetTreatsPositionOnlyOrientationAsIdentityOnEveryPath) +{ + SpicePlanetStateMsgPayload planet{}; // orientation fields left at their zero value + planet.PositionVector[0] = 1.0e9; // [m] + planet.VelocityVector[0] = 1.0e3; // [m/s] + BSKLogger logger; + + // disabled: the planet is returned as written, with a valid orientation + ScStateExtrapolation disabled; + disabled.prepare(20 * SECOND_NANOS, 10 * SECOND_NANOS, { 10 * SECOND_NANOS }, logger); + const SpicePlanetStateMsgPayload outDisabled = + disabled.applyPlanet(planet, 20 * SECOND_NANOS, 20 * SECOND_NANOS, 10 * SECOND_NANOS); + EXPECT_DOUBLE_EQ(outDisabled.PositionVector[0], planet.PositionVector[0]); + + // enabled, dt == 0: the planet is written at the epoch the spacecraft states stay at + ScStateExtrapolation startup; + startup.setEnabled(true); + startup.prepare(20 * SECOND_NANOS, 10 * SECOND_NANOS, { 10 * SECOND_NANOS }, logger); + const SpicePlanetStateMsgPayload outStartup = + startup.applyPlanet(planet, 20 * SECOND_NANOS, 10 * SECOND_NANOS, 10 * SECOND_NANOS); + EXPECT_DOUBLE_EQ(outStartup.PositionVector[0], planet.PositionVector[0]); + + for (const SpicePlanetStateMsgPayload& out : { outDisabled, outStartup }) { + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + EXPECT_DOUBLE_EQ(out.J20002Pfix[i][j], i == j ? 1.0 : 0.0); // [-] + } + } + } +} + +TEST(ScStateExtrapolation, evaluationEpochFollowsTheGeometry) +{ + const uint64_t step = 10 * SECOND_NANOS; // [ns] + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + + extrapolation.prepare(step, 0, { 0 }, logger); // period not yet known: geometry at the previous update + EXPECT_EQ(extrapolation.evaluationEpochNanos(step, 0), 0u); + + extrapolation.prepare(2 * step, step, { step }, logger); // period known: geometry at the midpoint + EXPECT_EQ(extrapolation.evaluationEpochNanos(2 * step, step), step + step / 2); + + ScStateExtrapolation disabled; + disabled.prepare(2 * step, step, { step }, logger); + EXPECT_EQ(disabled.evaluationEpochNanos(2 * step, step), 2 * step); +} + +namespace { +/*! Stand-in for a ReadFunctor of a spacecraft state message. */ +struct FakeScStateMsg +{ + SCStatesMsgPayload payload{}; + uint64_t written = 0; // [ns] time the message was written + + SCStatesMsgPayload operator()() { return this->payload; } + uint64_t timeWritten() { return this->written; } +}; + +/*! Stand-in for a ReadFunctor of a planet state message. */ +struct FakePlanetMsg +{ + SpicePlanetStateMsgPayload payload{}; + uint64_t written = 0; // [ns] time the message was written + + SpicePlanetStateMsgPayload operator()() { return this->payload; } + uint64_t timeWritten() { return this->written; } +}; +} // namespace + +TEST(ScStateExtrapolationMessages, prepareFromMessagesExtrapolatesAllSpacecraftWrittenAtThePreviousUpdate) +{ + const uint64_t step = 10 * SECOND_NANOS; // [ns] + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + std::vector msgs(2); + for (auto& msg : msgs) { + msg.payload = makeState(); + } + + extrapolation.prepareFromMessages(step, 0, msgs, logger); // first write time observed + msgs[0].written = msgs[1].written = step; + extrapolation.prepareFromMessages(2 * step, step, msgs, logger); // period known + + for (auto& msg : msgs) { + const SCStatesMsgPayload out = extrapolation.applyMessage(msg, 2 * step, step); + EXPECT_DOUBLE_EQ(out.r_BN_N[1], 7.5e3 * 5.0); // v * age / 2 + } + EXPECT_FALSE(extrapolation.mismatchDetected()); +} + +TEST(ScStateExtrapolationMessages, prepareFromMessagesMatchesPrepareWithTheWriteTimes) +{ + const uint64_t step = 10 * SECOND_NANOS; // [ns] + std::vector msgs(2); + msgs[0].written = step; + msgs[1].written = 0; // stale: disables the extrapolation of both + + ScStateExtrapolation fromMessages; + fromMessages.setEnabled(true); + ScStateExtrapolation fromTimes; + fromTimes.setEnabled(true); + BSKLogger loggerMessages; + BSKLogger loggerTimes; + for (uint64_t now : { step, 2 * step }) { + fromMessages.prepareFromMessages(now, now - step, msgs, loggerMessages); + fromTimes.prepare(now, now - step, { msgs[0].written, msgs[1].written }, loggerTimes); + } + + EXPECT_EQ(fromMessages.mismatchDetected(), fromTimes.mismatchDetected()); + for (auto& msg : msgs) { + msg.payload = makeState(); + const SCStatesMsgPayload expected = fromTimes.apply(msg.payload, 2 * step, msg.written, step); + const SCStatesMsgPayload out = fromMessages.applyMessage(msg, 2 * step, step); + EXPECT_DOUBLE_EQ(out.r_BN_N[1], expected.r_BN_N[1]); + EXPECT_DOUBLE_EQ(out.r_BN_N[1], msg.payload.r_BN_N[1]); // not extrapolated + } +} + +TEST(ScStateExtrapolationMessages, prepareFromMessageHandlesASingleSpacecraft) +{ + const uint64_t step = 10 * SECOND_NANOS; // [ns] + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + FakeScStateMsg msg; + msg.payload = makeState(); + + extrapolation.prepareFromMessage(step, 0, msg, logger); + msg.written = step; + extrapolation.prepareFromMessage(2 * step, step, msg, logger); + + const SCStatesMsgPayload out = extrapolation.applyMessage(msg, 2 * step, step); + EXPECT_DOUBLE_EQ(out.r_BN_N[1], 7.5e3 * 5.0); + EXPECT_EQ(extrapolation.evaluationEpochNanos(2 * step, step), step + step / 2); +} + +TEST(ScStateExtrapolationMessages, applyMessageDoesNotExtrapolateWhenDisabled) +{ + const uint64_t step = 10 * SECOND_NANOS; // [ns] + ScStateExtrapolation extrapolation; + BSKLogger logger; + FakeScStateMsg msg; + msg.payload = makeState(); + msg.written = step; + + extrapolation.prepareFromMessage(2 * step, step, msg, logger); + + const SCStatesMsgPayload out = extrapolation.applyMessage(msg, 2 * step, step); + EXPECT_DOUBLE_EQ(out.r_BN_N[1], msg.payload.r_BN_N[1]); +} + +TEST(ScStateExtrapolationMessages, applyPlanetMessageMovesThePlanetLikeApplyPlanet) +{ + const uint64_t step = 10 * SECOND_NANOS; // [ns] + ScStateExtrapolation extrapolation; + extrapolation.setEnabled(true); + BSKLogger logger; + FakeScStateMsg scMsg; + scMsg.payload = makeState(); + FakePlanetMsg planetMsg; + planetMsg.payload = makeMovingPlanet(); + planetMsg.written = 2 * step; + + extrapolation.prepareFromMessage(step, 0, scMsg, logger); + scMsg.written = step; + extrapolation.prepareFromMessage(2 * step, step, scMsg, logger); + + const SpicePlanetStateMsgPayload out = extrapolation.applyPlanetMessage(planetMsg, 2 * step, step); + const SpicePlanetStateMsgPayload expected = + extrapolation.applyPlanet(planetMsg.payload, 2 * step, planetMsg.written, step); + EXPECT_DOUBLE_EQ(out.PositionVector[0], expected.PositionVector[0]); + EXPECT_DOUBLE_EQ(out.PositionVector[0], 1.0e7 - 3.0e4 * 5.0); // moved back to the step midpoint +} diff --git a/src/architecture/utilities/tests/test_utcTime.cpp b/src/architecture/utilities/tests/test_utcTime.cpp new file mode 100644 index 00000000000..a269da38c59 --- /dev/null +++ b/src/architecture/utilities/tests/test_utcTime.cpp @@ -0,0 +1,92 @@ +/* + ISC License + + Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + + */ + +#include + +#include +#include + +#include "architecture/utilities/utcTime.h" + +namespace { +struct tm +makeDate(int year, int month, int day, int hour, int minute, int second) +{ + struct tm date{}; + date.tm_year = year - 1900; + date.tm_mon = month - 1; + date.tm_mday = day; + date.tm_hour = hour; + date.tm_min = minute; + date.tm_sec = second; + return date; +} +} // namespace + +TEST(UtcTime, returnsSecondsSinceTheUnixEpoch) +{ + struct tm date = makeDate(2026, 1, 1, 0, 0, 0); + + EXPECT_EQ(normalizeUtcTime(&date), 1767225600); // [s] 2026-01-01 00:00:00 UTC +} + +TEST(UtcTime, rollsOverTheCalendarFields) +{ + struct tm date = makeDate(2026, 12, 31, 23, 59, 60); + normalizeUtcTime(&date); + + EXPECT_EQ(date.tm_year, 2027 - 1900); + EXPECT_EQ(date.tm_mon, 0); + EXPECT_EQ(date.tm_mday, 1); + EXPECT_EQ(date.tm_hour, 0); + EXPECT_EQ(date.tm_min, 0); + EXPECT_EQ(date.tm_sec, 0); + EXPECT_EQ(date.tm_yday, 0); +} + +#ifndef _WIN32 +TEST(UtcTime, isIndependentOfTheTimeZoneAcrossADaylightSavingTransition) +{ + // Europe/Rome changes to summer time on 2026-03-29. The date is advanced like the environment modules do: the + // epoch is normalized once, then the elapsed seconds are added to a copy that is normalized again. + const char* previousTz = getenv("TZ"); + const std::string savedTz = previousTz ? previousTz : ""; + setenv("TZ", "Europe/Rome", 1); + tzset(); + + struct tm epoch = makeDate(2026, 3, 20, 0, 0, 0); + normalizeUtcTime(&epoch); + struct tm later = epoch; + later.tm_sec += 10 * 86400; // [s] 10 days, past the transition + normalizeUtcTime(&later); + + EXPECT_EQ(later.tm_mon, 2); + EXPECT_EQ(later.tm_mday, 30); + EXPECT_EQ(later.tm_hour, 0); + EXPECT_EQ(later.tm_min, 0); + EXPECT_EQ(later.tm_yday, 31 + 28 + 30 - 1); + + if (previousTz) { + setenv("TZ", savedTz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} +#endif diff --git a/src/architecture/utilities/utcTime.h b/src/architecture/utilities/utcTime.h new file mode 100644 index 00000000000..9f48f3f3d2c --- /dev/null +++ b/src/architecture/utilities/utcTime.h @@ -0,0 +1,47 @@ +/* + ISC License + + Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + + */ + +#ifndef UTC_TIME_H +#define UTC_TIME_H + +#include + +/*! @brief Normalize a broken-down UTC date and time without using the time zone of the computer. + * + * The fields of the structure are adjusted to a valid calendar date and time, so that for example adding seconds to + * `tm_sec` rolls over the minutes, hours, days, and years, and `tm_wday` and `tm_yday` are set. Unlike `mktime`, the + * structure is interpreted as UTC and not as local time. `mktime` shifts the hour by one when the daylight saving flag + * left in the structure by an earlier call does not match the date, which happens when a date is advanced across a + * daylight saving transition on a computer whose time zone has one. + * + * @param dateTime [in,out] broken-down UTC date and time, normalized in place; `tm_isdst` is set to 0 + * @return the time in seconds since 1970-01-01 00:00:00 UTC, or -1 if it cannot be represented + */ +static inline time_t +normalizeUtcTime(struct tm* dateTime) +{ + dateTime->tm_isdst = 0; +#ifdef _WIN32 + return _mkgmtime(dateTime); +#else + return timegm(dateTime); +#endif +} + +#endif diff --git a/src/simulation/dynamics/RadiationPressure/_UnitTest/test_radiation_pressure_integrated.py b/src/simulation/dynamics/RadiationPressure/_UnitTest/test_radiation_pressure_integrated.py index 6d5f6d70891..34cc3130f10 100644 --- a/src/simulation/dynamics/RadiationPressure/_UnitTest/test_radiation_pressure_integrated.py +++ b/src/simulation/dynamics/RadiationPressure/_UnitTest/test_radiation_pressure_integrated.py @@ -154,17 +154,19 @@ def radiationPressureIntegratedTest(show_plots): skipValue = int(len(pos_rel_earth) / (numTruthPoints - 1)) pos_rel_earth_parse = pos_rel_earth[::skipValue] - # true position for un perturbed 2 body GEO orbit with cannonball SRP + # true position of the GEO orbit with point-mass Earth gravity, the Sun as a third body, and cannonball SRP. + # The values come from an independent integration (scipy DOP853, rtol 1e-13) of these accelerations with the + # Sun position from the SPICE kernels, sampled at the logged times. true_pos = np.array([[-2.18197848e+07, 3.58872415e+07, 0.00000000e+00] - ,[-3.97753171e+07, 1.34888807e+07, -7.27348185e+01] - ,[-3.91389782e+07, -1.52401325e+07, -3.04240669e+02] - ,[-2.01837764e+07, -3.68366798e+07, -6.33981582e+02] - ,[ 8.21688462e+06, -4.11949916e+07, -9.08494855e+02] - ,[ 3.27533081e+07, -2.63022792e+07, -9.52917670e+02] - ,[ 4.19944317e+07, 9.02698019e+05, -6.74284599e+02] - ,[ 3.15826998e+07, 2.76843894e+07, -1.38342155e+02] - ,[ 6.38602614e+06, 4.15048356e+07, 4.30316096e+02] - ,[-2.18007830e+07, 3.58875125e+07, 7.40921837e+02]]) + ,[-3.97753164e+07, 1.34888800e+07, -7.27325107e+01] + ,[-3.91389742e+07, -1.52401342e+07, -3.04230960e+02] + ,[-2.01837657e+07, -3.68366793e+07, -6.33962893e+02] + ,[ 8.21690111e+06, -4.11949809e+07, -9.08472804e+02] + ,[ 3.27533203e+07, -2.63022524e+07, -9.52903969e+02] + ,[ 4.19944254e+07, 9.02735498e+05, -6.74291081e+02] + ,[ 3.15826687e+07, 2.76844202e+07, -1.38372868e+02] + ,[ 6.38598026e+06, 4.15048418e+07, 4.30269951e+02] + ,[-2.18008229e+07, 3.58874883e+07, 7.40880287e+02]]) # compare the results to the truth values accuracy = 10.0 # meters diff --git a/src/simulation/dynamics/_GeneralModuleFiles/_UnitTestDynamics/test_effectorBranching_integrated.py b/src/simulation/dynamics/_GeneralModuleFiles/_UnitTestDynamics/test_effectorBranching_integrated.py index 8afde52c858..a998a50217d 100644 --- a/src/simulation/dynamics/_GeneralModuleFiles/_UnitTestDynamics/test_effectorBranching_integrated.py +++ b/src/simulation/dynamics/_GeneralModuleFiles/_UnitTestDynamics/test_effectorBranching_integrated.py @@ -825,9 +825,9 @@ def facetDragLoad_P(facetProps, sigma_PN, v_PN_N): def facetSRPLoad_P(srpProps, sigma_PN, r_PN_N): """Reference faceted SRP force and moment about the parent frame origin.""" - SOLAR_RAD_FLUX = 1368.0 # [W/m^2] solar radiation flux at 1 AU, as the module defines it + SOLAR_RAD_FLUX = 1361.0 # [W/m^2] solar radiation flux at 1 AU, as the module defines it SPEED_LIGHT = 299792458.0 # [m/s] - ASTRONOMICAL_UNIT = 149597870700.0 # [m] + ASTRONOMICAL_UNIT = 149597870693.0 # [m] AU2M of astroConstants.h dcm_PN = rbk.MRP2C(sigma_PN) r_SP_P = dcm_PN @ (np.array(srpProps.r_SN_N) - np.array(r_PN_N)) diff --git a/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.cpp b/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.cpp index de71ce7e2fa..875827bfc32 100644 --- a/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.cpp +++ b/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.cpp @@ -19,9 +19,12 @@ #include +#include + #include "gravityEffector.h" #include "architecture/utilities/linearAlgebra.h" #include "architecture/utilities/macroDefinitions.h" +#include "architecture/utilities/stateExtrapolation.h" GravBodyData::GravBodyData() @@ -55,26 +58,30 @@ void GravBodyData::initBody(int64_t moduleID [[maybe_unused]]) Eigen::Vector3d GravBodyData::computeGravityInertial(Eigen::Vector3d r_I, uint64_t simTimeNanos) { double dt = diffNanoToSec(simTimeNanos, this->timeWritten); - Eigen::Matrix3d dcm_PfixN = c2DArray2EigenMatrix3d(this->localPlanet.J20002Pfix).transpose(); - if (dcm_PfixN - .isZero()) { // Sanity check for connected messages that do not initialize J20002Pfix - dcm_PfixN = Eigen::Matrix3d::Identity(); + + // The message holds [PN], the rotation from the inertial to the planet-fixed frame. The orientation is advanced + // in its transpose [NP]. + Eigen::Matrix3d dcm_NPfix = c2DArray2EigenMatrix3d(this->localPlanet.J20002Pfix).transpose(); + if (dcm_NPfix.isZero()) { // Sanity check for connected messages that do not initialize J20002Pfix + dcm_NPfix = Eigen::Matrix3d::Identity(); } + const Eigen::Matrix3d dcm_NPfix_dot = c2DArray2EigenMatrix3d(this->localPlanet.J20002Pfix_dot).transpose(); - Eigen::Matrix3d dcm_PfixN_dot = - c2DArray2EigenMatrix3d(this->localPlanet.J20002Pfix_dot).transpose(); - dcm_PfixN += dcm_PfixN_dot * dt; + // Advance the planet orientation by the time since the message was written as a rotation + // about the planet angular velocity, see advanceDcm(). + const PlanetSpin spin = planetSpin(dcm_NPfix, dcm_NPfix_dot); + const Eigen::Matrix3d dcmAdvanced_NPfix = advanceDcm(dcm_NPfix, spin, dt); + const Eigen::Matrix3d dcmAdvanced_NPfix_dot = advanceDcmDot(spin, dcmAdvanced_NPfix, dcm_NPfix_dot); - // store the current planet orientation and rates - *this->J20002Pfix = dcm_PfixN; - *this->J20002Pfix_dot = dcm_PfixN_dot; + // store the current planet orientation and rates as [PN] and [PN_dot], as documented for the properties + *this->J20002Pfix = dcmAdvanced_NPfix.transpose(); + *this->J20002Pfix_dot = dcmAdvanced_NPfix_dot.transpose(); // Compute position in the body-fixed reference frame and compute the gravity - Eigen::Matrix3d dcm_NPfix = dcm_PfixN.transpose(); - Eigen::Vector3d r_Pfix = dcm_NPfix * r_I; + Eigen::Vector3d r_Pfix = dcmAdvanced_NPfix.transpose() * r_I; Eigen::Vector3d grav_Pfix = this->gravityModel->computeField(r_Pfix); - return dcm_PfixN * grav_Pfix; + return dcmAdvanced_NPfix * grav_Pfix; } void GravBodyData::loadEphemeris() diff --git a/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.h b/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.h index feaad28f312..b34b543b77a 100644 --- a/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.h +++ b/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.h @@ -34,6 +34,7 @@ #include "gravityModel.h" #include "pointMassGravityModel.h" #include "architecture/utilities/avsEigenSupport.h" +#include "architecture/utilities/stateExtrapolation.h" /** Container for gravitational body data * @@ -104,8 +105,8 @@ class GravBodyData Eigen::MatrixXd *r_PN_N; /**< [m] (state engine property) planet inertial position vector */ Eigen::MatrixXd *v_PN_N; /**< [m/s] (state engine property) planet inertial velocity vector */ Eigen::MatrixXd *muPlanet; /**< [m/s] (state engine property) planet inertial velocity vector */ - Eigen::MatrixXd *J20002Pfix; /**< [m/s] (state engine property) planet attitude [PN] */ - Eigen::MatrixXd *J20002Pfix_dot; /**< [m/s] (state engine property) planet attitude rate [PN_dot] */ + Eigen::MatrixXd *J20002Pfix; /**< [-] (state engine property) planet attitude [PN] */ + Eigen::MatrixXd *J20002Pfix_dot; /**< [1/s] (state engine property) planet attitude rate [PN_dot] */ uint64_t timeWritten = 0; /**< [ns] time the input planet state message was written */ }; diff --git a/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.rst b/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.rst index 37609eea598..70a482d6944 100644 --- a/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.rst +++ b/src/simulation/dynamics/_GeneralModuleFiles/gravityEffector.rst @@ -54,3 +54,13 @@ Each gravity body data object can be created using:: Note that the ``simIncludeGradBody.py`` helper file contains a gravity body factor class to facilitate setting up gravity bodies. + +Planet Orientation +------------------ +The planet orientation is read from ``planetBodyInMsg`` together with its time derivative ``J20002Pfix_dot``. Between +message updates the orientation is advanced as a rotation about the planet angular velocity, which keeps the +orientation matrix orthonormal at every integrator stage. A first-order update of the matrix elements would scale the +evaluated field by a relative error of order :math:`(\omega\,\Delta t)^2`. +The advanced orientation and its time derivative at the same epoch are stored as the state-engine properties +``[planetName].J20002Pfix`` and ``[planetName].J20002Pfix_dot``, both in the :math:`[PN]` convention of the +planet state message. diff --git a/src/simulation/dynamics/facetSRPDynamicEffector/_UnitTest/test_unitFacetSRPDynamicEffector.py b/src/simulation/dynamics/facetSRPDynamicEffector/_UnitTest/test_unitFacetSRPDynamicEffector.py index 9390b3bbea9..072a3d8a0b8 100644 --- a/src/simulation/dynamics/facetSRPDynamicEffector/_UnitTest/test_unitFacetSRPDynamicEffector.py +++ b/src/simulation/dynamics/facetSRPDynamicEffector/_UnitTest/test_unitFacetSRPDynamicEffector.py @@ -490,8 +490,8 @@ def computeFacetSRPForceTorque(index, r_SN_N): # Define required constants speedLight = 299792458.0 # [m/s] Speed of light - AstU = 149597870700.0 # [m] Astronomical unit - solarRadFlux = 1368.0 # [W/m^2] Solar radiation flux at 1 AU + AstU = 149597870693.0 # [m] Astronomical unit, AU2M of astroConstants.h + solarRadFlux = 1361.0 # [W/m^2] Solar radiation flux at 1 AU # Compute Sun direction relative to point B in B frame components dcm_BN = rbk.MRP2C(sigma_BN) diff --git a/src/simulation/dynamics/facetSRPDynamicEffector/facetSRPDynamicEffector.cpp b/src/simulation/dynamics/facetSRPDynamicEffector/facetSRPDynamicEffector.cpp index 2f59e2dbecc..50e9067dc7d 100644 --- a/src/simulation/dynamics/facetSRPDynamicEffector/facetSRPDynamicEffector.cpp +++ b/src/simulation/dynamics/facetSRPDynamicEffector/facetSRPDynamicEffector.cpp @@ -20,12 +20,9 @@ #include "facetSRPDynamicEffector.h" #include "architecture/utilities/rigidBodyKinematics.h" #include "architecture/utilities/avsEigenSupport.h" +#include "architecture/utilities/astroConstants.h" #include -const double speedLight = 299792458.0; // [m/s] Speed of light -const double AstU = 149597870700.0; // [m] Astronomical unit -const double solarRadFlux = 1368.0; // [W/m^2] Solar radiation flux at 1 AU - /*! This method resets required module variables and checks the input messages to ensure they are linked. @param currentSimNanos [ns] Time the method is called @@ -200,8 +197,8 @@ void FacetSRPDynamicEffector::computeForceTorque(double callTime [[maybe_unused] double cosTheta = 0.0; // Calculate the SRP pressure acting at the current spacecraft location - double numAU = AstU / r_SB_B.norm(); - double SRPPressure = (solarRadFlux / speedLight) * numAU * numAU; + double numAU = AU2M / r_SB_B.norm(); // [-] + double SRPPressure = (SOLAR_FLUX_EARTH / SPEED_LIGHT) * numAU * numAU; // Loop through the facets and calculate the total SRP force and torque acting on the spacecraft about point B for (uint64_t i = 0; i < this->numFacets; i++) { diff --git a/src/simulation/dynamics/facetSRPDynamicEffector/facetSRPDynamicEffector.rst b/src/simulation/dynamics/facetSRPDynamicEffector/facetSRPDynamicEffector.rst index 6b56d3ea7fc..672846e0584 100644 --- a/src/simulation/dynamics/facetSRPDynamicEffector/facetSRPDynamicEffector.rst +++ b/src/simulation/dynamics/facetSRPDynamicEffector/facetSRPDynamicEffector.rst @@ -72,7 +72,8 @@ by summing the SRP force contribution from all :math:`N` facets: Note that all values in the above expression must be specified in the spacecraft body frame. :math:`\theta` is defined as the incidence angle between each facet normal vector and the Sun-direction vector. :math:`P(|\boldsymbol{r}_{\text{sc}/ \odot\ }|)` represents the pressure acting on the spacecraft -scaled by the spacecraft heliocentric distance. :math:`\boldsymbol{\hat{s}}` is the unit direction +scaled by the spacecraft heliocentric distance. At 1 AU it is the solar flux ``SOLAR_FLUX_EARTH`` of ``astroConstants`` +divided by the speed of light. :math:`\boldsymbol{\hat{s}}` is the unit direction vector pointing radially towards the Sun from the spacecraft body frame origin point :math:`B`. This vector is found by subtracting the current spacecraft inertial position from the Sun position: diff --git a/src/simulation/dynamics/gravityEffector/_UnitTest/test_gravityRotatingPlanet.py b/src/simulation/dynamics/gravityEffector/_UnitTest/test_gravityRotatingPlanet.py new file mode 100644 index 00000000000..41868fe93e9 --- /dev/null +++ b/src/simulation/dynamics/gravityEffector/_UnitTest/test_gravityRotatingPlanet.py @@ -0,0 +1,136 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Tests that the gravity effector keeps the extrapolated planet orientation a rotation.""" + +import numpy as np +import pytest + +from Basilisk.architecture import bskLogging, messaging +from Basilisk.simulation import spacecraft +from Basilisk.utilities import SimulationBaseClass, macros, simIncludeGravBody + +OMEGA_PLANET = 7.2921150e-5 # [rad/s] planet spin rate about the inertial z axis +MU_EARTH = 3.986004415e14 # [m^3/s^2] +R_INIT = [7.0e6, 0.0, 0.0] # [m] +V_INIT = [0.0, 5.0e3, 5.5e3] # [m/s] +SIM_TIME = 3600.0 # [s] + + +def _planetMessage(spinning): + """Return a planet state message with the planet spinning about the inertial z axis. + + The message is written once at t = 0, so the gravity effector must extrapolate the + orientation by the full elapsed time. + + Args: + spinning (bool): if False the orientation is the identity with a zero rate. + """ + payload = messaging.SpicePlanetStateMsgPayload() + payload.J20002Pfix = np.eye(3).tolist() + if spinning: + # J20002Pfix maps inertial components into the planet-fixed frame + payload.J20002Pfix_dot = (OMEGA_PLANET * np.array([[0.0, 1.0, 0.0], + [-1.0, 0.0, 0.0], + [0.0, 0.0, 0.0]])).tolist() + return messaging.SpicePlanetStateMsg().write(payload) + + +def _propagate(stepSeconds, spinning): + """Propagate a point-mass orbit about a planet with the given orientation message. + + Args: + stepSeconds (float): integration time step in seconds. + spinning (bool): whether the planet orientation message carries a spin rate. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(stepSeconds))) + + scObject = spacecraft.Spacecraft() + scObject.ModelTag = "sc" + scObject.gravField.bskLogger.setLogLevel(bskLogging.BSK_ERROR) + scSim.AddModelToTask("t", scObject) + + gravFactory = simIncludeGravBody.gravBodyFactory() + planet = gravFactory.createCustomGravObject("planet", MU_EARTH) + planet.isCentralBody = True + planetMsg = _planetMessage(spinning) + planet.planetBodyInMsg.subscribeTo(planetMsg) + scObject.gravField.gravBodies = spacecraft.GravBodyVector(list(gravFactory.gravBodies.values())) + scObject.hub.r_CN_NInit = R_INIT + scObject.hub.v_CN_NInit = V_INIT + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(SIM_TIME)) + scSim.ExecuteSimulation() + + stateManager = scObject.dynManager + dcm = np.array(stateManager.getPropertyReference("planet.J20002Pfix")) + dcmDot = np.array(stateManager.getPropertyReference("planet.J20002Pfix_dot")) + r_N = np.array(scObject.scStateOutMsg.read().r_BN_N) + return r_N, dcm, dcmDot + + +@pytest.mark.parametrize("stepSeconds", [10.0, 60.0]) +def test_extrapolated_orientation_is_orthonormal(stepSeconds): + """Verify the extrapolated planet orientation remains a proper rotation matrix. + + A first-order update of the matrix elements loses orthonormality by about + ``(omega * dt)^2 / 2``, which is a few percent after an hour of extrapolation.""" + _, dcm, _ = _propagate(stepSeconds, spinning=True) + + np.testing.assert_allclose(dcm @ dcm.T, np.eye(3), atol=1e-9) + assert np.linalg.det(dcm) == pytest.approx(1.0, abs=1e-9) + + +@pytest.mark.parametrize("stepSeconds", [10.0, 60.0]) +def test_point_mass_orbit_is_independent_of_planet_spin(stepSeconds): + """Verify a point-mass orbit does not depend on the planet orientation. + + The point-mass field is spherically symmetric, so a spinning planet must give the same + trajectory as a non-spinning one. A non-orthonormal orientation scales the evaluated + field and breaks this.""" + rSpinning, _, _ = _propagate(stepSeconds, spinning=True) + rFixed, _, _ = _propagate(stepSeconds, spinning=False) + + np.testing.assert_allclose(rSpinning, rFixed, atol=1e-6) + + +@pytest.mark.parametrize("stepSeconds", [10.0, 60.0]) +def test_stored_orientation_and_rate_are_consistent(stepSeconds): + """Verify the stored planet orientation and rate are [PN] and [PN_dot] of the same epoch. + + The planet spins about the inertial z axis and its message is written once at t = 0, so at the final time the + orientation is a rotation by ``OMEGA_PLANET * SIM_TIME`` and the rate satisfies + ``dcm_dot = -dcm [omega x]`` with the inertial angular velocity ``omega``.""" + _, dcm, dcmDot = _propagate(stepSeconds, spinning=True) + + angle = OMEGA_PLANET * SIM_TIME # [rad] + # the message rate dcm_dot = OMEGA * [[0, 1, 0], [-1, 0, 0], [0, 0, 0]] at the identity orientation is the rate of + # [PN] = exp(-[omega x] t) for an inertial angular velocity omega = OMEGA * z + expected = np.array([[np.cos(angle), np.sin(angle), 0.0], + [-np.sin(angle), np.cos(angle), 0.0], + [0.0, 0.0, 1.0]]) + skewOmega = OMEGA_PLANET * np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) # [rad/s] + + np.testing.assert_allclose(dcm, expected, atol=1e-9) + np.testing.assert_allclose(dcmDot, -dcm @ skewOmega, atol=1e-12) + + +if __name__ == "__main__": + test_extrapolated_orientation_is_orthonormal(10.0) + test_point_mass_orbit_is_independent_of_planet_spin(10.0) diff --git a/src/simulation/environment/ExponentialAtmosphere/_UnitTest/test_atmospherePolarRadius.py b/src/simulation/environment/ExponentialAtmosphere/_UnitTest/test_atmospherePolarRadius.py new file mode 100644 index 00000000000..9f285c37c60 --- /dev/null +++ b/src/simulation/environment/ExponentialAtmosphere/_UnitTest/test_atmospherePolarRadius.py @@ -0,0 +1,112 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Test the optional polar radius of the atmosphere base class, which selects a geodetic altitude.""" + +import numpy as np +import pytest + +from Basilisk.architecture import messaging +from Basilisk.simulation import exponentialAtmosphere, msisAtmosphere +from Basilisk.utilities import SimulationBaseClass, macros, orbitalMotion, simSetPlanetEnvironment + +RP_EARTH = orbitalMotion.RP_EARTH * 1000.0 # [m] polar radius +REQ_EARTH = orbitalMotion.REQ_EARTH * 1000.0 # [m] equatorial radius set by simSetPlanetEnvironment + + +def _density(polarRadius, setOrientation=True): + """Return the density 400 km above the geodetic pole. + + Args: + polarRadius (float): [m] polar radius given to the module, negative for a sphere. + setOrientation (bool): if False the planet message leaves ``J20002Pfix`` at zero, as a message that only sets + the position and the velocity does. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(1.0))) + + atmo = exponentialAtmosphere.ExponentialAtmosphere() + simSetPlanetEnvironment.exponentialAtmosphere(atmo, "earth") + atmo.setPlanetPolarRadius(polarRadius) + + planetPayload = messaging.SpicePlanetStateMsgPayload() + if setOrientation: + planetPayload.J20002Pfix = np.eye(3).tolist() + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload) + atmo.planetPosInMsg.subscribeTo(planetMsg) + + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = [1000.0, 0.0, RP_EARTH + 400.0e3] # [m] close to the pole + scMsg = messaging.SCStatesMsg().write(payload) + atmo.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", atmo) + recorder = atmo.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(1.0)) + scSim.ExecuteSimulation() + return recorder.neutralDensity[-1], atmo + + +def test_polar_radius_selects_geodetic_altitude(): + """Verify the density uses the geodetic altitude above the ellipsoid once the polar radius is set. + + Over the pole the ellipsoid is about 21 km closer to the planet center than the sphere, so a spacecraft + 400 km above the ellipsoid is 379 km above the sphere. The default planet is a sphere.""" + scaleHeight = 8500.0 # [m] + baseDensity = 1.217 # [kg/m^3] + sphericalAltitude = np.hypot(1000.0, RP_EARTH + 400.0e3) - REQ_EARTH # [m] + geodeticAltitude = 400.0e3 # [m] + + densitySphere, atmoSphere = _density(-1.0) + densityEllipsoid, atmoEllipsoid = _density(RP_EARTH) + + assert atmoSphere.getPlanetPolarRadius() < 0.0 + assert atmoEllipsoid.getPlanetPolarRadius() == pytest.approx(RP_EARTH) + assert densitySphere == pytest.approx(baseDensity * np.exp(-sphericalAltitude / scaleHeight), rel=1e-9, abs=0.0) + assert densityEllipsoid == pytest.approx(baseDensity * np.exp(-geodeticAltitude / scaleHeight), rel=1e-3, abs=0.0) + assert densityEllipsoid < densitySphere + + +def test_position_only_planet_message_gives_a_finite_altitude(): + """Verify a planet message without an orientation is treated as the identity. + + The message leaves ``J20002Pfix`` at zero. Without the identity fallback the planet-fixed position is zero, the + geodetic altitude is not a number and the density is zero.""" + densityIdentity, _ = _density(RP_EARTH, setOrientation=True) + densityPositionOnly, _ = _density(RP_EARTH, setOrientation=False) + + assert np.isfinite(densityPositionOnly) + assert densityPositionOnly > 0.0 + assert densityPositionOnly == pytest.approx(densityIdentity, rel=1e-12, abs=0.0) + + +def test_polar_radius_setter_and_getter(): + """Verify the polar radius defaults to a sphere, round-trips, and rejects zero.""" + for atmo in (exponentialAtmosphere.ExponentialAtmosphere(), msisAtmosphere.MsisAtmosphere()): + assert atmo.getPlanetPolarRadius() < 0.0 + atmo.setPlanetPolarRadius(RP_EARTH) + assert atmo.getPlanetPolarRadius() == RP_EARTH + with pytest.raises(Exception): + atmo.setPlanetPolarRadius(0.0) + + +if __name__ == "__main__": + test_polar_radius_selects_geodetic_altitude() + test_position_only_planet_message_gives_a_finite_altitude() + test_polar_radius_setter_and_getter() diff --git a/src/simulation/environment/ExponentialAtmosphere/_UnitTest/test_exponentialAtmosphereStepLag.py b/src/simulation/environment/ExponentialAtmosphere/_UnitTest/test_exponentialAtmosphereStepLag.py new file mode 100644 index 00000000000..c9ef863afcf --- /dev/null +++ b/src/simulation/environment/ExponentialAtmosphere/_UnitTest/test_exponentialAtmosphereStepLag.py @@ -0,0 +1,320 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Test that the atmosphere base class evaluates the spacecraft state extrapolated to the step midpoint.""" + +import numpy as np +import pytest + +from Basilisk.architecture import messaging +from Basilisk.simulation import exponentialAtmosphere +from Basilisk.utilities import SimulationBaseClass, macros, orbitalMotion, simSetPlanetEnvironment + +STEP = 10.0 # [s] module update period + + +def _density(r, v, timeNanos, stopSeconds=2 * STEP): + """Return the exponential atmosphere density at the third module update. + + The state message is rewritten one step after ``timeNanos``, so that the module observes the spacecraft task period + and the extrapolation applies. + + Args: + r (list): [m] spacecraft position written to the state message. + v (list): [m/s] spacecraft velocity written to the state message. + timeNanos (int): [ns] time at which the state message is written. + stopSeconds (float): [s] simulation stop time; the density of the last module update is returned. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + atmo = exponentialAtmosphere.ExponentialAtmosphere() + atmo.setExtrapolateScStateToStepMidpoint(True) + simSetPlanetEnvironment.exponentialAtmosphere(atmo, "earth") + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = list(r) + payload.v_BN_N = list(v) + scMsg = messaging.SCStatesMsg().write(payload, timeNanos) + atmo.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", atmo) + recorder = atmo.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + scMsg.write(payload, timeNanos + macros.sec2nano(STEP)) + scSim.ConfigureStopTime(macros.sec2nano(stopSeconds)) + scSim.ExecuteSimulation() + return recorder.neutralDensity[-1] + + +def test_density_is_evaluated_at_extrapolated_position(): + """Verify the density uses the state advanced by half of the message age. + + A state with a radial velocity, written at the start of the simulation and + rewritten one step later, must give the density of + the position advanced by v * dt / 2. The same state written at the current time, or a + zero velocity, is not advanced.""" + r0 = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + v0 = [1.0e3, 0.0, 0.0] # [m/s] radial + shift = v0[0] * STEP / 2.0 # [m] + + densityFromVelocity = _density(r0, v0, 0) + densityAdvanced = _density([r0[0] + shift, 0.0, 0.0], [0.0, 0.0, 0.0], macros.sec2nano(STEP)) + densityStatic = _density(r0, [0.0, 0.0, 0.0], 0) + densityWrittenNow = _density(r0, v0, macros.sec2nano(STEP)) + + assert densityFromVelocity == pytest.approx(densityAdvanced, rel=1e-12, abs=0.0) + assert densityFromVelocity < densityStatic + assert densityWrittenNow == pytest.approx(densityStatic, rel=1e-12, abs=0.0) + + +def test_stale_message_is_never_extrapolated(): + """Verify a state written once at the start of the simulation is not extrapolated. + + The module updates at 0, 10 and 20 s. A state that is never rewritten has no observed spacecraft task period and + is stale after the first update, so the density must be that of the written position.""" + r0 = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + v0 = [1.0e3, 0.0, 0.0] # [m/s] radial + + densityMoving = _densityWithRewrittenState(STEP, [0.0], 2 * STEP, r0, v0) + densityStatic = _densityWithRewrittenState(STEP, [0.0], 2 * STEP, r0, [0.0, 0.0, 0.0]) + + assert densityMoving == pytest.approx(densityStatic, rel=1e-12, abs=0.0) + + +def test_first_update_is_not_extrapolated_before_the_task_period_is_known(): + """Verify the extrapolation waits for two spacecraft write times. + + The state is written at 0 s and rewritten at 10 s. The update at 10 s has seen one write time only and is not + extrapolated; the update at 20 s has seen an interval equal to the module period and is.""" + r0 = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + v0 = [1.0e3, 0.0, 0.0] # [m/s] radial + + densityFirst = _densityWithRewrittenState(STEP, [0.0], STEP, r0, v0) + densityFirstStatic = _densityWithRewrittenState(STEP, [0.0], STEP, r0, [0.0, 0.0, 0.0]) + densitySecond = _density(r0, v0, 0) + densitySecondStatic = _density(r0, [0.0, 0.0, 0.0], 0) + + assert densityFirst == pytest.approx(densityFirstStatic, rel=1e-12, abs=0.0) + assert densitySecond < densitySecondStatic + + +def _densityWithRewrittenState(moduleStep, writeTimes, stopSeconds, r, v, writeBeforeUpdate=False): + """Return the density of the last module update for a state message that is rewritten during the run. + + The state is re-written with the same position at each time in ``writeTimes``, emulating a spacecraft that is + updated at a different task period than the atmosphere module. + + Args: + moduleStep (float): [s] task period of the atmosphere module. + writeTimes (list): [s] times at which the spacecraft state message is written, in increasing order. + stopSeconds (float): [s] simulation stop time. + r (list): [m] spacecraft position written to the state message. + v (list): [m/s] spacecraft velocity written to the state message. + writeBeforeUpdate (bool): if True the state message is written just before the module update at the same + time, as a spacecraft that runs before the module in the task does. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(moduleStep))) + + atmo = exponentialAtmosphere.ExponentialAtmosphere() + atmo.setExtrapolateScStateToStepMidpoint(True) + simSetPlanetEnvironment.exponentialAtmosphere(atmo, "earth") + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = list(r) + payload.v_BN_N = list(v) + scMsg = messaging.SCStatesMsg().write(payload, macros.sec2nano(writeTimes[0])) + atmo.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", atmo) + recorder = atmo.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + for writeTime in writeTimes[1:]: + scSim.ConfigureStopTime(macros.sec2nano(writeTime) - (1 if writeBeforeUpdate else 0)) # [ns] + scSim.ExecuteSimulation() + scMsg.write(payload, macros.sec2nano(writeTime)) + scSim.ConfigureStopTime(macros.sec2nano(stopSeconds)) + scSim.ExecuteSimulation() + return recorder.neutralDensity[-1] + + +def test_translating_planet_does_not_change_the_density(): + """Verify the spacecraft and the planet are evaluated at the same epoch at every update, including the startup. + + The spacecraft and the planet move together at 30 km/s, so the altitude is constant. At every update the planet + message is written at the current time while the spacecraft message is one step old. Before the task period of + the spacecraft is known (the first updates) the spacecraft is not extrapolated, so the planet must be moved back + to the epoch of the spacecraft message. Extrapolating only one of them would shift the relative position by up to + v * dt and change the density. The density is checked at the update at 0 s, at the first positive-time update + and at all the following ones.""" + speed = 3.0e4 # [m/s] common velocity of the spacecraft and the planet + r0 = np.array([(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0]) # [m] + updates = 6 # [-] number of module updates + + def run(moving): + velocity = np.array([0.0, speed if moving else 0.0, 0.0]) # [m/s] + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + atmo = exponentialAtmosphere.ExponentialAtmosphere() + atmo.setExtrapolateScStateToStepMidpoint(True) + simSetPlanetEnvironment.exponentialAtmosphere(atmo, "earth") + + def scPayload(time): + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = (r0 + velocity * time).tolist() + payload.v_BN_N = velocity.tolist() + return payload + + def planetPayload(time): + payload = messaging.SpicePlanetStateMsgPayload() + payload.PositionVector = (velocity * time).tolist() + payload.VelocityVector = velocity.tolist() + payload.J20002Pfix = np.eye(3).tolist() + return payload + + scMsg = messaging.SCStatesMsg().write(scPayload(0.0), 0) + atmo.addSpacecraftToModel(scMsg) + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload(0.0), 0) + atmo.planetPosInMsg.subscribeTo(planetMsg) + + scSim.AddModelToTask("t", atmo) + recorder = atmo.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + scSim.InitializeSimulation() + for k in range(updates): + time = k * STEP # [s] + # the planet is updated before the module, the spacecraft after it + planetMsg.write(planetPayload(time), macros.sec2nano(time)) + scSim.ConfigureStopTime(macros.sec2nano(time)) + scSim.ExecuteSimulation() + scMsg.write(scPayload(time), macros.sec2nano(time)) + return np.array(recorder.neutralDensity) + + moving = run(True) + static = run(False) + assert len(moving) == updates + for k in range(updates): + assert moving[k] == pytest.approx(static[k], rel=1e-9, abs=0.0), f"update {k}" + + +@pytest.mark.parametrize("moduleStep, writeTimes, stopSeconds", [ + (10.0, [0.0] + [float(t) for t in range(1, 10)], 10.0), # spacecraft every 1 s, module every 10 s: written at 9 s + (1.0, [0.0, 10.0], 12.0), # spacecraft every 10 s, module every 1 s: written at 10 s, module previous at 11 s +]) +def test_task_period_mismatch_disables_the_extrapolation(moduleStep, writeTimes, stopSeconds): + """Verify a spacecraft updated faster or slower than the module is not extrapolated. + + The state written by the spacecraft is not the output of the previous module update, so half of its age is not + the middle of the module interval. The density must be that of the written position, whichever direction the + task period mismatch goes.""" + r0 = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + v0 = [1.0e3, 0.0, 0.0] # [m/s] radial + + density = _densityWithRewrittenState(moduleStep, writeTimes, stopSeconds, r0, v0) + densityStatic = _densityWithRewrittenState(moduleStep, writeTimes, stopSeconds, r0, [0.0, 0.0, 0.0]) + + assert density == pytest.approx(densityStatic, rel=1e-12, abs=0.0) + + +@pytest.mark.parametrize("moduleStep, writeTimes, stopSeconds", [ + (10.0, [float(t) for t in range(0, 11)], 10.0), # spacecraft every 1 s, module every 10 s: written at 10 s + (10.0, [0.0, 20.0], 20.0), # spacecraft every 20 s, module every 10 s: written at 20 s +]) +def test_message_written_at_the_module_update_is_not_extrapolated(moduleStep, writeTimes, stopSeconds): + """Verify a message written at the time of the module update is used as written. + + A spacecraft that runs before the module in the task, with a different task period, writes its message at the + time of the module update. The message has no age, so it needs no extrapolation, and the sampled write times + cannot show the task period mismatch. The density must be that of the written position.""" + r0 = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + v0 = [1.0e3, 0.0, 0.0] # [m/s] radial + + density = _densityWithRewrittenState(moduleStep, writeTimes, stopSeconds, r0, v0, writeBeforeUpdate=True) + densityStatic = _densityWithRewrittenState(moduleStep, writeTimes, stopSeconds, r0, [0.0, 0.0, 0.0], + writeBeforeUpdate=True) + + assert density == pytest.approx(densityStatic, rel=1e-12, abs=0.0) + + +def _densityOfFirstOfTwoSpacecraft(secondWriteSeconds, r, v): + """Return the density at the second module update after STEP seconds for the first of two spacecraft. + + Args: + secondWriteSeconds (float): [s] time at which the state message of the second spacecraft is written. + r (list): [m] position of both spacecraft. + v (list): [m/s] velocity of the first spacecraft; the second one is at rest. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + atmo = exponentialAtmosphere.ExponentialAtmosphere() + atmo.setExtrapolateScStateToStepMidpoint(True) + simSetPlanetEnvironment.exponentialAtmosphere(atmo, "earth") + payloads = [] + for velocity in (v, [0.0, 0.0, 0.0]): + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = list(r) + payload.v_BN_N = list(velocity) + payloads.append(payload) + firstMsg = messaging.SCStatesMsg().write(payloads[0], 0) + secondMsg = messaging.SCStatesMsg().write(payloads[1], 0) + atmo.addSpacecraftToModel(firstMsg) + atmo.addSpacecraftToModel(secondMsg) + scSim.AddModelToTask("t", atmo) + recorder = atmo.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + # both spacecraft rewrite their state; the second one at secondWriteSeconds instead of the previous module update + firstMsg.write(payloads[0], macros.sec2nano(STEP)) + secondMsg.write(payloads[1], macros.sec2nano(secondWriteSeconds)) + scSim.ConfigureStopTime(macros.sec2nano(2 * STEP)) + scSim.ExecuteSimulation() + return recorder.neutralDensity[-1] + + +def test_one_mismatched_spacecraft_disables_the_extrapolation_of_all(): + """Verify the extrapolation is applied to all spacecraft of a module or to none. + + The planet state is shared by all spacecraft, so moving it for one spacecraft would make the geometry of another + one inconsistent. With both messages written at the previous module update (0 s) the first spacecraft is + extrapolated. If the second message was last written at 15 s, which is not the previous module update, the first + spacecraft is not extrapolated either.""" + r0 = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + v0 = [1.0e3, 0.0, 0.0] # [m/s] radial + + densityMatched = _densityOfFirstOfTwoSpacecraft(STEP, r0, v0) + densityMismatched = _densityOfFirstOfTwoSpacecraft(1.5 * STEP, r0, v0) + densityStatic = _densityOfFirstOfTwoSpacecraft(1.5 * STEP, r0, [0.0, 0.0, 0.0]) + + assert densityMatched < densityStatic + assert densityMismatched == pytest.approx(densityStatic, rel=1e-12, abs=0.0) + + +if __name__ == "__main__": + test_density_is_evaluated_at_extrapolated_position() + test_stale_message_is_never_extrapolated() + test_translating_planet_does_not_change_the_density() + test_one_mismatched_spacecraft_disables_the_extrapolation_of_all() diff --git a/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisApHistory.py b/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisApHistory.py new file mode 100644 index 00000000000..2f1ed5b8369 --- /dev/null +++ b/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisApHistory.py @@ -0,0 +1,132 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Test the optional use of the 3-hour Ap history of the MSIS atmosphere model.""" + +import datetime + +import numpy as np +import pytest + +from Basilisk.architecture import messaging +from Basilisk.simulation import msisAtmosphere +from Basilisk.utilities import SimulationBaseClass, macros + +EPOCH = datetime.datetime(2026, 1, 1, 12) # UTC +R_SC = [6778137.0, 0.0, 0.0] # [m] spacecraft position on the equator, planet-fixed frame is the inertial frame +STEP = 1.0 # [s] +F107 = 150.0 # [sfu] +AP_QUIET = 15.0 # [-] + + +def _density(apValues, useHistory=None): + """Return the MSIS density for the given Ap input messages. + + Args: + apValues (list): the 21 Ap values, in the order of the first 21 ``swDataInMsgs``: the daily Ap, the + 3-hour Ap values from the current one to 57 hours earlier. + useHistory (bool): whether the 3-hour Ap history is used, ``None`` leaves the module default. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + atmo = msisAtmosphere.MsisAtmosphere() + if useHistory is not None: + atmo.setUseApHistory(useHistory) + epochMsg = messaging.EpochMsg().write(messaging.EpochMsgPayload( + year=EPOCH.year, month=EPOCH.month, day=EPOCH.day, hours=EPOCH.hour, minutes=EPOCH.minute, + seconds=EPOCH.second)) + atmo.epochInMsg.subscribeTo(epochMsg) + + swMsgs = [] + for c in range(23): + value = F107 if c >= 21 else apValues[c] + swMsgs.append(messaging.SwDataMsg().write(messaging.SwDataMsgPayload(dataValue=value))) + atmo.swDataInMsgs[c].subscribeTo(swMsgs[-1]) + + planetPayload = messaging.SpicePlanetStateMsgPayload() + planetPayload.J20002Pfix = np.eye(3).tolist() + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload) + atmo.planetPosInMsg.subscribeTo(planetMsg) + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = list(R_SC) + scMsg = messaging.SCStatesMsg().write(payload) + atmo.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", atmo) + recorder = atmo.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + return recorder.neutralDensity[-1] + + +def _storm(daily=105.0, current=300.0): + """Return the 21 Ap values of a storm that starts in the current 3-hour interval. + + Args: + daily (float): daily Ap. + current (float): current 3-hour Ap, the other 3-hour values are quiet. + """ + values = [AP_QUIET] * 21 + values[0] = daily + values[1] = current + return values + + +def test_ap_history_setter_and_getter(): + """Verify the 3-hour Ap history option defaults to off and round-trips.""" + atmo = msisAtmosphere.MsisAtmosphere() + assert atmo.getUseApHistory() is False + atmo.setUseApHistory(True) + assert atmo.getUseApHistory() is True + atmo.setUseApHistory(False) + assert atmo.getUseApHistory() is False + + + +def test_ap_history_ignores_daily_ap(): + """Verify the daily Ap only matters when the history is off. + + With the history the density must not change with the daily Ap, and without the history it must.""" + quietDaily = _storm(daily=AP_QUIET) + stormDaily = _storm(daily=105.0) + assert _density(quietDaily, True) == pytest.approx(_density(stormDaily, True), rel=1e-12, abs=0.0) + assert _density(quietDaily, False) != pytest.approx(_density(stormDaily, False), rel=1e-6, abs=0.0) + + +def test_ap_history_follows_current_three_hour_ap(): + """Verify the current 3-hour Ap only matters when the history is on, and then raises the density.""" + quiet = [AP_QUIET] * 21 + stormStart = _storm(daily=AP_QUIET, current=300.0) + assert _density(stormStart, False) == pytest.approx(_density(quiet, False), rel=1e-12, abs=0.0) + assert _density(stormStart, True) > _density(quiet, True) * (1.0 + 1e-3) + + +def test_ap_history_default_is_daily_ap(): + """Verify a module that never calls the setter uses the daily Ap, and that the history gives another density.""" + values = _storm() + assert _density(values) == _density(values, False) + assert _density(values) != pytest.approx(_density(values, True), rel=1e-6, abs=0.0) + + +if __name__ == "__main__": + test_ap_history_setter_and_getter() + test_ap_history_ignores_daily_ap() + test_ap_history_follows_current_three_hour_ap() + test_ap_history_default_is_daily_ap() diff --git a/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisApparentSolarTime.py b/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisApparentSolarTime.py new file mode 100644 index 00000000000..c86254b5fd7 --- /dev/null +++ b/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisApparentSolarTime.py @@ -0,0 +1,131 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Test the optional apparent solar time of the MSIS atmosphere model.""" + +import datetime +import math + +import numpy as np +import pytest + +from Basilisk.architecture import messaging +from Basilisk.architecture.bskLogging import BasiliskError +from Basilisk.simulation import msisAtmosphere +from Basilisk.utilities import SimulationBaseClass, macros + +EPOCH = datetime.datetime(2026, 1, 1) # UTC +R_SC = [6778137.0, 0.0, 0.0] # [m] spacecraft position on the equator, planet-fixed frame is the inertial frame +STEP = 1.0 # [s] + + +def equation_of_time(date): + """Return the equation of time in seconds with the series of Meeus, Astronomical Algorithms, chapter 28. + + Args: + date (datetime.datetime): UTC date and time. + """ + julianDate = (date - datetime.datetime(1970, 1, 1)).total_seconds() / 86400.0 + 2440587.5 + T = (julianDate - 2451545.0) / 36525.0 + L0 = math.radians((280.46646 + 36000.76983 * T + 0.0003032 * T * T) % 360.0) + M = math.radians(357.52911 + 35999.05029 * T - 0.0001537 * T * T) + e = 0.016708634 - 0.000042037 * T + y = math.tan(math.radians(23.439291 - 0.0130042 * T) / 2.0) ** 2 + E = (y * math.sin(2 * L0) - 2 * e * math.sin(M) + 4 * e * y * math.sin(M) * math.cos(2 * L0) + - 0.5 * y * y * math.sin(4 * L0) - 1.25 * e * e * math.sin(2 * M)) + return math.degrees(E) * 240.0 + + +def _density(epoch, useApparent): + """Return the MSIS density for the given epoch. + + Args: + epoch (datetime.datetime): UTC epoch given to the module. + useApparent (bool): whether the apparent solar time is used. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + atmo = msisAtmosphere.MsisAtmosphere() + atmo.setUseApparentSolarTime(useApparent) + epochMsg = messaging.EpochMsg().write(messaging.EpochMsgPayload( + year=epoch.year, month=epoch.month, day=epoch.day, hours=epoch.hour, minutes=epoch.minute, + seconds=epoch.second + epoch.microsecond * 1e-6)) + atmo.epochInMsg.subscribeTo(epochMsg) + + swMsgs = [] + for c in range(23): + value = 150.0 if c >= 21 else 15.0 # f107 [sfu] and ap [-] + swMsgs.append(messaging.SwDataMsg().write(messaging.SwDataMsgPayload(dataValue=value))) + atmo.swDataInMsgs[c].subscribeTo(swMsgs[-1]) + + planetPayload = messaging.SpicePlanetStateMsgPayload() + planetPayload.J20002Pfix = np.eye(3).tolist() + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload) + atmo.planetPosInMsg.subscribeTo(planetMsg) + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = list(R_SC) + scMsg = messaging.SCStatesMsg().write(payload) + atmo.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", atmo) + recorder = atmo.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + return recorder.neutralDensity[-1] + + +def test_apparent_solar_time_setter_and_getter(): + """Verify the apparent solar time option defaults to off and round-trips.""" + atmo = msisAtmosphere.MsisAtmosphere() + assert atmo.getUseApparentSolarTime() is False + atmo.setUseApparentSolarTime(True) + assert atmo.getUseApparentSolarTime() is True + + +@pytest.mark.parametrize("epoch", [datetime.datetime(2026, 1, 1, 6), datetime.datetime(2026, 4, 10, 18)]) +def test_apparent_solar_time_matches_mean_time_shifted_by_equation_of_time(epoch): + """Verify the apparent solar time adds the equation of time to the local solar time. + + The density with the option on must equal the density with the option off for an epoch shifted + by the equation of time, because only the local solar time input of the model differs.""" + eot = equation_of_time(epoch) # [s] + shifted = epoch + datetime.timedelta(seconds=eot) + + apparent = _density(epoch, True) + meanShifted = _density(shifted, False) + mean = _density(epoch, False) + + assert apparent == pytest.approx(meanShifted, rel=2e-3, abs=0.0) + assert apparent != pytest.approx(mean, rel=1e-6, abs=0.0) + + +def test_apparent_solar_time_rejects_epoch_before_1970(): + """Verify the apparent solar time raises an error for an epoch before 1970, where the series is not valid, + while the mean solar time still works.""" + epoch = datetime.datetime(1969, 7, 20, 12) # UTC + assert _density(epoch, False) > 0.0 + with pytest.raises(BasiliskError): + _density(epoch, True) + + +if __name__ == "__main__": + test_apparent_solar_time_setter_and_getter() + test_apparent_solar_time_matches_mean_time_shifted_by_equation_of_time(datetime.datetime(2026, 1, 1, 6)) + test_apparent_solar_time_rejects_epoch_before_1970() diff --git a/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisStepLag.py b/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisStepLag.py new file mode 100644 index 00000000000..9de814de9e3 --- /dev/null +++ b/src/simulation/environment/MsisAtmosphere/_UnitTest/test_msisStepLag.py @@ -0,0 +1,112 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Test that the MSIS temporal inputs are evaluated at the epoch of the extrapolated spacecraft state.""" + +import datetime + +import pytest + +from Basilisk.architecture import messaging +from Basilisk.simulation import msisAtmosphere +from Basilisk.utilities import SimulationBaseClass, macros + +EPOCH = datetime.datetime(2026, 1, 1) # UTC +R_SC = [6778137.0, 0.0, 0.0] # [m] spacecraft position on the equator, planet-fixed frame is the inertial frame +STEP = 3600.0 # [s] module update period, long enough for the local solar time to change visibly + + +def _density(epoch, extrapolate, stopSeconds, rewriteTimes=()): + """Return the MSIS density of the last module update. + + The spacecraft is at rest, so the extrapolation moves it nowhere and only the epoch of the temporal inputs differs. + + Args: + epoch (datetime.datetime): UTC epoch given to the module. + extrapolate (bool): whether the extrapolation to the step midpoint is enabled. + stopSeconds (float): [s] simulation stop time. + rewriteTimes (tuple): [s] times at which the spacecraft state message is rewritten after the update, emulating + a spacecraft that runs after the module in the task with the same period. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + atmo = msisAtmosphere.MsisAtmosphere() + atmo.setExtrapolateScStateToStepMidpoint(extrapolate) + epochMsg = messaging.EpochMsg().write(messaging.EpochMsgPayload( + year=epoch.year, month=epoch.month, day=epoch.day, hours=epoch.hour, minutes=epoch.minute, + seconds=epoch.second)) + atmo.epochInMsg.subscribeTo(epochMsg) + + swMsgs = [] + for c in range(23): + value = 150.0 if c >= 21 else 15.0 # f107 [sfu] and ap [-] + swMsgs.append(messaging.SwDataMsg().write(messaging.SwDataMsgPayload(dataValue=value))) + atmo.swDataInMsgs[c].subscribeTo(swMsgs[-1]) + + planetPayload = messaging.SpicePlanetStateMsgPayload() + planetPayload.J20002Pfix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload) + atmo.planetPosInMsg.subscribeTo(planetMsg) + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = list(R_SC) + scMsg = messaging.SCStatesMsg().write(payload, 0) + atmo.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", atmo) + recorder = atmo.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + for writeTime in rewriteTimes: + scSim.ConfigureStopTime(macros.sec2nano(writeTime)) + scSim.ExecuteSimulation() + scMsg.write(payload, macros.sec2nano(writeTime)) + scSim.ConfigureStopTime(macros.sec2nano(stopSeconds)) + scSim.ExecuteSimulation() + return recorder.neutralDensity[-1] + + +def test_temporal_inputs_follow_the_extrapolated_midpoint(): + """Verify the local solar time uses the same epoch as the extrapolated spacecraft state. + + The state is written at 0 s and rewritten at 3600 s, so the update at 7200 s is extrapolated to 5400 s. Its density + must be that of a plain update at the epoch advanced by 5400 s, and differ from the one at 7200 s.""" + midpoint = _density(EPOCH, True, 2 * STEP, rewriteTimes=(STEP,)) + atMidpointEpoch = _density(EPOCH + datetime.timedelta(seconds=1.5 * STEP), False, 0.0) + atUpdateEpoch = _density(EPOCH + datetime.timedelta(seconds=2 * STEP), False, 0.0) + + assert midpoint == pytest.approx(atMidpointEpoch, rel=1e-12, abs=0.0) + assert midpoint != pytest.approx(atUpdateEpoch, rel=1e-3, abs=0.0) + + +def test_temporal_inputs_are_unchanged_without_extrapolation(): + """Verify the temporal inputs stay at the update epoch when the extrapolation is disabled.""" + disabled = _density(EPOCH, False, 2 * STEP, rewriteTimes=(STEP,)) + atUpdateEpoch = _density(EPOCH + datetime.timedelta(seconds=2 * STEP), False, 0.0) + + assert disabled == pytest.approx(atUpdateEpoch, rel=1e-12, abs=0.0) + + +def test_temporal_inputs_use_the_previous_update_while_the_period_is_unknown(): + """Verify the startup update, where the spacecraft is left at the previous update, uses that epoch as well. + + The state is written at 0 s. The update at 3600 s has seen one write time only, so the geometry stays at 0 s and the + temporal inputs must be those of 0 s.""" + startup = _density(EPOCH, True, STEP, rewriteTimes=()) + atPreviousEpoch = _density(EPOCH, False, 0.0) + + assert startup == pytest.approx(atPreviousEpoch, rel=1e-12, abs=0.0) diff --git a/src/simulation/environment/MsisAtmosphere/msisAtmosphere.cpp b/src/simulation/environment/MsisAtmosphere/msisAtmosphere.cpp index 935ce89004c..91ddf51ffe6 100644 --- a/src/simulation/environment/MsisAtmosphere/msisAtmosphere.cpp +++ b/src/simulation/environment/MsisAtmosphere/msisAtmosphere.cpp @@ -17,8 +17,11 @@ */ +#include + #include "msisAtmosphere.h" #include "architecture/utilities/astroConstants.h" +#include "architecture/utilities/utcTime.h" #include "architecture/utilities/geodeticConversion.h" #include "architecture/_GeneralModuleFiles/sys_model.h" @@ -92,6 +95,80 @@ MsisAtmosphere::~MsisAtmosphere() return; } +/*! Selects the local solar time used by NRLMSISE-00. By default the mean solar time + (second of day plus longitude divided by 15 deg/hr) is used. The model is defined with the apparent solar time, which + differs from the mean solar time by the equation of time, up to about 16 minutes during the year. + @param useApparent true to add the equation of time to the local solar time + */ +void MsisAtmosphere::setUseApparentSolarTime(bool useApparent) +{ + this->useApparentSolarTime = useApparent; +} + +/*! Returns whether the apparent solar time is used as the local solar time input. + @return true if the equation of time is added to the mean solar time + */ +bool MsisAtmosphere::getUseApparentSolarTime() const +{ + return this->useApparentSolarTime; +} + +/*! Selects the geomagnetic index used by NRLMSISE-00. By default the daily Ap index (input message 0) is used + (switch 9 set to 1). With the 3-hour history the model uses the 3-hour Ap values of messages 1 to 20, which are collected + into the Ap array of the model: the current 3-hour value, the values 3, 6 and 9 hours earlier, and the averages of the + values 12 to 33 and 36 to 57 hours earlier (switch 9 set to -1). The history follows a geomagnetic storm more closely + than the daily Ap, which applies from midnight. + @param useHistory true to use the 3-hour Ap history, false to use the daily Ap + */ +void MsisAtmosphere::setUseApHistory(bool useHistory) +{ + this->useApHistory = useHistory; + this->msisFlags.switches[9] = useHistory ? -1 : 1; +} + +/*! Returns whether NRLMSISE-00 uses the 3-hour Ap history instead of the daily Ap. + @return true if the 3-hour Ap history is used + */ +bool MsisAtmosphere::getUseApHistory() const +{ + return this->useApHistory; +} + +/*! Computes the equation of time, apparent minus mean solar time, with the series of Meeus, Astronomical + Algorithms, chapter 28. The accuracy is a few seconds. + @param year calendar year + @param dayOfYear day of year, 1 is January 1 + @param secondOfDay [s] second of the day, UTC + @return [s] equation of time + */ +double MsisAtmosphere::equationOfTime(int year, int dayOfYear, double secondOfDay) +{ + if (year < 1970) { + bskLogger.bskError("MsisAtmosphere: the apparent solar time requires an epoch year of 1970 or later."); + return 0.0; + } + // days from the Unix epoch to January 1 of the year, valid for years from 1970 + const int y = year - 1; + const long daysFromEpoch = + 365L * (year - 1970) + (y / 4 - 1969 / 4) - (y / 100 - 1969 / 100) + (y / 400 - 1969 / 400); // [day] + const double julianDate = static_cast(daysFromEpoch + dayOfYear - 1) + 2440587.5 + // [day] JD of the Unix epoch + secondOfDay / 86400.0; // [day] + const double T = (julianDate - 2451545.0) / 36525.0; // [century] since J2000, JD 2451545.0 [day] + // mean longitude of the Sun: 280.46646 [deg], 36000.76983 [deg/century], 0.0003032 [deg/century^2] + const double meanLongitude = std::fmod(280.46646 + 36000.76983 * T + 0.0003032 * T * T, 360.0) * D2R; // [rad] + // mean anomaly of the Sun: 357.52911 [deg], 35999.05029 [deg/century], 0.0001537 [deg/century^2] + const double meanAnomaly = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) * D2R; // [rad] + // orbit eccentricity of the Earth: 0.016708634 [-], 0.000042037 [1/century] + const double eccentricity = 0.016708634 - 0.000042037 * T; // [-] + // mean obliquity of the ecliptic: 23.439291 [deg], 0.0130042 [deg/century] + const double obliquity = (23.439291 - 0.0130042 * T) * D2R; // [rad] + const double y2 = std::pow(std::tan(0.5 * obliquity), 2.0); // [-] + const double equation = y2 * std::sin(2.0 * meanLongitude) - 2.0 * eccentricity * std::sin(meanAnomaly) + + 4.0 * eccentricity * y2 * std::sin(meanAnomaly) * std::cos(2.0 * meanLongitude) - + 0.5 * y2 * y2 * std::sin(4.0 * meanLongitude) - + 1.25 * eccentricity * eccentricity * std::sin(2.0 * meanAnomaly); // [rad] + return equation * R2D * 240.0; // [s] one degree of hour angle is 240 [s] +} /*! This method is used to reset the module. @@ -115,7 +192,7 @@ void MsisAtmosphere::customSetEpochFromVariable() if (this->epochDoy > 0.0) { /* here the BSK default epoch year is used on Jan 1, mid-night, and the requested days of year are added and converted to a proper date-time structure */ this->epochDateTime.tm_mday = this->epochDoy; // assumes 1 is first day of year - mktime(&this->epochDateTime); + normalizeUtcTime(&this->epochDateTime); } return; @@ -211,7 +288,7 @@ void MsisAtmosphere::evaluateAtmosphereModel(AtmoPropsMsgPayload *msg, double cu this->updateInputParams(); //! Compute the geodetic position using the planet orientation. - this->currentLLA = PCI2LLA(this->r_BP_N, this->planetState.J20002Pfix, this->planetRadius); + this->currentLLA = PCI2LLA(this->r_BP_N, this->planetState.J20002Pfix, this->planetRadius, this->getPlanetPolarRadius()); this->msisInput.g_lat = R2D*this->currentLLA[0]; this->msisInput.g_long = R2D*this->currentLLA[1]; this->msisInput.alt = this->currentLLA[2]/1000.0; // NRLMSISE Altitude input must be in kilometers! @@ -220,14 +297,19 @@ void MsisAtmosphere::evaluateAtmosphereModel(AtmoPropsMsgPayload *msg, double cu struct tm localDateTime; // [] date/time structure localDateTime = this->epochDateTime; localDateTime.tm_sec += (int) currentTime; // sets the current seconds - mktime(&localDateTime); + normalizeUtcTime(&localDateTime); this->msisInput.year = localDateTime.tm_year + 1900; this->msisInput.doy = localDateTime.tm_yday + 1; // Jan 1 is the 1st day of year, not 0th double fracSecond = currentTime - (int) currentTime; this->msisInput.sec = localDateTime.tm_hour * 3600.0 + localDateTime.tm_min * 60.0 + localDateTime.tm_sec + fracSecond; - // WIP - need to actually figure out how to pull in these values. + // Local solar time [hr]. By default this is the mean solar time. The apparent solar time, which NRLMSISE-00 is + // defined with, adds the equation of time. this->msisInput.lst = this->msisInput.sec/3600.0 + this->msisInput.g_long/15.0; + if (this->useApparentSolarTime) { + this->msisInput.lst += + this->equationOfTime(this->msisInput.year, this->msisInput.doy, this->msisInput.sec) / 3600.0; + } //! NRLMSISE-00 uses different models depending on the altitude. if(this->msisInput.alt < 500.0){ diff --git a/src/simulation/environment/MsisAtmosphere/msisAtmosphere.h b/src/simulation/environment/MsisAtmosphere/msisAtmosphere.h index 6af673b8b2e..780ae1f8aef 100644 --- a/src/simulation/environment/MsisAtmosphere/msisAtmosphere.h +++ b/src/simulation/environment/MsisAtmosphere/msisAtmosphere.h @@ -43,6 +43,10 @@ class MsisAtmosphere: public AtmosphereBase { public: MsisAtmosphere(); ~MsisAtmosphere(); + void setUseApparentSolarTime(bool useApparent); + bool getUseApparentSolarTime() const; + void setUseApHistory(bool useHistory); + bool getUseApHistory() const; private: void customWriteMessages(uint64_t CurrentClock); @@ -53,6 +57,7 @@ class MsisAtmosphere: public AtmosphereBase { void updateSwIndices(); void evaluateAtmosphereModel(AtmoPropsMsgPayload *msg, double currentTime); void customSetEpochFromVariable(); + double equationOfTime(int year, int dayOfYear, double secondOfDay); public: std::vector> swDataInMsgs; //!< Vector of space weather input message names @@ -72,8 +77,8 @@ class MsisAtmosphere: public AtmosphereBase { double ap; double f107; double f107A; - - + bool useApparentSolarTime = false; //!< [-] if true the local solar time input is the apparent solar time, otherwise the mean solar time + bool useApHistory = false; //!< [-] if true NRLMSISE-00 uses the 3-hour Ap history (switch 9 set to -1), otherwise the daily Ap }; diff --git a/src/simulation/environment/MsisAtmosphere/msisAtmosphere.rst b/src/simulation/environment/MsisAtmosphere/msisAtmosphere.rst index 358a9695433..a1faf3d2339 100644 --- a/src/simulation/environment/MsisAtmosphere/msisAtmosphere.rst +++ b/src/simulation/environment/MsisAtmosphere/msisAtmosphere.rst @@ -50,3 +50,32 @@ Regarding the vector ``swDataInMsgs``, the order of these 23 messages must follo 20 - ap_3_-57 21 - f107_1944_0 22 - f107_24_-24 + +Planet Shape +------------ +The NRLMSISE-00 model expects the geodetic latitude and altitude above the reference ellipsoid. By default the module +computes them for a sphere of radius ``planetRadius``. Call ``setPlanetPolarRadius(orbitalMotion.RP_EARTH * 1000.0)`` +(see :ref:`atmosphereBase`) to use the Earth ellipsoid. This requires ``planetPosInMsg`` to provide the planet orientation. + +Local Solar Time +---------------- +NRLMSISE-00 is defined with the local apparent solar time. By default the module uses the mean solar time, the second +of the day plus the longitude divided by 15 degrees per hour. The two differ by the equation of time, which is up to +about 16 minutes during the year. Call ``setUseApparentSolarTime(True)`` to add the equation of time, computed with the +series of Meeus, *Astronomical Algorithms*, chapter 28, with an accuracy of a few seconds. + +Density Model and Space Weather +------------------------------- +Below 500 km the density is the total mass density of NRLMSISE-00 (``gtd7``). From 500 km it is the effective total mass +density (``gtd7d``), which includes the contribution of the anomalous oxygen. The epoch is interpreted as UTC. + +By default all switches of NRLMSISE-00 are set to 1, so the model uses the daily Ap index (message 0) and the two F10.7 values +(messages 21 and 22). During a geomagnetic storm the density then follows the daily Ap from midnight and not the 3-hour +history. + +Call ``setUseApHistory(True)`` to use the 3-hour Ap history instead. This sets switch 9 of NRLMSISE-00 to -1, and the model +then uses the Ap array that the module builds from messages 1 to 20: the current 3-hour value (message 1), the values 3, 6 and +9 hours earlier (messages 2 to 4), the average of the eight values 12 to 33 hours earlier (messages 5 to 12), and the average +of the eight values 36 to 57 hours earlier (messages 13 to 20). The daily Ap of message 0 is then not used. The 3-hour values +must therefore be provided and kept up to date, for example with :ref:`spaceWeatherData`. When all Ap values are equal the two +options give nearly the same density, because the two formulations of NRLMSISE-00 differ slightly. ``getUseApHistory()`` returns the current setting. diff --git a/src/simulation/environment/ZeroWindModel/_UnitTest/test_windStepLag.py b/src/simulation/environment/ZeroWindModel/_UnitTest/test_windStepLag.py new file mode 100644 index 00000000000..81fd00384a2 --- /dev/null +++ b/src/simulation/environment/ZeroWindModel/_UnitTest/test_windStepLag.py @@ -0,0 +1,139 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Test that the wind base class evaluates the spacecraft state extrapolated to the step midpoint.""" + +import numpy as np + +from Basilisk.architecture import messaging +from Basilisk.simulation import zeroWindModel +from Basilisk.utilities import SimulationBaseClass, macros, orbitalMotion + +STEP = 10.0 # [s] module update period +OMEGA_PLANET = np.array([0.0, 0.0, orbitalMotion.OMEGA_EARTH]) # [rad/s] + + +def _airVelocity(r, v, timeNanos): + """Return the co-rotating air velocity at the third module update. + + Args: + r (list): [m] spacecraft position written to the state message. + v (list): [m/s] spacecraft velocity written to the state message. + timeNanos (int): [ns] time at which the state message is written. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + wind = zeroWindModel.ZeroWindModel() + wind.setExtrapolateScStateToStepMidpoint(True) + wind.setPlanetOmega_N(OMEGA_PLANET) + wind.setUseSpiceOmegaFlag(False) + planetMsg = messaging.SpicePlanetStateMsg().write(messaging.SpicePlanetStateMsgPayload()) + wind.planetPosInMsg.subscribeTo(planetMsg) + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = list(r) + payload.v_BN_N = list(v) + scMsg = messaging.SCStatesMsg().write(payload, timeNanos) + wind.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", wind) + recorder = wind.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + # the spacecraft rewrites its state one step later, so that the module observes the spacecraft task period + scMsg.write(payload, timeNanos + macros.sec2nano(STEP)) + scSim.ConfigureStopTime(macros.sec2nano(2 * STEP)) + scSim.ExecuteSimulation() + return np.array(recorder.v_air_N[-1]) + + +def test_air_velocity_is_evaluated_at_extrapolated_position(): + """Verify the co-rotating air velocity uses the state advanced by half of the message age. + + A state with a velocity along x, written at the start of the simulation and + rewritten one step later, must give the air velocity of the + position advanced by v * dt / 2, which differs from the static one along y. The same state written at the + current time is not advanced.""" + r0 = [6.778e6, 0.0, 0.0] # [m] + v0 = [0.0, 7.5e3, 0.0] # [m/s] + shift = np.array(v0) * STEP / 2.0 # [m] + + airFromVelocity = _airVelocity(r0, v0, 0) + airAdvanced = _airVelocity(list(np.array(r0) + shift), [0.0, 0.0, 0.0], macros.sec2nano(STEP)) + airStatic = _airVelocity(r0, [0.0, 0.0, 0.0], 0) + airWrittenNow = _airVelocity(r0, v0, macros.sec2nano(STEP)) + + np.testing.assert_allclose(airFromVelocity, airAdvanced, atol=1e-9) + assert abs(airFromVelocity[0] - airStatic[0]) > 1.0 # [m/s] the shift changes the x component + np.testing.assert_allclose(airWrittenNow, airStatic, atol=1e-9) + + +def _airVelocityWithPlanet(planetVelocity): + """Return the co-rotating air velocity at the third update for a spacecraft that moves together with the planet. + + The planet message is written at the current time and the spacecraft message one step earlier, so the relative + position is only constant if both are evaluated at the same epoch. + + Args: + planetVelocity (list): [m/s] common velocity of the spacecraft and the planet. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + wind = zeroWindModel.ZeroWindModel() + wind.setExtrapolateScStateToStepMidpoint(True) + wind.setPlanetOmega_N(OMEGA_PLANET) + wind.setUseSpiceOmegaFlag(False) + planetPayload = messaging.SpicePlanetStateMsgPayload() + planetPayload.PositionVector = (np.array(planetVelocity) * STEP).tolist() # [m] + planetPayload.VelocityVector = list(planetVelocity) + planetPayload.J20002Pfix = np.eye(3).tolist() + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload, macros.sec2nano(2 * STEP)) + wind.planetPosInMsg.subscribeTo(planetMsg) + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = [6.778e6, 0.0, 0.0] # [m] + payload.v_BN_N = list(planetVelocity) + scMsg = messaging.SCStatesMsg().write(payload, 0) + wind.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", wind) + recorder = wind.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + # the spacecraft rewrites its state one step later, so that the module observes the spacecraft task period + scMsg.write(payload, 0 + macros.sec2nano(STEP)) + scSim.ConfigureStopTime(macros.sec2nano(2 * STEP)) + scSim.ExecuteSimulation() + return np.array(recorder.v_air_N[-1]) + + +def test_translating_planet_does_not_change_the_air_velocity(): + """Verify the spacecraft and the planet are evaluated at the same epoch. + + The spacecraft and the planet move together at 30 km/s, so the relative position and the air velocity are + constant.""" + np.testing.assert_allclose(_airVelocityWithPlanet([0.0, 3.0e4, 0.0]), _airVelocityWithPlanet([0.0, 0.0, 0.0]), + atol=1e-9) + + +if __name__ == "__main__": + test_air_velocity_is_evaluated_at_extrapolated_position() diff --git a/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.cpp b/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.cpp index 04db05b14d9..49ba473e797 100644 --- a/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.cpp +++ b/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.cpp @@ -19,10 +19,14 @@ */ #include "atmosphereBase.h" +#include #include "architecture/messaging/ownedMessage.h" +#include "architecture/utilities/geodeticConversion.h" +#include "architecture/utilities/utcTime.h" #include "architecture/utilities/linearAlgebra.h" #include "architecture/utilities/macroDefinitions.h" #include "architecture/utilities/simDefinitions.h" +#include "architecture/utilities/stateExtrapolation.h" /*! This method initializes some basic parameters for the module. @@ -92,6 +96,20 @@ void AtmosphereBase::addSpacecraftToModel(Message *tmpScMsg) */ void AtmosphereBase::Reset(uint64_t CurrentSimNanos) { + this->previousUpdateNanos = CurrentSimNanos; + this->scStateExtrapolation.reset(); + + //! - the geodetic altitude requires the planet orientation and a consistent equatorial and polar radius + if (this->planetPolarRadius >= 0.0) { + if (!this->planetPosInMsg.isLinked()) { + bskLogger.bskError("Atmosphere model: planetPosInMsg must be linked to use a planet polar radius."); + } + if (this->planetPolarRadius > this->planetRadius) { + bskLogger.bskError( + "Atmosphere model: the planet polar radius must not exceed the equatorial radius planetRadius."); + } + } + //! - call the custom environment module reset method customReset(CurrentSimNanos); @@ -109,7 +127,7 @@ void AtmosphereBase::Reset(uint64_t CurrentSimNanos) this->epochDateTime.tm_hour = epochMsg.hours; this->epochDateTime.tm_min = epochMsg.minutes; this->epochDateTime.tm_sec = (int) round(epochMsg.seconds); - mktime(&this->epochDateTime); + normalizeUtcTime(&this->epochDateTime); } else { customSetEpochFromVariable(); } @@ -161,10 +179,14 @@ void AtmosphereBase::customWriteMessages(uint64_t CurrentClock [[maybe_unused]]) } /*! This method is used to read the incoming command message and set the - associated spacecraft positions for computing the atmosphere. - + associated spacecraft positions for computing the atmosphere. If enabled with setExtrapolateScStateToStepMidpoint(), + the spacecraft state is extrapolated to the middle of the interval the next spacecraft update integrates, see + extrapolateScStateToStepMidpoint(). + @param CurrentSimNanos [ns] current simulation time + @return true if all required messages were read */ -bool AtmosphereBase::readMessages() +bool +AtmosphereBase::readMessages(uint64_t CurrentSimNanos) { SCStatesMsgPayload scMsg; @@ -175,9 +197,12 @@ bool AtmosphereBase::readMessages() if(this->scStateInMsgs.size() > 0) { scRead = true; + this->scStateExtrapolation.prepareFromMessages( + CurrentSimNanos, this->previousUpdateNanos, this->scStateInMsgs, this->bskLogger); for(long unsigned int c = 0; cscStateInMsgs.size(); c++){ bool tmpScRead; - scMsg = this->scStateInMsgs.at(c)(); + scMsg = this->scStateExtrapolation.applyMessage( + this->scStateInMsgs.at(c), CurrentSimNanos, this->previousUpdateNanos); tmpScRead = this->scStateInMsgs.at(c).isWritten(); scRead = scRead && tmpScRead; @@ -192,7 +217,8 @@ bool AtmosphereBase::readMessages() bool planetRead = true; if(this->planetPosInMsg.isLinked()) { - this->planetState = this->planetPosInMsg(); + this->planetState = this->scStateExtrapolation.applyPlanetMessage( + this->planetPosInMsg, CurrentSimNanos, this->previousUpdateNanos); planetRead = this->planetPosInMsg.isWritten(); } @@ -211,6 +237,52 @@ bool AtmosphereBase::customReadMessages() return true; } +/*! Sets the polar radius of the planet. If set, the altitude (and the geodetic latitude in models that use it) is + computed above the oblate ellipsoid defined by the equatorial radius planetRadius and this polar radius. By default + the planet is a sphere of radius planetRadius, and altitude is the distance to the planet center minus planetRadius. + The planet orientation message planetPosInMsg is required when the polar radius is set. + @param polarRadius [m] planet polar radius; a negative value selects the spherical planet + */ +void +AtmosphereBase::setPlanetPolarRadius(double polarRadius) +{ + if (polarRadius == 0.0) { + bskLogger.bskError( + "Atmosphere model: the planet polar radius must be positive, or negative to select a spherical planet."); + } + this->planetPolarRadius = polarRadius; +} + +/*! Enables or disables the extrapolation of the spacecraft state to the middle of the interval the next spacecraft + update integrates, see extrapolateScStateToStepMidpoint(). It is disabled by default, in which case the spacecraft + state message is used as written. The extrapolation assumes that the module and the spacecraft run at the same task + rate with a constant spacecraft step; a warning is logged once if a spacecraft message was not written at the + previous module update, which is typically a task rate mismatch. A mismatch is not always detectable, but a message + that is not the output of the previous module update is never extrapolated. + @param enable [-] true to extrapolate the spacecraft state to the middle of the step + */ +void AtmosphereBase::setExtrapolateScStateToStepMidpoint(bool enable) +{ + this->scStateExtrapolation.setEnabled(enable); +} + +/*! Returns whether the spacecraft state extrapolation is enabled. + @return [-] true if the spacecraft state is extrapolated + */ +bool AtmosphereBase::getExtrapolateScStateToStepMidpoint() const +{ + return this->scStateExtrapolation.isEnabled(); +} + +/*! Returns the polar radius of the planet. + @return [m] planet polar radius; a negative value means the planet is treated as a sphere + */ +double +AtmosphereBase::getPlanetPolarRadius() const +{ + return this->planetPolarRadius; +} + /*! This method is used to determine the spacecraft position vector relative to the planet. @param planetState A space planetstate message struct. @param scState A spacecraft states message struct. @@ -226,7 +298,17 @@ void AtmosphereBase::updateRelativePos(SpicePlanetStateMsgPayload *planetState, //! - compute orbit radius this->orbitRadius = this->r_BP_N.norm(); - this->orbitAltitude = this->orbitRadius - this->planetRadius; + + //! - compute the altitude above a sphere, or above the oblate ellipsoid if the polar radius was set + if (this->planetPolarRadius >= 0.0) { + if (this->planetPolarRadius > this->planetRadius) { + bskLogger.bskError( + "Atmosphere model: the planet polar radius must not exceed the equatorial radius planetRadius."); + } + this->orbitAltitude = PCPF2LLA(this->r_BP_P, this->planetRadius, this->planetPolarRadius)[2]; + } else { + this->orbitAltitude = this->orbitRadius - this->planetRadius; + } return; } @@ -272,13 +354,15 @@ void AtmosphereBase::UpdateState(uint64_t CurrentSimNanos) *it = this->envOutMsgs[0]->zeroMsgPayload; } //! - update local neutral density information - if(this->readMessages()) - { - this->updateLocalAtmosphere(static_cast(CurrentSimNanos) * NANO2SEC); + if (this->readMessages(CurrentSimNanos)) { + // time dependent models are evaluated at the epoch of the (possibly extrapolated) geometry + const uint64_t epochNanos = this->scStateExtrapolation.evaluationEpochNanos(CurrentSimNanos, this->previousUpdateNanos); // [ns] + this->updateLocalAtmosphere(static_cast(epochNanos) * NANO2SEC); } //! - write out neutral density message this->writeMessages(CurrentSimNanos); + this->previousUpdateNanos = CurrentSimNanos; return; } diff --git a/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.h b/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.h index 262ed8f938f..0aa786a8103 100644 --- a/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.h +++ b/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.h @@ -34,6 +34,7 @@ #include "architecture/messaging/messaging.h" #include "architecture/utilities/bskLogging.h" +#include "architecture/utilities/stateExtrapolation.h" /*! @brief atmospheric density base class */ class AtmosphereBase: public SysModel { @@ -43,10 +44,14 @@ class AtmosphereBase: public SysModel { void Reset(uint64_t CurrentSimNanos); void addSpacecraftToModel(Message *tmpScMsg); void UpdateState(uint64_t CurrentSimNanos); + void setPlanetPolarRadius(double polarRadius); + double getPlanetPolarRadius() const; + void setExtrapolateScStateToStepMidpoint(bool enable); + bool getExtrapolateScStateToStepMidpoint() const; protected: void writeMessages(uint64_t CurrentClock); - bool readMessages(); + bool readMessages(uint64_t CurrentSimNanos); void updateLocalAtmosphere(double currentTime); void updateRelativePos(SpicePlanetStateMsgPayload *planetState, SCStatesMsgPayload *scState); virtual void evaluateAtmosphereModel(AtmoPropsMsgPayload *msg, double currentTime) = 0; //!< class method @@ -73,9 +78,14 @@ class AtmosphereBase: public SysModel { std::vector envOutBuffer; //!< -- Message buffer for magnetic field messages std::vector scStates; //!< vector of the spacecraft state messages SpicePlanetStateMsgPayload planetState; //!< planet state message + uint64_t previousUpdateNanos = 0; //!< [ns] time of the previous module update, used to detect + //!< stale spacecraft messages struct tm epochDateTime; //!< time/date structure containing the epoch information using a Gregorian calendar private: + double planetPolarRadius = -1.0; //!< [m] polar radius of the planet; a negative value selects a spherical planet + //!< of radius planetRadius, planetRadius must not change after Reset() + ScStateExtrapolation scStateExtrapolation; //!< opt-in extrapolation of the spacecraft state to the step midpoint // Public output-message vectors are borrowed views; only these smart pointers own the messages. std::vector>> ownedEnvOutMsgs; //!< Storage for envOutMsgs. }; diff --git a/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.rst b/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.rst index c7be735149e..eab33845b07 100644 --- a/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.rst +++ b/src/simulation/environment/_GeneralModuleFiles/atmosphereBase.rst @@ -22,4 +22,56 @@ provides information on what this message is used for. input planetPosInMsg SpicePlanetStateMsgPayload (optional) planet state input message. If not provided the planet state is zero information. input epochInMsg EpochMsgPayload - (optional) epoch date/time input message. + (optional) epoch date/time input message. The date and time are interpreted as UTC, independent of the time zone of the computer. + +Spacecraft State Timing +----------------------- +The module is normally executed before the spacecraft within a task, so the spacecraft state message it reads was written +at the end of the previous step. By default the message is used as written. To avoid an output that lags the interval it is applied +to, the extrapolation can be enabled with ``setExtrapolateScStateToStepMidpoint(True)``. The spacecraft position is +then advanced with the message velocity by half of the message age, which is the middle of the interval that the next +spacecraft update integrates. It is skipped for a message that was written at the current time and for a stale +message that was written before the previous update of the module, for example a state written once at the start of +the simulation. + +.. warning:: + + The extrapolation requires that the module is updated with the same task period as the spacecraft, so that the + state message written by the spacecraft at the previous module update is exactly one module interval old. If the + task periods differ, whether the spacecraft is faster or slower than the module, half of the message age is not the + middle of the module interval. The state is then not extrapolated to the midpoint: the spacecraft stay at the epoch + of their messages, and the planets are moved to that epoch if all the spacecraft messages were written at the + previous module update (they are left as written otherwise), so that every spacecraft is evaluated against the same + planet epoch. A warning is logged once when a spacecraft message is found not to have been written at the previous + module update. A task period mismatch is not always detectable this way: a message written at the current module + update, for example by a faster spacecraft that runs before the module, is used as written and gives no warning. No + message that is not the output of the previous module update is ever extrapolated. A task period that changes + during the simulation has the same effect. The extrapolation only starts once two write times of the spacecraft + state message have been observed and their interval equals the module update interval, so the first updates are not + extrapolated. Until then the spacecraft state is used as written and the planet is moved back to the epoch of that + state, so the relative geometry stays consistent. Run the module in the same task as the spacecraft, or at the same + period. + +When the extrapolation is enabled the position and the orientation of the planet (or of the sun and the planets for +the eclipse) are advanced, or moved back, with their message velocity and angular rate to the same middle of the +interval as the spacecraft, so that the relative geometry is evaluated at a single epoch. Time-dependent models, such +as the local solar time or the decimal year of the epoch, are evaluated at the same epoch as the geometry: the middle +of the interval if the extrapolation applies, the epoch of the previous update while the spacecraft state is not yet +extrapolated, and the current time otherwise. Inputs read from messages, such as a space weather sample, are not +shifted. +As in the gravity effector, the planet is advanced by the time since its message was written, whatever the age of the +message, so a planet message written once with a non-zero velocity is projected forward over the whole simulation. + +Planet Shape +------------ +By default the planet is a sphere and the altitude is the distance to the planet center minus ``planetRadius``. If the +planet polar radius is set with ``setPlanetPolarRadius()``, the altitude (and, for models that use it such as +:ref:`msisAtmosphere`, the latitude) is computed above the oblate ellipsoid defined by ``planetRadius`` and the polar +radius, and ``planetPosInMsg`` must be connected to provide the planet orientation. A planet message that leaves +``J20002Pfix`` at zero, as one that only sets the position and the velocity does, is treated as the identity. For the Earth use the equatorial +radius ``REQ_EARTH * 1000`` and the polar radius ``RP_EARTH * 1000`` from ``astroConstants``:: + + atmosphere.setPlanetPolarRadius(orbitalMotion.RP_EARTH * 1000.0) # [m] + +A negative polar radius, the default, selects the sphere. The difference between the two altitudes is up to about 21 km, +which changes the density of an atmosphere model by tens of percent at mid and high latitudes. diff --git a/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.cpp b/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.cpp index 5fb50c2c376..e5b325763ab 100644 --- a/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.cpp +++ b/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.cpp @@ -20,8 +20,10 @@ #include "magneticFieldBase.h" #include "architecture/messaging/ownedMessage.h" #include "architecture/utilities/linearAlgebra.h" +#include "architecture/utilities/utcTime.h" #include "architecture/utilities/macroDefinitions.h" #include "architecture/utilities/simDefinitions.h" +#include "architecture/utilities/stateExtrapolation.h" /*! This method initializes some basic parameters for the module. @@ -85,6 +87,9 @@ void MagneticFieldBase::addSpacecraftToModel(Message *tmpScM */ void MagneticFieldBase::Reset(uint64_t CurrentSimNanos) { + this->previousUpdateNanos = CurrentSimNanos; + this->scStateExtrapolation.reset(); + //! - call the custom environment module reset method customReset(CurrentSimNanos); @@ -99,7 +104,7 @@ void MagneticFieldBase::Reset(uint64_t CurrentSimNanos) this->epochDateTime.tm_hour = epochMsg.hours; this->epochDateTime.tm_min = epochMsg.minutes; this->epochDateTime.tm_sec = (int) round(epochMsg.seconds); - mktime(&this->epochDateTime); + normalizeUtcTime(&this->epochDateTime); } else { customSetEpochFromVariable(); } @@ -148,11 +153,35 @@ void MagneticFieldBase::customWriteMessages(uint64_t CurrentClock [[maybe_unused return; } -/*! This method is used to read the incoming command message and set the - associated spacecraft positions for computing the atmosphere. +/*! Enables or disables the extrapolation of the spacecraft state to the middle of the interval the next spacecraft + update integrates, see extrapolateScStateToStepMidpoint(). It is disabled by default, in which case the spacecraft + state message is used as written. The extrapolation assumes that the module and the spacecraft run at the same task + rate with a constant spacecraft step; a warning is logged once if a spacecraft message was not written at the + previous module update, which is typically a task rate mismatch. A mismatch is not always detectable, but a message + that is not the output of the previous module update is never extrapolated. + @param enable [-] true to extrapolate the spacecraft state to the middle of the step + */ +void MagneticFieldBase::setExtrapolateScStateToStepMidpoint(bool enable) +{ + this->scStateExtrapolation.setEnabled(enable); +} +/*! Returns whether the spacecraft state extrapolation is enabled. + @return [-] true if the spacecraft state is extrapolated */ -bool MagneticFieldBase::readMessages() +bool MagneticFieldBase::getExtrapolateScStateToStepMidpoint() const +{ + return this->scStateExtrapolation.isEnabled(); +} + +/*! This method is used to read the incoming command message and set the + associated spacecraft positions for computing the magnetic field. If enabled with + setExtrapolateScStateToStepMidpoint(), the spacecraft state is extrapolated to the middle of the interval the next + spacecraft update integrates, see extrapolateScStateToStepMidpoint(). + @param CurrentSimNanos [ns] current simulation time + @return true if all required messages were read + */ +bool MagneticFieldBase::readMessages(uint64_t CurrentSimNanos) { SCStatesMsgPayload scMsg; @@ -163,9 +192,12 @@ bool MagneticFieldBase::readMessages() if(this->scStateInMsgs.size() > 0) { scRead = true; + this->scStateExtrapolation.prepareFromMessages( + CurrentSimNanos, this->previousUpdateNanos, this->scStateInMsgs, this->bskLogger); for (long unsigned int c=0; cscStateInMsgs.size(); c++) { bool tmpScRead; - scMsg = this->scStateInMsgs.at(c)(); + scMsg = this->scStateExtrapolation.applyMessage( + this->scStateInMsgs.at(c), CurrentSimNanos, this->previousUpdateNanos); tmpScRead = this->scStateInMsgs.at(c).isWritten(); scRead = scRead && tmpScRead; @@ -180,7 +212,8 @@ bool MagneticFieldBase::readMessages() bool planetRead = true; if(this->planetPosInMsg.isLinked()) { - this->planetState = this->planetPosInMsg(); + this->planetState = this->scStateExtrapolation.applyPlanetMessage( + this->planetPosInMsg, CurrentSimNanos, this->previousUpdateNanos); planetRead = this->planetPosInMsg.isWritten(); } @@ -260,13 +293,15 @@ void MagneticFieldBase::UpdateState(uint64_t CurrentSimNanos) *it = this->envOutMsgs[0]->zeroMsgPayload; } //! - update local neutral density information - if(this->readMessages()) - { - updateLocalMagField(static_cast(CurrentSimNanos) * NANO2SEC); + if (this->readMessages(CurrentSimNanos)) { + // time dependent models are evaluated at the epoch of the (possibly extrapolated) geometry + const uint64_t epochNanos = this->scStateExtrapolation.evaluationEpochNanos(CurrentSimNanos, this->previousUpdateNanos); // [ns] + updateLocalMagField(static_cast(epochNanos) * NANO2SEC); } //! - write out neutral density message this->writeMessages(CurrentSimNanos); + this->previousUpdateNanos = CurrentSimNanos; return; } diff --git a/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.h b/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.h index a83a195ce19..a348e80a065 100644 --- a/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.h +++ b/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.h @@ -36,6 +36,7 @@ #include "architecture/messaging/messaging.h" #include "architecture/utilities/bskLogging.h" +#include "architecture/utilities/stateExtrapolation.h" /*! @brief magnetic field base class */ class MagneticFieldBase: public SysModel { @@ -45,10 +46,12 @@ class MagneticFieldBase: public SysModel { void Reset(uint64_t CurrentSimNanos); void addSpacecraftToModel(Message *tmpScMsg); void UpdateState(uint64_t CurrentSimNanos); + void setExtrapolateScStateToStepMidpoint(bool enable); + bool getExtrapolateScStateToStepMidpoint() const; protected: void writeMessages(uint64_t CurrentClock); - bool readMessages(); + bool readMessages(uint64_t CurrentSimNanos); void updateLocalMagField(double currentTime); void updateRelativePos(SpicePlanetStateMsgPayload *planetState, SCStatesMsgPayload *scState); virtual void evaluateMagneticFieldModel(MagneticFieldMsgPayload *msg, double currentTime) = 0; //!< class method @@ -75,10 +78,13 @@ class MagneticFieldBase: public SysModel { std::vector magFieldOutBuffer; //!< -- Message buffer for magnetic field messages std::vector scStates;//!< vector of the spacecraft state messages + uint64_t previousUpdateNanos = 0; //!< [ns] time of the previous module update, used to detect + //!< stale spacecraft messages SpicePlanetStateMsgPayload planetState; //!< planet state message struct tm epochDateTime; //!< time/date structure containing the epoch information using a Gregorian calendar private: + ScStateExtrapolation scStateExtrapolation; //!< opt-in extrapolation of the spacecraft state to the step midpoint // Public output-message vectors are borrowed views; only these smart pointers own the messages. std::vector>> ownedEnvOutMsgs; //!< Storage for envOutMsgs. }; diff --git a/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.rst b/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.rst index 9f009b8d26e..88d4b82f295 100644 --- a/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.rst +++ b/src/simulation/environment/_GeneralModuleFiles/magneticFieldBase.rst @@ -23,4 +23,42 @@ provides information on what this message is used for. input planetPosInMsg SpicePlanetStateMsgPayload (optional) planet state input message. If not provided the planet state is zero information. input epochInMsg EpochMsgPayload - (optional) epoch date/time input message. + (optional) epoch date/time input message. The date and time are interpreted as UTC, independent of the time zone of the computer. + +Spacecraft State Timing +----------------------- +The module is normally executed before the spacecraft within a task, so the spacecraft state message it reads was written +at the end of the previous step. By default the message is used as written. To avoid an output that lags the interval it is applied +to, the extrapolation can be enabled with ``setExtrapolateScStateToStepMidpoint(True)``. The spacecraft position is +then advanced with the message velocity by half of the message age, which is the middle of the interval that the next +spacecraft update integrates. It is skipped for a message that was written at the current time and for a stale +message that was written before the previous update of the module, for example a state written once at the start of +the simulation. + +.. warning:: + + The extrapolation requires that the module is updated with the same task period as the spacecraft, so that the + state message written by the spacecraft at the previous module update is exactly one module interval old. If the + task periods differ, whether the spacecraft is faster or slower than the module, half of the message age is not the + middle of the module interval. The state is then not extrapolated to the midpoint: the spacecraft stay at the epoch + of their messages, and the planets are moved to that epoch if all the spacecraft messages were written at the + previous module update (they are left as written otherwise), so that every spacecraft is evaluated against the same + planet epoch. A warning is logged once when a spacecraft message is found not to have been written at the previous + module update. A task period mismatch is not always detectable this way: a message written at the current module + update, for example by a faster spacecraft that runs before the module, is used as written and gives no warning. No + message that is not the output of the previous module update is ever extrapolated. A task period that changes + during the simulation has the same effect. The extrapolation only starts once two write times of the spacecraft + state message have been observed and their interval equals the module update interval, so the first updates are not + extrapolated. Until then the spacecraft state is used as written and the planet is moved back to the epoch of that + state, so the relative geometry stays consistent. Run the module in the same task as the spacecraft, or at the same + period. + +When the extrapolation is enabled the position and the orientation of the planet (or of the sun and the planets for +the eclipse) are advanced, or moved back, with their message velocity and angular rate to the same middle of the +interval as the spacecraft, so that the relative geometry is evaluated at a single epoch. Time-dependent models, such +as the local solar time or the decimal year of the epoch, are evaluated at the same epoch as the geometry: the middle +of the interval if the extrapolation applies, the epoch of the previous update while the spacecraft state is not yet +extrapolated, and the current time otherwise. Inputs read from messages, such as a space weather sample, are not +shifted. +As in the gravity effector, the planet is advanced by the time since its message was written, whatever the age of the +message, so a planet message written once with a non-zero velocity is projected forward over the whole simulation. diff --git a/src/simulation/environment/_GeneralModuleFiles/windBase.cpp b/src/simulation/environment/_GeneralModuleFiles/windBase.cpp index 0d3e3d7af96..8a871196e56 100644 --- a/src/simulation/environment/_GeneralModuleFiles/windBase.cpp +++ b/src/simulation/environment/_GeneralModuleFiles/windBase.cpp @@ -20,8 +20,10 @@ #include "windBase.h" #include "architecture/messaging/ownedMessage.h" #include "architecture/utilities/linearAlgebra.h" +#include "architecture/utilities/utcTime.h" #include "architecture/utilities/macroDefinitions.h" #include "architecture/utilities/simDefinitions.h" +#include "architecture/utilities/stateExtrapolation.h" WindBase::WindBase() { @@ -43,6 +45,9 @@ WindBase::~WindBase() = default; void WindBase::Reset(uint64_t CurrentSimNanos) { + this->previousUpdateNanos = CurrentSimNanos; + this->scStateExtrapolation.reset(); + if (this->scStateInMsgs.empty()) { bskLogger.bskError("Wind model has no spacecraft added to it."); } @@ -62,7 +67,7 @@ void WindBase::Reset(uint64_t CurrentSimNanos) this->epochDateTime.tm_hour = epochMsg.hours; this->epochDateTime.tm_min = epochMsg.minutes; this->epochDateTime.tm_sec = (int) round(epochMsg.seconds); - mktime(&this->epochDateTime); + normalizeUtcTime(&this->epochDateTime); } else { customSetEpochFromVariable(); } @@ -84,7 +89,17 @@ void WindBase::customReset(uint64_t CurrentClock [[maybe_unused]]) void WindBase::customSetEpochFromVariable() {} -bool WindBase::readMessages() +void WindBase::setExtrapolateScStateToStepMidpoint(bool enable) +{ + this->scStateExtrapolation.setEnabled(enable); +} + +bool WindBase::getExtrapolateScStateToStepMidpoint() const +{ + return this->scStateExtrapolation.isEnabled(); +} + +bool WindBase::readMessages(uint64_t CurrentSimNanos) { this->scStates.clear(); bool scRead = std::all_of( @@ -92,15 +107,18 @@ bool WindBase::readMessages() [](auto& msg) { return msg.isWritten(); } ); - if (scRead) { + bool planetRead = this->planetPosInMsg.isWritten(); + if (scRead && planetRead) { + this->scStateExtrapolation.prepareFromMessages( + CurrentSimNanos, this->previousUpdateNanos, this->scStateInMsgs, this->bskLogger); for (auto& msg : this->scStateInMsgs) { - this->scStates.push_back(msg()); + this->scStates.push_back( + this->scStateExtrapolation.applyMessage(msg, CurrentSimNanos, this->previousUpdateNanos)); } + this->planetState = this->scStateExtrapolation.applyPlanetMessage( + this->planetPosInMsg, CurrentSimNanos, this->previousUpdateNanos); } - bool planetRead = this->planetPosInMsg.isWritten(); - this->planetState = this->planetPosInMsg(); - // Update planetOmega_N from SPICE data only if SPICE mode is enabled and planetPosInMsg has ever been written to if (this->useSpiceOmega && planetRead) { this->updatePlanetOmegaFromSpice(); @@ -201,8 +219,10 @@ void WindBase::UpdateState(uint64_t CurrentSimNanos) { this->envOutBuffer.clear(); - if (this->readMessages()) { - this->updateLocalWind(static_cast(CurrentSimNanos) * NANO2SEC); + if (this->readMessages(CurrentSimNanos)) { + // time dependent models are evaluated at the epoch of the (possibly extrapolated) geometry + const uint64_t epochNanos = this->scStateExtrapolation.evaluationEpochNanos(CurrentSimNanos, this->previousUpdateNanos); // [ns] + this->updateLocalWind(static_cast(epochNanos) * NANO2SEC); } else { // Zero outputs when message reads fail to avoid stale data for (size_t c = 0; c < this->envOutMsgs.size(); c++) { @@ -212,4 +232,5 @@ void WindBase::UpdateState(uint64_t CurrentSimNanos) } this->writeMessages(CurrentSimNanos); + this->previousUpdateNanos = CurrentSimNanos; } diff --git a/src/simulation/environment/_GeneralModuleFiles/windBase.h b/src/simulation/environment/_GeneralModuleFiles/windBase.h index 469dea76d79..0fe6e78d4be 100644 --- a/src/simulation/environment/_GeneralModuleFiles/windBase.h +++ b/src/simulation/environment/_GeneralModuleFiles/windBase.h @@ -34,6 +34,7 @@ #include "architecture/messaging/messaging.h" #include "architecture/utilities/bskLogging.h" #include "architecture/utilities/astroConstants.h" +#include "architecture/utilities/stateExtrapolation.h" /*! @brief Abstract base class for atmospheric wind models. * @@ -74,10 +75,13 @@ class WindBase : public SysModel { */ void writeMessages(uint64_t CurrentClock); - /*! Reads the spacecraft and planet state messages. + /*! Reads the spacecraft and planet state messages. If enabled with setExtrapolateScStateToStepMidpoint(), the + * spacecraft state is extrapolated to the middle of the interval the next spacecraft update integrates, see + * extrapolateScStateToStepMidpoint(). + * @param CurrentSimNanos Current simulation time (ns). * @return true if all required messages were successfully read. */ - bool readMessages(); + bool readMessages(uint64_t CurrentSimNanos); /*! Computes `r_BP_N` from spacecraft and planet positions. * @@ -156,10 +160,30 @@ class WindBase : public SysModel { */ void setUseSpiceOmegaFlag(bool useSpice); + /*! Enables or disables the extrapolation of the spacecraft state to the middle of the interval the next + * spacecraft update integrates, see extrapolateScStateToStepMidpoint(). It is disabled by default and + * assumes that the module and the spacecraft run at the same task rate with a constant spacecraft step. A + * warning is logged once if a spacecraft message was not written at the previous module update, which is + * typically a task rate mismatch. A mismatch is not always detectable, but a message that is not the output of + * the previous module update is never extrapolated. + * + * @param enable If true, extrapolate the spacecraft state. + */ + void setExtrapolateScStateToStepMidpoint(bool enable); + + /*! Returns whether the spacecraft state extrapolation is enabled. + * + * @return true if the spacecraft state is extrapolated. + */ + bool getExtrapolateScStateToStepMidpoint() const; + protected: struct tm epochDateTime{}; //!< Epoch date/time (Gregorian) for time-dependent models std::vector envOutBuffer{}; //!< Message write buffer for each spacecraft std::vector scStates{}; //!< Cached spacecraft state messages + uint64_t previousUpdateNanos = 0; //!< [ns] Time of the previous module update, used to detect + //!< stale spacecraft messages + ScStateExtrapolation scStateExtrapolation{}; //!< Opt-in extrapolation of the spacecraft state to the step midpoint SpicePlanetStateMsgPayload planetState{}; //!< Cached planet state message /*! Updates `spiceOmega_N` from `J20002Pfix_dot` when SPICE provides it. diff --git a/src/simulation/environment/_GeneralModuleFiles/windBase.rst b/src/simulation/environment/_GeneralModuleFiles/windBase.rst index b9090b8f5a3..1d88f692573 100644 --- a/src/simulation/environment/_GeneralModuleFiles/windBase.rst +++ b/src/simulation/environment/_GeneralModuleFiles/windBase.rst @@ -44,7 +44,8 @@ For time-dependent empirical wind models, ``WindBase`` maintains an ``epochDateT structure (``struct tm``) initialised to the Basilisk standard epoch (2019-01-01 00:00:00). During ``Reset()``: -- If ``epochInMsg`` is linked, the epoch is read from that message. +- If ``epochInMsg`` is linked, the epoch is read from that message. It is interpreted as UTC, independent of the time + zone of the computer. - Otherwise, ``customSetEpochFromVariable()`` is called, giving subclasses the opportunity to set the epoch from a module-level variable. @@ -63,3 +64,41 @@ The following table lists all the module input and output messages. Planet SPICE state input message. Provides ``PositionVector`` used to compute ``r_BP_N``. Must be connected before ``InitializeSimulation()``. input epochInMsg EpochMsgPayload (Optional) Epoch date/time message. When connected, overrides the default Basilisk epoch stored in ``epochDateTime``. Required by empirical wind models that depend on calendar date. + +Spacecraft State Timing +----------------------- +The module is normally executed before the spacecraft within a task, so the spacecraft state message it reads was written +at the end of the previous step. By default the message is used as written. To avoid an output that lags the interval it is applied +to, the extrapolation can be enabled with ``setExtrapolateScStateToStepMidpoint(True)``. The spacecraft position is +then advanced with the message velocity by half of the message age, which is the middle of the interval that the next +spacecraft update integrates. It is skipped for a message that was written at the current time and for a stale +message that was written before the previous update of the module, for example a state written once at the start of +the simulation. + +.. warning:: + + The extrapolation requires that the module is updated with the same task period as the spacecraft, so that the + state message written by the spacecraft at the previous module update is exactly one module interval old. If the + task periods differ, whether the spacecraft is faster or slower than the module, half of the message age is not the + middle of the module interval. The state is then not extrapolated to the midpoint: the spacecraft stay at the epoch + of their messages, and the planets are moved to that epoch if all the spacecraft messages were written at the + previous module update (they are left as written otherwise), so that every spacecraft is evaluated against the same + planet epoch. A warning is logged once when a spacecraft message is found not to have been written at the previous + module update. A task period mismatch is not always detectable this way: a message written at the current module + update, for example by a faster spacecraft that runs before the module, is used as written and gives no warning. No + message that is not the output of the previous module update is ever extrapolated. A task period that changes + during the simulation has the same effect. The extrapolation only starts once two write times of the spacecraft + state message have been observed and their interval equals the module update interval, so the first updates are not + extrapolated. Until then the spacecraft state is used as written and the planet is moved back to the epoch of that + state, so the relative geometry stays consistent. Run the module in the same task as the spacecraft, or at the same + period. + +When the extrapolation is enabled the position and the orientation of the planet (or of the sun and the planets for +the eclipse) are advanced, or moved back, with their message velocity and angular rate to the same middle of the +interval as the spacecraft, so that the relative geometry is evaluated at a single epoch. Time-dependent models, such +as the local solar time or the decimal year of the epoch, are evaluated at the same epoch as the geometry: the middle +of the interval if the extrapolation applies, the epoch of the previous update while the spacecraft state is not yet +extrapolated, and the current time otherwise. Inputs read from messages, such as a space weather sample, are not +shifted. +As in the gravity effector, the planet is advanced by the time since its message was written, whatever the age of the +message, so a planet message written once with a non-zero velocity is projected forward over the whole simulation. diff --git a/src/simulation/environment/eclipse/_UnitTest/test_eclipse.py b/src/simulation/environment/eclipse/_UnitTest/test_eclipse.py index 8705a735632..ba75be21f1f 100755 --- a/src/simulation/environment/eclipse/_UnitTest/test_eclipse.py +++ b/src/simulation/environment/eclipse/_UnitTest/test_eclipse.py @@ -408,6 +408,75 @@ def unitEclipseCustom(show_plots): return [testFailCount, ''.join(testMessages)] +def test_eclipse_uses_spacecraft_state_extrapolated_to_step_midpoint(): + """ +**Test Description and Success Criteria** + +The spacecraft state message read by the module was written at the end of the previous step, so the +illumination factor lags the interval it is applied to. The module therefore advances the spacecraft position +with its velocity by half of the message age. + +A spacecraft is placed outside the Earth shadow, moving towards it fast enough that the position advanced +by half a step is inside the shadow. The state is given to one module with its velocity, written at the start +of the simulation and rewritten one step later, and to a second module as the already advanced position written at the current time. The two +illumination factors must match, and both must differ from a static spacecraft that does not move. + + """ + stepSeconds = 10.0 # [s] + halfStepShift = np.array([0.0, -4.0e4, 0.0]) * stepSeconds / 2.0 # [m] v * dt / 2 + rStart = np.array([-7.0e6, 6.50e6, 0.0]) # [m] outside the penumbra, which extends ~35 km beyond the Earth radius + + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(stepSeconds))) + + sunPayload = messaging.SpicePlanetStateMsgPayload() + sunPayload.PositionVector = [orbitalMotion.AU * 1000.0, 0.0, 0.0] # [m] + sunMsg = messaging.SpicePlanetStateMsg().write(sunPayload) + planetPayload = messaging.SpicePlanetStateMsgPayload() + planetPayload.PlanetName = "earth" + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload) + + rewrites = [] # (message, payload, [ns] time of the rewrite one step later) + + def stateMsg(r, v, timeNanos): + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = r.tolist() + payload.v_BN_N = v.tolist() + msg = messaging.SCStatesMsg().write(payload, timeNanos) + rewrites.append((msg, payload, timeNanos + macros.sec2nano(stepSeconds))) + return msg + + withVelocity = stateMsg(rStart, np.array([0.0, -4.0e4, 0.0]), 0) + advanced = stateMsg(rStart + halfStepShift, np.zeros(3), macros.sec2nano(stepSeconds)) + static = stateMsg(rStart, np.zeros(3), 0) + + recorders = [] + for scMsg in (withVelocity, advanced, static): + module = eclipse.Eclipse() + module.setExtrapolateScStateToStepMidpoint(True) + module.addSpacecraftToModel(scMsg) + module.addPlanetToModel(planetMsg) + module.sunInMsg.subscribeTo(sunMsg) + scSim.AddModelToTask("t", module) + recorders.append(module.eclipseOutMsgs[0].recorder()) + scSim.AddModelToTask("t", recorders[-1]) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(stepSeconds)) + scSim.ExecuteSimulation() + # the spacecraft rewrites its state one step later, so that the module observes the spacecraft task period + for msg, payload, timeNanos in rewrites: + msg.write(payload, timeNanos) + scSim.ConfigureStopTime(macros.sec2nano(2 * stepSeconds)) + scSim.ExecuteSimulation() + + factorVelocity, factorAdvanced, factorStatic = (r.illuminationFactor[-1] for r in recorders) + assert factorStatic == pytest.approx(1.0) + assert factorAdvanced < 1.0 + assert factorVelocity == pytest.approx(factorAdvanced, abs=1e-12) + + def test_shadow_vs_illumination_alias_and_deprecation_behavior(): """ Check aliasing and deprecation behavior for EclipseMsgPayload. diff --git a/src/simulation/environment/eclipse/eclipse.cpp b/src/simulation/environment/eclipse/eclipse.cpp index dd16fb9b79d..ea5b1761c95 100755 --- a/src/simulation/environment/eclipse/eclipse.cpp +++ b/src/simulation/environment/eclipse/eclipse.cpp @@ -22,6 +22,7 @@ #include #include "architecture/utilities/astroConstants.h" #include "architecture/utilities/avsEigenSupport.h" +#include "architecture/utilities/stateExtrapolation.h" Eclipse::Eclipse() @@ -37,8 +38,11 @@ Eclipse::~Eclipse() = default; /*! Reset the module to origina configuration values. */ -void Eclipse::Reset(uint64_t CurrenSimNanos [[maybe_unused]]) +void Eclipse::Reset(uint64_t CurrenSimNanos) { + this->previousUpdateNanos = CurrenSimNanos; + this->scStateExtrapolation.reset(); + if (!this->sunInMsg.isLinked()) { bskLogger.bskError("Eclipse: sunInMsg must be linked to sun Spice state message."); } @@ -53,19 +57,49 @@ void Eclipse::Reset(uint64_t CurrenSimNanos [[maybe_unused]]) } +/*! Enables or disables the extrapolation of the spacecraft state to the middle of the interval the next spacecraft + update integrates, see extrapolateScStateToStepMidpoint(). It is disabled by default, in which case the spacecraft + state message is used as written. The extrapolation assumes that the module and the spacecraft run at the same task + rate with a constant spacecraft step; a warning is logged once if a spacecraft message was not written at the + previous module update, which is typically a task rate mismatch. A mismatch is not always detectable, but a message + that is not the output of the previous module update is never extrapolated. + @param enable [-] true to extrapolate the spacecraft state to the middle of the step + */ +void Eclipse::setExtrapolateScStateToStepMidpoint(bool enable) +{ + this->scStateExtrapolation.setEnabled(enable); +} + +/*! Returns whether the spacecraft state extrapolation is enabled. + @return [-] true if the spacecraft state is extrapolated + */ +bool Eclipse::getExtrapolateScStateToStepMidpoint() const +{ + return this->scStateExtrapolation.isEnabled(); +} + /*! This method reads the spacecraft state, spice planet states and the sun position from the messaging system. + If enabled with setExtrapolateScStateToStepMidpoint(), the spacecraft state is extrapolated to the + middle of the interval the next spacecraft update integrates, see extrapolateScStateToStepMidpoint(). + @param CurrentSimNanos [ns] current simulation time */ -void Eclipse::readInputMessages() +void Eclipse::readInputMessages(uint64_t CurrentSimNanos) { + this->scStateExtrapolation.prepareFromMessages( + CurrentSimNanos, this->previousUpdateNanos, this->positionInMsgs, this->bskLogger); + for (long unsigned int c = 0; cpositionInMsgs.size(); c++){ - this->scStateBuffer.at(c) = this->positionInMsgs.at(c)(); + this->scStateBuffer.at(c) = this->scStateExtrapolation.applyMessage( + this->positionInMsgs.at(c), CurrentSimNanos, this->previousUpdateNanos); } - this->sunInMsgState = this->sunInMsg(); + this->sunInMsgState = this->scStateExtrapolation.applyPlanetMessage( + this->sunInMsg, CurrentSimNanos, this->previousUpdateNanos); for (long unsigned int c = 0; cplanetInMsgs.size(); c++){ - this->planetBuffer[c] = this->planetInMsgs[c](); + this->planetBuffer[c] = this->scStateExtrapolation.applyPlanetMessage( + this->planetInMsgs[c], CurrentSimNanos, this->previousUpdateNanos); } } @@ -90,7 +124,7 @@ void Eclipse::writeOutputMessages(uint64_t CurrentClock) */ void Eclipse::UpdateState(uint64_t CurrentSimNanos) { - this->readInputMessages(); + this->readInputMessages(CurrentSimNanos); // A lot of different vectors here. The below letters denote frames // P: planet frame @@ -178,6 +212,7 @@ void Eclipse::UpdateState(uint64_t CurrentSimNanos) scIdx++; } this->writeOutputMessages(CurrentSimNanos); + this->previousUpdateNanos = CurrentSimNanos; } /*! This method computes the fraction of sunlight given an eclipse. diff --git a/src/simulation/environment/eclipse/eclipse.h b/src/simulation/environment/eclipse/eclipse.h index f8b1f8828fd..918c2ca7250 100755 --- a/src/simulation/environment/eclipse/eclipse.h +++ b/src/simulation/environment/eclipse/eclipse.h @@ -33,6 +33,7 @@ #include "architecture/messaging/messaging.h" #include "architecture/utilities/linearAlgebra.h" +#include "architecture/utilities/stateExtrapolation.h" #include "architecture/utilities/bskLogging.h" @@ -47,6 +48,8 @@ class Eclipse: public SysModel { void writeOutputMessages(uint64_t CurrentClock); void addSpacecraftToModel(Message *tmpScMsg); void addPlanetToModel(Message *tmpSpMsg); + void setExtrapolateScStateToStepMidpoint(bool enable); + bool getExtrapolateScStateToStepMidpoint() const; public: ReadFunctor sunInMsg; //!< sun ephemeris input message name @@ -62,9 +65,12 @@ class Eclipse: public SysModel { std::vector planetBuffer; //!< buffer of the spacecraft state input messages SpicePlanetStateMsgPayload sunInMsgState; //!< copy of sun input msg std::vector eclipseIlluminationFactors; //!< vector of illumination factor output values + uint64_t previousUpdateNanos = 0; //!< [ns] time of the previous module update, used to detect + //!< stale spacecraft messages + ScStateExtrapolation scStateExtrapolation; //!< opt-in extrapolation of the spacecraft state to the step midpoint private: - void readInputMessages(); + void readInputMessages(uint64_t CurrentSimNanos); double computePercentIllumination(double planetRadius, Eigen::Vector3d r_HB_N, Eigen::Vector3d s_BP_N); double getPlanetEquatorialRadius(std::string planetSpiceName); diff --git a/src/simulation/environment/eclipse/eclipse.rst b/src/simulation/environment/eclipse/eclipse.rst index 01638b2eb55..5330a46b4f8 100644 --- a/src/simulation/environment/eclipse/eclipse.rst +++ b/src/simulation/environment/eclipse/eclipse.rst @@ -217,3 +217,39 @@ The ``eclipse`` module will output a series of messages of type :ref:`EclipseMsg eclipseObject.eclipseOutMsgs[2] where ``0`` indicates the first spacecraft illumination factor messages, etc. + +Spacecraft State Timing +----------------------- +The module is normally executed before the spacecraft within a task, so the spacecraft state message it reads was written +at the end of the previous step. By default the message is used as written. To avoid an output that lags the interval it is applied +to, the extrapolation can be enabled with ``setExtrapolateScStateToStepMidpoint(True)``. The spacecraft position is +then advanced with the message velocity by half of the message age, which is the middle of the interval that the next +spacecraft update integrates. It is skipped for a message that was written at the current time and for a stale +message that was written before the previous update of the module, for example a state written once at the start of +the simulation. + +.. warning:: + + The extrapolation requires that the module is updated with the same task period as the spacecraft, so that the + state message written by the spacecraft at the previous module update is exactly one module interval old. If the + task periods differ, whether the spacecraft is faster or slower than the module, half of the message age is not the + middle of the module interval. The state is then not extrapolated to the midpoint: the spacecraft stay at the epoch + of their messages, and the planets are moved to that epoch if all the spacecraft messages were written at the + previous module update (they are left as written otherwise), so that every spacecraft is evaluated against the same + planet epoch. A warning is logged once when a spacecraft message is found not to have been written at the previous + module update. A task period mismatch is not always detectable this way: a message written at the current module + update, for example by a faster spacecraft that runs before the module, is used as written and gives no warning. No + message that is not the output of the previous module update is ever extrapolated. A task period that changes + during the simulation has the same effect. The extrapolation only starts once two write times of the spacecraft + state message have been observed and their interval equals the module update interval, so the first updates are not + extrapolated. Until then the spacecraft state is used as written and the sun and the planets are moved back to the + epoch of that state, so the relative geometry stays consistent. Run the module in the same task as the spacecraft, + or at the same period. + +When the extrapolation is enabled the position and the orientation of the planet (or of the sun and the planets for +the eclipse) are advanced, or moved back, with their message velocity and angular rate to the same middle of the +interval as the spacecraft, so that the relative geometry is evaluated at a single epoch. Other time-dependent inputs, +such as the epoch used for solar time or a space weather sample, are not shifted and are evaluated at the current +time. +As in the gravity effector, the planet is advanced by the time since its message was written, whatever the age of the +message, so a planet message written once with a non-zero velocity is projected forward over the whole simulation. diff --git a/src/simulation/environment/magneticFieldCenteredDipole/_UnitTest/test_magneticFieldStepLag.py b/src/simulation/environment/magneticFieldCenteredDipole/_UnitTest/test_magneticFieldStepLag.py new file mode 100644 index 00000000000..0081f8f2c14 --- /dev/null +++ b/src/simulation/environment/magneticFieldCenteredDipole/_UnitTest/test_magneticFieldStepLag.py @@ -0,0 +1,140 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Test that the magnetic field base class evaluates the spacecraft state extrapolated to the step midpoint.""" + +import numpy as np +import pytest + +from Basilisk.architecture import messaging +from Basilisk.simulation import magneticFieldCenteredDipole +from Basilisk.utilities import SimulationBaseClass, macros, orbitalMotion, simSetPlanetEnvironment + +STEP = 10.0 # [s] module update period + + +def _field(r, v, timeNanos): + """Return the dipole field at the third module update. + + Args: + r (list): [m] spacecraft position written to the state message. + v (list): [m/s] spacecraft velocity written to the state message. + timeNanos (int): [ns] time at which the state message is written. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + dipole = magneticFieldCenteredDipole.MagneticFieldCenteredDipole() + dipole.setExtrapolateScStateToStepMidpoint(True) + simSetPlanetEnvironment.centeredDipoleMagField(dipole, "earth") + planetPayload = messaging.SpicePlanetStateMsgPayload() + planetPayload.PlanetName = "earth" + planetPayload.J20002Pfix = np.eye(3).tolist() + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload) + dipole.planetPosInMsg.subscribeTo(planetMsg) + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = list(r) + payload.v_BN_N = list(v) + scMsg = messaging.SCStatesMsg().write(payload, timeNanos) + dipole.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", dipole) + recorder = dipole.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + # the spacecraft rewrites its state one step later, so that the module observes the spacecraft task period + scMsg.write(payload, timeNanos + macros.sec2nano(STEP)) + scSim.ConfigureStopTime(macros.sec2nano(2 * STEP)) + scSim.ExecuteSimulation() + return np.array(recorder.magField_N[-1]) + + +def test_field_is_evaluated_at_extrapolated_position(): + """Verify the field uses the state advanced by half of the message age. + + A state with a radial velocity, written at the start of the simulation and + rewritten one step later, must give the field of + the position advanced by v * dt / 2; the field decays with the cube of the radius so the + advanced and static fields differ measurably.""" + r0 = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + v0 = [1.0e3, 0.0, 0.0] # [m/s] radial + shift = v0[0] * STEP / 2.0 # [m] + + fieldFromVelocity = _field(r0, v0, 0) + fieldAdvanced = _field([r0[0] + shift, 0.0, 0.0], [0.0, 0.0, 0.0], macros.sec2nano(STEP)) + fieldStatic = _field(r0, [0.0, 0.0, 0.0], 0) + fieldWrittenNow = _field(r0, v0, macros.sec2nano(STEP)) + + np.testing.assert_allclose(fieldFromVelocity, fieldAdvanced, rtol=1e-12) + assert np.linalg.norm(fieldFromVelocity) < np.linalg.norm(fieldStatic) + np.testing.assert_allclose(fieldWrittenNow, fieldStatic, rtol=1e-12) + + +def _fieldWithPlanet(planetVelocity): + """Return the dipole field at the third update for a spacecraft that moves together with the planet. + + The planet message is written at the current time and the spacecraft message one step earlier, so the relative + position is only constant if both are evaluated at the same epoch. + + Args: + planetVelocity (list): [m/s] common velocity of the spacecraft and the planet. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + dipole = magneticFieldCenteredDipole.MagneticFieldCenteredDipole() + dipole.setExtrapolateScStateToStepMidpoint(True) + simSetPlanetEnvironment.centeredDipoleMagField(dipole, "earth") + planetPayload = messaging.SpicePlanetStateMsgPayload() + planetPayload.PlanetName = "earth" + planetPayload.PositionVector = (np.array(planetVelocity) * STEP).tolist() # [m] + planetPayload.VelocityVector = list(planetVelocity) + planetPayload.J20002Pfix = np.eye(3).tolist() + planetMsg = messaging.SpicePlanetStateMsg().write(planetPayload, macros.sec2nano(2 * STEP)) + dipole.planetPosInMsg.subscribeTo(planetMsg) + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + payload.v_BN_N = list(planetVelocity) + scMsg = messaging.SCStatesMsg().write(payload, 0) + dipole.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", dipole) + recorder = dipole.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + # the spacecraft rewrites its state one step later, so that the module observes the spacecraft task period + scMsg.write(payload, 0 + macros.sec2nano(STEP)) + scSim.ConfigureStopTime(macros.sec2nano(2 * STEP)) + scSim.ExecuteSimulation() + return np.array(recorder.magField_N[-1]) + + +def test_translating_planet_does_not_change_the_field(): + """Verify the spacecraft and the planet are evaluated at the same epoch. + + The spacecraft and the planet move together at 30 km/s, so the relative position and the field are constant.""" + np.testing.assert_allclose(_fieldWithPlanet([0.0, 3.0e4, 0.0]), _fieldWithPlanet([0.0, 0.0, 0.0]), rtol=1e-9) + + +if __name__ == "__main__": + test_field_is_evaluated_at_extrapolated_position() + test_translating_planet_does_not_change_the_field() diff --git a/src/simulation/environment/magneticFieldWMM/_UnitTest/test_magneticFieldWMMStepLag.py b/src/simulation/environment/magneticFieldWMM/_UnitTest/test_magneticFieldWMMStepLag.py new file mode 100644 index 00000000000..06e6312f1c0 --- /dev/null +++ b/src/simulation/environment/magneticFieldWMM/_UnitTest/test_magneticFieldWMMStepLag.py @@ -0,0 +1,92 @@ +# ISC License +# +# Copyright (c) 2026, PIC4SeR & AVS Lab, Politecnico di Torino & Argotec S.R.L., University of Colorado 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. + +"""Test that the magnetic field base class evaluates a time dependent model at the step midpoint. + +The WMM field changes with the decimal year of the evaluation time. The spacecraft is at rest, so the extrapolation +moves nothing and only the evaluation time of the model can change the field.""" + +import numpy as np + +from Basilisk.architecture import messaging +from Basilisk.simulation import magneticFieldWMM +from Basilisk.utilities import SimulationBaseClass, macros, orbitalMotion +from Basilisk.utilities.supportDataTools.dataFetcher import get_path, DataFile + +STEP = 10.0 # [s] module update period +EPOCH_SECONDS = 30.0 # [s] seconds of the minute of the epoch 2025-06-01 12:00 + + +def _field(extrapolate, epochSeconds=EPOCH_SECONDS): + """Return the WMM field at the third module update, at 20 s. + + Args: + extrapolate (bool): True to enable the extrapolation of the spacecraft state to the step midpoint. + epochSeconds (float): [s] seconds of the minute of the epoch 2025-06-01 12:00. + """ + scSim = SimulationBaseClass.SimBaseClass() + proc = scSim.CreateNewProcess("p") + proc.addTask(scSim.CreateNewTask("t", macros.sec2nano(STEP))) + + wmm = magneticFieldWMM.MagneticFieldWMM() + wmm.ModelTag = "WMM" + wmm.configureWMMFile(str(get_path(DataFile.MagneticFieldData.WMM))) + wmm.setExtrapolateScStateToStepMidpoint(extrapolate) + epochPayload = messaging.EpochMsgPayload() + epochPayload.year = 2025 + epochPayload.month = 6 + epochPayload.day = 1 + epochPayload.hours = 12 + epochPayload.minutes = 0 + epochPayload.seconds = epochSeconds + epochMsg = messaging.EpochMsg().write(epochPayload) + wmm.epochInMsg.subscribeTo(epochMsg) + + payload = messaging.SCStatesMsgPayload() + payload.r_BN_N = [(orbitalMotion.REQ_EARTH + 400.0) * 1000.0, 0.0, 0.0] # [m] + scMsg = messaging.SCStatesMsg().write(payload, 0) + wmm.addSpacecraftToModel(scMsg) + scSim.AddModelToTask("t", wmm) + recorder = wmm.envOutMsgs[0].recorder() + scSim.AddModelToTask("t", recorder) + + scSim.InitializeSimulation() + scSim.ConfigureStopTime(macros.sec2nano(STEP)) + scSim.ExecuteSimulation() + # the spacecraft rewrites its state one step later, so that the module observes the spacecraft task period + scMsg.write(payload, macros.sec2nano(STEP)) + scSim.ConfigureStopTime(macros.sec2nano(2 * STEP)) + scSim.ExecuteSimulation() + return np.array(recorder.magField_N[-1]) + + +def test_model_time_is_the_step_midpoint(): + """Verify the model is evaluated at the step midpoint when the extrapolation applies. + + With the extrapolation the third update, at 20 s, evaluates the model at 15 s. This must equal the field of a run + without extrapolation whose epoch is 5 s earlier, and differ from the field of a run without extrapolation at the + same epoch, which evaluates at 20 s.""" + fieldMidpoint = _field(True) + fieldMidpointReference = _field(False, EPOCH_SECONDS - STEP / 2.0) + fieldCurrentTime = _field(False) + + np.testing.assert_allclose(fieldMidpoint, fieldMidpointReference, rtol=1e-13, atol=0.0) + relativeChange = np.linalg.norm(fieldMidpoint - fieldCurrentTime) / np.linalg.norm(fieldCurrentTime) + assert relativeChange > 1e-12 # the secular variation over 5 s is of the order of 1e-10 + + +if __name__ == "__main__": + test_model_time_is_the_step_midpoint() diff --git a/src/simulation/environment/magneticFieldWMM/magneticFieldWMM.cpp b/src/simulation/environment/magneticFieldWMM/magneticFieldWMM.cpp index 39d34ed3ae9..228815c0184 100644 --- a/src/simulation/environment/magneticFieldWMM/magneticFieldWMM.cpp +++ b/src/simulation/environment/magneticFieldWMM/magneticFieldWMM.cpp @@ -19,6 +19,7 @@ #include "magneticFieldWMM.h" #include "architecture/utilities/linearAlgebra.h" +#include "architecture/utilities/utcTime.h" #include "architecture/utilities/rigidBodyKinematics.h" #include "EGM9615.h" #include @@ -120,7 +121,7 @@ void MagneticFieldWMM::decimalYear2Gregorian(double fractionalYear [[maybe_unuse this->epochDateTime.tm_isdst = 0; //! - make sure a proper time structure is setup - mktime(&this->epochDateTime); + normalizeUtcTime(&this->epochDateTime); } /*! Convert a time structure with gregorian date/time information into a fraction year value. @@ -136,7 +137,7 @@ double MagneticFieldWMM::gregorian2DecimalYear(double currentTime) char Error_Message[255]; localDateTime = this->epochDateTime; localDateTime.tm_sec += (int) round(currentTime); // sets the current seconds - mktime(&localDateTime); + normalizeUtcTime(&localDateTime); calendar.Year = localDateTime.tm_year + 1900; calendar.Month = localDateTime.tm_mon + 1; diff --git a/src/simulation/environment/solarFlux/solarFlux.cpp b/src/simulation/environment/solarFlux/solarFlux.cpp index e12464c0f65..39dde17ba74 100755 --- a/src/simulation/environment/solarFlux/solarFlux.cpp +++ b/src/simulation/environment/solarFlux/solarFlux.cpp @@ -19,13 +19,16 @@ #include "solarFlux.h" #include "architecture/utilities/astroConstants.h" - +#include "architecture/utilities/stateExtrapolation.h" /*! This method is used to reset the module. Currently no tasks are required. */ -void SolarFlux::Reset(uint64_t CurrentSimNanos [[maybe_unused]]) +void SolarFlux::Reset(uint64_t CurrentSimNanos) { + this->previousUpdateNanos = CurrentSimNanos; + this->scStateExtrapolation.reset(); + // check if input message has not been included if (!this->sunPositionInMsg.isLinked()) { bskLogger.bskError("solarFlux.sunPositionInMsg was not linked."); @@ -42,7 +45,7 @@ void SolarFlux::Reset(uint64_t CurrentSimNanos [[maybe_unused]]) */ void SolarFlux::UpdateState(uint64_t CurrentSimNanos) { - this->readMessages(); + this->readMessages(CurrentSimNanos); /*! - evaluate spacecraft position relative to the sun in N frame components */ auto r_SSc_N = this->r_SN_N - this->r_ScN_N; @@ -54,23 +57,52 @@ void SolarFlux::UpdateState(uint64_t CurrentSimNanos) this->fluxAtSpacecraft = SOLAR_FLUX_EARTH * pow(AU, 2) / pow(dist_SSc_N, 2) * this->eclipseFactor; this->writeMessages(CurrentSimNanos); + this->previousUpdateNanos = CurrentSimNanos; } -/*! This method is used to read messages and save values to member attributes +/*! Enables or disables the extrapolation of the spacecraft state to the middle of the interval the next spacecraft + update integrates, see extrapolateScStateToStepMidpoint(). It is disabled by default, in which case the spacecraft + state message is used as written. The extrapolation assumes that the module and the spacecraft run at the same task + rate with a constant spacecraft step; a warning is logged once if a spacecraft message was not written at the + previous module update, which is typically a task rate mismatch. A mismatch is not always detectable, but a message + that is not the output of the previous module update is never extrapolated. + @param enable [-] true to extrapolate the spacecraft state to the middle of the step + */ +void SolarFlux::setExtrapolateScStateToStepMidpoint(bool enable) +{ + this->scStateExtrapolation.setEnabled(enable); +} +/*! Returns whether the spacecraft state extrapolation is enabled. + @return [-] true if the spacecraft state is extrapolated */ -void SolarFlux::readMessages() +bool SolarFlux::getExtrapolateScStateToStepMidpoint() const { - /*! - read in planet state message (required) */ - SpicePlanetStateMsgPayload sunPositionMsgData; - sunPositionMsgData = this->sunPositionInMsg(); - this->r_SN_N = Eigen::Vector3d(sunPositionMsgData.PositionVector); + return this->scStateExtrapolation.isEnabled(); +} + +/*! This method is used to read messages and save values to member attributes. If enabled with + setExtrapolateScStateToStepMidpoint(), the spacecraft position is extrapolated to the middle of the interval the + next spacecraft update integrates, see extrapolateScStateToStepMidpoint(). + @param CurrentSimNanos [ns] current simulation time + */ +void SolarFlux::readMessages(uint64_t CurrentSimNanos) +{ /*! - read in spacecraft state message (required) */ SCStatesMsgPayload scStatesMsgData; - scStatesMsgData = this->spacecraftStateInMsg(); + this->scStateExtrapolation.prepareFromMessage( + CurrentSimNanos, this->previousUpdateNanos, this->spacecraftStateInMsg, this->bskLogger); + scStatesMsgData = this->scStateExtrapolation.applyMessage( + this->spacecraftStateInMsg, CurrentSimNanos, this->previousUpdateNanos); this->r_ScN_N = Eigen::Vector3d(scStatesMsgData.r_BN_N); + /*! - read in planet state message (required), evaluated at the same epoch as the spacecraft state */ + SpicePlanetStateMsgPayload sunPositionMsgData; + sunPositionMsgData = this->scStateExtrapolation.applyPlanetMessage( + this->sunPositionInMsg, CurrentSimNanos, this->previousUpdateNanos); + this->r_SN_N = Eigen::Vector3d(sunPositionMsgData.PositionVector); + /*! - read in eclipse message (optional) */ if (this->eclipseInMsg.isLinked()) { EclipseMsgPayload eclipseInMsgData; diff --git a/src/simulation/environment/solarFlux/solarFlux.h b/src/simulation/environment/solarFlux/solarFlux.h index dea6490f439..8abf3ff1750 100755 --- a/src/simulation/environment/solarFlux/solarFlux.h +++ b/src/simulation/environment/solarFlux/solarFlux.h @@ -28,6 +28,7 @@ #include "architecture/msgPayloadDefC/SCStatesMsgPayload.h" #include "architecture/msgPayloadDefC/EclipseMsgPayload.h" #include "architecture/messaging/messaging.h" +#include "architecture/utilities/stateExtrapolation.h" /*! @brief solar flux class */ @@ -35,11 +36,13 @@ class SolarFlux: public SysModel { public: SolarFlux(){}; ~SolarFlux(){}; - + void Reset(uint64_t CurrentSimNanos) override; void UpdateState(uint64_t CurrentSimNanos) override; void writeMessages(uint64_t CurrentSimNanos); - void readMessages(); + void readMessages(uint64_t CurrentSimNanos); + void setExtrapolateScStateToStepMidpoint(bool enable); + bool getExtrapolateScStateToStepMidpoint() const; public: ReadFunctor sunPositionInMsg; //!< sun state input message @@ -54,5 +57,7 @@ class SolarFlux: public SysModel { double eclipseFactor = 1.0; //!< [] 1.0 is full sun, 0.0 is full eclipse Eigen::Vector3d r_SN_N; //!< [m] sun position Eigen::Vector3d r_ScN_N; //!< [m] s/c position - + uint64_t previousUpdateNanos = 0; //!< [ns] time of the previous module update, used to detect + //!< stale spacecraft messages + ScStateExtrapolation scStateExtrapolation; //!< opt-in extrapolation of the spacecraft state to the step midpoint }; diff --git a/src/simulation/environment/solarFlux/solarFlux.rst b/src/simulation/environment/solarFlux/solarFlux.rst index 356f955193b..02e18710c6b 100644 --- a/src/simulation/environment/solarFlux/solarFlux.rst +++ b/src/simulation/environment/solarFlux/solarFlux.rst @@ -77,3 +77,36 @@ The names below are only special in that they are useful defaults and are actual ``shadowFactor`` is **deprecated** in favor of ``illuminationFactor``. In C/C++ both names work until **Dec 31, 2026**. After that date, code must use ``illuminationFactor`` exclusively. + +Spacecraft State Timing +----------------------- +The module is normally executed before the spacecraft within a task, so the spacecraft state message it reads was written +at the end of the previous step. By default the message is used as written. To avoid an output that lags the interval it is applied +to, the extrapolation can be enabled with ``setExtrapolateScStateToStepMidpoint(True)``. The spacecraft position is +then advanced with the message velocity by half of the message age, which is the middle of the interval that the next +spacecraft update integrates. It is skipped for a message that was written at the current time and for a stale +message that was written before the previous update of the module, for example a state written once at the start of +the simulation. + +.. warning:: + + The extrapolation requires that the module is updated with the same task period as the spacecraft, so that the state + message written by the spacecraft at the previous module update is exactly one module interval old. If the task + periods differ, whether the spacecraft is faster or slower than the module, half of the message age is not the + middle of the module interval. The state is then not extrapolated. A warning is logged once when the spacecraft + message is found not to have been written at the previous module update. A task period mismatch is not always + detectable this way: a message written at the current module update, for example by a faster spacecraft that runs + before the module, is used as written and gives no warning. No message that is not the output of the previous module + update is ever extrapolated. A task period that changes during the simulation has the same effect. The extrapolation + only starts once two write times of the spacecraft state message have been observed and their interval equals the + module update interval, so the first updates are not extrapolated. Until then the spacecraft state is used as + written and the sun is moved back to the epoch of that state, so the relative geometry stays consistent. Run the + module in the same task as the spacecraft, or at the same period. + +When the extrapolation is enabled the position and the orientation of the planet (or of the sun and the planets for +the eclipse) are advanced, or moved back, with their message velocity and angular rate to the same middle of the +interval as the spacecraft, so that the relative geometry is evaluated at a single epoch. Other time-dependent inputs, +such as the epoch used for solar time or a space weather sample, are not shifted and are evaluated at the current +time. +As in the gravity effector, the planet is advanced by the time since its message was written, whatever the age of the +message, so a planet message written once with a non-zero velocity is projected forward over the whole simulation. diff --git a/src/tests/test_scenarioFormationMeanOEFeedback.py b/src/tests/test_scenarioFormationMeanOEFeedback.py index d835c89f27c..cfd684b10c1 100644 --- a/src/tests/test_scenarioFormationMeanOEFeedback.py +++ b/src/tests/test_scenarioFormationMeanOEFeedback.py @@ -63,43 +63,43 @@ def test_bskFormationMeanOEFeedback(show_plots, useClassicElem): # setup truth data for unit test truePos = [ [2.25733295e+06, 6.10774942e+06, 1.07696101e+06] - , [ -1.16512929e+07, -5.29348123e+06, -9.38586463e+05] - , [ -4.18797841e+06, -1.45287489e+07, -2.56449944e+06] - , [ 7.22483339e+06, -9.08494945e+06, -1.59809085e+06] + , [-1.16512871e+07, -5.29348500e+06, -9.38587959e+05] + , [-4.18795722e+06, -1.45287438e+07, -2.56449870e+06] + , [7.22485250e+06, -9.08490705e+06, -1.59808179e+06] ] trueVel = [ [-8.64065671e+03, 3.09716311e+03, 5.46113421e+02] - , [ 2.89326360e+02, -4.99814168e+03, -8.81761101e+02] - , [ 3.85696908e+03, -8.90509382e+02, -1.55219414e+02] - , [ 2.70894786e+03, 4.86593271e+03, 8.60079018e+02] + , [2.89330869e+02, -4.99814200e+03, -8.81761232e+02] + , [3.85697370e+03, -8.90502532e+02, -1.55217728e+02] + , [2.70893560e+03, 4.86595115e+03, 8.60082783e+02] ] truePos2 = trueVel2 = [] if(useClassicElem): truePos2 = [ [2.24178592e+06, 6.11203995e+06, 1.07566911e+06] - , [ -1.16485108e+07, -5.31123406e+06, -9.39973856e+05] - , [ -4.17872812e+06, -1.45368314e+07, -2.56333927e+06] - , [ 7.22884304e+06, -9.08762888e+06, -1.59750574e+06] + , [-1.16485050e+07, -5.31123782e+06, -9.39975349e+05] + , [-4.17870694e+06, -1.45368263e+07, -2.56333852e+06] + , [7.22886213e+06, -9.08758649e+06, -1.59749668e+06] ] trueVel2 = [ [-8.64850674e+03, 3.08090460e+03, 5.42902521e+02] - , [ 2.94836877e+02, -4.99616729e+03, -8.80700867e+02] - , [ 3.85655555e+03, -8.87290829e+02, -1.54783294e+02] - , [ 2.70687143e+03, 4.86568415e+03, 8.59163503e+02] + , [2.94841385e+02, -4.99616760e+03, -8.80700996e+02] + , [3.85656016e+03, -8.87283980e+02, -1.54781609e+02] + , [2.70685918e+03, 4.86570257e+03, 8.59167262e+02] ] else: truePos2 = [ [2.24178592e+06, 6.11203995e+06, 1.07566911e+06] - , [ -1.16531048e+07, -5.30437497e+06, -9.38902968e+05] - , [ -4.18989234e+06, -1.45340982e+07, -2.56591300e+06] - , [ 7.22077411e+06, -9.09444949e+06, -1.60489676e+06] + , [-1.16530991e+07, -5.30437873e+06, -9.38904459e+05] + , [-4.18987116e+06, -1.45340931e+07, -2.56591226e+06] + , [7.22079323e+06, -9.09440713e+06, -1.60488770e+06] ] trueVel2 = [ [-8.64850674e+03, 3.08090460e+03, 5.42902521e+02] - , [ 2.91744842e+02, -4.99568854e+03, -8.80758263e+02] - , [ 3.85565686e+03, -8.89929967e+02, -1.56724355e+02] - , [ 2.71047752e+03, 4.86243659e+03, 8.58719415e+02] + , [2.91749347e+02, -4.99568886e+03, -8.80758393e+02] + , [3.85566147e+03, -8.89923124e+02, -1.56722671e+02] + , [2.71046529e+03, 4.86245502e+03, 8.58723185e+02] ] # compare the results to the truth values diff --git a/src/tests/test_scenarioSpiceReconstruction.py b/src/tests/test_scenarioSpiceReconstruction.py index eb2d4c4a568..6571a66bc0a 100644 --- a/src/tests/test_scenarioSpiceReconstruction.py +++ b/src/tests/test_scenarioSpiceReconstruction.py @@ -43,5 +43,6 @@ def test_scenarioSpiceReconstruction(): for pltName, fig in list(figureList.items()): simHelpers.saveScenarioFigure(pltName, fig, path) - # gravityEffector's linear extrapolation carries a km-scale error at this step; guard the run. - assert errClassic > 100.0 + # The classic gravityEffector path is accurate at this step: its error is far below the integrator's own + # error. + assert errClassic < 10.0