From 65717a4a67ba2466768f34b4068315489f74c157 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sat, 13 Dec 2025 22:02:51 +0100 Subject: [PATCH 1/9] Save scenario related generated data in reports. --- .../core/reporting/reporting.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index 587a45bd..9df218d0 100644 --- a/src/openutm_verification/core/reporting/reporting.py +++ b/src/openutm_verification/core/reporting/reporting.py @@ -1,3 +1,4 @@ +import json import shutil from pathlib import Path @@ -21,6 +22,8 @@ def generate_reports( output_dir = Path(reporting_config.output_dir) output_dir.mkdir(parents=True, exist_ok=True) + _save_scenario_data(report_data, output_dir) + formats = reporting_config.formats if "json" in formats: json_report_path = _generate_json_report(report_data, output_dir, base_filename) @@ -117,15 +120,19 @@ def _generate_scenario_visualizations(result, telemetry_data, declaration_data, # Sanitize scenario name for filename sanitized_name = result.name.replace(" ", "_").replace("-", "_") + # Create scenario directory + scenario_dir = output_dir / result.name + scenario_dir.mkdir(parents=True, exist_ok=True) + # Generate 2D visualization vis_2d_filename = f"{base_filename}_{sanitized_name}_2d.html" - vis_2d_path = output_dir / vis_2d_filename + vis_2d_path = scenario_dir / vis_2d_filename visualize_flight_path_2d(telemetry_data, declaration_data, str(vis_2d_path)) result.visualization_2d_path = str(vis_2d_path.relative_to(output_dir)) # Generate 3D visualization vis_3d_filename = f"{base_filename}_{sanitized_name}_3d.html" - vis_3d_path = output_dir / vis_3d_filename + vis_3d_path = scenario_dir / vis_3d_filename visualize_flight_path_3d(telemetry_data, declaration_data, str(vis_3d_path)) result.visualization_3d_path = str(vis_3d_path.relative_to(output_dir)) @@ -152,3 +159,24 @@ def _generate_visualizations(report_data: ReportData, output_dir: Path, base_fil _generate_scenario_visualizations(result, telemetry_data, declaration_data, output_dir, base_filename) except Exception as e: logger.warning(f"Failed to generate visualizations for scenario '{result.name}': {e}") + + +def _save_scenario_data(report_data: ReportData, output_dir: Path): + """ + Saves generated data for each scenario in a subdirectory. + """ + for result in report_data.results: + scenario_dir = output_dir / result.name + scenario_dir.mkdir(parents=True, exist_ok=True) + + if result.flight_declaration_data: + file_path = scenario_dir / "flight_declaration.json" + with open(file_path, "w", encoding="utf-8") as f: + json.dump(result.flight_declaration_data, f, indent=2) + logger.debug(f"Saved flight declaration data to {file_path}") + + if result.telemetry_data: + file_path = scenario_dir / "telemetry.json" + with open(file_path, "w", encoding="utf-8") as f: + json.dump(result.telemetry_data, f, indent=2) + logger.debug(f"Saved telemetry data to {file_path}") From 2015a7b298de405cec2d853580d9b5a989437f35 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sat, 13 Dec 2025 22:16:04 +0100 Subject: [PATCH 2/9] typing --- .../core/execution/scenario_runner.py | 10 ++++++---- src/openutm_verification/core/reporting/reporting.py | 6 ++---- .../core/reporting/reporting_models.py | 8 +++++--- src/openutm_verification/scenarios/common.py | 6 ++++-- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 66854422..450d6395 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -7,10 +7,12 @@ from typing import Any, Awaitable, Callable, Coroutine, List, Optional, ParamSpec, Protocol, TypedDict, TypeVar, cast, overload from loguru import logger +from uas_standards.astm.f3411.v22a.api import RIDAircraftState from openutm_verification.core.clients.opensky.base_client import OpenSkyError from openutm_verification.core.reporting.reporting_models import ScenarioResult, Status, StepResult from openutm_verification.models import FlightBlenderError +from openutm_verification.simulator.models.declaration_models import FlightDeclaration T = TypeVar("T") P = ParamSpec("P") @@ -56,13 +58,13 @@ def add_result(cls, result: StepResult[Any]) -> None: state.steps.append(result) @classmethod - def set_flight_declaration_data(cls, data: Any) -> None: + def set_flight_declaration_data(cls, data: FlightDeclaration) -> None: state = _scenario_state.get() if state and state.active: state.flight_declaration_data = data @classmethod - def set_telemetry_data(cls, data: Any) -> None: + def set_telemetry_data(cls, data: list[RIDAircraftState]) -> None: state = _scenario_state.get() if state and state.active: state.telemetry_data = data @@ -75,14 +77,14 @@ def steps(self) -> List[StepResult[Any]]: return state.steps if state else [] @property - def flight_declaration_data(self) -> Optional[Any]: + def flight_declaration_data(self) -> Optional[FlightDeclaration]: if self._state: return self._state.flight_declaration_data state = _scenario_state.get() return state.flight_declaration_data if state else None @property - def telemetry_data(self) -> Optional[Any]: + def telemetry_data(self) -> Optional[list[RIDAircraftState]]: if self._state: return self._state.telemetry_data state = _scenario_state.get() diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index 9df218d0..0f9246b4 100644 --- a/src/openutm_verification/core/reporting/reporting.py +++ b/src/openutm_verification/core/reporting/reporting.py @@ -171,12 +171,10 @@ def _save_scenario_data(report_data: ReportData, output_dir: Path): if result.flight_declaration_data: file_path = scenario_dir / "flight_declaration.json" - with open(file_path, "w", encoding="utf-8") as f: - json.dump(result.flight_declaration_data, f, indent=2) + file_path.write_text(result.flight_declaration_data.model_dump_json(), encoding="utf-8") logger.debug(f"Saved flight declaration data to {file_path}") if result.telemetry_data: file_path = scenario_dir / "telemetry.json" - with open(file_path, "w", encoding="utf-8") as f: - json.dump(result.telemetry_data, f, indent=2) + file_path.write_text(json.dumps(result.telemetry_data, indent=2), encoding="utf-8") logger.debug(f"Saved telemetry data to {file_path}") diff --git a/src/openutm_verification/core/reporting/reporting_models.py b/src/openutm_verification/core/reporting/reporting_models.py index 944ef6fe..6d0eb1c0 100644 --- a/src/openutm_verification/core/reporting/reporting_models.py +++ b/src/openutm_verification/core/reporting/reporting_models.py @@ -6,8 +6,10 @@ from typing import Any, Dict, Generic, List, Optional, TypeVar from pydantic import BaseModel +from uas_standards.astm.f3411.v22a.api import RIDAircraftState from openutm_verification.core.execution.config_models import DeploymentDetails +from openutm_verification.simulator.models.declaration_models import FlightDeclaration class Status(StrEnum): @@ -30,7 +32,7 @@ class StepResult(BaseModel, Generic[T]): error_message: Optional[str] = None -class ScenarioResult(BaseModel): +class ScenarioResult(BaseModel, arbitrary_types_allowed=True): """Data model for the result of a single scenario.""" name: str @@ -41,8 +43,8 @@ class ScenarioResult(BaseModel): error_message: Optional[str] = None flight_declaration_filename: Optional[str] = None telemetry_filename: Optional[str] = None - flight_declaration_data: Optional[Any] = None - telemetry_data: Optional[Any] = None + flight_declaration_data: Optional[FlightDeclaration] = None + telemetry_data: Optional[list[RIDAircraftState]] = None visualization_2d_path: Optional[str] = None visualization_3d_path: Optional[str] = None docs: Optional[str] = None diff --git a/src/openutm_verification/scenarios/common.py b/src/openutm_verification/scenarios/common.py index d85105c0..5b69c859 100644 --- a/src/openutm_verification/scenarios/common.py +++ b/src/openutm_verification/scenarios/common.py @@ -3,9 +3,11 @@ from typing import Any, List from loguru import logger +from uas_standards.astm.f3411.v22a.api import RIDAircraftState from openutm_verification.simulator.flight_declaration import FlightDeclarationGenerator from openutm_verification.simulator.geo_json_telemetry import GeoJSONFlightsSimulator +from openutm_verification.simulator.models.declaration_models import FlightDeclaration from openutm_verification.simulator.models.flight_data_types import ( GeoJSONFlightsSimulatorConfiguration, ) @@ -13,7 +15,7 @@ DEFAULT_TELEMETRY_DURATION = 30 # seconds -def generate_flight_declaration(config_path: str) -> Any: +def generate_flight_declaration(config_path: str) -> FlightDeclaration: """Generate a flight declaration from the config file at the given path.""" try: generator = FlightDeclarationGenerator(bounds_path=Path(config_path)) @@ -23,7 +25,7 @@ def generate_flight_declaration(config_path: str) -> Any: raise -def generate_telemetry(config_path: str, duration: int = DEFAULT_TELEMETRY_DURATION) -> List[Any]: +def generate_telemetry(config_path: str, duration: int = DEFAULT_TELEMETRY_DURATION) -> List[RIDAircraftState]: """Generate telemetry states from the GeoJSON config file at the given path.""" try: logger.debug(f"Generating telemetry states from {config_path} for duration {duration} seconds") From abba8385f67ef9df00c6e7ae17c112db92615800 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sat, 13 Dec 2025 23:03:33 +0100 Subject: [PATCH 3/9] Save air traffic data --- .../clients/air_traffic/air_traffic_client.py | 3 +- .../flight_blender/flight_blender_client.py | 21 +++++---- .../core/clients/opensky/opensky_client.py | 8 ++-- .../core/execution/scenario_runner.py | 17 ++++++- .../core/reporting/reporting.py | 15 ++++-- .../core/reporting/reporting_models.py | 24 +++++----- .../scenarios/registry.py | 2 + .../simulator/geo_json_telemetry.py | 8 ++-- tests/test_client_steps.py | 47 +++++++++++++++++-- 9 files changed, 106 insertions(+), 39 deletions(-) diff --git a/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py b/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py index 12f6aa3c..bf61e446 100644 --- a/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py +++ b/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py @@ -18,6 +18,7 @@ ) from openutm_verification.simulator.models.flight_data_types import ( AirTrafficGeneratorConfiguration, + FlightObservationSchema, ) @@ -35,7 +36,7 @@ async def generate_simulated_air_traffic_data( self, config_path: Optional[str] = None, duration: Optional[int] = None, - ) -> list[list[dict]]: + ) -> list[list[FlightObservationSchema]]: """Generate simulated air traffic data from GeoJSON configuration. Loads GeoJSON data from the specified config path and uses it to generate diff --git a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py index 4038054b..7e8e9c76 100644 --- a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py +++ b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py @@ -37,6 +37,7 @@ RIDOperatorDetails, UAClassificationEU, ) +from openutm_verification.simulator.models.flight_data_types import FlightObservationSchema def _create_rid_operator_details(operation_id: str) -> RIDOperatorDetails: @@ -503,7 +504,7 @@ async def delete_flight_declaration(self) -> Dict[str, Any]: @scenario_step("Submit Simulated Air Traffic") async def submit_simulated_air_traffic( self, - observations: List[List[Dict[str, Any]]], + observations: list[list[FlightObservationSchema]], single_or_multiple_sensors: str = "single", ) -> bool: now = arrow.now() @@ -519,8 +520,8 @@ async def submit_simulated_air_traffic( for aircraft_obs in observations: if not aircraft_obs: continue - start_times.append(arrow.get(aircraft_obs[0]["timestamp"])) - end_times.append(arrow.get(aircraft_obs[-1]["timestamp"])) + start_times.append(arrow.get(aircraft_obs[0].timestamp)) + end_times.append(arrow.get(aircraft_obs[-1].timestamp)) simulation_start = min(start_times) simulation_end = max(end_times) @@ -536,31 +537,32 @@ async def submit_simulated_air_traffic( while arrow.now() < target_real_time: await asyncio.sleep(0.1) # For each aircraft, find the observation closest to the current simulation time - filtered_observations = [] + filtered_observations: list[list[FlightObservationSchema]] = [] for aircraft_obs in observations: if not aircraft_obs: continue closest_obs = min( aircraft_obs, - key=lambda obs: abs(arrow.get(obs["timestamp"]) - current_simulation_time), + key=lambda obs: abs(arrow.get(obs.timestamp) - current_simulation_time), ) filtered_observations.append([closest_obs]) # Submit the filtered observations for each aircraft to the API - logger.debug(f"Submitting {len(observations)} air traffic observations") + logger.debug(f"Submitting {len(filtered_observations)} air traffic observations") for filtered_observation in filtered_observations: - session_id = filtered_observation[0]["metadata"]["session_id"] + session_id = filtered_observation[0].metadata["session_id"] endpoint = f"/flight_stream/set_air_traffic/{session_id}" payload = {"observations": filtered_observation} + ScenarioContext.add_air_traffic_data(filtered_observation) response = await self.post(endpoint, json=payload) logger.debug(f"Air traffic submission response: {response.text}") - logger.info(f"Observations submitted for aircraft {filtered_observation[0]['icao_address']} at time {current_simulation_time}") + logger.info(f"Observations submitted for aircraft {filtered_observation[0].icao_address} at time {current_simulation_time}") # Advance the simulation time by 1 second current_simulation_time = current_simulation_time.shift(seconds=1) return True @scenario_step("Submit Air Traffic") - async def submit_air_traffic(self, observations: List[Dict[str, Any]]) -> Dict[str, Any]: + async def submit_air_traffic(self, observations: list[FlightObservationSchema]) -> Dict[str, Any]: """Submit air traffic observations to the Flight Blender API. Args: @@ -572,6 +574,7 @@ async def submit_air_traffic(self, observations: List[Dict[str, Any]]) -> Dict[s Raises: FlightBlenderError: If the submission request fails. """ + ScenarioContext.add_air_traffic_data(observations) session_id = uuid.uuid4() endpoint = f"/flight_stream/set_air_traffic/{session_id}" logger.debug(f"Submitting {len(observations)} air traffic observations") diff --git a/src/openutm_verification/core/clients/opensky/opensky_client.py b/src/openutm_verification/core/clients/opensky/opensky_client.py index b5bdba91..8eba8f50 100644 --- a/src/openutm_verification/core/clients/opensky/opensky_client.py +++ b/src/openutm_verification/core/clients/opensky/opensky_client.py @@ -74,7 +74,7 @@ async def fetch_states_data(self) -> Optional[pd.DataFrame]: logger.error(f"Failed to fetch states data: {e}") return None - def process_flight_data(self, flight_df: pd.DataFrame) -> list[dict]: + def process_flight_data(self, flight_df: pd.DataFrame) -> list[FlightObservationSchema]: """Process flight DataFrame into observation format.""" observations = [] for _, row in flight_df.iterrows(): @@ -91,11 +91,11 @@ def process_flight_data(self, flight_df: pd.DataFrame) -> list[dict]: altitude_mm=float(altitude), metadata={"velocity": row["velocity"]}, ) - observations.append(observation.model_dump()) + observations.append(observation) logger.info(f"Processed {len(observations)} observations") return observations - async def fetch_and_process_data(self) -> Optional[list[dict]]: + async def fetch_and_process_data(self) -> list[FlightObservationSchema] | None: """Fetch flight data and process into observations.""" flight_df = await self.fetch_states_data() if flight_df is None or flight_df.empty: @@ -104,7 +104,7 @@ async def fetch_and_process_data(self) -> Optional[list[dict]]: return self.process_flight_data(flight_df) @scenario_step("Fetch OpenSky Data") - async def fetch_data(self): + async def fetch_data(self) -> list[FlightObservationSchema] | None: """Fetch and process live flight data from OpenSky Network. Retrieves current flight states from the OpenSky API within the configured diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 450d6395..c3043a58 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from functools import wraps from pathlib import Path -from typing import Any, Awaitable, Callable, Coroutine, List, Optional, ParamSpec, Protocol, TypedDict, TypeVar, cast, overload +from typing import Any, Awaitable, Callable, Coroutine, Dict, List, Optional, ParamSpec, Protocol, TypedDict, TypeVar, cast, overload from loguru import logger from uas_standards.astm.f3411.v22a.api import RIDAircraftState @@ -13,6 +13,7 @@ from openutm_verification.core.reporting.reporting_models import ScenarioResult, Status, StepResult from openutm_verification.models import FlightBlenderError from openutm_verification.simulator.models.declaration_models import FlightDeclaration +from openutm_verification.simulator.models.flight_data_types import FlightObservationSchema T = TypeVar("T") P = ParamSpec("P") @@ -25,6 +26,7 @@ class ScenarioState: active: bool = False flight_declaration_data: Optional[Any] = None telemetry_data: Optional[Any] = None + air_traffic_data: list[list[FlightObservationSchema]] = field(default_factory=list) class ScenarioRegistry(TypedDict): @@ -69,6 +71,12 @@ def set_telemetry_data(cls, data: list[RIDAircraftState]) -> None: if state and state.active: state.telemetry_data = data + @classmethod + def add_air_traffic_data(cls, data: list[FlightObservationSchema]) -> None: + state = _scenario_state.get() + if state and state.active: + state.air_traffic_data.append(data) + @property def steps(self) -> List[StepResult[Any]]: if self._state: @@ -90,6 +98,13 @@ def telemetry_data(self) -> Optional[list[RIDAircraftState]]: state = _scenario_state.get() return state.telemetry_data if state else None + @property + def air_traffic_data(self) -> list[list[FlightObservationSchema]]: + if self._state: + return self._state.air_traffic_data + state = _scenario_state.get() + return state.air_traffic_data if state else [] + class StepDecorator(Protocol): @overload diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index 0f9246b4..581e46ee 100644 --- a/src/openutm_verification/core/reporting/reporting.py +++ b/src/openutm_verification/core/reporting/reporting.py @@ -169,12 +169,19 @@ def _save_scenario_data(report_data: ReportData, output_dir: Path): scenario_dir = output_dir / result.name scenario_dir.mkdir(parents=True, exist_ok=True) - if result.flight_declaration_data: + json_result = result.model_dump(mode="json") + + if "flight_declaration_data" in json_result: file_path = scenario_dir / "flight_declaration.json" - file_path.write_text(result.flight_declaration_data.model_dump_json(), encoding="utf-8") + file_path.write_text(json.dumps(json_result["flight_declaration_data"], indent=2), encoding="utf-8") logger.debug(f"Saved flight declaration data to {file_path}") - if result.telemetry_data: + if "telemetry_data" in json_result: file_path = scenario_dir / "telemetry.json" - file_path.write_text(json.dumps(result.telemetry_data, indent=2), encoding="utf-8") + file_path.write_text(json.dumps(json_result["telemetry_data"], indent=2), encoding="utf-8") logger.debug(f"Saved telemetry data to {file_path}") + + if "air_traffic_data" in json_result: + file_path = scenario_dir / "air_traffic.json" + file_path.write_text(json.dumps(json_result["air_traffic_data"], indent=2), encoding="utf-8") + logger.debug(f"Saved air traffic data to {file_path}") diff --git a/src/openutm_verification/core/reporting/reporting_models.py b/src/openutm_verification/core/reporting/reporting_models.py index 6d0eb1c0..e4d095f1 100644 --- a/src/openutm_verification/core/reporting/reporting_models.py +++ b/src/openutm_verification/core/reporting/reporting_models.py @@ -10,6 +10,7 @@ from openutm_verification.core.execution.config_models import DeploymentDetails from openutm_verification.simulator.models.declaration_models import FlightDeclaration +from openutm_verification.simulator.models.flight_data_types import FlightObservationSchema class Status(StrEnum): @@ -29,25 +30,26 @@ class StepResult(BaseModel, Generic[T]): status: Status duration: float details: T = None # type: ignore - error_message: Optional[str] = None + error_message: str | None = None class ScenarioResult(BaseModel, arbitrary_types_allowed=True): """Data model for the result of a single scenario.""" name: str - suite_name: Optional[str] = None + suite_name: str | None = None status: Status duration_seconds: float - steps: List[StepResult[Any]] - error_message: Optional[str] = None - flight_declaration_filename: Optional[str] = None - telemetry_filename: Optional[str] = None - flight_declaration_data: Optional[FlightDeclaration] = None - telemetry_data: Optional[list[RIDAircraftState]] = None - visualization_2d_path: Optional[str] = None - visualization_3d_path: Optional[str] = None - docs: Optional[str] = None + steps: list[StepResult[Any]] + error_message: str | None = None + flight_declaration_filename: str | None = None + telemetry_filename: str | None = None + flight_declaration_data: FlightDeclaration | None = None + telemetry_data: list[RIDAircraftState] | None = None + air_traffic_data: list[list[FlightObservationSchema]] | None = None + visualization_2d_path: str | None = None + visualization_3d_path: str | None = None + docs: str | None = None class ReportSummary(BaseModel): diff --git a/src/openutm_verification/scenarios/registry.py b/src/openutm_verification/scenarios/registry.py index c8fab04b..b39fa027 100644 --- a/src/openutm_verification/scenarios/registry.py +++ b/src/openutm_verification/scenarios/registry.py @@ -43,6 +43,7 @@ async def _run_scenario_simple_async(scenario_id: str, func: Callable, args, kwa steps = ctx.steps flight_declaration_data = ctx.flight_declaration_data telemetry_data = ctx.telemetry_data + air_traffic_data = ctx.air_traffic_data final_status = Status.PASS if all(s.status == Status.PASS for s in steps) else Status.FAIL total_duration = sum(s.duration for s in steps) @@ -53,6 +54,7 @@ async def _run_scenario_simple_async(scenario_id: str, func: Callable, args, kwa steps=steps, flight_declaration_data=flight_declaration_data, telemetry_data=telemetry_data, + air_traffic_data=air_traffic_data, ) except Exception as e: diff --git a/src/openutm_verification/simulator/geo_json_telemetry.py b/src/openutm_verification/simulator/geo_json_telemetry.py index d58c21e2..f78f1131 100644 --- a/src/openutm_verification/simulator/geo_json_telemetry.py +++ b/src/openutm_verification/simulator/geo_json_telemetry.py @@ -81,7 +81,7 @@ def generate_air_traffic_data( duration: int, session_ids: list[UUID], number_of_aircraft: int = 1, - ) -> list[list[dict]]: + ) -> list[list[FlightObservationSchema]]: """Generate simulated air traffic observations for the specified duration. Creates random flight trajectories within the configured geographic bounds @@ -105,11 +105,11 @@ def generate_air_traffic_data( trajectory_geojson = generate_random_geojson("LineString", boundingBox=self.box.bounds, numberVertices=duration) all_trajectories.append(trajectory_geojson) - all_air_traffic: list[list[dict]] = [] + all_air_traffic: list[list[FlightObservationSchema]] = [] for trajectory_geojson in all_trajectories: # A GeoJSON LineString has 'coordinates' as a list of [lon, lat] pairs coordinates = trajectory_geojson["coordinates"] - airtraffic: list[dict] = [] + airtraffic: list[FlightObservationSchema] = [] icao_address = "".join(random.choices("0123456789ABCDEF", k=6)) for i in range(duration): timestamp = self.reference_time.shift(seconds=i) @@ -125,7 +125,7 @@ def generate_air_traffic_data( icao_address=icao_address, timestamp=timestamp.int_timestamp, metadata=metadata, - ).model_dump() + ) ) all_air_traffic.append(airtraffic) logger.info(f"Generated observations for {len(all_air_traffic)} aircraft") diff --git a/tests/test_client_steps.py b/tests/test_client_steps.py index a844718c..0a7b71c3 100644 --- a/tests/test_client_steps.py +++ b/tests/test_client_steps.py @@ -8,6 +8,7 @@ from openutm_verification.core.clients.opensky.opensky_client import OpenSkyClient from openutm_verification.core.reporting.reporting_models import Status from openutm_verification.models import OperationState, SDSPSessionAction +from openutm_verification.simulator.models.flight_data_types import FlightObservationSchema @pytest.fixture @@ -256,12 +257,48 @@ async def test_submit_simulated_air_traffic(fb_client): # Create dummy observations obs = [ [ - {"timestamp": "2023-01-01T00:00:00Z", "metadata": {"session_id": "sess1"}, "icao_address": "A1"}, - {"timestamp": "2023-01-01T00:00:01Z", "metadata": {"session_id": "sess1"}, "icao_address": "A1"}, + FlightObservationSchema( + lat_dd=0.0, + lon_dd=0.0, + altitude_mm=0.0, + traffic_source=0, + source_type=0, + timestamp=0, + metadata={"session_id": "sess1"}, + icao_address="A1", + ), + FlightObservationSchema( + lat_dd=0.0, + lon_dd=0.0, + altitude_mm=0.0, + traffic_source=0, + source_type=0, + timestamp=1, + metadata={"session_id": "sess1"}, + icao_address="A1", + ), ], [ - {"timestamp": "2023-01-01T00:00:00Z", "metadata": {"session_id": "sess2"}, "icao_address": "A2"}, - {"timestamp": "2023-01-01T00:00:01Z", "metadata": {"session_id": "sess2"}, "icao_address": "A2"}, + FlightObservationSchema( + lat_dd=0.0, + lon_dd=0.0, + altitude_mm=0.0, + traffic_source=0, + source_type=0, + timestamp=0, + metadata={"session_id": "sess2"}, + icao_address="A2", + ), + FlightObservationSchema( + lat_dd=0.0, + lon_dd=0.0, + altitude_mm=0.0, + traffic_source=0, + source_type=0, + timestamp=1, + metadata={"session_id": "sess2"}, + icao_address="A2", + ), ], ] @@ -431,5 +468,5 @@ async def test_fetch_data(os_client): result = await os_client.fetch_data() assert len(result.details) == 1 - assert result.details[0]["icao_address"] == "icao1" + assert result.details[0].icao_address == "icao1" os_client.get.assert_called_once() From 69c5e7d7ec3793da479752c96029221197b1126f Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sat, 13 Dec 2025 23:11:55 +0100 Subject: [PATCH 4/9] change reporting --- config/pull_request.yaml | 2 +- src/openutm_verification/core/reporting/reporting.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/pull_request.yaml b/config/pull_request.yaml index d6a9b457..df65cc27 100644 --- a/config/pull_request.yaml +++ b/config/pull_request.yaml @@ -33,6 +33,7 @@ data_files: suites: pull_requests: scenarios: + - name: add_flight_declaration - name: F1_happy_path trajectory: "config/bern/trajectory_f1.json" - name: F2_contingent_path @@ -40,7 +41,6 @@ suites: - name: F3_non_conforming_path trajectory: "config/bern/trajectory_f3.json" - name: opensky_live_data - - name: add_flight_declaration - name: geo_fence_upload - name: openutm_sim_air_traffic_data diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index 581e46ee..fd731d30 100644 --- a/src/openutm_verification/core/reporting/reporting.py +++ b/src/openutm_verification/core/reporting/reporting.py @@ -94,7 +94,7 @@ def _generate_html_report(report_data: ReportData, output_dir: Path, base_filena env.filters["markdown"] = lambda text: markdown.markdown(text) if text else "" template = env.get_template("report_template.html") - html_content = template.render(report_data=report_data) + html_content = template.render(report_data=report_data.model_dump(mode="json")) report_path = output_dir / f"{base_filename}.html" with open(report_path, "w", encoding="utf-8") as f: From 015d44884b3eb48d29dc53db211756eae5e7f8fc Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sat, 13 Dec 2025 23:18:52 +0100 Subject: [PATCH 5/9] fix --- .../core/clients/flight_blender/flight_blender_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py index 7e8e9c76..4a88a643 100644 --- a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py +++ b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py @@ -551,7 +551,7 @@ async def submit_simulated_air_traffic( for filtered_observation in filtered_observations: session_id = filtered_observation[0].metadata["session_id"] endpoint = f"/flight_stream/set_air_traffic/{session_id}" - payload = {"observations": filtered_observation} + payload = {"observations": [obs.model_dump(mode="json") for obs in filtered_observation]} ScenarioContext.add_air_traffic_data(filtered_observation) response = await self.post(endpoint, json=payload) From 0056db365d52d1a7bb635647299d0797a21b9952 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sat, 13 Dec 2025 23:19:47 +0100 Subject: [PATCH 6/9] reduce report files if empty. --- src/openutm_verification/core/reporting/reporting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index fd731d30..bc198f65 100644 --- a/src/openutm_verification/core/reporting/reporting.py +++ b/src/openutm_verification/core/reporting/reporting.py @@ -171,17 +171,17 @@ def _save_scenario_data(report_data: ReportData, output_dir: Path): json_result = result.model_dump(mode="json") - if "flight_declaration_data" in json_result: + if json_result.get("flight_declaration_data"): file_path = scenario_dir / "flight_declaration.json" file_path.write_text(json.dumps(json_result["flight_declaration_data"], indent=2), encoding="utf-8") logger.debug(f"Saved flight declaration data to {file_path}") - if "telemetry_data" in json_result: + if json_result.get("telemetry_data"): file_path = scenario_dir / "telemetry.json" file_path.write_text(json.dumps(json_result["telemetry_data"], indent=2), encoding="utf-8") logger.debug(f"Saved telemetry data to {file_path}") - if "air_traffic_data" in json_result: + if json_result.get("air_traffic_data"): file_path = scenario_dir / "air_traffic.json" file_path.write_text(json.dumps(json_result["air_traffic_data"], indent=2), encoding="utf-8") logger.debug(f"Saved air traffic data to {file_path}") From 0f4f76026d7c9ccef69411d7fe99328c2141bbff Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sat, 13 Dec 2025 23:26:19 +0100 Subject: [PATCH 7/9] Fix serialization --- .../clients/flight_blender/flight_blender_client.py | 2 +- tests/test_client_steps.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py index 4a88a643..9bbf999c 100644 --- a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py +++ b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py @@ -578,7 +578,7 @@ async def submit_air_traffic(self, observations: list[FlightObservationSchema]) session_id = uuid.uuid4() endpoint = f"/flight_stream/set_air_traffic/{session_id}" logger.debug(f"Submitting {len(observations)} air traffic observations") - payload = {"observations": observations} + payload = {"observations": [obs.model_dump(mode="json") for obs in observations]} response = await self.post(endpoint, json=payload) logger.debug(f"Air traffic submission response: {response.text}") diff --git a/tests/test_client_steps.py b/tests/test_client_steps.py index 0a7b71c3..a9aba8f3 100644 --- a/tests/test_client_steps.py +++ b/tests/test_client_steps.py @@ -187,7 +187,18 @@ async def test_submit_air_traffic(fb_client): mock_response.json.return_value = {"status": "ok"} fb_client.post.return_value = mock_response - observations = [{"icao": "abc"}] + observations = [ + FlightObservationSchema( + lat_dd=0.0, + lon_dd=0.0, + altitude_mm=0.0, + traffic_source=0, + source_type=0, + timestamp=0, + metadata={"session_id": "sess1"}, + icao_address="A1", + ) + ] result = await fb_client.submit_air_traffic(observations=observations) assert result.status == Status.PASS From 184d86f9f8f91be6a623584c4c94ef906aa1af8d Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sun, 14 Dec 2025 13:46:51 +0100 Subject: [PATCH 8/9] Change timestamped files to directories. --- src/openutm_verification/auth/dev_auth.py | 5 +- src/openutm_verification/auth/noauth.py | 4 +- src/openutm_verification/auth/oauth2.py | 5 +- src/openutm_verification/auth/passport.py | 13 ++--- src/openutm_verification/cli/__init__.py | 12 +++- .../clients/air_traffic/air_traffic_client.py | 5 +- .../clients/flight_blender/base_client.py | 8 +-- .../flight_blender/flight_blender_client.py | 48 +++++++++------- .../core/clients/opensky/base_client.py | 12 ++-- .../core/clients/opensky/opensky_client.py | 4 +- .../core/execution/config_models.py | 40 +++++++------- .../core/execution/dependencies.py | 4 +- .../core/execution/dependency_resolution.py | 4 +- .../core/execution/execution.py | 12 ++-- .../core/execution/scenario_runner.py | 20 +++---- .../core/reporting/reporting_models.py | 14 +++-- .../importers/amqp_consumer.py | 3 +- .../importers/get_access_token.py | 3 +- src/openutm_verification/rid.py | 33 ++++++----- src/openutm_verification/scenarios/common.py | 3 +- .../simulator/geo_json_telemetry.py | 11 ++-- .../simulator/models/flight_data_types.py | 12 ++-- .../simulator/models/geo_json_models.py | 26 ++++----- .../simulator/models/utils.py | 4 +- tests/test_client_steps.py | 55 ++++++++++++++++++- 25 files changed, 205 insertions(+), 155 deletions(-) diff --git a/src/openutm_verification/auth/dev_auth.py b/src/openutm_verification/auth/dev_auth.py index 1fe7b0b8..ca81fc2e 100644 --- a/src/openutm_verification/auth/dev_auth.py +++ b/src/openutm_verification/auth/dev_auth.py @@ -1,7 +1,6 @@ import datetime import uuid from abc import ABC, abstractmethod -from typing import List import jwcrypto.jwk import jwcrypto.jwt @@ -23,7 +22,7 @@ class AuthAdapter(ABC): """Abstract base class for an adapter that adds JWTs to requests.""" @abstractmethod - def issue_token(self, intended_audience: str, scopes: List[str]) -> str: + def issue_token(self, intended_audience: str, scopes: list[str]) -> str: """Subclasses must return a bearer token for the given audience.""" pass @@ -60,7 +59,7 @@ def __init__(self, sub: str = "uss_noauth"): self.sub = sub # Overrides method in AuthAdapter - def issue_token(self, intended_audience: str, scopes: List[str]) -> str: + def issue_token(self, intended_audience: str, scopes: list[str]) -> str: timestamp = int((datetime.datetime.now(datetime.timezone.utc) - EPOCH).total_seconds()) jwt = jwcrypto.jwt.JWT( header={"typ": "JWT", "alg": "RS256"}, diff --git a/src/openutm_verification/auth/noauth.py b/src/openutm_verification/auth/noauth.py index f46d4d66..5ada4547 100644 --- a/src/openutm_verification/auth/noauth.py +++ b/src/openutm_verification/auth/noauth.py @@ -2,15 +2,13 @@ No-authentication credentials provider for development/testing. """ -from typing import List - from openutm_verification.auth.dev_auth import NoAuth class NoAuthCredentialsGetter: """Credentials getter that uses dummy authentication for development.""" - def get_cached_credentials(self, audience: str, scopes: List[str]): + def get_cached_credentials(self, audience: str, scopes: list[str]): """Get cached credentials using dummy authentication.""" if not audience: return {"error": "An audience parameter must be provided"} diff --git a/src/openutm_verification/auth/oauth2.py b/src/openutm_verification/auth/oauth2.py index d898b58e..59d5e83e 100644 --- a/src/openutm_verification/auth/oauth2.py +++ b/src/openutm_verification/auth/oauth2.py @@ -1,5 +1,4 @@ import time -from typing import Optional import httpx from loguru import logger @@ -16,7 +15,7 @@ class OAuth2Token(BaseModel): access_token: str token_type: str = "Bearer" expires_in: int - expires_at: Optional[float] = None + expires_at: float | None = None def is_expired(self, buffer_seconds: int = 60) -> bool: """Check if token is expired with buffer time.""" @@ -39,7 +38,7 @@ def __init__( self.client_id = client_id self.client_secret = client_secret self.client = httpx.AsyncClient(timeout=timeout) - self._token: Optional[OAuth2Token] = None + self._token: OAuth2Token | None = None async def get_access_token(self) -> str: """Get valid access token, acquiring or refreshing as needed.""" diff --git a/src/openutm_verification/auth/passport.py b/src/openutm_verification/auth/passport.py index 61ef8cb9..e2ef6ccb 100644 --- a/src/openutm_verification/auth/passport.py +++ b/src/openutm_verification/auth/passport.py @@ -3,7 +3,6 @@ """ from os import environ as env -from typing import List, Optional import requests from loguru import logger @@ -14,11 +13,11 @@ class PassportCredentialsGetter: def __init__( self, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - audience: Optional[str] = None, - token_endpoint: Optional[str] = None, - passport_base_url: Optional[str] = None, + client_id: str | None = None, + client_secret: str | None = None, + audience: str | None = None, + token_endpoint: str | None = None, + passport_base_url: str | None = None, ): self.client_id = client_id or env.get("BLENDER_WRITE_CLIENT_ID") self.client_secret = client_secret or env.get("BLENDER_WRITE_CLIENT_SECRET") @@ -26,7 +25,7 @@ def __init__( self.token_endpoint = token_endpoint or env.get("PASSPORT_TOKEN_URL") self.base_url = passport_base_url or env.get("PASSPORT_URL") - def get_cached_credentials(self, audience: Optional[str] = None, scopes: Optional[List[str]] = None): + def get_cached_credentials(self, audience: str | None = None, scopes: list[str] | None = None): """Get cached credentials with token refresh logic.""" if not audience: diff --git a/src/openutm_verification/cli/__init__.py b/src/openutm_verification/cli/__init__.py index 4a9822ac..6184ae2a 100644 --- a/src/openutm_verification/cli/__init__.py +++ b/src/openutm_verification/cli/__init__.py @@ -42,11 +42,17 @@ def main(): ConfigProxy.initialize(config) # Setup logging - output_dir = Path(config.reporting.output_dir) + run_timestamp = datetime.now(timezone.utc) + timestamp_str = run_timestamp.strftime("%Y-%m-%dT%H-%M-%SZ") + + base_output_dir = Path(config.reporting.output_dir) + output_dir = base_output_dir / f"run_{timestamp_str}" output_dir.mkdir(parents=True, exist_ok=True) - run_timestamp = datetime.now(timezone.utc) - base_filename = f"report_{run_timestamp.strftime('%Y-%m-%dT%H-%M-%SZ')}" + # Update config so downstream components use the new directory + config.reporting.output_dir = str(output_dir) + + base_filename = "report" log_file = setup_logging(output_dir, base_filename, config.reporting.formats, args.debug) # Run verification scenarios diff --git a/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py b/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py index bf61e446..d6a2f0fd 100644 --- a/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py +++ b/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py @@ -1,6 +1,5 @@ import json import uuid -from typing import Optional from uuid import UUID from loguru import logger @@ -34,8 +33,8 @@ def __init__(self, settings: AirTrafficSettings): @scenario_step("Generate Simulated Air Traffic Data") async def generate_simulated_air_traffic_data( self, - config_path: Optional[str] = None, - duration: Optional[int] = None, + config_path: str | None = None, + duration: int | None = None, ) -> list[list[FlightObservationSchema]]: """Generate simulated air traffic data from GeoJSON configuration. diff --git a/src/openutm_verification/core/clients/flight_blender/base_client.py b/src/openutm_verification/core/clients/flight_blender/base_client.py index 139e76f2..af2b4d49 100644 --- a/src/openutm_verification/core/clients/flight_blender/base_client.py +++ b/src/openutm_verification/core/clients/flight_blender/base_client.py @@ -1,8 +1,6 @@ -from typing import Any - import httpx from loguru import logger -from websocket import create_connection +from websocket import WebSocket, create_connection from openutm_verification.models import FlightBlenderError @@ -65,7 +63,7 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): await self.client.aclose() - def create_websocket_connection(self, endpoint) -> Any: + def create_websocket_connection(self, endpoint) -> WebSocket: """Create and return a WebSocket connection to the Flight Blender service. This method establishes a WebSocket connection using the configured @@ -86,7 +84,7 @@ def create_websocket_connection(self, endpoint) -> Any: websocket_connection.send(self.client.headers["Authorization"]) return websocket_connection - def close_websocket_connection(self, ws_connection: Any) -> None: + def close_websocket_connection(self, ws_connection: WebSocket) -> None: """Close the given WebSocket connection. Args: diff --git a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py index 9bbf999c..3879a9fc 100644 --- a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py +++ b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py @@ -4,10 +4,13 @@ import uuid from contextlib import asynccontextmanager from dataclasses import asdict -from typing import Any, Dict, List, Optional +from typing import Any import arrow from loguru import logger +from pydantic import BaseModel +from uas_standards.astm.f3411.v22a.api import RIDAircraftState +from websocket import WebSocket from openutm_verification.core.clients.flight_blender.base_client import ( BaseBlenderAPIClient, @@ -81,14 +84,14 @@ class FlightBlenderClient(BaseBlenderAPIClient): latest_flight_declaration_id: The ID of the most recently uploaded flight declaration. """ - def __init__(self, base_url: str, credentials: Dict[str, Any], request_timeout: int = 10) -> None: + def __init__(self, base_url: str, credentials: dict, request_timeout: int = 10) -> None: super().__init__(base_url=base_url, credentials=credentials, request_timeout=request_timeout) # Context: store the most recently created geo-fence id for teardown convenience - self.latest_geo_fence_id: Optional[str] = None + self.latest_geo_fence_id: str | None = None # Context: store the most recently created flight declaration id for teardown/steps - self.latest_flight_declaration_id: Optional[str] = None + self.latest_flight_declaration_id: str | None = None # Context: store the generated telemetry states for the current scenario - self.telemetry_states: Optional[List[Dict[str, Any]]] = None + self.telemetry_states: list[RIDAircraftState] | None = None logger.debug(f"Initialized FlightBlenderClient with base_url={base_url}, request_timeout={request_timeout}") async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: @@ -103,7 +106,7 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: return await super().__aexit__(exc_type, exc_val, exc_tb) @scenario_step("Upload Geo Fence") - async def upload_geo_fence(self, filename: Optional[str] = None) -> Dict[str, Any]: + async def upload_geo_fence(self, filename: str | None = None) -> dict[str, Any]: """Upload an Area-of-Interest (Geo Fence) to Flight Blender. Args: @@ -134,7 +137,7 @@ async def upload_geo_fence(self, filename: Optional[str] = None) -> Dict[str, An return body @scenario_step("Get Geo Fence") - async def get_geo_fence(self) -> Dict[str, Any]: + async def get_geo_fence(self) -> dict[str, Any]: """Retrieve the details of the most recently uploaded geo-fence. Returns: @@ -152,7 +155,7 @@ async def get_geo_fence(self) -> Dict[str, Any]: return response.json() @scenario_step("Delete Geo Fence") - async def delete_geo_fence(self, geo_fence_id: Optional[str] = None) -> Dict[str, Any]: + async def delete_geo_fence(self, geo_fence_id: str | None = None) -> dict[str, Any]: """Delete a geo-fence by ID. Args: @@ -190,7 +193,7 @@ async def delete_geo_fence(self, geo_fence_id: Optional[str] = None) -> Dict[str return {"deleted": response.status_code in (200, 204), "id": op_id} @scenario_step("Upload Flight Declaration") - async def upload_flight_declaration(self, declaration: str | Any) -> Dict[str, Any]: + async def upload_flight_declaration(self, declaration: str | BaseModel) -> dict[str, Any]: """Upload a flight declaration to the Flight Blender API. Accepts either a filename (str) containing JSON declaration data, or a @@ -260,7 +263,7 @@ async def wait_for_user_input(self, prompt: str = "Press Enter to continue...") return input(prompt) @scenario_step("Update Operation State") - async def update_operation_state(self, new_state: OperationState, duration_seconds: int = 0) -> Dict[str, Any]: + async def update_operation_state(self, new_state: OperationState, duration_seconds: int = 0) -> dict[str, Any]: """Update the state of a flight operation. Posts the new state and optionally waits for the specified duration. @@ -286,7 +289,7 @@ async def update_operation_state(self, new_state: OperationState, duration_secon await asyncio.sleep(duration_seconds) return response.json() - def _load_telemetry_file(self, filename: str) -> List[Dict[str, Any]]: + def _load_telemetry_file(self, filename: str) -> list[RIDAircraftState]: """Load telemetry states from a JSON file. Args: @@ -301,9 +304,9 @@ def _load_telemetry_file(self, filename: str) -> List[Dict[str, Any]]: logger.debug(f"Loading telemetry from {filename}") with open(filename, "r", encoding="utf-8") as rid_json_file: rid_json = json.loads(rid_json_file.read()) - return rid_json["current_states"] + return [RIDAircraftState(**state) for state in rid_json["current_states"]] - async def _submit_telemetry_states_impl(self, states: List[Dict[str, Any]], duration_seconds: int = 0) -> Optional[Dict[str, Any]]: + async def _submit_telemetry_states_impl(self, states: list[RIDAircraftState], duration_seconds: int = 0) -> dict[str, Any] | None: """Internal implementation for submitting telemetry states. Args: @@ -317,6 +320,7 @@ async def _submit_telemetry_states_impl(self, states: List[Dict[str, Any]], dura FlightBlenderError: If maximum waiting time is exceeded due to rate limits. """ endpoint = "/flight_stream/set_telemetry" + assert self.latest_flight_declaration_id is not None, "Latest flight declaration ID must be set" logger.debug(f"Submitting telemetry for operation {self.latest_flight_declaration_id}") rid_operator_details = _create_rid_operator_details(self.latest_flight_declaration_id) @@ -357,7 +361,7 @@ async def _submit_telemetry_states_impl(self, states: List[Dict[str, Any]], dura return last_response @scenario_step("Submit Telemetry (from file)") - async def submit_telemetry_from_file(self, filename: str, duration_seconds: int = 0) -> Optional[Dict[str, Any]]: + async def submit_telemetry_from_file(self, filename: str, duration_seconds: int = 0) -> dict[str, Any] | None: """Submit telemetry data for a flight operation. Loads telemetry states from file and submits them sequentially, with optional @@ -385,7 +389,7 @@ async def wait_x_seconds(self, wait_time_seconds: int = 5) -> str: return f"Waited for Flight Blender to process {wait_time_seconds} seconds." @scenario_step("Submit Telemetry") - async def submit_telemetry(self, states: Optional[List[Dict[str, Any]]] = None, duration_seconds: int = 0) -> Optional[Dict[str, Any]]: + async def submit_telemetry(self, states: list[RIDAircraftState] | None = None, duration_seconds: int = 0) -> dict[str, Any] | None: """Submit telemetry data for a flight operation from in-memory states. Submits telemetry states sequentially from the provided list, with optional @@ -436,7 +440,7 @@ async def check_operation_state_connected( self, expected_state: OperationState, duration_seconds: int = 0, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Check the operation state by polling the API until the expected state is reached. Args: @@ -472,7 +476,7 @@ async def check_operation_state_connected( ) @scenario_step("Delete Flight Declaration") - async def delete_flight_declaration(self) -> Dict[str, Any]: + async def delete_flight_declaration(self) -> dict[str, Any]: """Delete a flight declaration by ID. Returns: @@ -562,7 +566,7 @@ async def submit_simulated_air_traffic( return True @scenario_step("Submit Air Traffic") - async def submit_air_traffic(self, observations: list[FlightObservationSchema]) -> Dict[str, Any]: + async def submit_air_traffic(self, observations: list[FlightObservationSchema]) -> dict[str, Any]: """Submit air traffic observations to the Flight Blender API. Args: @@ -615,12 +619,12 @@ async def start_stop_sdsp_session(self, session_id: str, action: SDSPSessionActi logger.error(f"Failed to perform action {action.value} on SDSP session {session_id}. Response: {response.text}") raise FlightBlenderError(f"{action.value} Heartbeat Track message not received for {session_id}") - def initialize_heartbeat_websocket_connection(self, session_id: str) -> Any: + def initialize_heartbeat_websocket_connection(self, session_id: str) -> WebSocket: endpoint = f"/ws/surveillance/heartbeat/{session_id}" ws = self.create_websocket_connection(endpoint=endpoint) return ws - def initialize_track_websocket_connection(self, session_id: str) -> Any: + def initialize_track_websocket_connection(self, session_id: str) -> WebSocket: endpoint = f"/ws/surveillance/track/{session_id}" ws = self.create_websocket_connection(endpoint=endpoint) return ws @@ -763,7 +767,7 @@ async def initialize_verify_sdsp_heartbeat( duration=duration, ) - def close_heartbeat_websocket_connection(self, ws_connection: Any) -> None: + def close_heartbeat_websocket_connection(self, ws_connection: WebSocket) -> None: ws_connection.close() @scenario_step("Teardown Flight Declaration") @@ -797,6 +801,8 @@ async def setup_flight_declaration(self, flight_declaration_path: str, trajector @asynccontextmanager async def create_flight_declaration(self, data_files: DataFiles): """Context manager to setup and teardown a flight operation based on scenario config.""" + assert data_files.flight_declaration is not None, "Flight declaration file path must be provided" + assert data_files.trajectory is not None, "Trajectory file path must be provided" await self.setup_flight_declaration(data_files.flight_declaration, data_files.trajectory) try: yield diff --git a/src/openutm_verification/core/clients/opensky/base_client.py b/src/openutm_verification/core/clients/opensky/base_client.py index c047a60a..5273d517 100644 --- a/src/openutm_verification/core/clients/opensky/base_client.py +++ b/src/openutm_verification/core/clients/opensky/base_client.py @@ -1,5 +1,3 @@ -from typing import Optional, Tuple - import httpx from loguru import logger from pydantic_settings import BaseSettings @@ -27,7 +25,7 @@ class OpenSkySettings(BaseSettings): # Optional configuration with defaults base_url: str = "https://opensky-network.org/api" request_timeout: int = 10 - viewport: Tuple[float, float, float, float] = (45.8389, 47.8229, 5.9962, 10.5226) + viewport: tuple[float, float, float, float] = (45.8389, 47.8229, 5.9962, 10.5226) # Simulation settings simulation_config_path: str @@ -62,8 +60,8 @@ async def _request( self, method: str, endpoint: str, - params: Optional[dict] = None, - silent_status: Optional[list[int]] = None, + params: dict | None = None, + silent_status: list[int] | None = None, ) -> httpx.Response: """Make authenticated request to OpenSky API.""" url = f"{self.settings.base_url}{endpoint}" @@ -86,8 +84,8 @@ async def _request( async def get( self, endpoint: str, - params: Optional[dict] = None, - silent_status: Optional[list[int]] = None, + params: dict | None = None, + silent_status: list[int] | None = None, ) -> httpx.Response: """Make GET request to OpenSky API.""" return await self._request("GET", endpoint, params=params, silent_status=silent_status) diff --git a/src/openutm_verification/core/clients/opensky/opensky_client.py b/src/openutm_verification/core/clients/opensky/opensky_client.py index 8eba8f50..907f280e 100644 --- a/src/openutm_verification/core/clients/opensky/opensky_client.py +++ b/src/openutm_verification/core/clients/opensky/opensky_client.py @@ -1,5 +1,3 @@ -from typing import Optional - import pandas as pd from loguru import logger @@ -55,7 +53,7 @@ def _calculate_viewport_bounds(self) -> dict: "lomax": lng_max, } - async def fetch_states_data(self) -> Optional[pd.DataFrame]: + async def fetch_states_data(self) -> pd.DataFrame | None: """Fetch current flight states from OpenSky Network.""" try: response = await self.get("/states/all", params=self._viewport_bounds) diff --git a/src/openutm_verification/core/execution/config_models.py b/src/openutm_verification/core/execution/config_models.py index 04baacf4..34d1deab 100644 --- a/src/openutm_verification/core/execution/config_models.py +++ b/src/openutm_verification/core/execution/config_models.py @@ -2,8 +2,10 @@ Pydantic models for application configuration. """ +from __future__ import annotations + from pathlib import Path -from typing import Annotated, Dict, List, Literal, Optional, TypedDict +from typing import Annotated, Literal, TypedDict from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -16,12 +18,12 @@ class AuthConfig(StrictBaseModel): """Authentication configuration for Flight Blender.""" type: Literal["none", "passport", "oauth2"] = "none" - client_id: Optional[str] = None - client_secret: Optional[str] = None - audience: Optional[str] = None - scopes: List[str] | None = None - token_endpoint: Optional[str] = None - passport_base_url: Optional[str] = None + client_id: str | None = None + client_secret: str | None = None + audience: str | None = None + scopes: list[str] | None = None + token_endpoint: str | None = None + passport_base_url: str | None = None class FlightBlenderConfig(StrictBaseModel): @@ -35,7 +37,7 @@ class AirTrafficSimulatorSettings(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) + sensor_ids: list[str] = Field(default_factory=list) class OpenSkyConfig(StrictBaseModel): @@ -56,20 +58,20 @@ class ReportingConfig(StrictBaseModel): """Configuration for generating reports.""" output_dir: str = "reports" - formats: List[str] = Field(default_factory=lambda: ["json", "html", "log"]) + formats: list[str] = Field(default_factory=lambda: ["json", "html", "log"]) deployment_details: DeploymentDetails = Field(default_factory=DeploymentDetails) class DataFiles(StrictBaseModel): """Paths to data files used in the application.""" - trajectory: Optional[str] = None - flight_declaration: Optional[str] = None - geo_fence: Optional[str] = None + trajectory: str | None = None + flight_declaration: str | None = None + geo_fence: str | None = None @field_validator("trajectory", "flight_declaration", "geo_fence") @classmethod - def validate_path(cls, v: Optional[str]) -> Optional[str]: + def validate_path(cls, v: str | None) -> str | None: """Validate that path is a non-empty string if provided.""" if v is not None: if not isinstance(v, str): @@ -110,7 +112,7 @@ class SuiteScenario(DataFiles): class SuiteConfig(StrictBaseModel): """Configuration for a test suite.""" - scenarios: Optional[List[SuiteScenario]] = Field(default_factory=list) + scenarios: list[SuiteScenario] | None = Field(default_factory=list) def resolve_paths(self, base_path: Path) -> None: if self.scenarios: @@ -127,11 +129,11 @@ class AppConfig(StrictBaseModel): opensky: OpenSkyConfig air_traffic_simulator_settings: AirTrafficSimulatorSettings data_files: DataFiles - suites: Dict[str, SuiteConfig] = Field(default_factory=dict) + suites: dict[str, SuiteConfig] = Field(default_factory=dict) reporting: ReportingConfig # Runtime only - target_suites: List[str] = Field(default_factory=list) + target_suites: list[str] = Field(default_factory=list) def resolve_paths(self, config_file_path: Path) -> None: """Resolve all relative paths in the configuration to absolute paths.""" @@ -146,9 +148,9 @@ def resolve_paths(self, config_file_path: Path) -> None: class RunContext(TypedDict): scenario_id: str - docs: Optional[str] - suite_scenario: Optional[SuiteScenario] - suite_name: Optional[str] + docs: str | None + suite_scenario: SuiteScenario | None + suite_name: str | None class ConfigMeta(type): diff --git a/src/openutm_verification/core/execution/dependencies.py b/src/openutm_verification/core/execution/dependencies.py index 5230999c..4f285c13 100644 --- a/src/openutm_verification/core/execution/dependencies.py +++ b/src/openutm_verification/core/execution/dependencies.py @@ -1,4 +1,4 @@ -from typing import Any, AsyncGenerator, Callable, Coroutine, Generator, Iterable, Optional, TypeVar, cast +from typing import Any, AsyncGenerator, Callable, Coroutine, Generator, Iterable, TypeVar, cast from loguru import logger @@ -16,7 +16,7 @@ T = TypeVar("T") -def get_scenario_docs(scenario_id: str) -> Optional[str]: +def get_scenario_docs(scenario_id: str) -> str | None: docs_path = SCENARIO_REGISTRY[scenario_id].get("docs") if docs_path and docs_path.exists(): try: diff --git a/src/openutm_verification/core/execution/dependency_resolution.py b/src/openutm_verification/core/execution/dependency_resolution.py index 90bb35de..d1736d1d 100644 --- a/src/openutm_verification/core/execution/dependency_resolution.py +++ b/src/openutm_verification/core/execution/dependency_resolution.py @@ -1,7 +1,7 @@ import inspect from contextlib import AsyncExitStack, asynccontextmanager, contextmanager from contextvars import ContextVar -from typing import AsyncContextManager, AsyncGenerator, Callable, ContextManager, Generator, TypeVar, cast +from typing import Any, AsyncContextManager, AsyncGenerator, Callable, ContextManager, Coroutine, Generator, TypeVar, cast from openutm_verification.core.execution.config_models import RunContext @@ -29,7 +29,7 @@ def wrapper(func: Callable[..., Generator | AsyncGenerator]) -> Callable[..., Ge return wrapper -async def call_with_dependencies(func: Callable[..., T]) -> T: +async def call_with_dependencies(func: Callable[..., Coroutine[Any, Any, T]]) -> T: """Call a function with its dependencies automatically provided. Args: diff --git a/src/openutm_verification/core/execution/execution.py b/src/openutm_verification/core/execution/execution.py index 470e248f..bd93ed3d 100644 --- a/src/openutm_verification/core/execution/execution.py +++ b/src/openutm_verification/core/execution/execution.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone from importlib.metadata import version from pathlib import Path -from typing import Any +from typing import TypeVar from loguru import logger from pydantic import ValidationError @@ -29,8 +29,10 @@ ) from openutm_verification.utils.paths import get_docs_directory +T = TypeVar("T") -def _sanitize_config(data: Any) -> Any: + +def _sanitize_config(data: T) -> T: """ Recursively sanitize sensitive fields in the configuration data for logging and reporting. @@ -46,9 +48,9 @@ def _sanitize_config(data: Any) -> Any: sanitized[key] = sensitive_mask else: sanitized[key] = _sanitize_config(value) - return sanitized + return sanitized # type: ignore elif isinstance(data, list): - return [_sanitize_config(item) for item in data] + return [_sanitize_config(item) for item in data] # type: ignore else: return data @@ -122,6 +124,6 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path): logger.info(f"Verification run complete with overall status: {overall_status}") - base_filename = f"report_{run_timestamp.strftime('%Y-%m-%dT%H-%M-%SZ')}" + base_filename = "report" generate_reports(report_data, config.reporting, base_filename) return failed_scenarios diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index c3043a58..aebe41a9 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from functools import wraps from pathlib import Path -from typing import Any, Awaitable, Callable, Coroutine, Dict, List, Optional, ParamSpec, Protocol, TypedDict, TypeVar, cast, overload +from typing import Any, Awaitable, Callable, Coroutine, ParamSpec, Protocol, TypedDict, TypeVar, cast, overload from loguru import logger from uas_standards.astm.f3411.v22a.api import RIDAircraftState @@ -22,25 +22,25 @@ @dataclass class ScenarioState: - steps: List[StepResult[Any]] = field(default_factory=list) + steps: list[StepResult[Any]] = field(default_factory=list) active: bool = False - flight_declaration_data: Optional[Any] = None - telemetry_data: Optional[Any] = None + flight_declaration_data: FlightDeclaration | None = None + telemetry_data: list[RIDAircraftState] | None = None air_traffic_data: list[list[FlightObservationSchema]] = field(default_factory=list) class ScenarioRegistry(TypedDict): func: Callable[..., Coroutine[Any, Any, ScenarioResult]] - docs: Optional[Path] + docs: Path | None -_scenario_state: contextvars.ContextVar[Optional[ScenarioState]] = contextvars.ContextVar("scenario_state", default=None) +_scenario_state: contextvars.ContextVar[ScenarioState | None] = contextvars.ContextVar("scenario_state", default=None) class ScenarioContext: def __init__(self): self._token = None - self._state: Optional[ScenarioState] = None + self._state: ScenarioState | None = None def __enter__(self): self._state = ScenarioState(active=True) @@ -78,21 +78,21 @@ def add_air_traffic_data(cls, data: list[FlightObservationSchema]) -> None: state.air_traffic_data.append(data) @property - def steps(self) -> List[StepResult[Any]]: + def steps(self) -> list[StepResult[Any]]: if self._state: return self._state.steps state = _scenario_state.get() return state.steps if state else [] @property - def flight_declaration_data(self) -> Optional[FlightDeclaration]: + def flight_declaration_data(self) -> FlightDeclaration | None: if self._state: return self._state.flight_declaration_data state = _scenario_state.get() return state.flight_declaration_data if state else None @property - def telemetry_data(self) -> Optional[list[RIDAircraftState]]: + def telemetry_data(self) -> list[RIDAircraftState] | None: if self._state: return self._state.telemetry_data state = _scenario_state.get() diff --git a/src/openutm_verification/core/reporting/reporting_models.py b/src/openutm_verification/core/reporting/reporting_models.py index e4d095f1..fbad735c 100644 --- a/src/openutm_verification/core/reporting/reporting_models.py +++ b/src/openutm_verification/core/reporting/reporting_models.py @@ -3,9 +3,9 @@ """ from enum import StrEnum -from typing import Any, Dict, Generic, List, Optional, TypeVar +from typing import Any, Generic, TypeVar -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from uas_standards.astm.f3411.v22a.api import RIDAircraftState from openutm_verification.core.execution.config_models import DeploymentDetails @@ -33,9 +33,11 @@ class StepResult(BaseModel, Generic[T]): error_message: str | None = None -class ScenarioResult(BaseModel, arbitrary_types_allowed=True): +class ScenarioResult(BaseModel): """Data model for the result of a single scenario.""" + model_config = ConfigDict(arbitrary_types_allowed=True) + name: str suite_name: str | None = None status: Status @@ -72,7 +74,7 @@ class ReportData(BaseModel): flight_blender_url: str deployment_details: DeploymentDetails config_file: str - config: Dict[str, Any] - results: List[ScenarioResult] + config: dict[str, Any] + results: list[ScenarioResult] summary: ReportSummary - docs_dir: Optional[str] = None + docs_dir: str | None = None diff --git a/src/openutm_verification/importers/amqp_consumer.py b/src/openutm_verification/importers/amqp_consumer.py index 72dbabc0..69e0958b 100644 --- a/src/openutm_verification/importers/amqp_consumer.py +++ b/src/openutm_verification/importers/amqp_consumer.py @@ -2,13 +2,12 @@ import os import sys from os import environ as env -from typing import List import pika from dotenv import find_dotenv, load_dotenv -def parse_args(argv: List[str]): +def parse_args(argv: list[str]): parser = argparse.ArgumentParser(description="Retrieve an access token") parser.add_argument( "--queue", diff --git a/src/openutm_verification/importers/get_access_token.py b/src/openutm_verification/importers/get_access_token.py index 1ea7ae18..88f297b7 100644 --- a/src/openutm_verification/importers/get_access_token.py +++ b/src/openutm_verification/importers/get_access_token.py @@ -1,11 +1,10 @@ import argparse import sys -from typing import List from openutm_verification.auth.dev_auth import NoAuth -def parse_args(argv: List[str]): +def parse_args(argv: list[str]): parser = argparse.ArgumentParser(description="Retrieve an access token") parser.add_argument( "--scopes", diff --git a/src/openutm_verification/rid.py b/src/openutm_verification/rid.py index 9b4d197a..6dd1f3b5 100644 --- a/src/openutm_verification/rid.py +++ b/src/openutm_verification/rid.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional SpecificSessionID = str @@ -52,37 +51,37 @@ class AltitudeType(Enum): @dataclass class RIDAuthData: - data: Optional[str] = "" - format: Optional[int] = 0 + data: str | None = "" + format: int | None = 0 @dataclass class OperatorLocation: position: LatLngPoint - altitude: Optional[Altitude] = None - altitude_type: Optional[AltitudeType] = None + altitude: Altitude | None = None + altitude_type: AltitudeType | None = None @dataclass class UASID: - specific_session_id: Optional[SpecificSessionID] = None - serial_number: Optional[str] = "" - registration_id: Optional[str] = "" - utm_id: Optional[str] = "" + specific_session_id: SpecificSessionID | None = None + serial_number: str | None = "" + registration_id: str | None = "" + utm_id: str | None = "" @dataclass class UAClassificationEU: - category: Optional[Category] = "EUCategoryUndefined" - class_: Optional[Class] = "EUClassUndefined" + category: Category | None = Category.EUCategoryUndefined + class_: Class | None = Class.EUClassUndefined @dataclass class RIDOperatorDetails: id: str - eu_classification: Optional[UAClassificationEU] = None - uas_id: Optional[UASID] = None - operator_location: Optional[OperatorLocation] = None - auth_data: Optional[RIDAuthData] = None - operator_id: Optional[str] = "" - operation_description: Optional[str] = "" + eu_classification: UAClassificationEU | None = None + uas_id: UASID | None = None + operator_location: OperatorLocation | None = None + auth_data: RIDAuthData | None = None + operator_id: str | None = "" + operation_description: str | None = "" diff --git a/src/openutm_verification/scenarios/common.py b/src/openutm_verification/scenarios/common.py index 5b69c859..6b45a217 100644 --- a/src/openutm_verification/scenarios/common.py +++ b/src/openutm_verification/scenarios/common.py @@ -1,6 +1,5 @@ import json from pathlib import Path -from typing import Any, List from loguru import logger from uas_standards.astm.f3411.v22a.api import RIDAircraftState @@ -25,7 +24,7 @@ def generate_flight_declaration(config_path: str) -> FlightDeclaration: raise -def generate_telemetry(config_path: str, duration: int = DEFAULT_TELEMETRY_DURATION) -> List[RIDAircraftState]: +def generate_telemetry(config_path: str, duration: int = DEFAULT_TELEMETRY_DURATION) -> list[RIDAircraftState]: """Generate telemetry states from the GeoJSON config file at the given path.""" try: logger.debug(f"Generating telemetry states from {config_path} for duration {duration} seconds") diff --git a/src/openutm_verification/simulator/geo_json_telemetry.py b/src/openutm_verification/simulator/geo_json_telemetry.py index f78f1131..23ec36e8 100644 --- a/src/openutm_verification/simulator/geo_json_telemetry.py +++ b/src/openutm_verification/simulator/geo_json_telemetry.py @@ -2,7 +2,6 @@ import random import sys from pathlib import Path -from typing import List, Optional from uuid import UUID import arrow @@ -155,7 +154,7 @@ def __init__(self, config: GeoJSONFlightsSimulatorConfiguration) -> None: self.utm_zone: int = config.utm_zone self.altitude_agl: float = 50.0 - self.flight: Optional[FullFlightRecord] = None + self.flight: FullFlightRecord | None = None self.geod: Geod = Geod(ellps="WGS84") @@ -299,12 +298,12 @@ def generate_flight_details(self, flight_id: str) -> RIDFlightDetails: registration_number=my_flight_details_generator.generate_registration_number(), ) - def generate_states(self, duration: int, loop_path: bool = False) -> List[RIDAircraftState]: + def generate_states(self, duration: int, loop_path: bool = False) -> list[RIDAircraftState]: """ Generate rid_state objects that can be submitted as flight telemetry. """ logger.info(f"Generating flight states for {duration} seconds") - all_flight_telemetry: List[List[RIDAircraftState]] = [] + all_flight_telemetry: list[list[RIDAircraftState]] = [] flight_track_details: dict[int, dict[str, int]] = {} flight_current_index: dict[int, int] = {} num_flights = len(self.grid_cells_flight_tracks) @@ -359,7 +358,7 @@ def generate_states(self, duration: int, loop_path: bool = False) -> List[RIDAir all_flight_telemetry[k].append(rid_aircraft_state) flight_current_index[k] = flight_current_index[k] + 1 - flights: List[FullFlightRecord] = [] + flights: list[FullFlightRecord] = [] for m in range(num_flights): record = FullFlightRecord( reference_time=now_isoformat, @@ -378,7 +377,7 @@ def to_jsonable_state(self, state: RIDAircraftState) -> dict: """Convert a RIDAircraftState to a JSON-serializable dict.""" return json.loads(json.dumps(state)) - def to_jsonable_states(self, states: List[RIDAircraftState]) -> List[dict]: + def to_jsonable_states(self, states: list[RIDAircraftState]) -> list[dict]: """Convert a list of RIDAircraftState to a list of JSON-serializable dicts.""" return [self.to_jsonable_state(state) for state in states] diff --git a/src/openutm_verification/simulator/models/flight_data_types.py b/src/openutm_verification/simulator/models/flight_data_types.py index c76d2621..f4f9b721 100644 --- a/src/openutm_verification/simulator/models/flight_data_types.py +++ b/src/openutm_verification/simulator/models/flight_data_types.py @@ -1,5 +1,3 @@ -from typing import List, Optional - from implicitdict import ImplicitDict, StringBasedDateTime from pydantic import BaseModel, Field from uas_standards.astm.f3411.v22a.api import RIDAircraftState, RIDFlightDetails @@ -22,7 +20,7 @@ class FullFlightRecord(ImplicitDict): reference_time: StringBasedDateTime """The reference time of this flight (usually the time of first telemetry)""" - states: List[RIDAircraftState] + states: list[RIDAircraftState] """All telemetry that will be/was received for this flight""" flight_details: RIDFlightDetails @@ -33,7 +31,7 @@ class FullFlightRecord(ImplicitDict): class FlightRecordCollection(ImplicitDict): - flights: List[FullFlightRecord] + flights: list[FullFlightRecord] class AdjacentCircularFlightsSimulatorConfiguration(ImplicitDict): @@ -44,7 +42,7 @@ class AdjacentCircularFlightsSimulatorConfiguration(ImplicitDict): relative to a time close to the time of test. """ - random_seed: Optional[int] = 12345 + random_seed: int | None = 12345 """Pseudorandom seed that should be used, or specify None to use default Random.""" minx: float = 7.4735784530639648 @@ -78,7 +76,7 @@ class GeoJSONFlightsSimulatorConfiguration(ImplicitDict): relative to a time close to the time of test. """ - random_seed: Optional[int] = 12345 + random_seed: int | None = 12345 """Pseudorandom seed that should be used, or specify None to use default Random.""" geojson: dict utm_zone: int = 32 @@ -100,7 +98,7 @@ class AirTrafficGeneratorConfiguration(ImplicitDict): relative to a time close to the time of test. """ - random_seed: Optional[int] = 12345 + random_seed: int | None = 12345 """Pseudorandom seed that should be used, or specify None to use default Random.""" geojson: dict utm_zone: int = 32 diff --git a/src/openutm_verification/simulator/models/geo_json_models.py b/src/openutm_verification/simulator/models/geo_json_models.py index 4e5c3af3..30491769 100644 --- a/src/openutm_verification/simulator/models/geo_json_models.py +++ b/src/openutm_verification/simulator/models/geo_json_models.py @@ -9,24 +9,24 @@ from __future__ import annotations -from typing import Any, Dict, List, Literal, Tuple +from typing import Any, Literal import shapely.geometry from pydantic import BaseModel, Field, ValidationError, field_validator from pyproj import Geod -LngLat = Tuple[float, float] +LngLat = tuple[float, float] MINIMUM_LINESTRING_LENGTH_M = 300.0 # meters class GeoJSONLineString(BaseModel): type: Literal["LineString"] - coordinates: List[LngLat] + coordinates: list[LngLat] @field_validator("coordinates") @classmethod - def _validate_coords(cls, v: List[LngLat]) -> List[LngLat]: + def _validate_coords(cls, v: list[LngLat]) -> list[LngLat]: if len(v) < 2: raise ValueError("LineString must have at least two coordinates") for lon, lat in v: @@ -37,17 +37,17 @@ def _validate_coords(cls, v: List[LngLat]) -> List[LngLat]: class GeoJSONFeature(BaseModel): type: Literal["Feature"] = "Feature" - properties: Dict[str, Any] = Field(default_factory=dict) + properties: dict[str, Any] = Field(default_factory=dict) geometry: GeoJSONLineString class GeoJSONFeatureCollection(BaseModel): type: Literal["FeatureCollection"] = "FeatureCollection" - features: List[GeoJSONFeature] + features: list[GeoJSONFeature] @field_validator("features") @classmethod - def _validate_features(cls, v: List[GeoJSONFeature]) -> List[GeoJSONFeature]: + def _validate_features(cls, v: list[GeoJSONFeature]) -> list[GeoJSONFeature]: if not v: raise ValueError("FeatureCollection must contain at least one feature") return v @@ -69,15 +69,15 @@ class ValidatedFlightPath(BaseModel): start: LngLat end: LngLat - bounds: Tuple[float, float, float, float] - box_bounds: Tuple[float, float, float, float] - half_box_bounds: Tuple[float, float, float, float] + bounds: tuple[float, float, float, float] + box_bounds: tuple[float, float, float, float] + half_box_bounds: tuple[float, float, float, float] center: LngLat line_length_m: float - path_points: List[LngLat] + path_points: list[LngLat] @classmethod - def from_feature_collection(cls, fc: Dict[str, Any] | GeoJSONFeatureCollection) -> "ValidatedFlightPath": + def from_feature_collection(cls, fc: dict[str, Any] | GeoJSONFeatureCollection) -> "ValidatedFlightPath": """Validate the provided FeatureCollection and compute derived fields. Accepts either a raw dict (GeoJSON) or a typed GeoJSONFeatureCollection. @@ -118,7 +118,7 @@ def from_feature_collection(cls, fc: Dict[str, Any] | GeoJSONFeatureCollection) num_points = max(int(length_m / 2), 2) # Legacy used normalized steps of 0.004; keep same cadence and clamp to [0,1] fractions = (min(0.004 * i, 1.0) for i in range(num_points)) - path_points: List[LngLat] = [] + path_points: list[LngLat] = [] for f in fractions: p = shapely_line.interpolate(f, normalized=True) path_points.append((float(p.x), float(p.y))) diff --git a/src/openutm_verification/simulator/models/utils.py b/src/openutm_verification/simulator/models/utils.py index ef4899d3..0c1db3e6 100644 --- a/src/openutm_verification/simulator/models/utils.py +++ b/src/openutm_verification/simulator/models/utils.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, NamedTuple +from typing import NamedTuple import shapely.geometry from shapely.geometry import Polygon @@ -28,4 +28,4 @@ class GridCellFlight(NamedTuple): """A object to hold details of a grid location and the track within it""" bounds: shapely.geometry.polygon.Polygon - track: List[FlightPoint] + track: list[FlightPoint] diff --git a/tests/test_client_steps.py b/tests/test_client_steps.py index a9aba8f3..c9a34d2a 100644 --- a/tests/test_client_steps.py +++ b/tests/test_client_steps.py @@ -118,7 +118,29 @@ async def test_submit_telemetry_from_file(fb_client): mock_response.json.return_value = {"status": "ok"} fb_client.put.return_value = mock_response - with patch("builtins.open", mock_open(read_data='{"current_states": [{"position": "data"}]}')), patch("asyncio.sleep", AsyncMock()): + telemetry_data = { + "current_states": [ + { + "timestamp": {"value": "2023-01-01T00:00:00Z", "format": "RFC3339"}, + "operational_status": "Airborne", + "position": { + "lat": 46.9, + "lng": 7.4, + "alt": 500.0, + "accuracy_h": "HAUnknown", + "accuracy_v": "VAUnknown", + "extrapolated": False, + }, + "height": {"distance": 50.0, "reference": "TakeoffLocation"}, + "track": 90.0, + "speed": 10.0, + "timestamp_accuracy": 0.0, + "speed_accuracy": "SA3mps", + "vertical_speed": 0.0, + } + ] + } + with patch("builtins.open", mock_open(read_data=json.dumps(telemetry_data))), patch("asyncio.sleep", AsyncMock()): result = await fb_client.submit_telemetry_from_file(filename="telemetry.json") assert result.status == Status.PASS @@ -140,7 +162,36 @@ async def test_submit_telemetry(fb_client): mock_response.json.return_value = {"status": "ok"} fb_client.put.return_value = mock_response - states = [{"position": "data"}] + states = [ + { + "timestamp": "2023-10-26T12:00:00Z", + "timestamp_accuracy": 0.0, + "operational_status": "Undeclared", + "position": { + "lat": 37.7749, + "lng": -122.4194, + "alt": 100.0, + "accuracy_h": "HAHa", + "accuracy_v": "VAVa", + "extrapolated": False, + "pressure_altitude": 100.0, + }, + "speed": 10.0, + "track": 90.0, + "speed_accuracy": "SA3mps", + "vertical_speed": 0.0, + "height": { + "distance": 50.0, + "reference": "TakeoffLocation", + }, + "group_radius": 0, + "group_ceiling": 0, + "group_floor": 0, + "group_count": 1, + "group_time_start": "2023-10-26T12:00:00Z", + "group_time_end": "2023-10-26T12:00:00Z", + } + ] with patch("asyncio.sleep", AsyncMock()): result = await fb_client.submit_telemetry(states=states) From 0cf9f405aa5ce688cfab15235196e409425ece9c Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sun, 14 Dec 2025 14:00:39 +0100 Subject: [PATCH 9/9] typing --- .../core/reporting/reporting.py | 28 ++++++++----------- .../core/reporting/visualize_flight.py | 7 +++-- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index bc198f65..51243b9b 100644 --- a/src/openutm_verification/core/reporting/reporting.py +++ b/src/openutm_verification/core/reporting/reporting.py @@ -7,7 +7,7 @@ from loguru import logger from openutm_verification.core.execution.config_models import ReportingConfig -from openutm_verification.core.reporting.reporting_models import ReportData +from openutm_verification.core.reporting.reporting_models import ReportData, ScenarioResult from openutm_verification.core.reporting.visualize_flight import visualize_flight_path_2d, visualize_flight_path_3d @@ -82,7 +82,7 @@ def _generate_html_report(report_data: ReportData, output_dir: Path, base_filena base_filename: The base name for the report file (without extension). """ # Generate visualizations for scenarios with flight data - _generate_visualizations(report_data, output_dir, base_filename) + _generate_visualizations(report_data, output_dir) # Copy images referenced in docs _copy_docs_images(report_data, output_dir) @@ -102,7 +102,7 @@ def _generate_html_report(report_data: ReportData, output_dir: Path, base_filena return report_path -def _generate_scenario_visualizations(result, telemetry_data, declaration_data, output_dir: Path, base_filename: str): +def _generate_scenario_visualizations(result: ScenarioResult, output_dir: Path): """ Generates 2D and 3D visualizations for a single scenario. @@ -111,40 +111,36 @@ def _generate_scenario_visualizations(result, telemetry_data, declaration_data, telemetry_data: Loaded telemetry data. declaration_data: Loaded declaration data. output_dir: Directory to save visualizations. - base_filename: Base filename for consistent naming. """ - # Convert models to dicts if necessary - telemetry_data = telemetry_data.model_dump() if hasattr(telemetry_data, "model_dump") else telemetry_data - declaration_data = declaration_data.model_dump() if hasattr(declaration_data, "model_dump") else declaration_data + if result.flight_declaration_data is None or result.telemetry_data is None: + return - # Sanitize scenario name for filename - sanitized_name = result.name.replace(" ", "_").replace("-", "_") + flight_declaration_dict = result.flight_declaration_data.model_dump() # Create scenario directory scenario_dir = output_dir / result.name scenario_dir.mkdir(parents=True, exist_ok=True) # Generate 2D visualization - vis_2d_filename = f"{base_filename}_{sanitized_name}_2d.html" + vis_2d_filename = "visualization_2d.html" vis_2d_path = scenario_dir / vis_2d_filename - visualize_flight_path_2d(telemetry_data, declaration_data, str(vis_2d_path)) + visualize_flight_path_2d(result.telemetry_data, flight_declaration_dict, vis_2d_path) result.visualization_2d_path = str(vis_2d_path.relative_to(output_dir)) # Generate 3D visualization - vis_3d_filename = f"{base_filename}_{sanitized_name}_3d.html" + vis_3d_filename = "visualization_3d.html" vis_3d_path = scenario_dir / vis_3d_filename - visualize_flight_path_3d(telemetry_data, declaration_data, str(vis_3d_path)) + visualize_flight_path_3d(result.telemetry_data, flight_declaration_dict, vis_3d_path) result.visualization_3d_path = str(vis_3d_path.relative_to(output_dir)) -def _generate_visualizations(report_data: ReportData, output_dir: Path, base_filename: str): +def _generate_visualizations(report_data: ReportData, output_dir: Path): """ Generates flight visualizations for scenarios that have flight data. Args: report_data: The report data containing scenario results. output_dir: The directory where visualizations will be saved. - base_filename: The base filename used for consistent naming. """ for result in report_data.results: # Try to get data from in-memory fields first, fall back to loading from files @@ -156,7 +152,7 @@ def _generate_visualizations(report_data: ReportData, output_dir: Path, base_fil if telemetry_data and declaration_data: try: - _generate_scenario_visualizations(result, telemetry_data, declaration_data, output_dir, base_filename) + _generate_scenario_visualizations(result, output_dir) except Exception as e: logger.warning(f"Failed to generate visualizations for scenario '{result.name}': {e}") diff --git a/src/openutm_verification/core/reporting/visualize_flight.py b/src/openutm_verification/core/reporting/visualize_flight.py index c05842be..2e6d1718 100644 --- a/src/openutm_verification/core/reporting/visualize_flight.py +++ b/src/openutm_verification/core/reporting/visualize_flight.py @@ -20,16 +20,17 @@ import pythreejs as three from ipywidgets import embed from loguru import logger +from uas_standards.astm.f3411.v22a.api import RIDAircraftState -def visualize_flight_path_2d(telemetry_data, declaration_data, output_html_path): +def visualize_flight_path_2d(telemetry_data: list[RIDAircraftState], declaration_data: dict, output_html_path: Path): """ Creates an interactive 2D map from flight telemetry and declaration data. Args: telemetry_data (dict): The flight telemetry data as a dictionary. declaration_data (dict): The flight declaration data as a dictionary. - output_html_path (str): The full path where the output HTML map will be saved. + output_html_path (Path): The full path where the output HTML map will be saved. """ logger.info("Starting 2D flight path visualization") @@ -200,7 +201,7 @@ def create_cap_mesh(vertices_2d_xz, altitude, material): return geofence_group -def visualize_flight_path_3d(telemetry_data, declaration_data, output_html_path): +def visualize_flight_path_3d(telemetry_data: list[RIDAircraftState], declaration_data: dict, output_html_path: Path): """Creates an interactive 3D visualization of the flight path and geofence.""" logger.info("Starting 3D flight path visualization")