Skip to content

Conversation

@aZira371
Copy link
Collaborator

Pull request type

  • Code changes (bugfix, features)
  • Code maintenance (refactoring, formatting, tests)

Checklist

  • Tests for the changes have been added (if needed)
  • Docs have been reviewed and added / updated
  • Lint (black rocketpy/ tests/) has passed locally
  • All tests (pytest tests -m slow --runslow) have passed locally
  • CHANGELOG.md has 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 burnout

Implementation:

  • Extended Parachute.__evaluate_trigger_function() to parse time-based triggers
  • Added t (current time) and rocket (motor access) parameters to trigger function signature
  • Maintained backward compatibility via parameter count detection (existing pattern)
  • Updated all four call sites in Flight class to pass additional parameters (two original + two new)

Example usage:

# Model rocket with 3-second motor delay (like Estes C6-3)
rocket.add_parachute(
    name="Main",
    cd_s=10.0,
    trigger="burnout + 3",
    sampling_rate=100,
    lag=1.5,
)

# Simple timer deployment 5 seconds after launch
rocket.add_parachute(
    name="Drogue",
    cd_s=1.0,
    trigger="launch + 5",
    sampling_rate=100,
    lag=0.5,
)

Testing:

  • 11 unit tests covering parsing, functionality, edge cases, backward compatibility
  • 4 integration tests with full flight simulations with time-based triggers

Breaking change

  • Yes
  • No

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

- 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
@aZira371 aZira371 requested a review from a team as a code owner January 24, 2026 08:18
@aZira371 aZira371 self-assigned this Jan 24, 2026
@codecov
Copy link

codecov bot commented Jan 24, 2026

Codecov Report

❌ Patch coverage is 96.96970% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.04%. Comparing base (9cf3dd4) to head (6814de7).
⚠️ Report is 27 commits behind head on develop.

Files with missing lines Patch % Lines
rocketpy/rocket/parachute.py 94.28% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link

Copilot AI left a 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

Comment on lines +17 to +29
# 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),
)
Copy link

Copilot AI Jan 24, 2026

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:

  1. Create a new rocket instance in each test rather than reusing the fixture
  2. Use deep copies of the fixture in each test
  3. Ensure the fixture has function scope and creates fresh instances each time

This is a common issue in pytest when fixtures return mutable objects.

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +117
# 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)
Copy link

Copilot AI Jan 24, 2026

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:

  1. Moving MockMotor and MockRocket to module-level or as pytest fixtures if they'll be used in multiple tests
  2. Using unittest.mock.Mock or pytest's monkeypatch for cleaner mocking
  3. At minimum, extracting them to a helper function or fixture

This follows the coding guideline to avoid code duplication and improves maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +829 to +923
# 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]
)

Copy link

Copilot AI Jan 24, 2026

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:

  1. Initializes overshootable_nodes
  2. Adds parachutes, controllers, and sensors
  3. Checks for parachute triggers via __check_overshootable_parachute_triggers
  4. 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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +298
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."
)
Copy link

Copilot AI Jan 24, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +314 to +316
burnout_time = rocket.motor.burn_out_time
return t >= burnout_time + delay

Copy link

Copilot AI Jan 24, 2026

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants