Skip to content

models.grain.geometries

Concrete grain segment geometries, both analytical and FMM-based.

2D (constant cross-section):

  • BatesSegment — Cylindrical grain with an axial core, the most common amateur geometry (analytical).
  • RodAndTubeGrainSegment — Central rod surrounded by an outer tube (FMM-based).
  • MultiPortGrainSegment — Multiple circular ports arranged radially in the cross-section (FMM-based).
  • StarGrainSegment — Star-shaped port with configurable point count, length, and width (FMM-based).
  • DGrainSegment — D-shaped slot port (FMM-based).
  • WagonWheelGrainSegment — Multi-spoke geometry with a central core and radial ports (FMM-based).

3D (varying cross-section):

  • ConicalGrainSegment — Linearly tapered core from upper to lower diameter.
  • FinocylGrainSegment — Central circular bore with radial fins over a partial axial section (FMM-based).

All segments implement the GrainSegment interface and can be mixed within a single Grain.

machwave.models.grain.geometries

BatesSegment

Bases: GrainSegment2D

BATES grain segment: cylindrical with circular central port.

Source code in machwave/models/grain/geometries/bates.py
class BatesSegment(grain.GrainSegment2D):
    """BATES grain segment: cylindrical with circular central port."""

    INHIBITED_SURFACES = grain_base.InhibitedSurfaces(
        outer_surface=True,
        inner_surface=False,
        upper_end=False,
        lower_end=False,
    )

    def __init__(
        self,
        outer_diameter: float,
        core_diameter: float,
        length: float,
        density_ratio: float = 1.0,
    ) -> None:
        """
        Initialize a BATES grain segment.

        Args:
            outer_diameter: Outer diameter [m].
            core_diameter: Core (port) diameter [m].
            length: Segment length [m].
            density_ratio: Ratio of real to ideal propellant density.
        """
        self.core_diameter = core_diameter

        super().__init__(
            length=length,
            outer_diameter=outer_diameter,
            inhibited_surfaces=self.INHIBITED_SURFACES,
            density_ratio=density_ratio,
        )

    def validate(self) -> None:
        """Validate BATES segment geometry."""
        super().validate()

        if not self.outer_diameter > self.core_diameter:
            raise grain.GrainGeometryError(
                f"Outer diameter ({self.outer_diameter}) must be greater than "
                f"core diameter ({self.core_diameter})"
            )
        if not self.core_diameter > 0:
            raise grain.GrainGeometryError(
                f"Core diameter must be positive, got {self.core_diameter}"
            )

    def get_core_diameter(self, web_distance: float) -> float:
        """Return the core diameter at a given web distance [m]."""
        return self.core_diameter + 2 * web_distance

    def get_port_area(self, web_distance: float) -> float:
        """Return the port area at a given web distance [m^2]."""
        return geometric.get_circle_area(diameter=self.get_core_diameter(web_distance))

    def get_core_area(self, web_distance: float) -> float:
        """Return the core (inner cylindrical) surface area [m^2]."""
        length = self.get_length(web_distance=web_distance)
        core_diameter = self.core_diameter + 2 * web_distance
        return geometric.get_cylinder_surface_area(length, core_diameter)

    def get_face_area(self, web_distance: float) -> float:
        """Return the annular face area at a given web distance [m^2]."""
        core_diameter = self.get_core_diameter(web_distance)
        return np.pi * (((self.outer_diameter**2) - (core_diameter) ** 2) / 4)

    def get_web_thickness(self) -> float:
        """
        Return the BATES web thickness [m].

        See: https://www.nakka-rocketry.net/design1.html.
        """
        return 0.5 * (self.outer_diameter - self.core_diameter)

    def get_optimal_length(self) -> float:
        """
        Return the optimal length for neutral burn of a BATES segment [mm].

        See: https://www.nakka-rocketry.net/th_grain.html.
        """
        return 1e3 * 0.5 * (3 * self.outer_diameter + self.core_diameter)

    def get_center_of_gravity(
        self, web_distance: float = 0.0
    ) -> np.typing.NDArray[np.float64]:
        """
        Return the center of gravity of a BATES segment.

        BATES is a symmetrical 2D geometry that burns radially. Due to its
        cylindrical symmetry, the center of gravity remains constant at the
        geometric center regardless of web distance burned.

        Args:
            web_distance: Web distance traveled [m]. Unused for BATES due to
                symmetry; included for API consistency.

        Returns:
            Center of gravity as `(x, y, z)` [m], measured from the aft end (port,
            closest to nozzle). Always returns [length/2, 0, 0] for symmetric
            BATES grains.
        """
        # For symmetric BATES grains, CoG doesn't change with burn
        # The grain burns radially inward, maintaining axial symmetry
        # CoG is at geometric center, which is length/2 from the aft end (port)
        return np.array([self.length / 2, 0.0, 0.0], dtype=np.float64)

    def get_moment_of_inertia(
        self, ideal_density: float, web_distance: float = 0.0
    ) -> np.typing.NDArray[np.float64]:
        """
        Return the moment of inertia tensor of the BATES segment.

        Treats the segment as a hollow cylinder and evaluates the tensor at
        its center of gravity.

        Args:
            ideal_density: Propellant ideal density [kg/m^3].
            web_distance: Web distance traveled [m].

        Returns:
            A 3x3 inertia tensor [kg-m^2].
        """
        r_inner = (self.core_diameter + 2 * web_distance) / 2
        r_outer = self.outer_diameter / 2
        current_length = self.get_length(web_distance)

        volume = self.get_volume(web_distance)
        mass = volume * ideal_density * self.density_ratio

        r_sum_sq = r_inner**2 + r_outer**2

        # Ixx: moment about axial axis
        Ixx = mass * r_sum_sq / 2

        # Iyy, Izz: moments about radial axes
        Iyy = mass * (r_sum_sq / 4 + current_length**2 / 12)
        Izz = Iyy

        return np.array(
            [[Ixx, 0.0, 0.0], [0.0, Iyy, 0.0], [0.0, 0.0, Izz]], dtype=np.float64
        )

__init__(outer_diameter, core_diameter, length, density_ratio=1.0)

Initialize a BATES grain segment.

Parameters:

Name Type Description Default
outer_diameter float

Outer diameter [m].

required
core_diameter float

Core (port) diameter [m].

required
length float

Segment length [m].

required
density_ratio float

Ratio of real to ideal propellant density.

1.0
Source code in machwave/models/grain/geometries/bates.py
def __init__(
    self,
    outer_diameter: float,
    core_diameter: float,
    length: float,
    density_ratio: float = 1.0,
) -> None:
    """
    Initialize a BATES grain segment.

    Args:
        outer_diameter: Outer diameter [m].
        core_diameter: Core (port) diameter [m].
        length: Segment length [m].
        density_ratio: Ratio of real to ideal propellant density.
    """
    self.core_diameter = core_diameter

    super().__init__(
        length=length,
        outer_diameter=outer_diameter,
        inhibited_surfaces=self.INHIBITED_SURFACES,
        density_ratio=density_ratio,
    )

get_center_of_gravity(web_distance=0.0)

Return the center of gravity of a BATES segment.

BATES is a symmetrical 2D geometry that burns radially. Due to its cylindrical symmetry, the center of gravity remains constant at the geometric center regardless of web distance burned.

Parameters:

Name Type Description Default
web_distance float

Web distance traveled [m]. Unused for BATES due to symmetry; included for API consistency.

0.0

Returns:

Type Description
NDArray[float64]

Center of gravity as (x, y, z) [m], measured from the aft end (port,

NDArray[float64]

closest to nozzle). Always returns [length/2, 0, 0] for symmetric

NDArray[float64]

BATES grains.

Source code in machwave/models/grain/geometries/bates.py
def get_center_of_gravity(
    self, web_distance: float = 0.0
) -> np.typing.NDArray[np.float64]:
    """
    Return the center of gravity of a BATES segment.

    BATES is a symmetrical 2D geometry that burns radially. Due to its
    cylindrical symmetry, the center of gravity remains constant at the
    geometric center regardless of web distance burned.

    Args:
        web_distance: Web distance traveled [m]. Unused for BATES due to
            symmetry; included for API consistency.

    Returns:
        Center of gravity as `(x, y, z)` [m], measured from the aft end (port,
        closest to nozzle). Always returns [length/2, 0, 0] for symmetric
        BATES grains.
    """
    # For symmetric BATES grains, CoG doesn't change with burn
    # The grain burns radially inward, maintaining axial symmetry
    # CoG is at geometric center, which is length/2 from the aft end (port)
    return np.array([self.length / 2, 0.0, 0.0], dtype=np.float64)

get_core_area(web_distance)

Return the core (inner cylindrical) surface area [m^2].

Source code in machwave/models/grain/geometries/bates.py
def get_core_area(self, web_distance: float) -> float:
    """Return the core (inner cylindrical) surface area [m^2]."""
    length = self.get_length(web_distance=web_distance)
    core_diameter = self.core_diameter + 2 * web_distance
    return geometric.get_cylinder_surface_area(length, core_diameter)

get_core_diameter(web_distance)

Return the core diameter at a given web distance [m].

Source code in machwave/models/grain/geometries/bates.py
def get_core_diameter(self, web_distance: float) -> float:
    """Return the core diameter at a given web distance [m]."""
    return self.core_diameter + 2 * web_distance

get_face_area(web_distance)

Return the annular face area at a given web distance [m^2].

Source code in machwave/models/grain/geometries/bates.py
def get_face_area(self, web_distance: float) -> float:
    """Return the annular face area at a given web distance [m^2]."""
    core_diameter = self.get_core_diameter(web_distance)
    return np.pi * (((self.outer_diameter**2) - (core_diameter) ** 2) / 4)

get_moment_of_inertia(ideal_density, web_distance=0.0)

Return the moment of inertia tensor of the BATES segment.

Treats the segment as a hollow cylinder and evaluates the tensor at its center of gravity.

Parameters:

Name Type Description Default
ideal_density float

Propellant ideal density [kg/m^3].

required
web_distance float

Web distance traveled [m].

0.0

Returns:

Type Description
NDArray[float64]

A 3x3 inertia tensor [kg-m^2].

Source code in machwave/models/grain/geometries/bates.py
def get_moment_of_inertia(
    self, ideal_density: float, web_distance: float = 0.0
) -> np.typing.NDArray[np.float64]:
    """
    Return the moment of inertia tensor of the BATES segment.

    Treats the segment as a hollow cylinder and evaluates the tensor at
    its center of gravity.

    Args:
        ideal_density: Propellant ideal density [kg/m^3].
        web_distance: Web distance traveled [m].

    Returns:
        A 3x3 inertia tensor [kg-m^2].
    """
    r_inner = (self.core_diameter + 2 * web_distance) / 2
    r_outer = self.outer_diameter / 2
    current_length = self.get_length(web_distance)

    volume = self.get_volume(web_distance)
    mass = volume * ideal_density * self.density_ratio

    r_sum_sq = r_inner**2 + r_outer**2

    # Ixx: moment about axial axis
    Ixx = mass * r_sum_sq / 2

    # Iyy, Izz: moments about radial axes
    Iyy = mass * (r_sum_sq / 4 + current_length**2 / 12)
    Izz = Iyy

    return np.array(
        [[Ixx, 0.0, 0.0], [0.0, Iyy, 0.0], [0.0, 0.0, Izz]], dtype=np.float64
    )

get_optimal_length()

Return the optimal length for neutral burn of a BATES segment [mm].

See: https://www.nakka-rocketry.net/th_grain.html.

Source code in machwave/models/grain/geometries/bates.py
def get_optimal_length(self) -> float:
    """
    Return the optimal length for neutral burn of a BATES segment [mm].

    See: https://www.nakka-rocketry.net/th_grain.html.
    """
    return 1e3 * 0.5 * (3 * self.outer_diameter + self.core_diameter)

get_port_area(web_distance)

Return the port area at a given web distance [m^2].

Source code in machwave/models/grain/geometries/bates.py
def get_port_area(self, web_distance: float) -> float:
    """Return the port area at a given web distance [m^2]."""
    return geometric.get_circle_area(diameter=self.get_core_diameter(web_distance))

get_web_thickness()

Return the BATES web thickness [m].

See: https://www.nakka-rocketry.net/design1.html.

Source code in machwave/models/grain/geometries/bates.py
def get_web_thickness(self) -> float:
    """
    Return the BATES web thickness [m].

    See: https://www.nakka-rocketry.net/design1.html.
    """
    return 0.5 * (self.outer_diameter - self.core_diameter)

validate()

Validate BATES segment geometry.

Source code in machwave/models/grain/geometries/bates.py
def validate(self) -> None:
    """Validate BATES segment geometry."""
    super().validate()

    if not self.outer_diameter > self.core_diameter:
        raise grain.GrainGeometryError(
            f"Outer diameter ({self.outer_diameter}) must be greater than "
            f"core diameter ({self.core_diameter})"
        )
    if not self.core_diameter > 0:
        raise grain.GrainGeometryError(
            f"Core diameter must be positive, got {self.core_diameter}"
        )

ConicalGrainSegment

Bases: FMMGrainSegment3D

Grain segment with a conical port tapering between two diameters.

Source code in machwave/models/grain/geometries/conical.py
class ConicalGrainSegment(grain_fmm.FMMGrainSegment3D):
    """Grain segment with a conical port tapering between two diameters."""

    def __init__(
        self,
        length: float,
        outer_diameter: float,
        upper_core_diameter: float,
        lower_core_diameter: float,
        inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
        grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
        density_ratio: float = 1.0,
    ) -> None:
        """
        Initialize a conical grain segment.

        Args:
            length: Segment length [m].
            outer_diameter: Outer diameter [m].
            upper_core_diameter: Core diameter at the upper (bulkhead) end [m].
            lower_core_diameter: Core diameter at the lower (nozzle) end [m].
            inhibited_surfaces: Surfaces inhibited from burning.
            grid_resolution: Grid points per axis of the cross-section.
            density_ratio: Ratio of real to ideal propellant density.
        """
        self.upper_core_diameter = upper_core_diameter
        self.lower_core_diameter = lower_core_diameter

        super().__init__(
            length=length,
            outer_diameter=outer_diameter,
            inhibited_surfaces=inhibited_surfaces,
            grid_resolution=grid_resolution,
            density_ratio=density_ratio,
        )

    def validate(self) -> None:
        """Validate conical segment geometry."""
        super().validate()

        if not self.upper_core_diameter > 0:
            raise grain.GrainGeometryError(
                f"Upper core diameter must be positive, got {self.upper_core_diameter}"
            )
        if not self.upper_core_diameter < self.outer_diameter:
            raise grain.GrainGeometryError(
                f"Upper core diameter ({self.upper_core_diameter}) must be less than "
                f"outer diameter ({self.outer_diameter})"
            )
        if not self.lower_core_diameter > 0:
            raise grain.GrainGeometryError(
                f"Lower core diameter must be positive, got {self.lower_core_diameter}"
            )
        if not self.lower_core_diameter < self.outer_diameter:
            raise grain.GrainGeometryError(
                f"Lower core diameter ({self.lower_core_diameter}) must be less than "
                f"outer diameter ({self.outer_diameter})"
            )

    def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
        """Return the initial face map for the conical port."""
        map_x, map_y, map_z = self.get_coordinate_grids()
        core_map = self.get_empty_face_map()

        upper_core_normalized = self.normalize(self.upper_core_diameter)
        lower_core_normalized = self.normalize(self.lower_core_diameter)

        radius = np.sqrt(map_x**2 + map_y**2)
        # map_z is 1 at the aft (nozzle) slice and 0 at the forward (bulkhead) slice.
        core_diameter = (
            map_z * (lower_core_normalized - upper_core_normalized)
            + upper_core_normalized
        )

        core_map[radius < core_diameter / 2] = 0
        core_map[0] = 0  # Inhibit the bottom end
        core_map[-1] = 0  # Inhibit the top end

        return core_map

__init__(length, outer_diameter, upper_core_diameter, lower_core_diameter, inhibited_surfaces=None, grid_resolution=grain_fmm.DEFAULT_GRID_RESOLUTION, density_ratio=1.0)

Initialize a conical grain segment.

Parameters:

Name Type Description Default
length float

Segment length [m].

required
outer_diameter float

Outer diameter [m].

required
upper_core_diameter float

Core diameter at the upper (bulkhead) end [m].

required
lower_core_diameter float

Core diameter at the lower (nozzle) end [m].

required
inhibited_surfaces InhibitedSurfaces | None

Surfaces inhibited from burning.

None
grid_resolution int

Grid points per axis of the cross-section.

DEFAULT_GRID_RESOLUTION
density_ratio float

Ratio of real to ideal propellant density.

1.0
Source code in machwave/models/grain/geometries/conical.py
def __init__(
    self,
    length: float,
    outer_diameter: float,
    upper_core_diameter: float,
    lower_core_diameter: float,
    inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
    grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
    density_ratio: float = 1.0,
) -> None:
    """
    Initialize a conical grain segment.

    Args:
        length: Segment length [m].
        outer_diameter: Outer diameter [m].
        upper_core_diameter: Core diameter at the upper (bulkhead) end [m].
        lower_core_diameter: Core diameter at the lower (nozzle) end [m].
        inhibited_surfaces: Surfaces inhibited from burning.
        grid_resolution: Grid points per axis of the cross-section.
        density_ratio: Ratio of real to ideal propellant density.
    """
    self.upper_core_diameter = upper_core_diameter
    self.lower_core_diameter = lower_core_diameter

    super().__init__(
        length=length,
        outer_diameter=outer_diameter,
        inhibited_surfaces=inhibited_surfaces,
        grid_resolution=grid_resolution,
        density_ratio=density_ratio,
    )

generate_initial_face_map()

Return the initial face map for the conical port.

Source code in machwave/models/grain/geometries/conical.py
def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
    """Return the initial face map for the conical port."""
    map_x, map_y, map_z = self.get_coordinate_grids()
    core_map = self.get_empty_face_map()

    upper_core_normalized = self.normalize(self.upper_core_diameter)
    lower_core_normalized = self.normalize(self.lower_core_diameter)

    radius = np.sqrt(map_x**2 + map_y**2)
    # map_z is 1 at the aft (nozzle) slice and 0 at the forward (bulkhead) slice.
    core_diameter = (
        map_z * (lower_core_normalized - upper_core_normalized)
        + upper_core_normalized
    )

    core_map[radius < core_diameter / 2] = 0
    core_map[0] = 0  # Inhibit the bottom end
    core_map[-1] = 0  # Inhibit the top end

    return core_map

validate()

Validate conical segment geometry.

Source code in machwave/models/grain/geometries/conical.py
def validate(self) -> None:
    """Validate conical segment geometry."""
    super().validate()

    if not self.upper_core_diameter > 0:
        raise grain.GrainGeometryError(
            f"Upper core diameter must be positive, got {self.upper_core_diameter}"
        )
    if not self.upper_core_diameter < self.outer_diameter:
        raise grain.GrainGeometryError(
            f"Upper core diameter ({self.upper_core_diameter}) must be less than "
            f"outer diameter ({self.outer_diameter})"
        )
    if not self.lower_core_diameter > 0:
        raise grain.GrainGeometryError(
            f"Lower core diameter must be positive, got {self.lower_core_diameter}"
        )
    if not self.lower_core_diameter < self.outer_diameter:
        raise grain.GrainGeometryError(
            f"Lower core diameter ({self.lower_core_diameter}) must be less than "
            f"outer diameter ({self.outer_diameter})"
        )

DGrainSegment

Bases: FMMGrainSegment2D

D-shaped grain segment with a single offset planar slot.

Source code in machwave/models/grain/geometries/d_grain.py
class DGrainSegment(grain_fmm.FMMGrainSegment2D):
    """D-shaped grain segment with a single offset planar slot."""

    def __init__(
        self,
        length: float,
        outer_diameter: float,
        slot_offset: float,
        inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
        grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
        density_ratio: float = 1.0,
    ) -> None:
        """
        Initialize a D-grain segment.

        Args:
            length: Segment length [m].
            outer_diameter: Outer diameter [m].
            slot_offset: Distance from the grain center to the slot face [m].
            inhibited_surfaces: Surfaces inhibited from burning.
            grid_resolution: Grid points per axis of the cross-section.
            density_ratio: Ratio of real to ideal propellant density.
        """
        self.slot_offset = slot_offset

        super().__init__(
            length=length,
            outer_diameter=outer_diameter,
            inhibited_surfaces=inhibited_surfaces,
            grid_resolution=grid_resolution,
            density_ratio=density_ratio,
        )

    def validate(self) -> None:
        """Validate D-grain segment geometry."""
        super().validate()

        if not self.slot_offset >= 0:
            raise grain.GrainGeometryError(
                f"Slot offset must be non-negative, got {self.slot_offset}"
            )
        max_slot_offset = self.outer_diameter / 2
        if not self.slot_offset < max_slot_offset:
            raise grain.GrainGeometryError(
                f"Slot offset ({self.slot_offset}) must be less than "
                f"half the outer diameter ({max_slot_offset})"
            )

    def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
        """Return the initial face map for the D-grain port."""
        slot_offset_normalized = self.normalize(self.slot_offset)
        map_x = self.get_coordinate_grids()[0]
        core_map = self.get_empty_face_map()
        core_map[map_x > slot_offset_normalized] = 0
        return core_map

__init__(length, outer_diameter, slot_offset, inhibited_surfaces=None, grid_resolution=grain_fmm.DEFAULT_GRID_RESOLUTION, density_ratio=1.0)

Initialize a D-grain segment.

Parameters:

Name Type Description Default
length float

Segment length [m].

required
outer_diameter float

Outer diameter [m].

required
slot_offset float

Distance from the grain center to the slot face [m].

required
inhibited_surfaces InhibitedSurfaces | None

Surfaces inhibited from burning.

None
grid_resolution int

Grid points per axis of the cross-section.

DEFAULT_GRID_RESOLUTION
density_ratio float

Ratio of real to ideal propellant density.

1.0
Source code in machwave/models/grain/geometries/d_grain.py
def __init__(
    self,
    length: float,
    outer_diameter: float,
    slot_offset: float,
    inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
    grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
    density_ratio: float = 1.0,
) -> None:
    """
    Initialize a D-grain segment.

    Args:
        length: Segment length [m].
        outer_diameter: Outer diameter [m].
        slot_offset: Distance from the grain center to the slot face [m].
        inhibited_surfaces: Surfaces inhibited from burning.
        grid_resolution: Grid points per axis of the cross-section.
        density_ratio: Ratio of real to ideal propellant density.
    """
    self.slot_offset = slot_offset

    super().__init__(
        length=length,
        outer_diameter=outer_diameter,
        inhibited_surfaces=inhibited_surfaces,
        grid_resolution=grid_resolution,
        density_ratio=density_ratio,
    )

generate_initial_face_map()

Return the initial face map for the D-grain port.

Source code in machwave/models/grain/geometries/d_grain.py
def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
    """Return the initial face map for the D-grain port."""
    slot_offset_normalized = self.normalize(self.slot_offset)
    map_x = self.get_coordinate_grids()[0]
    core_map = self.get_empty_face_map()
    core_map[map_x > slot_offset_normalized] = 0
    return core_map

validate()

Validate D-grain segment geometry.

Source code in machwave/models/grain/geometries/d_grain.py
def validate(self) -> None:
    """Validate D-grain segment geometry."""
    super().validate()

    if not self.slot_offset >= 0:
        raise grain.GrainGeometryError(
            f"Slot offset must be non-negative, got {self.slot_offset}"
        )
    max_slot_offset = self.outer_diameter / 2
    if not self.slot_offset < max_slot_offset:
        raise grain.GrainGeometryError(
            f"Slot offset ({self.slot_offset}) must be less than "
            f"half the outer diameter ({max_slot_offset})"
        )

FinocylGrainSegment

Bases: FMMGrainSegment3D

Grain segment with a central bore and radial fins over a partial axial section.

Source code in machwave/models/grain/geometries/finocyl.py
class FinocylGrainSegment(grain_fmm.FMMGrainSegment3D):
    """Grain segment with a central bore and radial fins over a partial axial section."""

    def __init__(
        self,
        length: float,
        outer_diameter: float,
        core_diameter: float,
        number_of_fins: int,
        fin_length: float,
        fin_width: float,
        finned_length: float,
        fin_axial_offset: float = 0.0,
        transition_length: float = 0.0,
        inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
        grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
        density_ratio: float = 1.0,
    ) -> None:
        """
        Initialize a finocyl grain segment.

        Args:
            length: Segment length [m].
            outer_diameter: Outer diameter [m].
            core_diameter: Central bore diameter [m].
            number_of_fins: Number of evenly spaced radial fins.
            fin_length: Radial penetration of each fin, measured from the bore
                edge.
            fin_width: Tangential thickness of each fin slot [m].
            finned_length: Axial extent of the finned section [m].
            fin_axial_offset: Axial position where the finned section begins,
                measured from the aft (nozzle) end [m].
            transition_length: Axial length over which the fins taper linearly in
                depth between full depth and the surrounding cylindrical bore,
                applied at each interface between the finned and cylindrical
                sections. A value of 0.0 gives an abrupt step [m].
            inhibited_surfaces: Surfaces inhibited from burning.
            grid_resolution: Grid points per axis of the cross-section.
            density_ratio: Ratio of real to ideal propellant density.
        """
        self.core_diameter = core_diameter
        self.number_of_fins = int(number_of_fins)
        self.fin_length = fin_length
        self.fin_width = fin_width
        self.finned_length = finned_length
        self.fin_axial_offset = fin_axial_offset
        self.transition_length = transition_length

        super().__init__(
            length=length,
            outer_diameter=outer_diameter,
            inhibited_surfaces=inhibited_surfaces,
            grid_resolution=grid_resolution,
            density_ratio=density_ratio,
        )

    def validate(self) -> None:
        """Validate finocyl segment geometry."""
        super().validate()

        if not self.core_diameter > 0:
            raise grain.GrainGeometryError(
                f"Core diameter must be positive, got {self.core_diameter}"
            )
        if not self.core_diameter < self.outer_diameter:
            raise grain.GrainGeometryError(
                f"Core diameter ({self.core_diameter}) must be less than "
                f"outer diameter ({self.outer_diameter})"
            )
        if not isinstance(self.number_of_fins, int):
            raise grain.GrainGeometryError(
                f"Number of fins must be an integer, got "
                f"{type(self.number_of_fins).__name__}"
            )
        if not self.number_of_fins > 0:
            raise grain.GrainGeometryError(
                f"Number of fins must be positive, got {self.number_of_fins}"
            )
        if not self.number_of_fins < 12:
            raise grain.GrainGeometryError(
                f"Number of fins must be less than 12, got {self.number_of_fins}"
            )
        if not self.fin_length > 0:
            raise grain.GrainGeometryError(
                f"Fin length must be positive, got {self.fin_length}"
            )
        if not self.fin_width > 0:
            raise grain.GrainGeometryError(
                f"Fin width must be positive, got {self.fin_width}"
            )

        fin_tip_diameter = self.core_diameter + 2 * self.fin_length
        if not fin_tip_diameter < self.outer_diameter:
            raise grain.GrainGeometryError(
                f"Fin tip diameter ({fin_tip_diameter}) must be less than "
                f"outer diameter ({self.outer_diameter})"
            )

        if self.number_of_fins >= 2:
            fin_tip_radius = self.core_diameter / 2 + self.fin_length
            max_fin_width = 2 * fin_tip_radius * np.tan(np.pi / self.number_of_fins)
            if not self.fin_width < max_fin_width:
                raise grain.GrainGeometryError(
                    f"Fin width ({self.fin_width}) must be less than "
                    f"{max_fin_width} to avoid overlapping fins"
                )

        if not self.finned_length > 0:
            raise grain.GrainGeometryError(
                f"Finned length must be positive, got {self.finned_length}"
            )
        if not self.finned_length <= self.length:
            raise grain.GrainGeometryError(
                f"Finned length ({self.finned_length}) must not exceed "
                f"segment length ({self.length})"
            )
        if not self.fin_axial_offset >= 0:
            raise grain.GrainGeometryError(
                f"Fin axial offset must be non-negative, got {self.fin_axial_offset}"
            )
        if not self.fin_axial_offset + self.finned_length <= self.length:
            raise grain.GrainGeometryError(
                f"Finned section (offset {self.fin_axial_offset} + length "
                f"{self.finned_length}) must fit within the segment length "
                f"({self.length})"
            )
        if not self.transition_length >= 0:
            raise grain.GrainGeometryError(
                f"Transition length must be non-negative, got {self.transition_length}"
            )

        tapering_interfaces = (self.fin_axial_offset > 0) + (
            self.fin_axial_offset + self.finned_length < self.length
        )
        if not tapering_interfaces * self.transition_length <= self.finned_length:
            raise grain.GrainGeometryError(
                f"Transition length ({self.transition_length}) is too large for the "
                f"fins to reach full depth over a finned length of "
                f"{self.finned_length} with {tapering_interfaces} tapering "
                f"interface(s)"
            )

    def _fin_depth_fraction(
        self, axial_position: np.typing.NDArray[np.float64]
    ) -> np.typing.NDArray[np.float64]:
        """
        Return the fin depth as a fraction of full depth at each axial position.

        Arguments:
            axial_position: Axial position(s) along the grain, measured from the aft
                (nozzle) end [m].

        Returns:
            Fin depth fraction(s) between 0 and 1, where 1 corresponds to the full fin
            length.
        """
        fin_start = self.fin_axial_offset
        fin_end = self.fin_axial_offset + self.finned_length

        if self.transition_length == 0:
            return ((axial_position >= fin_start) & (axial_position <= fin_end)).astype(
                np.float64
            )

        rise = (
            np.clip((axial_position - fin_start) / self.transition_length, 0.0, 1.0)
            if fin_start > 0
            else (axial_position >= fin_start).astype(np.float64)
        )
        fall = (
            np.clip((fin_end - axial_position) / self.transition_length, 0.0, 1.0)
            if fin_end < self.length
            else (axial_position <= fin_end).astype(np.float64)
        )
        return np.minimum(rise, fall)

    def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
        """Return the initial face map for the finocyl port."""
        map_x, map_y, map_z = self.get_coordinate_grids()
        core_map = self.get_empty_face_map()

        core_radius_normalized = self.normalize(self.core_diameter) / 2
        fin_width_normalized = self.normalize(self.fin_width)
        fin_length_normalized = self.normalize(self.fin_length)

        radius = np.sqrt(map_x**2 + map_y**2)
        core_map[radius < core_radius_normalized] = 0

        axial_position = (1 - map_z) * self.length
        local_fin_tip_normalized = core_radius_normalized + (
            self._fin_depth_fraction(axial_position) * fin_length_normalized
        )

        for i in range(self.number_of_fins):
            theta = 2 * np.pi / self.number_of_fins * i
            within_width = (
                np.abs(np.cos(theta) * map_x + np.sin(theta) * map_y)
                < fin_width_normalized / 2
            )
            radial = np.sin(theta) * map_x - np.cos(theta) * map_y
            within_length = (radial > 0) & (radial < local_fin_tip_normalized)
            core_map[within_width & within_length] = 0

        core_map[0] = 0
        core_map[-1] = 0

        return core_map

__init__(length, outer_diameter, core_diameter, number_of_fins, fin_length, fin_width, finned_length, fin_axial_offset=0.0, transition_length=0.0, inhibited_surfaces=None, grid_resolution=grain_fmm.DEFAULT_GRID_RESOLUTION, density_ratio=1.0)

Initialize a finocyl grain segment.

Parameters:

Name Type Description Default
length float

Segment length [m].

required
outer_diameter float

Outer diameter [m].

required
core_diameter float

Central bore diameter [m].

required
number_of_fins int

Number of evenly spaced radial fins.

required
fin_length float

Radial penetration of each fin, measured from the bore edge.

required
fin_width float

Tangential thickness of each fin slot [m].

required
finned_length float

Axial extent of the finned section [m].

required
fin_axial_offset float

Axial position where the finned section begins, measured from the aft (nozzle) end [m].

0.0
transition_length float

Axial length over which the fins taper linearly in depth between full depth and the surrounding cylindrical bore, applied at each interface between the finned and cylindrical sections. A value of 0.0 gives an abrupt step [m].

0.0
inhibited_surfaces InhibitedSurfaces | None

Surfaces inhibited from burning.

None
grid_resolution int

Grid points per axis of the cross-section.

DEFAULT_GRID_RESOLUTION
density_ratio float

Ratio of real to ideal propellant density.

1.0
Source code in machwave/models/grain/geometries/finocyl.py
def __init__(
    self,
    length: float,
    outer_diameter: float,
    core_diameter: float,
    number_of_fins: int,
    fin_length: float,
    fin_width: float,
    finned_length: float,
    fin_axial_offset: float = 0.0,
    transition_length: float = 0.0,
    inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
    grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
    density_ratio: float = 1.0,
) -> None:
    """
    Initialize a finocyl grain segment.

    Args:
        length: Segment length [m].
        outer_diameter: Outer diameter [m].
        core_diameter: Central bore diameter [m].
        number_of_fins: Number of evenly spaced radial fins.
        fin_length: Radial penetration of each fin, measured from the bore
            edge.
        fin_width: Tangential thickness of each fin slot [m].
        finned_length: Axial extent of the finned section [m].
        fin_axial_offset: Axial position where the finned section begins,
            measured from the aft (nozzle) end [m].
        transition_length: Axial length over which the fins taper linearly in
            depth between full depth and the surrounding cylindrical bore,
            applied at each interface between the finned and cylindrical
            sections. A value of 0.0 gives an abrupt step [m].
        inhibited_surfaces: Surfaces inhibited from burning.
        grid_resolution: Grid points per axis of the cross-section.
        density_ratio: Ratio of real to ideal propellant density.
    """
    self.core_diameter = core_diameter
    self.number_of_fins = int(number_of_fins)
    self.fin_length = fin_length
    self.fin_width = fin_width
    self.finned_length = finned_length
    self.fin_axial_offset = fin_axial_offset
    self.transition_length = transition_length

    super().__init__(
        length=length,
        outer_diameter=outer_diameter,
        inhibited_surfaces=inhibited_surfaces,
        grid_resolution=grid_resolution,
        density_ratio=density_ratio,
    )

generate_initial_face_map()

Return the initial face map for the finocyl port.

Source code in machwave/models/grain/geometries/finocyl.py
def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
    """Return the initial face map for the finocyl port."""
    map_x, map_y, map_z = self.get_coordinate_grids()
    core_map = self.get_empty_face_map()

    core_radius_normalized = self.normalize(self.core_diameter) / 2
    fin_width_normalized = self.normalize(self.fin_width)
    fin_length_normalized = self.normalize(self.fin_length)

    radius = np.sqrt(map_x**2 + map_y**2)
    core_map[radius < core_radius_normalized] = 0

    axial_position = (1 - map_z) * self.length
    local_fin_tip_normalized = core_radius_normalized + (
        self._fin_depth_fraction(axial_position) * fin_length_normalized
    )

    for i in range(self.number_of_fins):
        theta = 2 * np.pi / self.number_of_fins * i
        within_width = (
            np.abs(np.cos(theta) * map_x + np.sin(theta) * map_y)
            < fin_width_normalized / 2
        )
        radial = np.sin(theta) * map_x - np.cos(theta) * map_y
        within_length = (radial > 0) & (radial < local_fin_tip_normalized)
        core_map[within_width & within_length] = 0

    core_map[0] = 0
    core_map[-1] = 0

    return core_map

validate()

Validate finocyl segment geometry.

Source code in machwave/models/grain/geometries/finocyl.py
def validate(self) -> None:
    """Validate finocyl segment geometry."""
    super().validate()

    if not self.core_diameter > 0:
        raise grain.GrainGeometryError(
            f"Core diameter must be positive, got {self.core_diameter}"
        )
    if not self.core_diameter < self.outer_diameter:
        raise grain.GrainGeometryError(
            f"Core diameter ({self.core_diameter}) must be less than "
            f"outer diameter ({self.outer_diameter})"
        )
    if not isinstance(self.number_of_fins, int):
        raise grain.GrainGeometryError(
            f"Number of fins must be an integer, got "
            f"{type(self.number_of_fins).__name__}"
        )
    if not self.number_of_fins > 0:
        raise grain.GrainGeometryError(
            f"Number of fins must be positive, got {self.number_of_fins}"
        )
    if not self.number_of_fins < 12:
        raise grain.GrainGeometryError(
            f"Number of fins must be less than 12, got {self.number_of_fins}"
        )
    if not self.fin_length > 0:
        raise grain.GrainGeometryError(
            f"Fin length must be positive, got {self.fin_length}"
        )
    if not self.fin_width > 0:
        raise grain.GrainGeometryError(
            f"Fin width must be positive, got {self.fin_width}"
        )

    fin_tip_diameter = self.core_diameter + 2 * self.fin_length
    if not fin_tip_diameter < self.outer_diameter:
        raise grain.GrainGeometryError(
            f"Fin tip diameter ({fin_tip_diameter}) must be less than "
            f"outer diameter ({self.outer_diameter})"
        )

    if self.number_of_fins >= 2:
        fin_tip_radius = self.core_diameter / 2 + self.fin_length
        max_fin_width = 2 * fin_tip_radius * np.tan(np.pi / self.number_of_fins)
        if not self.fin_width < max_fin_width:
            raise grain.GrainGeometryError(
                f"Fin width ({self.fin_width}) must be less than "
                f"{max_fin_width} to avoid overlapping fins"
            )

    if not self.finned_length > 0:
        raise grain.GrainGeometryError(
            f"Finned length must be positive, got {self.finned_length}"
        )
    if not self.finned_length <= self.length:
        raise grain.GrainGeometryError(
            f"Finned length ({self.finned_length}) must not exceed "
            f"segment length ({self.length})"
        )
    if not self.fin_axial_offset >= 0:
        raise grain.GrainGeometryError(
            f"Fin axial offset must be non-negative, got {self.fin_axial_offset}"
        )
    if not self.fin_axial_offset + self.finned_length <= self.length:
        raise grain.GrainGeometryError(
            f"Finned section (offset {self.fin_axial_offset} + length "
            f"{self.finned_length}) must fit within the segment length "
            f"({self.length})"
        )
    if not self.transition_length >= 0:
        raise grain.GrainGeometryError(
            f"Transition length must be non-negative, got {self.transition_length}"
        )

    tapering_interfaces = (self.fin_axial_offset > 0) + (
        self.fin_axial_offset + self.finned_length < self.length
    )
    if not tapering_interfaces * self.transition_length <= self.finned_length:
        raise grain.GrainGeometryError(
            f"Transition length ({self.transition_length}) is too large for the "
            f"fins to reach full depth over a finned length of "
            f"{self.finned_length} with {tapering_interfaces} tapering "
            f"interface(s)"
        )

MultiPortGrainSegment

Bases: FMMGrainSegment2D

Grain segment with multiple circular ports arranged radially.

Source code in machwave/models/grain/geometries/multi_port.py
class MultiPortGrainSegment(grain_fmm.FMMGrainSegment2D):
    """Grain segment with multiple circular ports arranged radially."""

    def __init__(
        self,
        length: float,
        outer_diameter: float,
        port_diameter: float,
        port_radial_count: float,
        port_level_count: float,
        inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
        grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
        density_ratio: float = 1.0,
    ) -> None:
        """
        Initialize a multi-port grain segment.

        Args:
            length: Segment length [m].
            outer_diameter: Outer diameter [m].
            port_diameter: Diameter of each port [m].
            port_radial_count: Number of ports per concentric ring.
            port_level_count: Number of concentric rings of ports.
            inhibited_surfaces: Surfaces inhibited from burning.
            grid_resolution: Grid points per axis of the cross-section.
            density_ratio: Ratio of real to ideal propellant density.
        """
        self.port_diameter = port_diameter
        self.port_radial_count = int(port_radial_count)
        self.port_level_count = int(port_level_count)

        super().__init__(
            length=length,
            outer_diameter=outer_diameter,
            inhibited_surfaces=inhibited_surfaces,
            grid_resolution=grid_resolution,
            density_ratio=density_ratio,
        )

    def validate(self) -> None:
        """Validate multi-port segment geometry."""
        super().validate()

        if not self.port_diameter > 0:
            raise grain.GrainGeometryError(
                f"Port diameter must be positive, got {self.port_diameter}"
            )
        if not self.port_level_count > 0:
            raise grain.GrainGeometryError(
                f"Port level count must be positive, got {self.port_level_count}"
            )
        max_port_size = self.outer_diameter / 2
        total_port_size = self.port_level_count * self.port_diameter
        if not total_port_size < max_port_size:
            raise grain.GrainGeometryError(
                f"Total port size ({total_port_size}) must be less than "
                f"half the outer diameter ({max_port_size})"
            )
        if not self.port_radial_count > 0:
            raise grain.GrainGeometryError(
                f"Port radial count must be positive, got {self.port_radial_count}"
            )

    def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
        """NOTE: Still needs to correctly implement wagon wheel ports."""
        map_x, map_y = self.get_coordinate_grids()
        core_map = self.get_empty_face_map()

        outer_diameter_normalized = self.normalize(self.outer_diameter)
        port_diameter_normalized = self.normalize(self.port_diameter)

        for radius in range(self.port_radial_count):
            angle = np.pi * 2 * radius / self.port_radial_count

            for level in range(self.port_level_count):
                radial_distance = (
                    outer_diameter_normalized * level / (self.port_level_count) / 2
                )

                x_offset = radial_distance * np.cos(angle)
                y_offset = radial_distance * np.sin(angle)

                radius = np.sqrt((map_x - x_offset) ** 2 + (map_y - y_offset) ** 2)
                core_map[radius < port_diameter_normalized / 2] = 0

        return core_map

__init__(length, outer_diameter, port_diameter, port_radial_count, port_level_count, inhibited_surfaces=None, grid_resolution=grain_fmm.DEFAULT_GRID_RESOLUTION, density_ratio=1.0)

Initialize a multi-port grain segment.

Parameters:

Name Type Description Default
length float

Segment length [m].

required
outer_diameter float

Outer diameter [m].

required
port_diameter float

Diameter of each port [m].

required
port_radial_count float

Number of ports per concentric ring.

required
port_level_count float

Number of concentric rings of ports.

required
inhibited_surfaces InhibitedSurfaces | None

Surfaces inhibited from burning.

None
grid_resolution int

Grid points per axis of the cross-section.

DEFAULT_GRID_RESOLUTION
density_ratio float

Ratio of real to ideal propellant density.

1.0
Source code in machwave/models/grain/geometries/multi_port.py
def __init__(
    self,
    length: float,
    outer_diameter: float,
    port_diameter: float,
    port_radial_count: float,
    port_level_count: float,
    inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
    grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
    density_ratio: float = 1.0,
) -> None:
    """
    Initialize a multi-port grain segment.

    Args:
        length: Segment length [m].
        outer_diameter: Outer diameter [m].
        port_diameter: Diameter of each port [m].
        port_radial_count: Number of ports per concentric ring.
        port_level_count: Number of concentric rings of ports.
        inhibited_surfaces: Surfaces inhibited from burning.
        grid_resolution: Grid points per axis of the cross-section.
        density_ratio: Ratio of real to ideal propellant density.
    """
    self.port_diameter = port_diameter
    self.port_radial_count = int(port_radial_count)
    self.port_level_count = int(port_level_count)

    super().__init__(
        length=length,
        outer_diameter=outer_diameter,
        inhibited_surfaces=inhibited_surfaces,
        grid_resolution=grid_resolution,
        density_ratio=density_ratio,
    )

generate_initial_face_map()

NOTE: Still needs to correctly implement wagon wheel ports.

Source code in machwave/models/grain/geometries/multi_port.py
def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
    """NOTE: Still needs to correctly implement wagon wheel ports."""
    map_x, map_y = self.get_coordinate_grids()
    core_map = self.get_empty_face_map()

    outer_diameter_normalized = self.normalize(self.outer_diameter)
    port_diameter_normalized = self.normalize(self.port_diameter)

    for radius in range(self.port_radial_count):
        angle = np.pi * 2 * radius / self.port_radial_count

        for level in range(self.port_level_count):
            radial_distance = (
                outer_diameter_normalized * level / (self.port_level_count) / 2
            )

            x_offset = radial_distance * np.cos(angle)
            y_offset = radial_distance * np.sin(angle)

            radius = np.sqrt((map_x - x_offset) ** 2 + (map_y - y_offset) ** 2)
            core_map[radius < port_diameter_normalized / 2] = 0

    return core_map

validate()

Validate multi-port segment geometry.

Source code in machwave/models/grain/geometries/multi_port.py
def validate(self) -> None:
    """Validate multi-port segment geometry."""
    super().validate()

    if not self.port_diameter > 0:
        raise grain.GrainGeometryError(
            f"Port diameter must be positive, got {self.port_diameter}"
        )
    if not self.port_level_count > 0:
        raise grain.GrainGeometryError(
            f"Port level count must be positive, got {self.port_level_count}"
        )
    max_port_size = self.outer_diameter / 2
    total_port_size = self.port_level_count * self.port_diameter
    if not total_port_size < max_port_size:
        raise grain.GrainGeometryError(
            f"Total port size ({total_port_size}) must be less than "
            f"half the outer diameter ({max_port_size})"
        )
    if not self.port_radial_count > 0:
        raise grain.GrainGeometryError(
            f"Port radial count must be positive, got {self.port_radial_count}"
        )

RodAndTubeGrainSegment

Bases: FMMGrainSegment2D

Rod-and-tube grain segment: central rod inside a concentric tube.

Source code in machwave/models/grain/geometries/rod_and_tube.py
class RodAndTubeGrainSegment(grain_fmm.FMMGrainSegment2D):
    """Rod-and-tube grain segment: central rod inside a concentric tube."""

    def __init__(
        self,
        length: float,
        outer_diameter: float,
        rod_outer_diameter: float,
        tube_inner_diameter: float,
        inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
        grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
        density_ratio: float = 1.0,
    ) -> None:
        """
        Initialize a rod-and-tube grain segment.

        Args:
            length: Segment length [m].
            outer_diameter: Outer (tube outer) diameter [m].
            rod_outer_diameter: Central rod outer diameter [m].
            tube_inner_diameter: Tube inner diameter [m].
            inhibited_surfaces: Surfaces inhibited from burning.
            grid_resolution: Grid points per axis of the cross-section.
            density_ratio: Ratio of real to ideal propellant density.
        """
        self.rod_outer_diameter = rod_outer_diameter
        self.tube_inner_diameter = tube_inner_diameter

        super().__init__(
            length=length,
            outer_diameter=outer_diameter,
            inhibited_surfaces=inhibited_surfaces,
            grid_resolution=grid_resolution,
            density_ratio=density_ratio,
        )

    def validate(self) -> None:
        """Validate rod-and-tube segment geometry."""
        super().validate()

        if not self.rod_outer_diameter > 0:
            raise grain.GrainGeometryError(
                f"Rod outer diameter must be positive, got {self.rod_outer_diameter}"
            )
        if not self.tube_inner_diameter > self.rod_outer_diameter:
            raise grain.GrainGeometryError(
                f"Tube inner diameter ({self.tube_inner_diameter}) must be greater than "
                f"rod outer diameter ({self.rod_outer_diameter})"
            )
        if not self.tube_inner_diameter < self.outer_diameter:
            raise grain.GrainGeometryError(
                f"Tube inner diameter ({self.tube_inner_diameter}) must be less than "
                f"outer diameter ({self.outer_diameter})"
            )

    def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
        """NOTE: Still needs to correctly implement wagon wheel ports."""
        map_x, map_y = self.get_coordinate_grids()
        core_map = self.get_empty_face_map()

        rod_outer_diameter_normalized = self.normalize(self.rod_outer_diameter)
        tube_inner_diameter_normalized = self.normalize(self.tube_inner_diameter)

        radius = np.sqrt(map_x**2 + map_y**2)

        core_map[
            (radius > rod_outer_diameter_normalized / 2)
            & (radius < tube_inner_diameter_normalized / 2)
        ] = 0

        return core_map

__init__(length, outer_diameter, rod_outer_diameter, tube_inner_diameter, inhibited_surfaces=None, grid_resolution=grain_fmm.DEFAULT_GRID_RESOLUTION, density_ratio=1.0)

Initialize a rod-and-tube grain segment.

Parameters:

Name Type Description Default
length float

Segment length [m].

required
outer_diameter float

Outer (tube outer) diameter [m].

required
rod_outer_diameter float

Central rod outer diameter [m].

required
tube_inner_diameter float

Tube inner diameter [m].

required
inhibited_surfaces InhibitedSurfaces | None

Surfaces inhibited from burning.

None
grid_resolution int

Grid points per axis of the cross-section.

DEFAULT_GRID_RESOLUTION
density_ratio float

Ratio of real to ideal propellant density.

1.0
Source code in machwave/models/grain/geometries/rod_and_tube.py
def __init__(
    self,
    length: float,
    outer_diameter: float,
    rod_outer_diameter: float,
    tube_inner_diameter: float,
    inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
    grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
    density_ratio: float = 1.0,
) -> None:
    """
    Initialize a rod-and-tube grain segment.

    Args:
        length: Segment length [m].
        outer_diameter: Outer (tube outer) diameter [m].
        rod_outer_diameter: Central rod outer diameter [m].
        tube_inner_diameter: Tube inner diameter [m].
        inhibited_surfaces: Surfaces inhibited from burning.
        grid_resolution: Grid points per axis of the cross-section.
        density_ratio: Ratio of real to ideal propellant density.
    """
    self.rod_outer_diameter = rod_outer_diameter
    self.tube_inner_diameter = tube_inner_diameter

    super().__init__(
        length=length,
        outer_diameter=outer_diameter,
        inhibited_surfaces=inhibited_surfaces,
        grid_resolution=grid_resolution,
        density_ratio=density_ratio,
    )

generate_initial_face_map()

NOTE: Still needs to correctly implement wagon wheel ports.

Source code in machwave/models/grain/geometries/rod_and_tube.py
def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
    """NOTE: Still needs to correctly implement wagon wheel ports."""
    map_x, map_y = self.get_coordinate_grids()
    core_map = self.get_empty_face_map()

    rod_outer_diameter_normalized = self.normalize(self.rod_outer_diameter)
    tube_inner_diameter_normalized = self.normalize(self.tube_inner_diameter)

    radius = np.sqrt(map_x**2 + map_y**2)

    core_map[
        (radius > rod_outer_diameter_normalized / 2)
        & (radius < tube_inner_diameter_normalized / 2)
    ] = 0

    return core_map

validate()

Validate rod-and-tube segment geometry.

Source code in machwave/models/grain/geometries/rod_and_tube.py
def validate(self) -> None:
    """Validate rod-and-tube segment geometry."""
    super().validate()

    if not self.rod_outer_diameter > 0:
        raise grain.GrainGeometryError(
            f"Rod outer diameter must be positive, got {self.rod_outer_diameter}"
        )
    if not self.tube_inner_diameter > self.rod_outer_diameter:
        raise grain.GrainGeometryError(
            f"Tube inner diameter ({self.tube_inner_diameter}) must be greater than "
            f"rod outer diameter ({self.rod_outer_diameter})"
        )
    if not self.tube_inner_diameter < self.outer_diameter:
        raise grain.GrainGeometryError(
            f"Tube inner diameter ({self.tube_inner_diameter}) must be less than "
            f"outer diameter ({self.outer_diameter})"
        )

StarGrainSegment

Bases: FMMGrainSegment2D

Star grain segment with a radial point pattern as the port.

Source code in machwave/models/grain/geometries/star.py
class StarGrainSegment(grain_fmm.FMMGrainSegment2D):
    """Star grain segment with a radial point pattern as the port."""

    def __init__(
        self,
        length: float,
        outer_diameter: float,
        number_of_points: int,
        point_length: float,
        point_width: float,
        inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
        grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
        density_ratio: float = 1.0,
    ) -> None:
        """
        Initialize a star grain segment.

        Args:
            length: Segment length [m].
            outer_diameter: Outer diameter [m].
            number_of_points: Number of star points (must be < 12).
            point_length: Radial length of each point [m].
            point_width: Width of each point at the base [m].
            inhibited_surfaces: Surfaces inhibited from burning.
            grid_resolution: Grid points per axis of the cross-section.
            density_ratio: Ratio of real to ideal propellant density.
        """
        self.number_of_points = int(number_of_points)
        self.point_length = point_length
        self.point_width = point_width

        super().__init__(
            length=length,
            outer_diameter=outer_diameter,
            inhibited_surfaces=inhibited_surfaces,
            grid_resolution=grid_resolution,
            density_ratio=density_ratio,
        )

    def validate(self) -> None:
        """Validate star segment geometry."""
        super().validate()

        if not self.number_of_points > 0:
            raise grain.GrainGeometryError(
                f"Number of points must be positive, got {self.number_of_points}"
            )
        if not self.number_of_points < 12:
            raise grain.GrainGeometryError(
                f"Number of points must be less than 12, got {self.number_of_points}"
            )
        if not isinstance(self.number_of_points, int):
            raise grain.GrainGeometryError(
                f"Number of points must be an integer, got {type(self.number_of_points).__name__}"
            )
        if not self.point_length > 0:
            raise grain.GrainGeometryError(
                f"Point length must be positive, got {self.point_length}"
            )
        if not self.point_width > 0:
            raise grain.GrainGeometryError(
                f"Point width must be positive, got {self.point_width}"
            )

    def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
        """Return the initial face map for a star grain segment."""
        map_x, map_y = self.get_coordinate_grids()
        core_map = self.get_empty_face_map()

        point_length_normalized = self.normalize(self.point_length)
        point_width_normalized = self.normalize(self.point_width)

        radius = (map_x**2 + map_y**2) ** 0.5

        for i in range(0, self.number_of_points):
            theta = 2 * np.pi / self.number_of_points * i
            rect = abs(np.cos(theta) * map_x + np.sin(theta) * map_y)

            width = (
                point_width_normalized / 2 * (1 - (radius / point_length_normalized))
            )
            vect = rect < width
            near = np.sin(theta) * map_x - np.cos(theta) * map_y > -0.025

            core_map[np.logical_and(vect, near)] = 0

        return core_map

__init__(length, outer_diameter, number_of_points, point_length, point_width, inhibited_surfaces=None, grid_resolution=grain_fmm.DEFAULT_GRID_RESOLUTION, density_ratio=1.0)

Initialize a star grain segment.

Parameters:

Name Type Description Default
length float

Segment length [m].

required
outer_diameter float

Outer diameter [m].

required
number_of_points int

Number of star points (must be < 12).

required
point_length float

Radial length of each point [m].

required
point_width float

Width of each point at the base [m].

required
inhibited_surfaces InhibitedSurfaces | None

Surfaces inhibited from burning.

None
grid_resolution int

Grid points per axis of the cross-section.

DEFAULT_GRID_RESOLUTION
density_ratio float

Ratio of real to ideal propellant density.

1.0
Source code in machwave/models/grain/geometries/star.py
def __init__(
    self,
    length: float,
    outer_diameter: float,
    number_of_points: int,
    point_length: float,
    point_width: float,
    inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
    grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
    density_ratio: float = 1.0,
) -> None:
    """
    Initialize a star grain segment.

    Args:
        length: Segment length [m].
        outer_diameter: Outer diameter [m].
        number_of_points: Number of star points (must be < 12).
        point_length: Radial length of each point [m].
        point_width: Width of each point at the base [m].
        inhibited_surfaces: Surfaces inhibited from burning.
        grid_resolution: Grid points per axis of the cross-section.
        density_ratio: Ratio of real to ideal propellant density.
    """
    self.number_of_points = int(number_of_points)
    self.point_length = point_length
    self.point_width = point_width

    super().__init__(
        length=length,
        outer_diameter=outer_diameter,
        inhibited_surfaces=inhibited_surfaces,
        grid_resolution=grid_resolution,
        density_ratio=density_ratio,
    )

generate_initial_face_map()

Return the initial face map for a star grain segment.

Source code in machwave/models/grain/geometries/star.py
def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
    """Return the initial face map for a star grain segment."""
    map_x, map_y = self.get_coordinate_grids()
    core_map = self.get_empty_face_map()

    point_length_normalized = self.normalize(self.point_length)
    point_width_normalized = self.normalize(self.point_width)

    radius = (map_x**2 + map_y**2) ** 0.5

    for i in range(0, self.number_of_points):
        theta = 2 * np.pi / self.number_of_points * i
        rect = abs(np.cos(theta) * map_x + np.sin(theta) * map_y)

        width = (
            point_width_normalized / 2 * (1 - (radius / point_length_normalized))
        )
        vect = rect < width
        near = np.sin(theta) * map_x - np.cos(theta) * map_y > -0.025

        core_map[np.logical_and(vect, near)] = 0

    return core_map

validate()

Validate star segment geometry.

Source code in machwave/models/grain/geometries/star.py
def validate(self) -> None:
    """Validate star segment geometry."""
    super().validate()

    if not self.number_of_points > 0:
        raise grain.GrainGeometryError(
            f"Number of points must be positive, got {self.number_of_points}"
        )
    if not self.number_of_points < 12:
        raise grain.GrainGeometryError(
            f"Number of points must be less than 12, got {self.number_of_points}"
        )
    if not isinstance(self.number_of_points, int):
        raise grain.GrainGeometryError(
            f"Number of points must be an integer, got {type(self.number_of_points).__name__}"
        )
    if not self.point_length > 0:
        raise grain.GrainGeometryError(
            f"Point length must be positive, got {self.point_length}"
        )
    if not self.point_width > 0:
        raise grain.GrainGeometryError(
            f"Point width must be positive, got {self.point_width}"
        )

WagonWheelGrainSegment

Bases: FMMGrainSegment2D

Wagon-wheel grain segment with a central core and radial spoke ports.

Source code in machwave/models/grain/geometries/wagon_wheel.py
class WagonWheelGrainSegment(grain_fmm.FMMGrainSegment2D):
    """Wagon-wheel grain segment with a central core and radial spoke ports."""

    def __init__(
        self,
        length: float,
        outer_diameter: float,
        core_diameter: float,
        number_of_ports: int,
        port_inner_diameter: float,
        port_outer_diameter: float,
        port_angular_width: float,
        inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
        grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
        density_ratio: float = 1.0,
    ) -> None:
        """
        Initialize a wagon-wheel grain segment.

        Args:
            length: Segment length [m].
            outer_diameter: Outer diameter [m].
            core_diameter: Central core diameter [m].
            number_of_ports: Number of radial spoke ports (must be even).
            port_inner_diameter: Inner radial extent of each spoke port [m].
            port_outer_diameter: Outer radial extent of each spoke port [m].
            port_angular_width: Angular width of each spoke port [deg].
            inhibited_surfaces: Surfaces inhibited from burning.
            grid_resolution: Grid points per axis of the cross-section.
            density_ratio: Ratio of real to ideal propellant density.
        """
        self.core_diameter = core_diameter
        self.number_of_ports = int(number_of_ports)
        self.port_inner_diameter = port_inner_diameter
        self.port_outer_diameter = port_outer_diameter
        self.port_angular_width = port_angular_width

        super().__init__(
            length=length,
            outer_diameter=outer_diameter,
            inhibited_surfaces=inhibited_surfaces,
            grid_resolution=grid_resolution,
            density_ratio=density_ratio,
        )

    def validate(self) -> None:
        """Validate wagon-wheel segment geometry."""
        super().validate()

        if not self.number_of_ports > 0:
            raise grain.GrainGeometryError(
                f"Number of ports must be positive, got {self.number_of_ports}"
            )
        if not self.number_of_ports < 12:
            raise grain.GrainGeometryError(
                f"Number of ports must be less than 12, got {self.number_of_ports}"
            )
        if not self.number_of_ports % 2 == 0:
            raise grain.GrainGeometryError(
                f"Number of ports must be even, got {self.number_of_ports}"
            )
        if not isinstance(self.number_of_ports, int):
            raise grain.GrainGeometryError(
                f"Number of ports must be an integer, got {type(self.number_of_ports).__name__}"
            )
        if not self.port_inner_diameter > self.core_diameter:
            raise grain.GrainGeometryError(
                f"Port inner diameter ({self.port_inner_diameter}) must be greater than "
                f"core diameter ({self.core_diameter})"
            )
        if not self.port_outer_diameter > self.port_inner_diameter:
            raise grain.GrainGeometryError(
                f"Port outer diameter ({self.port_outer_diameter}) must be greater than "
                f"port inner diameter ({self.port_inner_diameter})"
            )
        if not self.port_angular_width > 0:
            raise grain.GrainGeometryError(
                f"Port angular width must be positive, got {self.port_angular_width}"
            )
        max_angular_width = 360 / self.number_of_ports
        if not self.port_angular_width < max_angular_width:
            raise grain.GrainGeometryError(
                f"Port angular width ({self.port_angular_width}) must be less than "
                f"{max_angular_width} (360 / {self.number_of_ports})"
            )

    def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
        """NOTE: Still needs to correctly implement wagon wheel ports."""
        map_x, map_y = self.get_coordinate_grids()
        core_map = self.get_empty_face_map()

        core_diameter_normalized = self.normalize(self.core_diameter)
        port_inner_diameter_normalized = self.normalize(self.port_inner_diameter)
        port_outer_diameter_normalized = self.normalize(self.port_outer_diameter)

        radius = np.sqrt(map_x**2 + map_y**2)

        core_map[radius < core_diameter_normalized / 2] = 0

        for port_index in range(int(self.number_of_ports)):
            displacement_angle = 2 * np.pi / self.number_of_ports * (port_index)

            theta_2 = np.deg2rad(self.port_angular_width / 2) + displacement_angle
            theta_1 = displacement_angle - np.deg2rad(self.port_angular_width / 2)

            map_x_y_arctan = np.arctan(map_y / map_x)

            core_map[
                (radius < port_outer_diameter_normalized / 2)
                & (radius > port_inner_diameter_normalized / 2)
                & (np.abs(map_x_y_arctan) < theta_2)
                & (np.abs(map_x_y_arctan) > theta_1)
            ] = 0

        return core_map

__init__(length, outer_diameter, core_diameter, number_of_ports, port_inner_diameter, port_outer_diameter, port_angular_width, inhibited_surfaces=None, grid_resolution=grain_fmm.DEFAULT_GRID_RESOLUTION, density_ratio=1.0)

Initialize a wagon-wheel grain segment.

Parameters:

Name Type Description Default
length float

Segment length [m].

required
outer_diameter float

Outer diameter [m].

required
core_diameter float

Central core diameter [m].

required
number_of_ports int

Number of radial spoke ports (must be even).

required
port_inner_diameter float

Inner radial extent of each spoke port [m].

required
port_outer_diameter float

Outer radial extent of each spoke port [m].

required
port_angular_width float

Angular width of each spoke port [deg].

required
inhibited_surfaces InhibitedSurfaces | None

Surfaces inhibited from burning.

None
grid_resolution int

Grid points per axis of the cross-section.

DEFAULT_GRID_RESOLUTION
density_ratio float

Ratio of real to ideal propellant density.

1.0
Source code in machwave/models/grain/geometries/wagon_wheel.py
def __init__(
    self,
    length: float,
    outer_diameter: float,
    core_diameter: float,
    number_of_ports: int,
    port_inner_diameter: float,
    port_outer_diameter: float,
    port_angular_width: float,
    inhibited_surfaces: grain_base.InhibitedSurfaces | None = None,
    grid_resolution: int = grain_fmm.DEFAULT_GRID_RESOLUTION,
    density_ratio: float = 1.0,
) -> None:
    """
    Initialize a wagon-wheel grain segment.

    Args:
        length: Segment length [m].
        outer_diameter: Outer diameter [m].
        core_diameter: Central core diameter [m].
        number_of_ports: Number of radial spoke ports (must be even).
        port_inner_diameter: Inner radial extent of each spoke port [m].
        port_outer_diameter: Outer radial extent of each spoke port [m].
        port_angular_width: Angular width of each spoke port [deg].
        inhibited_surfaces: Surfaces inhibited from burning.
        grid_resolution: Grid points per axis of the cross-section.
        density_ratio: Ratio of real to ideal propellant density.
    """
    self.core_diameter = core_diameter
    self.number_of_ports = int(number_of_ports)
    self.port_inner_diameter = port_inner_diameter
    self.port_outer_diameter = port_outer_diameter
    self.port_angular_width = port_angular_width

    super().__init__(
        length=length,
        outer_diameter=outer_diameter,
        inhibited_surfaces=inhibited_surfaces,
        grid_resolution=grid_resolution,
        density_ratio=density_ratio,
    )

generate_initial_face_map()

NOTE: Still needs to correctly implement wagon wheel ports.

Source code in machwave/models/grain/geometries/wagon_wheel.py
def generate_initial_face_map(self) -> np.typing.NDArray[np.int_]:
    """NOTE: Still needs to correctly implement wagon wheel ports."""
    map_x, map_y = self.get_coordinate_grids()
    core_map = self.get_empty_face_map()

    core_diameter_normalized = self.normalize(self.core_diameter)
    port_inner_diameter_normalized = self.normalize(self.port_inner_diameter)
    port_outer_diameter_normalized = self.normalize(self.port_outer_diameter)

    radius = np.sqrt(map_x**2 + map_y**2)

    core_map[radius < core_diameter_normalized / 2] = 0

    for port_index in range(int(self.number_of_ports)):
        displacement_angle = 2 * np.pi / self.number_of_ports * (port_index)

        theta_2 = np.deg2rad(self.port_angular_width / 2) + displacement_angle
        theta_1 = displacement_angle - np.deg2rad(self.port_angular_width / 2)

        map_x_y_arctan = np.arctan(map_y / map_x)

        core_map[
            (radius < port_outer_diameter_normalized / 2)
            & (radius > port_inner_diameter_normalized / 2)
            & (np.abs(map_x_y_arctan) < theta_2)
            & (np.abs(map_x_y_arctan) > theta_1)
        ] = 0

    return core_map

validate()

Validate wagon-wheel segment geometry.

Source code in machwave/models/grain/geometries/wagon_wheel.py
def validate(self) -> None:
    """Validate wagon-wheel segment geometry."""
    super().validate()

    if not self.number_of_ports > 0:
        raise grain.GrainGeometryError(
            f"Number of ports must be positive, got {self.number_of_ports}"
        )
    if not self.number_of_ports < 12:
        raise grain.GrainGeometryError(
            f"Number of ports must be less than 12, got {self.number_of_ports}"
        )
    if not self.number_of_ports % 2 == 0:
        raise grain.GrainGeometryError(
            f"Number of ports must be even, got {self.number_of_ports}"
        )
    if not isinstance(self.number_of_ports, int):
        raise grain.GrainGeometryError(
            f"Number of ports must be an integer, got {type(self.number_of_ports).__name__}"
        )
    if not self.port_inner_diameter > self.core_diameter:
        raise grain.GrainGeometryError(
            f"Port inner diameter ({self.port_inner_diameter}) must be greater than "
            f"core diameter ({self.core_diameter})"
        )
    if not self.port_outer_diameter > self.port_inner_diameter:
        raise grain.GrainGeometryError(
            f"Port outer diameter ({self.port_outer_diameter}) must be greater than "
            f"port inner diameter ({self.port_inner_diameter})"
        )
    if not self.port_angular_width > 0:
        raise grain.GrainGeometryError(
            f"Port angular width must be positive, got {self.port_angular_width}"
        )
    max_angular_width = 360 / self.number_of_ports
    if not self.port_angular_width < max_angular_width:
        raise grain.GrainGeometryError(
            f"Port angular width ({self.port_angular_width}) must be less than "
            f"{max_angular_width} (360 / {self.number_of_ports})"
        )