pymooの多目的遺伝的アルゴリズムであるNSGA-IIと、 Scipy.solve_ivpを用いた航空機の6自由度の運動解析を用いて、久しぶりに鳥コン滑空機の飛行経路の最適化を行った(NSGA_NLFS6)
はじめに
pymooの多目的遺伝的アルゴリズムであるNSGA-IIと、 Scipy.solve_ivpを用いた航空機の6自由度の運動解析を用いて、久しぶりに鳥コン滑空機の飛行経路の最適化を行った
↓Scipy.solve_ivpについて
以前にもFORTRANを用いて鳥コン滑空機の飛行経路最適化を行ったことがあるので、今回のNSGA_NLFS6はv2.0.0である
↓前回の挑戦(GA_NLFS6)
最終的にはこんな感じになる
それではいってみよう
最適化問題の設定
今回作成したNSGA_NLFS6は、多目的遺伝的アルゴリズムNAGA-IIを用いてNLFS6_v2.0.0の制御パラメータを最適化することで、鳥コン滑空機における最適な飛行経路を求めるプログラムである
多目的最適化の概要は以下の通り
設計変数(遺伝子)
NLFS6 v2.0.0の制御パラメータ13個+テイクオフ時の初期ピッチ角の計14個
設計変数 | 型 | 説明 | 単位 | 最小値 | 最大値 | ndarray |
gamma_dive | float | ダイブ角 | deg | -30 | 0 | X[0] |
gamma_cruise | float | 巡航角 | deg | -5 | 0 | X[1] |
phi_turn | float | 旋回角 | deg | -10 | 0 | X[2] |
time_pullup | float | 引き起こし時刻 | s | 0 | 20 | X[3] |
time_flare | float | フレア時刻 | s | 0 | 60 | X[4] |
time_entry | float | 旋回開始時刻 | s | 0 | 60 | X[5] |
time_break | float | 旋回終了時刻 | s | 0 | 60 | X[6] |
PID_param_lon | ndarray | 縦方向PIDバラメータ | - | -1 | 1 | X[7:10] |
PID_param_lat | ndarray | 横方向PIDパラメータ | - | -1 | 1 | X[10:13] |
theta0 | float | 初期ピッチ角 | deg | -5 | 5 | X[13] |
目的関数
目的関数 | 説明 |
f1 | 目標経路角と実際の経路角とのずれ量の絶対値の時間平均値 |
f2 | 目標バンク角と実際のパンク角とのずれ量の絶対値の時間平均値 |
f3 | 飛行距離の逆符号 |
pymooのNSGA-IIでは目的関数の「最小化」を行うため、飛行距離の逆符号を目的関数にしている
制約条件
制約条件 | 説明 |
g1 | 引き起こし時刻<フレア開始時刻 |
g2 | 旋回開始時刻<旋回終了時刻 |
g3 | フレア開始時刻<フライト終了時刻 |
g4 | 旋回終了時刻<フライト終了時刻 |
g1とg2は当たり前の制約で、g3は明示しなくても勝手に着水前にフレアをするよう最適化される
g4が案外重要で、これがないと、f2との兼ね合いで、操縦しないフライト(ずっとフェーズ1 → f2=0で理論的な最適解) に収束してしまう
個体数、世代数、交差、突然変異、など
個体数は100、世代数は100、 交差と突然変異はよくわからないのでコピペ元のコードのまま
ソースコードの解説
MyProblemクラス
Problemクラスを継承したMyProblemクラスを定義し、最適化問題を定義している
メソッドは以下の通り
- __init__
- evaluate
__init__(self, u0=5.5.gamma0-3.5, uvw_gE=np.array({0.0.0]))
def __init__(self, u0=5.5, gamma0=3.5, uvw_gE=np.array([0, 0, 0])):
self.u0 = u0 # 初期速度
self.w0 = u0 * math.tan(math.radians(gamma0)) # 初期速度
self.uvw_gE = uvw_gE # 対地風速
# 最適化変数の下限と上限
xl = np.array([-30, -2, -10, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -5])
xu = np.array([ -2, -1, 0, 10, 60, 60, 60, +1, +1, +1, +1, +1, +1, +5])
super().__init__(n_var=14, n_obj=3, n_constr=4, xl=xl, xu=xu) # 問題の初期化
初期化メソッド
引数:
変数名 | 型 | 説明 | 単位 | デフォルト値 |
u0 | float | 初期速度 | m/s | 5.5 |
gamma0 | float | 初期経路角 | deg | -3.5 |
uvw_gE | ndarray | 対地風速 | m/s | (0,0,0) |
以下のインスタンス変数を初期化する
変数名 | 型 | 説明 | 単位 | デフォルト値 |
u0 | float | 機体軸x方向の初期速度 | m/s | 5.5 |
w0 | float | 機体軸z方向の初期速度 | m/s | -5.5*tan(theta0) |
uvw_gE | ndarray | 対地風速 | m/s | (0,0,0) |
xl | ndarray | 最適化変数の下限 | - | np.array([-30, -2, -10, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -5]) |
xu | ndarray | 最適化変数の上限 | - | np.array([ -2, -1, 0, 10, 60, 60, 60, +1, +1, +1, +1, +1, +1, +5]) |
継承元のProblemクラスの初期化メソッドに以下を引数として与える
引数 | 型 | 説明 | 単位 | デフォルト |
n_var | int | 設計変数の数 | - | 14 |
n_obj | int | 目的関数の数 | - | 3 |
n_const | int | 制約条件の数 | - | 4 |
xl, xu | ndarray | 設計変数の上限値と下限値 | - | - |
_evaluate (self, X, out, *args, **kwargs)
def _evaluate(self, X, out, *args, **kwargs):
# Xを丸める
X = np.round(X,5)
# 目的関数のリスト
f1_list = []
f2_list = []
f3_list = []
g3_list = []
g4_list = []
for i in range(X.shape[0]):
theta0 = X[i, 13] # 初期ピッチ角
initial_state = [0, 0, 0, self.u0, 0, self.w0, 0, theta0, 0, 0, 0, 0, 0, 0] # 初期状態
airplane = Plane(uvw_gE=self.uvw_gE, X=X[i, :13]) # Planeクラスのインスタンス作成
solution = airplane.simulate(initial_state=initial_state) # シミュレーション実行
XE, YE, ZE = solution.y[:3] # 結果の取得
Distance = np.sqrt(XE[-1]**2 + YE[-1]**2) # 飛行距離
# 時間差分と速度成分の計算
dt = np.diff(solution.t)
uE = np.diff(XE) / dt
wE = np.diff(ZE) / dt
gamma = np.degrees(np.arctan(wE / uE)) # 経路角の計算
# 目的関数1の計算
target = np.where(solution.t <= X[i, 3], X[i, 0], np.where(solution.t >= X[i, 4], 0, X[i, 1]))
diff1 = np.sum(np.abs(gamma - target[1:])) / solution.t[-1]
# 目的関数2の計算
phi = solution.y[6]
target = np.where(solution.t <= X[i, 5], phi, np.where(solution.t >= X[i, 6], 0, X[i, 2]))
diff2 = np.sum(np.abs(phi - target))/ np.abs(solution.t[-1]-X[i,5])
# 目的関数リストに追加
f1_list.append(diff1)
f2_list.append(diff2)
f3_list.append(-Distance)
g3_list.append(X[i,4]-solution.t[-1]) # time_flare < flight time
g4_list.append(X[i,6]-solution.t[-1]) # time_break < flight time
# 目的関数の結果をnumpy配列に変換
f1 = np.array(f1_list)
f2 = np.array(f2_list)
f3 = np.array(f3_list)
# 制約条件の計算
g1 = X[:, 3] - X[:, 4]
g2 = X[:, 5] - X[:, 6]
g3 = np.array(g3_list)
g4 = np.array(g4_list)
# 出力として目的関数と制約条件を設定
out["F"] = np.column_stack([f1, f2, f3])
out["G"] = np.column_stack([g1, g2, g3, g4])
最適化アルゴリズムによって呼び出されるメソッド
目的関数と制約条件の計算を行い、結果をout
に設定する
なお、制御パラメータの丸め誤差を回避するため、 Xを小数点以下5桁で四捨五入している
引数 | 型 | 説明 | 単位 | デフォルト |
X | ndarray | 設計変数 | - | xlとxuから生成される |
戻り値 | 型 | 説明 | 単位 |
out | dictionary | 目的関数と制約条件の計算結果を格納する辞書 | - |
↓Airplaneクラスの使い方についてはこちらを参照
MyOutputクラス
class MyOutput(Output):
def __init__(self):
super().__init__()
self.Dist_max = Column("Dist_max", width=13)
self.Dist_ave = Column("Dist_ave", width=13)
self.diff1 = Column("diff1", width=13)
self.diff2 = Column("diff2", width=13)
self.RunTime = Column("RunTime", width=13)
self.columns += [self.Dist_max, self.Dist_ave, self.diff1, self.diff2, self.RunTime]
self.start_time = time.time() # 開始時刻を記録
def update(self, algorithm):
super().update(algorithm)
self.diff1.set(np.min(algorithm.pop.get("F"), axis=0)[0])
self.diff2.set(np.min(algorithm.pop.get("F"), axis=0)[1])
self.Dist_max.set(-np.min(algorithm.pop.get("F"), axis=0)[2])
self.Dist_ave.set(-np.average(algorithm.pop.get("F"), axis=0)[2])
self.RunTime.set(time.time()-self.start_time)
Outputクラスを継承したMyOutputクラスを定義し、最適化実行中のプログラムの標準出力をアレンジする
NSGA_NLFS6では以下の値を出力する
表示 | 説明 | 単位 |
n_gen | これまでの世代数 | - |
n_eval | これまでの関数評価の数 | - |
Dist_max | 見つかった最適解の最大飛距離 | m |
Dist_ave | 見つかった最適解の平均飛距離 | m |
diff1 | 見つかった最適解の目的関数1の最小値 | - |
diff2 | 見つかった最適解の目的関数2の最小値 | - |
RunTime | これまでの実行時間 | s |
Columnクラスのsetメソッドに値を渡すときに、指数標記にならないようひと工夫している
__main__関数
if __name__ == '__main__':
start_time = time.time() # 開始時刻を記録
(処理)
# 終了時刻を記録し、実行時間を計算
end_time = time.time()
print(f"Run time: {end_time - start_time} s")
実行部分はここ
処理:
テイクオフ時の初速と経路角および地表風を引数として与えてMyProblemクラスのインスタンスを作成する
# 最適化問題の設定
problem = MyProblem(
u0=5.5,
gamma0=3.5,
uvw_gE=np.array([0, 2*math.sqrt(2), 0]),
)
NSGA-IIのパラメータを設定する
# NSGA-IIアルゴリズムの設定
algorithm = NSGA2(
pop_size=100,
sampling=LHS(),
crossover=SBX(prob=0.9, eta=15),
mutation=PolynomialMutation(eta=20),
eliminate_duplicates=True,
)
最適化を実行する(100個体100世代の最適化で半日かかる。 寝る前/出かける前の実行を推奨)
# 最適化の実行
res = minimize(
problem,
algorithm,
("n_gen", 100),
seed=1,
save_history=False,
verbose=True,
output=MyOutput(),
)
飛行距離が大きい順に結果をソートしてからCSVファイルに保存する
res_X = pd.DataFrame(np.round(res.X,5), columns=[f'X{i}' for i in range(14)])
res_F = pd.DataFrame(res.F, columns=['diff1', 'diff2', 'Distance'])
res_G = pd.DataFrame(res.G, columns=['Time_Cruise', 'Time_Turn'])
# 結果をCSVファイルに保存
res = pd.concat([res_X, res_F, res_G], axis=1) # 変数データと目的関数データを結合
res = res.sort_values('Distance') # 距離でソート
res[[f'X{i}' for i in range(14)]].to_csv('./res_X.csv', index=False)
res[['Distance','diff1', 'diff2']].to_csv('./res_F.csv', index=False)
res[['Time_Cruise', 'Time_Turn', 'Time_Flare', 'Time_NoBank']].to_csv('./res_G.csv', index=False)
使い方
NLFS.pyのinitにある性能諸元を任意に設定する
NSGA2.pyとcheck.pyの_main関数にあるu0
, gamma0
, uvw_gE
の値を任意に設定する
NSGA2.py___main_関数にあるn_gen
とpop_size
を任意に設定する
ターミナルで以下のコマンドを実行する
py NSGA2.py
実行中は、MyOutputクラスで定義した値が出力されるので、気長に放置する
プログラムが終了したら、 結果を確認するためにターミナルで以下のコマンドを実行する
py check.py
結果を示すグラフが続けて2枚表示され、主要な状態変数などがresult.csvに書き出される
考察
せっかくなので、いくつかのケースの最適化を行ってみた
Case | u0 | gamma0 | uvw_gE | theta0 | gamma dive | time dive | gamma cruise | time flare | time entry | phi turn | time break | flight_time | Distance |
(単位) | m/s | deg | m/s | deg | deg | s | deg | s | s | deg | s | s | m |
基本性能 | 10 | -1.45 | (0,0,0) | - | - | - | - | - | - | - | - | 40.514 | 404.830 |
無風 | 5.5 | -3.5 | (0,0,0) | 0.928 | -21.855 | 1.263 | -1.161 | 29.738 | 9.279 | -0.169 | 9.417 | 36.245 | 332.470 |
正対風 | 5.5 | -3.5 | (2√2,0,0) | 0.160 | -4.353 | 0.792 | -1.722 | 28.933 | 17.199 | -0.061 | 22.210 | 45.631 | 297.986 |
斜め風 | 5.5 | -3.5 | (2,2,0) | -0.932 | -4.012 | 4.390 | -1.231 | 32.875 | 3.615 | -2.191 | 30.920 | 43.806 | 356.284 |
横風 | 5.5 | -3.5 | (0,2√2,0) | -0.345 | -3.086 | 0.615 | -1.594 | 22.902 | 18.084 | -1.194 | 30.138 | 38.790 | 357.617 |
背風 | 5.5 | -3.5 | (-2,0,0) | 3.324 | -3.435 | 1.278 | -1.091 | 16.131 | 2.961 | -1.215 | 4.558 | 26.999 | 311.890 |
だいたい全機L/Dが40くらいの機体だと、 初速5.5[m/s]で飛距離が298m~358mまでぶれている
当日の風とそれに合わせたフライトプランの重要性がわかる
すごくきれいなダイブ→引き起こし→フレア
当然旋回は全くしていない
失速速度は7.5m/s程度、その時の迎角は10度程度
着水時のピッチ角も10度程度なので、尻摺り角とか胴体の角度を設計するときの参考になるかも
最後に謎のバンクが入ってしまっているのでもう少し最適化の余地はありそう
無風の時よりもダイブが短く、定常滑空角がやや深い
ポーラーカーブに従ってきちんと飛行速度を増しているのが良い
フレアを打つ際にエレベータの最大舵角-10まで切っている
俗にいうベストコンディション
きちんと緩やかに旋回しながら背風を背負うフライトに移行している
対気速度は10m/s 弱でほぼ一定のまま、対地速度が6m/s → 10m/s 強へと増加している
ラダーの最大舵角は8度程度
真横からの風なのでそこまで旋回する必要はなく、最初は流されるままにフライトしている
フレアが謎に長かったり、ラダーをぶんぶん振り回したりしているのでもう少し最適化の余地は残っていそう
狂気のコンディション
水面スレッスレで最大舵角のアップを打って引き起こしている
このコンディションだけきちんと初期ピッチ角を3degほどのして少しでも揚力を稼ごうとしているのが良い
対地速度が13m/sほど出ている
着水の瞬間にエレベーター舵角が最大舵角に到達しているが、その時の対気速度は8m/s強あるので、フレアが打ち切れていない可能性がある
引き起こしのことも考えると、エレベーター舵角のマイナス側(頭上げ側)の最大舵角はもう少し余裕を持たせた方がいいかもしれない
以上
おわりに
pymooの多目的遺伝的アルゴリズムであるNSGA-IIと、 Scipy.solve_ivpを用いた航空機の6自由度の運動解析を用いて、久しぶりに鳥コン滑空機の飛行経路の最適化を行った(NSGA_NLFS6)
個人的には、多目的最適化にしたことによって飛行経路がぬるぬる滑らかになったのが良かった
コメント