Skip to content

ENH: Implement 3-DOF Single Rail Button Flight Phase (Tip-off Analysis) - #920

Open
Rafit345 wants to merge 5 commits into
RocketPy-Team:developfrom
Rafit345:enh/tip-off-analysis-3-dof
Open

ENH: Implement 3-DOF Single Rail Button Flight Phase (Tip-off Analysis)#920
Rafit345 wants to merge 5 commits into
RocketPy-Team:developfrom
Rafit345:enh/tip-off-analysis-3-dof

Conversation

@Rafit345

@Rafit345 Rafit345 commented Dec 10, 2025

Copy link
Copy Markdown

Pull request type

  • Code changes (bugfix, features)
  • Code maintenance (refactoring, formatting, tests)
  • ReadMe, Docs and GitHub updates

Checklist

  • Tests for the changes have been added (if needed)
  • Docs have been reviewed and added / updated
  • Lint (ruff check, ruff format --check, pylint) has passed locally
  • All tests have passed locally
  • CHANGELOG.md has been updated (if relevant)

Current behavior

Refs #28.

The rail phase ends the moment the upper rail button reaches the end of the rail,
at effective_1rl, and the simulation jumps straight from 1-DOF rail motion to
6-DOF free flight. The interval in which the rocket is still guided by the lower
button alone — the tip-off phase — is not modelled, so the rocket begins free
flight with no angular rate and with exactly the rail's attitude.

New behavior

Adds Flight.udot_rail2, an intermediate 3-DOF flight phase covering the interval
between the two rail buttons leaving the rail:

udot_rail1        for d <  effective_1rl   (both buttons engaged, 1 DOF)
udot_rail2        for d in [effective_1rl, effective_2rl)  (lower button only, 3 DOF)
u_dot_generalized for d >= effective_2rl   (free flight, 6 DOF)

The phase models a constrained rigid body: the lower button slides along the fixed
inertial rail axis and roll is suppressed, leaving translation along the rail, pitch
and yaw. It reuses u_dot_generalized for the free solution and adds a reaction
wrench — a normal force at the button (perpendicular to the rail, 2 DOF) plus a roll
reaction moment (1 DOF) — solved from a 3x3 linear system built from the three
constraints: the button's perpendicular acceleration vanishes (2) and the roll angular
acceleration vanishes (1). Aerodynamics, thrust, body-frame gravity and the evolving
inertia tensor therefore come along by construction.

The derivation is documented in docs/technical/tip_off.rst.

Enabled with Flight(..., use_udot_rail2=True).

Breaking change

  • Yes
  • No

use_udot_rail2 defaults to False, and a disabled run is bit-for-bit identical
to develop — verified by comparing the full final state against a clean develop
checkout, not just the apogee.

Additional information

Validation

  • Constraint satisfaction: the button's perpendicular offset from the rail stays at
    ~1e-13 m; roll rate and roll acceleration stay at zero.
  • Physics: with no wind the nose pitches down (the center of mass is ahead of the
    pivot); with a crosswind the rocket weathercocks into the wind before fully leaving
    the rail, which is the acceptance criterion in ENH: Implement 3-DOF Single Rail Button Flight Phase (Tip-off Analysis) #28. Apogee shifts about -2 m on
    Calisto.
  • 1960 unit and simulation-integration tests pass locally; all CI checks pass.

Also fixed here: a Flight continued from another Flight object never set
t_initial, which raised AttributeError for any continued flight carrying sensors
or controllers. The bug predates this branch and is fixed by merging the two
initial-solution branches (a refactor the review asked for). Covered by a regression
test.

Known limitation: the button is modelled on the rocket axis, so the small roll
coupling through its radial standoff is not represented — roll is instead suppressed
explicitly by the reaction moment. Documented as a modelling assumption; a natural
follow-up.

References: "Tip-off effect analysis of a vehicle moving along an inclined
guideway by considering dynamic interactions" (Chou et al.) and "Analysis of Missile
Launchers Part Q: Tipoff Effects in Helical Rail Launchers" (Hosken et al.).

@Rafit345
Rafit345 requested a review from a team as a code owner December 10, 2025 23:47
@Rafit345
Rafit345 changed the base branch from master to develop December 10, 2025 23:47
@aZira371

Copy link
Copy Markdown
Collaborator

Hey @Rafit345 Thanks for this PR! You have done some solid work here. I am still reviewing your code changes. But I'll suggest to run make lint and make format with your commits!

@aZira371
aZira371 self-requested a review December 12, 2025 16:37
Comment thread rocketpy/simulation/flight.py Outdated
print(f"Current Simulation Time: {self.t:3.4f} s", end="\r")

# check for the first time the rocket is between the two rail buttons for tip-off analysis
if len(self.between_rails_state) == 1 and (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition len(self.betweenrailsstate) == 1 checks whether the state has been set once, not whether the rocket is currently between the buttons. Once betweenrailsstate is populated, subsequent integration steps will not re-enter this block, so the phase is only added at the exact instant the lower button clears, not maintained throughout the tip-off interval. This means the udot_rail2 phase may execute for only a single time step or not propagate correctly.

@aZira371 aZira371 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the following comments for the preliminary implementation. In general looks to be going in a good direction as of now. Will wait for u_dotrail2 implementation to be fully complete in order to review the tests and involved physics.

Comment thread rocketpy/simulation/flight.py Outdated
raise ValueError(
"Multiple roots found when solving for rail exit time."
)
if len(valid_t_root) == 0: # pragma: no cover

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fallback uses an arbitrary midpoint guess when the Hermite interpolation fails to find a valid root. This fallback has no physical justification, if no root exists in the bracket, taking the midpoint may place the transition at a point where the rocket is not at effective1rl, violating the state's physical meaning. The warning is issued, but the simulation proceeds with potentially incorrect rail-exit kinematics, and downstream analysis (e.g., launch angle) will be corrupted.​
Instead of a fallback, the code should either raise an exception (forcing the user to investigate convergence issues) or attempt a refined bracket search with tighter tolerances.

Comment thread rocketpy/simulation/flight.py Outdated
self.out_of_rail_time = self.initial_solution[0]
self.out_of_rail_time_index = 0
# save out of rail 2 state and time with the same data as out of rail
self.between_rails_state = self.initial_solution[1:]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In init, the code initializes self.betweenrailsstate and self.betweenrailstime in multiple branches (3 times once in if elif and else each). These three branches set identical values for betweenrailsstate and betweenrailstime. This is redundant. The initialization should be factored out after the branching logic.

Comment thread rocketpy/simulation/flight.py Outdated

def udot_rail2(self, t, u, post_processing=False): # pragma: no cover
"""[Still not implemented] Calculates derivative of u state vector with
"""[WIP] Calculates derivative of u state vector with

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion for WIP:

True 3-DOF tip-off on a rail with one button requires tracking the normal contact force and the reaction moment. The state vector for udot_rail2 appears to reuse the 13-state format from udot_generalized (x, y, z, vx, vy, vz, e0, e1, e2, e3, ω1, ω2, ω3). However, single-button dynamics are inherently unstable without proper constraint enforcement. The angular velocities ω1 and ω2 will grow due to aerodynamic and thrust misalignment torques, but the code does not implement any constraint forces to stabilize the solution. Without these, the integrator may produce non-physical angular accelerations.

Comment thread rocketpy/simulation/flight.py Outdated

def udot_rail2(self, t, u, post_processing=False): # pragma: no cover
"""[Still not implemented] Calculates derivative of u state vector with
"""[WIP] Calculates derivative of u state vector with

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing udot_rail1 (1-DOF rail phase) computes thrust, drag, and rail reaction forces. The new udot_rail2 must account for:

Off-axis aerodynamic forces and moments (due to angle of attack during tip-off)

Thrust misalignment effects

Gravity components in the body frame

Inertia tensor evolution as the rocket rotates

None of these appear in the provided code stub.

)


def test_udot_rail2_csv_comparison_generation(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_udot_rail2_csv_comparison_generation generates CSV files comparing enabled/disabled runs and computes pitch/yaw deltas, but does not assert any expected behavior—it only checks that files exist and contain headers. This is a structural test, not a validation test

@Gui-FernandesBR

Copy link
Copy Markdown
Member

@Rafit345 thank you for your submission! Please address all the comments and ask for a re-review whenever this PR is ready again.

We look forwarding to receiving updates from you soon!

@Gui-FernandesBR
Gui-FernandesBR marked this pull request as draft December 14, 2025 17:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a preliminary 3-DOF single rail button flight phase (tip-off analysis) for RocketPy, introducing an intermediate udot_rail2 phase that operates between the initial 1-DOF rail phase and the 6-DOF free flight phase. The implementation adds a feature flag use_udot_rail2 to enable/disable this behavior, includes a Hermite-root fallback for numerical robustness, and provides comprehensive unit tests to verify correctness.

  • Adds udot_rail2 method implementing 3-DOF equations of motion (linear motion along rail + pitch/yaw, enforcing zero roll)
  • Introduces use_udot_rail2 parameter (default True) to control the intermediate rail phase activation
  • Implements between-rails event detection and smooth phase transitions from 1-DOF → 3-DOF → 6-DOF
  • Adds fallback handling for edge cases where rail-exit root filtering returns no valid roots

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 15 comments.

File Description
rocketpy/simulation/flight.py Implements udot_rail2 3-DOF rail phase with equations of motion, adds use_udot_rail2 parameter, implements between-rails event detection, adds fallback for root finding failures, initializes attitude_unit and between_rails_state tracking
tests/unit/simulation/test_udot_rail2_feature.py Adds unit tests verifying phase insertion order, zero roll enforcement, rail alignment constraints, and CSV output generation for enabled/disabled comparison

return K @ Vector([0.0, 0.0, 1.0])


def test_udot_rail2_inserts_phase_in_order(calisto_robust, example_spaceport_env):

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name doesn't follow the project's naming convention "test_methodname_expectedbehaviour". It should be renamed to something like "test_udot_rail2_phase_insertion_order_correct" or "test_flight_with_udot_rail2_inserts_phase_before_generalized" to better match the convention and clearly indicate what behavior is expected.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +128 to +130
def test_udot_rail2_csv_comparison_generation(
calisto_robust, example_spaceport_env, tmp_path
):

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name doesn't follow the project's naming convention "test_methodname_expectedbehaviour". It should be renamed to something like "test_udot_rail2_csv_output_generated_correctly" to better match the convention and clearly indicate what behavior is expected.

Copilot generated this review using guidance from repository custom instructions.
Comment thread rocketpy/simulation/flight.py Outdated
if verbose:
print(f"Current Simulation Time: {self.t:3.4f} s", end="\r")

# check for the first time the rocket is between the two rail buttons for tip-off analysis

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment should use proper capitalization and end with a period for consistency with other comments in the codebase.

Suggested change
# check for the first time the rocket is between the two rail buttons for tip-off analysis
# Check for the first time the rocket is between the two rail buttons for tip-off analysis.

Copilot uses AI. Check for mistakes.
Comment thread rocketpy/simulation/flight.py Outdated
Comment on lines 539 to 544
use_udot_rail2 : bool, optional
If True, enable the intermediate 3-DOF rail phase `udot_rail2`.
If False, the flight remains in the rail1 phase until the
upper rail button exit and then transitions directly to the
generalized 6-DOF dynamics. Default is True.
w1_init, w2_init, w3_init

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use_udot_rail2 parameter documentation is incorrectly positioned in the middle of the initial_solution parameter's code block example. This breaks the docstring structure. The parameter documentation should be placed after the equations_of_motion parameter and before ode_solver, matching the actual parameter order in the function signature.

Copilot uses AI. Check for mistakes.
Comment thread rocketpy/simulation/flight.py Outdated
self.flight_phases.add_phase(
self.t_initial, self.initial_derivative, clear=False
)
# self.flight_phases.add_phase()

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commented-out line appears to be leftover debug code or an incomplete implementation. It should either be removed or completed with a meaningful comment explaining its purpose if it's intentionally left for future work.

Suggested change
# self.flight_phases.add_phase()

Copilot uses AI. Check for mistakes.
Comment thread rocketpy/simulation/flight.py Outdated
y1,
yp1,
)
a += 1e-5 # TODO: why??

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment "why??" indicates unclear or unexplained code logic. This addition of a small epsilon (1e-5) to the coefficient appears to be a workaround for a numerical issue, but the reason is not documented. This should be investigated and properly explained, or the workaround should be replaced with a more robust solution. Consider documenting the specific numerical issue this addresses or removing it if it's not necessary.

Suggested change
a += 1e-5 # TODO: why??
# Add a small epsilon to the cubic coefficient to avoid
# degenerate or nearly-degenerate cubic equations, which can
# cause numerical instability or missed roots in the root-finding
# process. This helps ensure that the cubic Hermite interpolation
# produces a valid solution for the rail exit time, especially in
# cases where the trajectory is nearly tangential to the rail.
a += 1e-5

Copilot uses AI. Check for mistakes.
Comment thread rocketpy/simulation/flight.py Outdated
@@ -1874,7 +2096,7 @@ def udot_rail1(self, t, u, post_processing=False):
return [vx, vy, vz, ax, ay, az, 0, 0, 0, 0, 0, 0, 0]

def udot_rail2(self, t, u, post_processing=False): # pragma: no cover

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pragma comment "# pragma: no cover" on this method indicates it's excluded from test coverage, but the PR adds comprehensive unit tests for this feature. Consider removing this pragma since the method is now tested and should be included in coverage metrics.

Copilot uses AI. Check for mistakes.
Comment thread rocketpy/simulation/flight.py Outdated
Comment on lines +2272 to +2276
e_dot = [
0.5 * (-omega1 * e1 - omega2 * e2), # - omega3 * e3),
0.5 * (omega1 * e0 - omega2 * e3), # omega3 * e2
0.5 * (omega2 * e0 + omega1 * e3), # - omega3 * e1
0.5 * (omega2 * e1 - omega1 * e2), # +omega3 * e0

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commented-out portions of the Euler parameter derivatives (e.g., "# - omega3 * e3", "# omega3 * e2") suggest incomplete implementation or uncertainty about the correct equations. For a 3-DOF rail phase that enforces zero roll, these omega3 terms should indeed be omitted, but this should be clearly documented in a comment explaining why they're excluded rather than leaving them as commented code. Consider either removing them entirely or adding a clear explanation.

Suggested change
e_dot = [
0.5 * (-omega1 * e1 - omega2 * e2), # - omega3 * e3),
0.5 * (omega1 * e0 - omega2 * e3), # omega3 * e2
0.5 * (omega2 * e0 + omega1 * e3), # - omega3 * e1
0.5 * (omega2 * e1 - omega1 * e2), # +omega3 * e0
# In the 3-DOF rail phase, roll is constrained and omega3 is always zero.
# Therefore, the omega3 terms are omitted from the quaternion derivative equations.
e_dot = [
0.5 * (-omega1 * e1 - omega2 * e2),
0.5 * (omega1 * e0 - omega2 * e3),
0.5 * (omega2 * e0 + omega1 * e3),
0.5 * (omega2 * e1 - omega1 * e2),

Copilot uses AI. Check for mistakes.

# u_dot layout for udot_rail2: [r_dot_x, r_dot_y, r_dot_z, v_dot_x, v_dot_y, v_dot_z, e_dot..., w_dot_x, w_dot_y, w_dot_z]
r_dot = Vector(u_dot[0:3])
v_dot = Vector(u_dot[3:6])

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable v_dot is not used.

Suggested change
v_dot = Vector(u_dot[3:6])

Copilot uses AI. Check for mistakes.
import math
import os

import numpy as np

Copilot AI Dec 14, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'np' is not used.

Suggested change
import numpy as np

Copilot uses AI. Check for mistakes.
@Gui-FernandesBR Gui-FernandesBR linked an issue Dec 21, 2025 that may be closed by this pull request
4 tasks
@Gui-FernandesBR Gui-FernandesBR changed the title "ENH: Implement 3-DOF Single Rail Button Flight Phase (Tip-off Analysis) - Preliminary" ENH: Implement 3-DOF Single Rail Button Flight Phase (Tip-off Analysis) Jul 4, 2026
@Gui-FernandesBR
Gui-FernandesBR force-pushed the enh/tip-off-analysis-3-dof branch from 90813ac to a2c5b42 Compare July 22, 2026 15:18
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.70115% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.62%. Comparing base (e0ff281) to head (f8685cd).
⚠️ Report is 41 commits behind head on develop.

Files with missing lines Patch % Lines
rocketpy/simulation/flight.py 97.70% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #920      +/-   ##
===========================================
+ Coverage    82.18%   82.62%   +0.44%     
===========================================
  Files          122      128       +6     
  Lines        16355    16693     +338     
===========================================
+ Hits         13441    13793     +352     
+ Misses        2914     2900      -14     

☔ View full report in Codecov by Harness.
📢 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.

Rafit345 and others added 4 commits August 8, 2026 16:19
…is)"

Refs RocketPy-Team#28.  This commit was made as a submission
to the selective process deliverables challenge.

The method udot_rail2 functions as an intermediate flight phase
before the rocket has fully left the guide rail, allowing for
3 degrees of freedom (linear motion along the rail, pitch and yaw).

Flight init includes a feature to run a simulation without udot_rail2.
Numerical values enabling udot_rail2 are very close to 1 DOF flight.
Flight phase transitions smoothly from 1 DOF rail phase to 3DOF
and from 3 DOF to 6 DOF free flight.

Current equations of motion inside udot_rail2 rely heavily on
udot_generalized, ensuring 3 DOF through vector operations.
Still working on the implementation of proper lagrangean expansion
/derivation of equations of motion. Articles
"Tip-off effect analysis of a vehicle moving along an inclined guideway
by considering dynamic interactions" by Chou et al and
"ANALYSIS OF MISSILE LAUNCHERS PART Q
Tipoff Effects in Helical Rail Launchers" by  Hosken et al
are proving useful.

--Summary--
Add preliminary udot_rail2 (3-DOF tip-off) support and safe,
deterministic phase-insertion handling during rail → 6DOF transitions.
Add a feature flag to enable/disable udot_rail2 on Flight init.
Add a Hermite-root fallback to avoid hard failures when rail-exit root
 filtering returns no valid root (warn + midpoint fallback).
Add comprehensive unit tests (alignment, no-roll, insertion-order,
CSV comparisons) and sample CSV output for comparison runs with udot_rail2
 enabled vs disabled.
Complete the 3-DOF single-rail-button (tip-off) phase from issue RocketPy-Team#28.

- Fix the phase transition ordering: rail1 -> udot_rail2 (at effective_1rl,
  upper button exit) -> u_dot_generalized (at effective_2rl, lower button
  exit). Previously the thresholds were swapped, so udot_rail2 was inserted
  after free flight and never exited.
- Replace the placeholder udot_rail2 (which reused free-flight dynamics with an
  ad-hoc velocity projection) with rigorous constrained dynamics: the lower
  button slides along the fixed rail while roll is suppressed. The reaction
  wrench (normal force + roll moment) is solved from a 3x3 linear system so the
  button's perpendicular acceleration and the roll acceleration vanish, derived
  in the true body frame on top of the validated u_dot_generalized solution.
- Make the feature opt-in (use_udot_rail2 defaults to False); disabled runs are
  bit-for-bit identical to previous behavior.
- Factor the rail-exit root finding into a shared helper.
- Rewrite the unit tests to check phase ordering, the opt-in default, the
  on-rail constraint (button stays on the rail to machine precision), zero
  roll, and the gravity tip-off direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Define the rail axis (`attitude_unit`) for every Flight, not only the ones
  that start on the rail. It depends solely on the launch inclination and
  heading, so `udot_rail2` no longer raises `AttributeError` when an
  `initial_solution` skips the rail phase. Verified equal to the previous
  quaternion-derived vector to 3e-16 across inclinations, headings and rolls.
- Compute the squared distance from the launch point once and share it between
  the two rail button exit checks.
- Rename `r_B` -> `r_button` and `I_CM_inv` -> `inv_inertia_cm`, drop the
  unused unpacking in `udot_rail2` and the now-dead `K_init`, so pylint is
  clean without relaxing `.pylintrc`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The udot_rail2 docstring pointed at a derivation that lived in an untracked
scratch file, so the reference was dead for anyone reading the code. Move the
derivation into the technical documentation, where the other equations of
motion are documented, and cite the two tip-off papers it follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Gui-FernandesBR
Gui-FernandesBR force-pushed the enh/tip-off-analysis-3-dof branch from a2c5b42 to 80ff5da Compare August 10, 2026 02:37
Fold the two initial-solution branches of __init_flight_state into one, as the
review asked: they set the same monitors, and the Flight-object branch differed
only by *not* assigning t_initial. That omission raised
`AttributeError: 'Flight' object has no attribute 't_initial'` whenever the
continued rocket carried sensors or controllers, since post-processing the
initial state reads it. The bug predates this branch; merging the branches fixes
it. Covered by a regression test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Gui-FernandesBR

Copy link
Copy Markdown
Member

@aZira371 @Rafit345 this PR is ready for another look. Since the last review the
udot_rail2 physics was rewritten, the phase transition was fixed, and the branch
was rebased onto current develop (no conflicts). All checks are green.

The two blocking problems

1. The phase transition was inverted. The thresholds were swapped: between_rails
keyed on effective_2rl as the leading if, out_of_rail on the smaller
effective_1rl as the elif. So out_of_rail fired first and udot_rail2 was
inserted after free flight, where it never exited. The observed phase order was
rail1 -> u_dot_generalized -> udot_rail2. It is now
rail1 -> udot_rail2 (at effective_1rl, upper button out) -> u_dot_generalized
(at effective_2rl, lower button out), which is also what broke ~32 existing flight
tests with ValueError: No valid roots found when solving for rail exit time.

2. The dynamics had no reaction force, which is the substance of your review
comment: it reused free-flight dynamics plus an ad-hoc roll-zeroing and a projection
of the velocity onto the rail. That is now replaced by proper constrained dynamics,
along the lines you asked for:

  • The lower button slides along the fixed inertial rail axis; roll is suppressed.
  • The unknowns are a normal reaction force at the button (perpendicular to the
    rail, 2 DOF) and a roll reaction moment (1 DOF).
  • They are solved from a 3x3 linear system built from the three constraints: the
    button's acceleration perpendicular to the rail vanishes (2) and the roll angular
    acceleration vanishes (1). 6 - 3 = 3 DOF remain: slide along the rail, pitch, yaw.
  • It builds on u_dot_generalized, so the off-axis aerodynamics, thrust,
    body-frame gravity and the evolving inertia tensor you listed are all included by
    construction rather than re-derived. The reaction wrench is added to the same
    T20/T21 totals and the same solve is applied.
  • The velocity is not projected onto the rail any more. The constraint acts at
    the acceleration level on the button; the CDM legitimately acquires a small
    perpendicular velocity as the rocket pivots.

The derivation is now in the technical documentation, docs/technical/tip_off.rst,
next to the other equations of motion.

Your other comments

  • Midpoint fallback with no physical justification - removed. The root finder was
    factored into one shared helper (__root_find_rail_exit_time) used by both events,
    and it raises rather than guessing.
  • len(between_rails_state) == 1 does not mean "currently between the buttons" -
    correct, and that is the intent: it is a "has this event fired yet" guard, the same
    idiom as the pre-existing len(out_of_rail_state) == 1. What keeps the phase active
    is the phase itself, not this check. It now also requires out_of_rail to have
    already fired, so it can only trigger inside the tip-off window.
  • Redundant between_rails initialization across three branches - fixed. The two
    initial-solution branches are now one. Worth flagging: they were not identical - the
    Flight-object branch also failed to set t_initial, which raised
    AttributeError: 'Flight' object has no attribute 't_initial' for any continued
    flight carrying sensors or controllers. That bug predates this branch; merging the
    branches fixes it, and there is a regression test.
  • CSV test asserts nothing - agreed, it was structural. The test file was rewritten
    around behaviour: phase ordering, the opt-in default, the on-rail constraint, zero
    roll, the gravity tip-off direction, and the rail axis being defined when an
    initial_solution skips the rail. No CSV artifacts.
  • Copilot's points - use_udot_rail2 now defaults to False, the docstring entry
    moved to match the signature order, the leftover commented-out code and unused
    imports/variables are gone, the pragma: no cover on udot_rail2 was dropped, and
    the test names follow test_methodname_expectedbehaviour.

Two Copilot comments I deliberately left alone: the a += 1e-5 # TODO: why?? in the
root finder and the negative vz doesn't really mean apogee TODO. Both are
pre-existing on develop and unrelated to tip-off; fixing them here would change
results for every flight and belongs in its own PR.

Validation

  • use_udot_rail2=False is bit-for-bit identical to develop - verified by
    running the same scenario against a clean develop worktree and comparing the full
    final state, not just the apogee. This is why the feature is opt-in for now.
  • Constraint satisfaction: the button's perpendicular offset from the rail stays at
    ~1e-13 m, and the roll rate and roll acceleration stay at zero.
  • Physics: with no wind the nose pitches down (the center of mass is ahead of the
    pivot), and the rocket weathercocks into a crosswind - the acceptance criterion in
    ENH: Implement 3-DOF Single Rail Button Flight Phase (Tip-off Analysis) #28. Apogee shifts by about -2 m on Calisto.
  • Suites: 1960 unit + simulation-integration tests pass locally; all 10 CI checks pass.

Known limitation

The button is modelled on the rocket axis, so the small roll coupling through its
radial standoff is not represented; roll is instead suppressed explicitly by the
reaction moment. This is documented as a modelling assumption and is a natural
follow-up.

@Gui-FernandesBR
Gui-FernandesBR marked this pull request as ready for review August 10, 2026 14:58
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.

ENH: Implement 3-DOF Single Rail Button Flight Phase (Tip-off Analysis)

4 participants