diff --git a/rocketpy/simulation/monte_carlo.py b/rocketpy/simulation/monte_carlo.py index 21c665d01..3ed65bf4e 100644 --- a/rocketpy/simulation/monte_carlo.py +++ b/rocketpy/simulation/monte_carlo.py @@ -267,6 +267,39 @@ def __setup_files(self, append): except OSError as error: raise OSError(f"Error creating files: {error}") from error + def _append_simulation_record(self, inputs_json, outputs_json): + """Append one simulation's inputs and outputs as a paired record. + + Writes the inputs row first, then the outputs row. If the outputs write + fails, the inputs file is truncated back to its size before this call so + the two files do not drift out of alignment. + + Parameters + ---------- + inputs_json : str + Serialized inputs row, including its trailing newline. + outputs_json : str + Serialized outputs row, including its trailing newline. + """ + input_path = self.input_file + output_path = self.output_file + + try: + previous_input_size = os.path.getsize(input_path) + except OSError: + previous_input_size = 0 + + with open(input_path, "a", encoding="utf-8") as f: + f.write(inputs_json) + + try: + with open(output_path, "a", encoding="utf-8") as f: + f.write(outputs_json) + except Exception: + with open(input_path, "rb+") as f: + f.truncate(previous_input_size) + raise + def __run_in_serial(self): """ Runs the monte carlo simulation in serial mode. @@ -289,10 +322,7 @@ def __run_in_serial(self): 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) + self._append_simulation_record(inputs_json, outputs_json) sim_monitor.print_update_status() @@ -431,10 +461,7 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa 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) + self._append_simulation_record(inputs_json, outputs_json) sim_monitor.print_update_status() finally: diff --git a/tests/unit/simulation/test_monte_carlo.py b/tests/unit/simulation/test_monte_carlo.py index 7e2e68804..bed874cb0 100644 --- a/tests/unit/simulation/test_monte_carlo.py +++ b/tests/unit/simulation/test_monte_carlo.py @@ -1,7 +1,10 @@ +import builtins import csv import json +import os import pathlib from collections import namedtuple +from unittest.mock import patch import matplotlib as plt import numpy as np @@ -86,6 +89,37 @@ def __init__(self): } +def test_append_simulation_record_rolls_back_inputs_on_output_failure(tmp_path): + """If the outputs append fails, the inputs row must not remain on disk.""" + mc = MockMonteCarlo() + input_file = tmp_path / "inputs.json" + output_file = tmp_path / "outputs.json" + input_file.write_text('{"index": 0}\n', encoding="utf-8") + output_file.write_text('{"index": 0}\n', encoding="utf-8") + mc._input_file = str(input_file) + mc._output_file = str(output_file) + + mc._append_simulation_record('{"index": 1}\n', '{"index": 1}\n') + + original_open = builtins.open + output_path = os.fspath(output_file) + + def failing_output_open(*args, **kwargs): + # Match builtins.open call shapes without keyword-before-vararg (W1113). + file = args[0] if args else kwargs["file"] + mode = args[1] if len(args) > 1 else kwargs.get("mode", "r") + if os.fspath(file) == output_path and "a" in mode: + raise OSError("no space left on device") + return original_open(*args, **kwargs) + + with pytest.raises(OSError, match="no space left on device"): + with patch("builtins.open", side_effect=failing_output_open): + mc._append_simulation_record('{"index": 2}\n', '{"index": 2}\n') + + assert input_file.read_text(encoding="utf-8") == '{"index": 0}\n{"index": 1}\n' + assert output_file.read_text(encoding="utf-8") == '{"index": 0}\n{"index": 1}\n' + + def test_estimate_confidence_interval_contains_known_mean(): """Checks that the confidence interval contains the known mean.""" mc = MockMonteCarlo()