Skip to content

Commit cbbdb65

Browse files
authored
ENH: Expansion of Encoders Implementation for Full Flights. (#679)
* ENH: expand encoders implementation to support full flights. MNT: Add encoding feature to CHANGELOG. BUG: add dill to the requirements file. * ENH: provide from_dict classmethods for decoding basic classes. ENH: extend encoding and decoding to Liquid and Hybrid. MNT: correct decoding of liquid and hybrid motors. STY: solve pylint remarks. MNT: adapt encoding to new post merge attributes. MNT: restore typo to correct values on flight test. ENH: add option for including outputs on JSON export. TST: add tests for motor encoding. DOC: Improve docstrings of encoders signature. MNT: Make no output encoding the default. MNT: Standardize include outputs parameter. DOC: Correct phrasing and typos of encoders docstring. MNT: Correct json export environment naming. * MNT: Allow for encoding customization of MonteCarlo. DEV: fix CHANGELOG MNT: reposition barometric height as env json output. MNT: Allow for encoding customization of MonteCarlo.
1 parent 2218f0f commit cbbdb65

36 files changed

+3660
-1815
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ Attention: The newest changes should be on top -->
3838
- ENH: create a dataset of pre-registered motors. See #664 [#744](https://github.com/RocketPy-Team/RocketPy/pull/744)
3939
- DOC: add Defiance flight example [#742](https://github.com/RocketPy-Team/RocketPy/pull/742)
4040
- ENH: Allow for Alternative and Custom ODE Solvers. [#748](https://github.com/RocketPy-Team/RocketPy/pull/748)
41+
- ENH: Expansion of Encoders Implementation for Full Flights. [#679](https://github.com/RocketPy-Team/RocketPy/pull/679)
42+
4143

4244

4345
### Changed

docs/notebooks/monte_carlo_analysis/monte_carlo_analysis_outputs/monte_carlo_class_example.inputs.txt

+1,000-819
Large diffs are not rendered by default.

docs/notebooks/monte_carlo_analysis/monte_carlo_analysis_outputs/monte_carlo_class_example.kml

+30-30
Large diffs are not rendered by default.

docs/notebooks/monte_carlo_analysis/monte_carlo_analysis_outputs/monte_carlo_class_example.outputs.txt

+1,000-819
Large diffs are not rendered by default.

docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb

+90-77
Large diffs are not rendered by default.

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ netCDF4>=1.6.4
55
requests
66
pytz
77
simplekml
8+
dill

rocketpy/_encoders.py

+130-8
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
"""Defines a custom JSON encoder for RocketPy objects."""
22

33
import json
4-
import types
4+
from datetime import datetime
5+
from importlib import import_module
56

67
import numpy as np
78

89
from rocketpy.mathutils.function import Function
910

1011

1112
class RocketPyEncoder(json.JSONEncoder):
12-
"""NOTE: This is still under construction, please don't use it yet."""
13+
"""Custom JSON encoder for RocketPy objects. It defines how to encode
14+
different types of objects to a JSON supported format."""
15+
16+
def __init__(self, *args, **kwargs):
17+
self.include_outputs = kwargs.pop("include_outputs", False)
18+
self.include_function_data = kwargs.pop("include_function_data", True)
19+
super().__init__(*args, **kwargs)
1320

1421
def default(self, o):
1522
if isinstance(
@@ -33,11 +40,126 @@ def default(self, o):
3340
return float(o)
3441
elif isinstance(o, np.ndarray):
3542
return o.tolist()
43+
elif isinstance(o, datetime):
44+
return [o.year, o.month, o.day, o.hour]
45+
elif hasattr(o, "__iter__") and not isinstance(o, str):
46+
return list(o)
47+
elif isinstance(o, Function):
48+
if not self.include_function_data:
49+
return str(o)
50+
else:
51+
encoding = o.to_dict(self.include_outputs)
52+
encoding["signature"] = get_class_signature(o)
53+
return encoding
3654
elif hasattr(o, "to_dict"):
37-
return o.to_dict()
38-
# elif isinstance(o, Function):
39-
# return o.__dict__()
40-
elif isinstance(o, (Function, types.FunctionType)):
41-
return repr(o)
55+
encoding = o.to_dict(self.include_outputs)
56+
encoding = remove_circular_references(encoding)
57+
58+
encoding["signature"] = get_class_signature(o)
59+
60+
return encoding
61+
62+
elif hasattr(o, "__dict__"):
63+
encoding = remove_circular_references(o.__dict__)
64+
65+
if "rocketpy" in o.__class__.__module__:
66+
encoding["signature"] = get_class_signature(o)
67+
68+
return encoding
69+
else:
70+
return super().default(o)
71+
72+
73+
class RocketPyDecoder(json.JSONDecoder):
74+
"""Custom JSON decoder for RocketPy objects. It defines how to decode
75+
different types of objects from a JSON supported format."""
76+
77+
def __init__(self, *args, **kwargs):
78+
super().__init__(object_hook=self.object_hook, *args, **kwargs)
79+
80+
def object_hook(self, obj):
81+
if "signature" in obj:
82+
signature = obj.pop("signature")
83+
84+
try:
85+
class_ = get_class_from_signature(signature)
86+
87+
if hasattr(class_, "from_dict"):
88+
return class_.from_dict(obj)
89+
else:
90+
# Filter keyword arguments
91+
kwargs = {
92+
key: value
93+
for key, value in obj.items()
94+
if key in class_.__init__.__code__.co_varnames
95+
}
96+
97+
return class_(**kwargs)
98+
except (ImportError, AttributeError):
99+
return obj
42100
else:
43-
return json.JSONEncoder.default(self, o)
101+
return obj
102+
103+
104+
def get_class_signature(obj):
105+
"""Returns the signature of a class so it can be identified on
106+
decoding. The signature is a dictionary with the module and
107+
name of the object's class as strings.
108+
109+
110+
Parameters
111+
----------
112+
obj : object
113+
Object to get the signature from.
114+
115+
Returns
116+
-------
117+
dict
118+
Signature of the class.
119+
"""
120+
class_ = obj.__class__
121+
name = getattr(class_, '__qualname__', class_.__name__)
122+
123+
return {"module": class_.__module__, "name": name}
124+
125+
126+
def get_class_from_signature(signature):
127+
"""Returns the class from its signature dictionary by
128+
importing the module and loading the class.
129+
130+
Parameters
131+
----------
132+
signature : dict
133+
Signature of the class.
134+
135+
Returns
136+
-------
137+
type
138+
Class defined by the signature.
139+
"""
140+
module = import_module(signature["module"])
141+
inner_class = None
142+
143+
for class_ in signature["name"].split("."):
144+
inner_class = getattr(module, class_)
145+
146+
return inner_class
147+
148+
149+
def remove_circular_references(obj_dict):
150+
"""Removes circular references from a dictionary.
151+
152+
Parameters
153+
----------
154+
obj_dict : dict
155+
Dictionary to remove circular references from.
156+
157+
Returns
158+
-------
159+
dict
160+
Dictionary without circular references.
161+
"""
162+
obj_dict.pop("prints", None)
163+
obj_dict.pop("plots", None)
164+
165+
return obj_dict

rocketpy/environment/environment.py

+103-7
Original file line numberDiff line numberDiff line change
@@ -366,12 +366,15 @@ def __initialize_constants(self):
366366
self.standard_g = 9.80665
367367
self.__weather_model_map = WeatherModelMapping()
368368
self.__atm_type_file_to_function_map = {
369-
("forecast", "GFS"): fetch_gfs_file_return_dataset,
370-
("forecast", "NAM"): fetch_nam_file_return_dataset,
371-
("forecast", "RAP"): fetch_rap_file_return_dataset,
372-
("forecast", "HIRESW"): fetch_hiresw_file_return_dataset,
373-
("ensemble", "GEFS"): fetch_gefs_ensemble,
374-
# ("ensemble", "CMC"): fetch_cmc_ensemble,
369+
"forecast": {
370+
"GFS": fetch_gfs_file_return_dataset,
371+
"NAM": fetch_nam_file_return_dataset,
372+
"RAP": fetch_rap_file_return_dataset,
373+
"HIRESW": fetch_hiresw_file_return_dataset,
374+
},
375+
"ensemble": {
376+
"GEFS": fetch_gefs_ensemble,
377+
},
375378
}
376379
self.__standard_atmosphere_layers = {
377380
"geopotential_height": [ # in geopotential m
@@ -1270,7 +1273,10 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
12701273
self.process_windy_atmosphere(file)
12711274
elif type in ["forecast", "reanalysis", "ensemble"]:
12721275
dictionary = self.__validate_dictionary(file, dictionary)
1273-
fetch_function = self.__atm_type_file_to_function_map.get((type, file))
1276+
try:
1277+
fetch_function = self.__atm_type_file_to_function_map[type][file]
1278+
except KeyError:
1279+
fetch_function = None
12741280

12751281
# Fetches the dataset using OpenDAP protocol or uses the file path
12761282
dataset = fetch_function() if fetch_function is not None else file
@@ -2748,6 +2754,96 @@ def decimal_degrees_to_arc_seconds(angle):
27482754
arc_seconds = (remainder * 60 - arc_minutes) * 60
27492755
return degrees, arc_minutes, arc_seconds
27502756

2757+
def to_dict(self, include_outputs=False):
2758+
env_dict = {
2759+
"gravity": self.gravity,
2760+
"date": self.date,
2761+
"latitude": self.latitude,
2762+
"longitude": self.longitude,
2763+
"elevation": self.elevation,
2764+
"datum": self.datum,
2765+
"timezone": self.timezone,
2766+
"max_expected_height": self.max_expected_height,
2767+
"atmospheric_model_type": self.atmospheric_model_type,
2768+
"pressure": self.pressure,
2769+
"temperature": self.temperature,
2770+
"wind_velocity_x": self.wind_velocity_x,
2771+
"wind_velocity_y": self.wind_velocity_y,
2772+
"wind_heading": self.wind_heading,
2773+
"wind_direction": self.wind_direction,
2774+
"wind_speed": self.wind_speed,
2775+
}
2776+
2777+
if include_outputs:
2778+
env_dict["density"] = self.density
2779+
env_dict["barometric_height"] = self.barometric_height
2780+
env_dict["speed_of_sound"] = self.speed_of_sound
2781+
env_dict["dynamic_viscosity"] = self.dynamic_viscosity
2782+
2783+
return env_dict
2784+
2785+
@classmethod
2786+
def from_dict(cls, data): # pylint: disable=too-many-statements
2787+
env = cls(
2788+
gravity=data["gravity"],
2789+
date=data["date"],
2790+
latitude=data["latitude"],
2791+
longitude=data["longitude"],
2792+
elevation=data["elevation"],
2793+
datum=data["datum"],
2794+
timezone=data["timezone"],
2795+
max_expected_height=data["max_expected_height"],
2796+
)
2797+
atmospheric_model = data["atmospheric_model_type"]
2798+
2799+
if atmospheric_model == "standard_atmosphere":
2800+
env.set_atmospheric_model("standard_atmosphere")
2801+
elif atmospheric_model == "custom_atmosphere":
2802+
env.set_atmospheric_model(
2803+
type="custom_atmosphere",
2804+
pressure=data["pressure"],
2805+
temperature=data["temperature"],
2806+
wind_u=data["wind_velocity_x"],
2807+
wind_v=data["wind_velocity_y"],
2808+
)
2809+
else:
2810+
env.__set_pressure_function(data["pressure"])
2811+
env.__set_temperature_function(data["temperature"])
2812+
env.__set_wind_velocity_x_function(data["wind_velocity_x"])
2813+
env.__set_wind_velocity_y_function(data["wind_velocity_y"])
2814+
env.__set_wind_heading_function(data["wind_heading"])
2815+
env.__set_wind_direction_function(data["wind_direction"])
2816+
env.__set_wind_speed_function(data["wind_speed"])
2817+
env.elevation = data["elevation"]
2818+
env.max_expected_height = data["max_expected_height"]
2819+
2820+
if atmospheric_model in ("windy", "forecast", "reanalysis", "ensemble"):
2821+
env.atmospheric_model_init_date = data["atmospheric_model_init_date"]
2822+
env.atmospheric_model_end_date = data["atmospheric_model_end_date"]
2823+
env.atmospheric_model_interval = data["atmospheric_model_interval"]
2824+
env.atmospheric_model_init_lat = data["atmospheric_model_init_lat"]
2825+
env.atmospheric_model_end_lat = data["atmospheric_model_end_lat"]
2826+
env.atmospheric_model_init_lon = data["atmospheric_model_init_lon"]
2827+
env.atmospheric_model_end_lon = data["atmospheric_model_end_lon"]
2828+
2829+
if atmospheric_model == "ensemble":
2830+
env.level_ensemble = data["level_ensemble"]
2831+
env.height_ensemble = data["height_ensemble"]
2832+
env.temperature_ensemble = data["temperature_ensemble"]
2833+
env.wind_u_ensemble = data["wind_u_ensemble"]
2834+
env.wind_v_ensemble = data["wind_v_ensemble"]
2835+
env.wind_heading_ensemble = data["wind_heading_ensemble"]
2836+
env.wind_direction_ensemble = data["wind_direction_ensemble"]
2837+
env.wind_speed_ensemble = data["wind_speed_ensemble"]
2838+
env.num_ensemble_members = data["num_ensemble_members"]
2839+
2840+
env.__reset_barometric_height_function()
2841+
env.calculate_density_profile()
2842+
env.calculate_speed_of_sound_profile()
2843+
env.calculate_dynamic_viscosity()
2844+
2845+
return env
2846+
27512847

27522848
if __name__ == "__main__":
27532849
import doctest

rocketpy/environment/environment_analysis.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,10 @@ def __check_coordinates_inside_grid(
423423
or lat_index > len(lat_array) - 1
424424
):
425425
raise ValueError(
426-
f"Latitude and longitude pair {(self.latitude, self.longitude)} is outside the grid available in the given file, which is defined by {(lat_array[0], lon_array[0])} and {(lat_array[-1], lon_array[-1])}."
426+
f"Latitude and longitude pair {(self.latitude, self.longitude)} "
427+
"is outside the grid available in the given file, which "
428+
f"is defined by {(lat_array[0], lon_array[0])} and "
429+
f"{(lat_array[-1], lon_array[-1])}."
427430
)
428431

429432
def __localize_input_dates(self):

rocketpy/mathutils/function.py

+49-3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
RBFInterpolator,
2323
)
2424

25+
from rocketpy.tools import from_hex_decode, to_hex_encode
26+
2527
from ..plots.plot_helpers import show_or_save_plot
2628

2729
# Numpy 1.x compatibility,
@@ -711,9 +713,9 @@ def set_discrete(
711713
if func.__dom_dim__ == 1:
712714
xs = np.linspace(lower, upper, samples)
713715
ys = func.get_value(xs.tolist()) if one_by_one else func.get_value(xs)
714-
func.set_source(np.concatenate(([xs], [ys])).transpose())
715-
func.set_interpolation(interpolation)
716-
func.set_extrapolation(extrapolation)
716+
func.__interpolation__ = interpolation
717+
func.__extrapolation__ = extrapolation
718+
func.set_source(np.column_stack((xs, ys)))
717719
elif func.__dom_dim__ == 2:
718720
lower = 2 * [lower] if isinstance(lower, NUMERICAL_TYPES) else lower
719721
upper = 2 * [upper] if isinstance(upper, NUMERICAL_TYPES) else upper
@@ -3418,6 +3420,50 @@ def __validate_extrapolation(self, extrapolation):
34183420
extrapolation = "natural"
34193421
return extrapolation
34203422

3423+
def to_dict(self, include_outputs=False): # pylint: disable=unused-argument
3424+
"""Serializes the Function instance to a dictionary.
3425+
3426+
Returns
3427+
-------
3428+
dict
3429+
A dictionary containing the Function's attributes.
3430+
"""
3431+
source = self.source
3432+
3433+
if callable(source):
3434+
source = to_hex_encode(source)
3435+
3436+
return {
3437+
"source": source,
3438+
"title": self.title,
3439+
"inputs": self.__inputs__,
3440+
"outputs": self.__outputs__,
3441+
"interpolation": self.__interpolation__,
3442+
"extrapolation": self.__extrapolation__,
3443+
}
3444+
3445+
@classmethod
3446+
def from_dict(cls, func_dict):
3447+
"""Creates a Function instance from a dictionary.
3448+
3449+
Parameters
3450+
----------
3451+
func_dict
3452+
The JSON like Function dictionary.
3453+
"""
3454+
source = func_dict["source"]
3455+
if func_dict["interpolation"] is None and func_dict["extrapolation"] is None:
3456+
source = from_hex_decode(source)
3457+
3458+
return cls(
3459+
source=source,
3460+
interpolation=func_dict["interpolation"],
3461+
extrapolation=func_dict["extrapolation"],
3462+
inputs=func_dict["inputs"],
3463+
outputs=func_dict["outputs"],
3464+
title=func_dict["title"],
3465+
)
3466+
34213467

34223468
def funcify_method(*args, **kwargs): # pylint: disable=too-many-statements
34233469
"""Decorator factory to wrap methods as Function objects and save them as

0 commit comments

Comments
 (0)