Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions config/bern/blue_sky_sim_example.scn
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Flight box around California (approx bounding box)
00:00:00.00>POLY CALIFORNIA_BOX 32.5343,-124.4096 32.5343,-114.1312 42.0095,-114.1312 42.0095,-124.4096
00:00:00.00>COLOR CALIFORNIA_BOX 0,255,0

00:00:00.00>TRAILS ON
00:00:00.00>RESO OFF
00:00:00.00>RTF 10
00:00:00.00>PAN 37.7600,-122.5200
00:00:00.00>-

# Create 5 aircraft close together (lat,lon around San Francisco Bay)
# Format: CRE <acid>,<type>,<lat>,<lon>,<hdg>,<alt>,<spd>
00:00:00.00>CRE AC001,B744,37.7000,-122.5200,090,FL080,250
00:00:00.00>AC001 ADDWPT 37.7300,-122.4500
00:00:00.00>AC001 ADDWPT 37.7600,-122.3600

00:00:00.00>CRE AC002,B744,37.7600,-122.5200,095,FL082,245
00:00:00.00>AC002 ADDWPT 37.7800,-122.4500
00:00:00.00>AC002 ADDWPT 37.8000,-122.3600

00:00:00.00>CRE AC003,B744,37.7300,-122.4800,085,FL078,255
00:00:00.00>AC003 ADDWPT 37.7400,-122.4200
00:00:00.00>AC003 ADDWPT 37.7700,-122.3400

00:00:00.00>CRE AC004,B744,37.7100,-122.4600,100,FL079,240
00:00:00.00>AC004 ADDWPT 37.7300,-122.4100
00:00:00.00>AC004 ADDWPT 37.7600,-122.3300

00:00:00.00>CRE AC005,B744,37.7500,-122.5000,080,FL081,260
00:00:00.00>AC005 ADDWPT 37.7600,-122.4400
00:00:00.00>AC005 ADDWPT 37.7800,-122.3500

# Start simulation (DEMO file uses FF)
# 00:00:00.00>FF

# Hold/pause simulation after 30 seconds.
# NOTE: The exact pause command can vary by BlueSky version. Common ones are HOLD or PAUSE.
00:00:30.00>HOLD
22 changes: 16 additions & 6 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,20 @@ opensky:
# Air traffic data configuration
air_traffic_simulator_settings:
number_of_aircraft: 3
simulation_duration_seconds: 10
simulation_duration_seconds: 30
single_or_multiple_sensors: "multiple" # this setting specifiies if the traffic data is submitted from a single sensor or multiple sensors
sensor_ids: ["a0b7d47e5eac45dc8cbaf47e6fe0e558"] # List of sensor IDs to use when 'multiple' is selected

# Bluesky Air traffic data configuration
blue_sky_air_traffic_simulator_settings:
number_of_aircraft: 3
simulation_duration_seconds: 30
single_or_multiple_sensors: "multiple" # this setting specifiies if the traffic data is submitted from a single sensor or multiple sensors
sensor_ids: ["562e6297036a4adebb4848afcd1ede90"] # List of sensor IDs to use when 'multiple' is selected

data_files:
trajectory: "config/bern/trajectory_f1.json" # Path to flight declarations JSON file
simulation: "config/bern/blue_sky_sim_example.scn" # Path to air traffic simulation scenario file, used for BlueSky enabled simulations only.
flight_declaration: "config/bern/flight_declaration.json" # Path to flight declarations JSON file
flight_declaration_via_operational_intent: "config/bern/flight_declaration_via_operational_intent.json" # Path to flight declaration via operational intent JSON file
# geo_fence: "config/geo_fences.json" # Path to geo-fences
Expand All @@ -41,11 +49,13 @@ data_files:
suites:
basic_conformance:
scenarios:
- name: F1_flow_no_telemetry_with_user_input
- name: F1_happy_path
trajectory: "config/bern/trajectory_f1.json"
- name: F2_contingent_path
trajectory: "config/bern/trajectory_f2.json"
- name: bluesky_sim_air_traffic_data
simulation: "config/bern/blue_sky_sim_example.scn"
- name: F1_flow_no_telemetry_with_user_input
- name: F1_happy_path
trajectory: "config/bern/trajectory_f1.json"
- name: F2_contingent_path
trajectory: "config/bern/trajectory_f2.json"
extra:
scenarios:
- name: F3_non_conforming_path
Expand Down
Empty file.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ dependencies = [
"websocket-client==1.9.0",
"markdown>=3.10",
"uas-standards==4.2.0",
"bluesky-simulator==1.1.0",
"rtree==1.4.1",
]

[project.scripts]
Expand Down
35 changes: 35 additions & 0 deletions src/openutm_verification/core/clients/air_traffic/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ class AirTrafficSettings(BaseSettings):
sensor_ids: list[str] = []


class BlueSkyAirTrafficSettings(BaseSettings):
"""Pydantic settings for BlueSky Air Traffic API with automatic .env loading."""

# Simulation settings
simulation_config_path: str
simulation_duration_seconds: int = 30
number_of_aircraft: int = 2
single_or_multiple_sensors: str = "single"
sensor_ids: list[str] = []


def create_air_traffic_settings() -> AirTrafficSettings:
"""Factory function to create AirTrafficSettings from config after initialization."""
return AirTrafficSettings(
Expand All @@ -31,6 +42,17 @@ def create_air_traffic_settings() -> AirTrafficSettings:
)


def create_blue_sky_air_traffic_settings() -> BlueSkyAirTrafficSettings:
"""Factory function to create BlueSkyAirTrafficSettings from config after initialization."""
return BlueSkyAirTrafficSettings(
simulation_config_path=config.data_files.simulation or "",
simulation_duration_seconds=config.blue_sky_air_traffic_simulator_settings.simulation_duration_seconds or 30,
number_of_aircraft=config.blue_sky_air_traffic_simulator_settings.number_of_aircraft or 2,
single_or_multiple_sensors=config.blue_sky_air_traffic_simulator_settings.single_or_multiple_sensors or "single",
sensor_ids=config.blue_sky_air_traffic_simulator_settings.sensor_ids or [],
)


class BaseAirTrafficAPIClient:
"""Base client for Air Traffic API interactions with OAuth2 authentication."""

Expand All @@ -42,3 +64,16 @@ async def __aenter__(self):

async def __aexit__(self, exc_type, exc_val, exc_tb):
pass


class BaseBlueSkyAirTrafficClient:
"""Base client for BlueSky Air Traffic API interactions with OAuth2 authentication."""

def __init__(self, settings: BlueSkyAirTrafficSettings):
self.settings = settings

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
139 changes: 139 additions & 0 deletions src/openutm_verification/core/clients/air_traffic/blue_sky_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from __future__ import annotations

import uuid
from collections.abc import Iterable
from uuid import UUID

import bluesky as bs
from bluesky.simulation.screenio import ScreenIO
from loguru import logger

from openutm_verification.core.clients.air_traffic.base_client import (
BaseBlueSkyAirTrafficClient,
BlueSkyAirTrafficSettings,
)
from openutm_verification.core.clients.flight_blender.base_client import (
BaseBlenderAPIClient,
)
from openutm_verification.core.execution.scenario_runner import scenario_step
from openutm_verification.simulator.models.flight_data_types import (
FlightObservationSchema,
)


class BlueSkyClient(BaseBlueSkyAirTrafficClient, BaseBlenderAPIClient):
"""BlueSky client that loads and runs a .scn file and samples aircraft states at 1 Hz."""

def __init__(self, settings: BlueSkyAirTrafficSettings):
BaseBlueSkyAirTrafficClient.__init__(self, settings)
# Initialize BaseBlenderAPIClient with dummy values since we don't use it for HTTP requests here
# but we inherit from it. Ideally, we should refactor to composition over inheritance.
BaseBlenderAPIClient.__init__(self, base_url="", credentials={})

@scenario_step("Generate BlueSky Simulation Air Traffic Data")
async def generate_bluesky_sim_air_traffic_data(
self,
config_path: str | None = None,
duration: int | None = None,
) -> list[list[FlightObservationSchema]]:
"""Run BlueSky scenario and sample aircraft state every second.

Args:
config_path: Path to .scn scenario file. Defaults to settings.simulation_config_path.
duration: Simulation duration in seconds. Defaults to settings.simulation_duration_seconds (expected 30).

Returns:
list[list[FlightObservationSchema]]: outer list per aircraft (icao_address),
inner list is time-series sampled at 1 Hz.
"""

scn_path = config_path or self.settings.simulation_config_path
duration_s = int(duration or self.settings.simulation_duration_seconds or 30)

session_ids = self.settings.sensor_ids

try:
# create a list of UUIDs with at least one UUID if session_ids is empty
session_ids = [UUID(x) for x in session_ids] if session_ids else [uuid.uuid4()]
except ValueError as exc:
logger.error(f"Invalid sensor ID in configuration, it should be a valid UUID: {exc}")
raise
current_session_id = session_ids[0]

if not scn_path:
raise ValueError("No scenario path provided. Provide config_path or set settings.simulation_config_path.")

# ---- Init BlueSky headless ----
# detached=True prevents UI/event loop from blocking.
bs.init(mode="sim", detached=True)

# Route console output to stdout (useful for debugging stack commands)
bs.scr = ScreenDummy()

logger.info(f"Initializing BlueSky (headless) and loading scenario: {scn_path} with duration {duration_s}s")

# ---- Load scenario ----
# BlueSky scenario files (like scenario/DEMO/bluesky_flight.scn) are typically loaded with IC.
# NOTE: Use absolute paths if relative paths cause issues inside Docker.
bs.stack.stack(f"IC {scn_path}")

# Ensure 1 Hz stepping; FF starts fast-time running mode, but we will still step manually.
# Some setups work fine with DT 1 and calling bs.sim.step().
bs.stack.stack("DT 1.0")

# ---- Sample data at 1 Hz for duration_s seconds ----
# Store per-aircraft series
results_by_acid: dict[str, list[FlightObservationSchema]] = {}

for t in range(1, duration_s + 1):
# Advance sim by one step (DT=1 sec)
bs.sim.step()

# Snapshot traffic arrays
acids: list[str] = list(getattr(bs.traf, "id", []))
lats: list[float] = _tolist(getattr(bs.traf, "lat", []))
lons: list[float] = _tolist(getattr(bs.traf, "lon", []))
alts: list[float] = _tolist(getattr(bs.traf, "alt", []))

for i, acid in enumerate(acids):
lat = float(lats[i])
lon = float(lons[i])
alt_m_or_ft = float(alts[i])

# BlueSky typically uses meters internally for alt, but some scenarios use FL/ft inputs.
# We store altitude_mm as "millimeters"; keep it consistent with your schema.
# If alt is actually feet, you can convert here: alt_m = alt_ft * 0.3048
altitude_mm = alt_m_or_ft * 1000.0
metadata = {"session_id": current_session_id} if current_session_id else {}

obs = FlightObservationSchema(
lat_dd=lat,
lon_dd=lon,
altitude_mm=altitude_mm,
traffic_source=0,
source_type=0,
icao_address=acid,
timestamp=t,
metadata=metadata,
)
results_by_acid.setdefault(acid, []).append(obs)

logger.debug(f"{acid:>6} lat={lat:.6f} lon={lon:.6f} alt_mm={altitude_mm:.1f}")

# Convert dict -> list[list[FlightObservationSchema]] with stable ordering
return [results_by_acid[acid] for acid in sorted(results_by_acid.keys())]


def _tolist(x: Iterable[float] | object) -> list[float]:
"""Convert numpy arrays / array-likes to a Python list."""
try:
return list(x) # type: ignore[arg-type]
except TypeError:
return [float(x)] # type: ignore[arg-type]


class ScreenDummy(ScreenIO):
"""Dummy screen that prints BlueSky echo/console messages."""

def echo(self, text: str = "", flags: int = 0) -> None:
logger.debug(f"BlueSky console: {text}")
12 changes: 12 additions & 0 deletions src/openutm_verification/core/execution/config_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class AirTrafficSimulatorSettings(StrictBaseModel):
sensor_ids: list[str] = Field(default_factory=list)


class BlueSkyAirTrafficSimulatorSettings(StrictBaseModel):
number_of_aircraft: int
simulation_duration_seconds: int
single_or_multiple_sensors: Literal["single", "multiple"] = "single"
sensor_ids: list[str] = Field(default_factory=list)


class OpenSkyConfig(StrictBaseModel):
"""OpenSky Network connection details."""

Expand All @@ -66,12 +73,14 @@ class DataFiles(StrictBaseModel):
"""Paths to data files used in the application."""

trajectory: str | None = None
simulation: str | None = None
flight_declaration: str | None = None
geo_fence: str | None = None
flight_declaration_via_operational_intent: str | None = None

@field_validator(
"trajectory",
"simulation",
"flight_declaration",
"flight_declaration_via_operational_intent",
"geo_fence",
Expand Down Expand Up @@ -103,6 +112,8 @@ def resolve_and_validate_path(path_str: str, field_name: str) -> str:

if self.trajectory:
self.trajectory = resolve_and_validate_path(self.trajectory, "Trajectory")
if self.simulation:
self.simulation = resolve_and_validate_path(self.simulation, "Simulation")
if self.flight_declaration:
self.flight_declaration = resolve_and_validate_path(self.flight_declaration, "Flight declaration")
if self.flight_declaration_via_operational_intent:
Expand Down Expand Up @@ -139,6 +150,7 @@ class AppConfig(StrictBaseModel):
flight_blender: FlightBlenderConfig
opensky: OpenSkyConfig
air_traffic_simulator_settings: AirTrafficSimulatorSettings
blue_sky_air_traffic_simulator_settings: BlueSkyAirTrafficSimulatorSettings
data_files: DataFiles
suites: dict[str, SuiteConfig] = Field(default_factory=dict)
reporting: ReportingConfig
Expand Down
14 changes: 14 additions & 0 deletions src/openutm_verification/core/execution/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
)
from openutm_verification.core.clients.air_traffic.base_client import (
create_air_traffic_settings,
create_blue_sky_air_traffic_settings,
)
from openutm_verification.core.clients.air_traffic.blue_sky_client import (
BlueSkyClient,
)
from openutm_verification.core.clients.flight_blender.flight_blender_client import (
FlightBlenderClient,
Expand Down Expand Up @@ -196,3 +200,13 @@ async def air_traffic_client(
settings = create_air_traffic_settings()
async with AirTrafficClient(settings) as air_traffic_client:
yield air_traffic_client


@dependency(BlueSkyClient)
async def bluesky_client(
config: AppConfig,
) -> AsyncGenerator[BlueSkyClient, None]:
"""Provides a BlueSkyClient instance for dependency injection."""
settings = create_blue_sky_air_traffic_settings()
async with BlueSkyClient(settings) as bluesky_client:
yield bluesky_client
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from loguru import logger

from openutm_verification.core.clients.air_traffic.air_traffic_client import (
AirTrafficClient,
)
from openutm_verification.core.clients.air_traffic.blue_sky_client import BlueSkyClient
from openutm_verification.core.clients.flight_blender.flight_blender_client import (
FlightBlenderClient,
)
from openutm_verification.scenarios.registry import register_scenario


@register_scenario("bluesky_sim_air_traffic_data")
async def test_bluesky_sim_air_traffic_data(
fb_client: FlightBlenderClient,
blue_sky_client: BlueSkyClient,
) -> None:
"""Generate simulated air traffic data using OpenSky client based off of BlueSky test
dataset and submit to Flight Blender using template.

The OpenSky client is provided by the caller; this function focuses on orchestration only.
"""
logger.info("Generating simulated air traffic data using BlueSky client")
result = await blue_sky_client.generate_bluesky_sim_air_traffic_data()
logger.info("Submitting simulated air traffic data to Flight Blender")

observations = result.details

await fb_client.submit_simulated_air_traffic(observations=observations)
Loading
Loading