base_agent/uas/route/base_route.py

565 lines
21 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 -*-
"""
航线抽象基类(核心)
─────────────────────────────────────────────────────────────────
BaseRoute —— 所有具体航线类型的统一抽象基类
继承体系:
BaseRouteABC
├── AirRoute 空中飞行航线(适配 FlightRoute
├── GroundRoute 陆地航线
└── WaterRoute 水中航线
设计原则:
1. 开闭原则:新增航线类型只需继承 BaseRoute实现抽象方法
2. 接口隔离:公共接口定义在基类,特有接口在子类扩展
3. 里氏替换:所有子类实例均可作为 BaseRoute 使用
4. PyQt5 兼容:继承 QObject 以支持信号槽体系
─────────────────────────────────────────────────────────────────
"""
from __future__ import annotations
import logging
import time
from abc import abstractmethod
from typing import Iterator, List, Optional, Tuple
from PyQt5.QtCore import QObject, pyqtSignal
from uas.route.base_params import BaseParams
from uas.route.base_segment import BaseSegment, BaseTransition
from uas.route.base_waypoint import BaseWaypoint, EquipmentDomain
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────
# 航线元数据
# ─────────────────────────────────────────────────────────────────
class RouteMetadata:
"""航线附加元数据(非必须,按需使用)"""
def __init__(self):
self.name: str = ""
self.description: str = ""
self.author: str = ""
self.created_at: float = time.time()
self.tags: list = []
self.extra: dict = {}
# ─────────────────────────────────────────────────────────────────
# 航线抽象基类
# ─────────────────────────────────────────────────────────────────
class BaseRoute(QObject):
"""
航线公共抽象基类
── 内部数据结构(交替排列)────────────────────────────────────
segments[0] → 直线段 0
transitions[0] → 过渡段 0可为 None
segments[1] → 直线段 1
transitions[1] → 过渡段 1可为 None
...
segments[N-1] → 最后直线段(无后续过渡段)
── 时间轴────────────────────────────────────────────────────
t0─[seg0]─t1─[trans0]─t2─[seg1]─t3─[trans1]─t4─...
── 子类必须实现的抽象方法 ────────────────────────────────────
_rebuild() 重建所有段(核心算法)
_make_segment(...) 构造具体 Segment 实例
_make_transition(...) 构造具体 Transition 实例(可返回 None
_interpolate_segment(...) 段内插值
_state_at_start() 起点状态
_state_at_end() 终点状态
domain 属性:返回 EquipmentDomain
"""
# ── PyQt5 信号 ────────────────────────────────────────────────
route_changed = pyqtSignal() # 航线发生变化(重建后)
waypoint_added = pyqtSignal(object) # 新增路径点BaseWaypoint
route_cleared = pyqtSignal() # 航线被清空
def __init__(
self,
start_time: float = 0.0,
parent: QObject = None,
):
super().__init__(parent)
self.start_time: float = start_time
self.total_duration: float = 0.0
self.metadata: RouteMetadata = RouteMetadata()
# 内部数据(子类通过 _rebuild() 填充)
self._waypoints: List[BaseWaypoint] = []
self._segments: List[BaseSegment] = []
self._transitions: List[Optional[BaseTransition]] = []
self.logger = logging.getLogger(self.__class__.__name__)
# ── 公共属性(只读视图)──────────────────────────────────────
@property
def waypoints(self) -> List[BaseWaypoint]:
return list(self._waypoints)
@property
def segments(self) -> List[BaseSegment]:
return list(self._segments)
@property
def transitions(self) -> List[Optional[BaseTransition]]:
return list(self._transitions)
@property
def waypoint_count(self) -> int:
return len(self._waypoints)
@property
def segment_count(self) -> int:
return len(self._segments)
@property
def t_start(self) -> float:
return self.start_time
@property
def t_end(self) -> float:
return self.start_time + self.total_duration
@property
def is_empty(self) -> bool:
return len(self._segments) == 0
@property
def total_distance(self) -> float:
"""总水平距离(米)"""
return sum(s.distance for s in self._segments)
@property
def total_transition_time(self) -> float:
"""所有过渡段总时长(秒)"""
return sum(
t.duration for t in self._transitions if t is not None
)
# ── 抽象属性 ──────────────────────────────────────────────────
@property
@abstractmethod
def domain(self) -> EquipmentDomain:
"""返回本航线所属的装备域"""
...
# ── 路径点管理(公共接口)────────────────────────────────────
def add_waypoint(self, wp: BaseWaypoint) -> "BaseRoute":
"""
添加单个路径点
:return: self支持链式调用
"""
self._validate_waypoint_domain(wp)
self._waypoints.append(wp)
self.waypoint_added.emit(wp)
if len(self._waypoints) >= 2:
self._rebuild()
self.route_changed.emit()
self.logger.debug(f"添加路径点: {wp.identifier}")
return self
def add_waypoints(self, wps: List[BaseWaypoint]) -> "BaseRoute":
"""
批量添加路径点(一次性重建,效率更高)
:return: self支持链式调用
"""
for wp in wps:
self._validate_waypoint_domain(wp)
self._waypoints.extend(wps)
if len(self._waypoints) >= 2:
self._rebuild()
self.route_changed.emit()
self.logger.debug(f"批量添加 {len(wps)} 个路径点")
return self
def load_waypoints(self, wps: List[BaseWaypoint]) -> "BaseRoute":
"""
替换全部路径点(先清空再加载)
:return: self支持链式调用
"""
self._waypoints = list(wps)
for wp in self._waypoints:
self._validate_waypoint_domain(wp)
if len(self._waypoints) >= 2:
self._rebuild()
self.route_changed.emit()
return self
def remove_waypoint(self, index: int) -> "BaseRoute":
"""
移除指定索引的路径点
:return: self支持链式调用
"""
if 0 <= index < len(self._waypoints):
removed = self._waypoints.pop(index)
self.logger.debug(f"移除路径点: {removed.identifier}")
if len(self._waypoints) >= 2:
self._rebuild()
else:
self._segments.clear()
self._transitions.clear()
self.total_duration = 0.0
self.route_changed.emit()
return self
def replace_waypoint(self, index: int,
wp: BaseWaypoint) -> "BaseRoute":
"""
替换指定索引的路径点
:return: self支持链式调用
"""
self._validate_waypoint_domain(wp)
if 0 <= index < len(self._waypoints):
self._waypoints[index] = wp
if len(self._waypoints) >= 2:
self._rebuild()
self.route_changed.emit()
return self
def clear(self) -> "BaseRoute":
"""
清空航线(路径点 + 所有段)
:return: self支持链式调用
"""
self._waypoints.clear()
self._segments.clear()
self._transitions.clear()
self.total_duration = 0.0
self.route_cleared.emit()
self.logger.debug("航线已清空")
return self
# ── 查询接口(公共)──────────────────────────────────────────
def query(self, t: float) -> BaseParams:
"""
查询时刻 t 的装备精确状态
时间轴遍历seg[0]→trans[0]→seg[1]→trans[1]→...→seg[N-1]
边界处理t < t_start → 起点状态t > t_end → 终点状态
"""
if self.is_empty:
raise ValueError(
f"{self.__class__.__name__}: 航线为空,"
f"请先添加至少两个路径点"
)
# 边界处理
if t <= self._segments[0].t_start:
return self._state_at_start()
if t >= self.t_end:
return self._state_at_end()
# 遍历所有段
for i, seg in enumerate(self._segments):
if seg.contains_time(t):
return self._interpolate_segment(seg, t)
if i < len(self._transitions):
trans = self._transitions[i]
if trans is not None and trans.contains_time(t):
return trans.query(t)
# 兜底(理论上不会到达)
return self._state_at_end()
def query_range(
self,
t_from: float,
t_to: float,
dt: float = 1.0,
) -> List[BaseParams]:
"""
查询时间范围内的状态序列
:param t_from: 起始时刻
:param t_to: 结束时刻
:param dt: 采样间隔(秒)
"""
results = []
t = t_from
while t <= t_to + 1e-9:
results.append(self.query(t))
t += dt
return results
def find_segment_at(
self, t: float
) -> Tuple[Optional[BaseSegment], Optional[BaseTransition]]:
"""
查找 t 时刻所在的段
:return: (BaseSegment, None) 或 (None, BaseTransition)
"""
for i, seg in enumerate(self._segments):
if seg.contains_time(t):
return seg, None
if i < len(self._transitions):
trans = self._transitions[i]
if trans is not None and trans.contains_time(t):
return None, trans
return None, None
def iter_segments(
self,
) -> Iterator[Tuple[BaseSegment, Optional[BaseTransition]]]:
"""
迭代所有 (直线段, 后续过渡段) 对
:yields: (BaseSegment, Optional[BaseTransition])
"""
for i, seg in enumerate(self._segments):
trans = (self._transitions[i]
if i < len(self._transitions) else None)
yield seg, trans
# ── 轨迹生成(公共)──────────────────────────────────────────
def generate_trajectory(
self, dt: float = 1.0
) -> List[BaseParams]:
"""
按均匀时间步长采样完整轨迹
:param dt: 采样间隔(秒)
:return: BaseParams 列表(含起点和终点)
"""
if self.is_empty:
return []
trajectory: List[BaseParams] = []
t = self.t_start
while t <= self.t_end + 1e-9:
trajectory.append(self.query(t))
t += dt
# 确保终点被包含
if trajectory and trajectory[-1].timestamp < self.t_end - 1e-9:
trajectory.append(self.query(self.t_end))
return trajectory
def generate_trajectory_by_distance(
self, step_m: float = 1000.0
) -> List[BaseParams]:
"""
按距离步长采样轨迹(适用于地图绘制)
:param step_m: 采样距离间隔(米)
"""
if self.is_empty:
return []
trajectory: List[BaseParams] = []
accumulated_dist: float = 0.0
next_sample_dist: float = 0.0
prev_state: Optional[BaseParams] = None
dt = 0.5
t = self.t_start
while t <= self.t_end + 1e-9:
state = self.query(t)
if prev_state is not None:
d = state.distance_to(prev_state)
accumulated_dist += d
if accumulated_dist >= next_sample_dist:
trajectory.append(state)
next_sample_dist += step_m
else:
trajectory.append(state) # 起点
prev_state = state
t += dt
return trajectory
# ── 统计与摘要(公共)────────────────────────────────────────
def statistics(self) -> dict:
"""返回航线统计数据字典"""
if self.is_empty:
return {}
segs = self._segments
return {
"domain": self.domain.value,
"waypoint_count": len(self._waypoints),
"segment_count": len(segs),
"transition_count": sum(1 for t in self._transitions
if t is not None),
"total_distance_km": self.total_distance / 1000,
"total_duration_s": self.total_duration,
"total_duration_min": self.total_duration / 60,
"transition_time_s": self.total_transition_time,
"climb_distance_km": sum(s.distance for s in segs
if s.is_climb) / 1000,
"descent_distance_km": sum(s.distance for s in segs
if s.is_descent) / 1000,
"level_distance_km": sum(s.distance for s in segs
if s.is_level) / 1000,
"max_altitude_m": max(max(s.start_alt, s.end_alt)
for s in segs),
"min_altitude_m": min(min(s.start_alt, s.end_alt)
for s in segs),
"avg_speed_ms": (
sum(s.avg_speed * s.duration for s in segs) /
sum(s.duration for s in segs)
if segs else 0.0
),
}
def info(self) -> str:
"""返回航线完整摘要字符串"""
if self.is_empty:
return f"{self.__class__.__name__}: 航线为空"
st = self.statistics()
lines = [
f"{''*65}",
f" {self.__class__.__name__} 航线概览 [{self.domain.value}]",
f"{''*65}",
f" 路径点数 : {st['waypoint_count']}",
f" 直线段数 : {st['segment_count']}",
f" 过渡段数 : {st['transition_count']}",
f" 总距离 : {st['total_distance_km']:.3f} km",
f" 总时长 : {st['total_duration_s']:.1f}s "
f"({st['total_duration_min']:.2f} min)",
f" 平均速度 : {st['avg_speed_ms']:.1f} m/s",
f" 高度范围 : {st['min_altitude_m']:.0f}m ~ "
f"{st['max_altitude_m']:.0f}m",
f"{''*65}",
f" {'#':>3} {'起点':>6}{'终点':<6} "
f"{'距离(km)':>9} {'方位(°)':>8} "
f"{'时长(s)':>8} {'速度(m/s)':>12}",
f"{''*65}",
]
for i, (seg, trans) in enumerate(self.iter_segments()):
lines.append(
f" {i+1:>3} "
f"{seg.wp_from.identifier:>6}{seg.wp_to.identifier:<6} "
f"{seg.distance/1000:>9.3f} "
f"{seg.bearing:>8.1f} "
f"{seg.duration:>8.1f} "
f"{seg.v_start:>5.1f}{seg.v_end:<5.1f}"
)
if trans is not None:
dir_str = "右转" if trans.turn_direction > 0 else "左转"
lines.append(
f"{dir_str}"
f"{abs(trans.turn_angle):.1f}° "
f"({trans.duration:.1f}s)"
)
lines.append(f"{''*65}")
return "\n".join(lines)
def validate(self) -> list:
"""
校验整条航线合法性
:return: 错误信息列表(空列表=合法)
"""
errors = []
if len(self._waypoints) < 2:
errors.append("路径点数量不足至少需要2个")
for i, wp in enumerate(self._waypoints):
wp_errors = wp.validate()
for e in wp_errors:
errors.append(f"路径点[{i}] {wp.identifier}: {e}")
for i, seg in enumerate(self._segments):
seg_errors = seg.validate()
for e in seg_errors:
errors.append(f"航段[{i}]: {e}")
return errors
# ── 抽象方法(子类必须实现)──────────────────────────────────
@abstractmethod
def _rebuild(self) -> None:
"""
重建所有直线段和过渡段(核心算法)
实现要求:
1. 清空 _segments 和 _transitions
2. 遍历 _waypoints逐段构建 BaseSegment
3. 在需要转弯处插入 BaseTransition
4. 更新 total_duration
"""
...
@abstractmethod
def _make_segment(
self,
wp_from: BaseWaypoint,
wp_to: BaseWaypoint,
start_lat: float, start_lon: float, start_alt: float,
end_lat: float, end_lon: float, end_alt: float,
bearing: float,
t_start: float,
) -> BaseSegment:
"""
构造具体 Segment 实例(工厂方法)
子类返回对应域的 Segment 子类实例
"""
...
@abstractmethod
def _make_transition(
self,
entry_lat: float, entry_lon: float, entry_alt: float,
entry_heading: float, exit_heading: float,
speed: float, altitude: float,
t_start: float,
) -> Optional[BaseTransition]:
"""
构造具体 Transition 实例(工厂方法)
子类返回对应域的 Transition 子类实例,
若不需要过渡段则返回 None
"""
...
@abstractmethod
def _interpolate_segment(
self, seg: BaseSegment, t: float
) -> BaseParams:
"""
在直线段内对 t 时刻进行插值
子类实现具体的运动学模型(匀速/匀加速等)
"""
...
@abstractmethod
def _state_at_start(self) -> BaseParams:
"""返回航线起点状态"""
...
@abstractmethod
def _state_at_end(self) -> BaseParams:
"""返回航线终点状态"""
...
# ── 内部工具 ──────────────────────────────────────────────────
def _validate_waypoint_domain(self, wp: BaseWaypoint) -> None:
"""
校验路径点域与航线域是否匹配
宽松检查domain=AIR 的基类 WP 可用于任何航线)
"""
pass # 子类可覆盖以实现严格检查
def _update_total_duration(self) -> None:
"""根据所有段重新计算 total_duration"""
if not self._segments:
self.total_duration = 0.0
return
seg_time = sum(s.duration for s in self._segments)
trans_time = sum(
t.duration for t in self._transitions if t is not None
)
self.total_duration = seg_time + trans_time
def __repr__(self) -> str:
return (f"{self.__class__.__name__}("
f"domain={self.domain.value}, "
f"wps={len(self._waypoints)}, "
f"segs={len(self._segments)}, "
f"dist={self.total_distance/1000:.1f}km, "
f"dur={self.total_duration:.0f}s)")
class FlightRoute(BaseRoute):
pass