Skip to content

Change simulation encoding workaround to official rocketpy encoders #51

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
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 src/models/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ApiBaseModel(BaseModel, ABC):
validate_default=True,
validate_all_in_root=True,
validate_assignment=True,
ser_json_exclude_none=True,
)

def set_id(self, value):
Expand Down
6 changes: 4 additions & 2 deletions src/repositories/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@ def __init__(self):

@repository_exception_handler
async def create_flight(self, flight: FlightModel) -> str:
return await self.insert(flight.model_dump())
return await self.insert(flight.model_dump(exclude_none=True))

@repository_exception_handler
async def read_flight_by_id(self, flight_id: str) -> Optional[FlightModel]:
return await self.find_by_id(data_id=flight_id)

@repository_exception_handler
async def update_flight_by_id(self, flight_id: str, flight: FlightModel):
await self.update_by_id(flight.model_dump(), data_id=flight_id)
await self.update_by_id(
flight.model_dump(exclude_none=True), data_id=flight_id
)
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Update semantics changed: None no longer clears fields

Same concern as in RocketRepository: exclude_none=True will ignore fields explicitly set to None, preventing clearing. Decide whether:

  • This is intended and document it, or
  • Implement $unset handling for None fields.

Happy to draft an interface-level change to support $unset cleanly if desired.


🏁 Script executed:

#!/bin/bash
# Sanity check: ensure all repositories applied the same exclude_none=True policy
rg -n "model_dump\(exclude_none=True\)" src/repositories

Length of output: 650


Support clearing fields set to None via $unset

Currently, all repository update calls use model_dump(exclude_none=True), so any fields explicitly set to None are omitted and won’t be cleared in the database. Please decide on one of the following:

• If omitting None is the intended behavior, add clear documentation (both inline and in our API docs) stating that fields cannot be cleared via None.
• Otherwise, extend update_by_id to handle $unset:
– Build a $set payload from non-None values
– Build an $unset payload from keys with None values
– Send both in a single update operation

Apply the chosen approach consistently in:

  • src/repositories/rocket.py (update_by_id call at line 31)
  • src/repositories/motor.py (update_by_id call at line 31)
  • src/repositories/flight.py (update_by_id call at line 31)

Let me know if you’d like a draft for the interface-level refactor to support $unset.


@repository_exception_handler
async def delete_flight_by_id(self, flight_id: str):
Expand Down
6 changes: 4 additions & 2 deletions src/repositories/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@ def __init__(self):

@repository_exception_handler
async def create_motor(self, motor: MotorModel) -> str:
return await self.insert(motor.model_dump())
return await self.insert(motor.model_dump(exclude_none=True))

@repository_exception_handler
async def read_motor_by_id(self, motor_id: str) -> Optional[MotorModel]:
return await self.find_by_id(data_id=motor_id)

@repository_exception_handler
async def update_motor_by_id(self, motor_id: str, motor: MotorModel):
await self.update_by_id(motor.model_dump(), data_id=motor_id)
await self.update_by_id(
motor.model_dump(exclude_none=True), data_id=motor_id
)
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

PUT behavior change: explicit nulls ignored on update

Applying exclude_none=True here prevents clients from nulling fields. If unsetting is a supported use case, consider the $set/$unset approach mentioned for Rocket/Flight repositories; otherwise, update API documentation to make this explicit.


🏁 Script executed:

#!/bin/bash
# Look for tests asserting null-able fields after update
rg -n "update_.*motor|client\.put\('/motors" -A 8 tests | rg -n "None|null"

Length of output: 179


Explicit nulls are ignored on updates; choose to implement unset logic or update docs accordingly

  • Location: src/repositories/motor.py, around the update_by_id( motor.model_dump(exclude_none=True), … ) call.
  • exclude_none=True drops any fields clients explicitly set to null, so you can’t use this endpoint to unset a value.
  • If unsetting fields is a required use case:
    • Adopt a $set/$unset pattern (see Rocket/Flight repos) to translate None into an $unset operation.
  • Otherwise, update the API documentation to clearly state that null values are ignored and cannot be used to clear existing fields.
🤖 Prompt for AI Agents
In src/repositories/motor.py around lines 30 to 32, the call to update_by_id
uses motor.model_dump(exclude_none=True) which strips fields explicitly set to
None so clients cannot unset values; either implement unset logic or document
behavior. To fix, implement a $set/$unset translation: serialize the model
without excluding None, iterate keys and build an update payload that places
non-None fields into a $set dict and None-valued fields into a $unset dict (or
omit $unset if unset not supported), then call update_by_id with the composed
Mongo-style update object; alternatively, if unsetting is intentionally
unsupported, update the API docs and client contract to state that nulls are
ignored and cannot clear fields.


@repository_exception_handler
async def delete_motor_by_id(self, motor_id: str):
Expand Down
6 changes: 4 additions & 2 deletions src/repositories/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@ def __init__(self):

@repository_exception_handler
async def create_rocket(self, rocket: RocketModel) -> str:
return await self.insert(rocket.model_dump())
return await self.insert(rocket.model_dump(exclude_none=True))

@repository_exception_handler
async def read_rocket_by_id(self, rocket_id: str) -> Optional[RocketModel]:
return await self.find_by_id(data_id=rocket_id)

@repository_exception_handler
async def update_rocket_by_id(self, rocket_id: str, rocket: RocketModel):
await self.update_by_id(rocket.model_dump(), data_id=rocket_id)
await self.update_by_id(
rocket.model_dump(exclude_none=True), data_id=rocket_id
)

@repository_exception_handler
async def delete_rocket_by_id(self, rocket_id: str):
Expand Down
6 changes: 4 additions & 2 deletions src/services/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import dill

from rocketpy.environment.environment import Environment as RocketPyEnvironment
from rocketpy.utilities import get_instance_attributes
from src.models.environment import EnvironmentModel
from src.views.environment import EnvironmentSimulation
from src.utils import rocketpy_encoder, DiscretizeConfig


class EnvironmentService:
Expand Down Expand Up @@ -50,7 +50,9 @@ def get_environment_simulation(self) -> EnvironmentSimulation:
EnvironmentSimulation
"""

attributes = get_instance_attributes(self.environment)
attributes = rocketpy_encoder(
self.environment, DiscretizeConfig.for_environment()
)
env_simulation = EnvironmentSimulation(**attributes)
return env_simulation

Expand Down
6 changes: 4 additions & 2 deletions src/services/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import dill

from rocketpy.simulation.flight import Flight as RocketPyFlight
from rocketpy.utilities import get_instance_attributes

from src.services.environment import EnvironmentService
from src.services.rocket import RocketService
from src.models.flight import FlightModel
from src.views.flight import FlightSimulation
from src.utils import rocketpy_encoder, DiscretizeConfig


class FlightService:
Expand Down Expand Up @@ -55,7 +55,9 @@ def get_flight_simulation(self) -> FlightSimulation:
Returns:
FlightSimulation
"""
attributes = get_instance_attributes(self.flight)
attributes = rocketpy_encoder(
self.flight, DiscretizeConfig.for_flight()
)
flight_simulation = FlightSimulation(**attributes)
return flight_simulation

Expand Down
4 changes: 2 additions & 2 deletions src/services/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from rocketpy.motors.solid_motor import SolidMotor
from rocketpy.motors.liquid_motor import LiquidMotor
from rocketpy.motors.hybrid_motor import HybridMotor
from rocketpy.utilities import get_instance_attributes
from rocketpy import (
LevelBasedTank,
MassBasedTank,
Expand All @@ -18,6 +17,7 @@
from src.models.sub.tanks import TankKinds
from src.models.motor import MotorKinds, MotorModel
from src.views.motor import MotorSimulation
from src.utils import rocketpy_encoder, DiscretizeConfig


class MotorService:
Expand Down Expand Up @@ -140,7 +140,7 @@ def get_motor_simulation(self) -> MotorSimulation:
Returns:
MotorSimulation
"""
attributes = get_instance_attributes(self.motor)
attributes = rocketpy_encoder(self.motor, DiscretizeConfig.for_motor())
motor_simulation = MotorSimulation(**attributes)
return motor_simulation

Expand Down
6 changes: 4 additions & 2 deletions src/services/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
Fins as RocketPyFins,
Tail as RocketPyTail,
)
from rocketpy.utilities import get_instance_attributes

from src import logger
from src.models.rocket import RocketModel, Parachute
from src.models.sub.aerosurfaces import NoseCone, Tail, Fins
from src.services.motor import MotorService
from src.views.rocket import RocketSimulation
from src.utils import rocketpy_encoder, DiscretizeConfig


class RocketService:
Expand Down Expand Up @@ -107,7 +107,9 @@ def get_rocket_simulation(self) -> RocketSimulation:
Returns:
RocketSimulation
"""
attributes = get_instance_attributes(self.rocket)
attributes = rocketpy_encoder(
self.rocket, DiscretizeConfig.for_rocket()
)
rocket_simulation = RocketSimulation(**attributes)
return rocket_simulation

Expand Down
118 changes: 92 additions & 26 deletions src/utils.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,113 @@
# fork of https://github.com/encode/starlette/blob/master/starlette/middleware/gzip.py
import gzip
import io
import logging
import json
import copy

from typing import Annotated, NoReturn, Any
import numpy as np
from typing import NoReturn, Tuple

from pydantic import PlainSerializer
from rocketpy import Function
from rocketpy._encoders import RocketPyEncoder
from starlette.datastructures import Headers, MutableHeaders
from starlette.types import ASGIApp, Message, Receive, Scope, Send

logger = logging.getLogger(__name__)

def to_python_primitive(v: Any) -> Any:

class DiscretizeConfig:
"""
Convert complex types to Python primitives.
Configuration class for RocketPy function discretization.

Args:
v: Any value, particularly those with a 'source' attribute
containing numpy arrays or generic types.
This class allows easy configuration of discretization parameters
for different types of RocketPy objects and their callable attributes.
"""

Returns:
The primitive representation of the input value.
def __init__(
self, bounds: Tuple[float, float] = (0, 10), samples: int = 200
):
self.bounds = bounds
self.samples = samples

@classmethod
def for_environment(cls) -> 'DiscretizeConfig':
return cls(bounds=(0, 50000), samples=100)

@classmethod
def for_motor(cls) -> 'DiscretizeConfig':
return cls(bounds=(0, 10), samples=150)

@classmethod
def for_rocket(cls) -> 'DiscretizeConfig':
return cls(bounds=(0, 1), samples=100)

@classmethod
def for_flight(cls) -> 'DiscretizeConfig':
return cls(bounds=(0, 30), samples=200)


def rocketpy_encoder(obj, config: DiscretizeConfig = DiscretizeConfig()):
"""
if hasattr(v, "source"):
if isinstance(v.source, np.ndarray):
return v.source.tolist()
Encode a RocketPy object using official RocketPy encoders.

if isinstance(v.source, (np.generic,)):
return v.source.item()
This function creates a copy of the object, discretizes callable Function
attributes on the copy, and then uses RocketPy's official RocketPyEncoder for
complete object serialization. The original object remains unchanged.

return str(v.source)
Args:
obj: RocketPy object (Environment, Motor, Rocket, Flight)
config: DiscretizeConfig object with discretization parameters (optional)

if isinstance(v, (np.generic,)):
return v.item()
Returns:
Dictionary of encoded attributes
"""

if isinstance(v, (np.ndarray,)):
return v.tolist()
# Create a copy to avoid mutating the original object
obj_copy = copy.deepcopy(obj)

for attr_name in dir(obj_copy):
if attr_name.startswith('_'):
continue

try:
attr_value = getattr(obj_copy, attr_name)
except Exception:
continue

if callable(attr_value) and isinstance(attr_value, Function):
try:
discretized_func = Function(attr_value.source)
Copy link
Preview

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new Function from attr_value.source may not preserve all properties of the original Function object. Consider using copy.deepcopy(attr_value) or a proper Function copy method if available.

Suggested change
discretized_func = Function(attr_value.source)
discretized_func = copy.deepcopy(attr_value)

Copilot uses AI. Check for mistakes.

discretized_func.set_discrete(
lower=config.bounds[0],
upper=config.bounds[1],
samples=config.samples,
mutate_self=True,
)

return str(v)
setattr(obj_copy, attr_name, discretized_func)

except Exception as e:
logger.warning(f"Failed to discretize {attr_name}: {e}")

AnyToPrimitive = Annotated[
Any,
PlainSerializer(to_python_primitive),
]
try:
json_str = json.dumps(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phmbressan @GabrielBarberini not sure who this is for but when i run the env class simulation I get an incorrect date format here

"date": [2025, 6, 9, 23]

get_instance_attributes parses it as a natural datetime object. Is this a encoder issue? Observed this behavior while printing this json_str variable.

obj_copy,
cls=RocketPyEncoder,
include_outputs=True,
include_function_data=True,
)
return json.loads(json_str)
Copy link
Preview

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encoding process involves creating a JSON string and then immediately parsing it back to a dictionary. Consider using the encoder directly to return a dictionary without the JSON round-trip if possible.

Suggested change
return json.loads(json_str)
encoder = RocketPyEncoder(
include_outputs=True,
include_function_data=True,
)
return encoder.default(obj_copy)

Copilot uses AI. Check for mistakes.

except Exception as e:
logger.warning(f"Failed to encode with RocketPyEncoder: {e}")
attributes = {}
for attr_name in dir(obj_copy):
if not attr_name.startswith('_'):
try:
attr_value = getattr(obj_copy, attr_name)
if not callable(attr_value):
attributes[attr_name] = str(attr_value)
except Exception:
continue
return attributes


class RocketPyGZipMiddleware:
Expand Down Expand Up @@ -70,6 +135,7 @@ async def __call__(


class GZipResponder:
# fork of https://github.com/encode/starlette/blob/master/starlette/middleware/gzip.py
def __init__(
self, app: ASGIApp, minimum_size: int, compresslevel: int = 9
) -> None:
Expand Down
60 changes: 37 additions & 23 deletions src/views/environment.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
from typing import Optional
from typing import Optional, Any
from datetime import datetime, timedelta
from pydantic import ConfigDict
from src.views.interface import ApiBaseView
from src.models.environment import EnvironmentModel
from src.utils import AnyToPrimitive


class EnvironmentSimulation(ApiBaseView):
"""
Environment simulation view that handles dynamically encoded RocketPy Environment attributes.

Uses the new rocketpy_encoder which may return different attributes based on the
actual RocketPy Environment object. The model allows extra fields to accommodate
any new attributes that might be encoded.
"""

model_config = ConfigDict(extra='allow', arbitrary_types_allowed=True)

message: str = "Environment successfully simulated"

# Core Environment attributes (always present)
latitude: Optional[float] = None
longitude: Optional[float] = None
elevation: Optional[float] = 1
Expand All @@ -26,27 +38,29 @@ class EnvironmentSimulation(ApiBaseView):
date: Optional[datetime] = datetime.today() + timedelta(days=1)
local_date: Optional[datetime] = datetime.today() + timedelta(days=1)
datetime_date: Optional[datetime] = datetime.today() + timedelta(days=1)
ellipsoid: Optional[AnyToPrimitive] = None
barometric_height: Optional[AnyToPrimitive] = None
barometric_height_ISA: Optional[AnyToPrimitive] = None
pressure: Optional[AnyToPrimitive] = None
pressure_ISA: Optional[AnyToPrimitive] = None
temperature: Optional[AnyToPrimitive] = None
temperature_ISA: Optional[AnyToPrimitive] = None
density: Optional[AnyToPrimitive] = None
speed_of_sound: Optional[AnyToPrimitive] = None
dynamic_viscosity: Optional[AnyToPrimitive] = None
gravity: Optional[AnyToPrimitive] = None
somigliana_gravity: Optional[AnyToPrimitive] = None
wind_speed: Optional[AnyToPrimitive] = None
wind_direction: Optional[AnyToPrimitive] = None
wind_heading: Optional[AnyToPrimitive] = None
wind_velocity_x: Optional[AnyToPrimitive] = None
wind_velocity_y: Optional[AnyToPrimitive] = None
calculate_earth_radius: Optional[AnyToPrimitive] = None
decimal_degrees_to_arc_seconds: Optional[AnyToPrimitive] = None
geodesic_to_utm: Optional[AnyToPrimitive] = None
utm_to_geodesic: Optional[AnyToPrimitive] = None

# Function attributes (discretized by rocketpy_encoder, serialized by RocketPyEncoder)
ellipsoid: Optional[Any] = None
barometric_height: Optional[Any] = None
barometric_height_ISA: Optional[Any] = None
pressure: Optional[Any] = None
pressure_ISA: Optional[Any] = None
temperature: Optional[Any] = None
temperature_ISA: Optional[Any] = None
density: Optional[Any] = None
speed_of_sound: Optional[Any] = None
dynamic_viscosity: Optional[Any] = None
gravity: Optional[Any] = None
somigliana_gravity: Optional[Any] = None
wind_speed: Optional[Any] = None
wind_direction: Optional[Any] = None
wind_heading: Optional[Any] = None
wind_velocity_x: Optional[Any] = None
wind_velocity_y: Optional[Any] = None
calculate_earth_radius: Optional[Any] = None
decimal_degrees_to_arc_seconds: Optional[Any] = None
geodesic_to_utm: Optional[Any] = None
utm_to_geodesic: Optional[Any] = None


class EnvironmentView(EnvironmentModel):
Expand Down
Loading