Skip to content
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
51 changes: 37 additions & 14 deletions rocketpy/rocket/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,9 +385,10 @@ def __init__( # pylint: disable=too-many-statements
inputs="Mach Number",
outputs="Total Lift Coefficient Derivative",
)
self.static_margin = Function(
self._static_margin = Function(
lambda time: 0, inputs="Time (s)", outputs="Static Margin (c)"
)
self._static_margin_dirty = True
self.stability_margin = Function(
lambda mach, time: 0,
inputs=["Mach", "Time (s)"],
Expand Down Expand Up @@ -442,10 +443,10 @@ def __init__( # pylint: disable=too-many-statements
self.evaluate_reduced_mass()
self.evaluate_thrust_to_weight()

# Evaluate stability (even though no aerodynamic surfaces are present yet)
# Evaluate stability quantities needed for later work. Static margin is
# left dirty and built lazily on first access (see static_margin).
self.evaluate_center_of_pressure()
self.evaluate_stability_margin()
self.evaluate_static_margin()

# Initialize plots and prints object
self.prints = _RocketPrints(self)
Expand Down Expand Up @@ -737,6 +738,27 @@ def evaluate_stability_margin(self):
)
return self.stability_margin

def _invalidate_static_margin(self):
"""Mark the cached static margin as stale.

Call this whenever rocket geometry, mass properties, or aerodynamic
surfaces change in a way that can alter the static margin. The next
access of :attr:`static_margin` (or an explicit call to
:meth:`evaluate_static_margin`) rebuilds the Function.
"""
self._static_margin_dirty = True

@property
def static_margin(self):
"""Static margin of the rocket as a function of time (calibers).

Computed lazily: rebuilt only when first accessed after construction or
after geometry/mass/surface changes that invalidate the cache.
"""
if self._static_margin_dirty:
self.evaluate_static_margin()
return self._static_margin

def evaluate_static_margin(self):
"""Calculates the static margin of the rocket as a function of time.

Expand All @@ -747,25 +769,26 @@ def evaluate_static_margin(self):
Static margin is defined as the distance between the center of
pressure and the center of mass, divided by the rocket's diameter.
"""
# Calculate static margin
self.static_margin.set_source(
# Calculate static margin; fold _csys into the source so we do not
# rebind a property when multiplying.
self._static_margin.set_source(
lambda time: (
(
self.center_of_mass.get_value_opt(time)
- self.cp_position.get_value_opt(0)
)
/ (2 * self.radius)
)
* self._csys
)
# Change sign if coordinate system is upside down
self.static_margin *= self._csys
self.static_margin.set_inputs("Time (s)")
self.static_margin.set_outputs("Static Margin (c)")
self.static_margin.set_title("Static Margin")
self.static_margin.set_discrete(
self._static_margin.set_inputs("Time (s)")
self._static_margin.set_outputs("Static Margin (c)")
self._static_margin.set_title("Static Margin")
self._static_margin.set_discrete(
lower=0, upper=self.motor.burn_out_time, samples=200
)
return self.static_margin
self._static_margin_dirty = False
return self._static_margin

def warn_if_unstable(self):
"""Warn if the rocket is aerodynamically unstable at motor ignition.
Expand Down Expand Up @@ -1137,7 +1160,7 @@ def add_motor(self, motor, position): # pylint: disable=too-many-statements
self.evaluate_center_of_pressure()
self.evaluate_surfaces_cp_to_cdm()
self.evaluate_stability_margin()
self.evaluate_static_margin()
self._invalidate_static_margin()
self.evaluate_com_to_cdm_function()
self.evaluate_nozzle_gyration_tensor()

Expand Down Expand Up @@ -1218,7 +1241,7 @@ def add_surfaces(self, surfaces, positions):

self.evaluate_center_of_pressure()
self.evaluate_stability_margin()
self.evaluate_static_margin()
self._invalidate_static_margin()

def _add_controllers(self, controllers):
"""Adds a controller to the rocket.
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/rocket/test_rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,55 @@ def test_evaluate_static_margin_assert_cp_equals_cm(dimensionless_calisto):
assert pytest.approx(rocket.cp_position(0), 1e-8) == pytest.approx(0, 1e-8)


def test_static_margin_lazy_until_accessed(calisto_motorless):
"""Static margin must not be discretized until first access."""
rocket = calisto_motorless
assert rocket._static_margin_dirty is True

with patch.object(
rocket._static_margin,
"set_discrete",
wraps=rocket._static_margin.set_discrete,
) as mock_set_discrete:
rocket.add_nose(length=0.55829, kind="ogive", position=1.160)
mock_set_discrete.assert_not_called()
assert rocket._static_margin_dirty is True

static_margin = rocket.static_margin
assert mock_set_discrete.call_count == 1
assert rocket._static_margin_dirty is False
assert isinstance(static_margin, Function)

# Second access must reuse the cached Function.
_ = rocket.static_margin(0)
assert mock_set_discrete.call_count == 1


def test_static_margin_rebuilds_after_adding_surface(calisto):
"""Adding an aero surface invalidates SM; access rebuilds it once."""
rocket = calisto
margin_before = rocket.static_margin(0)
assert rocket._static_margin_dirty is False

with patch.object(
rocket._static_margin,
"set_discrete",
wraps=rocket._static_margin.set_discrete,
) as mock_set_discrete:
rocket.add_nose(length=0.55829, kind="ogive", position=1.160)
mock_set_discrete.assert_not_called()
assert rocket._static_margin_dirty is True

margin_after = rocket.static_margin(0)
assert mock_set_discrete.call_count == 1
assert rocket._static_margin_dirty is False

_ = rocket.static_margin(0)
assert mock_set_discrete.call_count == 1

assert margin_after != pytest.approx(margin_before, abs=1e-6)


@pytest.mark.parametrize(
"k, type_",
([2 / 3, "conical"], [0.46469957130675876, "ogive"], [0.563, "lvhaack"]),
Expand Down