1. Drone Dynamics
1.1. 드론의 운동 방정식
-
병진 운동 (Translational Dynamics)
기체의 가속도()는 질량()과 기체에 작용하는 힘의 합으로 결정된다.이 식에서 우변의 항들은 각각 중력, 기체의 자세()에 따라 방향이 결정되는 추력, 그리고 속도에 비례하는 공기 저항을 의미한다.
-
회전 운동 (Rotational Dynamics): 기체의 각가속도()는 관성 행렬()과 작용하는 토크()로 결정된다.
두 번째 항 은 회전 운동에서 발생하는 자이로스코픽 효과(gyroscopic effect)를 나타내는 비선형 항이다.
-
코드:
DroneDynamics.update위 연속 시간 방정식을 이산 시간에서 수치 적분하여 드론의 다음 상태를 계산하는 구현부이다.
# 발췌: DroneDynamics.update
def update(self, total_thrust, torques, dt):
# 회전 운동: 토크로부터 각가속도를 계산
ang_accel = self.invI @ (torques - np.cross(self.angular_velocity, self.I @ self.angular_velocity))
self.angular_velocity += ang_accel * dt
# 병진 운동: 모든 힘을 합산하여 가속도를 계산
R = self.orientation.as_matrix()
thrust_vector = R @ np.array([0, 0, total_thrust])
# 중력, 공기 저항 계산
net_force = thrust_vector + drag_force + gravity_force
acceleration = net_force / self.mass
self.velocity += acceleration * dt
self.position += self.velocity * dt
드론 동역학에서 자세를 다룰 때, 우리는 두 가지 다른 수학적 표현을 사용한다.
- 쿼터니언(Quaternion): 자세를 저장하고 시간에 따라 업데이트(회전)할 때 사용한다.
- 회전 행렬: 힘 벡터 등을 좌표계에 맞게 변환하는 실제 물리 계산에 사용한다.
드론의 움직임, 즉 동역학은 병진 운동(Translational Motion) 과 회전 운동(Rotational Motion) 의 두 가지로 나눌 수 있다.
이를 6-DOF 를 갖는다고 말한다.
1.2. 병진 운동: 드론은 어떻게 이동하는가?
병진 운동은 드론의 무게 중심이 3차원 공간(X, Y, Z)에서 어떻게 움직이는지를 설명하며, 이는 뉴턴의 운동 제2법칙 에 의해 지배됩니다. 드론의 가속도()를 결정하기 위해, 우리는 드론에 작용하는 모든 힘을 알아야 합니다.
중력 ()
가장 간단한 힘으로, 항상 지구 중심 방향(월드 좌표계의 -Z축)으로 일정하게 작용합니다.
-
수식
-
코드
gravity_force = np.array([0, 0, -self.mass * self.g])
추력 ()
핵심은 추력이 항상 드론의 동체(Body Frame)를 기준으로 Z축 방향으로 작용한다는 것이다.
-
수식
여기서 는 4개 프로펠러가 만들어내는 총 추력의 크기이며, 는 드론 동체 기준의 추력 벡터이다.
회전 행렬 는 이 동체 기준의 힘을 좌표계의 힘으로 변환하는 역할이다. -
코드
# R은 self.orientation (쿼터니언) 으로부터 계산된 회전 행렬
R = self.orientation.as_matrix()
thrust_vector = R @ np.array([0, 0, total_thrust])
항력 ()
-
수식
는 항력 계수(drag coefficient)입니다.
-
코드
drag_force = -self.drag_coeff * self.velocity * np.linalg.norm(self.velocity)
이 세 가지 힘을 모두 합하여(net_force) 질량으로 나누면 최종적으로 드론의 가속도를 얻고, 이를 적분하여 속도와 위치를 업데이트한다.
1.3. 회전 운동: 드론은 어떻게 자세를 바꾸는가?
회전 운동은 드론의 자세, 즉 기울기가 어떻게 변하는지를 설명하며, 이는 오일러의 회전 운동 방정식에 의해 기술된다.
오일러 방정식 (Euler's Equation)
토크()가 관성 행렬()과 각가속도()에 미치는 영향을 설명하겠다.
- 수식
- : 제어 입력; 롤(roll), 피치(pitch), 요(yaw) 방향의 토크
- : 관성에 의해 회전을 시작하거나 멈추는 데 필요한 토크
- : 자이로스코프 효과(Gyroscopic Effect) 라 불리는 비선형 항. 회전하는 물체는 그 회전축을 유지하려는 성질이 있는데 그걸 제어해준다.
자세 업데이트 (Attitude Update)
위 방정식을 통해 얻은 각가속도 를 적분하여 각속도 를 구한다. 이 각속도를 이용해 드론의 최종 자세를 업데이트합니다. 이때 롤, 피치, 요 각도를 직접 사용하면 짐벌 락(Gimbal Lock) 이라는 현상이 발생하여 특정 자세에서 회전 자유도를 잃는 문제가 생길 수 있다.
이를 피하기 위해, 본 시뮬레이션에서는 수학적으로 더 안정적인 쿼터니언(Quaternion) 을 사용하여 자세를 표현했다.
- 코드
# 발췌: DroneDynamics.update
# 각가속도 계산
ang_accel = self.invI @ (torques - np.cross(self.angular_velocity, self.I @ self.angular_velocity))
self.angular_velocity += ang_accel * dt
# 각속도를 이용해 쿼터니언 자세 업데이트
q_dot = self.orientation * Rotation.from_rotvec(self.angular_velocity * dt)
self.orientation = q_dot
1.4. 쿼터니언이란 무엇이며 왜 사용하는가?
단순히 롤(Roll), 피치(Pitch), 요(Yaw) 3개의 각도로 자세를 표현하면 **짐벌 락(Gimbal Lock)**이라는 심각한 문제가 발생할 수 있다.
특정 자세(예: 피치 각도가 90도일 때)에서 회전 자유도 하나를 잃어버려 제어가 불가능해지는 현상입니다.
이 문제를 해결하기 위해 쿼터니언(Quaternion) 이라는 4차원 복소수 체계를 사용한다.
- 수식: 쿼터니언의 정의
쿼터니언 는 하나의 실수부()와 세 개의 허수부()로 구성된 4차원 벡터이다. 여기서 는 실수이며, 는 허수 단위이다.
자세를 표현할 때는 크기가 1인 단위 쿼터니언(Unit Quaternion), 즉 을 사용합니다. 단위 쿼터니언은 특정 회전축 ()을 중심으로 특정 회전각 ()만큼 회전하는 것을 매우 효율적이고 안정적으로 표현할 수 있다.
쿼터니언을 회전 행렬로 변환하기
- 수식: 쿼터니언-회전 행렬 변환 공식
단위 쿼터니언 는 다음과 같은 3x3 회전 행렬 로 변환될 수 있다.
이 행렬은 쿼터니언 가 나타내는 회전과 정확히 동일한 변환을 수행한다.
2. 궤적 생성: 볼록 최적화를 이용한 Minimum Snap 경로 계획
여러 경유지를 통과하는 비행에서는 단순한 경로 연결이 아닌, 동역학적으로 안정적이고 부드러운 전역 궤적(global trajectory) 생성이 필수적이다.
-
수식: 최소 스냅(Minimum Snap) 문제 정형화
물리적으로 가장 부드러운 경로는 위치의 4차 미분인 스냅(Snap) 의 제곱 적분 값을 최소화하는 경로로 알려져 있다.이 문제는 모든 경유지를 지정된 시간에 통과해야 한다는 등식 제약 조건() 하에서 목적 함수를 최소화하는 QP(Quadratic Programming) 문제로 귀결된다. QP는 볼록 최적화 문제의 일종으로, 전역 최적해(global optimum)의 존재가 보장된다.
-
코드:
generate_optimal_trajectory
이 최적화 문제는cvxpy라이브러리를 통해 효율적으로 해결할 수 있다. 목적 함수는cp.quad_form으로, 제약 조건은A @ p == b의 형태로 명시하여 Solver에 전달한다.
# 발췌: generate_optimal_trajectory
# p: 최적화할 다항식 계수 벡터
p = cp.Variable(n_segments * n_coeffs)
# Objective: p^T * Q * p 형태의 이차식을 최소화
objective = cp.Minimize(cp.quad_form(p, Q_total + 1e-6 * np.eye(n_segments * n_coeffs)))
# Constraints: Ap = b 형태의 등식 제약조건
constraints = [A @ p == b[i, :]]
# 문제 정의 및 풀이
prob = cp.Problem(objective, constraints)
prob.solve(solver=cp.OSQP)
3. 피드백 제어(PID Control)
계획된 궤적을 정확히 추종하기 위해 비선형 피드백 제어기인 기하학적 제어기를 사용한다. 이 제어기는 드론의 회전 그룹 SO(3)의 구조를 직접 활용하여 큰 자세 오차에 대해서도 안정적인 성능을 보인다.
-
수식: 목표 힘 벡터 및 자세 제어 법칙
제어기의 핵심은 현재 상태 오차를 바탕으로 드론을 궤적으로 복귀시키기 위한 **목표 힘 벡터()**를 계산하는 것이다.여기서 는 각각 위치와 속도 오차이며, 는 양의 정부호(Positive Definite) 게인 행렬이다. 는 궤적의 목표 가속도를 따라가기 위한 피드포워드 항이다.
계산된 로부터 목표 추력 와 목표 자세 를 분리할 수 있다.
최종 토크 는 현재 자세 과 목표 자세 간의 오차 에 기반하여 결정된다.
- 코드:
GeometricController.update
위 제어 법칙이update함수 내에 순차적으로 구현되어 있다.
# 발췌: GeometricController.update
# 1. 목표 힘 벡터(f_des) 계산
e_pos = self.drone.position - pos_target
e_vel = self.drone.velocity - vel_target
f_des = -self.kp_pos @ e_pos - self.kd_pos @ e_vel + ...
# 2. 목표 추력(thrust_cmd) 및 목표 자세(R_des) 계산
thrust_cmd = np.dot(f_des, b3)
# ...
R_des = np.vstack([b1_des, b2_des, b3_des]).T
# 3. 자세 오차(e_R)를 이용해 최종 토크(torques_cmd) 계산
e_R_matrix = 0.5 * (R_des.T @ R - R.T @ R_des)
e_R = np.array([e_R_matrix[2,1], e_R_matrix[0,2], e_R_matrix[1,0]])
torques_cmd = -self.kp_att @ e_R - self.kd_att @ e_omega + ...
4. 제어 파라미터 최적화: 자동 튜닝
처음에는 게인 파라미터 는 수동 튜닝으로 했는데, 너무 어려워서 수치 최적화를 통해 자동으로 탐색했다.
-
수식: 비용 함수 최소화
게인 벡터 의 성능은 비용 함수 를 통해 정량적으로 평가된다. 비용 함수는 궤적 추종 오차와 제어 노력(각속도 크기)의 가중합으로 정의된다.최적의 게인 는 위 비용 함수를 최소화하는 해이다.
-
코드:
objective_function및minimize
objective_function은 주어진gains로 전체 시뮬레이션을 수행하고 비용 를 반환한다.scipy.optimize.minimize는 이 함수의 출력을 최소화하는gains를 Nelder-Mead와 같은 도함수-무관(derivative-free) 알고리즘을 사용하여 탐색한다.
# 비용 함수: 주어진 gains로 시뮬레이션을 돌리고 성능 점수를 반환
def objective_function(gains):
# ... 전체 시뮬레이션 실행 ...
cost_pos = np.mean(error_pos**2)
cost_rot = np.mean(np.array(angular_velocities)**2)
total_cost = cost_pos + weight_rot * cost_rot
return total_cost
# 최적화 실행: objective_function의 점수를 최소화하는 gains를 찾음
result = minimize(objective_function, initial_gains, method='Nelder-Mead', ...)
best_gains = result.x
전체코드
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from matplotlib.animation import FuncAnimation
from scipy.spatial.transform import Rotation
from scipy.optimize import minimize
from IPython.display import HTML
import cvxpy as cp
import math
from typing import List, Tuple
# --- 시뮬레이션 설정 ---
# 이륙 지점 및 방문할 경유지 목록
START_POINT: np.ndarray = np.array([0., 0., 2.])
WAYPOINTS_TO_VISIT: List[np.ndarray] = [
np.array([8., -8., 8.]),
np.array([8., 8., 4.]),
np.array([-8., 8., 8.]),
np.array([-8., -8., 5.]),
np.array([0., 0., 15.])
]
# 비행 및 시뮬레이션 파라미터
MAX_SPEED: float = 3.0
SIMULATION_TIMESTEP: float = 0.01
# 최적화 및 제어 게인 설정
INITIAL_GAINS: np.ndarray = np.array([6.0, 8.0, 4.0, 5.0, 20.0, 10.0, 2.0, 1.0])
GAIN_BOUNDS: List[Tuple[float, float]] = [(0.1, 50.0)] * 8
OPTIMIZER_MAX_ITERATIONS: int = 100
# --- 클래스 정의 ---
class DroneDynamics:
"""드론의 물리적 역학을 시뮬레이션하는 클래스."""
def __init__(self, start_pos: np.ndarray = np.zeros(3)):
self.mass = 1.0
self.g = 9.81
self.I = np.diag([0.01, 0.01, 0.02])
self.invI = np.linalg.inv(self.I)
self.drag_coeff = 0.1
self.position = start_pos.copy()
self.velocity = np.zeros(3)
self.orientation = Rotation.from_quat([0, 0, 0, 1])
self.angular_velocity = np.zeros(3)
def update(self, total_thrust: float, torques: np.ndarray, dt: float) -> None:
"""한 타임스텝 동안 드론의 상태를 업데이트합니다."""
ang_accel = self.invI @ (torques - np.cross(self.angular_velocity, self.I @ self.angular_velocity))
self.angular_velocity += ang_accel * dt
q_dot = self.orientation * Rotation.from_rotvec(self.angular_velocity * dt)
self.orientation = q_dot
R = self.orientation.as_matrix()
thrust_vector = R @ np.array([0, 0, total_thrust])
drag_force = -self.drag_coeff * self.velocity * np.linalg.norm(self.velocity)
gravity_force = np.array([0, 0, -self.mass * self.g])
net_force = thrust_vector + drag_force + gravity_force
acceleration = net_force / self.mass
self.velocity += acceleration * dt
self.position += self.velocity * dt
if self.position[2] < 0:
self.position[2] = 0
self.velocity[2] = 0
class Polynomial:
"""궤적의 한 조각을 나타내는 1차원 다항식 클래스."""
def __init__(self, coeffs: np.ndarray):
self.coeffs = coeffs
def get_state(self, t: float) -> Tuple[float, float, float]:
pos = np.polyval(self.coeffs, t)
vel = np.polyval(np.polyder(self.coeffs, 1), t)
acc = np.polyval(np.polyder(self.coeffs, 2), t)
return pos, vel, acc
class PiecewisePolynomialTrajectory:
"""여러 다항식 조각으로 구성된 전체 비행 궤적 클래스."""
def __init__(self, polys_xyz: List[List[Polynomial]], durations: List[float]):
self.polys_xyz = polys_xyz
self.durations = durations
self.cumulative_durations = np.cumsum([0] + durations)
def get_state(self, t: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""특정 시간 t에서의 궤적 상태(위치, 속도, 가속도)를 반환합니다."""
if t >= self.cumulative_durations[-1]:
t = self.cumulative_durations[-1] - 1e-6
segment_index = np.searchsorted(self.cumulative_durations, t, side='right') - 1
time_in_segment = t - self.cumulative_durations[segment_index]
pos = np.array([p_list[segment_index].get_state(time_in_segment)[0] for p_list in self.polys_xyz])
vel = np.array([p_list[segment_index].get_state(time_in_segment)[1] for p_list in self.polys_xyz])
acc = np.array([p_list[segment_index].get_state(time_in_segment)[2] for p_list in self.polys_xyz])
return pos, vel, acc
class GeometricController:
"""기하학적 제어기(Geometric Controller) 클래스."""
def __init__(self, drone: DroneDynamics, gains: np.ndarray):
self.drone = drone
self.kp_pos = np.diag([gains[0], gains[0], gains[1]])
self.kd_pos = np.diag([gains[2], gains[2], gains[3]])
self.kp_att = np.diag([gains[4], gains[4], gains[5]])
self.kd_att = np.diag([gains[6], gains[6], gains[7]])
def update(self, trajectory_target: Tuple[np.ndarray, np.ndarray, np.ndarray], yaw_target: float, dt: float) -> Tuple[float, np.ndarray]:
"""목표 궤적을 따라가기 위한 추력과 토크를 계산합니다."""
pos_target, vel_target, acc_target = trajectory_target
e_pos = self.drone.position - pos_target
e_vel = self.drone.velocity - vel_target
f_des = (-self.kp_pos @ e_pos - self.kd_pos @ e_vel +
self.drone.mass * self.drone.g * np.array([0, 0, 1]) +
self.drone.mass * acc_target)
R = self.drone.orientation.as_matrix()
b3 = R[:, 2]
thrust_cmd = np.dot(f_des, b3)
b3_des = f_des / np.linalg.norm(f_des) if np.linalg.norm(f_des) > 1e-6 else np.array([0, 0, 1])
a_yaw = np.array([np.cos(yaw_target), np.sin(yaw_target), 0])
b2_des_numerator = np.cross(b3_des, a_yaw)
b2_des = b2_des_numerator / np.linalg.norm(b2_des_numerator) if np.linalg.norm(b2_des_numerator) > 1e-6 else np.cross(b3_des, a_yaw)
b1_des = np.cross(b2_des, b3_des)
R_des = np.vstack([b1_des, b2_des, b3_des]).T
e_R_matrix = 0.5 * (R_des.T @ R - R.T @ R_des)
e_R = np.array([e_R_matrix[2, 1], e_R_matrix[0, 2], e_R_matrix[1, 0]])
e_omega = self.drone.angular_velocity
torques_cmd = -self.kp_att @ e_R - self.kd_att @ e_omega + np.cross(self.drone.angular_velocity, self.drone.I @ self.drone.angular_velocity)
return thrust_cmd, torques_cmd
# --- 핵심 기능 함수 ---
def generate_optimal_trajectory(waypoints: List[np.ndarray], durations: List[float], order: int = 7) -> PiecewisePolynomialTrajectory:
"""경유지들을 통과하는 최적의(Minimum Snap) 부드러운 궤적을 생성합니다."""
n_segments = len(waypoints) - 1
n_coeffs = order + 1
# QP 문제의 목적 함수 (p'Qp)를 위한 Q 행렬 구성
Q_total = np.zeros((n_segments * n_coeffs, n_segments * n_coeffs))
for i in range(n_segments):
Q_i = np.zeros((n_coeffs, n_coeffs))
T = durations[i]
for r in range(4, n_coeffs):
for c in range(4, n_coeffs):
k1, k2 = order - r, order - c
if k1 >= 4 and k2 >= 4:
d = (math.factorial(k1) / math.factorial(k1-4)) * (math.factorial(k2) / math.factorial(k2-4))
power = k1 + k2 - 7
if power > 0: Q_i[r, c] = 2 * d / power * (T**power)
Q_total[i*n_coeffs:(i+1)*n_coeffs, i*n_coeffs:(i+1)*n_coeffs] = Q_i
# 제약 조건 (Ap = b)을 위한 A, b 행렬 구성
def get_der_coeffs(k, t, order):
coeffs = np.zeros(order + 1)
for i in range(order + 1):
p = order - i
if p >= k: coeffs[i] = math.factorial(p)/math.factorial(p-k) * (t**(p-k))
return coeffs
n_derivatives = 4
n_constraints = 2 * n_derivatives + (n_segments - 1) * n_derivatives
A = np.zeros((n_constraints, n_segments * n_coeffs))
b = np.zeros((3, n_constraints))
row = 0
A[row, :n_coeffs] = get_der_coeffs(0, 0, order)
for dim in range(3): b[dim, row] = waypoints[0][dim]
row += 1
for k in range(1, n_derivatives): A[row, :n_coeffs] = get_der_coeffs(k, 0, order); row += 1
A[row, (n_segments-1)*n_coeffs:] = get_der_coeffs(0, durations[-1], order)
for dim in range(3): b[dim, row] = waypoints[-1][dim]
row += 1
for k in range(1, n_derivatives): A[row, (n_segments-1)*n_coeffs:] = get_der_coeffs(k, durations[-1], order); row += 1
for i in range(n_segments - 1):
T_i = durations[i]
A[row, i*n_coeffs:(i+1)*n_coeffs] = get_der_coeffs(0, T_i, order)
for dim in range(3): b[dim, row] = waypoints[i+1][dim]
row += 1
for k in range(1, n_derivatives):
A[row, i*n_coeffs:(i+1)*n_coeffs] = get_der_coeffs(k, T_i, order)
A[row, (i+1)*n_coeffs:(i+2)*n_coeffs] = -get_der_coeffs(k, 0, order)
row += 1
# 각 축(x, y, z)에 대해 QP 문제 풀이
polys_xyz = [[], [], []]
for i in range(3):
p = cp.Variable(n_segments * n_coeffs)
objective = cp.Minimize(cp.quad_form(p, Q_total + 1e-6 * np.eye(n_segments * n_coeffs)))
constraints = [A @ p == b[i, :]]
prob = cp.Problem(objective, constraints)
prob.solve(solver=cp.OSQP, verbose=False, max_iter=50000)
if prob.status not in ['optimal', 'optimal_inaccurate']:
raise ValueError(f"최적화 실패 (축: {i}, 상태: {prob.status})")
coeffs_all = p.value
for j in range(n_segments):
polys_xyz[i].append(Polynomial(coeffs_all[j*n_coeffs:(j+1)*n_coeffs]))
return PiecewisePolynomialTrajectory(polys_xyz, durations)
def plan_tsp_mission(waypoints_to_visit: List[np.ndarray], start_end_point: np.ndarray, max_speed: float) -> Tuple[List[np.ndarray], List[float]]:
"""최근접 이웃 알고리즘으로 여러 경유지의 최적 방문 순서와 비행 시간을 계획합니다."""
unvisited = list(waypoints_to_visit)
ordered_path = [start_end_point]
current_node = start_end_point
while unvisited:
distances = [np.linalg.norm(current_node - node) for node in unvisited]
nearest_index = np.argmin(distances)
current_node = unvisited.pop(nearest_index)
ordered_path.append(current_node)
ordered_path.append(start_end_point)
durations = [max(np.linalg.norm(p2 - p1) / max_speed, 3.0) for p1, p2 in zip(ordered_path[:-1], ordered_path[1:])]
return ordered_path, durations
def objective_function(gains: np.ndarray) -> float:
"""제어 게인 튜닝을 위한 목적 함수. 시뮬레이션 오차를 비용으로 반환합니다."""
drone = DroneDynamics(start_pos=START_POINT)
controller = GeometricController(drone, gains)
positions, angular_velocities = [], []
last_yaw_target = 0.0
for i in range(timesteps):
t = i * SIMULATION_TIMESTEP
pos_target, vel_target, acc_target = trajectory.get_state(t)
if np.linalg.norm(vel_target[:2]) > 0.1:
current_yaw_target = np.arctan2(vel_target[1], vel_target[0])
diff = current_yaw_target - last_yaw_target
if diff > np.pi: last_yaw_target += 2 * np.pi
elif diff < -np.pi: last_yaw_target -= 2 * np.pi
target_yaw = 0.9 * last_yaw_target + 0.1 * current_yaw_target
last_yaw_target = target_yaw
else:
target_yaw = last_yaw_target
thrust, torques = controller.update((pos_target, vel_target, acc_target), target_yaw, SIMULATION_TIMESTEP)
drone.update(thrust, torques, SIMULATION_TIMESTEP)
positions.append(drone.position.copy())
angular_velocities.append(drone.angular_velocity.copy())
cost_pos = np.mean((np.array(positions) - ref_path)**2)
cost_rot = np.mean(np.array(angular_velocities)**2)
total_cost = cost_pos + 0.01 * cost_rot
print(f"Cost: {total_cost:.6f} | Gains: {[f'{g:.2f}' for g in gains]}")
return total_cost
# --- 메인 실행 로직 ---
# 1. 임무 계획 및 궤적 생성
print("1. 임무 계획 및 궤적 생성 중...")
waypoints, durations = plan_tsp_mission(WAYPOINTS_TO_VISIT, START_POINT, MAX_SPEED)
trajectory = generate_optimal_trajectory(waypoints, durations)
sim_time = sum(durations)
timesteps = int(sim_time / SIMULATION_TIMESTEP)
ref_path = np.array([trajectory.get_state(i * SIMULATION_TIMESTEP)[0] for i in range(timesteps)])
# 2. 제어 게인 자동 튜닝
print("\n2. 제어 게인 자동 튜닝 시작...")
result = minimize(objective_function, INITIAL_GAINS, method='Nelder-Mead', bounds=GAIN_BOUNDS,
options={'disp': True, 'maxiter': OPTIMIZER_MAX_ITERATIONS})
best_gains = result.x
print(f"\n튜닝 완료! 최적 게인: {[f'{g:.3f}' for g in best_gains]}")
# 3. 최적 게인으로 최종 시뮬레이션 실행
print("\n3. 최적 게인으로 최종 시뮬레이션 실행 중...")
drone = DroneDynamics(start_pos=START_POINT)
controller = GeometricController(drone, gains=best_gains)
history = {'time': [], 'pos': [], 'rot': []}
last_yaw_target = 0.0
for i in range(timesteps):
t = i * SIMULATION_TIMESTEP
pos_target, vel_target, acc_target = trajectory.get_state(t)
if np.linalg.norm(vel_target[:2]) > 0.1:
current_yaw_target = np.arctan2(vel_target[1], vel_target[0])
diff = current_yaw_target - last_yaw_target
if diff > np.pi: last_yaw_target += 2 * np.pi
elif diff < -np.pi: last_yaw_target -= 2 * np.pi
target_yaw = 0.9 * last_yaw_target + 0.1 * current_yaw_target; last_yaw_target = target_yaw
else: target_yaw = last_yaw_target
thrust, torques = controller.update((pos_target, vel_target, acc_target), target_yaw, SIMULATION_TIMESTEP)
drone.update(thrust, torques, SIMULATION_TIMESTEP)
history['time'].append(t)
history['pos'].append(drone.position.copy())
history['rot'].append(drone.orientation.as_matrix())
# 4. 애니메이션 생성
print("\n4. 최종 애니메이션 생성 중...")
fig = plt.figure(figsize=(12, 10))
ax = fig.add_subplot(111, projection='3d')
body_size = 0.3
drone_body = np.array([[body_size, 0, 0], [-body_size, 0, 0], [0, body_size, 0], [0, -body_size, 0]]).T
all_points = np.array(waypoints)
padding = 3.0
ax_limits = [
(all_points[:,0].min() - padding, all_points[:,0].max() + padding),
(all_points[:,1].min() - padding, all_points[:,1].max() + padding),
(0, all_points[:,2].max() + padding)
]
def update_animation(i: int):
frame_idx = min(i * (timesteps // 500), len(history['pos']) - 1)
ax.cla()
pos_now, R_now = history['pos'][frame_idx], history['rot'][frame_idx]
drone_body_world = (R_now @ drone_body).T + pos_now
ax.plot(drone_body_world[:2,0], drone_body_world[:2,1], drone_body_world[:2,2], 'k-', lw=3)
ax.plot(drone_body_world[2:,0], drone_body_world[2:,1], drone_body_world[2:,2], 'k-', lw=3)
front_marker = (R_now @ np.array([body_size, 0, 0])).T + pos_now
ax.scatter(front_marker[0], front_marker[1], front_marker[2], color='red', s=40, label='Front')
ax.plot(np.array(history['pos'])[:frame_idx,0], np.array(history['pos'])[:frame_idx,1], np.array(history['pos'])[:frame_idx,2], 'b-', lw=2.5, label='실제 궤적')
ax.plot(ref_path[:,0], ref_path[:,1], ref_path[:,2], 'g--', lw=2, label='목표 궤적')
for j, wp in enumerate(waypoints):
ax.scatter(wp[0], wp[1], wp[2], s=150, marker='s' if j in [0, len(waypoints)-1] else 'o', label=f'경유지 {j}', depthshade=False)
ax.set(xlabel='X (m)', ylabel='Y (m)', zlabel='Z (m)',
title=f"자동 튜닝된 드론 임무 | 시간: {history['time'][frame_idx]:.2f}s",
xlim=ax_limits[0], ylim=ax_limits[1], zlim=ax_limits[2])
ax.legend()
ax.view_init(elev=30, azim=-110)
ani = FuncAnimation(fig, update_animation, frames=500, interval=40)
plt.close()
display(HTML(ani.to_html5_video()))
print("\n애니메이션 준비 완료.")