nost-tools 2.1.1__tar.gz → 2.3.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.1.1 → nost_tools-2.3.0}/PKG-INFO +1 -1
  2. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/__init__.py +1 -1
  3. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/application.py +120 -50
  4. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/application_utils.py +6 -7
  5. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/configuration.py +41 -7
  6. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/managed_application.py +50 -41
  7. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/manager.py +115 -33
  8. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/schemas.py +82 -8
  9. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/simulator.py +1 -3
  10. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools.egg-info/PKG-INFO +1 -1
  11. {nost_tools-2.1.1 → nost_tools-2.3.0}/LICENSE +0 -0
  12. {nost_tools-2.1.1 → nost_tools-2.3.0}/README.md +0 -0
  13. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/entity.py +0 -0
  14. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/errors.py +0 -0
  15. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/logger_application.py +0 -0
  16. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/observer.py +0 -0
  17. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools/publisher.py +0 -0
  18. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools.egg-info/SOURCES.txt +0 -0
  19. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools.egg-info/dependency_links.txt +0 -0
  20. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools.egg-info/requires.txt +0 -0
  21. {nost_tools-2.1.1 → nost_tools-2.3.0}/nost_tools.egg-info/top_level.txt +0 -0
  22. {nost_tools-2.1.1 → nost_tools-2.3.0}/pyproject.toml +0 -0
  23. {nost_tools-2.1.1 → nost_tools-2.3.0}/setup.cfg +0 -0
  24. {nost_tools-2.1.1 → nost_tools-2.3.0}/tests/test_entity.py +0 -0
  25. {nost_tools-2.1.1 → nost_tools-2.3.0}/tests/test_observer.py +0 -0
  26. {nost_tools-2.1.1 → nost_tools-2.3.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.1.1
3
+ Version: 2.3.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.1.1"
1
+ __version__ = "2.3.0"
2
2
 
3
3
  from .application import Application
4
4
  from .application_utils import ConnectionConfig, ModeStatusObserver, TimeStatusPublisher
@@ -48,13 +48,19 @@ class Application:
48
48
  time_status_init (:obj:`datetime`): Scenario time of first time status message
49
49
  """
50
50
 
51
- def __init__(self, app_name: str, app_description: str = None):
51
+ def __init__(
52
+ self,
53
+ app_name: str,
54
+ app_description: str = None,
55
+ setup_signal_handlers: bool = True,
56
+ ):
52
57
  """
53
58
  Initializes a new application.
54
59
 
55
60
  Args:
56
61
  app_name (str): application name
57
62
  app_description (str): application description (optional)
63
+ setup_signal_handlers (bool): whether to set up signal handlers (default: True)
58
64
  """
59
65
  self.simulator = Simulator()
60
66
  self.connection = None
@@ -85,8 +91,12 @@ class Application:
85
91
  self._token_refresh_thread = None
86
92
  self.token_refresh_interval = None
87
93
  self._reconnect_delay = None
94
+ # Offset
95
+ self._wallclock_refresh_thread = None
96
+ self.wallclock_offset_refresh_interval = None
88
97
  # Set up signal handlers for graceful shutdown
89
- self._setup_signal_handlers()
98
+ if setup_signal_handlers:
99
+ self._setup_signal_handlers()
90
100
 
91
101
  def _setup_signal_handlers(self):
92
102
  """
@@ -194,6 +204,42 @@ class Application:
194
204
  self._token_refresh_thread.start()
195
205
  logger.debug("Starting refresh token thread successfully completed.")
196
206
 
207
+ def start_wallclock_refresh_thread(self): # , interval=30, host="pool.ntp.org"):
208
+ """
209
+ Starts a background thread to refresh the wallclock offset periodically.
210
+
211
+ Args:
212
+ interval (int): Seconds between wallclock offset refreshes (default: 3600 seconds/1 hour)
213
+ host (str): NTP host to query (default: 'pool.ntp.org')
214
+ """
215
+ logger.debug("Starting wallclock offset refresh thread.")
216
+
217
+ def refresh_wallclock_periodically():
218
+ while not self._should_stop.wait(
219
+ timeout=self.config.rc.wallclock_offset_properties.wallclock_offset_refresh_interval
220
+ ):
221
+ logger.debug("Wallclock refresh thread is running.")
222
+ try:
223
+ logger.info(
224
+ f"Contacting {self.config.rc.wallclock_offset_properties.ntp_host} to retrieve wallclock offset."
225
+ )
226
+ response = ntplib.NTPClient().request(
227
+ self.config.rc.wallclock_offset_properties.ntp_host,
228
+ version=3,
229
+ timeout=2,
230
+ )
231
+ offset = timedelta(seconds=response.offset)
232
+ self.simulator.set_wallclock_offset(offset)
233
+ logger.info(f"Wallclock offset updated to {offset}.")
234
+ except Exception as e:
235
+ logger.debug(f"Failed to refresh wallclock offset: {e}")
236
+
237
+ self._wallclock_refresh_thread = threading.Thread(
238
+ target=refresh_wallclock_periodically
239
+ )
240
+ self._wallclock_refresh_thread.start()
241
+ logger.debug("Starting wallclock offset refresh thread successfully completed.")
242
+
197
243
  def update_connection_credentials(self, access_token):
198
244
  """
199
245
  Updates the connection credentials with the new access token.
@@ -203,14 +249,33 @@ class Application:
203
249
  """
204
250
  self.connection.update_secret(access_token, "secret")
205
251
 
252
+ def _get_parameters_from_config(self):
253
+ """
254
+ Gets application parameters from configuration or returns None if not available.
255
+ This method can be overridden by subclasses to customize parameter retrieval.
256
+
257
+ Returns:
258
+ object: Configuration parameters object or None
259
+ """
260
+ if self.config and self.config.rc.yaml_file:
261
+ try:
262
+ return getattr(
263
+ self.config.rc.simulation_configuration.execution_parameters,
264
+ "application",
265
+ None,
266
+ )
267
+ except (AttributeError, KeyError):
268
+ return None
269
+ return None
270
+
206
271
  def start_up(
207
272
  self,
208
273
  prefix: str,
209
274
  config: ConnectionConfig,
210
- set_offset: bool = None, # True,
275
+ set_offset: bool = True,
211
276
  time_status_step: timedelta = None,
212
277
  time_status_init: datetime = None,
213
- shut_down_when_terminated: bool = None,
278
+ shut_down_when_terminated: bool = False,
214
279
  ) -> None:
215
280
  """
216
281
  Starts up the application to prepare for scenario execution.
@@ -225,31 +290,45 @@ class Application:
225
290
  time_status_init (:obj:`datetime`): scenario time for first time status message
226
291
  shut_down_when_terminated (bool): True, if the application should shut down when the simulation is terminated
227
292
  """
228
- if (
229
- set_offset is not None
230
- and time_status_step is not None
231
- and time_status_init is not None
232
- and shut_down_when_terminated is not None
233
- ):
293
+ self.config = config
294
+
295
+ if self.config.rc.yaml_file:
296
+ logger.info(
297
+ f"Collecting start up parameters from YAML configuration file: {self.config.rc.yaml_file}"
298
+ )
299
+ parameters = self._get_parameters_from_config()
300
+ if parameters:
301
+ self.set_offset = getattr(parameters, "set_offset", set_offset)
302
+ self.time_status_step = getattr(
303
+ parameters, "time_status_step", time_status_step
304
+ )
305
+ self.time_status_init = getattr(
306
+ parameters, "time_status_init", time_status_init
307
+ )
308
+ self.shut_down_when_terminated = getattr(
309
+ parameters, "shut_down_when_terminated", shut_down_when_terminated
310
+ )
311
+ else:
312
+ logger.warning("No parameters found in configuration, using defaults")
313
+ self.set_offset = set_offset
314
+ self.time_status_step = time_status_step
315
+ self.time_status_init = time_status_init
316
+ self.shut_down_when_terminated = shut_down_when_terminated
317
+ else:
318
+ logger.info(
319
+ f"Collecting start up parameters from user input or default values."
320
+ )
234
321
  self.set_offset = set_offset
235
322
  self.time_status_step = time_status_step
236
323
  self.time_status_init = time_status_init
237
324
  self.shut_down_when_terminated = shut_down_when_terminated
238
- else:
239
- self.config = config
240
- parameters = getattr(
241
- self.config.rc.simulation_configuration.execution_parameters,
242
- self.app_name,
243
- None,
244
- )
245
- self.set_offset = parameters.set_offset
246
- self.time_status_step = parameters.time_status_step
247
- self.time_status_init = parameters.time_status_init
248
- self.shut_down_when_terminated = parameters.shut_down_when_terminated
249
325
 
250
326
  if self.set_offset:
251
- # Set the system clock offset
252
- self.set_wallclock_offset()
327
+ # Start periodic wallclock offset updates instead of one-time call
328
+ 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}."
330
+ )
331
+ self.start_wallclock_refresh_thread()
253
332
 
254
333
  # Set the prefix and configuration parameters
255
334
  self.prefix = prefix
@@ -290,7 +369,6 @@ class Application:
290
369
  locale=config.rc.server_configuration.servers.rabbitmq.locale,
291
370
  blocked_connection_timeout=config.rc.server_configuration.servers.rabbitmq.blocked_connection_timeout,
292
371
  )
293
- logger.info(parameters)
294
372
 
295
373
  # Configure transport layer security (TLS) if needed
296
374
  if self.config.rc.server_configuration.servers.rabbitmq.tls:
@@ -1348,34 +1426,26 @@ class Application:
1348
1426
  )
1349
1427
  else:
1350
1428
  logger.info("Closing token refresh thread completed successfully")
1351
-
1429
+ # Also stop wallclock refresh thread if it exists
1430
+ if (
1431
+ hasattr(self, "_wallclock_refresh_thread")
1432
+ and self._wallclock_refresh_thread
1433
+ and self._wallclock_refresh_thread.is_alive()
1434
+ ):
1435
+ logger.info("Closing wallclock refresh thread.")
1436
+ # Set a timeout to avoid hanging indefinitely
1437
+ self._wallclock_refresh_thread.join(timeout=60.0)
1438
+ # Check if it's still alive after timeout
1439
+ if self._wallclock_refresh_thread.is_alive():
1440
+ logger.warning(
1441
+ "Closing wallclock refresh thread timed out after 60 seconds. "
1442
+ )
1443
+ else:
1444
+ logger.info(
1445
+ "Closing wallclock refresh thread completed successfully"
1446
+ )
1352
1447
  logger.debug("Stop_application completed successfully.")
1353
1448
 
1354
- def set_wallclock_offset(
1355
- self, host="pool.ntp.org", retry_delay_s: int = 5, max_retry: int = 5
1356
- ) -> None:
1357
- """
1358
- Issues a Network Time Protocol (NTP) request to determine the system clock offset.
1359
-
1360
- Args:
1361
- host (str): NTP host (default: 'pool.ntp.org')
1362
- retry_delay_s (int): number of seconds to wait before retrying
1363
- max_retry (int): maximum number of retries allowed
1364
- """
1365
- for i in range(max_retry):
1366
- try:
1367
- logger.info(f"Contacting {host} to retrieve wallclock offset.")
1368
- response = ntplib.NTPClient().request(host, version=3, timeout=2)
1369
- offset = timedelta(seconds=response.offset)
1370
- self.simulator.set_wallclock_offset(offset)
1371
- logger.info(f"Wallclock offset updated to {offset}.")
1372
- return
1373
- except ntplib.NTPException:
1374
- logger.warning(
1375
- f"Could not connect to {host}, attempt #{i+1}/{max_retry} in {retry_delay_s} s."
1376
- )
1377
- time.sleep(retry_delay_s)
1378
-
1379
1449
  def _create_time_status_publisher(
1380
1450
  self, time_status_step: timedelta, time_status_init: datetime
1381
1451
  ) -> None:
@@ -253,10 +253,9 @@ class ModeStatusObserver(Observer):
253
253
  if not isinstance(self.app.prefix, str):
254
254
  raise ValueError(f"Exchange ({self.app.prefix}) must be a string")
255
255
 
256
- # Declare the topic exchange
257
- if self.app.channel.is_open and self.app.connection.is_open:
258
- self.app.send_message(
259
- app_name=self.app.app_name,
260
- app_topics="status.mode",
261
- payload=status.model_dump_json(by_alias=True, exclude_none=True),
262
- )
256
+ # if self.app.channel.is_open and self.app.connection.is_open:
257
+ self.app.send_message(
258
+ app_name=self.app.app_name,
259
+ app_topics="status.mode",
260
+ payload=status.model_dump_json(by_alias=True, exclude_none=True),
261
+ )
@@ -21,10 +21,12 @@ from .schemas import (
21
21
  RuntimeConfig,
22
22
  ServersConfig,
23
23
  SimulationConfig,
24
+ WallclockOffsetProperties,
24
25
  )
25
26
 
26
27
  logger = logging.getLogger(__name__)
27
28
 
29
+
28
30
  class ConnectionConfig:
29
31
  """Connection configuration.
30
32
 
@@ -51,6 +53,7 @@ class ConnectionConfig:
51
53
  password: str = None,
52
54
  rabbitmq_host: str = None,
53
55
  rabbitmq_port: int = None,
56
+ keycloak_authentication: bool = False,
54
57
  keycloak_host: str = None,
55
58
  keycloak_port: int = None,
56
59
  keycloak_realm: str = None,
@@ -59,6 +62,7 @@ class ConnectionConfig:
59
62
  virtual_host: str = None,
60
63
  is_tls: bool = True,
61
64
  yaml_file: str = None,
65
+ app_name: str = None,
62
66
  ):
63
67
  """
64
68
  Initializes a new connection configuration.
@@ -68,6 +72,7 @@ class ConnectionConfig:
68
72
  password (str): client password, provided by NOS-T operator
69
73
  host (str): broker hostname
70
74
  rabbitmq_port (int): RabbitMQ broker port number
75
+ keycloak_authentication (bool): True, if Keycloak IAM authentication is used
71
76
  keycloak_port (int): Keycloak IAM port number
72
77
  keycloak_realm (str): Keycloak realm name
73
78
  client_id (str): Keycloak client ID
@@ -75,12 +80,14 @@ class ConnectionConfig:
75
80
  virtual_host (str): RabbitMQ virtual host
76
81
  is_tls (bool): True, if the connection uses TLS
77
82
  yaml_file (str): Path to the YAML configuration file
83
+ app_name (str): Name of the application to get specific configuration for
78
84
  """
79
85
  self.username = username
80
86
  self.password = password
81
87
  self.rabbitmq_host = rabbitmq_host
82
88
  self.keycloak_host = keycloak_host
83
89
  self.rabbitmq_port = rabbitmq_port
90
+ self.keycloak_authentication = keycloak_authentication
84
91
  self.keycloak_port = keycloak_port
85
92
  self.keycloak_realm = keycloak_realm
86
93
  self.client_id = client_id
@@ -93,6 +100,8 @@ class ConnectionConfig:
93
100
  self.yaml_file = yaml_file
94
101
  self.unique_exchanges = {}
95
102
  self.channel_configs = []
103
+ self.app_name = app_name
104
+ self.app_specific = None
96
105
 
97
106
  self.create_connection_config()
98
107
 
@@ -208,6 +217,29 @@ class ConnectionConfig:
208
217
  except ValidationError as err:
209
218
  raise EnvironmentVariableError(f"Invalid environment variables: {err}")
210
219
 
220
+ def get_app_specific_config(self, app_name):
221
+ """
222
+ Get application-specific configuration from execution.managed_applications if available.
223
+
224
+ Args:
225
+ app_name (str): Name of the application
226
+
227
+ Returns:
228
+ dict: Application-specific configuration parameters if available, otherwise None.
229
+ """
230
+ if not os.path.exists(self.yaml_file):
231
+ raise ConfigurationError("Couldn't load config file (not found)")
232
+
233
+ with open(self.yaml_file, "r", encoding="utf-8") as f:
234
+ yaml_data = yaml.safe_load(f)
235
+
236
+ try:
237
+ return yaml_data["execution"]["managed_applications"][app_name][
238
+ "configuration_parameters"
239
+ ]
240
+ except:
241
+ return None
242
+
211
243
  def load_yaml_config_file(self):
212
244
  """
213
245
  Loads a YAML configuration file and returns the parsed data.
@@ -243,6 +275,9 @@ class ConnectionConfig:
243
275
  ), "Application names do not match the channels defined in the configuration file."
244
276
  except ConfigAssertionError as e:
245
277
  raise ValueError(f"Assertion error: {e}")
278
+ # Load app-specific configuration if app_name is provided
279
+ if self.app_name:
280
+ self.app_specific = self.get_app_specific_config(self.app_name)
246
281
  else:
247
282
  try:
248
283
  self.yaml_config = Config(
@@ -252,6 +287,7 @@ class ConnectionConfig:
252
287
  port=self.rabbitmq_port,
253
288
  virtual_host=self.virtual_host,
254
289
  tls=self.is_tls,
290
+ keycloak_authentication=self.keycloak_authentication,
255
291
  ),
256
292
  keycloak=KeycloakConfig(
257
293
  host=self.keycloak_host,
@@ -281,13 +317,8 @@ class ConnectionConfig:
281
317
  del server_config.execution
282
318
  self.server_config = server_config
283
319
 
284
- if (
285
- self.username is not None
286
- and self.password is not None
287
- and self.client_id is not None
288
- and self.client_secret_key is not None
289
- ):
290
- logger.info("Using provided credentials.")
320
+ if self.username is not None and self.password is not None:
321
+ logger.info("Using user-provided credentials.")
291
322
  self.credentials_config = Credentials(
292
323
  username=self.username,
293
324
  password=self.password,
@@ -298,7 +329,10 @@ class ConnectionConfig:
298
329
  self.load_environment_variables()
299
330
 
300
331
  self.rc = RuntimeConfig(
332
+ wallclock_offset_properties=WallclockOffsetProperties(),
301
333
  credentials=self.credentials_config,
302
334
  server_configuration=server_config,
303
335
  simulation_configuration=self.simulation_config,
336
+ application_configuration=self.app_specific,
337
+ yaml_file=self.yaml_file,
304
338
  )
@@ -32,27 +32,57 @@ class ManagedApplication(Application):
32
32
  time_step (:obj:`timedelta`): scenario time step used in execution
33
33
  """
34
34
 
35
- def __init__(self, app_name: str, app_description: str = None):
35
+ def __init__(
36
+ self,
37
+ app_name: str,
38
+ app_description: str = None,
39
+ setup_signal_handlers: bool = True,
40
+ ):
36
41
  """
37
42
  Initializes a new managed application.
38
43
 
39
44
  Args:
40
45
  app_name (str): application name
41
46
  app_description (str): application description
47
+ setup_signal_handlers (bool): whether to set up signal handlers (default: True)
42
48
  """
43
- super().__init__(app_name, app_description)
49
+ super().__init__(
50
+ app_name, app_description, setup_signal_handlers=setup_signal_handlers
51
+ )
44
52
  self.time_step = None
45
53
  self._sim_start_time = None
46
54
  self._sim_stop_time = None
47
55
 
56
+ def _get_parameters_from_config(self):
57
+ """
58
+ Override to get parameters specific to managed applications
59
+
60
+ Returns:
61
+ object: Configuration parameters for this managed application
62
+ """
63
+ if self.config and self.config.rc.yaml_file:
64
+ try:
65
+ parameters = (
66
+ self.config.rc.simulation_configuration.execution_parameters.managed_applications
67
+ )
68
+ try:
69
+ # Try to get app-specific parameters
70
+ return parameters[self.app_name]
71
+ except KeyError:
72
+ # Fall back to default parameters
73
+ return parameters.get("default")
74
+ except (AttributeError, KeyError):
75
+ return None
76
+ return None
77
+
48
78
  def start_up(
49
79
  self,
50
80
  prefix: str,
51
81
  config: ConnectionConfig,
52
- set_offset: bool = None,
82
+ set_offset: bool = True,
53
83
  time_status_step: timedelta = None,
54
84
  time_status_init: datetime = None,
55
- shut_down_when_terminated: bool = None,
85
+ shut_down_when_terminated: bool = False,
56
86
  time_step: timedelta = None,
57
87
  manager_app_name: str = None,
58
88
  ) -> None:
@@ -70,48 +100,27 @@ class ManagedApplication(Application):
70
100
  time_step (:obj:`timedelta`): scenario time step used in execution (Default: 1 second)
71
101
  manager_app_name (str): manager application name (Default: manager)
72
102
  """
73
- if (
74
- set_offset is not None
75
- and time_status_step is not None
76
- and time_status_init is not None
77
- and shut_down_when_terminated is not None
78
- and time_step is not None
79
- and manager_app_name is not None
80
- ):
81
- self.set_offset = set_offset
82
- self.time_status_step = time_status_step
83
- self.time_status_init = time_status_init
84
- self.shut_down_when_terminated = shut_down_when_terminated
85
- self.time_step = time_step
86
- self.manager_app_name = manager_app_name
87
- else:
88
- self.config = config
89
- parameters = (
90
- self.config.rc.simulation_configuration.execution_parameters.managed_applications
91
- )
92
-
93
- try:
94
- parameters = parameters[self.app_name]
95
- except KeyError:
96
- parameters = parameters["default"]
97
- self.set_offset = parameters.set_offset
98
- self.time_status_step = parameters.time_status_step
99
- self.time_status_init = parameters.time_status_init
100
- self.shut_down_when_terminated = parameters.shut_down_when_terminated
101
- self.time_step = parameters.time_step
102
- self.manager_app_name = parameters.manager_app_name
103
+ self.config = config
103
104
 
104
- # start up base application
105
+ # Call base start_up to handle common parameters
105
106
  super().start_up(
106
107
  prefix,
107
108
  config,
108
- self.set_offset,
109
- self.time_status_step,
110
- self.time_status_init,
111
- self.shut_down_when_terminated,
109
+ set_offset,
110
+ time_status_step,
111
+ time_status_init,
112
+ shut_down_when_terminated,
112
113
  )
113
- self.time_step = self.time_step
114
- self.manager_app_name = self.manager_app_name
114
+
115
+ # Get additional parameters specific to managed applications
116
+ if self.config.rc.yaml_file:
117
+ parameters = self._get_parameters_from_config()
118
+ if parameters:
119
+ self.time_step = parameters.time_step
120
+ self.manager_app_name = parameters.manager_app_name
121
+ else:
122
+ self.time_step = time_step
123
+ self.manager_app_name = manager_app_name
115
124
 
116
125
  # Register callback functions
117
126
  self.add_message_callback(
@@ -13,6 +13,7 @@ from typing import List
13
13
  from pydantic import ValidationError
14
14
 
15
15
  from .application import Application
16
+ from .application_utils import ConnectionConfig
16
17
  from .schemas import (
17
18
  InitCommand,
18
19
  ReadyStatus,
@@ -66,12 +67,22 @@ class Manager(Application):
66
67
  required_apps_status (dict): Ready status for all required applications
67
68
  """
68
69
 
69
- def __init__(self):
70
+ def __init__(
71
+ self,
72
+ app_name: str = "manager",
73
+ app_description: str = None,
74
+ setup_signal_handlers: bool = True,
75
+ ):
70
76
  """
71
77
  Initializes a new manager.
78
+
79
+ Attributes:
80
+ setup_signal_handlers (bool): whether to set up signal handlers (default: True)
72
81
  """
73
82
  # call super class constructor
74
- super().__init__("manager")
83
+ super().__init__(
84
+ app_name, app_description, setup_signal_handlers=setup_signal_handlers
85
+ )
75
86
  self.required_apps_status = {}
76
87
 
77
88
  self.sim_start_time = None
@@ -128,6 +139,60 @@ class Manager(Application):
128
139
  f"Heartbeat check: {remaining:.2f} seconds remaining in sleep"
129
140
  )
130
141
 
142
+ def _get_parameters_from_config(self):
143
+ """
144
+ Override to get parameters specific to manager application
145
+
146
+ Returns:
147
+ object: Configuration parameters for the manager application
148
+ """
149
+ if self.config and self.config.rc.yaml_file:
150
+ try:
151
+ return getattr(
152
+ self.config.rc.simulation_configuration.execution_parameters,
153
+ "manager",
154
+ None,
155
+ )
156
+ except (AttributeError, KeyError):
157
+ return None
158
+ return None
159
+
160
+ def start_up(
161
+ self,
162
+ prefix: str,
163
+ config: ConnectionConfig,
164
+ set_offset: bool = True,
165
+ time_status_step: timedelta = None,
166
+ time_status_init: datetime = None,
167
+ shut_down_when_terminated: bool = False,
168
+ ) -> None:
169
+ """
170
+ Starts up the application by connecting to message broker, starting a background event loop,
171
+ subscribing to manager events, and registering callback functions.
172
+
173
+ Args:
174
+ prefix (str): execution namespace (prefix)
175
+ config (:obj:`ConnectionConfig`): connection configuration
176
+ set_offset (bool): True, if the system clock offset shall be set using a NTP request prior to execution
177
+ time_status_step (:obj:`timedelta`): scenario duration between time status messages
178
+ time_status_init (:obj:`datetime`): scenario time for first time status message
179
+ shut_down_when_terminated (bool): True, if the application should shut down when the simulation is terminated
180
+ """
181
+ self.config = config
182
+
183
+ # Call base start_up to handle common parameters
184
+ super().start_up(
185
+ prefix,
186
+ config,
187
+ set_offset,
188
+ time_status_step,
189
+ time_status_init,
190
+ shut_down_when_terminated,
191
+ )
192
+
193
+ # Additional manager-specific setup: establish the exchange
194
+ self.establish_exchange()
195
+
131
196
  def execute_test_plan(self, *args, **kwargs) -> None:
132
197
  """
133
198
  Starts the test plan execution in a background thread.
@@ -177,8 +242,33 @@ class Manager(Application):
177
242
  init_retry_delay_s (float): number of seconds to wait between initialization commands while waiting for required applications
178
243
  init_max_retry (int): number of initialization commands while waiting for required applications before continuing to execution
179
244
  """
180
- # Initialize parameters from arguments or config
181
- if sim_start_time is not None and sim_stop_time is not None:
245
+ if self.config.rc.yaml_file:
246
+ logger.info(
247
+ f"Collecting execution parameters from YAML configuration file: {self.config.rc.yaml_file}"
248
+ )
249
+ parameters = getattr(
250
+ self.config.rc.simulation_configuration.execution_parameters,
251
+ self.app_name,
252
+ None,
253
+ )
254
+ self.sim_start_time = parameters.sim_start_time
255
+ self.sim_stop_time = parameters.sim_stop_time
256
+ self.start_time = parameters.start_time
257
+ self.time_step = parameters.time_step
258
+ self.time_scale_factor = parameters.time_scale_factor
259
+ self.time_scale_updates = parameters.time_scale_updates
260
+ self.time_status_step = parameters.time_status_step
261
+ self.time_status_init = parameters.time_status_init
262
+ self.command_lead = parameters.command_lead
263
+ self.required_apps = [
264
+ app for app in parameters.required_apps if app != self.app_name
265
+ ]
266
+ self.init_retry_delay_s = parameters.init_retry_delay_s
267
+ self.init_max_retry = parameters.init_max_retry
268
+ else:
269
+ logger.info(
270
+ f"Collecting execution parameters from user input or default values."
271
+ )
182
272
  self.sim_start_time = sim_start_time
183
273
  self.sim_stop_time = sim_stop_time
184
274
  self.start_time = start_time
@@ -191,34 +281,17 @@ class Manager(Application):
191
281
  self.required_apps = required_apps
192
282
  self.init_retry_delay_s = init_retry_delay_s
193
283
  self.init_max_retry = init_max_retry
194
- else:
195
- if self.config.rc:
196
- logger.info("Retrieving execution parameters from YAML file.")
197
- parameters = getattr(
198
- self.config.rc.simulation_configuration.execution_parameters,
199
- self.app_name,
200
- None,
201
- )
202
- self.sim_start_time = parameters.sim_start_time
203
- self.sim_stop_time = parameters.sim_stop_time
204
- self.start_time = parameters.start_time
205
- self.time_step = parameters.time_step
206
- self.time_scale_factor = parameters.time_scale_factor
207
- self.time_scale_updates = parameters.time_scale_updates
208
- self.time_status_step = parameters.time_status_step
209
- self.time_status_init = parameters.time_status_init
210
- self.command_lead = parameters.command_lead
211
- self.required_apps = [
212
- app for app in parameters.required_apps if app != self.app_name
213
- ]
214
- self.init_retry_delay_s = parameters.init_retry_delay_s
215
- self.init_max_retry = parameters.init_max_retry
216
- else:
217
- raise ValueError(
218
- "No configuration runtime. Please provide simulation start and stop times."
219
- )
220
284
 
221
- self.establish_exchange()
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
222
295
 
223
296
  # Set up tracking of required applications
224
297
  self.required_apps_status = dict(
@@ -490,8 +563,17 @@ class Manager(Application):
490
563
  app_topics="stop",
491
564
  payload=command.model_dump_json(by_alias=True),
492
565
  )
493
- # update the execution end time
494
- self.simulator.set_end_time(sim_stop_time)
566
+
567
+ # Update the execution end time if simulator is in EXECUTING mode
568
+ if self.simulator.get_mode() == Mode.EXECUTING:
569
+ try:
570
+ self.simulator.set_end_time(sim_stop_time)
571
+ except RuntimeError as e:
572
+ logger.warning(f"Could not set simulator end time: {e}")
573
+ else:
574
+ logger.debug(
575
+ "Skipping setting simulator end time as simulator is not in EXECUTING mode"
576
+ )
495
577
 
496
578
  def update(self, time_scale_factor: float, sim_update_time: datetime) -> None:
497
579
  """
@@ -307,8 +307,34 @@ class ServersConfig(BaseModel):
307
307
  return values
308
308
 
309
309
 
310
+ class WallclockOffsetProperties(BaseModel):
311
+ """
312
+ Properties to report wallclock offset.
313
+ """
314
+
315
+ wallclock_offset_refresh_interval: Optional[int] = Field(
316
+ 10800, description="Wallclock offset refresh interval, in seconds."
317
+ )
318
+ ntp_host: Optional[str] = Field(
319
+ "pool.ntp.org", description="NTP host for wallclock offset synchronization."
320
+ )
321
+
322
+
310
323
  class GeneralConfig(BaseModel):
311
- prefix: str = Field("nost", description="Execution prefix.")
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
+ )
312
338
 
313
339
 
314
340
  class ManagerConfig(BaseModel):
@@ -322,7 +348,7 @@ class ManagerConfig(BaseModel):
322
348
  description="Time step for the simulation.",
323
349
  )
324
350
  time_scale_factor: float = Field(1.0, description="Time scale factor.")
325
- time_scale_updates: List[str] = Field(
351
+ time_scale_updates: List[TimeScaleUpdateSchema] = Field(
326
352
  default_factory=list, description="List of time scale updates."
327
353
  )
328
354
  time_status_step: Optional[timedelta] = Field(None, description="Time status step.")
@@ -339,6 +365,10 @@ class ManagerConfig(BaseModel):
339
365
  shut_down_when_terminated: bool = Field(
340
366
  False, description="Shut down when terminated."
341
367
  )
368
+ is_scenario_time_step: bool = Field(
369
+ True,
370
+ description="If True, time_step is in scenario time and won't be scaled. If False, time_step is in wallclock time and will be scaled by the time scale factor.",
371
+ )
342
372
  is_scenario_time_status_step: bool = Field(
343
373
  True,
344
374
  description="If True, time_status_step is in scenario time and won't be scaled. If False, time_status_step is in wallclock time and will be scaled by the time scale factor.",
@@ -348,6 +378,16 @@ class ManagerConfig(BaseModel):
348
378
  def scale_time(cls, values):
349
379
  time_scale_factor = values.get("time_scale_factor", 1.0)
350
380
 
381
+ if "time_step" in values and not values.get("is_scenario_time_step", True):
382
+ time_step = values["time_step"]
383
+ if isinstance(time_step, str):
384
+ hours, minutes, seconds = map(int, time_step.split(":"))
385
+ time_step = timedelta(hours=hours, minutes=minutes, seconds=seconds)
386
+ if isinstance(time_step, timedelta):
387
+ values["time_step"] = timedelta(
388
+ seconds=time_step.total_seconds() * time_scale_factor
389
+ )
390
+
351
391
  if "time_status_step" in values and not values.get(
352
392
  "is_scenario_time_status_step", True
353
393
  ):
@@ -427,7 +467,27 @@ class LoggerApplicationConfig(BaseModel):
427
467
  timedelta(seconds=10), description="Time status step."
428
468
  )
429
469
  time_status_init: Optional[datetime] = Field(
430
- datetime(2019, 3, 1, 0, 0, 0), description="Time status init."
470
+ datetime.now(), description="Time status init."
471
+ )
472
+ shut_down_when_terminated: Optional[bool] = Field(
473
+ False, description="Shut down when terminated."
474
+ )
475
+ manager_app_name: Optional[str] = Field(
476
+ "manager", description="Manager application name."
477
+ )
478
+
479
+
480
+ class ApplicationConfig(BaseModel):
481
+ set_offset: Optional[bool] = Field(True, description="Set offset.")
482
+ time_scale_factor: Optional[float] = Field(1.0, description="Time scale factor.")
483
+ time_step: Optional[timedelta] = Field(
484
+ timedelta(seconds=1), description="Time step for swe_change."
485
+ )
486
+ time_status_step: Optional[timedelta] = Field(
487
+ timedelta(seconds=10), description="Time status step."
488
+ )
489
+ time_status_init: Optional[datetime] = Field(
490
+ datetime.now(), description="Time status init."
431
491
  )
432
492
  shut_down_when_terminated: Optional[bool] = Field(
433
493
  False, description="Shut down when terminated."
@@ -438,7 +498,9 @@ class LoggerApplicationConfig(BaseModel):
438
498
 
439
499
 
440
500
  class ExecConfig(BaseModel):
441
- general: GeneralConfig
501
+ general: Optional[GeneralConfig] = Field(
502
+ None, description="General configuration for the execution."
503
+ )
442
504
  manager: Optional[ManagerConfig] = Field(None, description="Manager configuration.")
443
505
  managed_applications: Optional[Dict[str, ManagedApplicationConfig]] = Field(
444
506
  default_factory=lambda: {"default": ManagedApplicationConfig()},
@@ -447,6 +509,9 @@ class ExecConfig(BaseModel):
447
509
  logger_application: Optional[LoggerApplicationConfig] = Field(
448
510
  None, description="Logger application configuration."
449
511
  )
512
+ application: Optional[ApplicationConfig] = Field(
513
+ None, description="Application configuration."
514
+ )
450
515
 
451
516
 
452
517
  class Config(BaseModel):
@@ -473,11 +538,11 @@ class ChannelConfig(BaseModel):
473
538
 
474
539
 
475
540
  class Credentials(BaseModel):
476
- username: str = Field(..., description="Username for authentication.")
477
- password: str = Field(..., description="Password for authentication.")
478
- client_id: Optional[str] = Field("", description="Client ID for authentication.")
541
+ username: Optional[str] = Field("admin", description="Username for authentication.")
542
+ password: Optional[str] = Field("admin", description="Password for authentication.")
543
+ client_id: Optional[str] = Field(None, description="Client ID for authentication.")
479
544
  client_secret_key: Optional[str] = Field(
480
- "", description="Client secret key for authentication."
545
+ None, description="Client secret key for authentication."
481
546
  )
482
547
 
483
548
 
@@ -493,6 +558,9 @@ class SimulationConfig(BaseModel):
493
558
 
494
559
 
495
560
  class RuntimeConfig(BaseModel):
561
+ wallclock_offset_properties: WallclockOffsetProperties = Field(
562
+ ..., description="Properties for wallclock offset."
563
+ )
496
564
  credentials: Credentials = Field(..., description="Credentials for authentication.")
497
565
  server_configuration: Config = (
498
566
  Field(..., description="Simulation configuration."),
@@ -500,3 +568,9 @@ class RuntimeConfig(BaseModel):
500
568
  simulation_configuration: SimulationConfig = Field(
501
569
  ..., description="Simulation configuration."
502
570
  )
571
+ application_configuration: Optional[Dict] = Field(
572
+ None, description="Application-specific, user-provided configuration."
573
+ )
574
+ yaml_file: Optional[str] = Field(
575
+ None, description="Path to the YAML file containing the configuration."
576
+ )
@@ -516,9 +516,7 @@ class Simulator(Observable):
516
516
  Args:
517
517
  wallclock_offset(:obj:`timedelta`): difference between system clock and trusted wallclock source
518
518
  """
519
- if self._mode == Mode.EXECUTING:
520
- raise RuntimeError("Cannot set wallclock offset: simulator is executing")
521
- elif self._mode == Mode.TERMINATING:
519
+ if self._mode == Mode.TERMINATING:
522
520
  raise RuntimeError("Cannot set wallclock offset: simulator is terminating")
523
521
  self._wallclock_offset = wallclock_offset
524
522
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nost_tools
3
- Version: 2.1.1
3
+ Version: 2.3.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