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 75ce3215..11718678 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.PRE_FLIGHT) 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.PRE_FLIGHT) 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.PRE_FLIGHT) 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 afe1ded6..eeb6cd08 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.PRE_FLIGHT) 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.PRE_FLIGHT) async def generate_bayesian_sim_air_traffic_data( self, config_path: str | None = None, @@ -206,7 +207,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.PRE_FLIGHT) 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 d63f0bbe..0e7e6659 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.PRE_FLIGHT) async def generate_bluesky_sim_air_traffic_data( self, config_path: str | None = None, @@ -141,7 +142,7 @@ async def generate_bluesky_sim_air_traffic_data( all_obs.extend(results_by_acid[acid]) return all_obs - @scenario_step("Generate BlueSky Simulation Air Traffic Data with latency issues") + @scenario_step("Generate BlueSky Simulation Air Traffic Data with latency issues", phase=FlightPhase.PRE_FLIGHT) 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 6972682a..fb094720 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.PRE_FLIGHT) 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.CRUISE) 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.CRUISE) async def wait_for_messages( self, count: int = 1, @@ -395,7 +396,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. @@ -409,7 +410,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.PRE_FLIGHT) 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 b14903ef..8f978bb8 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 @@ -22,6 +22,7 @@ ScenarioContext, scenario_step, ) +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.core.reporting.reporting_models import ( Status, StepResult, @@ -133,7 +134,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.PRE_FLIGHT) async def upload_geo_fence(self, filename: str | None = None) -> dict[str, Any]: """Upload an Area-of-Interest (Geo Fence) to Flight Blender. @@ -165,7 +166,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.PRE_FLIGHT) async def get_geo_fence(self) -> dict[str, Any]: """Retrieve the details of the most recently uploaded geo-fence. @@ -183,7 +184,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. @@ -221,7 +222,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.PRE_FLIGHT) async def upload_flight_declaration(self, declaration: str | BaseModel) -> dict[str, Any]: """Upload a flight declaration to the Flight Blender API. @@ -273,7 +274,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.PRE_FLIGHT) async def upload_multiple_flight_declarations(self, declarations: list[BaseModel]) -> dict[str, Any]: """ Upload multiple flight declarations to Flight Blender. @@ -318,7 +319,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.PRE_FLIGHT) async def upload_flight_declaration_via_operational_intent(self, declaration: str | BaseModel) -> dict[str, Any]: """Upload a flight declaration to the Flight Blender API. @@ -371,7 +372,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.PRE_FLIGHT) async def upload_multiple_flight_declarations_via_operational_intents(self, declarations: list[BaseModel]) -> dict[str, Any]: endpoint = "/flight_declaration_ops/set_operational_intents_bulk" @@ -406,7 +407,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.CRUISE) async def update_operation_state(self, state: OperationState, duration: str | int | float = 0) -> dict[str, Any]: """Update the state of a flight operation. @@ -434,7 +435,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.CRUISE) async def update_operation_state_of_declaration( self, state: OperationState, declaration_id: str, duration: str | int | float = 0 ) -> dict[str, Any]: @@ -542,7 +543,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.CRUISE) 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. @@ -563,7 +564,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.CRUISE) 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. @@ -587,7 +588,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.CRUISE) async def check_operation_state( self, expected_state: OperationState, @@ -612,7 +613,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.CRUISE) async def check_operation_state_connected( self, expected_state: OperationState, @@ -653,7 +654,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. @@ -704,7 +705,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. @@ -738,7 +739,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.CRUISE) async def submit_simulated_air_traffic( self, observations: list[FlightObservationSchema], @@ -840,7 +841,7 @@ async def submit_simulated_air_traffic( "simulation_duration": (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.CRUISE) async def submit_simulated_air_traffic_at_random_refresh_rates( self, observations: list[FlightObservationSchema], @@ -1010,7 +1011,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[FlightObservationSchema], session_id: uuid.UUID | None = None): """ Queries the SDSP metrics endpoint and verifies reported values against expected values @@ -1077,7 +1078,7 @@ async def verify_reported_metrics_in_flight_blender(self, observations: list[Fli error_message=None if not errors else "; ".join(errors), ) - @scenario_step("Submit Air Traffic") + @scenario_step("Submit Air Traffic", phase=FlightPhase.CRUISE) 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. @@ -1101,7 +1102,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.PRE_FLIGHT) async def get_active_sensors(self): endpoint = "/surveillance_monitoring_ops/list_surveillance_sensors" response = await self.get(endpoint) @@ -1113,7 +1114,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.CRUISE) async def set_sensor_failure(self, sensor_id: str): endpoint = f"/surveillance_monitoring_ops/update_sensor_health/{sensor_id}" new_status_payload = {"status": "outage"} @@ -1125,7 +1126,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.CRUISE) async def list_sensor_failure_notifications(self) -> StepResult: endpoint = "/surveillance_monitoring_ops/list_sensor_health_notifications" response = await self.get(endpoint) @@ -1184,7 +1185,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.CRUISE) async def initialize_verify_sdsp_track( self, expected_track_interval_seconds: int, @@ -1246,7 +1247,7 @@ async def initialize_verify_sdsp_track( duration=duration, ) - @scenario_step("Verify SDSP Heartbeat") + @scenario_step("Verify SDSP Heartbeat", phase=FlightPhase.CRUISE) async def initialize_verify_sdsp_heartbeat( self, expected_heartbeat_interval_seconds: int, @@ -1318,12 +1319,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.PRE_FLIGHT) async def setup_flight_declaration_via_operational_intent( self, flight_declaration_via_operational_intent_path: str, @@ -1371,7 +1372,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.PRE_FLIGHT) async def setup_flight_declaration( self, flight_declaration_path: str | None = None, @@ -1429,7 +1430,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.PRE_FLIGHT) async def setup_two_flight_declarations( self, flight_declaration_path: str | None = None, @@ -1492,7 +1493,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.PRE_FLIGHT) 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..728f7a75 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.CRUISE) 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 40422f0c..7d992d91 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[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..92f544d0 --- /dev/null +++ b/src/openutm_verification/core/flight_phase.py @@ -0,0 +1,89 @@ +""" +Flight phase taxonomy for verification step annotations. + +Phases are optional annotations on scenario steps that allow grouping +related steps in reports and the UI. +""" + +from enum import StrEnum + + +class FlightPhase(StrEnum): + """Flight phase categories for annotating verification steps.""" + + FLIGHT_PLANNING = "FPL" + """Flight planning phase.""" + + PRE_FLIGHT = "PRF" + """Pre-flight checks and setup.""" + + ENGINE_START = "ESD" + """Engine start / depart phase.""" + + TAXI_OUT = "TXO" + """Taxi-out phase.""" + + TAKEOFF = "TOF" + """Takeoff phase.""" + + REJECTED_TAKEOFF = "RTO" + """Rejected takeoff phase.""" + + INITIAL_CLIMB = "ICL" + """Initial climb phase.""" + + EN_ROUTE_CLIMB = "ERC" + """En route climb phase.""" + + CRUISE = "CRZ" + """Cruise phase.""" + + DESCENT = "DES" + """Descent phase.""" + + APPROACH = "APR" + """Approach phase.""" + + GO_AROUND = "GAR" + """Go-around phase.""" + + LANDING = "LDG" + """Landing phase.""" + + TAXI_IN = "TXI" + """Taxi-in phase.""" + + ARRIVAL = "AES" + """Arrival / engine shutdown phase.""" + + POST_FLIGHT = "PST" + """Post-flight phase.""" + + FLIGHT_CLOSE = "FCL" + """Flight close phase.""" + + GROUND_SERVICES = "GND" + """Ground services phase.""" + + +# Human-readable labels for display +FLIGHT_PHASE_LABELS: dict[FlightPhase, str] = { + FlightPhase.FLIGHT_PLANNING: "Flight Planning", + FlightPhase.PRE_FLIGHT: "Pre-flight", + FlightPhase.ENGINE_START: "Engine Start / Depart", + FlightPhase.TAXI_OUT: "Taxi Out", + FlightPhase.TAKEOFF: "Takeoff", + FlightPhase.REJECTED_TAKEOFF: "Rejected Takeoff", + FlightPhase.INITIAL_CLIMB: "Initial Climb", + FlightPhase.EN_ROUTE_CLIMB: "En Route Climb", + FlightPhase.CRUISE: "Cruise", + FlightPhase.DESCENT: "Descent", + FlightPhase.APPROACH: "Approach", + FlightPhase.GO_AROUND: "Go-around", + FlightPhase.LANDING: "Landing", + FlightPhase.TAXI_IN: "Taxi In", + FlightPhase.ARRIVAL: "Arrival / Engine Shutdown", + FlightPhase.POST_FLIGHT: "Post-flight", + FlightPhase.FLIGHT_CLOSE: "Flight Close", + FlightPhase.GROUND_SERVICES: "Ground Services", +} diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index 455567e1..33988687 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, @@ -189,9 +190,13 @@ def _generate_html_report(report_data: ReportData, output_dir: Path, base_filena autoescape=select_autoescape(enabled_extensions=("html", "xml"), default_for_string=True, default=True), ) env.filters["markdown"] = lambda text: markdown.markdown(text) if text else "" + env.filters["default_phase"] = lambda steps: [{**s, "phase": s.get("phase") or ""} for s in steps] 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 42b29572..430d171c 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 a7a47069..2128d4de 100644 --- a/src/openutm_verification/core/steps/air_traffic_step.py +++ b/src/openutm_verification/core/steps/air_traffic_step.py @@ -11,6 +11,7 @@ from openutm_verification.core.execution.config_models import get_settings from openutm_verification.core.execution.dependency_resolution import CONTEXT from openutm_verification.core.execution.scenario_runner import scenario_step +from openutm_verification.core.flight_phase import FlightPhase from openutm_verification.core.providers import DataQualityType, ProviderType, create_provider from openutm_verification.core.streamers import RefreshModeType, StreamResult, TargetType, create_streamer @@ -141,8 +142,8 @@ def _apply_config_defaults( duration = 30 return (duration, config_path, number_of_aircraft, sensor_ids, session_ids) - - @scenario_step("Stream Air Traffic") + + @scenario_step("Stream Air Traffic", phase=FlightPhase.CRUISE) 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..cfc34d48 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,45 @@| Step Name | Status | Duration (s) | Result |
|---|---|---|---|
| {{ step.name }} | +{{ step.status }} | +{{ "%.2f"|format(step.duration) }} | +
+ {% if line_count > 10 %}
+
+
+ {% else %}
+ {{ result_json }}
+
+ {{ result_json }}
+ {% endif %}
+ |
+