From 0610412b9148f095d9b0e70698894302ab147186 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 25 Dec 2023 13:05:28 +0100 Subject: [PATCH 01/97] visualization code from examples, added thermal sim to get familiar (now in my own fork instead of Moana's) --- .idea/.gitignore | 8 ++ .idea/PASEOS.iml | 12 +++ .idea/inspectionProfiles/Project_Default.xml | 14 ++++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 4 + .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 ++ test_attitude_code/test.mp4 | Bin 0 -> 116933 bytes test_attitude_code/visualize_sim.py | 77 ++++++++++++++++++ 9 files changed, 135 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/PASEOS.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 test_attitude_code/test.mp4 create mode 100644 test_attitude_code/visualize_sim.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/PASEOS.iml b/.idea/PASEOS.iml new file mode 100644 index 0000000..8a05c6e --- /dev/null +++ b/.idea/PASEOS.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7341d3e --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..9b1ed25 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5d5a3da --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/test_attitude_code/test.mp4 b/test_attitude_code/test.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..31ac0a916cb4d64239c42efe09bf5805964dca77 GIT binary patch literal 116933 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 z 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 literal 0 HcmV?d00001 diff --git a/test_attitude_code/visualize_sim.py b/test_attitude_code/visualize_sim.py new file mode 100644 index 0000000..766c5d7 --- /dev/null +++ b/test_attitude_code/visualize_sim.py @@ -0,0 +1,77 @@ +# We use pykep for orbit determination +import pykep as pk + +import paseos +from paseos.actors.spacecraft_actor import SpacecraftActor +from paseos.actors.actor_builder import ActorBuilder +paseos.set_log_level("INFO") + +# Define central body +earth = pk.planet.jpl_lp("earth") +sat1 = ActorBuilder.get_actor_scaffold( + "sat1", SpacecraftActor, pk.epoch(0) + ) +sat2 = ActorBuilder.get_actor_scaffold( + "sat2", SpacecraftActor, pk.epoch(0) +) + +# Define local actor +sat3 = ActorBuilder.get_actor_scaffold( + "sat3", SpacecraftActor, pk.epoch(0) +) +ActorBuilder.set_orbit(sat3, [-10000000, 0.1, 0.1], [0, 8000.0, 0], pk.epoch(0), earth) +ActorBuilder.set_power_devices(sat3, 500, 10000, 1) + +sat4 = ActorBuilder.get_actor_scaffold( + "sat4", SpacecraftActor, pk.epoch(0) +) +ActorBuilder.set_orbit(sat4, [0, 10000000, 0], [0, 0, 8000.0], pk.epoch(0), earth) + + +ActorBuilder.set_orbit( + sat1, + position=[10000000, 1e-3, 1e-3], + velocity=[1e-3, 8000, 1e-3], + epoch=pk.epoch(0), + central_body=earth, +) +ActorBuilder.set_orbit( + sat2, + position=[10000000, 1e-3, 1e-3], + velocity=[1e-3, -8000, 1e-3], + epoch=pk.epoch(0), + central_body=earth, +) + +ActorBuilder.set_thermal_model( + sat1, + actor_mass=100, + actor_initial_temperature_in_K=270, + actor_sun_absorptance=0.5, + actor_infrared_absorptance=0.5, + actor_sun_facing_area=1, + actor_central_body_facing_area=4, + actor_emissive_area=18, + actor_thermal_capacity=0.89, +) + +# Add communication link +ActorBuilder.add_comm_device(sat1, device_name="link1", bandwidth_in_kbps=1) +ActorBuilder.add_comm_device(sat2, device_name="link1", bandwidth_in_kbps=2) +ActorBuilder.set_power_devices(sat1, 500, 10000, 1) +ActorBuilder.set_power_devices(sat2, 500, 10000, 1) +sim = paseos.init_sim(sat1) +sim.add_known_actor(sat2) +sim.add_known_actor(sat3) +sim.add_known_actor(sat4) + +# Plot current status of PASEOS and get a plotter +plotter = paseos.plot(sim, paseos.PlotType.SpacePlot) +#%% +# Run some operations and inbetween update PASEOS +for i in range(100): + sim.advance_time(10,0) + plotter.update(sim) +#%% +# Write an animation of the next 50 steps a 100s to a file called test.mp4 +plotter.animate(sim,dt=200,steps=100,save_to_file="test") \ No newline at end of file From fa7c86743afd9b907775db29bfac89cfd9f1112b Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 26 Dec 2023 13:43:32 +0100 Subject: [PATCH 02/97] added Earth-Centered Inertial Frame (ECI) to Roll-Pitch-Yaw (RPY) reference frame transformation function --- paseos/attitude/reference_frame_transfer.py | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 paseos/attitude/reference_frame_transfer.py diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py new file mode 100644 index 0000000..3615749 --- /dev/null +++ b/paseos/attitude/reference_frame_transfer.py @@ -0,0 +1,31 @@ +import numpy as np + +def eci_to_rpy(u, r, v): + """ + Converts a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the + spacecraft (variant to Gaussian reference frame, useful for attitude disturbance modelling) + x: along track direction + y: -ĥ (angular momentum of the orbit). perpendicular to the orbital plane + z: -r̂ (nadir) + Args: + u: vector in ECI + r: position vector of RPY reference frame wrt ECI frame + v: velocity of the spacecraft in earth reference frame, centered on spacecraft + + Returns: + vector u w.r.t. RPY frame + """ + # determine y' base by use of the cross product: (V x r)/||(V x r)|| + cross_vp = np.cross(v, r) + y = cross_vp / np.linalg.norm(cross_vp) + # determine z' base by use of the nadir pointing vector + z = -r / np.linalg.norm(r) + # determine x' base by use of the cross product: (y' x z')/||(y' x z')|| + cross_yz = np.cross(y, z) + x = cross_yz / np.linalg.norm(cross_yz) + + # Form transformation matrix + T = np.array([x, y, z]) + + # transform u vector with matrix multiplication + return T@u From 01e9f74e9480577fe9dc81011ca9192d09d7a639 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 26 Dec 2023 15:27:58 +0100 Subject: [PATCH 03/97] added function to transform a vector from ECI to RPY. (also added a function to form this transformation matrix) --- paseos/attitude/reference_frame_transfer.py | 64 +++++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 3615749..246ccf0 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -1,20 +1,25 @@ import numpy as np -def eci_to_rpy(u, r, v): +def transformation_matrix_eci_rpy(r, v): """ - Converts a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the - spacecraft (variant to Gaussian reference frame, useful for attitude disturbance modelling) - x: along track direction - y: -ĥ (angular momentum of the orbit). perpendicular to the orbital plane - z: -r̂ (nadir) - Args: - u: vector in ECI - r: position vector of RPY reference frame wrt ECI frame - v: velocity of the spacecraft in earth reference frame, centered on spacecraft + Creates the transformation matrix to transform a vector in the Earth-Centered Inertial Frame (ECI) to the + Roll-Pitch-Yaw (RPY) reference frame of the spacecraft (variant to Gaussian reference frame, useful for attitude + disturbance modelling) + + RPY reference frame: x: along track direction + y: -ĥ (angular momentum of the orbit). perpendicular to the orbital plane + z: -r̂ (nadir) + To go from ECI to RPY, this transformation matrix is used + To go from RPY to ECI, the inverse is used. + + Args: + r (numpy array): position vector of RPY reference frame wrt ECI frame + v (numpy array): velocity of the spacecraft in earth reference frame, centered on spacecraft Returns: - vector u w.r.t. RPY frame + T (numpy array): transformation matrix """ + # determine y' base by use of the cross product: (V x r)/||(V x r)|| cross_vp = np.cross(v, r) y = cross_vp / np.linalg.norm(cross_vp) @@ -27,5 +32,42 @@ def eci_to_rpy(u, r, v): # Form transformation matrix T = np.array([x, y, z]) + return T + +def eci_to_rpy(u, r, v): + """ + Converts a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the + spacecraft, using transformation matrix from transformation_matrix_eci_rpy function. + + Args: + u: vector in ECI + r: position vector of RPY reference frame wrt ECI frame + v: velocity of the spacecraft in earth reference frame, centered on spacecraft + + Returns: + vector u w.r.t. RPY frame + """ + + T = transformation_matrix_eci_rpy(r, v) + + # transform u vector with matrix multiplication + return T@u + +def rpy_to_eci(u, r, v): + """ + Converts a vector in the Roll-Pitch-Yaw (RPY) of the spacecraft to the Earth-Centered Inertial Frame (ECI) + reference frame, using transformation matrix from transformation_matrix_eci_rpy function. + + Args: + u: vector in ECI + r: position vector of RPY reference frame wrt ECI frame + v: velocity of the spacecraft in earth reference frame, centered on spacecraft + + Returns: + vector u w.r.t. ECI frame + """ + + T = np.linalg.inv(transformation_matrix_eci_rpy(r, v)) + # transform u vector with matrix multiplication return T@u From 0242126e51ec97d435d507d4c29af78078388911 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 26 Dec 2023 16:43:36 +0100 Subject: [PATCH 04/97] added transformation matrix for RPY to body fixed frames --- paseos/attitude/reference_frame_transfer.py | 72 +++++++++++++++++---- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 246ccf0..928f723 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -2,7 +2,7 @@ def transformation_matrix_eci_rpy(r, v): """ - Creates the transformation matrix to transform a vector in the Earth-Centered Inertial Frame (ECI) to the + Creates the transformation matrix to transform a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the spacecraft (variant to Gaussian reference frame, useful for attitude disturbance modelling) @@ -34,9 +34,55 @@ def transformation_matrix_eci_rpy(r, v): return T -def eci_to_rpy(u, r, v): + +def transformation_matrix_rpy_body(euler_angles_in_rad): + """Creates the transformation matrix to transform a vector in the Roll-Pitch-Yaw (RPY) reference frame to the body + fixed reference frame of the spacecraft. + + RPY reference frame: x: along track direction + y: -ĥ (angular momentum of the orbit). perpendicular to the orbital plane + z: -r̂ (nadir) + + body fixed frame: when unperturbed, coincides with RPY frame. Perturbations result in non-zero roll pitch and + yaw angles, rotating the body fixed frame. + + To go from RPY to body fixed, this transformation matrix is used + To go from body fixed to RPY, the inverse is used. + + Args: + euler_angles_in_rad (list of floats): [roll, pitch, yaw] in radians + + Returns: + T (numpy array of floats): transformation matrix """ - Converts a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the + roll, pitch, yaw = euler_angles_in_rad + + # individual axis rotations: + A = np.array([ + [1, 0, 0], + [0, np.cos(roll), np.sin(roll)], + [0, -np.sin(roll), np.cos(roll)] + ]) + + B = np.array([ + [np.cos(pitch), 0, -np.sin(pitch)], + [0, 1, 0], + [np.sin(pitch), 0, np.cos(pitch)] + ]) + + C = np.array([ + [np.cos(yaw), np.sin(yaw), 0], + [-np.sin(yaw), np.cos(yaw), 0], + [0, 0, 1] + ]) + + # Transformation matrix: + T = A @ B @ C + + return T + +def eci_to_rpy(u, r, v): + """Converts a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the spacecraft, using transformation matrix from transformation_matrix_eci_rpy function. Args: @@ -54,20 +100,20 @@ def eci_to_rpy(u, r, v): return T@u def rpy_to_eci(u, r, v): - """ - Converts a vector in the Roll-Pitch-Yaw (RPY) of the spacecraft to the Earth-Centered Inertial Frame (ECI) - reference frame, using transformation matrix from transformation_matrix_eci_rpy function. + """Converts a vector in the Roll-Pitch-Yaw (RPY) of the spacecraft to the Earth-Centered Inertial Frame (ECI) + reference frame, using transformation matrix from transformation_matrix_eci_rpy function. - Args: - u: vector in ECI - r: position vector of RPY reference frame wrt ECI frame - v: velocity of the spacecraft in earth reference frame, centered on spacecraft + Args: + u: vector in ECI + r: position vector of RPY reference frame wrt ECI frame + v: velocity of the spacecraft in earth reference frame, centered on spacecraft - Returns: - vector u w.r.t. ECI frame - """ + Returns: + vector u w.r.t. ECI frame + """ T = np.linalg.inv(transformation_matrix_eci_rpy(r, v)) # transform u vector with matrix multiplication return T@u + From 86c55ca42bd24d84e522f3d72d09c717e987d0b8 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 26 Dec 2023 16:51:08 +0100 Subject: [PATCH 05/97] RPY to body fixed vector transformation functions added --- paseos/attitude/reference_frame_transfer.py | 32 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 928f723..17b734a 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -34,7 +34,6 @@ def transformation_matrix_eci_rpy(r, v): return T - def transformation_matrix_rpy_body(euler_angles_in_rad): """Creates the transformation matrix to transform a vector in the Roll-Pitch-Yaw (RPY) reference frame to the body fixed reference frame of the spacecraft. @@ -101,10 +100,10 @@ def eci_to_rpy(u, r, v): def rpy_to_eci(u, r, v): """Converts a vector in the Roll-Pitch-Yaw (RPY) of the spacecraft to the Earth-Centered Inertial Frame (ECI) - reference frame, using transformation matrix from transformation_matrix_eci_rpy function. + reference frame, using the inverse transformation matrix from transformation_matrix_eci_rpy function. Args: - u: vector in ECI + u: vector in RPY r: position vector of RPY reference frame wrt ECI frame v: velocity of the spacecraft in earth reference frame, centered on spacecraft @@ -117,3 +116,30 @@ def rpy_to_eci(u, r, v): # transform u vector with matrix multiplication return T@u +def rpy_to_body(u, euler_angles_in_rad): + """Converts a vector in the Roll-Pitch-Yaw (RPY) reference frame to the body fixed reference frame of the + spacecraft, using transformation matrix from transformation_matrix_rpy_body function. + + Args: + u (list of floats): vector in RPY + euler_angles_in_rad (list of floats): [roll, pitch, yaw] in radians + + Returns: + vector u w.r.t. the body fixed frame + """ + T = transformation_matrix_rpy_body(euler_angles_in_rad) + return T@u + +def body_to_rpy(u, euler_angles_in_rad): + """Converts a vector in the body fixed reference frame to the Roll-Pitch-Yaw (RPY) reference frame of the + spacecraft, using the inverse transformation matrix from transformation_matrix_rpy_body function. + + Args: + u (list of floats): vector in the body fixed frame + euler_angles_in_rad (list of floats): [roll, pitch, yaw] in radians + + Returns: + vector u w.r.t. the RPY frame + """ + T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) + return T @ u From e967cc4b182e8674a8f8afc7d09e05baf6ab3aa3 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 26 Dec 2023 16:59:09 +0100 Subject: [PATCH 06/97] v1 reference frame transfer file. cleaned up comments. inputs: lists of floats, outputs: numpy arrays (needs to be reviewed in implementation) --- paseos/attitude/reference_frame_transfer.py | 41 +++++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 17b734a..2a1e09a 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -1,5 +1,6 @@ import numpy as np + def transformation_matrix_eci_rpy(r, v): """ Creates the transformation matrix to transform a vector in the Earth-Centered Inertial Frame (ECI) to the @@ -14,15 +15,18 @@ def transformation_matrix_eci_rpy(r, v): To go from RPY to ECI, the inverse is used. Args: - r (numpy array): position vector of RPY reference frame wrt ECI frame - v (numpy array): velocity of the spacecraft in earth reference frame, centered on spacecraft + r (list of floats): position vector of RPY reference frame wrt ECI frame + v (list of floats): velocity of the spacecraft in earth reference frame, centered on spacecraft Returns: T (numpy array): transformation matrix """ + # convert list of floats to numpy arrays + r = np.array(r) + v = np.array(v) # determine y' base by use of the cross product: (V x r)/||(V x r)|| - cross_vp = np.cross(v, r) - y = cross_vp / np.linalg.norm(cross_vp) + cross_vr = np.cross(v, r) + y = cross_vr / np.linalg.norm(cross_vr) # determine z' base by use of the nadir pointing vector z = -r / np.linalg.norm(r) # determine x' base by use of the cross product: (y' x z')/||(y' x z')|| @@ -34,6 +38,7 @@ def transformation_matrix_eci_rpy(r, v): return T + def transformation_matrix_rpy_body(euler_angles_in_rad): """Creates the transformation matrix to transform a vector in the Roll-Pitch-Yaw (RPY) reference frame to the body fixed reference frame of the spacecraft. @@ -80,17 +85,18 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): return T + def eci_to_rpy(u, r, v): """Converts a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the spacecraft, using transformation matrix from transformation_matrix_eci_rpy function. Args: - u: vector in ECI - r: position vector of RPY reference frame wrt ECI frame - v: velocity of the spacecraft in earth reference frame, centered on spacecraft + u (list of floats): vector in ECI + r (list of floats): position vector of RPY reference frame wrt ECI frame + v (list of floats): velocity of the spacecraft in earth reference frame, centered on spacecraft Returns: - vector u w.r.t. RPY frame + numpy array of floats: vector u w.r.t. RPY frame """ T = transformation_matrix_eci_rpy(r, v) @@ -98,23 +104,25 @@ def eci_to_rpy(u, r, v): # transform u vector with matrix multiplication return T@u + def rpy_to_eci(u, r, v): """Converts a vector in the Roll-Pitch-Yaw (RPY) of the spacecraft to the Earth-Centered Inertial Frame (ECI) reference frame, using the inverse transformation matrix from transformation_matrix_eci_rpy function. Args: - u: vector in RPY - r: position vector of RPY reference frame wrt ECI frame - v: velocity of the spacecraft in earth reference frame, centered on spacecraft + u (list of floats): vector in RPY + r (list of floats): position vector of RPY reference frame wrt ECI frame + v (list of floats): velocity of the spacecraft in earth reference frame, centered on spacecraft Returns: - vector u w.r.t. ECI frame + numpy array of floats: vector u w.r.t. ECI frame """ T = np.linalg.inv(transformation_matrix_eci_rpy(r, v)) # transform u vector with matrix multiplication - return T@u + return T@np.array(u) + def rpy_to_body(u, euler_angles_in_rad): """Converts a vector in the Roll-Pitch-Yaw (RPY) reference frame to the body fixed reference frame of the @@ -125,10 +133,11 @@ def rpy_to_body(u, euler_angles_in_rad): euler_angles_in_rad (list of floats): [roll, pitch, yaw] in radians Returns: - vector u w.r.t. the body fixed frame + numpy array of floats: vector u w.r.t. the body fixed frame """ T = transformation_matrix_rpy_body(euler_angles_in_rad) - return T@u + return T@np.array(u) + def body_to_rpy(u, euler_angles_in_rad): """Converts a vector in the body fixed reference frame to the Roll-Pitch-Yaw (RPY) reference frame of the @@ -142,4 +151,4 @@ def body_to_rpy(u, euler_angles_in_rad): vector u w.r.t. the RPY frame """ T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) - return T @ u + return T @ np.array(u) From 53a8a3438f56f777de369d6555e915d5dacc68b8 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 26 Dec 2023 17:27:04 +0100 Subject: [PATCH 07/97] Some cleaning up --- paseos/attitude/reference_frame_transfer.py | 23 +++++++++++---------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 2a1e09a..a1d26a1 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -1,3 +1,15 @@ +"""This file contains functions to transform vectors between three reference frames: + +- Earth-centered inertial frame (ECI) + +- Roll-Pitch-Yaw frame (RPY): x: along track direction + y: -ĥ (angular momentum of the orbit). perpendicular to the orbital plane + z: -r̂ (nadir) + +- Body fixed frame: when body is unperturbed, frame coincides with RPY frame. Perturbations result in non-zero roll + pitch and yaw angles, rotating the body fixed frame w.r.t the RPY frame. +""" + import numpy as np @@ -7,10 +19,6 @@ def transformation_matrix_eci_rpy(r, v): Roll-Pitch-Yaw (RPY) reference frame of the spacecraft (variant to Gaussian reference frame, useful for attitude disturbance modelling) - RPY reference frame: x: along track direction - y: -ĥ (angular momentum of the orbit). perpendicular to the orbital plane - z: -r̂ (nadir) - To go from ECI to RPY, this transformation matrix is used To go from RPY to ECI, the inverse is used. @@ -43,13 +51,6 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): """Creates the transformation matrix to transform a vector in the Roll-Pitch-Yaw (RPY) reference frame to the body fixed reference frame of the spacecraft. - RPY reference frame: x: along track direction - y: -ĥ (angular momentum of the orbit). perpendicular to the orbital plane - z: -r̂ (nadir) - - body fixed frame: when unperturbed, coincides with RPY frame. Perturbations result in non-zero roll pitch and - yaw angles, rotating the body fixed frame. - To go from RPY to body fixed, this transformation matrix is used To go from body fixed to RPY, the inverse is used. From 3993c6a58d7c9478c39ee3e8503c7fbc98809a80 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 15:11:08 +0100 Subject: [PATCH 08/97] added functions for user to identify disturbances (aero, gravitational and magnetic) on actor. started with set_attitude_model and attitude model class as well. --- paseos/actors/actor_builder.py | 30 +++++++++++++++++++++ paseos/actors/base_actor.py | 6 +++++ paseos/attitude/attitude_model.py | 43 +++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 paseos/attitude/attitude_model.py diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index 1a53532..2fd563e 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -13,6 +13,7 @@ from ..thermal.thermal_model import ThermalModel from ..power.power_device_type import PowerDeviceType from ..radiation.radiation_model import RadiationModel +from ..attitude.attitude_model import AttitudeModel class ActorBuilder: @@ -490,6 +491,35 @@ def set_thermal_model( power_consumption_to_heat_ratio=power_consumption_to_heat_ratio, ) + @staticmethod + def set_disturbances( + actor: SpacecraftActor, + aerodynamic: bool = False, + gravitational: bool = False, + magnetic: bool = False, + ): + disturbance_list = [] + if aerodynamic: + disturbance_list.append("aerodynamic") + if gravitational: + disturbance_list.append("gravitational") + if magnetic: + disturbance_list.append("magnetic") + actor._disturbances = disturbance_list + + @staticmethod + def set_attitude_model( + actor: SpacecraftActor, + actor_initial_attitude_in_rad: list[float] = [0, 0, 0], + + ): + + actor._attitude_model = AttitudeModel( + local_actor=actor, + actor_initial_attitude_in_rad=actor_initial_attitude_in_rad, + + ) + @staticmethod def add_comm_device(actor: BaseActor, device_name: str, bandwidth_in_kbps: float): """Creates a communication device. diff --git a/paseos/actors/base_actor.py b/paseos/actors/base_actor.py index bae022c..226f495 100644 --- a/paseos/actors/base_actor.py +++ b/paseos/actors/base_actor.py @@ -47,6 +47,9 @@ class BaseActor(ABC): # Tracks the current activity _current_activity = None + # Attitude disturbances experienced by the actor + _disturbances = None + # The following variables are used to track last evaluated state vectors to avoid recomputation. _previous_position = None _time_of_previous_position = None @@ -328,6 +331,9 @@ def get_position_velocity(self, epoch: pk.epoch): self._time_of_previous_position = epoch.mjd2000 return pos, vel + def get_disturbances(self): + return self._disturbances + def is_in_line_of_sight( self, other_actor: "BaseActor", diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py new file mode 100644 index 0000000..4714ea0 --- /dev/null +++ b/paseos/attitude/attitude_model.py @@ -0,0 +1,43 @@ +from loguru import logger +import pykep as pk +import numpy as np + + +class AttitudeModel: + + + _actor = None + _actor_attitude_in_rad = None + def __init__( + self, + local_actor, + actor_initial_attitude_in_rad: list[float] = [0, 0, 0], + ## add args with default value = None, if + # actor_dipole + # actor_drag_coefficient + # body_J2 + # + ): + self._actor = local_actor + self._actor_attitude_in_rad = actor_initial_attitude_in_rad + + def nadir_vector(self): + """computes unit vector pointing towards earth, inertial body frame + + Returns: + np array ([x, y, z]): unit nadir vector in ECIF (Earth-centered inertial frame) + """ + u = np.array(self._actor.get_position) + return -u/np.linalg.norm(u) + + def update_attitude(self, dt): + """ + + Args: + dt (float): How far to advance the attitude computation. + + Returns: + np array + """ + # out: new euler angles + #T = self._actor.get_disturbance \ No newline at end of file From 07047e4eecb9c93ed7e19efac31aa850d8f70318 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 15:23:35 +0100 Subject: [PATCH 09/97] created disturbance function file --- paseos/attitude/disturbance calculations.py | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 paseos/attitude/disturbance calculations.py diff --git a/paseos/attitude/disturbance calculations.py b/paseos/attitude/disturbance calculations.py new file mode 100644 index 0000000..25d8bed --- /dev/null +++ b/paseos/attitude/disturbance calculations.py @@ -0,0 +1,25 @@ +# this functions could be implemented inside the attitude model class but I'd rather have it +# separately right now. + + +def calculate_aero_torque(): + + # calculations for torques + # T must be in actor body fixed frame (to be discussed) + T = [0, 0, 0] + return T + + +def calculate_grav_torque(): + # calculations for torques + # T must be in actor body fixed frame (to be discussed) + T = [0, 0, 0] + return T + + +def calculate_magnetic_torque(): + # calculations for torques + # T must be in actor body fixed frame (to be discussed) + T = [0, 0, 0] + return T + From 935ccc49dc04926c5e7c9d346d05b97fa58ef9f7 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 15:41:06 +0100 Subject: [PATCH 10/97] disturbance torque in attitude model --- paseos/attitude/attitude_model.py | 17 +++++++++++++++++ ...culations.py => disturbance_calculations.py} | 8 ++++---- 2 files changed, 21 insertions(+), 4 deletions(-) rename paseos/attitude/{disturbance calculations.py => disturbance_calculations.py} (86%) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 4714ea0..0c42851 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -2,6 +2,7 @@ import pykep as pk import numpy as np +from disturbance_calculations import calculate_aero_torque, calculate_magnetic_torque, calculate_grav_torque class AttitudeModel: @@ -30,6 +31,22 @@ def nadir_vector(self): u = np.array(self._actor.get_position) return -u/np.linalg.norm(u) + def disturbance_torque(self): + """Computes total torque due to user specified disturbances + + Returns: + list [Tx, Ty, Tz]: total torques in Nm + """ + T = np.array([0,0,0]) + if "aerodynamic" in self._actor.get_disturbances: + T += calculate_aero_torque() + if "gravitational" in self._actor.get_disturbances: + T += calculate_grav_torque() + if "magnetic" in self._actor.get_disturbances: + T += calculate_magnetic_torque() + return T + + def update_attitude(self, dt): """ diff --git a/paseos/attitude/disturbance calculations.py b/paseos/attitude/disturbance_calculations.py similarity index 86% rename from paseos/attitude/disturbance calculations.py rename to paseos/attitude/disturbance_calculations.py index 25d8bed..5a06426 100644 --- a/paseos/attitude/disturbance calculations.py +++ b/paseos/attitude/disturbance_calculations.py @@ -1,25 +1,25 @@ # this functions could be implemented inside the attitude model class but I'd rather have it # separately right now. - +import numpy as np def calculate_aero_torque(): # calculations for torques # T must be in actor body fixed frame (to be discussed) T = [0, 0, 0] - return T + return np.array(T) def calculate_grav_torque(): # calculations for torques # T must be in actor body fixed frame (to be discussed) T = [0, 0, 0] - return T + return np.array(T) def calculate_magnetic_torque(): # calculations for torques # T must be in actor body fixed frame (to be discussed) T = [0, 0, 0] - return T + return np.array(T) From dfa12e5e01452a043b2e7177da77dc7609ab7beb Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 16:01:12 +0100 Subject: [PATCH 11/97] added case for zero euler angles (for ref frame transfers) --- paseos/attitude/reference_frame_transfer.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index a1d26a1..132cbfa 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -136,8 +136,13 @@ def rpy_to_body(u, euler_angles_in_rad): Returns: numpy array of floats: vector u w.r.t. the body fixed frame """ - T = transformation_matrix_rpy_body(euler_angles_in_rad) - return T@np.array(u) + # for undisturbed calculations: zero euler angles result in no transformation + # numpy default absolute tolerance: 1e-0.8 + if np.isclose(euler_angles_in_rad, np.zeros(3)): + return u + else: + T = transformation_matrix_rpy_body(euler_angles_in_rad) + return T@np.array(u) def body_to_rpy(u, euler_angles_in_rad): @@ -151,5 +156,10 @@ def body_to_rpy(u, euler_angles_in_rad): Returns: vector u w.r.t. the RPY frame """ - T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) - return T @ np.array(u) + # for undisturbed calculations: zero euler angles result in no transformation + # numpy default absolute tolerance: 1e-0.8 + if np.isclose(euler_angles_in_rad, np.zeros(3)): + return u + else: + T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) + return T @ np.array(u) From 7cd2e31f6f5be2c29952f24e521f611f9e1feea9 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 16:17:02 +0100 Subject: [PATCH 12/97] initial onditions added --- paseos/actors/actor_builder.py | 5 +++++ paseos/attitude/attitude_model.py | 18 ++++++++++++++---- paseos/attitude/disturbance_calculations.py | 2 ++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index 2fd563e..54ee253 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -511,12 +511,17 @@ def set_disturbances( def set_attitude_model( actor: SpacecraftActor, actor_initial_attitude_in_rad: list[float] = [0, 0, 0], + actor_initial_angular_velocity: list[float] = [0, 0, 0], + actor_initial_angular_acceleration: list[float] = [0, 0, 0], ): actor._attitude_model = AttitudeModel( local_actor=actor, actor_initial_attitude_in_rad=actor_initial_attitude_in_rad, + actor_initial_angular_velocity=actor_initial_angular_velocity, + actor_initial_angular_acceleration=actor_initial_angular_acceleration, + ) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 0c42851..5fb63fb 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -3,16 +3,22 @@ import numpy as np from disturbance_calculations import calculate_aero_torque, calculate_magnetic_torque, calculate_grav_torque +from reference_frame_transfer import eci_to_rpy, rpy_to_eci, rpy_to_body, body_to_rpy class AttitudeModel: _actor = None _actor_attitude_in_rad = None + _actor_angular_velocity = None + _actor_angular_acceleration = None def __init__( self, local_actor, + # initial angular conditions: (defaults to 0) actor_initial_attitude_in_rad: list[float] = [0, 0, 0], + actor_initial_angular_velocity: list[float] = [0, 0, 0], + actor_initial_angular_acceleration: list[float] = [0, 0, 0], ## add args with default value = None, if # actor_dipole # actor_drag_coefficient @@ -21,6 +27,8 @@ def __init__( ): self._actor = local_actor self._actor_attitude_in_rad = actor_initial_attitude_in_rad + self._actor_angular_velocity = actor_initial_angular_velocity + self._actor_angular_acceleration = actor_initial_angular_acceleration def nadir_vector(self): """computes unit vector pointing towards earth, inertial body frame @@ -31,11 +39,11 @@ def nadir_vector(self): u = np.array(self._actor.get_position) return -u/np.linalg.norm(u) - def disturbance_torque(self): + def calculate_disturbance_torque(self): """Computes total torque due to user specified disturbances Returns: - list [Tx, Ty, Tz]: total torques in Nm + list [Tx, Ty, Tz]: total combined torques in Nm """ T = np.array([0,0,0]) if "aerodynamic" in self._actor.get_disturbances: @@ -56,5 +64,7 @@ def update_attitude(self, dt): Returns: np array """ - # out: new euler angles - #T = self._actor.get_disturbance \ No newline at end of file + # disturbance torque vector + disturbance_torque = self.calculate_disturbance_torque() + + # out: new euler angles \ No newline at end of file diff --git a/paseos/attitude/disturbance_calculations.py b/paseos/attitude/disturbance_calculations.py index 5a06426..1f09d1a 100644 --- a/paseos/attitude/disturbance_calculations.py +++ b/paseos/attitude/disturbance_calculations.py @@ -1,5 +1,7 @@ # this functions could be implemented inside the attitude model class but I'd rather have it # separately right now. + +# OUTPUT NUMPY ARRAYS import numpy as np def calculate_aero_torque(): From e2b535189390de48bf732c875394fc73190dce46 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 17:51:11 +0100 Subject: [PATCH 13/97] Now able to call attitude in rad and deg from spacecraft actor. --- paseos/actors/spacecraft_actor.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/paseos/actors/spacecraft_actor.py b/paseos/actors/spacecraft_actor.py index 30f7527..3795be6 100644 --- a/paseos/actors/spacecraft_actor.py +++ b/paseos/actors/spacecraft_actor.py @@ -1,3 +1,4 @@ +import numpy as np from loguru import logger import pykep as pk @@ -21,6 +22,7 @@ class SpacecraftActor(BaseActor): _thermal_model = None _radiation_model = None + _attitude_model = None # If radiation randomly restarted the device _was_interrupted = False @@ -164,3 +166,26 @@ def charge(self, duration_in_s: float): self = charge_model.charge(self, duration_in_s) logger.debug(f"New battery level is {self.battery_level_in_Ws}") + + def attitude_in_rad(self): + """Returns the current attitude of the actor in radians + + Returns: + list[floats]: actor attitude in radians + """ + if type(self._attitude_model._actor_attitude_in_rad) == np.ndarray: + return np.ndarray.tolist(self._attitude_model._actor_attitude_in_rad) + else: + return self._attitude_model._actor_attitude_in_rad + + def attitude_in_deg(self): + """Returns the current attitude of the actor in degrees + + Returns: + list[floats]: actor attitude in degrees + """ + if type(self._attitude_model._actor_attitude_in_rad) == np.ndarray: + return np.ndarray.tolist(self._attitude_model._actor_attitude_in_rad * 360/np.pi) + else: + return np.ndarray.tolist(np.array(self._attitude_model._actor_attitude_in_rad) * 360 / np.pi) + From ce85d1528b4ccf7bd6b447378b706d786e2fbdc9 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 17:55:04 +0100 Subject: [PATCH 14/97] added some dynamics to attitude model (not complete at all, needs to access the I-matrix) --- paseos/attitude/attitude_model.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 5fb63fb..967d3d9 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -2,8 +2,13 @@ import pykep as pk import numpy as np -from disturbance_calculations import calculate_aero_torque, calculate_magnetic_torque, calculate_grav_torque -from reference_frame_transfer import eci_to_rpy, rpy_to_eci, rpy_to_body, body_to_rpy +from paseos.attitude.disturbance_calculations import (calculate_aero_torque, + calculate_magnetic_torque, + calculate_grav_torque) +from paseos.attitude.reference_frame_transfer import (eci_to_rpy, + rpy_to_eci, + rpy_to_body, + body_to_rpy) class AttitudeModel: @@ -45,12 +50,13 @@ def calculate_disturbance_torque(self): Returns: list [Tx, Ty, Tz]: total combined torques in Nm """ + T = np.array([0,0,0]) - if "aerodynamic" in self._actor.get_disturbances: + if "aerodynamic" in self._actor.get_disturbances(): T += calculate_aero_torque() - if "gravitational" in self._actor.get_disturbances: + if "gravitational" in self._actor.get_disturbances(): T += calculate_grav_torque() - if "magnetic" in self._actor.get_disturbances: + if "magnetic" in self._actor.get_disturbances(): T += calculate_magnetic_torque() return T @@ -64,7 +70,22 @@ def update_attitude(self, dt): Returns: np array """ + # constants: + # self._actor_I = + # self._actor_mass = 50 + + I = np.array([[1,0,0], [0,1,0], [0,0,1]]) + # disturbance torque vector disturbance_torque = self.calculate_disturbance_torque() + disturbance_torque = np.array([1,2,3]) # placeholder + + #dynamics: + self._actor_angular_acceleration = ( + np.linalg.inv(I) @ (disturbance_torque - + np.cross(np.array(self._actor_angular_velocity), + I @ np.array(self._actor_angular_velocity)))) + + # update attitude # out: new euler angles \ No newline at end of file From eb4ba792da019a4798e26f29cad66c824f9b9f5a Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 18:22:34 +0100 Subject: [PATCH 15/97] added more dynamics but I think its wrong (too simple) --- paseos/attitude/attitude_model.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 967d3d9..e40be5f 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -78,14 +78,17 @@ def update_attitude(self, dt): # disturbance torque vector disturbance_torque = self.calculate_disturbance_torque() - disturbance_torque = np.array([1,2,3]) # placeholder + disturbance_torque = np.array([1,0,0]) # placeholder - #dynamics: + # dynamics: + # acceleration self._actor_angular_acceleration = ( np.linalg.inv(I) @ (disturbance_torque - np.cross(np.array(self._actor_angular_velocity), I @ np.array(self._actor_angular_velocity)))) - + # velocity + self._actor_angular_velocity += self._actor_angular_acceleration * dt # update attitude + self._actor_attitude_in_rad += self._actor_angular_velocity * dt # out: new euler angles \ No newline at end of file From da4ba34957395d6f988c778ac12c85c35e2f707b Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 20:14:32 +0100 Subject: [PATCH 16/97] added has_attitude_model function --- paseos/actors/base_actor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/paseos/actors/base_actor.py b/paseos/actors/base_actor.py index 226f495..d76f749 100644 --- a/paseos/actors/base_actor.py +++ b/paseos/actors/base_actor.py @@ -160,6 +160,15 @@ def has_thermal_model(self) -> bool: """ return hasattr(self, "_thermal_model") and self._thermal_model is not None + @property + def has_attitude_model(self) -> bool: + """Returns true if actor's attitude is modeled, else false. + + Returns: + bool: bool indicating presence. + """ + return hasattr(self, "_attitude_model") and self._attitude_model is not None + @property def mass(self) -> float: """Returns actor's mass in kg. From 3b214e4ea74c8b6b3cee64ee0411109a592a4379 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 21:19:48 +0100 Subject: [PATCH 17/97] fixed error converting angles --- paseos/actors/spacecraft_actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paseos/actors/spacecraft_actor.py b/paseos/actors/spacecraft_actor.py index 3795be6..7d02421 100644 --- a/paseos/actors/spacecraft_actor.py +++ b/paseos/actors/spacecraft_actor.py @@ -185,7 +185,7 @@ def attitude_in_deg(self): list[floats]: actor attitude in degrees """ if type(self._attitude_model._actor_attitude_in_rad) == np.ndarray: - return np.ndarray.tolist(self._attitude_model._actor_attitude_in_rad * 360/np.pi) + return np.ndarray.tolist(self._attitude_model._actor_attitude_in_rad * 180 / np.pi) else: - return np.ndarray.tolist(np.array(self._attitude_model._actor_attitude_in_rad) * 360 / np.pi) + return np.ndarray.tolist(np.array(self._attitude_model._actor_attitude_in_rad) * 180 / np.pi) From 70c9a616fa5277c86e0c35a0e457cbfb1c998fa1 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 23:14:52 +0100 Subject: [PATCH 18/97] added get_previous_position function --- paseos/actors/base_actor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/paseos/actors/base_actor.py b/paseos/actors/base_actor.py index d76f749..ce986d7 100644 --- a/paseos/actors/base_actor.py +++ b/paseos/actors/base_actor.py @@ -342,6 +342,8 @@ def get_position_velocity(self, epoch: pk.epoch): def get_disturbances(self): return self._disturbances + def get_previous_position(self): + return self._previous_position def is_in_line_of_sight( self, From 8ebaeea09120f02718b6d86d2202a68e05276227 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 23:15:21 +0100 Subject: [PATCH 19/97] update atitude model in paseos --- paseos/paseos.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/paseos/paseos.py b/paseos/paseos.py index 94661f0..a6c6a33 100644 --- a/paseos/paseos.py +++ b/paseos/paseos.py @@ -167,6 +167,10 @@ def advance_time( if self.local_actor.has_power_model: self.local_actor.discharge(current_power_consumption_in_W, dt) + # Update actor attitude + if self.local_actor.has_attitude_model: + self.local_actor._attitude_model.update_attitude(dt) + # Update user-defined properties in the actor for property_name in self.local_actor.custom_properties.keys(): update_function = self.local_actor.get_custom_property_update_function( From 0a0cdad8e24003a792020744cd65a0112ca66151 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 23:15:52 +0100 Subject: [PATCH 20/97] fixing ref frame transformations --- paseos/attitude/reference_frame_transfer.py | 24 +++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 132cbfa..e0687cf 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -87,7 +87,7 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): return T -def eci_to_rpy(u, r, v): +def eci_to_rpy(u, r, v, translation=True): """Converts a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the spacecraft, using transformation matrix from transformation_matrix_eci_rpy function. @@ -95,6 +95,7 @@ def eci_to_rpy(u, r, v): u (list of floats): vector in ECI r (list of floats): position vector of RPY reference frame wrt ECI frame v (list of floats): velocity of the spacecraft in earth reference frame, centered on spacecraft + translation (boolean): does the vector need to be translated? (default=True) Returns: numpy array of floats: vector u w.r.t. RPY frame @@ -102,11 +103,16 @@ def eci_to_rpy(u, r, v): T = transformation_matrix_eci_rpy(r, v) + if translation: + shift = r + else: + shift = 0 + # transform u vector with matrix multiplication - return T@u + return T@np.array(u) - shift -def rpy_to_eci(u, r, v): +def rpy_to_eci(u, r, v, translation=True): """Converts a vector in the Roll-Pitch-Yaw (RPY) of the spacecraft to the Earth-Centered Inertial Frame (ECI) reference frame, using the inverse transformation matrix from transformation_matrix_eci_rpy function. @@ -114,15 +120,19 @@ def rpy_to_eci(u, r, v): u (list of floats): vector in RPY r (list of floats): position vector of RPY reference frame wrt ECI frame v (list of floats): velocity of the spacecraft in earth reference frame, centered on spacecraft + translation (boolean): does the vector need to be translated? (default=True) Returns: numpy array of floats: vector u w.r.t. ECI frame """ T = np.linalg.inv(transformation_matrix_eci_rpy(r, v)) - + if translation: + shift = r + else: + shift = 0 # transform u vector with matrix multiplication - return T@np.array(u) + return T@np.array(u) + shift def rpy_to_body(u, euler_angles_in_rad): @@ -138,7 +148,7 @@ def rpy_to_body(u, euler_angles_in_rad): """ # for undisturbed calculations: zero euler angles result in no transformation # numpy default absolute tolerance: 1e-0.8 - if np.isclose(euler_angles_in_rad, np.zeros(3)): + if all(np.isclose(euler_angles_in_rad, np.zeros(3))): return u else: T = transformation_matrix_rpy_body(euler_angles_in_rad) @@ -158,7 +168,7 @@ def body_to_rpy(u, euler_angles_in_rad): """ # for undisturbed calculations: zero euler angles result in no transformation # numpy default absolute tolerance: 1e-0.8 - if np.isclose(euler_angles_in_rad, np.zeros(3)): + if all(np.isclose(euler_angles_in_rad, np.zeros(3))): return u else: T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) From 3cea066ca896f5bf7ab70aff2013e9b4bca05ded Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 23:16:25 +0100 Subject: [PATCH 21/97] TODO add pointing vector in inertial frame, updating extra component of euler angles --- paseos/attitude/attitude_model.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index e40be5f..0368db5 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -17,6 +17,8 @@ class AttitudeModel: _actor_attitude_in_rad = None _actor_angular_velocity = None _actor_angular_acceleration = None + _actor_pointing_vector_rpy = None + _actor_pointing_vector_eci = None def __init__( self, local_actor, @@ -24,6 +26,9 @@ def __init__( actor_initial_attitude_in_rad: list[float] = [0, 0, 0], actor_initial_angular_velocity: list[float] = [0, 0, 0], actor_initial_angular_acceleration: list[float] = [0, 0, 0], + # trial: + actor_initial_pointing_vector_rpy: list[float] = [0,0,1], # make this better + actor_initial_pointing_vector_eci: list[float] = None ## add args with default value = None, if # actor_dipole # actor_drag_coefficient @@ -35,6 +40,10 @@ def __init__( self._actor_angular_velocity = actor_initial_angular_velocity self._actor_angular_acceleration = actor_initial_angular_acceleration + self._actor_pointing_vector_rpy = actor_initial_pointing_vector_rpy + self._actor_pointing_vector_eci = actor_initial_pointing_vector_eci + + def nadir_vector(self): """computes unit vector pointing towards earth, inertial body frame @@ -60,6 +69,14 @@ def calculate_disturbance_torque(self): T += calculate_magnetic_torque() return T + def move_pointing_vector(self): + self._actor_pointing_vector_eci = rpy_to_eci(self._actor_pointing_vector_rpy, + self._actor.get_position, + self._actor.get_position_velocity[1]) + self._actor_pointing_vector_eci = (self._actor_pointing_vector_eci + + self._actor.get_previous_position - + self._actor.get_position) + def update_attitude(self, dt): """ @@ -70,25 +87,34 @@ def update_attitude(self, dt): Returns: np array """ + self.move_pointing_vector() + eci_to_rpy(self._actor_pointing_vector_eci, self._actor.get_position, self._actor.get_position_velocity[1]) + # calculate angles between nedir and new pointing vector. + # constants: # self._actor_I = # self._actor_mass = 50 - I = np.array([[1,0,0], [0,1,0], [0,0,1]]) + I = np.array([[50,0,0], [0,50,0], [0,0,50]]) # disturbance torque vector disturbance_torque = self.calculate_disturbance_torque() - disturbance_torque = np.array([1,0,0]) # placeholder + disturbance_torque = np.array([0,0,0]) # placeholder # dynamics: # acceleration + """ self._actor_angular_acceleration = ( np.linalg.inv(I) @ (disturbance_torque - np.cross(np.array(self._actor_angular_velocity), I @ np.array(self._actor_angular_velocity)))) + """ + self._actor_angular_acceleration = np.linalg.inv(I) @ disturbance_torque # velocity self._actor_angular_velocity += self._actor_angular_acceleration * dt # update attitude self._actor_attitude_in_rad += self._actor_angular_velocity * dt + self._actor_attitude_in_rad = np.arctan2( + np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) # out: new euler angles \ No newline at end of file From 608ef320a2828a843342211f647893491185180a Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 28 Dec 2023 23:16:46 +0100 Subject: [PATCH 22/97] test file to see if model makes some sensez --- test_attitude_code/play.py | 108 +++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 test_attitude_code/play.py diff --git a/test_attitude_code/play.py b/test_attitude_code/play.py new file mode 100644 index 0000000..33a254a --- /dev/null +++ b/test_attitude_code/play.py @@ -0,0 +1,108 @@ +# We use pykep for orbit determination +import pykep as pk +import matplotlib.pyplot as plt +import numpy as np + + +import paseos +from paseos.actors.spacecraft_actor import SpacecraftActor +from paseos.actors.actor_builder import ActorBuilder +from paseos.attitude.reference_frame_transfer import body_to_rpy, rpy_to_eci + +# Define central body +earth = pk.planet.jpl_lp("earth") + +sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) + +ActorBuilder.set_orbit( + sat1, + position=[10000000, 1e-3, 1e-3], + velocity=[1e-3, 8000, 1e-3], + epoch=pk.epoch(0), + central_body=earth, +) + +ActorBuilder.set_thermal_model( + sat1, + actor_mass=100, + actor_initial_temperature_in_K=270, + actor_sun_absorptance=0.5, + actor_infrared_absorptance=0.5, + actor_sun_facing_area=1, + actor_central_body_facing_area=4, + actor_emissive_area=18, + actor_thermal_capacity=0.89, +) + +ActorBuilder.set_attitude_model(sat1) + +ActorBuilder.set_disturbances(sat1,True, True) + +""" +att_list = [] +for i in range(0,10): + att_list.append(sat1.attitude_in_deg()[0]) + sat1._attitude_model.update_attitude(0.1) +plt.plot(range(0,10), att_list) +plt.show() +""" +""" +x = [] +y = [] +z = [] +for i in range(100): + x.append() + +""" +sim = paseos.init_sim(sat1) +plt.close() +# Plot current status of PASEOS and get a plotter +#%% +# Run some operations and inbetween update PASEOS +pos = [] +x = [] +y = [] +z = [] +pointing_vector = [] +ratio = 100/440 +""" +for i in range(100): + sim.advance_time(440,0) + x.append(sat1.get_position(sat1.local_time)[0]) + y.append(sat1.get_position(sat1.local_time)[1]) + z.append(sat1.get_position(sat1.local_time)[2]) + print(sat1.attitude_in_deg(), i) +""" +ax = plt.figure().add_subplot(111, projection='3d') +for i in range(20): + sim.advance_time(100,0) + + pos = (sat1.get_position(sat1.local_time)) + x.append(sat1.get_position(sat1.local_time)[0]) + y.append(sat1.get_position(sat1.local_time)[1]) + z.append(sat1.get_position(sat1.local_time)[2]) + + euler = sat1.attitude_in_rad() + """ + pointing_vector.append( + rpy_to_eci(body_to_rpy([0,0,1], pointing_vector_in_body), + sat1.get_position_velocity(sat1.local_time)[0], + sat1.get_position_velocity(sat1.local_time)[1])) + """ + vector = rpy_to_eci(body_to_rpy([0,0,1], euler), + sat1.get_position_velocity(sat1.local_time)[0], + sat1.get_position_velocity(sat1.local_time)[1]) + print(pos, sat1.attitude_in_deg()) + ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2], length=0.2) +axmin = min(pos) +axmax = max(pos) +ax.axes.set_xlim3d(left=axmin, right=axmax) +ax.axes.set_ylim3d(bottom=axmin, top=axmax) +ax.axes.set_zlim3d(bottom=axmin, top=axmax) +ax.plot(x,y,z) +ax.scatter(0,0,0) +plt.show() +#%% +# Write an animation of the next 50 steps a 100s to a file called test.mp4 +# plotter.animate(sim,dt=200,steps=100,save_to_file="test") + From dd38d0c8caea1863af061c9456c15ccbe742bae9 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Fri, 29 Dec 2023 14:49:22 +0100 Subject: [PATCH 23/97] added function (might be deleted later) and fixed some stuff --- paseos/attitude/reference_frame_transfer.py | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index e0687cf..b763c98 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -173,3 +173,38 @@ def body_to_rpy(u, euler_angles_in_rad): else: T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) return T @ np.array(u) + +def get_euler(u, v): + """Returns euler angles between two vectors in the same reference frame + + Args: + u (numpy ndarray): vector 1 + v (numpy ndarray): vector 2 + + Returns: numpy ndarray ([roll, pitch, yaw]) in radians + """ + # roll: angle between yz components + # components may be zero: (zero denominator) + if all(np.isclose(u[1:3], np.zeros(2))) or all(np.isclose(v[1:3], np.zeros(2))): + roll = 0 + else: + roll = float(np.arccos((np.linalg.multi_dot([u[1:3], v[1:3]]))/ + (np.linalg.norm(u[1:3])*np.linalg.norm(v[1:3])))) + + # pitch: angle between xz components + # components may be zero: (zero denominator) + if all(np.isclose(u[0:3:2], np.zeros(2))) or all(np.isclose(v[0:3:2], np.zeros(2))): + pitch = 0 + else: + pitch = np.arccos((np.linalg.multi_dot([u[0:3:2], v[0:3:2]]))/ + (np.linalg.norm(u[0:3:2])*np.linalg.norm(v[0:3:2]))) + + # yaw: angle between xy components + # components may be zero: (zero denominator) + if all(np.isclose(u[0:2], np.zeros(2))) or all(np.isclose(v[0:2], np.zeros(2))): + yaw = 0 + else: + yaw = np.arccos((np.linalg.multi_dot([u[0:2], v[0:2]])) / + (np.linalg.norm(u[0:2]) * np.linalg.norm(v[0:2]))) + + return [roll, pitch, yaw] \ No newline at end of file From 62e97f5a299f8895190b92eac833c0601ce93a0c Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Fri, 29 Dec 2023 14:58:26 +0100 Subject: [PATCH 24/97] play python file to plot attitude. Works with plotting Nadir vector --- test_attitude_code/play.py | 39 ++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/test_attitude_code/play.py b/test_attitude_code/play.py index 33a254a..10b4365 100644 --- a/test_attitude_code/play.py +++ b/test_attitude_code/play.py @@ -1,19 +1,21 @@ # We use pykep for orbit determination import pykep as pk +import matplotlib import matplotlib.pyplot as plt import numpy as np - import paseos from paseos.actors.spacecraft_actor import SpacecraftActor from paseos.actors.actor_builder import ActorBuilder from paseos.attitude.reference_frame_transfer import body_to_rpy, rpy_to_eci +matplotlib.use("Qt5Agg") + # Define central body earth = pk.planet.jpl_lp("earth") sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) - +""" ActorBuilder.set_orbit( sat1, position=[10000000, 1e-3, 1e-3], @@ -21,6 +23,14 @@ epoch=pk.epoch(0), central_body=earth, ) +""" +ActorBuilder.set_orbit( + sat1, + position=[10000000, 0, 0], + velocity=[0, 8000, 0], + epoch=pk.epoch(0), + central_body=earth, +) ActorBuilder.set_thermal_model( sat1, @@ -73,7 +83,8 @@ z.append(sat1.get_position(sat1.local_time)[2]) print(sat1.attitude_in_deg(), i) """ -ax = plt.figure().add_subplot(111, projection='3d') +fig = plt.figure() +ax = fig.add_subplot(111, projection='3d') for i in range(20): sim.advance_time(100,0) @@ -89,19 +100,31 @@ sat1.get_position_velocity(sat1.local_time)[0], sat1.get_position_velocity(sat1.local_time)[1])) """ - vector = rpy_to_eci(body_to_rpy([0,0,1], euler), + + #vector = rpy_to_eci(body_to_rpy([0,0,1], euler), sat1.get_position_velocity(sat1.local_time)[0], sat1.get_position_velocity(sat1.local_time)[1]) - print(pos, sat1.attitude_in_deg()) - ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2], length=0.2) -axmin = min(pos) -axmax = max(pos) + + vector = sat1._attitude_model.nadir_vector()*1000000 + # print(pos, sat1.attitude_in_deg()) + ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) +axmin = min(min([x,y,z]))*1.2 +axmax = max(max([x,y,z]))*1.2 + ax.axes.set_xlim3d(left=axmin, right=axmax) ax.axes.set_ylim3d(bottom=axmin, top=axmax) ax.axes.set_zlim3d(bottom=axmin, top=axmax) + +ax.set_xlabel("x") +ax.set_ylabel("y") +ax.set_zlabel("z") + ax.plot(x,y,z) ax.scatter(0,0,0) +""" +plt.plot([1,2,3,4,5]) plt.show() +""" #%% # Write an animation of the next 50 steps a 100s to a file called test.mp4 # plotter.animate(sim,dt=200,steps=100,save_to_file="test") From b4080b327cd2f4619d333fa0cddabc0abf9f7213 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Fri, 29 Dec 2023 14:58:56 +0100 Subject: [PATCH 25/97] commit attitude model before changing most of it (to get dynamics correct) --- paseos/attitude/attitude_model.py | 43 +++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 0368db5..e567e8e 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -8,7 +8,8 @@ from paseos.attitude.reference_frame_transfer import (eci_to_rpy, rpy_to_eci, rpy_to_body, - body_to_rpy) + body_to_rpy, + get_euler) class AttitudeModel: @@ -19,6 +20,8 @@ class AttitudeModel: _actor_angular_acceleration = None _actor_pointing_vector_rpy = None _actor_pointing_vector_eci = None + + _actor_prev_pos = None def __init__( self, local_actor, @@ -28,7 +31,9 @@ def __init__( actor_initial_angular_acceleration: list[float] = [0, 0, 0], # trial: actor_initial_pointing_vector_rpy: list[float] = [0,0,1], # make this better - actor_initial_pointing_vector_eci: list[float] = None + actor_initial_pointing_vector_eci: list[float] = None, + + actor_initial_previous_position = [1.e+07, 1.e-03, 1.e-03] ## add args with default value = None, if # actor_dipole # actor_drag_coefficient @@ -43,6 +48,8 @@ def __init__( self._actor_pointing_vector_rpy = actor_initial_pointing_vector_rpy self._actor_pointing_vector_eci = actor_initial_pointing_vector_eci + self._actor_prev_pos = actor_initial_previous_position + def nadir_vector(self): """computes unit vector pointing towards earth, inertial body frame @@ -50,7 +57,7 @@ def nadir_vector(self): Returns: np array ([x, y, z]): unit nadir vector in ECIF (Earth-centered inertial frame) """ - u = np.array(self._actor.get_position) + u = np.array(self._actor.get_position(self._actor.local_time)) return -u/np.linalg.norm(u) def calculate_disturbance_torque(self): @@ -69,15 +76,15 @@ def calculate_disturbance_torque(self): T += calculate_magnetic_torque() return T - def move_pointing_vector(self): + def move_pointing_vector(self, pos, vel): self._actor_pointing_vector_eci = rpy_to_eci(self._actor_pointing_vector_rpy, - self._actor.get_position, - self._actor.get_position_velocity[1]) + pos, + vel) self._actor_pointing_vector_eci = (self._actor_pointing_vector_eci + - self._actor.get_previous_position - - self._actor.get_position) - + np.array(self._actor_prev_pos) - + pos) + #print(np.array(pos) - np.array(self._actor_prev_pos)) def update_attitude(self, dt): """ @@ -87,9 +94,14 @@ def update_attitude(self, dt): Returns: np array """ - self.move_pointing_vector() - eci_to_rpy(self._actor_pointing_vector_eci, self._actor.get_position, self._actor.get_position_velocity[1]) - # calculate angles between nedir and new pointing vector. + position = self._actor.get_position(self._actor.local_time) + velocity = self._actor.get_position_velocity(self._actor.local_time)[1] + + self.move_pointing_vector(position, velocity) + self._actor_pointing_vector_rpy = eci_to_rpy(self._actor_pointing_vector_eci, + position, + velocity) + # calculate euler angles between nadir and new pointing vector. # constants: # self._actor_I = @@ -114,7 +126,10 @@ def update_attitude(self, dt): # velocity self._actor_angular_velocity += self._actor_angular_acceleration * dt # update attitude - self._actor_attitude_in_rad += self._actor_angular_velocity * dt + self._actor_attitude_in_rad += self._actor_angular_velocity * dt + get_euler(self._actor_pointing_vector_rpy, + self.nadir_vector()) + #print(get_euler(self._actor_pointing_vector_rpy, self.nadir_vector())) self._actor_attitude_in_rad = np.arctan2( np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) - # out: new euler angles \ No newline at end of file + # previous position (to be used in next computation) + self._actor_prev_pos = position \ No newline at end of file From 8a6783485abfcea3bccb3410261e33e6c4ab8690 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Fri, 29 Dec 2023 15:01:52 +0100 Subject: [PATCH 26/97] play file fixed --- test_attitude_code/play.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test_attitude_code/play.py b/test_attitude_code/play.py index 10b4365..c7f7802 100644 --- a/test_attitude_code/play.py +++ b/test_attitude_code/play.py @@ -100,11 +100,11 @@ sat1.get_position_velocity(sat1.local_time)[0], sat1.get_position_velocity(sat1.local_time)[1])) """ - - #vector = rpy_to_eci(body_to_rpy([0,0,1], euler), + """ + vector = rpy_to_eci(body_to_rpy([0,0,1], euler), sat1.get_position_velocity(sat1.local_time)[0], sat1.get_position_velocity(sat1.local_time)[1]) - + """ vector = sat1._attitude_model.nadir_vector()*1000000 # print(pos, sat1.attitude_in_deg()) ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) From 385ccb01cdc1f0ea7e7c15c4013b4670eb673795 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 30 Dec 2023 01:34:19 +0100 Subject: [PATCH 27/97] tried to apply correct dynamics. Not there yet --- paseos/attitude/attitude_model.py | 93 +++++++++++++++++++------------ 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index e567e8e..79b3b2f 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -18,10 +18,10 @@ class AttitudeModel: _actor_attitude_in_rad = None _actor_angular_velocity = None _actor_angular_acceleration = None - _actor_pointing_vector_rpy = None + #_actor_pointing_vector_rpy = None _actor_pointing_vector_eci = None - _actor_prev_pos = None + #_actor_prev_pos = None def __init__( self, local_actor, @@ -30,10 +30,10 @@ def __init__( actor_initial_angular_velocity: list[float] = [0, 0, 0], actor_initial_angular_acceleration: list[float] = [0, 0, 0], # trial: - actor_initial_pointing_vector_rpy: list[float] = [0,0,1], # make this better + #actor_initial_pointing_vector_rpy: list[float] = [0,0,1], # make this better actor_initial_pointing_vector_eci: list[float] = None, - actor_initial_previous_position = [1.e+07, 1.e-03, 1.e-03] + #actor_initial_previous_position = [1.e+07, 1.e-03, 1.e-03] ## add args with default value = None, if # actor_dipole # actor_drag_coefficient @@ -45,10 +45,14 @@ def __init__( self._actor_angular_velocity = actor_initial_angular_velocity self._actor_angular_acceleration = actor_initial_angular_acceleration - self._actor_pointing_vector_rpy = actor_initial_pointing_vector_rpy - self._actor_pointing_vector_eci = actor_initial_pointing_vector_eci + #self._actor_pointing_vector_rpy = actor_initial_pointing_vector_rpy + #self._actor_pointing_vector_eci = actor_initial_pointing_vector_eci + if not actor_initial_pointing_vector_eci: + self._actor_pointing_vector_eci = self.nadir_vector() + else: + self._actor_pointing_vector_eci = actor_initial_pointing_vector_eci - self._actor_prev_pos = actor_initial_previous_position + #self._actor_prev_pos = actor_initial_previous_position def nadir_vector(self): @@ -76,15 +80,6 @@ def calculate_disturbance_torque(self): T += calculate_magnetic_torque() return T - def move_pointing_vector(self, pos, vel): - self._actor_pointing_vector_eci = rpy_to_eci(self._actor_pointing_vector_rpy, - pos, - vel) - self._actor_pointing_vector_eci = (self._actor_pointing_vector_eci + - np.array(self._actor_prev_pos) - - pos) - - #print(np.array(pos) - np.array(self._actor_prev_pos)) def update_attitude(self, dt): """ @@ -95,41 +90,65 @@ def update_attitude(self, dt): np array """ position = self._actor.get_position(self._actor.local_time) + if not self._actor._previous_position: + previous_position = position + else: + previous_position = self._actor._previous_position velocity = self._actor.get_position_velocity(self._actor.local_time)[1] - - self.move_pointing_vector(position, velocity) - self._actor_pointing_vector_rpy = eci_to_rpy(self._actor_pointing_vector_eci, - position, - velocity) + # euler angles between pointing vector in SBF and (0, 0, 1) z vector in RPY + euler = self._actor_attitude_in_rad + print(euler) + #self._actor_pointing_vector_rpy = eci_to_rpy(self._actor_pointing_vector_eci, + # position, + # velocity) # calculate euler angles between nadir and new pointing vector. # constants: # self._actor_I = # self._actor_mass = 50 - I = np.array([[50,0,0], [0,50,0], [0,0,50]]) + I = np.array([[50, 0, 0], + [0, 50, 0], + [0, 0, 50]]) # disturbance torque vector - disturbance_torque = self.calculate_disturbance_torque() - disturbance_torque = np.array([0,0,0]) # placeholder + #disturbance_torque = self.calculate_disturbance_torque() + disturbance_torque = np.array([0,0,0]) # placeholder. IN SBF # dynamics: - # acceleration - """ + angular_velocity_sbf_wrt_rpy = body_to_rpy(self._actor_angular_velocity, euler) + # acceleration euler equation for rigid body rotation + self._actor_angular_acceleration = ( np.linalg.inv(I) @ (disturbance_torque - - np.cross(np.array(self._actor_angular_velocity), - I @ np.array(self._actor_angular_velocity)))) + np.cross(np.array(angular_velocity_sbf_wrt_rpy), + I @ np.array(angular_velocity_sbf_wrt_rpy)))) + # angular velocity of body frame wrt RPY + #self._actor_angular_velocity += body_to_rpy(self._actor_angular_acceleration, euler) * dt + angular_velocity_rpy = (angular_velocity_sbf_wrt_rpy + + body_to_rpy(self._actor_angular_acceleration, euler) * dt) + self._actor_angular_velocity = rpy_to_body(angular_velocity_rpy, euler) + # angular velocity of the RPY frame wrt ECI + etha = np.arccos(np.linalg.multi_dot([position, previous_position]) / + (np.linalg.norm(position)*np.linalg.norm(previous_position))) / dt + angular_velocity_of_rpy_wrt_eci = rpy_to_eci([0, etha, 0], position, velocity, translation=False) + + # angular velocity of the spacecraft body wrt inertial frame + angular_velocity_body_wrt_eci = (rpy_to_eci(angular_velocity_rpy, position, velocity) + + angular_velocity_of_rpy_wrt_eci) - """ - self._actor_angular_acceleration = np.linalg.inv(I) @ disturbance_torque - # velocity - self._actor_angular_velocity += self._actor_angular_acceleration * dt # update attitude - self._actor_attitude_in_rad += self._actor_angular_velocity * dt + get_euler(self._actor_pointing_vector_rpy, - self.nadir_vector()) - #print(get_euler(self._actor_pointing_vector_rpy, self.nadir_vector())) + k = angular_velocity_body_wrt_eci / np.linalg.norm(angular_velocity_body_wrt_eci) + theta = np.linalg.norm(angular_velocity_body_wrt_eci) * dt + P = self._actor_pointing_vector_eci + self._actor_pointing_vector_eci = (P * np.cos(theta) + + (np.cross(k, P) * np.sin(theta)) + + k * (np.linalg.multi_dot([k, P])) * (1 - np.cos(theta))) + #print(P) + #self._actor_attitude_in_rad += self._actor_angular_velocity * dt + get_euler(self._actor_pointing_vector_rpy, + # self.nadir_vector()) + self._actor_attitude_in_rad = get_euler( + eci_to_rpy(self._actor_pointing_vector_eci, position, velocity, translation=False), [0,0,1]) + self._actor_attitude_in_rad = np.arctan2( np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) - # previous position (to be used in next computation) - self._actor_prev_pos = position \ No newline at end of file From e28964eca44d203e67fa4fb3943f3e72e43d0f3f Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 30 Dec 2023 19:44:40 +0100 Subject: [PATCH 28/97] get_euler, angles can now also be negative. Right handed system applied --- paseos/attitude/reference_frame_transfer.py | 36 ++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index b763c98..eaa5eb8 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -174,6 +174,21 @@ def body_to_rpy(u, euler_angles_in_rad): T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) return T @ np.array(u) +def angle_between_vectors(u, v, n): + """Returns right-handed rotation angle between u and v, with u being the reference for measuring the right-handed + rotation. Formula: + angle = arctan2( u x v . n, u . v) + + Args: + u (numpy ndarray): reference vector + v (numpy ndarray): rotated vector + n (numpy ndarray): plane normal vector + + Returns: float angle in radians + + """ + return np.arctan2(np.linalg.multi_dot([np.cross(u, v), n]), np.linalg.multi_dot([u, v])) + def get_euler(u, v): """Returns euler angles between two vectors in the same reference frame @@ -183,6 +198,7 @@ def get_euler(u, v): Returns: numpy ndarray ([roll, pitch, yaw]) in radians """ + """ # roll: angle between yz components # components may be zero: (zero denominator) if all(np.isclose(u[1:3], np.zeros(2))) or all(np.isclose(v[1:3], np.zeros(2))): @@ -206,5 +222,23 @@ def get_euler(u, v): else: yaw = np.arccos((np.linalg.multi_dot([u[0:2], v[0:2]])) / (np.linalg.norm(u[0:2]) * np.linalg.norm(v[0:2]))) + """ + # roll: angle between yz components + # normal vector = x-axis + u_yz = np.array([0, u[1], u[2]]) + v_yz = np.array([0, v[1], v[2]]) + roll = angle_between_vectors(u_yz, v_yz, np.array([1,0,0])) + + # pitch: angle between xz components + # normal vector = y-axis + u_xz = np.array([u[0], 0, u[2]]) + v_xz = np.array([v[0], 0, v[2]]) + pitch = angle_between_vectors(u_xz, v_xz, np.array([0,1,0])) + + # yaw: angle between xy components + # normal vector = z-axis + u_xy = np.array([u[0], u[1], 0]) + v_xy = np.array([v[0], v[1], 0]) + yaw = angle_between_vectors(u_xy, v_xy, np.array([0,0,1])) - return [roll, pitch, yaw] \ No newline at end of file + return [roll, pitch, yaw] From 438eca603bdfb13814c7641add5983620229fe14 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 30 Dec 2023 19:45:31 +0100 Subject: [PATCH 29/97] Update base_actor.py deleted redundant function (which I added myself) --- paseos/actors/base_actor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/paseos/actors/base_actor.py b/paseos/actors/base_actor.py index ce986d7..f016dcd 100644 --- a/paseos/actors/base_actor.py +++ b/paseos/actors/base_actor.py @@ -341,9 +341,12 @@ def get_position_velocity(self, epoch: pk.epoch): return pos, vel def get_disturbances(self): + """Get the user specified spacecraft attitude disturbances + + Returns: + list[string]: name of disturbances + """ return self._disturbances - def get_previous_position(self): - return self._previous_position def is_in_line_of_sight( self, From 4c0457b46d331d9693d48341049a80944b9ca39b Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 30 Dec 2023 19:47:08 +0100 Subject: [PATCH 30/97] Update spacecraft_actor.py pointing vector function --- paseos/actors/spacecraft_actor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/paseos/actors/spacecraft_actor.py b/paseos/actors/spacecraft_actor.py index 7d02421..a2c6040 100644 --- a/paseos/actors/spacecraft_actor.py +++ b/paseos/actors/spacecraft_actor.py @@ -189,3 +189,11 @@ def attitude_in_deg(self): else: return np.ndarray.tolist(np.array(self._attitude_model._actor_attitude_in_rad) * 180 / np.pi) + + def pointing_vector(self): + """Returns the spacecraft pointing vector + + Returns: + numpy ndarray (x, y, z) + """ + return self._attitude_model._actor_pointing_vector_eci \ No newline at end of file From 7f7f893d2dc6305602c3ae06114d2e27bfa75ec4 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 30 Dec 2023 19:47:34 +0100 Subject: [PATCH 31/97] Update play.py extra stuff in play file --- test_attitude_code/play.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test_attitude_code/play.py b/test_attitude_code/play.py index c7f7802..0209c9b 100644 --- a/test_attitude_code/play.py +++ b/test_attitude_code/play.py @@ -44,7 +44,7 @@ actor_thermal_capacity=0.89, ) -ActorBuilder.set_attitude_model(sat1) +ActorBuilder.set_attitude_model(sat1, actor_initial_angular_velocity=[0,10,0]) ActorBuilder.set_disturbances(sat1,True, True) @@ -86,9 +86,8 @@ fig = plt.figure() ax = fig.add_subplot(111, projection='3d') for i in range(20): - sim.advance_time(100,0) - pos = (sat1.get_position(sat1.local_time)) + print(pos) x.append(sat1.get_position(sat1.local_time)[0]) y.append(sat1.get_position(sat1.local_time)[1]) z.append(sat1.get_position(sat1.local_time)[2]) @@ -105,9 +104,16 @@ sat1.get_position_velocity(sat1.local_time)[0], sat1.get_position_velocity(sat1.local_time)[1]) """ - vector = sat1._attitude_model.nadir_vector()*1000000 + # vector = sat1._attitude_model.nadir_vector()*1000000 + vector = sat1.pointing_vector() + #print(vector) + vector[np.isclose(vector, np.zeros(3))] = 0 + #print(vector) + vector = vector * 1e6 + print(vector) # print(pos, sat1.attitude_in_deg()) ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) + sim.advance_time(100, 0) axmin = min(min([x,y,z]))*1.2 axmax = max(max([x,y,z]))*1.2 From 14945856df9d04ff9e33be372536912cc6b9d7bd Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 30 Dec 2023 19:47:58 +0100 Subject: [PATCH 32/97] Update attitude_model.py Tried to update dynamics, not correct yet. --- paseos/attitude/attitude_model.py | 80 ++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 79b3b2f..b6d0d8b 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -1,3 +1,4 @@ +import numpy from loguru import logger import pykep as pk import numpy as np @@ -18,7 +19,6 @@ class AttitudeModel: _actor_attitude_in_rad = None _actor_angular_velocity = None _actor_angular_acceleration = None - #_actor_pointing_vector_rpy = None _actor_pointing_vector_eci = None #_actor_prev_pos = None @@ -30,8 +30,8 @@ def __init__( actor_initial_angular_velocity: list[float] = [0, 0, 0], actor_initial_angular_acceleration: list[float] = [0, 0, 0], # trial: - #actor_initial_pointing_vector_rpy: list[float] = [0,0,1], # make this better - actor_initial_pointing_vector_eci: list[float] = None, + # actor_initial_pointing_vector_rpy: list[float] = [0,0,1], # make this better + # actor_initial_pointing_vector_eci: list[float] = None, #actor_initial_previous_position = [1.e+07, 1.e-03, 1.e-03] ## add args with default value = None, if @@ -47,10 +47,13 @@ def __init__( #self._actor_pointing_vector_rpy = actor_initial_pointing_vector_rpy #self._actor_pointing_vector_eci = actor_initial_pointing_vector_eci - if not actor_initial_pointing_vector_eci: + if actor_initial_attitude_in_rad == [0, 0, 0]: self._actor_pointing_vector_eci = self.nadir_vector() else: - self._actor_pointing_vector_eci = actor_initial_pointing_vector_eci + self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist( # todo: consistency in ndarray or lists + body_to_rpy([0,0,1], actor_initial_attitude_in_rad)), + self._actor.get_position(self._actor.local_time), + self._actor.get_position_velocity(self._actor.local_time)[1]) #self._actor_prev_pos = actor_initial_previous_position @@ -89,22 +92,35 @@ def update_attitude(self, dt): Returns: np array """ + + # define position, previous position, velocity vectors + # and euler angles (between the pointing vector in SBF and (0, 0, 1) z vector in RPY) + + #################################### STARTING CONDITIONS OF UPDATE ATTITUDE #################################### + # position position = self._actor.get_position(self._actor.local_time) + # previous position (will be None at first timestep) if not self._actor._previous_position: previous_position = position else: previous_position = self._actor._previous_position + # velocity velocity = self._actor.get_position_velocity(self._actor.local_time)[1] - # euler angles between pointing vector in SBF and (0, 0, 1) z vector in RPY + + # nadir pointing vector in ECI + nadir_eci = self.nadir_vector() + + # update the euler angles (attitude) of the spacecraft body wrt rpy frame + attitude_eci = get_euler(nadir_eci, self._actor_pointing_vector_eci) # rotation in ECI + self._actor_attitude_in_rad = eci_to_rpy(attitude_eci, position, velocity, translation=False) # rotation in RPY euler = self._actor_attitude_in_rad - print(euler) - #self._actor_pointing_vector_rpy = eci_to_rpy(self._actor_pointing_vector_eci, - # position, - # velocity) - # calculate euler angles between nadir and new pointing vector. + + # body angular velocity wrt RPY frame after timestep dt + angular_velocity_body_wrt_rpy = np.array(self._actor_attitude_in_rad) / dt + self._actor_angular_velocity = rpy_to_body(np.ndarray.tolist(angular_velocity_body_wrt_rpy), euler) # constants: - # self._actor_I = + # self._actor_I = (to do: from geometric model) # self._actor_mass = 50 I = np.array([[50, 0, 0], @@ -112,39 +128,49 @@ def update_attitude(self, dt): [0, 0, 50]]) # disturbance torque vector - #disturbance_torque = self.calculate_disturbance_torque() + # disturbance_torque = self.calculate_disturbance_torque() disturbance_torque = np.array([0,0,0]) # placeholder. IN SBF + # dynamics: - angular_velocity_sbf_wrt_rpy = body_to_rpy(self._actor_angular_velocity, euler) - # acceleration euler equation for rigid body rotation + ####################################### ADD DISTURBANCE, UPDATE ATTITUDE ####################################### + + # angular_velocity_sbf_wrt_rpy = self._actor_angular_velocity + # acceleration euler equation for rigid body rotation (apply disturbance torques) self._actor_angular_acceleration = ( np.linalg.inv(I) @ (disturbance_torque - - np.cross(np.array(angular_velocity_sbf_wrt_rpy), - I @ np.array(angular_velocity_sbf_wrt_rpy)))) - # angular velocity of body frame wrt RPY - #self._actor_angular_velocity += body_to_rpy(self._actor_angular_acceleration, euler) * dt - angular_velocity_rpy = (angular_velocity_sbf_wrt_rpy + + np.cross(np.array(angular_velocity_body_wrt_rpy), + I @ np.array(angular_velocity_body_wrt_rpy)))) + # new angular velocity of body frame wrt RPY + # todo: check time when angular velocity of body wrt rpy is calculated + angular_velocity_body_wrt_rpy = (angular_velocity_body_wrt_rpy + body_to_rpy(self._actor_angular_acceleration, euler) * dt) - self._actor_angular_velocity = rpy_to_body(angular_velocity_rpy, euler) + + self._actor_angular_velocity = rpy_to_body(angular_velocity_body_wrt_rpy, euler) # angular velocity of the RPY frame wrt ECI - etha = np.arccos(np.linalg.multi_dot([position, previous_position]) / - (np.linalg.norm(position)*np.linalg.norm(previous_position))) / dt + etha = -(np.arccos(np.linalg.multi_dot([position, previous_position]) / + (np.linalg.norm(position)*np.linalg.norm(previous_position)))) / dt * 0 + # ^ minus because rotation of the spacecraft cg is in negative y direction in RPY + angular_velocity_of_rpy_wrt_eci = rpy_to_eci([0, etha, 0], position, velocity, translation=False) # angular velocity of the spacecraft body wrt inertial frame - angular_velocity_body_wrt_eci = (rpy_to_eci(angular_velocity_rpy, position, velocity) + + angular_velocity_body_wrt_eci = (rpy_to_eci(angular_velocity_body_wrt_rpy, position, velocity, translation=False) + angular_velocity_of_rpy_wrt_eci) # update attitude - k = angular_velocity_body_wrt_eci / np.linalg.norm(angular_velocity_body_wrt_eci) + if all(np.isclose(angular_velocity_of_rpy_wrt_eci, numpy.zeros(3))): + k = np.zeros(3) + else: + k = angular_velocity_body_wrt_eci / np.linalg.norm(angular_velocity_body_wrt_eci) theta = np.linalg.norm(angular_velocity_body_wrt_eci) * dt P = self._actor_pointing_vector_eci - self._actor_pointing_vector_eci = (P * np.cos(theta) + + P = (P * np.cos(theta) + (np.cross(k, P) * np.sin(theta)) + k * (np.linalg.multi_dot([k, P])) * (1 - np.cos(theta))) - #print(P) + # normalize pointing vector + self._actor_pointing_vector_eci = P / np.linalg.norm(P) #self._actor_attitude_in_rad += self._actor_angular_velocity * dt + get_euler(self._actor_pointing_vector_rpy, # self.nadir_vector()) self._actor_attitude_in_rad = get_euler( From a18cd6a6060bdd8db3758bfdafa5ea99119154da Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 2 Jan 2024 16:49:24 +0100 Subject: [PATCH 33/97] changed translation= to default False, Euler angle function is redundant and wrong. --- paseos/attitude/reference_frame_transfer.py | 41 ++++++++------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index eaa5eb8..1dc99f4 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -87,7 +87,7 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): return T -def eci_to_rpy(u, r, v, translation=True): +def eci_to_rpy(u, r, v, translation=False): """Converts a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the spacecraft, using transformation matrix from transformation_matrix_eci_rpy function. @@ -112,7 +112,7 @@ def eci_to_rpy(u, r, v, translation=True): return T@np.array(u) - shift -def rpy_to_eci(u, r, v, translation=True): +def rpy_to_eci(u, r, v, translation=False): """Converts a vector in the Roll-Pitch-Yaw (RPY) of the spacecraft to the Earth-Centered Inertial Frame (ECI) reference frame, using the inverse transformation matrix from transformation_matrix_eci_rpy function. @@ -187,9 +187,12 @@ def angle_between_vectors(u, v, n): Returns: float angle in radians """ + # todo: solve problem when two return np.arctan2(np.linalg.multi_dot([np.cross(u, v), n]), np.linalg.multi_dot([u, v])) +# getting euler angls from one vector is impossible def get_euler(u, v): +#def get_euler(u): """Returns euler angles between two vectors in the same reference frame Args: @@ -200,30 +203,6 @@ def get_euler(u, v): """ """ # roll: angle between yz components - # components may be zero: (zero denominator) - if all(np.isclose(u[1:3], np.zeros(2))) or all(np.isclose(v[1:3], np.zeros(2))): - roll = 0 - else: - roll = float(np.arccos((np.linalg.multi_dot([u[1:3], v[1:3]]))/ - (np.linalg.norm(u[1:3])*np.linalg.norm(v[1:3])))) - - # pitch: angle between xz components - # components may be zero: (zero denominator) - if all(np.isclose(u[0:3:2], np.zeros(2))) or all(np.isclose(v[0:3:2], np.zeros(2))): - pitch = 0 - else: - pitch = np.arccos((np.linalg.multi_dot([u[0:3:2], v[0:3:2]]))/ - (np.linalg.norm(u[0:3:2])*np.linalg.norm(v[0:3:2]))) - - # yaw: angle between xy components - # components may be zero: (zero denominator) - if all(np.isclose(u[0:2], np.zeros(2))) or all(np.isclose(v[0:2], np.zeros(2))): - yaw = 0 - else: - yaw = np.arccos((np.linalg.multi_dot([u[0:2], v[0:2]])) / - (np.linalg.norm(u[0:2]) * np.linalg.norm(v[0:2]))) - """ - # roll: angle between yz components # normal vector = x-axis u_yz = np.array([0, u[1], u[2]]) v_yz = np.array([0, v[1], v[2]]) @@ -240,5 +219,15 @@ def get_euler(u, v): u_xy = np.array([u[0], u[1], 0]) v_xy = np.array([v[0], v[1], 0]) yaw = angle_between_vectors(u_xy, v_xy, np.array([0,0,1])) + """ + roll = angle_between_vectors(u, v, np.array([1, 0, 0])) + pitch = angle_between_vectors(u, v, np.array([0,1,0])) + yaw = angle_between_vectors(u, v, np.array([0,0,1])) + """ + # just vector u wrt rpy frame. + roll = angle_between_vectors(u, np.array([0, 0, 1]), np.array([1, 0, 0])) + pitch = angle_between_vectors(u, np.array([0, 0, 1]), np.array([0, 1, 0])) + yaw = angle_between_vectors(u, np.array([0, 0, 1]), np.array([0, 0, 1])) + """ return [roll, pitch, yaw] From 195c28e038c0cb565867ca5bb732f363e468e8d5 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 2 Jan 2024 16:50:35 +0100 Subject: [PATCH 34/97] model "v3" works with constant angular velocity, breaks after certain number of runs (most probably due to wrong angle signs) --- paseos/attitude/attitude_model.py | 137 ++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 26 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index b6d0d8b..ec768ce 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -19,6 +19,8 @@ class AttitudeModel: _actor_attitude_in_rad = None _actor_angular_velocity = None _actor_angular_acceleration = None + + _actor_pointing_vector_body = None _actor_pointing_vector_eci = None #_actor_prev_pos = None @@ -29,6 +31,7 @@ def __init__( actor_initial_attitude_in_rad: list[float] = [0, 0, 0], actor_initial_angular_velocity: list[float] = [0, 0, 0], actor_initial_angular_acceleration: list[float] = [0, 0, 0], + actor_pointing_vector_body: list[float] = [0,0,1] # todo: letting user specify attitude and pointing vector doesn't make sense # trial: # actor_initial_pointing_vector_rpy: list[float] = [0,0,1], # make this better # actor_initial_pointing_vector_eci: list[float] = None, @@ -44,19 +47,26 @@ def __init__( self._actor_attitude_in_rad = actor_initial_attitude_in_rad self._actor_angular_velocity = actor_initial_angular_velocity self._actor_angular_acceleration = actor_initial_angular_acceleration - - #self._actor_pointing_vector_rpy = actor_initial_pointing_vector_rpy - #self._actor_pointing_vector_eci = actor_initial_pointing_vector_eci + # pointing vectors: default body: z-axis, eci: initial nadir pointing + self._actor_pointing_vector_body = actor_pointing_vector_body if actor_initial_attitude_in_rad == [0, 0, 0]: self._actor_pointing_vector_eci = self.nadir_vector() else: self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist( # todo: consistency in ndarray or lists - body_to_rpy([0,0,1], actor_initial_attitude_in_rad)), + body_to_rpy(actor_pointing_vector_body, actor_initial_attitude_in_rad)), self._actor.get_position(self._actor.local_time), self._actor.get_position_velocity(self._actor.local_time)[1]) - #self._actor_prev_pos = actor_initial_previous_position - + self._actor_t = 0 + self._actor_starting_position = self._actor.get_position(self._actor.local_time) + # can't do this, it messes up "previous position" + #self._actor_starting_velocity = self._actor.get_position_velocity(self._actor.local_time)[1] + #self._actor_orbital_plane_normal = (np.cross(self._actor_starting_position, + # self._actor_starting_velocity) / + # np.linalg.norm(np.cross(self._actor_starting_position, + # self._actor_starting_velocity))) + self._actor_orbital_plane_normal = None + self._actor_body_rotation = np.array([0.0,0.0,0.0]) def nadir_vector(self): """computes unit vector pointing towards earth, inertial body frame @@ -92,7 +102,7 @@ def update_attitude(self, dt): Returns: np array """ - + """ # define position, previous position, velocity vectors # and euler angles (between the pointing vector in SBF and (0, 0, 1) z vector in RPY) @@ -112,12 +122,12 @@ def update_attitude(self, dt): # update the euler angles (attitude) of the spacecraft body wrt rpy frame attitude_eci = get_euler(nadir_eci, self._actor_pointing_vector_eci) # rotation in ECI - self._actor_attitude_in_rad = eci_to_rpy(attitude_eci, position, velocity, translation=False) # rotation in RPY + self._actor_attitude_in_rad = eci_to_rpy(attitude_eci, position, velocity, translation=False) # rotation in RPY euler = self._actor_attitude_in_rad # body angular velocity wrt RPY frame after timestep dt angular_velocity_body_wrt_rpy = np.array(self._actor_attitude_in_rad) / dt - self._actor_angular_velocity = rpy_to_body(np.ndarray.tolist(angular_velocity_body_wrt_rpy), euler) + self._actor_angular_velocity += rpy_to_body(np.ndarray.tolist(angular_velocity_body_wrt_rpy), euler) # constants: # self._actor_I = (to do: from geometric model) @@ -129,7 +139,7 @@ def update_attitude(self, dt): # disturbance torque vector # disturbance_torque = self.calculate_disturbance_torque() - disturbance_torque = np.array([0,0,0]) # placeholder. IN SBF + disturbance_torque = np.array([0,100,0]) # placeholder. IN SBF # dynamics: @@ -150,7 +160,7 @@ def update_attitude(self, dt): self._actor_angular_velocity = rpy_to_body(angular_velocity_body_wrt_rpy, euler) # angular velocity of the RPY frame wrt ECI etha = -(np.arccos(np.linalg.multi_dot([position, previous_position]) / - (np.linalg.norm(position)*np.linalg.norm(previous_position)))) / dt * 0 + (np.linalg.norm(position)*np.linalg.norm(previous_position)))) / dt # ^ minus because rotation of the spacecraft cg is in negative y direction in RPY angular_velocity_of_rpy_wrt_eci = rpy_to_eci([0, etha, 0], position, velocity, translation=False) @@ -160,21 +170,96 @@ def update_attitude(self, dt): angular_velocity_of_rpy_wrt_eci) # update attitude - if all(np.isclose(angular_velocity_of_rpy_wrt_eci, numpy.zeros(3))): - k = np.zeros(3) - else: - k = angular_velocity_body_wrt_eci / np.linalg.norm(angular_velocity_body_wrt_eci) - theta = np.linalg.norm(angular_velocity_body_wrt_eci) * dt - P = self._actor_pointing_vector_eci - P = (P * np.cos(theta) + - (np.cross(k, P) * np.sin(theta)) + - k * (np.linalg.multi_dot([k, P])) * (1 - np.cos(theta))) - # normalize pointing vector - self._actor_pointing_vector_eci = P / np.linalg.norm(P) - #self._actor_attitude_in_rad += self._actor_angular_velocity * dt + get_euler(self._actor_pointing_vector_rpy, - # self.nadir_vector()) - self._actor_attitude_in_rad = get_euler( - eci_to_rpy(self._actor_pointing_vector_eci, position, velocity, translation=False), [0,0,1]) + + self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy([0,0,1], self._actor_attitude_in_rad), position, + velocity, translation=False) + self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 self._actor_attitude_in_rad = np.arctan2( np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) + """ + + #################################### STARTING CONDITIONS OF UPDATE ATTITUDE #################################### + # position + position = self._actor.get_position(self._actor.local_time) + + # previous position (will be None at first timestep) + previous_position = self._actor._previous_position + # call previous position before velocity, as "get_position_velocity" sets previous position to current one + # todo: solve this? constrained to get previous position only before doing "get_pos_vel()" + + if not previous_position: # first timestep + + # velocity, called only to update previous position. + velocity = self._actor.get_position_velocity(self._actor.local_time)[1] + starting_position = position + + else: + + # velocity + velocity = self._actor.get_position_velocity(self._actor.local_time)[1] + # orbital plane normal unit vector + self._actor_orbital_plane_normal = (np.cross(position, velocity) / + np.linalg.norm(np.cross(position, velocity))) + # nadir pointing vector in ECI + nadir_eci = self.nadir_vector() + + # attitude change due to two rotations: + # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. + # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt + + # theta_1: + rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, self._actor_starting_position]) / + (np.linalg.norm(position) * np.linalg.norm(self._actor_starting_position))) + rpy_inertial_rotation_vector = self._actor_orbital_plane_normal * rpy_inertial_rotation_angle + theta_1 = -eci_to_rpy(rpy_inertial_rotation_vector, position, velocity) + + # theta_2: + + #body_rotation += np.ndarray.tolist(np.array(self._actor_angular_velocity) * dt) + #theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) + + self._actor_body_rotation += (np.array(self._actor_angular_velocity) * dt) + theta_2 = body_to_rpy(np.ndarray.tolist(self._actor_body_rotation), self._actor_attitude_in_rad) + + # updated attitude + self._actor_attitude_in_rad = theta_1 + theta_2 + + # attitude in range [-π, π]: + self._actor_attitude_in_rad = np.arctan2( + np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) + + ### pointing vector + + self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy( + self._actor_pointing_vector_body, np.ndarray.tolist(self._actor_attitude_in_rad)), position, velocity) + # set values close to zero equal to zero. + self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 + + + # convert to list + self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) + """ + # attitude wrt rpy changes from previous timestep. + # due to rotation of the rpy frame itself wrt eci, and the angular velocity of the body wrt eci. + rpy_rotation_in_eci = get_euler(nadir_eci, self._actor_pointing_vector_eci) + + body_rotation_in_rpy = np.ndarray.tolist(np.array(body_to_rpy(self._actor_angular_velocity, self._actor_attitude_in_rad)) * dt) + + change_in_attitude_due_to_ang_rotation_in_rpy = np.array(rpy_to_eci(body_rotation_in_rpy, position, velocity, translation=False)) + self._actor_attitude_in_rad = \ + (np.array(eci_to_rpy(rpy_rotation_in_eci + change_in_attitude_due_to_ang_rotation_in_rpy, position, velocity, translation=False))) + + # self._actor_angular_velocity = changes with acceleration in body frame only + + self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy([0,0,1], self._actor_attitude_in_rad), position, velocity, translation=False) + self._actor_pointing_vector_eci = self._actor_pointing_vector_eci / np.linalg.norm(self._actor_pointing_vector_eci) + self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 + + self._actor_attitude_in_rad = np.arctan2( + np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) + """ + + print(self._actor_t, self._actor.attitude_in_deg()) + self._actor_t += 1 + From 967b17754ed5abf6dca06cf579b9a0f11be3223d Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 2 Jan 2024 23:14:35 +0100 Subject: [PATCH 35/97] set_attitude_model: added correct arguments --- paseos/actors/actor_builder.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index 54ee253..8f6bd6a 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -510,9 +510,10 @@ def set_disturbances( @staticmethod def set_attitude_model( actor: SpacecraftActor, - actor_initial_attitude_in_rad: list[float] = [0, 0, 0], - actor_initial_angular_velocity: list[float] = [0, 0, 0], - actor_initial_angular_acceleration: list[float] = [0, 0, 0], + actor_initial_attitude_in_rad: list[float] = [0.0, 0.0, 0.0], + actor_initial_angular_velocity: list[float] = [0.0, 0.0, 0.0], + actor_initial_angular_acceleration: list[float] = [0.0, 0.0, 0.0], + actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0] ): @@ -521,6 +522,7 @@ def set_attitude_model( actor_initial_attitude_in_rad=actor_initial_attitude_in_rad, actor_initial_angular_velocity=actor_initial_angular_velocity, actor_initial_angular_acceleration=actor_initial_angular_acceleration, + actor_pointing_vector_body=actor_pointing_vector_body, ) From 376a56588a581ed29f2893303d66cd67346c1047 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 2 Jan 2024 23:14:48 +0100 Subject: [PATCH 36/97] own test file --- test_attitude_code/play.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/test_attitude_code/play.py b/test_attitude_code/play.py index 0209c9b..b9f872d 100644 --- a/test_attitude_code/play.py +++ b/test_attitude_code/play.py @@ -43,8 +43,11 @@ actor_emissive_area=18, actor_thermal_capacity=0.89, ) - -ActorBuilder.set_attitude_model(sat1, actor_initial_angular_velocity=[0,10,0]) +# when i = 21 in loop, 0.00158 rad/sec will rotate 180 deg about 1 axis +ActorBuilder.set_attitude_model( + sat1, + actor_initial_angular_velocity=[0.00158, 0.0, 0.0], + actor_pointing_vector_body=[0.0, 0.0, 1.0]) ActorBuilder.set_disturbances(sat1,True, True) @@ -85,9 +88,8 @@ """ fig = plt.figure() ax = fig.add_subplot(111, projection='3d') -for i in range(20): +for i in range(21): pos = (sat1.get_position(sat1.local_time)) - print(pos) x.append(sat1.get_position(sat1.local_time)[0]) y.append(sat1.get_position(sat1.local_time)[1]) z.append(sat1.get_position(sat1.local_time)[2]) @@ -108,14 +110,15 @@ vector = sat1.pointing_vector() #print(vector) vector[np.isclose(vector, np.zeros(3))] = 0 + print(vector, "test test test test test") #print(vector) - vector = vector * 1e6 - print(vector) + vector = vector * 2e6 # print(pos, sat1.attitude_in_deg()) ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) sim.advance_time(100, 0) -axmin = min(min([x,y,z]))*1.2 -axmax = max(max([x,y,z]))*1.2 +axmin = min([min(x), min(y), min(z)])*1.1 +axmax = max([max(x), max(y), max(z)])*1.1 +print(axmin, axmax) ax.axes.set_xlim3d(left=axmin, right=axmax) ax.axes.set_ylim3d(bottom=axmin, top=axmax) @@ -127,11 +130,3 @@ ax.plot(x,y,z) ax.scatter(0,0,0) -""" -plt.plot([1,2,3,4,5]) -plt.show() -""" -#%% -# Write an animation of the next 50 steps a 100s to a file called test.mp4 -# plotter.animate(sim,dt=200,steps=100,save_to_file="test") - From 0c90588b1042934db76526b61f3113f339d942c8 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 2 Jan 2024 23:15:32 +0100 Subject: [PATCH 37/97] Attitude model now correctly models attitude with constant angular velocity (I think --- paseos/attitude/attitude_model.py | 37 ++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index ec768ce..c7b6fe9 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -49,6 +49,7 @@ def __init__( self._actor_angular_acceleration = actor_initial_angular_acceleration # pointing vectors: default body: z-axis, eci: initial nadir pointing self._actor_pointing_vector_body = actor_pointing_vector_body + """ if actor_initial_attitude_in_rad == [0, 0, 0]: self._actor_pointing_vector_eci = self.nadir_vector() else: @@ -56,7 +57,13 @@ def __init__( body_to_rpy(actor_pointing_vector_body, actor_initial_attitude_in_rad)), self._actor.get_position(self._actor.local_time), self._actor.get_position_velocity(self._actor.local_time)[1]) - + """ + # todo: make function transforming a vector from body to eci + # todo: consistency in ndarray or lists + self._actor_pointing_vector_eci = rpy_to_eci( + body_to_rpy(actor_pointing_vector_body, actor_initial_attitude_in_rad), + self._actor.get_position(self._actor.local_time), + self._actor.get_position_velocity(self._actor.local_time)[1]) self._actor_t = 0 self._actor_starting_position = self._actor.get_position(self._actor.local_time) # can't do this, it messes up "previous position" @@ -187,7 +194,8 @@ def update_attitude(self, dt): previous_position = self._actor._previous_position # call previous position before velocity, as "get_position_velocity" sets previous position to current one # todo: solve this? constrained to get previous position only before doing "get_pos_vel()" - + # todo: velocity function starts previous position... when initializing pointing vector, + # looks like this means next line is not needed if not previous_position: # first timestep # velocity, called only to update previous position. @@ -195,7 +203,7 @@ def update_attitude(self, dt): starting_position = position else: - + step = self._actor_t # velocity velocity = self._actor.get_position_velocity(self._actor.local_time)[1] # orbital plane normal unit vector @@ -204,26 +212,32 @@ def update_attitude(self, dt): # nadir pointing vector in ECI nadir_eci = self.nadir_vector() + # print(self._actor_t, "difference ", np.array(position) - np.array(previous_position)) + # print(position, previous_position) + # attitude change due to two rotations: # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt # theta_1: - rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, self._actor_starting_position]) / - (np.linalg.norm(position) * np.linalg.norm(self._actor_starting_position))) + rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, previous_position]) / + (np.linalg.norm(position) * np.linalg.norm(previous_position))) rpy_inertial_rotation_vector = self._actor_orbital_plane_normal * rpy_inertial_rotation_angle theta_1 = -eci_to_rpy(rpy_inertial_rotation_vector, position, velocity) # theta_2: - - #body_rotation += np.ndarray.tolist(np.array(self._actor_angular_velocity) * dt) + body_rotation = np.ndarray.tolist(np.array(self._actor_angular_velocity) * dt) #theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) + theta_2 = np.array(body_rotation) - self._actor_body_rotation += (np.array(self._actor_angular_velocity) * dt) - theta_2 = body_to_rpy(np.ndarray.tolist(self._actor_body_rotation), self._actor_attitude_in_rad) + body_z_in_rpy = body_to_rpy([0,0,1], self._actor_attitude_in_rad) + body_x_in_rpy = body_to_rpy([1,0,0], self._actor_attitude_in_rad) # updated attitude - self._actor_attitude_in_rad = theta_1 + theta_2 + self._actor_attitude_in_rad += theta_1 + theta_2 + + # set values close to zero equal to zero. + self._actor_attitude_in_rad[np.isclose(self._actor_attitude_in_rad, np.zeros(3))] = 0 # attitude in range [-π, π]: self._actor_attitude_in_rad = np.arctan2( @@ -260,6 +274,7 @@ def update_attitude(self, dt): np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) """ - print(self._actor_t, self._actor.attitude_in_deg()) + #print(self._actor_t, self._actor.attitude_in_deg(), self._actor_pointing_vector_eci) + self._actor_t += 1 From 78594d092718e0377e79db78dd28d3bb273afc5d Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 2 Jan 2024 23:30:15 +0100 Subject: [PATCH 38/97] Needs to be fixed: with z angular velocity, roll is introduced --- paseos/attitude/attitude_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index c7b6fe9..88f273e 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -250,7 +250,7 @@ def update_attitude(self, dt): # set values close to zero equal to zero. self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 - + # todo: fix: with only z rotaion, roll is introduced to pointing vector # convert to list self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) """ From fc3a857d8bee194364c607a42ae4bf04b800b4cc Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 3 Jan 2024 15:09:07 +0100 Subject: [PATCH 39/97] different transformation matrix (roll pitch yaw angles are not the same as euler angles) --- paseos/attitude/reference_frame_transfer.py | 24 ++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 1dc99f4..7f7e774 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -61,7 +61,7 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): T (numpy array of floats): transformation matrix """ roll, pitch, yaw = euler_angles_in_rad - + """ # individual axis rotations: A = np.array([ [1, 0, 0], @@ -80,6 +80,24 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): [-np.sin(yaw), np.cos(yaw), 0], [0, 0, 1] ]) + """ + A = np.array([ + [np.cos(yaw), np.sin(yaw), 0], + [-np.sin(yaw), np.cos(yaw), 0], + [0, 0, 1] + ]) + + B = np.array([ + [np.cos(pitch), 0, -np.sin(pitch)], + [0, 1, 0], + [np.sin(pitch), 0, np.cos(pitch)] + ]) + + C = np.array([ + [1, 0, 0], + [0, np.cos(roll), np.sin(roll)], + [0, -np.sin(roll), np.cos(roll)] + ]) # Transformation matrix: T = A @ B @ C @@ -149,7 +167,7 @@ def rpy_to_body(u, euler_angles_in_rad): # for undisturbed calculations: zero euler angles result in no transformation # numpy default absolute tolerance: 1e-0.8 if all(np.isclose(euler_angles_in_rad, np.zeros(3))): - return u + return np.array(u) else: T = transformation_matrix_rpy_body(euler_angles_in_rad) return T@np.array(u) @@ -169,7 +187,7 @@ def body_to_rpy(u, euler_angles_in_rad): # for undisturbed calculations: zero euler angles result in no transformation # numpy default absolute tolerance: 1e-0.8 if all(np.isclose(euler_angles_in_rad, np.zeros(3))): - return u + return np.array(u) else: T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) return T @ np.array(u) From 0a537a704dff38efeefac2247f5c4fd28a7c9e5f Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 3 Jan 2024 15:11:56 +0100 Subject: [PATCH 40/97] one rotation (depending on the transformation sequence in body_to_rpy) doesn't produce correct pointing vectors, need to find solution to transform vector from body to ECI with non-zero 3d attitude angle vector (roll, pitch, yaw), resulting in correct pointing. --- paseos/attitude/attitude_model.py | 55 ++++++++++--------------------- test_attitude_code/play.py | 2 +- 2 files changed, 18 insertions(+), 39 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 88f273e..59b1389 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -195,7 +195,7 @@ def update_attitude(self, dt): # call previous position before velocity, as "get_position_velocity" sets previous position to current one # todo: solve this? constrained to get previous position only before doing "get_pos_vel()" # todo: velocity function starts previous position... when initializing pointing vector, - # looks like this means next line is not needed + # looks like this means next line is not needed: if not previous_position: # first timestep # velocity, called only to update previous position. @@ -203,36 +203,38 @@ def update_attitude(self, dt): starting_position = position else: + # step: step = self._actor_t + # velocity velocity = self._actor.get_position_velocity(self._actor.local_time)[1] + # orbital plane normal unit vector self._actor_orbital_plane_normal = (np.cross(position, velocity) / np.linalg.norm(np.cross(position, velocity))) - # nadir pointing vector in ECI - nadir_eci = self.nadir_vector() - - # print(self._actor_t, "difference ", np.array(position) - np.array(previous_position)) - # print(position, previous_position) # attitude change due to two rotations: # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt # theta_1: + # rotation angle: arccos( (p . p_previous) / (||p|| ||p_previous||) ) rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, previous_position]) / (np.linalg.norm(position) * np.linalg.norm(previous_position))) + # assign this rotation to the vector perpendicular to rotation plane rpy_inertial_rotation_vector = self._actor_orbital_plane_normal * rpy_inertial_rotation_angle + # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed theta_1 = -eci_to_rpy(rpy_inertial_rotation_vector, position, velocity) # theta_2: - body_rotation = np.ndarray.tolist(np.array(self._actor_angular_velocity) * dt) - #theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) - theta_2 = np.array(body_rotation) - - body_z_in_rpy = body_to_rpy([0,0,1], self._actor_attitude_in_rad) - body_x_in_rpy = body_to_rpy([1,0,0], self._actor_attitude_in_rad) - + # to not have the spacecraft rotate in the first timestep: + if self._actor_t == 0: + theta_2 = np.array([0,0,0]) + else: + body_rotation = np.ndarray.tolist(np.array(self._actor_angular_velocity) * dt) + #theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it + theta_2 = np.array(body_rotation) + # updated attitude self._actor_attitude_in_rad += theta_1 + theta_2 @@ -242,39 +244,16 @@ def update_attitude(self, dt): # attitude in range [-π, π]: self._actor_attitude_in_rad = np.arctan2( np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) - - ### pointing vector - + # pointing vector self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy( self._actor_pointing_vector_body, np.ndarray.tolist(self._actor_attitude_in_rad)), position, velocity) + # set values close to zero equal to zero. self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 # todo: fix: with only z rotaion, roll is introduced to pointing vector # convert to list self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) - """ - # attitude wrt rpy changes from previous timestep. - # due to rotation of the rpy frame itself wrt eci, and the angular velocity of the body wrt eci. - rpy_rotation_in_eci = get_euler(nadir_eci, self._actor_pointing_vector_eci) - - body_rotation_in_rpy = np.ndarray.tolist(np.array(body_to_rpy(self._actor_angular_velocity, self._actor_attitude_in_rad)) * dt) - - change_in_attitude_due_to_ang_rotation_in_rpy = np.array(rpy_to_eci(body_rotation_in_rpy, position, velocity, translation=False)) - self._actor_attitude_in_rad = \ - (np.array(eci_to_rpy(rpy_rotation_in_eci + change_in_attitude_due_to_ang_rotation_in_rpy, position, velocity, translation=False))) - - # self._actor_angular_velocity = changes with acceleration in body frame only - - self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy([0,0,1], self._actor_attitude_in_rad), position, velocity, translation=False) - self._actor_pointing_vector_eci = self._actor_pointing_vector_eci / np.linalg.norm(self._actor_pointing_vector_eci) - self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 - - self._actor_attitude_in_rad = np.arctan2( - np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) - """ - - #print(self._actor_t, self._actor.attitude_in_deg(), self._actor_pointing_vector_eci) self._actor_t += 1 diff --git a/test_attitude_code/play.py b/test_attitude_code/play.py index b9f872d..835f117 100644 --- a/test_attitude_code/play.py +++ b/test_attitude_code/play.py @@ -47,7 +47,7 @@ ActorBuilder.set_attitude_model( sat1, actor_initial_angular_velocity=[0.00158, 0.0, 0.0], - actor_pointing_vector_body=[0.0, 0.0, 1.0]) + actor_pointing_vector_body=[1.0, 0.0, 0.0]) ActorBuilder.set_disturbances(sat1,True, True) From 3d4b3519234ca8dd978661ffb19a9ab666674813 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 3 Jan 2024 22:28:21 +0100 Subject: [PATCH 41/97] Model works with constant angular velocity input, outputs correct pointing vectors. Exact workings of these rotations not understood fully yet, needs more looking into. next step: add accelerations --- paseos/attitude/attitude_model.py | 50 +++++++++++++++++++++++++++++-- test_attitude_code/play.py | 9 ++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 59b1389..52c39ee 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -75,6 +75,9 @@ def __init__( self._actor_orbital_plane_normal = None self._actor_body_rotation = np.array([0.0,0.0,0.0]) + self._actor_theta_1 = np.array([0.0,0.0,0.0]) + self._actor_theta_2 = np.array([0.0,0.0,0.0]) + def nadir_vector(self): """computes unit vector pointing towards earth, inertial body frame @@ -212,7 +215,7 @@ def update_attitude(self, dt): # orbital plane normal unit vector self._actor_orbital_plane_normal = (np.cross(position, velocity) / np.linalg.norm(np.cross(position, velocity))) - + """ # attitude change due to two rotations: # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt @@ -247,11 +250,52 @@ def update_attitude(self, dt): # pointing vector self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy( self._actor_pointing_vector_body, np.ndarray.tolist(self._actor_attitude_in_rad)), position, velocity) - + """ + # attitude change due to two rotations: + # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. + # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt + + # theta_1: + # rotation angle: arccos( (p . p_previous) / (||p|| ||p_previous||) ) + rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, previous_position]) / + (np.linalg.norm(position) * np.linalg.norm(previous_position))) + # assign this rotation to the vector perpendicular to rotation plane + rpy_inertial_rotation_vector = self._actor_orbital_plane_normal * rpy_inertial_rotation_angle + # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed + self._actor_theta_1 += -eci_to_rpy(rpy_inertial_rotation_vector, position, velocity) + + # theta_2: + # to not have the spacecraft rotate in the first timestep: + if self._actor_t == 0: + self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) + else: + body_rotation = np.array(self._actor_angular_velocity) * dt + # theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it + self._actor_theta_2 += body_rotation + + # updated attitude + self._actor_attitude_in_rad = self._actor_theta_1 + self._actor_theta_2 + + # set values close to zero equal to zero. + self._actor_attitude_in_rad[np.isclose(self._actor_attitude_in_rad, np.zeros(3))] = 0 + + # attitude in range [-π, π]: + self._actor_attitude_in_rad = np.arctan2( + np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) + + # pointing vector + # todo: change function names to make sense + # the following sequence of rotations is very important in order to make the model work + # more insight into the transformation functions rotation sequences is needed to make sense of this + # first rotate body pointing vector with theta 2: + pointing_vector = rpy_to_body(self._actor_pointing_vector_body, np.ndarray.tolist(self._actor_theta_2)) + # secondly rotate body pointing vector with theta 1: + pointing_vector = body_to_rpy(np.ndarray.tolist(pointing_vector), np.ndarray.tolist(self._actor_theta_1)) + self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) + # set values close to zero equal to zero. self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 - # todo: fix: with only z rotaion, roll is introduced to pointing vector # convert to list self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) diff --git a/test_attitude_code/play.py b/test_attitude_code/play.py index 835f117..c8cfe40 100644 --- a/test_attitude_code/play.py +++ b/test_attitude_code/play.py @@ -43,12 +43,17 @@ actor_emissive_area=18, actor_thermal_capacity=0.89, ) +pure_axis_rotation = [[0.00158, 0.0, 0.0], [0.0, 0.00158, 0.0], [0.0, 0.0, 0.00158]] +axes_list = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] +axis = 2 # x:0, y:1, z:2 # when i = 21 in loop, 0.00158 rad/sec will rotate 180 deg about 1 axis ActorBuilder.set_attitude_model( sat1, + #actor_initial_angular_velocity=pure_axis_rotation[axis], + #actor_pointing_vector_body=axes_list[axis]) actor_initial_angular_velocity=[0.00158, 0.0, 0.0], - actor_pointing_vector_body=[1.0, 0.0, 0.0]) - + actor_pointing_vector_body=[0.0, 0.0, 1.0] +) ActorBuilder.set_disturbances(sat1,True, True) """ From 7f6f14461d72190a24fa0f3619fe01f919204370 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 3 Jan 2024 22:43:48 +0100 Subject: [PATCH 42/97] minor adjustments normalizing input pointing vector, todo added --- paseos/attitude/attitude_model.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 52c39ee..0104645 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -48,7 +48,9 @@ def __init__( self._actor_angular_velocity = actor_initial_angular_velocity self._actor_angular_acceleration = actor_initial_angular_acceleration # pointing vectors: default body: z-axis, eci: initial nadir pointing - self._actor_pointing_vector_body = actor_pointing_vector_body + # normalize inputted pointing vector + self._actor_pointing_vector_body = (np.array(actor_pointing_vector_body) / + np.linalg.norm(np.array(actor_pointing_vector_body))) """ if actor_initial_attitude_in_rad == [0, 0, 0]: self._actor_pointing_vector_eci = self.nadir_vector() @@ -60,8 +62,9 @@ def __init__( """ # todo: make function transforming a vector from body to eci # todo: consistency in ndarray or lists + # todo: allow for initial attitude self._actor_pointing_vector_eci = rpy_to_eci( - body_to_rpy(actor_pointing_vector_body, actor_initial_attitude_in_rad), + body_to_rpy(self._actor_pointing_vector_body, actor_initial_attitude_in_rad), self._actor.get_position(self._actor.local_time), self._actor.get_position_velocity(self._actor.local_time)[1]) self._actor_t = 0 From 6f7c781de2d71e1d5bc761bee84ab0f0dd962671 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 3 Jan 2024 22:50:14 +0100 Subject: [PATCH 43/97] minor adjustments normalizing input pointing vector, sign fixed --- paseos/attitude/attitude_model.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 0104645..9ae3cd5 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -63,6 +63,7 @@ def __init__( # todo: make function transforming a vector from body to eci # todo: consistency in ndarray or lists # todo: allow for initial attitude + # todo: positive roll seems to result in negative rotations self._actor_pointing_vector_eci = rpy_to_eci( body_to_rpy(self._actor_pointing_vector_body, actor_initial_attitude_in_rad), self._actor.get_position(self._actor.local_time), @@ -257,7 +258,7 @@ def update_attitude(self, dt): # attitude change due to two rotations: # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt - + # todo: both thetas are negative. change? # theta_1: # rotation angle: arccos( (p . p_previous) / (||p|| ||p_previous||) ) rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, previous_position]) / @@ -274,7 +275,7 @@ def update_attitude(self, dt): else: body_rotation = np.array(self._actor_angular_velocity) * dt # theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it - self._actor_theta_2 += body_rotation + self._actor_theta_2 += -body_rotation # updated attitude self._actor_attitude_in_rad = self._actor_theta_1 + self._actor_theta_2 From 816f155f505e836051ff1f19217e9835f4161daf Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 4 Jan 2024 15:30:59 +0100 Subject: [PATCH 44/97] use rodriguez rotations instead of reference frame rotations (this doesn't physically rotate a vector) --- paseos/attitude/attitude_model.py | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 9ae3cd5..48ac4a1 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -255,6 +255,8 @@ def update_attitude(self, dt): self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy( self._actor_pointing_vector_body, np.ndarray.tolist(self._actor_attitude_in_rad)), position, velocity) """ + """ + # following code works for constant angular velocity on one axis (x, y, z) not other vectors. # attitude change due to two rotations: # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt @@ -302,6 +304,65 @@ def update_attitude(self, dt): # convert to list self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) + """ + # attitude change due to two rotations: + # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. + # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt + # todo: both thetas are negative. change? + # theta_1: + # rotation angle: arccos( (p . p_previous) / (||p|| ||p_previous||) ) + rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, previous_position]) / + (np.linalg.norm(position) * np.linalg.norm(previous_position))) + # assign this rotation to the vector perpendicular to rotation plane + rpy_inertial_rotation_vector = self._actor_orbital_plane_normal * rpy_inertial_rotation_angle + # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed + self._actor_theta_1 += -eci_to_rpy(rpy_inertial_rotation_vector, position, velocity) + + # theta_2: + # to not have the spacecraft rotate in the first timestep: + if self._actor_t == 0: + self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) + else: + body_rotation = np.array(self._actor_angular_velocity) * dt + # theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it + self._actor_theta_2 += body_rotation + + # updated attitude + self._actor_attitude_in_rad = self._actor_theta_1 + self._actor_theta_2 + # set values close to zero equal to zero. + self._actor_attitude_in_rad[np.isclose(self._actor_attitude_in_rad, np.zeros(3))] = 0 + + # attitude in range [-π, π]: + self._actor_attitude_in_rad = np.arctan2( + np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) + + # pointing vector + # todo: change function names to make sense + # the following sequence of rotations is very important in order to make the model work + # more insight into the transformation functions rotation sequences is needed to make sense of this + # first rotate body pointing vector with theta 2: + body_p = self._actor_pointing_vector_body + if np.linalg.norm(self._actor_theta_2) == 0.0: + body_rotation_vector_k = np.zeros(3) + else: + body_rotation_vector_k = self._actor_theta_2 / np.linalg.norm(self._actor_theta_2) + body_rotation_angle = np.linalg.norm(self._actor_theta_2) + + # rotate the body frame around the angular velocity vector: + pointing_vector = ((body_p * np.cos(body_rotation_angle) + + (np.cross(body_rotation_vector_k, body_p)) * np.sin(body_rotation_angle)) + + body_rotation_vector_k*(np.linalg.multi_dot([body_rotation_vector_k, body_p])) * + (1-np.cos(body_rotation_angle))) + + # secondly rotate body pointing vector with theta 1: + pointing_vector = body_to_rpy(np.ndarray.tolist(pointing_vector), np.ndarray.tolist(self._actor_theta_1)) + self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) + + # set values close to zero equal to zero. + self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 + + # convert to list + self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) self._actor_t += 1 From 4b1e5d3263801a9ac6d8f4c0e92a497ad36ae526 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 4 Jan 2024 15:31:18 +0100 Subject: [PATCH 45/97] added rodriguez rotation function --- paseos/attitude/reference_frame_transfer.py | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 7f7e774..8b62c88 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -249,3 +249,31 @@ def get_euler(u, v): yaw = angle_between_vectors(u, np.array([0, 0, 1]), np.array([0, 0, 1])) """ return [roll, pitch, yaw] + +def rodriguez_rotation(p, angles): + """Rotates vector p around the rotation vector v in the same reference frame. + + Args: + p (numpy ndarray): vector to be rotated [x, y, z] + angles (numpy ndarray): vector with decomposed rotation angles [theta_x, theta_y, theta_z] + + Returns: + numpy ndarray, new vector p rotated with given angles + + """ + # scalar rotation: + theta = np.linalg.norm(angles) + + # no rotation: + if theta == 0.0: + return p + # non-zero rotation + else: + # unit rotation vector + k = angles / theta + + # Rodriguez' formula: + p_rotated = ((p * np.cos(theta) + + (np.cross(k, p)) * np.sin(theta)) + + k * (np.linalg.multi_dot([k, p])) *(1 - np.cos(theta))) + return p_rotated From aba8df1f4ff701a9b6e05ff8e971a690438c7a89 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 4 Jan 2024 16:50:03 +0100 Subject: [PATCH 46/97] implemented rodriguez rotation for both rotations of the body frame --- paseos/attitude/attitude_model.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 48ac4a1..dcd1ef3 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -10,7 +10,8 @@ rpy_to_eci, rpy_to_body, body_to_rpy, - get_euler) + get_euler, + rodriguez_rotation) class AttitudeModel: @@ -342,22 +343,23 @@ def update_attitude(self, dt): # the following sequence of rotations is very important in order to make the model work # more insight into the transformation functions rotation sequences is needed to make sense of this # first rotate body pointing vector with theta 2: - body_p = self._actor_pointing_vector_body - if np.linalg.norm(self._actor_theta_2) == 0.0: - body_rotation_vector_k = np.zeros(3) - else: - body_rotation_vector_k = self._actor_theta_2 / np.linalg.norm(self._actor_theta_2) - body_rotation_angle = np.linalg.norm(self._actor_theta_2) - # rotate the body frame around the angular velocity vector: - pointing_vector = ((body_p * np.cos(body_rotation_angle) + - (np.cross(body_rotation_vector_k, body_p)) * np.sin(body_rotation_angle)) + - body_rotation_vector_k*(np.linalg.multi_dot([body_rotation_vector_k, body_p])) * - (1-np.cos(body_rotation_angle))) + # body rotation in body frame + pointing_vector = rodriguez_rotation(self._actor_pointing_vector_body, self._actor_theta_2) # secondly rotate body pointing vector with theta 1: + """ + # todo: figure out how this works: + # pointing vector is rotated every step wrt beginning position, in the beginning body coincides with rpy, + # thus rodriguez rotations happen in rpy frame, not body. + + # therefore the following actually rotates the body within rpy with theta 1: pointing_vector = body_to_rpy(np.ndarray.tolist(pointing_vector), np.ndarray.tolist(self._actor_theta_1)) self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) + """ + # body rotation in rpy frame + pointing_vector = rodriguez_rotation(pointing_vector, self._actor_theta_1) + self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) # set values close to zero equal to zero. self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 From 0b444dd07f65206e12bf927c33174ecb58cc4cf0 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 4 Jan 2024 22:44:28 +0100 Subject: [PATCH 47/97] body to rpy convention: yaw - pitch - roll. transformation functions are correct. Find relationship between theta1 and thata2 and roll - pitch - yaw --- paseos/attitude/attitude_model.py | 13 ++++++------ paseos/attitude/reference_frame_transfer.py | 23 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index dcd1ef3..41f3d94 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -10,7 +10,6 @@ rpy_to_eci, rpy_to_body, body_to_rpy, - get_euler, rodriguez_rotation) class AttitudeModel: @@ -81,7 +80,8 @@ def __init__( self._actor_body_rotation = np.array([0.0,0.0,0.0]) self._actor_theta_1 = np.array([0.0,0.0,0.0]) - self._actor_theta_2 = np.array([0.0,0.0,0.0]) + #self._actor_theta_2 = np.array([0.0,0.0,0.0]) + self._actor_theta_2 = actor_initial_attitude_in_rad def nadir_vector(self): """computes unit vector pointing towards earth, inertial body frame @@ -201,9 +201,9 @@ def update_attitude(self, dt): # previous position (will be None at first timestep) previous_position = self._actor._previous_position # call previous position before velocity, as "get_position_velocity" sets previous position to current one - # todo: solve this? constrained to get previous position only before doing "get_pos_vel()" # todo: velocity function starts previous position... when initializing pointing vector, # looks like this means next line is not needed: + # todo: find correct way of relating roll, pitch, yaw angles to a vector (to have initial attitude) if not previous_position: # first timestep # velocity, called only to update previous position. @@ -322,7 +322,8 @@ def update_attitude(self, dt): # theta_2: # to not have the spacecraft rotate in the first timestep: if self._actor_t == 0: - self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) + #self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) + pass else: body_rotation = np.array(self._actor_angular_velocity) * dt # theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it @@ -348,11 +349,11 @@ def update_attitude(self, dt): pointing_vector = rodriguez_rotation(self._actor_pointing_vector_body, self._actor_theta_2) # secondly rotate body pointing vector with theta 1: - """ + # todo: figure out how this works: # pointing vector is rotated every step wrt beginning position, in the beginning body coincides with rpy, # thus rodriguez rotations happen in rpy frame, not body. - + """ # therefore the following actually rotates the body within rpy with theta 1: pointing_vector = body_to_rpy(np.ndarray.tolist(pointing_vector), np.ndarray.tolist(self._actor_theta_1)) self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 8b62c88..b010e67 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -61,7 +61,7 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): T (numpy array of floats): transformation matrix """ roll, pitch, yaw = euler_angles_in_rad - """ + # individual axis rotations: A = np.array([ [1, 0, 0], @@ -98,6 +98,7 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): [0, np.cos(roll), np.sin(roll)], [0, -np.sin(roll), np.cos(roll)] ]) + """ # Transformation matrix: T = A @ B @ C @@ -192,6 +193,7 @@ def body_to_rpy(u, euler_angles_in_rad): T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) return T @ np.array(u) +# next two functions don't work def angle_between_vectors(u, v, n): """Returns right-handed rotation angle between u and v, with u being the reference for measuring the right-handed rotation. Formula: @@ -277,3 +279,22 @@ def rodriguez_rotation(p, angles): (np.cross(k, p)) * np.sin(theta)) + k * (np.linalg.multi_dot([k, p])) *(1 - np.cos(theta))) return p_rotated + +def rpy_to_body_two(u, rpy_angles): + x = np.array([rpy_angles[0], 0, 0]) + y = np.array([0, rpy_angles[1], 0]) + z = np.array([0, 0, rpy_angles[2]]) + + # yaw: rotation around z-axis + u = rodriguez_rotation(u, z) + y = rodriguez_rotation(y, z) + x = rodriguez_rotation(x, z) + + # pitch: rotation about new y-axis + u = rodriguez_rotation(u, y) + x = rodriguez_rotation(x, y) + + # roll: rotation about new x-axis + u = rodriguez_rotation(u, x) + + return u \ No newline at end of file From 4a5ebee3b5518b61b322a731736891d602b70ba2 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Fri, 5 Jan 2024 01:04:03 +0100 Subject: [PATCH 48/97] added function to get rpy angles, todo: perform actual body rotations with rodriguez' rotations and get attitude with new function --- paseos/attitude/reference_frame_transfer.py | 58 ++++++++++++++------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index b010e67..bafd521 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -80,25 +80,6 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): [-np.sin(yaw), np.cos(yaw), 0], [0, 0, 1] ]) - """ - A = np.array([ - [np.cos(yaw), np.sin(yaw), 0], - [-np.sin(yaw), np.cos(yaw), 0], - [0, 0, 1] - ]) - - B = np.array([ - [np.cos(pitch), 0, -np.sin(pitch)], - [0, 1, 0], - [np.sin(pitch), 0, np.cos(pitch)] - ]) - - C = np.array([ - [1, 0, 0], - [0, np.cos(roll), np.sin(roll)], - [0, -np.sin(roll), np.cos(roll)] - ]) - """ # Transformation matrix: T = A @ B @ C @@ -281,6 +262,7 @@ def rodriguez_rotation(p, angles): return p_rotated def rpy_to_body_two(u, rpy_angles): + # same as rpy_to_body but just a test x = np.array([rpy_angles[0], 0, 0]) y = np.array([0, rpy_angles[1], 0]) z = np.array([0, 0, rpy_angles[2]]) @@ -297,4 +279,40 @@ def rpy_to_body_two(u, rpy_angles): # roll: rotation about new x-axis u = rodriguez_rotation(u, x) - return u \ No newline at end of file + return u + +def get_rpy_angles(x, y, z): + """Returns Roll, Pitch, Yaw angles of rotated frame wrt fixed frame. + example: input body frame primary axes expressed wrt rpy frame, returns roll, pitch, yaw of body + + Args: + x (numpy ndarray): new orientation of [1,0,0] vector expressed in fixed reference frame + y (numpy ndarray): new orientation of [0,1,0] vector expressed in fixed reference frame + z (numpy ndarray): new orientation of [0,0,1] vector expressed in fixed reference frame + + Returns: list of floats [roll, pitch, yaw] + + """ # | R11 R12 R13 | + # create rotation matrix: R = | R21 R22 R23 | + # | R31 R32 R33 | + R = np.c_[x, y, z] + # when pitch = +- 90 degrees(R_13 = +-1), yaw and roll have the same effect. Choose roll to be zero + if np.isclose(R[0][2], -1): + pitch = np.pi/2 + roll = 0.0 + yaw = -np.arctan2(R[1][0], R[2][0]) + # or yaw = -np.arctan2( - R[2][1], R[1][1]) + elif np.isclose(R[0][2], 1): + pitch = - np.pi/2 + roll = 0.0 + yaw = np.arctan2( - R[1][0], R[2][0]) + # or yaw = np.arctan2( - R[2][1], - R[1][1]) + else: + # problem: two rpy sets possible as pitch = atan2( ..., +- sqrt(..)). Positive x component of atan2 is chosen + pitch = np.arctan2(-R[0][2], np.sqrt((R[1][2]) ** 2 + (R[2][2]) ** 2)) + roll = np.arctan2(R[1][2] / np.cos(pitch), R[2][2] / np.cos(pitch)) + yaw = np.arctan2(R[0][1] / np.cos(pitch), R[0][0] / np.cos(pitch)) + + return [roll, pitch, yaw] + + From 270d859ca4768ffa300d03dc82c84c5a4cf56273 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 8 Jan 2024 23:48:55 +0100 Subject: [PATCH 49/97] Now model works with initial attitude specified, calculates attitude relative to previous timestep (instead of t0, this avoids problems for later) and rpy angles are now correctly implemented (with default order y-p-r) --- paseos/attitude/attitude_model.py | 179 ++++++++++---------- paseos/attitude/reference_frame_transfer.py | 23 ++- 2 files changed, 107 insertions(+), 95 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 41f3d94..38d9e51 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -10,7 +10,9 @@ rpy_to_eci, rpy_to_body, body_to_rpy, - rodriguez_rotation) + rodriguez_rotation, + get_rpy_angles, + rotate_body_vectors) class AttitudeModel: @@ -81,8 +83,9 @@ def __init__( self._actor_theta_1 = np.array([0.0,0.0,0.0]) #self._actor_theta_2 = np.array([0.0,0.0,0.0]) - self._actor_theta_2 = actor_initial_attitude_in_rad + self._actor_theta_2 = np.array([0.0,0.0,0.0]) + #self._actor_xb_rpy = np.array([]) def nadir_vector(self): """computes unit vector pointing towards earth, inertial body frame @@ -192,8 +195,9 @@ def update_attitude(self, dt): self._actor_attitude_in_rad = np.arctan2( np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) - """ - + """ + """ + # this code works, but not for an initial attitude (wrong maths), commented out to implement that #################################### STARTING CONDITIONS OF UPDATE ATTITUDE #################################### # position position = self._actor.get_position(self._actor.local_time) @@ -220,44 +224,7 @@ def update_attitude(self, dt): # orbital plane normal unit vector self._actor_orbital_plane_normal = (np.cross(position, velocity) / np.linalg.norm(np.cross(position, velocity))) - """ - # attitude change due to two rotations: - # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. - # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt - # theta_1: - # rotation angle: arccos( (p . p_previous) / (||p|| ||p_previous||) ) - rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, previous_position]) / - (np.linalg.norm(position) * np.linalg.norm(previous_position))) - # assign this rotation to the vector perpendicular to rotation plane - rpy_inertial_rotation_vector = self._actor_orbital_plane_normal * rpy_inertial_rotation_angle - # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed - theta_1 = -eci_to_rpy(rpy_inertial_rotation_vector, position, velocity) - - # theta_2: - # to not have the spacecraft rotate in the first timestep: - if self._actor_t == 0: - theta_2 = np.array([0,0,0]) - else: - body_rotation = np.ndarray.tolist(np.array(self._actor_angular_velocity) * dt) - #theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it - theta_2 = np.array(body_rotation) - - # updated attitude - self._actor_attitude_in_rad += theta_1 + theta_2 - - # set values close to zero equal to zero. - self._actor_attitude_in_rad[np.isclose(self._actor_attitude_in_rad, np.zeros(3))] = 0 - - # attitude in range [-π, π]: - self._actor_attitude_in_rad = np.arctan2( - np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) - # pointing vector - self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy( - self._actor_pointing_vector_body, np.ndarray.tolist(self._actor_attitude_in_rad)), position, velocity) - """ - """ - # following code works for constant angular velocity on one axis (x, y, z) not other vectors. # attitude change due to two rotations: # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt @@ -274,11 +241,12 @@ def update_attitude(self, dt): # theta_2: # to not have the spacecraft rotate in the first timestep: if self._actor_t == 0: - self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) + #self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) + pass else: body_rotation = np.array(self._actor_angular_velocity) * dt # theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it - self._actor_theta_2 += -body_rotation + self._actor_theta_2 += body_rotation # updated attitude self._actor_attitude_in_rad = self._actor_theta_1 + self._actor_theta_2 @@ -295,9 +263,22 @@ def update_attitude(self, dt): # the following sequence of rotations is very important in order to make the model work # more insight into the transformation functions rotation sequences is needed to make sense of this # first rotate body pointing vector with theta 2: - pointing_vector = rpy_to_body(self._actor_pointing_vector_body, np.ndarray.tolist(self._actor_theta_2)) + + # body rotation in body frame + pointing_vector = rodriguez_rotation(self._actor_pointing_vector_body, self._actor_theta_2) + # secondly rotate body pointing vector with theta 1: - pointing_vector = body_to_rpy(np.ndarray.tolist(pointing_vector), np.ndarray.tolist(self._actor_theta_1)) + + # todo: figure out how this works: + # pointing vector is rotated every step wrt beginning position, in the beginning body coincides with rpy, + # thus rodriguez rotations happen in rpy frame, not body. + + # therefore the following actually rotates the body within rpy with theta 1: + # pointing_vector = body_to_rpy(np.ndarray.tolist(pointing_vector), np.ndarray.tolist(self._actor_theta_1)) + # self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) + + # body rotation in rpy frame + pointing_vector = rodriguez_rotation(pointing_vector, self._actor_theta_1) self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) # set values close to zero equal to zero. @@ -306,66 +287,86 @@ def update_attitude(self, dt): # convert to list self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) """ + # position + position = self._actor.get_position(self._actor.local_time) + + # previous position (will be None at first timestep) + previous_position = self._actor._previous_position + # call previous position before velocity, as "get_position_velocity" sets previous position to current one + # todo: velocity function starts previous position... when initializing pointing vector, + # looks like this means next line is not needed: + # todo: find correct way of relating roll, pitch, yaw angles to a vector (to have initial attitude) + if not previous_position: # first timestep + + # velocity, called only to update previous position. + velocity = self._actor.get_position_velocity(self._actor.local_time)[1] + starting_position = position + + else: + # step: + step = self._actor_t + + # velocity + velocity = self._actor.get_position_velocity(self._actor.local_time)[1] + + # orbital plane normal unit vector + self._actor_orbital_plane_normal = (np.cross(position, velocity) / + np.linalg.norm(np.cross(position, velocity))) + # attitude change due to two rotations: - # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. - # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt - # todo: both thetas are negative. change? + # theta_1: # rotation angle: arccos( (p . p_previous) / (||p|| ||p_previous||) ) - rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, previous_position]) / - (np.linalg.norm(position) * np.linalg.norm(previous_position))) - # assign this rotation to the vector perpendicular to rotation plane - rpy_inertial_rotation_vector = self._actor_orbital_plane_normal * rpy_inertial_rotation_angle + rpy_frame_rotation_angle_in_eci = np.arccos(np.linalg.multi_dot([position, previous_position]) / + (np.linalg.norm(position) * np.linalg.norm(previous_position))) + # assign this scalar rotation angle to the vector perpendicular to rotation plane + rpy_frame_rotation_vector_in_eci = self._actor_orbital_plane_normal * rpy_frame_rotation_angle_in_eci # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed - self._actor_theta_1 += -eci_to_rpy(rpy_inertial_rotation_vector, position, velocity) + self._actor_theta_1 = -eci_to_rpy(np.ndarray.tolist(rpy_frame_rotation_vector_in_eci), position, velocity) # theta_2: # to not have the spacecraft rotate in the first timestep: - if self._actor_t == 0: - #self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) - pass - else: + # self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) + if self._actor_t != 0: body_rotation = np.array(self._actor_angular_velocity) * dt - # theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it - self._actor_theta_2 += body_rotation + self._actor_theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) + #self._actor_theta_2 = body_rotation - # updated attitude - self._actor_attitude_in_rad = self._actor_theta_1 + self._actor_theta_2 + xb_rpy = body_to_rpy([1,0,0], self._actor_attitude_in_rad) + yb_rpy = body_to_rpy([0,1,0], self._actor_attitude_in_rad) + zb_rpy = body_to_rpy([0,0,1], self._actor_attitude_in_rad) + #att = self._actor_attitude_in_rad + #point_b = self._actor_pointing_vector_body + # transform body pointing vector to RPY "fixed" frame + pointing_vector_rpy = body_to_rpy(self._actor_pointing_vector_body, self._actor_attitude_in_rad) - # set values close to zero equal to zero. - self._actor_attitude_in_rad[np.isclose(self._actor_attitude_in_rad, np.zeros(3))] = 0 + # rotate the body within the rpy frame with these angles + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = ( + rotate_body_vectors(xb_rpy, + yb_rpy, + zb_rpy, + pointing_vector_rpy, + self._actor_theta_1) - # attitude in range [-π, π]: - self._actor_attitude_in_rad = np.arctan2( - np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) + ) - # pointing vector - # todo: change function names to make sense - # the following sequence of rotations is very important in order to make the model work - # more insight into the transformation functions rotation sequences is needed to make sense of this - # first rotate body pointing vector with theta 2: + new_theta_2 = rodriguez_rotation(self._actor_theta_2, self._actor_theta_1) - # body rotation in body frame - pointing_vector = rodriguez_rotation(self._actor_pointing_vector_body, self._actor_theta_2) + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = ( + rotate_body_vectors(xb_rpy, + yb_rpy, + zb_rpy, + pointing_vector_rpy, + new_theta_2) + ) - # secondly rotate body pointing vector with theta 1: + # update new attitude: + self._actor_attitude_in_rad = np.ndarray.tolist(np.array(get_rpy_angles(xb_rpy, yb_rpy, zb_rpy))) - # todo: figure out how this works: - # pointing vector is rotated every step wrt beginning position, in the beginning body coincides with rpy, - # thus rodriguez rotations happen in rpy frame, not body. - """ - # therefore the following actually rotates the body within rpy with theta 1: - pointing_vector = body_to_rpy(np.ndarray.tolist(pointing_vector), np.ndarray.tolist(self._actor_theta_1)) - self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) - """ - # body rotation in rpy frame - pointing_vector = rodriguez_rotation(pointing_vector, self._actor_theta_1) - self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) - # set values close to zero equal to zero. - self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 + # update pointing vector + self._actor_pointing_vector_eci = rpy_to_eci(pointing_vector_rpy, position, velocity) + - # convert to list - self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) self._actor_t += 1 diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index bafd521..fa8c574 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -281,22 +281,27 @@ def rpy_to_body_two(u, rpy_angles): return u -def get_rpy_angles(x, y, z): - """Returns Roll, Pitch, Yaw angles of rotated frame wrt fixed frame. +#todo: gives negative angles +def get_rpy_angles(x, y, z, vectors_in_rpy_frame = True): + """Returns Roll, Pitch, Yaw angles of rotated frame wrt fixed frame. example: input body frame primary axes expressed wrt rpy frame, returns roll, pitch, yaw of body Args: x (numpy ndarray): new orientation of [1,0,0] vector expressed in fixed reference frame y (numpy ndarray): new orientation of [0,1,0] vector expressed in fixed reference frame z (numpy ndarray): new orientation of [0,0,1] vector expressed in fixed reference frame + vectors_in_rpy_frame (boolean): are the input vectors expressed in the rpy frame? (default: True) Returns: list of floats [roll, pitch, yaw] - """ # | R11 R12 R13 | - # create rotation matrix: R = | R21 R22 R23 | - # | R31 R32 R33 | + """ # | R00 R01 R02 | + # create rotation matrix: R = | R10 R11 R12 | + # | R20 R21 R22 | R = np.c_[x, y, z] - # when pitch = +- 90 degrees(R_13 = +-1), yaw and roll have the same effect. Choose roll to be zero + if vectors_in_rpy_frame: + # different transformation matrix + R = np.linalg.inv(R) + # when pitch = +- 90 degrees(R_03 = +-1), yaw and roll have the same effect. Choose roll to be zero if np.isclose(R[0][2], -1): pitch = np.pi/2 roll = 0.0 @@ -316,3 +321,9 @@ def get_rpy_angles(x, y, z): return [roll, pitch, yaw] +def rotate_body_vectors(x, y, z, p, angle): + x = rodriguez_rotation(x, angle) + y = rodriguez_rotation(y, angle) + z = rodriguez_rotation(z, angle) + p = rodriguez_rotation(p, angle) + return x, y, z, p From 67864fff017aa31b15df994d7309f970e3065ebf Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 9 Jan 2024 18:09:43 +0100 Subject: [PATCH 50/97] Clean up code + added acceleration calculations (deleted possibility of having an initial acceleration). --- paseos/actors/actor_builder.py | 2 - paseos/attitude/attitude_model.py | 467 +++++++------------- paseos/attitude/disturbance_calculations.py | 3 +- paseos/attitude/reference_frame_transfer.py | 207 +++------ 4 files changed, 223 insertions(+), 456 deletions(-) diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index 8f6bd6a..e00aff3 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -512,7 +512,6 @@ def set_attitude_model( actor: SpacecraftActor, actor_initial_attitude_in_rad: list[float] = [0.0, 0.0, 0.0], actor_initial_angular_velocity: list[float] = [0.0, 0.0, 0.0], - actor_initial_angular_acceleration: list[float] = [0.0, 0.0, 0.0], actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0] ): @@ -521,7 +520,6 @@ def set_attitude_model( local_actor=actor, actor_initial_attitude_in_rad=actor_initial_attitude_in_rad, actor_initial_angular_velocity=actor_initial_angular_velocity, - actor_initial_angular_acceleration=actor_initial_angular_acceleration, actor_pointing_vector_body=actor_pointing_vector_body, diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 38d9e51..e37942f 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -3,20 +3,23 @@ import pykep as pk import numpy as np -from paseos.attitude.disturbance_calculations import (calculate_aero_torque, - calculate_magnetic_torque, - calculate_grav_torque) -from paseos.attitude.reference_frame_transfer import (eci_to_rpy, - rpy_to_eci, - rpy_to_body, - body_to_rpy, - rodriguez_rotation, - get_rpy_angles, - rotate_body_vectors) - -class AttitudeModel: +from paseos.attitude.disturbance_calculations import ( + calculate_aero_torque, + calculate_magnetic_torque, + calculate_grav_torque, +) +from paseos.attitude.reference_frame_transfer import ( + eci_to_rpy, + rpy_to_eci, + rpy_to_body, + body_to_rpy, + rodriguez_rotation, + get_rpy_angles, + rotate_body_vectors, +) +class AttitudeModel: _actor = None _actor_attitude_in_rad = None _actor_angular_velocity = None @@ -25,348 +28,198 @@ class AttitudeModel: _actor_pointing_vector_body = None _actor_pointing_vector_eci = None - #_actor_prev_pos = None + # _actor_prev_pos = None def __init__( - self, - local_actor, - # initial angular conditions: (defaults to 0) - actor_initial_attitude_in_rad: list[float] = [0, 0, 0], - actor_initial_angular_velocity: list[float] = [0, 0, 0], - actor_initial_angular_acceleration: list[float] = [0, 0, 0], - actor_pointing_vector_body: list[float] = [0,0,1] # todo: letting user specify attitude and pointing vector doesn't make sense - # trial: - # actor_initial_pointing_vector_rpy: list[float] = [0,0,1], # make this better - # actor_initial_pointing_vector_eci: list[float] = None, - - #actor_initial_previous_position = [1.e+07, 1.e-03, 1.e-03] - ## add args with default value = None, if - # actor_dipole - # actor_drag_coefficient - # body_J2 - # + self, + local_actor, + # initial conditions: (defaults to 0) + actor_initial_attitude_in_rad: list[float] = [0.0, 0.0, 0.0], + actor_initial_angular_velocity: list[float] = [0.0, 0.0, 0.0], + # pointing vector in body frame: (defaults to z-axis) + actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0] + ## add args with default value = ... + # actor_dipole + # actor_drag_coefficient + # body_J2 + # ): self._actor = local_actor - self._actor_attitude_in_rad = actor_initial_attitude_in_rad - self._actor_angular_velocity = actor_initial_angular_velocity - self._actor_angular_acceleration = actor_initial_angular_acceleration - # pointing vectors: default body: z-axis, eci: initial nadir pointing - # normalize inputted pointing vector - self._actor_pointing_vector_body = (np.array(actor_pointing_vector_body) / - np.linalg.norm(np.array(actor_pointing_vector_body))) - """ - if actor_initial_attitude_in_rad == [0, 0, 0]: - self._actor_pointing_vector_eci = self.nadir_vector() - else: - self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist( # todo: consistency in ndarray or lists - body_to_rpy(actor_pointing_vector_body, actor_initial_attitude_in_rad)), - self._actor.get_position(self._actor.local_time), - self._actor.get_position_velocity(self._actor.local_time)[1]) - """ - # todo: make function transforming a vector from body to eci - # todo: consistency in ndarray or lists - # todo: allow for initial attitude - # todo: positive roll seems to result in negative rotations + self._actor_attitude_in_rad = np.array(actor_initial_attitude_in_rad) + self._actor_angular_velocity = np.array(actor_initial_angular_velocity) + + # normalize inputted pointing vector & convert to np.ndarray + self._actor_pointing_vector_body = np.array( + actor_pointing_vector_body + ) / np.linalg.norm(np.array(actor_pointing_vector_body)) + # pointing vector expressed in Earth-centered inertial frame self._actor_pointing_vector_eci = rpy_to_eci( - body_to_rpy(self._actor_pointing_vector_body, actor_initial_attitude_in_rad), - self._actor.get_position(self._actor.local_time), - self._actor.get_position_velocity(self._actor.local_time)[1]) - self._actor_t = 0 - self._actor_starting_position = self._actor.get_position(self._actor.local_time) - # can't do this, it messes up "previous position" - #self._actor_starting_velocity = self._actor.get_position_velocity(self._actor.local_time)[1] - #self._actor_orbital_plane_normal = (np.cross(self._actor_starting_position, - # self._actor_starting_velocity) / - # np.linalg.norm(np.cross(self._actor_starting_position, - # self._actor_starting_velocity))) - self._actor_orbital_plane_normal = None - self._actor_body_rotation = np.array([0.0,0.0,0.0]) - - self._actor_theta_1 = np.array([0.0,0.0,0.0]) - #self._actor_theta_2 = np.array([0.0,0.0,0.0]) - self._actor_theta_2 = np.array([0.0,0.0,0.0]) - - #self._actor_xb_rpy = np.array([]) + body_to_rpy(self._actor_pointing_vector_body, self._actor_attitude_in_rad), + np.array(self._actor.get_position(self._actor.local_time)), + np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), + ) + + self._first_run = True + def nadir_vector(self): - """computes unit vector pointing towards earth, inertial body frame + # unused + """compute unit vector pointing towards earth, inertial body frame Returns: np array ([x, y, z]): unit nadir vector in ECIF (Earth-centered inertial frame) """ u = np.array(self._actor.get_position(self._actor.local_time)) - return -u/np.linalg.norm(u) + return -u / np.linalg.norm(u) def calculate_disturbance_torque(self): - """Computes total torque due to user specified disturbances + """Compute total torque due to user specified disturbances Returns: - list [Tx, Ty, Tz]: total combined torques in Nm + np.array([Tx, Ty, Tz]): total combined torques in Nm """ - T = np.array([0,0,0]) + T = np.array([0.0, 0.0, 0.0]) if "aerodynamic" in self._actor.get_disturbances(): T += calculate_aero_torque() if "gravitational" in self._actor.get_disturbances(): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): T += calculate_magnetic_torque() + # T += np.array([0.0, 0.0001, 0.0]) test placeholder return T - def update_attitude(self, dt): - """ + def calculate_angular_acceleration(self): + """Calculate the spacecraft angular acceleration (external disturbance torques and gyroscopic accelerations)""" + # todo: implement geometric model + #I = self._actor_I + I = np.array([[50, 0, 0], [0, 50, 0], [0, 0, 50]]) # placeholder + + # Euler's equation for rigid body rotation: a = I^(-1) (T - w x (Iw)) + # a = angular acceleration, I = inertia matrix, T = torque vector, w = angular velocity + self._actor_angular_acceleration = np.linalg.inv(I) @ ( + self.calculate_disturbance_torque() + - np.cross(self._actor_angular_velocity, I @ self._actor_angular_velocity) + ) + + def body_rotation(self, dt): + """Calculates the rotation vector around which the spacecraft body rotates + based on angular acceleration and velocity Args: - dt (float): How far to advance the attitude computation. + dt (float): time to advance - Returns: - np array + Returns: rotation vector of spacecraft body expressed in the body frame """ + # todo: check if need to skip first step + # theta_2: + + # to not have the spacecraft rotate in the first timestep: + # if self._first_run: + # body_rotation = self._actor_angular_velocity * dt + # self._actor_theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) + # self._first_run = False + self.calculate_angular_acceleration() + self._actor_angular_velocity += self._actor_angular_acceleration * dt + body_rotation = self._actor_angular_velocity * dt + return body_to_rpy(body_rotation, self._actor_attitude_in_rad) + + def frame_rotation(self, position, previous_position, velocity): + """Calculate the rotation vector of the RPY frame rotation within the inertial frame. + this rotation component makes the actor body attitude stay constant w.r.t. inertial frame, + + Args: + position (np.ndarray): actor position vector + previous_position (np.ndarray): actor position vector in previous timestep + velocity (np.ndarray): actor velocity vector + + Returns: rotation vector of RPY frame w.r.t. ECI frame expressed in the ECI frame """ - # define position, previous position, velocity vectors - # and euler angles (between the pointing vector in SBF and (0, 0, 1) z vector in RPY) + # orbital plane normal unit vector: (p x v)/||p x v|| + orbital_plane_normal = np.cross(position, velocity) / np.linalg.norm( + np.cross(position, velocity) + ) - #################################### STARTING CONDITIONS OF UPDATE ATTITUDE #################################### - # position - position = self._actor.get_position(self._actor.local_time) - # previous position (will be None at first timestep) - if not self._actor._previous_position: - previous_position = position - else: - previous_position = self._actor._previous_position - # velocity - velocity = self._actor.get_position_velocity(self._actor.local_time)[1] + # rotation angle: arccos((p . p_previous) / (||p|| ||p_previous||)) + rpy_frame_rotation_angle_in_eci = np.arccos( + np.linalg.multi_dot([position, previous_position]) + / (np.linalg.norm(position) * np.linalg.norm(previous_position)) + ) + + # assign this scalar rotation angle to the vector perpendicular to rotation plane + rpy_frame_rotation_vector_in_eci = ( + orbital_plane_normal * rpy_frame_rotation_angle_in_eci + ) - # nadir pointing vector in ECI - nadir_eci = self.nadir_vector() + # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed + return -eci_to_rpy(rpy_frame_rotation_vector_in_eci, position, velocity) - # update the euler angles (attitude) of the spacecraft body wrt rpy frame - attitude_eci = get_euler(nadir_eci, self._actor_pointing_vector_eci) # rotation in ECI - self._actor_attitude_in_rad = eci_to_rpy(attitude_eci, position, velocity, translation=False) # rotation in RPY - euler = self._actor_attitude_in_rad - # body angular velocity wrt RPY frame after timestep dt - angular_velocity_body_wrt_rpy = np.array(self._actor_attitude_in_rad) / dt - self._actor_angular_velocity += rpy_to_body(np.ndarray.tolist(angular_velocity_body_wrt_rpy), euler) + def body_axes_in_rpy(self): + """Transforms vectors expressed in the spacecraft body frame to the roll pitch yaw frame. + Vectors: - x, y, z axes + - user specified pointing vector - # constants: - # self._actor_I = (to do: from geometric model) - # self._actor_mass = 50 + Returns: transformed vectors + """ + # principal axes: + x = body_to_rpy(np.array([1, 0, 0]), self._actor_attitude_in_rad) + y = body_to_rpy(np.array([0, 1, 0]), self._actor_attitude_in_rad) + z = body_to_rpy(np.array([0, 0, 1]), self._actor_attitude_in_rad) - I = np.array([[50, 0, 0], - [0, 50, 0], - [0, 0, 50]]) + # pointing vector: + p = body_to_rpy(self._actor_pointing_vector_body, self._actor_attitude_in_rad) + return x, y, z, p - # disturbance torque vector - # disturbance_torque = self.calculate_disturbance_torque() - disturbance_torque = np.array([0,100,0]) # placeholder. IN SBF + def update_attitude(self, dt): + """Updates the actor attitude based on initial conditions and disturbance torques over time + Args: + dt (float): How far to advance the attitude computation. + """ - # dynamics: - ####################################### ADD DISTURBANCE, UPDATE ATTITUDE ####################################### + # position + position = np.array(self._actor.get_position(self._actor.local_time)) - # angular_velocity_sbf_wrt_rpy = self._actor_angular_velocity - # acceleration euler equation for rigid body rotation (apply disturbance torques) + # previous position + previous_position = self._actor._previous_position - self._actor_angular_acceleration = ( - np.linalg.inv(I) @ (disturbance_torque - - np.cross(np.array(angular_velocity_body_wrt_rpy), - I @ np.array(angular_velocity_body_wrt_rpy)))) - # new angular velocity of body frame wrt RPY - # todo: check time when angular velocity of body wrt rpy is calculated - angular_velocity_body_wrt_rpy = (angular_velocity_body_wrt_rpy + - body_to_rpy(self._actor_angular_acceleration, euler) * dt) + # velocity + velocity = np.array( + self._actor.get_position_velocity(self._actor.local_time)[1] + ) - self._actor_angular_velocity = rpy_to_body(angular_velocity_body_wrt_rpy, euler) - # angular velocity of the RPY frame wrt ECI - etha = -(np.arccos(np.linalg.multi_dot([position, previous_position]) / - (np.linalg.norm(position)*np.linalg.norm(previous_position)))) / dt - # ^ minus because rotation of the spacecraft cg is in negative y direction in RPY + # body axes expressed in rpy frame: (x, y, z, custom pointing vector) + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = self.body_axes_in_rpy() - angular_velocity_of_rpy_wrt_eci = rpy_to_eci([0, etha, 0], position, velocity, translation=False) + # todo: check if possible to do both angles at once - # angular velocity of the spacecraft body wrt inertial frame - angular_velocity_body_wrt_eci = (rpy_to_eci(angular_velocity_body_wrt_rpy, position, velocity, translation=False) + - angular_velocity_of_rpy_wrt_eci) + # attitude change due to two rotations + # rpy frame rotation, in inertial frame: + theta_1 = self.frame_rotation(position, previous_position, velocity) + # body rotation due to its physical rotation + theta_2 = self.body_rotation(dt) - # update attitude + # rotate the body vectors in rpy frame with frame rotation + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = rotate_body_vectors( + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy, theta_1 + ) - self._actor_pointing_vector_eci = rpy_to_eci(body_to_rpy([0,0,1], self._actor_attitude_in_rad), position, - velocity, translation=False) - self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 + # rotate the body rotation vector as well + theta_2 = rodriguez_rotation(theta_2, theta_1) + # rotate the body vectors in rpy frame with body rotation + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = rotate_body_vectors( + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy, theta_2 + ) - self._actor_attitude_in_rad = np.arctan2( - np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) """ + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = rotate_body_vectors( + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy, theta_1 + theta_2 + ) """ - # this code works, but not for an initial attitude (wrong maths), commented out to implement that - #################################### STARTING CONDITIONS OF UPDATE ATTITUDE #################################### - # position - position = self._actor.get_position(self._actor.local_time) - - # previous position (will be None at first timestep) - previous_position = self._actor._previous_position - # call previous position before velocity, as "get_position_velocity" sets previous position to current one - # todo: velocity function starts previous position... when initializing pointing vector, - # looks like this means next line is not needed: - # todo: find correct way of relating roll, pitch, yaw angles to a vector (to have initial attitude) - if not previous_position: # first timestep - - # velocity, called only to update previous position. - velocity = self._actor.get_position_velocity(self._actor.local_time)[1] - starting_position = position - - else: - # step: - step = self._actor_t - - # velocity - velocity = self._actor.get_position_velocity(self._actor.local_time)[1] - - # orbital plane normal unit vector - self._actor_orbital_plane_normal = (np.cross(position, velocity) / - np.linalg.norm(np.cross(position, velocity))) - - # attitude change due to two rotations: - # theta_1: rotation of the body frame wrt RPY, because of its fixed attitude in the inertial frame. - # theta_2: rotation of the body frame wrt RPY due to the body angular velocity * dt - # todo: both thetas are negative. change? - # theta_1: - # rotation angle: arccos( (p . p_previous) / (||p|| ||p_previous||) ) - rpy_inertial_rotation_angle = np.arccos(np.linalg.multi_dot([position, previous_position]) / - (np.linalg.norm(position) * np.linalg.norm(previous_position))) - # assign this rotation to the vector perpendicular to rotation plane - rpy_inertial_rotation_vector = self._actor_orbital_plane_normal * rpy_inertial_rotation_angle - # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed - self._actor_theta_1 += -eci_to_rpy(rpy_inertial_rotation_vector, position, velocity) - - # theta_2: - # to not have the spacecraft rotate in the first timestep: - if self._actor_t == 0: - #self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) - pass - else: - body_rotation = np.array(self._actor_angular_velocity) * dt - # theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) # this seems to break it - self._actor_theta_2 += body_rotation - - # updated attitude - self._actor_attitude_in_rad = self._actor_theta_1 + self._actor_theta_2 - - # set values close to zero equal to zero. - self._actor_attitude_in_rad[np.isclose(self._actor_attitude_in_rad, np.zeros(3))] = 0 - - # attitude in range [-π, π]: - self._actor_attitude_in_rad = np.arctan2( - np.sin(self._actor_attitude_in_rad), np.cos(self._actor_attitude_in_rad)) - - # pointing vector - # todo: change function names to make sense - # the following sequence of rotations is very important in order to make the model work - # more insight into the transformation functions rotation sequences is needed to make sense of this - # first rotate body pointing vector with theta 2: - - # body rotation in body frame - pointing_vector = rodriguez_rotation(self._actor_pointing_vector_body, self._actor_theta_2) - - # secondly rotate body pointing vector with theta 1: - - # todo: figure out how this works: - # pointing vector is rotated every step wrt beginning position, in the beginning body coincides with rpy, - # thus rodriguez rotations happen in rpy frame, not body. - - # therefore the following actually rotates the body within rpy with theta 1: - # pointing_vector = body_to_rpy(np.ndarray.tolist(pointing_vector), np.ndarray.tolist(self._actor_theta_1)) - # self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) - - # body rotation in rpy frame - pointing_vector = rodriguez_rotation(pointing_vector, self._actor_theta_1) - self._actor_pointing_vector_eci = rpy_to_eci(np.ndarray.tolist(pointing_vector), position, velocity) - - # set values close to zero equal to zero. - self._actor_pointing_vector_eci[np.isclose(self._actor_pointing_vector_eci, np.zeros(3))] = 0 - - # convert to list - self._actor_attitude_in_rad = np.ndarray.tolist(self._actor_attitude_in_rad) - """ - # position - position = self._actor.get_position(self._actor.local_time) - # previous position (will be None at first timestep) - previous_position = self._actor._previous_position - # call previous position before velocity, as "get_position_velocity" sets previous position to current one - # todo: velocity function starts previous position... when initializing pointing vector, - # looks like this means next line is not needed: - # todo: find correct way of relating roll, pitch, yaw angles to a vector (to have initial attitude) - if not previous_position: # first timestep - - # velocity, called only to update previous position. - velocity = self._actor.get_position_velocity(self._actor.local_time)[1] - starting_position = position - - else: - # step: - step = self._actor_t - - # velocity - velocity = self._actor.get_position_velocity(self._actor.local_time)[1] - - # orbital plane normal unit vector - self._actor_orbital_plane_normal = (np.cross(position, velocity) / - np.linalg.norm(np.cross(position, velocity))) - - # attitude change due to two rotations: - - # theta_1: - # rotation angle: arccos( (p . p_previous) / (||p|| ||p_previous||) ) - rpy_frame_rotation_angle_in_eci = np.arccos(np.linalg.multi_dot([position, previous_position]) / - (np.linalg.norm(position) * np.linalg.norm(previous_position))) - # assign this scalar rotation angle to the vector perpendicular to rotation plane - rpy_frame_rotation_vector_in_eci = self._actor_orbital_plane_normal * rpy_frame_rotation_angle_in_eci - # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed - self._actor_theta_1 = -eci_to_rpy(np.ndarray.tolist(rpy_frame_rotation_vector_in_eci), position, velocity) - - # theta_2: - # to not have the spacecraft rotate in the first timestep: - # self._actor_theta_2 = np.array([0.0, 0.0, 0.0]) - if self._actor_t != 0: - body_rotation = np.array(self._actor_angular_velocity) * dt - self._actor_theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) - #self._actor_theta_2 = body_rotation - - xb_rpy = body_to_rpy([1,0,0], self._actor_attitude_in_rad) - yb_rpy = body_to_rpy([0,1,0], self._actor_attitude_in_rad) - zb_rpy = body_to_rpy([0,0,1], self._actor_attitude_in_rad) - #att = self._actor_attitude_in_rad - #point_b = self._actor_pointing_vector_body - # transform body pointing vector to RPY "fixed" frame - pointing_vector_rpy = body_to_rpy(self._actor_pointing_vector_body, self._actor_attitude_in_rad) - - # rotate the body within the rpy frame with these angles - xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = ( - rotate_body_vectors(xb_rpy, - yb_rpy, - zb_rpy, - pointing_vector_rpy, - self._actor_theta_1) - - ) - - new_theta_2 = rodriguez_rotation(self._actor_theta_2, self._actor_theta_1) - - xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = ( - rotate_body_vectors(xb_rpy, - yb_rpy, - zb_rpy, - pointing_vector_rpy, - new_theta_2) - ) - - # update new attitude: - self._actor_attitude_in_rad = np.ndarray.tolist(np.array(get_rpy_angles(xb_rpy, yb_rpy, zb_rpy))) - - - # update pointing vector - self._actor_pointing_vector_eci = rpy_to_eci(pointing_vector_rpy, position, velocity) - - - self._actor_t += 1 + # update new attitude: + self._actor_attitude_in_rad = get_rpy_angles(xb_rpy, yb_rpy, zb_rpy) + # update pointing vector + self._actor_pointing_vector_eci = rpy_to_eci( + pointing_vector_rpy, position, velocity + ) diff --git a/paseos/attitude/disturbance_calculations.py b/paseos/attitude/disturbance_calculations.py index 1f09d1a..3c1e904 100644 --- a/paseos/attitude/disturbance_calculations.py +++ b/paseos/attitude/disturbance_calculations.py @@ -4,8 +4,8 @@ # OUTPUT NUMPY ARRAYS import numpy as np -def calculate_aero_torque(): +def calculate_aero_torque(): # calculations for torques # T must be in actor body fixed frame (to be discussed) T = [0, 0, 0] @@ -24,4 +24,3 @@ def calculate_magnetic_torque(): # T must be in actor body fixed frame (to be discussed) T = [0, 0, 0] return np.array(T) - diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index fa8c574..3c38d00 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -23,14 +23,11 @@ def transformation_matrix_eci_rpy(r, v): To go from RPY to ECI, the inverse is used. Args: - r (list of floats): position vector of RPY reference frame wrt ECI frame - v (list of floats): velocity of the spacecraft in earth reference frame, centered on spacecraft + r (np.ndarray): position vector of RPY reference frame wrt ECI frame + v (np.ndarray): velocity of the spacecraft in earth reference frame, centered on spacecraft Returns: - T (numpy array): transformation matrix + T (np.ndarray): transformation matrix """ - # convert list of floats to numpy arrays - r = np.array(r) - v = np.array(v) # determine y' base by use of the cross product: (V x r)/||(V x r)|| cross_vr = np.cross(v, r) @@ -55,31 +52,29 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): To go from body fixed to RPY, the inverse is used. Args: - euler_angles_in_rad (list of floats): [roll, pitch, yaw] in radians + euler_angles_in_rad (np.ndarray): [roll, pitch, yaw] in radians Returns: - T (numpy array of floats): transformation matrix + T (np.ndarray): transformation matrix """ roll, pitch, yaw = euler_angles_in_rad # individual axis rotations: - A = np.array([ - [1, 0, 0], - [0, np.cos(roll), np.sin(roll)], - [0, -np.sin(roll), np.cos(roll)] - ]) - - B = np.array([ - [np.cos(pitch), 0, -np.sin(pitch)], - [0, 1, 0], - [np.sin(pitch), 0, np.cos(pitch)] - ]) - - C = np.array([ - [np.cos(yaw), np.sin(yaw), 0], - [-np.sin(yaw), np.cos(yaw), 0], - [0, 0, 1] - ]) + A = np.array( + [[1, 0, 0], [0, np.cos(roll), np.sin(roll)], [0, -np.sin(roll), np.cos(roll)]] + ) + + B = np.array( + [ + [np.cos(pitch), 0, -np.sin(pitch)], + [0, 1, 0], + [np.sin(pitch), 0, np.cos(pitch)], + ] + ) + + C = np.array( + [[np.cos(yaw), np.sin(yaw), 0], [-np.sin(yaw), np.cos(yaw), 0], [0, 0, 1]] + ) # Transformation matrix: T = A @ B @ C @@ -92,13 +87,13 @@ def eci_to_rpy(u, r, v, translation=False): spacecraft, using transformation matrix from transformation_matrix_eci_rpy function. Args: - u (list of floats): vector in ECI - r (list of floats): position vector of RPY reference frame wrt ECI frame - v (list of floats): velocity of the spacecraft in earth reference frame, centered on spacecraft - translation (boolean): does the vector need to be translated? (default=True) + u (np.ndarray): vector in ECI + r (np.ndarray): position vector of RPY reference frame wrt ECI frame + v (np.ndarray): velocity of the spacecraft in earth reference frame, centered on spacecraft + translation (bool): does the vector need to be translated? (default=True) Returns: - numpy array of floats: vector u w.r.t. RPY frame + vector u w.r.t. RPY frame """ T = transformation_matrix_eci_rpy(r, v) @@ -109,7 +104,7 @@ def eci_to_rpy(u, r, v, translation=False): shift = 0 # transform u vector with matrix multiplication - return T@np.array(u) - shift + return T @ u - shift def rpy_to_eci(u, r, v, translation=False): @@ -117,13 +112,13 @@ def rpy_to_eci(u, r, v, translation=False): reference frame, using the inverse transformation matrix from transformation_matrix_eci_rpy function. Args: - u (list of floats): vector in RPY - r (list of floats): position vector of RPY reference frame wrt ECI frame - v (list of floats): velocity of the spacecraft in earth reference frame, centered on spacecraft - translation (boolean): does the vector need to be translated? (default=True) + u (np.ndarray): vector in RPY + r (np.ndarray): position vector of RPY reference frame wrt ECI frame + v (np.ndarray): velocity of the spacecraft in earth reference frame, centered on spacecraft + translation (bool): does the vector need to be translated? (default=True) Returns: - numpy array of floats: vector u w.r.t. ECI frame + vector u w.r.t. ECI frame """ T = np.linalg.inv(transformation_matrix_eci_rpy(r, v)) @@ -132,7 +127,7 @@ def rpy_to_eci(u, r, v, translation=False): else: shift = 0 # transform u vector with matrix multiplication - return T@np.array(u) + shift + return T @ u + shift def rpy_to_body(u, euler_angles_in_rad): @@ -140,19 +135,19 @@ def rpy_to_body(u, euler_angles_in_rad): spacecraft, using transformation matrix from transformation_matrix_rpy_body function. Args: - u (list of floats): vector in RPY - euler_angles_in_rad (list of floats): [roll, pitch, yaw] in radians + u (np.ndarray): vector in RPY + euler_angles_in_rad (np.ndarray): [roll, pitch, yaw] in radians Returns: - numpy array of floats: vector u w.r.t. the body fixed frame + vector u w.r.t. the body fixed frame """ # for undisturbed calculations: zero euler angles result in no transformation # numpy default absolute tolerance: 1e-0.8 if all(np.isclose(euler_angles_in_rad, np.zeros(3))): - return np.array(u) + return u else: T = transformation_matrix_rpy_body(euler_angles_in_rad) - return T@np.array(u) + return T @ u def body_to_rpy(u, euler_angles_in_rad): @@ -160,8 +155,8 @@ def body_to_rpy(u, euler_angles_in_rad): spacecraft, using the inverse transformation matrix from transformation_matrix_rpy_body function. Args: - u (list of floats): vector in the body fixed frame - euler_angles_in_rad (list of floats): [roll, pitch, yaw] in radians + u (np.ndarray): vector in the body fixed frame + euler_angles_in_rad (np.ndarray): [roll, pitch, yaw] in radians Returns: vector u w.r.t. the RPY frame @@ -169,85 +164,27 @@ def body_to_rpy(u, euler_angles_in_rad): # for undisturbed calculations: zero euler angles result in no transformation # numpy default absolute tolerance: 1e-0.8 if all(np.isclose(euler_angles_in_rad, np.zeros(3))): - return np.array(u) + return u else: T = np.linalg.inv(transformation_matrix_rpy_body(euler_angles_in_rad)) - return T @ np.array(u) + return T @ u -# next two functions don't work -def angle_between_vectors(u, v, n): - """Returns right-handed rotation angle between u and v, with u being the reference for measuring the right-handed - rotation. Formula: - angle = arctan2( u x v . n, u . v) - - Args: - u (numpy ndarray): reference vector - v (numpy ndarray): rotated vector - n (numpy ndarray): plane normal vector - - Returns: float angle in radians - - """ - # todo: solve problem when two - return np.arctan2(np.linalg.multi_dot([np.cross(u, v), n]), np.linalg.multi_dot([u, v])) - -# getting euler angls from one vector is impossible -def get_euler(u, v): -#def get_euler(u): - """Returns euler angles between two vectors in the same reference frame - - Args: - u (numpy ndarray): vector 1 - v (numpy ndarray): vector 2 - - Returns: numpy ndarray ([roll, pitch, yaw]) in radians - """ - """ - # roll: angle between yz components - # normal vector = x-axis - u_yz = np.array([0, u[1], u[2]]) - v_yz = np.array([0, v[1], v[2]]) - roll = angle_between_vectors(u_yz, v_yz, np.array([1,0,0])) - - # pitch: angle between xz components - # normal vector = y-axis - u_xz = np.array([u[0], 0, u[2]]) - v_xz = np.array([v[0], 0, v[2]]) - pitch = angle_between_vectors(u_xz, v_xz, np.array([0,1,0])) - - # yaw: angle between xy components - # normal vector = z-axis - u_xy = np.array([u[0], u[1], 0]) - v_xy = np.array([v[0], v[1], 0]) - yaw = angle_between_vectors(u_xy, v_xy, np.array([0,0,1])) - """ - - roll = angle_between_vectors(u, v, np.array([1, 0, 0])) - pitch = angle_between_vectors(u, v, np.array([0,1,0])) - yaw = angle_between_vectors(u, v, np.array([0,0,1])) - """ - # just vector u wrt rpy frame. - roll = angle_between_vectors(u, np.array([0, 0, 1]), np.array([1, 0, 0])) - pitch = angle_between_vectors(u, np.array([0, 0, 1]), np.array([0, 1, 0])) - yaw = angle_between_vectors(u, np.array([0, 0, 1]), np.array([0, 0, 1])) - """ - return [roll, pitch, yaw] def rodriguez_rotation(p, angles): """Rotates vector p around the rotation vector v in the same reference frame. - + Args: - p (numpy ndarray): vector to be rotated [x, y, z] - angles (numpy ndarray): vector with decomposed rotation angles [theta_x, theta_y, theta_z] + p (np.ndarray): vector to be rotated [x, y, z] + angles (np.ndarray): vector with decomposed rotation angles [theta_x, theta_y, theta_z] Returns: - numpy ndarray, new vector p rotated with given angles + new vector p rotated with given angles """ # scalar rotation: theta = np.linalg.norm(angles) - - # no rotation: + + # no rotation: if theta == 0.0: return p # non-zero rotation @@ -256,61 +193,41 @@ def rodriguez_rotation(p, angles): k = angles / theta # Rodriguez' formula: - p_rotated = ((p * np.cos(theta) + - (np.cross(k, p)) * np.sin(theta)) + - k * (np.linalg.multi_dot([k, p])) *(1 - np.cos(theta))) + p_rotated = (p * np.cos(theta) + (np.cross(k, p)) * np.sin(theta)) + k * ( + np.linalg.multi_dot([k, p]) + ) * (1 - np.cos(theta)) return p_rotated -def rpy_to_body_two(u, rpy_angles): - # same as rpy_to_body but just a test - x = np.array([rpy_angles[0], 0, 0]) - y = np.array([0, rpy_angles[1], 0]) - z = np.array([0, 0, rpy_angles[2]]) - # yaw: rotation around z-axis - u = rodriguez_rotation(u, z) - y = rodriguez_rotation(y, z) - x = rodriguez_rotation(x, z) - - # pitch: rotation about new y-axis - u = rodriguez_rotation(u, y) - x = rodriguez_rotation(x, y) - - # roll: rotation about new x-axis - u = rodriguez_rotation(u, x) - - return u - -#todo: gives negative angles -def get_rpy_angles(x, y, z, vectors_in_rpy_frame = True): +def get_rpy_angles(x, y, z, vectors_in_rpy_frame=True): """Returns Roll, Pitch, Yaw angles of rotated frame wrt fixed frame. example: input body frame primary axes expressed wrt rpy frame, returns roll, pitch, yaw of body - + Args: - x (numpy ndarray): new orientation of [1,0,0] vector expressed in fixed reference frame - y (numpy ndarray): new orientation of [0,1,0] vector expressed in fixed reference frame - z (numpy ndarray): new orientation of [0,0,1] vector expressed in fixed reference frame + x (np.ndarray): new orientation of [1,0,0] vector expressed in fixed reference frame + y (np.ndarray): new orientation of [0,1,0] vector expressed in fixed reference frame + z (np.ndarray): new orientation of [0,0,1] vector expressed in fixed reference frame vectors_in_rpy_frame (boolean): are the input vectors expressed in the rpy frame? (default: True) - Returns: list of floats [roll, pitch, yaw] + Returns: roll, pitch, yaw angles - """ # | R00 R01 R02 | + """ # | R00 R01 R02 | # create rotation matrix: R = | R10 R11 R12 | - # | R20 R21 R22 | + # | R20 R21 R22 | R = np.c_[x, y, z] if vectors_in_rpy_frame: # different transformation matrix R = np.linalg.inv(R) # when pitch = +- 90 degrees(R_03 = +-1), yaw and roll have the same effect. Choose roll to be zero if np.isclose(R[0][2], -1): - pitch = np.pi/2 + pitch = np.pi / 2 roll = 0.0 yaw = -np.arctan2(R[1][0], R[2][0]) # or yaw = -np.arctan2( - R[2][1], R[1][1]) elif np.isclose(R[0][2], 1): - pitch = - np.pi/2 + pitch = -np.pi / 2 roll = 0.0 - yaw = np.arctan2( - R[1][0], R[2][0]) + yaw = np.arctan2(-R[1][0], R[2][0]) # or yaw = np.arctan2( - R[2][1], - R[1][1]) else: # problem: two rpy sets possible as pitch = atan2( ..., +- sqrt(..)). Positive x component of atan2 is chosen @@ -318,7 +235,7 @@ def get_rpy_angles(x, y, z, vectors_in_rpy_frame = True): roll = np.arctan2(R[1][2] / np.cos(pitch), R[2][2] / np.cos(pitch)) yaw = np.arctan2(R[0][1] / np.cos(pitch), R[0][0] / np.cos(pitch)) - return [roll, pitch, yaw] + return np.array([roll, pitch, yaw]) def rotate_body_vectors(x, y, z, p, angle): From 4a130b3faaed84f00547e399bc3bb6922a1decc6 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 9 Jan 2024 22:40:46 +0100 Subject: [PATCH 51/97] fixed problem where when pitch = 90 deg, it stays 90. --- paseos/attitude/attitude_model.py | 2 +- paseos/attitude/reference_frame_transfer.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index e37942f..452758b 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -175,7 +175,7 @@ def update_attitude(self, dt): Args: dt (float): How far to advance the attitude computation. """ - + # todo: long simulations result in sudden small change # position position = np.array(self._actor.get_position(self._actor.local_time)) diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/attitude/reference_frame_transfer.py index 3c38d00..5664d29 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/attitude/reference_frame_transfer.py @@ -211,20 +211,23 @@ def get_rpy_angles(x, y, z, vectors_in_rpy_frame=True): Returns: roll, pitch, yaw angles - """ # | R00 R01 R02 | - # create rotation matrix: R = | R10 R11 R12 | - # | R20 R21 R22 | + """ + # create rotation matrix: + # ____| R00 R01 R02 | + # R = | R10 R11 R12 | + # ____| R20 R21 R22 | R = np.c_[x, y, z] if vectors_in_rpy_frame: # different transformation matrix R = np.linalg.inv(R) # when pitch = +- 90 degrees(R_03 = +-1), yaw and roll have the same effect. Choose roll to be zero - if np.isclose(R[0][2], -1): + # (avoid dividing by zero) + if R[0][2] == -1: pitch = np.pi / 2 roll = 0.0 yaw = -np.arctan2(R[1][0], R[2][0]) # or yaw = -np.arctan2( - R[2][1], R[1][1]) - elif np.isclose(R[0][2], 1): + elif R[0][2] == 1: pitch = -np.pi / 2 roll = 0.0 yaw = np.arctan2(-R[1][0], R[2][0]) From d8b6abea906a33e0b6fa428774a4c5b27b3828f8 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 9 Jan 2024 23:57:59 +0100 Subject: [PATCH 52/97] code cleanup, more comments, rodrigues instead of rodriguez, moved reference frame transfer file to utils --- paseos/attitude/attitude_model.py | 29 ++++--- .../reference_frame_transfer.py | 87 ++++++++++++++----- 2 files changed, 80 insertions(+), 36 deletions(-) rename paseos/{attitude => utils}/reference_frame_transfer.py (74%) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 452758b..2fa2a7a 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -1,6 +1,3 @@ -import numpy -from loguru import logger -import pykep as pk import numpy as np from paseos.attitude.disturbance_calculations import ( @@ -8,19 +5,29 @@ calculate_magnetic_torque, calculate_grav_torque, ) -from paseos.attitude.reference_frame_transfer import ( +from paseos.utils.reference_frame_transfer import ( eci_to_rpy, rpy_to_eci, - rpy_to_body, body_to_rpy, - rodriguez_rotation, + rodrigues_rotation, get_rpy_angles, rotate_body_vectors, ) class AttitudeModel: + """This model describes the attitude (Roll, Pitch and Yaw) evolution of a spacecraft actor. + Starting from an initial attitude and angular velocity, the spacecraft response to disturbance torques is simulated. + + The model allows for one pointing vector to be defined in the actor body frame for visualization and possibly + could be used for future antenna pointing applications. Its position in time within the Earth-centered inertial + frame is also calculated alongside the general body attitude + + The attitude calculations are based in three reference frames, refer to reference_frame_transfer.py in utils folder. + """ + _actor = None + _actor_attitude_in_rad = None _actor_angular_velocity = None _actor_angular_acceleration = None @@ -39,9 +46,8 @@ def __init__( actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0] ## add args with default value = ... # actor_dipole - # actor_drag_coefficient + # actor_drag_coefficient (more for geometric model) # body_J2 - # ): self._actor = local_actor self._actor_attitude_in_rad = np.array(actor_initial_attitude_in_rad) @@ -61,7 +67,7 @@ def __init__( self._first_run = True def nadir_vector(self): - # unused + # unused but might be useful during disturbance calculations or pointing vector relative position """compute unit vector pointing towards earth, inertial body frame Returns: @@ -90,7 +96,7 @@ def calculate_disturbance_torque(self): def calculate_angular_acceleration(self): """Calculate the spacecraft angular acceleration (external disturbance torques and gyroscopic accelerations)""" # todo: implement geometric model - #I = self._actor_I + # I = self._actor_I I = np.array([[50, 0, 0], [0, 50, 0], [0, 0, 50]]) # placeholder # Euler's equation for rigid body rotation: a = I^(-1) (T - w x (Iw)) @@ -152,7 +158,6 @@ def frame_rotation(self, position, previous_position, velocity): # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed return -eci_to_rpy(rpy_frame_rotation_vector_in_eci, position, velocity) - def body_axes_in_rpy(self): """Transforms vectors expressed in the spacecraft body frame to the roll pitch yaw frame. Vectors: - x, y, z axes @@ -204,7 +209,7 @@ def update_attitude(self, dt): ) # rotate the body rotation vector as well - theta_2 = rodriguez_rotation(theta_2, theta_1) + theta_2 = rodrigues_rotation(theta_2, theta_1) # rotate the body vectors in rpy frame with body rotation xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = rotate_body_vectors( xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy, theta_2 diff --git a/paseos/attitude/reference_frame_transfer.py b/paseos/utils/reference_frame_transfer.py similarity index 74% rename from paseos/attitude/reference_frame_transfer.py rename to paseos/utils/reference_frame_transfer.py index 5664d29..556ff54 100644 --- a/paseos/attitude/reference_frame_transfer.py +++ b/paseos/utils/reference_frame_transfer.py @@ -8,6 +8,11 @@ - Body fixed frame: when body is unperturbed, frame coincides with RPY frame. Perturbations result in non-zero roll pitch and yaw angles, rotating the body fixed frame w.r.t the RPY frame. + +It also provides functions for rotating vectors around another vector and determining roll, pitch and yaw angles between +two reference frames. + +Note: the order of rotation yaw -> pitch -> roll is used for transforming from "space frame (RPY here)" to body frame """ import numpy as np @@ -29,11 +34,16 @@ def transformation_matrix_eci_rpy(r, v): T (np.ndarray): transformation matrix """ + # y' coincides with -ĥ vector which is perpendicular to the orbital plane. Thus, perpendicular to v and p vectors # determine y' base by use of the cross product: (V x r)/||(V x r)|| cross_vr = np.cross(v, r) y = cross_vr / np.linalg.norm(cross_vr) - # determine z' base by use of the nadir pointing vector + + # z' coincides with the nadir pointing vector + # determine z' base by use of the position vector of the RPY frame z = -r / np.linalg.norm(r) + + # x' completes the right-handed frame # determine x' base by use of the cross product: (y' x z')/||(y' x z')|| cross_yz = np.cross(y, z) x = cross_yz / np.linalg.norm(cross_yz) @@ -61,7 +71,11 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): # individual axis rotations: A = np.array( - [[1, 0, 0], [0, np.cos(roll), np.sin(roll)], [0, -np.sin(roll), np.cos(roll)]] + [ + [1, 0, 0], + [0, np.cos(roll), np.sin(roll)], + [0, -np.sin(roll), np.cos(roll)] + ] ) B = np.array( @@ -73,7 +87,10 @@ def transformation_matrix_rpy_body(euler_angles_in_rad): ) C = np.array( - [[np.cos(yaw), np.sin(yaw), 0], [-np.sin(yaw), np.cos(yaw), 0], [0, 0, 1]] + [ + [np.cos(yaw), np.sin(yaw), 0], + [-np.sin(yaw), np.cos(yaw), 0], + [0, 0, 1]] ) # Transformation matrix: @@ -90,7 +107,7 @@ def eci_to_rpy(u, r, v, translation=False): u (np.ndarray): vector in ECI r (np.ndarray): position vector of RPY reference frame wrt ECI frame v (np.ndarray): velocity of the spacecraft in earth reference frame, centered on spacecraft - translation (bool): does the vector need to be translated? (default=True) + translation (bool): does the vector need to be translated? (default=False) Returns: vector u w.r.t. RPY frame @@ -115,7 +132,7 @@ def rpy_to_eci(u, r, v, translation=False): u (np.ndarray): vector in RPY r (np.ndarray): position vector of RPY reference frame wrt ECI frame v (np.ndarray): velocity of the spacecraft in earth reference frame, centered on spacecraft - translation (bool): does the vector need to be translated? (default=True) + translation (bool): does the vector need to be translated? (default=False) Returns: vector u w.r.t. ECI frame @@ -170,8 +187,8 @@ def body_to_rpy(u, euler_angles_in_rad): return T @ u -def rodriguez_rotation(p, angles): - """Rotates vector p around the rotation vector v in the same reference frame. +def rodrigues_rotation(p, angles): + """Rotates vector p around the rotation vector v in the same reference frame. Using Rodrigues' rotation formula Args: p (np.ndarray): vector to be rotated [x, y, z] @@ -179,7 +196,6 @@ def rodriguez_rotation(p, angles): Returns: new vector p rotated with given angles - """ # scalar rotation: theta = np.linalg.norm(angles) @@ -187,12 +203,12 @@ def rodriguez_rotation(p, angles): # no rotation: if theta == 0.0: return p - # non-zero rotation + # non-zero rotation: else: # unit rotation vector k = angles / theta - # Rodriguez' formula: + # Rodrigues' formula: p_rotated = (p * np.cos(theta) + (np.cross(k, p)) * np.sin(theta)) + k * ( np.linalg.multi_dot([k, p]) ) * (1 - np.cos(theta)) @@ -203,23 +219,32 @@ def get_rpy_angles(x, y, z, vectors_in_rpy_frame=True): """Returns Roll, Pitch, Yaw angles of rotated frame wrt fixed frame. example: input body frame primary axes expressed wrt rpy frame, returns roll, pitch, yaw of body - Args: - x (np.ndarray): new orientation of [1,0,0] vector expressed in fixed reference frame - y (np.ndarray): new orientation of [0,1,0] vector expressed in fixed reference frame - z (np.ndarray): new orientation of [0,0,1] vector expressed in fixed reference frame - vectors_in_rpy_frame (boolean): are the input vectors expressed in the rpy frame? (default: True) + This function assumes the following transformation matrix: where c = cos, s = sin, and the angles being: + roll (r), pitch (p) and yaw (y) - Returns: roll, pitch, yaw angles + | R00 R01 R02 | | cp cy cp sy -sp | ^ (-1) + R = | R10 R11 R12 | = | sr sp cy - cr sy sr sp sy + cr cy sr cp | + | R20 R21 R22 | | cr sp cy + sr sy cr sp sy - sr cy sr cp | + This is the transformation matrix from RPY to body frame, when the body frame is expressed in RPY, to find the + roll, pitch, yaw angles, this matrix needs to be inverted. + + Args: + x (np.ndarray): new orientation of [1,0,0] x-axis expressed in fixed reference frame + y (np.ndarray): new orientation of [0,1,0] y-axis expressed in fixed reference frame + z (np.ndarray): new orientation of [0,0,1] z-axis expressed in fixed reference frame + vectors_in_rpy_frame (bool): are the input vectors expressed in the rpy frame? (default: True) + + Returns: + roll, pitch, yaw angles """ - # create rotation matrix: - # ____| R00 R01 R02 | - # R = | R10 R11 R12 | - # ____| R20 R21 R22 | + # transformation matrix: R = np.c_[x, y, z] + if vectors_in_rpy_frame: # different transformation matrix R = np.linalg.inv(R) + # when pitch = +- 90 degrees(R_03 = +-1), yaw and roll have the same effect. Choose roll to be zero # (avoid dividing by zero) if R[0][2] == -1: @@ -242,8 +267,22 @@ def get_rpy_angles(x, y, z, vectors_in_rpy_frame=True): def rotate_body_vectors(x, y, z, p, angle): - x = rodriguez_rotation(x, angle) - y = rodriguez_rotation(y, angle) - z = rodriguez_rotation(z, angle) - p = rodriguez_rotation(p, angle) + """Used for rotating multiple vectors about one common rotation vector. In this case rotating the primary x-, y-, z- + axes and the actor's pointing vector. + + Args: + x (np.ndarray): x-axis + y (np.ndarray): y-axis + z (np.ndarray): z-axis + p (np.ndarray): pointing vector + angle (np.ndarray): rotation vector with magnitude being the rotation angle + + Returns: + rotated x-, y-, z- axes and pointing vector + """ + x = rodrigues_rotation(x, angle) + y = rodrigues_rotation(y, angle) + z = rodrigues_rotation(z, angle) + p = rodrigues_rotation(p, angle) + return x, y, z, p From a68f04d7bde4fce3b333ec14b874acdbdd49385b Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 10 Jan 2024 00:00:09 +0100 Subject: [PATCH 53/97] renamed play file to test attitude plotting --- .idea/misc.xml | 4 + .../{play.py => test_attitude_plotting.py} | 76 ++++++------------- 2 files changed, 27 insertions(+), 53 deletions(-) rename test_attitude_code/{play.py => test_attitude_plotting.py} (59%) diff --git a/.idea/misc.xml b/.idea/misc.xml index 9b1ed25..d5642e8 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,8 @@ + + \ No newline at end of file diff --git a/test_attitude_code/play.py b/test_attitude_code/test_attitude_plotting.py similarity index 59% rename from test_attitude_code/play.py rename to test_attitude_code/test_attitude_plotting.py index c8cfe40..26e29fd 100644 --- a/test_attitude_code/play.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -7,7 +7,6 @@ import paseos from paseos.actors.spacecraft_actor import SpacecraftActor from paseos.actors.actor_builder import ActorBuilder -from paseos.attitude.reference_frame_transfer import body_to_rpy, rpy_to_eci matplotlib.use("Qt5Agg") @@ -15,22 +14,24 @@ earth = pk.planet.jpl_lp("earth") sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) -""" + + ActorBuilder.set_orbit( sat1, - position=[10000000, 1e-3, 1e-3], - velocity=[1e-3, 8000, 1e-3], + position=[10000000, 0, 0], + velocity=[0, 8000, 0], epoch=pk.epoch(0), central_body=earth, ) """ ActorBuilder.set_orbit( sat1, - position=[10000000, 0, 0], - velocity=[0, 8000, 0], + position=[10000000, 3000000, 0], + velocity=[0, 8000, 2000], epoch=pk.epoch(0), central_body=earth, ) +""" ActorBuilder.set_thermal_model( sat1, @@ -43,35 +44,21 @@ actor_emissive_area=18, actor_thermal_capacity=0.89, ) +# following used to check if model is correct. Pure rotations around pointig vector should not move the pointing vector. pure_axis_rotation = [[0.00158, 0.0, 0.0], [0.0, 0.00158, 0.0], [0.0, 0.0, 0.00158]] axes_list = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] -axis = 2 # x:0, y:1, z:2 +axis = 1 # x:0, y:1, z:2 # when i = 21 in loop, 0.00158 rad/sec will rotate 180 deg about 1 axis ActorBuilder.set_attitude_model( sat1, - #actor_initial_angular_velocity=pure_axis_rotation[axis], - #actor_pointing_vector_body=axes_list[axis]) - actor_initial_angular_velocity=[0.00158, 0.0, 0.0], - actor_pointing_vector_body=[0.0, 0.0, 1.0] + #actor_initial_angular_velocity=pure_axis_rotation[1], + #actor_pointing_vector_body=axes_list[0], + actor_initial_angular_velocity=[0.0, 0.0, 0.0], + actor_pointing_vector_body=[0.0, 0.0, 1.0], + actor_initial_attitude_in_rad=[0.0, 0.0, 0.0] ) ActorBuilder.set_disturbances(sat1,True, True) -""" -att_list = [] -for i in range(0,10): - att_list.append(sat1.attitude_in_deg()[0]) - sat1._attitude_model.update_attitude(0.1) -plt.plot(range(0,10), att_list) -plt.show() -""" -""" -x = [] -y = [] -z = [] -for i in range(100): - x.append() - -""" sim = paseos.init_sim(sat1) plt.close() # Plot current status of PASEOS and get a plotter @@ -83,47 +70,30 @@ z = [] pointing_vector = [] ratio = 100/440 -""" -for i in range(100): - sim.advance_time(440,0) - x.append(sat1.get_position(sat1.local_time)[0]) - y.append(sat1.get_position(sat1.local_time)[1]) - z.append(sat1.get_position(sat1.local_time)[2]) - print(sat1.attitude_in_deg(), i) -""" + + fig = plt.figure() ax = fig.add_subplot(111, projection='3d') -for i in range(21): +for i in range(301): pos = (sat1.get_position(sat1.local_time)) x.append(sat1.get_position(sat1.local_time)[0]) y.append(sat1.get_position(sat1.local_time)[1]) z.append(sat1.get_position(sat1.local_time)[2]) euler = sat1.attitude_in_rad() - """ - pointing_vector.append( - rpy_to_eci(body_to_rpy([0,0,1], pointing_vector_in_body), - sat1.get_position_velocity(sat1.local_time)[0], - sat1.get_position_velocity(sat1.local_time)[1])) - """ - """ - vector = rpy_to_eci(body_to_rpy([0,0,1], euler), - sat1.get_position_velocity(sat1.local_time)[0], - sat1.get_position_velocity(sat1.local_time)[1]) - """ - # vector = sat1._attitude_model.nadir_vector()*1000000 + vector = sat1.pointing_vector() - #print(vector) vector[np.isclose(vector, np.zeros(3))] = 0 - print(vector, "test test test test test") - #print(vector) + #print(vector, "test test test test test") + #print(i, sat1.attitude_in_deg()) + print(i, vector, sat1.attitude_in_deg()) vector = vector * 2e6 - # print(pos, sat1.attitude_in_deg()) + ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) sim.advance_time(100, 0) + axmin = min([min(x), min(y), min(z)])*1.1 axmax = max([max(x), max(y), max(z)])*1.1 -print(axmin, axmax) ax.axes.set_xlim3d(left=axmin, right=axmax) ax.axes.set_ylim3d(bottom=axmin, top=axmax) From 9e4b78d77ea0ac4f9889b85df237a3406fa4d4e8 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 10 Jan 2024 12:02:43 +0100 Subject: [PATCH 54/97] attitude model pull request Final commits before pull request --- paseos/attitude/attitude_model.py | 7 +++---- paseos/utils/reference_frame_transfer.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 2fa2a7a..1feb389 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -90,14 +90,14 @@ def calculate_disturbance_torque(self): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): T += calculate_magnetic_torque() - # T += np.array([0.0, 0.0001, 0.0]) test placeholder + # T += np.array([0.0, 0.0001, 0.0]) # test placeholder return T def calculate_angular_acceleration(self): """Calculate the spacecraft angular acceleration (external disturbance torques and gyroscopic accelerations)""" # todo: implement geometric model - # I = self._actor_I - I = np.array([[50, 0, 0], [0, 50, 0], [0, 0, 50]]) # placeholder + # I = self._actor_moment_of_inertia + I = np.array([[50, 0, 0], [0, 50, 0], [0, 0, 50]]) # test placeholder # Euler's equation for rigid body rotation: a = I^(-1) (T - w x (Iw)) # a = angular acceleration, I = inertia matrix, T = torque vector, w = angular velocity @@ -180,7 +180,6 @@ def update_attitude(self, dt): Args: dt (float): How far to advance the attitude computation. """ - # todo: long simulations result in sudden small change # position position = np.array(self._actor.get_position(self._actor.local_time)) diff --git a/paseos/utils/reference_frame_transfer.py b/paseos/utils/reference_frame_transfer.py index 556ff54..4522672 100644 --- a/paseos/utils/reference_frame_transfer.py +++ b/paseos/utils/reference_frame_transfer.py @@ -247,12 +247,12 @@ def get_rpy_angles(x, y, z, vectors_in_rpy_frame=True): # when pitch = +- 90 degrees(R_03 = +-1), yaw and roll have the same effect. Choose roll to be zero # (avoid dividing by zero) - if R[0][2] == -1: + if R[0][2] == -1.0: pitch = np.pi / 2 roll = 0.0 yaw = -np.arctan2(R[1][0], R[2][0]) # or yaw = -np.arctan2( - R[2][1], R[1][1]) - elif R[0][2] == 1: + elif R[0][2] == 1.0: pitch = -np.pi / 2 roll = 0.0 yaw = np.arctan2(-R[1][0], R[2][0]) From 724e9f57791c98b15b34eb7d34c40339ff096bfb Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 10 Jan 2024 12:10:09 +0100 Subject: [PATCH 55/97] Removed own test files for pr --- test_attitude_code/test.mp4 | Bin 116933 -> 0 bytes test_attitude_code/test_attitude_plotting.py | 107 ------------------- test_attitude_code/visualize_sim.py | 77 ------------- 3 files changed, 184 deletions(-) delete mode 100644 test_attitude_code/test.mp4 delete mode 100644 test_attitude_code/test_attitude_plotting.py delete mode 100644 test_attitude_code/visualize_sim.py diff --git a/test_attitude_code/test.mp4 b/test_attitude_code/test.mp4 deleted file mode 100644 index 31ac0a916cb4d64239c42efe09bf5805964dca77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 116933 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 z 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 diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py deleted file mode 100644 index 26e29fd..0000000 --- a/test_attitude_code/test_attitude_plotting.py +++ /dev/null @@ -1,107 +0,0 @@ -# We use pykep for orbit determination -import pykep as pk -import matplotlib -import matplotlib.pyplot as plt -import numpy as np - -import paseos -from paseos.actors.spacecraft_actor import SpacecraftActor -from paseos.actors.actor_builder import ActorBuilder - -matplotlib.use("Qt5Agg") - -# Define central body -earth = pk.planet.jpl_lp("earth") - -sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) - - -ActorBuilder.set_orbit( - sat1, - position=[10000000, 0, 0], - velocity=[0, 8000, 0], - epoch=pk.epoch(0), - central_body=earth, -) -""" -ActorBuilder.set_orbit( - sat1, - position=[10000000, 3000000, 0], - velocity=[0, 8000, 2000], - epoch=pk.epoch(0), - central_body=earth, -) -""" - -ActorBuilder.set_thermal_model( - sat1, - actor_mass=100, - actor_initial_temperature_in_K=270, - actor_sun_absorptance=0.5, - actor_infrared_absorptance=0.5, - actor_sun_facing_area=1, - actor_central_body_facing_area=4, - actor_emissive_area=18, - actor_thermal_capacity=0.89, -) -# following used to check if model is correct. Pure rotations around pointig vector should not move the pointing vector. -pure_axis_rotation = [[0.00158, 0.0, 0.0], [0.0, 0.00158, 0.0], [0.0, 0.0, 0.00158]] -axes_list = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] -axis = 1 # x:0, y:1, z:2 -# when i = 21 in loop, 0.00158 rad/sec will rotate 180 deg about 1 axis -ActorBuilder.set_attitude_model( - sat1, - #actor_initial_angular_velocity=pure_axis_rotation[1], - #actor_pointing_vector_body=axes_list[0], - actor_initial_angular_velocity=[0.0, 0.0, 0.0], - actor_pointing_vector_body=[0.0, 0.0, 1.0], - actor_initial_attitude_in_rad=[0.0, 0.0, 0.0] -) -ActorBuilder.set_disturbances(sat1,True, True) - -sim = paseos.init_sim(sat1) -plt.close() -# Plot current status of PASEOS and get a plotter -#%% -# Run some operations and inbetween update PASEOS -pos = [] -x = [] -y = [] -z = [] -pointing_vector = [] -ratio = 100/440 - - -fig = plt.figure() -ax = fig.add_subplot(111, projection='3d') -for i in range(301): - pos = (sat1.get_position(sat1.local_time)) - x.append(sat1.get_position(sat1.local_time)[0]) - y.append(sat1.get_position(sat1.local_time)[1]) - z.append(sat1.get_position(sat1.local_time)[2]) - - euler = sat1.attitude_in_rad() - - vector = sat1.pointing_vector() - vector[np.isclose(vector, np.zeros(3))] = 0 - #print(vector, "test test test test test") - #print(i, sat1.attitude_in_deg()) - print(i, vector, sat1.attitude_in_deg()) - vector = vector * 2e6 - - ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) - sim.advance_time(100, 0) - -axmin = min([min(x), min(y), min(z)])*1.1 -axmax = max([max(x), max(y), max(z)])*1.1 - -ax.axes.set_xlim3d(left=axmin, right=axmax) -ax.axes.set_ylim3d(bottom=axmin, top=axmax) -ax.axes.set_zlim3d(bottom=axmin, top=axmax) - -ax.set_xlabel("x") -ax.set_ylabel("y") -ax.set_zlabel("z") - -ax.plot(x,y,z) -ax.scatter(0,0,0) diff --git a/test_attitude_code/visualize_sim.py b/test_attitude_code/visualize_sim.py deleted file mode 100644 index 766c5d7..0000000 --- a/test_attitude_code/visualize_sim.py +++ /dev/null @@ -1,77 +0,0 @@ -# We use pykep for orbit determination -import pykep as pk - -import paseos -from paseos.actors.spacecraft_actor import SpacecraftActor -from paseos.actors.actor_builder import ActorBuilder -paseos.set_log_level("INFO") - -# Define central body -earth = pk.planet.jpl_lp("earth") -sat1 = ActorBuilder.get_actor_scaffold( - "sat1", SpacecraftActor, pk.epoch(0) - ) -sat2 = ActorBuilder.get_actor_scaffold( - "sat2", SpacecraftActor, pk.epoch(0) -) - -# Define local actor -sat3 = ActorBuilder.get_actor_scaffold( - "sat3", SpacecraftActor, pk.epoch(0) -) -ActorBuilder.set_orbit(sat3, [-10000000, 0.1, 0.1], [0, 8000.0, 0], pk.epoch(0), earth) -ActorBuilder.set_power_devices(sat3, 500, 10000, 1) - -sat4 = ActorBuilder.get_actor_scaffold( - "sat4", SpacecraftActor, pk.epoch(0) -) -ActorBuilder.set_orbit(sat4, [0, 10000000, 0], [0, 0, 8000.0], pk.epoch(0), earth) - - -ActorBuilder.set_orbit( - sat1, - position=[10000000, 1e-3, 1e-3], - velocity=[1e-3, 8000, 1e-3], - epoch=pk.epoch(0), - central_body=earth, -) -ActorBuilder.set_orbit( - sat2, - position=[10000000, 1e-3, 1e-3], - velocity=[1e-3, -8000, 1e-3], - epoch=pk.epoch(0), - central_body=earth, -) - -ActorBuilder.set_thermal_model( - sat1, - actor_mass=100, - actor_initial_temperature_in_K=270, - actor_sun_absorptance=0.5, - actor_infrared_absorptance=0.5, - actor_sun_facing_area=1, - actor_central_body_facing_area=4, - actor_emissive_area=18, - actor_thermal_capacity=0.89, -) - -# Add communication link -ActorBuilder.add_comm_device(sat1, device_name="link1", bandwidth_in_kbps=1) -ActorBuilder.add_comm_device(sat2, device_name="link1", bandwidth_in_kbps=2) -ActorBuilder.set_power_devices(sat1, 500, 10000, 1) -ActorBuilder.set_power_devices(sat2, 500, 10000, 1) -sim = paseos.init_sim(sat1) -sim.add_known_actor(sat2) -sim.add_known_actor(sat3) -sim.add_known_actor(sat4) - -# Plot current status of PASEOS and get a plotter -plotter = paseos.plot(sim, paseos.PlotType.SpacePlot) -#%% -# Run some operations and inbetween update PASEOS -for i in range(100): - sim.advance_time(10,0) - plotter.update(sim) -#%% -# Write an animation of the next 50 steps a 100s to a file called test.mp4 -plotter.animate(sim,dt=200,steps=100,save_to_file="test") \ No newline at end of file From 6ce55d72f9b85e0373811e18c9526d3e652ad06a Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 10 Jan 2024 13:12:26 +0100 Subject: [PATCH 56/97] Added plotting file back to branch for visually checking attitude model --- test_attitude_code/test_attitude_plotting.py | 107 +++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 test_attitude_code/test_attitude_plotting.py diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py new file mode 100644 index 0000000..36ab6a5 --- /dev/null +++ b/test_attitude_code/test_attitude_plotting.py @@ -0,0 +1,107 @@ +# We use pykep for orbit determination +import pykep as pk +import matplotlib +import matplotlib.pyplot as plt +import numpy as np + +import paseos +from paseos.actors.spacecraft_actor import SpacecraftActor +from paseos.actors.actor_builder import ActorBuilder + +matplotlib.use("Qt5Agg") + +# Define central body +earth = pk.planet.jpl_lp("earth") + +sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) + + +ActorBuilder.set_orbit( + sat1, + position=[10000000, 0, 0], + velocity=[0, 8000, 0], + epoch=pk.epoch(0), + central_body=earth, +) +""" +ActorBuilder.set_orbit( + sat1, + position=[10000000, 3000000, 0], + velocity=[0, 8000, 2000], + epoch=pk.epoch(0), + central_body=earth, +) +""" + +ActorBuilder.set_thermal_model( + sat1, + actor_mass=100, + actor_initial_temperature_in_K=270, + actor_sun_absorptance=0.5, + actor_infrared_absorptance=0.5, + actor_sun_facing_area=1, + actor_central_body_facing_area=4, + actor_emissive_area=18, + actor_thermal_capacity=0.89, +) +# following used to check if model is correct. Pure rotations around pointig vector should not move the pointing vector. +pure_axis_rotation = [[0.00158, 0.0, 0.0], [0.0, 0.00158, 0.0], [0.0, 0.0, 0.00158]] +axes_list = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] +axis = 1 # x:0, y:1, z:2 +# when i = 21 in loop, 0.00158 rad/sec will rotate 180 deg about 1 axis +ActorBuilder.set_attitude_model( + sat1, + #actor_initial_angular_velocity=pure_axis_rotation[1], + #actor_pointing_vector_body=axes_list[0], + actor_initial_angular_velocity=[0.0, 0.0, 0.0], + actor_pointing_vector_body=[0.0, 0.0, 1.0], + actor_initial_attitude_in_rad=[0.0, 0.0, 0.0] +) +ActorBuilder.set_disturbances(sat1,True, True) + +sim = paseos.init_sim(sat1) +plt.close() +# Plot current status of PASEOS and get a plotter +#%% +# Run some operations and inbetween update PASEOS +pos = [] +x = [] +y = [] +z = [] +pointing_vector = [] +ratio = 100/440 + + +fig = plt.figure() +ax = fig.add_subplot(111, projection='3d') +for i in range(41): + pos = (sat1.get_position(sat1.local_time)) + x.append(sat1.get_position(sat1.local_time)[0]) + y.append(sat1.get_position(sat1.local_time)[1]) + z.append(sat1.get_position(sat1.local_time)[2]) + + euler = sat1.attitude_in_rad() + + vector = sat1.pointing_vector() + vector[np.isclose(vector, np.zeros(3))] = 0 + #print(vector, "test test test test test") + #print(i, sat1.attitude_in_deg()) + print(i, vector, sat1.attitude_in_deg()) + vector = vector * 2e6 + + ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) + sim.advance_time(100, 0) + +axmin = min([min(x), min(y), min(z)])*1.1 +axmax = max([max(x), max(y), max(z)])*1.1 + +ax.axes.set_xlim3d(left=axmin, right=axmax) +ax.axes.set_ylim3d(bottom=axmin, top=axmax) +ax.axes.set_zlim3d(bottom=axmin, top=axmax) + +ax.set_xlabel("x") +ax.set_ylabel("y") +ax.set_zlabel("z") + +ax.plot(x,y,z) +ax.scatter(0,0,0) From 3633631184fe9f5f7574fa03dfeb2a7556b45f55 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 11 Jan 2024 22:54:45 +0100 Subject: [PATCH 57/97] implemented some feedback --- paseos/attitude/attitude_model.py | 6 +++--- paseos/attitude/disturbance_calculations.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 1feb389..0b94d9a 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -80,7 +80,7 @@ def calculate_disturbance_torque(self): """Compute total torque due to user specified disturbances Returns: - np.array([Tx, Ty, Tz]): total combined torques in Nm + np.array([Tx, Ty, Tz]): total combined torques in Nm expressed in the spacecraft body frame """ T = np.array([0.0, 0.0, 0.0]) @@ -90,13 +90,13 @@ def calculate_disturbance_torque(self): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): T += calculate_magnetic_torque() - # T += np.array([0.0, 0.0001, 0.0]) # test placeholder return T def calculate_angular_acceleration(self): """Calculate the spacecraft angular acceleration (external disturbance torques and gyroscopic accelerations)""" - # todo: implement geometric model + # TODO implement geometric model # I = self._actor_moment_of_inertia + # TODO in the future control torques could be added I = np.array([[50, 0, 0], [0, 50, 0], [0, 0, 50]]) # test placeholder # Euler's equation for rigid body rotation: a = I^(-1) (T - w x (Iw)) diff --git a/paseos/attitude/disturbance_calculations.py b/paseos/attitude/disturbance_calculations.py index 3c1e904..43f6fe2 100644 --- a/paseos/attitude/disturbance_calculations.py +++ b/paseos/attitude/disturbance_calculations.py @@ -1,7 +1,7 @@ -# this functions could be implemented inside the attitude model class but I'd rather have it -# separately right now. +"""this file contains functions that return attitude disturbance torque vectors expressed in the actor body frame""" # OUTPUT NUMPY ARRAYS +# OUTPUT IN BODY FRAME import numpy as np From db97bd75a761c531ae4c94d8a5fa1735eed0c6be Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Fri, 19 Jan 2024 18:11:51 +0100 Subject: [PATCH 58/97] Implemented geometric model, angular velocity vector is also possible output --- paseos/actors/spacecraft_actor.py | 15 ++++++-- paseos/attitude/attitude_model.py | 29 +++++++++------- test_attitude_code/test_attitude_plotting.py | 36 +++++++++++++++----- 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/paseos/actors/spacecraft_actor.py b/paseos/actors/spacecraft_actor.py index a2c6040..65c5065 100644 --- a/paseos/actors/spacecraft_actor.py +++ b/paseos/actors/spacecraft_actor.py @@ -191,9 +191,18 @@ def attitude_in_deg(self): def pointing_vector(self): - """Returns the spacecraft pointing vector + """Returns the spacecraft pointing vector in the Earth-centered inertial frame Returns: - numpy ndarray (x, y, z) + np.ndarray (x, y, z) """ - return self._attitude_model._actor_pointing_vector_eci \ No newline at end of file + return self._attitude_model._actor_pointing_vector_eci + + + def angular_velocity(self): + """Returns the spacecraft angular velocity vector in the Earth-centered inertial frame + + Returns: + np.ndarray (owega_x, omega_y, omega_z) + """ + return self._attitude_model._actor_angular_velocity_eci \ No newline at end of file diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 0b94d9a..17ac692 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -63,7 +63,11 @@ def __init__( np.array(self._actor.get_position(self._actor.local_time)), np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), ) - + self._actor_angular_velocity_eci = rpy_to_eci( + body_to_rpy(self._actor_angular_velocity, self._actor_attitude_in_rad), + np.array(self._actor.get_position(self._actor.local_time)), + np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), + ) self._first_run = True def nadir_vector(self): @@ -94,13 +98,13 @@ def calculate_disturbance_torque(self): def calculate_angular_acceleration(self): """Calculate the spacecraft angular acceleration (external disturbance torques and gyroscopic accelerations)""" - # TODO implement geometric model - # I = self._actor_moment_of_inertia # TODO in the future control torques could be added - I = np.array([[50, 0, 0], [0, 50, 0], [0, 0, 50]]) # test placeholder + + # moment of Inertia matrix: + I = self._actor._moment_of_inertia # Euler's equation for rigid body rotation: a = I^(-1) (T - w x (Iw)) - # a = angular acceleration, I = inertia matrix, T = torque vector, w = angular velocity + # with: a = angular acceleration, I = inertia matrix, T = torque vector, w = angular velocity self._actor_angular_acceleration = np.linalg.inv(I) @ ( self.calculate_disturbance_torque() - np.cross(self._actor_angular_velocity, I @ self._actor_angular_velocity) @@ -214,15 +218,16 @@ def update_attitude(self, dt): xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy, theta_2 ) - """ - xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = rotate_body_vectors( - xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy, theta_1 + theta_2 - ) - """ - - # update new attitude: + # update new attitude in ECI: self._actor_attitude_in_rad = get_rpy_angles(xb_rpy, yb_rpy, zb_rpy) + # update new angular velocity vector in ECI: + self._actor_angular_velocity_eci = rpy_to_eci( + body_to_rpy(self._actor_angular_velocity, self._actor_attitude_in_rad), + position, + velocity, + ) + # update pointing vector self._actor_pointing_vector_eci = rpy_to_eci( pointing_vector_rpy, position, velocity diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index 36ab6a5..dcc38a1 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -14,7 +14,7 @@ earth = pk.planet.jpl_lp("earth") sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) - +sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) ActorBuilder.set_orbit( sat1, @@ -32,6 +32,12 @@ central_body=earth, ) """ +ActorBuilder.set_geometric_model(sat1, mass=500) +print(sat1._moment_of_inertia) + +ActorBuilder.set_geometric_model(sat2, mass=5000) +print(sat2._moment_of_inertia) + ActorBuilder.set_thermal_model( sat1, @@ -44,7 +50,7 @@ actor_emissive_area=18, actor_thermal_capacity=0.89, ) -# following used to check if model is correct. Pure rotations around pointig vector should not move the pointing vector. +# following used to check if model is correct. Pure rotations around pointing vector should not move the pointing vector. pure_axis_rotation = [[0.00158, 0.0, 0.0], [0.0, 0.00158, 0.0], [0.0, 0.0, 0.00158]] axes_list = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] axis = 1 # x:0, y:1, z:2 @@ -53,7 +59,7 @@ sat1, #actor_initial_angular_velocity=pure_axis_rotation[1], #actor_pointing_vector_body=axes_list[0], - actor_initial_angular_velocity=[0.0, 0.0, 0.0], + actor_initial_angular_velocity=[0.0, 0.00158, 0.0], actor_pointing_vector_body=[0.0, 0.0, 1.0], actor_initial_attitude_in_rad=[0.0, 0.0, 0.0] ) @@ -74,24 +80,36 @@ fig = plt.figure() ax = fig.add_subplot(111, projection='3d') -for i in range(41): - pos = (sat1.get_position(sat1.local_time)) +for i in range(21): + + pos = sat1.get_position(sat1.local_time) x.append(sat1.get_position(sat1.local_time)[0]) y.append(sat1.get_position(sat1.local_time)[1]) z.append(sat1.get_position(sat1.local_time)[2]) euler = sat1.attitude_in_rad() + # pointing vector: vector = sat1.pointing_vector() vector[np.isclose(vector, np.zeros(3))] = 0 - #print(vector, "test test test test test") - #print(i, sat1.attitude_in_deg()) - print(i, vector, sat1.attitude_in_deg()) vector = vector * 2e6 + # angular velocity vector: + # normalize first: + ang_vel = sat1.angular_velocity() + if all(ang_vel == np.zeros(3)): + ang_vel = np.zeros(3) + else: + ang_vel = sat1.angular_velocity() / np.linalg.norm(sat1.angular_velocity()) + ang_vel[np.isclose(ang_vel, np.zeros(3))] = 0 + + ang_vel = ang_vel * 2e6 + + # print(i, vector / 2e6, sat1.attitude_in_deg(), sat1.angular_velocity() / 2e6) + + ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) sim.advance_time(100, 0) - axmin = min([min(x), min(y), min(z)])*1.1 axmax = max([max(x), max(y), max(z)])*1.1 From 0f68861cea97d44ece92845e384362cdf7b7926c Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Fri, 19 Jan 2024 18:19:02 +0100 Subject: [PATCH 59/97] visual test for attitude plotting cleaned up a bit --- test_attitude_code/test_attitude_plotting.py | 39 +++++--------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index dcc38a1..9202642 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -13,8 +13,8 @@ # Define central body earth = pk.planet.jpl_lp("earth") +# Define spacecraft actor sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) -sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) ActorBuilder.set_orbit( sat1, @@ -23,21 +23,7 @@ epoch=pk.epoch(0), central_body=earth, ) -""" -ActorBuilder.set_orbit( - sat1, - position=[10000000, 3000000, 0], - velocity=[0, 8000, 2000], - epoch=pk.epoch(0), - central_body=earth, -) -""" ActorBuilder.set_geometric_model(sat1, mass=500) -print(sat1._moment_of_inertia) - -ActorBuilder.set_geometric_model(sat2, mass=5000) -print(sat2._moment_of_inertia) - ActorBuilder.set_thermal_model( sat1, @@ -50,33 +36,25 @@ actor_emissive_area=18, actor_thermal_capacity=0.89, ) -# following used to check if model is correct. Pure rotations around pointing vector should not move the pointing vector. -pure_axis_rotation = [[0.00158, 0.0, 0.0], [0.0, 0.00158, 0.0], [0.0, 0.0, 0.00158]] -axes_list = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] -axis = 1 # x:0, y:1, z:2 + # when i = 21 in loop, 0.00158 rad/sec will rotate 180 deg about 1 axis ActorBuilder.set_attitude_model( sat1, - #actor_initial_angular_velocity=pure_axis_rotation[1], - #actor_pointing_vector_body=axes_list[0], actor_initial_angular_velocity=[0.0, 0.00158, 0.0], actor_pointing_vector_body=[0.0, 0.0, 1.0], actor_initial_attitude_in_rad=[0.0, 0.0, 0.0] ) +# disturbances: ActorBuilder.set_disturbances(sat1,True, True) sim = paseos.init_sim(sat1) plt.close() -# Plot current status of PASEOS and get a plotter -#%% -# Run some operations and inbetween update PASEOS + pos = [] x = [] y = [] z = [] pointing_vector = [] -ratio = 100/440 - fig = plt.figure() ax = fig.add_subplot(111, projection='3d') @@ -92,6 +70,7 @@ # pointing vector: vector = sat1.pointing_vector() vector[np.isclose(vector, np.zeros(3))] = 0 + # scale for plotting vector = vector * 2e6 # angular velocity vector: @@ -102,14 +81,16 @@ else: ang_vel = sat1.angular_velocity() / np.linalg.norm(sat1.angular_velocity()) ang_vel[np.isclose(ang_vel, np.zeros(3))] = 0 - + # scale for plotting ang_vel = ang_vel * 2e6 - # print(i, vector / 2e6, sat1.attitude_in_deg(), sat1.angular_velocity() / 2e6) - + # plot vectors ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) + sim.advance_time(100, 0) + +# 3D figure limits axmin = min([min(x), min(y), min(z)])*1.1 axmax = max([max(x), max(y), max(z)])*1.1 From 64b1eceba7b57297084f0d5b51bc2fd691fc8fd6 Mon Sep 17 00:00:00 2001 From: Marte <92796339+Mr-Medina@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:13:43 +0100 Subject: [PATCH 60/97] pull request #198 feedback Co-authored-by: Gabriele Meoni <70584239+GabrieleMeoni@users.noreply.github.com> --- paseos/actors/base_actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseos/actors/base_actor.py b/paseos/actors/base_actor.py index f016dcd..739f685 100644 --- a/paseos/actors/base_actor.py +++ b/paseos/actors/base_actor.py @@ -341,7 +341,7 @@ def get_position_velocity(self, epoch: pk.epoch): return pos, vel def get_disturbances(self): - """Get the user specified spacecraft attitude disturbances + """Get the user-specified spacecraft attitude disturbances. Returns: list[string]: name of disturbances From 28c55defc111aa7fa59eeeba0cb35a5b5a99f197 Mon Sep 17 00:00:00 2001 From: Marte <92796339+Mr-Medina@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:13:59 +0100 Subject: [PATCH 61/97] pull request #198 feedback Co-authored-by: Gabriele Meoni <70584239+GabrieleMeoni@users.noreply.github.com> --- paseos/actors/spacecraft_actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseos/actors/spacecraft_actor.py b/paseos/actors/spacecraft_actor.py index 65c5065..a2aaf06 100644 --- a/paseos/actors/spacecraft_actor.py +++ b/paseos/actors/spacecraft_actor.py @@ -168,7 +168,7 @@ def charge(self, duration_in_s: float): logger.debug(f"New battery level is {self.battery_level_in_Ws}") def attitude_in_rad(self): - """Returns the current attitude of the actor in radians + """Returns the current attitude of the actor in radians. Returns: list[floats]: actor attitude in radians From b645b3bd8cad2937c65bf09ec957923f1e70efe1 Mon Sep 17 00:00:00 2001 From: Marte <92796339+Mr-Medina@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:14:08 +0100 Subject: [PATCH 62/97] pull request #198 feedback Co-authored-by: Gabriele Meoni <70584239+GabrieleMeoni@users.noreply.github.com> --- paseos/actors/spacecraft_actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseos/actors/spacecraft_actor.py b/paseos/actors/spacecraft_actor.py index a2aaf06..973adad 100644 --- a/paseos/actors/spacecraft_actor.py +++ b/paseos/actors/spacecraft_actor.py @@ -171,7 +171,7 @@ def attitude_in_rad(self): """Returns the current attitude of the actor in radians. Returns: - list[floats]: actor attitude in radians + list[floats]: actor attitude in radians. """ if type(self._attitude_model._actor_attitude_in_rad) == np.ndarray: return np.ndarray.tolist(self._attitude_model._actor_attitude_in_rad) From 612b3b2fdf3542ce262d3ba7c228773a2dbeb53f Mon Sep 17 00:00:00 2001 From: Marte <92796339+Mr-Medina@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:14:44 +0100 Subject: [PATCH 63/97] pull request #198 feedback Co-authored-by: Gabriele Meoni <70584239+GabrieleMeoni@users.noreply.github.com> --- paseos/actors/spacecraft_actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseos/actors/spacecraft_actor.py b/paseos/actors/spacecraft_actor.py index 973adad..44382ab 100644 --- a/paseos/actors/spacecraft_actor.py +++ b/paseos/actors/spacecraft_actor.py @@ -179,7 +179,7 @@ def attitude_in_rad(self): return self._attitude_model._actor_attitude_in_rad def attitude_in_deg(self): - """Returns the current attitude of the actor in degrees + """Returns the current attitude of the actor in degrees. Returns: list[floats]: actor attitude in degrees From d818d3fa8ae4ad95932b280a3568acb372108099 Mon Sep 17 00:00:00 2001 From: Marte <92796339+Mr-Medina@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:31:09 +0100 Subject: [PATCH 64/97] Apply suggestions from code review Co-authored-by: Gabriele Meoni <70584239+GabrieleMeoni@users.noreply.github.com> --- paseos/actors/spacecraft_actor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/paseos/actors/spacecraft_actor.py b/paseos/actors/spacecraft_actor.py index 44382ab..c18dd13 100644 --- a/paseos/actors/spacecraft_actor.py +++ b/paseos/actors/spacecraft_actor.py @@ -182,7 +182,7 @@ def attitude_in_deg(self): """Returns the current attitude of the actor in degrees. Returns: - list[floats]: actor attitude in degrees + list[floats]: actor attitude in degrees. """ if type(self._attitude_model._actor_attitude_in_rad) == np.ndarray: return np.ndarray.tolist(self._attitude_model._actor_attitude_in_rad * 180 / np.pi) @@ -191,18 +191,18 @@ def attitude_in_deg(self): def pointing_vector(self): - """Returns the spacecraft pointing vector in the Earth-centered inertial frame + """Returns the spacecraft pointing vector in the Earth-centered inertial frame. Returns: - np.ndarray (x, y, z) + np.ndarray (x, y, z). """ return self._attitude_model._actor_pointing_vector_eci def angular_velocity(self): - """Returns the spacecraft angular velocity vector in the Earth-centered inertial frame + """Returns the spacecraft angular velocity vector in the Earth-centered inertial frame. Returns: - np.ndarray (owega_x, omega_y, omega_z) + np.ndarray (owega_x, omega_y, omega_z). """ return self._attitude_model._actor_angular_velocity_eci \ No newline at end of file From 71c3f50b287870900fcd7d67a0dde31fa86c1277 Mon Sep 17 00:00:00 2001 From: Marte <92796339+Mr-Medina@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:32:04 +0100 Subject: [PATCH 65/97] Apply suggestions from code review Co-authored-by: Gabriele Meoni <70584239+GabrieleMeoni@users.noreply.github.com> --- paseos/attitude/attitude_model.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 17ac692..aff05a7 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -21,7 +21,7 @@ class AttitudeModel: The model allows for one pointing vector to be defined in the actor body frame for visualization and possibly could be used for future antenna pointing applications. Its position in time within the Earth-centered inertial - frame is also calculated alongside the general body attitude + frame is also calculated alongside the general body attitude. The attitude calculations are based in three reference frames, refer to reference_frame_transfer.py in utils folder. """ @@ -72,7 +72,7 @@ def __init__( def nadir_vector(self): # unused but might be useful during disturbance calculations or pointing vector relative position - """compute unit vector pointing towards earth, inertial body frame + """Compute unit vector pointing towards earth, inertial body frame. Returns: np array ([x, y, z]): unit nadir vector in ECIF (Earth-centered inertial frame) @@ -81,10 +81,10 @@ def nadir_vector(self): return -u / np.linalg.norm(u) def calculate_disturbance_torque(self): - """Compute total torque due to user specified disturbances + """Compute total torque due to user specified disturbances. Returns: - np.array([Tx, Ty, Tz]): total combined torques in Nm expressed in the spacecraft body frame + np.array([Tx, Ty, Tz]): total combined torques in Nm expressed in the spacecraft body frame. """ T = np.array([0.0, 0.0, 0.0]) @@ -97,7 +97,7 @@ def calculate_disturbance_torque(self): return T def calculate_angular_acceleration(self): - """Calculate the spacecraft angular acceleration (external disturbance torques and gyroscopic accelerations)""" + """Calculate the spacecraft angular acceleration (external disturbance torques and gyroscopic accelerations).""" # TODO in the future control torques could be added # moment of Inertia matrix: @@ -112,10 +112,10 @@ def calculate_angular_acceleration(self): def body_rotation(self, dt): """Calculates the rotation vector around which the spacecraft body rotates - based on angular acceleration and velocity + based on angular acceleration and velocity. Args: - dt (float): time to advance + dt (float): time to advance. Returns: rotation vector of spacecraft body expressed in the body frame """ @@ -134,10 +134,10 @@ def body_rotation(self, dt): def frame_rotation(self, position, previous_position, velocity): """Calculate the rotation vector of the RPY frame rotation within the inertial frame. - this rotation component makes the actor body attitude stay constant w.r.t. inertial frame, + This rotation component makes the actor body attitude stay constant w.r.t. inertial frame. Args: - position (np.ndarray): actor position vector + position (np.ndarray): actor position vector. previous_position (np.ndarray): actor position vector in previous timestep velocity (np.ndarray): actor velocity vector From 7dd542bd627941db5db7859bafba78743aa6093d Mon Sep 17 00:00:00 2001 From: Marte <92796339+Mr-Medina@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:36:49 +0100 Subject: [PATCH 66/97] Apply suggestions from code review Co-authored-by: Gabriele Meoni <70584239+GabrieleMeoni@users.noreply.github.com> --- paseos/attitude/attitude_model.py | 12 ++++++------ paseos/utils/reference_frame_transfer.py | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index aff05a7..0d00dcb 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -138,10 +138,10 @@ def frame_rotation(self, position, previous_position, velocity): Args: position (np.ndarray): actor position vector. - previous_position (np.ndarray): actor position vector in previous timestep - velocity (np.ndarray): actor velocity vector + previous_position (np.ndarray): actor position vector in previous timestep. + velocity (np.ndarray): actor velocity vector. - Returns: rotation vector of RPY frame w.r.t. ECI frame expressed in the ECI frame + Returns: rotation vector of RPY frame w.r.t. ECI frame expressed in the ECI frame. """ # orbital plane normal unit vector: (p x v)/||p x v|| orbital_plane_normal = np.cross(position, velocity) / np.linalg.norm( @@ -159,7 +159,7 @@ def frame_rotation(self, position, previous_position, velocity): orbital_plane_normal * rpy_frame_rotation_angle_in_eci ) - # this rotation needs to be compensated in the rotation of the body frame, so it's attitude stays fixed + # this rotation needs to be compensated in the rotation of the body frame, so its attitude stays fixed return -eci_to_rpy(rpy_frame_rotation_vector_in_eci, position, velocity) def body_axes_in_rpy(self): @@ -167,7 +167,7 @@ def body_axes_in_rpy(self): Vectors: - x, y, z axes - user specified pointing vector - Returns: transformed vectors + Returns: transformed vectors. """ # principal axes: x = body_to_rpy(np.array([1, 0, 0]), self._actor_attitude_in_rad) @@ -179,7 +179,7 @@ def body_axes_in_rpy(self): return x, y, z, p def update_attitude(self, dt): - """Updates the actor attitude based on initial conditions and disturbance torques over time + """Updates the actor attitude based on initial conditions and disturbance torques over time. Args: dt (float): How far to advance the attitude computation. diff --git a/paseos/utils/reference_frame_transfer.py b/paseos/utils/reference_frame_transfer.py index 4522672..c92d1be 100644 --- a/paseos/utils/reference_frame_transfer.py +++ b/paseos/utils/reference_frame_transfer.py @@ -22,9 +22,9 @@ def transformation_matrix_eci_rpy(r, v): """ Creates the transformation matrix to transform a vector in the Earth-Centered Inertial Frame (ECI) to the Roll-Pitch-Yaw (RPY) reference frame of the spacecraft (variant to Gaussian reference frame, useful for attitude - disturbance modelling) + disturbance modelling). - To go from ECI to RPY, this transformation matrix is used + To go from ECI to RPY, this transformation matrix is used. To go from RPY to ECI, the inverse is used. Args: @@ -55,17 +55,17 @@ def transformation_matrix_eci_rpy(r, v): def transformation_matrix_rpy_body(euler_angles_in_rad): - """Creates the transformation matrix to transform a vector in the Roll-Pitch-Yaw (RPY) reference frame to the body + """Creates the transformation matrix to transform a vector in the Roll-Pitch-Yaw (RPY) reference frame to the body. fixed reference frame of the spacecraft. - To go from RPY to body fixed, this transformation matrix is used + To go from RPY to body fixed, this transformation matrix is used. To go from body fixed to RPY, the inverse is used. Args: - euler_angles_in_rad (np.ndarray): [roll, pitch, yaw] in radians + euler_angles_in_rad (np.ndarray): [roll, pitch, yaw] in radians. Returns: - T (np.ndarray): transformation matrix + T (np.ndarray): transformation matrix. """ roll, pitch, yaw = euler_angles_in_rad @@ -188,7 +188,7 @@ def body_to_rpy(u, euler_angles_in_rad): def rodrigues_rotation(p, angles): - """Rotates vector p around the rotation vector v in the same reference frame. Using Rodrigues' rotation formula + """Rotates vector p around the rotation vector v in the same reference frame. Using Rodrigues' rotation formula. Args: p (np.ndarray): vector to be rotated [x, y, z] From aa215739ef8727bbb7b3eebdb2779f3ffde4f22b Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 23 Jan 2024 21:36:51 +0100 Subject: [PATCH 67/97] Apply suggestion from code review --- paseos/attitude/attitude_model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 0d00dcb..51e66eb 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -132,7 +132,8 @@ def body_rotation(self, dt): body_rotation = self._actor_angular_velocity * dt return body_to_rpy(body_rotation, self._actor_attitude_in_rad) - def frame_rotation(self, position, previous_position, velocity): + @staticmethod + def frame_rotation(position, previous_position, velocity): """Calculate the rotation vector of the RPY frame rotation within the inertial frame. This rotation component makes the actor body attitude stay constant w.r.t. inertial frame. From 55b6443e2e01d9d664a0ac43af63e7923240c2e8 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 23 Jan 2024 22:10:54 +0100 Subject: [PATCH 68/97] Apply suggestion from code review --- paseos/actors/actor_builder.py | 21 +++++++++++++++++++++ paseos/attitude/attitude_model.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index 350ca88..1915e08 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -527,13 +527,25 @@ def set_disturbances( gravitational: bool = False, magnetic: bool = False, ): + """Enables the attitude disturbances to be considered in the attitude modelling for an actor. + + Args: + actor (SpacecraftActor): The actor to add to. + aerodynamic (bool): Whether to consider aerodynamic disturbances in the attitude model. Defaults to False + gravitational (bool): Whether to consider gravitational disturbances in the attitude model. Defaults to False + magnetic (bool): Whether to consider magnetic disturbances in the attitude model. Defaults to False + """ + # Create a list with user specified disturbances which are considered in the attitude modelling. disturbance_list = [] + if aerodynamic: disturbance_list.append("aerodynamic") if gravitational: disturbance_list.append("gravitational") if magnetic: disturbance_list.append("magnetic") + + # Assign disturbance list to actor. actor._disturbances = disturbance_list @staticmethod @@ -544,6 +556,15 @@ def set_attitude_model( actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0] ): + """Add an attitude model to the actor based on initial conditions: attitude (roll, pitch & yaw angles) + and angular velocity vector, modeling the evolution of the user specified pointing vector. + + Args: + actor (SpacecraftActor): Actor to model. + actor_initial_attitude_in_rad (list of floats): Actor's initial attitude. Defaults to [0.0, 0.0, 0.0]. + actor_initial_angular_velocity (list of floats): Actor's initial angular velocity Defaults to [0.0, 0.0, 0.0]. + actor_pointing_vector_body (list of floats): Actor's pointing vector. Defaults to [0.0, 0.0, 1.0]. + """ actor._attitude_model = AttitudeModel( local_actor=actor, diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 51e66eb..e1fa0ad 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -117,7 +117,7 @@ def body_rotation(self, dt): Args: dt (float): time to advance. - Returns: rotation vector of spacecraft body expressed in the body frame + Returns: rotation vector of spacecraft body expressed in the RPY frame """ # todo: check if need to skip first step # theta_2: From 50ceeac54811ccab008c220eb6c52cf102ffb065 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 24 Jan 2024 16:44:44 +0100 Subject: [PATCH 69/97] Fixed time discrepancies of model. both rotations now applied at correct timestep. --- paseos/attitude/attitude_model.py | 48 ++++++++++---------- test_attitude_code/test_attitude_plotting.py | 24 ++++++---- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index e1fa0ad..a164c7b 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -1,4 +1,5 @@ import numpy as np +import pykep as pk from paseos.attitude.disturbance_calculations import ( calculate_aero_torque, @@ -117,29 +118,28 @@ def body_rotation(self, dt): Args: dt (float): time to advance. - Returns: rotation vector of spacecraft body expressed in the RPY frame + Returns: rotation vector of spacecraft body expressed in the RPY frame. """ - # todo: check if need to skip first step - # theta_2: - - # to not have the spacecraft rotate in the first timestep: - # if self._first_run: - # body_rotation = self._actor_angular_velocity * dt - # self._actor_theta_2 = body_to_rpy(body_rotation, self._actor_attitude_in_rad) - # self._first_run = False + # Calculate angular acceleration self.calculate_angular_acceleration() + + # Add angular velocity self._actor_angular_velocity += self._actor_angular_acceleration * dt + + # Body rotation vector: body_rotation = self._actor_angular_velocity * dt + + # Return rotation vector in RPY frame return body_to_rpy(body_rotation, self._actor_attitude_in_rad) @staticmethod - def frame_rotation(position, previous_position, velocity): + def frame_rotation(position, next_position, velocity): """Calculate the rotation vector of the RPY frame rotation within the inertial frame. This rotation component makes the actor body attitude stay constant w.r.t. inertial frame. Args: position (np.ndarray): actor position vector. - previous_position (np.ndarray): actor position vector in previous timestep. + next_position (np.ndarray): actor position vector in the next timestep. velocity (np.ndarray): actor velocity vector. Returns: rotation vector of RPY frame w.r.t. ECI frame expressed in the ECI frame. @@ -151,8 +151,8 @@ def frame_rotation(position, previous_position, velocity): # rotation angle: arccos((p . p_previous) / (||p|| ||p_previous||)) rpy_frame_rotation_angle_in_eci = np.arccos( - np.linalg.multi_dot([position, previous_position]) - / (np.linalg.norm(position) * np.linalg.norm(previous_position)) + np.linalg.multi_dot([position, next_position]) + / (np.linalg.norm(position) * np.linalg.norm(next_position)) ) # assign this scalar rotation angle to the vector perpendicular to rotation plane @@ -185,25 +185,28 @@ def update_attitude(self, dt): Args: dt (float): How far to advance the attitude computation. """ + # time + t = self._actor.local_time + # position - position = np.array(self._actor.get_position(self._actor.local_time)) + position = np.array(self._actor.get_position(t)) - # previous position - previous_position = self._actor._previous_position + # next position + next_position = np.array( + self._actor.get_position(pk.epoch(t.mjd2000 + dt * pk.SEC2DAY, "mjd2000")) + ) # velocity velocity = np.array( self._actor.get_position_velocity(self._actor.local_time)[1] ) - # body axes expressed in rpy frame: (x, y, z, custom pointing vector) + # Initial body vectors expressed in rpy frame: (x, y, z, custom pointing vector) xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = self.body_axes_in_rpy() - # todo: check if possible to do both angles at once - # attitude change due to two rotations # rpy frame rotation, in inertial frame: - theta_1 = self.frame_rotation(position, previous_position, velocity) + theta_1 = self.frame_rotation(position, next_position, velocity) # body rotation due to its physical rotation theta_2 = self.body_rotation(dt) @@ -225,11 +228,10 @@ def update_attitude(self, dt): # update new angular velocity vector in ECI: self._actor_angular_velocity_eci = rpy_to_eci( body_to_rpy(self._actor_angular_velocity, self._actor_attitude_in_rad), - position, + next_position, velocity, ) - # update pointing vector self._actor_pointing_vector_eci = rpy_to_eci( - pointing_vector_rpy, position, velocity + pointing_vector_rpy, next_position, velocity ) diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index 9202642..8d93308 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -37,15 +37,16 @@ actor_thermal_capacity=0.89, ) -# when i = 21 in loop, 0.00158 rad/sec will rotate 180 deg about 1 axis +# when i = 21 in loop and advance time =100, pi/2000 rad/sec will rotate 180 deg about 1 axis ActorBuilder.set_attitude_model( sat1, - actor_initial_angular_velocity=[0.0, 0.00158, 0.0], + actor_initial_angular_velocity=[0.0, np.pi / 2000, 0.0], actor_pointing_vector_body=[0.0, 0.0, 1.0], - actor_initial_attitude_in_rad=[0.0, 0.0, 0.0] + actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], ) # disturbances: -ActorBuilder.set_disturbances(sat1,True, True) +# ActorBuilder.set_disturbances(sat1,True, True) +ActorBuilder.set_disturbances(sat1) sim = paseos.init_sim(sat1) plt.close() @@ -57,9 +58,9 @@ pointing_vector = [] fig = plt.figure() -ax = fig.add_subplot(111, projection='3d') +ax = fig.add_subplot(111, projection="3d") for i in range(21): - + print("----------", i, "----------") pos = sat1.get_position(sat1.local_time) x.append(sat1.get_position(sat1.local_time)[0]) y.append(sat1.get_position(sat1.local_time)[1]) @@ -84,15 +85,18 @@ # scale for plotting ang_vel = ang_vel * 2e6 + print("plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector/2e6) # plot vectors ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) + # sim.advance_time(100, 0) sim.advance_time(100, 0) + # 3D figure limits -axmin = min([min(x), min(y), min(z)])*1.1 -axmax = max([max(x), max(y), max(z)])*1.1 +axmin = min([min(x), min(y), min(z)]) * 1.1 +axmax = max([max(x), max(y), max(z)]) * 1.1 ax.axes.set_xlim3d(left=axmin, right=axmax) ax.axes.set_ylim3d(bottom=axmin, top=axmax) @@ -102,5 +106,5 @@ ax.set_ylabel("y") ax.set_zlabel("z") -ax.plot(x,y,z) -ax.scatter(0,0,0) +ax.plot(x, y, z) +ax.scatter(0, 0, 0) From 428a75e54126022e2e22da5406964b4c3e4efe11 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Wed, 24 Jan 2024 16:54:53 +0100 Subject: [PATCH 70/97] formatting --- paseos/actors/actor_builder.py | 4 ++-- test_attitude_code/test_attitude_plotting.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index 1915e08..c567ecf 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -532,7 +532,7 @@ def set_disturbances( Args: actor (SpacecraftActor): The actor to add to. aerodynamic (bool): Whether to consider aerodynamic disturbances in the attitude model. Defaults to False - gravitational (bool): Whether to consider gravitational disturbances in the attitude model. Defaults to False + gravitational (bool): Whether to consider gravity disturbances in the attitude model. Defaults to False magnetic (bool): Whether to consider magnetic disturbances in the attitude model. Defaults to False """ # Create a list with user specified disturbances which are considered in the attitude modelling. @@ -562,7 +562,7 @@ def set_attitude_model( Args: actor (SpacecraftActor): Actor to model. actor_initial_attitude_in_rad (list of floats): Actor's initial attitude. Defaults to [0.0, 0.0, 0.0]. - actor_initial_angular_velocity (list of floats): Actor's initial angular velocity Defaults to [0.0, 0.0, 0.0]. + actor_initial_angular_velocity (list of floats): Actor's initial angular velocity. Defaults to [0.0, 0.0, 0.0]. actor_pointing_vector_body (list of floats): Actor's pointing vector. Defaults to [0.0, 0.0, 1.0]. """ diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index 8d93308..5088d7b 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -85,7 +85,9 @@ # scale for plotting ang_vel = ang_vel * 2e6 - print("plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector/2e6) + print( + "plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6 + ) # plot vectors ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) From 9732308399885d9d500dcbae75e2d806cdbb4fad Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 25 Jan 2024 16:36:56 +0100 Subject: [PATCH 71/97] Attitude model also runs without set_disturbances (for test file) --- paseos/actors/base_actor.py | 9 +++++++++ paseos/attitude/attitude_model.py | 15 ++++++++------- test_attitude_code/test_attitude_plotting.py | 3 ++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/paseos/actors/base_actor.py b/paseos/actors/base_actor.py index 739f685..150cb5d 100644 --- a/paseos/actors/base_actor.py +++ b/paseos/actors/base_actor.py @@ -169,6 +169,15 @@ def has_attitude_model(self) -> bool: """ return hasattr(self, "_attitude_model") and self._attitude_model is not None + @property + def has_attitude_disturbances(self) -> bool: + """Returns true if actor has attitude disturbances attributed, else false. + + Returns: + bool: bool indicating presence. + """ + return hasattr(self, "_disturbances") and self._disturbances is not None + @property def mass(self) -> float: """Returns actor's mass in kg. diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index a164c7b..1b278f7 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -87,14 +87,15 @@ def calculate_disturbance_torque(self): Returns: np.array([Tx, Ty, Tz]): total combined torques in Nm expressed in the spacecraft body frame. """ - T = np.array([0.0, 0.0, 0.0]) - if "aerodynamic" in self._actor.get_disturbances(): - T += calculate_aero_torque() - if "gravitational" in self._actor.get_disturbances(): - T += calculate_grav_torque() - if "magnetic" in self._actor.get_disturbances(): - T += calculate_magnetic_torque() + + if self._actor.has_attitude_disturbances: + if "aerodynamic" in self._actor.get_disturbances(): + T += calculate_aero_torque() + if "gravitational" in self._actor.get_disturbances(): + T += calculate_grav_torque() + if "magnetic" in self._actor.get_disturbances(): + T += calculate_magnetic_torque() return T def calculate_angular_acceleration(self): diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index 5088d7b..46ed59e 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -46,7 +46,7 @@ ) # disturbances: # ActorBuilder.set_disturbances(sat1,True, True) -ActorBuilder.set_disturbances(sat1) +# ActorBuilder.set_disturbances(sat1) sim = paseos.init_sim(sat1) plt.close() @@ -110,3 +110,4 @@ ax.plot(x, y, z) ax.scatter(0, 0, 0) +plt.show() From 7b6db6d4a834355bc3428548090e613d9cdba13a Mon Sep 17 00:00:00 2001 From: Moanalengkeek <69005558+Moanalengkeek@users.noreply.github.com> Date: Thu, 25 Jan 2024 16:47:37 +0100 Subject: [PATCH 72/97] Added two tests, one for attitude model with known angular velocity, and one ensuring the attitude and thermal models don't break each other --- paseos/tests/attitude_test.py | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 paseos/tests/attitude_test.py diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py new file mode 100644 index 0000000..9225f7c --- /dev/null +++ b/paseos/tests/attitude_test.py @@ -0,0 +1,82 @@ +"""Tests to see whether the attitude and disturbance models work as intended""" + +import numpy as np +import pykep as pk +import sys + +sys.path.append("../..") +import paseos +from paseos import ActorBuilder, SpacecraftActor + + +def attitude_model_test(): + """Testing the attitude model with no disturbances and known angular velocity""" + earth = pk.planet.jpl_lp("earth") + + # Define local actor + sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) + ActorBuilder.set_orbit(sat1, [7000000, 0, 0], [0, 8000.0, 0], pk.epoch(0), earth) + ActorBuilder.set_geometric_model(sat1, mass=100) + ActorBuilder.set_attitude_model(sat1, + actor_initial_angular_velocity=[0.0, np.pi/2000, 0.0], + actor_pointing_vector_body=[0, 0, 1]) + ActorBuilder.set_disturbances(sat1) + + # Check Initial values + assert np.all(sat1._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) + assert np.all(sat1._attitude_model._actor_attitude_in_rad == [0.0, 0.0, 0.0]) + + # Initialise simulation + sim = paseos.init_sim(sat1) + + # Run simulation 20 steps + for i in range(21): + vector = sat1.pointing_vector() + sim.advance_time(100, 0) + + # Testing the simulation went as intended + assert vector[0] == 1.0 + assert np.round(sat1._attitude_model._actor_angular_velocity[1],5) == 0.00157 + assert np.round(sat1._attitude_model._actor_attitude_in_rad[1], 0) == -1 + +def attitude_thermal_model_test(): + """Testing the attitude model with no disturbances and no angular velocity, and ensuring the attitude model doesn't + break the thermal model (or vice versa)""" + earth = pk.planet.jpl_lp("earth") + + # Define local actor + sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) + ActorBuilder.set_orbit(sat1, [7000000, 0, 0], [0, 8000.0, 0], pk.epoch(0), earth) + ActorBuilder.set_geometric_model(sat1, mass=100) + ActorBuilder.set_thermal_model( + actor=sat1, + actor_mass=sat1.mass, + actor_initial_temperature_in_K=273.15, + actor_sun_absorptance=1.0, + actor_infrared_absorptance=1.0, + actor_sun_facing_area=1.0, + actor_central_body_facing_area=1.0, + actor_emissive_area=1.0, + actor_thermal_capacity=1000, + ) + ActorBuilder.set_attitude_model(sat1, + actor_pointing_vector_body=[0, 0, 1]) + + # Check Initial values + assert np.all(sat1._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) + assert np.all(sat1._attitude_model._actor_attitude_in_rad == [0.0, 0.0, 0.0]) + assert sat1.temperature_in_K == 273.15 + + # Initialise simulation + sim = paseos.init_sim(sat1) + + # Run simulation 20 steps + for i in range(21): + vector = sat1.pointing_vector() + sim.advance_time(100, 0) + + # Testing the simulation went as intended + assert vector[0] == -1.0 + assert sat1._attitude_model._actor_angular_velocity[1] == 0.0 + assert np.round(sat1._attitude_model._actor_attitude_in_rad[0], 3) == 3.142 + assert np.round(sat1.temperature_in_K,3) == 278.522 From 11eb7848dde3f11a6cdd2207fb6502fdf1d9a877 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 25 Jan 2024 23:19:51 +0100 Subject: [PATCH 73/97] setting disturbances when no disturbances used. --- paseos/tests/attitude_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index 9225f7c..6d0eac0 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -20,7 +20,6 @@ def attitude_model_test(): ActorBuilder.set_attitude_model(sat1, actor_initial_angular_velocity=[0.0, np.pi/2000, 0.0], actor_pointing_vector_body=[0, 0, 1]) - ActorBuilder.set_disturbances(sat1) # Check Initial values assert np.all(sat1._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) From 2f9969f04d6e370494937071091b7c7395cc51cf Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Thu, 25 Jan 2024 23:38:44 +0100 Subject: [PATCH 74/97] test commit for seeing if merge shows up. --- paseos/attitude/attitude_model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 1b278f7..b699550 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -95,6 +95,7 @@ def calculate_disturbance_torque(self): if "gravitational" in self._actor.get_disturbances(): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): + # add something like this: self.central_body.rotate_body() T += calculate_magnetic_torque() return T From 8b1d525c7016f138df669c64c52942dcde94130f Mon Sep 17 00:00:00 2001 From: Moanalengkeek <69005558+Moanalengkeek@users.noreply.github.com> Date: Fri, 26 Jan 2024 10:01:00 +0100 Subject: [PATCH 75/97] Added additional test to confirm conditions after 1 orbit are initial conditions --- paseos/tests/attitude_test.py | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index 9225f7c..1262a45 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -10,7 +10,9 @@ def attitude_model_test(): - """Testing the attitude model with no disturbances and known angular velocity""" + """Testing the attitude model with no disturbances and known angular velocity. + This test mainly checks that the attitude calculations are correct, but it doesn't check the reference + frame transformations""" earth = pk.planet.jpl_lp("earth") # Define local actor @@ -37,7 +39,6 @@ def attitude_model_test(): # Testing the simulation went as intended assert vector[0] == 1.0 assert np.round(sat1._attitude_model._actor_angular_velocity[1],5) == 0.00157 - assert np.round(sat1._attitude_model._actor_attitude_in_rad[1], 0) == -1 def attitude_thermal_model_test(): """Testing the attitude model with no disturbances and no angular velocity, and ensuring the attitude model doesn't @@ -80,3 +81,38 @@ def attitude_thermal_model_test(): assert sat1._attitude_model._actor_angular_velocity[1] == 0.0 assert np.round(sat1._attitude_model._actor_attitude_in_rad[0], 3) == 3.142 assert np.round(sat1.temperature_in_K,3) == 278.522 + +def attitude_and_orbit_test(): + """This test checks both the orbit calculations, as well as the attitude. + The input is a simple orbit, and the angular velocity if 2pi/period. This means the initial conditions should be + the same as the conditions after one orbit""" + + earth = pk.planet.jpl_lp("earth") + + # Define local actor + sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) + ActorBuilder.set_orbit(sat1, [7000000, 0, 0], [0, 5460.0, 0], pk.epoch(0), earth) + ActorBuilder.set_geometric_model(sat1, mass=100) + orbit_period = 2 * np.pi * np.sqrt((6371000+7000000) ** 3 / 3.986004418e14) + ActorBuilder.set_attitude_model(sat1, + actor_initial_angular_velocity=[0.0, 2*np.pi / orbit_period, 0.0], + actor_pointing_vector_body=[0, 0, 1]) + ActorBuilder.set_disturbances(sat1) + + # Check Initial values + assert np.all(sat1._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) + assert np.all(sat1._attitude_model._actor_attitude_in_rad == [0.0, 0.0, 0.0]) + vector = sat1.pointing_vector() + assert vector[0] == -1.0 + + # Initialise simulation + sim = paseos.init_sim(sat1) + + # Run simulation 10 steps + for i in range(11): + vector = sat1.pointing_vector() + sim.advance_time(orbit_period/10, 0) + + # Testing the simulation went as intended + assert sat1._attitude_model._actor_pointing_vector_body[2] == 1.0 + assert vector[0] == -1.0 From f0522e4272b6314f15bccae3b3588b707f877720 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Fri, 26 Jan 2024 17:44:00 +0100 Subject: [PATCH 76/97] added attitude tests --- paseos/tests/attitude_test.py | 91 +++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 19 deletions(-) diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index 04d438d..25ef4ca 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -11,33 +11,81 @@ def attitude_model_test(): """Testing the attitude model with no disturbances and known angular velocity. - This test mainly checks that the attitude calculations are correct, but it doesn't check the reference - frame transformations""" + One actor has orbit in Earth inertial x-y plane (equatorial) with initial velocity which rotates the actor with 180° + in 20 steps advancing time 100 seconds (pi/ (20 * 100)). + Another one has zero initial angular velocity. + This test mainly checks whether the model correctly models constant angular velocity without disturbances + """ + earth = pk.planet.jpl_lp("earth") - # Define local actor + # First actor constant angular acceleration + omega = np.pi/2000 + + # Define first local actor with angular velocity sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) ActorBuilder.set_orbit(sat1, [7000000, 0, 0], [0, 8000.0, 0], pk.epoch(0), earth) ActorBuilder.set_geometric_model(sat1, mass=100) - ActorBuilder.set_attitude_model(sat1, - actor_initial_angular_velocity=[0.0, np.pi/2000, 0.0], - actor_pointing_vector_body=[0, 0, 1]) + ActorBuilder.set_attitude_model( + sat1, + actor_initial_angular_velocity=[0.0, omega, 0.0], + actor_pointing_vector_body=[0, 0, 1], + ) + + # Define second local actor without angular velocity + sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) + ActorBuilder.set_orbit(sat2, [7000000, 0, 0], [0, 8000.0, 0], pk.epoch(0), earth) + ActorBuilder.set_geometric_model(sat2, mass=100) + ActorBuilder.set_attitude_model( + sat2, + actor_initial_angular_velocity=[0.0, 0.0, 0.0], + actor_pointing_vector_body=[0, 0, 1], + ) # Check Initial values + + # sat1 assert np.all(sat1._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) + assert np.all(sat1._attitude_model._actor_pointing_vector_eci == [-1.0, 0.0, 0.0]) assert np.all(sat1._attitude_model._actor_attitude_in_rad == [0.0, 0.0, 0.0]) + # positive angular velocity in body y direction is negative angular velocity in Earth inertial z direction: + assert np.all(sat1._attitude_model._actor_angular_velocity_eci == [0.0, 0.0, -omega]) + + # sat2 + assert np.all(sat2._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) + assert np.all(sat2._attitude_model._actor_pointing_vector_eci == [-1.0, 0.0, 0.0]) + assert np.all(sat2._attitude_model._actor_attitude_in_rad == [0.0, 0.0, 0.0]) # Initialise simulation sim = paseos.init_sim(sat1) - + sim.add_known_actor(sat2) # Run simulation 20 steps - for i in range(21): - vector = sat1.pointing_vector() + for i in range(20): sim.advance_time(100, 0) # Testing the simulation went as intended - assert vector[0] == 1.0 - assert np.round(sat1._attitude_model._actor_angular_velocity[1],5) == 0.00157 + # Pointing vector from sat1 must be rotated from [-1, 0, 0] to [1, 0, 0]: + assert np.all(np.isclose(sat1.pointing_vector(), np.array([1.0, 0.0, 0.0]))) + # Sat1 angular velocity in the body frame must stay constant: + assert np.all( + np.isclose( + sat1._attitude_model._actor_angular_velocity, + np.array([0.0, omega, 0.0]), + ) + ) + # Sat1 angular velocity in the Earth inertial frame must stay constant: + assert np.all( + np.isclose( + sat1.angular_velocity(), + np.array([0.0, 0.0, -omega]), + ) + ) + + # Pointing vector from sat2 must not be rotated. + assert np.all(sat2.pointing_vector() == np.array([-1.0, 0.0, 0.0])) + # Sat2 angular velocity in the body frame must stay zero: + assert np.all(sat2._attitude_model._actor_angular_velocity == np.array([0.0, 0.0, 0.0])) + def attitude_thermal_model_test(): """Testing the attitude model with no disturbances and no angular velocity, and ensuring the attitude model doesn't @@ -59,8 +107,7 @@ def attitude_thermal_model_test(): actor_emissive_area=1.0, actor_thermal_capacity=1000, ) - ActorBuilder.set_attitude_model(sat1, - actor_pointing_vector_body=[0, 0, 1]) + ActorBuilder.set_attitude_model(sat1, actor_pointing_vector_body=[0, 0, 1]) # Check Initial values assert np.all(sat1._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) @@ -79,7 +126,8 @@ def attitude_thermal_model_test(): assert vector[0] == -1.0 assert sat1._attitude_model._actor_angular_velocity[1] == 0.0 assert np.round(sat1._attitude_model._actor_attitude_in_rad[0], 3) == 3.142 - assert np.round(sat1.temperature_in_K,3) == 278.522 + assert np.round(sat1.temperature_in_K, 3) == 278.522 + def attitude_and_orbit_test(): """This test checks both the orbit calculations, as well as the attitude. @@ -92,10 +140,12 @@ def attitude_and_orbit_test(): sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) ActorBuilder.set_orbit(sat1, [7000000, 0, 0], [0, 5460.0, 0], pk.epoch(0), earth) ActorBuilder.set_geometric_model(sat1, mass=100) - orbit_period = 2 * np.pi * np.sqrt((6371000+7000000) ** 3 / 3.986004418e14) - ActorBuilder.set_attitude_model(sat1, - actor_initial_angular_velocity=[0.0, 2*np.pi / orbit_period, 0.0], - actor_pointing_vector_body=[0, 0, 1]) + orbit_period = 2 * np.pi * np.sqrt((6371000 + 7000000) ** 3 / 3.986004418e14) + ActorBuilder.set_attitude_model( + sat1, + actor_initial_angular_velocity=[0.0, 2 * np.pi / orbit_period, 0.0], + actor_pointing_vector_body=[0, 0, 1], + ) ActorBuilder.set_disturbances(sat1) # Check Initial values @@ -110,8 +160,11 @@ def attitude_and_orbit_test(): # Run simulation 10 steps for i in range(11): vector = sat1.pointing_vector() - sim.advance_time(orbit_period/10, 0) + sim.advance_time(orbit_period / 10, 0) # Testing the simulation went as intended assert sat1._attitude_model._actor_pointing_vector_body[2] == 1.0 assert vector[0] == -1.0 + + +attitude_model_test() From b56c0765a1fecdddf45e6de433c838da87f987b6 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 27 Jan 2024 13:17:54 +0100 Subject: [PATCH 77/97] added Earth magnetic dipole moment vector function --- paseos/actors/base_actor.py | 5 ++- paseos/attitude/attitude_model.py | 39 +++++++++++++++++++-- paseos/attitude/disturbance_calculations.py | 2 +- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/paseos/actors/base_actor.py b/paseos/actors/base_actor.py index 150cb5d..a5dc9e0 100644 --- a/paseos/actors/base_actor.py +++ b/paseos/actors/base_actor.py @@ -355,7 +355,10 @@ def get_disturbances(self): Returns: list[string]: name of disturbances """ - return self._disturbances + if self.has_attitude_disturbances: + return self._disturbances + else: + return "No disturbances" def is_in_line_of_sight( self, diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index b699550..ccaec82 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -1,5 +1,7 @@ import numpy as np import pykep as pk +from skyfield.api import wgs84, load + from paseos.attitude.disturbance_calculations import ( calculate_aero_torque, @@ -81,6 +83,38 @@ def nadir_vector(self): u = np.array(self._actor.get_position(self._actor.local_time)) return -u / np.linalg.norm(u) + def earth_magnetic_dipole_moment(self): + """Returns the Earth magnetic dipole moment vector from the northern geomagnetic pole position using skyfield + api, and actor epoch. To model the simplified Earth magnetic field as a magnetic dipole with an offset from + the Earth rotational axis, at a specific point in time. + + Pole position and dipole moment strength values from the year 2000: + Latitude: 79.6° N + Longitude: 71.6° W + Dipole moment: 7.79 x 10²² + https://wdc.kugi.kyoto-u.ac.jp/poles/polesexp.html + + (The same method used as ground station actor position determination) + + Returns: Time-dependent Earth dipole moment vector in ECI (np.ndarray): [mx, my, mz] + """ + # North geomagnetic pole location + lat = 79.6 + lon = -71.6 + + # Converting time to skyfield to use its API + t_skyfield = load.timescale().tt_jd(self._actor.local_time.jd) + + # North geomagnetic pole location on Earth surface in cartesian coordinates + dipole_north_direction = np.array( + wgs84.latlon(lat, lon).at(t_skyfield).position.m + ) + # Multiply geomagnetic pole position unit vector with dipole moment strength + magnetic_dipole_moment = ( + dipole_north_direction / np.linalg.norm(dipole_north_direction) * 7.79e22 + ) + return magnetic_dipole_moment + def calculate_disturbance_torque(self): """Compute total torque due to user specified disturbances. @@ -95,8 +129,9 @@ def calculate_disturbance_torque(self): if "gravitational" in self._actor.get_disturbances(): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): - # add something like this: self.central_body.rotate_body() - T += calculate_magnetic_torque() + # Earth magnetic dipole moment at right position + magnetic_dipole_moment = self.earth_magnetic_dipole_moment() + T += calculate_magnetic_torque(magnetic_dipole_moment) return T def calculate_angular_acceleration(self): diff --git a/paseos/attitude/disturbance_calculations.py b/paseos/attitude/disturbance_calculations.py index 43f6fe2..f2ebd34 100644 --- a/paseos/attitude/disturbance_calculations.py +++ b/paseos/attitude/disturbance_calculations.py @@ -19,7 +19,7 @@ def calculate_grav_torque(): return np.array(T) -def calculate_magnetic_torque(): +def calculate_magnetic_torque(earth_m, sat_m, pos): # calculations for torques # T must be in actor body fixed frame (to be discussed) T = [0, 0, 0] From ce745722f7ed1d6e27037f5d250fe33341c0643e Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 27 Jan 2024 13:38:07 +0100 Subject: [PATCH 78/97] Commit (unimportant or placeholder files/changes) --- paseos/attitude/attitude_model.py | 4 +++- test_attitude_code/test_attitude_plotting.py | 18 ++++-------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index ccaec82..dde97c1 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -131,7 +131,9 @@ def calculate_disturbance_torque(self): if "magnetic" in self._actor.get_disturbances(): # Earth magnetic dipole moment at right position magnetic_dipole_moment = self.earth_magnetic_dipole_moment() - T += calculate_magnetic_torque(magnetic_dipole_moment) + m_sat = "placeholder" + position = "placeholder" + T += calculate_magnetic_torque(magnetic_dipole_moment, m_sat, position) return T def calculate_angular_acceleration(self): diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index 46ed59e..09a56e2 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -25,18 +25,6 @@ ) ActorBuilder.set_geometric_model(sat1, mass=500) -ActorBuilder.set_thermal_model( - sat1, - actor_mass=100, - actor_initial_temperature_in_K=270, - actor_sun_absorptance=0.5, - actor_infrared_absorptance=0.5, - actor_sun_facing_area=1, - actor_central_body_facing_area=4, - actor_emissive_area=18, - actor_thermal_capacity=0.89, -) - # when i = 21 in loop and advance time =100, pi/2000 rad/sec will rotate 180 deg about 1 axis ActorBuilder.set_attitude_model( sat1, @@ -45,7 +33,7 @@ actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], ) # disturbances: -# ActorBuilder.set_disturbances(sat1,True, True) +ActorBuilder.set_disturbances(sat1, False, False, True) # ActorBuilder.set_disturbances(sat1) sim = paseos.init_sim(sat1) @@ -88,11 +76,12 @@ print( "plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6 ) + m = sat1._attitude_model.earth_magnetic_dipole_moment() * 1e-16 # plot vectors ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) + ax.quiver(0, 0, 0, m[0], m[1], m[2], color="g") - # sim.advance_time(100, 0) sim.advance_time(100, 0) @@ -111,3 +100,4 @@ ax.plot(x, y, z) ax.scatter(0, 0, 0) plt.show() + From ddf100658072e5e6d8589448ad517ecc9c342b63 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 27 Jan 2024 13:42:28 +0100 Subject: [PATCH 79/97] Detele file for pr --- test_attitude_code/test_attitude_plotting.py | 113 ------------------- 1 file changed, 113 deletions(-) delete mode 100644 test_attitude_code/test_attitude_plotting.py diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py deleted file mode 100644 index 46ed59e..0000000 --- a/test_attitude_code/test_attitude_plotting.py +++ /dev/null @@ -1,113 +0,0 @@ -# We use pykep for orbit determination -import pykep as pk -import matplotlib -import matplotlib.pyplot as plt -import numpy as np - -import paseos -from paseos.actors.spacecraft_actor import SpacecraftActor -from paseos.actors.actor_builder import ActorBuilder - -matplotlib.use("Qt5Agg") - -# Define central body -earth = pk.planet.jpl_lp("earth") - -# Define spacecraft actor -sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) - -ActorBuilder.set_orbit( - sat1, - position=[10000000, 0, 0], - velocity=[0, 8000, 0], - epoch=pk.epoch(0), - central_body=earth, -) -ActorBuilder.set_geometric_model(sat1, mass=500) - -ActorBuilder.set_thermal_model( - sat1, - actor_mass=100, - actor_initial_temperature_in_K=270, - actor_sun_absorptance=0.5, - actor_infrared_absorptance=0.5, - actor_sun_facing_area=1, - actor_central_body_facing_area=4, - actor_emissive_area=18, - actor_thermal_capacity=0.89, -) - -# when i = 21 in loop and advance time =100, pi/2000 rad/sec will rotate 180 deg about 1 axis -ActorBuilder.set_attitude_model( - sat1, - actor_initial_angular_velocity=[0.0, np.pi / 2000, 0.0], - actor_pointing_vector_body=[0.0, 0.0, 1.0], - actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], -) -# disturbances: -# ActorBuilder.set_disturbances(sat1,True, True) -# ActorBuilder.set_disturbances(sat1) - -sim = paseos.init_sim(sat1) -plt.close() - -pos = [] -x = [] -y = [] -z = [] -pointing_vector = [] - -fig = plt.figure() -ax = fig.add_subplot(111, projection="3d") -for i in range(21): - print("----------", i, "----------") - pos = sat1.get_position(sat1.local_time) - x.append(sat1.get_position(sat1.local_time)[0]) - y.append(sat1.get_position(sat1.local_time)[1]) - z.append(sat1.get_position(sat1.local_time)[2]) - - euler = sat1.attitude_in_rad() - - # pointing vector: - vector = sat1.pointing_vector() - vector[np.isclose(vector, np.zeros(3))] = 0 - # scale for plotting - vector = vector * 2e6 - - # angular velocity vector: - # normalize first: - ang_vel = sat1.angular_velocity() - if all(ang_vel == np.zeros(3)): - ang_vel = np.zeros(3) - else: - ang_vel = sat1.angular_velocity() / np.linalg.norm(sat1.angular_velocity()) - ang_vel[np.isclose(ang_vel, np.zeros(3))] = 0 - # scale for plotting - ang_vel = ang_vel * 2e6 - - print( - "plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6 - ) - # plot vectors - ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") - ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) - - # sim.advance_time(100, 0) - sim.advance_time(100, 0) - - -# 3D figure limits -axmin = min([min(x), min(y), min(z)]) * 1.1 -axmax = max([max(x), max(y), max(z)]) * 1.1 - -ax.axes.set_xlim3d(left=axmin, right=axmax) -ax.axes.set_ylim3d(bottom=axmin, top=axmax) -ax.axes.set_zlim3d(bottom=axmin, top=axmax) - -ax.set_xlabel("x") -ax.set_ylabel("y") -ax.set_zlabel("z") - -ax.plot(x, y, z) -ax.scatter(0, 0, 0) -plt.show() From 536f6069246c4fbabf5f0f4280c29af4e1b128ef Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 27 Jan 2024 14:26:47 +0100 Subject: [PATCH 80/97] Commenting --- paseos/attitude/attitude_model.py | 32 ++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 1b278f7..765dec3 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -36,21 +36,29 @@ class AttitudeModel: _actor_pointing_vector_body = None _actor_pointing_vector_eci = None - # _actor_prev_pos = None def __init__( self, local_actor, - # initial conditions: (defaults to 0) - actor_initial_attitude_in_rad: list[float] = [0.0, 0.0, 0.0], - actor_initial_angular_velocity: list[float] = [0.0, 0.0, 0.0], - # pointing vector in body frame: (defaults to z-axis) - actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0] - ## add args with default value = ... - # actor_dipole - # actor_drag_coefficient (more for geometric model) - # body_J2 + # initial conditions: + actor_initial_attitude_in_rad: list[float] = [0., 0., 0.], + actor_initial_angular_velocity: list[float] = [0., 0., 0.], + # pointing vector in body frame: (defaults to body z-axis) + actor_pointing_vector_body: list[float] = [0., 0., 1.] ): + """ Creates an attitude model to model actor attitude based on + initial conditions (initial attitude and angular velocity) and + external disturbance torques. + + Args: + actor (SpacecraftActor): Actor to model. + actor_initial_attitude_in_rad (list of floats): Actor's initial attitude ([roll, pitch, yaw]) angles. + Defaults to [0., 0., 0.]. + actor_initial_angular_velocity (list of floats): Actor's initial angular velocity vector. + Defaults to [0., 0., 0.]. + actor_pointing_vector_body (list of floats): User defined vector in the Actor body. Defaults to [0., 0., 1] + """ self._actor = local_actor + # convert to np.ndarray self._actor_attitude_in_rad = np.array(actor_initial_attitude_in_rad) self._actor_angular_velocity = np.array(actor_initial_angular_velocity) @@ -58,18 +66,20 @@ def __init__( self._actor_pointing_vector_body = np.array( actor_pointing_vector_body ) / np.linalg.norm(np.array(actor_pointing_vector_body)) + # pointing vector expressed in Earth-centered inertial frame self._actor_pointing_vector_eci = rpy_to_eci( body_to_rpy(self._actor_pointing_vector_body, self._actor_attitude_in_rad), np.array(self._actor.get_position(self._actor.local_time)), np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), ) + + # angular velocity vector expressed in Earth-centered inertial frame self._actor_angular_velocity_eci = rpy_to_eci( body_to_rpy(self._actor_angular_velocity, self._actor_attitude_in_rad), np.array(self._actor.get_position(self._actor.local_time)), np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), ) - self._first_run = True def nadir_vector(self): # unused but might be useful during disturbance calculations or pointing vector relative position From bdcd11b1ab89446853eb69676c6342e3d9da3b28 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 27 Jan 2024 21:27:54 +0100 Subject: [PATCH 81/97] magnetic disturbance code v1 --- paseos/attitude/attitude_model.py | 24 ++++++------ paseos/attitude/disturbance_calculations.py | 42 ++++++++++++++++++--- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index dde97c1..b3d5c6a 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -38,7 +38,7 @@ class AttitudeModel: _actor_pointing_vector_body = None _actor_pointing_vector_eci = None - # _actor_prev_pos = None + def __init__( self, local_actor, @@ -46,11 +46,8 @@ def __init__( actor_initial_attitude_in_rad: list[float] = [0.0, 0.0, 0.0], actor_initial_angular_velocity: list[float] = [0.0, 0.0, 0.0], # pointing vector in body frame: (defaults to z-axis) - actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0] - ## add args with default value = ... - # actor_dipole - # actor_drag_coefficient (more for geometric model) - # body_J2 + actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0], + actor_residual_magnetic_field: list[float] = None ): self._actor = local_actor self._actor_attitude_in_rad = np.array(actor_initial_attitude_in_rad) @@ -71,7 +68,7 @@ def __init__( np.array(self._actor.get_position(self._actor.local_time)), np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), ) - self._first_run = True + self._actor_residual_magnetic_field = np.array(actor_residual_magnetic_field) def nadir_vector(self): # unused but might be useful during disturbance calculations or pointing vector relative position @@ -91,7 +88,7 @@ def earth_magnetic_dipole_moment(self): Pole position and dipole moment strength values from the year 2000: Latitude: 79.6° N Longitude: 71.6° W - Dipole moment: 7.79 x 10²² + Dipole moment: 7.79 x 10²² Am² https://wdc.kugi.kyoto-u.ac.jp/poles/polesexp.html (The same method used as ground station actor position determination) @@ -129,11 +126,12 @@ def calculate_disturbance_torque(self): if "gravitational" in self._actor.get_disturbances(): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): - # Earth magnetic dipole moment at right position - magnetic_dipole_moment = self.earth_magnetic_dipole_moment() - m_sat = "placeholder" - position = "placeholder" - T += calculate_magnetic_torque(magnetic_dipole_moment, m_sat, position) + T += calculate_magnetic_torque( + m_earth=self.earth_magnetic_dipole_moment(), + m_sat=self._actor_residual_magnetic_field, + position=self._actor.get_position(self._actor.local_time), + velocity=self._actor.get_position_velocity(self._actor.local_time)[1], + attitude=self._actor_attitude_in_rad) return T def calculate_angular_acceleration(self): diff --git a/paseos/attitude/disturbance_calculations.py b/paseos/attitude/disturbance_calculations.py index f2ebd34..f9b9073 100644 --- a/paseos/attitude/disturbance_calculations.py +++ b/paseos/attitude/disturbance_calculations.py @@ -3,7 +3,7 @@ # OUTPUT NUMPY ARRAYS # OUTPUT IN BODY FRAME import numpy as np - +from ..utils.reference_frame_transfer import rpy_to_body, eci_to_rpy def calculate_aero_torque(): # calculations for torques @@ -19,8 +19,38 @@ def calculate_grav_torque(): return np.array(T) -def calculate_magnetic_torque(earth_m, sat_m, pos): - # calculations for torques - # T must be in actor body fixed frame (to be discussed) - T = [0, 0, 0] - return np.array(T) +def calculate_magnetic_torque(m_earth, m_sat, position, velocity, attitude): + """Calculates the external disturbance torque acting on the actor due to the magnetic field of the earth. + a dipole magnetic field flux density is described by the formula: B = μ0/(4πr³) * [3 r_hat(r_hat ⋅ m) − m] + With μ0 = 4 π 1e-7 H/m (vacuum permeability), r = actor distance from dipole center, r_hat = unit position vector, + and m the magnetic dipole moment vector of the Earth (magnitude in the year 2000 = 7.79 x 10²² Am²) + + The disturbance torque is then calculated by: T = m_sat x B + With m_sat the (residual) magnetic dipole moment vector of the actor, magnitude usually between 0.1 - 20 Am² (SMAD) + + https://en.wikipedia.org/wiki/Magnetic_dipole (or Chow, Tai L. (2006). Introduction to electromagnetic theory: + a modern perspective, used formular on p. 149) + + Args: + m_earth (np.ndarray): magnetic dipole moment vector of the Earth in Am² + m_sat (np.ndarray): magnetic dipole moment vector of the actor, magnitude usually between 0.1 - 20 Am² + position (np.ndarray): actor position + velocity (np.ndarray): actor velocity (used for frame transformation) + attitude (np.ndarray): actor velocity (used for frame transformation) + + Returns: Disturbance torque vector T (nd.array) in Nm in the actor body frame + """ + # actor distance and unit position vector + r = np.linalg.norm(position) + r_hat = position / r + + # magnetic field flux density at actor's position in Earth inertial frame + B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r ** 3) + + # transform field vector to body frame + B = rpy_to_body(eci_to_rpy(B, position, velocity), attitude) + + # disturbance torque: + T = np.cross(m_sat, B) + + return T From f64cc0eb82f70f2c21a00e6617fad520b7742e5b Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sat, 27 Jan 2024 22:43:58 +0100 Subject: [PATCH 82/97] fixed some stuff, added way to visualize B on actor position (needs to be deleted) --- paseos/actors/actor_builder.py | 7 ++++-- paseos/attitude/attitude_model.py | 24 +++++++++++++++----- paseos/attitude/disturbance_calculations.py | 14 ++++++++---- test_attitude_code/test_attitude_plotting.py | 15 ++++++++---- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/paseos/actors/actor_builder.py b/paseos/actors/actor_builder.py index c567ecf..f6b0d8c 100644 --- a/paseos/actors/actor_builder.py +++ b/paseos/actors/actor_builder.py @@ -553,7 +553,8 @@ def set_attitude_model( actor: SpacecraftActor, actor_initial_attitude_in_rad: list[float] = [0.0, 0.0, 0.0], actor_initial_angular_velocity: list[float] = [0.0, 0.0, 0.0], - actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0] + actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0], + actor_residual_magnetic_field: list[float] = [0.0, 0.0, 0.0], ): """Add an attitude model to the actor based on initial conditions: attitude (roll, pitch & yaw angles) @@ -564,6 +565,8 @@ def set_attitude_model( actor_initial_attitude_in_rad (list of floats): Actor's initial attitude. Defaults to [0.0, 0.0, 0.0]. actor_initial_angular_velocity (list of floats): Actor's initial angular velocity. Defaults to [0.0, 0.0, 0.0]. actor_pointing_vector_body (list of floats): Actor's pointing vector. Defaults to [0.0, 0.0, 1.0]. + actor_residual_magnetic_field (list of floats): Actor's residual magnetic dipole moment vector. + Defaults to [0.0, 0.0, 0.0]. """ actor._attitude_model = AttitudeModel( @@ -571,7 +574,7 @@ def set_attitude_model( actor_initial_attitude_in_rad=actor_initial_attitude_in_rad, actor_initial_angular_velocity=actor_initial_angular_velocity, actor_pointing_vector_body=actor_pointing_vector_body, - + actor_residual_magnetic_field=actor_residual_magnetic_field, ) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index b3d5c6a..46ade23 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -38,7 +38,6 @@ class AttitudeModel: _actor_pointing_vector_body = None _actor_pointing_vector_eci = None - def __init__( self, local_actor, @@ -47,7 +46,7 @@ def __init__( actor_initial_angular_velocity: list[float] = [0.0, 0.0, 0.0], # pointing vector in body frame: (defaults to z-axis) actor_pointing_vector_body: list[float] = [0.0, 0.0, 1.0], - actor_residual_magnetic_field: list[float] = None + actor_residual_magnetic_field: list[float] = [0.0, 0.0, 0.0], ): self._actor = local_actor self._actor_attitude_in_rad = np.array(actor_initial_attitude_in_rad) @@ -69,6 +68,7 @@ def __init__( np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), ) self._actor_residual_magnetic_field = np.array(actor_residual_magnetic_field) + self._actor_magnetic_flux = np.array([0, 0, 0]) def nadir_vector(self): # unused but might be useful during disturbance calculations or pointing vector relative position @@ -119,6 +119,12 @@ def calculate_disturbance_torque(self): np.array([Tx, Ty, Tz]): total combined torques in Nm expressed in the spacecraft body frame. """ T = np.array([0.0, 0.0, 0.0]) + dt = 10 + # time + t = self._actor.local_time + next_position = np.array( + self._actor.get_position(pk.epoch(t.mjd2000 + dt * pk.SEC2DAY, "mjd2000")) + ) if self._actor.has_attitude_disturbances: if "aerodynamic" in self._actor.get_disturbances(): @@ -126,12 +132,18 @@ def calculate_disturbance_torque(self): if "gravitational" in self._actor.get_disturbances(): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): - T += calculate_magnetic_torque( + t, b = calculate_magnetic_torque( m_earth=self.earth_magnetic_dipole_moment(), m_sat=self._actor_residual_magnetic_field, - position=self._actor.get_position(self._actor.local_time), - velocity=self._actor.get_position_velocity(self._actor.local_time)[1], - attitude=self._actor_attitude_in_rad) + # position=self._actor.get_position(self._actor.local_time), + position=next_position, + velocity=self._actor.get_position_velocity(self._actor.local_time)[ + 1 + ], + attitude=self._actor_attitude_in_rad, + ) + T += t + self._actor_magnetic_flux = b return T def calculate_angular_acceleration(self): diff --git a/paseos/attitude/disturbance_calculations.py b/paseos/attitude/disturbance_calculations.py index f9b9073..4726d44 100644 --- a/paseos/attitude/disturbance_calculations.py +++ b/paseos/attitude/disturbance_calculations.py @@ -34,23 +34,27 @@ def calculate_magnetic_torque(m_earth, m_sat, position, velocity, attitude): Args: m_earth (np.ndarray): magnetic dipole moment vector of the Earth in Am² m_sat (np.ndarray): magnetic dipole moment vector of the actor, magnitude usually between 0.1 - 20 Am² - position (np.ndarray): actor position - velocity (np.ndarray): actor velocity (used for frame transformation) + position (tuple or np.ndarray): actor position + velocity (tuple or np.ndarray): actor velocity (used for frame transformation) attitude (np.ndarray): actor velocity (used for frame transformation) Returns: Disturbance torque vector T (nd.array) in Nm in the actor body frame """ + # convert to np.ndarray + position = np.array(position) + velocity = np.array(velocity) + # actor distance and unit position vector r = np.linalg.norm(position) r_hat = position / r # magnetic field flux density at actor's position in Earth inertial frame B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r ** 3) - + B_test = B # transform field vector to body frame B = rpy_to_body(eci_to_rpy(B, position, velocity), attitude) # disturbance torque: T = np.cross(m_sat, B) - - return T + print("B ", B, " x m ", m_sat, " = ", T) + return T, B_test diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index 09a56e2..90bdcf7 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -15,11 +15,14 @@ # Define spacecraft actor sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) +R = 3000000+6371000 +theta = 11*np.pi/180 ActorBuilder.set_orbit( sat1, - position=[10000000, 0, 0], - velocity=[0, 8000, 0], + position=[R * np.cos(theta), 0, -R * np.sin(theta)], + #velocity=[0, 8000, 0], + velocity=[0, 6519.49, 0], epoch=pk.epoch(0), central_body=earth, ) @@ -28,9 +31,10 @@ # when i = 21 in loop and advance time =100, pi/2000 rad/sec will rotate 180 deg about 1 axis ActorBuilder.set_attitude_model( sat1, - actor_initial_angular_velocity=[0.0, np.pi / 2000, 0.0], + actor_initial_angular_velocity=[0.0, 0 * np.pi / 2000, 0.0], actor_pointing_vector_body=[0.0, 0.0, 1.0], actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], + actor_residual_magnetic_field=[0.0, 0.0, 0.05], ) # disturbances: ActorBuilder.set_disturbances(sat1, False, False, True) @@ -77,12 +81,14 @@ "plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6 ) m = sat1._attitude_model.earth_magnetic_dipole_moment() * 1e-16 + B = sat1._attitude_model._actor_magnetic_flux * 1e12 # plot vectors ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) ax.quiver(0, 0, 0, m[0], m[1], m[2], color="g") + ax.quiver(pos[0], pos[1], pos[2], B[0], B[1], B[2], color="y") - sim.advance_time(100, 0) + sim.advance_time(300, 0) # 3D figure limits @@ -100,4 +106,3 @@ ax.plot(x, y, z) ax.scatter(0, 0, 0) plt.show() - From 30d108f80ffa72a9fa0ceb0ac04f2078fd9d0f51 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sun, 28 Jan 2024 18:46:42 +0100 Subject: [PATCH 83/97] next position in disturbance calculations and cleanup --- paseos/attitude/attitude_model.py | 13 ++++--------- paseos/attitude/disturbance_calculations.py | 11 ++++++----- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 46ade23..abf9fdf 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -68,7 +68,6 @@ def __init__( np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), ) self._actor_residual_magnetic_field = np.array(actor_residual_magnetic_field) - self._actor_magnetic_flux = np.array([0, 0, 0]) def nadir_vector(self): # unused but might be useful during disturbance calculations or pointing vector relative position @@ -125,6 +124,7 @@ def calculate_disturbance_torque(self): next_position = np.array( self._actor.get_position(pk.epoch(t.mjd2000 + dt * pk.SEC2DAY, "mjd2000")) ) + position = np.array(self._actor.get_position(t)) if self._actor.has_attitude_disturbances: if "aerodynamic" in self._actor.get_disturbances(): @@ -132,18 +132,13 @@ def calculate_disturbance_torque(self): if "gravitational" in self._actor.get_disturbances(): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): - t, b = calculate_magnetic_torque( + T += calculate_magnetic_torque( m_earth=self.earth_magnetic_dipole_moment(), m_sat=self._actor_residual_magnetic_field, - # position=self._actor.get_position(self._actor.local_time), position=next_position, - velocity=self._actor.get_position_velocity(self._actor.local_time)[ - 1 - ], + velocity=self._actor.get_position_velocity(self._actor.local_time)[1], attitude=self._actor_attitude_in_rad, ) - T += t - self._actor_magnetic_flux = b return T def calculate_angular_acceleration(self): @@ -151,7 +146,7 @@ def calculate_angular_acceleration(self): # TODO in the future control torques could be added # moment of Inertia matrix: - I = self._actor._moment_of_inertia + I = self._actor._moment_of_inertia() # Euler's equation for rigid body rotation: a = I^(-1) (T - w x (Iw)) # with: a = angular acceleration, I = inertia matrix, T = torque vector, w = angular velocity diff --git a/paseos/attitude/disturbance_calculations.py b/paseos/attitude/disturbance_calculations.py index 4726d44..7e52795 100644 --- a/paseos/attitude/disturbance_calculations.py +++ b/paseos/attitude/disturbance_calculations.py @@ -5,6 +5,7 @@ import numpy as np from ..utils.reference_frame_transfer import rpy_to_body, eci_to_rpy + def calculate_aero_torque(): # calculations for torques # T must be in actor body fixed frame (to be discussed) @@ -44,17 +45,17 @@ def calculate_magnetic_torque(m_earth, m_sat, position, velocity, attitude): position = np.array(position) velocity = np.array(velocity) - # actor distance and unit position vector + # actor distance r = np.linalg.norm(position) + # actor unit position vector r_hat = position / r # magnetic field flux density at actor's position in Earth inertial frame - B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r ** 3) - B_test = B + B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r**3) + # transform field vector to body frame B = rpy_to_body(eci_to_rpy(B, position, velocity), attitude) # disturbance torque: T = np.cross(m_sat, B) - print("B ", B, " x m ", m_sat, " = ", T) - return T, B_test + return T From e95504bb484a993f0972d4aa54fb359394aa0a89 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sun, 28 Jan 2024 18:49:37 +0100 Subject: [PATCH 84/97] commit to save, but won't work (intentional) --- test_attitude_code/test_attitude_plotting.py | 83 ++++++++++++++------ 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index 90bdcf7..4168231 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -15,32 +15,60 @@ # Define spacecraft actor sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) -R = 3000000+6371000 -theta = 11*np.pi/180 +sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) +# geostationary orbit: + +lat = 79.6 * np.pi / 180 +lon = -71.6 * np.pi / 180 + +R = 6371000 + 35786000 +v = 3074.66 + +arr = np.array([-1.24217547e-09, 1.01735657e-07, -3.87201400e-08]) +initial_magn = np.ndarray.tolist(arr / np.linalg.norm(arr) * 20) +initial_pointing = np.ndarray.tolist(arr / np.linalg.norm(arr)) ActorBuilder.set_orbit( sat1, - position=[R * np.cos(theta), 0, -R * np.sin(theta)], - #velocity=[0, 8000, 0], - velocity=[0, 6519.49, 0], + position=[R * np.cos(np.pi / 2 + lon), R * np.sin(np.pi / 2 + lon), 0], + velocity=[-v * np.sin(np.pi / 2 + lon), v * np.cos(np.pi / 2 + lon), 0], + epoch=pk.epoch(0), + central_body=earth, +) +ActorBuilder.set_orbit( + sat2, + position=[R * np.cos(np.pi / 2 + lon), R * np.sin(np.pi / 2 + lon), 0], + velocity=[-v * np.sin(np.pi / 2 + lon), v * np.cos(np.pi / 2 + lon), 0], epoch=pk.epoch(0), central_body=earth, ) -ActorBuilder.set_geometric_model(sat1, mass=500) + +ActorBuilder.set_geometric_model(sat1, mass=100) +ActorBuilder.set_geometric_model(sat2, mass=100) # when i = 21 in loop and advance time =100, pi/2000 rad/sec will rotate 180 deg about 1 axis ActorBuilder.set_attitude_model( sat1, - actor_initial_angular_velocity=[0.0, 0 * np.pi / 2000, 0.0], - actor_pointing_vector_body=[0.0, 0.0, 1.0], + actor_initial_angular_velocity=[0.0, 0.0, 0.0], + actor_pointing_vector_body=initial_pointing, actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], - actor_residual_magnetic_field=[0.0, 0.0, 0.05], + actor_residual_magnetic_field=initial_magn, ) +ActorBuilder.set_attitude_model( + sat2, + actor_initial_angular_velocity=[0.0, 0.0, 0.0], + actor_pointing_vector_body=initial_pointing, + actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], + actor_residual_magnetic_field=[0.0, 0.0, 0.0], +) + # disturbances: -ActorBuilder.set_disturbances(sat1, False, False, True) -# ActorBuilder.set_disturbances(sat1) +ActorBuilder.set_disturbances(sat1, magnetic=True) +ActorBuilder.set_disturbances(sat2, magnetic=True) + sim = paseos.init_sim(sat1) +sim.add_known_actor(sat2) plt.close() pos = [] @@ -51,7 +79,7 @@ fig = plt.figure() ax = fig.add_subplot(111, projection="3d") -for i in range(21): +for i in range(46): print("----------", i, "----------") pos = sat1.get_position(sat1.local_time) x.append(sat1.get_position(sat1.local_time)[0]) @@ -64,7 +92,13 @@ vector = sat1.pointing_vector() vector[np.isclose(vector, np.zeros(3))] = 0 # scale for plotting - vector = vector * 2e6 + vector = vector * 8e6 + + # pointing vector: + vector2 = sat2.pointing_vector() + vector2[np.isclose(vector2, np.zeros(3))] = 0 + # scale for plotting + vector2 = vector2 * 8e6 # angular velocity vector: # normalize first: @@ -75,20 +109,23 @@ ang_vel = sat1.angular_velocity() / np.linalg.norm(sat1.angular_velocity()) ang_vel[np.isclose(ang_vel, np.zeros(3))] = 0 # scale for plotting - ang_vel = ang_vel * 2e6 + ang_vel = ang_vel * 2e7 + + # print("plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6) - print( - "plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6 - ) - m = sat1._attitude_model.earth_magnetic_dipole_moment() * 1e-16 - B = sat1._attitude_model._actor_magnetic_flux * 1e12 + m = sat1._attitude_model.earth_magnetic_dipole_moment() * 6e-16 + B = sat1._attitude_model._actor_magnetic_flux * 2e14 + print(sat1._attitude_model._actor_magnetic_flux) # plot vectors - ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") - ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2]) - ax.quiver(0, 0, 0, m[0], m[1], m[2], color="g") + # ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") + ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2], linewidths=3, color="r") + ax.quiver(pos[0], pos[1], pos[2], vector2[0], vector2[1], vector2[2], linewidths=3, color="m") + if not i % 10: + ax.quiver(0, 0, 0, m[0], m[1], m[2], color="y") ax.quiver(pos[0], pos[1], pos[2], B[0], B[1], B[2], color="y") - sim.advance_time(300, 0) + sim.advance_time(1000, 0) + # sim.advance_time(3446, 0) # 3D figure limits From ac935ccf6efe13d9bf6ca32a15b355b2e656826c Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Sun, 28 Jan 2024 18:51:08 +0100 Subject: [PATCH 85/97] attitude model bug with geometric model interaction fixed --- paseos/attitude/attitude_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 765dec3..ab3e98f 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -113,7 +113,7 @@ def calculate_angular_acceleration(self): # TODO in the future control torques could be added # moment of Inertia matrix: - I = self._actor._moment_of_inertia + I = self._actor._moment_of_inertia() # Euler's equation for rigid body rotation: a = I^(-1) (T - w x (Iw)) # with: a = angular acceleration, I = inertia matrix, T = torque vector, w = angular velocity From 437b8d914616be49ca177ca1605331738bf77fcd Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 15:13:06 +0100 Subject: [PATCH 86/97] test for magnetic disturbance --- paseos/tests/attitude_test.py | 141 ++++++++++++++++++- test_attitude_code/test_attitude_plotting.py | 23 ++- 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index 25ef4ca..37acb28 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -7,6 +7,7 @@ sys.path.append("../..") import paseos from paseos import ActorBuilder, SpacecraftActor +from paseos.utils.reference_frame_transfer import eci_to_rpy, rpy_to_body def attitude_model_test(): @@ -20,7 +21,7 @@ def attitude_model_test(): earth = pk.planet.jpl_lp("earth") # First actor constant angular acceleration - omega = np.pi/2000 + omega = np.pi / 2000 # Define first local actor with angular velocity sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) @@ -49,7 +50,9 @@ def attitude_model_test(): assert np.all(sat1._attitude_model._actor_pointing_vector_eci == [-1.0, 0.0, 0.0]) assert np.all(sat1._attitude_model._actor_attitude_in_rad == [0.0, 0.0, 0.0]) # positive angular velocity in body y direction is negative angular velocity in Earth inertial z direction: - assert np.all(sat1._attitude_model._actor_angular_velocity_eci == [0.0, 0.0, -omega]) + assert np.all( + sat1._attitude_model._actor_angular_velocity_eci == [0.0, 0.0, -omega] + ) # sat2 assert np.all(sat2._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) @@ -84,7 +87,9 @@ def attitude_model_test(): # Pointing vector from sat2 must not be rotated. assert np.all(sat2.pointing_vector() == np.array([-1.0, 0.0, 0.0])) # Sat2 angular velocity in the body frame must stay zero: - assert np.all(sat2._attitude_model._actor_angular_velocity == np.array([0.0, 0.0, 0.0])) + assert np.all( + sat2._attitude_model._actor_angular_velocity == np.array([0.0, 0.0, 0.0]) + ) def attitude_thermal_model_test(): @@ -167,4 +172,132 @@ def attitude_and_orbit_test(): assert vector[0] == -1.0 -attitude_model_test() +def magnetic_disturbance_test(): + """Tests the magnetic disturbance torques applied in the attitude model. + First: put two spacecraft actors in a geostationary orbit (disregarding the relative magnetic field rotation of the + Earth). Both actor's own magnetic dipole moment aligned with the local magnetic flux density vector of the Earth + magnetic field. One is non-magnetic and is expected to have a fixed attitude in the Earth inertial frame. + The other (magnetic) actor should stay aligned with the Earth magnetic field. + """ + # Define central body + earth = pk.planet.jpl_lp("earth") + + # Define spacecraft actors + sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) + sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) + + # geostationary orbital parameters: + r = 6371000 + 35786000 # radius [km] + v = 3074.66 # velocity [m/s] + + # To have a more symmetric case, let the actors be on same longitude as Earth magnetic dipole vector + longitude = -71.6 * np.pi / 180 + + # set orbits: + ActorBuilder.set_orbit( + sat1, + position=[ + r * np.cos(np.pi / 2 + longitude), + r * np.sin(np.pi / 2 + longitude), + 0, + ], + velocity=[ + -v * np.sin(np.pi / 2 + longitude), + v * np.cos(np.pi / 2 + longitude), + 0, + ], + epoch=pk.epoch(0), + central_body=earth, + ) + ActorBuilder.set_orbit( + sat2, + position=[ + r * np.cos(np.pi / 2 + longitude), + r * np.sin(np.pi / 2 + longitude), + 0, + ], + velocity=[ + -v * np.sin(np.pi / 2 + longitude), + v * np.cos(np.pi / 2 + longitude), + 0, + ], + epoch=pk.epoch(0), + central_body=earth, + ) + print([ + r * np.cos(np.pi / 2 + longitude), + r * np.sin(np.pi / 2 + longitude), + 0, + ],) + # set geometric model + ActorBuilder.set_geometric_model(sat1, mass=100) + ActorBuilder.set_geometric_model(sat2, mass=100) + + # now, align the body magnetic dipole with the local Earth magnetic flux density vector + # Earth magnetic flux density vector at start position is approximately: + + B = np.array([-3.18159529e-09, 1.02244882e-07, -3.72362170e-08]) + + B_direction = B / np.linalg.norm(B) + # define a very large dipole moment for magnetic actor to compensate for the low magnetic field at GEO orbit + m_body = 500 # Am² + actor_dipole = np.ndarray.tolist(B_direction * m_body) + initial_pointing_vector_body = np.ndarray.tolist(B_direction) + + # set attitude models + ActorBuilder.set_attitude_model( + sat1, + actor_initial_angular_velocity=[0.0, 0.0, 0.0], + actor_pointing_vector_body=initial_pointing_vector_body, + actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], + actor_residual_magnetic_field=actor_dipole, + ) + + ActorBuilder.set_attitude_model( + sat2, + actor_initial_angular_velocity=[0.0, 0.0, 0.0], + actor_pointing_vector_body=initial_pointing_vector_body, + actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], + actor_residual_magnetic_field=[0.0, 0.0, 0.0], + ) + + # disturbances: + ActorBuilder.set_disturbances(sat1, magnetic=True) + ActorBuilder.set_disturbances(sat2, magnetic=True) + + # Initial pointing vector in Earth inertial frame + initial_pointing_vector_eci = np.array(sat1.pointing_vector()) + # Check if pointing vectors in Earth inertial frame are equal + assert np.all(sat1.pointing_vector() == sat2.pointing_vector()) + + # start simulation + sim = paseos.init_sim(sat1) + sim.add_known_actor(sat2) + + for i in range(20): + sim.advance_time(200, 0) + + # get Earth B vector at timestep + # Earth magnetic dipole moment: + m_earth = sat1._attitude_model.earth_magnetic_dipole_moment() + # parameters to calculate local B vector: + actor_position = np.array(sat1.get_position(sat1.local_time)) + r = np.linalg.norm(actor_position) + r_hat = actor_position / r + # local B vector: + B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r**3) + + # B vector direction: + B_direction = B / np.linalg.norm(B) + + # angle between the B vector and the actor's magnetic dipole vector (which is in the pointing vector direction): + angle = np.arccos(np.dot(B_direction, sat1.pointing_vector())) * 180/np.pi + + # check if the magnetic actor dipole moment vector doesn't deviate more than 1° from the B vector. + assert angle < 1 + + # check if the non-magnetic actor didn't rotate + assert np.all(sat2.pointing_vector() == initial_pointing_vector_eci) + + +magnetic_disturbance_test() diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index 4168231..d6ef9b7 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -7,6 +7,7 @@ import paseos from paseos.actors.spacecraft_actor import SpacecraftActor from paseos.actors.actor_builder import ActorBuilder +from paseos.utils.reference_frame_transfer import rpy_to_body, eci_to_rpy matplotlib.use("Qt5Agg") @@ -24,8 +25,9 @@ R = 6371000 + 35786000 v = 3074.66 -arr = np.array([-1.24217547e-09, 1.01735657e-07, -3.87201400e-08]) -initial_magn = np.ndarray.tolist(arr / np.linalg.norm(arr) * 20) +#arr = np.array([-1.24217547e-09, 1.01735657e-07, -3.87201400e-08]) +arr = np.array([-3.18159529e-09, 1.02244882e-07, -3.72362170e-08]) +initial_magn = np.ndarray.tolist(arr / np.linalg.norm(arr) * 1000) initial_pointing = np.ndarray.tolist(arr / np.linalg.norm(arr)) ActorBuilder.set_orbit( @@ -46,7 +48,6 @@ ActorBuilder.set_geometric_model(sat1, mass=100) ActorBuilder.set_geometric_model(sat2, mass=100) -# when i = 21 in loop and advance time =100, pi/2000 rad/sec will rotate 180 deg about 1 axis ActorBuilder.set_attitude_model( sat1, actor_initial_angular_velocity=[0.0, 0.0, 0.0], @@ -114,8 +115,18 @@ # print("plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6) m = sat1._attitude_model.earth_magnetic_dipole_moment() * 6e-16 - B = sat1._attitude_model._actor_magnetic_flux * 2e14 - print(sat1._attitude_model._actor_magnetic_flux) + + # get new Earth B vector + m_earth = sat1._attitude_model.earth_magnetic_dipole_moment() + actor_position = np.array(sat1.get_position(sat1.local_time)) + actor_velocity = np.array(sat1.get_position_velocity(sat1.local_time)[1]) + r = np.linalg.norm(actor_position) + r_hat = actor_position / r + + B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r**3) + #B = rpy_to_body(eci_to_rpy(B, actor_position, actor_velocity), sat1.attitude_in_rad()) + + B = B * 2e14 # plot vectors # ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2], linewidths=3, color="r") @@ -125,8 +136,6 @@ ax.quiver(pos[0], pos[1], pos[2], B[0], B[1], B[2], color="y") sim.advance_time(1000, 0) - # sim.advance_time(3446, 0) - # 3D figure limits axmin = min([min(x), min(y), min(z)]) * 1.1 From ef25a18c0cddd8672c432606f5e70d460b8d41c6 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 17:59:33 +0100 Subject: [PATCH 87/97] test for magnetic disturbance finished --- paseos/tests/attitude_test.py | 95 +++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 32 deletions(-) diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index 37acb28..dd1982f 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -179,21 +179,57 @@ def magnetic_disturbance_test(): magnetic field. One is non-magnetic and is expected to have a fixed attitude in the Earth inertial frame. The other (magnetic) actor should stay aligned with the Earth magnetic field. """ + + def flux_density_vector(actor, frame="eci"): + """Function to calculate the local magnetic field flux density vector (B) at the actor's location. + B vector is calculated multiple times. Use of this function for clarity. + + Args: + actor (SpacecraftActor): Spacecraft actor + frame (string): B expressed in which frame (actor body frame or Earth-centered inertial frame) + + Returns: B vector (np.ndarray) + """ + # get Earth B vector at specific timestep + + # Earth magnetic dipole moment: + m_earth = actor._attitude_model.earth_magnetic_dipole_moment() + + # parameters to calculate local B vector: + actor_position = np.array(actor.get_position(actor.local_time)) + r = np.linalg.norm(actor_position) + r_hat = actor_position / r + + # local B vector: + B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r**3) + if frame == "eci": + # return B vector in ECI frame + return B + elif frame == "body": + # transform B vector to the actor body frame and return + actor_velocity = np.array(actor.get_position_velocity(actor.local_time)[1]) + actor_attitude = np.array(actor.attitude_in_rad()) + return rpy_to_body( + eci_to_rpy(B, actor_position, actor_velocity), actor_attitude + ) + # Define central body earth = pk.planet.jpl_lp("earth") # Define spacecraft actors + # magnetic: sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) + # non-magnetic: sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) - # geostationary orbital parameters: + # Geostationary orbital parameters: r = 6371000 + 35786000 # radius [km] v = 3074.66 # velocity [m/s] # To have a more symmetric case, let the actors be on same longitude as Earth magnetic dipole vector longitude = -71.6 * np.pi / 180 - # set orbits: + # Set orbits: ActorBuilder.set_orbit( sat1, position=[ @@ -224,33 +260,29 @@ def magnetic_disturbance_test(): epoch=pk.epoch(0), central_body=earth, ) - print([ - r * np.cos(np.pi / 2 + longitude), - r * np.sin(np.pi / 2 + longitude), - 0, - ],) - # set geometric model + + # Set geometric model ActorBuilder.set_geometric_model(sat1, mass=100) ActorBuilder.set_geometric_model(sat2, mass=100) - # now, align the body magnetic dipole with the local Earth magnetic flux density vector + # Now, align the body magnetic dipole with the local Earth magnetic flux density vector # Earth magnetic flux density vector at start position is approximately: + B0 = np.array([-3.18159529e-09, 1.02244882e-07, -3.72362170e-08]) - B = np.array([-3.18159529e-09, 1.02244882e-07, -3.72362170e-08]) + B_direction = B0 / np.linalg.norm(B0) - B_direction = B / np.linalg.norm(B) - # define a very large dipole moment for magnetic actor to compensate for the low magnetic field at GEO orbit + # Define a very large dipole moment for magnetic actor to compensate for the low magnetic field at GEO orbit m_body = 500 # Am² actor_dipole = np.ndarray.tolist(B_direction * m_body) initial_pointing_vector_body = np.ndarray.tolist(B_direction) - # set attitude models + # Set attitude models ActorBuilder.set_attitude_model( sat1, actor_initial_angular_velocity=[0.0, 0.0, 0.0], actor_pointing_vector_body=initial_pointing_vector_body, actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], - actor_residual_magnetic_field=actor_dipole, + actor_residual_magnetic_field=actor_dipole, # magnetic ) ActorBuilder.set_attitude_model( @@ -258,10 +290,10 @@ def magnetic_disturbance_test(): actor_initial_angular_velocity=[0.0, 0.0, 0.0], actor_pointing_vector_body=initial_pointing_vector_body, actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], - actor_residual_magnetic_field=[0.0, 0.0, 0.0], + actor_residual_magnetic_field=[0.0, 0.0, 0.0], # non-magnetic ) - # disturbances: + # Disturbances: ActorBuilder.set_disturbances(sat1, magnetic=True) ActorBuilder.set_disturbances(sat2, magnetic=True) @@ -270,33 +302,32 @@ def magnetic_disturbance_test(): # Check if pointing vectors in Earth inertial frame are equal assert np.all(sat1.pointing_vector() == sat2.pointing_vector()) - # start simulation + # Start simulation sim = paseos.init_sim(sat1) sim.add_known_actor(sat2) + # Check if B0, used to define the actors' magnetic field orientations is the initial B vector orientation in sim. + assert np.all(np.isclose(B0, flux_density_vector(sat1, "body"))) + assert np.all(np.isclose(B0, flux_density_vector(sat2, "body"))) + + # The magnetic actor will oscillate slightly, following the Earth's magnetic field lines. (check for multiple steps) + # The actor's magnetic field will not stay perfectly aligned with the Earth's field but needs to stay close. for i in range(20): sim.advance_time(200, 0) - - # get Earth B vector at timestep - # Earth magnetic dipole moment: - m_earth = sat1._attitude_model.earth_magnetic_dipole_moment() - # parameters to calculate local B vector: - actor_position = np.array(sat1.get_position(sat1.local_time)) - r = np.linalg.norm(actor_position) - r_hat = actor_position / r - # local B vector: - B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r**3) - + + # Get the magnetic flux density vector: + B = flux_density_vector(sat1) + # B vector direction: B_direction = B / np.linalg.norm(B) - # angle between the B vector and the actor's magnetic dipole vector (which is in the pointing vector direction): - angle = np.arccos(np.dot(B_direction, sat1.pointing_vector())) * 180/np.pi + # Angle between the B vector and the actor's magnetic dipole vector (which is in the pointing vector direction): + angle = np.arccos(np.dot(B_direction, sat1.pointing_vector())) * 180 / np.pi - # check if the magnetic actor dipole moment vector doesn't deviate more than 1° from the B vector. + # Check if the magnetic actor dipole moment vector doesn't deviate more than 1° from the B vector. assert angle < 1 - # check if the non-magnetic actor didn't rotate + # Check if the non-magnetic actor didn't rotate assert np.all(sat2.pointing_vector() == initial_pointing_vector_eci) From 7914692464ab4e414016f15928b338d38d1a9aee Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 18:01:51 +0100 Subject: [PATCH 88/97] test for magnetic disturbance cleaned up --- paseos/tests/attitude_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index dd1982f..18b8425 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -174,15 +174,15 @@ def attitude_and_orbit_test(): def magnetic_disturbance_test(): """Tests the magnetic disturbance torques applied in the attitude model. - First: put two spacecraft actors in a geostationary orbit (disregarding the relative magnetic field rotation of the - Earth). Both actor's own magnetic dipole moment aligned with the local magnetic flux density vector of the Earth + Put two spacecraft actors in a geostationary orbit (disregarding the relative magnetic field rotation of the Earth). + Both actor's own magnetic dipole moment aligned with the local magnetic flux density vector of the Earth magnetic field. One is non-magnetic and is expected to have a fixed attitude in the Earth inertial frame. The other (magnetic) actor should stay aligned with the Earth magnetic field. """ def flux_density_vector(actor, frame="eci"): """Function to calculate the local magnetic field flux density vector (B) at the actor's location. - B vector is calculated multiple times. Use of this function for clarity. + B vector is calculated multiple times. Use of this function for code clarity. Args: actor (SpacecraftActor): Spacecraft actor From 21d963422f2a24e657b52338afc194ad4a013df9 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 18:26:25 +0100 Subject: [PATCH 89/97] deleted unnecessary lines --- paseos/tests/attitude_test.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index 18b8425..6c0cbff 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -151,7 +151,6 @@ def attitude_and_orbit_test(): actor_initial_angular_velocity=[0.0, 2 * np.pi / orbit_period, 0.0], actor_pointing_vector_body=[0, 0, 1], ) - ActorBuilder.set_disturbances(sat1) # Check Initial values assert np.all(sat1._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) @@ -329,6 +328,3 @@ def flux_density_vector(actor, frame="eci"): # Check if the non-magnetic actor didn't rotate assert np.all(sat2.pointing_vector() == initial_pointing_vector_eci) - - -magnetic_disturbance_test() From 747a2703fcba479143980eb676fbcea5247a08d8 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 18:27:53 +0100 Subject: [PATCH 90/97] Apply suggestion from code review --- paseos/tests/attitude_test.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index 25ef4ca..8328d81 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -146,7 +146,6 @@ def attitude_and_orbit_test(): actor_initial_angular_velocity=[0.0, 2 * np.pi / orbit_period, 0.0], actor_pointing_vector_body=[0, 0, 1], ) - ActorBuilder.set_disturbances(sat1) # Check Initial values assert np.all(sat1._attitude_model._actor_pointing_vector_body == [0.0, 0.0, 1.0]) @@ -165,6 +164,3 @@ def attitude_and_orbit_test(): # Testing the simulation went as intended assert sat1._attitude_model._actor_pointing_vector_body[2] == 1.0 assert vector[0] == -1.0 - - -attitude_model_test() From 8e37a3b8ba1e68d25646d144cef0322071f3ff55 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 19:15:08 +0100 Subject: [PATCH 91/97] no more next position in magnetic disturbance model. --- paseos/attitude/attitude_model.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 2316c6a..a6c8d92 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -134,13 +134,6 @@ def calculate_disturbance_torque(self): np.array([Tx, Ty, Tz]): total combined torques in Nm expressed in the spacecraft body frame. """ T = np.array([0.0, 0.0, 0.0]) - dt = 10 - # time - t = self._actor.local_time - next_position = np.array( - self._actor.get_position(pk.epoch(t.mjd2000 + dt * pk.SEC2DAY, "mjd2000")) - ) - position = np.array(self._actor.get_position(t)) if self._actor.has_attitude_disturbances: if "aerodynamic" in self._actor.get_disturbances(): @@ -148,13 +141,12 @@ def calculate_disturbance_torque(self): if "gravitational" in self._actor.get_disturbances(): T += calculate_grav_torque() if "magnetic" in self._actor.get_disturbances(): + time = self._actor.local_time T += calculate_magnetic_torque( m_earth=self.earth_magnetic_dipole_moment(), m_sat=self._actor_residual_magnetic_field, - position=next_position, - velocity=self._actor.get_position_velocity(self._actor.local_time)[ - 1 - ], + position=self._actor.get_position(time), + velocity=self._actor.get_position_velocity(time)[1], attitude=self._actor_attitude_in_rad, ) return T From 4240289c68a6dbd87c2c31a3bb7ac7613498be76 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 20:10:11 +0100 Subject: [PATCH 92/97] made functions in attitude model internal --- paseos/attitude/attitude_model.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index ab3e98f..8ac666e 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -81,8 +81,7 @@ def __init__( np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), ) - def nadir_vector(self): - # unused but might be useful during disturbance calculations or pointing vector relative position + def _nadir_vector(self): """Compute unit vector pointing towards earth, inertial body frame. Returns: @@ -91,7 +90,7 @@ def nadir_vector(self): u = np.array(self._actor.get_position(self._actor.local_time)) return -u / np.linalg.norm(u) - def calculate_disturbance_torque(self): + def _calculate_disturbance_torque(self): """Compute total torque due to user specified disturbances. Returns: @@ -108,7 +107,7 @@ def calculate_disturbance_torque(self): T += calculate_magnetic_torque() return T - def calculate_angular_acceleration(self): + def _calculate_angular_acceleration(self): """Calculate the spacecraft angular acceleration (external disturbance torques and gyroscopic accelerations).""" # TODO in the future control torques could be added @@ -118,11 +117,11 @@ def calculate_angular_acceleration(self): # Euler's equation for rigid body rotation: a = I^(-1) (T - w x (Iw)) # with: a = angular acceleration, I = inertia matrix, T = torque vector, w = angular velocity self._actor_angular_acceleration = np.linalg.inv(I) @ ( - self.calculate_disturbance_torque() + self._calculate_disturbance_torque() - np.cross(self._actor_angular_velocity, I @ self._actor_angular_velocity) ) - def body_rotation(self, dt): + def _body_rotation(self, dt): """Calculates the rotation vector around which the spacecraft body rotates based on angular acceleration and velocity. @@ -132,7 +131,7 @@ def body_rotation(self, dt): Returns: rotation vector of spacecraft body expressed in the RPY frame. """ # Calculate angular acceleration - self.calculate_angular_acceleration() + self._calculate_angular_acceleration() # Add angular velocity self._actor_angular_velocity += self._actor_angular_acceleration * dt @@ -144,7 +143,7 @@ def body_rotation(self, dt): return body_to_rpy(body_rotation, self._actor_attitude_in_rad) @staticmethod - def frame_rotation(position, next_position, velocity): + def _frame_rotation(position, next_position, velocity): """Calculate the rotation vector of the RPY frame rotation within the inertial frame. This rotation component makes the actor body attitude stay constant w.r.t. inertial frame. @@ -174,7 +173,7 @@ def frame_rotation(position, next_position, velocity): # this rotation needs to be compensated in the rotation of the body frame, so its attitude stays fixed return -eci_to_rpy(rpy_frame_rotation_vector_in_eci, position, velocity) - def body_axes_in_rpy(self): + def _body_axes_in_rpy(self): """Transforms vectors expressed in the spacecraft body frame to the roll pitch yaw frame. Vectors: - x, y, z axes - user specified pointing vector @@ -213,13 +212,13 @@ def update_attitude(self, dt): ) # Initial body vectors expressed in rpy frame: (x, y, z, custom pointing vector) - xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = self.body_axes_in_rpy() + xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = self._body_axes_in_rpy() # attitude change due to two rotations # rpy frame rotation, in inertial frame: - theta_1 = self.frame_rotation(position, next_position, velocity) + theta_1 = self._frame_rotation(position, next_position, velocity) # body rotation due to its physical rotation - theta_2 = self.body_rotation(dt) + theta_2 = self._body_rotation(dt) # rotate the body vectors in rpy frame with frame rotation xb_rpy, yb_rpy, zb_rpy, pointing_vector_rpy = rotate_body_vectors( From dee082b60c2ad1749704a53109356997966b4d58 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 20:13:35 +0100 Subject: [PATCH 93/97] made functions in attitude model internal --- paseos/attitude/attitude_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 8ac666e..9100b53 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -82,7 +82,7 @@ def __init__( ) def _nadir_vector(self): - """Compute unit vector pointing towards earth, inertial body frame. + """Compute unit vector pointing towards earth, in the inertial frame. Returns: np array ([x, y, z]): unit nadir vector in ECIF (Earth-centered inertial frame) From f257760928e05b338687441c43dd271e09651960 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 23:25:43 +0100 Subject: [PATCH 94/97] commenting & cleaning up --- paseos/attitude/attitude_model.py | 8 ++++++-- test_attitude_code/test_attitude_plotting.py | 8 +++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index bc9d1eb..ca0fc09 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -59,6 +59,8 @@ def __init__( actor_initial_angular_velocity (list of floats): Actor's initial angular velocity vector. Defaults to [0., 0., 0.]. actor_pointing_vector_body (list of floats): User defined vector in the Actor body. Defaults to [0., 0., 1] + actor_residual_magnetic_field (list of floats): Actor's own magnetic field modeled as dipole moment vector. + Defaults to [0., 0., 0.]. """ self._actor = local_actor # convert to np.ndarray @@ -83,6 +85,8 @@ def __init__( np.array(self._actor.get_position(self._actor.local_time)), np.array(self._actor.get_position_velocity(self._actor.local_time)[1]), ) + + # actor residual magnetic field (modeled as dipole) self._actor_residual_magnetic_field = np.array(actor_residual_magnetic_field) def _nadir_vector(self): @@ -94,7 +98,7 @@ def _nadir_vector(self): u = np.array(self._actor.get_position(self._actor.local_time)) return -u / np.linalg.norm(u) - def earth_magnetic_dipole_moment(self): + def _earth_magnetic_dipole_moment(self): """Returns the Earth magnetic dipole moment vector from the northern geomagnetic pole position using skyfield api, and actor epoch. To model the simplified Earth magnetic field as a magnetic dipole with an offset from the Earth rotational axis, at a specific point in time. @@ -142,7 +146,7 @@ def _calculate_disturbance_torque(self): if "magnetic" in self._actor.get_disturbances(): time = self._actor.local_time T += calculate_magnetic_torque( - m_earth=self.earth_magnetic_dipole_moment(), + m_earth=self._earth_magnetic_dipole_moment(), m_sat=self._actor_residual_magnetic_field, position=self._actor.get_position(time), velocity=self._actor.get_position_velocity(time)[1], diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py index d6ef9b7..5d3bac1 100644 --- a/test_attitude_code/test_attitude_plotting.py +++ b/test_attitude_code/test_attitude_plotting.py @@ -7,7 +7,6 @@ import paseos from paseos.actors.spacecraft_actor import SpacecraftActor from paseos.actors.actor_builder import ActorBuilder -from paseos.utils.reference_frame_transfer import rpy_to_body, eci_to_rpy matplotlib.use("Qt5Agg") @@ -104,7 +103,7 @@ # angular velocity vector: # normalize first: ang_vel = sat1.angular_velocity() - if all(ang_vel == np.zeros(3)): + if np.all(ang_vel == np.zeros(3)): ang_vel = np.zeros(3) else: ang_vel = sat1.angular_velocity() / np.linalg.norm(sat1.angular_velocity()) @@ -114,17 +113,16 @@ # print("plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6) - m = sat1._attitude_model.earth_magnetic_dipole_moment() * 6e-16 + m = sat1._attitude_model._earth_magnetic_dipole_moment() * 6e-16 # get new Earth B vector - m_earth = sat1._attitude_model.earth_magnetic_dipole_moment() + m_earth = sat1._attitude_model._earth_magnetic_dipole_moment() actor_position = np.array(sat1.get_position(sat1.local_time)) actor_velocity = np.array(sat1.get_position_velocity(sat1.local_time)[1]) r = np.linalg.norm(actor_position) r_hat = actor_position / r B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r**3) - #B = rpy_to_body(eci_to_rpy(B, actor_position, actor_velocity), sat1.attitude_in_rad()) B = B * 2e14 # plot vectors From e94520a7c7e92ec4eb332a3e991031251cfb382e Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Mon, 29 Jan 2024 23:45:42 +0100 Subject: [PATCH 95/97] todo added --- paseos/attitude/attitude_model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index ca0fc09..3dd1423 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -139,6 +139,7 @@ def _calculate_disturbance_torque(self): T = np.array([0.0, 0.0, 0.0]) if self._actor.has_attitude_disturbances: + # TODO add solar disturbance if "aerodynamic" in self._actor.get_disturbances(): T += calculate_aero_torque() if "gravitational" in self._actor.get_disturbances(): From e51829e805d3218eb10a8bfbd19f52c45c9be515 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 30 Jan 2024 00:33:33 +0100 Subject: [PATCH 96/97] Deleted file --- test_attitude_code/test_attitude_plotting.py | 152 ------------------- 1 file changed, 152 deletions(-) delete mode 100644 test_attitude_code/test_attitude_plotting.py diff --git a/test_attitude_code/test_attitude_plotting.py b/test_attitude_code/test_attitude_plotting.py deleted file mode 100644 index 5d3bac1..0000000 --- a/test_attitude_code/test_attitude_plotting.py +++ /dev/null @@ -1,152 +0,0 @@ -# We use pykep for orbit determination -import pykep as pk -import matplotlib -import matplotlib.pyplot as plt -import numpy as np - -import paseos -from paseos.actors.spacecraft_actor import SpacecraftActor -from paseos.actors.actor_builder import ActorBuilder - -matplotlib.use("Qt5Agg") - -# Define central body -earth = pk.planet.jpl_lp("earth") - -# Define spacecraft actor -sat1 = ActorBuilder.get_actor_scaffold("sat1", SpacecraftActor, pk.epoch(0)) -sat2 = ActorBuilder.get_actor_scaffold("sat2", SpacecraftActor, pk.epoch(0)) -# geostationary orbit: - -lat = 79.6 * np.pi / 180 -lon = -71.6 * np.pi / 180 - -R = 6371000 + 35786000 -v = 3074.66 - -#arr = np.array([-1.24217547e-09, 1.01735657e-07, -3.87201400e-08]) -arr = np.array([-3.18159529e-09, 1.02244882e-07, -3.72362170e-08]) -initial_magn = np.ndarray.tolist(arr / np.linalg.norm(arr) * 1000) -initial_pointing = np.ndarray.tolist(arr / np.linalg.norm(arr)) - -ActorBuilder.set_orbit( - sat1, - position=[R * np.cos(np.pi / 2 + lon), R * np.sin(np.pi / 2 + lon), 0], - velocity=[-v * np.sin(np.pi / 2 + lon), v * np.cos(np.pi / 2 + lon), 0], - epoch=pk.epoch(0), - central_body=earth, -) -ActorBuilder.set_orbit( - sat2, - position=[R * np.cos(np.pi / 2 + lon), R * np.sin(np.pi / 2 + lon), 0], - velocity=[-v * np.sin(np.pi / 2 + lon), v * np.cos(np.pi / 2 + lon), 0], - epoch=pk.epoch(0), - central_body=earth, -) - -ActorBuilder.set_geometric_model(sat1, mass=100) -ActorBuilder.set_geometric_model(sat2, mass=100) - -ActorBuilder.set_attitude_model( - sat1, - actor_initial_angular_velocity=[0.0, 0.0, 0.0], - actor_pointing_vector_body=initial_pointing, - actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], - actor_residual_magnetic_field=initial_magn, -) -ActorBuilder.set_attitude_model( - sat2, - actor_initial_angular_velocity=[0.0, 0.0, 0.0], - actor_pointing_vector_body=initial_pointing, - actor_initial_attitude_in_rad=[0.0, 0.0, 0.0], - actor_residual_magnetic_field=[0.0, 0.0, 0.0], -) - -# disturbances: -ActorBuilder.set_disturbances(sat1, magnetic=True) -ActorBuilder.set_disturbances(sat2, magnetic=True) - - -sim = paseos.init_sim(sat1) -sim.add_known_actor(sat2) -plt.close() - -pos = [] -x = [] -y = [] -z = [] -pointing_vector = [] - -fig = plt.figure() -ax = fig.add_subplot(111, projection="3d") -for i in range(46): - print("----------", i, "----------") - pos = sat1.get_position(sat1.local_time) - x.append(sat1.get_position(sat1.local_time)[0]) - y.append(sat1.get_position(sat1.local_time)[1]) - z.append(sat1.get_position(sat1.local_time)[2]) - - euler = sat1.attitude_in_rad() - - # pointing vector: - vector = sat1.pointing_vector() - vector[np.isclose(vector, np.zeros(3))] = 0 - # scale for plotting - vector = vector * 8e6 - - # pointing vector: - vector2 = sat2.pointing_vector() - vector2[np.isclose(vector2, np.zeros(3))] = 0 - # scale for plotting - vector2 = vector2 * 8e6 - - # angular velocity vector: - # normalize first: - ang_vel = sat1.angular_velocity() - if np.all(ang_vel == np.zeros(3)): - ang_vel = np.zeros(3) - else: - ang_vel = sat1.angular_velocity() / np.linalg.norm(sat1.angular_velocity()) - ang_vel[np.isclose(ang_vel, np.zeros(3))] = 0 - # scale for plotting - ang_vel = ang_vel * 2e7 - - # print("plotted attitude:", euler, " at position: ", pos, " pointing v: ", vector / 2e6) - - m = sat1._attitude_model._earth_magnetic_dipole_moment() * 6e-16 - - # get new Earth B vector - m_earth = sat1._attitude_model._earth_magnetic_dipole_moment() - actor_position = np.array(sat1.get_position(sat1.local_time)) - actor_velocity = np.array(sat1.get_position_velocity(sat1.local_time)[1]) - r = np.linalg.norm(actor_position) - r_hat = actor_position / r - - B = 1e-7 * (3 * np.dot(m_earth, r_hat) * r_hat - m_earth) / (r**3) - - B = B * 2e14 - # plot vectors - # ax.quiver(pos[0], pos[1], pos[2], ang_vel[0], ang_vel[1], ang_vel[2], color="m") - ax.quiver(pos[0], pos[1], pos[2], vector[0], vector[1], vector[2], linewidths=3, color="r") - ax.quiver(pos[0], pos[1], pos[2], vector2[0], vector2[1], vector2[2], linewidths=3, color="m") - if not i % 10: - ax.quiver(0, 0, 0, m[0], m[1], m[2], color="y") - ax.quiver(pos[0], pos[1], pos[2], B[0], B[1], B[2], color="y") - - sim.advance_time(1000, 0) - -# 3D figure limits -axmin = min([min(x), min(y), min(z)]) * 1.1 -axmax = max([max(x), max(y), max(z)]) * 1.1 - -ax.axes.set_xlim3d(left=axmin, right=axmax) -ax.axes.set_ylim3d(bottom=axmin, top=axmax) -ax.axes.set_zlim3d(bottom=axmin, top=axmax) - -ax.set_xlabel("x") -ax.set_ylabel("y") -ax.set_zlabel("z") - -ax.plot(x, y, z) -ax.scatter(0, 0, 0) -plt.show() From 70a4e27daad98e15eb965c8e8df1e5da46250ea6 Mon Sep 17 00:00:00 2001 From: Mr-Medina Date: Tue, 30 Jan 2024 20:19:41 +0100 Subject: [PATCH 97/97] Moved magnetic_dipole_moment function to central body. --- paseos/attitude/attitude_model.py | 35 +----------- paseos/central_body/central_body.py | 82 +++++++++++++++++++++++++++-- paseos/tests/attitude_test.py | 3 +- 3 files changed, 79 insertions(+), 41 deletions(-) diff --git a/paseos/attitude/attitude_model.py b/paseos/attitude/attitude_model.py index 3dd1423..170f6bf 100644 --- a/paseos/attitude/attitude_model.py +++ b/paseos/attitude/attitude_model.py @@ -1,6 +1,5 @@ import numpy as np import pykep as pk -from skyfield.api import wgs84, load from paseos.attitude.disturbance_calculations import ( @@ -98,38 +97,6 @@ def _nadir_vector(self): u = np.array(self._actor.get_position(self._actor.local_time)) return -u / np.linalg.norm(u) - def _earth_magnetic_dipole_moment(self): - """Returns the Earth magnetic dipole moment vector from the northern geomagnetic pole position using skyfield - api, and actor epoch. To model the simplified Earth magnetic field as a magnetic dipole with an offset from - the Earth rotational axis, at a specific point in time. - - Pole position and dipole moment strength values from the year 2000: - Latitude: 79.6° N - Longitude: 71.6° W - Dipole moment: 7.79 x 10²² Am² - https://wdc.kugi.kyoto-u.ac.jp/poles/polesexp.html - - (The same method used as ground station actor position determination) - - Returns: Time-dependent Earth dipole moment vector in ECI (np.ndarray): [mx, my, mz] - """ - # North geomagnetic pole location - lat = 79.6 - lon = -71.6 - - # Converting time to skyfield to use its API - t_skyfield = load.timescale().tt_jd(self._actor.local_time.jd) - - # North geomagnetic pole location on Earth surface in cartesian coordinates - dipole_north_direction = np.array( - wgs84.latlon(lat, lon).at(t_skyfield).position.m - ) - # Multiply geomagnetic pole position unit vector with dipole moment strength - magnetic_dipole_moment = ( - dipole_north_direction / np.linalg.norm(dipole_north_direction) * 7.79e22 - ) - return magnetic_dipole_moment - def _calculate_disturbance_torque(self): """Compute total torque due to user specified disturbances. @@ -147,7 +114,7 @@ def _calculate_disturbance_torque(self): if "magnetic" in self._actor.get_disturbances(): time = self._actor.local_time T += calculate_magnetic_torque( - m_earth=self._earth_magnetic_dipole_moment(), + m_earth=self._actor.central_body.magnetic_dipole_moment(time), m_sat=self._actor_residual_magnetic_field, position=self._actor.get_position(time), velocity=self._actor.get_position_velocity(time)[1], diff --git a/paseos/central_body/central_body.py b/paseos/central_body/central_body.py index d844525..60dab13 100644 --- a/paseos/central_body/central_body.py +++ b/paseos/central_body/central_body.py @@ -5,6 +5,8 @@ from loguru import logger from pyquaternion import Quaternion from skspatial.objects import Sphere +from skyfield.api import wgs84, load + import pykep as pk import numpy as np @@ -102,7 +104,9 @@ def blocks_sun(self, actor, t: pk.epoch, plot=False) -> bool: # Compute central body position in solar reference frame r_central_body_heliocentric, _ = np.array(self._planet.eph(t)) - logger.trace("r_central_body_heliocentric is" + str(r_central_body_heliocentric)) + logger.trace( + "r_central_body_heliocentric is" + str(r_central_body_heliocentric) + ) # Compute satellite / actor position in solar reference frame r_sat_central_body_frame = np.array(actor.get_position(t)) @@ -126,7 +130,12 @@ def is_between_actors(self, actor_1, actor_2, t: pk.epoch, plot=False) -> bool: Returns: bool: True if the central body is between the two actors """ - logger.debug("Computing line of sight between actors: " + str(actor_1) + " " + str(actor_2)) + logger.debug( + "Computing line of sight between actors: " + + str(actor_1) + + " " + + str(actor_2) + ) pos_1 = actor_1.get_position_velocity(t) pos_2 = actor_2.get_position_velocity(t) @@ -153,7 +162,12 @@ def is_between_points( Returns: bool: True if the central body is between the two points """ - logger.debug("Computing line of sight between points: " + str(point_1) + " " + str(point_2)) + logger.debug( + "Computing line of sight between points: " + + str(point_1) + + " " + + str(point_2) + ) point_1 = np.array(point_1) point_2 = np.array(point_2) @@ -183,8 +197,12 @@ def is_between_points( mesh_triangles=self._mesh[1], ) else: - logger.error("No mesh or encompassing sphere provided. Cannot check visibility.") - raise ValueError("No mesh or encompassing sphere provided. Cannot check visibility.") + logger.error( + "No mesh or encompassing sphere provided. Cannot check visibility." + ) + raise ValueError( + "No mesh or encompassing sphere provided. Cannot check visibility." + ) def _apply_rotation(self, point, epoch: pk.epoch): """Applies the inverse rotation of the central body to the given point. This way @@ -210,3 +228,57 @@ def _apply_rotation(self, point, epoch: pk.epoch): # Rotate point q = Quaternion(axis=self._rotation_axis, angle=angle) return q.rotate(point) + + def magnetic_dipole_moment( + self, + epoch: pk.epoch, + strength_in_Am2=7.79e22, + pole_latitude_in_deg=79.6, + pole_longitude_in_deg=-71.6, + ): + """Returns the time-dependent magnetic dipole moment vector of central body. + Default values are for Earth. Earth dipole moment vector determined from the northern geomagnetic pole position + using skyfield api, and actor epoch. To model the simplified Earth magnetic field as a magnetic dipole with an + offset from the Earth rotational axis, at a specific point in time. + + Earth geomagnetic pole position and dipole moment strength values from the year 2000: + Latitude: 79.6° N + Longitude: 71.6° W + Dipole moment: 7.79 x 10²² Am² + https://wdc.kugi.kyoto-u.ac.jp/poles/polesexp.html + + (The same method used as ground station actor position determination) + + Args: + epoch (pk.epoch): Epoch at which to get the dipole + strength_in_Am2 (float): dipole strength in Am². Defaults to 7.79e22 + pole_latitude_in_deg (float): latitude of the Northern geomagnetic pole in degrees. Defaults to 79.6 + pole_longitude_in_deg (float): longitude of the Northern geomagnetic pole in degrees. Defaults to -71.6 + + Returns: Time-dependent dipole moment vector in inertial frame (np.ndarray): [mx, my, mz] + """ + if self.planet.name == "earth": + # Converting time to skyfield to use its API + t_skyfield = load.timescale().tt_jd(epoch.jd) + + # North geomagnetic pole location on Earth surface in cartesian coordinates + dipole_north_direction = np.array( + wgs84.latlon(pole_latitude_in_deg, pole_longitude_in_deg) + .at(t_skyfield) + .position.m + ) + # Multiply geomagnetic pole position unit vector with dipole moment strength + magnetic_dipole_moment = ( + dipole_north_direction + / np.linalg.norm(dipole_north_direction) + * strength_in_Am2 + ) + else: + # TODO add other planets' magnetic fields + logger.warning( + "Magnetic dipole moment only modeled for Earth" + ) + # For now: assume alignment with rotation axis + magnetic_dipole_moment = np.array([0, 0, 1]) * strength_in_Am2 + + return magnetic_dipole_moment diff --git a/paseos/tests/attitude_test.py b/paseos/tests/attitude_test.py index 2866d44..e7efcbf 100644 --- a/paseos/tests/attitude_test.py +++ b/paseos/tests/attitude_test.py @@ -186,9 +186,8 @@ def flux_density_vector(actor, frame="eci"): Returns: B vector (np.ndarray) """ # get Earth B vector at specific timestep - # Earth magnetic dipole moment: - m_earth = actor._attitude_model.earth_magnetic_dipole_moment() + m_earth = actor.central_body.magnetic_dipole_moment(actor.local_time) # parameters to calculate local B vector: actor_position = np.array(actor.get_position(actor.local_time))