From 3cd6d40e61690c0c13de00e93ff1a275bd8577bd Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Thu, 12 Mar 2026 22:13:59 +0100 Subject: [PATCH 1/8] Implement tagging steps --- .../clients/air_traffic/air_traffic_client.py | 7 +- .../bayesian_air_traffic_client.py | 7 +- .../clients/air_traffic/blue_sky_client.py | 5 +- .../core/clients/amqp/amqp_client.py | 13 ++-- .../flight_blender/flight_blender_client.py | 61 +++++++++--------- .../core/clients/opensky/opensky_client.py | 3 +- .../core/execution/scenario_runner.py | 18 ++++-- src/openutm_verification/core/flight_phase.py | 64 +++++++++++++++++++ .../core/reporting/reporting.py | 6 +- .../core/reporting/reporting_models.py | 2 + .../core/steps/air_traffic_step.py | 3 +- .../core/templates/report_template.html | 49 ++++++++++++++ .../server/introspection.py | 1 + .../components/ScenarioEditor/CustomNode.tsx | 8 ++- .../ScenarioEditor/PropertiesPanel.tsx | 17 +++++ .../src/components/ScenarioEditor/Toolbox.tsx | 9 ++- web-editor/src/hooks/useScenarioGraph.ts | 1 + web-editor/src/styles/Node.module.css | 15 +++++ web-editor/src/styles/Toolbox.module.css | 22 +++++++ web-editor/src/types/scenario.ts | 3 + web-editor/src/utils/scenarioConversion.ts | 2 + 21 files changed, 261 insertions(+), 55 deletions(-) create mode 100644 src/openutm_verification/core/flight_phase.py 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 73a68b54..0bedaa64 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 @@ -14,6 +14,7 @@ BaseBlenderAPIClient, ) from openutm_verification.core.execution.scenario_runner import scenario_step +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.simulator.geo_json_telemetry import ( GeoJSONAirtrafficSimulator, ) @@ -33,7 +34,7 @@ def __init__(self, settings: AirTrafficSettings): # but we inherit from it. Ideally, we should refactor to composition over inheritance. BaseBlenderAPIClient.__init__(self, base_url="", credentials={}) - @scenario_step("Fetch Session IDs") + @scenario_step("Fetch Session IDs", phase=FlightPhase.STANDING) async def get_configured_session_ids( self, ) -> list[UUID]: @@ -54,7 +55,7 @@ async def get_configured_session_ids( raise return session_ids - @scenario_step("Generate Simulated Air Traffic Data") + @scenario_step("Generate Simulated Air Traffic Data", phase=FlightPhase.EN_ROUTE) async def generate_simulated_air_traffic_data( self, config_path: str | None = None, @@ -105,7 +106,7 @@ async def generate_simulated_air_traffic_data( logger.error(f"Failed to generate telemetry states from {config_path}: {exc}") raise - @scenario_step("Generate Simulated Air Traffic Data with Latency") + @scenario_step("Generate Simulated Air Traffic Data with Latency", phase=FlightPhase.EN_ROUTE) async def generate_simulated_air_traffic_data_with_latency( self, config_path: str | None = None, diff --git a/src/openutm_verification/core/clients/air_traffic/bayesian_air_traffic_client.py b/src/openutm_verification/core/clients/air_traffic/bayesian_air_traffic_client.py index a9af8ff9..96330070 100644 --- a/src/openutm_verification/core/clients/air_traffic/bayesian_air_traffic_client.py +++ b/src/openutm_verification/core/clients/air_traffic/bayesian_air_traffic_client.py @@ -18,6 +18,7 @@ BaseBlenderAPIClient, ) from openutm_verification.core.execution.scenario_runner import scenario_step +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.simulator.models.flight_data_types import FlightObservationSchema @@ -42,7 +43,7 @@ def __init__(self, settings: BayesianAirTrafficSettings): # but we inherit from it. Ideally, we should refactor to composition over inheritance. BaseBlenderAPIClient.__init__(self, base_url="", credentials={}) - @scenario_step("Fetch Session IDs for Bayesian Simulation") + @scenario_step("Fetch Session IDs for Bayesian Simulation", phase=FlightPhase.STANDING) async def get_configured_bayesian_session_ids( self, ) -> list[UUID]: @@ -65,7 +66,7 @@ async def get_configured_bayesian_session_ids( raise return session_ids - @scenario_step("Generate Bayesian Simulation Air Traffic Data") + @scenario_step("Generate Bayesian Simulation Air Traffic Data", phase=FlightPhase.EN_ROUTE) async def generate_bayesian_sim_air_traffic_data( self, config_path: str | None = None, @@ -208,7 +209,7 @@ def _convert_track_to_observations( return observations - @scenario_step("Generate Bayesian Simulation Air Traffic Data with latency issues") + @scenario_step("Generate Bayesian Simulation Air Traffic Data with latency issues", phase=FlightPhase.EN_ROUTE) async def generate_bayesian_sim_air_traffic_data_with_sensor_latency_issues( self, config_path: str | None = None, diff --git a/src/openutm_verification/core/clients/air_traffic/blue_sky_client.py b/src/openutm_verification/core/clients/air_traffic/blue_sky_client.py index 34eb47aa..c0027bf1 100644 --- a/src/openutm_verification/core/clients/air_traffic/blue_sky_client.py +++ b/src/openutm_verification/core/clients/air_traffic/blue_sky_client.py @@ -21,6 +21,7 @@ BaseBlenderAPIClient, ) from openutm_verification.core.execution.scenario_runner import scenario_step +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.simulator.models.flight_data_types import ( FlightObservationSchema, ) @@ -36,7 +37,7 @@ def __init__(self, settings: BlueSkyAirTrafficSettings): # 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") + @scenario_step("Generate BlueSky Simulation Air Traffic Data", phase=FlightPhase.EN_ROUTE) async def generate_bluesky_sim_air_traffic_data( self, config_path: str | None = None, @@ -138,7 +139,7 @@ async def generate_bluesky_sim_air_traffic_data( # Convert dict -> list[list[FlightObservationSchema]] with stable ordering return [results_by_acid[acid] for acid in sorted(results_by_acid.keys())] - @scenario_step("Generate BlueSky Simulation Air Traffic Data with latency issues") + @scenario_step("Generate BlueSky Simulation Air Traffic Data with latency issues", phase=FlightPhase.EN_ROUTE) async def generate_bluesky_sim_air_traffic_data_with_sensor_latency_issues( self, config_path: str | None = None, diff --git a/src/openutm_verification/core/clients/amqp/amqp_client.py b/src/openutm_verification/core/clients/amqp/amqp_client.py index 8aa12e4d..f24bcea7 100644 --- a/src/openutm_verification/core/clients/amqp/amqp_client.py +++ b/src/openutm_verification/core/clients/amqp/amqp_client.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from openutm_verification.core.execution.scenario_runner import scenario_step +from openutm_verification.core.flight_phase import FlightPhase if TYPE_CHECKING: from openutm_verification.core.execution.config_models import AMQPConfig @@ -246,7 +247,7 @@ def _consumer_loop( pass logger.info("AMQP consumer stopped") - @scenario_step("Start AMQP Queue Monitor") + @scenario_step("Start AMQP Queue Monitor", phase=FlightPhase.STANDING) async def start_queue_monitor( self, queue_name: str | None = None, @@ -295,7 +296,7 @@ async def start_queue_monitor( "duration": duration, } - @scenario_step("Stop AMQP Queue Monitor") + @scenario_step("Stop AMQP Queue Monitor", phase=FlightPhase.POST_FLIGHT) async def stop_queue_monitor(self) -> dict[str, Any]: """Stop the AMQP queue monitor. @@ -321,7 +322,7 @@ async def stop_queue_monitor(self) -> dict[str, Any]: "error": self._state.error, } - @scenario_step("Get AMQP Messages") + @scenario_step("Get AMQP Messages", phase=FlightPhase.EN_ROUTE) async def get_received_messages( self, routing_key_filter: str | None = None, @@ -353,7 +354,7 @@ async def get_received_messages( return [m.to_dict() for m in messages] - @scenario_step("Wait for AMQP Messages") + @scenario_step("Wait for AMQP Messages", phase=FlightPhase.EN_ROUTE) async def wait_for_messages( self, count: int = 1, @@ -393,7 +394,7 @@ async def wait_for_messages( "error": f"Timed out waiting for {count} messages, got {len(messages)}", } - @scenario_step("Clear AMQP Messages") + @scenario_step("Clear AMQP Messages", phase=FlightPhase.POST_FLIGHT) async def clear_messages(self) -> dict[str, Any]: """Clear the collected messages buffer. @@ -407,7 +408,7 @@ async def clear_messages(self) -> dict[str, Any]: logger.info(f"Cleared {count} AMQP messages") return {"cleared_count": count} - @scenario_step("Check AMQP Connection") + @scenario_step("Check AMQP Connection", phase=FlightPhase.STANDING) async def check_connection(self) -> dict[str, Any]: """Check if AMQP connection can be established. 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 775cedfd..07fcdd42 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 @@ -21,6 +21,7 @@ ScenarioContext, scenario_step, ) +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.core.reporting.reporting_models import ( Status, StepResult, @@ -132,7 +133,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") + @scenario_step("Upload Geo Fence", phase=FlightPhase.STANDING) async def upload_geo_fence(self, filename: str | None = None) -> dict[str, Any]: """Upload an Area-of-Interest (Geo Fence) to Flight Blender. @@ -164,7 +165,7 @@ async def upload_geo_fence(self, filename: str | None = None) -> dict[str, Any]: logger.warning("Failed to extract geo-fence ID from response") return body - @scenario_step("Get Geo Fence") + @scenario_step("Get Geo Fence", phase=FlightPhase.STANDING) async def get_geo_fence(self) -> dict[str, Any]: """Retrieve the details of the most recently uploaded geo-fence. @@ -182,7 +183,7 @@ async def get_geo_fence(self) -> dict[str, Any]: logger.info(f"Retrieved geo-fence details for ID: {geo_fence_id}") return response.json() - @scenario_step("Delete Geo Fence") + @scenario_step("Delete Geo Fence", phase=FlightPhase.POST_FLIGHT) async def delete_geo_fence(self, geo_fence_id: str | None = None) -> dict[str, Any]: """Delete a geo-fence by ID. @@ -220,7 +221,7 @@ async def delete_geo_fence(self, geo_fence_id: str | None = None) -> dict[str, A logger.warning(f"Non-JSON response on geo-fence deletion, status: {response.status_code}") return {"deleted": response.status_code in (200, 204), "id": op_id} - @scenario_step("Upload Flight Declaration") + @scenario_step("Upload Flight Declaration", phase=FlightPhase.STANDING) async def upload_flight_declaration(self, declaration: str | BaseModel) -> dict[str, Any]: """Upload a flight declaration to the Flight Blender API. @@ -272,7 +273,7 @@ async def upload_flight_declaration(self, declaration: str | BaseModel) -> dict[ return response_json - @scenario_step("Bulk Upload Flight Declarations") + @scenario_step("Bulk Upload Flight Declarations", phase=FlightPhase.STANDING) async def upload_multiple_flight_declarations(self, declarations: list[BaseModel]) -> dict[str, Any]: """ Upload multiple flight declarations to Flight Blender. @@ -317,7 +318,7 @@ async def upload_multiple_flight_declarations(self, declarations: list[BaseModel logger.warning("Failed to extract flight declaration IDs from response") return response_json - @scenario_step("Upload Flight Declaration Via Operational Intent") + @scenario_step("Upload Flight Declaration Via Operational Intent", phase=FlightPhase.STANDING) async def upload_flight_declaration_via_operational_intent(self, declaration: str | BaseModel) -> dict[str, Any]: """Upload a flight declaration to the Flight Blender API. @@ -370,7 +371,7 @@ async def upload_flight_declaration_via_operational_intent(self, declaration: st return response_json - @scenario_step("Upload two Flight Declarations Via Operational Intent") + @scenario_step("Upload two Flight Declarations Via Operational Intent", phase=FlightPhase.STANDING) async def upload_multiple_flight_declarations_via_operational_intents(self, declarations: list[BaseModel]) -> dict[str, Any]: endpoint = "/flight_declaration_ops/set_operational_intents_bulk" @@ -405,7 +406,7 @@ async def wait_for_user_input(self, prompt: str = "Press Enter to continue...") """ return input(prompt) - @scenario_step("Update Operation State") + @scenario_step("Update Operation State", phase=FlightPhase.EN_ROUTE) async def update_operation_state(self, state: OperationState, duration: str | int | float = 0) -> dict[str, Any]: """Update the state of a flight operation. @@ -433,7 +434,7 @@ async def update_operation_state(self, state: OperationState, duration: str | in await asyncio.sleep(duration_seconds) return response.json() - @scenario_step("Update Operation State of declaration") + @scenario_step("Update Operation State of declaration", phase=FlightPhase.EN_ROUTE) async def update_operation_state_of_declaration( self, state: OperationState, declaration_id: str, duration: str | int | float = 0 ) -> dict[str, Any]: @@ -541,7 +542,7 @@ async def _submit_telemetry_states_impl(self, states: list[RIDAircraftState], du logger.info("Telemetry submission completed") return last_response - @scenario_step("Submit Telemetry (from file)") + @scenario_step("Submit Telemetry (from file)", phase=FlightPhase.EN_ROUTE) async def submit_telemetry_from_file(self, filename: str, duration: str | int | float = 0) -> dict[str, Any] | None: """Submit telemetry data for a flight operation. @@ -562,7 +563,7 @@ async def submit_telemetry_from_file(self, filename: str, duration: str | int | states = self._load_telemetry_file(filename) return await self._submit_telemetry_states_impl(states, duration) - @scenario_step("Submit Telemetry") + @scenario_step("Submit Telemetry", phase=FlightPhase.EN_ROUTE) async def submit_telemetry(self, states: list[RIDAircraftState] | None = None, duration: str | int | float = 0) -> dict[str, Any] | None: """Submit telemetry data for a flight operation from in-memory states. @@ -586,7 +587,7 @@ async def submit_telemetry(self, states: list[RIDAircraftState] | None = None, d return await self._submit_telemetry_states_impl(telemetry_states, duration) - @scenario_step("Check Operation State") + @scenario_step("Check Operation State", phase=FlightPhase.EN_ROUTE) async def check_operation_state( self, expected_state: OperationState, @@ -611,7 +612,7 @@ async def check_operation_state( logger.info(f"Flight state check for {self.latest_flight_declaration_id} completed (simulated).") return f"Waited for Flight Blender to process {expected_state} state." - @scenario_step("Check Operation State Connected") + @scenario_step("Check Operation State Connected", phase=FlightPhase.EN_ROUTE) async def check_operation_state_connected( self, expected_state: OperationState, @@ -652,7 +653,7 @@ async def check_operation_state_connected( f"Operation {self.latest_flight_declaration_id} did not reach expected state {expected_state.name} within {duration_seconds} seconds" ) - @scenario_step("Cleanup Flight Declarations") + @scenario_step("Cleanup Flight Declarations", phase=FlightPhase.POST_FLIGHT) async def cleanup_flight_declarations(self) -> dict[str, Any]: """Specific cleanup for flight declarations in the active volume. @@ -703,7 +704,7 @@ async def cleanup_flight_declarations(self) -> dict[str, Any]: logger.error(f"Error during flight declaration cleanup: {e}") return {"cleaned": False, "error": str(e)} - @scenario_step("Delete Flight Declaration") + @scenario_step("Delete Flight Declaration", phase=FlightPhase.POST_FLIGHT) async def delete_flight_declaration(self, flight_declaration_id: str | None = None) -> dict[str, Any]: """Delete a flight declaration by ID. @@ -737,7 +738,7 @@ async def delete_flight_declaration(self, flight_declaration_id: str | None = No logger.warning(f"Non-JSON response on flight declaration deletion, status: {response.status_code}") return {"deleted": response.status_code in (200, 204), "id": op_id} - @scenario_step("Submit Simulated Air Traffic") + @scenario_step("Submit Simulated Air Traffic", phase=FlightPhase.EN_ROUTE) async def submit_simulated_air_traffic( self, observations: list[list[FlightObservationSchema]], @@ -846,7 +847,7 @@ async def submit_simulated_air_traffic( "simulation_duration_seconds": (simulation_end - simulation_start).total_seconds(), } - @scenario_step("Submit Simulated Air Traffic at varying refresh rates") + @scenario_step("Submit Simulated Air Traffic at varying refresh rates", phase=FlightPhase.EN_ROUTE) async def submit_simulated_air_traffic_at_random_refresh_rates( self, observations: list[list[FlightObservationSchema]], @@ -1022,7 +1023,7 @@ def _validate_reported_metrics( errors.append("aggregate_health is empty") return errors - @scenario_step("Verify Reported Metrics in Flight Blender") + @scenario_step("Verify Reported Metrics in Flight Blender", phase=FlightPhase.POST_FLIGHT) async def verify_reported_metrics_in_flight_blender(self, observations: list[list[FlightObservationSchema]], session_id: uuid.UUID | None = None): """ Queries the SDSP metrics endpoint and verifies reported values against expected values @@ -1079,7 +1080,7 @@ async def verify_reported_metrics_in_flight_blender(self, observations: list[lis error_message=None if not errors else "; ".join(errors), ) - @scenario_step("Submit Air Traffic") + @scenario_step("Submit Air Traffic", phase=FlightPhase.EN_ROUTE) async def submit_air_traffic(self, observations: list[FlightObservationSchema], session_id: uuid.UUID = uuid.uuid4()) -> dict[str, Any]: """Submit air traffic observations to the Flight Blender API. @@ -1103,7 +1104,7 @@ async def submit_air_traffic(self, observations: list[FlightObservationSchema], logger.info(f"Air traffic observations submitted successfully for session {session_id}") return response.json() - @scenario_step("Get Active Sensors from SDSP") + @scenario_step("Get Active Sensors from SDSP", phase=FlightPhase.STANDING) async def get_active_sensors(self): endpoint = "/surveillance_monitoring_ops/list_surveillance_sensors" response = await self.get(endpoint) @@ -1115,7 +1116,7 @@ async def get_active_sensors(self): logger.error(f"Failed to retrieve active sensors. Response: {response.text}") raise FlightBlenderError("Failed to retrieve active sensors from SDSP") - @scenario_step("Set Sensor Failure in SDSP") + @scenario_step("Set Sensor Failure in SDSP", phase=FlightPhase.EN_ROUTE) async def set_sensor_failure(self, sensor_id: str): endpoint = f"/surveillance_monitoring_ops/update_sensor_health/{sensor_id}" new_status_payload = {"status": "outage"} @@ -1127,7 +1128,7 @@ async def set_sensor_failure(self, sensor_id: str): logger.error(f"Failed to update sensor {sensor_id} status. Response: {response.text}") raise FlightBlenderError(f"Failed to update sensor {sensor_id} status to outage") - @scenario_step("List Sensor Failure Notifications from SDSP") + @scenario_step("List Sensor Failure Notifications from SDSP", phase=FlightPhase.EN_ROUTE) async def list_sensor_failure_notifications(self) -> StepResult: endpoint = "/surveillance_monitoring_ops/list_sensor_health_notifications" response = await self.get(endpoint) @@ -1146,7 +1147,7 @@ async def list_sensor_failure_notifications(self) -> StepResult: result=f"Retrieved {len(notifications)} sensor failure notifications", ) - @scenario_step("Start / Stop SDSP Session") + @scenario_step("Start / Stop SDSP Session", phase=FlightPhase.EN_ROUTE) async def start_stop_sdsp_session(self, session_id: str, action: SDSPSessionAction) -> str: """ Starts or stops an SDSP (Strategic Deconfliction Service Provider) session based on the specified action. @@ -1186,7 +1187,7 @@ async def initialize_track_websocket_connection(self, session_id: str) -> Client ws = await self.create_websocket_connection(endpoint=endpoint) return ws - @scenario_step("Verify SDSP Track") + @scenario_step("Verify SDSP Track", phase=FlightPhase.EN_ROUTE) async def initialize_verify_sdsp_track( self, expected_track_interval_seconds: int, @@ -1248,7 +1249,7 @@ async def initialize_verify_sdsp_track( duration=duration, ) - @scenario_step("Verify SDSP Heartbeat") + @scenario_step("Verify SDSP Heartbeat", phase=FlightPhase.EN_ROUTE) async def initialize_verify_sdsp_heartbeat( self, expected_heartbeat_interval_seconds: int, @@ -1320,12 +1321,12 @@ async def initialize_verify_sdsp_heartbeat( async def close_heartbeat_websocket_connection(self, ws_connection: ClientConnection) -> None: await ws_connection.close() - @scenario_step("Teardown Flight Declaration") + @scenario_step("Teardown Flight Declaration", phase=FlightPhase.POST_FLIGHT) async def teardown_flight_declaration(self, flight_declaration_id: str | None = None) -> dict[str, Any]: logger.info("Tearing down flight declaration...") await self.delete_flight_declaration(flight_declaration_id=flight_declaration_id) - @scenario_step("Setup Flight Declaration via Operational Intent") + @scenario_step("Setup Flight Declaration via Operational Intent", phase=FlightPhase.STANDING) async def setup_flight_declaration_via_operational_intent( self, flight_declaration_via_operational_intent_path: str, @@ -1373,7 +1374,7 @@ async def setup_flight_declaration_via_operational_intent( "end_datetime": flight_declaration.end_datetime, } - @scenario_step("Setup Flight Declaration") + @scenario_step("Setup Flight Declaration", phase=FlightPhase.STANDING) async def setup_flight_declaration( self, flight_declaration_path: str | None = None, @@ -1431,7 +1432,7 @@ async def setup_flight_declaration( "end_datetime": flight_declaration.end_datetime, } - @scenario_step("Setup Two Flight Declarations") + @scenario_step("Setup Two Flight Declarations", phase=FlightPhase.STANDING) async def setup_two_flight_declarations( self, flight_declaration_path: str | None = None, @@ -1494,7 +1495,7 @@ async def setup_two_flight_declarations( return asdict(all_declaration_details) - @scenario_step("Setup Two Operational Intents") + @scenario_step("Setup Two Operational Intents", phase=FlightPhase.STANDING) async def setup_two_flight_declarations_via_operational_intents( self, flight_declaration_via_operational_intent_path: str | None = None, diff --git a/src/openutm_verification/core/clients/opensky/opensky_client.py b/src/openutm_verification/core/clients/opensky/opensky_client.py index 0519d116..cbffb4ae 100644 --- a/src/openutm_verification/core/clients/opensky/opensky_client.py +++ b/src/openutm_verification/core/clients/opensky/opensky_client.py @@ -6,6 +6,7 @@ OpenSkySettings, ) from openutm_verification.core.execution.scenario_runner import scenario_step +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.simulator.models.flight_data_types import ( FlightObservationSchema, ) @@ -102,7 +103,7 @@ async def fetch_and_process_data(self) -> list[FlightObservationSchema] | None: return self.process_flight_data(flight_df) - @scenario_step("Fetch OpenSky Data") + @scenario_step("Fetch OpenSky Data", phase=FlightPhase.EN_ROUTE) async def fetch_data(self) -> list[FlightObservationSchema] | None: """Fetch and process live flight data from OpenSky Network. diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 018bb812..585bc3ea 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -22,6 +22,7 @@ from openutm_verification.core.clients.opensky.base_client import OpenSkyError from openutm_verification.core.execution.dependency_resolution import DEPENDENCIES +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.core.reporting.reporting_models import ( ScenarioResult, Status, @@ -191,10 +192,11 @@ def air_traffic_data(self) -> list[list[FlightObservationSchema]]: class ScenarioStepDescriptor: - def __init__(self, func: Callable[..., Awaitable[Any]], step_name: str): + def __init__(self, func: Callable[..., Awaitable[Any]], step_name: str, phase: FlightPhase | None = None): self.func = func self.step_name = step_name - self.wrapper = self._create_wrapper(func, step_name) + self.phase = phase + self.wrapper = self._create_wrapper(func, step_name, phase) self.param_model = self._create_param_model(func, step_name) def _create_param_model(self, func: Callable[..., Any], step_name: str) -> type[BaseModel]: @@ -225,17 +227,20 @@ def _create_param_model(self, func: Callable[..., Any], step_name: str) -> type[ **fields, ) - def _create_wrapper(self, func: Callable[..., Awaitable[Any]], step_name: str) -> Callable[..., Awaitable[Any]]: + def _create_wrapper(self, func: Callable[..., Awaitable[Any]], step_name: str, phase: FlightPhase | None = None) -> Callable[..., Awaitable[Any]]: def handle_result(result: Any, start_time: float) -> StepResult[Any]: duration = time.time() - start_time logger.info(f"Step '{step_name}' successful in {duration:.2f} seconds.") step_result: StepResult[Any] if isinstance(result, StepResult): + if result.phase is None and phase is not None: + result.phase = phase step_result = result else: step_result = StepResult( name=step_name, + phase=phase, status=Status.PASS, duration=duration, result=result, @@ -251,6 +256,7 @@ def handle_exception(e: Exception, start_time: float) -> StepResult[Any]: logger.error(f"Step '{step_name}' failed after {duration:.2f} seconds: {e}") step_result = StepResult( name=step_name, + phase=phase, status=Status.FAIL, duration=duration, error_message=str(e), @@ -259,6 +265,7 @@ def handle_exception(e: Exception, start_time: float) -> StepResult[Any]: logger.error(f"Step '{step_name}' encountered an unexpected error after {duration:.2f} seconds: {e}") step_result = StepResult( name=step_name, + phase=phase, status=Status.FAIL, duration=duration, error_message=f"Unexpected error: {e}", @@ -303,6 +310,7 @@ def log_filter(record): # Attach metadata for introspection setattr(async_wrapper, "_is_scenario_step", True) setattr(async_wrapper, "_step_name", step_name) + setattr(async_wrapper, "_step_phase", phase) return async_wrapper @@ -323,8 +331,8 @@ def __call__(self, *args: Any, **kwargs: Any): return self.wrapper(*args, **kwargs) -def scenario_step(step_name: str) -> Callable[[Callable[..., Awaitable[Any]]], Any]: +def scenario_step(step_name: str, phase: FlightPhase | None = None) -> Callable[[Callable[..., Awaitable[Any]]], Any]: def decorator(func: Callable[..., Awaitable[Any]]) -> Any: - return ScenarioStepDescriptor(func, step_name) + return ScenarioStepDescriptor(func, step_name, phase) return decorator diff --git a/src/openutm_verification/core/flight_phase.py b/src/openutm_verification/core/flight_phase.py new file mode 100644 index 00000000..8a80dc46 --- /dev/null +++ b/src/openutm_verification/core/flight_phase.py @@ -0,0 +1,64 @@ +""" +Flight phase taxonomy for verification step annotations. + +Based on the IATA/CAST Flight Phase Taxonomy. +See: https://skybrary.aero/articles/flight-phase-taxonomy + +Phases are optional annotations on scenario steps that allow grouping +related steps in reports and the UI. +""" + +from enum import StrEnum + + +class FlightPhase(StrEnum): + """IATA flight phase categories for annotating verification steps.""" + + STANDING = "STD" + """Pre-flight standing / setup / configuration.""" + + PUSHBACK = "PBT" + """Pushback or towing phase.""" + + TAXI_OUT = "TXO" + """Taxi-out phase.""" + + TAKEOFF = "TOF" + """Takeoff phase.""" + + INITIAL_CLIMB = "ICL" + """Initial climb phase.""" + + EN_ROUTE = "ENR" + """En route / cruise phase.""" + + MANEUVERING = "MAN" + """Maneuvering / holding / aerial work.""" + + APPROACH = "APR" + """Approach phase.""" + + LANDING = "LDG" + """Landing phase.""" + + TAXI_IN = "TXI" + """Taxi-in phase.""" + + POST_FLIGHT = "PST" + """Post-flight / parking / teardown.""" + + +# Human-readable labels for display +FLIGHT_PHASE_LABELS: dict[FlightPhase, str] = { + FlightPhase.STANDING: "Standing", + FlightPhase.PUSHBACK: "Pushback / Towing", + FlightPhase.TAXI_OUT: "Taxi Out", + FlightPhase.TAKEOFF: "Takeoff", + FlightPhase.INITIAL_CLIMB: "Initial Climb", + FlightPhase.EN_ROUTE: "En Route", + FlightPhase.MANEUVERING: "Maneuvering", + FlightPhase.APPROACH: "Approach", + FlightPhase.LANDING: "Landing", + FlightPhase.TAXI_IN: "Taxi In", + FlightPhase.POST_FLIGHT: "Post-Flight", +} diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index 455567e1..8dce65e4 100644 --- a/src/openutm_verification/core/reporting/reporting.py +++ b/src/openutm_verification/core/reporting/reporting.py @@ -10,6 +10,7 @@ from loguru import logger from openutm_verification.core.execution.config_models import AppConfig, ReportingConfig +from openutm_verification.core.flight_phase import FLIGHT_PHASE_LABELS from openutm_verification.core.reporting.reporting_models import ( ReportData, ReportSummary, @@ -191,7 +192,10 @@ 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.model_dump(mode="json")) + html_content = template.render( + report_data=report_data.model_dump(mode="json"), + phase_labels={k.value: v for k, v in FLIGHT_PHASE_LABELS.items()}, + ) report_path = output_dir / f"{base_filename}.html" with open(report_path, "w", encoding="utf-8") as f: diff --git a/src/openutm_verification/core/reporting/reporting_models.py b/src/openutm_verification/core/reporting/reporting_models.py index bb829b7f..3742168a 100644 --- a/src/openutm_verification/core/reporting/reporting_models.py +++ b/src/openutm_verification/core/reporting/reporting_models.py @@ -10,6 +10,7 @@ from uas_standards.astm.f3411.v22a.api import RIDAircraftState from openutm_verification.core.execution.config_models import DeploymentDetails +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.simulator.models.declaration_models import ( FlightDeclaration, FlightDeclarationViaOperationalIntent, @@ -37,6 +38,7 @@ class StepResult(BaseModel, Generic[T]): id: str | None = None name: str + phase: FlightPhase | None = None status: Status duration: float result: T = None # type: ignore diff --git a/src/openutm_verification/core/steps/air_traffic_step.py b/src/openutm_verification/core/steps/air_traffic_step.py index 5b44882b..dfa423c4 100644 --- a/src/openutm_verification/core/steps/air_traffic_step.py +++ b/src/openutm_verification/core/steps/air_traffic_step.py @@ -5,6 +5,7 @@ """ from openutm_verification.core.execution.scenario_runner import scenario_step +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.core.providers import ProviderType, create_provider from openutm_verification.core.streamers import StreamResult, TargetType, create_streamer @@ -23,7 +24,7 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): pass - @scenario_step("Stream Air Traffic") + @scenario_step("Stream Air Traffic", phase=FlightPhase.EN_ROUTE) async def stream_air_traffic( self, provider: ProviderType, diff --git a/src/openutm_verification/core/templates/report_template.html b/src/openutm_verification/core/templates/report_template.html index bce14b43..125438c5 100644 --- a/src/openutm_verification/core/templates/report_template.html +++ b/src/openutm_verification/core/templates/report_template.html @@ -50,6 +50,9 @@ color: #495057; } .toggle-btn:hover { background-color: rgba(222, 226, 230, 0.95); } + .phase-group { margin-bottom: 15px; } + .phase-header { background-color: #e9ecef; padding: 8px 12px; border-radius: 4px; font-weight: bold; margin-bottom: 5px; display: flex; align-items: center; gap: 8px; } + .phase-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; background-color: #6c757d; color: white; font-weight: normal; } @@ -125,6 +128,51 @@

Documentation:

{% endif %}

Steps:

+ {% set has_phases = result.steps | selectattr('phase') | list | length > 0 %} + {% if has_phases %} + {% set ns = namespace(current_phase='__none__') %} + {% for step in result.steps %} + {% set step_phase = step.phase or '__ungrouped__' %} + {% if step_phase != ns.current_phase %} + {% if ns.current_phase != '__none__' %} + + {% endif %} + {% set ns.current_phase = step_phase %} +
+ {% if step.phase %} +
+ {{ step.phase }} + {{ phase_labels.get(step.phase, step.phase) }} +
+ {% else %} +
Other Steps
+ {% endif %} + + + + {% endif %} + {% set result_json = step.result | tojson(indent=2) %} + {% set line_count = result_json.split('\n') | length %} + + + + + + + {% endfor %} + {% if ns.current_phase != '__none__' %} +
Step NameStatusDuration (s)Result
{{ step.name }}{{ step.status }}{{ "%.2f"|format(step.duration) }} + {% if line_count > 10 %} +
+
{{ result_json }}
+ +
+ {% else %} +
{{ result_json }}
+ {% endif %} +
+ {% endif %} + {% else %} @@ -156,6 +204,7 @@

Steps:

{% endfor %}
+ {% endif %} diff --git a/src/openutm_verification/server/introspection.py b/src/openutm_verification/server/introspection.py index 0f89fe56..73d4c73b 100644 --- a/src/openutm_verification/server/introspection.py +++ b/src/openutm_verification/server/introspection.py @@ -88,4 +88,5 @@ def process_method(client_class: Type, method: Any) -> Dict[str, Any] | None: "category": client_class.__name__, "description": inspect.getdoc(method) or "", "parameters": parameters, + "phase": getattr(method, "_step_phase", None), } diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx index f3e8d3ae..53eb52ea 100644 --- a/web-editor/src/components/ScenarioEditor/CustomNode.tsx +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -1,6 +1,6 @@ import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; -import { Box, CheckCircle, XCircle, AlertTriangle, Loader2, MinusCircle, RotateCw, GitBranch, Timer, Hourglass } from 'lucide-react'; +import { Box, CheckCircle, XCircle, AlertTriangle, Loader2, MinusCircle, RotateCw, GitBranch, Timer, Hourglass, Plane } from 'lucide-react'; import styles from '../../styles/Node.module.css'; import type { NodeData } from '../../types/scenario'; @@ -59,6 +59,12 @@ export const CustomNode = ({ data, selected }: NodeProps>) => {
{data.label} + {data.phase && ( +
+ + {data.phase} +
+ )}
{data.runInBackground && (
diff --git a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx index 01c2ad36..3b91b4d3 100644 --- a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx @@ -163,6 +163,23 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, allNodes, onClos

{selectedNode.data.label}

Node ID: {selectedNode.id}
+ {selectedNode.data.phase && ( +
+ + ✈ {selectedNode.data.phase} + +
+ )}
diff --git a/web-editor/src/components/ScenarioEditor/Toolbox.tsx b/web-editor/src/components/ScenarioEditor/Toolbox.tsx index b9c446f6..4b9609e2 100644 --- a/web-editor/src/components/ScenarioEditor/Toolbox.tsx +++ b/web-editor/src/components/ScenarioEditor/Toolbox.tsx @@ -24,7 +24,7 @@ const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => {
{ event.dataTransfer.setData('application/reactflow', op.name); event.dataTransfer.setData('application/reactflow/id', op.id); @@ -34,7 +34,12 @@ const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => { tabIndex={0} > - {op.name} +
+ {op.name} + {op.phase && ( + {op.phase} + )} +
))}
diff --git a/web-editor/src/hooks/useScenarioGraph.ts b/web-editor/src/hooks/useScenarioGraph.ts index c97a7de2..02353c05 100644 --- a/web-editor/src/hooks/useScenarioGraph.ts +++ b/web-editor/src/hooks/useScenarioGraph.ts @@ -129,6 +129,7 @@ export const useScenarioGraph = (initialNodesParams: Node[] = [], init label: type, operationId: opId, description: operation?.description, + phase: operation?.phase, parameters: operation?.parameters ? JSON.parse(JSON.stringify(operation.parameters)) : [], // Deep copy parameters }, }; diff --git a/web-editor/src/styles/Node.module.css b/web-editor/src/styles/Node.module.css index e1eba623..0f348253 100644 --- a/web-editor/src/styles/Node.module.css +++ b/web-editor/src/styles/Node.module.css @@ -106,6 +106,21 @@ margin-left: auto; } +/* Flight phase badge */ +.phaseBadge { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + background-color: rgba(107, 114, 128, 0.15); + color: var(--text-secondary); + border: 1px solid rgba(107, 114, 128, 0.3); +} + .loopBadge, .conditionBadge, .backgroundBadge { diff --git a/web-editor/src/styles/Toolbox.module.css b/web-editor/src/styles/Toolbox.module.css index 6613a70b..7cfa4a1c 100644 --- a/web-editor/src/styles/Toolbox.module.css +++ b/web-editor/src/styles/Toolbox.module.css @@ -59,6 +59,28 @@ min-width: 0; } +.nodeItemContent { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.nodeItemContent span { + flex: initial; +} + +.phaseBadge { + font-size: 9px; + padding: 1px 5px; + border-radius: 6px; + background-color: var(--bg-tertiary); + color: var(--text-tertiary); + font-weight: 600; + width: fit-content; +} + .nodeItem svg { flex-shrink: 0; } diff --git a/web-editor/src/types/scenario.ts b/web-editor/src/types/scenario.ts index be484eb2..602f4389 100644 --- a/web-editor/src/types/scenario.ts +++ b/web-editor/src/types/scenario.ts @@ -12,6 +12,7 @@ export interface Operation { description: string; parameters: OperationParam[]; category?: string; + phase?: string; } export interface NodeData extends Record { @@ -20,6 +21,7 @@ export interface NodeData extends Record { operationId?: string; description?: string; parameters?: OperationParam[]; + phase?: string; status?: 'success' | 'failure' | 'error' | 'running' | 'waiting' | 'skipped'; result?: unknown; runInBackground?: boolean; @@ -69,6 +71,7 @@ export interface ScenarioDefinition { export interface StepResult { id: string; + phase?: string; status: 'success' | 'failure' | 'error'; result: unknown; error?: string; diff --git a/web-editor/src/utils/scenarioConversion.ts b/web-editor/src/utils/scenarioConversion.ts index 7ac30de5..36118579 100644 --- a/web-editor/src/utils/scenarioConversion.ts +++ b/web-editor/src/utils/scenarioConversion.ts @@ -123,6 +123,7 @@ export const convertYamlToGraph = ( stepId: groupStep.id || groupStep.step, operationId: groupOperation?.id, description: groupStep.description || groupOperation?.description || '', + phase: groupOperation?.phase, parameters: groupParameters } }; @@ -180,6 +181,7 @@ export const convertYamlToGraph = ( stepId: step.id, operationId: operation?.id, description: step.description || operation?.description || '', + phase: operation?.phase, parameters: parameters, runInBackground: step.background, ifCondition: step.if, From 896789e5743cc787c16a31490d8d5db8e85d4340 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Thu, 12 Mar 2026 23:12:08 +0100 Subject: [PATCH 2/8] add some color --- .../components/ScenarioEditor/CustomNode.tsx | 11 ++- .../ScenarioEditor/PropertiesPanel.tsx | 6 +- .../src/components/ScenarioEditor/Toolbox.tsx | 88 ++++++++++++++++--- web-editor/src/styles/Node.module.css | 3 - web-editor/src/styles/Toolbox.module.css | 43 ++++++++- web-editor/src/utils/phaseColors.ts | 38 ++++++++ 6 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 web-editor/src/utils/phaseColors.ts diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx index 53eb52ea..451daf06 100644 --- a/web-editor/src/components/ScenarioEditor/CustomNode.tsx +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -2,6 +2,7 @@ import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; import { Box, CheckCircle, XCircle, AlertTriangle, Loader2, MinusCircle, RotateCw, GitBranch, Timer, Hourglass, Plane } from 'lucide-react'; import styles from '../../styles/Node.module.css'; +import { getPhaseColor } from '../../utils/phaseColors'; import type { NodeData } from '../../types/scenario'; export const CustomNode = ({ data, selected }: NodeProps>) => { @@ -60,7 +61,15 @@ export const CustomNode = ({ data, selected }: NodeProps>) => { {data.label} {data.phase && ( -
+
{data.phase}
diff --git a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx index 3b91b4d3..59a9afe5 100644 --- a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx @@ -5,6 +5,7 @@ import layoutStyles from '../../styles/EditorLayout.module.css'; import styles from '../../styles/SidebarPanel.module.css'; import type { NodeData } from '../../types/scenario'; import { useSidebarResize } from '../../hooks/useSidebarResize'; +import { getPhaseColor } from '../../utils/phaseColors'; const DocstringViewer = ({ text }: { text: string }) => { const [expanded, setExpanded] = useState(false); @@ -171,8 +172,9 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, allNodes, onClos gap: '4px', padding: '2px 8px', borderRadius: '10px', - backgroundColor: 'var(--bg-secondary)', - border: '1px solid var(--border-color)', + backgroundColor: getPhaseColor(selectedNode.data.phase!).bg, + color: getPhaseColor(selectedNode.data.phase!).text, + border: `1px solid ${getPhaseColor(selectedNode.data.phase!).border}`, fontSize: '11px', fontWeight: 600 }}> diff --git a/web-editor/src/components/ScenarioEditor/Toolbox.tsx b/web-editor/src/components/ScenarioEditor/Toolbox.tsx index 4b9609e2..ad2eb807 100644 --- a/web-editor/src/components/ScenarioEditor/Toolbox.tsx +++ b/web-editor/src/components/ScenarioEditor/Toolbox.tsx @@ -1,10 +1,13 @@ import { useState, useMemo } from 'react'; import { ChevronDown, ChevronRight, Box } from 'lucide-react'; import styles from '../../styles/Toolbox.module.css'; +import { getPhaseColor, PHASE_LABELS, PHASE_ORDER } from '../../utils/phaseColors'; import layoutStyles from '../../styles/EditorLayout.module.css'; import type { Operation } from '../../types/scenario'; -const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => { +type GroupBy = 'client' | 'phase'; + +const ToolboxGroup = ({ title, ops, badge }: { title: string, ops: Operation[], badge?: { code: string } }) => { const [isExpanded, setIsExpanded] = useState(true); return ( @@ -16,6 +19,16 @@ const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => { type="button" > {isExpanded ? : } + {badge && ( + {badge.code} + )} {title} {isExpanded && ( @@ -37,7 +50,14 @@ const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => {
{op.name} {op.phase && ( - {op.phase} + {op.phase} )}
@@ -50,8 +70,9 @@ const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => { export const Toolbox = ({ operations, children }: { operations: Operation[], children?: React.ReactNode }) => { const [activeTab, setActiveTab] = useState<'toolbox' | 'scenarios'>('scenarios'); + const [groupBy, setGroupBy] = useState('client'); - const groupedOperations = useMemo(() => { + const groupedByClient = useMemo(() => { const grouped = operations.reduce((acc, op) => { const groupName = op.category || 'General'; if (!acc[groupName]) { @@ -68,6 +89,34 @@ export const Toolbox = ({ operations, children }: { operations: Operation[], chi return { grouped, sortedKeys }; }, [operations]); + const groupedByPhase = useMemo(() => { + const grouped = operations.reduce((acc, op) => { + const phase = op.phase || '_none'; + if (!acc[phase]) { + acc[phase] = []; + } + acc[phase].push(op); + return acc; + }, {} as Record); + + // Sort keys: known phases in flight order, then unknown, then _none last + const sortedKeys = Object.keys(grouped).sort((a, b) => { + const ai = PHASE_ORDER.indexOf(a); + const bi = PHASE_ORDER.indexOf(b); + if (a === '_none') return 1; + if (b === '_none') return -1; + if (ai >= 0 && bi >= 0) return ai - bi; + if (ai >= 0) return -1; + if (bi >= 0) return 1; + return a.localeCompare(b); + }); + + for (const key of sortedKeys) { + grouped[key].sort((a, b) => a.name.localeCompare(b.name)); + } + return { grouped, sortedKeys }; + }, [operations]); + return (