bluebird-api 0.1.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.
- bluebird_api/__init__.py +77 -0
- bluebird_api/models.py +39 -0
- bluebird_api/route_tags.py +23 -0
- bluebird_api/routers/__init__.py +19 -0
- bluebird_api/routers/core.py +295 -0
- bluebird_api/routes.py +50 -0
- bluebird_api/runner.py +27 -0
- bluebird_api/runnerabc.py +148 -0
- bluebird_api-0.1.0.dist-info/METADATA +37 -0
- bluebird_api-0.1.0.dist-info/RECORD +11 -0
- bluebird_api-0.1.0.dist-info/WHEEL +4 -0
bluebird_api/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from bluebird_dt import logger
|
|
8
|
+
from fastapi import FastAPI, Request, Response
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
11
|
+
from fastapi.routing import APIRoute
|
|
12
|
+
from fastapi.staticfiles import StaticFiles
|
|
13
|
+
|
|
14
|
+
from .route_tags import tags_metadata
|
|
15
|
+
from .routes import router
|
|
16
|
+
|
|
17
|
+
bluebird_logger = logging.getLogger("bluebird_dt")
|
|
18
|
+
bluebird_logger.setLevel(logging.DEBUG)
|
|
19
|
+
stream_handler = logging.StreamHandler()
|
|
20
|
+
stream_handler.setFormatter(logger.CustomFormatter())
|
|
21
|
+
bluebird_logger.addHandler(stream_handler)
|
|
22
|
+
|
|
23
|
+
app = FastAPI(
|
|
24
|
+
title="BluebirdATC: AI for air traffic control",
|
|
25
|
+
description="FastAPI interface to control the simulation framework BluebirdATC.",
|
|
26
|
+
license_info={
|
|
27
|
+
"name": "License",
|
|
28
|
+
"url": "https://github.com/project-bluebird/BluebirdATC/blob/dev/LICENSE",
|
|
29
|
+
},
|
|
30
|
+
openapi_tags=tags_metadata,
|
|
31
|
+
strict_content_type=False,
|
|
32
|
+
)
|
|
33
|
+
app.include_router(router)
|
|
34
|
+
app.add_middleware(
|
|
35
|
+
CORSMiddleware,
|
|
36
|
+
allow_origins=["*"],
|
|
37
|
+
allow_credentials=True,
|
|
38
|
+
allow_methods=["*"],
|
|
39
|
+
allow_headers=["*"],
|
|
40
|
+
)
|
|
41
|
+
app.add_middleware(GZipMiddleware, minimum_size=500)
|
|
42
|
+
|
|
43
|
+
# serve the frontend
|
|
44
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
45
|
+
dist_path = repo_root / "bluebird-hmi" / "dist"
|
|
46
|
+
|
|
47
|
+
if dist_path.is_dir():
|
|
48
|
+
app.mount("/hmi", StaticFiles(directory=str(dist_path), html=True), name="frontend")
|
|
49
|
+
app.mount("/hmi/assets", StaticFiles(directory=os.path.join(dist_path, "assets")), name="assets")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# For webapp, requests will be prepended with "/api" - strip that out here
|
|
53
|
+
@app.middleware("http")
|
|
54
|
+
async def strip_api_prefix(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
|
55
|
+
path = request.url.path
|
|
56
|
+
if path.startswith("/api"):
|
|
57
|
+
newpath = re.sub("/api", "", path)
|
|
58
|
+
request.scope["path"] = newpath
|
|
59
|
+
return await call_next(request)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def use_route_names_as_operation_ids(app: FastAPI) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Simplify operation IDs so that generated API clients have simpler function
|
|
65
|
+
names.
|
|
66
|
+
|
|
67
|
+
Must be called after all routes have been added.
|
|
68
|
+
"""
|
|
69
|
+
for route in app.routes:
|
|
70
|
+
if isinstance(route, APIRoute):
|
|
71
|
+
route.operation_id = route.name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
use_route_names_as_operation_ids(app)
|
|
75
|
+
|
|
76
|
+
# keep track of current simulation instance
|
|
77
|
+
# app.state.current_runner = None
|
bluebird_api/models.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from bluebird_dt.utility.supported_actions import SUPPORTED_ACTIONS
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from bluebird_api.runnerabc import RunnerABC
|
|
7
|
+
|
|
8
|
+
SUPPORTED_ACTIONS_LIST = [action for actions in SUPPORTED_ACTIONS.values() for action in actions]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RunnerStore:
|
|
12
|
+
current_runner: RunnerABC = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ActionInput(BaseModel):
|
|
16
|
+
"""An action to be sent to the simulator."""
|
|
17
|
+
|
|
18
|
+
agent: str = Field(
|
|
19
|
+
description="The agent performing the action.",
|
|
20
|
+
json_schema_extra={"example": "atc_1"},
|
|
21
|
+
)
|
|
22
|
+
callsign: str = Field(
|
|
23
|
+
description="The callsign of the flight being acted on.",
|
|
24
|
+
json_schema_extra={"example": "AIR123"},
|
|
25
|
+
)
|
|
26
|
+
kind: typing.Literal[tuple(SUPPORTED_ACTIONS_LIST)] = Field(
|
|
27
|
+
description="The kind of action to perform. Must be a string that is one of the supported actions.",
|
|
28
|
+
json_schema_extra={"example": "change_heading_to"},
|
|
29
|
+
)
|
|
30
|
+
value: typing.Any = Field(
|
|
31
|
+
description=(
|
|
32
|
+
"The value associated with the action. The type of this value depends on the action being performed."
|
|
33
|
+
),
|
|
34
|
+
json_schema_extra={"example": 90},
|
|
35
|
+
)
|
|
36
|
+
sector: str = Field(
|
|
37
|
+
description="The sector that the action is being performed in.",
|
|
38
|
+
json_schema_extra={"example": "sector_1"},
|
|
39
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# FastAPI metadata tags for use in Swagger autodocs.
|
|
2
|
+
tags_metadata = [
|
|
3
|
+
{
|
|
4
|
+
"name": "Control",
|
|
5
|
+
"description": "High level control of BluebirdATC.",
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"name": "Scenarios",
|
|
9
|
+
"description": "Routes to get information about scenarios.",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "Evolve",
|
|
13
|
+
"description": "Routes to start, stop and evolve the simulation.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "State",
|
|
17
|
+
"description": "Routes to get the state of the simulation.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "Submit",
|
|
21
|
+
"description": "Routes to submit actions, plans and more to the simulation.",
|
|
22
|
+
},
|
|
23
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains all the routers for the different features available in BluebirdATC. Note that, unlike in compiled
|
|
3
|
+
programming languages where disabled features are not included in the machine code, not including certain routers just
|
|
4
|
+
means that the endpoint is not available but the logic will continue to be available in BluebirdATC,
|
|
5
|
+
and therefore logged.
|
|
6
|
+
|
|
7
|
+
All the following routers are independent on the implementation of the storage of the runners and get this through the
|
|
8
|
+
fastapi dependency. See the respective documentation in ../runnerabc.py.
|
|
9
|
+
|
|
10
|
+
Note the endpoint for loading a run, that the HMI and most clients expect, is not included here as they are dependent on
|
|
11
|
+
the implementation of the store, which is dependent on the use case. An example of this endpoint, and any other
|
|
12
|
+
endpoints not included within the groups defined here are available in ../routes.py
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .core import core_router
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"core_router",
|
|
19
|
+
]
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from bluebird_dt.events.event_logger import SimStartStop
|
|
6
|
+
from bluebird_dt.simulator.common import list_sim_scenario_categories, list_sim_scenarios
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
|
|
9
|
+
from bluebird_api.models import ActionInput, RunnerStore
|
|
10
|
+
|
|
11
|
+
from ..runnerabc import RunnerDep
|
|
12
|
+
|
|
13
|
+
core_router = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# explicitly store background tasks
|
|
17
|
+
background_tasks = set()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@core_router.get("/", tags=["Control"])
|
|
21
|
+
async def index() -> str:
|
|
22
|
+
"""
|
|
23
|
+
Verify that the API is running.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
return "Hello, BluebirdATC!"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@core_router.get("/list_scenario_categories", tags=["Scenarios"])
|
|
30
|
+
async def list_scenario_categories(): # noqa: ANN201
|
|
31
|
+
"""
|
|
32
|
+
List the scenario categories.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
return list_sim_scenario_categories()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@core_router.get("/list_scenarios/{category}", tags=["Scenarios"])
|
|
39
|
+
async def list_scenarios(category: str): # noqa: ANN201
|
|
40
|
+
"""
|
|
41
|
+
List the scenarios in a given category.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
return list_sim_scenarios(category)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@core_router.post("/close", tags=["Control"])
|
|
48
|
+
async def close(runner: RunnerDep) -> bool:
|
|
49
|
+
"""
|
|
50
|
+
Unload a given simulator scenario.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
await runner.delete()
|
|
54
|
+
RunnerStore.current_runner = None
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@core_router.post("/evolve/{time_delta}", tags=["Evolve"])
|
|
59
|
+
async def evolve(runner: RunnerDep, time_delta: float) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Evolve the simulation by a given time delta (in seconds).
|
|
62
|
+
Note that this steps through the sim in steps of sim.evolve_period.
|
|
63
|
+
"""
|
|
64
|
+
if time_delta <= 0.0:
|
|
65
|
+
raise Exception("Time delta must be positive.")
|
|
66
|
+
|
|
67
|
+
# evolve the sim in steps of self.evolve_period until time_delta is reached
|
|
68
|
+
update_amount = runner.evolve_period
|
|
69
|
+
|
|
70
|
+
while time_delta > 0.0:
|
|
71
|
+
runner.sim.evolve(update_amount)
|
|
72
|
+
time_delta -= update_amount
|
|
73
|
+
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@core_router.post("/start/{tick_frequency_period}", tags=["Evolve"])
|
|
78
|
+
async def start(runner: RunnerDep, tick_frequency_period: float) -> bool:
|
|
79
|
+
"""
|
|
80
|
+
Start the simulation running with a given tick_frequency_period (in seconds).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
if tick_frequency_period <= 0.0:
|
|
84
|
+
raise Exception("tick_frequency_period must be positive.")
|
|
85
|
+
|
|
86
|
+
runner.tick_frequency_period = tick_frequency_period
|
|
87
|
+
runner.sim.manager.event_logger.log_sim_event(
|
|
88
|
+
SimStartStop(event="clocks on", simulation_datetime=runner.sim.manager.environment.datetime)
|
|
89
|
+
)
|
|
90
|
+
runner.log_simrate()
|
|
91
|
+
runner.running = True
|
|
92
|
+
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@core_router.get("/environment", tags=["State"])
|
|
97
|
+
async def complete_environment( # noqa: ANN201
|
|
98
|
+
runner: RunnerDep, no_airspace: bool = False, last_n_observations: int = 0
|
|
99
|
+
):
|
|
100
|
+
"""
|
|
101
|
+
Get the all of the environment data.
|
|
102
|
+
"""
|
|
103
|
+
if RunnerStore.current_runner is None or runner.sim is None:
|
|
104
|
+
return {"exists": False}
|
|
105
|
+
# HMI doesn't need to reload the environment again
|
|
106
|
+
runner.sim.manager.reload_environment = False
|
|
107
|
+
|
|
108
|
+
return runner.sim.environment(
|
|
109
|
+
sim_time=runner.sim.manager.environment.time,
|
|
110
|
+
sector_id=None,
|
|
111
|
+
no_airspace=no_airspace,
|
|
112
|
+
last_n_observations=last_n_observations,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@core_router.get("/environment/{sector_id}", tags=["State"])
|
|
117
|
+
async def environment( # noqa: ANN201
|
|
118
|
+
runner: RunnerDep, sector_id: str | None = None, no_airspace: bool = False, last_n_observations: int = 0
|
|
119
|
+
):
|
|
120
|
+
"""
|
|
121
|
+
Get the environment data for a given sector.
|
|
122
|
+
"""
|
|
123
|
+
if RunnerStore.current_runner is None or runner.sim is None:
|
|
124
|
+
return {"exists": False}
|
|
125
|
+
# HMI doesn't need to reload the environment again
|
|
126
|
+
runner.sim.manager.reload_environment = False
|
|
127
|
+
|
|
128
|
+
return runner.sim.environment(
|
|
129
|
+
sim_time=runner.sim.manager.environment.time,
|
|
130
|
+
sector_id=sector_id,
|
|
131
|
+
no_airspace=no_airspace,
|
|
132
|
+
last_n_observations=last_n_observations,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@core_router.get("/static_data", tags=["State"])
|
|
137
|
+
async def static_data( # noqa: ANN201
|
|
138
|
+
runner: RunnerDep,
|
|
139
|
+
):
|
|
140
|
+
"""
|
|
141
|
+
Get the static data for the scenario.
|
|
142
|
+
"""
|
|
143
|
+
if RunnerStore.current_runner is None or runner.sim is None:
|
|
144
|
+
return {"exists": False}
|
|
145
|
+
return {"exists": True} | runner.sim.static_data(
|
|
146
|
+
sim_time=runner.sim.manager.environment.time,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@core_router.get("/dynamic_data/{sector_id}", tags=["State"])
|
|
151
|
+
async def dynamic_data( # noqa: ANN201
|
|
152
|
+
runner: RunnerDep, sector_id: str | None = None
|
|
153
|
+
):
|
|
154
|
+
"""
|
|
155
|
+
Get the dynamic data for the scenario.
|
|
156
|
+
"""
|
|
157
|
+
if RunnerStore.current_runner is None or runner.sim is None:
|
|
158
|
+
return {"exists": False}
|
|
159
|
+
sim_time = runner.sim.manager.environment.time
|
|
160
|
+
if sector_id.lower() == "none" or sector_id.lower() == "all":
|
|
161
|
+
return {"exists": True} | runner.sim.dynamic_data(sim_time)
|
|
162
|
+
|
|
163
|
+
return {"exists": True} | runner.sim.dynamic_data(sim_time=sim_time, sector_id=sector_id)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@core_router.post("/actions", tags=["Submit"])
|
|
167
|
+
async def actions(runner: RunnerDep, action_input: list[ActionInput]) -> bool:
|
|
168
|
+
"""
|
|
169
|
+
Send actions to the simulator. The POST body should be a JSON list of
|
|
170
|
+
actions.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
timed_actions = []
|
|
174
|
+
|
|
175
|
+
for act in action_input:
|
|
176
|
+
agent = act.agent
|
|
177
|
+
callsign = act.callsign
|
|
178
|
+
kind = act.kind
|
|
179
|
+
value = act.value
|
|
180
|
+
sector = act.sector
|
|
181
|
+
|
|
182
|
+
action_time = (
|
|
183
|
+
datetime.fromtimestamp(
|
|
184
|
+
runner.sim.manager.environment.time,
|
|
185
|
+
tz=timezone.utc,
|
|
186
|
+
).isoformat(timespec="microseconds")[:-6]
|
|
187
|
+
).replace("T", " ")
|
|
188
|
+
|
|
189
|
+
print(f"{action_time} : {agent} : {callsign} -> {kind} = {value}")
|
|
190
|
+
|
|
191
|
+
action: dict[str, typing.Any] = {
|
|
192
|
+
"agent": agent,
|
|
193
|
+
"callsign": callsign,
|
|
194
|
+
"kind": kind,
|
|
195
|
+
"value": value,
|
|
196
|
+
"time": action_time,
|
|
197
|
+
"sector": sector,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
timed_actions.append(action)
|
|
201
|
+
|
|
202
|
+
runner.sim.action(timed_actions)
|
|
203
|
+
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@core_router.get("/status", tags=["Control"])
|
|
208
|
+
async def runner_status(runner: RunnerDep): # noqa: ANN201
|
|
209
|
+
"""
|
|
210
|
+
Get the current state of the current run.
|
|
211
|
+
"""
|
|
212
|
+
if RunnerStore.current_runner is None:
|
|
213
|
+
return {"exists": False}
|
|
214
|
+
return {
|
|
215
|
+
"exists": runner.scenario_name is not None,
|
|
216
|
+
"iterations": runner.tick,
|
|
217
|
+
"category": runner.category,
|
|
218
|
+
"scenario": runner.scenario_name,
|
|
219
|
+
"running": runner.running,
|
|
220
|
+
"evolve_period": runner.evolve_period,
|
|
221
|
+
"tick_frequency_period": runner.tick_frequency_period,
|
|
222
|
+
"kill": runner.kill,
|
|
223
|
+
"reload": runner.sim.manager.reload_environment if runner.sim is not None else False,
|
|
224
|
+
"time_of_next_tick": runner.time_of_next_tick.isoformat(),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@core_router.post("/save", tags=["Control"])
|
|
229
|
+
async def save(runner: RunnerDep) -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Save sim state to JSON file.
|
|
232
|
+
"""
|
|
233
|
+
runner.sim.save()
|
|
234
|
+
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@core_router.post("/evolve_period/{evolve_period}", tags=["Evolve"])
|
|
239
|
+
async def set_evolve_period(runner: RunnerDep, evolve_period: float) -> bool:
|
|
240
|
+
"""
|
|
241
|
+
Change the update period.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
if evolve_period <= 0.0:
|
|
245
|
+
raise Exception("Evolve period must be positive.")
|
|
246
|
+
|
|
247
|
+
runner.evolve_period = evolve_period
|
|
248
|
+
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@core_router.post("/tick_frequency/{tick_frequency}", tags=["Evolve"])
|
|
253
|
+
async def set_tick_frequency(runner: RunnerDep, tick_frequency: float) -> bool:
|
|
254
|
+
"""
|
|
255
|
+
Change the update tick frequency.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
if tick_frequency <= 0.0:
|
|
259
|
+
raise Exception("Tick frequency must be positive.")
|
|
260
|
+
|
|
261
|
+
runner.tick_frequency_period = tick_frequency
|
|
262
|
+
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@core_router.post("/pause", tags=["Evolve"])
|
|
267
|
+
async def pause(runner: RunnerDep) -> bool:
|
|
268
|
+
"""
|
|
269
|
+
Pause the simulation.
|
|
270
|
+
"""
|
|
271
|
+
runner.running = False
|
|
272
|
+
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@core_router.post("/rewind/{new_time}", tags=["Control"])
|
|
277
|
+
async def rewind(runner: RunnerDep, new_time: str) -> bool:
|
|
278
|
+
"""
|
|
279
|
+
Rewind sim to a previous time
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
date_format = "%Y-%m-%d %H:%M:%S"
|
|
283
|
+
rewind_to_time = pd.to_datetime(new_time, format=date_format)
|
|
284
|
+
runner.sim.manager.rewind_to_time(rewind_to_time)
|
|
285
|
+
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@core_router.get("/selected_aircraft/{sector_id}", tags=["State"])
|
|
290
|
+
async def get_selected_aircraft(sector_id: str, runner: RunnerDep) -> dict:
|
|
291
|
+
"""
|
|
292
|
+
Get the currently selected aircraft for a given sector.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
return runner.hmi.get(sector_id, {"selected_aircraft": ""})
|
bluebird_api/routes.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The routes module builds the router for the proided endpoint and adds any endpoints only available for
|
|
3
|
+
BluebirdATC, including loading which is implementation dependent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, Request
|
|
9
|
+
|
|
10
|
+
from bluebird_api.models import RunnerStore
|
|
11
|
+
from bluebird_api.routers.core import background_tasks
|
|
12
|
+
from bluebird_api.runner import Runner
|
|
13
|
+
|
|
14
|
+
from .routers import (
|
|
15
|
+
core_router,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def simulator(request: Request):
|
|
20
|
+
"""
|
|
21
|
+
The simulator dependency, available to endpoints using the RunnerDep dependency, provides access to the runner.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
request.state.runner = RunnerStore.current_runner
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
router = APIRouter(dependencies=[Depends(simulator)])
|
|
28
|
+
|
|
29
|
+
router.include_router(core_router)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/load/{category}/{scenario_name}", tags=["Control"])
|
|
33
|
+
async def load(category: str, scenario_name: str) -> bool: # noqa: ARG001
|
|
34
|
+
"""
|
|
35
|
+
End any existing run, then create a new Runner and load a given simulator scenario.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
if RunnerStore.current_runner is not None:
|
|
39
|
+
await RunnerStore.current_runner.delete()
|
|
40
|
+
|
|
41
|
+
RunnerStore.current_runner = Runner(category, scenario_name)
|
|
42
|
+
|
|
43
|
+
# start the task
|
|
44
|
+
task = asyncio.create_task(RunnerStore.current_runner.run_main())
|
|
45
|
+
|
|
46
|
+
# add the task to the background tasks set and have it auto-remove its reference from the set when done
|
|
47
|
+
background_tasks.add(task)
|
|
48
|
+
task.add_done_callback(background_tasks.remove)
|
|
49
|
+
|
|
50
|
+
return True
|
bluebird_api/runner.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The runner implementation for the general BluebirdATC API. This runner is responsible for the initialisation of the
|
|
3
|
+
simulator class.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from bluebird_dt.simulator import Simulator
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from bluebird_api.runnerabc import RunnerABC
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Runner(RunnerABC):
|
|
15
|
+
def __init__(self, category: str, scenario_name: str):
|
|
16
|
+
super().__init__(category, scenario_name)
|
|
17
|
+
|
|
18
|
+
@override
|
|
19
|
+
def initialise_simulator(self, *args, **kwargs) -> Simulator: # noqa: ANN002, ANN003
|
|
20
|
+
return Simulator.from_category(*args, **kwargs)
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
async def delete(self):
|
|
24
|
+
self.kill = True
|
|
25
|
+
self.sim.save()
|
|
26
|
+
self.sim = None
|
|
27
|
+
await asyncio.sleep(3)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the interfaces used in all endpoints to obtain data from the runner and simulator that is being
|
|
3
|
+
requested.
|
|
4
|
+
|
|
5
|
+
The first thing available is the RunnerABC class, which is implemented for BluebirdATC in ./runner.py.
|
|
6
|
+
|
|
7
|
+
Secondly, and most importantly, this module provides the FastAPI dependency for the runner, used to access the simulator
|
|
8
|
+
instance, RunnerDep.
|
|
9
|
+
To use this dependency, create an endpoint as would be done normally.
|
|
10
|
+
As one of the arguments to the function, include the RunnerDep type alias as shown in the example below and
|
|
11
|
+
it will be available to interact with.
|
|
12
|
+
|
|
13
|
+
>>> from ..runnerabc import RunnerDep
|
|
14
|
+
>>> @core_router.post("/close", tags=["Control"])
|
|
15
|
+
>>> async def close(runner: RunnerDep) -> bool:
|
|
16
|
+
>>> await runner.delete()
|
|
17
|
+
>>> return True
|
|
18
|
+
|
|
19
|
+
If during resolution of the runner, for example trying to find the runner, the runner is not available,
|
|
20
|
+
a HTTP error 404 (Not found) will be returned before even running the function above.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import typing
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from collections import defaultdict
|
|
27
|
+
from datetime import datetime, timedelta
|
|
28
|
+
|
|
29
|
+
from bluebird_dt.events.event_logger import SimRateUpdate
|
|
30
|
+
from bluebird_dt.logger import logger
|
|
31
|
+
from bluebird_dt.simulator.simulator import Simulator
|
|
32
|
+
from fastapi import Depends, HTTPException, Request
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RunnerABC(ABC):
|
|
36
|
+
kill: bool
|
|
37
|
+
category: str
|
|
38
|
+
scenario_name: str
|
|
39
|
+
sim: Simulator
|
|
40
|
+
running: bool
|
|
41
|
+
evolve_period: float
|
|
42
|
+
tick_frequency_period: float
|
|
43
|
+
tick: int
|
|
44
|
+
hmi: dict
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def initialise_simulator(self, *args, **kwargs) -> Simulator: # noqa: ANN002, ANN003
|
|
48
|
+
"""
|
|
49
|
+
Function to instantiate the simulator class, or variations of it for each use case.
|
|
50
|
+
|
|
51
|
+
This function is designed to be a transparent function taking all the arguments passed into the constructor of
|
|
52
|
+
the runner class from the load endpoint.
|
|
53
|
+
"""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def delete(self):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def log_simrate(self):
|
|
61
|
+
"""
|
|
62
|
+
Creates an entry in the file logs with the current tick frequency and evolve period of the runner.
|
|
63
|
+
|
|
64
|
+
See documentation for the event_logger.log_sim_event for more information.
|
|
65
|
+
"""
|
|
66
|
+
self.sim.manager.event_logger.log_sim_event(
|
|
67
|
+
SimRateUpdate(
|
|
68
|
+
simulation_datetime=self.sim.manager.environment.datetime,
|
|
69
|
+
tick_frequency=self.tick_frequency_period,
|
|
70
|
+
evolve_period=self.evolve_period,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def __init__(self, category: str, scenario_name: str, log_name: str | None = None):
|
|
75
|
+
"""
|
|
76
|
+
Constructor of the Runner classes.
|
|
77
|
+
|
|
78
|
+
Although the argumets of this function are currently hardcoded, if a usecase requires different ones they will
|
|
79
|
+
be replaced by args and kwargs placeholders.
|
|
80
|
+
|
|
81
|
+
Arguments
|
|
82
|
+
---------
|
|
83
|
+
category: str
|
|
84
|
+
The category of scenario to load
|
|
85
|
+
scenario_name: str
|
|
86
|
+
The specific scenario of the category to load
|
|
87
|
+
log_name: str
|
|
88
|
+
The name to store the logs for the run.
|
|
89
|
+
"""
|
|
90
|
+
self.category = category
|
|
91
|
+
self.scenario_name = scenario_name
|
|
92
|
+
self.sim = self.initialise_simulator(self.category, self.scenario_name, log_filename=log_name)
|
|
93
|
+
self.running = False
|
|
94
|
+
self.evolve_period = 6.0
|
|
95
|
+
self.tick_frequency_period = 6.0
|
|
96
|
+
self.kill = False
|
|
97
|
+
self.time_of_next_tick = datetime.min
|
|
98
|
+
self.tick = 0
|
|
99
|
+
self.hmi = defaultdict(lambda: {"selected_aircraft": ""})
|
|
100
|
+
|
|
101
|
+
# allow 'None' and 'ALL' sectors to also select aircraft
|
|
102
|
+
self.hmi["None"] = {"selected_aircraft": ""}
|
|
103
|
+
self.hmi["ALL"] = {"selected_aircraft": ""}
|
|
104
|
+
# try a lock to avoid concurrency bugs
|
|
105
|
+
self._sim_lock = asyncio.Lock()
|
|
106
|
+
|
|
107
|
+
async def run_main(self):
|
|
108
|
+
self.time_of_next_tick = datetime.now()
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
if self.running and datetime.now() >= self.time_of_next_tick:
|
|
112
|
+
async with self._sim_lock:
|
|
113
|
+
start_time = datetime.now()
|
|
114
|
+
self.time_of_next_tick = start_time + timedelta(seconds=self.tick_frequency_period)
|
|
115
|
+
|
|
116
|
+
self.sim.evolve(self.evolve_period)
|
|
117
|
+
self.tick += 1
|
|
118
|
+
logger.info(f"evolve time: {datetime.now() - start_time}")
|
|
119
|
+
|
|
120
|
+
if self.kill:
|
|
121
|
+
self.category = None
|
|
122
|
+
self.scenario_name = None
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
await asyncio.sleep(0.1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def runner(request: Request) -> RunnerABC: # noqa: ARG001
|
|
129
|
+
"""
|
|
130
|
+
Function taking the runner information from the state, and making it available for the endpoint that uses it. See
|
|
131
|
+
module documentation for more details on usage, and an example.
|
|
132
|
+
|
|
133
|
+
This FastAPI dependency will throw a HTTP exception if the runner isinstance is not found, therefore this does not
|
|
134
|
+
need to be handled by the individual endpoint.
|
|
135
|
+
"""
|
|
136
|
+
runner = request.state.runner
|
|
137
|
+
|
|
138
|
+
if runner is None:
|
|
139
|
+
raise HTTPException(404, "Runner instance not found")
|
|
140
|
+
|
|
141
|
+
if not isinstance(runner, RunnerABC):
|
|
142
|
+
raise HTTPException(500, "The runner passed is not a valid type.")
|
|
143
|
+
|
|
144
|
+
request.state.runner = None
|
|
145
|
+
return runner
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
RunnerDep = typing.Annotated[RunnerABC, Depends(runner)]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bluebird-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A REST API for the Project Bluebird Digital Twin
|
|
5
|
+
Author: Project Bluebird
|
|
6
|
+
License-Expression: AGPL-3.0-or-later
|
|
7
|
+
Requires-Python: <3.15,>=3.10
|
|
8
|
+
Requires-Dist: bluebird-dt
|
|
9
|
+
Requires-Dist: fastapi<0.137,>=0.136.1
|
|
10
|
+
Requires-Dist: lxml-html-clean>=0.4.4
|
|
11
|
+
Requires-Dist: uvicorn<0.47,>=0.46.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
## The REST API for BluebirdATC
|
|
15
|
+
|
|
16
|
+
It is possible to run the BluebirdATC digital twin in a server process, such that the simulation will evolve at regular time intervals, and Agents and/or frontend visualization software can interact with it via HTTP requests.
|
|
17
|
+
In particular, users can:
|
|
18
|
+
* Query available scenario categories and scenarios.
|
|
19
|
+
* Load a selected scenario.
|
|
20
|
+
* Evolve the simulation by a specified time interval.
|
|
21
|
+
* Obtain the current state of the `Environment`.
|
|
22
|
+
* Submit `Actions` to individual aircraft.
|
|
23
|
+
* Save logfiles with data on all steps of the simulation.
|
|
24
|
+
|
|
25
|
+
In order to run the app, with all the correct dependencies for this feature, from the `BluebirdATC/bluebird-api` directory, run the command:
|
|
26
|
+
|
|
27
|
+
```shell
|
|
28
|
+
uv run uvicorn bluebird_api:app --port 8000
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
You should then be able to go to [http://localhost:8000](http://localhost:8000) in a web browser, and see the message "Hello, BluebirdATC!".
|
|
32
|
+
|
|
33
|
+
To see the full list and description of API endpoints, with the application running, go to [http://localhost:8000/docs](http://localhost:8000/docs).
|
|
34
|
+
|
|
35
|
+
## Frontend visualisation
|
|
36
|
+
|
|
37
|
+
The app also serves the frontend visualization (more details on that can be found [here](../bluebird-hmi/README.md)), at the URL [http://localhost:8000/hmi](http://localhost:8000/hmi).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
bluebird_api/__init__.py,sha256=MknZUHlacEtAxkoDnRM3seRpPc-NoU3ZBrXWhq51GUU,2425
|
|
2
|
+
bluebird_api/models.py,sha256=WDn5DY3e9-vkEYiJUMcXLFTPfXm9GMaErgCFLcBV0y0,1310
|
|
3
|
+
bluebird_api/route_tags.py,sha256=O7lt8V_lICSDaiv_bCBtloZyIkuFtrSIoP7rVzAl3Ko,631
|
|
4
|
+
bluebird_api/routes.py,sha256=Ta-olnEcMiDVrIgSWBDs7StLbzXJ_CCI0NXQIUoIkNM,1457
|
|
5
|
+
bluebird_api/runner.py,sha256=0S2IdAcOzbROARSx1OySp6xBM2qwM3VEmoo3fQw34v8,731
|
|
6
|
+
bluebird_api/runnerabc.py,sha256=BV9Z2hPYrE3-ay0TDZtqlOPHIduuDKqqQ9ApOHvIpEk,5310
|
|
7
|
+
bluebird_api/routers/__init__.py,sha256=1ta7f7k3a6GaAKIYniQs4xquL7OwXJtAsipaWfS7POs,946
|
|
8
|
+
bluebird_api/routers/core.py,sha256=k2RIdhLVzF5mjs-iuLlsidtYXDj_d_tSD_P09QT8--4,8320
|
|
9
|
+
bluebird_api-0.1.0.dist-info/METADATA,sha256=kd1F1ZW5AcL1T6J-ysWUW5clAT4tGRyn16E-EIFr4RA,1664
|
|
10
|
+
bluebird_api-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
bluebird_api-0.1.0.dist-info/RECORD,,
|