Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Attention: The newest changes should be on top -->

### Fixed

- BUG: Report a Monte Carlo worker that fails instead of hanging or passing for a finished run [#1182](https://github.com/RocketPy-Team/RocketPy/pull/1182)
- BUG: Sample `StochasticFlight` inputs once per simulation [#1126](https://github.com/RocketPy-Team/RocketPy/pull/1126) [#1090](https://github.com/RocketPy-Team/RocketPy/issues/1090)
- BUG: Fix spurious `ValueError` from floating-point roundoff at exact tank depletion [#1166](https://github.com/RocketPy-Team/RocketPy/pull/1166)
- BUG: Draw each declared eccentricity once per simulation [#1168](https://github.com/RocketPy-Team/RocketPy/pull/1168)
Expand Down
223 changes: 207 additions & 16 deletions rocketpy/simulation/monte_carlo.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
import os
import traceback
import warnings
from contextlib import suppress
from numbers import Real
from pathlib import Path
from time import time
from time import monotonic, time

import numpy as np
import simplekml
Expand All @@ -43,6 +44,12 @@
# this is the only format it can both resume from and overwrite safely.
_SIMULATION_LOG_SUFFIX = ".txt"

# Which simulation a row belongs to. Every check on a finished run reads it.
_SIMULATION_INDEX_KEY = "index"

# How a manager that has gone away answers a proxy call.
_MANAGER_IS_GONE = (OSError, EOFError)


def _refuse_logs_this_run_cannot_write(
input_file, output_file, error_file, export_config=None
Expand Down Expand Up @@ -300,6 +307,14 @@ def simulate(
-------
None

Raises
------
RuntimeError
If a parallel run does not finish. A worker that ends badly, one
that reports a failure, and logs that do not hold every simulation
asked for are each refused, since a run that lost work must not be
reported as one that completed.

Notes
-----
If you need to stop the simulations after starting them, you can
Expand Down Expand Up @@ -485,8 +500,10 @@ def __run_in_parallel(self, n_workers=None):
sim_producer.start()

try:
for sim_producer in processes:
sim_producer.join()
_join_the_workers(processes, simulation_error_event)

# Before the event: a killed worker never sets it.
_refuse_a_worker_that_did_not_finish(processes)

# Handle error from the child processes
if simulation_error_event.is_set():
Expand All @@ -496,15 +513,21 @@ def __run_in_parallel(self, n_workers=None):
"for more information."
)

# An exit code cannot show a worker that left between
# claiming an index and recording it.
_refuse_logs_missing_a_simulation(
self.input_file, self.output_file, self.number_of_simulations
)

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()
# Bounded here too. An unbounded join undid the bound above.
_stop_the_workers_still_running(
processes, simulation_error_event, _SHUTDOWN_GRACE_SECONDS
)

if not isinstance(error, KeyboardInterrupt):
raise error
Expand All @@ -531,6 +554,8 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
error_event : multiprocess.Event
Event signaling an error occurred during the simulation.
"""
# The handler reads both, and a failure above the loop precedes them.
sim_idx, inputs_json = None, ""
Comment thread
thc1006 marked this conversation as resolved.
try:
# Ensure Processes generate different random numbers
self.environment._set_stochastic(seed)
Expand Down Expand Up @@ -567,18 +592,44 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
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)
# Nothing is in flight between two simulations, nor are these.
sim_idx, inputs_json = None, ""

# See note above: must use print() to remain visible from a
# multiprocessing worker process.
_SimMonitor.reprint(
f"Error on iteration {sim_idx}:\n{traceback.format_exc()}"
)
except Exception: # pylint: disable=broad-except
if not self.__report_a_failed_simulation(
sim_idx, inputs_json, mutex, error_event
):
# The event could not be set; the exit code is what is left.
raise

def __report_a_failed_simulation(self, sim_idx, inputs_json, mutex, error_event):
"""Write down and announce a simulation this worker could not finish.

The event goes first and from outside the lock, since a worker that
cannot write its diagnostics still has to be able to stop the others.
Each step under the lock is suppressed on its own: a full disk would
otherwise replace the failure being reported, and the lock is a
manager's, so ending while holding it leaves the next worker waiting
on a process that no longer exists.
"""
details = traceback.format_exc()
where = "worker startup" if sim_idx is None else f"iteration {sim_idx}"
announced = False
with suppress(_MANAGER_IS_GONE):
error_event.set()
announced = True

mutex.acquire()
try:
with suppress(OSError):
with open(self.error_file, "a", encoding="utf-8") as f:
f.write(inputs_json or _worker_failure_record(where, details))
with suppress(OSError, ValueError):
# Must use print() to remain visible from a worker process.
_SimMonitor.reprint(f"Error on {where}:\n{details}")
finally:
mutex.release()
return announced

def __run_single_simulation(self):
"""Runs a single simulation and returns the inputs and outputs.
Expand Down Expand Up @@ -983,6 +1034,13 @@ def _check_data_collector(self, data_collector):
"Invalid 'data_collector' key! "
f"Variable names overwrites 'export_list' key '{key}'."
)
if key == _SIMULATION_INDEX_KEY:
raise ValueError(
f"Invalid 'data_collector' key '{key}'! It is the "
f"number of the simulation the row belongs to, which "
f"is written after the collectors run and cannot be "
f"replaced by one."
)
if not callable(callback):
raise ValueError(
f"Invalid value in 'data_collector' for key '{key}'! "
Expand Down Expand Up @@ -1755,6 +1813,139 @@ def export_errors_to_json(self, filename):
self._write_log_to_json(self.errors_log, filename)


# Prompt enough to notice a dead worker, cheap enough over a run of hours.
_JOIN_POLL_SECONDS = 0.2
_SHUTDOWN_GRACE_SECONDS = 5.0


def _ended_badly(worker):
"""Whether a worker has stopped, and stopped for the wrong reason."""
return worker.exitcode not in (None, 0)


def _wait_for_the_workers(processes, seconds):
"""Join every worker against one shared deadline, not one each.

Monotonic, since a clock correction would move a wall-clock deadline.
"""
deadline = monotonic() + seconds
for worker in processes:
worker.join(timeout=max(0.0, deadline - monotonic()))


def _stop_the_workers_still_running(processes, error_event, grace_period):
"""Ask the rest to stop, end what cannot, kill what outlives that.

Asked first because a worker between simulations reads the event and leaves
with its logs intact. One blocked on a lock its dead sibling was holding
never reaches that check. Terminate runs no handlers, so it comes second,
and a worker can still ignore it.
"""
with suppress(_MANAGER_IS_GONE):
error_event.set()
_wait_for_the_workers(processes, grace_period)

for worker in processes:
if worker.is_alive():
worker.terminate()
_wait_for_the_workers(processes, grace_period)

for worker in processes:
if worker.is_alive():
worker.kill()
_wait_for_the_workers(processes, grace_period)


def _join_the_workers(processes, error_event, grace_period=_SHUTDOWN_GRACE_SECONDS):
"""Wait for the workers, and stop once one of them has died badly.

The shared lock belongs to the manager and outlives a killed holder, so a
sibling can block on a lock nobody owns. Neither slowness nor a reported
failure ends the wait: a worker that reports leaves nothing behind, and
its siblings stop once they finish the simulation in hand.
"""
while any(worker.is_alive() for worker in processes):
for worker in processes:
worker.join(timeout=_JOIN_POLL_SECONDS)
if any(_ended_badly(worker) for worker in processes):
_stop_the_workers_still_running(processes, error_event, grace_period)
return


def _worker_failure_record(where, details):
"""A row for a worker that failed before it drew anything."""
return json.dumps({"index": None, "stage": where, "error": details}) + "\n"


def _indices_a_log_holds(path):
"""Every index a log records, in order, and ``None`` for a row it cannot."""
found = []
with open(path, "r", encoding="utf-8") as recorded:
for line in recorded:
if not line.strip():
continue
try:
found.append(json.loads(line)["index"])
except (ValueError, KeyError, TypeError):
found.append(None)
return found


def _refuse_logs_missing_a_simulation(input_file, output_file, target):
"""Raise unless both logs hold every simulation the run was asked for.

An exit code says how a worker ended, never whether the index it had
already claimed reached the logs, and the monitor counts claims rather than
rows. A worker that leaves between the two is invisible to everything else
here, so the logs themselves are what the run is judged on.

Rows numbered past the target are left alone: an append given a smaller
target than the checkpoint already holds is an append question, not a lost
simulation. Streamed rather than read through ``_read_log_file``, which
would hold every row of a long study in memory to look at one field.
"""
wanted = set(range(target))
for label, path in (("input", input_file), ("output", output_file)):
found = _indices_a_log_holds(path)
held = set(found)
if None in held:
raise RuntimeError(
f"The run is incomplete: the {label} log has rows that cannot "
f"be read, so what it holds cannot be established."
)
if len(found) != len(held):
raise RuntimeError(
f"The run is incomplete: the {label} log records "
f"{len(found) - len(held)} simulation(s) more than once."
)
missing = sorted(wanted - held)
if missing:
raise RuntimeError(
f"The run is incomplete: the {label} log is missing "
f"{len(missing)} of {target} simulations, the first being "
f"{missing[0]}."
)


def _refuse_a_worker_that_did_not_finish(processes):
"""Raise if any worker left without exiting cleanly.

A negative code is the signal that ended it, ``None`` one still running.
"""
unfinished = [
f"worker {position} with exit code {process.exitcode}"
for position, process in enumerate(processes)
if process.exitcode != 0
]
if not unfinished:
return
raise RuntimeError(
f"The run is incomplete: {', '.join(unfinished)}. A worker that ends "
"this way records nothing and cannot say why, so the simulations it "
"held are missing from the results."
)


def _import_multiprocess():
"""Import the necessary modules and submodules for the
multiprocess library.
Expand Down
20 changes: 16 additions & 4 deletions rocketpy/stochastic/stochastic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from rocketpy.mathutils.function import Function
from rocketpy.stochastic.custom_sampler import CustomSampler

from ..tools import get_distribution
from ..tools import _seed_sequence_to_int, get_distribution


def _names_as_spawn_key(input_names):
Expand Down Expand Up @@ -41,6 +41,18 @@ def _format_number(value):
return f"array of shape {np.shape(value)}"


def _seed_as_entropy(seed):
"""A seed as something ``SeedSequence`` will take as entropy.

A parallel run is handed a ``SeedSequence``, which it will not take. Any
other seed goes through untouched, so the stream an int reaches stays where
it was.
"""
if not isinstance(seed, np.random.SeedSequence):
return seed
return _seed_sequence_to_int(seed)


def _sampler_seed(seed, input_names):
"""Derive a seed for one sampler, or for one group that shares a generator.

Expand All @@ -54,10 +66,10 @@ def _sampler_seed(seed, input_names):
# Sorted here rather than trusting the caller, so a future call site cannot
# give one group two different seeds by listing its members another way.
root = np.random.SeedSequence(
entropy=seed, spawn_key=_names_as_spawn_key(tuple(sorted(input_names)))
entropy=_seed_as_entropy(seed),
spawn_key=_names_as_spawn_key(tuple(sorted(input_names))),
)
words = root.generate_state(4, dtype=np.uint32)
return sum(int(word) << (32 * position) for position, word in enumerate(words))
return _seed_sequence_to_int(root)


# TODO: Stop using assert in production code. Use exceptions instead.
Expand Down
11 changes: 11 additions & 0 deletions rocketpy/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1377,6 +1377,17 @@ def euler313_to_quaternions(phi, theta, psi):
return e0, e1, e2, e3


def _seed_sequence_to_int(seed_sequence):
"""Returns a ``SeedSequence`` as the 128-bit ``int`` it can be rebuilt from.

Folded through ``generate_state`` rather than read off ``entropy``, since
the children of one root differ only by ``spawn_key``, and combined by
value so it does not depend on byte order.
"""
words = seed_sequence.generate_state(4, dtype=np.uint32)
return sum(int(word) << (32 * position) for position, word in enumerate(words))


def get_matplotlib_supported_file_endings():
"""Gets the file endings supported by matplotlib.

Expand Down
33 changes: 33 additions & 0 deletions tests/unit/simulation/test_monte_carlo_parallel_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import pytest

from rocketpy.simulation.monte_carlo import MonteCarlo


@pytest.mark.parametrize("parallel", [False, True])
def test_a_monte_carlo_run_finishes(
stochastic_environment, stochastic_calisto, stochastic_flight, tmp_path, parallel
):
"""A real run completes and records every simulation, both modes."""
# The parallel path hands each worker a SeedSequence rather than an int, and
# nothing else in the suite exercises that. A worker that dies on it is not
# reported, so this reads as a hang rather than as a failure.
#
# Built here rather than taken from the monte_carlo_calisto fixture, whose
# own filename is fixed, since `filename` is a plain attribute and the three
# working paths are settled when the object is constructed.
analysis = MonteCarlo(
filename=str(tmp_path / "study"),
environment=stochastic_environment,
rocket=stochastic_calisto,
flight=stochastic_flight,
)

analysis.simulate(
number_of_simulations=2,
append=False,
parallel=parallel,
n_workers=2 if parallel else None,
)

assert analysis.num_of_loaded_sims == 2
assert str(tmp_path) in str(analysis.output_file)
Loading
Loading