nost-tools 2.4.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 +2 -2
- nost_tools/application.py +6 -6
- nost_tools/configuration.py +0 -2
- nost_tools/managed_application.py +180 -6
- nost_tools/manager.py +301 -87
- nost_tools/observer.py +10 -0
- nost_tools/publisher.py +11 -0
- nost_tools/schemas.py +159 -31
- nost_tools/simulator.py +52 -21
- {nost_tools-2.4.0.dist-info → nost_tools-3.0.0.dist-info}/METADATA +1 -1
- nost_tools-3.0.0.dist-info/RECORD +18 -0
- nost_tools-2.4.0.dist-info/RECORD +0 -18
- {nost_tools-2.4.0.dist-info → nost_tools-3.0.0.dist-info}/WHEEL +0 -0
- {nost_tools-2.4.0.dist-info → nost_tools-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {nost_tools-2.4.0.dist-info → nost_tools-3.0.0.dist-info}/top_level.txt +0 -0
nost_tools/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "
|
|
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
|
|
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
|
@@ -217,21 +217,21 @@ class Application:
|
|
|
217
217
|
|
|
218
218
|
def refresh_wallclock_periodically():
|
|
219
219
|
while not self._should_stop.wait(
|
|
220
|
-
timeout=self.config.rc.
|
|
220
|
+
timeout=self.config.rc.simulation_configuration.execution_parameters.general.wallclock_offset_refresh_interval
|
|
221
221
|
):
|
|
222
222
|
logger.debug("Wallclock refresh thread is running.")
|
|
223
223
|
try:
|
|
224
|
-
logger.
|
|
225
|
-
f"Contacting {self.config.rc.
|
|
224
|
+
logger.debug(
|
|
225
|
+
f"Contacting {self.config.rc.simulation_configuration.execution_parameters.general.ntp_host} to retrieve wallclock offset."
|
|
226
226
|
)
|
|
227
227
|
response = ntplib.NTPClient().request(
|
|
228
|
-
self.config.rc.
|
|
228
|
+
self.config.rc.simulation_configuration.execution_parameters.general.ntp_host,
|
|
229
229
|
version=3,
|
|
230
230
|
timeout=2,
|
|
231
231
|
)
|
|
232
232
|
offset = timedelta(seconds=response.offset)
|
|
233
233
|
self.simulator.set_wallclock_offset(offset)
|
|
234
|
-
logger.
|
|
234
|
+
logger.debug(f"Wallclock offset updated to {offset}.")
|
|
235
235
|
except Exception as e:
|
|
236
236
|
logger.debug(f"Failed to refresh wallclock offset: {e}")
|
|
237
237
|
|
|
@@ -338,7 +338,7 @@ class Application:
|
|
|
338
338
|
if self.set_offset:
|
|
339
339
|
# Start periodic wallclock offset updates instead of one-time call
|
|
340
340
|
logger.info(
|
|
341
|
-
f"Wallclock offset will be set every {self.config.rc.
|
|
341
|
+
f"Wallclock offset will be set every {self.config.rc.simulation_configuration.execution_parameters.general.wallclock_offset_refresh_interval} seconds using {self.config.rc.simulation_configuration.execution_parameters.general.ntp_host}."
|
|
342
342
|
)
|
|
343
343
|
self.start_wallclock_refresh_thread()
|
|
344
344
|
|
nost_tools/configuration.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
init_max_retry = None
|
|
72
|
+
self.start_time = None
|
|
73
|
+
self.time_step = None
|
|
74
|
+
self.time_scale_factor = None
|
|
75
|
+
self.time_status_step = None
|
|
76
|
+
self.time_status_init = None
|
|
77
|
+
self.command_lead = None
|
|
78
|
+
self.required_apps = None
|
|
79
|
+
self.init_retry_delay_s = None
|
|
80
|
+
self.init_max_retry = None
|
|
100
81
|
|
|
101
82
|
def establish_exchange(self):
|
|
102
83
|
"""
|
|
@@ -164,7 +145,7 @@ class Manager(Application):
|
|
|
164
145
|
set_offset: bool = True,
|
|
165
146
|
time_status_step: timedelta = None,
|
|
166
147
|
time_status_init: datetime = None,
|
|
167
|
-
shut_down_when_terminated: bool = False
|
|
148
|
+
shut_down_when_terminated: bool = False,
|
|
168
149
|
) -> None:
|
|
169
150
|
"""
|
|
170
151
|
Starts up the application by connecting to message broker, starting a background event loop,
|
|
@@ -190,8 +171,147 @@ class Manager(Application):
|
|
|
190
171
|
shut_down_when_terminated,
|
|
191
172
|
)
|
|
192
173
|
|
|
193
|
-
#
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
365
|
-
|
|
464
|
+
if time_until_stop <= 0:
|
|
465
|
+
break
|
|
366
466
|
|
|
367
|
-
#
|
|
368
|
-
|
|
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.
|
|
@@ -307,11 +463,8 @@ class ServersConfig(BaseModel):
|
|
|
307
463
|
return values
|
|
308
464
|
|
|
309
465
|
|
|
310
|
-
class
|
|
311
|
-
"""
|
|
312
|
-
Properties to report wallclock offset.
|
|
313
|
-
"""
|
|
314
|
-
|
|
466
|
+
class GeneralConfig(BaseModel):
|
|
467
|
+
prefix: Optional[str] = Field("nost", description="Execution prefix.")
|
|
315
468
|
wallclock_offset_refresh_interval: Optional[int] = Field(
|
|
316
469
|
10800, description="Wallclock offset refresh interval, in seconds."
|
|
317
470
|
)
|
|
@@ -320,23 +473,6 @@ class WallclockOffsetProperties(BaseModel):
|
|
|
320
473
|
)
|
|
321
474
|
|
|
322
475
|
|
|
323
|
-
class GeneralConfig(BaseModel):
|
|
324
|
-
prefix: Optional[str] = Field("nost", description="Execution prefix.")
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
class TimeScaleUpdateSchema(BaseModel):
|
|
328
|
-
"""
|
|
329
|
-
Provides a scheduled update to the simulation time scale factor.
|
|
330
|
-
"""
|
|
331
|
-
|
|
332
|
-
time_scale_factor: float = Field(
|
|
333
|
-
..., description="Scenario seconds per wallclock second"
|
|
334
|
-
)
|
|
335
|
-
sim_update_time: datetime = Field(
|
|
336
|
-
..., description="Scenario time that the update will occur"
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
|
|
340
476
|
class LoggingConfig(BaseModel):
|
|
341
477
|
"""
|
|
342
478
|
Configuration for logging.
|
|
@@ -345,9 +481,7 @@ class LoggingConfig(BaseModel):
|
|
|
345
481
|
enable_file_logging: Optional[bool] = Field(
|
|
346
482
|
False, description="Enable file logging."
|
|
347
483
|
)
|
|
348
|
-
log_dir: Optional[str] = Field(
|
|
349
|
-
"logs", description="Directory path for log files."
|
|
350
|
-
)
|
|
484
|
+
log_dir: Optional[str] = Field("logs", description="Directory path for log files.")
|
|
351
485
|
log_filename: Optional[str] = Field(None, description="Path to the log file.")
|
|
352
486
|
log_level: Optional[str] = Field(
|
|
353
487
|
"INFO", description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)."
|
|
@@ -376,9 +510,6 @@ class ManagerConfig(LoggingConfig):
|
|
|
376
510
|
description="Time step for the simulation.",
|
|
377
511
|
)
|
|
378
512
|
time_scale_factor: float = Field(1.0, description="Time scale factor.")
|
|
379
|
-
time_scale_updates: List[TimeScaleUpdateSchema] = Field(
|
|
380
|
-
default_factory=list, description="List of time scale updates."
|
|
381
|
-
)
|
|
382
513
|
time_status_step: Optional[timedelta] = Field(None, description="Time status step.")
|
|
383
514
|
time_status_init: Optional[datetime] = Field(None, description="Time status init.")
|
|
384
515
|
command_lead: timedelta = Field(
|
|
@@ -586,9 +717,6 @@ class SimulationConfig(BaseModel):
|
|
|
586
717
|
|
|
587
718
|
|
|
588
719
|
class RuntimeConfig(BaseModel):
|
|
589
|
-
wallclock_offset_properties: WallclockOffsetProperties = Field(
|
|
590
|
-
..., description="Properties for wallclock offset."
|
|
591
|
-
)
|
|
592
720
|
credentials: Credentials = Field(..., description="Credentials for authentication.")
|
|
593
721
|
server_configuration: Config = (
|
|
594
722
|
Field(..., description="Simulation configuration."),
|
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
|
|
93
|
-
raise RuntimeError(
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
145
|
-
raise RuntimeError(
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
177
|
-
raise RuntimeError(
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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:
|
|
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=PHcnb5Yc5yUfKYuHt2GDCto5nsYeqZwpA7cVQBOHNZI,870
|
|
2
|
-
nost_tools/application.py,sha256=YCLE17NZzkk12gPCJjlXVCtaf2cUWoYG2qMoEPhwRR4,67274
|
|
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=nW74aER5K-8ad8FUb1-QKQbJAY3v-crvp1iWYpjv6DQ,24320
|
|
10
|
-
nost_tools/observer.py,sha256=D64V0KTvHRPEqbB8q3BosJhoAlpBah2vyBlVbxWQR44,8161
|
|
11
|
-
nost_tools/publisher.py,sha256=omU8tb0AXnA6RfhYSh0vnXbJtrRo4ukx1J5ANl4bDLQ,5291
|
|
12
|
-
nost_tools/schemas.py,sha256=39vbVG0G81vnoJ6YoHLCzz2_g3ZTtn8L3gxPQ5ttCYA,21499
|
|
13
|
-
nost_tools/simulator.py,sha256=pWfMSarMCuInQTlvlJ5l53w5ZZP6jjyUtY8uWOkbe-4,20062
|
|
14
|
-
nost_tools-2.4.0.dist-info/licenses/LICENSE,sha256=aAMU-mTHTKpWkBsg9QhkhCQpEm3Gri7J_fVuJov8s3s,1539
|
|
15
|
-
nost_tools-2.4.0.dist-info/METADATA,sha256=1zLzpRY32Q1xrd_sJiowqEMvY3lsL8VhcYkMOx_wj1E,4256
|
|
16
|
-
nost_tools-2.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
-
nost_tools-2.4.0.dist-info/top_level.txt,sha256=LNChUgrv2-wiym12O0r61kY83COjTpTiJ2Ly1Ca58A8,11
|
|
18
|
-
nost_tools-2.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|