Repository: cmu-argus-2/GNC-Payload
Pull request #135: EKF and Dynamics Scripts
Author: login: fredmarkus
Created: 2025-03-24T21:33:45Z | Merged: 2025-04-03T16:59:04Z

This PR adds the EKF script and the trajectory script as discussed. Both are callable using command line args.

Also moves the IMU initialization to a separate utils file to reduce replicated code.


TO SOLVE:

_- [ ] The run_ekf file currently does not take in measurements but instead does the measurements itself. This should be changed once it is clear where the measurements are stored from the inference script._
- [x] Store day time check as well in order to reduce the amount of visualization necessary (no images need to be simulated at night time)

Solves #128, #133

Once the inference PR has been made, the EKF script needs to be adjusted to work with the correct measurements.


Diff:
diff --git a/orbit_determination/test_ekf.py b/orbit_determination/test_ekf.py
index 1113d9f1..dfdd8cb2 100644
--- a/orbit_determination/test_ekf.py
+++ b/orbit_determination/test_ekf.py
@@ -19,10 +19,8 @@
     SimulatedMLLandmarkBearingSensor,
 )
 from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
-from sensors.bias import BiasParams
 from sensors.camera_model import CameraModelManager
-from sensors.imu import IMU, IMUNoiseParams
-from sensors.sensor import SensorNoiseParams
+from sensors.imu import IMU
 from utils.brahe_utils import load_brahe_data_files_if_needed
 from utils.config_utils import load_config
 from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
@@ -30,64 +28,6 @@
 # pylint: disable=too-many-locals
 
 
-def imu_init(dt: float) -> IMU:
-    """
-    Initializes the IMU.
-
-    :param dt: The time step for the simulation.
-
-    :return: The initialized IMU.
-    """
-    # Initialize the IMU
-    # bias params are min max range of bias and sigma_w
-    # [units] and [(units/s)/sqrt(Hz)]
-    bias_params_x = BiasParams.get_random_params([-1e-2, 1e-2], [1e-6, 1e-5])
-    bias_params_y = BiasParams.get_random_params([-1e-2, 1e-2], [1e-6, 1e-5])
-    bias_params_z = BiasParams.get_random_params([-1e-2, 1e-2], [1e-6, 1e-5])
-    # bias_params = BiasParams.get_random_params([0, 0], [0, 0])
-    # sigma_v [units/sqrt(Hz)] & scale_factor_error [-]
-    sensor_noise_params_accel_x = SensorNoiseParams.get_random_params(
-        bias_params_x, [0, 0.0], [0, 0.0]
-    )
-    sensor_noise_params_accel_y = SensorNoiseParams.get_random_params(
-        bias_params_y, [0, 0.0], [0, 0.0]
-    )
-    sensor_noise_params_accel_z = SensorNoiseParams.get_random_params(
-        bias_params_z, [0, 0.0], [0, 0.0]
-    )
-    sensor_noise_params_accel = [
-        sensor_noise_params_accel_x,
-        sensor_noise_params_accel_y,
-        sensor_noise_params_accel_z,
-    ]
-    # sigma_v [units/sqrt(Hz)] & scale_factor_error [-]
-    sensor_noise_params_gyro_x = SensorNoiseParams.get_random_params(
-        bias_params_x, [1e-6, 1e-5], [0, 0.01]
-    )
-    sensor_noise_params_gyro_y = SensorNoiseParams.get_random_params(
-        bias_params_y, [1e-6, 1e-5], [0, 0.01]
-    )
-    sensor_noise_params_gyro_z = SensorNoiseParams.get_random_params(
-        bias_params_z, [1e-6, 1e-5], [0, 0.01]
-    )
-    sensor_noise_params_gyro = [
-        sensor_noise_params_gyro_x,
-        sensor_noise_params_gyro_y,
-        sensor_noise_params_gyro_z,
-    ]
-
-    imu_noise_params = IMUNoiseParams(
-        gyro_params=sensor_noise_params_gyro, accel_params=sensor_noise_params_accel
-    )
-    imu = IMU(
-        dt=dt,
-        IMU_noise_params=imu_noise_params,
-        misalignment_range=[-0.01, 0.01],
-    )
-
-    return imu
-
-
 def run_simulation() -> None:
     """
     Run the simulation.
@@ -173,7 +113,7 @@ def run_simulation() -> None:
     )
 
     # Initialize IMU and EKF
-    imu = imu_init(dt)
+    imu = IMU.get_default_imu(dt)
     gyro_bias = (imu.get_bias()[0] + np.random.normal(0, 5e-5, 3)) * gyro_bias_scale
     ekf = EKF(
         # error ranges are in meters and m/s
diff --git a/scripts/config.json b/scripts/config.json
new file mode 100644
index 00000000..f06d0965
--- /dev/null
+++ b/scripts/config.json
@@ -0,0 +1,9 @@
+{
+    "frequency": "2",
+    "duration": "2700",
+    "angular_velocity": [
+        "0.0",
+        "0.0",
+        "0.174532925"
+    ]
+}
diff --git a/scripts/run_dynamics.py b/scripts/run_dynamics.py
new file mode 100644
index 00000000..9b7b410a
--- /dev/null
+++ b/scripts/run_dynamics.py
@@ -0,0 +1,196 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+    --start_date: Start date of the spacecraft mission [MJD]
+    --northwards: Whether the spacecraft is moving northwards. If False, the spacecraft will move southwards.
+
+Arguments that are required by several scripts are loaded from the config.json file:
+    - angular_velocity: Angular velocity of the spacecraft [rad/s]
+    - frequency: Frequency of the dynamics model [Hz]
+    - duration: Duration of the spacecraft mission [s]
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import json
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_max_sso_latitude, get_sso_orbit_state, is_over_daytime
+
+
+def parse_args() -> argparse.Namespace:
+    """
+    Parse command-line arguments.
+    :return: The parsed arguments.
+    """
+    parser = argparse.ArgumentParser(description="Generate spacecraft trajectory")
+    parser.add_argument(
+        "--lat",
+        type=float,
+        help="Starting latitude of the spacecraft",
+    )
+    parser.add_argument(
+        "--lon",
+        type=float,
+        help="Starting longitude of the spacecraft",
+    )
+    parser.add_argument(
+        "--altitude",
+        type=float,
+        help="Starting altitude of the spacecraft [m]",
+    )
+    parser.add_argument(
+        "--name",
+        type=str,
+        default="test",
+        help="Name of the experiment",
+    )
+    parser.add_argument(
+        "--start_date",
+        type=float,
+        help="Start date of the spacecraft mission [MJD]",
+    )
+    parser.add_argument(
+        "--northwards",
+        type=bool,
+        default=True,
+        help="Whether the spacecraft is moving northwards. If False, the spacecraft will move southwards.",
+    )
+    return parser.parse_args()
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+
+    # Load json
+    with open(os.path.join(__file__, "../config.json"), "r") as jsonfile:
+        json_config = json.load(jsonfile)
+        angular_velocity = np.array(
+            json_config.get("angular_velocity", [0, 0, np.pi / 18]), dtype=float
+        )
+        f = float(json_config.get("frequency", 2))
+        mission_duration = float(json_config.get("duration", 2700))
+
+    # Check if altitude is provided and if not, generate a random altitude
+    # Then check that if a latitude is given, it is within the calculated sso bounds
+    if args.altitude is None:
+        init_altitude = 510e3 + np.random.uniform(-20e3, 20e3)
+        if args.lat is None:
+            max_lat = get_max_sso_latitude(init_altitude)
+            init_lat = np.random.uniform(-max_lat, max_lat)
+        else:
+            max_lat = get_max_sso_latitude(init_altitude)
+            if abs(args.lat) > max_lat:
+                raise ValueError(f"Latitude must be between -{max_lat} and {max_lat}")
+            init_lat = args.lat
+
+    else:
+        init_altitude = args.altitude
+        max_lat = get_max_sso_latitude(init_altitude)
+        if args.lat is not None and abs(args.lat) > max_lat:
+            raise ValueError(f"Latitude must be between -{max_lat} and {max_lat}")
+        else:
+            init_lat = np.random.uniform(-max_lat, max_lat)
+
+    if args.lon is None:
+        init_lon = np.random.uniform(-180, 180)
+    else:
+        init_lon = args.lon
+
+    # Check if start date is provided and if not, generate a random start date
+    # Start date between 2024-01-01 and 2025-01-01 (leap year so 366 days)
+    if args.start_date is None:
+        # Get random start date and round to 1 decimal place
+        args.start_date = round(np.random.uniform(60310, 60676), 1)
+
+    config = load_config()
+    config["solver"]["world_update_rate"] = f  # Hz
+    config["mission"]["duration"] = mission_duration  # s
+    config["mission"]["start_date"] = args.start_date
+
+    dt = 1 / config["solver"]["world_update_rate"]
+
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))
+    daytime = []
+
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    initial_state = get_sso_orbit_state(
+        starting_epoch, init_lat, init_lon, init_altitude, northwards=args.northwards
+    )
+    initial_rot = np.eye(3)
+    daytime.append(1 if is_over_daytime(starting_epoch, initial_state[0:3]) else 0)
+    data_manager.push_next_state(initial_state, initial_rot)
+
+    w = np.array(angular_velocity)
+
+    ground_truth_dynamics = Dynamics(
+        config=config,
+        use_drag=True,
+        use_j2=True,
+        use_j34=False,
+        use_moon_grav=False,
+        use_sun_grav=False,
+    )
+
+    for _ in range(0, N - 1):
+        state = data_manager.latest_state
+        q = data_manager.latest_attitude
+
+        next_state = ground_truth_dynamics.perturbed_f(
+            x=state[0:6],
+            dt=dt,
+            epoch=data_manager.latest_epoch,
+        )
+        next_quat = quaternion.from_rotation_matrix(q) * quaternion.from_rotation_vector(w * dt)
+
+        daytime.append(1 if is_over_daytime(data_manager.latest_epoch, state[0:3]) else 0)
+
+        data_manager.push_next_state(next_state[0:6], quaternion.as_rotation_matrix(next_quat))
+
+    # Save the trajectory and attitude data
+    trajectory = data_manager.states
+    attitude = data_manager.eci_Rs_body
+    daytime = np.array(daytime)
+
+    if not os.path.exists(f"output_dir/{args.name}"):
+        print(f"Directory output_dir/{args.name} does not exist.")
+        os.makedirs(f"output_dir/{args.name}")
+        print(f"Created directory output_dir/{args.name}")
+    np.save(f"output_dir/{args.name}/trajectory_gt.npy", trajectory)
+    np.save(f"output_dir/{args.name}/attitude_gt.npy", attitude)
+    np.save(f"output_dir/{args.name}/daytime_gt.npy", daytime)
+
+
+if __name__ == "__main__":
+
+    args = parse_args()
+    generate_trajectory(args)
diff --git a/scripts/run_ekf.py b/scripts/run_ekf.py
new file mode 100644
index 00000000..9c109964
--- /dev/null
+++ b/scripts/run_ekf.py
@@ -0,0 +1,268 @@
+"""
+Run the EKF using CLI.
+
+Arguments that are required by several scripts are loaded from the config.json file:
+    - angular_velocity: Angular velocity of the spacecraft [rad/s]
+    - frequency: Frequency of the dynamics model [Hz]
+    - duration: Duration of the spacecraft mission [s]
+This script requires the following arguments:
+    --name: Name of the experiment
+    --meas_rate: Rate at which measurements are taken
+
+The script expects to find the following contents in the output directory:
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+
+The script will generate the following contents in the output directory:
+- /output_dir
+    - /experiment_name
+        - ekf_state_data_.pkl
+
+The state data contains a dictionary with the following fields:
+    - timestep: The time step
+    - prior_position: The prior position estimate
+    - prior_velocity: The prior velocity estimate
+    - prior_attitude: The prior attitude estimate
+    - prior_covariance: The prior covariance estimate
+    - posterior_position: The posterior position estimate
+    - posterior_velocity: The posterior velocity estimate
+    - posterior_attitude: The posterior attitude estimate
+    - posterior_covariance: The posterior covariance estimate
+    - gyro_bias_estimate: The estimated gyro bias
+    - gyro_bias: The actual gyro bias
+    - unmodelled_acceleration: The unmodelled acceleration estimate
+    - drag_scalar_estimate: The drag scalar estimate
+
+"""
+
+import argparse
+import json
+import os
+import pickle
+from time import time
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.ekf_dynamics import EKFDynamics
+from orbit_determination.ekf import EKF
+from orbit_determination.landmark_bearing_sensors import (
+    GroundTruthLandmarkBearingSensor,
+    SimulatedMLLandmarkBearingSensor,
+)
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from sensors.camera_model import CameraModelManager
+from sensors.imu import IMU
+from utils.brahe_utils import load_brahe_data_files_if_needed
+from utils.config_utils import load_config
+from utils.orbit_utils import is_over_daytime
+
+# pylint: disable=too-many-locals
+
+
+def parse_args() -> argparse.Namespace:
+    """
+    Parse command-line arguments.
+    :return: The parsed arguments.
+    """
+    parser = argparse.ArgumentParser(description="Run the EKF simulation.")
+    parser.add_argument(
+        "--name",
+        type=str,
+        default="test",
+        help="Name of the experiment",
+    )
+    parser.add_argument(
+        "--meas_rate",
+        type=int,
+        default=120,
+        help="The rate at which measurements are supposed to be taken. 120 means that a measurement"
+        "is taken every 120 timesteps",
+    )
+
+    return parser.parse_args()
+
+
+def run_simulation(args) -> None:
+    """
+    Run the simulation.
+
+    :param args: The command line arguments.
+
+    :return: None
+    """
+
+    # Load json
+    with open("scripts/config.json", "r") as jsonfile:
+        json_config = json.load(jsonfile)
+        angular_velocity = np.array(
+            json_config.get("angular_velocity", [0, 0, np.pi / 18]), dtype=float
+        )
+        f = float(json_config.get("frequency", 2))
+        mission_duration = float(json_config.get("duration", 2700))
+
+    config = load_config()
+    # Set the world update rate and mission duration to a rate that is workable for testing
+    config["solver"]["world_update_rate"] = f  # Hz
+    config["mission"]["duration"] = mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))  # number of time steps in the simulation
+
+    landmark_bearing_sensor = GroundTruthLandmarkBearingSensor()
+    camera_model_manager = CameraModelManager()
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    if not os.path.exists(f"output_dir/{args.name}/trajectory_gt.npy") or not os.path.exists(
+        f"output_dir/{args.name}/attitude_gt.npy"
+    ):
+        raise FileNotFoundError(
+            f"One of the required files in {args.name} does not exist. Please run the trajectory generation script first."
+        )
+
+    trajectory_gt = np.load(f"output_dir/{args.name}/trajectory_gt.npy")
+    attitude_gt = np.load(f"output_dir/{args.name}/attitude_gt.npy")
+    # Set the initial rotation matrix to identity
+
+    data_manager.push_next_state(trajectory_gt[0], attitude_gt[0])
+
+    # Apply error to init_rot and ensure orthonormality
+    noisy_rot = attitude_gt[0] + np.random.normal(0, 1e-2, (3, 3))
+    noisy_rot = noisy_rot @ np.linalg.inv(np.linalg.cholesky(noisy_rot.T @ noisy_rot))
+
+    # Assert orthonormality
+    assert np.allclose(noisy_rot @ noisy_rot.T, np.eye(3), atol=1e-3) and np.isclose(
+        np.linalg.det(noisy_rot), 1
+    ), "Rotation matrix is not a proper rotation matrix"
+
+    # Set the number of update iterations for the IEKF
+    num_iter = 5
+
+    # Set up scaling parameter for the unmodelled acceleration
+    ua_scale = 10
+
+    # Set up scaling parameter for gyro bias
+    gyro_bias_scale = 2
+
+    # Fix a constant rotation velocity for the test.
+    rot = np.array(angular_velocity)
+
+    # Prep Q matrix for the EKF.
+    Q = np.eye(16) * 1e-12
+    # Unmodelled acceleration has larger uncertainty
+    Q[6:9, 6:9] = np.eye(3) * 1e-9
+    # Bias uncertainty also larger
+    Q[13:16, 13:16] = np.eye(3) * 1e-9
+
+    P = np.eye(16)
+    P[0:3, 0:3] *= 5
+    P[3:6, 3:6] *= 5
+    P[6:9, 6:9] *= 1e-4
+    P[9:10, 9:10] *= 1e-4
+    P[10:13, 10:13] *= 1e-4
+    P[13:16, 13:16] *= 1e-4
+
+    ekf_dynamics = EKFDynamics(
+        config=config,
+        use_drag=False,
+        use_j2=False,
+        use_unmodelled_a=True,
+        use_drag_scalar=True,
+        use_moon_grav=False,
+        use_sun_grav=False,
+        ua_scale=ua_scale,
+    )
+
+    # Initialize IMU and EKF
+    imu = IMU.get_default_imu(dt)
+    gyro_bias = (imu.get_bias()[0] + np.random.normal(0, 5e-5, 3)) * gyro_bias_scale
+    ekf = EKF(
+        # error ranges are in meters and m/s
+        r=trajectory_gt[0][0:3] + np.random.normal(0, 5000, 3),
+        v=trajectory_gt[0][3:6] + np.random.normal(0, 10, 3),
+        ua=np.random.normal(0, 1e-5, 3) * ua_scale,
+        q=quaternion.as_float_array(quaternion.from_rotation_matrix(noisy_rot)),
+        P=P,
+        Q=Q,
+        dt=dt,
+        config=config,
+        ekf_dynamics=ekf_dynamics,
+        w_b=gyro_bias,
+        gyro_bias_scale=gyro_bias_scale,
+    )
+
+    for t in range(0, N - 1):
+        # Apply noise to x, y to generate angular wobble around the primary rotation axis z
+        # One rotation every 10 seconds to model a relatively slow wobble
+        w = rot + 0.05 * np.array(
+            [np.cos(2 * np.pi * t / (10 / dt)), np.sin(2 * np.pi * t / (10 / dt)), 0]
+        )
+
+        # Get a gyro measurement to use in the EKF and the current gyro bias for the ground truth
+        gyro_meas, _ = imu.update(w, np.zeros((3)))
+        imu_gyro_bias = imu.get_bias()[0]
+
+        ekf.predict(u=gyro_meas, epoch=data_manager.latest_epoch)
+        data_manager.push_next_state(trajectory_gt[t], attitude_gt[t])
+
+        if t % args.meas_rate == 0 and is_over_daytime(
+            data_manager.latest_epoch, data_manager.latest_state[:3]
+        ):
+            for camera_name in CameraModelManager.CAMERA_NAMES:
+                data_manager.take_measurement(
+                    landmark_bearing_sensor, camera_model_manager[camera_name]
+                )
+            print(f"Total measurements so far: {data_manager.measurement_count}")
+            print(f"Completion: {100 * t / N:.2f}%")
+
+            # EKF prediction step
+            measurement_camera_names, *z = data_manager.latest_measurements
+
+            if z[0].shape[0] > 0:
+                ekf.measurement(
+                    z=z,
+                    camera_model_manager=camera_model_manager,
+                    measurement_camera_names=measurement_camera_names,
+                    epoch=data_manager.latest_epoch,
+                    num_iter=num_iter,
+                )
+            else:
+                ekf.no_measurement()
+                print("No measurements made in measurement step")
+        else:
+            ekf.no_measurement()
+
+        state_data = {
+            "timestep": t,
+            "prior_position": ekf.r_p,
+            "prior_velocity": ekf.v_p,
+            "prior_attitude": ekf.q_p,
+            "prior_covariance": ekf.P_p,
+            "posterior_position": ekf.r_m,
+            "posterior_velocity": ekf.v_m,
+            "posterior_attitude": ekf.q_m,
+            "posterior_covariance": ekf.P_m,
+            "gyro_bias_estimate": ekf.w_b,
+            "gyro_bias": imu_gyro_bias,
+            "unmodelled_acceleration": ekf.ua,
+            "drag_scalar_estimate": ekf.drag_est,
+        }
+        # Save the state data to a file
+        with open(f"output_dir/{args.name}/ekf_state_data_.pkl", "ab") as file:
+            pickle.dump(state_data, file)
+
+    if isinstance(landmark_bearing_sensor, SimulatedMLLandmarkBearingSensor):
+        # save measurements to pickle file
+        with open(f"od-simulation-data-{time()}.pkl", "wb") as file:
+            pickle.dump(data_manager, file)
+
+
+if __name__ == "__main__":
+    args = parse_args()
+    load_brahe_data_files_if_needed()
+    run_simulation(args)
diff --git a/sensors/imu.py b/sensors/imu.py
index fbe36a22..d797012d 100644
--- a/sensors/imu.py
+++ b/sensors/imu.py
@@ -4,7 +4,8 @@
 
 import numpy as np
 
-from sensors.sensor import TriAxisSensor
+from sensors.bias import BiasParams
+from sensors.sensor import SensorNoiseParams, TriAxisSensor
 
 
 class IMUNoiseParams:
@@ -20,7 +21,9 @@ def __init__(self, gyro_params: list, accel_params: list) -> None:
 
 
 class IMU:
-    def __init__(self, dt: float, IMU_noise_params: IMUNoiseParams, misalignment_range: list) -> None:
+    def __init__(
+        self, dt: float, IMU_noise_params: IMUNoiseParams, misalignment_range: list
+    ) -> None:
         """
         Initialize an IMU sensor with given noise parameters.
 
@@ -51,3 +54,61 @@ def update(self, clean_gyro_signal: np.ndarray, clean_accel_signal: np.ndarray)
         gyro_measurement = self.gyro.update(clean_gyro_signal)
         accel_measurement = self.accel.update(clean_accel_signal)
         return gyro_measurement, accel_measurement
+
+    @staticmethod
+    def get_default_imu(dt: float) -> "IMU":
+        """
+        Initializes the IMU.
+
+        :param dt: The time step for the simulation.
+
+        :return: The initialized IMU.
+        """
+        # Initialize the IMU
+        # bias params are min max range of bias and sigma_w
+        # [units] and [(units/s)/sqrt(Hz)]
+        bias_params_x = BiasParams.get_random_params([-1e-2, 1e-2], [1e-6, 1e-5])
+        bias_params_y = BiasParams.get_random_params([-1e-2, 1e-2], [1e-6, 1e-5])
+        bias_params_z = BiasParams.get_random_params([-1e-2, 1e-2], [1e-6, 1e-5])
+        # bias_params = BiasParams.get_random_params([0, 0], [0, 0])
+        # sigma_v [units/sqrt(Hz)] & scale_factor_error [-]
+        sensor_noise_params_accel_x = SensorNoiseParams.get_random_params(
+            bias_params_x, [0, 0.0], [0, 0.0]
+        )
+        sensor_noise_params_accel_y = SensorNoiseParams.get_random_params(
+            bias_params_y, [0, 0.0], [0, 0.0]
+        )
+        sensor_noise_params_accel_z = SensorNoiseParams.get_random_params(
+            bias_params_z, [0, 0.0], [0, 0.0]
+        )
+        sensor_noise_params_accel = [
+            sensor_noise_params_accel_x,
+            sensor_noise_params_accel_y,
+            sensor_noise_params_accel_z,
+        ]
+        # sigma_v [units/sqrt(Hz)] & scale_factor_error [-]
+        sensor_noise_params_gyro_x = SensorNoiseParams.get_random_params(
+            bias_params_x, [1e-6, 1e-5], [0, 0.01]
+        )
+        sensor_noise_params_gyro_y = SensorNoiseParams.get_random_params(
+            bias_params_y, [1e-6, 1e-5], [0, 0.01]
+        )
+        sensor_noise_params_gyro_z = SensorNoiseParams.get_random_params(
+            bias_params_z, [1e-6, 1e-5], [0, 0.01]
+        )
+        sensor_noise_params_gyro = [
+            sensor_noise_params_gyro_x,
+            sensor_noise_params_gyro_y,
+            sensor_noise_params_gyro_z,
+        ]
+
+        imu_noise_params = IMUNoiseParams(
+            gyro_params=sensor_noise_params_gyro, accel_params=sensor_noise_params_accel
+        )
+        imu = IMU(
+            dt=dt,
+            IMU_noise_params=imu_noise_params,
+            misalignment_range=[-0.01, 0.01],
+        )
+
+        return imu
diff --git a/sensors/sensor.py b/sensors/sensor.py
index 82176e26..3f88c87e 100644
--- a/sensors/sensor.py
+++ b/sensors/sensor.py
@@ -70,7 +70,6 @@ def __init__(self, dt: float, sensor_noise_params: SensorNoiseParams) -> None:
 
         self.value = 0
 
-
     def update(self, clean_signal: np.ndarray) -> np.ndarray:
         """
         Update the measurements of the sensor by applying noise to the clean signal.
@@ -91,7 +90,7 @@ def get_bias(self) -> float:
         Getter for the bias
         """
         return self.bias.get_bias()
-    
+
     def get_value(self) -> float:
         """
         Getter for the latest signal value
@@ -140,7 +139,9 @@ def update(self, clean_signal: np.ndarray) -> np.ndarray:
 
         # Apply misalignment to each sensor
         signal_x = (
-            self.x.get_value() + self.misalignmentxy * clean_signal[1] + self.misalignmentxz * clean_signal[2]
+            self.x.get_value()
+            + self.misalignmentxy * clean_signal[1]
+            + self.misalignmentxz * clean_signal[2]
         )
         signal_y = (
             self.y.get_value()


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces new scripts for spacecraft trajectory generation using dynamics and for running an Extended Kalman Filter (EKF) simulation while also refactoring duplicate code.  
- Added a dynamics script (run_dynamics.py) to generate spacecraft trajectories with day/night detection.  
- Added an EKF simulation script (run_ekf.py) that leverages trajectory ground truth and performs EKF prediction/measurement updates.  
- Moved IMU initialization to a common utils module and updated tests accordingly.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

| File                           | Description                                                                              |
| ------------------------------ | ---------------------------------------------------------------------------------------- |
| scripts/run_dynamics.py        | Implements dynamics-based trajectory generation and day/night detection.                |
| utils/imu_utils.py             | Contains common IMU initialization code to reduce duplication across the codebase.         |
| scripts/run_ekf.py             | Adds EKF simulation functionality integrating measurement updates and state saving.      |
| orbit_determination/test_ekf.py| Updates tests to use the centralized IMU initialization function.                        |


<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**scripts/run_dynamics.py:59**
* The variable 'inital_rot' appears to be misspelled; consider renaming it to 'initial_rot' for clarity.
```
inital_rot = np.eye(3)
```
**scripts/run_ekf.py:247**
* Using 'type=list' for the '--angular_velocity' argument may not properly parse CLI inputs; consider using 'nargs=3' with a type conversion (e.g., float) to ensure correct parsing.
```
parser.add_argument( "--angular_velocity", type=list, default=[0, 0, np.pi / 18],
```
**scripts/run_ekf.py:253**
* The measurement rate is used with a modulo operation which requires an integer; consider changing the argument type to int to avoid potential issues.
```
parser.add_argument( "--meas_rate", type=float, default=120,
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-03-26T18:09:18Z
user: login: amaarquadri
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T20:09:26Z
user: login: fredmarkus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T18:24:41Z
user: login: fredmarkus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T18:58:57Z
user: login: amaarquadri
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T18:27:59Z
user: login: fredmarkus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T19:59:07Z
user: login: amaarquadri
body: LGTM, great work! I'm just pushing 2 small nits and then I'll merge
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-03T16:54:12Z
user: login: amaarquadri
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T16:57:11Z

Review comments:
path: utils/imu_utils.py
user: login: amaarquadri
body: I think we should move this to be a static member function of the IMU class called `get_default_imu`. Currently, none of the utils files have dependencies from outside that package, which I think makes sense from a repo architecture point of view.
created_at: 2025-03-26T19:42:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,146 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+    config = load_config()
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))
+    daytime = []
+
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    initial_state = get_sso_orbit_state(
+        starting_epoch, args.lat, args.lon, args.altitude, northwards=True
+    )
+    initial_rot = np.eye(3)
+    daytime.append(1 if is_over_daytime(starting_epoch, initial_state[0:3]) else 0)
+    data_manager.push_next_state(initial_state, initial_rot)
+
+    w = np.array(args.angular_velocity)
+
+    ground_truth_dynamics = Dynamics(
+        config=config,
+        use_drag=True,
+        use_j2=True,
+    )
+
+    for _ in range(0, N - 1):
+        state = data_manager.latest_state
+        q = data_manager.latest_attitude
+
+        next_state = ground_truth_dynamics.perturbed_f(
+            x=state[0:6],
+            dt=dt,
+            epoch=data_manager.latest_epoch,
+        )
+        next_quat = quaternion.from_rotation_matrix(q) * quaternion.from_rotation_vector(w * dt)
+
+        daytime.append(1 if is_over_daytime(data_manager.latest_epoch, state[0:3]) else 0)
+
+        data_manager.push_next_state(next_state[0:6], quaternion.as_rotation_matrix(next_quat))
+
+    # Save the trajectory and attitude data
+    trajectory = data_manager.states
+    attitude = data_manager.eci_Rs_body
+    daytime = np.array(daytime)
+
+    if not os.path.exists(f"output_dir/{args.name}"):
+        print(f"Directory output_dir/{args.name} does not exist.")
+        os.makedirs(f"output_dir/{args.name}")
+        print(f"Created directory output_dir/{args.name}")
+    np.save(f"output_dir/{args.name}/trajectory_gt.npy", trajectory)
+    np.save(f"output_dir/{args.name}/attitude_gt.npy", attitude)
+    np.save(f"output_dir/{args.name}/daytime_gt.npy", daytime)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Generate spacecraft trajectory")
path: scripts/run_dynamics.py
user: login: amaarquadri
body: Can you please extract this into a `parse_args` function? That's the convention we've been following for all the training scripts.
created_at: 2025-03-26T19:44:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,146 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+    config = load_config()
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))
+    daytime = []
+
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    initial_state = get_sso_orbit_state(
+        starting_epoch, args.lat, args.lon, args.altitude, northwards=True
+    )
+    initial_rot = np.eye(3)
+    daytime.append(1 if is_over_daytime(starting_epoch, initial_state[0:3]) else 0)
+    data_manager.push_next_state(initial_state, initial_rot)
+
+    w = np.array(args.angular_velocity)
+
+    ground_truth_dynamics = Dynamics(
+        config=config,
+        use_drag=True,
+        use_j2=True,
+    )
+
+    for _ in range(0, N - 1):
+        state = data_manager.latest_state
+        q = data_manager.latest_attitude
+
+        next_state = ground_truth_dynamics.perturbed_f(
+            x=state[0:6],
+            dt=dt,
+            epoch=data_manager.latest_epoch,
+        )
+        next_quat = quaternion.from_rotation_matrix(q) * quaternion.from_rotation_vector(w * dt)
+
+        daytime.append(1 if is_over_daytime(data_manager.latest_epoch, state[0:3]) else 0)
+
+        data_manager.push_next_state(next_state[0:6], quaternion.as_rotation_matrix(next_quat))
+
+    # Save the trajectory and attitude data
+    trajectory = data_manager.states
+    attitude = data_manager.eci_Rs_body
+    daytime = np.array(daytime)
+
+    if not os.path.exists(f"output_dir/{args.name}"):
+        print(f"Directory output_dir/{args.name} does not exist.")
+        os.makedirs(f"output_dir/{args.name}")
+        print(f"Created directory output_dir/{args.name}")
+    np.save(f"output_dir/{args.name}/trajectory_gt.npy", trajectory)
+    np.save(f"output_dir/{args.name}/attitude_gt.npy", attitude)
+    np.save(f"output_dir/{args.name}/daytime_gt.npy", daytime)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Generate spacecraft trajectory")
+    parser.add_argument(
+        "--f",
+        type=float,
+        default="1",
+        help="Frequency of the spacecraft trajectory",
+    )
+    parser.add_argument(
+        "--mission_duration",
+        type=float,
+        default="2700",
+        help="Duration of the spacecraft mission for which we are generating the trajectory [s]",
+    )
+    parser.add_argument(
+        "--lat",
+        type=float,
+        default="0",
+        help="Starting latitude of the spacecraft",
+    )
+    parser.add_argument(
+        "--lon",
+        type=float,
+        default="-73",
+        help="Starting longitude of the spacecraft",
+    )
+    parser.add_argument(
+        "--altitude",
+        type=float,
+        default="600_000",
path: scripts/run_dynamics.py
user: login: amaarquadri
body: 510km is our anticipated deployment altitude and this is the altitude range used for training.
```suggestion
        default=510e3 + np.random.uniform(-20e3, 20e3),
```
created_at: 2025-03-26T19:46:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,146 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+    config = load_config()
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
path: scripts/run_dynamics.py
user: login: amaarquadri
body: We should add the starting time as an argument too. We'll definitely want to test different times of day/year.
created_at: 2025-03-26T19:50:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,146 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+    config = load_config()
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))
+    daytime = []
+
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    initial_state = get_sso_orbit_state(
+        starting_epoch, args.lat, args.lon, args.altitude, northwards=True
path: scripts/run_dynamics.py
user: login: amaarquadri
body: Might as well as northwards as an argument too.
created_at: 2025-03-26T19:51:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,146 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+    config = load_config()
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))
+    daytime = []
+
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    initial_state = get_sso_orbit_state(
+        starting_epoch, args.lat, args.lon, args.altitude, northwards=True
+    )
+    initial_rot = np.eye(3)
+    daytime.append(1 if is_over_daytime(starting_epoch, initial_state[0:3]) else 0)
+    data_manager.push_next_state(initial_state, initial_rot)
+
+    w = np.array(args.angular_velocity)
+
+    ground_truth_dynamics = Dynamics(
+        config=config,
+        use_drag=True,
+        use_j2=True,
+    )
+
+    for _ in range(0, N - 1):
+        state = data_manager.latest_state
+        q = data_manager.latest_attitude
+
+        next_state = ground_truth_dynamics.perturbed_f(
+            x=state[0:6],
+            dt=dt,
+            epoch=data_manager.latest_epoch,
+        )
+        next_quat = quaternion.from_rotation_matrix(q) * quaternion.from_rotation_vector(w * dt)
+
+        daytime.append(1 if is_over_daytime(data_manager.latest_epoch, state[0:3]) else 0)
+
+        data_manager.push_next_state(next_state[0:6], quaternion.as_rotation_matrix(next_quat))
+
+    # Save the trajectory and attitude data
+    trajectory = data_manager.states
+    attitude = data_manager.eci_Rs_body
+    daytime = np.array(daytime)
+
+    if not os.path.exists(f"output_dir/{args.name}"):
+        print(f"Directory output_dir/{args.name} does not exist.")
+        os.makedirs(f"output_dir/{args.name}")
+        print(f"Created directory output_dir/{args.name}")
+    np.save(f"output_dir/{args.name}/trajectory_gt.npy", trajectory)
+    np.save(f"output_dir/{args.name}/attitude_gt.npy", attitude)
+    np.save(f"output_dir/{args.name}/daytime_gt.npy", daytime)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Generate spacecraft trajectory")
+    parser.add_argument(
+        "--f",
+        type=float,
+        default="1",
+        help="Frequency of the spacecraft trajectory",
+    )
+    parser.add_argument(
+        "--mission_duration",
+        type=float,
+        default="2700",
path: scripts/run_dynamics.py
user: login: amaarquadri
body: ```suggestion
        default=2700,
```
created_at: 2025-03-26T19:52:48Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,146 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+    config = load_config()
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))
+    daytime = []
+
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    initial_state = get_sso_orbit_state(
+        starting_epoch, args.lat, args.lon, args.altitude, northwards=True
+    )
+    initial_rot = np.eye(3)
+    daytime.append(1 if is_over_daytime(starting_epoch, initial_state[0:3]) else 0)
+    data_manager.push_next_state(initial_state, initial_rot)
+
+    w = np.array(args.angular_velocity)
+
+    ground_truth_dynamics = Dynamics(
+        config=config,
+        use_drag=True,
+        use_j2=True,
+    )
+
+    for _ in range(0, N - 1):
+        state = data_manager.latest_state
+        q = data_manager.latest_attitude
+
+        next_state = ground_truth_dynamics.perturbed_f(
+            x=state[0:6],
+            dt=dt,
+            epoch=data_manager.latest_epoch,
+        )
+        next_quat = quaternion.from_rotation_matrix(q) * quaternion.from_rotation_vector(w * dt)
+
+        daytime.append(1 if is_over_daytime(data_manager.latest_epoch, state[0:3]) else 0)
+
+        data_manager.push_next_state(next_state[0:6], quaternion.as_rotation_matrix(next_quat))
+
+    # Save the trajectory and attitude data
+    trajectory = data_manager.states
+    attitude = data_manager.eci_Rs_body
+    daytime = np.array(daytime)
+
+    if not os.path.exists(f"output_dir/{args.name}"):
+        print(f"Directory output_dir/{args.name} does not exist.")
+        os.makedirs(f"output_dir/{args.name}")
+        print(f"Created directory output_dir/{args.name}")
+    np.save(f"output_dir/{args.name}/trajectory_gt.npy", trajectory)
+    np.save(f"output_dir/{args.name}/attitude_gt.npy", attitude)
+    np.save(f"output_dir/{args.name}/daytime_gt.npy", daytime)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Generate spacecraft trajectory")
+    parser.add_argument(
+        "--f",
+        type=float,
+        default="1",
path: scripts/run_dynamics.py
user: login: amaarquadri
body: ```suggestion
        default=1,
```
created_at: 2025-03-26T19:52:56Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,146 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+    config = load_config()
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))
+    daytime = []
+
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    initial_state = get_sso_orbit_state(
+        starting_epoch, args.lat, args.lon, args.altitude, northwards=True
+    )
+    initial_rot = np.eye(3)
+    daytime.append(1 if is_over_daytime(starting_epoch, initial_state[0:3]) else 0)
+    data_manager.push_next_state(initial_state, initial_rot)
+
+    w = np.array(args.angular_velocity)
+
+    ground_truth_dynamics = Dynamics(
+        config=config,
+        use_drag=True,
+        use_j2=True,
+    )
+
+    for _ in range(0, N - 1):
+        state = data_manager.latest_state
+        q = data_manager.latest_attitude
+
+        next_state = ground_truth_dynamics.perturbed_f(
+            x=state[0:6],
+            dt=dt,
+            epoch=data_manager.latest_epoch,
+        )
+        next_quat = quaternion.from_rotation_matrix(q) * quaternion.from_rotation_vector(w * dt)
+
+        daytime.append(1 if is_over_daytime(data_manager.latest_epoch, state[0:3]) else 0)
+
+        data_manager.push_next_state(next_state[0:6], quaternion.as_rotation_matrix(next_quat))
+
+    # Save the trajectory and attitude data
+    trajectory = data_manager.states
+    attitude = data_manager.eci_Rs_body
+    daytime = np.array(daytime)
+
+    if not os.path.exists(f"output_dir/{args.name}"):
+        print(f"Directory output_dir/{args.name} does not exist.")
+        os.makedirs(f"output_dir/{args.name}")
+        print(f"Created directory output_dir/{args.name}")
+    np.save(f"output_dir/{args.name}/trajectory_gt.npy", trajectory)
+    np.save(f"output_dir/{args.name}/attitude_gt.npy", attitude)
+    np.save(f"output_dir/{args.name}/daytime_gt.npy", daytime)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Generate spacecraft trajectory")
+    parser.add_argument(
+        "--f",
+        type=float,
+        default="1",
+        help="Frequency of the spacecraft trajectory",
+    )
+    parser.add_argument(
+        "--mission_duration",
+        type=float,
+        default="2700",
+        help="Duration of the spacecraft mission for which we are generating the trajectory [s]",
+    )
+    parser.add_argument(
+        "--lat",
+        type=float,
+        default="0",
path: scripts/run_dynamics.py
user: login: amaarquadri
body: ```suggestion
        default=np.random.uniform(-89, 89),
```
created_at: 2025-03-26T19:54:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,146 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_sso_orbit_state, is_over_daytime
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+    config = load_config()
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))
+    daytime = []
+
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    initial_state = get_sso_orbit_state(
+        starting_epoch, args.lat, args.lon, args.altitude, northwards=True
+    )
+    initial_rot = np.eye(3)
+    daytime.append(1 if is_over_daytime(starting_epoch, initial_state[0:3]) else 0)
+    data_manager.push_next_state(initial_state, initial_rot)
+
+    w = np.array(args.angular_velocity)
+
+    ground_truth_dynamics = Dynamics(
+        config=config,
+        use_drag=True,
+        use_j2=True,
+    )
+
+    for _ in range(0, N - 1):
+        state = data_manager.latest_state
+        q = data_manager.latest_attitude
+
+        next_state = ground_truth_dynamics.perturbed_f(
+            x=state[0:6],
+            dt=dt,
+            epoch=data_manager.latest_epoch,
+        )
+        next_quat = quaternion.from_rotation_matrix(q) * quaternion.from_rotation_vector(w * dt)
+
+        daytime.append(1 if is_over_daytime(data_manager.latest_epoch, state[0:3]) else 0)
+
+        data_manager.push_next_state(next_state[0:6], quaternion.as_rotation_matrix(next_quat))
+
+    # Save the trajectory and attitude data
+    trajectory = data_manager.states
+    attitude = data_manager.eci_Rs_body
+    daytime = np.array(daytime)
+
+    if not os.path.exists(f"output_dir/{args.name}"):
+        print(f"Directory output_dir/{args.name} does not exist.")
+        os.makedirs(f"output_dir/{args.name}")
+        print(f"Created directory output_dir/{args.name}")
+    np.save(f"output_dir/{args.name}/trajectory_gt.npy", trajectory)
+    np.save(f"output_dir/{args.name}/attitude_gt.npy", attitude)
+    np.save(f"output_dir/{args.name}/daytime_gt.npy", daytime)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Generate spacecraft trajectory")
+    parser.add_argument(
+        "--f",
+        type=float,
+        default="1",
+        help="Frequency of the spacecraft trajectory",
+    )
+    parser.add_argument(
+        "--mission_duration",
+        type=float,
+        default="2700",
+        help="Duration of the spacecraft mission for which we are generating the trajectory [s]",
+    )
+    parser.add_argument(
+        "--lat",
+        type=float,
+        default="0",
+        help="Starting latitude of the spacecraft",
+    )
+    parser.add_argument(
+        "--lon",
+        type=float,
+        default="-73",
path: scripts/run_dynamics.py
user: login: amaarquadri
body: ```suggestion
        default=np.random.uniform(-180, 180),
```
created_at: 2025-03-26T19:54:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,263 @@
+"""
+Run the EKF using CLI.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --name: Name of the experiment
+    --angular_velocity: Angular velocity of the spacecraft [rad/s]
+    --meas_rate: Rate at which measurements are taken
+
+The script expects to find the following contents in the output directory:
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+
+The script will generate the following contents in the output directory:
+- /output_dir
+    - /experiment_name
+        - ekf_state_data_.pkl
+
+The state data contains the following fields:
path: scripts/run_ekf.py
user: login: amaarquadri
body: ```suggestion
The state data contains a dictionary with the following fields:
```
created_at: 2025-03-26T19:58:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,263 @@
+"""
+Run the EKF using CLI.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
path: scripts/run_ekf.py
user: login: amaarquadri
body: I think the frequency, duration, and angular velocity should all be determined based on the output of run_dynamics as opposed to separate arguments. I can definitely foresee myself forgetting the values I used.
created_at: 2025-03-26T20:02:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,263 @@
+"""
+Run the EKF using CLI.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
+    --mission_duration: Duration of the spacecraft mission for which we are generating the trajectory [s]
+    --name: Name of the experiment
+    --angular_velocity: Angular velocity of the spacecraft [rad/s]
+    --meas_rate: Rate at which measurements are taken
+
+The script expects to find the following contents in the output directory:
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+
+The script will generate the following contents in the output directory:
+- /output_dir
+    - /experiment_name
+        - ekf_state_data_.pkl
+
+The state data contains the following fields:
+    - timestep: The time step
+    - prior_position: The prior position estimate
+    - prior_velocity: The prior velocity estimate
+    - prior_attitude: The prior attitude estimate
+    - prior_covariance: The prior covariance estimate
+    - posterior_position: The posterior position estimate
+    - posterior_velocity: The posterior velocity estimate
+    - posterior_attitude: The posterior attitude estimate
+    - posterior_covariance: The posterior covariance estimate
+    - gyro_bias_estimate: The estimated gyro bias
+    - gyro_bias: The actual gyro bias
+    - unmodelled_acceleration: The unmodelled acceleration estimate
+
+"""
+
+import os
+import pickle
+from argparse import ArgumentParser
+from time import time
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.ekf_dynamics import EKFDynamics
+from orbit_determination.ekf import EKF
+from orbit_determination.landmark_bearing_sensors import (
+    GroundTruthLandmarkBearingSensor,
+    SimulatedMLLandmarkBearingSensor,
+)
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from sensors.camera_model import CameraModelManager
+from utils.brahe_utils import load_brahe_data_files_if_needed
+from utils.config_utils import load_config
+from utils.imu_utils import imu_init
+from utils.orbit_utils import is_over_daytime
+
+# pylint: disable=too-many-locals
+
+
+def run_simulation(args) -> None:
+    """
+    Run the simulation.
+
+    :param args: The command line arguments.
+
+    :return: None
+    """
+
+    config = load_config()
+    # Set the world update rate and mission duration to a rate that is workable for testing
+    config["solver"]["world_update_rate"] = args.f  # Hz
+    config["mission"]["duration"] = args.mission_duration  # s
+
+    dt = 1 / config["solver"]["world_update_rate"]
+    starting_epoch = Epoch(*brahe.time.mjd_to_caldate(config["mission"]["start_date"]))
+    N = int(np.ceil(config["mission"]["duration"] / dt))  # number of time steps in the simulation
+
+    landmark_bearing_sensor = GroundTruthLandmarkBearingSensor()
+    camera_model_manager = CameraModelManager()
+    data_manager = ODSimulationDataManager(starting_epoch, dt)
+
+    if not os.path.exists(f"output_dir/{args.name}/trajectory_gt.npy") or not os.path.exists(
+        f"output_dir/{args.name}/attitude_gt.npy"
+    ):
+        raise FileNotFoundError(
+            f"One of the required files in {args.name} does not exist. Please run the trajectory generation script first."
+        )
+
+    trajectory_gt = np.load(f"output_dir/{args.name}/trajectory_gt.npy")
+    attitude_gt = np.load(f"output_dir/{args.name}/attitude_gt.npy")
+    # Set the initial rotation matrix to identity
+
+    data_manager.push_next_state(trajectory_gt[0], attitude_gt[0])
+
+    # Apply error to init_rot and ensure orthonormality
+    noisy_rot = attitude_gt[0] + np.random.normal(0, 1e-2, (3, 3))
+    noisy_rot = noisy_rot @ np.linalg.inv(np.linalg.cholesky(noisy_rot.T @ noisy_rot))
+
+    # Assert orthonormality
+    assert np.allclose(noisy_rot @ noisy_rot.T, np.eye(3), atol=1e-3) and np.isclose(
+        np.linalg.det(noisy_rot), 1
+    ), "Rotation matrix is not a proper rotation matrix"
+
+    # Set the number of update iterations for the IEKF
+    num_iter = 5
+
+    # Set up scaling parameter for the unmodelled acceleration
+    ua_scale = 10
+
+    # Set up scaling parameter for gyro bias
+    gyro_bias_scale = 2
+
+    # Fix a constant rotation velocity for the test.
+    rot = np.array(args.angular_velocity)
+
+    # Prep Q matrix for the EKF.
+    Q = np.eye(15) * 1e-12
+    # Unmodelled acceleration has larger uncertainty
+    Q[6:9, 6:9] = np.eye(3) * 1e-9
+    # Bias uncertainty also larger
+    Q[12:15, 12:15] = np.eye(3) * 1e-9
+
+    P = np.eye(15)
+    P[0:3, 0:3] *= 5
+    P[3:6, 3:6] *= 5
+    P[6:9, 6:9] *= 1e-4
+    P[9:12, 9:12] *= 1e-4
+    P[12:15, 12:15] *= 1e-4
+
+    ekf_dynamics = EKFDynamics(
+        config=config,
+        use_drag=False,
+        use_j2=False,
+        use_unmodelled_a=True,
+        ua_scale=ua_scale,
+    )
+
+    # Initialize IMU and EKF
+    imu = imu_init(dt)
+    gyro_bias = (imu.get_bias()[0] + np.random.normal(0, 5e-5, 3)) * gyro_bias_scale
+    ekf = EKF(
+        # error ranges are in meters and m/s
+        r=trajectory_gt[0][0:3] + np.random.normal(0, 5000, 3),
+        v=trajectory_gt[0][3:6] + np.random.normal(0, 10, 3),
+        ua=np.random.normal(0, 1e-5, 3) * ua_scale,
+        q=quaternion.as_float_array(quaternion.from_rotation_matrix(noisy_rot)),
+        P=P,
+        Q=Q,
+        dt=dt,
+        config=config,
+        ekf_dynamics=ekf_dynamics,
+        w_b=gyro_bias,
+        gyro_bias_scale=gyro_bias_scale,
+    )
+
+    for t in range(0, N - 1):
+        # Apply noise to x, y to generate angular wobble around the primary rotation axis z
+        # One rotation every 10 seconds to model a relatively slow wobble
+        w = rot + 0.05 * np.array(
+            [np.cos(2 * np.pi * t / (10 / dt)), np.sin(2 * np.pi * t / (10 / dt)), 0]
+        )
+
+        # Get a gyro measurement to use in the EKF and the current gyro bias for the ground truth
+        gyro_meas, _ = imu.update(w, np.zeros((3)))
+        imu_gyro_bias = imu.get_bias()[0]
+
+        ekf.predict(u=gyro_meas, epoch=data_manager.latest_epoch)
+        data_manager.push_next_state(trajectory_gt[t], attitude_gt[t])
+
+        if t % args.meas_rate == 0 and is_over_daytime(
+            data_manager.latest_epoch, data_manager.latest_state[:3]
+        ):
+            for camera_name in CameraModelManager.CAMERA_NAMES:
+                data_manager.take_measurement(
+                    landmark_bearing_sensor, camera_model_manager[camera_name]
+                )
+            print(f"Total measurements so far: {data_manager.measurement_count}")
+            print(f"Completion: {100 * t / N:.2f}%")
+
+            # EKF prediction step
+            measurement_camera_names, *z = data_manager.latest_measurements
+
+            if z[0].shape[0] > 0:
+                ekf.measurement(
+                    z=z,
+                    camera_model_manager=camera_model_manager,
+                    measurement_camera_names=measurement_camera_names,
+                    epoch=data_manager.latest_epoch,
+                    num_iter=num_iter,
+                )
+            else:
+                ekf.no_measurement()
+                print("No measurements made in measurement step")
+        else:
+            ekf.no_measurement()
+
+        state_data = {
+            "timestep": t,
+            "prior_position": ekf.r_p,
+            "prior_velocity": ekf.v_p,
+            "prior_attitude": ekf.q_p,
+            "prior_covariance": ekf.P_p,
+            "posterior_position": ekf.r_m,
+            "posterior_velocity": ekf.v_m,
+            "posterior_attitude": ekf.q_m,
+            "posterior_covariance": ekf.P_m,
+            "gyro_bias_estimate": ekf.w_b,
+            "gyro_bias": imu_gyro_bias,
+            "unmodelled_acceleration": ekf.ua,
+        }
+        # Save the state data to a file
+        with open(f"output_dir/{args.name}/ekf_state_data_.pkl", "ab") as file:
+            pickle.dump(state_data, file)
+
+    if isinstance(landmark_bearing_sensor, SimulatedMLLandmarkBearingSensor):
+        # save measurements to pickle file
+        with open(f"od-simulation-data-{time()}.pkl", "wb") as file:
+            pickle.dump(data_manager, file)
+
+
+if __name__ == "__main__":
+    parser = ArgumentParser(description="Run the EKF simulation.")
path: scripts/run_ekf.py
user: login: amaarquadri
body: Extract to parse_args function please.
created_at: 2025-03-26T20:08:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,263 @@
+"""
+Run the EKF using CLI.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
path: scripts/run_ekf.py
user: login: fredmarkus
body: i don't know if that's a good idea. It would create dependencies between individual scripts. We could instead provide one script to call all scripts and there it would be easy to set one frequency, one ang. velocity etc.
created_at: 2025-03-27T18:24:41Z
author_association: CONTRIBUTOR
path: utils/imu_utils.py
user: login: fredmarkus
body: Done
created_at: 2025-03-27T18:58:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,263 @@
+"""
+Run the EKF using CLI.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
path: scripts/run_ekf.py
user: login: amaarquadri
body: I meant like write a json file or something. Not actually pass values between scripts. I agree that the scripts should be programmatically independent, but they necessarily share the same data.
created_at: 2025-04-02T18:27:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,263 @@
+"""
+Run the EKF using CLI.
+
+This script requires the following arguments:
+    -f: Frequency of the spacecraft trajectory
path: scripts/run_ekf.py
user: login: fredmarkus
body: ok so I moved the shared args to a json file called `config.json` and call that in both scripts. Consequently I removed the corresponding args from argparse.
created_at: 2025-04-02T19:59:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,196 @@
+"""
+Generate the trajectory of the spacecraft using the dynamics model.
+
+This script requires the following arguments:
+    --lat: Starting latitude of the spacecraft
+    --lon: Starting longitude of the spacecraft
+    --altitude: Starting altitude of the spacecraft [m]
+    --name: Name of the experiment
+    --start_date: Start date of the spacecraft mission [MJD]
+    --northwards: Whether the spacecraft is moving northwards. If False, the spacecraft will move southwards.
+
+Arguments that are required by several scripts are loaded from the config.json file:
+    - angular_velocity: Angular velocity of the spacecraft [rad/s]
+    - frequency: Frequency of the dynamics model [Hz]
+    - duration: Duration of the spacecraft mission [s]
+The script will generate a ground truth trajectory of the spacecraft using the dynamics model and save this 
+the experiment in the output directory. It will store the spacecraft's position and velocity in trajectory_gt.npy,
+the spacecraft's attitude in attitude_gt.npy and a boolean specifying whether the spacecraft is currently on the 
+day (1) or night (0) side of the earth.
+
+- /output_dir
+    - /experiment_name
+        - trajectory_gt.npy
+        - attitude_gt.npy
+        - daytime_gt.npy
+"""
+
+import argparse
+import json
+import os
+
+import brahe
+import numpy as np
+import quaternion
+from brahe.epoch import Epoch
+
+from dynamics.orbital_dynamics import Dynamics
+from orbit_determination.od_simulation_data_manager import ODSimulationDataManager
+from utils.config_utils import load_config
+from utils.orbit_utils import get_max_sso_latitude, get_sso_orbit_state, is_over_daytime
+
+
+def parse_args() -> argparse.Namespace:
+    """
+    Parse command-line arguments.
+    :return: The parsed arguments.
+    """
+    parser = argparse.ArgumentParser(description="Generate spacecraft trajectory")
+    parser.add_argument(
+        "--lat",
+        type=float,
+        help="Starting latitude of the spacecraft",
+    )
+    parser.add_argument(
+        "--lon",
+        type=float,
+        help="Starting longitude of the spacecraft",
+    )
+    parser.add_argument(
+        "--altitude",
+        type=float,
+        help="Starting altitude of the spacecraft [m]",
+    )
+    parser.add_argument(
+        "--name",
+        type=str,
+        default="test",
+        help="Name of the experiment",
+    )
+    parser.add_argument(
+        "--start_date",
+        type=float,
+        help="Start date of the spacecraft mission [MJD]",
+    )
+    parser.add_argument(
+        "--northwards",
+        type=bool,
+        default=True,
+        help="Whether the spacecraft is moving northwards. If False, the spacecraft will move southwards.",
+    )
+    return parser.parse_args()
+
+
+def generate_trajectory(args) -> None:
+    """
+    Generate the trajectory of a spacecraft using the dynamics model.
+    :param args: The command line arguments.
+
+    :return: None
+    """
+
+    # Load json
+    with open("scripts/config.json", "r") as jsonfile:
path: scripts/run_dynamics.py
user: login: amaarquadri
body: ```suggestion
    with open(os.path.join(__file__, "../config.json"), "r") as jsonfile:
```
created_at: 2025-04-03T16:50:42Z
author_association: COLLABORATOR
diff_hunk: @@ -2,9 +2,12 @@
 Inertial Measurement Unit (IMU) Sensor Module
 """
 
+from __future__ import annotations
+
path: sensors/imu.py
user: login: amaarquadri
body: ```suggestion
```
created_at: 2025-04-03T16:53:16Z
author_association: COLLABORATOR
diff_hunk: @@ -51,3 +56,61 @@ def update(self, clean_gyro_signal: np.ndarray, clean_accel_signal: np.ndarray)
         gyro_measurement = self.gyro.update(clean_gyro_signal)
         accel_measurement = self.accel.update(clean_accel_signal)
         return gyro_measurement, accel_measurement
+
+    @staticmethod
+    def get_default_imu(dt: float) -> IMU:
path: sensors/imu.py
user: login: amaarquadri
body: ```suggestion
    def get_default_imu(dt: float) -> "IMU":
```
created_at: 2025-04-03T16:57:11Z
author_association: COLLABORATOR

PR comments:
user: login: fredmarkus
created_at: 2025-03-27T17:48:26Z
body: @amaarquadri If we are gonna use random initial lat, lon we don't need to have them as arguments. We could maybe do a setup where we check that if the argument hasn't been set, we initialize randomly.

author_association: CONTRIBUTOR
user: login: fredmarkus
created_at: 2025-03-27T18:59:49Z
body: @amaarquadri Resolved most issues besides the args passing between the different scripts.
author_association: CONTRIBUTOR
user: login: amaarquadri
created_at: 2025-04-02T18:30:17Z
body: > @amaarquadri If we are gonna use random initial lat, lon we don't need to have them as arguments. We could maybe do a setup where we check that if the argument hasn't been set, we initialize randomly.

Why not both? We can have them as arguments, and if they are not provided we can sample random values. I think being able to choose specific values is useful for debugging.
author_association: COLLABORATOR
user: login: fredmarkus
created_at: 2025-04-02T18:35:17Z
body: > > @amaarquadri If we are gonna use random initial lat, lon we don't need to have them as arguments. We could maybe do a setup where we check that if the argument hasn't been set, we initialize randomly.
> 
> Why not both? We can have them as arguments, and if they are not provided we can sample random values. I think being able to choose specific values is useful for debugging.

This is what I'm doing now in `run_dynamics.py`. It checks the provided args and samples randomly if None are provided.
author_association: CONTRIBUTOR