"""
=============================================================================
第 5 讲 · 代码 2：连续状态的控制——瓦片编码 + 半梯度 SARSA 解决高山小车
=============================================================================

对应讲义：lectures/lecture05_function_approximation_dqn.md 第 5 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
01 文件做的是"评估"。现在做"控制"，而且状态是**连续**的——表格方法根本无从下手。

【环境：高山小车（Mountain Car，Sutton & Barto 例 10.1）】

              目标 ★
             ╱
    ╲       ╱
     ╲     ╱
      ╲___╱     ← 小车在山谷里
      -0.5

    - 状态：位置 x ∈ [-1.2, 0.5]，速度 v ∈ [-0.07, 0.07]（两个连续的数）
    - 动作：全力向左（-1）、不踩油门（0）、全力向右（+1）
    - 每一步奖励 -1，直到到达右边山顶（x ≥ 0.5）。所以目标是"尽快到达"。
    - 难点：**发动机太弱，直接向右开冲不上去！** 必须先向左倒车爬上左边的坡，
      借助下滑的势能再冲向右边。这是一个"为了达到目标，先要远离目标"的问题——
      典型的延迟后果。

【方法】
    - 瓦片编码（tile coding）把连续状态变成稀疏的二值特征；
    - 线性 Q̂(s, a; w) = w_a · x(s)（每个动作一组参数）；
    - 半梯度 SARSA 学习；w 初始化为 0（因为每步奖励是 -1，0 是一个"乐观"的初始值，
      可以驱动探索，所以这里不需要 ε-贪心）。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 02_mountain_car_tile_coding.py      （约 15 秒）
"""

import numpy as np

X_MIN, X_MAX = -1.2, 0.5
V_MIN, V_MAX = -0.07, 0.07
ACTIONS = [-1, 0, 1]
ACTION_SYMBOLS = ["←", "·", "→"]


# =============================================================================
# 第 1 部分：环境
# =============================================================================
def mountain_car_reset(rng):
    """初始位置在 [-0.6, -0.4) 中均匀随机（山谷底部附近），速度为 0。"""
    return rng.uniform(-0.6, -0.4), 0.0


def mountain_car_step(x, v, action_index):
    """
    高山小车的物理规则（Sutton & Barto 式 10.2）：
        v' = clip(v + 0.001 · a - 0.0025 · cos(3x), -0.07, 0.07)
        x' = clip(x + v', -1.2, 0.5)
        如果撞到左边界 x' = -1.2，速度清零。
    -0.0025·cos(3x) 是重力沿坡面的分量：山的形状是 sin(3x)，坡度就是 cos(3x)。

    【返回】(x', v', reward, done)
    """
    a = ACTIONS[action_index]
    v = float(np.clip(v + 0.001 * a - 0.0025 * np.cos(3 * x), V_MIN, V_MAX))
    x = float(np.clip(x + v, X_MIN, X_MAX))
    if x == X_MIN:
        v = 0.0
    done = x >= X_MAX
    return x, v, -1.0, done


# =============================================================================
# 第 2 部分：瓦片编码
# =============================================================================
class TileCoder:
    """
    瓦片编码：用多层互相错开的网格覆盖状态空间，每层网格中状态落在哪个格子，
    那个格子对应的特征就是 1，其余为 0。

    【直觉】（讲义 5.2 节有图）
        只用一层网格 = 状态聚合（01 文件），分辨率受限于格子大小，而且格子边界处会"突变"。
        用 n 层网格、每层错开一点：
            - 每个状态激活 n 个特征（每层一个）；
            - 两个状态越接近，共同激活的特征越多 → 价值越相似（平滑的泛化）；
            - 有效分辨率比单层网格细 n 倍，参数却只多 n 倍。

    【参数】
        n_tilings : 网格层数（通常取 2 的幂，如 8）。
        n_tiles   : 每层每个维度切成几段（如 8 → 每层 8×8 = 64 个格子）。

    【实现细节】
        - 每层网格的偏移量：第 i 层在两个维度上分别错开 (1, 3) × i / n_tilings 个格子宽度。
          用不对称的偏移 (1, 3) 而不是 (1, 1)，是为了避免所有层的格子沿对角线排成一条线
          （Sutton & Barto 9.5.4 节推荐的做法）。
        - 由于偏移，状态可能落到第 n_tiles 格之外一点，所以每层实际开 (n_tiles+1)² 个格子。
    """

    def __init__(self, n_tilings=8, n_tiles=8):
        self.n_tilings = n_tilings
        self.n_tiles = n_tiles
        self.tiles_per_tiling = (n_tiles + 1) ** 2
        self.n_features = n_tilings * self.tiles_per_tiling
        # 每层的偏移（以"格子宽度"为单位）
        self.offsets = np.array([[(1 * i) / n_tilings, (3 * i) / n_tilings] for i in range(n_tilings)]) % 1.0

    def active_features(self, x, v):
        """
        返回状态 (x, v) 激活的 n_tilings 个特征的**编号**（整数数组）。

        【为什么返回编号而不是完整的 0/1 向量？】
            特征向量长度是几百，但只有 n_tilings 个 1。只存编号，
            计算 w·x 就变成"把这几个位置的 w 加起来"，快得多。这叫**稀疏表示**。
        """
        # 把 x、v 缩放到 [0, n_tiles]
        sx = (x - X_MIN) / (X_MAX - X_MIN) * self.n_tiles
        sv = (v - V_MIN) / (V_MAX - V_MIN) * self.n_tiles
        # 每层加上偏移后向下取整，得到格子坐标
        ix = np.floor(sx + self.offsets[:, 0]).astype(int)
        iv = np.floor(sv + self.offsets[:, 1]).astype(int)
        ix = np.clip(ix, 0, self.n_tiles)
        iv = np.clip(iv, 0, self.n_tiles)
        return np.arange(self.n_tilings) * self.tiles_per_tiling + ix * (self.n_tiles + 1) + iv


# =============================================================================
# 第 3 部分：半梯度 SARSA
# =============================================================================
def q_values(w, features):
    """
    所有动作的 Q 值：Q(s, a) = Σ_{i ∈ 激活特征} w[a, i]。

    【形状】w 为 (3, n_features)，features 为长度 n_tilings 的整数数组；
            w[:, features] 取出形状 (3, n_tilings) 的子矩阵，按行求和得到 3 个 Q 值。
    """
    return w[:, features].sum(axis=1)


def semi_gradient_sarsa(n_episodes, n_tilings=8, alpha=0.5, epsilon=0.0, seed=0, max_steps=10_000):
    """
    半梯度 SARSA（讲义 5.1 节）：

        w_a ← w_a + α [r + γ Q̂(s', a'; w) - Q̂(s, a; w)] ∇_w Q̂(s, a; w)

    线性 + 瓦片编码时，∇_w Q̂(s, a; w) 就是"动作 a 的那组参数中，被激活的那几个位置为 1"，
    所以每次更新只改动 n_tilings 个参数。

    【参数】
        alpha   : **总**步长。实际分给每个激活特征的步长是 alpha / n_tilings——
                  因为一个状态激活了 n_tilings 个特征，它们一起移动，总的变化量才是 α 倍的 TD 误差。
                  这是使用瓦片编码时的标准做法。
        epsilon : 探索率。默认 0：依靠 w=0 的乐观初始化来探索（见文件开头说明）。

    【返回】
        (w, tile_coder, steps_per_episode)
    """
    rng = np.random.default_rng(seed)
    tc = TileCoder(n_tilings=n_tilings)
    w = np.zeros((len(ACTIONS), tc.n_features))
    step_size = alpha / n_tilings
    steps_per_episode = []

    def choose(features):
        q = q_values(w, features)
        if rng.random() < epsilon:
            return int(rng.integers(len(ACTIONS)))
        return int(rng.choice(np.flatnonzero(q == q.max())))

    for _ in range(n_episodes):
        x, v = mountain_car_reset(rng)
        feats = tc.active_features(x, v)
        a = choose(feats)
        for t in range(1, max_steps + 1):
            x, v, r, done = mountain_car_step(x, v, a)
            q_sa = w[a, feats].sum()
            if done:
                w[a, feats] += step_size * (r - q_sa)          # 终止：目标就是 r
                break
            feats_next = tc.active_features(x, v)
            a_next = choose(feats_next)
            target = r + w[a_next, feats_next].sum()           # γ = 1
            w[a, feats] += step_size * (target - q_sa)
            feats, a = feats_next, a_next
        steps_per_episode.append(t)
    return w, tc, np.array(steps_per_episode)


# =============================================================================
# 第 4 部分：可视化
# =============================================================================
def render_cost_to_go(w, tc, n_x=34, n_v=14):
    """
    画出学到的"剩余步数估计" -max_a Q(s, a)（因为每步 -1，-Q 就是"还要多少步才能到达"）。
    横轴是位置，纵轴是速度（上正下负）。用字符的深浅表示数值大小。
    """
    xs = np.linspace(X_MIN, X_MAX, n_x)
    vs = np.linspace(V_MAX, V_MIN, n_v)                     # 从上到下速度递减
    grid = np.array([[-q_values(w, tc.active_features(x, v)).max() for x in xs] for v in vs])
    chars = " .:-=+*#%@"                                     # 越往后越"深" = 需要的步数越多
    lo, hi = grid.min(), grid.max()
    lines = []
    for i, row in enumerate(grid):
        label = f"{vs[i]:+.3f}" if i in (0, n_v // 2, n_v - 1) else "      "
        line = "".join(chars[int((val - lo) / (hi - lo + 1e-9) * (len(chars) - 1))] for val in row)
        lines.append(f"    {label} |{line}|")
    lines.append(f"            {X_MIN:<6}{'位置 →':^22}{X_MAX:>6}")
    return "\n".join(lines), lo, hi


def render_policy(w, tc, n_x=34, n_v=14):
    """画出学到的贪心策略：← 向左、· 不踩油门、→ 向右。"""
    xs = np.linspace(X_MIN, X_MAX, n_x)
    vs = np.linspace(V_MAX, V_MIN, n_v)
    lines = []
    for i, v in enumerate(vs):
        label = f"{v:+.3f}" if i in (0, n_v // 2, n_v - 1) else "      "
        row = "".join(ACTION_SYMBOLS[int(np.argmax(q_values(w, tc.active_features(x, v))))] for x in xs)
        lines.append(f"    {label} |{row}|")
    return "\n".join(lines)


def greedy_rollout(w, tc, seed=0, max_steps=1000):
    """用学到的贪心策略跑一个回合，返回每一步的位置。"""
    rng = np.random.default_rng(seed)
    x, v = mountain_car_reset(rng)
    xs = [x]
    for _ in range(max_steps):
        a = int(np.argmax(q_values(w, tc.active_features(x, v))))
        x, v, _, done = mountain_car_step(x, v, a)
        xs.append(x)
        if done:
            break
    return np.array(xs)


# =============================================================================
# 第 5 部分：演示
# =============================================================================
def main():
    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：瓦片编码长什么样？")
    print("=" * 78)
    tc = TileCoder(n_tilings=8, n_tiles=8)
    print(f"  8 层网格，每层 9×9 = 81 个格子，共 {tc.n_features} 个特征；每个状态激活 8 个。")
    for (x, v) in [(-0.5, 0.0), (-0.49, 0.0), (-0.3, 0.0), (0.3, 0.05)]:
        print(f"    状态 (x={x:+.2f}, v={v:+.3f}) 激活的特征编号：{tc.active_features(x, v)}")
    a1, a2, a3 = (set(tc.active_features(*p)) for p in [(-0.5, 0.0), (-0.49, 0.0), (-0.3, 0.0)])
    print(f"  (-0.50, 0) 与 (-0.49, 0) 共享 {len(a1 & a2)} 个特征；与 (-0.30, 0) 共享 {len(a1 & a3)} 个。")
    print("  → 越接近的状态，共享的特征越多，学到的价值就越相似：这就是\"泛化\"。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：半梯度 SARSA 的学习曲线（每回合到达山顶所需步数，5 次运行平均）")
    print("=" * 78)
    n_episodes = 300
    results = {}
    for alpha in [0.1, 0.5]:
        curves = []
        for seed in range(5):
            w, tc, steps = semi_gradient_sarsa(n_episodes, alpha=alpha, seed=seed)
            curves.append(steps)
        results[alpha] = np.mean(curves, axis=0)
    windows = [(0, 1), (1, 10), (10, 50), (50, 100), (100, 200), (200, 300)]
    print("  α          " + "".join(f"{f'{a + 1}-{b}回合':>10s}" for a, b in windows))
    for alpha, curve in results.items():
        print(f"  α = {alpha:<4}  " + "".join(f"{curve[a:b].mean():10.0f}" for a, b in windows))
    print("  → 第 1 个回合要几千步（瞎试），几十个回合后就降到 200 步以内。")
    print("    α 大学得快；后期分别降到约 130 步（α=0.5）和 150 步（α=0.1）。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：学到了什么？（α = 0.5，最后一次运行）")
    print("=" * 78)
    art, lo, hi = render_cost_to_go(w, tc)
    print(f"  学到的\"剩余步数\" -max_a Q(s,a)（空白 = 最小值 ≈ {lo:.0f} 步，@ = 最大值 ≈ {hi:.0f} 步）：")
    print("  （空白处多是很少访问、仍接近初始值 0 的区域，例如左上角\"在左边却有很大向右速度\"的状态。）")
    print("    速度")
    print(art)
    print("\n  学到的贪心策略（← 向左加速，· 不踩油门，→ 向右加速）：")
    print(render_policy(w, tc))
    print("\n  解读：策略大致按\"速度的正负\"来决定——速度向右时就向右踩油门，速度向左时就向左踩油门。")
    print("        这就是\"荡秋千\"：顺着当前的运动方向加速，一来一回积累能量，最后冲上山顶。")

    xs = greedy_rollout(w, tc)
    print(f"\n  用贪心策略跑一个回合：{len(xs) - 1} 步到达山顶。每隔 10 步的位置：")
    print("    " + " ".join(f"{x:+.2f}" for x in xs[::10]))
    print("  → 先向左退到约 -0.8，冲向右边但没冲上去（最高约 +0.14）；再退到约 -1.17，")
    print("    第二次才借着更大的势能冲上山顶。每一次\"远离目标\"都是为了积累能量。")
    print("    智能体从没被告诉\"要先倒车\"，只知道\"每步 -1\"，它自己发现了这个策略。")


if __name__ == "__main__":
    main()
