diff --git a/.gitignore b/.gitignore index cc9ac3788..84d8f1615 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,12 @@ coverage.xml cover/ .pylint-report.txt .ruff-report.txt +# Ignore temporary MonteCarlo export test artifacts +temp_test_output.json +monte_carlo_test.inputs.txt +monte_carlo_test.outputs.txt +monte_carlo_test.errors.txt + # Translations *.mo diff --git a/rocketpy/simulation/monte_carlo.py b/rocketpy/simulation/monte_carlo.py index e10789a7d..c2ead0e54 100644 --- a/rocketpy/simulation/monte_carlo.py +++ b/rocketpy/simulation/monte_carlo.py @@ -14,6 +14,7 @@ """ import json +import csv import os import traceback import warnings @@ -1150,6 +1151,129 @@ def export_ellipses_to_kml( # pylint: disable=too-many-statements kml.save(filename) + def export_json(self, filename, indent_size=4): + """ + Export Monte Carlo results into a JSON file. + + The exported data reflects exactly what is stored inside ``self.results``. + Depending on how the MonteCarlo object was populated, values may be either + lists (when results come from running Monte Carlo simulations) or scalars + (when results are imported from previously saved output summaries). + + Parameters + ---------- + filename : str + Path to the JSON file that will be created. If the file already exists, + it will be overwritten. + indent_size : int, optional + Number of spaces used for indentation in the generated JSON file. + Defaults to 4. + + Notes + ----- + This function only exports the data already contained inside + ``self.results``. No computations are performed during export. Users + should call ``simulate()`` or ``import_results()`` before exporting. + + Examples + -------- + Run new Monte Carlo simulations and export results:: + + mc = MonteCarlo(environment=env, rocket=rocket, flight=flight) + mc.simulate(20) + mc.export_json("results.json") + + Export results previously loaded from file:: + + mc = MonteCarlo(environment=env, rocket=rocket, flight=flight) + mc.import_results("sample.outputs.txt") + mc.export_json("summary.json") + """ + if filename is None: + raise ValueError("A valid filename must be provided") + + if not filename.lower().endswith(".json"): + raise ValueError("filename must end with .json") + + if not self.results or len(self.results) == 0: + raise RuntimeError( + "No reesults found run simulation() or import results first" + ) + + export_dictionary = {} + + for key, value_list in self.results.items(): + converted_values = [] + + for value in value_list: + if isinstance(value, np.generic): + converted_values.append(float(value)) + + else: + converted_values.append(value) + + export_dictionary[key] = converted_values + + with open(filename, "w", encoding="utf-8") as file: + json.dump(export_dictionary, file, indent=indent_size, cls=RocketPyEncoder) + + def export_csv(self, filename: str): + """ + Export Monte Carlo results into a CSV file. + + The CSV rows correspond to individual simulation iterations whenever + the stored values are lists. Scalar values (from imported summaries) + are repeated across all rows. + + Parameters + ---------- + filename : str + Output CSV file path. Must end in ".csv". + """ + if filename is None: + raise ValueError("A Filename must be provided") + + if not filename.lower().endswith(".csv"): + raise ValueError("The filename must end with .csv") + + if not self.results or len(self.results) == 0: + raise RuntimeError( + "No results found run simulations() or import results first" + ) + + # collection of keys and length + headers = list(self.results.keys()) + + # Determine number of rows + # If lists exist → number of simulations + # If all scalars → only 1 row + max_len = 1 + + for _ in self.results.values(): + if isinstance(_, (list, np.ndarray)): + max_len = len(_) + break + + with open(filename, "w", newline="", encoding="utf-8") as file: + writer = csv.writer(file) + + # write headers + writer.writerow(headers) + + # write rows + for i in range(max_len): + row = [] + for key in headers: + value = self.results[key] + + if isinstance(value, np.ndarray): + row.append(value[i]) + + else: + row.append(value) + + writer.writerow(row) + def info(self): """ Print information about the Monte Carlo simulation. diff --git a/tests/integration/simulation/test_monte_carlo_export_csv_create_valid_file.py b/tests/integration/simulation/test_monte_carlo_export_csv_create_valid_file.py new file mode 100644 index 000000000..fee6d6af8 --- /dev/null +++ b/tests/integration/simulation/test_monte_carlo_export_csv_create_valid_file.py @@ -0,0 +1,26 @@ +import os +import csv + + +def test_csv_export(monte_carlo_calisto): + mc = monte_carlo_calisto + mc.simulate(3) + + filename = "temp_mc_export.csv" + """ + tests that results of monte carlo are exported to a CSV file + """ + mc.export_csv(filename) + + assert os.path.exists(filename) + + with open(filename, newline="") as f: + reader = list(csv.reader(f)) + + # Header exists + assert len(reader[0]) > 0 + + # Should have 3 rows of data after header + assert len(reader) == 4 + + os.remove(filename) diff --git a/tests/integration/simulation/test_monte_carlo_export_json_creates_valid_file.py b/tests/integration/simulation/test_monte_carlo_export_json_creates_valid_file.py new file mode 100644 index 000000000..6e30c1a5d --- /dev/null +++ b/tests/integration/simulation/test_monte_carlo_export_json_creates_valid_file.py @@ -0,0 +1,32 @@ +import os +import json + + +def test_json_export(monte_carlo_calisto): + mc = monte_carlo_calisto + mc.simulate(3) + + filename = "temp_test_output.json" + """ + tests weather the results of monte carlo are exported to a JSON file. + """ + mc.export_json(filename) + + assert os.path.exists(filename) + + try: + mc.export_json(filename) + assert os.path.exists(filename) + with open(filename, "r") as f: + data = json.load(f) + # Assert dictionary keys exist + assert len(data.keys()) > 0 + # Check that at least one key corresponds to a list of simulation results + list_keys = [k for k, v in data.items() if isinstance(v, list)] + # There must be at least 1 Monte Carlo-dependent field + assert len(list_keys) > 0 + first_list_key = list_keys[0] + assert len(data[first_list_key]) == 3 + finally: + if os.path.exists(filename): + os.remove(filename)