Files
chvt-bak/module5/module5.py
T
2026-09-09 16:31:27 +03:00

922 lines
37 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.
#!/usr/bin/env python3
"""Модуль Д: один скрипт для складского кейса RMC1 + RMC2."""
import argparse
import json
import math
import os
import sys
import threading
import time
from collections import deque
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from tempfile import NamedTemporaryFile
from types import SimpleNamespace
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
ROWS = 6
COLS = 6
MARKER_SPACING = 1.0
ROBOT_NAMES = ("RMC1", "RMC2")
def clamp(value, limit):
return max(-limit, min(limit, value))
def wrap_angle(value):
return math.atan2(math.sin(value), math.cos(value))
def marker_xy(marker_id):
"""Координаты поля: 0=(0,0), 6=(-1,0), 1=(0,1)."""
row, column = divmod(marker_id, COLS)
return -row * MARKER_SPACING, column * MARKER_SPACING
def neighbours(marker_id):
row, column = divmod(marker_id, COLS)
result = []
for dr, dc in ((-1, 0), (0, -1), (0, 1), (1, 0)):
nr, nc = row + dr, column + dc
if 0 <= nr < ROWS and 0 <= nc < COLS:
result.append(nr * COLS + nc)
return result
def shortest_path(start, goal, blocked=()):
"""BFS по регулярному графу ArUco."""
forbidden = set(blocked) - {start, goal}
queue = deque([start])
previous = {start: None}
while queue:
current = queue.popleft()
if current == goal:
break
for candidate in neighbours(current):
if candidate not in forbidden and candidate not in previous:
previous[candidate] = current
queue.append(candidate)
if goal not in previous:
raise ValueError(f"Нет маршрута {start} -> {goal}; занято: {sorted(forbidden)}")
route = []
current = goal
while current is not None:
route.append(current)
current = previous[current]
return list(reversed(route))
def face_marker(position, target):
x1, y1 = marker_xy(position)
x2, y2 = marker_xy(target)
return math.atan2(y2 - y1, x2 - x1)
def parse_markers(value):
if not value.strip():
return set()
try:
return {int(item.strip()) for item in value.split(",")}
except ValueError as error:
raise argparse.ArgumentTypeError("Нужны ID через запятую, например 31,35") from error
class EventLog:
def __init__(self):
directory = HERE / "logs"
directory.mkdir(parents=True, exist_ok=True)
self.path = directory / f"module5_{datetime.now():%Y%m%d_%H%M%S}.jsonl"
self.lock = threading.Lock()
def write(self, event, **fields):
record = {"time": datetime.now().isoformat(timespec="milliseconds"), "event": event}
record.update(fields)
line = json.dumps(record, ensure_ascii=False)
with self.lock:
print(line, flush=True)
with self.path.open("a", encoding="utf-8") as stream:
stream.write(line + "\n")
stream.flush()
os.fsync(stream.fileno())
@dataclass
class Scenario:
rmc1_start: int
rmc2_start: int
rack: int
assembly: int
assembly_approach: int
delivery: int
delivery_approach: int
blocked: set
def validate(self):
points = {
"rmc1-start": self.rmc1_start,
"rmc2-start": self.rmc2_start,
"rack": self.rack,
"assembly": self.assembly,
"assembly-approach": self.assembly_approach,
"delivery": self.delivery,
"delivery-approach": self.delivery_approach,
}
for name, marker in points.items():
if marker not in range(ROWS * COLS):
raise ValueError(f"{name}: ID {marker} вне диапазона 0..35")
if self.assembly_approach not in neighbours(self.assembly):
raise ValueError("assembly-approach должен быть соседней ячейкой")
if self.delivery_approach not in neighbours(self.delivery):
raise ValueError("delivery-approach должен быть соседней ячейкой")
if self.assembly in self.blocked or self.rack in self.blocked:
raise ValueError("Активный стеллаж и точка комплектации не должны быть в --blocked")
if self.rack == self.assembly:
raise ValueError("Стеллаж и точка комплектации должны отличаться")
def routes(self):
# Полка сдачи и чужие стеллажи считаются препятствиями для RMC2.
rmc2_blocked = self.blocked | {self.delivery}
# Для RMC1 стеллаж уже стоит в комплектации, а полка занята физически.
rmc1_blocked = self.blocked | {self.assembly, self.delivery}
to_assembly = shortest_path(self.rack, self.assembly, rmc2_blocked)
parking = to_assembly[-2]
return {
"rmc2_to_rack": shortest_path(self.rmc2_start, self.rack, rmc2_blocked),
"rmc2_to_assembly": to_assembly,
"rmc2_clear_assembly": [self.assembly, parking],
"rmc2_back_to_assembly": [parking, self.assembly],
"rmc1_to_assembly": shortest_path(
self.rmc1_start, self.assembly_approach, rmc1_blocked
),
"rmc1_to_delivery": shortest_path(
self.assembly_approach, self.delivery_approach, rmc1_blocked
),
"rmc2_return_rack": shortest_path(self.assembly, self.rack, rmc2_blocked),
"rmc1_return": shortest_path(
self.delivery_approach, self.rmc1_start, rmc1_blocked
),
"rmc2_return": shortest_path(self.rack, self.rmc2_start, rmc2_blocked),
}
@dataclass
class Anchor:
world_x: float
world_y: float
world_yaw: float
odom_x: float
odom_y: float
odom_yaw: float
def to_odom(self, world_x, world_y, world_yaw):
dx, dy = world_x - self.world_x, world_y - self.world_y
rotation = self.odom_yaw - self.world_yaw
cosine, sine = math.cos(rotation), math.sin(rotation)
return (
self.odom_x + cosine * dx - sine * dy,
self.odom_y + sine * dx + cosine * dy,
wrap_angle(self.odom_yaw + world_yaw - self.world_yaw),
)
def arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", help="1/hammer, 2/wrench, 3/pliers")
parser.add_argument("--rmc1-start", type=int, default=15)
parser.add_argument("--rmc2-start", type=int, default=0)
parser.add_argument("--rack", type=int, default=34)
parser.add_argument("--assembly", type=int, default=16)
parser.add_argument("--assembly-approach", type=int, default=10)
parser.add_argument("--delivery", type=int, default=5)
parser.add_argument("--delivery-approach", type=int, default=4)
parser.add_argument("--blocked", type=parse_markers, default={31, 35})
parser.add_argument("--rmc1-start-yaw", type=float, default=math.pi)
parser.add_argument("--rmc2-start-yaw", type=float, default=math.pi)
parser.add_argument("--rack-yaw", type=float, default=math.pi)
parser.add_argument("--auto", action="store_true", help="Не ждать Enter перед стартом")
parser.add_argument("--dry-run", action="store_true", help="Показать сценарий без ROS")
parser.add_argument("--skip-arm", action="store_true", help="Только роверы и лифт")
parser.add_argument("--weights", default="module3/models/latest.pt")
parser.add_argument(
"--camera-topic", default="/RMC1/arm95/camera_gripper/image_color"
)
parser.add_argument("--conf", type=float, default=0.20)
parser.add_argument("--vision-timeout", type=float, default=60.0)
parser.add_argument("--plane-z", type=float, default=0.128)
parser.add_argument("--pick-z", type=float, default=0.210)
parser.add_argument("--approach-z", type=float, default=0.38)
parser.add_argument("--drop-arm-x", type=float, default=0.55)
parser.add_argument("--drop-arm-y", type=float, default=0.0)
parser.add_argument("--drop-arm-z", type=float, default=0.220)
parser.add_argument("--drop-arm-yaw", type=float, default=math.pi / 2)
parser.add_argument("--sensor-timeout", type=float, default=3.0)
parser.add_argument("--waypoint-timeout", type=float, default=35.0)
args = parser.parse_args()
if not 0.0 <= args.conf <= 1.0:
parser.error("--conf должен быть от 0 до 1")
if args.approach_z <= max(args.pick_z, args.drop_arm_z):
parser.error("--approach-z должен быть выше pick/drop")
if min(args.sensor_timeout, args.waypoint_timeout, args.vision_timeout) <= 0:
parser.error("таймауты должны быть положительными")
return args
def make_scenario(args):
scenario = Scenario(
args.rmc1_start,
args.rmc2_start,
args.rack,
args.assembly,
args.assembly_approach,
args.delivery,
args.delivery_approach,
set(args.blocked),
)
scenario.validate()
return scenario
def print_plan(scenario, routes, target):
print("\n=== МОДУЛЬ Д: ПЛАН ДО ЗАПУСКА ===")
print(f"Целевая деталь: {target}")
print(
f"RMC2: {scenario.rmc2_start} -> стеллаж {scenario.rack} -> "
f"комплектация {scenario.assembly} -> {scenario.rack} -> {scenario.rmc2_start}"
)
print(
f"RMC1: {scenario.rmc1_start} -> подход {scenario.assembly_approach} -> "
f"сдача через {scenario.delivery_approach} -> {scenario.rmc1_start}"
)
for name, route in routes.items():
print(f"{name}: {route}")
print("Приоритет: STOP -> безопасность -> точность -> скорость")
print("Команды: stop, resume, abort. Внешний стоп: /mvch/module5/emergency_stop")
print("====================================\n")
def build_ros_node(args, event_log):
import rclpy
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import LaserScan
from std_msgs.msg import Bool, Float64, String
if not rclpy.ok():
rclpy.init()
class WarehouseNode(Node):
def __init__(self):
super().__init__("mvch_module5")
self.lock = threading.Lock()
self.poses = {name: None for name in ROBOT_NAMES}
self.odom_seen = {name: 0.0 for name in ROBOT_NAMES}
self.front_distance = {name: math.inf for name in ROBOT_NAMES}
self.scan_seen = {name: 0.0 for name in ROBOT_NAMES}
self.last_command = {name: (0.0, 0.0, 0.0, time.monotonic()) for name in ROBOT_NAMES}
self.emergency = threading.Event()
self.abort = threading.Event()
self.lift_status = "unknown"
self.lift_updates = 0
self.cmd_publishers = {
name: self.create_publisher(Twist, f"/{name}/cmd_vel", 10)
for name in ROBOT_NAMES
}
self.lift_publisher = self.create_publisher(Float64, "/RMC2/lift", 10)
for name in ROBOT_NAMES:
self.create_subscription(
Odometry,
f"/{name}/odometry",
lambda message, robot=name: self.on_odom(robot, message),
qos_profile_sensor_data,
)
self.create_subscription(
LaserScan,
f"/{name}/scan_front",
lambda message, robot=name: self.on_scan(robot, message),
qos_profile_sensor_data,
)
self.create_subscription(String, "/RMC2/lift_status", self.on_lift, 10)
self.create_subscription(Bool, "/mvch/module5/emergency_stop", self.on_stop, 10)
self.create_subscription(String, "/mvch/module5/command", self.on_command, 10)
def on_odom(self, robot, message):
orientation = message.pose.pose.orientation
siny = 2.0 * (orientation.w * orientation.z + orientation.x * orientation.y)
cosy = 1.0 - 2.0 * (orientation.y**2 + orientation.z**2)
pose = (
float(message.pose.pose.position.x),
float(message.pose.pose.position.y),
math.atan2(siny, cosy),
)
with self.lock:
self.poses[robot] = pose
self.odom_seen[robot] = time.monotonic()
def on_scan(self, robot, message):
distances = []
for index, distance in enumerate(message.ranges):
angle = message.angle_min + index * message.angle_increment
if abs(angle) <= math.radians(24) and math.isfinite(distance):
if message.range_min <= distance <= message.range_max:
distances.append(float(distance))
with self.lock:
self.front_distance[robot] = min(distances, default=math.inf)
self.scan_seen[robot] = time.monotonic()
def on_lift(self, message):
with self.lock:
self.lift_status = str(message.data)
self.lift_updates += 1
def on_stop(self, message):
if message.data:
self.emergency.set()
event_log.write("EMERGENCY_STOP", source="topic")
else:
self.emergency.clear()
event_log.write("RESUME", source="topic")
def on_command(self, message):
command = message.data.strip().lower()
if command == "stop":
self.emergency.set()
event_log.write("EMERGENCY_STOP", source="command")
elif command == "resume":
self.emergency.clear()
event_log.write("RESUME", source="command")
elif command in {"abort", "quit"}:
self.abort.set()
def pose(self, robot):
with self.lock:
return self.poses[robot], self.odom_seen[robot]
def scan(self, robot):
with self.lock:
return self.front_distance[robot], self.scan_seen[robot]
def command(self, robot, x=0.0, y=0.0, yaw=0.0, immediate=False):
now = time.monotonic()
old_x, old_y, old_yaw, old_time = self.last_command[robot]
if immediate:
next_x, next_y, next_yaw = x, y, yaw
else:
dt = min(0.15, max(0.02, now - old_time))
linear_step, angular_step = 0.65 * dt, 1.2 * dt
next_x = old_x + clamp(x - old_x, linear_step)
next_y = old_y + clamp(y - old_y, linear_step)
next_yaw = old_yaw + clamp(yaw - old_yaw, angular_step)
message = Twist()
message.linear.x = next_x
message.linear.y = next_y if robot == "RMC1" else 0.0
message.angular.z = next_yaw
self.cmd_publishers[robot].publish(message)
self.last_command[robot] = (next_x, next_y, next_yaw, now)
def stop_all(self):
for _ in range(3):
for robot in ROBOT_NAMES:
self.command(robot, immediate=True)
time.sleep(0.04)
def set_lift(self, raised, allow_already=False):
height = 0.1 if raised else 0.0
event_log.write("LIFT_COMMAND", state="up" if raised else "down", height=height)
with self.lock:
updates_before = self.lift_updates
for _ in range(5):
self.lift_publisher.publish(Float64(data=height))
time.sleep(0.1)
started = time.monotonic()
success = False
while time.monotonic() - started < 6.0:
with self.lock:
status = self.lift_status
received_update = self.lift_updates > updates_before
# Драйвер публикует status только при его изменении. Если лифт уже
# был внизу до нашей подписки, команда down не создаст сообщения.
if time.monotonic() - started >= 1.1 and received_update and "success" in status.lower():
success = True
break
if time.monotonic() - started >= 1.3 and allow_already and not received_update:
event_log.write(
"LIFT_ALREADY_AT_TARGET",
state="up" if raised else "down",
status=status,
)
success = True
break
time.sleep(0.1)
if not success:
raise RuntimeError(f"RMC2: лифт не завершил движение, status={status}")
event_log.write("LIFT_DONE", state="up" if raised else "down", status=status)
return WarehouseNode(), rclpy, MultiThreadedExecutor
class Motion:
def __init__(self, node, args, event_log, anchors):
self.node = node
self.args = args
self.log = event_log
self.anchors = anchors
def wait_ready(self):
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
odometry_ready = all(
self.node.pose(robot)[0] is not None for robot in ROBOT_NAMES
)
lidar_ready = all(self.node.scan(robot)[1] > 0.0 for robot in ROBOT_NAMES)
if odometry_ready and lidar_ready:
return
time.sleep(0.1)
missing_odom = [robot for robot in ROBOT_NAMES if self.node.pose(robot)[0] is None]
missing_lidar = [robot for robot in ROBOT_NAMES if self.node.scan(robot)[1] == 0.0]
raise RuntimeError(
f"Датчики не готовы; odometry={missing_odom or 'ok'}, "
f"scan_front={missing_lidar or 'ok'}"
)
def wait_if_stopped(self):
announced = False
while self.node.emergency.is_set():
self.node.stop_all()
if not announced:
self.log.write("PAUSED")
announced = True
if self.node.abort.is_set():
raise RuntimeError("Остановлено оператором")
time.sleep(0.1)
def drive_route(
self, robot, name, route, final_world_yaw, carrying=False,
rack_entry=False, rack_exit=False,
):
self.log.write("ROUTE", robot=robot, stage=name, markers=route, carrying=carrying)
for index, marker in enumerate(route[1:], start=1):
x, y = marker_xy(marker)
if index == len(route) - 1:
world_yaw = final_world_yaw
else:
nx, ny = marker_xy(route[index + 1])
world_yaw = math.atan2(ny - y, nx - x)
target = self.anchors[robot].to_odom(x, y, world_yaw)
self.drive_to(
robot,
marker,
target,
carrying=carrying,
allow_rack_entry=(rack_entry and index == len(route) - 1)
or (rack_exit and index == 1),
)
self.log.write("ROUTE_DONE", robot=robot, stage=name, marker=route[-1])
def drive_to(self, robot, marker, target, carrying=False, allow_rack_entry=False):
started = time.monotonic()
obstacle_since = None
while time.monotonic() - started < self.args.waypoint_timeout:
if self.node.abort.is_set():
raise RuntimeError("Остановлено оператором")
self.wait_if_stopped()
pose, seen = self.node.pose(robot)
if pose is None or time.monotonic() - seen > self.args.sensor_timeout:
self.node.command(robot, immediate=True)
raise RuntimeError(f"{robot}: потеря odometry")
x, y, yaw = pose
tx, ty, target_yaw = target
dx, dy = tx - x, ty - y
distance = math.hypot(dx, dy)
front, scan_seen = self.node.scan(robot)
fresh_scan = time.monotonic() - scan_seen <= self.args.sensor_timeout
stop_distance = 0.34 if robot == "RMC2" else 0.30
# Стеллаж имеет размер около 0.95 м и стоит центром на ArUco.
# На финальном въезде RMC2 штатно едет под его платформу.
rack_zone = allow_rack_entry and distance < 1.10
blocked = fresh_scan and front < stop_distance and not rack_zone
if blocked:
self.node.command(robot, immediate=True)
if obstacle_since is None:
obstacle_since = time.monotonic()
self.log.write(
"OBSTACLE_STOP", robot=robot, marker=marker, distance=round(front, 3)
)
if time.monotonic() - obstacle_since > 12.0:
raise RuntimeError(f"{robot}: препятствие не освободило путь")
time.sleep(0.08)
continue
obstacle_since = None
yaw_error = wrap_angle(target_yaw - yaw)
if distance < 0.09:
if abs(yaw_error) < 0.10:
self.node.command(robot, immediate=True)
self.log.write(
"WAYPOINT", robot=robot, marker=marker, error=round(distance, 3)
)
return
self.node.command(robot, yaw=clamp(yaw_error * 1.5, 0.42 if carrying else 0.55))
time.sleep(0.05)
continue
if robot == "RMC1":
cosine, sine = math.cos(yaw), math.sin(yaw)
body_x = cosine * dx + sine * dy
body_y = -sine * dx + cosine * dy
speed = 0.23 if carrying else 0.32
self.node.command(
robot,
x=clamp(body_x * 0.75, speed),
y=clamp(body_y * 0.75, speed),
yaw=clamp(yaw_error * 1.2, 0.40),
)
else:
heading_error = wrap_angle(math.atan2(dy, dx) - yaw)
if abs(heading_error) > 0.18:
self.node.command(
robot, yaw=clamp(heading_error * 1.45, 0.42 if carrying else 0.58)
)
else:
maximum = 0.18 if carrying else 0.27
self.node.command(
robot,
x=min(maximum, max(0.07, distance * 0.55)),
yaw=clamp(heading_error * 1.2, 0.30),
)
time.sleep(0.05)
self.node.command(robot, immediate=True)
raise RuntimeError(f"{robot}: таймаут движения к marker {marker}")
class ArmWorkflow:
"""Тонкая обвязка проверенного зрения и MoveIt из модуля В."""
def __init__(self, args, event_log, executor, target):
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
try:
import cv2
import numpy as np
import yaml
from ament_index_python.packages import get_package_share_directory
from cv_bridge import CvBridge
from geometry_msgs.msg import PoseStamped
from moveit.planning import MoveItPy
from moveit_configs_utils import MoveItConfigsBuilder
from rclpy.duration import Duration
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import CameraInfo, Image
from tf2_ros import Buffer, TransformListener
from tf_transformations import quaternion_from_euler
from ultralytics import YOLO
from module3.module3 import (
Arm,
build_vision_node,
card_near_center,
choose_target,
estimate_pose,
find_targets,
)
except ImportError as error:
raise RuntimeError(
f"Нет зависимости {error.name}: запустите из module3/.venv "
"после source ROS или используйте --skip-arm"
) from error
if int(np.__version__.split(".", 1)[0]) >= 2:
raise RuntimeError("Для cv_bridge нужен numpy<2 в module3/.venv")
weights = Path(args.weights).expanduser()
if not weights.is_absolute():
weights = ROOT / weights
if not weights.is_file():
raise RuntimeError(f"Не найдены веса YOLO: {weights}")
self.args = args
self.log = event_log
self.executor = executor
self.target = target
self.cv2 = cv2
self.np = np
self.find_targets = find_targets
self.choose_target = choose_target
self.estimate_pose = estimate_pose
self.card_near_center = card_near_center
imports = (
Node,
CvBridge,
Image,
CameraInfo,
qos_profile_sensor_data,
Buffer,
TransformListener,
Duration,
Time,
)
self.vision = build_vision_node(SimpleNamespace(topic=args.camera_topic), imports)
executor.add_node(self.vision)
self.model = YOLO(str(weights))
self.log.write("VISION_READY", target=target, weights=str(weights))
package_dir = Path(get_package_share_directory("ar_webots_fms_ros2"))
moveit_config = (
MoveItConfigsBuilder("arm95")
.robot_description(file_path=str(package_dir / "resource/urdf/arm95_webots.urdf"))
.trajectory_execution(
file_path=str(package_dir / "resource/config/moveit_controllers.yaml")
)
.moveit_cpp(file_path=str(package_dir / "resource/config/moveit_cpp.yaml"))
.to_moveit_configs()
.to_dict()
)
moveit_config["use_sim_time"] = True
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as stream:
yaml.safe_dump({"/**": {"ros__parameters": moveit_config}}, stream)
params_file = stream.name
try:
self.arm = Arm(
MoveItPy,
params_file,
PoseStamped,
quaternion_from_euler,
event_log,
)
finally:
Path(params_file).unlink(missing_ok=True)
self.log.write("MOVEIT_READY")
def detect(self):
started = time.monotonic()
last_report = 0.0
seen = {}
while time.monotonic() - started < self.args.vision_timeout:
frame = self.vision.latest_frame()
if frame is None:
time.sleep(0.05)
continue
result = self.model.predict(
source=frame,
conf=self.args.conf,
imgsz=512,
device="cpu",
verbose=False,
)[0]
if result.boxes is not None:
for box in result.boxes:
name = str(result.names[int(box.cls.item())]).lower()
seen[name] = max(seen.get(name, 0.0), float(box.conf.item()))
detections = self.find_targets(
result, frame, self.target, self.cv2, self.np
)
detection = self.choose_target(self.vision, detections, self.args.plane_z)
if detection is not None:
self.log.write(
"DETECTION",
target=self.target,
confidence=round(detection.confidence, 4),
)
return detection
if time.monotonic() - last_report > 5.0:
self.log.write(
"VISION_WAIT",
target=self.target,
seen={key: round(value, 3) for key, value in seen.items()},
)
last_report = time.monotonic()
raise RuntimeError(
f"Класс {self.target} не найден за {self.args.vision_timeout:.0f} с; "
f"видел: {seen or 'ничего'}"
)
def pick(self):
self.log.write("ARM_STAGE", stage="pick_start")
self.arm.open()
self.arm.initial("транспортировочное положение перед захватом")
detection = self.detect()
x, y, yaw = self.estimate_pose(self.vision, detection, self.args.plane_z)
self.log.write("PICK_TARGET", x=round(x, 4), y=round(y, 4), yaw=round(yaw, 4))
self.arm.pose(x, y, self.args.approach_z, yaw, "подход над деталью")
# Камера приблизилась: уточняем положение по контуру карточки.
time.sleep(0.6)
frame = self.vision.latest_frame()
if frame is not None:
try:
close = self.card_near_center(frame, self.target, self.cv2, self.np)
refined_x, refined_y, refined_yaw = self.estimate_pose(
self.vision, close, self.args.plane_z
)
correction = math.hypot(refined_x - x, refined_y - y)
if correction <= 0.12:
x, y, yaw = refined_x, refined_y, refined_yaw
self.log.write("PICK_REFINED", correction=round(correction, 4))
except Exception as error:
self.log.write("PICK_REFINE_SKIPPED", reason=str(error))
self.arm.pose(x, y, self.args.approach_z, yaw, "точный подход")
self.arm.pose(x, y, self.args.pick_z, yaw, "опустить схват")
self.arm.close()
self.arm.pose(x, y, self.args.approach_z, yaw, "поднять деталь")
self.arm.initial("транспортировочное положение с деталью")
self.log.write("DETAIL_PICKED", target=self.target)
def place(self):
self.log.write("ARM_STAGE", stage="place_start")
x, y = self.args.drop_arm_x, self.args.drop_arm_y
yaw = self.args.drop_arm_yaw
self.arm.pose(x, y, self.args.approach_z, yaw, "подход к полке сдачи")
self.arm.pose(x, y, self.args.drop_arm_z, yaw, "опустить деталь на полку")
self.arm.open()
self.arm.pose(x, y, self.args.approach_z, yaw, "отойти от детали")
self.arm.initial("транспортировочное положение без детали")
self.log.write("DETAIL_PLACED", target=self.target)
def close(self):
try:
self.executor.remove_node(self.vision)
self.vision.destroy_node()
except Exception:
pass
def terminal_commands(node):
while not node.abort.is_set():
try:
command = input().strip().lower()
except EOFError:
return
if command == "stop":
node.emergency.set()
elif command == "resume":
node.emergency.clear()
elif command in {"abort", "quit"}:
node.abort.set()
return
def resolve_target(value):
names = {"1": "hammer", "2": "wrench", "3": "pliers"}
value = (value or "").strip().lower()
return names.get(value, value)
def run(args):
target = resolve_target(args.target)
if not target and not args.dry_run:
target = resolve_target(input("Цель (1 hammer, 2 wrench, 3 pliers): "))
if not target:
target = "hammer"
if target not in {"hammer", "wrench", "pliers"}:
raise ValueError(f"Неизвестная цель: {target}")
scenario = make_scenario(args)
routes = scenario.routes()
print_plan(scenario, routes, target)
if args.dry_run:
return 0
if not args.auto:
input("Проверьте точки и маршруты. Enter — начать: ")
event_log = EventLog()
event_log.write("MISSION_START", target=target, log=str(event_log.path))
mission_started = time.monotonic()
node, rclpy, MultiThreadedExecutor = build_ros_node(args, event_log)
executor = MultiThreadedExecutor(num_threads=3)
executor.add_node(node)
spin_thread = threading.Thread(target=executor.spin, daemon=True)
spin_thread.start()
exit_code = 0
arm_workflow = None
try:
initial_motion = Motion(node, args, event_log, {})
initial_motion.wait_ready()
anchors = {}
for robot, start_marker, start_yaw in (
("RMC1", scenario.rmc1_start, args.rmc1_start_yaw),
("RMC2", scenario.rmc2_start, args.rmc2_start_yaw),
):
pose, _ = node.pose(robot)
world_x, world_y = marker_xy(start_marker)
anchors[robot] = Anchor(world_x, world_y, start_yaw, *pose)
event_log.write("LOCALIZED", robot=robot, marker=start_marker, odom=pose)
motion = Motion(node, args, event_log, anchors)
threading.Thread(target=terminal_commands, args=(node,), daemon=True).start()
parking = routes["rmc2_clear_assembly"][-1]
assembly_entry_yaw = face_marker(parking, scenario.assembly)
node.set_lift(False, allow_already=True)
motion.drive_route(
"RMC2", "to_rack", routes["rmc2_to_rack"], args.rack_yaw, rack_entry=True
)
node.set_lift(True)
motion.drive_route(
"RMC2",
"rack_to_assembly",
routes["rmc2_to_assembly"],
assembly_entry_yaw,
carrying=True,
rack_exit=True,
)
node.set_lift(False)
motion.drive_route(
"RMC2",
"clear_assembly",
routes["rmc2_clear_assembly"],
face_marker(parking, scenario.assembly),
rack_exit=True,
)
assembly_yaw = face_marker(scenario.assembly_approach, scenario.assembly)
motion.drive_route(
"RMC1", "to_assembly", routes["rmc1_to_assembly"], assembly_yaw
)
if args.skip_arm:
event_log.write("ARM_SKIPPED", stage="pick", reason="--skip-arm")
else:
arm_workflow = ArmWorkflow(args, event_log, executor, target)
arm_workflow.pick()
delivery_yaw = face_marker(scenario.delivery_approach, scenario.delivery)
motion.drive_route(
"RMC1",
"to_delivery",
routes["rmc1_to_delivery"],
delivery_yaw,
carrying=not args.skip_arm,
)
if args.skip_arm:
event_log.write("ARM_SKIPPED", stage="place", reason="--skip-arm")
else:
arm_workflow.place()
motion.drive_route(
"RMC2",
"back_to_assembly",
routes["rmc2_back_to_assembly"],
assembly_entry_yaw,
rack_entry=True,
)
node.set_lift(True)
motion.drive_route(
"RMC2",
"assembly_to_rack",
routes["rmc2_return_rack"],
args.rack_yaw,
carrying=True,
rack_entry=True,
rack_exit=True,
)
node.set_lift(False)
motion.drive_route(
"RMC1", "return_start", routes["rmc1_return"], args.rmc1_start_yaw
)
motion.drive_route(
"RMC2", "return_start", routes["rmc2_return"], args.rmc2_start_yaw,
rack_exit=True,
)
event_log.write(
"MISSION_FINISHED",
target=target,
elapsed_seconds=round(time.monotonic() - mission_started, 2),
)
print("\nMISSION FINISHED: оба РМК вернулись на старт")
except KeyboardInterrupt:
event_log.write("MISSION_ABORTED", reason="Ctrl+C")
exit_code = 130
except Exception as error:
event_log.write("MISSION_FAILED", reason=str(error))
exit_code = 1
finally:
node.abort.set()
node.stop_all()
if arm_workflow is not None:
arm_workflow.close()
if not executor.shutdown(timeout_sec=2.0):
event_log.write("SHUTDOWN_TIMEOUT", component="ROS executor")
# rclpy/MoveItPy Jazzy иногда зависают или падают в C++-деструкторах.
# Нулевые скорости и логи уже отправлены; ОС безопасно освободит ресурсы.
sys.stdout.flush()
sys.stderr.flush()
os._exit(exit_code)
return exit_code
def main():
try:
raise SystemExit(run(arguments()))
except (ValueError, ImportError) as error:
raise SystemExit(str(error)) from error
if __name__ == "__main__":
main()