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/**" 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 != '' }} 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 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/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 43fb6210c..084416d1b 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) - - # Create solver for this flight phase - self.function_evaluations.append(0) + self.post_process_simulation() + self.initialize_prints_plots() + return - 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, - ) + phase = self.flight_phases[phase_index] - # 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, + ) + + 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 - state["node_index"] += 1 + # 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. @@ -2148,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 @@ -2194,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 @@ -2419,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) @@ -2879,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 += ( @@ -2915,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"): @@ -2931,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) 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 = ( 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.