Skip to content

Commit c7078c7

Browse files
TST: carry #1177's interrupt tests onto the reseeded serial loop
The rebase onto develop was clean in three files and left one add/add conflict in __run_in_serial: both sides bind a name before the try so the handlers cannot meet it unbound. #1177 binds inputs_json for the KeyboardInterrupt handler, this branch binds sim_idx for the Exception one. Both are kept; dropping either puts back the UnboundLocalError the other side had just removed. Keeping both then pushed the function to 27 statements, two over max-statements, so the error-file append the two handlers had copies of moved into __record_failed_inputs. The guard travels with it: the error file is created in __setup_files, so appending "" to it was already a no-op, and the unconditional copy and the guarded one did the same thing. What the merge could not show is that #1177's tests were written against the loop this branch replaces: - _InterruptingMonteCarlo stubs only what __run_in_serial touched, and the loop now also calls __seed_this_simulation, which reads models the double has none of. Stubbed, like the other name-mangled members; seeding is pinned by test_monte_carlo_seeding.py. - Its rows carried no run_root, so the append test tripped the guard for studies written before that check existed instead of exercising the continuation. The double now writes the root the way the real row builders do, the inputs file whole and the outputs file by digest. - _SimMonitor.keep_simulating is now claim_next_index, and one test monkeypatches it by name. - Serial numbering now starts at zero, as the parallel path always did, so the three tests reading indices off disk expected 1, 2, 3 where a run writes 0, 1, 2. tests/unit/simulation: 322 passed, 8 skipped. ruff clean, pylint 10.00. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a5a47c4 commit c7078c7

2 files changed

Lines changed: 65 additions & 15 deletions

File tree

‎rocketpy/simulation/monte_carlo.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,28 @@ def _append_simulation_record(self, inputs_json, outputs_json):
656656
f.truncate(previous_output_size)
657657
raise
658658

659+
def __record_failed_inputs(self, inputs_json):
660+
"""
661+
Appends an unpaired inputs row to the error file, if there is one.
662+
663+
A row reaches here when the simulation it belongs to was cut short
664+
between the inputs being evaluated and the record being appended, so
665+
it has no matching outputs row and cannot go to the inputs file.
666+
667+
Parameters
668+
----------
669+
inputs_json : str
670+
Serialized inputs row, or an empty string when the run was cut
671+
short with no row in hand.
672+
673+
Returns
674+
-------
675+
None
676+
"""
677+
if inputs_json:
678+
with open(self._error_file, "a", encoding="utf-8") as f:
679+
f.write(inputs_json)
680+
659681
def __run_in_serial(self):
660682
"""
661683
Runs the monte carlo simulation in serial mode.
@@ -692,15 +714,12 @@ def __run_in_serial(self):
692714

693715
except KeyboardInterrupt:
694716
print("Keyboard interrupt received. Files saved.")
695-
if inputs_json:
696-
with open(self._error_file, "a", encoding="utf-8") as f:
697-
f.write(inputs_json)
717+
self.__record_failed_inputs(inputs_json)
698718
raise
699719

700720
except Exception as error:
701721
print(f"Error on iteration {sim_idx}: {error}")
702-
with open(self._error_file, "a", encoding="utf-8") as f:
703-
f.write(inputs_json)
722+
self.__record_failed_inputs(inputs_json)
704723
raise error
705724

706725
def __run_in_parallel(self, n_workers=None):

‎tests/unit/simulation/test_monte_carlo.py‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,9 @@ class _InterruptingMonteCarlo(MonteCarlo):
797797
798798
Only the attributes ``simulate`` and ``__run_in_serial`` touch are set, so no
799799
stochastic object graph or real flight is needed. The name-mangled overrides
800-
stand in for the members ``MonteCarlo`` calls on itself.
800+
stand in for the members ``MonteCarlo`` calls on itself, seeding included:
801+
there are no models here to reseed, and what a seeded run draws is pinned
802+
by ``tests/unit/simulation/test_monte_carlo_seeding.py``.
801803
"""
802804

803805
# pylint: disable=super-init-not-called,invalid-name,unused-argument
@@ -814,17 +816,46 @@ def __init__(self, filename, interrupt_after):
814816
self.interrupt_after = interrupt_after
815817
self.completed = 0
816818

819+
def _MonteCarlo__seed_this_simulation(self, sim_idx):
820+
pass
821+
817822
def _MonteCarlo__run_single_simulation(self):
818823
if self.completed >= self.interrupt_after:
819824
raise KeyboardInterrupt("ctrl-c")
820825
self.completed += 1
821826
return object()
822827

823828
def _MonteCarlo__evaluate_flight_inputs(self, index):
824-
return json.dumps({"index": index}) + "\n"
829+
# The root goes in because an append reads it back to check the rows
830+
# it is continuing were drawn by the same stream; a row without it is
831+
# what a study written before that check looks like, and is refused.
832+
return (
833+
json.dumps(
834+
{
835+
"index": index,
836+
mc_module._SIMULATION_ROOT_KEY: (
837+
mc_module._root_written_into_a_row(self._MonteCarlo__root_state)
838+
),
839+
}
840+
)
841+
+ "\n"
842+
)
825843

826844
def _MonteCarlo__evaluate_flight_outputs(self, flight, index):
827-
return json.dumps({"index": index, "apogee": 1000.0 + index}) + "\n"
845+
# The outputs rows name the same root by its digest, which is what
846+
# an append checks them against.
847+
return (
848+
json.dumps(
849+
{
850+
"index": index,
851+
"apogee": 1000.0 + index,
852+
mc_module._SIMULATION_ROOT_KEY: mc_module._root_digest(
853+
mc_module._root_written_into_a_row(self._MonteCarlo__root_state)
854+
),
855+
}
856+
)
857+
+ "\n"
858+
)
828859

829860

830861
def test_interrupted_serial_run_reaches_the_caller(tmp_path):
@@ -871,7 +902,7 @@ def test_interrupted_serial_run_still_reloads_the_logs(tmp_path):
871902
assert mc.num_of_loaded_sims == 2
872903
assert len(mc.inputs_log) == 2
873904
assert len(mc.outputs_log) == 2
874-
assert mc.results["apogee"] == pytest.approx([1001.0, 1002.0])
905+
assert mc.results["apogee"] == pytest.approx([1000.0, 1001.0])
875906

876907

877908
def test_an_interrupted_run_can_be_continued_with_append(tmp_path):
@@ -892,23 +923,23 @@ def test_an_interrupted_run_can_be_continued_with_append(tmp_path):
892923
mc.simulate(number_of_simulations=10, append=True, parallel=False)
893924

894925
rows = pathlib.Path(stem + ".outputs.txt").read_text(encoding="utf-8").splitlines()
895-
assert [json.loads(row)["index"] for row in rows] == list(range(1, 11))
926+
assert [json.loads(row)["index"] for row in rows] == list(range(10))
896927

897928

898929
def test_ctrl_c_before_the_first_simulation_is_still_the_interrupt(
899930
tmp_path, monkeypatch
900931
):
901932
"""The handler appends ``inputs_json``, which used to be unbound this early.
902933
903-
Ctrl-C during the first ``keep_simulating()`` call reached the handler
934+
Ctrl-C during the first ``claim_next_index()`` call reached the handler
904935
before the loop body had bound the name, so the run died with
905936
``UnboundLocalError`` from inside the cleanup instead of with the interrupt.
906937
"""
907938

908939
def interrupt(self):
909940
raise KeyboardInterrupt("ctrl-c before the first simulation")
910941

911-
monkeypatch.setattr(mc_module._SimMonitor, "keep_simulating", interrupt)
942+
monkeypatch.setattr(mc_module._SimMonitor, "claim_next_index", interrupt)
912943
mc = _InterruptingMonteCarlo(str(tmp_path / "run"), interrupt_after=0)
913944

914945
with pytest.raises(KeyboardInterrupt):
@@ -1131,7 +1162,7 @@ def interrupt_third_outputs_append(*args, **kwargs):
11311162
]
11321163
# The simulation that was cut short is recorded where errors go, so the
11331164
# rolled-back row is preserved rather than lost.
1134-
assert [json.loads(row)["index"] for row in errors] == [3]
1165+
assert [json.loads(row)["index"] for row in errors] == [2]
11351166
assert mc.num_of_loaded_sims == 2
11361167

11371168

@@ -1141,7 +1172,7 @@ def test_ctrl_c_in_the_progress_print_leaves_the_error_file_empty(tmp_path):
11411172
After ``_append_simulation_record`` returns, the pair is on disk. The
11421173
handler used to write ``inputs_json`` to the error file anyway when the
11431174
interrupt landed in ``print_update_status()`` — or in the next
1144-
``keep_simulating()`` call — because the name still held the committed row.
1175+
``claim_next_index()`` call — because the name still held the committed row.
11451176
"""
11461177
stem = str(tmp_path / "run")
11471178
mc = _InterruptingMonteCarlo(stem, interrupt_after=10)
@@ -1226,5 +1257,5 @@ def torn_third_outputs_append(*args, **kwargs):
12261257
assert len(outputs) == 2, "the torn outputs row was not rolled back"
12271258
for row in inputs + outputs:
12281259
json.loads(row) # every surviving row must still parse
1229-
assert [json.loads(row)["index"] for row in errors] == [3]
1260+
assert [json.loads(row)["index"] for row in errors] == [2]
12301261
assert mc.num_of_loaded_sims == 2

0 commit comments

Comments
 (0)