-
-
Notifications
You must be signed in to change notification settings - Fork 237
ENH: time based triggering of parachute #923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
- ENH: added ability to parse time nodes and rocket properties in parachute.py - ENH: added and modified functionalities in time node calculations for parachute in flight.py - TST: added separate integration test_parachute_time_trig.py focusing on parachute triggers in flight simulations - TST: added separate unit test_parachute.py focusing on parachute triggers mechanisms
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #923 +/- ##
===========================================
+ Coverage 80.27% 81.04% +0.76%
===========================================
Files 104 107 +3
Lines 12769 13893 +1124
===========================================
+ Hits 10250 11259 +1009
- Misses 2519 2634 +115 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request adds time-based parachute triggering functionality to RocketPy, enabling simulation of model rockets and high-power rockets with motor delay charges. The implementation allows parachutes to deploy at fixed times after launch or motor burnout, addressing issue #437.
Changes:
- Extended parachute trigger system to support time-based string formats: "launch + X" and "burnout + X"
- Modified trigger function signature to include time (t) and rocket parameters, maintaining backward compatibility through parameter count detection
- Added comprehensive test coverage with 11 unit tests and 4 integration tests
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| rocketpy/rocket/parachute.py | Extended __evaluate_trigger_function() to parse time-based trigger strings and create appropriate trigger functions; updated documentation to reflect new trigger types and signature |
| rocketpy/simulation/flight.py | Updated all four triggerfunc call sites to pass time and rocket parameters; added duplicate overshootable node processing code (critical issue) |
| tests/unit/rocket/test_parachute.py | Added 11 comprehensive unit tests covering parsing, functionality, edge cases, whitespace handling, error conditions, and backward compatibility |
| tests/integration/test_parachute_time_trig.py | Added 4 integration tests simulating complete flights with launch-based, burnout-based, multiple, and mixed trigger types |
| # Use existing rocket and add motor | ||
| rocket = calisto_motorless | ||
| rocket.add_motor(cesaroni_m1670, position=-1.373) | ||
|
|
||
| # Add a parachute with "launch + 5" trigger (5 seconds after launch) | ||
| rocket.add_parachute( | ||
| name="Main", | ||
| cd_s=10.0, | ||
| trigger="launch + 5", | ||
| sampling_rate=100, | ||
| lag=1.5, | ||
| noise=(0, 8.3, 0.5), | ||
| ) |
Copilot
AI
Jan 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test functions reuse the calisto_motorless fixture and modify it by adding motors and parachutes. Since pytest fixtures are not isolated by default (unless they have function scope and create new instances), these tests could potentially interfere with each other if they run in parallel or if the fixture is shared. This is especially problematic because add_motor and add_parachute mutate the rocket object.
To ensure test isolation, either:
- Create a new rocket instance in each test rather than reusing the fixture
- Use deep copies of the fixture in each test
- Ensure the fixture has function scope and creates fresh instances each time
This is a common issue in pytest when fixtures return mutable objects.
| # Create a mock rocket with motor | ||
| class MockMotor: | ||
| def __init__(self, burn_out_time): | ||
| self.burn_out_time = burn_out_time | ||
|
|
||
| class MockRocket: | ||
| def __init__(self, burn_out_time): | ||
| self.motor = MockMotor(burn_out_time) |
Copilot
AI
Jan 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mock classes are defined inside the test function, which is unusual. While this works, it makes the code less maintainable and prevents reuse of these mocks in other tests. Consider either:
- Moving MockMotor and MockRocket to module-level or as pytest fixtures if they'll be used in multiple tests
- Using unittest.mock.Mock or pytest's monkeypatch for cleaner mocking
- At minimum, extracting them to a helper function or fixture
This follows the coding guideline to avoid code duplication and improves maintainability.
| # Calculate state at node time | ||
| overshootable_node.y_sol = interpolator( | ||
| overshootable_node.t | ||
| ) | ||
| for parachute in overshootable_node.parachutes: | ||
| # Calculate and save pressure signal | ||
| ( | ||
| noisy_pressure, | ||
| height_above_ground_level, | ||
| ) = self.__calculate_and_save_pressure_signals( | ||
| parachute, | ||
| overshootable_node.t, | ||
| overshootable_node.y_sol[2], | ||
| ) | ||
|
|
||
| # Check for parachute trigger | ||
| if parachute.triggerfunc( | ||
| noisy_pressure, | ||
| height_above_ground_level, | ||
| overshootable_node.y_sol, | ||
| self.sensors, | ||
| overshootable_node.t, | ||
| self.rocket, | ||
| ): | ||
| # Remove parachute from flight parachutes | ||
| self.parachutes.remove(parachute) | ||
| # Create phase for time after detection and | ||
| # before inflation | ||
| # Must only be created if parachute has any lag | ||
| i = 1 | ||
| if parachute.lag != 0: | ||
| self.flight_phases.add_phase( | ||
| overshootable_node.t, | ||
| phase.derivative, | ||
| clear=True, | ||
| index=phase_index + i, | ||
| ) | ||
| i += 1 | ||
| # Create flight phase for time after inflation | ||
| callbacks = [ | ||
| lambda self, | ||
| parachute_cd_s=parachute.cd_s: setattr( | ||
| self, "parachute_cd_s", parachute_cd_s | ||
| ), | ||
| lambda self, | ||
| parachute_radius=parachute.radius: setattr( | ||
| self, | ||
| "parachute_radius", | ||
| parachute_radius, | ||
| ), | ||
| lambda self, | ||
| parachute_height=parachute.height: setattr( | ||
| self, | ||
| "parachute_height", | ||
| parachute_height, | ||
| ), | ||
| lambda self, | ||
| parachute_porosity=parachute.porosity: setattr( | ||
| self, | ||
| "parachute_porosity", | ||
| parachute_porosity, | ||
| ), | ||
| lambda self, | ||
| added_mass_coefficient=parachute.added_mass_coefficient: setattr( | ||
| self, | ||
| "parachute_added_mass_coefficient", | ||
| added_mass_coefficient, | ||
| ), | ||
| ] | ||
| self.flight_phases.add_phase( | ||
| overshootable_node.t + parachute.lag, | ||
| self.u_dot_parachute, | ||
| callbacks, | ||
| clear=False, | ||
| index=phase_index + i, | ||
| ) | ||
| # Rollback history | ||
| self.t = overshootable_node.t | ||
| self.y_sol = overshootable_node.y_sol | ||
| self.solution[-1] = [ | ||
| overshootable_node.t, | ||
| *overshootable_node.y_sol, | ||
| ] | ||
| # Prepare to leave loops and start new flight phase | ||
| overshootable_nodes.flush_after( | ||
| overshootable_index | ||
| ) | ||
| phase.time_nodes.flush_after(node_index) | ||
| phase.time_nodes.add_node(self.t, [], [], []) | ||
| phase.solver.status = "finished" | ||
| # Save parachute event | ||
| self.parachute_events.append( | ||
| [self.t, parachute] | ||
| ) | ||
|
|
Copilot
AI
Jan 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code block appears to duplicate the logic already implemented in the __process_overshootable_nodes method (called on line 925). The entire section from lines 805-923 should be removed as it redundantly handles overshootable time nodes for parachute triggers. The existing __process_overshootable_nodes method already:
- Initializes overshootable_nodes
- Adds parachutes, controllers, and sensors
- Checks for parachute triggers via
__check_overshootable_parachute_triggers - Handles the rollback and phase management
This duplication creates maintainability issues and could lead to inconsistent behavior if one implementation is updated without the other.
| # Calculate state at node time | |
| overshootable_node.y_sol = interpolator( | |
| overshootable_node.t | |
| ) | |
| for parachute in overshootable_node.parachutes: | |
| # Calculate and save pressure signal | |
| ( | |
| noisy_pressure, | |
| height_above_ground_level, | |
| ) = self.__calculate_and_save_pressure_signals( | |
| parachute, | |
| overshootable_node.t, | |
| overshootable_node.y_sol[2], | |
| ) | |
| # Check for parachute trigger | |
| if parachute.triggerfunc( | |
| noisy_pressure, | |
| height_above_ground_level, | |
| overshootable_node.y_sol, | |
| self.sensors, | |
| overshootable_node.t, | |
| self.rocket, | |
| ): | |
| # Remove parachute from flight parachutes | |
| self.parachutes.remove(parachute) | |
| # Create phase for time after detection and | |
| # before inflation | |
| # Must only be created if parachute has any lag | |
| i = 1 | |
| if parachute.lag != 0: | |
| self.flight_phases.add_phase( | |
| overshootable_node.t, | |
| phase.derivative, | |
| clear=True, | |
| index=phase_index + i, | |
| ) | |
| i += 1 | |
| # Create flight phase for time after inflation | |
| callbacks = [ | |
| lambda self, | |
| parachute_cd_s=parachute.cd_s: setattr( | |
| self, "parachute_cd_s", parachute_cd_s | |
| ), | |
| lambda self, | |
| parachute_radius=parachute.radius: setattr( | |
| self, | |
| "parachute_radius", | |
| parachute_radius, | |
| ), | |
| lambda self, | |
| parachute_height=parachute.height: setattr( | |
| self, | |
| "parachute_height", | |
| parachute_height, | |
| ), | |
| lambda self, | |
| parachute_porosity=parachute.porosity: setattr( | |
| self, | |
| "parachute_porosity", | |
| parachute_porosity, | |
| ), | |
| lambda self, | |
| added_mass_coefficient=parachute.added_mass_coefficient: setattr( | |
| self, | |
| "parachute_added_mass_coefficient", | |
| added_mass_coefficient, | |
| ), | |
| ] | |
| self.flight_phases.add_phase( | |
| overshootable_node.t + parachute.lag, | |
| self.u_dot_parachute, | |
| callbacks, | |
| clear=False, | |
| index=phase_index + i, | |
| ) | |
| # Rollback history | |
| self.t = overshootable_node.t | |
| self.y_sol = overshootable_node.y_sol | |
| self.solution[-1] = [ | |
| overshootable_node.t, | |
| *overshootable_node.y_sol, | |
| ] | |
| # Prepare to leave loops and start new flight phase | |
| overshootable_nodes.flush_after( | |
| overshootable_index | |
| ) | |
| phase.time_nodes.flush_after(node_index) | |
| phase.time_nodes.add_node(self.t, [], [], []) | |
| phase.solver.status = "finished" | |
| # Save parachute event | |
| self.parachute_events.append( | |
| [self.t, parachute] | |
| ) | |
| # Legacy inlined overshootable-node processing has | |
| # been consolidated into `__process_overshootable_nodes` | |
| # to avoid duplicated logic and inconsistent behavior. | |
| # This loop is intentionally left as a no-op. | |
| pass |
| event = parts[0].strip() | ||
| try: | ||
| delay = float(parts[1].strip()) | ||
| except ValueError: | ||
| raise ValueError( | ||
| f"Invalid delay value in trigger '{trigger}' for parachute '{self.name}'. " | ||
| + "Delay must be a number in seconds." | ||
| ) |
Copilot
AI
Jan 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implementation doesn't validate that the delay value is non-negative. Users could potentially specify negative delays like "launch + -5" or "burnout + -3.5", which would create parachutes that trigger before the event occurs. While the trigger would technically work (it would immediately trigger if the current time is greater than the negative target time), this is likely a user error that should be caught during initialization.
Consider adding validation to ensure delay >= 0, or document that negative delays are intentionally supported if there's a valid use case.
| burnout_time = rocket.motor.burn_out_time | ||
| return t >= burnout_time + delay | ||
|
|
Copilot
AI
Jan 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The burnout trigger implementation doesn't handle the case where a rocket might not have a motor or when accessing motor.burn_out_time might fail. While the trigger returns False if rocket is None, it doesn't handle potential AttributeError if rocket.motor doesn't exist or if burn_out_time is not set.
Consider adding defensive checks like:
- Verify hasattr(rocket, 'motor') before accessing rocket.motor
- Verify hasattr(rocket.motor, 'burn_out_time') or handle the case where burn_out_time might be None
- Add a try-except block to gracefully handle unexpected cases
This would make the implementation more robust against edge cases or incomplete rocket configurations.
| burnout_time = rocket.motor.burn_out_time | |
| return t >= burnout_time + delay | |
| # Defensive checks: rocket may not have a motor or burn_out_time | |
| motor = getattr(rocket, "motor", None) | |
| if motor is None: | |
| return False | |
| burnout_time = getattr(motor, "burn_out_time", None) | |
| if burnout_time is None: | |
| return False | |
| try: | |
| return t >= burnout_time + delay | |
| except (TypeError, ValueError, AttributeError): | |
| # If burnout_time is not a valid number or attribute access fails, | |
| # do not trigger the parachute instead of raising an exception. | |
| return False |
Pull request type
Checklist
black rocketpy/ tests/) has passed locallypytest tests -m slow --runslow) have passed locallyCHANGELOG.mdhas been updated (if relevant)Current behavior
RocketPy cannot simulate rockets with motor delay charges (fixed-time deployment) like model rocket motors or high-power motors without avionics. Users must resort to workarounds or cannot accurately simulate kits like Loc-IV powered by Aerotech motors with delay elements. #437
New behavior
Parachute triggers now accept time-based string formats:
"launch + X"- deploys X seconds after t=0"burnout + X"- deploys X seconds after motor burnoutImplementation:
Parachute.__evaluate_trigger_function()to parse time-based triggerst(current time) androcket(motor access) parameters to trigger function signatureFlightclass to pass additional parameters (two original + two new)Example usage:
Testing:
Breaking change
Additional information
Existing trigger types (callable, float altitude, "apogee") remain unchanged. The trigger function signature extension uses optional parameters with defaults, ensuring zero-impact to existing code