base_agent/uas/route/routes/air_route.py

541 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
空中航线AirRoute
─────────────────────────────────────────────────────────────────
继承 BaseRoute适配已有 FlightRoute 的实现逻辑。
FlightRoute 只需 `class FlightRoute(AirRoute): pass` 即可适配。
─────────────────────────────────────────────────────────────────
"""
from __future__ import annotations
import math
import os
import sys
from dataclasses import dataclass, field
from typing import List, Optional
from PyQt5.QtCore import QObject
from uas.route.base_params import AirParams
from uas.route.base_route import BaseRoute
from uas.route.base_segment import BaseSegment, BaseTransition, SegmentKind
from uas.route.base_waypoint import EquipmentDomain
from uas.route.routes.arinc424 import ARINC424Parser
from uas.utils.geo_utils import GeoUtils
DEG2RAD = math.pi / 180.0
RAD2DEG = 180.0 / math.pi
# ─────────────────────────────────────────────────────────────────
# 空中直线段
# ─────────────────────────────────────────────────────────────────
@dataclass
class AirSegment(BaseSegment):
"""
空中直线航段
完全兼容原 RouteSegment可直接替换。
"""
domain: EquipmentDomain = field(default=EquipmentDomain.AIR, repr=False)
kind: SegmentKind = field(default=SegmentKind.STRAIGHT, repr=False)
def query(self, t: float) -> AirParams:
return _air_seg_interpolate(self, t)
def interpolate(self, ratio: float) -> AirParams:
t = self.t_start + max(0.0, min(1.0, ratio)) * self.duration
return _air_seg_interpolate(self, t)
def validate(self) -> list:
errors = []
if self.distance < 0:
errors.append("距离不能为负")
if self.duration < 0:
errors.append("时长不能为负")
if self.v_start < 0 or self.v_end < 0:
errors.append("速度不能为负")
return errors
# ── 衍生属性(覆盖基类,使用 alt_start/alt_end 字段名)────────
@property
def alt_start(self) -> float:
return self.start_alt
@property
def alt_end(self) -> float:
return self.end_alt
@property
def avg_speed(self) -> float:
return (self.v_start + self.v_end) / 2.0
@property
def vertical_speed(self) -> float:
if self.duration < 1e-6:
return 0.0
return (self.end_alt - self.start_alt) / self.duration
def _air_seg_interpolate(seg: AirSegment, t: float) -> AirParams:
"""空中直线段内匀加速插值"""
dt = t - seg.t_start
ratio = dt / seg.duration if seg.duration > 1e-6 else 1.0
ratio = max(0.0, min(1.0, ratio))
# 匀加速位移s = v0*t + 0.5*a*t²
dist = seg.v_start * dt + 0.5 * seg.acceleration * dt * dt
dist = max(0.0, min(dist, seg.distance))
new_lat, new_lon = GeoUtils.offset_position(
seg.start_lat, seg.start_lon, seg.bearing, dist
)
speed = max(seg.v_start + seg.acceleration * dt, 0.0)
alt = seg.start_alt + (seg.end_alt - seg.start_alt) * ratio
vs = seg.vertical_speed
#TODO: 计算phase
# phase = determine_phase(...)
return AirParams(
timestamp = t,
latitude = new_lat,
longitude = new_lon,
altitude = alt,
heading = seg.bearing,
speed = speed,
acceleration = seg.acceleration,
airspeed = speed,
vertical_speed = vs,
# phase = phase,
domain = EquipmentDomain.AIR,
)
# ─────────────────────────────────────────────────────────────────
# 空中转弯过渡段Fly-by 圆弧)
# ─────────────────────────────────────────────────────────────────
@dataclass
class AirTurnSegment(BaseTransition):
"""
空中 Fly-by 圆弧转弯段
完全兼容原 TurnSegment可直接替换。
"""
turn_radius: float = 2000.0 # 转弯半径(米)
domain: EquipmentDomain = field(
default=EquipmentDomain.AIR, repr=False
)
kind: SegmentKind = field(
default=SegmentKind.TURN, repr=False
)
# 圆心坐标__post_init__ 计算)
_center_lat: float = field(default=0.0, init=False, repr=False)
_center_lon: float = field(default=0.0, init=False, repr=False)
_exit_lat: float = field(default=0.0, init=False, repr=False)
_exit_lon: float = field(default=0.0, init=False, repr=False)
def __post_init__(self):
self._compute_geometry()
def _compute_geometry(self):
"""计算圆弧圆心、出口坐标和时长"""
angle = self.turn_angle # 有符号转弯角(度)
r = self.turn_radius
# 圆心方向:转弯方向的垂直方向
if self.turn_direction >= 0:
center_bearing = (self.entry_heading + 90) % 360 # 右转
else:
center_bearing = (self.entry_heading - 90) % 360 # 左转
self._center_lat, self._center_lon = GeoUtils.offset_position(
self.entry_lat, self.entry_lon, center_bearing, r
)
# 出口坐标:圆心到出口的方向
if self.turn_direction >= 0:
exit_bearing = (center_bearing + 180 + angle) % 360
else:
exit_bearing = (center_bearing + 180 + angle) % 360
self._exit_lat, self._exit_lon = GeoUtils.offset_position(
self._center_lat, self._center_lon,
(exit_bearing + 180) % 360, r
)
# 圆弧时长
arc_len = abs(math.radians(angle)) * r
self.duration = arc_len / self.speed if self.speed > 1e-6 else 0.0
@property
def exit_lat(self) -> float:
return self._exit_lat
@property
def exit_lon(self) -> float:
return self._exit_lon
def query(self, t: float) -> AirParams:
"""圆弧段内按角速度插值"""
dt = t - self.t_start
ratio = dt / self.duration if self.duration > 1e-6 else 1.0
ratio = max(0.0, min(1.0, ratio))
# 已转过的角度
swept_angle = self.turn_angle * ratio
# 当前位置:从圆心出发,按已转角度计算
if self.turn_direction >= 0:
center_bearing = (self.entry_heading + 90) % 360
else:
center_bearing = (self.entry_heading - 90) % 360
entry_from_center = (center_bearing + 180) % 360
current_from_center = (entry_from_center + swept_angle) % 360
cur_lat, cur_lon = GeoUtils.offset_position(
self._center_lat, self._center_lon,
current_from_center, self.turn_radius
)
# 当前航向 = 入口航向 + 已转角度
cur_heading = (self.entry_heading + swept_angle) % 360
# 转弯率(°/s
turn_rate = (self.turn_angle / self.duration
if self.duration > 1e-6 else 0.0)
# 坡度角bank angle
g = 9.81
bank_rad = math.atan(
self.speed * abs(math.radians(turn_rate)) / g
)
bank_angle = math.degrees(bank_rad) * self.turn_direction
return AirParams(
timestamp = t,
latitude = cur_lat,
longitude = cur_lon,
altitude = self.altitude,
heading = cur_heading,
roll = bank_angle,
speed = self.speed,
airspeed = self.speed,
vertical_speed = 0.0,
turn_rate = turn_rate,
turn_radius = self.turn_radius,
bank_angle = bank_angle,
phase = "CRUISE",
domain = EquipmentDomain.AIR,
)
def validate(self) -> list:
errors = []
if self.turn_radius <= 0:
errors.append(f"转弯半径必须为正: {self.turn_radius}")
if self.speed <= 0:
errors.append(f"速度必须为正: {self.speed}")
return errors
# ─────────────────────────────────────────────────────────────────
# 空中航线AirRoute
# ─────────────────────────────────────────────────────────────────
class AirRoute(BaseRoute):
"""
空中飞行航线
实现 BaseRoute 所有抽象方法,
算法逻辑与原 FlightRoute._rebuild() 完全一致。
适配方式:
原 FlightRoute 只需改为:
class FlightRoute(AirRoute):
pass
即可纳入 BaseRoute 继承体系,无需其他改动。
"""
def __init__(
self,
start_time: float = 0.0,
default_turn_radius: float = 2000.0,
min_turn_radius: float = 500.0,
parent: QObject = None,
):
super().__init__(start_time=start_time, parent=parent)
self.default_turn_radius = default_turn_radius
self.min_turn_radius = min_turn_radius
# ── 域标识 ────────────────────────────────────────────────────
@property
def domain(self) -> EquipmentDomain:
return EquipmentDomain.AIR
# ── 核心重建算法 ──────────────────────────────────────────────
def _rebuild(self) -> None:
"""
重建所有直线段和圆弧转弯段
算法与原 FlightRoute._rebuild() 完全一致
"""
self._segments.clear()
self._transitions.clear()
n = len(self._waypoints)
if n < 2:
self.total_duration = 0.0
return
# Step 1预计算所有段方位角
bearings: List[float] = []
for i in range(n - 1):
brg = GeoUtils.bearing(
self._waypoints[i].latitude, self._waypoints[i].longitude,
self._waypoints[i+1].latitude, self._waypoints[i+1].longitude,
)
bearings.append(brg)
t_cursor = self.start_time
prev_exit_lat = self._waypoints[0].latitude
prev_exit_lon = self._waypoints[0].longitude
# Step 2-5逐段构建
for i in range(n - 1):
wp_a = self._waypoints[i]
wp_b = self._waypoints[i + 1]
brg_in = bearings[i]
brg_out = bearings[i + 1] if (i + 1) < len(bearings) else brg_in
# 确定转弯半径
r = getattr(wp_b, "turn_radius", 0.0)
if r <= 0:
r = self.default_turn_radius
r = max(r, self.min_turn_radius)
# 转弯角
turn_angle = GeoUtils.heading_diff(brg_in, brg_out)
# 判断是否需要转弯
fly_over = getattr(wp_b, "fly_over", False)
need_turn = (
i < n - 2
and not fly_over
and abs(turn_angle) > 0.5
)
# 计算 TIP提前转弯点
if need_turn:
tip_dist_m = r * abs(math.tan(math.radians(turn_angle / 2)))
tip_lat, tip_lon = GeoUtils.offset_position(
wp_b.latitude, wp_b.longitude,
(brg_in + 180) % 360, tip_dist_m
)
# 安全检查TIP 不能超过航路点
seg_total_dist = GeoUtils.haversine(
prev_exit_lat, prev_exit_lon,
wp_b.latitude, wp_b.longitude,
)
tip_check_dist = GeoUtils.haversine(
wp_b.latitude, wp_b.longitude,
tip_lat, tip_lon,
)
if tip_check_dist > seg_total_dist * 0.95:
need_turn = False
tip_lat, tip_lon = wp_b.latitude, wp_b.longitude
else:
tip_lat, tip_lon = wp_b.latitude, wp_b.longitude
# 构建直线段
seg_dist = GeoUtils.haversine(
prev_exit_lat, prev_exit_lon, tip_lat, tip_lon
)
v0 = wp_a.speed
v1 = wp_b.speed
v_avg = (v0 + v1) / 2.0
if v_avg < 1e-6:
v_avg = 1.0
duration = seg_dist / v_avg if v_avg > 1e-6 else 0.0
acc = (v1 - v0) / duration if duration > 1e-6 else 0.0
seg = self._make_segment(
wp_from = wp_a,
wp_to = wp_b,
start_lat = prev_exit_lat,
start_lon = prev_exit_lon,
start_alt = wp_a.altitude,
end_lat = tip_lat,
end_lon = tip_lon,
end_alt = wp_b.altitude,
bearing = brg_in,
t_start = t_cursor,
v_start = v0,
v_end = v1,
acc = acc,
dist = seg_dist,
duration = duration,
)
self._segments.append(seg)
t_cursor += duration
# 构建转弯段
if need_turn:
trans = self._make_transition(
entry_lat = tip_lat,
entry_lon = tip_lon,
entry_alt = wp_b.altitude,
entry_heading = brg_in,
exit_heading = brg_out,
speed = wp_b.speed,
altitude = wp_b.altitude,
t_start = t_cursor,
turn_radius = r,
)
self._transitions.append(trans)
if trans is not None:
t_cursor += trans.duration
prev_exit_lat = trans.exit_lat
prev_exit_lon = trans.exit_lon
else:
prev_exit_lat = tip_lat
prev_exit_lon = tip_lon
else:
self._transitions.append(None)
prev_exit_lat = tip_lat
prev_exit_lon = tip_lon
self._update_total_duration()
# ── 工厂方法 ──────────────────────────────────────────────────
def _make_segment(
self,
wp_from, wp_to,
start_lat, start_lon, start_alt,
end_lat, end_lon, end_alt,
bearing, t_start,
v_start=0.0, v_end=0.0, acc=0.0,
dist=0.0, duration=0.0,
**kwargs,
) -> AirSegment:
return AirSegment(
wp_from = wp_from,
wp_to = wp_to,
start_lat = start_lat,
start_lon = start_lon,
start_alt = start_alt,
end_lat = end_lat,
end_lon = end_lon,
end_alt = end_alt,
distance = dist,
bearing = bearing,
t_start = t_start,
t_end = t_start + duration,
duration = duration,
v_start = v_start,
v_end = v_end,
acceleration = acc,
)
def _make_transition(
self,
entry_lat, entry_lon, entry_alt,
entry_heading, exit_heading,
speed, altitude, t_start,
turn_radius=2000.0,
**kwargs,
) -> Optional[AirTurnSegment]:
return AirTurnSegment(
entry_lat = entry_lat,
entry_lon = entry_lon,
entry_alt = entry_alt,
entry_heading = entry_heading,
exit_heading = exit_heading,
speed = speed,
altitude = altitude,
t_start = t_start,
turn_radius = turn_radius,
)
def _interpolate_segment(
self, seg: BaseSegment, t: float
) -> AirParams:
return _air_seg_interpolate(seg, t) # type: ignore[arg-type]
def _state_at_start(self) -> AirParams:
seg = self._segments[0]
wp = self._waypoints[0]
vs = seg.vertical_speed
return AirParams(
timestamp = self.t_start,
latitude = seg.start_lat,
longitude = seg.start_lon,
altitude = seg.start_alt,
heading = seg.bearing,
speed = seg.v_start,
airspeed = seg.v_start,
vertical_speed = vs,
# phase = determine_phase(
# seg.start_alt, seg.end_alt, vs
# ),
domain = EquipmentDomain.AIR,
)
def _state_at_end(self) -> AirParams:
last_trans = self._transitions[-1] if self._transitions else None
if last_trans is not None:
s = last_trans.query(last_trans.t_end)
s.timestamp = self.t_end
s.phase = "LAND"
return s
last_seg = self._segments[-1]
return AirParams(
timestamp = self.t_end,
latitude = last_seg.end_lat,
longitude = last_seg.end_lon,
altitude = last_seg.end_alt,
heading = last_seg.bearing,
speed = last_seg.v_end,
airspeed = last_seg.v_end,
vertical_speed = 0.0,
phase = "LAND",
domain = EquipmentDomain.AIR,
)
# ─────────────────────────────────────────────────────────────────
# FlightRoute 适配(最小改动)
# ─────────────────────────────────────────────────────────────────
class FlightRoute(AirRoute):
"""
原 FlightRoute 适配类
继承 AirRoute保持与原有代码的完全兼容。
原有代码中所有对 FlightRoute 的引用无需修改。
"""
@staticmethod
def load_from_arinc424(route_file, start_time: float = 0.0,
default_turn_radius: float = 2000.0,
min_turn_radius: float = 500.0,) -> FlightRoute:
"""加载航线:优先从文件,否则使用内置示例"""
parser = ARINC424Parser(default_speed_ms=220.0, default_altitude_m=10000.0)
if not os.path.isfile(route_file):
print(f"[错误] 航线文件不存在: {route_file}", file=sys.stderr)
sys.exit(1)
with open(route_file, "r", encoding="utf-8") as f:
content = f.read()
ext = os.path.splitext(route_file)[1].lower()
if ext == ".csv":
waypoints = parser.parse_csv(content)
else:
waypoints = parser.parse_arinc424(content)
print(f" [航线] 已从文件加载: {route_file}{len(waypoints)} 个航路点)")
route = FlightRoute(start_time=start_time,
default_turn_radius=default_turn_radius,
min_turn_radius=min_turn_radius)
route.add_waypoints(waypoints)
return route