|
| 1 | +"""End-to-end determinism tests for ``MonteCarlo.simulate(random_seed=...)``. |
| 2 | +
|
| 3 | +With a fixed ``random_seed`` the generated random *inputs* are reproducible and |
| 4 | +identical across serial and parallel execution and across any number of workers. |
| 5 | +Each simulation index draws from its own child stream spawned from the run's root |
| 6 | +seed, and ``SeedSequence.spawn`` is prefix-stable, so index ``i`` maps to the same |
| 7 | +seed regardless of the worker that runs it. (The seed-handling helpers themselves |
| 8 | +are unit tested in ``tests/unit/simulation/test_monte_carlo_determinism``.) |
| 9 | +
|
| 10 | +The trajectory integration (``Flight``) is stubbed: worker invariance is a |
| 11 | +property of the *input sampling*, which happens before ``Flight`` is built, so a |
| 12 | +stub keeps the runs fast while still driving the real serial and parallel loops. |
| 13 | +Stubbing the module-level ``Flight`` symbol reaches the parallel workers only |
| 14 | +under the ``fork`` start method, so the worker-invariance test skips otherwise and |
| 15 | +is marked ``slow`` to match the other Monte Carlo multiprocessing tests. |
| 16 | +
|
| 17 | +A dedicated numpy-only rocket is used so *all* randomness flows through the seeded |
| 18 | +numpy generator. List-valued stochastic attributes are sampled with the standard |
| 19 | +library ``random.choice`` (an unseeded global generator) which ``random_seed`` |
| 20 | +does not govern; the fixture drops the only such attribute (a multi-element |
| 21 | +``thrust_source``) so the inputs are byte-for-byte reproducible from the seed. |
| 22 | +""" |
| 23 | + |
| 24 | +import json |
| 25 | + |
| 26 | +import pytest |
| 27 | + |
| 28 | +import rocketpy.simulation.monte_carlo as mc_module |
| 29 | +from rocketpy.simulation import MonteCarlo |
| 30 | +from rocketpy.stochastic import StochasticRocket, StochasticSolidMotor |
| 31 | + |
| 32 | + |
| 33 | +class _StubFlight: |
| 34 | + """Minimal stand-in for ``Flight`` that skips trajectory integration.""" |
| 35 | + |
| 36 | + def __init__(self, **kwargs): # accepts and ignores MonteCarlo's Flight kwargs |
| 37 | + pass |
| 38 | + |
| 39 | + def __getattr__(self, name): |
| 40 | + return 0.0 |
| 41 | + |
| 42 | + |
| 43 | +@pytest.fixture |
| 44 | +def stochastic_calisto_numpy_only( |
| 45 | + cesaroni_m1670, |
| 46 | + calisto_robust, |
| 47 | + stochastic_nose_cone, |
| 48 | + stochastic_trapezoidal_fins, |
| 49 | + stochastic_tail, |
| 50 | + stochastic_rail_buttons, |
| 51 | + stochastic_main_parachute, |
| 52 | + stochastic_drogue_parachute, |
| 53 | +): |
| 54 | + """A ``StochasticRocket`` whose randomness flows entirely through numpy. |
| 55 | +
|
| 56 | + Mirrors the shared ``stochastic_calisto`` fixture but gives the solid motor a |
| 57 | + single ``thrust_source`` instead of a multi-element list, so no attribute is |
| 58 | + sampled through the unseeded standard-library ``random.choice``. |
| 59 | + """ |
| 60 | + motor = StochasticSolidMotor( |
| 61 | + solid_motor=cesaroni_m1670, |
| 62 | + burn_out_time=(4, 0.1), |
| 63 | + grains_center_of_mass_position=0.001, |
| 64 | + grain_density=50, |
| 65 | + grain_separation=1 / 1000, |
| 66 | + grain_initial_height=1 / 1000, |
| 67 | + grain_initial_inner_radius=0.375 / 1000, |
| 68 | + grain_outer_radius=0.375 / 1000, |
| 69 | + total_impulse=(6500, 1000), |
| 70 | + throat_radius=0.5 / 1000, |
| 71 | + nozzle_radius=0.5 / 1000, |
| 72 | + nozzle_position=0.001, |
| 73 | + ) |
| 74 | + rocket = StochasticRocket( |
| 75 | + rocket=calisto_robust, |
| 76 | + radius=0.0127 / 2000, |
| 77 | + mass=(15.426, 0.5, "normal"), |
| 78 | + inertia_11=(6.321, 0), |
| 79 | + inertia_22=0.01, |
| 80 | + inertia_33=0.01, |
| 81 | + center_of_mass_without_motor=0, |
| 82 | + ) |
| 83 | + rocket.add_motor(motor, position=0.001) |
| 84 | + rocket.add_nose(stochastic_nose_cone, position=(1.134, 0.001)) |
| 85 | + rocket.add_trapezoidal_fins(stochastic_trapezoidal_fins, position=(0.001, "normal")) |
| 86 | + rocket.add_tail(stochastic_tail) |
| 87 | + rocket.set_rail_buttons( |
| 88 | + stochastic_rail_buttons, lower_button_position=(-0.618, 0.001, "normal") |
| 89 | + ) |
| 90 | + rocket.add_parachute(parachute=stochastic_main_parachute) |
| 91 | + rocket.add_parachute(parachute=stochastic_drogue_parachute) |
| 92 | + return rocket |
| 93 | + |
| 94 | + |
| 95 | +def _read_inputs_by_index(input_file): |
| 96 | + """Read a ``.inputs.txt`` file into ``{index: raw_json_line}``.""" |
| 97 | + by_index = {} |
| 98 | + with open(input_file, mode="r", encoding="utf-8") as rows: |
| 99 | + for line in rows: |
| 100 | + line = line.strip() |
| 101 | + if not line: |
| 102 | + continue |
| 103 | + by_index[json.loads(line)["index"]] = line |
| 104 | + return by_index |
| 105 | + |
| 106 | + |
| 107 | +def _simulate_inputs( |
| 108 | + monkeypatch, tmp_path, environment, rocket, flight, tag, **simulate_kwargs |
| 109 | +): |
| 110 | + """Run a Monte Carlo with a stubbed ``Flight`` and return inputs by index.""" |
| 111 | + monkeypatch.setattr(mc_module, "Flight", _StubFlight) |
| 112 | + montecarlo = MonteCarlo( |
| 113 | + filename=str(tmp_path / tag), |
| 114 | + environment=environment, |
| 115 | + rocket=rocket, |
| 116 | + flight=flight, |
| 117 | + ) |
| 118 | + montecarlo.simulate(**simulate_kwargs) |
| 119 | + return _read_inputs_by_index(montecarlo.input_file) |
| 120 | + |
| 121 | + |
| 122 | +def test_serial_inputs_are_reproducible( |
| 123 | + monkeypatch, |
| 124 | + tmp_path, |
| 125 | + stochastic_environment, |
| 126 | + stochastic_calisto_numpy_only, |
| 127 | + stochastic_flight, |
| 128 | +): |
| 129 | + """Two serial runs with the same seed yield byte-identical inputs per index. |
| 130 | +
|
| 131 | + This drives the serial ``simulate`` path end to end; the flexible seed types |
| 132 | + are covered by the unit test of ``__root_seed_sequence``. |
| 133 | + """ |
| 134 | + models = (stochastic_environment, stochastic_calisto_numpy_only, stochastic_flight) |
| 135 | + run_a = _simulate_inputs( |
| 136 | + monkeypatch, tmp_path, *models, "a", number_of_simulations=3, random_seed=7 |
| 137 | + ) |
| 138 | + run_b = _simulate_inputs( |
| 139 | + monkeypatch, tmp_path, *models, "b", number_of_simulations=3, random_seed=7 |
| 140 | + ) |
| 141 | + assert sorted(run_a) == list(range(3)) |
| 142 | + assert run_a == run_b |
| 143 | + |
| 144 | + |
| 145 | +@pytest.mark.slow |
| 146 | +def test_inputs_are_worker_invariant( |
| 147 | + monkeypatch, |
| 148 | + tmp_path, |
| 149 | + stochastic_environment, |
| 150 | + stochastic_calisto_numpy_only, |
| 151 | + stochastic_flight, |
| 152 | +): |
| 153 | + """serial == parallel(2) == parallel(4): inputs are bit-identical per index.""" |
| 154 | + multiprocess = pytest.importorskip("multiprocess") |
| 155 | + if multiprocess.get_start_method() != "fork": |
| 156 | + pytest.skip( |
| 157 | + "stub-based parallel determinism test requires the 'fork' start method" |
| 158 | + ) |
| 159 | + |
| 160 | + models = (stochastic_environment, stochastic_calisto_numpy_only, stochastic_flight) |
| 161 | + common = {"number_of_simulations": 8, "random_seed": 314159} |
| 162 | + |
| 163 | + serial = _simulate_inputs(monkeypatch, tmp_path, *models, "serial", **common) |
| 164 | + par2 = _simulate_inputs( |
| 165 | + monkeypatch, tmp_path, *models, "par2", parallel=True, n_workers=2, **common |
| 166 | + ) |
| 167 | + par4 = _simulate_inputs( |
| 168 | + monkeypatch, tmp_path, *models, "par4", parallel=True, n_workers=4, **common |
| 169 | + ) |
| 170 | + |
| 171 | + expected = list(range(8)) |
| 172 | + assert sorted(serial) == expected |
| 173 | + assert sorted(par2) == expected |
| 174 | + assert sorted(par4) == expected |
| 175 | + for index in expected: |
| 176 | + assert serial[index] == par2[index], f"serial vs parallel(2) differ at {index}" |
| 177 | + assert serial[index] == par4[index], f"serial vs parallel(4) differ at {index}" |
0 commit comments