Skip to content

Commit 4a67a41

Browse files
Merge branch 'develop' into master
2 parents fca8149 + 5c17f5a commit 4a67a41

6 files changed

Lines changed: 123 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Attention: The newest changes should be on top -->
4141

4242
### Added
4343

44+
- ENH: ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055)
45+
- ENH: TST: Add unit tests for evaluate_reduced_mass and remove TODO [#1051](https://github.com/RocketPy-Team/RocketPy/pull/1051)
4446
- ENH: FIX: pre release hardening [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047)
4547
- ENH: DEV: Claude Code ruff hooks (auto-format on edit + pre-push lint guard) [#1046](https://github.com/RocketPy-Team/RocketPy/pull/1046)
4648
- ENH: CI: create a CI for testing docs updates + solve different docs issues [#1045](https://github.com/RocketPy-Team/RocketPy/pull/1045)
@@ -72,6 +74,7 @@ Attention: The newest changes should be on top -->
7274
- MNT: Discrete controllers are now called exactly once per time node (previously twice); results may change for stateful controllers and `observed_variables` no longer contains duplicated entries [#949](https://github.com/RocketPy-Team/RocketPy/pull/949)
7375
- MNT: Multi-dimensional linear `Function` objects now apply their extrapolation rule to points outside the data's convex hull (previously returned NaN) [#969](https://github.com/RocketPy-Team/RocketPy/pull/969)
7476
- MNT: Informational messages previously shown with `print()` now use Python logging and are silent by default; call `rocketpy.utils.enable_logging()` to see them [#973](https://github.com/RocketPy-Team/RocketPy/pull/973)
77+
- ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055)
7578

7679
### Deprecated
7780

rocketpy/rocket/rocket.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,6 @@ def evaluate_reduced_mass(self):
612612
self.reduced_mass : Function
613613
Function of time expressing the reduced mass of the rocket.
614614
"""
615-
# TODO: add tests for reduced_mass values
616615
# Make sure there is a motor associated with the rocket
617616
if self.motor is None:
618617
logger.warning("Please associate this rocket with a motor!")

rocketpy/simulation/flight.py

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
find_closest,
2222
find_root_linear_interpolation,
2323
find_roots_cubic_function,
24+
inverted_haversine,
2425
quaternions_to_nutation,
2526
quaternions_to_precession,
2627
quaternions_to_spin,
@@ -3813,40 +3814,29 @@ def latitude(self):
38133814
"""Rocket latitude coordinate, in degrees, as a Function of
38143815
time.
38153816
"""
3816-
lat1 = np.deg2rad(self.env.latitude) # Launch lat point converted to radians
3817-
38183817
# Applies the haversine equation to find final lat/lon coordinates
3819-
latitude = np.rad2deg(
3820-
np.arcsin(
3821-
np.sin(lat1) * np.cos(self.drift[:, 1] / self.env.earth_radius)
3822-
+ np.cos(lat1)
3823-
* np.sin(self.drift[:, 1] / self.env.earth_radius)
3824-
* np.cos(np.deg2rad(self.bearing[:, 1]))
3825-
)
3818+
latitude, _ = inverted_haversine(
3819+
self.env.latitude,
3820+
self.env.longitude,
3821+
self.drift[:, 1],
3822+
self.bearing[:, 1],
3823+
self.env.earth_radius,
38263824
)
38273825
return np.column_stack((self.time, latitude))
38283826

3829-
# TODO: haversine should be defined in tools.py so we just invoke it in here.
38303827
@funcify_method("Time (s)", "Longitude (°)", "linear", "constant")
38313828
def longitude(self):
38323829
"""Rocket longitude coordinate, in degrees, as a Function of
38333830
time.
38343831
"""
3835-
lat1 = np.deg2rad(self.env.latitude) # Launch lat point converted to radians
3836-
lon1 = np.deg2rad(self.env.longitude) # Launch lon point converted to radians
3837-
38383832
# Applies the haversine equation to find final lat/lon coordinates
3839-
longitude = np.rad2deg(
3840-
lon1
3841-
+ np.arctan2(
3842-
np.sin(np.deg2rad(self.bearing[:, 1]))
3843-
* np.sin(self.drift[:, 1] / self.env.earth_radius)
3844-
* np.cos(lat1),
3845-
np.cos(self.drift[:, 1] / self.env.earth_radius)
3846-
- np.sin(lat1) * np.sin(np.deg2rad(self.latitude[:, 1])),
3847-
)
3833+
_, longitude = inverted_haversine(
3834+
self.env.latitude,
3835+
self.env.longitude,
3836+
self.drift[:, 1],
3837+
self.bearing[:, 1],
3838+
self.env.earth_radius,
38483839
)
3849-
38503840
return np.column_stack((self.time, longitude))
38513841

38523842
def get_controller_observed_variables(self):

rocketpy/tools.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -425,18 +425,18 @@ def inverted_haversine(lat0, lon0, distance, bearing, earth_radius=6.3781e6):
425425
lon0_rad = np.deg2rad(lon0)
426426

427427
# Apply inverted Haversine formula
428-
lat1_rad = math.asin(
429-
math.sin(lat0_rad) * math.cos(distance / earth_radius)
430-
+ math.cos(lat0_rad)
431-
* math.sin(distance / earth_radius)
432-
* math.cos(math.radians(bearing))
428+
lat1_rad = np.arcsin(
429+
np.sin(lat0_rad) * np.cos(distance / earth_radius)
430+
+ np.cos(lat0_rad)
431+
* np.sin(distance / earth_radius)
432+
* np.cos(np.radians(bearing))
433433
)
434434

435-
lon1_rad = lon0_rad + math.atan2(
436-
math.sin(math.radians(bearing))
437-
* math.sin(distance / earth_radius)
438-
* math.cos(lat0_rad),
439-
math.cos(distance / earth_radius) - math.sin(lat0_rad) * math.sin(lat1_rad),
435+
lon1_rad = lon0_rad + np.arctan2(
436+
np.sin(np.radians(bearing))
437+
* np.sin(distance / earth_radius)
438+
* np.cos(lat0_rad),
439+
np.cos(distance / earth_radius) - np.sin(lat0_rad) * np.sin(lat1_rad),
440440
)
441441

442442
# Convert back to degrees and then return

tests/unit/rocket/test_rocket.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,3 +996,52 @@ def test_power_drag_exposed_as_function_objects_and_inputs_preserved():
996996
# Raw user input is preserved for serialization / round-tripping.
997997
assert rocket._power_off_drag_input == off_input
998998
assert rocket._power_on_drag_input == on_input
999+
1000+
1001+
def test_evaluate_reduced_mass_without_motor(calisto_motorless):
1002+
"""Test evaluate_reduced_mass returns False when there is no motor
1003+
associated with the rocket (self.motor is None).
1004+
"""
1005+
# Arrange
1006+
rocket = calisto_motorless
1007+
rocket.motor = None
1008+
1009+
# Act
1010+
result = rocket.evaluate_reduced_mass()
1011+
1012+
# Assert
1013+
assert result is False
1014+
1015+
1016+
def test_evaluate_reduced_mass_empty_motor(calisto_motorless):
1017+
"""Test evaluate_reduced_mass returns a Function evaluating to 0 when
1018+
the motor is an EmptyMotor.
1019+
"""
1020+
# Arrange
1021+
rocket = calisto_motorless
1022+
1023+
# Act
1024+
reduced_mass = rocket.evaluate_reduced_mass()
1025+
1026+
# Assert
1027+
assert isinstance(reduced_mass, Function)
1028+
assert reduced_mass(0) == pytest.approx(0)
1029+
1030+
1031+
def test_evaluate_reduced_mass_with_motor(calisto):
1032+
"""Test evaluate_reduced_mass returns the correct Function when a motor
1033+
is associated with the rocket.
1034+
"""
1035+
# Arrange
1036+
rocket = calisto
1037+
dry_mass = rocket.dry_mass
1038+
prop_mass = rocket.motor.propellant_mass
1039+
1040+
# Act
1041+
reduced_mass = rocket.evaluate_reduced_mass()
1042+
1043+
# Assert
1044+
assert isinstance(reduced_mass, Function)
1045+
for t in [0.0, 1.0, 2.0, 3.0]:
1046+
expected = prop_mass(t) * dry_mass / (prop_mass(t) + dry_mass)
1047+
assert reduced_mass(t) == pytest.approx(expected)

tests/unit/test_tools.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
euler313_to_quaternions,
88
find_roots_cubic_function,
99
haversine,
10+
inverted_haversine,
1011
tuple_handler,
1112
)
1213

@@ -137,3 +138,50 @@ def test_invalid_pressure_conversion_factor(pressure_conversion_factor):
137138
dictionary="ECMWF",
138139
pressure_conversion_factor=pressure_conversion_factor,
139140
)
141+
142+
143+
def test_inverted_haversine_scalar():
144+
"""Test inverted_haversine with scalar arguments matches haversine distance."""
145+
# Arrange
146+
lat0, lon0 = -23.508958, -46.720080
147+
lat1, lon1 = -23.522939, -46.558253
148+
earth_radius = 6378100.0
149+
distance = haversine(lat0, lon0, lat1, lon1, earth_radius)
150+
bearing = 90.0
151+
152+
# Act
153+
lat_result, lon_result = inverted_haversine(
154+
lat0, lon0, distance, bearing, earth_radius
155+
)
156+
157+
# Assert
158+
recalculated_distance = haversine(lat0, lon0, lat_result, lon_result, earth_radius)
159+
assert recalculated_distance == pytest.approx(distance, abs=1e-2)
160+
161+
162+
def test_inverted_haversine_array():
163+
"""Test inverted_haversine with NumPy arrays returns correct array results."""
164+
# Arrange
165+
lat0, lon0 = -23.508958, -46.720080
166+
distances = np.array([0.0, 5000.0, 16591.438])
167+
bearings = np.array([0.0, 45.0, 90.0])
168+
earth_radius = 6378100.0
169+
170+
# Act
171+
lat_results, lon_results = inverted_haversine(
172+
lat0, lon0, distances, bearings, earth_radius
173+
)
174+
175+
# Assert
176+
assert isinstance(lat_results, np.ndarray)
177+
assert isinstance(lon_results, np.ndarray)
178+
assert len(lat_results) == 3
179+
assert len(lon_results) == 3
180+
181+
# Check scalar consistency for each element
182+
for i, distance in enumerate(distances):
183+
lat_scalar, lon_scalar = inverted_haversine(
184+
lat0, lon0, distance, bearings[i], earth_radius
185+
)
186+
assert lat_results[i] == pytest.approx(lat_scalar)
187+
assert lon_results[i] == pytest.approx(lon_scalar)

0 commit comments

Comments
 (0)