Skip to content

Commit

Permalink
ENH: Expansion of Encoders Implementation for Full Flights. (#679)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
phmbressan authored Dec 21, 2024
1 parent 2218f0f commit cbbdb65
Show file tree
Hide file tree
Showing 36 changed files with 3,660 additions and 1,815 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Attention: The newest changes should be on top -->
- ENH: create a dataset of pre-registered motors. See #664 [#744](https://github.com/RocketPy-Team/RocketPy/pull/744)
- DOC: add Defiance flight example [#742](https://github.com/RocketPy-Team/RocketPy/pull/742)
- ENH: Allow for Alternative and Custom ODE Solvers. [#748](https://github.com/RocketPy-Team/RocketPy/pull/748)
- ENH: Expansion of Encoders Implementation for Full Flights. [#679](https://github.com/RocketPy-Team/RocketPy/pull/679)



### Changed
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

167 changes: 90 additions & 77 deletions docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ netCDF4>=1.6.4
requests
pytz
simplekml
dill
138 changes: 130 additions & 8 deletions rocketpy/_encoders.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
"""Defines a custom JSON encoder for RocketPy objects."""

import json
import types
from datetime import datetime
from importlib import import_module

import numpy as np

from rocketpy.mathutils.function import Function


class RocketPyEncoder(json.JSONEncoder):
"""NOTE: This is still under construction, please don't use it yet."""
"""Custom JSON encoder for RocketPy objects. It defines how to encode
different types of objects to a JSON supported format."""

def __init__(self, *args, **kwargs):
self.include_outputs = kwargs.pop("include_outputs", False)
self.include_function_data = kwargs.pop("include_function_data", True)
super().__init__(*args, **kwargs)

def default(self, o):
if isinstance(
Expand All @@ -33,11 +40,126 @@ def default(self, o):
return float(o)
elif isinstance(o, np.ndarray):
return o.tolist()
elif isinstance(o, datetime):
return [o.year, o.month, o.day, o.hour]
elif hasattr(o, "__iter__") and not isinstance(o, str):
return list(o)
elif isinstance(o, Function):
if not self.include_function_data:
return str(o)
else:
encoding = o.to_dict(self.include_outputs)
encoding["signature"] = get_class_signature(o)
return encoding
elif hasattr(o, "to_dict"):
return o.to_dict()
# elif isinstance(o, Function):
# return o.__dict__()
elif isinstance(o, (Function, types.FunctionType)):
return repr(o)
encoding = o.to_dict(self.include_outputs)
encoding = remove_circular_references(encoding)

encoding["signature"] = get_class_signature(o)

return encoding

elif hasattr(o, "__dict__"):
encoding = remove_circular_references(o.__dict__)

if "rocketpy" in o.__class__.__module__:
encoding["signature"] = get_class_signature(o)

return encoding
else:
return super().default(o)


class RocketPyDecoder(json.JSONDecoder):
"""Custom JSON decoder for RocketPy objects. It defines how to decode
different types of objects from a JSON supported format."""

def __init__(self, *args, **kwargs):
super().__init__(object_hook=self.object_hook, *args, **kwargs)

def object_hook(self, obj):
if "signature" in obj:
signature = obj.pop("signature")

try:
class_ = get_class_from_signature(signature)

if hasattr(class_, "from_dict"):
return class_.from_dict(obj)
else:
# Filter keyword arguments
kwargs = {
key: value
for key, value in obj.items()
if key in class_.__init__.__code__.co_varnames
}

return class_(**kwargs)
except (ImportError, AttributeError):
return obj
else:
return json.JSONEncoder.default(self, o)
return obj


def get_class_signature(obj):
"""Returns the signature of a class so it can be identified on
decoding. The signature is a dictionary with the module and
name of the object's class as strings.
Parameters
----------
obj : object
Object to get the signature from.
Returns
-------
dict
Signature of the class.
"""
class_ = obj.__class__
name = getattr(class_, '__qualname__', class_.__name__)

return {"module": class_.__module__, "name": name}


def get_class_from_signature(signature):
"""Returns the class from its signature dictionary by
importing the module and loading the class.
Parameters
----------
signature : dict
Signature of the class.
Returns
-------
type
Class defined by the signature.
"""
module = import_module(signature["module"])
inner_class = None

for class_ in signature["name"].split("."):
inner_class = getattr(module, class_)

return inner_class


def remove_circular_references(obj_dict):
"""Removes circular references from a dictionary.
Parameters
----------
obj_dict : dict
Dictionary to remove circular references from.
Returns
-------
dict
Dictionary without circular references.
"""
obj_dict.pop("prints", None)
obj_dict.pop("plots", None)

return obj_dict
110 changes: 103 additions & 7 deletions rocketpy/environment/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,12 +366,15 @@ def __initialize_constants(self):
self.standard_g = 9.80665
self.__weather_model_map = WeatherModelMapping()
self.__atm_type_file_to_function_map = {
("forecast", "GFS"): fetch_gfs_file_return_dataset,
("forecast", "NAM"): fetch_nam_file_return_dataset,
("forecast", "RAP"): fetch_rap_file_return_dataset,
("forecast", "HIRESW"): fetch_hiresw_file_return_dataset,
("ensemble", "GEFS"): fetch_gefs_ensemble,
# ("ensemble", "CMC"): fetch_cmc_ensemble,
"forecast": {
"GFS": fetch_gfs_file_return_dataset,
"NAM": fetch_nam_file_return_dataset,
"RAP": fetch_rap_file_return_dataset,
"HIRESW": fetch_hiresw_file_return_dataset,
},
"ensemble": {
"GEFS": fetch_gefs_ensemble,
},
}
self.__standard_atmosphere_layers = {
"geopotential_height": [ # in geopotential m
Expand Down Expand Up @@ -1270,7 +1273,10 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
self.process_windy_atmosphere(file)
elif type in ["forecast", "reanalysis", "ensemble"]:
dictionary = self.__validate_dictionary(file, dictionary)
fetch_function = self.__atm_type_file_to_function_map.get((type, file))
try:
fetch_function = self.__atm_type_file_to_function_map[type][file]
except KeyError:
fetch_function = None

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

def to_dict(self, include_outputs=False):
env_dict = {
"gravity": self.gravity,
"date": self.date,
"latitude": self.latitude,
"longitude": self.longitude,
"elevation": self.elevation,
"datum": self.datum,
"timezone": self.timezone,
"max_expected_height": self.max_expected_height,
"atmospheric_model_type": self.atmospheric_model_type,
"pressure": self.pressure,
"temperature": self.temperature,
"wind_velocity_x": self.wind_velocity_x,
"wind_velocity_y": self.wind_velocity_y,
"wind_heading": self.wind_heading,
"wind_direction": self.wind_direction,
"wind_speed": self.wind_speed,
}

if include_outputs:
env_dict["density"] = self.density
env_dict["barometric_height"] = self.barometric_height
env_dict["speed_of_sound"] = self.speed_of_sound
env_dict["dynamic_viscosity"] = self.dynamic_viscosity

return env_dict

@classmethod
def from_dict(cls, data): # pylint: disable=too-many-statements
env = cls(
gravity=data["gravity"],
date=data["date"],
latitude=data["latitude"],
longitude=data["longitude"],
elevation=data["elevation"],
datum=data["datum"],
timezone=data["timezone"],
max_expected_height=data["max_expected_height"],
)
atmospheric_model = data["atmospheric_model_type"]

if atmospheric_model == "standard_atmosphere":
env.set_atmospheric_model("standard_atmosphere")
elif atmospheric_model == "custom_atmosphere":
env.set_atmospheric_model(
type="custom_atmosphere",
pressure=data["pressure"],
temperature=data["temperature"],
wind_u=data["wind_velocity_x"],
wind_v=data["wind_velocity_y"],
)
else:
env.__set_pressure_function(data["pressure"])
env.__set_temperature_function(data["temperature"])
env.__set_wind_velocity_x_function(data["wind_velocity_x"])
env.__set_wind_velocity_y_function(data["wind_velocity_y"])
env.__set_wind_heading_function(data["wind_heading"])
env.__set_wind_direction_function(data["wind_direction"])
env.__set_wind_speed_function(data["wind_speed"])
env.elevation = data["elevation"]
env.max_expected_height = data["max_expected_height"]

if atmospheric_model in ("windy", "forecast", "reanalysis", "ensemble"):
env.atmospheric_model_init_date = data["atmospheric_model_init_date"]
env.atmospheric_model_end_date = data["atmospheric_model_end_date"]
env.atmospheric_model_interval = data["atmospheric_model_interval"]
env.atmospheric_model_init_lat = data["atmospheric_model_init_lat"]
env.atmospheric_model_end_lat = data["atmospheric_model_end_lat"]
env.atmospheric_model_init_lon = data["atmospheric_model_init_lon"]
env.atmospheric_model_end_lon = data["atmospheric_model_end_lon"]

if atmospheric_model == "ensemble":
env.level_ensemble = data["level_ensemble"]
env.height_ensemble = data["height_ensemble"]
env.temperature_ensemble = data["temperature_ensemble"]
env.wind_u_ensemble = data["wind_u_ensemble"]
env.wind_v_ensemble = data["wind_v_ensemble"]
env.wind_heading_ensemble = data["wind_heading_ensemble"]
env.wind_direction_ensemble = data["wind_direction_ensemble"]
env.wind_speed_ensemble = data["wind_speed_ensemble"]
env.num_ensemble_members = data["num_ensemble_members"]

env.__reset_barometric_height_function()
env.calculate_density_profile()
env.calculate_speed_of_sound_profile()
env.calculate_dynamic_viscosity()

return env


if __name__ == "__main__":
import doctest
Expand Down
5 changes: 4 additions & 1 deletion rocketpy/environment/environment_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,10 @@ def __check_coordinates_inside_grid(
or lat_index > len(lat_array) - 1
):
raise ValueError(
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])}."
f"Latitude and longitude pair {(self.latitude, self.longitude)} "
"is outside the grid available in the given file, which "
f"is defined by {(lat_array[0], lon_array[0])} and "
f"{(lat_array[-1], lon_array[-1])}."
)

def __localize_input_dates(self):
Expand Down
52 changes: 49 additions & 3 deletions rocketpy/mathutils/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
RBFInterpolator,
)

from rocketpy.tools import from_hex_decode, to_hex_encode

from ..plots.plot_helpers import show_or_save_plot

# Numpy 1.x compatibility,
Expand Down Expand Up @@ -711,9 +713,9 @@ def set_discrete(
if func.__dom_dim__ == 1:
xs = np.linspace(lower, upper, samples)
ys = func.get_value(xs.tolist()) if one_by_one else func.get_value(xs)
func.set_source(np.concatenate(([xs], [ys])).transpose())
func.set_interpolation(interpolation)
func.set_extrapolation(extrapolation)
func.__interpolation__ = interpolation
func.__extrapolation__ = extrapolation
func.set_source(np.column_stack((xs, ys)))
elif func.__dom_dim__ == 2:
lower = 2 * [lower] if isinstance(lower, NUMERICAL_TYPES) else lower
upper = 2 * [upper] if isinstance(upper, NUMERICAL_TYPES) else upper
Expand Down Expand Up @@ -3418,6 +3420,50 @@ def __validate_extrapolation(self, extrapolation):
extrapolation = "natural"
return extrapolation

def to_dict(self, include_outputs=False): # pylint: disable=unused-argument
"""Serializes the Function instance to a dictionary.
Returns
-------
dict
A dictionary containing the Function's attributes.
"""
source = self.source

if callable(source):
source = to_hex_encode(source)

return {
"source": source,
"title": self.title,
"inputs": self.__inputs__,
"outputs": self.__outputs__,
"interpolation": self.__interpolation__,
"extrapolation": self.__extrapolation__,
}

@classmethod
def from_dict(cls, func_dict):
"""Creates a Function instance from a dictionary.
Parameters
----------
func_dict
The JSON like Function dictionary.
"""
source = func_dict["source"]
if func_dict["interpolation"] is None and func_dict["extrapolation"] is None:
source = from_hex_decode(source)

return cls(
source=source,
interpolation=func_dict["interpolation"],
extrapolation=func_dict["extrapolation"],
inputs=func_dict["inputs"],
outputs=func_dict["outputs"],
title=func_dict["title"],
)


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

0 comments on commit cbbdb65

Please sign in to comment.