From c15632a3771c587bcfc2a3927defc9593b6c23ea Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:26:42 +0800 Subject: [PATCH 1/6] Coverage that cannot report a green it did not earn Two parts of the coverage path report success while doing nothing. Both are in the logs of any recent run. The six matrix legs all uploaded an artifact named `coverage` with `overwrite: true`, which deletes the previous artifact rather than merging into it. Run 30461091931 shows `Artifact 'coverage' (ID: 8727950028) deleted` and then finalized again by a later leg, so five of the six reports were thrown away and the surviving one was whichever leg happened to finish last. Each leg now writes and uploads a report named after itself, and the upload job downloads all of them. The headline number was not far off, since all six legs land on 80%, but which platform's report reached Codecov was decided by a race, and code that only runs on one platform was counted or not depending on it. `files:` was a YAML block literal, so the name arrived as "coverage.xml\n" and was not found. The upload only worked because the uploader falls back to searching. That fallback is now unnecessary: the reports are in one directory, and the directory is what is passed. `fail_ci_if_error` is tied to whether a token was there to use. A pull request from a fork gets no secrets, and a contributor should not see red for that. What this does not fix: `CODECOV_TOKEN` resolves to empty on this repository, so the upload is still rejected with "Token required - not valid tokenless upload", and with no token the run stays green. Setting that secret is the last step and it needs repository admin. Once it is set, this workflow fails when an upload fails, without another change. Upstream RocketPy carries both of these unchanged. Happy to send the same patch there so it comes back through the usual sync. Verified: actionlint clean, `--cov-report=xml:` writes the named file, and `pattern` / `merge-multiple` / `directory` / `fail_ci_if_error` all exist as inputs on the pinned actions. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/test_pytest.yaml | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test_pytest.yaml b/.github/workflows/test_pytest.yaml index 51c6febcf..2e7692cd9 100644 --- a/.github/workflows/test_pytest.yaml +++ b/.github/workflows/test_pytest.yaml @@ -61,14 +61,16 @@ jobs: run: pytest tests/integration --cov=rocketpy --cov-append - name: Run Acceptance Tests - run: pytest tests/acceptance --cov=rocketpy --cov-append --cov-report=xml + run: pytest tests/acceptance --cov=rocketpy --cov-append --cov-report=xml:coverage-${{ matrix.os }}-${{ matrix.python-version }}.xml - name: Upload coverage to artifacts uses: actions/upload-artifact@main with: - name: coverage - path: coverage.xml - overwrite: true + # One name per leg. Six legs sharing a name with overwrite deleted each + # other's artifact rather than merging, so five reports were discarded + # and the surviving one was whichever leg happened to finish last. + name: coverage-${{ matrix.os }}-${{ matrix.python-version }} + path: coverage-${{ matrix.os }}-${{ matrix.python-version }}.xml if-no-files-found: error CodecovUpload: @@ -76,11 +78,24 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@main - - name: Download latest coverage report + - name: Download every coverage report uses: actions/download-artifact@main + with: + pattern: coverage-* + merge-multiple: true + path: coverage-reports + - name: Refuse to upload nothing + # `ls` exits non-zero on no match. Without this the upload has nothing to + # send and still reports success, which is the failure being fixed here. + run: ls coverage-reports/*.xml - name: Upload to Codecov uses: codecov/codecov-action@main with: token: ${{ secrets.CODECOV_TOKEN }} - files: | - coverage.xml + # A directory rather than a filename: there is one report per matrix + # leg now. The previous block literal passed "coverage.xml\n", which was + # not found, and only the fallback search made the upload work at all. + directory: coverage-reports + # Only fail when a token was there to use. Pull requests from forks get + # no secrets, and a contributor should not see red for that. + fail_ci_if_error: ${{ secrets.CODECOV_TOKEN != '' }} From afdb0323fe57f257f4fe3eb898eabd9733b8800d Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:57:35 +0800 Subject: [PATCH 2/6] Docs that can build The docs job has failed on every run since it was added: five runs, back to 2026-07-26, all red. `sphinx-build -W` turns four warnings into errors, and all four come from this fork's own additions rather than from upstream. Three are one stray line, copied three times. `add_thrust_vector_control`, `add_roll_control` and `add_throttle_control` describe the controller arguments as a numbered list, and item 7 ends with a loose ``interactive_objects`` at one space less than the continuation lines above it. docutils reads that as a definition list ending without a blank line. The same item in upstream's `add_air_brakes` does not have it, so removing it puts these three back in step with the method they were copied from. Measured per docstring, running napoleon and docutils the way Sphinx does. Before: those three warn at line 28, `add_air_brakes` does not. After: none of the four warn. The fourth is `halcyon_flight_sim_active_control.ipynb`, which is in `docs/examples/` and in no toctree, so Sphinx builds it and then reports that nothing links to it. It is this fork's own example of the feature the fork exists for, so it goes in the list next to the flight it varies rather than into an exclude. Not verified by a full local build: an unrelated notebook fetches a live weather forecast and the file it reaches no longer covers the date it asks for, which stops the build here for a reason CI does not have. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- docs/examples/index.rst | 1 + rocketpy/rocket/rocket.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/examples/index.rst b/docs/examples/index.rst index bd7506c30..d90fcfc83 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -106,6 +106,7 @@ In the next sections you will find the simulations of the rockets listed above. prometheus_2022_flight_sim.ipynb erebus_flight_sim.ipynb halcyon_flight_sim.ipynb + halcyon_flight_sim_active_control.ipynb cavour_flight_sim.ipynb genesis_flight_sim.ipynb camoes_flight_sim.ipynb diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 46d11cc08..f3b8c4a95 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2004,7 +2004,6 @@ def add_thrust_vector_control( rocket. The most recent measurements of the sensors are provided with the ``sensor.measurement`` attribute. The sensors are listed in the same order as they are added to the rocket - ``interactive_objects`` This function will be called during the simulation at the specified sampling rate. The function should evaluate and change the observed @@ -2164,7 +2163,6 @@ def add_roll_control( rocket. The most recent measurements of the sensors are provided with the ``sensor.measurement`` attribute. The sensors are listed in the same order as they are added to the rocket - `interactive_objects` This function will be called during the simulation at the specified sampling rate. The function should evaluate and change the observed @@ -2300,7 +2298,6 @@ def add_throttle_control( rocket. The most recent measurements of the sensors are provided with the ``sensor.measurement`` attribute. The sensors are listed in the same order as they are added to the rocket - ``interactive_objects`` This function will be called during the simulation at the specified sampling rate. The function should evaluate and change the observed From dab3ed52c1e24b04def639f391953fa136c35f10 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:13:47 +0800 Subject: [PATCH 3/6] Run the docs check before the release merge, not at it `docs.yml` triggered on a base of master only, so the only pull request that ever started it was develop into master. A docstring that does not build therefore lands on develop unremarked and first fails the merge that was meant to ship it, which is what #19 and #23 hit and why the four warnings in the commit before this one went unnoticed for three days. develop as well. The same paths filter, so it still only runs when something it reads has changed. This also makes the commit before it self checking: without this, a pull request into develop cannot start the job that would prove the warnings are gone, and the only evidence would be a measurement in the description. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/docs.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 806bef0dc..25b76cef0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,10 +1,13 @@ name: Documentation on: - # Only PRs targeting master (base branch = master) and pushes to master. + # develop as well as master. Work lands on develop first, so a base of master + # alone meant the only run was the release pull request: a docstring that does + # not build sat on develop until then and failed the merge that was supposed + # to ship it, which is how the four warnings this fixes went unnoticed. pull_request: types: [opened, synchronize, reopened, ready_for_review] - branches: [master] + branches: [master, develop] paths: - "docs/**" - "rocketpy/**" # docstrings feed the autodoc API reference @@ -13,7 +16,7 @@ on: - ".readthedocs.yaml" - ".github/workflows/docs.yml" push: - branches: [master] + branches: [master, develop] paths: - "docs/**" - "rocketpy/**" From 3798c19a3555ea8df6c175892d25761232664728 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:49:45 +0800 Subject: [PATCH 4/6] BUG: make every step_simulation call advance the flight A call that landed on a phase boundary advanced the phase index, left the new phase uninitialised and returned, so it moved neither t nor y_sol. A caller counting one step per call, which is what the Balloon Popping Challenge environment does, then ran ahead of the flight's own clock. The phase transition now carries on into the new phase in the same call, so the only path that returns without advancing is the one that has just set finished, and by then there is nothing left to advance. Measured on flight_calisto: 5 calls with 2 that did not advance, against 3 calls with none. Final t is 48.4363 either way, so the same flight is being walked in fewer calls rather than a different one. Ignoring whitespace the change is a while loop and a continue; the rest of the diff is the indentation that loop adds. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- rocketpy/simulation/flight.py | 188 +++++++++--------- tests/unit/simulation/test_step_simulation.py | 38 ++++ 2 files changed, 135 insertions(+), 91 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 43fb6210c..49da800f8 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -879,97 +879,78 @@ def step_simulation(self): if state["finished"]: return - phase_index = state["phase_index"] - if phase_index >= len(self.flight_phases) - 1: - state["finished"] = True + # One call has to leave the flight further along than it found it, so a + # call that lands on a phase boundary carries on into the new phase + # instead of returning. Only the finish path below returns without + # advancing, and by then there is nothing left to advance. + while True: + phase_index = state["phase_index"] + if phase_index >= len(self.flight_phases) - 1: + state["finished"] = True - self.post_process_simulation() - self.initialize_prints_plots() - return - - phase = self.flight_phases[phase_index] - - # Determine maximum time for this flight phase - phase.time_bound = self.flight_phases[phase_index + 1].t - - # Initialize phase only once - if not state["phase_initialized"]: - # Evaluate callbacks - for callback in phase.callbacks: - callback(self) + self.post_process_simulation() + self.initialize_prints_plots() + return - # Create solver for this flight phase - self.function_evaluations.append(0) + phase = self.flight_phases[phase_index] - phase.solver = self._solver( - phase.derivative, - t0=phase.t, - y0=self.y_sol, - t_bound=phase.time_bound, - rtol=self.rtol, - atol=self.atol, - max_step=self.max_time_step, - min_step=self.min_time_step, - ) - - # Initialize phase time nodes - self.__setup_phase_time_nodes(phase) + # Determine maximum time for this flight phase + phase.time_bound = self.flight_phases[phase_index + 1].t - state["phase_initialized"] = True - state["node_index"] = 0 + # Initialize phase only once + if not state["phase_initialized"]: + # Evaluate callbacks + for callback in phase.callbacks: + callback(self) - # Check if current phase is fully processed - if state["node_index"] >= len(phase.time_nodes) - 1: - state["phase_index"] += 1 - state["phase_initialized"] = False - state["node_index"] = 0 - return # Move to next phase on next call + # Create solver for this flight phase + self.function_evaluations.append(0) - node_index = state["node_index"] - node = phase.time_nodes[node_index] + phase.solver = self._solver( + phase.derivative, + t0=phase.t, + y0=self.y_sol, + t_bound=phase.time_bound, + rtol=self.rtol, + atol=self.atol, + max_step=self.max_time_step, + min_step=self.min_time_step, + ) - # Determine time bound for this time node - node.time_bound = phase.time_nodes[node_index + 1].t - phase.solver.t_bound = node.time_bound + # Initialize phase time nodes + self.__setup_phase_time_nodes(phase) - if self.__is_lsoda: - phase.solver._lsoda_solver._integrator.rwork[0] = phase.solver.t_bound - phase.solver._lsoda_solver._integrator.call_args[4] = ( - phase.solver._lsoda_solver._integrator.rwork - ) + state["phase_initialized"] = True + state["node_index"] = 0 - phase.solver.status = "running" + # Check if current phase is fully processed + if state["node_index"] >= len(phase.time_nodes) - 1: + state["phase_index"] += 1 + state["phase_initialized"] = False + state["node_index"] = 0 + continue # the new phase is initialised below, in this same call - # Feed required parachute and discrete controller triggers - # TODO: parachutes should be moved to controllers - for callback in node.callbacks: - callback(self) + node_index = state["node_index"] + node = phase.time_nodes[node_index] - for controller in node._controllers: - controller( - self.t, - self.y_sol, - self.solution, - self.sensors, - self.env, - ) + # Determine time bound for this time node + node.time_bound = phase.time_nodes[node_index + 1].t + phase.solver.t_bound = node.time_bound - # Placeholder for parachute triggers in step simulation, which is currently not migrated + if self.__is_lsoda: + phase.solver._lsoda_solver._integrator.rwork[0] = phase.solver.t_bound + phase.solver._lsoda_solver._integrator.call_args[4] = ( + phase.solver._lsoda_solver._integrator.rwork + ) - while phase.solver.status == "running": - # Execute solver step, log solution and function evaluations - phase.solver.step() - self.solution += [[phase.solver.t, *phase.solver.y]] - self.function_evaluations.append(phase.solver.nfev) + phase.solver.status = "running" - # Update time and state - self.t = phase.solver.t - self.y_sol = phase.solver.y - if self.verbose: - print(f"Current Simulation Time: {self.t:3.4f} s", end="\r") - logger.debug("Current Simulation Time: %3.4f s", self.t) + # Feed required parachute and discrete controller triggers + # TODO: parachutes should be moved to controllers + for callback in node.callbacks: + callback(self) - for controller in self._continuous_controllers: + for controller in node._controllers: controller( self.t, self.y_sol, @@ -978,25 +959,50 @@ def step_simulation(self): self.env, ) - if self.__check_simulation_events(phase, phase_index, node_index): - break # Stop if simulation termination event occurred + # Placeholder for parachute triggers in step simulation, which is currently not migrated - # Process overshootable time nodes if enabled - if self.time_overshoot and self.__process_overshootable_nodes( - phase, phase_index, node_index - ): - break + while phase.solver.status == "running": + # Execute solver step, log solution and function evaluations + phase.solver.step() + self.solution += [[phase.solver.t, *phase.solver.y]] + self.function_evaluations.append(phase.solver.nfev) - # If controlled flight, post process must be done on sim time - # Post-process controllers if needed - if self._controllers: - phase.derivative(self.t, self.y_sol, post_processing=True) + # Update time and state + self.t = phase.solver.t + self.y_sol = phase.solver.y + if self.verbose: + print(f"Current Simulation Time: {self.t:3.4f} s", end="\r") + logger.debug("Current Simulation Time: %3.4f s", self.t) - if node._component_sensors: - u_dot = phase.derivative(self.t, self.y_sol) - self.__measure_sensors(node._component_sensors, u_dot) + for controller in self._continuous_controllers: + controller( + self.t, + self.y_sol, + self.solution, + self.sensors, + self.env, + ) - state["node_index"] += 1 + if self.__check_simulation_events(phase, phase_index, node_index): + break # Stop if simulation termination event occurred + + # Process overshootable time nodes if enabled + if self.time_overshoot and self.__process_overshootable_nodes( + phase, phase_index, node_index + ): + break + + # If controlled flight, post process must be done on sim time + # Post-process controllers if needed + if self._controllers: + phase.derivative(self.t, self.y_sol, post_processing=True) + + if node._component_sensors: + u_dot = phase.derivative(self.t, self.y_sol) + self.__measure_sensors(node._component_sensors, u_dot) + + state["node_index"] += 1 + return def __setup_phase_time_nodes(self, phase): """Set up time nodes for the current phase. diff --git a/tests/unit/simulation/test_step_simulation.py b/tests/unit/simulation/test_step_simulation.py index c7ac43df9..11936918a 100644 --- a/tests/unit/simulation/test_step_simulation.py +++ b/tests/unit/simulation/test_step_simulation.py @@ -177,6 +177,44 @@ def _step_with_roll(env, rocket, command, max_steps=100000): return flight, steps +class TestEveryCallAdvances: + """A call has to leave the flight further along than it found it. + + The phase transition used to return without touching ``t`` or ``y_sol``, + leaving the new phase to be initialised on the call after. A caller that + counts a step per call, which is what the Balloon Popping Challenge + environment does, then has its own clock ahead of the flight's. + """ + + def test_no_call_returns_without_advancing(self, flight_calisto): + stepped = _stepped_twin(flight_calisto) + stalled = [] + calls = 0 + while not stepped._step_state["finished"]: + before = stepped.t + stepped.step_simulation() + calls += 1 + if not stepped._step_state["finished"] and stepped.t <= before: + stalled.append(calls) + + assert not stalled, f"calls {stalled} of {calls} did not advance" + + def test_a_transition_is_absorbed_rather_than_costing_a_call(self, flight_calisto): + """The control for the test above, which returning early on every call + would also pass. More than one phase has to actually be visited.""" + stepped = _stepped_twin(flight_calisto) + _, phases_seen = _run_stepped(stepped) + + assert len(phases_seen) > 1 + + def test_the_flight_still_ends_where_simulate_ends(self, flight_calisto): + """Absorbing the transition must not skip the node it was standing on.""" + stepped = _stepped_twin(flight_calisto) + _run_stepped(stepped) + + np.testing.assert_allclose(stepped.t, flight_calisto.t, rtol=1e-8, atol=1e-10) + + class TestControlledStepSimulation: """Injecting an actuator command between steps must move the trajectory. From d61ccd924f6388c35d414026c3f92851d6387df1 Mon Sep 17 00:00:00 2001 From: ZuoRen Chen <180084773+zuorenchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:32:04 +0100 Subject: [PATCH 5/6] BUG: Fix the missing x y forces from TVC (#28) * Fix the missing x y forces from TVC * Fix the cases where thrust1/2 are not calculated --- rocketpy/simulation/flight.py | 91 +++++++++++++++-------------------- 1 file changed, 38 insertions(+), 53 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 49da800f8..084416d1b 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -2154,39 +2154,28 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals # Thrust Vector Control (TVC) if hasattr(self.rocket, "thrust_vector_control"): - # TVC Fz thrust: F = T * sqrt(1 - sin(gimbal_angle_x)**2 - sin(gimbal_angle_y)**2) - thrust3 = effective_thrust * np.sqrt( - 1 - - np.sin( - self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180) - ) - ** 2 - - np.sin( - self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180) - ) - ** 2 - ) - tvc_lever = self.rocket.nozzle_to_cdm - # TVC Mx My moments: M = T * sin(x) * r - M1 += ( - np.sin( - self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180) - ) - * effective_thrust - * tvc_lever + # thrust{1/2/3}: thrust force vector on nozzle among body axes. + # positive gimbal_angle results in positive moment. + thrust1 = -np.sin( + self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180) ) - M2 += ( - np.sin( - self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180) - ) - * effective_thrust - * tvc_lever + thrust2 = np.sin( + self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180) ) + # thrust3 is the remaining force on body3 direction + thrust3 = effective_thrust * np.sqrt(1 - thrust1**2 - thrust2**2) + tvc_lever = self.rocket.nozzle_to_cdm + M1 += thrust2 * effective_thrust * tvc_lever + M2 += -thrust1 * effective_thrust * tvc_lever else: - thrust3 = effective_thrust + thrust1, thrust2, thrust3 = 0, 0, effective_thrust # Off center moment M1 += self.rocket.thrust_eccentricity_y * thrust3 M2 -= self.rocket.thrust_eccentricity_x * thrust3 + M3 += ( + self.rocket.thrust_eccentricity_x * thrust2 + - self.rocket.thrust_eccentricity_y * thrust1 + ) else: # Motor stopped @@ -2200,7 +2189,7 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals # Mass mass_flow_rate_at_t, propellant_mass_at_t = 0, 0 # thrust - thrust3 = 0 + thrust1, thrust2, thrust3 = 0, 0, 0 net_thrust = 0 # Retrieve important quantities @@ -2425,12 +2414,14 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals R1 - b * propellant_mass_at_t * (omega2**2 + omega3**2) - 2 * c * mass_flow_rate_at_t * omega2 + + thrust1 ) / total_mass_at_t, ( R2 + b * propellant_mass_at_t * (alpha3 + omega1 * omega2) + 2 * c * mass_flow_rate_at_t * omega1 + + thrust2 ) / total_mass_at_t, (R3 - b * propellant_mass_at_t * (alpha2 - omega1 * omega3) + thrust3) @@ -2885,32 +2876,21 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too # Thrust Vector Control (TVC) if hasattr(self.rocket, "thrust_vector_control"): - tvc_lever = self.rocket.nozzle_to_cdm - # TVC Mx My moments: M = T * sin(x) * r - M1 += ( - np.sin(self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180)) - * effective_thrust - * tvc_lever + # thrust{1/2/3}: thrust force vector on nozzle among body axes. + # positive gimbal_angle results in positive moment. + thrust1 = -np.sin( + self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180) ) - M2 += ( - np.sin(self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180)) - * effective_thrust - * tvc_lever - ) - # TVC Fz thrust: F = T * sqrt(1 - sin^2(x) - sin^2(y)) - thrust3 = effective_thrust * np.sqrt( - 1 - - np.sin( - self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180) - ) - ** 2 - - np.sin( - self.rocket.thrust_vector_control.gimbal_angle_y * (np.pi / 180) - ) - ** 2 + thrust2 = np.sin( + self.rocket.thrust_vector_control.gimbal_angle_x * (np.pi / 180) ) + # thrust3 is the remaining force on body3 direction + thrust3 = effective_thrust * np.sqrt(1 - thrust1**2 - thrust2**2) + tvc_lever = self.rocket.nozzle_to_cdm + M1 += thrust2 * effective_thrust * tvc_lever + M2 += -thrust1 * effective_thrust * tvc_lever else: - thrust3 = effective_thrust + thrust1, thrust2, thrust3 = 0, 0, effective_thrust # Off center moment M1 += ( @@ -2921,7 +2901,12 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too self.rocket.cp_eccentricity_x * R3 + self.rocket.thrust_eccentricity_x * thrust3 ) - M3 += self.rocket.cp_eccentricity_x * R2 - self.rocket.cp_eccentricity_y * R1 + M3 += ( + self.rocket.cp_eccentricity_x * R2 + - self.rocket.cp_eccentricity_y * R1 + + self.rocket.thrust_eccentricity_x * thrust2 + - self.rocket.thrust_eccentricity_y * thrust1 + ) # Roll control moment if hasattr(self.rocket, "roll_control"): @@ -2937,7 +2922,7 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too T00 = total_mass * r_CM T03 = 2 * total_mass_dot * (r_NOZ - r_CM) - 2 * total_mass * r_CM_dot T04 = ( - Vector([0, 0, thrust3]) + Vector([thrust1, thrust2, thrust3]) - total_mass * r_CM_ddot - 2 * total_mass_dot * r_CM_dot + total_mass_ddot * (r_NOZ - r_CM) From b4bbcf176860ea7b58b8050c9ef526dcbb97598c Mon Sep 17 00:00:00 2001 From: ZuoRen Chen <180084773+zuorenchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:32:20 +0100 Subject: [PATCH 6/6] BUG: Fix gravity term in accelerometer (#29) * Fix gravity term in accelerometer * Fix accelerometer gravity model in tests --- rocketpy/sensors/accelerometer.py | 2 +- tests/unit/sensors/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rocketpy/sensors/accelerometer.py b/rocketpy/sensors/accelerometer.py index b6a477c11..9722b2ecc 100644 --- a/rocketpy/sensors/accelerometer.py +++ b/rocketpy/sensors/accelerometer.py @@ -232,7 +232,7 @@ def measure(self, time, **kwargs): gravity = ( Vector([0, 0, -gravity]) if self.consider_gravity else Vector([0, 0, 0]) ) - inertial_acceleration = Vector(u_dot[3:6]) + gravity + inertial_acceleration = Vector(u_dot[3:6]) - gravity # Vector from rocket cdm to sensor in rocket frame r = relative_position diff --git a/tests/unit/sensors/test_sensor.py b/tests/unit/sensors/test_sensor.py index 17a185586..1eabde5c1 100644 --- a/tests/unit/sensors/test_sensor.py +++ b/tests/unit/sensors/test_sensor.py @@ -272,7 +272,7 @@ def test_noisy_rotated_accelerometer(noisy_rotated_accelerometer, example_plain_ # calculate acceleration at sensor position in inertial frame relative_position = Vector([0.4, 0.4, 1]) - inertial_acceleration = Vector(U_DOT[3:6]) + Vector([0, 0, -GRAVITY]) + inertial_acceleration = Vector(U_DOT[3:6]) - Vector([0, 0, -GRAVITY]) omega = Vector(U[10:13]) omega_dot = Vector(U_DOT[10:13]) acceleration = (