Skip to content

Commit 490a293

Browse files
authored
Merge pull request #1059 from RocketPy-Team/bug/environment-decode
BUG: Environment not Encoding Necessary Parameters for Decode
1 parent 7d232fa commit 490a293

5 files changed

Lines changed: 200 additions & 2 deletions

File tree

CHANGELOG.md

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

8888
### Fixed
8989

90+
- BUG: Environment not Encoding Necessary Parameters for Decode [#1059](https://github.com/RocketPy-Team/RocketPy/pull/1059)
9091
- BUG: fix individual fin (`TrapezoidalFin`, `EllipticalFin`, `FreeFormFin`) serialization crash when saving rockets/flights [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048)
9192
- 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)
9293
- FIX: pre-release hardening — bug fixes across the unreleased features [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047)

rocketpy/environment/environment.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2924,13 +2924,43 @@ def to_dict(self, **kwargs):
29242924
"timezone": self.timezone,
29252925
"max_expected_height": self.max_expected_height,
29262926
"atmospheric_model_type": self.atmospheric_model_type,
2927+
"atmospheric_model_init_date": getattr(
2928+
self, "atmospheric_model_init_date", None
2929+
),
2930+
"atmospheric_model_end_date": getattr(
2931+
self, "atmospheric_model_end_date", None
2932+
),
2933+
"atmospheric_model_interval": getattr(
2934+
self, "atmospheric_model_interval", None
2935+
),
2936+
"atmospheric_model_init_lat": getattr(
2937+
self, "atmospheric_model_init_lat", None
2938+
),
2939+
"atmospheric_model_end_lat": getattr(
2940+
self, "atmospheric_model_end_lat", None
2941+
),
2942+
"atmospheric_model_init_lon": getattr(
2943+
self, "atmospheric_model_init_lon", None
2944+
),
2945+
"atmospheric_model_end_lon": getattr(
2946+
self, "atmospheric_model_end_lon", None
2947+
),
29272948
"pressure": self.pressure,
29282949
"temperature": self.temperature,
29292950
"wind_velocity_x": wind_velocity_x,
29302951
"wind_velocity_y": wind_velocity_y,
29312952
"wind_heading": wind_heading,
29322953
"wind_direction": wind_direction,
29332954
"wind_speed": wind_speed,
2955+
"level_ensemble": getattr(self, "level_ensemble", None),
2956+
"height_ensemble": getattr(self, "height_ensemble", None),
2957+
"temperature_ensemble": getattr(self, "temperature_ensemble", None),
2958+
"wind_u_ensemble": getattr(self, "wind_u_ensemble", None),
2959+
"wind_v_ensemble": getattr(self, "wind_v_ensemble", None),
2960+
"wind_heading_ensemble": getattr(self, "wind_heading_ensemble", None),
2961+
"wind_direction_ensemble": getattr(self, "wind_direction_ensemble", None),
2962+
"wind_speed_ensemble": getattr(self, "wind_speed_ensemble", None),
2963+
"num_ensemble_members": getattr(self, "num_ensemble_members", None),
29342964
}
29352965

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

29582989
match atmospheric_model:
29592990
case "standard_atmosphere":

rocketpy/environment/tools.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99
import math
1010
import warnings
11+
from datetime import datetime
1112

1213
import netCDF4
1314
import numpy as np
@@ -16,6 +17,23 @@
1617

1718
logger = logging.getLogger(__name__)
1819

20+
21+
def _to_datetime(date):
22+
"""Convert netCDF/cftime date-like values to standard datetimes."""
23+
if isinstance(date, datetime):
24+
return date
25+
26+
return datetime(
27+
date.year,
28+
date.month,
29+
date.day,
30+
date.hour,
31+
getattr(date, "minute", 0),
32+
getattr(date, "second", 0),
33+
getattr(date, "microsecond", 0),
34+
)
35+
36+
1937
## Wind data functions
2038

2139

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

595613

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

614632

615633
def get_interval_date_from_time_array(time_array, units=None):

tests/fixtures/environment/environment_fixtures.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@
55
from rocketpy import Environment, EnvironmentAnalysis
66

77

8+
class _DummyTimeArray:
9+
"""Minimal time array with netCDF-like units metadata."""
10+
11+
units = "hours since 2023-06-24 00:00:00"
12+
13+
def __init__(self, values):
14+
self.values = values
15+
16+
def __getitem__(self, index):
17+
return self.values[index]
18+
19+
20+
class _DummyCftimeDate:
21+
"""Small cftime-like datetime used to exercise NetCDF date conversion."""
22+
23+
year = 2023
24+
month = 6
25+
day = 24
26+
hour = 9
27+
minute = 30
28+
second = 15
29+
microsecond = 123456
30+
31+
32+
@pytest.fixture
33+
def dummy_time_array():
34+
"""NetCDF-like time array for environment date helper tests."""
35+
return _DummyTimeArray([0, 6])
36+
37+
38+
@pytest.fixture
39+
def dummy_cftime_date():
40+
"""cftime-like date object for environment date helper tests."""
41+
return _DummyCftimeDate()
42+
43+
844
@pytest.fixture
945
def example_plain_env():
1046
"""Simple object of the Environment class to be used in the tests.

tests/unit/environment/test_environment.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
22
import os
3+
from datetime import datetime
34

45
import numpy as np
6+
import numpy.testing as npt
57
import pytest
68
import pytz
79

@@ -10,6 +12,8 @@
1012
find_longitude_index,
1113
geodesic_to_lambert_conformal,
1214
geodesic_to_utm,
15+
get_final_date_from_time_array,
16+
get_initial_date_from_time_array,
1317
utm_to_geodesic,
1418
)
1519
from rocketpy.environment.weather_model_mapping import WeatherModelMapping
@@ -24,6 +28,27 @@ class DummyLambertProjection:
2428
earth_radius = 6371229.0
2529

2630

31+
@pytest.mark.parametrize(
32+
"date_helper", [get_initial_date_from_time_array, get_final_date_from_time_array]
33+
)
34+
def test_time_array_date_helpers_convert_cftime_dates(
35+
monkeypatch, date_helper, dummy_time_array, dummy_cftime_date
36+
):
37+
"""Convert NetCDF/cftime date objects to JSON-serializable datetimes."""
38+
39+
# Arrange
40+
def fake_num2date(*_args, **_kwargs):
41+
return dummy_cftime_date
42+
43+
monkeypatch.setattr("rocketpy.environment.tools.netCDF4.num2date", fake_num2date)
44+
45+
# Act
46+
converted_date = date_helper(dummy_time_array)
47+
48+
# Assert
49+
assert converted_date == datetime(2023, 6, 24, 9, 30, 15, 123456)
50+
51+
2752
@pytest.mark.parametrize(
2853
"latitude, longitude", [(-21.960641, -47.482122), (0, 0), (21.960641, 47.482122)]
2954
)
@@ -306,6 +331,93 @@ def test_environment_export_environment_exports_valid_environment_json(
306331
os.remove("environment.json")
307332

308333

334+
@pytest.mark.parametrize(
335+
"atmospheric_model_type", ["windy", "forecast", "reanalysis", "ensemble"]
336+
)
337+
def test_environment_to_dict_from_dict_round_trip_preserves_weather_metadata(
338+
example_plain_env, atmospheric_model_type
339+
):
340+
"""Round-trip weather-model environments without losing metadata.
341+
342+
Parameters
343+
----------
344+
example_plain_env : rocketpy.Environment
345+
Baseline environment used to build the serialized state.
346+
atmospheric_model_type : str
347+
Weather-model label stored in the serialized payload.
348+
"""
349+
# Arrange
350+
env = example_plain_env
351+
352+
weather_metadata = {
353+
"atmospheric_model_type": atmospheric_model_type,
354+
"atmospheric_model_file": None,
355+
"atmospheric_model_dict": {"time": "time"},
356+
"atmospheric_model_init_date": datetime(2024, 1, 1, 0),
357+
"atmospheric_model_end_date": datetime(2024, 1, 1, 6),
358+
"atmospheric_model_interval": 6,
359+
"atmospheric_model_init_lat": -10.0,
360+
"atmospheric_model_end_lat": 10.0,
361+
"atmospheric_model_init_lon": -20.0,
362+
"atmospheric_model_end_lon": 20.0,
363+
}
364+
365+
ensemble_metadata = {
366+
"level_ensemble": None,
367+
"height_ensemble": None,
368+
"temperature_ensemble": None,
369+
"wind_u_ensemble": None,
370+
"wind_v_ensemble": None,
371+
"wind_heading_ensemble": None,
372+
"wind_direction_ensemble": None,
373+
"wind_speed_ensemble": None,
374+
"num_ensemble_members": None,
375+
}
376+
377+
if atmospheric_model_type == "ensemble":
378+
ensemble_metadata.update(
379+
{
380+
"level_ensemble": np.array([1000.0, 900.0]),
381+
"height_ensemble": np.array([[0.0, 1000.0]]),
382+
"temperature_ensemble": np.array([[288.15, 281.15]]),
383+
"wind_u_ensemble": np.array([[2.0, 3.0]]),
384+
"wind_v_ensemble": np.array([[4.0, 5.0]]),
385+
"wind_heading_ensemble": np.array([[26.565051, 30.963757]]),
386+
"wind_direction_ensemble": np.array([[206.565051, 210.963757]]),
387+
"wind_speed_ensemble": np.array([[4.472136, 5.830952]]),
388+
"num_ensemble_members": 1,
389+
}
390+
)
391+
392+
for metadata in (weather_metadata, ensemble_metadata):
393+
for attribute, value in metadata.items():
394+
setattr(env, attribute, value)
395+
396+
env_dict = env.to_dict()
397+
398+
# The serialized payload should be self-contained and not depend on files.
399+
assert "atmospheric_model_file" not in env_dict
400+
assert "atmospheric_model_dict" not in env_dict
401+
402+
# Act
403+
restored_env = Environment.from_dict(env_dict)
404+
405+
# Assert
406+
assert restored_env.atmospheric_model_type == atmospheric_model_type
407+
assert restored_env.atmospheric_model_init_date == env.atmospheric_model_init_date
408+
assert restored_env.atmospheric_model_end_date == env.atmospheric_model_end_date
409+
assert restored_env.atmospheric_model_interval == env.atmospheric_model_interval
410+
assert restored_env.atmospheric_model_init_lat == env.atmospheric_model_init_lat
411+
assert restored_env.atmospheric_model_end_lat == env.atmospheric_model_end_lat
412+
assert restored_env.atmospheric_model_init_lon == env.atmospheric_model_init_lon
413+
assert restored_env.atmospheric_model_end_lon == env.atmospheric_model_end_lon
414+
415+
if atmospheric_model_type == "ensemble":
416+
npt.assert_allclose(restored_env.level_ensemble, env.level_ensemble)
417+
npt.assert_allclose(restored_env.height_ensemble, env.height_ensemble)
418+
assert restored_env.num_ensemble_members == env.num_ensemble_members
419+
420+
309421
class _DummyDataset:
310422
"""Small test double that mimics a netCDF dataset variables mapping."""
311423

0 commit comments

Comments
 (0)