Newer
Older
RobotCar / src / common / steering / ts_pd_control.py
"""
ts_pd_control
Theil-Sen 直線近似による PD 制御モジュール
行中心点に Theil-Sen 直線をフィッティングし,
位置偏差・傾き・微分項から操舵量を算出する
"""

import time
from dataclasses import dataclass

import numpy as np

from common import config
from common.steering.base import SteeringBase, SteeringOutput
from common.vision.fitting import theil_sen_fit
from common.vision.line_detector import (
    ImageParams,
    LineDetectResult,
)


@dataclass
class TsPdParams:
    """Theil-Sen PD 制御のパラメータ

    Attributes:
        kp: 位置偏差ゲイン
        kh: 傾き(Theil-Sen slope)ゲイン
        kd: 微分ゲイン
        max_steer_rate: 1フレームあたりの最大操舵変化量
        max_throttle: 直線での最大速度
        speed_k: 傾きベースの減速係数
    """
    kp: float = 0.5
    kh: float = 0.3
    kd: float = 0.1
    max_steer_rate: float = 0.1
    max_throttle: float = 0.4
    speed_k: float = 2.0


class TsPdControl(SteeringBase):
    """Theil-Sen 直線近似による PD 制御クラス

    行中心点から Theil-Sen 直線近似を行い,
    画像下端での位置偏差と直線の傾きから PD 制御で操舵量を計算する
    """

    def __init__(
        self,
        params: TsPdParams | None = None,
        image_params: ImageParams | None = None,
    ) -> None:
        super().__init__(image_params)
        self.params: TsPdParams = (
            params or TsPdParams()
        )
        self._prev_error: float = 0.0
        self._prev_time: float = 0.0

    def _compute_from_result(
        self, result: LineDetectResult,
    ) -> SteeringOutput:
        """Theil-Sen PD 制御で操舵量を計算する

        Args:
            result: 線検出の結果

        Returns:
            計算された操舵量
        """
        if not result.detected or result.row_centers is None:
            return SteeringOutput(
                throttle=0.0, steer=0.0,
            )

        p = self.params
        centers = result.row_centers

        # 有効な点(NaN でない行)を抽出
        valid = ~np.isnan(centers)
        ys = np.where(valid)[0].astype(float)
        xs = centers[valid]

        if len(ys) < 2:
            return SteeringOutput(
                throttle=0.0, steer=0.0,
            )

        # Theil-Sen 直線近似: x = slope * y + intercept
        slope, intercept = theil_sen_fit(ys, xs)

        center_x = config.FRAME_WIDTH / 2.0
        h = len(centers)

        # 画像下端での位置偏差
        bottom_x = slope * (h - 1) + intercept
        position_error = (center_x - bottom_x) / center_x

        # 操舵量: P 項(位置偏差)+ Heading 項(傾き)
        error = p.kp * position_error + p.kh * slope

        # 時間差分の計算
        now = time.time()
        dt = (
            now - self._prev_time
            if self._prev_time > 0
            else 0.033
        )
        dt = max(dt, 0.001)

        # D 項(微分項)
        derivative = (error - self._prev_error) / dt
        steer = error + p.kd * derivative

        # 操舵量のクランプ
        steer = max(-1.0, min(1.0, steer))

        # 速度制御(傾きベース)
        throttle = p.max_throttle - p.speed_k * abs(slope)
        throttle = max(0.0, throttle)

        # 状態の更新
        self._prev_error = error
        self._prev_time = now

        return SteeringOutput(
            throttle=throttle, steer=steer,
        )

    def _max_steer_rate(self) -> float:
        return self.params.max_steer_rate

    def reset(self) -> None:
        """内部状態をリセットする"""
        super().reset()
        self._prev_error = 0.0
        self._prev_time = 0.0