import argparse import contextlib import io import itertools import json import math import os import sys import jsbsim_runner DEFAULT_AIRCRAFT_ID = "f22cobra" DEFAULT_ALTITUDE_M = 3000 DEFAULT_SAMPLE_RATE_HZ = 20 DEFAULT_INTEGRATION_RATE_HZ = 120 DEFAULT_DURATION_SEC = 5 def main(): args = parse_args() candidates = build_grid(args.mode) results = [] print( f"Running {len(candidates)} F-22 cobra candidates " f"({args.mode}, duration={args.duration_sec}s)...", file=sys.stderr, ) for index, parameters in enumerate(candidates, start=1): if index == 1 or index % 10 == 0 or index == len(candidates): print(f"[{index}/{len(candidates)}]", file=sys.stderr) result = run_candidate(parameters, args) if result: results.append(result) ranked = sorted(results, key=lambda item: item["score"]) top = ranked[: args.top] print_summary(top) if args.json: print(json.dumps(top, indent=2)) def parse_args(): parser = argparse.ArgumentParser( description="Sweep F-22 JSBSim cobra maneuver parameters." ) parser.add_argument( "--mode", choices=["quick", "wide"], default="quick", help="quick is small enough for iteration; wide explores more combinations.", ) parser.add_argument("--top", type=int, default=10) parser.add_argument("--duration-sec", type=float, default=DEFAULT_DURATION_SEC) parser.add_argument("--json", action="store_true") return parser.parse_args() def build_grid(mode): if mode == "wide": entry_speeds = [180, 200, 220, 240, 260] pull_durations = [0.45, 0.6, 0.75, 0.9, 1.05] unload_durations = [0.35, 0.5, 0.7] recovery_elevators = [0.2, 0.35, 0.5, 0.65] recovery_tvc_cmds = [0.0, 0.35, 0.7, 1.0] throttle_cmds = [0.85, 1.0] else: entry_speeds = [200, 220, 240] pull_durations = [0.45, 0.6, 0.75, 0.9] unload_durations = [0.35, 0.5, 0.7] recovery_elevators = [0.25, 0.4, 0.55] recovery_tvc_cmds = [0.0, 0.5, 1.0] throttle_cmds = [1.0] grid = [] for ( speed_mps, pull_duration_sec, unload_duration_sec, recovery_elevator_cmd, recovery_tvc_cmd, throttle_cmd, ) in itertools.product( entry_speeds, pull_durations, unload_durations, recovery_elevators, recovery_tvc_cmds, throttle_cmds, ): grid.append( { "entrySpeedMps": speed_mps, "pullDurationSec": pull_duration_sec, "unloadDurationSec": unload_duration_sec, "recoveryDurationSec": 2.0, "maxElevatorCmd": -1.0, "recoveryElevatorCmd": recovery_elevator_cmd, "pullTvcCmd": 0.0, "recoveryTvcCmd": recovery_tvc_cmd, "throttleCmd": throttle_cmd, } ) return grid def run_candidate(parameters, args): payload = { "operation": "simulate", "aircraftId": DEFAULT_AIRCRAFT_ID, "sampleRateHz": DEFAULT_SAMPLE_RATE_HZ, "integrationRateHz": DEFAULT_INTEGRATION_RATE_HZ, "trim": False, "durationSec": args.duration_sec, "initialState": { "position": [0, DEFAULT_ALTITUDE_M, 0], "velocityMps": parameters["entrySpeedMps"], "headingDeg": 0, }, "maneuver": { "type": "cobra", "parameters": { "flightPathAngleDeg": 0, "pullDurationSec": parameters["pullDurationSec"], "unloadDurationSec": parameters["unloadDurationSec"], "recoveryDurationSec": parameters["recoveryDurationSec"], "maxElevatorCmd": parameters["maxElevatorCmd"], "recoveryElevatorCmd": parameters["recoveryElevatorCmd"], "pullTvcCmd": parameters["pullTvcCmd"], "recoveryTvcCmd": parameters["recoveryTvcCmd"], "throttleCmd": parameters["throttleCmd"], }, }, } try: with contextlib.redirect_stdout(io.StringIO()): simulation = jsbsim_runner.simulate(payload) except Exception as error: return { "score": 1_000_000, "failed": True, "error": str(error), "parameters": parameters, } samples = simulation["samples"] metrics = compute_metrics(samples, parameters) return { "score": round(score(metrics), 6), "failed": False, "parameters": parameters, "metrics": metrics, } def compute_metrics(samples, parameters): first = samples[0] last = samples[-1] max_alpha_sample = max(samples, key=lambda sample: sample.get("alphaDeg", -999)) max_pitch_sample = max(samples, key=lambda sample: sample.get("pitchDeg", -999)) min_speed_sample = min(samples, key=lambda sample: sample.get("velocityMps", 999999)) heading_delta = angle_delta_deg(first.get("headingDeg", 0), last.get("headingDeg", 0)) altitude_delta = last["position"][1] - first["position"][1] forward_delta = last["position"][2] - first["position"][2] lateral_delta = last["position"][0] - first["position"][0] return { "maxAlphaDeg": round_float(max_alpha_sample.get("alphaDeg", 0)), "maxAlphaTimeSec": round_float(max_alpha_sample.get("t", 0)), "maxPitchDeg": round_float(max_pitch_sample.get("pitchDeg", 0)), "maxPitchTimeSec": round_float(max_pitch_sample.get("t", 0)), "endAlphaDeg": round_float(last.get("alphaDeg", 0)), "endPitchDeg": round_float(last.get("pitchDeg", 0)), "minSpeedMps": round_float(min_speed_sample.get("velocityMps", 0)), "endSpeedMps": round_float(last.get("velocityMps", 0)), "altitudeDeltaM": round_float(altitude_delta), "forwardDeltaM": round_float(forward_delta), "lateralDeltaM": round_float(lateral_delta), "headingDeltaDeg": round_float(heading_delta), "entrySpeedMps": parameters["entrySpeedMps"], "maxTvcDeg": round_float( max( abs(sample.get("controlSurfaces", {}).get("tvcDeg", 0)) for sample in samples ) ), "maxThrustNorm": round_float( max(sample.get("fcs", {}).get("thrustNorm", 0) for sample in samples) ), } def score(metrics): max_alpha = metrics["maxAlphaDeg"] max_pitch = metrics["maxPitchDeg"] end_alpha = abs(metrics["endAlphaDeg"]) end_pitch = abs(metrics["endPitchDeg"]) altitude_delta = metrics["altitudeDeltaM"] lateral_delta = abs(metrics["lateralDeltaM"]) speed_loss = metrics["entrySpeedMps"] - metrics["minSpeedMps"] return ( abs(max_alpha - 105) * 2.0 + abs(max_pitch - 95) * 1.2 + max(0, 90 - max_alpha) * 8.0 + max(0, max_alpha - 125) * 10.0 + max(0, end_alpha - 25) * 1.5 + max(0, end_pitch - 35) * 0.8 + max(0, 35 - speed_loss) * 0.8 + lateral_delta * 0.15 + max(0, -altitude_delta) * 0.1 ) def print_summary(results): print("") print("rank score speed pull unload recElev recTvc maxA tA maxP endA endP minV dAlt dFwd dHead") for index, result in enumerate(results, start=1): params = result["parameters"] metrics = result.get("metrics", {}) if result.get("failed"): print(f"{index:>4} FAILED {result.get('error')}") continue print( f"{index:>4} " f"{result['score']:>6.1f} " f"{params['entrySpeedMps']:>5.0f} " f"{params['pullDurationSec']:>4.2f} " f"{params['unloadDurationSec']:>5.2f} " f"{params['recoveryElevatorCmd']:>7.2f} " f"{params['recoveryTvcCmd']:>6.2f} " f"{metrics['maxAlphaDeg']:>5.1f} " f"{metrics['maxAlphaTimeSec']:>3.1f} " f"{metrics['maxPitchDeg']:>5.1f} " f"{metrics['endAlphaDeg']:>5.1f} " f"{metrics['endPitchDeg']:>5.1f} " f"{metrics['minSpeedMps']:>5.1f} " f"{metrics['altitudeDeltaM']:>5.0f} " f"{metrics['forwardDeltaM']:>5.0f} " f"{metrics['headingDeltaDeg']:>5.1f}" ) def angle_delta_deg(start, end): return ((end - start + 540) % 360) - 180 def round_float(value): return round(float(value), 6) if math.isfinite(float(value)) else value if __name__ == "__main__": main()