diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 3f4748ac0..272e83730 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -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)"], @@ -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) @@ -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. @@ -747,8 +769,9 @@ 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) @@ -756,16 +779,16 @@ def evaluate_static_margin(self): ) / (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. @@ -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() @@ -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. diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py index 7a37cbd4e..2682f5b90 100644 --- a/tests/unit/rocket/test_rocket.py +++ b/tests/unit/rocket/test_rocket.py @@ -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"]),