diff --git a/.vscode/settings.json b/.vscode/settings.json index 6337fe44b..ebf6bf7dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -206,6 +206,7 @@ "Metrum", "modindex", "mult", + "multiprocess", "Mumma", "NASADEM", "nbformat", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd27ae4e..6bab51154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,11 +32,18 @@ Attention: The newest changes should be on top --> ### Added -- +- DOC: EREBUS Flight Example [#757](https://github.com/RocketPy-Team/RocketPy/pull/757)) +- DOC: Lince Flight Example [#752](https://github.com/RocketPy-Team/RocketPy/pull/752) +- DOC: Andromeda Flight Example [#754](https://github.com/RocketPy-Team/RocketPy/pull/754) +- ENH: create a dataset of pre-registered motors. See #664 [#744](https://github.com/RocketPy-Team/RocketPy/pull/744) +- DOC: add Defiance flight example [#742](https://github.com/RocketPy-Team/RocketPy/pull/742) +- ENH: Allow for Alternative and Custom ODE Solvers. [#748](https://github.com/RocketPy-Team/RocketPy/pull/748) + ### Changed -- +- MNT: move piecewise functions to separate file [#746](https://github.com/RocketPy-Team/RocketPy/pull/746) +- DOC: flight comparison improvements [#755](https://github.com/RocketPy-Team/RocketPy/pull/755) ### Fixed @@ -65,6 +72,7 @@ To install this version, run `pip install rocketpy==1.8.0` ## [v1.7.1] - 2024-12-07 + ### Changed - REL: update version to 1.7.1 in configuration files [#750](https://github.com/RocketPy-Team/RocketPy/pull/750) diff --git a/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb b/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb index 0d3a010e6..8b5113e08 100644 --- a/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb +++ b/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb @@ -760,7 +760,7 @@ "The following input file was imported: monte_carlo_analysis_outputs/monte_carlo_class_example.inputs.txt\n", "A total of 10 simulations results were loaded from the following output file: monte_carlo_analysis_outputs/monte_carlo_class_example.outputs.txt\n", "\n", - "The following error file was imported: monte_carlo_analysis_outputs/monte_carlo_class_example.errors.txt\n" + "The following error file was imported: monte_carlo_analysis_outputs/monte_carlo_class_example.errors.txt \n" ] }, { @@ -1156,7 +1156,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 23, "metadata": {}, "outputs": [], "source": [ @@ -1191,9 +1191,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 24, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The following input file was imported: monte_carlo_analysis_outputs/monte_carlo_class_example_customized.inputs.txt\n", + "A total of 0 simulations results were loaded from the following output file: monte_carlo_analysis_outputs/monte_carlo_class_example_customized.outputs.txt\n", + "\n", + "The following error file was imported: monte_carlo_analysis_outputs/monte_carlo_class_example_customized.errors.txt \n" + ] + } + ], "source": [ "test_dispersion = MonteCarlo(\n", " filename=\"monte_carlo_analysis_outputs/monte_carlo_class_example_customized\",\n", @@ -1207,18 +1218,50 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 25, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Starting Monte Carlo analysis \n", + "Current iteration: 000010 | Average Time per Iteration: 1.623 s | Estimated time left: 0 s \n", + "Completed 10 iterations. In total, 10 simulations are exported.\n", + "Total wall time: 16.2 s \n", + "Results saved to monte_carlo_analysis_outputs/monte_carlo_class_example_customized.outputs.txt\n" + ] + } + ], "source": [ "test_dispersion.simulate(number_of_simulations=10, append=False)" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 26, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Monte Carlo Simulation by RocketPy\n", + "Data Source: monte_carlo_analysis_outputs/monte_carlo_class_example_customized\n", + "Number of simulations: 10\n", + "Results: \n", + "\n", + " Parameter Mean Std. Dev.\n", + "------------------------------------------------------------\n", + " apogee 3440.625 437.300\n", + " apogee_time 25.689 1.438\n", + " x_impact 2033.059 443.921\n", + " index 5.500 2.872\n", + " average_reynolds_number 1132749.860 108479.383\n", + " date 516.250 870.502\n" + ] + } + ], "source": [ "test_dispersion.prints.all()" ] diff --git a/pyproject.toml b/pyproject.toml index a6787bd58..8d1071b04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,8 @@ env-analysis = [ ] monte-carlo = [ - "imageio", + "imageio", + "multiprocess>=0.70", "statsmodels", "prettytable", ] diff --git a/requirements-optional.txt b/requirements-optional.txt index 31c37c91b..58ed1030b 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -4,5 +4,6 @@ ipywidgets>=7.6.3 jsonpickle timezonefinder imageio +multiprocess>=0.70 statsmodels prettytable \ No newline at end of file diff --git a/rocketpy/rocket/components.py b/rocketpy/rocket/components.py index 43d32b074..66448eb69 100644 --- a/rocketpy/rocket/components.py +++ b/rocketpy/rocket/components.py @@ -148,6 +148,8 @@ def remove(self, component): """ for index, comp in enumerate(self._components): if comp.component == component: + self.__component_list.pop(index) + self.__position_list.pop(index) self._components.pop(index) break else: @@ -168,6 +170,8 @@ def pop(self, index=-1): component : Any The component removed from the list of components. """ + self.__component_list.pop(index) + self.__position_list.pop(index) return self._components.pop(index) def clear(self): @@ -177,6 +181,8 @@ def clear(self): ------- None """ + self.__component_list.clear() + self.__position_list.clear() self._components.clear() def sort_by_position(self, reverse=False): diff --git a/rocketpy/simulation/monte_carlo.py b/rocketpy/simulation/monte_carlo.py index f92fe3f32..886fee1e2 100644 --- a/rocketpy/simulation/monte_carlo.py +++ b/rocketpy/simulation/monte_carlo.py @@ -14,8 +14,11 @@ """ import json +import os +import traceback import warnings -from time import process_time, time +from pathlib import Path +from time import time import numpy as np import simplekml @@ -27,6 +30,7 @@ from rocketpy.tools import ( generate_monte_carlo_ellipses, generate_monte_carlo_ellipses_coordinates, + import_optional_dependency, ) # TODO: Create evolution plots to analyze convergence @@ -136,7 +140,7 @@ def __init__( UserWarning, ) - self.filename = filename + self.filename = Path(filename) self.environment = environment self.rocket = rocket self.flight = flight @@ -149,32 +153,23 @@ def __init__( self.processed_results = {} self.prints = _MonteCarloPrints(self) self.plots = _MonteCarloPlots(self) - self._inputs_dict = {} - self._last_print_len = 0 # used to print on the same line self.export_list = self.__check_export_list(export_list) self._check_data_collector(data_collector) self.data_collector = data_collector - try: - self.import_inputs() - except FileNotFoundError: - self._input_file = f"{filename}.inputs.txt" + self.import_inputs(self.filename.with_suffix(".inputs.txt")) + self.import_outputs(self.filename.with_suffix(".outputs.txt")) + self.import_errors(self.filename.with_suffix(".errors.txt")) - try: - self.import_outputs() - except FileNotFoundError: - self._output_file = f"{filename}.outputs.txt" - - try: - self.import_errors() - except FileNotFoundError: - self._error_file = f"{filename}.errors.txt" - - # pylint: disable=consider-using-with def simulate( - self, number_of_simulations, append=False, **kwargs - ): # pylint: disable=too-many-statements + self, + number_of_simulations, + append=False, + parallel=False, + n_workers=None, + **kwargs + ): # pylint: disable=too-many-statements """ Runs the Monte Carlo simulation and saves all data. @@ -185,6 +180,13 @@ def simulate( append : bool, optional If True, the results will be appended to the existing files. If False, the files will be overwritten. Default is False. + parallel : bool, optional + If True, the simulations will be run in parallel. Default is False. + n_workers : int, optional + Number of workers to be used if ``parallel=True``. If None, the + number of workers will be equal to the number of CPUs available. + A minimum of 2 workers is required for parallel mode. + Default is None. kwargs : dict Custom arguments for simulation export of the ``inputs`` file. Options are: @@ -224,13 +226,11 @@ def simulate( # initialize counters self.number_of_simulations = number_of_simulations - self.__iteration_count = self.num_of_loaded_sims if append else 0 - self.__start_time = time() - self.__start_cpu_time = process_time() + self._initial_sim_idx = self.num_of_loaded_sims if append else 0 - # Begin display - print("Starting Monte Carlo analysis", end="\r") + _SimMonitor.reprint("Starting Monte Carlo analysis") + self.__setup_files(append) try: while self.__iteration_count < self.number_of_simulations: self.__run_single_simulation(input_file, output_file) @@ -254,155 +254,285 @@ def simulate( self.__close_files(input_file, output_file, error_file) raise error finally: - self.total_cpu_time = process_time() - self.__start_cpu_time self.total_wall_time = time() - self.__start_time - self.__terminate_simulation(input_file, output_file, error_file) + if parallel: + self.__run_in_parallel(n_workers) + else: + self.__run_in_serial() - # Auxiliary methods + self.__terminate_simulation() - def __run_single_simulation(self, input_file, output_file): + def __setup_files(self, append): """ - Runs a single simulation and saves the inputs and outputs to the - respective files. + Sets up the files for the simulation, creating them if necessary. Parameters ---------- - input_file : str - The file object to write the inputs. - output_file : str - The file object to write the outputs. + append : bool + If ``True``, the results will be appended to the existing files. If + ``False``, the files will be overwritten. Returns ------- None """ - self.__iteration_count += 1 + # Create data files for inputs, outputs and error logging + open_mode = "r+" if append else "w+" - monte_carlo_flight = Flight( - rocket=self.rocket.create_object(), - environment=self.environment.create_object(), - rail_length=self.flight._randomize_rail_length(), - inclination=self.flight._randomize_inclination(), - heading=self.flight._randomize_heading(), - initial_solution=self.flight.initial_solution, - terminate_on_apogee=self.flight.terminate_on_apogee, - ) + try: + with open(self._input_file, open_mode, encoding="utf-8") as input_file: + idx_i = len(input_file.readlines()) + with open(self._output_file, open_mode, encoding="utf-8") as output_file: + idx_o = len(output_file.readlines()) + with open(self._error_file, open_mode, encoding="utf-8"): + pass + + if idx_i != idx_o and not append: + warnings.warn( + "Input and output files are not synchronized", UserWarning + ) - self._inputs_dict = dict( - item - for d in [ - self.environment.last_rnd_dict, - self.rocket.last_rnd_dict, - self.flight.last_rnd_dict, - ] - for item in d.items() - ) + except OSError as error: + raise OSError(f"Error creating files: {error}") from error - self.__export_flight_data( - flight=monte_carlo_flight, - inputs_dict=self._inputs_dict, - input_file=input_file, - output_file=output_file, - ) + def __run_in_serial(self): + """ + Runs the monte carlo simulation in serial mode. - average_time = (process_time() - self.__start_cpu_time) / self.__iteration_count - estimated_time = int( - (self.number_of_simulations - self.__iteration_count) * average_time - ) - self.__reprint( - f"Current iteration: {self.__iteration_count:06d} | " - f"Average Time per Iteration: {average_time:.3f} s | " - f"Estimated time left: {estimated_time} s", - end="\r", - flush=True, + Returns + ------- + None + """ + sim_monitor = _SimMonitor( + initial_count=self._initial_sim_idx, + n_simulations=self.number_of_simulations, + start_time=time(), ) + try: + while sim_monitor.keep_simulating(): + sim_monitor.increment() + + flight = self.__run_single_simulation() + inputs_json = self.__evaluate_flight_inputs(sim_monitor.count) + outputs_json = self.__evaluate_flight_outputs(flight, sim_monitor.count) + + with open(self.input_file, "a", encoding="utf-8") as f: + f.write(inputs_json) + with open(self.output_file, "a", encoding="utf-8") as f: + f.write(outputs_json) + + sim_monitor.print_update_status(sim_monitor.count) + + sim_monitor.print_final_status() + + except KeyboardInterrupt: + _SimMonitor.reprint("Keyboard Interrupt, files saved.") + with open(self._error_file, "a", encoding="utf-8") as f: + f.write(inputs_json) + + except Exception as error: + _SimMonitor.reprint(f"Error on iteration {sim_monitor.count}: {error}") + with open(self._error_file, "a", encoding="utf-8") as f: + f.write(inputs_json) + raise error - def __close_files(self, input_file, output_file, error_file): + # pylint: disable=too-many-statements + def __run_in_parallel(self, n_workers=None): """ - Closes all the files. + Runs the monte carlo simulation in parallel. Parameters ---------- - input_file : str - The file object to write the inputs. - output_file : str - The file object to write the outputs. - error_file : str - The file object to write the errors. + n_workers: int, optional + Number of workers to be used. If None, the number of workers + will be equal to the number of CPUs available. Default is None. Returns ------- None """ - input_file.close() - output_file.close() - error_file.close() + if n_workers is None or n_workers > os.cpu_count(): + n_workers = os.cpu_count() - def __terminate_simulation(self, input_file, output_file, error_file): - """ - Terminates the simulation, closes the files and prints the results. + if n_workers < 2: + raise ValueError("Number of workers must be at least 2 for parallel mode.") + + _SimMonitor.reprint(f"Running Monte Carlo simulation with {n_workers} workers.") + + multiprocess, managers = _import_multiprocess() + + with _create_multiprocess_manager(multiprocess, managers) as manager: + mutex = manager.Lock() + simulation_error_event = manager.Event() + sim_monitor = manager._SimMonitor( + initial_count=self._initial_sim_idx, + n_simulations=self.number_of_simulations, + start_time=time(), + ) + + processes = [] + seeds = np.random.SeedSequence().spawn(n_workers) + + for seed in seeds: + sim_producer = multiprocess.Process( + target=self.__sim_producer, + args=( + seed, + sim_monitor, + mutex, + simulation_error_event, + ), + ) + processes.append(sim_producer) + sim_producer.start() + + try: + for sim_producer in processes: + sim_producer.join() + + # Handle error from the child processes + if simulation_error_event.is_set(): + raise RuntimeError( + "An error occurred during the simulation. \n" + f"Check the logs and error file {self.error_file} " + "for more information." + ) + + sim_monitor.print_final_status() + + # Handle error from the main process + # pylint: disable=broad-except + except (Exception, KeyboardInterrupt) as error: + simulation_error_event.set() + + for sim_producer in processes: + sim_producer.join() + + if not isinstance(error, KeyboardInterrupt): + raise error + + def __sim_producer(self, seed, sim_monitor, mutex, error_event): + """Simulation producer to be used in parallel by multiprocessing. Parameters ---------- - input_file : str - The file object to write the inputs. - output_file : str - The file object to write the outputs. - error_file : str - The file object to write the errors. + seed : int + The seed to set the random number generator. + sim_monitor : _SimMonitor + The simulation monitor object to keep track of the simulations. + mutex : multiprocess.Lock + The mutex to lock access to critical regions. + error_event : multiprocess.Event + Event signaling an error occurred during the simulation. + """ + try: + # Ensure Processes generate different random numbers + self.environment._set_stochastic(seed) + self.rocket._set_stochastic(seed) + self.flight._set_stochastic(seed) + + while sim_monitor.keep_simulating(): + sim_idx = sim_monitor.increment() - 1 + + flight = self.__run_single_simulation() + inputs_json = self.__evaluate_flight_inputs(sim_idx) + outputs_json = self.__evaluate_flight_outputs(flight, sim_idx) + + try: + mutex.acquire() + if error_event.is_set(): + sim_monitor.reprint( + "Simulation Interrupt, files from simulation " + f"{sim_idx} saved." + ) + with open(self.error_file, "a", encoding="utf-8") as f: + f.write(inputs_json) + + break + + with open(self.input_file, "a", encoding="utf-8") as f: + f.write(inputs_json) + with open(self.output_file, "a", encoding="utf-8") as f: + f.write(outputs_json) + + sim_monitor.print_update_status(sim_idx) + finally: + mutex.release() + + except Exception: # pylint: disable=broad-except + mutex.acquire() + with open(self.error_file, "a", encoding="utf-8") as f: + f.write(inputs_json) + + sim_monitor.reprint(f"Error on iteration {sim_idx}:") + sim_monitor.reprint(traceback.format_exc()) + error_event.set() + mutex.release() + + def __run_single_simulation(self): + """Runs a single simulation and returns the inputs and outputs. Returns ------- - None + Flight + The flight object of the simulation. """ - final_string = ( - f"Completed {self.__iteration_count} iterations. Total CPU time: " - f"{process_time() - self.__start_cpu_time:.1f} s. Total wall time: " - f"{time() - self.__start_time:.1f} s\n" + return Flight( + rocket=self.rocket.create_object(), + environment=self.environment.create_object(), + rail_length=self.flight._randomize_rail_length(), + inclination=self.flight._randomize_inclination(), + heading=self.flight._randomize_heading(), + initial_solution=self.flight.initial_solution, + terminate_on_apogee=self.flight.terminate_on_apogee, ) - self.__reprint(final_string + "Saving results.", flush=True) - - # close files to guarantee saving - self.__close_files(input_file, output_file, error_file) - - # resave the files on self and calculate post simulation attributes - self.input_file = f"{self.filename}.inputs.txt" - self.output_file = f"{self.filename}.outputs.txt" - self.error_file = f"{self.filename}.errors.txt" + def __evaluate_flight_inputs(self, sim_idx): + """Evaluates the inputs of a single flight simulation. - print(f"Results saved to {self._output_file}") + Parameters + ---------- + sim_idx : int + The index of the simulation. - def __export_flight_data( - self, - flight, - inputs_dict, - input_file, - output_file, - ): + Returns + ------- + str + A JSON compatible dictionary with the inputs of the simulation. """ - Exports the flight data to the respective files. + inputs_dict = dict( + item + for d in [ + self.environment.last_rnd_dict, + self.rocket.last_rnd_dict, + self.flight.last_rnd_dict, + ] + for item in d.items() + ) + inputs_dict["index"] = sim_idx + return json.dumps(inputs_dict, cls=RocketPyEncoder) + "\n" + + def __evaluate_flight_outputs(self, flight, sim_idx): + """Evaluates the outputs of a single flight simulation. Parameters ---------- flight : Flight - The Flight object containing the flight data. - inputs_dict : dict - Dictionary containing the inputs used in the simulation. - input_file : str - The file object to write the inputs. - output_file : str - The file object to write the outputs. + The flight object to be evaluated. + sim_idx : int + The index of the simulation. Returns ------- - None + str + A JSON compatible dictionary with the outputs of the simulation. """ - results = { + outputs_dict = { export_item: getattr(flight, export_item) for export_item in self.export_list } + outputs_dict["index"] = sim_idx if self.data_collector is not None: additional_exports = {} @@ -413,13 +543,29 @@ def __export_flight_data( raise ValueError( f"An error was encountered running 'data_collector' callback {key}. " ) from e - results = results | additional_exports + outputs_dict = outputs_dict | additional_exports - input_file.write( - json.dumps(inputs_dict, cls=RocketPyEncoder, **self._export_config) + "\n" + return json.dumps(outputs_dict, cls=RocketPyEncoder) + "\n" + + def __terminate_simulation(self): + """ + Terminates the simulation, closes the files and prints the results. + + Returns + ------- + None + """ + # resave the files on self and calculate post simulation attributes + self.input_file = self._input_file + self.output_file = self._output_file + self.error_file = self._error_file + + _SimMonitor.reprint(f"Results saved to {self._output_file}") + self.input_file.write( + json.dumps(self.inputs_dict, cls=RocketPyEncoder, **self._export_config) + "\n" ) - output_file.write( - json.dumps(results, cls=RocketPyEncoder, **self._export_config) + "\n" + self.output_file.write( + json.dumps(self.results, cls=RocketPyEncoder, **self._export_config) + "\n" ) def __check_export_list(self, export_list): @@ -557,35 +703,6 @@ def _check_data_collector(self, data_collector): "Values must be python callables (callback functions)." ) - def __reprint(self, msg, end="\n", flush=False): - """ - Prints a message on the same line as the previous one and replaces the - previous message with the new one, deleting the extra characters from - the previous message. - - Parameters - ---------- - msg : str - Message to be printed. - end : str, optional - String appended after the message. Default is a new line. - flush : bool, optional - If True, the output is flushed. Default is False. - - Returns - ------- - None - """ - len_msg = len(msg) - if len_msg < self._last_print_len: - msg += " " * (self._last_print_len - len_msg) - else: - self._last_print_len = len_msg - - print(msg, end=end, flush=flush) - - # Properties and setters - @property def input_file(self): """String representing the filepath of the input file""" @@ -782,16 +899,16 @@ def import_outputs(self, filename=None): file without the need to run simulations. You can use previously saved files to process analyze the results or to continue a simulation. """ - filepath = filename if filename else self.filename + filepath = filename if filename else self.filename.with_suffix(".outputs.txt") try: - with open(f"{filepath}.outputs.txt", "r+", encoding="utf-8"): - self.output_file = f"{filepath}.outputs.txt" - except FileNotFoundError: with open(filepath, "r+", encoding="utf-8"): self.output_file = filepath + except FileNotFoundError: + with open(filepath, "w+", encoding="utf-8"): + self.output_file = filepath - print( + _SimMonitor.reprint( f"A total of {self.num_of_loaded_sims} simulations results were " f"loaded from the following output file: {self.output_file}\n" ) @@ -810,16 +927,16 @@ def import_inputs(self, filename=None): ------- None """ - filepath = filename if filename else self.filename + filepath = filename if filename else self.filename.with_suffix(".inputs.txt") try: - with open(f"{filepath}.inputs.txt", "r+", encoding="utf-8"): - self.input_file = f"{filepath}.inputs.txt" - except FileNotFoundError: with open(filepath, "r+", encoding="utf-8"): self.input_file = filepath + except FileNotFoundError: + with open(filepath, "w+", encoding="utf-8"): + self.input_file = filepath - print(f"The following input file was imported: {self.input_file}") + _SimMonitor.reprint(f"The following input file was imported: {self.input_file}") def import_errors(self, filename=None): """ @@ -835,15 +952,16 @@ def import_errors(self, filename=None): ------- None """ - filepath = filename if filename else self.filename + filepath = filename if filename else self.filename.with_suffix(".errors.txt") try: - with open(f"{filepath}.errors.txt", "r+", encoding="utf-8"): - self.error_file = f"{filepath}.errors.txt" - except FileNotFoundError: with open(filepath, "r+", encoding="utf-8"): self.error_file = filepath - print(f"The following error file was imported: {self.error_file}") + except FileNotFoundError: + with open(filepath, "w+", encoding="utf-8"): + self.error_file = filepath + + _SimMonitor.reprint(f"The following error file was imported: {self.error_file}") def import_results(self, filename=None): """ @@ -852,18 +970,16 @@ def import_results(self, filename=None): Parameters ---------- filename : str, optional - Name or directory path to the file to be imported. If none, + Name or directory path to the file to be imported. If ``None``, self.filename will be used. Returns ------- None """ - filepath = filename if filename else self.filename - - self.import_outputs(filename=filepath) - self.import_inputs(filename=filepath) - self.import_errors(filename=filepath) + self.import_outputs(filename=filename) + self.import_inputs(filename=filename) + self.import_errors(filename=filename) # Export methods @@ -1021,3 +1137,128 @@ def all_info(self): self.info() self.plots.ellipses() self.plots.all() + + +def _import_multiprocess(): + """Import the necessary modules and submodules for the + multiprocess library. + + Returns + ------- + tuple + Tuple containing the imported modules. + """ + multiprocess = import_optional_dependency("multiprocess") + managers = import_optional_dependency("multiprocess.managers") + + return multiprocess, managers + + +def _create_multiprocess_manager(multiprocess, managers): + """Creates a manager for the multiprocess control of the + Monte Carlo simulation. + + Parameters + ---------- + multiprocess : module + Multiprocess module. + managers : module + Managing submodules of the multiprocess module. + + Returns + ------- + MonteCarloManager + Subclass of BaseManager with the necessary classes registered. + """ + + class MonteCarloManager(managers.BaseManager): + """Custom manager for shared objects in the Monte Carlo simulation.""" + + def __init__(self): + super().__init__() + self.register('Lock', multiprocess.Lock) + self.register('Queue', multiprocess.Queue) + self.register('Event', multiprocess.Event) + self.register('_SimMonitor', _SimMonitor) + + return MonteCarloManager() + + +class _SimMonitor: + """Class to monitor the simulation progress and display the status.""" + + _last_print_len = 0 + + def __init__(self, initial_count, n_simulations, start_time): + self.initial_count = initial_count + self.count = initial_count + self.n_simulations = n_simulations + self.start_time = start_time + + def keep_simulating(self): + return self.count < self.n_simulations + + def increment(self): + self.count += 1 + return self.count + + def print_update_status(self, sim_idx): + """Prints a message on the same line as the previous one and replaces + the previous message with the new one, deleting the extra characters + from the previous message. + + Parameters + ---------- + sim_idx : int + Index of the current simulation. + + Returns + ------- + None + """ + average_time = (time() - self.start_time) / (self.count - self.initial_count) + estimated_time = int((self.n_simulations - self.count) * average_time) + + msg = f"Current iteration: {sim_idx:06d}" + msg += f" | Average Time per Iteration: {average_time:.3f} s" + msg += f" | Estimated time left: {estimated_time} s" + + _SimMonitor.reprint(msg, end="\r", flush=True) + + def print_final_status(self): + """Prints the final status of the simulation.""" + print() + msg = f"Completed {self.count - self.initial_count} iterations." + msg += f" In total, {self.count} simulations are exported.\n" + msg += f"Total wall time: {time() - self.start_time:.1f} s" + + _SimMonitor.reprint(msg, end="\n", flush=True) + + @staticmethod + def reprint(msg, end="\n", flush=True): + """ + Prints a message on the same line as the previous one and replaces the + previous message with the new one, deleting the extra characters from + the previous message. + + Parameters + ---------- + msg : str + Message to be printed. + end : str, optional + String appended after the message. Default is a new line. + flush : bool, optional + If True, the output is flushed. Default is True. + + Returns + ------- + None + """ + padding = "" + + if len(msg) < _SimMonitor._last_print_len: + padding = " " * (_SimMonitor._last_print_len - len(msg)) + + print(msg + padding, end=end, flush=flush) + + _SimMonitor._last_print_len = len(msg) diff --git a/rocketpy/stochastic/stochastic_model.py b/rocketpy/stochastic/stochastic_model.py index 02341a11d..e8eabf833 100644 --- a/rocketpy/stochastic/stochastic_model.py +++ b/rocketpy/stochastic/stochastic_model.py @@ -40,7 +40,7 @@ class StochasticModel: "ensemble_member", ] - def __init__(self, obj, **kwargs): + def __init__(self, obj, seed=None, **kwargs): """ Initialize the StochasticModel class with validated input arguments. @@ -48,6 +48,9 @@ def __init__(self, obj, **kwargs): ---------- obj : object The main object of the class. + seed : int, optional + Seed for the random number generator. The default is None so that + a new ``numpy.random.Generator`` object is created. **kwargs : dict Dictionary of input arguments for the class. Valid argument types include tuples, lists, ints, floats, or None. Arguments will be @@ -63,9 +66,24 @@ def __init__(self, obj, **kwargs): self.obj = obj self.last_rnd_dict = {} + self.__stochastic_dict = kwargs + self._set_stochastic(seed) + + def _set_stochastic(self, seed=None): + """Set the stochastic attributes from the input dictionary. + This method is useful to reset or reseed the attributes of the instance. + + Parameters + ---------- + seed : int, optional + Seed for the random number generator. + """ + self.__random_number_generator = np.random.default_rng(seed) + self.last_rnd_dict = {} # TODO: This code block is too complex. Refactor it. - for input_name, input_value in kwargs.items(): + # TODO: Resetting a instance should not require re-validation. + for input_name, input_value in self.__stochastic_dict.items(): if input_name not in self.exception_list: attr_value = None if input_value is not None: @@ -163,14 +181,18 @@ def _validate_tuple_length_two( # is the standard deviation, and the second item is the distribution # function. In this case, the nominal value will be taken from the # object passed. - dist_func = get_distribution(input_value[1]) + dist_func = get_distribution(input_value[1], self.__random_number_generator) return (getattr(self.obj, input_name), input_value[0], dist_func) else: # if second item is an int or float, then it is assumed that the # first item is the nominal value and the second item is the # standard deviation. The distribution function will be set to # "normal". - return (input_value[0], input_value[1], get_distribution("normal")) + return ( + input_value[0], + input_value[1], + get_distribution("normal", self.__random_number_generator), + ) def _validate_tuple_length_three( self, input_name, input_value, getattr=getattr @@ -206,7 +228,7 @@ def _validate_tuple_length_three( f"'{input_name}': Third item of tuple must be a string containing the " "name of a valid numpy.random distribution function." ) - dist_func = get_distribution(input_value[2]) + dist_func = get_distribution(input_value[2], self.__random_number_generator) return (input_value[0], input_value[1], dist_func) def _validate_list( @@ -265,7 +287,7 @@ def _validate_scalar( return ( getattr(self.obj, input_name), input_value, - get_distribution("normal"), + get_distribution("normal", self.__random_number_generator), ) def _validate_factors(self, input_name, input_value): @@ -330,13 +352,19 @@ def _validate_tuple_factor(self, input_name, factor_tuple): ) if len(factor_tuple) == 2: - return (factor_tuple[0], factor_tuple[1], get_distribution("normal")) + return ( + factor_tuple[0], + factor_tuple[1], + get_distribution("normal", self.__random_number_generator), + ) elif len(factor_tuple) == 3: assert isinstance(factor_tuple[2], str), ( f"'{input_name}`: Third item of tuple must be a string containing " "the name of a valid numpy.random distribution function" ) - dist_func = get_distribution(factor_tuple[2]) + dist_func = get_distribution( + factor_tuple[2], self.__random_number_generator + ) return (factor_tuple[0], factor_tuple[1], dist_func) def _validate_list_factor(self, input_name, factor_list): diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index 01e6c66a5..2df822ea7 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -144,6 +144,11 @@ def __init__( # TODO: mention that these factors are validated differently self._validate_1d_array_like("power_off_drag", power_off_drag) self._validate_1d_array_like("power_on_drag", power_on_drag) + self.motors = Components() + self.aerodynamic_surfaces = Components() + self.rail_buttons = Components() + self.parachutes = [] + self.__components_map = {} super().__init__( obj=rocket, radius=radius, @@ -161,10 +166,53 @@ def __init__( center_of_mass_without_motor=center_of_mass_without_motor, coordinate_system_orientation=None, ) - self.motors = Components() - self.aerodynamic_surfaces = Components() - self.rail_buttons = Components() - self.parachutes = [] + + def _set_stochastic(self, seed=None): + """Set the stochastic attributes for Components, positions and + inputs. + + Parameters + ---------- + seed : int, optional + Seed for the random number generator. + """ + super()._set_stochastic(seed) + self.aerodynamic_surfaces = self.__reset_components( + self.aerodynamic_surfaces, seed + ) + self.motors = self.__reset_components(self.motors, seed) + self.rail_buttons = self.__reset_components(self.rail_buttons, seed) + for parachute in self.parachutes: + parachute._set_stochastic(seed) + + def __reset_components(self, components, seed): + """Creates a new Components whose stochastic structures + and their positions are reset. + + Parameters + ---------- + components : Components + The components which contains the stochastic structure that + will be used to create the new components. + seed : int, optional + Seed for the random number generator. + + Returns + ------- + new_components : Components + A components whose stochastic structure and position match the + input component but are reset. Ideally, it should replace the + input component. + """ + new_components = Components() + for stochastic_obj, _ in components: + stochastic_obj_position_info = self.__components_map[stochastic_obj] + stochastic_obj._set_stochastic(seed) + new_components.add( + stochastic_obj, + self._validate_position(stochastic_obj, stochastic_obj_position_info), + ) + return new_components def add_motor(self, motor, position=None): """Adds a stochastic motor to the stochastic rocket. If a motor is @@ -194,6 +242,7 @@ def add_motor(self, motor, position=None): motor = StochasticSolidMotor(solid_motor=motor) elif isinstance(motor, GenericMotor): motor = StochasticGenericMotor(generic_motor=motor) + self.__components_map[motor] = position self.motors.add(motor, self._validate_position(motor, position)) def _add_surfaces(self, surfaces, positions, type_, stochastic_type, error_message): @@ -220,6 +269,7 @@ def _add_surfaces(self, surfaces, positions, type_, stochastic_type, error_messa raise AssertionError(error_message) if isinstance(surfaces, type_): surfaces = stochastic_type(component=surfaces) + self.__components_map[surfaces] = positions self.aerodynamic_surfaces.add( surfaces, self._validate_position(surfaces, positions) ) @@ -333,6 +383,7 @@ def set_rail_buttons( ) if isinstance(rail_buttons, RailButtons): rail_buttons = StochasticRailButtons(rail_buttons=rail_buttons) + self.__components_map[rail_buttons] = lower_button_position self.rail_buttons.add( rail_buttons, self._validate_position(rail_buttons, lower_button_position) ) @@ -357,7 +408,6 @@ def _validate_position(self, validated_object, position): ValueError If the position argument does not conform to the specified formats. """ - if isinstance(position, tuple): return self._validate_tuple( "position", diff --git a/rocketpy/tools.py b/rocketpy/tools.py index 9962e9442..91428d50b 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -235,7 +235,7 @@ def bilinear_interpolation(x, y, x1, x2, y1, y2, z11, z12, z21, z22): ) / ((x2 - x1) * (y2 - y1)) -def get_distribution(distribution_function_name): +def get_distribution(distribution_function_name, random_number_generator=None): """Sets the distribution function to be used in the monte carlo analysis. Parameters @@ -243,24 +243,31 @@ def get_distribution(distribution_function_name): distribution_function_name : string The type of distribution to be used in the analysis. It can be 'uniform', 'normal', 'lognormal', etc. + random_number_generator : np.random.Generator, optional + The random number generator to be used. If None, the default generator + ``numpy.random.default_rng`` is used. Returns ------- np.random distribution function The distribution function to be used in the analysis. """ + if random_number_generator is None: + random_number_generator = np.random.default_rng() + + # Dictionary mapping distribution names to RNG methods distributions = { - "normal": np.random.normal, - "binomial": np.random.binomial, - "chisquare": np.random.chisquare, - "exponential": np.random.exponential, - "gamma": np.random.gamma, - "gumbel": np.random.gumbel, - "laplace": np.random.laplace, - "logistic": np.random.logistic, - "poisson": np.random.poisson, - "uniform": np.random.uniform, - "wald": np.random.wald, + "normal": random_number_generator.normal, + "binomial": random_number_generator.binomial, + "chisquare": random_number_generator.chisquare, + "exponential": random_number_generator.exponential, + "gamma": random_number_generator.gamma, + "gumbel": random_number_generator.gumbel, + "laplace": random_number_generator.laplace, + "logistic": random_number_generator.logistic, + "poisson": random_number_generator.poisson, + "uniform": random_number_generator.uniform, + "wald": random_number_generator.wald, } try: return distributions[distribution_function_name] diff --git a/tests/integration/test_flight.py b/tests/integration/test_flight.py index 3efc0c285..8e5d518e5 100644 --- a/tests/integration/test_flight.py +++ b/tests/integration/test_flight.py @@ -69,6 +69,42 @@ def test_all_info_different_solvers( assert test_flight.all_info() is None +@pytest.mark.slow +@patch("matplotlib.pyplot.show") +@pytest.mark.parametrize("solver_method", ["RK45", "DOP853", "Radau", "BDF"]) +# RK23 is unstable and requires a very low tolerance to work +# pylint: disable=unused-argument +def test_all_info_different_solvers( + mock_show, calisto_robust, example_spaceport_env, solver_method +): + """Test that the flight class is working as intended with different solver + methods. This basically calls the all_info() method and checks if it returns + None. It is not testing if the values are correct, but whether the method is + working without errors. + + Parameters + ---------- + mock_show : unittest.mock.MagicMock + Mock object to replace matplotlib.pyplot.show + calisto_robust : rocketpy.Rocket + Rocket to be simulated. See the conftest.py file for more info. + example_spaceport_env : rocketpy.Environment + Environment to be simulated. See the conftest.py file for more info. + solver_method : str + The solver method to be used in the simulation. + """ + test_flight = Flight( + environment=example_spaceport_env, + rocket=calisto_robust, + rail_length=5.2, + inclination=85, + heading=0, + terminate_on_apogee=False, + ode_solver=solver_method, + ) + assert test_flight.all_info() is None + + class TestExportData: """Tests the export_data method of the Flight class.""" diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index 51d8bfae9..48e47a6e2 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -10,7 +10,8 @@ @pytest.mark.slow -def test_monte_carlo_simulate(monte_carlo_calisto): +@pytest.mark.parametrize("parallel", [False, True]) +def test_monte_carlo_simulate(monte_carlo_calisto, parallel): """Tests the simulate method of the MonteCarlo class. Parameters @@ -19,20 +20,22 @@ def test_monte_carlo_simulate(monte_carlo_calisto): The MonteCarlo object, this is a pytest fixture. """ # NOTE: this is really slow, it runs 10 flight simulations - monte_carlo_calisto.simulate(number_of_simulations=10, append=False) + monte_carlo_calisto.simulate( + number_of_simulations=10, append=False, parallel=parallel + ) assert monte_carlo_calisto.num_of_loaded_sims == 10 assert monte_carlo_calisto.number_of_simulations == 10 - assert monte_carlo_calisto.filename == "monte_carlo_test" - assert monte_carlo_calisto.error_file == "monte_carlo_test.errors.txt" - assert monte_carlo_calisto.output_file == "monte_carlo_test.outputs.txt" + assert str(monte_carlo_calisto.filename.name) == "monte_carlo_test" + assert str(monte_carlo_calisto.error_file.name) == "monte_carlo_test.errors.txt" + assert str(monte_carlo_calisto.output_file.name) == "monte_carlo_test.outputs.txt" assert np.isclose( - monte_carlo_calisto.processed_results["apogee"][0], 4711, rtol=0.15 + monte_carlo_calisto.processed_results["apogee"][0], 4711, rtol=0.2 ) assert np.isclose( monte_carlo_calisto.processed_results["impact_velocity"][0], -5.234, - rtol=0.15, + rtol=0.2, ) os.remove("monte_carlo_test.errors.txt") os.remove("monte_carlo_test.outputs.txt")