Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Attention: The newest changes should be on top -->

### Fixed

- BUG: Environment not Encoding Necessary Parameters for Decode [#1059](https://github.com/RocketPy-Team/RocketPy/pull/1059)
- BUG: fix individual fin (`TrapezoidalFin`, `EllipticalFin`, `FreeFormFin`) serialization crash when saving rockets/flights [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048)
- BUG: support the new Wyoming sounding WSGI page format (legacy cgi-bin endpoint was discontinued by UWyo) [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048)
- FIX: pre-release hardening — bug fixes across the unreleased features [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047)
Expand Down
31 changes: 31 additions & 0 deletions rocketpy/environment/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2924,13 +2924,43 @@ def to_dict(self, **kwargs):
"timezone": self.timezone,
"max_expected_height": self.max_expected_height,
"atmospheric_model_type": self.atmospheric_model_type,
"atmospheric_model_init_date": getattr(
self, "atmospheric_model_init_date", None
),
"atmospheric_model_end_date": getattr(
self, "atmospheric_model_end_date", None
),
"atmospheric_model_interval": getattr(
self, "atmospheric_model_interval", None
),
"atmospheric_model_init_lat": getattr(
self, "atmospheric_model_init_lat", None
),
"atmospheric_model_end_lat": getattr(
self, "atmospheric_model_end_lat", None
),
"atmospheric_model_init_lon": getattr(
self, "atmospheric_model_init_lon", None
),
"atmospheric_model_end_lon": getattr(
self, "atmospheric_model_end_lon", None
),
"pressure": self.pressure,
"temperature": self.temperature,
"wind_velocity_x": wind_velocity_x,
"wind_velocity_y": wind_velocity_y,
"wind_heading": wind_heading,
"wind_direction": wind_direction,
"wind_speed": wind_speed,
"level_ensemble": getattr(self, "level_ensemble", None),
"height_ensemble": getattr(self, "height_ensemble", None),
"temperature_ensemble": getattr(self, "temperature_ensemble", None),
"wind_u_ensemble": getattr(self, "wind_u_ensemble", None),
"wind_v_ensemble": getattr(self, "wind_v_ensemble", None),
"wind_heading_ensemble": getattr(self, "wind_heading_ensemble", None),
"wind_direction_ensemble": getattr(self, "wind_direction_ensemble", None),
"wind_speed_ensemble": getattr(self, "wind_speed_ensemble", None),
"num_ensemble_members": getattr(self, "num_ensemble_members", None),
}

if kwargs.get("include_outputs", False):
Expand All @@ -2954,6 +2984,7 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
max_expected_height=data["max_expected_height"],
)
atmospheric_model = data["atmospheric_model_type"]
env.atmospheric_model_type = atmospheric_model

match atmospheric_model:
case "standard_atmosphere":
Expand Down
22 changes: 20 additions & 2 deletions rocketpy/environment/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import logging
import math
import warnings
from datetime import datetime

import netCDF4
import numpy as np
Expand All @@ -16,6 +17,23 @@

logger = logging.getLogger(__name__)


def _to_datetime(date):
"""Convert netCDF/cftime date-like values to standard datetimes."""
if isinstance(date, datetime):
return date

return datetime(
date.year,
date.month,
date.day,
date.hour,
getattr(date, "minute", 0),
getattr(date, "second", 0),
getattr(date, "microsecond", 0),
)


## Wind data functions


Expand Down Expand Up @@ -590,7 +608,7 @@ def get_initial_date_from_time_array(time_array, units=None):
A datetime object representing the first time in the time array.
"""
units = units or time_array.units
return netCDF4.num2date(time_array[0], units, calendar="gregorian")
return _to_datetime(netCDF4.num2date(time_array[0], units, calendar="gregorian"))


def get_final_date_from_time_array(time_array, units=None):
Expand All @@ -609,7 +627,7 @@ def get_final_date_from_time_array(time_array, units=None):
A datetime object representing the last time in the time array.
"""
units = units if units is not None else time_array.units
return netCDF4.num2date(time_array[-1], units, calendar="gregorian")
return _to_datetime(netCDF4.num2date(time_array[-1], units, calendar="gregorian"))


def get_interval_date_from_time_array(time_array, units=None):
Expand Down
36 changes: 36 additions & 0 deletions tests/fixtures/environment/environment_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,42 @@
from rocketpy import Environment, EnvironmentAnalysis


class _DummyTimeArray:
"""Minimal time array with netCDF-like units metadata."""

units = "hours since 2023-06-24 00:00:00"

def __init__(self, values):
self.values = values

def __getitem__(self, index):
return self.values[index]


class _DummyCftimeDate:
"""Small cftime-like datetime used to exercise NetCDF date conversion."""

year = 2023
month = 6
day = 24
hour = 9
minute = 30
second = 15
microsecond = 123456


@pytest.fixture
def dummy_time_array():
"""NetCDF-like time array for environment date helper tests."""
return _DummyTimeArray([0, 6])


@pytest.fixture
def dummy_cftime_date():
"""cftime-like date object for environment date helper tests."""
return _DummyCftimeDate()


@pytest.fixture
def example_plain_env():
"""Simple object of the Environment class to be used in the tests.
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/environment/test_environment.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
import os
from datetime import datetime

import numpy as np
import numpy.testing as npt
import pytest
import pytz

Expand All @@ -10,6 +12,8 @@
find_longitude_index,
geodesic_to_lambert_conformal,
geodesic_to_utm,
get_final_date_from_time_array,
get_initial_date_from_time_array,
utm_to_geodesic,
)
from rocketpy.environment.weather_model_mapping import WeatherModelMapping
Expand All @@ -24,6 +28,27 @@ class DummyLambertProjection:
earth_radius = 6371229.0


@pytest.mark.parametrize(
"date_helper", [get_initial_date_from_time_array, get_final_date_from_time_array]
)
def test_time_array_date_helpers_convert_cftime_dates(
monkeypatch, date_helper, dummy_time_array, dummy_cftime_date
):
"""Convert NetCDF/cftime date objects to JSON-serializable datetimes."""

# Arrange
def fake_num2date(*_args, **_kwargs):
return dummy_cftime_date

monkeypatch.setattr("rocketpy.environment.tools.netCDF4.num2date", fake_num2date)

# Act
converted_date = date_helper(dummy_time_array)

# Assert
assert converted_date == datetime(2023, 6, 24, 9, 30, 15, 123456)


@pytest.mark.parametrize(
"latitude, longitude", [(-21.960641, -47.482122), (0, 0), (21.960641, 47.482122)]
)
Expand Down Expand Up @@ -306,6 +331,93 @@ def test_environment_export_environment_exports_valid_environment_json(
os.remove("environment.json")


@pytest.mark.parametrize(
"atmospheric_model_type", ["windy", "forecast", "reanalysis", "ensemble"]
)
def test_environment_to_dict_from_dict_round_trip_preserves_weather_metadata(
example_plain_env, atmospheric_model_type
):
"""Round-trip weather-model environments without losing metadata.

Parameters
----------
example_plain_env : rocketpy.Environment
Baseline environment used to build the serialized state.
atmospheric_model_type : str
Weather-model label stored in the serialized payload.
"""
# Arrange
env = example_plain_env

weather_metadata = {
"atmospheric_model_type": atmospheric_model_type,
"atmospheric_model_file": None,
"atmospheric_model_dict": {"time": "time"},
"atmospheric_model_init_date": datetime(2024, 1, 1, 0),
"atmospheric_model_end_date": datetime(2024, 1, 1, 6),
"atmospheric_model_interval": 6,
"atmospheric_model_init_lat": -10.0,
"atmospheric_model_end_lat": 10.0,
"atmospheric_model_init_lon": -20.0,
"atmospheric_model_end_lon": 20.0,
}

ensemble_metadata = {
"level_ensemble": None,
"height_ensemble": None,
"temperature_ensemble": None,
"wind_u_ensemble": None,
"wind_v_ensemble": None,
"wind_heading_ensemble": None,
"wind_direction_ensemble": None,
"wind_speed_ensemble": None,
"num_ensemble_members": None,
}

if atmospheric_model_type == "ensemble":
ensemble_metadata.update(
{
"level_ensemble": np.array([1000.0, 900.0]),
"height_ensemble": np.array([[0.0, 1000.0]]),
"temperature_ensemble": np.array([[288.15, 281.15]]),
"wind_u_ensemble": np.array([[2.0, 3.0]]),
"wind_v_ensemble": np.array([[4.0, 5.0]]),
"wind_heading_ensemble": np.array([[26.565051, 30.963757]]),
"wind_direction_ensemble": np.array([[206.565051, 210.963757]]),
"wind_speed_ensemble": np.array([[4.472136, 5.830952]]),
"num_ensemble_members": 1,
}
)

for metadata in (weather_metadata, ensemble_metadata):
for attribute, value in metadata.items():
setattr(env, attribute, value)

env_dict = env.to_dict()

# The serialized payload should be self-contained and not depend on files.
assert "atmospheric_model_file" not in env_dict
assert "atmospheric_model_dict" not in env_dict

# Act
restored_env = Environment.from_dict(env_dict)

# Assert
assert restored_env.atmospheric_model_type == atmospheric_model_type
assert restored_env.atmospheric_model_init_date == env.atmospheric_model_init_date
assert restored_env.atmospheric_model_end_date == env.atmospheric_model_end_date
assert restored_env.atmospheric_model_interval == env.atmospheric_model_interval
assert restored_env.atmospheric_model_init_lat == env.atmospheric_model_init_lat
assert restored_env.atmospheric_model_end_lat == env.atmospheric_model_end_lat
assert restored_env.atmospheric_model_init_lon == env.atmospheric_model_init_lon
assert restored_env.atmospheric_model_end_lon == env.atmospheric_model_end_lon

if atmospheric_model_type == "ensemble":
npt.assert_allclose(restored_env.level_ensemble, env.level_ensemble)
npt.assert_allclose(restored_env.height_ensemble, env.height_ensemble)
assert restored_env.num_ensemble_members == env.num_ensemble_members


class _DummyDataset:
"""Small test double that mimics a netCDF dataset variables mapping."""

Expand Down
Loading