nost-tools 2.3.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.3.0 → nost_tools-3.0.0}/PKG-INFO +1 -1
  2. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/__init__.py +2 -2
  3. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/application.py +77 -7
  4. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/configuration.py +0 -2
  5. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/managed_application.py +180 -6
  6. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/manager.py +300 -86
  7. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/observer.py +10 -0
  8. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/publisher.py +11 -0
  9. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/schemas.py +180 -24
  10. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/simulator.py +52 -21
  11. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools.egg-info/PKG-INFO +1 -1
  12. {nost_tools-2.3.0 → nost_tools-3.0.0}/LICENSE +0 -0
  13. {nost_tools-2.3.0 → nost_tools-3.0.0}/README.md +0 -0
  14. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/application_utils.py +0 -0
  15. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/entity.py +0 -0
  16. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/errors.py +0 -0
  17. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools/logger_application.py +0 -0
  18. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools.egg-info/SOURCES.txt +0 -0
  19. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools.egg-info/dependency_links.txt +0 -0
  20. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools.egg-info/requires.txt +0 -0
  21. {nost_tools-2.3.0 → nost_tools-3.0.0}/nost_tools.egg-info/top_level.txt +0 -0
  22. {nost_tools-2.3.0 → nost_tools-3.0.0}/pyproject.toml +0 -0
  23. {nost_tools-2.3.0 → nost_tools-3.0.0}/setup.cfg +0 -0
  24. {nost_tools-2.3.0 → nost_tools-3.0.0}/tests/test_entity.py +0 -0
  25. {nost_tools-2.3.0 → nost_tools-3.0.0}/tests/test_observer.py +0 -0
  26. {nost_tools-2.3.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.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
@@ -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 (
@@ -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
+ )