nost-tools 2.3.0__py3-none-any.whl → 3.0.0__py3-none-any.whl

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.

nost_tools/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "2.3.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 (
nost_tools/application.py CHANGED
@@ -4,6 +4,7 @@ Provides a base application that publishes messages from a simulator to a broker
4
4
 
5
5
  import functools
6
6
  import logging
7
+ import logging.handlers
7
8
  import os
8
9
  import signal
9
10
  import ssl
@@ -216,21 +217,21 @@ class Application:
216
217
 
217
218
  def refresh_wallclock_periodically():
218
219
  while not self._should_stop.wait(
219
- 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
220
221
  ):
221
222
  logger.debug("Wallclock refresh thread is running.")
222
223
  try:
223
- logger.info(
224
- 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."
225
226
  )
226
227
  response = ntplib.NTPClient().request(
227
- self.config.rc.wallclock_offset_properties.ntp_host,
228
+ self.config.rc.simulation_configuration.execution_parameters.general.ntp_host,
228
229
  version=3,
229
230
  timeout=2,
230
231
  )
231
232
  offset = timedelta(seconds=response.offset)
232
233
  self.simulator.set_wallclock_offset(offset)
233
- logger.info(f"Wallclock offset updated to {offset}.")
234
+ logger.debug(f"Wallclock offset updated to {offset}.")
234
235
  except Exception as e:
235
236
  logger.debug(f"Failed to refresh wallclock offset: {e}")
236
237
 
@@ -308,6 +309,17 @@ class Application:
308
309
  self.shut_down_when_terminated = getattr(
309
310
  parameters, "shut_down_when_terminated", shut_down_when_terminated
310
311
  )
312
+
313
+ # Configure file logging if requested
314
+ if getattr(parameters, "enable_file_logging", False):
315
+ self.configure_file_logging(
316
+ log_dir=getattr(parameters, "log_dir", None),
317
+ log_filename=getattr(parameters, "log_filename", None),
318
+ log_level=getattr(parameters, "log_level", None),
319
+ max_bytes=getattr(parameters, "max_bytes", None),
320
+ backup_count=getattr(parameters, "backup_count", None),
321
+ log_format=getattr(parameters, "log_format", None),
322
+ )
311
323
  else:
312
324
  logger.warning("No parameters found in configuration, using defaults")
313
325
  self.set_offset = set_offset
@@ -326,7 +338,7 @@ class Application:
326
338
  if self.set_offset:
327
339
  # Start periodic wallclock offset updates instead of one-time call
328
340
  logger.info(
329
- 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}."
330
342
  )
331
343
  self.start_wallclock_refresh_thread()
332
344
 
@@ -1475,9 +1487,67 @@ class Application:
1475
1487
 
1476
1488
  def _create_shut_down_observer(self) -> None:
1477
1489
  """
1478
- Creates an observer to shut down the application when the simulation is terminated.
1490
+ Creates a shut down observer to close the application when the simulator is terminated.
1479
1491
  """
1480
1492
  if self._shut_down_observer is not None:
1481
1493
  self.simulator.remove_observer(self._shut_down_observer)
1482
1494
  self._shut_down_observer = ShutDownObserver(self)
1483
1495
  self.simulator.add_observer(self._shut_down_observer)
1496
+
1497
+ def configure_file_logging(
1498
+ self,
1499
+ log_dir: str = None,
1500
+ log_filename: str = None,
1501
+ log_level: str = None,
1502
+ max_bytes: int = None,
1503
+ backup_count: int = None,
1504
+ log_format: str = None,
1505
+ ):
1506
+ """
1507
+ Configures file logging for the application.
1508
+
1509
+ Args:
1510
+ log_dir (str): Directory where log files will be stored
1511
+ log_filename (str): Name of the log file. If None, a timestamped filename will be used
1512
+ log_level (str): Logging level (e.g., 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
1513
+ max_bytes (int): Maximum file size in bytes before rotating
1514
+ backup_count (int): Number of backup files to keep
1515
+ log_format (str): Log message format
1516
+ """
1517
+ try:
1518
+ if log_filename is None:
1519
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1520
+ log_filename = os.path.join(log_dir, f"{self.app_name}_{timestamp}.log")
1521
+ else:
1522
+ log_filename = os.path.join(log_dir, log_filename)
1523
+
1524
+ # Create log directory if it doesn't exist
1525
+ if log_dir and not os.path.exists(log_dir):
1526
+ os.makedirs(log_dir)
1527
+ logger.info(f"Log directory {log_dir} successfully created.")
1528
+
1529
+ # Configure rotating file handler
1530
+ handler = logging.handlers.RotatingFileHandler(
1531
+ log_filename, maxBytes=max_bytes, backupCount=backup_count
1532
+ )
1533
+
1534
+ # Set log level
1535
+ level = getattr(logging, log_level.upper(), logging.INFO)
1536
+ handler.setLevel(level)
1537
+
1538
+ # Set log format
1539
+ if log_format is not None:
1540
+ formatter = logging.Formatter(log_format)
1541
+ handler.setFormatter(formatter)
1542
+ else:
1543
+ # Default format
1544
+ formatter = logging.Formatter(
1545
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
1546
+ )
1547
+ handler.setFormatter(formatter)
1548
+
1549
+ # Add the handler to the root logger
1550
+ logging.getLogger().addHandler(handler)
1551
+ logger.info(f"File logging configured: {log_filename} (level: {log_level})")
1552
+ except Exception as e:
1553
+ logger.error(f"Error configuring file logging: {e}")
@@ -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
+ )
nost_tools/manager.py CHANGED
@@ -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
  """
@@ -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()
nost_tools/observer.py CHANGED
@@ -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:
nost_tools/publisher.py CHANGED
@@ -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()
nost_tools/schemas.py CHANGED
@@ -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.
@@ -280,7 +436,7 @@ class KeycloakConfig(BaseModel):
280
436
  realm: str = Field("master", description="Keycloak realm.")
281
437
  tls: bool = Field(False, description="Keycloak TLS/SSL.")
282
438
  token_refresh_interval: int = Field(
283
- 60, description="Keycloak token refresh interval, in seconds."
439
+ 240, description="Keycloak token refresh interval, in seconds."
284
440
  )
285
441
 
286
442
 
@@ -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,24 +473,33 @@ 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):
476
+ class LoggingConfig(BaseModel):
328
477
  """
329
- Provides a scheduled update to the simulation time scale factor.
478
+ Configuration for logging.
330
479
  """
331
480
 
332
- time_scale_factor: float = Field(
333
- ..., description="Scenario seconds per wallclock second"
481
+ enable_file_logging: Optional[bool] = Field(
482
+ False, description="Enable file logging."
334
483
  )
335
- sim_update_time: datetime = Field(
336
- ..., description="Scenario time that the update will occur"
484
+ log_dir: Optional[str] = Field("logs", description="Directory path for log files.")
485
+ log_filename: Optional[str] = Field(None, description="Path to the log file.")
486
+ log_level: Optional[str] = Field(
487
+ "INFO", description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)."
488
+ )
489
+ max_bytes: Optional[int] = Field(
490
+ 10 * 1024 * 1024,
491
+ description="Maximum size of the log file in bytes. Default is 10MB.",
492
+ )
493
+ backup_count: Optional[int] = Field(
494
+ 5, description="Number of backup log files to keep."
495
+ )
496
+ log_format: Optional[str] = Field(
497
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
498
+ description="Format of the log messages.",
337
499
  )
338
500
 
339
501
 
340
- class ManagerConfig(BaseModel):
502
+ class ManagerConfig(LoggingConfig):
341
503
  sim_start_time: Optional[datetime] = Field(
342
504
  None, description="Simulation start time."
343
505
  )
@@ -348,9 +510,6 @@ class ManagerConfig(BaseModel):
348
510
  description="Time step for the simulation.",
349
511
  )
350
512
  time_scale_factor: float = Field(1.0, description="Time scale factor.")
351
- time_scale_updates: List[TimeScaleUpdateSchema] = Field(
352
- default_factory=list, description="List of time scale updates."
353
- )
354
513
  time_status_step: Optional[timedelta] = Field(None, description="Time status step.")
355
514
  time_status_init: Optional[datetime] = Field(None, description="Time status init.")
356
515
  command_lead: timedelta = Field(
@@ -405,7 +564,7 @@ class ManagerConfig(BaseModel):
405
564
  return values
406
565
 
407
566
 
408
- class ManagedApplicationConfig(BaseModel):
567
+ class ManagedApplicationConfig(LoggingConfig):
409
568
  time_scale_factor: float = Field(1.0, description="Time scale factor.")
410
569
  time_step: timedelta = Field(
411
570
  timedelta(seconds=1), description="Time step for swe_change."
@@ -558,9 +717,6 @@ class SimulationConfig(BaseModel):
558
717
 
559
718
 
560
719
  class RuntimeConfig(BaseModel):
561
- wallclock_offset_properties: WallclockOffsetProperties = Field(
562
- ..., description="Properties for wallclock offset."
563
- )
564
720
  credentials: Credentials = Field(..., description="Credentials for authentication.")
565
721
  server_configuration: Config = (
566
722
  Field(..., description="Simulation configuration."),
nost_tools/simulator.py CHANGED
@@ -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.3.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
@@ -0,0 +1,18 @@
1
+ nost_tools/__init__.py,sha256=U8ylAZeztjYh1QXalVZU8-0BsY1JKRqTFAcnUBxEJro,853
2
+ nost_tools/application.py,sha256=C-19kvRTHUt-xXA_0hBh1aoIE32fvTETwCqtrFswYCQ,67406
3
+ nost_tools/application_utils.py,sha256=jiMzuuP6-47UlUO64HhwNvbl6uKvVnsksYgOw7CmxL4,9327
4
+ nost_tools/configuration.py,sha256=qHXfWK_IrLj5Z8xWfF3H5rqHOLL0bLlZ3Ls73rTDHUk,13046
5
+ nost_tools/entity.py,sha256=JrSN5rz7h-N9zIQsaJOQVBIYPDfygacedks55fsq_QQ,2802
6
+ nost_tools/errors.py,sha256=0JcDlMEkZAya3-5c0rRozLuxp8qF58StG4JgRsaxfKU,344
7
+ nost_tools/logger_application.py,sha256=rxPBfyA7Zym5b_EsoSJvT9JWNIVWZX1a-4czFwCqaQ4,7217
8
+ nost_tools/managed_application.py,sha256=0P3_UHSQfdLqgaAnxjEJetkYU4P6M7NAHkOcRTUXe9o,18631
9
+ nost_tools/manager.py,sha256=Xu200PlrNNMBhWMryRcuVT-_vE0oW5wrn7PtLLAPSIY,34148
10
+ nost_tools/observer.py,sha256=PmqxnVN422dIRgwH29RcvK331Q4vtH_eD7MP1eIe_vk,8564
11
+ nost_tools/publisher.py,sha256=-p5G9JLVZKhNNOSUGlvDKyWZSHlWikQV1TabsNIyLJA,5714
12
+ nost_tools/schemas.py,sha256=nuo0mdL1kouuuWXOROHTxA63lGJSvr15BDVygvl3PKU,24727
13
+ nost_tools/simulator.py,sha256=AtAHVl0BLAZuewqnpheNoLIDbObD-PWh9an_-d4Dx40,21148
14
+ nost_tools-3.0.0.dist-info/licenses/LICENSE,sha256=aAMU-mTHTKpWkBsg9QhkhCQpEm3Gri7J_fVuJov8s3s,1539
15
+ nost_tools-3.0.0.dist-info/METADATA,sha256=OoZcUL8cm9QG7JNAyF4rVNPIntY1cX8Fsooq-U40dhw,4256
16
+ nost_tools-3.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
+ nost_tools-3.0.0.dist-info/top_level.txt,sha256=LNChUgrv2-wiym12O0r61kY83COjTpTiJ2Ly1Ca58A8,11
18
+ nost_tools-3.0.0.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- nost_tools/__init__.py,sha256=5DeYQxWXibC9y__BvjwL8YC8K4EYMZbAV6E75k5nuL8,870
2
- nost_tools/application.py,sha256=gh48fokVifuhKyAoJylZ2mF_WHW90G9L8j99Ct7j2Pg,64257
3
- nost_tools/application_utils.py,sha256=jiMzuuP6-47UlUO64HhwNvbl6uKvVnsksYgOw7CmxL4,9327
4
- nost_tools/configuration.py,sha256=4WLs1BrHMMvVhSIpJfjVZe-zw04WygAzjiLX2pVXibY,13146
5
- nost_tools/entity.py,sha256=JrSN5rz7h-N9zIQsaJOQVBIYPDfygacedks55fsq_QQ,2802
6
- nost_tools/errors.py,sha256=0JcDlMEkZAya3-5c0rRozLuxp8qF58StG4JgRsaxfKU,344
7
- nost_tools/logger_application.py,sha256=rxPBfyA7Zym5b_EsoSJvT9JWNIVWZX1a-4czFwCqaQ4,7217
8
- nost_tools/managed_application.py,sha256=Xa6qGsNVrr_XNnpJt_-L5PVnpeUy7GXIf0p2aEI4dSE,11673
9
- nost_tools/manager.py,sha256=KqFyE-vv9dPRuVdz_r1SAsPeWt9LwKOTF-a2cJId0rY,24321
10
- nost_tools/observer.py,sha256=D64V0KTvHRPEqbB8q3BosJhoAlpBah2vyBlVbxWQR44,8161
11
- nost_tools/publisher.py,sha256=omU8tb0AXnA6RfhYSh0vnXbJtrRo4ukx1J5ANl4bDLQ,5291
12
- nost_tools/schemas.py,sha256=RZH9LCWSZT8hGteI_Cc5a_w_TyFYx0Kh5mBM02SObrk,20575
13
- nost_tools/simulator.py,sha256=pWfMSarMCuInQTlvlJ5l53w5ZZP6jjyUtY8uWOkbe-4,20062
14
- nost_tools-2.3.0.dist-info/licenses/LICENSE,sha256=aAMU-mTHTKpWkBsg9QhkhCQpEm3Gri7J_fVuJov8s3s,1539
15
- nost_tools-2.3.0.dist-info/METADATA,sha256=6ovwTQnDSv0ODsIsMQcFI5-kN652WW8FYOYlwzg-SH8,4256
16
- nost_tools-2.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
- nost_tools-2.3.0.dist-info/top_level.txt,sha256=LNChUgrv2-wiym12O0r61kY83COjTpTiJ2Ly1Ca58A8,11
18
- nost_tools-2.3.0.dist-info/RECORD,,