nost-tools 2.4.0__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nost-tools might be problematic. Click here for more details.

Files changed (26) hide show
  1. {nost_tools-2.4.0 → nost_tools-3.0.0}/PKG-INFO +1 -1
  2. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/__init__.py +2 -2
  3. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/application.py +6 -6
  4. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/configuration.py +0 -2
  5. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/managed_application.py +180 -6
  6. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/manager.py +301 -87
  7. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/observer.py +10 -0
  8. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/publisher.py +11 -0
  9. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/schemas.py +159 -31
  10. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/simulator.py +52 -21
  11. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools.egg-info/PKG-INFO +1 -1
  12. {nost_tools-2.4.0 → nost_tools-3.0.0}/LICENSE +0 -0
  13. {nost_tools-2.4.0 → nost_tools-3.0.0}/README.md +0 -0
  14. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/application_utils.py +0 -0
  15. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/entity.py +0 -0
  16. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/errors.py +0 -0
  17. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools/logger_application.py +0 -0
  18. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools.egg-info/SOURCES.txt +0 -0
  19. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools.egg-info/dependency_links.txt +0 -0
  20. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools.egg-info/requires.txt +0 -0
  21. {nost_tools-2.4.0 → nost_tools-3.0.0}/nost_tools.egg-info/top_level.txt +0 -0
  22. {nost_tools-2.4.0 → nost_tools-3.0.0}/pyproject.toml +0 -0
  23. {nost_tools-2.4.0 → nost_tools-3.0.0}/setup.cfg +0 -0
  24. {nost_tools-2.4.0 → nost_tools-3.0.0}/tests/test_entity.py +0 -0
  25. {nost_tools-2.4.0 → nost_tools-3.0.0}/tests/test_observer.py +0 -0
  26. {nost_tools-2.4.0 → nost_tools-3.0.0}/tests/test_simulator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nost_tools
3
- Version: 2.4.0
3
+ Version: 3.0.0
4
4
  Summary: Tools for Novel Observing Strategies Testbed (NOS-T) Applications
5
5
  Author-email: "Paul T. Grogan" <paul.grogan@asu.edu>, "Emmanuel M. Gonzalez" <emmanuelgonzalez@asu.edu>
6
6
  License-Expression: BSD-3-Clause
@@ -1,4 +1,4 @@
1
- __version__ = "2.4.0"
1
+ __version__ = "3.0.0"
2
2
 
3
3
  from .application import Application
4
4
  from .application_utils import ConnectionConfig, ModeStatusObserver, TimeStatusPublisher
@@ -6,7 +6,7 @@ from .configuration import ConnectionConfig
6
6
  from .entity import Entity
7
7
  from .logger_application import LoggerApplication
8
8
  from .managed_application import ManagedApplication
9
- from .manager import Manager, TimeScaleUpdate
9
+ from .manager import Manager
10
10
  from .observer import Observable, Observer
11
11
  from .publisher import ScenarioTimeIntervalPublisher, WallclockTimeIntervalPublisher
12
12
  from .schemas import (
@@ -217,21 +217,21 @@ class Application:
217
217
 
218
218
  def refresh_wallclock_periodically():
219
219
  while not self._should_stop.wait(
220
- timeout=self.config.rc.wallclock_offset_properties.wallclock_offset_refresh_interval
220
+ timeout=self.config.rc.simulation_configuration.execution_parameters.general.wallclock_offset_refresh_interval
221
221
  ):
222
222
  logger.debug("Wallclock refresh thread is running.")
223
223
  try:
224
- logger.info(
225
- f"Contacting {self.config.rc.wallclock_offset_properties.ntp_host} to retrieve wallclock offset."
224
+ logger.debug(
225
+ f"Contacting {self.config.rc.simulation_configuration.execution_parameters.general.ntp_host} to retrieve wallclock offset."
226
226
  )
227
227
  response = ntplib.NTPClient().request(
228
- self.config.rc.wallclock_offset_properties.ntp_host,
228
+ self.config.rc.simulation_configuration.execution_parameters.general.ntp_host,
229
229
  version=3,
230
230
  timeout=2,
231
231
  )
232
232
  offset = timedelta(seconds=response.offset)
233
233
  self.simulator.set_wallclock_offset(offset)
234
- logger.info(f"Wallclock offset updated to {offset}.")
234
+ logger.debug(f"Wallclock offset updated to {offset}.")
235
235
  except Exception as e:
236
236
  logger.debug(f"Failed to refresh wallclock offset: {e}")
237
237
 
@@ -338,7 +338,7 @@ class Application:
338
338
  if self.set_offset:
339
339
  # Start periodic wallclock offset updates instead of one-time call
340
340
  logger.info(
341
- f"Wallclock offset will be set every {self.config.rc.wallclock_offset_properties.wallclock_offset_refresh_interval} seconds using {self.config.rc.wallclock_offset_properties.ntp_host}."
341
+ f"Wallclock offset will be set every {self.config.rc.simulation_configuration.execution_parameters.general.wallclock_offset_refresh_interval} seconds using {self.config.rc.simulation_configuration.execution_parameters.general.ntp_host}."
342
342
  )
343
343
  self.start_wallclock_refresh_thread()
344
344
 
@@ -21,7 +21,6 @@ from .schemas import (
21
21
  RuntimeConfig,
22
22
  ServersConfig,
23
23
  SimulationConfig,
24
- WallclockOffsetProperties,
25
24
  )
26
25
 
27
26
  logger = logging.getLogger(__name__)
@@ -329,7 +328,6 @@ class ConnectionConfig:
329
328
  self.load_environment_variables()
330
329
 
331
330
  self.rc = RuntimeConfig(
332
- wallclock_offset_properties=WallclockOffsetProperties(),
333
331
  credentials=self.credentials_config,
334
332
  server_configuration=server_config,
335
333
  simulation_configuration=self.simulation_config,
@@ -4,12 +4,25 @@ Provides a base application that manages communication between a simulator and b
4
4
 
5
5
  import logging
6
6
  import threading
7
+ import time
7
8
  import traceback
8
9
  from datetime import datetime, timedelta
9
10
 
11
+ from nost_tools.simulator import Mode
12
+
10
13
  from .application import Application
11
14
  from .application_utils import ConnectionConfig
12
- from .schemas import InitCommand, StartCommand, StopCommand, UpdateCommand
15
+ from .schemas import (
16
+ FreezeCommand,
17
+ FreezeRequest,
18
+ InitCommand,
19
+ ResumeCommand,
20
+ ResumeRequest,
21
+ StartCommand,
22
+ StopCommand,
23
+ UpdateCommand,
24
+ UpdateRequest,
25
+ )
13
26
 
14
27
  logger = logging.getLogger(__name__)
15
28
 
@@ -143,6 +156,17 @@ class ManagedApplication(Application):
143
156
  app_topic="update",
144
157
  user_callback=self.on_manager_update,
145
158
  )
159
+ self.add_message_callback(
160
+ app_name=self.manager_app_name,
161
+ app_topic="freeze",
162
+ user_callback=self.on_manager_freeze,
163
+ )
164
+
165
+ self.add_message_callback(
166
+ app_name=self.manager_app_name,
167
+ app_topic="resume",
168
+ user_callback=self.on_manager_resume,
169
+ )
146
170
 
147
171
  def shut_down(self) -> None:
148
172
  """
@@ -259,16 +283,166 @@ class ManagedApplication(Application):
259
283
  body (bytes): The actual message body sent, containing the message payload.
260
284
  """
261
285
  try:
262
- # Parse message payload
263
286
  message = body.decode("utf-8")
264
- params = UpdateCommand.model_validate_json(message).tasking_parameters
287
+ update_cmd = UpdateCommand.model_validate_json(message)
288
+ params = update_cmd.tasking_parameters
289
+ tcf = params.time_scaling_factor
290
+ sim_epoch = params.sim_update_time
291
+
265
292
  logger.info(f"Received update command {message}")
266
- # update execution time scale factor
267
- self.simulator.set_time_scale_factor(
268
- params.time_scaling_factor, params.sim_update_time
293
+
294
+ def _apply_when_executing():
295
+ while self.simulator.get_mode() != Mode.EXECUTING:
296
+ time.sleep(0.01)
297
+ # Apply update once executing
298
+ self.simulator.set_time_scale_factor(tcf, sim_epoch)
299
+
300
+ if self.simulator.get_mode() != Mode.EXECUTING:
301
+ logger.debug("Deferring time scale update until EXECUTING")
302
+ threading.Thread(target=_apply_when_executing, daemon=True).start()
303
+ else:
304
+ self.simulator.set_time_scale_factor(tcf, sim_epoch)
305
+ except Exception as e:
306
+ logger.error(
307
+ f"Exception (topic: {method.routing_key}, payload: {message}): {e}"
269
308
  )
309
+ print(traceback.format_exc())
310
+
311
+ def on_manager_freeze(self, ch, method, properties, body) -> None:
312
+ """
313
+ Callback function for the managed application ('self') to respond to a freeze command sent from the manager.
314
+ Parses the freeze duration and simulation freeze time and updates the simulator.
315
+
316
+ Args:
317
+ ch (:obj:`pika.channel.Channel`): The channel object used to communicate with the RabbitMQ server.
318
+ method (:obj:`pika.spec.Basic.Deliver`): Delivery-related information such as delivery tag, exchange, and routing key.
319
+ properties (:obj:`pika.BasicProperties`): Message properties including content type, headers, and more.
320
+ body (bytes): The actual message body sent, containing the message payload.
321
+ """
322
+ try:
323
+ # Parse message payload
324
+ message = body.decode("utf-8")
325
+ params = FreezeCommand.model_validate_json(message).tasking_parameters
326
+ logger.info(f"Received freeze command {message}")
327
+ # freeze simulation time
328
+ self.simulator.pause()
329
+
330
+ except Exception as e:
331
+ logger.error(
332
+ f"Exception (topic: {method.routing_key}, payload: {message}): {e}"
333
+ )
334
+ print(traceback.format_exc())
335
+
336
+ def on_manager_resume(self, ch, method, properties, body) -> None:
337
+ """
338
+ Callback function for the managed application ('self') to respond to a resume command sent from the manager.
339
+ Resumes the simulator execution.
340
+
341
+ Args:
342
+ ch (:obj:`pika.channel.Channel`): The channel object used to communicate with the RabbitMQ server.
343
+ method (:obj:`pika.spec.Basic.Deliver`): Delivery-related information such as delivery tag, exchange, and routing key.
344
+ properties (:obj:`pika.BasicProperties`): Message properties including content type, headers, and more.
345
+ body (bytes): The actual message body sent, containing the message payload.
346
+ """
347
+ try:
348
+ # Parse message payload
349
+ message = body.decode("utf-8")
350
+ params = ResumeCommand.model_validate_json(message).tasking_parameters
351
+ logger.info(f"Received resume command {message}")
352
+ # resume simulation time
353
+ self.simulator.resume()
270
354
  except Exception as e:
271
355
  logger.error(
272
356
  f"Exception (topic: {method.routing_key}, payload: {message}): {e}"
273
357
  )
274
358
  print(traceback.format_exc())
359
+
360
+ def request_freeze(
361
+ self, freeze_duration: timedelta = None, sim_freeze_time: datetime = None
362
+ ) -> None:
363
+ """
364
+ Request a freeze from the manager.
365
+
366
+ Args:
367
+ freeze_duration (:obj:`timedelta`, optional): Duration for which to freeze execution.
368
+ If None, creates an indefinite freeze.
369
+ sim_freeze_time (:obj:`datetime`, optional): Scenario time at which to freeze execution.
370
+ If None, freezes immediately.
371
+ """
372
+ # Publish a freeze request message
373
+ wallclock_time = self.simulator.get_wallclock_time_at_simulation_time(
374
+ sim_freeze_time
375
+ )
376
+ request_params = {
377
+ "simFreezeTime": sim_freeze_time,
378
+ "freezeTime": wallclock_time,
379
+ "requestingApp": self.app_name,
380
+ }
381
+ if freeze_duration is not None:
382
+ request_params["freezeDuration"] = freeze_duration
383
+ request_params["resumeTime"] = wallclock_time + freeze_duration
384
+ # Create the freeze request
385
+ request = FreezeRequest.model_validate({"taskingParameters": request_params})
386
+ freeze_type = (
387
+ "indefinite" if freeze_duration is None else f"timed ({freeze_duration})"
388
+ )
389
+ logger.info(
390
+ f"Requesting {freeze_type} freeze: {request.model_dump_json(by_alias=True)}"
391
+ )
392
+ # Send the request to the manager
393
+ self.send_message(
394
+ app_name=self.app_name,
395
+ app_topics="request.freeze",
396
+ payload=request.model_dump_json(by_alias=True),
397
+ )
398
+
399
+ def request_resume(self) -> None:
400
+ """
401
+ Request a resume from the manager.
402
+ """
403
+ # Create the resume request
404
+ request = ResumeRequest.model_validate(
405
+ {"taskingParameters": {"requestingApp": self.app_name}}
406
+ )
407
+
408
+ logger.info(f"Requesting resume: {request.model_dump_json(by_alias=True)}")
409
+
410
+ # Send the request to the manager
411
+ self.send_message(
412
+ app_name=self.app_name,
413
+ app_topics="request.resume",
414
+ payload=request.model_dump_json(by_alias=True),
415
+ )
416
+
417
+ def request_update(
418
+ self, time_scale_factor: float, sim_update_time: datetime = None
419
+ ) -> None:
420
+ """
421
+ Request a time scale factor update from the manager.
422
+
423
+ Args:
424
+ time_scale_factor (float): scenario seconds per wallclock second
425
+ sim_update_time (:obj:`datetime`, optional): Scenario time at which to update.
426
+ If None, updates immediately.
427
+ """
428
+ # Publish an update request message
429
+ request_params = {
430
+ "timeScalingFactor": time_scale_factor,
431
+ "requestingApp": self.app_name,
432
+ }
433
+ if sim_update_time is not None:
434
+ request_params["simUpdateTime"] = sim_update_time
435
+
436
+ # Create the update request
437
+ request = UpdateRequest.model_validate({"taskingParameters": request_params})
438
+
439
+ logger.info(
440
+ f"Requesting time scale factor update to {time_scale_factor}: {request.model_dump_json(by_alias=True)}"
441
+ )
442
+
443
+ # Send the request to the manager
444
+ self.send_message(
445
+ app_name=self.app_name,
446
+ app_topics="request.update",
447
+ payload=request.model_dump_json(by_alias=True),
448
+ )
@@ -15,40 +15,23 @@ from pydantic import ValidationError
15
15
  from .application import Application
16
16
  from .application_utils import ConnectionConfig
17
17
  from .schemas import (
18
+ FreezeCommand,
19
+ FreezeRequest,
18
20
  InitCommand,
19
21
  ReadyStatus,
22
+ ResumeCommand,
23
+ ResumeRequest,
20
24
  StartCommand,
21
25
  StopCommand,
22
26
  TimeStatus,
23
27
  UpdateCommand,
28
+ UpdateRequest,
24
29
  )
25
30
  from .simulator import Mode
26
31
 
27
32
  logger = logging.getLogger(__name__)
28
33
 
29
34
 
30
- class TimeScaleUpdate(object):
31
- """
32
- Provides a scheduled update to the simulation time scale factor by sending a message at the designated sim_update_time
33
- to change the time_scale_factor to the indicated value.
34
-
35
- Attributes:
36
- time_scale_factor (float): scenario seconds per wallclock second
37
- sim_update_time (:obj:`datetime`): scenario time that the update will occur
38
- """
39
-
40
- def __init__(self, time_scale_factor: float, sim_update_time: datetime):
41
- """
42
- Instantiates a new time scale update.
43
-
44
- Args:
45
- time_scale_factor (float): scenario seconds per wallclock second
46
- sim_update_time (:obj:`datetime`): scenario time that the update will occur
47
- """
48
- self.time_scale_factor = time_scale_factor
49
- self.sim_update_time = sim_update_time
50
-
51
-
52
35
  class Manager(Application):
53
36
  """
54
37
  NOS-T Manager Application.
@@ -84,19 +67,17 @@ class Manager(Application):
84
67
  app_name, app_description, setup_signal_handlers=setup_signal_handlers
85
68
  )
86
69
  self.required_apps_status = {}
87
-
88
70
  self.sim_start_time = None
89
71
  self.sim_stop_time = None
90
- start_time = None
91
- time_step = None
92
- time_scale_factor = None
93
- time_scale_updates = None
94
- time_status_step = None
95
- time_status_init = None
96
- command_lead = None
97
- required_apps = None
98
- init_retry_delay_s = None
99
- init_max_retry = None
72
+ self.start_time = None
73
+ self.time_step = None
74
+ self.time_scale_factor = None
75
+ self.time_status_step = None
76
+ self.time_status_init = None
77
+ self.command_lead = None
78
+ self.required_apps = None
79
+ self.init_retry_delay_s = None
80
+ self.init_max_retry = None
100
81
 
101
82
  def establish_exchange(self):
102
83
  """
@@ -164,7 +145,7 @@ class Manager(Application):
164
145
  set_offset: bool = True,
165
146
  time_status_step: timedelta = None,
166
147
  time_status_init: datetime = None,
167
- shut_down_when_terminated: bool = False
148
+ shut_down_when_terminated: bool = False,
168
149
  ) -> None:
169
150
  """
170
151
  Starts up the application by connecting to message broker, starting a background event loop,
@@ -190,8 +171,147 @@ class Manager(Application):
190
171
  shut_down_when_terminated,
191
172
  )
192
173
 
193
- # Additional manager-specific setup: establish the exchange
174
+ # Establish the RabbitMQ exchange
194
175
  self.establish_exchange()
176
+ # Register callbacks for freeze, resume, and update requests from managed applications
177
+ self.add_message_callback("*", "request.freeze", self.on_freeze_request)
178
+ self.add_message_callback("*", "request.resume", self.on_resume_request)
179
+ self.add_message_callback("*", "request.update", self.on_update_request)
180
+
181
+ def on_freeze_request(self, ch, method, properties, body) -> None:
182
+ """
183
+ Callback to handle freeze requests from managed applications.
184
+
185
+ Args:
186
+ ch (:obj:`pika.channel.Channel`): The channel object used to communicate with the RabbitMQ server.
187
+ method (:obj:`pika.spec.Basic.Deliver`): Delivery-related information such as delivery tag, exchange, and routing key.
188
+ properties (:obj:`pika.BasicProperties`): Message properties including content type, headers, and more.
189
+ body (bytes): The actual message body sent, containing the message payload.
190
+ """
191
+ try:
192
+ # Parse the freeze request
193
+ message = body.decode("utf-8")
194
+ freeze_request = FreezeRequest.model_validate_json(message)
195
+ params = freeze_request.tasking_parameters
196
+
197
+ logger.info(
198
+ f"Received freeze request from {params.requesting_app}: {message}"
199
+ )
200
+
201
+ # Use a separate thread to handle the freeze to avoid blocking the callback
202
+ freeze_thread = threading.Thread(
203
+ target=self._handle_freeze_request,
204
+ args=(
205
+ params.freeze_duration,
206
+ params.sim_freeze_time,
207
+ params.resume_time,
208
+ ),
209
+ daemon=True,
210
+ )
211
+ freeze_thread.start()
212
+
213
+ except ValidationError as e:
214
+ logger.error(f"Validation error in freeze request: {e}")
215
+ except Exception as e:
216
+ logger.error(
217
+ f"Exception handling freeze request (topic: {method.routing_key}, payload: {message}): {e}"
218
+ )
219
+ print(traceback.format_exc())
220
+
221
+ def _handle_freeze_request(
222
+ self,
223
+ freeze_duration: timedelta = None,
224
+ sim_freeze_time: datetime = None,
225
+ resume_time: datetime = None,
226
+ ) -> None:
227
+ try:
228
+ if freeze_duration is not None:
229
+ self.freeze(freeze_duration, sim_freeze_time, resume_time)
230
+ # Only resume if we are still paused and not terminating
231
+ if self.simulator.get_mode() == Mode.PAUSED:
232
+ self.resume()
233
+ else:
234
+ self.freeze(None, sim_freeze_time, None)
235
+ logger.info("Indefinite freeze requested - manual resume required")
236
+
237
+ except Exception as e:
238
+ logger.error(f"Error handling freeze request: {e}")
239
+ print(traceback.format_exc())
240
+
241
+ def on_resume_request(self, ch, method, properties, body) -> None:
242
+ """
243
+ Callback to handle resume requests from managed applications.
244
+
245
+ Args:
246
+ ch (:obj:`pika.channel.Channel`): The channel object used to communicate with the RabbitMQ server.
247
+ method (:obj:`pika.spec.Basic.Deliver`): Delivery-related information such as delivery tag, exchange, and routing key.
248
+ properties (:obj:`pika.BasicProperties`): Message properties including content type, headers, and more.
249
+ body (bytes): The actual message body sent, containing the message payload.
250
+ """
251
+ try:
252
+ # Parse the resume request
253
+ message = body.decode("utf-8")
254
+ resume_request = ResumeRequest.model_validate_json(message)
255
+ params = resume_request.tasking_parameters
256
+ logger.info(
257
+ f"Received resume request from {params.requesting_app}: {message}"
258
+ )
259
+ # Execute the resume command
260
+ self.resume()
261
+
262
+ except ValidationError as e:
263
+ logger.error(f"Validation error in resume request: {e}")
264
+ except Exception as e:
265
+ logger.error(
266
+ f"Exception handling resume request (topic: {method.routing_key}, payload: {message}): {e}"
267
+ )
268
+ print(traceback.format_exc())
269
+
270
+ def on_update_request(self, ch, method, properties, body) -> None:
271
+ """
272
+ Callback to handle update requests from managed applications.
273
+
274
+ Args:
275
+ ch (:obj:`pika.channel.Channel`): The channel object used to communicate with the RabbitMQ server.
276
+ method (:obj:`pika.spec.Basic.Deliver`): Delivery-related information such as delivery tag, exchange, and routing key.
277
+ properties (:obj:`pika.BasicProperties`): Message properties including content type, headers, and more.
278
+ body (bytes): The actual message body sent, containing the message payload.
279
+ """
280
+ try:
281
+ message = body.decode("utf-8")
282
+ update_request = UpdateRequest.model_validate_json(message)
283
+ params = update_request.tasking_parameters
284
+ logger.info(
285
+ f"Received update request from {params.requesting_app}: {message}"
286
+ )
287
+
288
+ def _apply_update_when_executing():
289
+ while self.simulator.get_mode() != Mode.EXECUTING:
290
+ self._sleep_with_heartbeat(0.01)
291
+ self.update(
292
+ params.time_scale_factor,
293
+ params.sim_update_time or self.simulator.get_time(),
294
+ )
295
+ # Wait until update takes effect
296
+ while (
297
+ self.simulator.get_time_scale_factor() != params.time_scale_factor
298
+ ):
299
+ self._sleep_with_heartbeat(0.01)
300
+
301
+ # Defer if not executing yet
302
+ if self.simulator.get_mode() != Mode.EXECUTING:
303
+ threading.Thread(
304
+ target=_apply_update_when_executing, daemon=True
305
+ ).start()
306
+ else:
307
+ _apply_update_when_executing()
308
+ except ValidationError as e:
309
+ logger.error(f"Validation error in update request: {e}")
310
+ except Exception as e:
311
+ logger.error(
312
+ f"Exception handling update request (topic: {method.routing_key}, payload: {message}): {e}"
313
+ )
314
+ print(traceback.format_exc())
195
315
 
196
316
  def execute_test_plan(self, *args, **kwargs) -> None:
197
317
  """
@@ -214,7 +334,6 @@ class Manager(Application):
214
334
  start_time: datetime = None,
215
335
  time_step: timedelta = timedelta(seconds=1),
216
336
  time_scale_factor: float = 1.0,
217
- time_scale_updates: List[TimeScaleUpdate] = [],
218
337
  time_status_step: timedelta = None,
219
338
  time_status_init: datetime = None,
220
339
  command_lead: timedelta = timedelta(seconds=0),
@@ -234,7 +353,6 @@ class Manager(Application):
234
353
  start_time (:obj:`datetime`): wallclock time at which to start execution (default: now)
235
354
  time_step (:obj:`timedelta`): scenario time step used in execution (default: 1 second)
236
355
  time_scale_factor (float): scenario seconds per wallclock second (default: 1.0)
237
- time_scale_updates (list(:obj:`TimeScaleUpdate`)): list of scheduled time scale updates (default: [])
238
356
  time_status_step (:obj:`timedelta`): scenario duration between time status messages
239
357
  time_status_init (:obj:`datetime`): scenario time of first time status message
240
358
  command_lead (:obj:`timedelta`): wallclock lead time between command and action (default: 0 seconds)
@@ -243,7 +361,7 @@ class Manager(Application):
243
361
  init_max_retry (int): number of initialization commands while waiting for required applications before continuing to execution
244
362
  """
245
363
  if self.config.rc.yaml_file:
246
- logger.info(
364
+ logger.debug(
247
365
  f"Collecting execution parameters from YAML configuration file: {self.config.rc.yaml_file}"
248
366
  )
249
367
  parameters = getattr(
@@ -256,7 +374,6 @@ class Manager(Application):
256
374
  self.start_time = parameters.start_time
257
375
  self.time_step = parameters.time_step
258
376
  self.time_scale_factor = parameters.time_scale_factor
259
- self.time_scale_updates = parameters.time_scale_updates
260
377
  self.time_status_step = parameters.time_status_step
261
378
  self.time_status_init = parameters.time_status_init
262
379
  self.command_lead = parameters.command_lead
@@ -266,7 +383,7 @@ class Manager(Application):
266
383
  self.init_retry_delay_s = parameters.init_retry_delay_s
267
384
  self.init_max_retry = parameters.init_max_retry
268
385
  else:
269
- logger.info(
386
+ logger.debug(
270
387
  f"Collecting execution parameters from user input or default values."
271
388
  )
272
389
  self.sim_start_time = sim_start_time
@@ -274,7 +391,6 @@ class Manager(Application):
274
391
  self.start_time = start_time
275
392
  self.time_step = time_step
276
393
  self.time_scale_factor = time_scale_factor
277
- self.time_scale_updates = time_scale_updates
278
394
  self.time_status_step = time_status_step
279
395
  self.time_status_init = time_status_init
280
396
  self.command_lead = command_lead
@@ -282,17 +398,6 @@ class Manager(Application):
282
398
  self.init_retry_delay_s = init_retry_delay_s
283
399
  self.init_max_retry = init_max_retry
284
400
 
285
- # Convert TimeScaleUpdateSchema objects to TimeScaleUpdate objects
286
- converted_updates = []
287
- for update_schema in self.time_scale_updates:
288
- converted_updates.append(
289
- TimeScaleUpdate(
290
- time_scale_factor=update_schema.time_scale_factor,
291
- sim_update_time=update_schema.sim_update_time,
292
- )
293
- )
294
- self.time_scale_updates = converted_updates
295
-
296
401
  # Set up tracking of required applications
297
402
  self.required_apps_status = dict(
298
403
  zip(self.required_apps, [False] * len(self.required_apps))
@@ -346,44 +451,22 @@ class Manager(Application):
346
451
  while self.simulator.get_mode() != Mode.EXECUTING:
347
452
  time.sleep(0.001)
348
453
 
349
- # Process time scale updates
350
- for update in self.time_scale_updates:
351
- update_time = self.simulator.get_wallclock_time_at_simulation_time(
352
- update.sim_update_time
353
- )
354
- # Sleep until update time using heartbeat-safe approach
355
- sleep_seconds = max(
356
- 0,
357
- (
358
- (update_time - self.simulator.get_wallclock_time())
359
- - self.command_lead
360
- )
361
- / timedelta(seconds=1),
454
+ # Wait for stop time - simulator now handles freeze time internally
455
+ while True:
456
+ end_time = self.simulator.get_wallclock_time_at_simulation_time(
457
+ self.simulator.get_end_time()
362
458
  )
459
+ current_time = self.simulator.get_wallclock_time()
460
+ time_until_stop = (
461
+ end_time - current_time - self.command_lead
462
+ ).total_seconds()
363
463
 
364
- # Use our heartbeat-safe sleep
365
- self._sleep_with_heartbeat(sleep_seconds)
464
+ if time_until_stop <= 0:
465
+ break
366
466
 
367
- # Issue the update command
368
- self.update(update.time_scale_factor, update.sim_update_time)
369
-
370
- # Wait until update takes effect
371
- while self.simulator.get_time_scale_factor() != update.time_scale_factor:
372
- time.sleep(0.001)
373
-
374
- end_time = self.simulator.get_wallclock_time_at_simulation_time(
375
- self.simulator.get_end_time()
376
- )
377
-
378
- # Sleep until stop time using heartbeat-safe approach
379
- sleep_seconds = max(
380
- 0,
381
- ((end_time - self.simulator.get_wallclock_time()) - self.command_lead)
382
- / timedelta(seconds=1),
383
- )
384
-
385
- # Use our heartbeat-safe sleep
386
- self._sleep_with_heartbeat(sleep_seconds)
467
+ # Wait for timeout
468
+ timeout = min(30.0, time_until_stop)
469
+ time.sleep(timeout)
387
470
 
388
471
  # Issue the stop command
389
472
  self.stop(self.sim_stop_time)
@@ -601,3 +684,134 @@ class Manager(Application):
601
684
  )
602
685
  # update the execution time scale factor
603
686
  self.simulator.set_time_scale_factor(time_scale_factor, sim_update_time)
687
+
688
+ def freeze(
689
+ self,
690
+ freeze_duration: timedelta = None,
691
+ sim_freeze_time: datetime = None,
692
+ resume_time: datetime = None,
693
+ ) -> None:
694
+ """
695
+ Command to freeze a test run execution by updating the execution freeze duration and publishing a freeze command.
696
+
697
+ Args:
698
+ freeze_duration (:obj:`timedelta`, optional): Duration for which to freeze execution.
699
+ If None, creates an indefinite freeze.
700
+ sim_freeze_time (:obj:`datetime`, optional): Scenario time at which to freeze execution.
701
+ If None, freezes immediately.
702
+ """
703
+ # publish a freeze command message
704
+ command_params = {"simFreezeTime": sim_freeze_time}
705
+ if freeze_duration is not None:
706
+ command_params["freezeDuration"] = freeze_duration
707
+ command = FreezeCommand.model_validate({"taskingParameters": command_params})
708
+ freeze_type = (
709
+ "indefinite" if freeze_duration is None else f"timed ({freeze_duration})"
710
+ )
711
+ logger.info(
712
+ f"Sending {freeze_type} freeze command {command.model_dump_json(by_alias=True)}."
713
+ )
714
+ self.send_message(
715
+ app_name=self.app_name,
716
+ app_topics="freeze",
717
+ payload=command.model_dump_json(by_alias=True),
718
+ )
719
+
720
+ # If a future scenario freeze time is specified, wait until that time before pausing
721
+ if sim_freeze_time is not None:
722
+ try:
723
+ if self.simulator.get_time() < sim_freeze_time:
724
+ target_wc = self.simulator.get_wallclock_time_at_simulation_time(
725
+ sim_freeze_time
726
+ )
727
+ delay = (
728
+ target_wc - self.simulator.get_wallclock_time()
729
+ ).total_seconds()
730
+ if delay > 0:
731
+ self._sleep_with_heartbeat(delay)
732
+ except Exception as e:
733
+ logger.warning(
734
+ f"Could not align to simFreezeTime={sim_freeze_time}: {e}"
735
+ )
736
+
737
+ # Freeze simulation time (skip if already paused/pausing)
738
+ if self.simulator.get_mode() not in (Mode.PAUSED, Mode.PAUSING):
739
+ self.simulator.pause()
740
+ # Wait until the simulator is paused to anchor the resume time
741
+ while self.simulator.get_mode() == Mode.PAUSING:
742
+ self._sleep_with_heartbeat(0.01)
743
+ while self.simulator.get_mode() != Mode.PAUSED:
744
+ if self.simulator.get_mode() in (Mode.TERMINATING, Mode.TERMINATED):
745
+ logger.info("Abort freeze wait due to termination")
746
+ return
747
+ self._sleep_with_heartbeat(0.01)
748
+
749
+ # Snapshot wallclock time at the moment of freeze
750
+ base = self.simulator.get_wallclock_time()
751
+
752
+ if freeze_duration is not None:
753
+ # Validate duration
754
+ if freeze_duration.total_seconds() <= 0:
755
+ logger.warning(
756
+ f"Ignoring non-positive freeze duration: {freeze_duration}"
757
+ )
758
+ return
759
+
760
+ # Compute authoritative resume time
761
+ target_resume_time = base + freeze_duration
762
+ # Optionally honor requested resume_time if it's later than base
763
+ if resume_time is not None and resume_time > base:
764
+ # Keep the earlier of the two if you want to minimize drift across nodes,
765
+ # or the later if you prefer never resuming before a requested time.
766
+ # Here we choose the later to avoid early resume vs. a peer's expectation.
767
+ target_resume_time = max(target_resume_time, resume_time)
768
+
769
+ logger.info(
770
+ f"Resume Time: requested={resume_time} calculated={target_resume_time} "
771
+ f"delta={abs((target_resume_time - (resume_time or target_resume_time)).total_seconds())}s"
772
+ )
773
+
774
+ # Poll until we reach the target, allowing early exit and heartbeats
775
+ while True:
776
+ mode = self.simulator.get_mode()
777
+ if mode in (
778
+ Mode.EXECUTING,
779
+ Mode.RESUMING,
780
+ Mode.TERMINATING,
781
+ Mode.TERMINATED,
782
+ ):
783
+ break
784
+ remaining = (
785
+ target_resume_time - self.simulator.get_wallclock_time()
786
+ ).total_seconds()
787
+ if remaining <= 0:
788
+ break
789
+ self._sleep_with_heartbeat(min(1.0, max(0.01, remaining)))
790
+ else:
791
+ logger.info("Indefinite freeze active. Call resume() to continue.")
792
+ while self.simulator.get_mode() not in (Mode.EXECUTING, Mode.RESUMING):
793
+ if self.simulator.get_mode() in (Mode.TERMINATING, Mode.TERMINATED):
794
+ break
795
+ self._sleep_with_heartbeat(0.01)
796
+
797
+ def resume(self) -> None:
798
+ """
799
+ Command to resume a test run execution by unpausing the simulator.
800
+ """
801
+ # resume the simulator execution
802
+ command = ResumeCommand.model_validate(
803
+ {
804
+ "taskingParameters": {
805
+ "resumeTime": self.simulator.get_wallclock_time(),
806
+ "simResumeTime": self.simulator.get_time(),
807
+ }
808
+ }
809
+ )
810
+ logger.info(f"Sending resume command {command.model_dump_json(by_alias=True)}.")
811
+ self.send_message(
812
+ app_name=self.app_name,
813
+ app_topics="resume",
814
+ payload=command.model_dump_json(by_alias=True),
815
+ )
816
+ # resume simulation time
817
+ self.simulator.resume()
@@ -247,6 +247,16 @@ class WallclockTimeIntervalCallback(Observer):
247
247
  ):
248
248
  from nost_tools.simulator import Mode, Simulator
249
249
 
250
+ # Reset timing when resuming
251
+ if (
252
+ property_name == Simulator.PROPERTY_MODE
253
+ and old_value == Mode.RESUMING
254
+ and new_value == Mode.EXECUTING
255
+ ):
256
+ wallclock_now = self.simulator.get_wallclock_time()
257
+ self._next_time = wallclock_now + (self.time_interval if self.time_interval is not None else timedelta())
258
+ return
259
+
250
260
  if property_name == Simulator.PROPERTY_MODE and new_value == Mode.INITIALIZED:
251
261
  self._next_time = self.time_init
252
262
  elif property_name == Simulator.PROPERTY_TIME:
@@ -125,6 +125,17 @@ class WallclockTimeIntervalPublisher(Observer, ABC):
125
125
  old_value (obj): old value of the named property
126
126
  new_value (obj): new value of the named property
127
127
  """
128
+ # Reset timing when resuming from pause
129
+ if (
130
+ property_name == Simulator.PROPERTY_MODE
131
+ and old_value == Mode.RESUMING
132
+ and new_value == Mode.EXECUTING
133
+ ):
134
+ self._next_time_status = self.app.simulator.get_wallclock_time()
135
+ if self.time_status_step is not None:
136
+ self._next_time_status += self.time_status_step
137
+ return
138
+
128
139
  if property_name == Simulator.PROPERTY_MODE and new_value == Mode.INITIALIZED:
129
140
  if self.time_status_init is None:
130
141
  self._next_time_status = self.app.simulator.get_wallclock_time()
@@ -132,6 +132,162 @@ class UpdateCommand(BaseModel):
132
132
  )
133
133
 
134
134
 
135
+ class FreezeTaskingParameters(BaseModel):
136
+ """
137
+ Tasking parameters to freeze an execution.
138
+ """
139
+
140
+ sim_freeze_time: datetime = Field(
141
+ ...,
142
+ gt=0,
143
+ description="Scenario time at which to freeze execution.",
144
+ alias="simFreezeTime",
145
+ )
146
+ freeze_duration: Optional[timedelta] = Field(
147
+ None,
148
+ # timedelta(seconds=60),
149
+ description="Wallclock time duration for which to freeze execution.",
150
+ alias="freezeDuration",
151
+ )
152
+
153
+
154
+ class FreezeCommand(BaseModel):
155
+ """
156
+ Command message to freeze an execution.
157
+ """
158
+
159
+ tasking_parameters: FreezeTaskingParameters = Field(
160
+ ...,
161
+ description="Tasking parameters for the freeze command.",
162
+ alias="taskingParameters",
163
+ )
164
+
165
+
166
+ class ResumeTaskingParameters(BaseModel):
167
+ """
168
+ Tasking parameters to resume an execution.
169
+ """
170
+
171
+ resume_time: datetime = Field(
172
+ ...,
173
+ gt=0,
174
+ description="Wallclock time at which to resume execution.",
175
+ alias="resumeTime",
176
+ )
177
+
178
+ sim_resume_time: datetime = Field(
179
+ ...,
180
+ gt=0,
181
+ description="Scenario time at which to resume execution.",
182
+ alias="simResumeTime",
183
+ )
184
+
185
+
186
+ class ResumeCommand(BaseModel):
187
+ """
188
+ Command message to resume an execution.
189
+ """
190
+
191
+ tasking_parameters: ResumeTaskingParameters = Field(
192
+ ...,
193
+ description="Tasking parameters for the resume command.",
194
+ alias="taskingParameters",
195
+ )
196
+
197
+
198
+ class FreezeRequestParameters(BaseModel):
199
+ """
200
+ Parameters for requesting a freeze from a managed application.
201
+ """
202
+
203
+ sim_freeze_time: datetime = Field(
204
+ ...,
205
+ gt=0,
206
+ description="Scenario time at which to freeze execution.",
207
+ alias="simFreezeTime",
208
+ )
209
+ freezeTime: datetime = Field(
210
+ ...,
211
+ description="Wallclock time at which to freeze execution.",
212
+ alias="freezeTime",
213
+ )
214
+ freeze_duration: Optional[timedelta] = Field(
215
+ None,
216
+ description="Wallclock time duration for which to freeze execution.",
217
+ alias="freezeDuration",
218
+ )
219
+ resume_time: Optional[datetime] = Field(
220
+ None,
221
+ description="Scenario time at which to resume execution.",
222
+ alias="resumeTime",
223
+ )
224
+ requesting_app: str = Field(
225
+ ...,
226
+ description="Name of the application requesting the freeze.",
227
+ alias="requestingApp",
228
+ )
229
+
230
+
231
+ class FreezeRequest(BaseModel):
232
+ """
233
+ Request message for a managed application to request a freeze.
234
+ """
235
+
236
+ tasking_parameters: FreezeRequestParameters = Field(alias="taskingParameters")
237
+
238
+
239
+ class ResumeRequestParameters(BaseModel):
240
+ """
241
+ Parameters for requesting a resume from a managed application.
242
+ """
243
+
244
+ requesting_app: str = Field(
245
+ ...,
246
+ description="Name of the application requesting the freeze.",
247
+ alias="requestingApp",
248
+ )
249
+
250
+
251
+ class ResumeRequest(BaseModel):
252
+ """
253
+ Request message for a managed application to request a resume.
254
+ """
255
+
256
+ tasking_parameters: ResumeRequestParameters = Field(alias="taskingParameters")
257
+
258
+
259
+ class UpdateRequestParameters(BaseModel):
260
+ """
261
+ Parameters for requesting an update from a managed application.
262
+ """
263
+
264
+ time_scale_factor: float = Field(
265
+ ...,
266
+ gt=0,
267
+ description="Time scaling factor (scenario seconds per wallclock second).",
268
+ alias="timeScalingFactor",
269
+ )
270
+ sim_update_time: Optional[datetime] = Field(
271
+ # ...,
272
+ None,
273
+ description="Scenario time at which to update the time scaling factor.",
274
+ alias="simUpdateTime",
275
+ )
276
+ requesting_app: str = Field(
277
+ ...,
278
+ description="Name of the application requesting the update.",
279
+ alias="requestingApp",
280
+ )
281
+
282
+
283
+ class UpdateRequest(BaseModel):
284
+ """
285
+ Request message for a managed application to request an update.
286
+ """
287
+
288
+ tasking_parameters: UpdateRequestParameters = Field(alias="taskingParameters")
289
+
290
+
135
291
  class TimeStatusProperties(BaseModel):
136
292
  """
137
293
  Properties to report time status.
@@ -307,11 +463,8 @@ class ServersConfig(BaseModel):
307
463
  return values
308
464
 
309
465
 
310
- class WallclockOffsetProperties(BaseModel):
311
- """
312
- Properties to report wallclock offset.
313
- """
314
-
466
+ class GeneralConfig(BaseModel):
467
+ prefix: Optional[str] = Field("nost", description="Execution prefix.")
315
468
  wallclock_offset_refresh_interval: Optional[int] = Field(
316
469
  10800, description="Wallclock offset refresh interval, in seconds."
317
470
  )
@@ -320,23 +473,6 @@ class WallclockOffsetProperties(BaseModel):
320
473
  )
321
474
 
322
475
 
323
- class GeneralConfig(BaseModel):
324
- prefix: Optional[str] = Field("nost", description="Execution prefix.")
325
-
326
-
327
- class TimeScaleUpdateSchema(BaseModel):
328
- """
329
- Provides a scheduled update to the simulation time scale factor.
330
- """
331
-
332
- time_scale_factor: float = Field(
333
- ..., description="Scenario seconds per wallclock second"
334
- )
335
- sim_update_time: datetime = Field(
336
- ..., description="Scenario time that the update will occur"
337
- )
338
-
339
-
340
476
  class LoggingConfig(BaseModel):
341
477
  """
342
478
  Configuration for logging.
@@ -345,9 +481,7 @@ class LoggingConfig(BaseModel):
345
481
  enable_file_logging: Optional[bool] = Field(
346
482
  False, description="Enable file logging."
347
483
  )
348
- log_dir: Optional[str] = Field(
349
- "logs", description="Directory path for log files."
350
- )
484
+ log_dir: Optional[str] = Field("logs", description="Directory path for log files.")
351
485
  log_filename: Optional[str] = Field(None, description="Path to the log file.")
352
486
  log_level: Optional[str] = Field(
353
487
  "INFO", description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)."
@@ -376,9 +510,6 @@ class ManagerConfig(LoggingConfig):
376
510
  description="Time step for the simulation.",
377
511
  )
378
512
  time_scale_factor: float = Field(1.0, description="Time scale factor.")
379
- time_scale_updates: List[TimeScaleUpdateSchema] = Field(
380
- default_factory=list, description="List of time scale updates."
381
- )
382
513
  time_status_step: Optional[timedelta] = Field(None, description="Time status step.")
383
514
  time_status_init: Optional[datetime] = Field(None, description="Time status init.")
384
515
  command_lead: timedelta = Field(
@@ -586,9 +717,6 @@ class SimulationConfig(BaseModel):
586
717
 
587
718
 
588
719
  class RuntimeConfig(BaseModel):
589
- wallclock_offset_properties: WallclockOffsetProperties = Field(
590
- ..., description="Properties for wallclock offset."
591
- )
592
720
  credentials: Credentials = Field(..., description="Credentials for authentication.")
593
721
  server_configuration: Config = (
594
722
  Field(..., description="Simulation configuration."),
@@ -33,6 +33,9 @@ class Mode(str, Enum):
33
33
  INITIALIZING = "INITIALIZING"
34
34
  INITIALIZED = "INITIALIZED"
35
35
  EXECUTING = "EXECUTING"
36
+ PAUSING = "PAUSING"
37
+ PAUSED = "PAUSED"
38
+ RESUMING = "RESUMING"
36
39
  TERMINATING = "TERMINATING"
37
40
  TERMINATED = "TERMINATED"
38
41
 
@@ -89,12 +92,10 @@ class Simulator(Observable):
89
92
  Args:
90
93
  entity (:obj:`Entity`): entity to be added
91
94
  """
92
- if self._mode == Mode.INITIALIZING:
93
- raise RuntimeError("Cannot add entity: simulator is initializing")
94
- elif self._mode == Mode.EXECUTING:
95
- raise RuntimeError("Cannot add entity: simulator is executing")
96
- elif self._mode == Mode.TERMINATING:
97
- raise RuntimeError("Cannot add entity: simulator is terminating")
95
+ if self._mode not in [Mode.UNDEFINED, Mode.INITIALIZED, Mode.TERMINATED]:
96
+ raise RuntimeError(
97
+ "Can only add entity from UNDEFINED, INITIALIZED, or TERMINATED modes."
98
+ )
98
99
  self._set_mode(Mode.UNDEFINED)
99
100
  self._entities.append(entity)
100
101
 
@@ -141,12 +142,10 @@ class Simulator(Observable):
141
142
  Returns:
142
143
  :obj:`Entity`: removed entity
143
144
  """
144
- if self._mode == Mode.INITIALIZING:
145
- raise RuntimeError("Cannot add entity: simulator is initializing")
146
- elif self._mode == Mode.EXECUTING:
147
- raise RuntimeError("Cannot add entity: simulator is executing")
148
- elif self._mode == Mode.TERMINATING:
149
- raise RuntimeError("Cannot add entity: simulator is terminating")
145
+ if self._mode not in [Mode.UNDEFINED, Mode.INITIALIZED, Mode.TERMINATED]:
146
+ raise RuntimeError(
147
+ "Can only remove entity from UNDEFINED, INITIALIZED, or TERMINATED modes."
148
+ )
150
149
  if entity in self._entities:
151
150
  self._set_mode(Mode.UNDEFINED)
152
151
  return self._entities.remove(entity)
@@ -173,12 +172,10 @@ class Simulator(Observable):
173
172
  initial scenario time, None uses the current wallclock time (default: None)
174
173
  time_scale_factor (float): number of scenario seconds per wallclock second (default: 1)
175
174
  """
176
- if self._mode == Mode.INITIALIZING:
177
- raise RuntimeError("Cannot initialize: simulator is initializing.")
178
- elif self._mode == Mode.EXECUTING:
179
- raise RuntimeError("Cannot initialize: simulator is executing.")
180
- elif self._mode == Mode.TERMINATING:
181
- raise RuntimeError("Cannot initialize: simulator is terminating.")
175
+ if self._mode not in [Mode.UNDEFINED, Mode.INITIALIZED, Mode.TERMINATED]:
176
+ raise RuntimeError(
177
+ "Can only initialize from UNDEFINED, INITIALIZED, or TERMINATED modes."
178
+ )
182
179
  self._set_mode(Mode.INITIALIZING)
183
180
  logger.info(
184
181
  f"Initializing simulator to time {init_time} (wallclock time {wallclock_epoch})"
@@ -219,6 +216,11 @@ class Simulator(Observable):
219
216
  initial scenario time, None uses the current wallclock time (default: None)
220
217
  time_scale_factor (float): number of scenario seconds per wallclock second (default value: 1)
221
218
  """
219
+ if self._mode not in [Mode.UNDEFINED, Mode.INITIALIZED, Mode.TERMINATED]:
220
+ raise RuntimeError(
221
+ f"Cannot execute: simulator is {self._mode}. Wait for TERMINATED or terminate the current run."
222
+ )
223
+
222
224
  if self._mode != Mode.INITIALIZED:
223
225
  self.initialize(init_time, wallclock_epoch, time_scale_factor)
224
226
 
@@ -233,9 +235,8 @@ class Simulator(Observable):
233
235
 
234
236
  logger.info("Starting main simulation loop.")
235
237
  while (
236
- self._mode == Mode.EXECUTING
237
- and self.get_time() < self.get_init_time() + self.get_duration()
238
- ):
238
+ self._mode == Mode.EXECUTING or self._mode == Mode.PAUSING
239
+ ) and self.get_time() < self.get_init_time() + self.get_duration():
239
240
  # compute time step (last step may be shorter)
240
241
  time_step = min(
241
242
  self._time_step, self._init_time + self._duration - self._time
@@ -302,6 +303,17 @@ class Simulator(Observable):
302
303
  """
303
304
  Waits until the wallclock time matches the next time step interval.
304
305
  """
306
+ if self._mode == Mode.PAUSING:
307
+ self._set_mode(Mode.PAUSED)
308
+ while self._mode == Mode.PAUSED:
309
+ time.sleep(0.01)
310
+ if self._mode == Mode.RESUMING:
311
+ # reset the wallclock and simulation epochs
312
+ self._wallclock_epoch = self.get_wallclock_time()
313
+ self._simulation_epoch = self._time
314
+ self._set_mode(Mode.EXECUTING)
315
+ # Return immediately after resume to avoid waiting
316
+ return
305
317
  while (
306
318
  self._mode == Mode.EXECUTING
307
319
  and self.get_wallclock_time_at_simulation_time(self._next_time)
@@ -520,6 +532,25 @@ class Simulator(Observable):
520
532
  raise RuntimeError("Cannot set wallclock offset: simulator is terminating")
521
533
  self._wallclock_offset = wallclock_offset
522
534
 
535
+ def pause(self) -> None:
536
+ """
537
+ Pauses the scenario execution. Requires that the simulator is in EXECUTING mode.
538
+ """
539
+ logger.info("Pausing simulation execution.")
540
+ if self._mode != Mode.EXECUTING:
541
+ raise RuntimeError("Cannot pause: simulator is not executing.")
542
+
543
+ self._set_mode(Mode.PAUSING)
544
+
545
+ def resume(self) -> None:
546
+ """
547
+ Resumes the scenario execution. Requires that the simulator is in PAUSING or PAUSED mode.
548
+ """
549
+ if self._mode not in [Mode.PAUSING, Mode.PAUSED]:
550
+ raise RuntimeError("Cannot resume: simulator is not pausing or paused.")
551
+ self._next_time = self._time
552
+ self._set_mode(Mode.RESUMING)
553
+
523
554
  def terminate(self) -> None:
524
555
  """
525
556
  Terminates the scenario execution. Requires that the simulator is in EXECUTING mode.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nost_tools
3
- Version: 2.4.0
3
+ Version: 3.0.0
4
4
  Summary: Tools for Novel Observing Strategies Testbed (NOS-T) Applications
5
5
  Author-email: "Paul T. Grogan" <paul.grogan@asu.edu>, "Emmanuel M. Gonzalez" <emmanuelgonzalez@asu.edu>
6
6
  License-Expression: BSD-3-Clause
File without changes
File without changes
File without changes
File without changes