Skip to content

adapters.rocketpy

machwave.adapters.rocketpy

RocketPy adapter module for converting machwave motors to RocketPy motor objects.

RocketPyAdapterError

Bases: Exception

Base exception for RocketPy adapter errors.

Source code in machwave/adapters/rocketpy/base.py
class RocketPyAdapterError(Exception):
    """Base exception for RocketPy adapter errors."""

    pass

RocketPyMotorAdapter

Bases: ABC, Generic[M]

Abstract base class for RocketPy motor adapters.

Dynamically inherits from the appropriate RocketPy Motor class. Subclasses must specify _rocketpy_motor_class to indicate which RocketPy Motor class to adapt to.

Source code in machwave/adapters/rocketpy/base.py
class RocketPyMotorAdapter(abc.ABC, typing.Generic[M]):
    """Abstract base class for RocketPy motor adapters.

    Dynamically inherits from the appropriate RocketPy Motor class. Subclasses must
    specify _rocketpy_motor_class to indicate which RocketPy Motor class to adapt to.
    """

    # Subclasses must override this to specify the RocketPy motor class name
    _rocketpy_motor_class: typing.ClassVar[str] = "Motor"

    @staticmethod
    def _require_rocketpy() -> None:
        """Ensure rocketpy is available.

        Raises:
            ImportError: If rocketpy is not installed.
        """
        try:
            import rocketpy  # noqa: F401
        except ImportError as e:
            raise ImportError(
                "RocketPy adapters require the 'rocketpy' package. Install it with: "
                "pip install rocketpy"
            ) from e

    def __init_subclass__(cls, **kwargs: typing.Any) -> None:
        """Dynamically inherit from specified RocketPy Motor class when subclass is created."""
        super().__init_subclass__(**kwargs)

        cls._require_rocketpy()
        import rocketpy.motors as rocketpy_motors

        # Get the specific motor class to inherit from
        motor_class_name = cls._rocketpy_motor_class
        if not hasattr(rocketpy_motors, motor_class_name):
            raise AttributeError(
                f"RocketPy motors module has no class '{motor_class_name}'"
            )

        motor_class = getattr(rocketpy_motors, motor_class_name)

        # Dynamically add the specific Motor class to the base classes if not already there
        if not any(
            isinstance(base, type) and issubclass(base, motor_class)
            for base in cls.__bases__
        ):
            cls.__bases__ = cls.__bases__ + (motor_class,)

    def __init__(self, motor_state: M) -> None:
        """Initialize the adapter with a Machwave motor state.

        Args:
            The Machwave motor state to adapt.
        """
        self._require_rocketpy()

        self.motor_state = motor_state
        self.motor = motor_state.motor

        attrs = self._get_rocketpy_attributes()
        super().__init__(**attrs)

    def _get_rocketpy_attributes(self) -> dict[str, typing.Any]:
        """Extract motor attributes and time series compatible with RocketPy."""
        time = self.motor_state.t
        thrust = self.motor_state.thrust

        thrust_chamber = self.motor.thrust_chamber
        nozzle = thrust_chamber.nozzle

        thrust_source = np.column_stack((time, thrust))

        # Axial position of the dry mass center of gravity.
        # Both machwave and RocketPy use nozzle exit as origin, positive toward
        # the bulkhead ("nozzle_to_combustion_chamber" orientation).
        center_of_dry_mass_position = (
            thrust_chamber.center_of_gravity_coordinate[0]
            if thrust_chamber.center_of_gravity_coordinate is not None
            else 0.0
        )

        return {
            "thrust_source": thrust_source,
            "dry_inertia": (0.0, 0.0, 0.0),  # TODO: Calculate dry mass inertia tensor
            "nozzle_radius": nozzle.outlet_diameter / 2,
            "center_of_dry_mass_position": center_of_dry_mass_position,
            "dry_mass": self.motor.get_dry_mass(),
            "nozzle_position": 0.0,
            "burn_time": (time[0], self.motor_state.thrust_time),
            "reshape_thrust_curve": RESHAPE_THRUST_CURVE,
            "interpolation_method": INTERPOLATION_METHOD,
            "coordinate_system_orientation": ROCKETPY_MOTOR_COORDINATE_SYSTEM,
            "reference_pressure": self.motor_state.P_exit[0],
        }

    @property
    def exhaust_velocity(self) -> "Function":
        """Exhaust velocity as a function of time.

        Computed as total impulse divided by propellant initial mass,
        assumed constant and discretized over the burn time.

        Returns:
            Gas exhaust velocity [m/s] as a function of time.
        """
        from rocketpy import Function

        total_impulse = float(np.mean(self.motor_state.thrust) * self.motor_state.t[-1])
        v_exh = total_impulse / self.propellant_initial_mass
        return Function(v_exh).set_discrete_based_on_model(self.thrust)  # type: ignore[attr-defined]

    @property
    def propellant_initial_mass(self) -> float:
        """Initial mass of propellant.

        Returns:
            Initial propellant mass [kg].
        """
        return self.motor_state.initial_propellant_mass

    @property
    def center_of_propellant_mass(self) -> "Function":
        """Position of propellant center of mass as a function of time.

        Uses pre-computed values from motor state simulation.
        Extracts the x-coordinate (axial position) from the propellant's center of
        gravity over time.

        Returns:
            Function object with (time, position) data [m].
        """
        from rocketpy import Function

        time = self.motor_state.t
        center_positions = np.array([cog[0] for cog in self.motor_state.propellant_cog])  # type: ignore[attr-defined]

        # Replace NaN/Inf values with last valid value (propellant burned out)
        mask = np.isfinite(center_positions)
        if not mask.all():
            last_valid_idx = np.where(mask)[0][-1] if mask.any() else 0
            center_positions[~mask] = center_positions[last_valid_idx]

        data = np.column_stack((time, center_positions))
        return Function(data)

    def _get_inertia_tensor_over_time(self) -> npt.NDArray[np.float64]:
        """Get pre-computed inertia tensors from motor state.

        Returns:
            Array of shape (n_timesteps, 3, 3) containing inertia tensors.
        """
        tensors = np.array(self.motor_state.propellant_moi, dtype=np.float64)  # type: ignore[attr-defined]

        # Replace NaN/Inf values with last valid tensor (propellant burned out)
        for i in range(tensors.shape[0]):
            if not np.isfinite(tensors[i]).all():
                if i > 0:
                    tensors[i] = tensors[i - 1]
                else:
                    tensors[i] = np.zeros((3, 3))

        return tensors

    def _get_propellant_inertia_component(self, i: int, j: int) -> "Function":
        """Get a specific component of the propellant inertia tensor.

        Args:
            i: First index (0, 1, or 2).
            j: Second index (0, 1, or 2).

        Returns:
            Function object with (time, I_ij) data [kg-m^2].
        """
        from rocketpy import Function

        time = self.motor_state.t
        inertia_tensors = self._get_inertia_tensor_over_time()
        I_values = inertia_tensors[:, i, j]
        data = np.column_stack((time, I_values))
        return Function(data)

    @property
    def propellant_I_11(self) -> "Function":
        """Inertia tensor I_11 (I_xx) component of the propellant.

        Inertia relative to the e_1 axis (perpendicular to motor body axis), centered at
        the instantaneous propellant center of mass.

        Returns:
            Function object with (time, I_11) data [kg-m^2].
        """
        return self._get_propellant_inertia_component(0, 0)

    @property
    def propellant_I_12(self) -> "Function":
        """Inertia tensor I_12 (I_xy) component of the propellant.

        Returns:
            Function object with (time, I_12) data [kg-m^2].
        """
        return self._get_propellant_inertia_component(0, 1)

    @property
    def propellant_I_13(self) -> "Function":
        """Inertia tensor I_13 (I_xz) component of the propellant.

        Returns:
            Function object with (time, I_13) data [kg-m^2].
        """
        return self._get_propellant_inertia_component(0, 2)

    @property
    def propellant_I_22(self) -> "Function":
        """Inertia tensor I_22 (I_yy) component of the propellant.

        Inertia relative to the e_2 axis (perpendicular to motor body axis), centered at
        the instantaneous propellant center of mass.

        Returns:
            Function object with (time, I_22) data [kg-m^2].
        """
        return self._get_propellant_inertia_component(1, 1)

    @property
    def propellant_I_23(self) -> "Function":
        """Inertia tensor I_23 (I_yz) component of the propellant.

        Returns:
            Function object with (time, I_23) data [kg-m^2].
        """
        return self._get_propellant_inertia_component(1, 2)

    @property
    def propellant_I_33(self) -> "Function":
        """Inertia tensor I_33 (I_zz) component of the propellant.

        Inertia relative to the e_3 axis (motor body axis), centered at the
        instantaneous propellant center of mass.

        Returns:
            Function object with (time, I_33) data [kg-m^2].
        """
        return self._get_propellant_inertia_component(2, 2)

center_of_propellant_mass property

Position of propellant center of mass as a function of time.

Uses pre-computed values from motor state simulation. Extracts the x-coordinate (axial position) from the propellant's center of gravity over time.

Returns:

Type Description
Function

Function object with (time, position) data [m].

exhaust_velocity property

Exhaust velocity as a function of time.

Computed as total impulse divided by propellant initial mass, assumed constant and discretized over the burn time.

Returns:

Type Description
Function

Gas exhaust velocity [m/s] as a function of time.

propellant_I_11 property

Inertia tensor I_11 (I_xx) component of the propellant.

Inertia relative to the e_1 axis (perpendicular to motor body axis), centered at the instantaneous propellant center of mass.

Returns:

Type Description
Function

Function object with (time, I_11) data [kg-m^2].

propellant_I_12 property

Inertia tensor I_12 (I_xy) component of the propellant.

Returns:

Type Description
Function

Function object with (time, I_12) data [kg-m^2].

propellant_I_13 property

Inertia tensor I_13 (I_xz) component of the propellant.

Returns:

Type Description
Function

Function object with (time, I_13) data [kg-m^2].

propellant_I_22 property

Inertia tensor I_22 (I_yy) component of the propellant.

Inertia relative to the e_2 axis (perpendicular to motor body axis), centered at the instantaneous propellant center of mass.

Returns:

Type Description
Function

Function object with (time, I_22) data [kg-m^2].

propellant_I_23 property

Inertia tensor I_23 (I_yz) component of the propellant.

Returns:

Type Description
Function

Function object with (time, I_23) data [kg-m^2].

propellant_I_33 property

Inertia tensor I_33 (I_zz) component of the propellant.

Inertia relative to the e_3 axis (motor body axis), centered at the instantaneous propellant center of mass.

Returns:

Type Description
Function

Function object with (time, I_33) data [kg-m^2].

propellant_initial_mass property

Initial mass of propellant.

Returns:

Type Description
float

Initial propellant mass [kg].

__init__(motor_state)

Initialize the adapter with a Machwave motor state.

Source code in machwave/adapters/rocketpy/base.py
def __init__(self, motor_state: M) -> None:
    """Initialize the adapter with a Machwave motor state.

    Args:
        The Machwave motor state to adapt.
    """
    self._require_rocketpy()

    self.motor_state = motor_state
    self.motor = motor_state.motor

    attrs = self._get_rocketpy_attributes()
    super().__init__(**attrs)

__init_subclass__(**kwargs)

Dynamically inherit from specified RocketPy Motor class when subclass is created.

Source code in machwave/adapters/rocketpy/base.py
def __init_subclass__(cls, **kwargs: typing.Any) -> None:
    """Dynamically inherit from specified RocketPy Motor class when subclass is created."""
    super().__init_subclass__(**kwargs)

    cls._require_rocketpy()
    import rocketpy.motors as rocketpy_motors

    # Get the specific motor class to inherit from
    motor_class_name = cls._rocketpy_motor_class
    if not hasattr(rocketpy_motors, motor_class_name):
        raise AttributeError(
            f"RocketPy motors module has no class '{motor_class_name}'"
        )

    motor_class = getattr(rocketpy_motors, motor_class_name)

    # Dynamically add the specific Motor class to the base classes if not already there
    if not any(
        isinstance(base, type) and issubclass(base, motor_class)
        for base in cls.__bases__
    ):
        cls.__bases__ = cls.__bases__ + (motor_class,)

RocketPySolidMotorAdapter

Bases: RocketPyMotorAdapter['SolidMotorState']

Adapter to use Machwave SolidMotorState as a RocketPy SolidMotor.

Source code in machwave/adapters/rocketpy/solid_motor.py
class RocketPySolidMotorAdapter(RocketPyMotorAdapter["SolidMotorState"]):
    """Adapter to use Machwave SolidMotorState as a RocketPy SolidMotor."""

    _rocketpy_motor_class = "SolidMotor"

    def _get_rocketpy_attributes(self) -> dict[str, typing.Any]:
        """Extract motor and grain attributes compatible with RocketPy SolidMotor.

        Returns:
            Attributes for RocketPy SolidMotor initialization.

        Raises:
            ValueError: If grain configuration is incompatible with RocketPy.
        """
        # Get base motor attributes
        base_attrs = super()._get_rocketpy_attributes()

        motor = typing.cast("SolidMotor", self.motor)
        grain = motor.grain

        if not grain.segments:
            raise ValueError("Grain must have at least one segment")

        # RocketPy's SolidMotor assumes all segments are identical, use first one
        first_segment = grain.segments[0]

        grain_number = grain.segment_count
        grain_separation = grain.spacing
        grain_outer_radius = first_segment.outer_diameter / 2
        grain_initial_height = first_segment.length
        throat_radius = motor.thrust_chamber.nozzle.throat_diameter / 2
        grains_center_of_mass_position = motor.grain.get_center_of_gravity(
            web_distance=0.0
        )[0]

        # RocketPy handles real density internally
        grain_density = motor.propellant.ideal_density

        if hasattr(first_segment, "core_diameter"):
            grain_initial_inner_radius = first_segment.core_diameter / 2  # type: ignore[attr-defined]
        else:  # Some geometries might not have core_diameter, use 0 as default
            grain_initial_inner_radius = 0.0

        # Combine base attributes with grain-specific parameters
        solid_motor_attrs = {
            **base_attrs,
            "grain_number": grain_number,
            "grain_density": grain_density,
            "grain_outer_radius": grain_outer_radius,
            "grain_initial_inner_radius": grain_initial_inner_radius,
            "grain_initial_height": grain_initial_height,
            "grain_separation": grain_separation,
            "grains_center_of_mass_position": grains_center_of_mass_position,
            "throat_radius": throat_radius,
        }

        return solid_motor_attrs