From b9aca884f88abdda860d5c62be841a9e21ce68a1 Mon Sep 17 00:00:00 2001
From: yincheng.zhong <634916154@qq.com>
Date: 星期四, 13 十一月 2025 17:07:26 +0800
Subject: [PATCH] 添加python 算法仿真代码

---
 .gitignore                          |    1 
 python/check_trajectory.py          |   42 +
 python/path_planner.py              |  164 ++++
 python/check_path_heading.py        |   25 
 python/mower_controller.py          | 1013 ++++++++++++++++++++++++++++++
 python/debug_pure_pursuit.py        |   65 +
 python/viz.py                       |  147 ++++
 python/debug_desired_heading.py     |   54 +
 python/check_approach_trajectory.py |   79 ++
 python/sim_offline.py               |  241 +++++++
 python/analyze_path_heading.py      |   30 
 python/view_simulation.py           |   22 
 python/debug_start_check.py         |   52 +
 13 files changed, 1,935 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore
index 50373a6..45854bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@
 /STM32H743/MDK-ARM/STM32H743/*.o
 /STM32H743/MDK-ARM/STM32H743/*.htm
 /STM32H743/MDK-ARM/STM32H743/*.axf
+/STM32H743/MDK-ARM/STM32H743/*.hex
diff --git a/python/analyze_path_heading.py b/python/analyze_path_heading.py
new file mode 100644
index 0000000..f90071a
--- /dev/null
+++ b/python/analyze_path_heading.py
@@ -0,0 +1,30 @@
+"""Analyze path_heading from log"""
+from math import atan2, pi
+
+with open('controller_log.csv') as f:
+    lines = [l.strip() for l in f.readlines()[1:]]
+
+filtered = [l.split(',') for l in lines if len(l.split(',')) >= 9]
+
+print("Analysis at key time points:")
+for target_t in [10.0, 20.0, 30.0]:
+    rows = [p for p in filtered if abs(float(p[0]) - target_t) < 0.05]
+    if rows:
+        r = rows[0]
+        t = float(r[0])
+        x, y = float(r[1]), float(r[2])
+        heading = float(r[3])
+        tx, ty = float(r[4]), float(r[5])
+        xte = float(r[8])
+        heading_err = float(r[9])
+        
+        path_heading = atan2(ty-y, tx-x)
+        
+        print(f"\nt={t:.1f}s:")
+        print(f"  pos=({x:.3f}, {y:.3f}), heading={heading:.3f} rad = {heading*180/pi:.1f} deg")
+        print(f"  target=({tx:.3f}, {ty:.3f})")
+        print(f"  path_heading={path_heading:.3f} rad = {path_heading*180/pi:.1f} deg")
+        print(f"  xte={xte:.3f}, heading_err={heading_err:.3f}")
+        print(f"  => desired_heading = path_heading + xte_correction")
+        print(f"             = {path_heading:.3f} + (-1.2 * {xte:.3f})")
+        print(f"             = {path_heading - 1.2*xte:.3f} rad = {(path_heading - 1.2*xte)*180/pi:.1f} deg")
diff --git a/python/check_approach_trajectory.py b/python/check_approach_trajectory.py
new file mode 100644
index 0000000..6ee323f
--- /dev/null
+++ b/python/check_approach_trajectory.py
@@ -0,0 +1,79 @@
+"""
+Check trajectory with approach path visualization
+"""
+import json
+
+# Load controller log
+lines = []
+with open('controller_log.csv', 'r') as f:
+    for line in f:
+        lines.append(line.strip())
+
+# Find reached_start marker
+reached_start_idx = None
+for i, line in enumerate(lines):
+    if 'reached_start' in line:
+        reached_start_idx = i
+        print(f"Found reached_start at line {i+1}")
+        break
+
+if reached_start_idx is None:
+    print("No reached_start found")
+    exit(1)
+
+# Parse and display trajectory
+print("\n=== TRAJECTORY ANALYSIS WITH APPROACH PATH ===\n")
+
+# Show first 20 frames after reached_start
+print("First 20 frames after reached_start (now following combined path):")
+for i in range(reached_start_idx + 1, min(reached_start_idx + 21, len(lines))):
+    parts = lines[i].split(',')
+    if len(parts) >= 8:
+        t = float(parts[0])
+        x, y = float(parts[1]), float(parts[2])
+        heading = float(parts[3])
+        target_x, target_y = float(parts[4]), float(parts[5])
+        target_idx = int(parts[7])
+        xte = float(parts[8]) if len(parts) > 8 else 0.0
+        h_err = float(parts[9]) if len(parts) > 9 else 0.0
+        print(f"t={t:6.3f} pos=({x:6.3f},{y:6.3f}) h={heading:5.3f} target=({target_x:6.3f},{target_y:6.3f}) idx={target_idx:3d} xte={xte:6.3f} h_err={h_err:6.3f}")
+
+# Sample every 10 seconds
+print("\n\nPosition every 10 seconds:")
+for target_t in [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110]:
+    for line in lines[reached_start_idx+1:]:
+        parts = line.split(',')
+        if len(parts) >= 8:
+            t = float(parts[0])
+            if abs(t - target_t) < 0.1:  # within 0.1s
+                x, y = float(parts[1]), float(parts[2])
+                heading = float(parts[3])
+                print(f"t={t:6.1f}s: pos=({x:7.3f},{y:7.3f}) heading={heading:6.3f}")
+                break
+
+# Check if completed
+print("\n\nCompletion status:")
+last_line = lines[-1]
+parts = last_line.split(',')
+if len(parts) >= 14:
+    t = float(parts[0])
+    x, y = float(parts[1]), float(parts[2])
+    stage = parts[13]
+    print(f"Last frame: t={t:.1f}s, pos=({x:.3f},{y:.3f}), stage={stage}")
+    if t >= 119.0:
+        print("鈿狅笍 TIMEOUT - did not complete in 120s")
+    else:
+        print("鉁� Completed successfully")
+
+# Load paths and check indices
+print("\n\n=== PATH INFORMATION ===")
+with open('example_path.json', 'r') as f:
+    work_path = json.load(f)
+
+print(f"Work path: {len(work_path)} points ({len(work_path)-1} segments)")
+print(f"Work path range: x=[{min(p[0] for p in work_path):.1f}, {max(p[0] for p in work_path):.1f}], "
+      f"y=[{min(p[1] for p in work_path):.1f}, {max(p[1] for p in work_path):.1f}]")
+
+# Estimate approach path length
+print(f"\nNote: Combined path includes approach path + work path")
+print(f"Check simulation_results.png to see both paths visualized")
diff --git a/python/check_path_heading.py b/python/check_path_heading.py
new file mode 100644
index 0000000..d7fd45c
--- /dev/null
+++ b/python/check_path_heading.py
@@ -0,0 +1,25 @@
+"""Check path segment headings"""
+from math import atan2, pi
+
+approach_points = [
+    (-1.5, -1.0),
+    (-1.262, -0.749),
+    (-1.045, -0.514),
+    (-0.829, -0.309),
+    (-0.596, -0.146),
+    (-0.326, -0.039),
+    (0, 0)
+]
+
+print("Approach path segment headings:")
+for i in range(len(approach_points)-1):
+    p1, p2 = approach_points[i], approach_points[i+1]
+    heading = atan2(p2[1]-p1[1], p2[0]-p1[0])
+    print(f"Segment {i}->{i+1}: heading={heading:.3f} rad = {heading*180/pi:.1f} deg")
+
+print("\nWork path segment headings (first few):")
+work_points = [(0, 0), (10, 0), (10, 1), (0, 1)]
+for i in range(len(work_points)-1):
+    p1, p2 = work_points[i], work_points[i+1]
+    heading = atan2(p2[1]-p1[1], p2[0]-p1[0])
+    print(f"Segment {i}->{i+1}: heading={heading:.3f} rad = {heading*180/pi:.1f} deg")
diff --git a/python/check_trajectory.py b/python/check_trajectory.py
new file mode 100644
index 0000000..9cf4869
--- /dev/null
+++ b/python/check_trajectory.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python
+# 妫�鏌ヨ建杩硅缁嗘暟鎹�
+
+with open('controller_log.csv', 'r') as f:
+    lines = f.readlines()
+
+# 鎵惧埌 reached_start
+reached_idx = None
+for i, line in enumerate(lines):
+    if 'reached_start' in line:
+        reached_idx = i
+        break
+
+if reached_idx:
+    print(f"Found reached_start at line {reached_idx}")
+    
+    # 鍓�20甯х殑璇︾粏鏁版嵁
+    print("\nFirst 20 frames after reached_start:")
+    count = 0
+    for i in range(reached_idx + 1, min(reached_idx + 100, len(lines))):
+        if 'running' in lines[i]:
+            parts = lines[i].strip().split(',')
+            if len(parts) >= 11:
+                t, x, y, heading, tx, ty, tdist, tidx, xte, herr = parts[:10]
+                print(f"t={t:>6s} pos=({x:>6s},{y:>6s}) h={heading:>5s} target=({tx:>6s},{ty:>6s}) idx={tidx:>3s} xte={xte:>6s} h_err={herr:>6s}")
+                count += 1
+                if count >= 20:
+                    break
+    
+    # 澶氫釜鏃堕棿鐐圭殑浣嶇疆
+    print("\n\nPosition every 10 seconds:")
+    target_times = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]
+    for target_t in target_times:
+        for i in range(reached_idx + 1, len(lines)):
+            if 'running' in lines[i]:
+                parts = lines[i].strip().split(',')
+                if len(parts) >= 3:
+                    t = float(parts[0])
+                    if t >= target_t:
+                        x, y, heading = parts[1], parts[2], parts[3]
+                        print(f"t={t:>6.1f}s: pos=({x:>7s},{y:>7s}) heading={heading:>6s}")
+                        break
diff --git a/python/debug_desired_heading.py b/python/debug_desired_heading.py
new file mode 100644
index 0000000..28f378b
--- /dev/null
+++ b/python/debug_desired_heading.py
@@ -0,0 +1,54 @@
+"""Debug desired_heading calculation"""
+from math import atan2, pi
+
+def wrap_angle(a):
+    while a > pi:
+        a -= 2 * pi
+    while a < -pi:
+        a += 2 * pi
+    return a
+
+# From log at t=10s
+x, y = -0.282, -0.638
+heading = -0.470
+tx, ty = 0.000, 0.000
+xte = 0.573
+heading_err_log = -0.095
+
+# Calculate path_heading
+path_heading = atan2(ty - y, tx - x)
+print(f"path_heading = atan2({ty} - {y}, {tx} - {x}) = atan2({ty-y:.3f}, {tx-x:.3f}) = {path_heading:.3f}")
+
+# Calculate xte_correction
+k_xte = 1.2
+max_xte = 1.0
+xte_gain_scale = 1.0
+xte_normalized = max(-1.0, min(1.0, xte / max_xte))
+xte_correction = -k_xte * xte_normalized * xte_gain_scale
+print(f"xte_correction = -k_xte * xte_normalized * xte_gain_scale")
+print(f"               = -{k_xte} * {xte_normalized:.3f} * {xte_gain_scale}")
+print(f"               = {xte_correction:.3f}")
+
+# Calculate desired_heading
+desired_heading_raw = path_heading + xte_correction
+desired_heading = wrap_angle(desired_heading_raw)
+print(f"desired_heading = wrap_angle({path_heading:.3f} + {xte_correction:.3f})")
+print(f"                = wrap_angle({desired_heading_raw:.3f})")
+print(f"                = {desired_heading:.3f}")
+
+# Calculate heading_err
+heading_err_calc = wrap_angle(desired_heading - heading)
+print(f"\nheading_err = wrap_angle({desired_heading:.3f} - {heading:.3f})")
+print(f"            = wrap_angle({desired_heading - heading:.3f})")
+print(f"            = {heading_err_calc:.3f}")
+
+print(f"\n heading_err from log: {heading_err_log:.3f}")
+print(f"heading_err calculated: {heading_err_calc:.3f}")
+print(f"           Difference: {abs(heading_err_log - heading_err_calc):.3f}")
+
+if abs(heading_err_log - heading_err_calc) > 0.1:
+    print("\n鈿狅笍 WARNING: Significant difference detected!")
+    print("Either:")
+    print("1. xte_correction is not being applied")
+    print("2. Different target point is being used")
+    print("3. Log data is from a different code path")
diff --git a/python/debug_pure_pursuit.py b/python/debug_pure_pursuit.py
new file mode 100644
index 0000000..0c90858
--- /dev/null
+++ b/python/debug_pure_pursuit.py
@@ -0,0 +1,65 @@
+"""Debug Pure Pursuit logic"""
+import json
+from math import pi, hypot, atan2, cos, sin
+from path_planner import plan_approach_path, compute_path_heading, combine_paths
+from mower_controller import MowerController
+
+# Load work path
+work_path = json.load(open('example_path.json'))
+mower_start_pos = (work_path[0][0] - 1.5, work_path[0][1] - 1.0)
+mower_start_heading = pi / 4
+
+# Generate approach path
+work_path_heading = compute_path_heading(work_path, 0)
+approach_path = plan_approach_path(mower_start_pos, mower_start_heading,
+                                   work_path[0], work_path_heading, 
+                                   method='smooth')
+
+# Combine paths
+combined_path, approach_end_idx = combine_paths(approach_path, work_path)
+
+print(f"Combined path: {len(combined_path)} points")
+print(f"First 10 points:")
+for i in range(min(10, len(combined_path))):
+    print(f"  {i}: {combined_path[i]}")
+
+# Initialize controller
+ctrl = MowerController(combined_path)
+print(f"\nDensified path: {len(ctrl.path)} points")
+print(f"First 20 densified points:")
+for i in range(min(20, len(ctrl.path))):
+    print(f"  {i}: {ctrl.path[i]}")
+
+# Simulate a few steps
+ctrl.update_gps(pos_xy=mower_start_pos, heading=mower_start_heading)
+ctrl.stage = ctrl.STAGE_FOLLOW_PATH  # Skip goto_start
+
+print("\n=== Testing Pure Pursuit Logic ===")
+for step in range(5):
+    result = ctrl.compute_control(timestamp=step * 0.014)
+    x, y = ctrl.pos
+    target_idx = ctrl.current_target_idx if hasattr(ctrl, 'current_target_idx') else -1
+    if target_idx >= 0 and target_idx < len(ctrl.path):
+        tx, ty = ctrl.path[target_idx]
+        target_heading = atan2(ty - y, tx - x)
+        print(f"\nStep {step}:")
+        print(f"  Pos: ({x:.3f}, {y:.3f}), heading: {ctrl.heading:.3f}")
+        print(f"  Target idx: {target_idx}, target: ({tx:.3f}, {ty:.3f})")
+        print(f"  Target heading: {target_heading:.3f}")
+        print(f"  Command: forward={result['forward']}, turn={result['turn']}")
+    
+    # Simulate motion (simplified kinematic model)
+    dt = 0.014
+    forward_mps = result['forward'] / 100.0 * ctrl.max_forward_mps
+    yawrate = result['turn'] / 100.0 * ctrl.max_yawrate
+    
+    # Update heading
+    new_heading = ctrl.heading + yawrate * dt
+    
+    # Update position
+    vx = forward_mps * cos(new_heading)
+    vy = forward_mps * sin(new_heading)
+    new_x = x + vx * dt
+    new_y = y + vy * dt
+    
+    ctrl.update_gps(pos_xy=(new_x, new_y), heading=new_heading)
diff --git a/python/debug_start_check.py b/python/debug_start_check.py
new file mode 100644
index 0000000..d0d1d1c
--- /dev/null
+++ b/python/debug_start_check.py
@@ -0,0 +1,52 @@
+import csv, json, math
+
+with open('example_path.json') as f:
+    path = json.load(f)
+start = tuple(path[0])
+
+rows = []
+with open('controller_log.csv') as f:
+    reader = csv.DictReader(f)
+    for r in reader:
+        rows.append(r)
+
+first_follow_idx = None
+for i, r in enumerate(rows):
+    if r.get('stage','') == 'follow_path':
+        first_follow_idx = i
+        break
+
+if first_follow_idx is not None:
+    print('Found follow_path at row', first_follow_idx, 't=', rows[first_follow_idx]['t'])
+    for j in range(max(0, first_follow_idx-5), first_follow_idx+5):
+        r = rows[j]
+        x = float(r['x']); y = float(r['y']); heading = float(r['heading'])
+        dx = start[0]-x; dy = start[1]-y
+        desired = math.atan2(dy, dx)
+        h_err = (desired - heading + math.pi) % (2*math.pi) - math.pi
+        print(j, r['t'], r['stage'], r['status'], f"pos=({x:.3f},{y:.3f})", f"dist={(math.hypot(dx,dy)):.3f}", f"h_err={h_err:.3f}")
+else:
+    print('No follow_path in log. Scanning for closest approach to start...')
+    min_dist = float('inf')
+    min_idx = None
+    for i, r in enumerate(rows):
+        x = float(r['x']); y = float(r['y']);
+        d = math.hypot(start[0]-x, start[1]-y)
+        if d < min_dist:
+            min_dist = d; min_idx = i
+    print('min dist to start =', min_dist, 'at row', min_idx, 't=', rows[min_idx]['t'])
+    print('Nearby log lines:')
+    for j in range(max(0, min_idx-10), min_idx+11):
+        r = rows[j]
+        x = float(r['x']); y = float(r['y']); heading = float(r['heading'])
+        dx = start[0]-x; dy = start[1]-y
+        desired = math.atan2(dy, dx)
+        h_err = (desired - heading + math.pi) % (2*math.pi) - math.pi
+        print(j, r['t'], r['stage'], r['status'], f"pos=({x:.3f},{y:.3f})", f"dist={(math.hypot(dx,dy)):.3f}", f"h_err={h_err:.3f}")
+
+# also print counts of key statuses
+from collections import Counter
+cnt = Counter((r['stage'], r['status']) for r in rows)
+print('\nStage/Status counts (top 20):')
+for k,v in cnt.most_common(20):
+    print(k, v)
diff --git a/python/mower_controller.py b/python/mower_controller.py
new file mode 100644
index 0000000..0f7b48d
--- /dev/null
+++ b/python/mower_controller.py
@@ -0,0 +1,1013 @@
+"""
+MowerController
+
+鎺у埗鍣ㄥ亣璁句笌濂戠害锛�
+- 鍧愭爣绯伙細XY 骞抽潰鍗曚綅涓虹背锛圧TK 鎻愪緵锛夛紝Z 蹇界暐鐢ㄤ簬骞抽潰璺熻釜銆�
+- 鑸悜锛坔eading锛夛細寮у害锛岃寖鍥翠换鎰忥紝鎺у埗鍣ㄤ細澶勭悊鐜粫銆�
+- GPS 棰戠巼锛堜綅缃�/閫熷害/heading/attitude锛夌害 10Hz锛汭MU锛堝姞閫熷害銆佽閫熷害锛�100Hz銆�
+- 鎺у埗杈撳嚭棰戠巼锛�74Hz锛岃繑鍥炰袱涓俊鍙凤細forward锛�-100..100锛屽搴� -1..+1 m/s锛夊拰 turn锛�-100..100锛屽搴� -pi/2..+pi/2 rad/s锛屾涓哄乏杞級銆�
+- 璺緞鏂囦欢锛欽SON 鍒楄〃 [[x,y],[x,y],...]
+
+璁捐璇存槑锛�
+- 浣跨敤绾拷韪紙pure pursuit锛夋柟娉曢�夊彇鍓嶈鐐癸紝骞跺熀浜庤搴﹁宸娇鐢� P 鎺у埗璁$畻鏈熸湜鍋忚埅閫熺巼锛坮ad/s锛夈��
+- 鍓嶈繘閫熷害涓庡亸鑸宸�﹀悎锛氬亸鑸宸ぇ鏃堕檷浣庡墠杩涢�熷害浠ヤ繚璇佸彲杞集鑳藉姏銆�
+
+鎺ュ彛锛�
+- controller = MowerController(path_points, params)
+- controller.update_gps(pos_xy, heading, vel_xy, timestamp)
+- controller.update_imu(accel3, gyro3, timestamp)
+- controller.compute_control(timestamp) -> {'forward': int, 'turn': int, 'info': {...}}
+
+"""
+from math import atan2, hypot, sin, cos, pi, acos
+import time
+
+
+def wrap_angle(a):
+    # wrap to [-pi, pi)
+    while a >= pi:
+        a -= 2 * pi
+    while a < -pi:
+        a += 2 * pi
+    return a
+
+
+class MowerController:
+    def __init__(self, path_points, params=None):
+        # path_points: list of [x,y]
+        # 鍘熷璺緞鐐�
+        original_path = [tuple(p) for p in path_points]
+        if len(original_path) == 0:
+            raise ValueError("path_points cannot be empty")
+        
+        # 淇濆瓨鍘熷璺緞鐢ㄤ簬鎷愮偣妫�娴�
+        self.original_path = original_path
+        
+        # 璺緞鍔犲瘑锛坉ensify锛夛細閬垮厤棣栨杩囬暱瀵艰嚧绾拷韪墠瑙嗙偣璺冲埌杩滃
+        p_params = params or {}
+        max_seg_len = p_params.get('max_segment_length', 0.6)  # 榛樿灏嗛暱娈靛垏鍒嗕负 <=0.6m
+        densified = []
+        for i in range(len(original_path) - 1):
+            x1, y1 = original_path[i]
+            x2, y2 = original_path[i + 1]
+            densified.append((x1, y1))
+            dx = x2 - x1
+            dy = y2 - y1
+            seg_len = hypot(dx, dy)
+            if seg_len > max_seg_len and seg_len > 1e-6:
+                # 闇�瑕佹彃鍏ヨ嫢骞蹭腑闂寸偣锛堜笉鍖呭惈缁堢偣锛岀粓鐐逛笅涓�寰幆娣诲姞锛�
+                steps = int(seg_len // max_seg_len)
+                # 浣跨敤鍧囧寑绾挎�ф彃鍊硷紙1..steps锛夛紝闃叉棣栨杩囬暱绾拷韪洿鎺ヨ烦杩�
+                for k in range(1, steps + 1):
+                    ratio = k / (steps + 1)
+                    densified.append((x1 + dx * ratio, y1 + dy * ratio))
+        # 娣诲姞鏈�鍚庝竴涓偣
+        densified.append(original_path[-1])
+        self.path = densified
+        self.n = len(self.path)
+        # 棰勮绠楁瘡娈甸暱搴﹀強绱Н闀垮害鐢ㄤ簬杩涘害璁$畻
+        self.segment_lengths = []
+        self.cum_lengths = [0.0]
+        for i in range(self.n - 1):
+            ax, ay = self.path[i]
+            bx, by = self.path[i+1]
+            L = hypot(bx-ax, by-ay)
+            self.segment_lengths.append(L)
+            self.cum_lengths.append(self.cum_lengths[-1] + L)
+        self.total_length = self.cum_lengths[-1] if self.cum_lengths else 0.0
+        # 杩涘害鍗曡皟鎬т繚鎶ゅ彉閲�
+        self.last_progress = 0.0
+
+        # parameters and gains
+        p = params or {}
+        self.max_forward_mps = p.get('max_forward_mps', 0.6)    # 鏇翠繚瀹堢殑閫熷害
+        self.max_reverse_mps = p.get('max_reverse_mps', 0.2)    # 淇濆畧鐨勫�掕溅閫熷害
+        self.max_yawrate = p.get('max_yawrate', pi/4)          # 鏇翠繚瀹堢殑杞悜閫熺巼
+
+        # 杞惎鍔ㄥ拰鍔犲噺閫�
+        self.accel = p.get('accel', 0.2)                       # m/s^2
+        self.current_speed_target = 0.0                         # 鐩爣閫熷害
+        self.current_speed = 0.0                               # 瀹為檯閫熷害
+        
+        # 鎺у埗澧炵泭锛堟彁楂樺搷搴旈�熷害鍜岀簿搴︼級
+        self.k_heading = p.get('k_heading', 1.8)               # 澶у箙鎻愰珮杞悜澧炵泭锛屽姞蹇埅鍚戞敹鏁�
+        self.k_heading_d = p.get('k_heading_d', 0.25)          # 澧炲姞寰垎浣滅敤锛屾姂鍒舵尟鑽�
+        self.k_xte = p.get('k_xte', 1.2)                       # 澶у箙鎻愰珮妯悜璇樊澧炵泭锛屾縺杩涚籂姝e亸绂�
+        self.k_curv = p.get('k_curv', 0.2)                     # 闄嶄綆鏇茬巼鍓嶉澧炵泭
+        self.base_speed = p.get('base_speed', 0.5)             # 鎻愰珮鍩虹閫熷害
+
+        # 璺緞璺熻釜鍙傛暟锛堝噺灏弆ookahead浠ユ彁楂樼簿搴︼級
+        self.lookahead_time = p.get('lookahead_time', 2.0)    # 鍑忓皬鍓嶈鏃堕棿
+        # 鍑忓皬鏈�灏忓墠瑙嗚窛绂讳互鎻愰珮杞ㄨ抗绮惧害
+        self.lookahead_min = p.get('lookahead_min', 0.35)
+        self.lookahead_max = p.get('lookahead_max', 1.5)      # 鍑忓皬鏈�澶у墠瑙嗚窛绂�
+        self.goal_tolerance = p.get('goal_tolerance', 0.3)     # m
+        self.slow_zone = p.get('slow_zone', 1.5)              # 鍑忓皬鍑忛�熷尯鍩�
+        self.final_approach_dist = p.get('final_approach_dist', 0.8)  # 鍑忓皬绮剧‘鎺ヨ繎鍖哄煙
+
+        # 鏂板锛氭í鍚戣宸浉鍏�
+        self.max_xte = p.get('max_xte', 1.0)                  # 鍑忓皬鏈�澶фí鍚戣宸檺鍒讹紝鎻愰珮绮惧害
+        self.xte_gain_scale = p.get('xte_gain_scale', 1.0)    # 绉婚櫎妯悜璇樊缂╂斁锛屼娇鐢ㄥ畬鏁村鐩�
+        
+        # 閫熷害鎺у埗缂╂斁鍙傛暟
+        self.curv_gain_scale = p.get('curv_gain_scale', 2.0)  # 鏇茬巼缂╂斁澧炵泭
+        self.heading_err_gain_scale = p.get('heading_err_gain_scale', 1.5)  # 鑸悜璇樊缂╂斁澧炵泭
+        
+        # 鐘舵�佷及璁″拰婊ゆ尝
+        self.yawrate_filter = 0.0                             
+        self.yawrate_alpha = p.get('yawrate_alpha', 0.90)     # 杩涗竴姝ュ鍔犲钩婊戝害
+        self.last_heading_err = 0.0                           # 鐢ㄤ簬D鎺у埗
+        self.last_time = None                                 # 鐢ㄤ簬璁$畻dt
+        self.last_curv = 0.0                                  # 鐢ㄤ簬骞虫粦鏇茬巼鍙樺寲
+        
+        # 绱Н璇樊鎺у埗
+        self.heading_err_sum = 0.0                            # 鑸悜璇樊绉垎
+        self.xte_sum = 0.0                                    # 妯悜璇樊绉垎
+        self.max_sum = 2.0                                    # 绉垎闄愬箙
+        self.k_heading_i = p.get('k_heading_i', 0.01)        # 鑸悜璇樊绉垎澧炵泭
+        self.k_xte_i = p.get('k_xte_i', 0.01)               # 妯悜璇樊绉垎澧炵泭
+        
+        # 閿欒鎭㈠
+        self.consecutive_large_err = 0                        # 杩炵画澶ц宸鏁�
+        self.large_err_threshold = p.get('large_err_threshold', 0.5)  # 澶ц宸槇鍊�
+        self.max_large_err_count = 10                         # 鏈�澶ц繛缁ぇ璇樊娆℃暟
+        
+    # (鏃ュ織鏂囦欢灏嗗湪涓嬮潰缁熶竴鍒涘缓)
+
+        # state (most recent readings)
+        self.pos = None  # (x,y)
+        self.heading = None  # rad
+        self.vel = (0.0, 0.0)  # vx, vy in m/s (ground frame)
+        self.imu_accel = None
+        self.imu_gyro = None
+
+        # control stages
+        self.STAGE_GOTO_START = 'goto_start'  # 绉诲姩鍒拌捣鐐归樁娈�
+        self.STAGE_FOLLOW_PATH = 'follow_path' # 璺熼殢璺緞闃舵
+        self.stage = self.STAGE_GOTO_START
+        
+        # 璧风偣鎺ヨ繎鍙傛暟 - 鏀剁揣浠ョ‘淇濈湡姝e埌杈捐捣鐐�
+        self.start_tolerance = p.get('start_tolerance', 0.12)   # 浣嶇疆瀹瑰樊锛堟敹绱у埌12cm锛�
+        self.start_heading_tolerance = p.get('start_heading_tolerance', 0.01)  # 鑸悜瀹瑰樊 (~14掳锛屾敹绱э級
+        self.start_approach_dist = p.get('start_approach_dist', 1.5)  # 鍑忛�熻窛绂�
+        self.start_slow_speed = p.get('start_slow_speed', 0.25)  # 鎺ヨ繎閫熷害
+        self.start_min_speed = p.get('start_min_speed', 0.25)   # 鎻愰珮鏈�灏忛�熷害
+        # 鏂板锛氬埌璧风偣鏃跺厛杞悜鍐嶅墠杩涚殑闃堝�硷紙鑻ヤ笌璧风偣鏂瑰悜璇樊澶т簬姝ら槇鍊硷紝鍒欏厛杞悜锛�
+        self.start_turn_first_heading = p.get('start_turn_first_heading', pi/50)  # ~15搴�
+        # 璺緞璺熼殢闃舵鐨勬渶灏忓墠杩涢�熷害锛堥槻姝㈡槧灏�/缂╂斁瀵艰嚧閫熷害褰掗浂锛�
+        self.min_follow_speed = p.get('min_follow_speed', 0.08)
+        # Recovery 妯″紡鍙傛暟
+        self.in_recovery = False
+        self.recovery_dist = p.get('recovery_dist', 2.0)
+        self.recovery_exit_dist = p.get('recovery_exit_dist', 0.8)
+        self.recovery_speed = p.get('recovery_speed', 0.55)
+        
+        # progress - 鍙娇鐢ㄧ偣绱㈠紩锛屼笉浣跨敤segment
+        self.current_target_idx = 0
+        self.nearest_path_idx = 0  # 鏈�杩戣矾寰勭偣绱㈠紩
+        self.finished = False
+        # goto_start 瀛愮姸鎬侊細'rotate'|'move'|'align'锛屼繚璇侀『搴忔墽琛屼笖涓嶅弽澶嶅垏鎹�
+        self.goto_substage = 'rotate'
+        
+        # 鎷愮偣杞悜鍙傛暟
+        self.waypoint_approach_mode = False  # 鏄惁鍦ㄦ帴杩戞嫄鐐规ā寮�
+        self.waypoint_turn_mode = False  # 鏄惁鍦ㄦ嫄鐐硅浆鍚戞ā寮�
+        self.waypoint_turn_target_idx = None  # 褰撳墠杞悜鐨勬嫄鐐瑰湪鍘熷璺緞涓殑绱㈠紩
+        self.waypoint_turn_tolerance = p.get('waypoint_turn_tolerance', 0.12)  # 鍒拌揪鎷愮偣鐨勮窛绂诲宸�(12cm)
+        self.waypoint_approach_dist = p.get('waypoint_approach_dist', 0.8)  # 寮�濮嬪噺閫熸帴杩戞嫄鐐圭殑璺濈(80cm)
+        self.waypoint_turn_heading_tol = p.get('waypoint_turn_heading_tol', 0.1)  # 杞悜瀹屾垚鐨勮埅鍚戝宸�(~6搴�)
+        self.waypoint_turned = set()  # 璁板綍宸茬粡杞悜杩囩殑鎷愮偣锛岄伩鍏嶉噸澶嶈浆鍚�
+        # 鍒涘缓鏃ュ織鏂囦欢
+        self.log_file = open('controller_log.csv', 'w')
+        # 绠�鍖栨棩蹇楁牸寮忥細绉婚櫎segment鐩稿叧
+        self.log_file.write('t,x,y,heading,target_x,target_y,target_dist,target_idx,xte,heading_err,forward,turn,speed,stage,status\n')
+
+    def update_gps(self, pos_xy, heading, vel_xy=None, timestamp=None):
+        """Update GPS-like measurements at ~10Hz.
+
+        pos_xy: (x,y) in meters
+        heading: radians
+        vel_xy: (vx, vy) in m/s in global frame (optional)
+        """
+        self.pos = tuple(pos_xy)
+        self.heading = float(heading)
+        if vel_xy is not None:
+            self.vel = tuple(vel_xy)
+
+    def update_imu(self, accel3, gyro3, timestamp=None):
+        """Update IMU (100Hz). accel3 and gyro3 are 3-element tuples."""
+        self.imu_accel = tuple(accel3)
+        self.imu_gyro = tuple(gyro3)
+
+    def _calc_xte(self, p1, p2, pos):
+        """Calculate cross track error (perpendicular distance to line segment)"""
+        x, y = pos
+        x1, y1 = p1
+        x2, y2 = p2
+        
+        # Convert to local coordinates
+        dx = x2 - x1
+        dy = y2 - y1
+        l2 = dx*dx + dy*dy
+        
+        if l2 == 0:  # points are identical
+            return hypot(x - x1, y - y1)
+        
+        # Project point onto line
+        t = ((x - x1)*dx + (y - y1)*dy) / l2
+        
+        if t < 0:  # before start
+            return hypot(x - x1, y - y1)
+        elif t > 1:  # after end
+            return hypot(x - x2, y - y2)
+        else:  # projection point lies on segment
+            px = x1 + t*dx
+            py = y1 + t*dy
+            xte = hypot(x - px, y - py)
+            # Determine sign based on which side of the line the point is on
+            sign = 1 if (x - px)*dy - (y - py)*dx > 0 else -1
+            return sign * xte
+
+    def _is_waypoint_turn_needed(self, idx):
+        """
+        妫�娴嬭矾寰勭偣idx鏄惁鏄嫄鐐癸紙闇�瑕佸師鍦拌浆鍚戯級
+        
+        鎷愮偣瀹氫箟锛氬墠鍚庝袱娈佃矾寰勬柟鍚戝彉鍖栬秴杩�45搴�
+        """
+        if idx <= 0 or idx >= len(self.path) - 1:
+            return False
+        
+        # 鍓嶄竴娈垫柟鍚�
+        p_prev = self.path[idx - 1]
+        p_curr = self.path[idx]
+        p_next = self.path[idx + 1]
+        
+        dx1 = p_curr[0] - p_prev[0]
+        dy1 = p_curr[1] - p_prev[1]
+        dx2 = p_next[0] - p_curr[0]
+        dy2 = p_next[1] - p_curr[1]
+        
+        # 璁$畻涓ゆ鏂瑰悜
+        heading1 = atan2(dy1, dx1)
+        heading2 = atan2(dy2, dx2)
+        
+        # 鏂瑰悜鍙樺寲瑙掑害
+        angle_diff = abs(wrap_angle(heading2 - heading1))
+        
+        # 濡傛灉鏂瑰悜鍙樺寲瓒呰繃45搴︼紝璁や负鏄嫄鐐�
+        return angle_diff > pi / 4
+    
+    def _find_nearest_original_waypoint(self):
+        """
+        鍦ㄥ師濮嬭矾寰勪腑鎵惧埌鏈�鎺ヨ繎褰撳墠浣嶇疆鐨勬嫄鐐�
+        
+        Returns: (waypoint_idx, distance) 鎴� (None, None)
+        """
+        if self.pos is None:
+            return None, None
+        
+        x, y = self.pos
+        nearest_waypoint_idx = None
+        nearest_dist = float('inf')
+        
+        # 閬嶅巻鍘熷璺緞鐨勬墍鏈夋嫄鐐�
+        for i in range(1, len(self.original_path) - 1):
+            # 妫�鏌ユ槸鍚︽槸鎷愮偣
+            p_prev = self.original_path[i - 1]
+            p_curr = self.original_path[i]
+            p_next = self.original_path[i + 1]
+            
+            dx1 = p_curr[0] - p_prev[0]
+            dy1 = p_curr[1] - p_prev[1]
+            dx2 = p_next[0] - p_curr[0]
+            dy2 = p_next[1] - p_curr[1]
+            
+            heading1 = atan2(dy1, dx1)
+            heading2 = atan2(dy2, dx2)
+            angle_diff = abs(wrap_angle(heading2 - heading1))
+            
+            # 濡傛灉鏄嫄鐐癸紙鏂瑰悜鍙樺寲>45搴︼級
+            if angle_diff > pi / 4:
+                dist = hypot(p_curr[0] - x, p_curr[1] - y)
+                if dist < nearest_dist:
+                    nearest_dist = dist
+                    nearest_waypoint_idx = i
+        
+        return nearest_waypoint_idx, nearest_dist
+    
+    def _get_target_heading_at_waypoint(self, idx):
+        """鑾峰彇鍘熷璺緞鐐筰dx澶勭殑鐩爣鑸悜锛堜笅涓�娈电殑鏂瑰悜锛�"""
+        if idx >= len(self.original_path) - 1:
+            # 鏈�鍚庝竴涓偣锛岃繑鍥炲墠涓�娈垫柟鍚�
+            if idx > 0:
+                dx = self.original_path[idx][0] - self.original_path[idx-1][0]
+                dy = self.original_path[idx][1] - self.original_path[idx-1][1]
+                return atan2(dy, dx)
+            return 0.0
+        
+        # 杩斿洖鍒颁笅涓�涓偣鐨勬柟鍚�
+        dx = self.original_path[idx + 1][0] - self.original_path[idx][0]
+        dy = self.original_path[idx + 1][1] - self.original_path[idx][1]
+        return atan2(dy, dx)
+
+    def _find_lookahead_point(self, lookahead_dist):
+        """
+        绠�鍖栫殑Pure Pursuit鍓嶈鐐规煡鎵� - 鍙娇鐢ㄧ偣绱㈠紩
+        
+        浠巒earest_path_idx寮�濮嬪悜鍓嶆悳绱紝鎵惧埌绗竴涓窛绂�>=lookahead_dist鐨勭偣
+        
+        Returns: (lookahead_xy, curvature, lookahead_idx, nearest_idx)
+        """
+        if self.pos is None:
+            return self.path[0], 0.0, 0, 0
+
+        x, y = self.pos
+        
+        # 1. 鎵炬渶杩戠偣 - 濡傛灉绂昏矾寰勫緢杩�,浣跨敤鍏ㄥ眬鎼滅储;鍚﹀垯浣跨敤绐楀彛鎼滅储
+        # 鍏堟鏌ュ綋鍓峮earest_path_idx鐨勮窛绂�
+        px, py = self.path[self.nearest_path_idx]
+        current_nearest_dist = hypot(px - x, py - y)
+        
+        # 濡傛灉璺濈>1.0m,浣跨敤鍏ㄥ眬鎼滅储
+        if current_nearest_dist > 1.0:
+            search_start = 0
+            search_end = len(self.path)
+        else:
+            # 浣跨敤绐楀彛鎼滅储
+            search_start = max(0, self.nearest_path_idx - 5)
+            search_end = min(len(self.path), self.nearest_path_idx + 30)
+        
+        nearest_idx = self.nearest_path_idx
+        nearest_dist = float('inf')
+        
+        for i in range(search_start, search_end):
+            px, py = self.path[i]
+            d = hypot(px - x, py - y)
+            if d < nearest_dist:
+                nearest_dist = d
+                nearest_idx = i
+        
+        self.nearest_path_idx = nearest_idx  # 鏇存柊鏈�杩戠偣绱㈠紩
+        
+        # 2. 浠巒earest_idx寮�濮嬪悜鍓嶆悳绱ookahead鐐�
+        lookahead_idx = nearest_idx
+        for i in range(nearest_idx, len(self.path)):
+            px, py = self.path[i]
+            d = hypot(px - x, py - y)
+            if d >= lookahead_dist:
+                lookahead_idx = i
+                break
+        else:
+            # 娌℃壘鍒帮紝浣跨敤鏈�鍚庝竴涓偣
+            lookahead_idx = len(self.path) - 1
+        
+        # 3. 浼拌鏇茬巼锛堝熀浜巒earest_idx闄勮繎鐨勭偣锛�
+        curvature = 0.0
+        if 1 <= nearest_idx < len(self.path) - 1:
+            p1 = self.path[nearest_idx - 1]
+            p2 = self.path[nearest_idx]
+            p3 = self.path[nearest_idx + 1]
+            
+            v1 = (p2[0] - p1[0], p2[1] - p1[1])
+            v2 = (p3[0] - p2[0], p3[1] - p2[1])
+            
+            l1 = hypot(v1[0], v1[1])
+            l2 = hypot(v2[0], v2[1])
+            
+            if l1 > 1e-6 and l2 > 1e-6:
+                dot = v1[0]*v2[0] + v1[1]*v2[1]
+                cos_angle = max(-1.0, min(1.0, dot / (l1 * l2)))
+                angle_change = abs(pi - acos(cos_angle))
+                curvature = angle_change / max(l1, 1e-6)
+        
+        return self.path[lookahead_idx], curvature, lookahead_idx, nearest_idx
+
+    def _find_nearest_original_waypoint(self):
+        """
+        鍦ㄥ師濮嬭矾寰勪腑鏌ユ壘涓嬩竴涓湭鍒拌揪鐨勬嫄鐐�
+        鎸夌収璺緞椤哄簭鏌ユ壘锛屼笉璺宠繃
+        
+        Returns: (waypoint_idx, distance) 鎴� (None, inf) 濡傛灉娌℃湁鎵惧埌
+        """
+        if not hasattr(self, 'original_path') or len(self.original_path) < 3:
+            return None, float('inf')
+        
+        x, y = self.pos
+        
+        # 閬嶅巻鍘熷璺緞涓殑鎷愮偣锛堣烦杩囪捣鐐瑰拰缁堢偣锛�
+        for i in range(1, len(self.original_path) - 1):
+            # 濡傛灉杩欎釜鎷愮偣宸茬粡杞悜杩囷紝璺宠繃
+            if i in self.waypoint_turned:
+                continue
+                
+            # 妫�鏌ユ柟鍚戞槸鍚︽敼鍙�
+            prev_pt = self.original_path[i-1]
+            curr_pt = self.original_path[i]
+            next_pt = self.original_path[i+1]
+            
+            # 璁$畻鍓嶅悗鏂瑰悜鍚戦噺
+            dx1 = curr_pt[0] - prev_pt[0]
+            dy1 = curr_pt[1] - prev_pt[1]
+            dx2 = next_pt[0] - curr_pt[0]
+            dy2 = next_pt[1] - curr_pt[1]
+            
+            # 璁$畻瑙掑害
+            angle1 = atan2(dy1, dx1)
+            angle2 = atan2(dy2, dx2)
+            angle_change = abs(wrap_angle(angle2 - angle1))
+            
+            # 濡傛灉瑙掑害鍙樺寲澶т簬45搴︼紝璁や负鏄嫄鐐�
+            if angle_change > pi / 4:
+                # 鎵惧埌绗竴涓湭瀹屾垚鐨勬嫄鐐癸紝鐩存帴杩斿洖
+                dist = hypot(curr_pt[0] - x, curr_pt[1] - y)
+                return i, dist
+        
+        return None, float('inf')
+    
+    def _get_target_heading_at_waypoint(self, waypoint_idx):
+        """
+        鑾峰彇鎷愮偣澶勭殑鐩爣鑸悜锛堜笅涓�娈佃矾寰勭殑鏂瑰悜锛�
+        
+        Args:
+            waypoint_idx: 鎷愮偣鍦ㄥ師濮嬭矾寰勪腑鐨勭储寮�
+        
+        Returns: 鐩爣鑸悜锛堝姬搴︼級
+        """
+        if waypoint_idx is None:
+            return 0.0  # 榛樿鍚戜笢
+        
+        # 濡傛灉鏄渶鍚庝竴涓偣锛屼娇鐢ㄥ墠涓�娈电殑鏂瑰悜锛堜繚鎸佷笉鍙橈級
+        if waypoint_idx >= len(self.original_path) - 1:
+            if waypoint_idx > 0:
+                prev_pt = self.original_path[waypoint_idx - 1]
+                curr_pt = self.original_path[waypoint_idx]
+                return atan2(curr_pt[1] - prev_pt[1], curr_pt[0] - prev_pt[0])
+            else:
+                return 0.0  # 鍙湁涓�涓偣锛岄粯璁ゅ悜涓�
+        
+        curr_pt = self.original_path[waypoint_idx]
+        next_pt = self.original_path[waypoint_idx + 1]
+        
+        dx = next_pt[0] - curr_pt[0]
+        dy = next_pt[1] - curr_pt[1]
+        
+        return atan2(dy, dx)
+
+    def _compute_xte_simple(self, pos):
+        """
+        绠�鍖栫殑妯悜璇樊璁$畻 - 鍩轰簬鏈�杩戠偣
+        
+        Returns: (xte, proj_x, proj_y)
+        """
+        x, y = pos
+        nearest_idx = self.nearest_path_idx
+        
+        # 璁$畻鍒版渶杩戠偣鐨勫悜閲�
+        px, py = self.path[nearest_idx]
+        
+        # 濡傛灉鏈変笅涓�涓偣锛岃绠楀埌璺緞娈电殑妯悜璇樊
+        if nearest_idx < len(self.path) - 1:
+            nx, ny = self.path[nearest_idx + 1]
+            
+            # 娈垫柟鍚戝悜閲�
+            dx = nx - px
+            dy = ny - py
+            seg_len2 = dx*dx + dy*dy
+            
+            if seg_len2 > 1e-9:
+                # 鎶曞奖鍒版涓�
+                t = ((x - px)*dx + (y - py)*dy) / seg_len2
+                t = max(0.0, min(1.0, t))  # 闄愬埗鍦╗0,1]
+                
+                proj_x = px + t * dx
+                proj_y = py + t * dy
+                
+                # 妯悜璇樊锛堝甫绗﹀彿锛�
+                xte_vec_x = x - proj_x
+                xte_vec_y = y - proj_y
+                xte_mag = hypot(xte_vec_x, xte_vec_y)
+                
+                # 浣跨敤鍙変箻鍒ゆ柇宸﹀彸
+                cross = xte_vec_x * dy - xte_vec_y * dx
+                xte = xte_mag if cross > 0 else -xte_mag
+                
+                return xte, proj_x, proj_y
+        
+        # 鍚﹀垯鐩存帴鐢ㄥ埌鏈�杩戠偣鐨勮窛绂�
+        xte = hypot(x - px, y - py)
+        return xte, px, py
+
+    def compute_control(self, timestamp=None):
+        """Compute forward and turn outputs mapped to [-100,100].
+        
+        Returns dict with keys: forward(int), turn(int), info(dict)
+        """
+        if self.finished:
+            return {'forward': 0, 'turn': 0, 'info': {'status': 'finished'}}
+
+        if self.pos is None or self.heading is None:
+            return {'forward': 0, 'turn': 0, 'info': {'status': 'no_state'}}
+            
+        # ensure timestamp is numeric for logging/formatting
+        if timestamp is None:
+            timestamp = time.time()
+
+        # 璁$畻鏃堕棿澧為噺
+        if self.last_time is None:
+            dt = 0.014  # 绾�74Hz
+        else:
+            dt = timestamp - self.last_time if timestamp else 0.014
+        self.last_time = timestamp if timestamp else (self.last_time + dt if self.last_time else 0.0)
+
+        x, y = self.pos
+        
+        # 澶勭悊绉诲姩鍒拌捣鐐归樁娈碉細浣跨敤鎸佷箙瀛愮姸鎬佺‘淇濋『搴忔墽琛岋細rotate -> move -> align
+        if self.stage == self.STAGE_GOTO_START:
+            start_point = self.path[0]
+            dx = start_point[0] - x
+            dy = start_point[1] - y
+            dist_to_start = hypot(dx, dy)
+
+            # 鐩爣鏈濆悜鎸囧悜璧风偣
+            desired_heading_to_start = atan2(dy, dx)
+            heading_err_to_start = wrap_angle(desired_heading_to_start - self.heading)
+
+            # 鍒濆鍖栧瓙鐘舵�侊紙淇濆畧锛�
+            if not hasattr(self, 'goto_substage') or self.goto_substage is None:
+                self.goto_substage = 'rotate'
+
+            # 瀛愮姸鎬侊細rotate -> move -> align
+            if self.goto_substage == 'rotate':
+                # 鍘熷湴杞悜鐩村埌鏈濆悜璇樊瓒冲灏忥紙绉婚櫎璺濈閫�鍑烘潯浠讹紝纭繚鍏堣浆濂斤級
+                if abs(heading_err_to_start) <= self.start_turn_first_heading:
+                    self.goto_substage = 'move'
+                else:
+                    yawrate_cmd = self.k_heading * heading_err_to_start * 1.3  # 绋嶅井鎻愰珮杞悜閫熷害
+                    turn_signal = int(round((yawrate_cmd / self.max_yawrate) * 100))
+                    turn_signal = max(-100, min(100, turn_signal))
+                    forward_signal = 0
+                    # target is start_point for goto_start substage
+                    target_x, target_y = start_point
+                    target_dist = hypot(target_x - x, target_y - y)
+                    target_idx = 0
+                    self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                        f'{target_x:.3f},{target_y:.3f},{target_dist:.3f},{target_idx},'
+                                        f'0.000,{heading_err_to_start:.3f},{forward_signal},{turn_signal},0.000,{self.stage},rotate_to_start\n')
+                    self.log_file.flush()
+                    return {'forward': forward_signal, 'turn': turn_signal, 'info': {
+                        'status': 'rotate_to_start', 'stage': self.stage, 'heading_err': heading_err_to_start,
+                        'dist_to_start': dist_to_start
+                    }}
+
+            if self.goto_substage == 'move':
+                # 鍦ㄧЩ鍔ㄥ瓙鐘舵�佹湡闂达紝涓嶄細鍥炲埌 rotate 瀛愮姸鎬侊紝鐩村埌鏄惧紡鍒囨崲
+                desired_heading = desired_heading_to_start
+                heading_err = wrap_angle(desired_heading - self.heading)
+
+                # 濡傛灉宸茬粡鎺ヨ繎璧风偣鍒欒繘鍏� align 瀛愮姸鎬�
+                if dist_to_start < self.start_tolerance:
+                    self.goto_substage = 'align'
+                else:
+                    # 甯歌锛氶潰鍚戣捣鐐瑰苟鍓嶈繘
+                    if dist_to_start < self.start_approach_dist:
+                        dist_ratio = dist_to_start / self.start_approach_dist
+                        speed_scale = 0.4 + 0.6 * dist_ratio  # 鎻愰珮鏈�灏忛�熷害姣斾緥
+                        base_target_speed = self.start_slow_speed + (self.base_speed - self.start_slow_speed) * dist_ratio
+                    else:
+                        speed_scale = 1.0
+                        base_target_speed = min(self.base_speed * 0.8, 0.5)  # 闄愬埗鏈�澶ч�熷害閬垮厤杩囧啿
+
+                    # 鑸悜璇樊缂╂斁鏇村鏉�
+                    heading_ratio = abs(heading_err) / pi
+                    err_scale = 0.5 + 0.5 * (1.0 - heading_ratio)  # 鏇村鏉�
+                    target_speed = max(self.start_min_speed * 1.5, base_target_speed * speed_scale * err_scale)
+                    target_speed = max(target_speed, self.start_min_speed * 1.2)  # 纭繚鏈�灏忛�熷害
+
+                    yawrate_cmd = self.k_heading * heading_err
+                    forward_signal = int(round((target_speed / self.max_forward_mps) * 100))
+                    forward_signal = max(-100, min(100, forward_signal))
+                    turn_signal = int(round((yawrate_cmd / self.max_yawrate) * 100))
+                    turn_signal = max(-100, min(100, turn_signal))
+
+                    target_x, target_y = start_point
+                    target_dist = hypot(target_x - x, target_y - y)
+                    target_idx = 0
+                    self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                      f'{target_x:.3f},{target_y:.3f},{target_dist:.3f},{target_idx},'
+                                      f'0.000,{heading_err:.3f},{forward_signal},{turn_signal},{target_speed:.3f},{self.stage},moving_to_start\n')
+                    self.log_file.flush()
+
+                    return {'forward': forward_signal, 'turn': turn_signal, 'info': {
+                        'status': 'moving_to_start', 'stage': self.stage, 'dist_to_start': dist_to_start,
+                        'heading_err': heading_err
+                    }}
+
+            # align 瀛愮姸鎬侊細鍒拌揪璧风偣鍚庤繘琛屽師鍦版湞鍚戜慨姝o紝瀹屾垚鍚庡垏鎹㈠埌璺熻釜
+            if self.goto_substage == 'align':
+                if len(self.path) > 1:
+                    path_dx = self.path[1][0] - self.path[0][0]
+                    path_dy = self.path[1][1] - self.path[0][1]
+                    desired_start_heading = atan2(path_dy, path_dx)
+                else:
+                    desired_start_heading = 0.0
+                heading_err_final = wrap_angle(desired_start_heading - self.heading)
+                # 濡傛灉鏈�缁堟湞鍚戝榻愭垨闈炲父鎺ヨ繎浣嶇疆锛屽垯鍒囨崲鍒拌窡韪�
+                # 鍙湁鍚屾椂婊¤冻浣嶇疆涓庤埅鍚戞潯浠舵墠绠楃湡姝e埌杈捐捣鐐癸紝閬垮厤鈥滃亸杩�+鏈濆悜宸�濇彁鍓嶅垏鎹�
+                if (dist_to_start < self.start_tolerance) and (abs(heading_err_final) < self.start_heading_tolerance):
+                    self.stage = self.STAGE_FOLLOW_PATH
+                    self.goto_substage = None
+                    # 淇濇寔 current_target_idx = 0锛岃 pure pursuit 鑷劧閫夋嫨鍓嶈鐐�
+                    self.current_target_idx = 0
+                    # 閲嶇疆閫熷害涓鸿緝灏忓�硷紝閬垮厤 lookahead 杩囧ぇ
+                    self.current_speed = self.start_min_speed
+                    # 灏忓箙鍓嶈繘浠ョ‘淇濅笉鍗″湪璧风偣
+                    f_sig = int(round((self.start_min_speed / self.max_forward_mps) * 100))
+                    target_x, target_y = self.path[0]  # 浣跨敤璧风偣浣滀负鍒濆鐩爣
+                    target_dist = hypot(target_x - x, target_y - y)
+                    target_idx = 0
+                    self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                        f'{target_x:.3f},{target_y:.3f},{target_dist:.3f},{target_idx},'
+                                        f'0.000,{heading_err_final:.3f},{f_sig},0,{self.start_min_speed:.3f},{self.stage},reached_start\n')
+                    self.log_file.flush()
+                    return {'forward': f_sig, 'turn': 0, 'info': {'status': 'reached_start', 'stage': self.stage, 'advanced_idx': self.current_target_idx}}
+                else:
+                    # 鍦ㄨ捣鐐归檮杩戜絾鏈濆悜涓嶅锛岃繘琛屽師鍦颁慨姝o紙鍋滄鍓嶈繘锛�
+                    sign = 1 if heading_err_final >= 0 else -1
+                    yawrate_cmd = sign * min(self.max_yawrate * 0.6, max(0.05, abs(heading_err_final) * 0.8))
+                    turn_signal = int(round((yawrate_cmd / self.max_yawrate) * 100))
+                    turn_signal = max(-100, min(100, turn_signal))
+                    target_x, target_y = start_point
+                    target_dist = hypot(target_x - x, target_y - y)
+                    target_idx = 0
+                    self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                        f'{target_x:.3f},{target_y:.3f},{target_dist:.3f},{target_idx},'
+                                        f'0.000,{heading_err_final:.3f},0,{turn_signal},{0.000:.3f},{self.stage},align_at_start\n')
+                    self.log_file.flush()
+                    return {'forward': 0, 'turn': turn_signal, 'info': {'status': 'align_at_start', 'heading_err': heading_err_final}}
+
+        # 璁$畻鏃堕棿澧為噺
+        if self.last_time is None:
+            dt = 0.014  # 绾�74Hz
+        else:
+            dt = timestamp - self.last_time if timestamp else 0.014
+        self.last_time = timestamp if timestamp else (self.last_time + dt if self.last_time else 0.0)
+
+        # 璁$畻妯悜璇樊锛堜娇鐢ㄧ畝鍖栨柟娉曪級
+        x, y = self.pos
+        xte, proj_x, proj_y = self._compute_xte_simple(self.pos)
+
+        # 棰勫厛璁$畻鍒扮粓鐐圭殑璺濈
+        final_dist = hypot(self.path[-1][0] - x, self.path[-1][1] - y)
+        
+        # ========== 鎷愮偣鎺ヨ繎鍜岃浆鍚戞ā寮忔娴� ==========
+        # 妫�鏌ユ槸鍚﹂渶瑕佽繘鍏ユ嫄鐐规帴杩戞垨杞悜妯″紡
+        if not self.waypoint_approach_mode and not self.waypoint_turn_mode:
+            # 鍦ㄥ師濮嬭矾寰勪腑鏌ユ壘鏈�杩戠殑鎷愮偣
+            waypoint_idx, waypoint_dist = self._find_nearest_original_waypoint()
+            
+            # 濡傛灉鎷愮偣鍦ㄦ帴杩戣窛绂诲唴锛�2m锛変笖鏈浆鍚戣繃锛岃繘鍏ユ帴杩戞ā寮�
+            # 娉ㄦ剰锛氬澶ф娴嬭窛绂伙紝纭繚鍦ㄧ煭杈逛篃鑳芥彁鍓嶈繘鍏ユ帴杩戞ā寮�
+            if (waypoint_idx is not None and 
+                waypoint_dist < 2.0 and
+                waypoint_idx not in self.waypoint_turned):
+                self.waypoint_approach_mode = True
+                self.waypoint_turn_target_idx = waypoint_idx  # 璁板綍褰撳墠鎺ヨ繎鐨勬嫄鐐�
+                self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                    f'{self.original_path[waypoint_idx][0]:.3f},'
+                                    f'{self.original_path[waypoint_idx][1]:.3f},'
+                                    f'{waypoint_dist:.3f},{waypoint_idx},'
+                                    f'{xte:.3f},0.000,0,0,0.000,{self.stage},enter_waypoint_approach\n')
+                self.log_file.flush()
+        
+        # ========== 鎷愮偣鎺ヨ繎妯″紡锛氱簿纭帴杩戞嫄鐐逛綅缃� ==========
+        if self.waypoint_approach_mode:
+            waypoint_x, waypoint_y = self.original_path[self.waypoint_turn_target_idx]
+            dx = waypoint_x - x
+            dy = waypoint_y - y
+            waypoint_dist = hypot(dx, dy)
+            
+            # 妫�鏌ユ槸鍚﹀埌杈炬嫄鐐癸紙12cm瀹瑰樊锛�
+            if waypoint_dist < self.waypoint_turn_tolerance:
+                # 鍒拌揪鎷愮偣锛屽垏鎹㈠埌杞悜妯″紡
+                self.waypoint_approach_mode = False
+                self.waypoint_turn_mode = True
+                self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                    f'{waypoint_x:.3f},{waypoint_y:.3f},'
+                                    f'{waypoint_dist:.3f},{self.waypoint_turn_target_idx},'
+                                    f'{xte:.3f},0.000,0,0,0.000,{self.stage},reached_waypoint\n')
+                self.log_file.flush()
+            else:
+                # 鐩存帴鏈濆悜鎷愮偣鍓嶈繘锛堝拷鐣ヨ矾寰勶紝鐩存帴鐩爣鐐瑰鑸級
+                desired_heading_to_waypoint = atan2(dy, dx)
+                heading_err_to_waypoint = wrap_angle(desired_heading_to_waypoint - self.heading)
+                
+                # 閫熷害鎺у埗锛氭帴杩戞椂閫愭笎鍑忛��
+                if waypoint_dist < 0.5:  # 50cm鍐呭噺閫�
+                    speed_ratio = waypoint_dist / 0.5
+                    target_speed = self.start_min_speed * (0.4 + 0.6 * speed_ratio)
+                else:
+                    target_speed = self.base_speed * 0.7
+                
+                # 鑸悜璇樊涔熶細闄嶄綆閫熷害
+                heading_ratio = abs(heading_err_to_waypoint) / pi
+                err_scale = 0.5 + 0.5 * (1.0 - heading_ratio)
+                target_speed = target_speed * err_scale
+                target_speed = max(self.start_min_speed * 0.5, target_speed)
+                
+                # 杞悜鎺у埗
+                yawrate_cmd = self.k_heading * heading_err_to_waypoint * 1.3  # 绋嶅井鎻愰珮杞悜澧炵泭
+                
+                forward_signal = int(round((target_speed / self.max_forward_mps) * 100))
+                forward_signal = max(-100, min(100, forward_signal))
+                turn_signal = int(round((yawrate_cmd / self.max_yawrate) * 100))
+                turn_signal = max(-100, min(100, turn_signal))
+                
+                self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                    f'{waypoint_x:.3f},{waypoint_y:.3f},'
+                                    f'{waypoint_dist:.3f},{self.waypoint_turn_target_idx},'
+                                    f'{xte:.3f},{heading_err_to_waypoint:.3f},{forward_signal},{turn_signal},{target_speed:.3f},{self.stage},approaching_waypoint\n')
+                self.log_file.flush()
+                
+                return {'forward': forward_signal, 'turn': turn_signal, 'info': {
+                    'status': 'approaching_waypoint',
+                    'waypoint_dist': waypoint_dist,
+                    'heading_err': heading_err_to_waypoint,
+                    'target_speed': target_speed
+                }}
+        
+        # ========== 鎷愮偣杞悜妯″紡锛氬師鍦拌浆鍚� ==========
+        if self.waypoint_turn_mode:
+            # 鑾峰彇鐩爣鑸悜锛堜笅涓�娈电殑鏂瑰悜锛�
+            target_heading = self._get_target_heading_at_waypoint(self.waypoint_turn_target_idx)
+            heading_err = wrap_angle(target_heading - self.heading)
+            
+            # 妫�鏌ユ槸鍚﹁浆鍚戝畬鎴�
+            if abs(heading_err) < self.waypoint_turn_heading_tol:
+                # 杞悜瀹屾垚锛岄��鍑鸿浆鍚戞ā寮�
+                self.waypoint_turned.add(self.waypoint_turn_target_idx)  # 璁板綍宸茶浆鍚�
+                self.waypoint_turn_mode = False
+                self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                    f'{self.original_path[self.waypoint_turn_target_idx][0]:.3f},'
+                                    f'{self.original_path[self.waypoint_turn_target_idx][1]:.3f},'
+                                    f'0.000,{self.waypoint_turn_target_idx},'
+                                    f'{xte:.3f},{heading_err:.3f},0,0,0.000,{self.stage},exit_waypoint_turn\n')
+                self.log_file.flush()
+            else:
+                # 鍘熷湴杞悜锛歠orward=0, turn鍩轰簬鑸悜璇樊
+                yawrate_cmd = self.k_heading * heading_err * 1.2  # 绋嶅井鎻愰珮杞悜澧炵泭
+                turn_signal = int(round((yawrate_cmd / self.max_yawrate) * 100))
+                turn_signal = max(-100, min(100, turn_signal))
+                
+                self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                    f'{self.original_path[self.waypoint_turn_target_idx][0]:.3f},'
+                                    f'{self.original_path[self.waypoint_turn_target_idx][1]:.3f},'
+                                    f'0.000,{self.waypoint_turn_target_idx},'
+                                    f'{xte:.3f},{heading_err:.3f},0,{turn_signal},0.000,{self.stage},waypoint_turning\n')
+                self.log_file.flush()
+                
+                return {'forward': 0, 'turn': turn_signal, 'info': {
+                    'status': 'waypoint_turning',
+                    'heading_err': heading_err,
+                    'target_heading': target_heading
+                }}
+
+        # 鍔ㄦ�佸墠瑙嗚窛绂伙細鍩轰簬褰撳墠閫熷害鍜屾í鍚戣宸�
+        actual_speed = abs(self.current_speed)
+        xte_mag = abs(xte)
+        
+        # lookahead = lookahead_min + speed_component - xte_penalty
+        base_lookahead = self.lookahead_min + self.lookahead_time * actual_speed - 0.3 * min(xte_mag, 0.5)
+        lookahead = max(self.lookahead_min, min(self.lookahead_max, base_lookahead))
+
+        # 鎭㈠妯″紡鍒ゅ畾
+        dist_to_path = abs(xte)
+        if not self.in_recovery and dist_to_path > self.recovery_dist:
+            self.in_recovery = True
+            self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                f'{proj_x:.3f},{proj_y:.3f},{dist_to_path:.3f},{self.nearest_path_idx},'
+                                f'{xte:.3f},0.000,0,0,0.000,{self.stage},enter_recovery\n')
+            self.log_file.flush()
+        
+        if self.in_recovery and dist_to_path < self.recovery_exit_dist:
+            self.in_recovery = False
+            self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                f'{proj_x:.3f},{proj_y:.3f},{dist_to_path:.3f},{self.nearest_path_idx},'
+                                f'{xte:.3f},0.000,0,0,0.000,{self.stage},exit_recovery\n')
+            self.log_file.flush()
+
+        # ========== 鏍囧噯 Pure Pursuit 鎺у埗 ==========
+        # 閫夋嫨鍓嶈鐐�
+        (lx, ly), path_curvature, lookahead_idx, nearest_idx = self._find_lookahead_point(lookahead)
+        self.current_target_idx = lookahead_idx
+        
+        # 璁$畻鍓嶈鐐规柟鍚�
+        path_heading = atan2(ly - y, lx - x)
+        
+        # 妯悜璇樊淇
+        xte_normalized = max(-1.0, min(1.0, xte / self.max_xte))
+        xte_correction = -self.k_xte * xte_normalized * self.xte_gain_scale
+        
+        # 鏈熸湜鑸悜 = 鍓嶈鏂瑰悜 + xte淇
+        desired_heading = wrap_angle(path_heading + xte_correction)
+        heading_err = wrap_angle(desired_heading - self.heading)
+        
+        # 璁$畻鑸悜璇樊鐨勫彉鍖栫巼
+        heading_err_d = (heading_err - self.last_heading_err) / dt if dt > 0 else 0
+        self.last_heading_err = heading_err
+        
+        # 绉垎椤硅绠楋紙甯﹂槻楗卞拰锛�
+        self.heading_err_sum += heading_err * dt
+        self.xte_sum += xte * dt
+        self.heading_err_sum = max(-self.max_sum, min(self.max_sum, self.heading_err_sum))
+        self.xte_sum = max(-self.max_sum, min(self.max_sum, self.xte_sum))
+        
+        # PID鎺у埗
+        yawrate_cmd = (self.k_heading * heading_err + 
+                      self.k_heading_d * heading_err_d +
+                      self.k_heading_i * self.heading_err_sum +
+                      self.k_xte_i * self.xte_sum)
+        
+        # 閫熷害鎺у埗锛氬熀浜庢洸鐜囥�佽埅鍚戣宸�佹í鍚戣宸�
+        # 1) 鏇茬巼缂╂斁
+        curv_scale = 1.0 / (1.0 + abs(path_curvature) * self.curv_gain_scale)
+        
+        # 2) 鑸悜璇樊缂╂斁
+        heading_err_abs = abs(heading_err)
+        err_scale = 1.0 / (1.0 + heading_err_abs * self.heading_err_gain_scale)
+        
+        # 3) 妯悜璇樊缂╂斁
+        xte_abs = abs(xte)
+        xte_scale = 1.0 / (1.0 + xte_abs * self.xte_gain_scale)
+        
+        # 娣峰悎缂╂斁锛氬嚑浣曞钩鍧� + 鏈�灏忓��
+        geom_scale = (curv_scale * err_scale * xte_scale) ** (1.0/3.0)
+        min_scale = min(curv_scale, err_scale, xte_scale)
+        blend_scale = 0.8 * geom_scale + 0.2 * min_scale
+        
+        target_speed = self.base_speed * blend_scale
+        
+        # 婊ゆ尝鍜岄檺骞�
+        self.yawrate_filter = (self.yawrate_alpha * yawrate_cmd + 
+                              (1-self.yawrate_alpha) * self.yawrate_filter)
+        max_yawrate = self.max_yawrate
+        yawrate_cmd = max(-max_yawrate, min(max_yawrate, self.yawrate_filter))
+        
+        # 鎭㈠妯″紡瑕嗙洊锛堜紭鍏堢骇鏇撮珮锛�
+        if self.in_recovery:
+            # 鎭㈠妯″紡锛氭湞鍚戞姇褰辩偣
+            path_heading = atan2(proj_y - y, proj_x - x)
+            xte_normalized = max(-1.0, min(1.0, xte / self.max_xte))
+            xte_correction = -self.k_xte * xte_normalized * self.xte_gain_scale
+            desired_heading = wrap_angle(path_heading + xte_correction)
+            heading_err = wrap_angle(desired_heading - self.heading)
+            yawrate_cmd = self.k_heading * heading_err
+            target_speed = self.recovery_speed
+        # 鍩轰簬璺濈缁堢偣鐨勮窛绂昏皟鏁撮�熷害
+        in_slow_zone = final_dist < self.slow_zone
+        in_final_approach = final_dist < self.final_approach_dist
+        
+        # 閿欒鎭㈠閫昏緫
+        if abs(xte) > self.large_err_threshold * 1.5 or abs(heading_err) > pi/2.5:
+            self.consecutive_large_err += 1
+            if self.consecutive_large_err > self.max_large_err_count:
+                # 閲嶇疆鍒板畨鍏ㄧ姸鎬�
+                self.consecutive_large_err = 0
+                self.heading_err_sum = 0
+                self.xte_sum = 0
+        else:
+            self.consecutive_large_err = 0
+
+        # 鎺ヨ繎缁堢偣鏃惰皟鏁撮�熷害
+        if not self.in_recovery:
+            if in_slow_zone:
+                dist_scale = max(0.3, min(1.0, final_dist / self.slow_zone))
+                if in_final_approach:
+                    dist_scale *= max(0.4, final_dist / self.final_approach_dist)
+                target_speed *= dist_scale
+            
+            # 鍦ㄧ粓鐐归檮杩戝鍔犵簿搴�
+            if in_final_approach:
+                target_speed *= 0.6
+                if abs(xte) > 0.15 or abs(heading_err) > pi/10:
+                    target_speed *= 0.7
+
+        # 寮哄埗鏈�灏忛�熷害锛堥伩鍏嶅畬鍏ㄥ仠婊烇級
+        if self.stage == self.STAGE_FOLLOW_PATH and not self.in_recovery:
+            dynamic_min = self.min_follow_speed if in_slow_zone else self.min_follow_speed * 1.5
+            if abs(heading_err) < 3.0 and target_speed < dynamic_min:
+                target_x, target_y = lx, ly
+                target_dist = hypot(target_x - x, target_y - y)
+                self.log_file.write(f'{timestamp:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                    f'{target_x:.3f},{target_y:.3f},{target_dist:.3f},{self.current_target_idx},'
+                                    f'{xte:.3f},{heading_err:.3f},0,0,'
+                                    f'{dynamic_min:.3f},{self.stage},enforce_min_speed\n')
+                self.log_file.flush()
+                target_speed = dynamic_min
+
+        # 杞惎鍔細闄愬埗鍔犻�熷害
+        accel = self.accel * (0.5 if in_slow_zone else 1.0)
+        speed_change = target_speed - self.current_speed
+        max_change = accel * dt
+        self.current_speed += max(-max_change, min(max_change, speed_change))
+
+        # 鍊掕溅鎺у埗
+        if self.stage == self.STAGE_GOTO_START:
+            if abs(heading_err) > 2.9:
+                self.current_speed = -self.max_reverse_mps * 0.5
+        else:
+            if abs(heading_err) > 2.9 and not self.in_recovery:
+                self.current_speed = -self.max_reverse_mps * 0.4
+            if self.current_speed < 0 and abs(heading_err) < 2.5:
+                self.current_speed = 0.0
+
+        # 缁堢偣妫�娴嬶紙绠�鍖栵紝鍙熀浜庤窛绂诲拰鐐圭储寮曪級
+        if final_dist < self.goal_tolerance:
+            self.finished = True
+            return {'forward': 0, 'turn': 0, 'info': {'status': 'finished'}}
+        
+        # 鎵╁睍瀹屾垚鏉′欢锛氭帴杩戠粓鐐逛笖璇樊灏�
+        if (final_dist < self.goal_tolerance * 1.5 and 
+            self.current_target_idx >= self.n - 2 and
+            abs(heading_err) < 0.3 and abs(xte) < 0.2):
+            self.finished = True
+            return {'forward': 0, 'turn': 0, 'info': {'status': 'finished'}}
+
+        # map to signals [-100,100]
+        # 璁板綍鏄犲皠鍓嶇殑閫熷害淇℃伅浠ヤ究璇婃柇
+        log_t = timestamp if timestamp is not None else time.time()
+        pre_map_speed = self.current_speed
+        # 璇婃柇鏃ュ織锛堢畝鍖栵級
+        target_x, target_y = lx, ly
+        target_dist = hypot(target_x - x, target_y - y)
+        pre_map_forward = int(round((pre_map_speed / self.max_forward_mps) * 100)) if self.max_forward_mps != 0 else 0
+        pre_map_forward = max(-100, min(100, pre_map_forward))
+        pre_map_turn = int(round((yawrate_cmd / self.max_yawrate) * 100)) if self.max_yawrate != 0 else 0
+        pre_map_turn = max(-100, min(100, pre_map_turn))
+        self.log_file.write(f'{log_t:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                            f'{target_x:.3f},{target_y:.3f},{target_dist:.3f},{self.current_target_idx},'
+                            f'{xte:.3f},{heading_err:.3f},{pre_map_forward},{pre_map_turn},{pre_map_speed:.3f},'
+                            f'{self.stage},pre_map\n')
+        self.log_file.flush()
+
+        forward_signal = int(round((self.current_speed / self.max_forward_mps) * 100)) if self.max_forward_mps != 0 else 0
+        forward_signal = max(-100, min(100, forward_signal))
+
+        # 寮哄埗鏈�灏忓墠杩涗俊鍙凤紙闃叉瀹屽叏鍋滄锛�
+        if forward_signal == 0 and not self.finished and abs(heading_err) < 3.0:
+            forced_signal = int(round((self.min_follow_speed / self.max_forward_mps) * 100)) if self.max_forward_mps != 0 else 0
+            if forced_signal != 0:
+                target_x, target_y = lx, ly
+                target_dist = hypot(target_x - x, target_y - y)
+                self.log_file.write(f'{log_t:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                                    f'{target_x:.3f},{target_y:.3f},{target_dist:.3f},{self.current_target_idx},'
+                                    f'{xte:.3f},{heading_err:.3f},{forced_signal},{int(round((yawrate_cmd / self.max_yawrate) * 100))},'
+                                    f'{self.min_follow_speed:.3f},{self.stage},force_min_forward\n')
+                self.log_file.flush()
+                forward_signal = forced_signal
+                self.current_speed = self.min_follow_speed
+
+        turn_signal = int(round((yawrate_cmd / self.max_yawrate) * 100)) if self.max_yawrate != 0 else 0
+        turn_signal = max(-100, min(100, turn_signal))
+
+        status = 'finished' if self.finished else 'running'
+        info = {
+            'status': status,
+            'pos': (x, y),
+            'heading': self.heading,
+            'desired_heading': desired_heading,
+            'heading_err': heading_err,
+            'current_speed': self.current_speed,
+            'yawrate_cmd': yawrate_cmd,
+            'target_xy': (lx, ly),
+            'path_curvature': path_curvature,
+            'final_dist': final_dist,
+        }
+
+        # 绠�鍖栫殑鏃ュ織璁板綍
+        t = log_t
+        target_x, target_y = lx, ly
+        target_dist = hypot(target_x - x, target_y - y)
+        self.log_file.write(f'{t:.3f},{x:.3f},{y:.3f},{self.heading:.3f},'
+                            f'{target_x:.3f},{target_y:.3f},{target_dist:.3f},{self.current_target_idx},'
+                            f'{xte:.3f},{heading_err:.3f},{forward_signal},{turn_signal},{self.current_speed:.3f},'
+                            f'{self.stage},{status}\n')
+        self.log_file.flush()
+
+        return {'forward': forward_signal, 'turn': turn_signal, 'info': info}
+
+
+if __name__ == '__main__':
+    # quick manual test
+    import json
+    path = [[0,0],[5,0],[10,0]]
+    c = MowerController(path)
+    c.update_gps((0, -1.0), 0.0)
+    for i in range(20):
+        out = c.compute_control()
+        print(i, out)
+        # fake forward motion
+        f = out['forward'] / 100.0 * c.max_forward_mps
+        hd = c.heading + (out['turn'] / 100.0) * c.max_yawrate * 0.1
+        px = c.pos[0] + f * cos(hd) * 0.1
+        py = c.pos[1] + f * sin(hd) * 0.1
+        c.update_gps((px,py), hd)
diff --git a/python/path_planner.py b/python/path_planner.py
new file mode 100644
index 0000000..17ec239
--- /dev/null
+++ b/python/path_planner.py
@@ -0,0 +1,164 @@
+"""
+Simple path planner to generate approach path from mower start position to planned path start.
+"""
+import numpy as np
+from math import hypot, atan2, cos, sin, pi
+
+
+def plan_approach_path(start_pos, start_heading, target_pos, target_heading, method='line'):
+    """
+    Generate approach path from mower start to planned path start.
+    
+    Args:
+        start_pos: (x, y) mower starting position
+        start_heading: mower starting heading in radians
+        target_pos: (x, y) planned path starting position
+        target_heading: planned path starting heading in radians
+        method: 'line' | 'dubins' | 'smooth'
+    
+    Returns:
+        List of (x, y) waypoints forming the approach path
+    """
+    sx, sy = start_pos
+    tx, ty = target_pos
+    dist = hypot(tx - sx, ty - sy)
+    
+    if dist < 0.05:  # Already at start, no approach needed
+        return [start_pos, target_pos]
+    
+    if method == 'line':
+        # Simple straight line with waypoints every 0.5m
+        num_points = max(3, int(dist / 0.5) + 1)
+        path = []
+        for i in range(num_points):
+            t = i / (num_points - 1)
+            x = sx + t * (tx - sx)
+            y = sy + t * (ty - sy)
+            path.append((x, y))
+        return path
+    
+    elif method == 'smooth':
+        # Smooth curve considering start and target headings
+        # Use cubic bezier curve
+        # Control points based on headings
+        ctrl_dist = min(dist * 0.4, 2.0)  # Control point distance
+        
+        # Start control point along start heading
+        c1x = sx + ctrl_dist * cos(start_heading)
+        c1y = sy + ctrl_dist * sin(start_heading)
+        
+        # End control point along target heading (backwards)
+        c2x = tx - ctrl_dist * cos(target_heading)
+        c2y = ty - ctrl_dist * sin(target_heading)
+        
+        # Generate bezier curve points
+        num_points = max(5, int(dist / 0.3) + 1)
+        path = []
+        for i in range(num_points):
+            t = i / (num_points - 1)
+            # Cubic bezier formula
+            x = (1-t)**3 * sx + 3*(1-t)**2*t * c1x + 3*(1-t)*t**2 * c2x + t**3 * tx
+            y = (1-t)**3 * sy + 3*(1-t)**2*t * c1y + 3*(1-t)*t**2 * c2y + t**3 * ty
+            path.append((x, y))
+        return path
+    
+    else:
+        raise ValueError(f"Unknown method: {method}")
+
+
+def compute_path_heading(path, idx=0):
+    """Compute heading of path at given index."""
+    if idx >= len(path) - 1:
+        idx = len(path) - 2
+    if idx < 0:
+        idx = 0
+    
+    dx = path[idx + 1][0] - path[idx][0]
+    dy = path[idx + 1][1] - path[idx][1]
+    return atan2(dy, dx)
+
+
+def combine_paths(approach_path, work_path):
+    """
+    Combine approach path and work path into single path with metadata.
+    
+    Returns:
+        combined_path: List of (x, y) waypoints
+        approach_end_idx: Index where approach path ends (work path starts)
+    """
+    # Remove duplicate point at junction if exists
+    if len(approach_path) > 0 and len(work_path) > 0:
+        if hypot(approach_path[-1][0] - work_path[0][0], 
+                 approach_path[-1][1] - work_path[0][1]) < 0.01:
+            combined = approach_path[:-1] + work_path
+            approach_end_idx = len(approach_path) - 1
+        else:
+            combined = approach_path + work_path
+            approach_end_idx = len(approach_path)
+    else:
+        combined = approach_path + work_path
+        approach_end_idx = len(approach_path)
+    
+    return combined, approach_end_idx
+
+
+if __name__ == '__main__':
+    # Test path planning
+    import json
+    import matplotlib.pyplot as plt
+    
+    # Load planned path
+    with open('example_path.json', 'r') as f:
+        work_path = json.load(f)
+    
+    # Mower starts 2m away from path start
+    mower_start = (work_path[0][0] - 1.5, work_path[0][1] - 1.5)
+    mower_heading = pi / 4  # 45 degrees
+    
+    # Plan path heading
+    plan_heading = compute_path_heading(work_path, 0)
+    
+    # Generate approach paths with different methods
+    approach_line = plan_approach_path(mower_start, mower_heading, 
+                                       work_path[0], plan_heading, 'line')
+    approach_smooth = plan_approach_path(mower_start, mower_heading,
+                                        work_path[0], plan_heading, 'smooth')
+    
+    # Plot comparison
+    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
+    
+    # Line method
+    ax1.plot([p[0] for p in work_path], [p[1] for p in work_path], 
+             'b-', linewidth=2, label='Work Path')
+    ax1.plot([p[0] for p in approach_line], [p[1] for p in approach_line],
+             'r--', linewidth=2, marker='o', markersize=4, label='Approach (Line)')
+    ax1.plot(mower_start[0], mower_start[1], 'go', markersize=10, label='Mower Start')
+    ax1.arrow(mower_start[0], mower_start[1], 0.3*cos(mower_heading), 0.3*sin(mower_heading),
+              head_width=0.15, head_length=0.1, fc='g', ec='g')
+    ax1.set_xlabel('X (m)')
+    ax1.set_ylabel('Y (m)')
+    ax1.set_title('Line Approach')
+    ax1.legend()
+    ax1.grid(True)
+    ax1.axis('equal')
+    
+    # Smooth method
+    ax2.plot([p[0] for p in work_path], [p[1] for p in work_path],
+             'b-', linewidth=2, label='Work Path')
+    ax2.plot([p[0] for p in approach_smooth], [p[1] for p in approach_smooth],
+             'r--', linewidth=2, marker='o', markersize=4, label='Approach (Smooth)')
+    ax2.plot(mower_start[0], mower_start[1], 'go', markersize=10, label='Mower Start')
+    ax2.arrow(mower_start[0], mower_start[1], 0.3*cos(mower_heading), 0.3*sin(mower_heading),
+              head_width=0.15, head_length=0.1, fc='g', ec='g')
+    ax2.set_xlabel('X (m)')
+    ax2.set_ylabel('Y (m)')
+    ax2.set_title('Smooth Approach')
+    ax2.legend()
+    ax2.grid(True)
+    ax2.axis('equal')
+    
+    plt.tight_layout()
+    plt.savefig('approach_path_test.png', dpi=150)
+    print("Saved approach_path_test.png")
+    print(f"Line approach: {len(approach_line)} points")
+    print(f"Smooth approach: {len(approach_smooth)} points")
diff --git a/python/sim_offline.py b/python/sim_offline.py
new file mode 100644
index 0000000..04f2a38
--- /dev/null
+++ b/python/sim_offline.py
@@ -0,0 +1,241 @@
+"""
+Offline simulation and path visualization
+- Run complete trajectory with approach path
+- Plot final path and control signals
+- Save detailed logs for analysis
+"""
+import json
+import time
+from math import cos, sin, pi, hypot, atan2
+import numpy as np
+import matplotlib
+# use non-interactive backend to avoid blocking GUI (run headless)
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+from mower_controller import MowerController, wrap_angle
+from path_planner import plan_approach_path, compute_path_heading, combine_paths
+
+
+def simulate_full_path(work_path, mower_start_pos, mower_start_heading, params=None, 
+                       approach_method='smooth'):
+    """
+    Run complete simulation with approach path and return state history.
+    
+    Args:
+        work_path: Planned work path [(x, y), ...]
+        mower_start_pos: (x, y) mower starting position
+        mower_start_heading: mower starting heading in radians
+        params: Controller parameters dict
+        approach_method: 'line' or 'smooth'
+    """
+    # Generate approach path from mower start to work path start
+    work_path_heading = compute_path_heading(work_path, 0)
+    approach_path = plan_approach_path(mower_start_pos, mower_start_heading,
+                                       work_path[0], work_path_heading, 
+                                       method=approach_method)
+    
+    # Combine paths
+    combined_path, approach_end_idx = combine_paths(approach_path, work_path)
+    
+    print(f"Generated approach path: {len(approach_path)} points")
+    print(f"Work path: {len(work_path)} points")
+    print(f"Combined path: {len(combined_path)} points (approach ends at idx {approach_end_idx})")
+    
+    # Initialize controller with combined path
+    ctrl = MowerController(combined_path, params=params)
+    
+    # Initial state
+    x, y = mower_start_pos
+    heading = mower_start_heading
+    t = 0.0
+    dt = 1.0/74.0  # control period
+    
+    # History for plotting
+    history = {
+        't': [0.0],
+        'x': [x],
+        'y': [y],
+        'heading': [heading],
+        'forward': [0],
+        'turn': [0],
+        'approach_end_idx': approach_end_idx,  # Store for visualization
+        'approach_path': approach_path,
+        'work_path': work_path,
+    }
+    
+    finished = False
+    while not finished and t < 300.0:  # timeout after 300s
+        # Update controller
+        ctrl.update_gps((x, y), heading)
+        ctrl.update_imu((0,0,0), (0,0,0))
+        
+        # Get control signals
+        out = ctrl.compute_control(t)
+        f_sig = out['forward']
+        t_sig = out['turn']
+        
+        # Convert to physical quantities
+        f_mps = (f_sig / 100.0) * ctrl.max_forward_mps
+        yawrate = (t_sig / 100.0) * ctrl.max_yawrate
+        
+        # Update state
+        heading = wrap_angle(heading + yawrate * dt)
+        x = x + f_mps * cos(heading) * dt
+        y = y + f_mps * sin(heading) * dt
+        t += dt
+        
+        # Record
+        history['t'].append(t)
+        history['x'].append(x)
+        history['y'].append(y)
+        history['heading'].append(heading)
+        history['forward'].append(f_sig)
+        history['turn'].append(t_sig)
+        
+        # Check completion
+        if out['info'].get('status') == 'finished':
+            finished = True
+            print(f'Reached goal in {t:.1f}s')
+    
+    if not finished:
+        print('Timeout!')
+    
+    return history
+
+
+def plot_results(history):
+    """Plot path following results with approach and work paths."""
+    approach_path = history['approach_path']
+    work_path = history['work_path']
+    approach_end_idx = history['approach_end_idx']
+    
+    fig = plt.figure(figsize=(15, 10))
+    gs = fig.add_gridspec(3, 2)
+    
+    # Path plot (larger, spans two rows)
+    ax_path = fig.add_subplot(gs[0:2, 0])
+    
+    # Plot approach path (dashed orange)
+    apx = [p[0] for p in approach_path]
+    apy = [p[1] for p in approach_path]
+    ax_path.plot(apx, apy, '--', color='orange', lw=2, label='Approach Path', alpha=0.7)
+    ax_path.scatter(apx, apy, c='orange', s=15, alpha=0.5)
+    
+    # Plot work path (solid black)
+    wpx = [p[0] for p in work_path]
+    wpy = [p[1] for p in work_path]
+    ax_path.plot(wpx, wpy, '-k', lw=2, label='Work Path')
+    ax_path.scatter(wpx, wpy, c='k', s=20)
+    
+    # Plot actual trajectory
+    ax_path.plot(history['x'], history['y'], '-r', lw=1.5, label='Actual', alpha=0.8)
+    
+    # Add markers
+    ax_path.plot(history['x'][0], history['y'][0], 'go', markersize=12, 
+                label='Mower Start', zorder=10)
+    ax_path.plot(work_path[0][0], work_path[0][1], 'bs', markersize=10,
+                label='Work Path Start', zorder=10)
+    ax_path.plot(history['x'][-1], history['y'][-1], 'ro', markersize=10,
+                label='End', zorder=10)
+    
+    ax_path.set_aspect('equal')
+    ax_path.grid(True, alpha=0.3)
+    ax_path.set_xlabel('X (m)')
+    ax_path.set_ylabel('Y (m)')
+    ax_path.set_title('Path Following Results (Approach + Work)')
+    ax_path.legend(loc='best')
+    
+    # XY position vs time
+    ax_xy = fig.add_subplot(gs[0:2, 1])
+    ax_xy.plot(history['t'], history['x'], '-b', label='X')
+    ax_xy.plot(history['t'], history['y'], '-r', label='Y')
+    ax_xy.set_xlabel('Time (s)')
+    ax_xy.set_ylabel('Position (m)')
+    ax_xy.grid(True)
+    ax_xy.legend()
+    
+    # Control signals
+    ax_ctrl = fig.add_subplot(gs[2, :])
+    ax_ctrl.plot(history['t'], history['forward'], '-b', label='Forward')
+    ax_ctrl.plot(history['t'], history['turn'], '-r', label='Turn')
+    ax_ctrl.set_xlabel('Time (s)')
+    ax_ctrl.set_ylabel('Control Signal')
+    ax_ctrl.grid(True)
+    ax_ctrl.legend()
+    
+    plt.tight_layout()
+    plt.savefig('simulation_results.png', dpi=300, bbox_inches='tight')
+    plt.show()
+
+
+def main():
+    # Load planned work path
+    try:
+        with open('example_path.json', 'r') as f:
+            work_path = json.load(f)
+    except Exception as e:
+        print(f'Failed to load path: {e}')
+        return
+    
+    # Define mower starting position (offset from work path start)
+    mower_start_pos = (work_path[0][0] + 3.5, work_path[0][1] + 1.0)
+    mower_start_heading = pi / 4  # 45 degrees
+    
+    print(f"Mower starts at: {mower_start_pos}, heading: {mower_start_heading:.2f} rad")
+    print(f"Work path starts at: {work_path[0]}")
+    
+    # Run simulation with stable high-precision parameters
+    params = {
+        # === 鏍稿績鎺у埗澧炵泭锛堜慨姝e悗锛� ===
+        'k_heading': 2.0,            # 鑸悜澧炵泭
+        'k_xte': 0.8,                # 妯悜璇樊澧炵泭锛�2.0 鈫� 0.8 (澶у箙闄嶄綆浠ラ槻姝㈣繃搴︿慨姝�)
+        'k_heading_d': 0.1,          # 鑸悜寰垎澧炵泭
+        'k_heading_i': 0.015,        # 鑸悜绉垎澧炵泭
+        'k_xte_i': 0.02,             # 妯悜绉垎澧炵泭
+        
+        # === 閫熷害鎺у埗 ===
+        'base_speed': 0.8,           # 鍩虹閫熷害锛�0.6 鈫� 0.8
+        'max_forward_mps': 1.2,      # 鏈�澶ч�熷害锛�0.95 鈫� 1.2
+        'min_follow_speed': 0.40,    # 鏈�灏忛�熷害锛�0.30 鈫� 0.40
+        'max_reverse_mps': 0.25,
+        'max_yawrate': pi/2.5,       # 杞悜閫熺巼锛歱i/3 鈫� pi/2.5 (鏇村揩杞悜)
+        
+        # === 鍓嶈璺濈 ===
+        'lookahead_min': 0.35,       # 鏈�灏忓墠瑙�
+        'lookahead_max': 1.2,        # 鏈�澶у墠瑙�
+        'lookahead_time': 2.0,       # 鍓嶈鏃堕棿绯绘暟
+        
+        # === 璇樊瀹瑰繊搴� ===
+        'max_xte':1.5,              # 鏈�澶фí鍚戣宸細0.8 鈫� 1.0 (澧炲ぇ浠ュ噺灏戝綊涓�鍖栨斁澶�)
+        'goal_tolerance': 0.1,       # 鐩爣瀹瑰繊
+        'large_err_threshold': 0.5,  # 澶ц宸槇鍊�
+        
+        # === 婊ゆ尝鍙傛暟 ===
+        'yawrate_alpha': 0.85,       # 杞悜婊ゆ尝
+        
+        # === 閫熷害缂╂斁澧炵泭 ===
+        'curv_gain_scale': 2.0,      # 鏇茬巼缂╂斁
+        'heading_err_gain_scale': 1.5,  # 鑸悜璇樊缂╂斁
+        'xte_gain_scale': 1.0,       # 妯悜璇樊缂╂斁锛�1.2 鈫� 1.0 (绉婚櫎棰濆鏀惧ぇ)
+        
+        # === 缁堢偣鍑忛�熷尯鍩� ===
+        'slow_zone': 1.5,
+        'final_approach_dist': 0.8,
+        
+        # === 璧风偣闃舵 ===
+        'start_min_speed': 0.28,
+        'start_slow_speed': 0.38,
+        
+        # === 鎭㈠妯″紡 ===
+        'recovery_dist': 2.5,
+        'recovery_exit_dist': 0.6,
+        'recovery_speed': 0.6,
+    }
+    
+    history = simulate_full_path(work_path, mower_start_pos, mower_start_heading, 
+                                 params, approach_method='smooth')
+    plot_results(history)
+
+
+if __name__ == '__main__':
+    main()
\ No newline at end of file
diff --git a/python/view_simulation.py b/python/view_simulation.py
new file mode 100644
index 0000000..320c370
--- /dev/null
+++ b/python/view_simulation.py
@@ -0,0 +1,22 @@
+"""
+Simple image viewer for simulation results
+"""
+from PIL import Image
+import matplotlib.pyplot as plt
+
+# Open the saved simulation image
+img = Image.open('simulation_results.png')
+
+# Display
+plt.figure(figsize=(16, 10))
+plt.imshow(img)
+plt.axis('off')
+plt.title('Simulation Results with Approach Path')
+plt.tight_layout()
+plt.savefig('view_results.png', dpi=150, bbox_inches='tight')
+print("Saved view_results.png")
+print(f"Image size: {img.size}")
+print("\n鉁� Approach path (orange dashed) + Work path (black) are now visualized")
+print("鉁� Mower starts from offset position and follows approach path to work path start")
+print("\n鈿狅笍 Note: Trajectory still has issues after ~40s due to segment progression bug")
+print("   This is the existing Pure Pursuit restoration issue, not related to approach path")
diff --git a/python/viz.py b/python/viz.py
new file mode 100644
index 0000000..474ecfe
--- /dev/null
+++ b/python/viz.py
@@ -0,0 +1,147 @@
+"""
+viz.py
+
+璇诲彇 sim_log.csv锛堢敱 test.py 鐢熸垚锛夊苟鐢� matplotlib 鍔ㄧ敾鏄剧ず锛�
+- 涓诲浘锛氳矾寰勭偣锛坄example_path.json`锛夊拰鍓茶崏鏈哄綋鍓嶄綅缃�/鑸悜绠ご
+- 涓嬫柟涓や釜瀛愬浘锛歠orward_sig 涓� turn_sig 闅忔椂闂寸殑鏇茬嚎锛屽綋鍓嶅抚鐢ㄧ珫绾挎爣绀�
+
+杩愯锛�
+    python viz.py
+
+濡傛灉娌℃湁 `sim_log.csv`锛岃剼鏈細鎻愮ず鍏堣繍琛� `test.py` 鐢熸垚鏁版嵁銆�
+"""
+import os
+import json
+import csv
+import sys
+from math import cos, sin
+import numpy as np
+
+try:
+    import matplotlib.pyplot as plt
+    from matplotlib.animation import FuncAnimation
+except Exception as e:
+    print('matplotlib required. Install with: pip install matplotlib')
+    raise
+
+
+SIM_CSV = 'sim_log.csv'
+PATH_FILE = 'example_path.json'
+
+
+def load_sim(csv_path=SIM_CSV):
+    if not os.path.exists(csv_path):
+        print(f"Sim log '{csv_path}' not found. Run test.py first to generate it.")
+        sys.exit(1)
+    t, xs, ys, headings, forward_sig, turn_sig = [], [], [], [], [], []
+    with open(csv_path, 'r', newline='') as f:
+        reader = csv.DictReader(f)
+        for row in reader:
+            t.append(float(row['t']))
+            xs.append(float(row['x']))
+            ys.append(float(row['y']))
+            headings.append(float(row['heading']))
+            forward_sig.append(int(row['forward_sig']))
+            turn_sig.append(int(row['turn_sig']))
+    return {'t': t, 'x': xs, 'y': ys, 'heading': headings, 'forward': forward_sig, 'turn': turn_sig}
+
+
+def load_path(pth=PATH_FILE):
+    if not os.path.exists(pth):
+        return []
+    with open(pth, 'r') as f:
+        return json.load(f)
+
+
+def animate_sim(simdata, path):
+    t = simdata['t']
+    x = simdata['x']
+    y = simdata['y']
+    h = simdata['heading']
+    fwd = simdata['forward']
+    trn = simdata['turn']
+
+    fig = plt.figure(figsize=(10, 8))
+    gs = fig.add_gridspec(3, 1, height_ratios=[3, 1, 1])
+
+    ax_map = fig.add_subplot(gs[0])
+    ax_f = fig.add_subplot(gs[1])
+    ax_t = fig.add_subplot(gs[2])
+
+    # map plot
+    if path:
+        px = [p[0] for p in path]
+        py = [p[1] for p in path]
+        ax_map.plot(px, py, '-k', lw=1, label='planned path')
+        ax_map.scatter(px, py, c='k', s=10)
+
+    line_pos, = ax_map.plot([], [], '-r', lw=2, label='actual')
+    point_pos, = ax_map.plot([], [], 'ro')
+    # heading arrow using quiver (initialize at first pose if available)
+    if len(x) > 0 and len(h) > 0:
+        quiv = ax_map.quiver([x[0]], [y[0]], [cos(h[0])], [sin(h[0])], angles='xy', scale_units='xy', scale=4, color='b')
+    else:
+        quiv = ax_map.quiver([0], [0], [1], [0], angles='xy', scale_units='xy', scale=4, color='b')
+
+    ax_map.set_aspect('equal', 'box')
+    ax_map.set_title('Mower path and pose')
+    ax_map.legend()
+
+    # forward subplot
+    ax_f.plot(t, fwd, color='gray', alpha=0.5)
+    f_line = ax_f.axvline(t[0], color='r')
+    ax_f.set_ylabel('forward_sig')
+
+    # turn subplot
+    ax_t.plot(t, trn, color='gray', alpha=0.5)
+    t_line = ax_t.axvline(t[0], color='r')
+    ax_t.set_ylabel('turn_sig')
+    ax_t.set_xlabel('time (s)')
+
+    # set map limits
+    margin = 1.0
+    all_x = x + ( [p[0] for p in path] if path else [] )
+    all_y = y + ( [p[1] for p in path] if path else [] )
+    if all_x and all_y:
+        ax_map.set_xlim(min(all_x)-margin, max(all_x)+margin)
+        ax_map.set_ylim(min(all_y)-margin, max(all_y)+margin)
+
+    def init():
+        line_pos.set_data([], [])
+        point_pos.set_data([], [])
+        return line_pos, point_pos, quiv, f_line, t_line
+
+    def update(i):
+        # i is frame index
+        xi = x[:i+1]
+        yi = y[:i+1]
+        line_pos.set_data(xi, yi)
+        point_pos.set_data([x[i]], [y[i]])
+
+        # update quiver in-place
+        hx = cos(h[i])
+        hy = sin(h[i])
+        # set new offset and vector
+        quiv.set_offsets(np.array([[x[i], y[i]]]))
+        quiv.set_UVC(np.array([hx]), np.array([hy]))
+
+        # update vertical lines
+        f_line.set_xdata(t[i])
+        t_line.set_xdata(t[i])
+
+        return line_pos, point_pos, quiv, f_line, t_line
+
+    interval_ms = max(10, int(1000.0 / 74.0))
+    ani = FuncAnimation(fig, update, frames=len(t), init_func=init, blit=False, interval=interval_ms)
+    plt.tight_layout()
+    plt.show()
+
+
+def main():
+    sim = load_sim()
+    path = load_path()
+    animate_sim(sim, path)
+
+
+if __name__ == '__main__':
+    main()

--
Gitblit v1.9.3