Skip to content

Commit 3c7dcf1

Browse files
BUG: fix wind heading and direction wraparound interpolation
Unwrap wind heading/direction angles (np.unwrap) before building the linear interpolation Function, then wrap back with % 360 on evaluation. This removes the spurious mid-altitude spikes that appeared when the profile crossed the 360/0 boundary (e.g. 350 deg -> 10 deg). The shared logic lives in a single private helper (__set_wind_angle_function) used by both wind setters, keeping the module under pylint's max-module-lines. Adds a unit test covering the wraparound case. Closes #253 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a5b3b1f commit 3c7dcf1

3 files changed

Lines changed: 61 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Attention: The newest changes should be on top -->
5252

5353
### Fixed
5454

55+
- BUG: fix wind heading and direction wraparound interpolation [#974](https://github.com/RocketPy-Team/RocketPy/pull/974)
5556
- BUG: fix NaN in ND linear interpolation outside convex hull [#926](https://github.com/RocketPy-Team/RocketPy/issues/926)
5657
- BUG: Add wraparound logic for wind direction in environment plots [#939](https://github.com/RocketPy-Team/RocketPy/pull/939)
5758

rocketpy/environment/environment.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -537,20 +537,42 @@ def __set_wind_speed_function(self, source):
537537
interpolation="linear",
538538
)
539539

540+
def __set_wind_angle_function(self, source, attribute, output):
541+
"""Set ``attribute`` (e.g. ``wind_direction``) as a Function of height.
542+
For 2D-array sources the angles are unwrapped across the 360/0 boundary
543+
before linear interpolation, avoiding spurious spikes near the wrap."""
544+
if isinstance(source, (np.ndarray, list, tuple)) and np.ndim(source) == 2:
545+
array = np.asarray(source)
546+
unwrapped_deg = np.rad2deg(np.unwrap(np.deg2rad(array[:, 1])))
547+
unwrapped = Function(
548+
np.column_stack((array[:, 0], unwrapped_deg)),
549+
inputs="Height Above Sea Level (m)",
550+
outputs=output,
551+
interpolation="linear",
552+
)
553+
setattr(self, f"{attribute}_unwrapped", unwrapped)
554+
source = Function(
555+
lambda h: unwrapped(h) % 360,
556+
inputs="Height Above Sea Level (m)",
557+
outputs=output,
558+
)
559+
else:
560+
source = Function(
561+
source,
562+
inputs="Height Above Sea Level (m)",
563+
outputs=output,
564+
interpolation="linear",
565+
)
566+
setattr(self, attribute, source)
567+
540568
def __set_wind_direction_function(self, source):
541-
self.wind_direction = Function(
542-
source,
543-
inputs="Height Above Sea Level (m)",
544-
outputs="Wind Direction (Deg True)",
545-
interpolation="linear",
569+
self.__set_wind_angle_function(
570+
source, "wind_direction", "Wind Direction (Deg True)"
546571
)
547572

548573
def __set_wind_heading_function(self, source):
549-
self.wind_heading = Function(
550-
source,
551-
inputs="Height Above Sea Level (m)",
552-
outputs="Wind Heading (Deg True)",
553-
interpolation="linear",
574+
self.__set_wind_angle_function(
575+
source, "wind_heading", "Wind Heading (Deg True)"
554576
)
555577

556578
def __reset_barometric_height_function(self):

tests/unit/environment/test_environment.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,34 @@ def test_set_atmospheric_model_raises_for_unknown_model_type(example_plain_env):
604604
environment.set_atmospheric_model(type="unknown_type")
605605

606606

607+
def test_wind_heading_direction_wraparound_interpolation(example_plain_env):
608+
"""Test that wind heading and direction interpolation wraps around correctly
609+
across the 360°/0° boundary when initialized with a 2D array.
610+
"""
611+
# Create discrete points at 1000m and 1100m
612+
# 350 deg at 1000m, 10 deg at 1100m.
613+
# Midpoint should be 360 deg or 0 deg, NOT 180 deg.
614+
heading_data = np.array([[1000, 350], [1100, 10]])
615+
direction_data = np.array([[1000, 350], [1100, 10]])
616+
617+
example_plain_env._Environment__set_wind_heading_function(heading_data)
618+
example_plain_env._Environment__set_wind_direction_function(direction_data)
619+
620+
# Evaluate at midpoint (1050m)
621+
mid_heading = example_plain_env.wind_heading(1050)
622+
mid_direction = example_plain_env.wind_direction(1050)
623+
624+
# Check that it's close to 0 or 360 (which is also 0 modulo 360)
625+
assert np.isclose(mid_heading, 0.0) or np.isclose(mid_heading, 360.0)
626+
assert np.isclose(mid_direction, 0.0) or np.isclose(mid_direction, 360.0)
627+
628+
# Also test another wrap-around case, e.g. 10 to 350
629+
heading_data2 = np.array([[1000, 10], [1100, 350]])
630+
example_plain_env._Environment__set_wind_heading_function(heading_data2)
631+
mid_heading2 = example_plain_env.wind_heading(1050)
632+
assert np.isclose(mid_heading2, 0.0) or np.isclose(mid_heading2, 360.0)
633+
634+
607635
@pytest.mark.parametrize("shortcut_name", ["AIGFS", "HRRR"])
608636
def test_forecast_shortcut_and_dictionary_are_case_insensitive(
609637
monkeypatch, shortcut_name

0 commit comments

Comments
 (0)