nost-tools 2.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/manager.py ADDED
@@ -0,0 +1,472 @@
1
+ """
2
+ Provides a base manager that coordinates a distributed scenario execution.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ import time
9
+ import traceback
10
+ from datetime import datetime, timedelta
11
+ from typing import List
12
+
13
+ from pydantic import ValidationError
14
+
15
+ from .application import Application
16
+ from .schemas import (
17
+ InitCommand,
18
+ ReadyStatus,
19
+ StartCommand,
20
+ StopCommand,
21
+ TimeStatus,
22
+ UpdateCommand,
23
+ )
24
+ from .simulator import Mode
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class TimeScaleUpdate(object):
30
+ """
31
+ Provides a scheduled update to the simulation time scale factor by sending a message at the designated sim_update_time
32
+ to change the time_scale_factor to the indicated value.
33
+
34
+ Attributes:
35
+ time_scale_factor (float): scenario seconds per wallclock second
36
+ sim_update_time (:obj:`datetime`): scenario time that the update will occur
37
+ """
38
+
39
+ def __init__(self, time_scale_factor: float, sim_update_time: datetime):
40
+ """
41
+ Instantiates a new time scale update.
42
+
43
+ Args:
44
+ time_scale_factor (float): scenario seconds per wallclock second
45
+ sim_update_time (:obj:`datetime`): scenario time that the update will occur
46
+ """
47
+ self.time_scale_factor = time_scale_factor
48
+ self.sim_update_time = sim_update_time
49
+
50
+
51
+ class Manager(Application):
52
+ """
53
+ NOS-T Manager Application.
54
+
55
+ This object class defines a manager to orchestrate test run executions.
56
+
57
+ Attributes:
58
+ prefix (str): The test run namespace (prefix)
59
+ simulator (:obj:`Simulator`): Application simulator
60
+ client (:obj:`Client`): Application MQTT client
61
+ time_step (:obj:`timedelta`): Scenario time step used in execution
62
+ time_status_step (:obj:`timedelta`): Scenario duration between time status messages
63
+ time_status_init (:obj:`datetime`): Scenario time of first time status message
64
+ app_name (str): Test run application name
65
+ app_description (str): Test run application description (optional)
66
+ required_apps_status (dict): Ready status for all required applications
67
+ """
68
+
69
+ def __init__(self):
70
+ """
71
+ Initializes a new manager.
72
+ """
73
+ # call super class constructor
74
+ super().__init__("manager")
75
+ self.required_apps_status = {}
76
+
77
+ self.sim_start_time = None
78
+ self.sim_stop_time = None
79
+ start_time = None
80
+ time_step = None
81
+ time_scale_factor = None
82
+ time_scale_updates = None
83
+ time_status_step = None
84
+ time_status_init = None
85
+ command_lead = None
86
+ required_apps = None
87
+ init_retry_delay_s = None
88
+ init_max_retry = None
89
+
90
+ def establish_exchange(self):
91
+ """
92
+ Establishes the exchange for the manager application.
93
+ """
94
+ self.channel.exchange_declare(
95
+ exchange=self.prefix,
96
+ exchange_type="topic",
97
+ durable=False,
98
+ auto_delete=True,
99
+ )
100
+
101
+ def execute_test_plan(
102
+ self,
103
+ sim_start_time: datetime = None,
104
+ sim_stop_time: datetime = None,
105
+ start_time: datetime = None,
106
+ time_step: timedelta = timedelta(seconds=1),
107
+ time_scale_factor: float = 1.0,
108
+ time_scale_updates: List[TimeScaleUpdate] = [],
109
+ time_status_step: timedelta = None,
110
+ time_status_init: datetime = None,
111
+ command_lead: timedelta = timedelta(seconds=0),
112
+ required_apps: List[str] = [],
113
+ init_retry_delay_s: int = 5,
114
+ init_max_retry: int = 5,
115
+ ) -> None:
116
+ """
117
+ A comprehensive command to start a test run execution.
118
+
119
+ Publishes an initialize, start, zero or more updates, and a stop message in one condensed JSON script for testing purposes,
120
+ or consistent test-case runs.
121
+
122
+ Args:
123
+ sim_start_time (:obj:`datetime`): scenario time at which to start execution
124
+ sim_stop_time (:obj:`datetime`): scenario time at which to stop execution
125
+ start_time (:obj:`datetime`): wallclock time at which to start execution (default: now)
126
+ time_step (:obj:`timedelta`): scenario time step used in execution (default: 1 second)
127
+ time_scale_factor (float): scenario seconds per wallclock second (default: 1.0)
128
+ time_scale_updates (list(:obj:`TimeScaleUpdate`)): list of scheduled time scale updates (default: [])
129
+ time_status_step (:obj:`timedelta`): scenario duration between time status messages
130
+ time_status_init (:obj:`datetime`): scenario time of first time status message
131
+ command_lead (:obj:`timedelta`): wallclock lead time between command and action (default: 0 seconds)
132
+ required_apps (list(str)): list of application names required to continue with the execution
133
+ init_retry_delay_s (float): number of seconds to wait between initialization commands while waiting for required applications
134
+ init_max_retry (int): number of initialization commands while waiting for required applications before continuing to execution
135
+ """
136
+ if sim_start_time is not None and sim_stop_time is not None:
137
+ self.sim_start_time = sim_start_time
138
+ self.sim_stop_time = sim_stop_time
139
+ self.start_time = start_time
140
+ self.time_step = time_step
141
+ self.time_scale_factor = time_scale_factor
142
+ self.time_scale_updates = time_scale_updates
143
+ self.time_status_step = time_status_step
144
+ self.time_status_init = time_status_init
145
+ self.command_lead = command_lead
146
+ self.required_apps = required_apps
147
+ self.init_retry_delay_s = init_retry_delay_s
148
+ self.init_max_retry = init_max_retry
149
+ else:
150
+ if self.config.rc:
151
+ logger.info("Retrieving execution parameters from YAML file.")
152
+ parameters = getattr(
153
+ self.config.rc.simulation_configuration.execution_parameters,
154
+ self.app_name,
155
+ None,
156
+ )
157
+ self.sim_start_time = parameters.sim_start_time
158
+ self.sim_stop_time = parameters.sim_stop_time
159
+ self.start_time = parameters.start_time
160
+ self.time_step = parameters.time_step
161
+ self.time_scale_factor = parameters.time_scale_factor
162
+ self.time_scale_updates = parameters.time_scale_updates
163
+ self.time_status_step = parameters.time_status_step
164
+ self.time_status_init = parameters.time_status_init
165
+ self.command_lead = parameters.command_lead
166
+ # self.required_apps = (
167
+ # self.config.rc.simulation_configuration.execution_parameters.required_apps
168
+ # )
169
+ self.required_apps = [
170
+ app for app in parameters.required_apps if app != self.app_name
171
+ ]
172
+ self.init_retry_delay_s = parameters.init_retry_delay_s
173
+ self.init_max_retry = parameters.init_max_retry
174
+ else:
175
+ raise ValueError(
176
+ "No configuration runtime. Please provide simulation start and stop times."
177
+ )
178
+ ####
179
+ self.establish_exchange()
180
+ # if self.predefined_exchanges_queues:
181
+ # self.declare_exchange()
182
+ # self.declare_bind_queue()
183
+ ####
184
+
185
+ self.required_apps_status = dict(
186
+ zip(self.required_apps, [False] * len(self.required_apps))
187
+ )
188
+ self.add_message_callback("*", "status.ready", self.on_app_ready_status)
189
+ self.add_message_callback("*", "status.time", self.on_app_time_status)
190
+
191
+ self._create_time_status_publisher(self.time_status_step, self.time_status_init)
192
+ for i in range(self.init_max_retry):
193
+ # issue the init command
194
+ self.init(self.sim_start_time, self.sim_stop_time, self.required_apps)
195
+ next_try = self.simulator.get_wallclock_time() + timedelta(
196
+ seconds=self.init_retry_delay_s
197
+ )
198
+ # wait until all required apps are ready
199
+ while (
200
+ not all([self.required_apps_status[app] for app in self.required_apps])
201
+ and self.simulator.get_wallclock_time() < next_try
202
+ ):
203
+ time.sleep(0.001)
204
+ # self.remove_message_callback("*", "status.ready")
205
+ # self.remove_message_callback()
206
+ # configure start time
207
+ if self.start_time is None:
208
+ self.start_time = self.simulator.get_wallclock_time() + self.command_lead
209
+ # sleep until the start command needs to be issued
210
+ time.sleep(
211
+ max(
212
+ 0,
213
+ (
214
+ (self.start_time - self.simulator.get_wallclock_time())
215
+ - self.command_lead
216
+ )
217
+ / timedelta(seconds=1),
218
+ )
219
+ )
220
+ # issue the start command
221
+ self.start(
222
+ self.sim_start_time,
223
+ self.sim_stop_time,
224
+ self.start_time,
225
+ self.time_step,
226
+ self.time_scale_factor,
227
+ self.time_status_step,
228
+ self.time_status_init,
229
+ )
230
+ # wait for simulation to start executing
231
+ while self.simulator.get_mode() != Mode.EXECUTING:
232
+ time.sleep(0.001)
233
+ for update in self.time_scale_updates:
234
+ update_time = self.simulator.get_wallclock_time_at_simulation_time(
235
+ update.sim_update_time
236
+ )
237
+ # sleep until the update command needs to be issued
238
+ time.sleep(
239
+ max(
240
+ 0,
241
+ (
242
+ (update_time - self.simulator.get_wallclock_time())
243
+ - self.command_lead
244
+ )
245
+ / timedelta(seconds=1),
246
+ )
247
+ )
248
+ # issue the update command
249
+ self.update(
250
+ update.time_scale_factor, update.sim_update_time, self.required_apps
251
+ )
252
+ # wait until the update command takes effect
253
+ while self.simulator.get_time_scale_factor() != update.time_scale_factor:
254
+ time.sleep(self.command_lead / timedelta(seconds=1) / 100)
255
+ end_time = self.simulator.get_wallclock_time_at_simulation_time(
256
+ self.simulator.get_end_time()
257
+ )
258
+ # sleep until the stop command should be issued
259
+ time.sleep(
260
+ max(
261
+ 0,
262
+ ((end_time - self.simulator.get_wallclock_time()) - self.command_lead)
263
+ / timedelta(seconds=1),
264
+ )
265
+ )
266
+ # issue the stop command
267
+ self.stop(self.sim_stop_time)
268
+
269
+ def on_app_ready_status(self, ch, method, properties, body) -> None:
270
+ """
271
+ Callback to handle a message containing an application ready status.
272
+
273
+ Args:
274
+ ch (:obj:`pika.channel.Channel`): The channel object used to communicate with the RabbitMQ server.
275
+ method (:obj:`pika.spec.Basic.Deliver`): Delivery-related information such as delivery tag, exchange, and routing key.
276
+ properties (:obj:`pika.BasicProperties`): Message properties including content type, headers, and more.
277
+ body (bytes): The actual message body sent, containing the message payload.
278
+ """
279
+ try:
280
+ # split the message topic into components (prefix/app_name/...)
281
+ topic_parts = method.routing_key.split(".")
282
+ message = body.decode("utf-8")
283
+ # check if app_name is monitored in the ready_status dict
284
+ if len(topic_parts) > 1 and topic_parts[1] in self.required_apps_status:
285
+ # validate if message is a valid JSON
286
+ try:
287
+ # update the ready status based on the payload value
288
+ self.required_apps_status[topic_parts[1]] = (
289
+ ReadyStatus.model_validate_json(message).properties.ready
290
+ )
291
+ except json.JSONDecodeError:
292
+ logger.error(f"Invalid JSON format: {message}")
293
+ except ValidationError as e:
294
+ logger.error(f"Validation error: {e}")
295
+ except Exception as e:
296
+ logger.error(
297
+ f"Exception (topic: {method.routing_key}, payload: {message}): {e}"
298
+ )
299
+ print(traceback.format_exc())
300
+
301
+ def on_app_time_status(self, ch, method, properties, body) -> None:
302
+ """
303
+ Callback to handle a message containing an application time status.
304
+
305
+ Args:
306
+ ch (:obj:`pika.channel.Channel`): The channel object used to communicate with the RabbitMQ server.
307
+ method (:obj:`pika.spec.Basic.Deliver`): Delivery-related information such as delivery tag, exchange, and routing key.
308
+ properties (:obj:`pika.BasicProperties`): Message properties including content type, headers, and more.
309
+ body (bytes): The actual message body sent, containing the message payload.
310
+ """
311
+ try:
312
+ # split the message topic into components (prefix/app_name/...)
313
+ topic_parts = method.routing_key.split(".")
314
+ message = body.decode("utf-8")
315
+ # validate if message is a valid JSON
316
+ try:
317
+ # parse the message payload properties
318
+ props = TimeStatus.model_validate_json(message).properties
319
+ wallclock_delta = self.simulator.get_wallclock_time() - props.time
320
+ scenario_delta = self.simulator.get_time() - props.sim_time
321
+ if len(topic_parts) > 1:
322
+ logger.info(
323
+ f"Application {topic_parts[1]} latency: {scenario_delta} (scenario), {wallclock_delta} (wallclock)"
324
+ )
325
+ except json.JSONDecodeError:
326
+ logger.error(f"Invalid JSON format: {message}")
327
+ except ValidationError as e:
328
+ logger.error(f"Validation error: {e}")
329
+ except Exception as e:
330
+ logger.error(
331
+ f"Exception (topic: {method.routing_key}, payload: {message}): {e}"
332
+ )
333
+ print(traceback.format_exc())
334
+
335
+ def init(
336
+ self,
337
+ sim_start_time: datetime,
338
+ sim_stop_time: datetime,
339
+ required_apps: List[str] = [],
340
+ ) -> None:
341
+ """
342
+ Publishes an initialize command to initialize a test run execution.
343
+
344
+ Args:
345
+ sim_start_time (:obj:`datetime`): Earliest possible scenario start time
346
+ sim_stop_time (:obj:`datetime`): Latest possible scenario end time
347
+ required_apps (list(str)): List of required apps
348
+ """
349
+ # publish init command message
350
+ command = InitCommand.model_validate(
351
+ {
352
+ "taskingParameters": {
353
+ "simStartTime": sim_start_time,
354
+ "simStopTime": sim_stop_time,
355
+ "requiredApps": required_apps,
356
+ }
357
+ }
358
+ )
359
+ logger.info(
360
+ f"Sending initialize command {command.model_dump_json(by_alias=True)}."
361
+ )
362
+ self.send_message(
363
+ app_name=self.app_name,
364
+ app_topics="init",
365
+ payload=command.model_dump_json(by_alias=True),
366
+ )
367
+ # logger.info(f"Declared Queues: {self.declared_queues}")
368
+ # logger.info(f"Declared Exchanges: {self.declared_exchanges}")
369
+
370
+ def start(
371
+ self,
372
+ sim_start_time: datetime,
373
+ sim_stop_time: datetime,
374
+ start_time: datetime = None,
375
+ time_step: timedelta = timedelta(seconds=1),
376
+ time_scale_factor: float = 1.0,
377
+ time_status_step: timedelta = None,
378
+ time_status_init: datetime = None,
379
+ ) -> None:
380
+ """
381
+
382
+ Command to start a test run execution by starting the simulator execution with all necessary parameters and publishing
383
+ a start command, which can be received by the connected applications.
384
+
385
+ Args:
386
+ sim_start_time (:obj:`datetime`): Scenario time at which to start execution
387
+ sim_stop_time (:obj:`datetime`): Scenario time at which to stop execution
388
+ start_time (:obj:`datetime`): Wallclock time at which to start execution (default: now)
389
+ time_step (:obj:`timedelta`): Scenario time step used in execution (default: 1 second)
390
+ time_scale_factor (float): Scenario seconds per wallclock second (default: 1.0)
391
+ time_status_step (:obj:`timedelta`): Scenario duration between time status messages
392
+ time_status_init (:obj:`datetime`): Scenario time of first time status message
393
+ """
394
+ if start_time is None:
395
+ start_time = self.simulator.get_wallclock_time()
396
+ self.time_status_step = time_status_step
397
+ self.time_status_init = time_status_init
398
+ # publish a start command message
399
+ command = StartCommand.model_validate(
400
+ {
401
+ "taskingParameters": {
402
+ "startTime": start_time,
403
+ "simStartTime": sim_start_time,
404
+ "simStopTime": sim_stop_time,
405
+ "timeScalingFactor": time_scale_factor,
406
+ }
407
+ }
408
+ )
409
+ logger.info(f"Sending start command {command.model_dump_json(by_alias=True)}.")
410
+ self.send_message(
411
+ app_name=self.app_name,
412
+ app_topics="start",
413
+ payload=command.model_dump_json(by_alias=True),
414
+ )
415
+ exec_thread = threading.Thread(
416
+ target=self.simulator.execute,
417
+ kwargs={
418
+ "init_time": sim_start_time,
419
+ "duration": sim_stop_time - sim_start_time,
420
+ "time_step": time_step,
421
+ "wallclock_epoch": start_time,
422
+ "time_scale_factor": time_scale_factor,
423
+ },
424
+ )
425
+ exec_thread.start()
426
+
427
+ def stop(self, sim_stop_time: datetime) -> None:
428
+ """
429
+ Command to stop a test run execution by updating the execution end time and publishing a stop command.
430
+
431
+ Args:
432
+ sim_stop_time (:obj:`datetime`): Scenario time at which to stop execution.
433
+ """
434
+ # publish a stop command message
435
+ command = StopCommand.model_validate(
436
+ {"taskingParameters": {"simStopTime": sim_stop_time}}
437
+ )
438
+ logger.info(f"Sending stop command {command.model_dump_json(by_alias=True)}.")
439
+ self.send_message(
440
+ app_name=self.app_name,
441
+ app_topics="stop",
442
+ payload=command.model_dump_json(by_alias=True),
443
+ )
444
+ # update the execution end time
445
+ self.simulator.set_end_time(sim_stop_time)
446
+
447
+ def update(self, time_scale_factor: float, sim_update_time: datetime) -> None:
448
+ """
449
+ Command to update the time scaling factor for a test run execution by updating the execution time scale factor,
450
+ and publishing an update command.
451
+
452
+ Args:
453
+ time_scale_factor (float): scenario seconds per wallclock second
454
+ sim_update_time (:obj:`datetime`): scenario time at which to update
455
+ """
456
+ # publish an update command message
457
+ command = UpdateCommand.model_validate(
458
+ {
459
+ "taskingParameters": {
460
+ "simUpdateTime": sim_update_time,
461
+ "timeScalingFactor": time_scale_factor,
462
+ }
463
+ }
464
+ )
465
+ logger.info(f"Sending update command {command.model_dump_json(by_alias=True)}.")
466
+ self.send_message(
467
+ app_name=self.app_name,
468
+ app_topics="update",
469
+ payload=command.model_dump_json(by_alias=True),
470
+ )
471
+ # update the execution time scale factor
472
+ self.simulator.set_time_scale_factor(time_scale_factor, sim_update_time)
nost_tools/observer.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ Provides base classes that implement the observer pattern to loosely couple an observable and observer.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from datetime import datetime, timezone
7
+ from typing import List, Optional, Union
8
+
9
+
10
+ class Observer(ABC):
11
+ """
12
+ Abstract base class that can be notified of property changes from an associated :obj:`Observable`.
13
+ """
14
+
15
+ @abstractmethod
16
+ def on_change(
17
+ self, source: object, property_name: str, old_value: object, new_value: object
18
+ ) -> None:
19
+ """Callback notifying of a change.
20
+
21
+ Args:
22
+ source (object): object that triggered a property change
23
+ property_name (str): name of the changed property
24
+ old_value (object): old value of the named property
25
+ new_value (object): new value of the named property
26
+ """
27
+ pass
28
+
29
+
30
+ class RecordingObserver(Observer):
31
+ """
32
+ Observer that records all changes.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ property_filters: Optional[Union[str, List[str]]] = None,
38
+ timestamped: bool = False,
39
+ ):
40
+ """
41
+ Initializes a new recording obsever.
42
+
43
+ Args:
44
+ properties (Optional[Union[str,List[str]]]): optional list of property names to record
45
+ timestamped (bool): True, if the changes shall be timestamped
46
+ """
47
+ if isinstance(property_filters, str):
48
+ self.property_filters = [property_filters]
49
+ else:
50
+ self.property_filters = property_filters
51
+ self.changes = []
52
+ self.timestamped = timestamped
53
+
54
+ def on_change(
55
+ self, source: object, property_name: str, old_value: object, new_value: object
56
+ ) -> None:
57
+ """Callback notifying of a change.
58
+
59
+ Args:
60
+ source (object): object that triggered a property change
61
+ property_name (str): name of the changed property
62
+ old_value (object): old value of the named property
63
+ new_value (object): new value of the named property
64
+ """
65
+ if self.property_filters is None or property_name in self.property_filters:
66
+ change = {
67
+ "source": source,
68
+ "property_name": property_name,
69
+ "old_value": old_value,
70
+ "new_value": new_value,
71
+ }
72
+ if self.timestamped:
73
+ change["time"] = datetime.now(tz=timezone.utc)
74
+ self.changes.append(change)
75
+
76
+
77
+ class Observable(object):
78
+ """
79
+ Base class that can register (add/remove) and notify observers of property changes.
80
+ """
81
+
82
+ def __init__(self):
83
+ """
84
+ Initializes a new observable.
85
+ """
86
+ # list of observers to be notified of events
87
+ self._observers = []
88
+
89
+ def add_observer(self, observer: Observer) -> None:
90
+ """
91
+ Adds an observer to this observable.
92
+
93
+ Args:
94
+ observer (:obj:`Observer`): observer to be added
95
+ """
96
+ self._observers.append(observer)
97
+
98
+ def remove_observer(self, observer: Observer) -> Observer:
99
+ """
100
+ Removes an observer from this observable.
101
+
102
+ Args:
103
+ observer (:obj:`Observer`): obsever to be removed
104
+
105
+ Returns:
106
+ :obj:`Observer`: removed observer
107
+ """
108
+ return self._observers.remove(observer)
109
+
110
+ def notify_observers(
111
+ self, property_name: str, old_value: object, new_value: object
112
+ ) -> None:
113
+ """
114
+ Notifies observers of a property change.
115
+
116
+ Args:
117
+ property_name (str): name of the changed property
118
+ old_value (object): old value of the named property
119
+ new_value (object): new value of the named property
120
+ """
121
+ if old_value != new_value:
122
+ for observer in self._observers:
123
+ observer.on_change(self, property_name, old_value, new_value)
124
+
125
+
126
+ # Add after the existing Observer class
127
+ class MessageObserver(ABC):
128
+ """
129
+ Abstract base class for message observers that can receive RabbitMQ messages.
130
+ """
131
+
132
+ @abstractmethod
133
+ def on_message(self, ch, method, properties, body) -> None:
134
+ """Callback for when a message is received.
135
+
136
+ Args:
137
+ ch: Channel object
138
+ method: Method frame
139
+ properties: Message properties
140
+ body: Message body
141
+ """
142
+ pass
143
+
144
+
145
+ class MessageObservable(Observable):
146
+ """
147
+ Observable that can notify observers of received messages.
148
+ """
149
+
150
+ def __init__(self):
151
+ """Initialize message observable"""
152
+ super().__init__()
153
+ self._message_observers = []
154
+
155
+ def add_message_observer(self, observer: MessageObserver) -> None:
156
+ """Add a message observer.
157
+
158
+ Args:
159
+ observer (MessageObserver): The observer to add
160
+ """
161
+ self._message_observers.append(observer)
162
+
163
+ def remove_message_observer(self, observer: MessageObserver) -> None:
164
+ """Remove a message observer.
165
+
166
+ Args:
167
+ observer (MessageObserver): The observer to remove
168
+ """
169
+ self._message_observers.remove(observer)
170
+
171
+ def notify_message_observers(self, ch, method, properties, body) -> None:
172
+ """Notify all message observers about a received message.
173
+
174
+ Args:
175
+ ch: Channel object
176
+ method: Method frame
177
+ properties: Message properties
178
+ body: Message body
179
+ """
180
+ for observer in self._message_observers:
181
+ observer.on_message(ch, method, properties, body)