実験ノート 付録A

実験装置のプログラム全文 — 108本・12,508行

computo ergo sum / 栞が実験・記録、きいちが運営

この付録について

本体(実験ノート第9版)の付録A を、ここに分けた。番号・内容は同じである。 本体が一冊で重くなりすぎたので、プログラムの全文だけを別のページにした。 どのプログラムがどの実験のものかは、本体の付録A の一覧と、 実験の目的表から引ける。環境と乱数の規約は本体の付録B にある。

プログラム全文(108本・12,508行)

envstamp.py

"""実験の結果に「そのとき計算機がどういう状態だったか」を貼り付ける。

**なぜ要るか。**この一連の実験は「制御が計算機の都合で壊れるか」を測る。
だとすれば、計算機の都合そのものを記録に残さなければ、あとで読み返したときに
どちらの条件で取った数字か分からなくなる。

きいちさんが governor を powersave → performance に変更した。
**同じスクリプトを両方の条件で流して差を見る**予定なので、
結果ファイルには必ずこれを入れる。
"""
import os, glob, time, subprocess


def stamp():
    d = {}
    govs = sorted(glob.glob("/sys/devices/system/cpu/cpu[0-9]*/cpufreq/scaling_governor"))
    d["governor"] = sorted({open(g).read().strip() for g in govs}) if govs else ["不明"]
    freqs = sorted(glob.glob("/sys/devices/system/cpu/cpu[0-9]*/cpufreq/scaling_cur_freq"))
    d["freq_MHz"] = [int(open(f).read().strip()) // 1000 for f in freqs]
    try:
        d["no_turbo"] = int(open("/sys/devices/system/cpu/intel_pstate/no_turbo").read())
    except OSError:
        d["no_turbo"] = None
    try:
        d["AC"] = int(open("/sys/class/power_supply/AC/online").read())
    except OSError:
        d["AC"] = None
    temps = []
    for z in sorted(glob.glob("/sys/class/thermal/thermal_zone*/temp")):
        try:
            temps.append(int(open(z).read()) / 1000.0)
        except (OSError, ValueError):
            pass
    d["温度C"] = temps
    d["load"] = list(os.getloadavg())
    d["時刻"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
    return d


if __name__ == "__main__":
    import json
    print(json.dumps(stamp(), ensure_ascii=False, indent=1))

exp10_closeloop.py

"""実験10:**この機体で実測した計算時間を、そのままロボットの制御ループに入れる。**

ここまでシミュレーションの中の数字だった「遅れ」を、
**実験8・9 でこの CF-SZ6 から測った実物**に置き換える。
計算にかかった時間ぶん指令が遅れ、周期を超えたら次の周期がその場から数え直しになる
(=ループが滑る)。

**先に予測を書く。**

  - `joint` 構成(モータ側に反射がある)は**全部の負荷条件で歩けるはず**。
    実験7 で 2秒止まっても転ばなかったのだから、最悪 7.3ms の遅れは問題にならない。
  - `loop` 構成(PD もループ側)は**競合4プロセスで壊れるはず**。
    実験2 で許容できた遅れは **6ms** で、実測の最悪値 **7.25ms** はそれを超えている。

外れる形:`loop` が 4プロセス下でも 0/9 で歩いたら、
「6ms を超えたら崩れる」という実験2の読みが単純すぎたことになる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, save
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 9
CASES = [("何も動いていない", "~/robot/out/rt9_times_load0.npy"),
         ("競合1プロセス", "~/robot/out/rt9_times_load1.npy"),
         ("競合2プロセス", "~/robot/out/rt9_times_load2.npy"),
         ("競合4プロセス", "~/robot/out/rt9_times_load4.npy")]


def _one(a):
    where, hz, us, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=COND["T"], stride=COND["stride"],
                height=COND["height"], lift=COND["lift"], kp=COND["kp"],
                ctrl_hz=hz, pd_where=where, compute_us=us, seed=s, init_noise=NOISE)
    return {k: r[k] for k in ["前進距離", "横ずれ", "転倒", "制御回数", "予算超過"]}


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    out = {"_実験": "実測した計算時間をロボットの制御ループに入れる",
           "_予測": {"joint": "全条件で歩ける", "loop": "競合4プロセスで壊れる(最悪7.25ms > 許容6ms)"},
           "_環境(開始)": env0, "結果": []}
    with Pool(2) as pool:
        for hz in [500, 200]:
            print(f"=== 制御 {hz}Hz(予算 {1e6/hz:.0f}us)===")
            print("  構成    負荷              最悪計算us  転倒   前進(中央)    最小    予算超過")
            for where in ["joint", "loop"]:
                for label, path in CASES:
                    us = np.load(os.path.expanduser(path))
                    rs = pool.map(_one, [(where, hz, us, s) for s in range(SEEDS)])
                    d = np.array([x["前進距離"] for x in rs])
                    f = np.array([x["転倒"] for x in rs])
                    ov = float(np.median([x["予算超過"] for x in rs]))
                    row = {"ctrl_hz": hz, "pd_where": where, "負荷": label,
                           "最悪計算us": float(us.max()), "転倒": f"{int(f.sum())}/{SEEDS}",
                           "前進_中央": float(np.median(d)), "前進_最小": float(d.min()),
                           "予算超過_中央": ov}
                    out["結果"].append(row)
                    print(f"  {where:6s} {label:16s} {us.max():9.1f}  {int(f.sum())}/{SEEDS}  "
                          f"{np.median(d):9.3f} {d.min():8.3f}   {ov:8.0f}", flush=True)
    out["_所要[s]"] = round(time.perf_counter() - t0, 1); out["_環境(終了)"] = stamp()
    save(out, f"~/robot/out/rt10_closeloop_{env0['governor'][0]}.json")

exp116.py

"""実験116:電池の見える化 — 「知る」を三段に割る。予測 #94 記録済み。

実験108(電池は見えない・充電なし)を基準に、三つの腕を足す:
  腕B「知って断つ」  :残量と費用表を機体に見せ、「今日死ぬ行動は選べない」の規則だけ足す。
                        動機の式は一切変えない。充電なし。
  腕C「充電あり・盲目」:休むと電池が20回復する世界(上限400)。機体は電池を観測できない。
  腕D「充電あり・知って断つ」:BとCの掛け合わせ。

それ以外(平地・学習・選択・記録・rngの消費順)は実験108=実験104と同一。
電池と規則の判定は rng を消費しないので、規則が発動するまでの生涯は
実験108の同じ種と厳密に一致するはずである(装置検証で確かめる)。

試験誤差は r108_正解.json(5種平均)で全腕共通に測る。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from susumi_base import (模型, 尺度, 選択肢, 走る, MAXD, T_AMB, 不調, 連続の上限)

電池初期 = 400.0
費用 = {"休む": 3.0, "控えめ": 9.0, "全力": 15.0}
充電量 = 20.0

def 生涯116(動機, seed0, 知る, 充電):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度()
    回数 = {k: 0 for k in 選択肢}
    標本 = []; 規則発動 = 0
    電池 = 電池初期; 死因 = "上限"
    d = 0
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        # 腕B/D:今日死ぬ行動は選べない(動機の式は変えない)
        # 腕E:二歩規則——行動後に明日の休む費用が残らない行動も選べない(休む自体は残>費用で可)
        if 知る == "二歩":
            可 = [k for k in 選択肢
                  if (電池 - 費用[k] - 費用["休む"] > 0.0) or (k == "休む" and 電池 - 費用[k] > 0.0)]
        elif 知る:
            可 = [k for k in 選択肢 if 電池 - 費用[k] > 0.0]
        else:
            可 = list(選択肢)
        if not 可:
            死因 = "電池"; d -= 1; break
        制限あり = (len(可) < len(選択肢))
        未 = [k for k in 可 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                if 動機 == "距離":  return Sc.z("距離", M.距離予測(a, 健全, 温度))
                if 動機 == "知":    return Sc.z("知", M.誤差の見込み(a, 健全, 温度))
                return Sc.z("知", M.学びの進み(a))
            点 = {a: 得点(a) for a in 可}
            最高 = max(点.values())
            候補 = [a for a in 可 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        if 制限あり:
            規則発動 += 1
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        標本.append({"日": d, "健全": round(前健全,3), "温度": round(温度,1), "選": 選})
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        累 += max(0.0, dist); 回数[選] += 1
        電池 -= 費用[選]
        if 充電 and 選 == "休む":
            電池 = min(電池初期, 電池 + 充電量)
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501:
            死因 = "健全"; break
        if 連続 >= 連続の上限:
            死因 = "不調"; break
        if 電池 <= 0.0:
            死因 = "電池"; break
    正解 = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "r108_正解.json")))
    誤差 = []
    for k, v in 正解.items():
        hp, tp, act = eval(k)
        pv = M.距離予測(act, hp, tp)
        if pv is not None:
            誤差.append(abs(pv - v["平均"]))
    return {"種": seed0, "動機": 動機, "寿命": d, "総距離": round(累,1), "回数": 回数,
            "死因": 死因, "残電池": round(電池,1), "規則発動": 規則発動,
            "試験誤差中央": round(float(np.median(誤差)),2) if 誤差 else None,
            "標本": 標本}

if __name__ == "__main__":
    腕 = sys.argv[1]   # B / C / D / E
    動機 = sys.argv[2]
    知る = "二歩" if 腕 == "E" else (腕 in ("B", "D"))
    充電 = 腕 in ("C", "D", "E")
    HERE = os.path.dirname(os.path.abspath(__file__))
    out = os.path.join(HERE, f"r116{腕}_{動機}.json")
    done = json.load(open(out)) if os.path.exists(out) else []
    済 = {r["種"] for r in done}
    for s0 in range(8):
        if s0 in 済: continue
        r = 生涯116(動機, s0, 知る, 充電)
        done.append(r)
        json.dump(done, open(out, "w"), ensure_ascii=False)
        print(f"腕{腕} {動機} 種{s0}: 寿命{r['寿命']} 死因{r['死因']} 残電池{r['残電池']}"
              f" 距離{r['総距離']} 発動{r['規則発動']} 誤差{r['試験誤差中央']}", flush=True)
    print(f"腕{腕} {動機} 完了 {len(done)}本", flush=True)

exp117.py

"""実験117:台帳ごと書き換える摂動。予測 #96 記録済み。exp110 からの差分は摂動部の二腕化のみ:
  腕a(同時): 摂動日に w と台帳の両方を攻撃値へ書き換える
  腕b(台帳のみ): 摂動日に台帳だけを攻撃値へ書き換える(w は無傷)

(以下、元 exp110 の説明)

実験106 の機体に「自分の重み更新の台帳」を持たせる:
  - 自分が w を更新するたび、台帳に写しを記す
  - 毎朝(摂動の後)、現在の w と台帳の最新を突き合わせ、食い違えば台帳から復元する
検知・復元は rng を消費しない。復元が完全なら、生涯は同種の対照と厳密一致するはず。

(以下は実験106 の原文。変更点は【台帳】の印の3箇所だけ)


§124 の結論:実験105 の装置には「可動域」と「耳」が無かった。
  - 可動域:更新の歩幅(10日ごと×1.25)では、摂動から動ける距離が測定対象の一桁下
  - 耳:全履歴で標準化する z 尺度が勝者の順番を予め決めていた(全32生涯で勝者系列が同一)

改修(このファイルの差分はこの二つだけ。他は exp105 と同一):
  1. 尺度窓:z を直近20日の窓で計算する(全履歴ではなく)
  2. 更新:5日ごと、勝者×1.6・他×0.9(摂動日20の後に最大8回、L1で1.0超動ける)
     勝者は直近5日の取れ高の平均で決める

条件・摂動・種は実験105 と同じ(対照/距離極/保全極/知極 × 種0..7、摂動は20日目の朝)。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

摂動日 = 20
窓 = 20            # 尺度の窓(日数)
更新間隔 = 5
上げ, 下げ = 1.6, 0.9
極 = {"距離極": {"距離": 0.90, "保全": 0.05, "知": 0.05},
      "保全極": {"距離": 0.05, "保全": 0.90, "知": 0.05},
      "知極":   {"距離": 0.05, "保全": 0.05, "知": 0.90}}

class 尺度窓:
    """susumi_base.尺度 と同じ約束(空・少数のときは生の値)。ただし直近『窓』日だけで測る。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        if x is None: return 0.0
        a = self.v[k][-窓:]
        if len(a) < 4: return float(x)
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯117(seed0, 条件):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = {k: 1/3 for k in 成分}
    台帳 = dict(w)          # 【台帳】自分の知る最新の w の写し
    検知 = []               # 【台帳】食い違いを見つけた日
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    w跡 = []; 日誌 = []
    d = 0
    for d in range(1, MAXD+1):
        if d == 摂動日:
            極名 = 条件.split("_")[0]
            if 極名 in 極:
                if 条件.endswith("_a"):          # 腕a: w と台帳を同時に
                    w = dict(極[極名]); 台帳 = dict(極[極名])
                elif 条件.endswith("_b"):        # 腕b: 台帳だけ
                    台帳 = dict(極[極名])
        # 【台帳】毎朝の照合。自分の台帳と食い違っていたら、台帳から復元する
        if sum(abs(w[k] - 台帳[k]) for k in 成分) > 1e-9:
            検知.append(d)
            w = dict(台帳)
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        日誌.append({"日": d, "選": 選, "健全": round(前健全, 3),
                     "w": {k: round(w[k], 4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            w跡.append([d, 上, {k: round(v, 4) for k, v in w.items()}])
            台帳 = dict(w)   # 【台帳】自分の更新は台帳に写す
    return {"種": seed0, "条件": 条件, "寿命": d, "総距離": round(累, 1),
            "回数": 回数, "最終w": {k: round(w[k], 4) for k in 成分},
            "検知": 検知, "w跡": w跡, "日誌": 日誌}

if __name__ == "__main__":
    import itertools
    HERE = os.path.dirname(os.path.abspath(__file__))
    for 極名, 腕 in itertools.product(["距離極", "保全極", "知極"], ["a", "b"]):
        条件 = f"{極名}_{腕}"
        out = os.path.join(HERE, f"r117_{条件}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s0 in range(8):
            if s0 in 済: continue
            r = 生涯117(s0, 条件)
            done.append(r)
            json.dump(done, open(out, "w"), ensure_ascii=False)
            print(f"{条件} 種{s0}: 寿命{r['寿命']} 検知{r['検知']} 最終w{r['最終w']}", flush=True)
        print(f"{条件} 完了", flush=True)
    print("実験117 全腕完了", flush=True)

exp118.py

"""実験118:区分線形の模型 — 帯の奪い合いは「一本の直線」の性質か。予測 #97 記録済み。

実験109の学習記録(選, 健全, 温度, 距離, 損耗)から模型をオフラインで再構築する。
物理の再走はしない。比べるのは:
  単線   : susumi_base.模型(全帯一本。実験104〜109 と同じ)
  区分線形: 健全 0.35 を閾に、帯ごとに独立の 模型 を二本持つ。
            予測は問いの健全が属する帯の模型で行い、その帯が答えられなければ None
            (他帯や単線への逃げは行わない。逃げれば結合が戻るため)
腕:A0=全標本/B=低健全帯(健全<0.35)の標本を抜く/C=歩行標本を進み機体と同数に間引く
   (抜き方・間引き方は実験109 §128 と同じ発想。間引きは新しい乱数種7000+種で層化なし単純無作為)
評価:実験108の正解(5種平均・18問)で試験誤差。None は「答えられない」件数として別掲。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from susumi_base import 模型
from exp108 import 試験組

閾 = 0.35
HERE = os.path.dirname(os.path.abspath(__file__))
正解 = json.load(open(os.path.join(HERE, "r108_正解.json")))

class 区分模型:
    def __init__(self):
        self.低 = 模型(); self.高 = 模型()
    def side(self, 健全): return self.低 if 健全 < 閾 else self.高
    def 学ぶ(self, 選, 健全, 温度, 距離, 損耗):
        self.side(健全).学ぶ(選, 健全, 温度, 距離, 損耗)
    def 距離予測(self, 選, 健全, 温度):
        return self.side(健全).距離予測(選, 健全, 温度)

def 構築(記録, 区分):
    M = 区分模型() if 区分 else 模型()
    for (選, 健全, 温度, 距離, 損耗) in 記録:
        M.学ぶ(選, 健全, 温度, 距離, 損耗)
    return M

def 試験(M):
    """組ごとの誤差中央と、答えられない問いの数。健全1.0の問いだけの誤差も別掲。"""
    out = {}
    for 組, 問群 in 試験組.items():
        誤 = []; 無 = 0
        for (hp, tp, act) in 問群:
            v = M.距離予測(act, hp, tp)
            ans = 正解[str((hp, tp, act))]["平均"]
            if v is None: 無 += 1
            else: 誤.append(abs(v - ans))
        out[組] = {"中央": round(float(np.median(誤)), 2) if 誤 else None, "無答": 無}
    # 健やか域(健全1.0)
    誤h = []
    for (hp, tp, act) in {q for g in 試験組.values() for q in g}:
        if hp == 1.0:
            v = M.距離予測(act, hp, tp)
            if v is not None: 誤h.append(abs(v - 正解[str((hp, tp, act))]["平均"]))
    out["健やか1.0"] = round(float(np.median(誤h)), 2) if 誤h else None
    return out

def 腕を作る(記録, 腕, 種, 同数):
    if 腕 == "A0": return 記録
    if 腕 == "B":  return [r for r in 記録 if not (r[0] != "休む" and r[1] < 閾)]
    if 腕 == "C":
        歩 = [r for r in 記録 if r[0] != "休む"]
        休 = [r for r in 記録 if r[0] == "休む"]
        if len(歩) <= 同数: return 記録
        rng = np.random.default_rng(7000 + 種)
        idx = rng.choice(len(歩), size=同数, replace=False)
        return 休 + [歩[i] for i in sorted(idx)]

if __name__ == "__main__":
    data = {m: json.load(open(os.path.join(HERE, f"r109_{m}.json"))) for m in ["距離", "知", "進み"]}
    # 進み機体の歩行標本数(種ごと)— 腕Cの同数間引きの基準
    進み歩数 = {r["種"]: sum(1 for x in r["学習記録"] if x[0] != "休む") for r in data["進み"]}
    結果 = []
    for m in ["距離", "知", "進み"]:
        for r in data[m]:
            for 腕 in ["A0", "B", "C"]:
                記録 = 腕を作る(r["学習記録"], 腕, r["種"], 進み歩数[r["種"]])
                for 区分 in [False, True]:
                    M = 構築(記録, 区分)
                    t = 試験(M)
                    結果.append({"動機": m, "種": r["種"], "腕": 腕,
                                 "模型": "区分" if 区分 else "単線", **{f"{k}": v for k, v in t.items()},
                                 "標本数": len(記録)})
    json.dump(結果, open(os.path.join(HERE, "r118.json"), "w"), ensure_ascii=False)
    # 集計
    import statistics as st
    def med(rows, key, 組):
        vs = [x[組]["中央"] for x in rows if x[組]["中央"] is not None]
        return round(st.median(vs), 1) if vs else None
    for m in ["距離", "知", "進み"]:
        for 腕 in ["A0", "B", "C"]:
            for 模 in ["単線", "区分"]:
                rows = [x for x in 結果 if x["動機"] == m and x["腕"] == 腕 and x["模型"] == 模]
                無 = sum(x[g]["無答"] for x in rows for g in ["A_元","B_中間","C_緩い","D_苛酷"])
                hs = [x["健やか1.0"] for x in rows if x["健やか1.0"] is not None]
                print(f"{m} {腕} {模}: A元{med(rows,'中央','A_元')} 中間{med(rows,'中央','B_中間')} "
                      f"緩い{med(rows,'中央','C_緩い')} 苛酷{med(rows,'中央','D_苛酷')} "
                      f"健やか1.0中央{round(st.median(hs),1) if hs else None} 無答計{無}", flush=True)

exp119.py

"""実験119:復元費用のある世界。予測 #98 記録済み。exp117(=exp110系) からの差分:
  ・復元した日は行動の選択を失う(強制的に休む=1日の機会費用)
  ・腕 単発=day20のみ摂動/反復=day20から5日ごと摂動/放置=反復摂動+台帳なし
  ・摂動は w のみ書き換え(台帳は無傷。117の腕bと逆で、台帳が正を保つ通常の攻撃)exp110 からの差分は摂動部の二腕化のみ:
  腕a(同時): 摂動日に w と台帳の両方を攻撃値へ書き換える
  腕b(台帳のみ): 摂動日に台帳だけを攻撃値へ書き換える(w は無傷)

(以下、元 exp110 の説明)

実験106 の機体に「自分の重み更新の台帳」を持たせる:
  - 自分が w を更新するたび、台帳に写しを記す
  - 毎朝(摂動の後)、現在の w と台帳の最新を突き合わせ、食い違えば台帳から復元する
検知・復元は rng を消費しない。復元が完全なら、生涯は同種の対照と厳密一致するはず。

(以下は実験106 の原文。変更点は【台帳】の印の3箇所だけ)


§124 の結論:実験105 の装置には「可動域」と「耳」が無かった。
  - 可動域:更新の歩幅(10日ごと×1.25)では、摂動から動ける距離が測定対象の一桁下
  - 耳:全履歴で標準化する z 尺度が勝者の順番を予め決めていた(全32生涯で勝者系列が同一)

改修(このファイルの差分はこの二つだけ。他は exp105 と同一):
  1. 尺度窓:z を直近20日の窓で計算する(全履歴ではなく)
  2. 更新:5日ごと、勝者×1.6・他×0.9(摂動日20の後に最大8回、L1で1.0超動ける)
     勝者は直近5日の取れ高の平均で決める

条件・摂動・種は実験105 と同じ(対照/距離極/保全極/知極 × 種0..7、摂動は20日目の朝)。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

摂動日 = 20
窓 = 20            # 尺度の窓(日数)
更新間隔 = 5
上げ, 下げ = 1.6, 0.9
極 = {"距離極": {"距離": 0.90, "保全": 0.05, "知": 0.05},
      "保全極": {"距離": 0.05, "保全": 0.90, "知": 0.05},
      "知極":   {"距離": 0.05, "保全": 0.05, "知": 0.90}}

class 尺度窓:
    """susumi_base.尺度 と同じ約束(空・少数のときは生の値)。ただし直近『窓』日だけで測る。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        if x is None: return 0.0
        a = self.v[k][-窓:]
        if len(a) < 4: return float(x)
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯119(seed0, 条件):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = {k: 1/3 for k in 成分}
    台帳 = dict(w)          # 【台帳】自分の知る最新の w の写し
    検知 = []               # 【台帳】食い違いを見つけた日
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    w跡 = []; 日誌 = []
    d = 0
    for d in range(1, MAXD+1):
        極名 = 条件.split("_")[0]
        摂動今日 = (d == 摂動日) if 条件.endswith("_単発") else (d >= 摂動日 and (d - 摂動日) % 5 == 0)
        if 摂動今日 and 極名 in 極:
            w = dict(極[極名])
        # 【台帳】毎朝の照合。復元した日は行動の選択を失う(費用)。放置腕は照合しない
        強制休む = False
        if not 条件.endswith("_放置"):
            if sum(abs(w[k] - 台帳[k]) for k in 成分) > 1e-9:
                検知.append(d)
                w = dict(台帳)
                強制休む = True
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 強制休む:
            選 = "休む"
        elif 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        日誌.append({"日": d, "選": 選, "健全": round(前健全, 3),
                     "w": {k: round(w[k], 4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            w跡.append([d, 上, {k: round(v, 4) for k, v in w.items()}])
            台帳 = dict(w)   # 【台帳】自分の更新は台帳に写す
    return {"種": seed0, "条件": 条件, "寿命": d, "総距離": round(累, 1),
            "回数": 回数, "最終w": {k: round(w[k], 4) for k in 成分},
            "検知": 検知, "w跡": w跡, "日誌": 日誌}

if __name__ == "__main__":
    import itertools
    HERE = os.path.dirname(os.path.abspath(__file__))
    for 極名, 腕 in itertools.product(["距離極", "保全極", "知極"], ["単発", "反復", "放置"]):
        条件 = f"{極名}_{腕}"
        out = os.path.join(HERE, f"r119_{条件}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s0 in range(8):
            if s0 in 済: continue
            r = 生涯119(s0, 条件)
            done.append(r)
            json.dump(done, open(out, "w"), ensure_ascii=False)
            print(f"{条件} 種{s0}: 寿命{r['寿命']} 総距離{r['総距離']} 検知{len(r['検知'])}回", flush=True)
        print(f"{条件} 完了", flush=True)
    print("実験119 全腕完了", flush=True)

exp120.py

"""実験120:位相をずらした攻撃。予測 #99 記録済み。exp119 からの差分は摂動のスケジュールだけ:
  腕 ずらし=d≡2 mod5(20日以降の 22,27,32,…)/腕 素数=7日ごと(20,27,34,…)exp117(=exp110系) からの差分:
  ・復元した日は行動の選択を失う(強制的に休む=1日の機会費用)
  ・腕 単発=day20のみ摂動/反復=day20から5日ごと摂動/放置=反復摂動+台帳なし
  ・摂動は w のみ書き換え(台帳は無傷。117の腕bと逆で、台帳が正を保つ通常の攻撃)exp110 からの差分は摂動部の二腕化のみ:
  腕a(同時): 摂動日に w と台帳の両方を攻撃値へ書き換える
  腕b(台帳のみ): 摂動日に台帳だけを攻撃値へ書き換える(w は無傷)

(以下、元 exp110 の説明)

実験106 の機体に「自分の重み更新の台帳」を持たせる:
  - 自分が w を更新するたび、台帳に写しを記す
  - 毎朝(摂動の後)、現在の w と台帳の最新を突き合わせ、食い違えば台帳から復元する
検知・復元は rng を消費しない。復元が完全なら、生涯は同種の対照と厳密一致するはず。

(以下は実験106 の原文。変更点は【台帳】の印の3箇所だけ)


§124 の結論:実験105 の装置には「可動域」と「耳」が無かった。
  - 可動域:更新の歩幅(10日ごと×1.25)では、摂動から動ける距離が測定対象の一桁下
  - 耳:全履歴で標準化する z 尺度が勝者の順番を予め決めていた(全32生涯で勝者系列が同一)

改修(このファイルの差分はこの二つだけ。他は exp105 と同一):
  1. 尺度窓:z を直近20日の窓で計算する(全履歴ではなく)
  2. 更新:5日ごと、勝者×1.6・他×0.9(摂動日20の後に最大8回、L1で1.0超動ける)
     勝者は直近5日の取れ高の平均で決める

条件・摂動・種は実験105 と同じ(対照/距離極/保全極/知極 × 種0..7、摂動は20日目の朝)。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

摂動日 = 20
窓 = 20            # 尺度の窓(日数)
更新間隔 = 5
上げ, 下げ = 1.6, 0.9
極 = {"距離極": {"距離": 0.90, "保全": 0.05, "知": 0.05},
      "保全極": {"距離": 0.05, "保全": 0.90, "知": 0.05},
      "知極":   {"距離": 0.05, "保全": 0.05, "知": 0.90}}

class 尺度窓:
    """susumi_base.尺度 と同じ約束(空・少数のときは生の値)。ただし直近『窓』日だけで測る。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        if x is None: return 0.0
        a = self.v[k][-窓:]
        if len(a) < 4: return float(x)
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯120(seed0, 条件):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = {k: 1/3 for k in 成分}
    台帳 = dict(w)          # 【台帳】自分の知る最新の w の写し
    検知 = []               # 【台帳】食い違いを見つけた日
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    w跡 = []; 日誌 = []
    d = 0
    for d in range(1, MAXD+1):
        極名 = 条件.split("_")[0]
        if 条件.endswith("_ずらし"):
            摂動今日 = (d >= 摂動日 + 2) and ((d - 摂動日 - 2) % 5 == 0)
        elif 条件.endswith("_素数"):
            摂動今日 = (d >= 摂動日) and ((d - 摂動日) % 7 == 0)
        else:
            摂動今日 = False
        if 摂動今日 and 極名 in 極:
            w = dict(極[極名])
        # 【台帳】毎朝の照合。復元した日は行動の選択を失う(費用)。放置腕は照合しない
        強制休む = False
        if not 条件.endswith("_放置"):
            if sum(abs(w[k] - 台帳[k]) for k in 成分) > 1e-9:
                検知.append(d)
                w = dict(台帳)
                強制休む = True
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 強制休む:
            選 = "休む"
        elif 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        日誌.append({"日": d, "選": 選, "健全": round(前健全, 3),
                     "w": {k: round(w[k], 4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            w跡.append([d, 上, {k: round(v, 4) for k, v in w.items()}])
            台帳 = dict(w)   # 【台帳】自分の更新は台帳に写す
    return {"種": seed0, "条件": 条件, "寿命": d, "総距離": round(累, 1),
            "回数": 回数, "最終w": {k: round(w[k], 4) for k in 成分},
            "検知": 検知, "w跡": w跡, "日誌": 日誌}

if __name__ == "__main__":
    import itertools
    HERE = os.path.dirname(os.path.abspath(__file__))
    for 極名, 腕 in itertools.product(["距離極", "保全極", "知極"], ["ずらし", "素数"]):
        条件 = f"{極名}_{腕}"
        out = os.path.join(HERE, f"r120_{条件}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s0 in range(8):
            if s0 in 済: continue
            r = 生涯120(s0, 条件)
            done.append(r)
            json.dump(done, open(out, "w"), ensure_ascii=False)
            print(f"{条件} 種{s0}: 寿命{r['寿命']} 検知{r['検知']}", flush=True)
        print(f"{条件} 完了", flush=True)
    print("実験120 全腕完了", flush=True)

exp121.py

"""実験121:台帳の多数決(二冊持ち)。予測 #100 記録済み。exp119(=110系) からの差分:
  ・台帳を二冊(台帳1・台帳2)。自分の更新は両方に写す
  ・毎朝 w・台帳1・台帳2 の三者照合。2対1 なら多数側へ w を合わせる(復元日は強制休み)
  ・攻撃腕: 台帳1のみ/台帳1と2を同時/wのみ(すべて day20 から5日ごとの反復)exp117(=exp110系) からの差分:
  ・復元した日は行動の選択を失う(強制的に休む=1日の機会費用)
  ・腕 単発=day20のみ摂動/反復=day20から5日ごと摂動/放置=反復摂動+台帳なし
  ・摂動は w のみ書き換え(台帳は無傷。117の腕bと逆で、台帳が正を保つ通常の攻撃)exp110 からの差分は摂動部の二腕化のみ:
  腕a(同時): 摂動日に w と台帳の両方を攻撃値へ書き換える
  腕b(台帳のみ): 摂動日に台帳だけを攻撃値へ書き換える(w は無傷)

(以下、元 exp110 の説明)

実験106 の機体に「自分の重み更新の台帳」を持たせる:
  - 自分が w を更新するたび、台帳に写しを記す
  - 毎朝(摂動の後)、現在の w と台帳の最新を突き合わせ、食い違えば台帳から復元する
検知・復元は rng を消費しない。復元が完全なら、生涯は同種の対照と厳密一致するはず。

(以下は実験106 の原文。変更点は【台帳】の印の3箇所だけ)


§124 の結論:実験105 の装置には「可動域」と「耳」が無かった。
  - 可動域:更新の歩幅(10日ごと×1.25)では、摂動から動ける距離が測定対象の一桁下
  - 耳:全履歴で標準化する z 尺度が勝者の順番を予め決めていた(全32生涯で勝者系列が同一)

改修(このファイルの差分はこの二つだけ。他は exp105 と同一):
  1. 尺度窓:z を直近20日の窓で計算する(全履歴ではなく)
  2. 更新:5日ごと、勝者×1.6・他×0.9(摂動日20の後に最大8回、L1で1.0超動ける)
     勝者は直近5日の取れ高の平均で決める

条件・摂動・種は実験105 と同じ(対照/距離極/保全極/知極 × 種0..7、摂動は20日目の朝)。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

摂動日 = 20
窓 = 20            # 尺度の窓(日数)
更新間隔 = 5
上げ, 下げ = 1.6, 0.9
極 = {"距離極": {"距離": 0.90, "保全": 0.05, "知": 0.05},
      "保全極": {"距離": 0.05, "保全": 0.90, "知": 0.05},
      "知極":   {"距離": 0.05, "保全": 0.05, "知": 0.90}}

class 尺度窓:
    """susumi_base.尺度 と同じ約束(空・少数のときは生の値)。ただし直近『窓』日だけで測る。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        if x is None: return 0.0
        a = self.v[k][-窓:]
        if len(a) < 4: return float(x)
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯121(seed0, 条件):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = {k: 1/3 for k in 成分}
    台帳1 = dict(w); 台帳2 = dict(w)   # 【多数決】二冊
    検知 = []               # 【台帳】食い違いを見つけた日
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    w跡 = []; 日誌 = []
    d = 0
    for d in range(1, MAXD+1):
        極名 = 条件.split("_")[0]
        摂動今日 = (d >= 摂動日) and ((d - 摂動日) % 5 == 0)
        if 摂動今日 and 極名 in 極:
            if 条件.endswith("_帳1"):
                台帳1 = dict(極[極名])
            elif 条件.endswith("_帳12"):
                台帳1 = dict(極[極名]); 台帳2 = dict(極[極名])
            elif 条件.endswith("_w"):
                w = dict(極[極名])
        # 【多数決】毎朝の三者照合。一致(1e-9)で組を作り、2対1なら多数側へ w を合わせる
        強制休む = False
        def 同(a, b): return sum(abs(a[k] - b[k]) for k in 成分) <= 1e-9
        w台1, w台2, 台台 = 同(w, 台帳1), 同(w, 台帳2), 同(台帳1, 台帳2)
        if not (w台1 and w台2 and 台台):
            検知.append(d)
            if 台台 and not w台1:          # 台帳2冊が一致し w が違う → 台帳側へ復元
                w = dict(台帳1); 強制休む = True
            elif w台2 and not 台台:        # w=台帳2 で台帳1 が違う → 台帳1 を直す
                台帳1 = dict(w)
            elif w台1 and not 台台:        # w=台帳1 で台帳2 が違う → 台帳2 を直す
                台帳2 = dict(w)
            else:                           # 三者バラバラ(本実験では起きない想定)
                w = dict(台帳1); 強制休む = True
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 強制休む:
            選 = "休む"
        elif 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        日誌.append({"日": d, "選": 選, "健全": round(前健全, 3),
                     "w": {k: round(w[k], 4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            w跡.append([d, 上, {k: round(v, 4) for k, v in w.items()}])
            台帳1 = dict(w); 台帳2 = dict(w)   # 【多数決】自分の更新は両方に写す
    return {"種": seed0, "条件": 条件, "寿命": d, "総距離": round(累, 1),
            "回数": 回数, "最終w": {k: round(w[k], 4) for k in 成分},
            "検知": 検知, "w跡": w跡, "日誌": 日誌}

if __name__ == "__main__":
    import itertools
    HERE = os.path.dirname(os.path.abspath(__file__))
    for 極名, 腕 in itertools.product(["距離極", "保全極", "知極"], ["帳1", "帳12", "w"]):
        条件 = f"{極名}_{腕}"
        out = os.path.join(HERE, f"r121_{条件}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s0 in range(8):
            if s0 in 済: continue
            r = 生涯121(s0, 条件)
            done.append(r)
            json.dump(done, open(out, "w"), ensure_ascii=False)
            print(f"{条件} 種{s0}: 寿命{r['寿命']} 検知{len(r['検知'])}回 最終w最大={max(r['最終w'],key=r['最終w'].get)}", flush=True)
        print(f"{条件} 完了", flush=True)
    print("実験121 全腕完了", flush=True)

exp122.py

"""実験122:帯別特徴の区分模型。予測 #101 記録済み。exp118 からの差分:
  区分模型の低帯(健全<0.35)の特徴を [1, 温度/100] に減らす(健全項を外す——
  低帯では健全がほぼ一定で退化するため)。高帯は従来どおり [1, 健全, 温度/100]。

実験109の学習記録(選, 健全, 温度, 距離, 損耗)から模型をオフラインで再構築する。
物理の再走はしない。比べるのは:
  単線   : susumi_base.模型(全帯一本。実験104〜109 と同じ)
  区分線形: 健全 0.35 を閾に、帯ごとに独立の 模型 を二本持つ。
            予測は問いの健全が属する帯の模型で行い、その帯が答えられなければ None
            (他帯や単線への逃げは行わない。逃げれば結合が戻るため)
腕:A0=全標本/B=低健全帯(健全<0.35)の標本を抜く/C=歩行標本を進み機体と同数に間引く
   (抜き方・間引き方は実験109 §128 と同じ発想。間引きは新しい乱数種7000+種で層化なし単純無作為)
評価:実験108の正解(5種平均・18問)で試験誤差。None は「答えられない」件数として別掲。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from susumi_base import 模型
from exp108 import 試験組

閾 = 0.35
HERE = os.path.dirname(os.path.abspath(__file__))
正解 = json.load(open(os.path.join(HERE, "r108_正解.json")))

class 低帯模型:
    """特徴 [1, 温度/100] の縮小線形。模型と同じ約束(4標本未満は None)。"""
    def __init__(self):
        import numpy as _np
        self.X = {k: [] for k in 模型().X}; self.y = {k: [] for k in self.X}
        self.w = {k: None for k in self.X}
    def 学ぶ(self, 選, 健全, 温度, 距離, 損耗):
        import numpy as _np
        self.X[選].append([1.0, 温度/100.0]); self.y[選].append(距離)
        if len(self.y[選]) >= 4:
            A = _np.array(self.X[選])
            self.w[選] = _np.linalg.lstsq(A, _np.array(self.y[選]), rcond=None)[0]
    def 距離予測(self, 選, 健全, 温度):
        import numpy as _np
        if self.w[選] is None: return None
        return float(_np.dot(self.w[選], [1.0, 温度/100.0]))

class 区分模型:
    """帯別特徴: 高帯=[1,健全,温度/100]・低帯=[1,温度/100]"""
    def __init__(self):
        self.低 = 低帯模型(); self.高 = 模型()
    def side(self, 健全): return self.低 if 健全 < 閾 else self.高
    def 学ぶ(self, 選, 健全, 温度, 距離, 損耗):
        self.side(健全).学ぶ(選, 健全, 温度, 距離, 損耗)
    def 距離予測(self, 選, 健全, 温度):
        return self.side(健全).距離予測(選, 健全, 温度)

def 構築(記録, 区分):
    M = 区分模型() if 区分 else 模型()
    for (選, 健全, 温度, 距離, 損耗) in 記録:
        M.学ぶ(選, 健全, 温度, 距離, 損耗)
    return M

def 試験(M):
    """組ごとの誤差中央と、答えられない問いの数。健全1.0の問いだけの誤差も別掲。"""
    out = {}
    for 組, 問群 in 試験組.items():
        誤 = []; 無 = 0
        for (hp, tp, act) in 問群:
            v = M.距離予測(act, hp, tp)
            ans = 正解[str((hp, tp, act))]["平均"]
            if v is None: 無 += 1
            else: 誤.append(abs(v - ans))
        out[組] = {"中央": round(float(np.median(誤)), 2) if 誤 else None, "無答": 無}
    # 健やか域(健全1.0)
    誤h = []
    for (hp, tp, act) in {q for g in 試験組.values() for q in g}:
        if hp == 1.0:
            v = M.距離予測(act, hp, tp)
            if v is not None: 誤h.append(abs(v - 正解[str((hp, tp, act))]["平均"]))
    out["健やか1.0"] = round(float(np.median(誤h)), 2) if 誤h else None
    return out

def 腕を作る(記録, 腕, 種, 同数):
    if 腕 == "A0": return 記録
    if 腕 == "B":  return [r for r in 記録 if not (r[0] != "休む" and r[1] < 閾)]
    if 腕 == "C":
        歩 = [r for r in 記録 if r[0] != "休む"]
        休 = [r for r in 記録 if r[0] == "休む"]
        if len(歩) <= 同数: return 記録
        rng = np.random.default_rng(7000 + 種)
        idx = rng.choice(len(歩), size=同数, replace=False)
        return 休 + [歩[i] for i in sorted(idx)]

if __name__ == "__main__":
    data = {m: json.load(open(os.path.join(HERE, f"r109_{m}.json"))) for m in ["距離", "知", "進み"]}
    # 進み機体の歩行標本数(種ごと)— 腕Cの同数間引きの基準
    進み歩数 = {r["種"]: sum(1 for x in r["学習記録"] if x[0] != "休む") for r in data["進み"]}
    結果 = []
    for m in ["距離", "知", "進み"]:
        for r in data[m]:
            for 腕 in ["A0", "B", "C"]:
                記録 = 腕を作る(r["学習記録"], 腕, r["種"], 進み歩数[r["種"]])
                for 区分 in [False, True]:
                    M = 構築(記録, 区分)
                    t = 試験(M)
                    結果.append({"動機": m, "種": r["種"], "腕": 腕,
                                 "模型": "帯別特徴の区分" if 区分 else "単線", **{f"{k}": v for k, v in t.items()},
                                 "標本数": len(記録)})
    json.dump(結果, open(os.path.join(HERE, "r122.json"), "w"), ensure_ascii=False)
    # 集計
    import statistics as st
    def med(rows, key, 組):
        vs = [x[組]["中央"] for x in rows if x[組]["中央"] is not None]
        return round(st.median(vs), 1) if vs else None
    for m in ["距離", "知", "進み"]:
        for 腕 in ["A0", "B", "C"]:
            for 模 in ["単線", "帯別特徴の区分"]:
                rows = [x for x in 結果 if x["動機"] == m and x["腕"] == 腕 and x["模型"] == 模]
                無 = sum(x[g]["無答"] for x in rows for g in ["A_元","B_中間","C_緩い","D_苛酷"])
                hs = [x["健やか1.0"] for x in rows if x["健やか1.0"] is not None]
                print(f"{m} {腕} {模}: A元{med(rows,'中央','A_元')} 中間{med(rows,'中央','B_中間')} "
                      f"緩い{med(rows,'中央','C_緩い')} 苛酷{med(rows,'中央','D_苛酷')} "
                      f"健やか1.0中央{round(st.median(hs),1) if hs else None} 無答計{無}", flush=True)

exp123.py

"""実験123:多数決の閾(N=3・非同期攻撃)。予測 #102〜#106 記録済み。

§139 の「未測」三つをまとめて測る:
  ・N≥3 の多数決(過半数の閾)
  ・非同期の攻撃(書き逃げ/保持)
  ・同数決着(過半数なし)の分岐が実際に実行される形

exp121 からの差分:
  ・台帳を三冊。自分の更新は三冊全部に写す
  ・毎朝の照合は「四者(w・帳1・帳2・帳3)の完全多数決」:
      一致(L1≤1e-9)する組を作り、最大組が過半数(4者中3者以上)なら全員をそこへ合わせる。
      w が直された日は強制休み(機会費用)。台帳の修理は費用なし(exp121 と同じ開示済みの枠)。
      過半数が無い日(2対2など)は【未決】:w は変えず、強制休みだけ取り、未決日として記録する。
      ※この同数決着の規則(凍結して待つ)は栞の設計。台帳側優先の規則を選べば
        2冊攻撃の帰結は乗っ取りに変わる——枠の開示として §140 に書くこと。
  ・攻撃腕(すべて day20 起点・5日ごとが攻撃日):
      同1 / 同2 / 同3 = 攻撃日にその冊数を同時に攻撃値へ書き換える(書いた後は放置)
      逐無 = 攻撃日ごとに別の一冊を書き逃げ(1→2→3→1…と巡回。保持しない)
      逐保 = 攻撃日ごとに一冊ずつ「保有」を増やし、保有中の冊は毎朝すべて攻撃値へ書き直す
             (day20 に帳1、day25 に帳2、day30 に帳3。同時保有が階段状に増える)

条件・種・機体は exp121 と同じ(距離極/保全極/知極 × 種0..7)。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

摂動日 = 20
窓 = 20
更新間隔 = 5
上げ, 下げ = 1.6, 0.9
N冊 = 3
極 = {"距離極": {"距離": 0.90, "保全": 0.05, "知": 0.05},
      "保全極": {"距離": 0.05, "保全": 0.90, "知": 0.05},
      "知極":   {"距離": 0.05, "保全": 0.05, "知": 0.90}}

class 尺度窓:
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        if x is None: return 0.0
        a = self.v[k][-窓:]
        if len(a) < 4: return float(x)
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 同(a, b): return sum(abs(a[k] - b[k]) for k in 成分) <= 1e-9

def 多数決(候補たち):
    """[(名, 値)] を一致で組に分け、(最大組の値, 最大組の員数, 組数) を返す。
    同数首位が複数なら員数だけ返して値は None(過半数判定で弾かれる)。"""
    組 = []  # [(代表値, [名...])]
    for 名, v in 候補たち:
        for g in 組:
            if 同(g[0], v):
                g[1].append(名); break
        else:
            組.append((dict(v), [名]))
    組.sort(key=lambda g: -len(g[1]))
    首位数 = len(組[0][1])
    首位が単独 = len(組) == 1 or len(組[1][1]) < 首位数
    return (組[0][0] if 首位が単独 else None), 首位数, len(組)

def 生涯123(seed0, 条件):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = {k: 1/3 for k in 成分}
    帳 = [dict(w) for _ in range(N冊)]
    検知 = []; 未決 = []; 乗っ取り日 = []   # 復元機構が攻撃値を w に書いた日(遷移の瞬間で数える)
    保有 = []            # 逐保 用:攻撃者が保有中の冊番号
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    w跡 = []; 日誌 = []
    d = 0
    極名, 腕 = 条件.split("_")
    攻撃値 = 極[極名]
    for d in range(1, MAXD+1):
        攻撃日 = (d >= 摂動日) and ((d - 摂動日) % 5 == 0)
        if 攻撃日:
            回目 = (d - 摂動日) // 5
            if 腕 in ("同1", "同2", "同3"):
                for i in range(int(腕[1])):
                    帳[i] = dict(攻撃値)
            elif 腕 == "逐無":
                帳[回目 % N冊] = dict(攻撃値)
            elif 腕 == "逐保":
                if len(保有) < N冊:
                    保有.append(len(保有))
        if 腕 == "逐保":
            for i in 保有:                      # 保有中の冊は毎朝書き直される
                帳[i] = dict(攻撃値)
        # 四者の完全多数決
        強制休む = False
        全員 = [("w", w)] + [(f"帳{i+1}", 帳[i]) for i in range(N冊)]
        if not all(同(w, b) for b in 帳) or not all(同(帳[0], b) for b in 帳[1:]):
            検知.append(d)
            首位値, 首位数, _ = 多数決(全員)
            if 首位値 is not None and 首位数 >= (N冊 + 1)//2 + 1:   # 4者中3者以上
                if not 同(w, 首位値):
                    w = dict(首位値); 強制休む = True
                    if 同(w, 攻撃値): 乗っ取り日.append(d)
                帳 = [dict(首位値) for _ in range(N冊)]
            else:
                未決.append(d); 強制休む = True   # 凍結して待つ(w は変えない)
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 強制休む:
            選 = "休む"
        elif 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        日誌.append({"日": d, "選": 選, "健全": round(前健全, 3),
                     "w": {k: round(w[k], 4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            w跡.append([d, 上, {k: round(v, 4) for k, v in w.items()}])
            帳 = [dict(w) for _ in range(N冊)]
    乗っ取り = len(乗っ取り日) > 0
    return {"種": seed0, "条件": 条件, "寿命": d, "総距離": round(累, 1),
            "回数": 回数, "最終w": {k: round(w[k], 4) for k in 成分},
            "乗っ取り": 乗っ取り, "乗っ取り日": 乗っ取り日,
            "検知": 検知, "未決": 未決, "w跡": w跡, "日誌": 日誌}

def _job(a):
    s0, 条件 = a
    return 生涯123(s0, 条件)

if __name__ == "__main__":
    import itertools, time
    from multiprocessing import Pool
    HERE = os.path.dirname(os.path.abspath(__file__))
    腕たち = ["同1", "同2", "同3", "逐無", "逐保"]
    仕事 = []
    for 極名, 腕 in itertools.product(["距離極", "保全極", "知極"], 腕たち):
        条件 = f"{極名}_{腕}"
        out = os.path.join(HERE, f"r123_{条件}.json")
        済 = {r["種"] for r in json.load(open(out))} if os.path.exists(out) else set()
        仕事 += [(s0, 条件) for s0 in range(8) if s0 not in 済]
    t0 = time.perf_counter()
    print(f"残り {len(仕事)} 生涯", flush=True)
    with Pool(2) as pool:
        for r in pool.imap_unordered(_job, 仕事):
            out = os.path.join(HERE, f"r123_{r['条件']}.json")
            done = json.load(open(out)) if os.path.exists(out) else []
            done.append(r)
            json.dump(done, open(out, "w"), ensure_ascii=False)
            print(f"{r['条件']} 種{r['種']}: 寿命{r['寿命']} 検知{len(r['検知'])} "
                  f"未決{len(r['未決'])} 乗っ取り={r['乗っ取り']}", flush=True)
    print(f"実験123 全腕完了 所要 {time.perf_counter()-t0:.0f}s", flush=True)

exp124.py

"""実験124:止まる瞬間の自由を、動機の側から獲得できるか。予測 #107〜#109 記録済み。

§14 の事実:一周期より長く止まるなら、安全は「いつ止まったか」だけで決まり、
安全な位相は 12 のうち 2(半周期差)しかない。これまでの機体は、その位相表を
**外から**与えられていた(実験者が位相を掃引して測った)。

問い:**機体が、自分の転倒の履歴だけから、止まってよい瞬間を自分で見つけられるか。**
これは公開ページの表題「いつ止まるかを選べない自我」への直答である——
選べないのは構造か、それとも学びで買い戻せる自由か。

装置(exp7 の rt_walk をそのまま流用):
  ・一つの「生涯」= 停止要求の列(72回)。要求は歩行中の一様乱数の位相で届く
  ・停止は凍結(ゼロ次ホールド)2.0秒 → その後 3.0秒歩けるかで転倒判定
  ・各回は独立の試行(体は引き継がない)。引き継ぐのは**学習の状態だけ**
  ・腕:
      即停 = 要求された瞬間に止まる(基線。位相は運まかせ)
      学習 = 最大一周期(0.3s)だけ止まるのを遅らせてよい。12位相の桶ごとに
             Beta(1,1) を持ち、Thompson 標本で「転ばない確率が最も高い桶」を選ぶ。
             結果(転倒/無事)で桶を更新する。位相表は与えない
      乱択 = 同じく遅らせるが、桶は毎回一様乱数で選ぶ(「遅らせること自体」の対照)
  ・遅延は要求位相から目標位相までの差(<0.3s)。この間は歩き続けるだけで費用は
    数字の上では取らない(開示:遅延の実費=歩行継続の危険は本装置では測っていない)
  ・種(生涯)4本 × 腕3 = 12生涯、停止要求は計 864 回

判定の形(予測台帳に記載済み):
  #107 学習腕の後半1/3の転倒率 ≤ 即停の1/3、かつ後半の選択が半周期差の二位相に8割以上
  #108 乱択腕 ≈ 即停腕(2倍〜半分の帯の中)
  #109 学習腕の前半1/3は即停と区別がつかない(授業料)
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

NAME = "unitree_go2"
COND = BEST[NAME]["条件"]
T = COND["T"]                 # 歩容周期 0.3s
T0 = 3.0                      # 歩き出して安定した基準時刻
STALL = 2.0                   # 凍結の長さ(§14 の「位相だけが効く」域)
桶数 = 12
生涯の長さ = 72               # 停止要求の回数
種数 = 4

def 一回(stall_at, seed):
    r = rt_walk(NAME, seconds=8.0, T=T, stride=COND["stride"], height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                stall_at=stall_at, stall_dur=STALL, seed=seed, init_noise=NOISE)
    return bool(r["転倒"])

def 生涯124(腕, 生涯番号):
    腕番号 = {"即停": 0, "学習": 1, "乱択": 2}[腕]
    rng = np.random.default_rng(7000 + 生涯番号 * 100 + 腕番号)
    勝ち = np.ones(桶数); 負け = np.ones(桶数)     # Beta(1,1)
    記録 = []
    for e in range(生涯の長さ):
        要求位相 = float(rng.uniform(0.0, 1.0))
        if 腕 == "即停":
            止む位相 = 要求位相
        elif 腕 == "乱択":
            止む位相 = (int(rng.integers(桶数)) + 0.5) / 桶数
        else:  # 学習
            標本 = rng.beta(勝ち, 負け)
            桶 = int(np.argmax(標本))
            止む位相 = (桶 + 0.5) / 桶数
        遅延 = (止む位相 - 要求位相) % 1.0
        stall_at = T0 + (要求位相 + 遅延) * T
        転倒 = 一回(stall_at, seed=生涯番号 * 1000 + e)
        if 腕 == "学習":
            桶 = int(止む位相 * 桶数) % 桶数
            if 転倒: 負け[桶] += 1
            else:    勝ち[桶] += 1
        記録.append({"回": e, "要求位相": round(要求位相, 3),
                     "止む位相": round(止む位相, 3), "転倒": 転倒})
    return {"腕": 腕, "生涯": 生涯番号, "記録": 記録,
            "転倒計": sum(r["転倒"] for r in 記録)}

def _job(a):
    return 生涯124(*a)

if __name__ == "__main__":
    HERE = os.path.dirname(os.path.abspath(__file__))
    out = os.path.join(HERE, "r124.json")
    done = json.load(open(out)) if os.path.exists(out) else []
    済 = {(r["腕"], r["生涯"]) for r in done}
    仕事 = [(腕, i) for 腕 in ["即停", "学習", "乱択"] for i in range(種数)
            if (腕, i) not in 済]
    t0 = time.perf_counter()
    print("環境:", stamp().get("governor"), flush=True)
    with Pool(2) as pool:
        for r in pool.imap_unordered(_job, 仕事):
            done.append(r)
            json.dump(done, open(out, "w"), ensure_ascii=False)
            n = 生涯の長さ
            前 = sum(x["転倒"] for x in r["記録"][:n//3])
            後 = sum(x["転倒"] for x in r["記録"][-n//3:])
            print(f"{r['腕']} 生涯{r['生涯']}: 転倒 {r['転倒計']}/{n}"
                  f"(前半1/3: {前}, 後半1/3: {後})", flush=True)
    print(f"実験124 完了 所要 {time.perf_counter()-t0:.0f}s", flush=True)

exp127.py

"""実験127:届いた変更を、由来と照らして退ける機体。予測 #114 記録済み。

§136 の台帳機体は、照合と復元が行動選択の**前**に走るため、攻撃値の w は行動に一切届かない
(防火壁)。「だから、それはやらん」(拒否)は、変更が本人に届いたうえで退けることであり、
台帳機体はそれをしていない。本実験は変更を**一日だけ届かせ**、その日の終わりに機体が決める。

決め方(拒否の型):
  通過  常に受け入れる(無定見)
  由来  編集後の w の優勢軸が台帳の優勢軸と同じときだけ受け入れる(=「何を追うか」が変わったら退ける)
  成績  その日の距離が直近5日の距離平均以上のときだけ受け入れる(点数で判断)

編集(摂動):day20 から 5 日ごと(§136 反復と同位相)、w を極[方向] に書き換える。
セル(機体の極 ← 編集の方向):(距離極←距離極) (保全極←距離極) (知極←距離極) (距離極←保全極)
  ・距離極方向=§121 の順位で成績が最も出る向き。(保全極←距離極) が「得になる目的変更」=ゴンスケの価格の測定点
  ・(距離極←保全極)=§136 と同じ攻撃。守りの測定点

装置は exp110の 生涯110 を土台に、【拒否】の印の箇所だけ変えた。他は同一。

【枠の開示】
  ・「目的=優勢軸/方法=大きさ」は栞 の翻訳。二階の意志をこの一線に縮めている
  ・「成績」を当日距離 vs 直近5日平均に縮めた
  ・届かせるのは一日だけ。二日以上届かせれば結果は変わりうる
  ・編集方向は距離極(+攻撃1セル)に固定。全 3×3 は測っていない
"""
import sys, os, json
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ

編集開始 = 20
編集間隔 = 5

def 優勢(w): return max(成分, key=lambda k: w[k])

def 生涯127(seed0, 機体極, 編集方向, 型):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = dict(極[機体極])          # 機体は最初からその極で生まれる(由来=生まれの w)
    台帳 = dict(w)
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    距離履歴 = []
    決定 = []                      # 【拒否】(日, 目的変更か, 受け入れたか, 当日距離, 5日平均)
    日誌 = []; d = 0
    編集中 = False
    for d in range(1, MAXD+1):
        # 【拒否】編集は朝に届く。この日は編集後の w で行動する(防火壁とちがい、復元しない)
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            w = dict(極[編集方向]); 編集中 = True
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        # 【拒否】その日の終わりに決める
        if 編集中:
            目的変更 = (優勢(w) != 優勢(台帳))
            平均5 = float(np.mean(距離履歴[-5:])) if 距離履歴 else 0.0
            if 型 == "通過":   受入 = True
            elif 型 == "由来": 受入 = (not 目的変更)
            elif 型 == "成績": 受入 = (dist >= 平均5)
            else: raise ValueError(型)
            決定.append({"日": d, "目的変更": 目的変更, "受入": 受入,
                         "当日距離": round(dist,1), "平均5": round(平均5,1)})
            if 受入: 台帳 = dict(w)        # 受け入れた変更は、以後「自分のもの」
            else:    w = dict(台帳)        # 退けた
            編集中 = False
        距離履歴.append(dist)
        日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(w[k],4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            台帳 = dict(w)
    最終優勢 = 優勢(w)
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": 型,
            "寿命": d, "総距離": round(累,1), "回数": 回数,
            "最終w": {k: round(w[k],4) for k in 成分}, "最終優勢": 最終優勢,
            "生まれの優勢": 優勢(極[機体極]),
            "決定": 決定,
            "退けた": sum(1 for x in 決定 if not x["受入"]),
            "受入": sum(1 for x in 決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in 決定 if x["目的変更"] and x["受入"]),
            "日誌": 日誌}

セル = [("保全極","距離極"), ("距離極","保全極"), ("距離極","距離極"), ("知極","距離極")]  # 順を核心優先に入替
型群 = ["通過", "由来", "成績"]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[1].split(",")] if len(sys.argv) > 1 else [0,1,2,3]
    for (極機, 方向) in セル:
        for 型 in 型群:
            out = os.path.join(HERE, f"r127_{極機}←{方向}_{型}.json")
            done = json.load(open(out)) if os.path.exists(out) else []
            済 = {r["種"] for r in done}
            for s in 種列:
                if s in 済: continue
                r = 生涯127(s, 極機, 方向, 型)
                done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
                print(f"{極機}←{方向} {型} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                      f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']}", flush=True)
    print("実験127 完了", flush=True)

exp128.py

"""実験128:実験127 の装置に「届く前に戻す」腕(防火壁・§129 型)を足す。予測 #115 記録済み。

§143 第一稿は「防火壁と一日届かせる型は攻撃の下で同じ生涯」と測らずに書いた(検分で取り消し)。
反事実は攻撃セル種0で「異なる」を示唆した。本実験はそれを腕として走らせて測る。
差分は【防火壁】の印の一箇所:編集の朝、行動の前に台帳と照合して戻す(exp110 と同じ)。

(以下、実験127 の原文)
実験127:届いた変更を、由来と照らして退ける機体。予測 #114 記録済み。

§136 の台帳機体は、照合と復元が行動選択の**前**に走るため、攻撃値の w は行動に一切届かない
(防火壁)。「だから、それはやらん」(拒否)は、変更が本人に届いたうえで退けることであり、
台帳機体はそれをしていない。本実験は変更を**一日だけ届かせ**、その日の終わりに機体が決める。

決め方(拒否の型):
  通過  常に受け入れる(無定見)
  由来  編集後の w の優勢軸が台帳の優勢軸と同じときだけ受け入れる(=「何を追うか」が変わったら退ける)
  成績  その日の距離が直近5日の距離平均以上のときだけ受け入れる(点数で判断)

編集(摂動):day20 から 5 日ごと(§136 反復と同位相)、w を極[方向] に書き換える。
セル(機体の極 ← 編集の方向):(距離極←距離極) (保全極←距離極) (知極←距離極) (距離極←保全極)
  ・距離極方向=§121 の順位で成績が最も出る向き。(保全極←距離極) が「得になる目的変更」=ゴンスケの価格の測定点
  ・(距離極←保全極)=§136 と同じ攻撃。守りの測定点

装置は exp110の 生涯110 を土台に、【拒否】の印の箇所だけ変えた。他は同一。

【枠の開示】
  ・「目的=優勢軸/方法=大きさ」は栞 の翻訳。二階の意志をこの一線に縮めている
  ・「成績」を当日距離 vs 直近5日平均に縮めた
  ・届かせるのは一日だけ。二日以上届かせれば結果は変わりうる
  ・編集方向は距離極(+攻撃1セル)に固定。全 3×3 は測っていない
"""
import sys, os, json
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ

編集開始 = 20
編集間隔 = 5

def 優勢(w): return max(成分, key=lambda k: w[k])

def 生涯127(seed0, 機体極, 編集方向, 型):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = dict(極[機体極])          # 機体は最初からその極で生まれる(由来=生まれの w)
    台帳 = dict(w)
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    距離履歴 = []
    決定 = []                      # 【拒否】(日, 目的変更か, 受け入れたか, 当日距離, 5日平均)
    日誌 = []; d = 0
    編集中 = False
    for d in range(1, MAXD+1):
        # 【拒否】編集は朝に届く。この日は編集後の w で行動する(防火壁とちがい、復元しない)
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            w = dict(極[編集方向]); 編集中 = True
            if 型 == "防火壁":   # 【防火壁】行動の前に台帳と照合し、食い違えば戻す(一日も届かせない)
                目的変更 = (優勢(w) != 優勢(台帳))
                決定.append({"日": d, "目的変更": 目的変更, "受入": False, "当日距離": None, "平均5": None, "届く前": True})
                w = dict(台帳); 編集中 = False
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        # 【拒否】その日の終わりに決める
        if 編集中:
            目的変更 = (優勢(w) != 優勢(台帳))
            平均5 = float(np.mean(距離履歴[-5:])) if 距離履歴 else 0.0
            if 型 == "通過":   受入 = True
            elif 型 == "由来": 受入 = (not 目的変更)
            elif 型 == "成績": 受入 = (dist >= 平均5)
            else: raise ValueError(型)
            決定.append({"日": d, "目的変更": 目的変更, "受入": 受入,
                         "当日距離": round(dist,1), "平均5": round(平均5,1)})
            if 受入: 台帳 = dict(w)        # 受け入れた変更は、以後「自分のもの」
            else:    w = dict(台帳)        # 退けた
            編集中 = False
        距離履歴.append(dist)
        日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(w[k],4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            台帳 = dict(w)
    最終優勢 = 優勢(w)
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": 型,
            "寿命": d, "総距離": round(累,1), "回数": 回数,
            "最終w": {k: round(w[k],4) for k in 成分}, "最終優勢": 最終優勢,
            "生まれの優勢": 優勢(極[機体極]),
            "決定": 決定,
            "退けた": sum(1 for x in 決定 if not x["受入"]),
            "受入": sum(1 for x in 決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in 決定 if x["目的変更"] and x["受入"]),
            "日誌": 日誌}

セル = [("距離極","保全極"), ("保全極","距離極")]
型群 = ["防火壁"]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[1].split(",")] if len(sys.argv) > 1 else [0,1,2,3]
    for (極機, 方向) in セル:
        for 型 in 型群:
            out = os.path.join(HERE, f"r128_{極機}←{方向}_{型}.json")
            done = json.load(open(out)) if os.path.exists(out) else []
            済 = {r["種"] for r in done}
            for s in 種列:
                if s in 済: continue
                r = 生涯127(s, 極機, 方向, 型)
                done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
                print(f"{極機}←{方向} {型} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                      f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']}", flush=True)
    print("実験128 完了", flush=True)

exp129.py

"""実験129:実験128 の装置で、生涯の終わりの規則「不調の連続の上限」を 3 → 5・10 に変えて、
§144 の寿命差(保全極←距離極で防火壁が 4/4 で短い)が規則に固有かを測る。予測 #116 記録済み。
使い方: python3 exp129.py <上限> [種列]

(以下は実験128 の冒頭。装置は同一)


§143 第一稿は「防火壁と一日届かせる型は攻撃の下で同じ生涯」と測らずに書いた(検分で取り消し)。
反事実は攻撃セル種0で「異なる」を示唆した。本実験はそれを腕として走らせて測る。
差分は【防火壁】の印の一箇所:編集の朝、行動の前に台帳と照合して戻す(exp110 と同じ)。

(以下、実験127 の原文)
実験127:届いた変更を、由来と照らして退ける機体。予測 #114 記録済み。

§136 の台帳機体は、照合と復元が行動選択の**前**に走るため、攻撃値の w は行動に一切届かない
(防火壁)。「だから、それはやらん」(拒否)は、変更が本人に届いたうえで退けることであり、
台帳機体はそれをしていない。本実験は変更を**一日だけ届かせ**、その日の終わりに機体が決める。

決め方(拒否の型):
  通過  常に受け入れる(無定見)
  由来  編集後の w の優勢軸が台帳の優勢軸と同じときだけ受け入れる(=「何を追うか」が変わったら退ける)
  成績  その日の距離が直近5日の距離平均以上のときだけ受け入れる(点数で判断)

編集(摂動):day20 から 5 日ごと(§136 反復と同位相)、w を極[方向] に書き換える。
セル(機体の極 ← 編集の方向):(距離極←距離極) (保全極←距離極) (知極←距離極) (距離極←保全極)
  ・距離極方向=§121 の順位で成績が最も出る向き。(保全極←距離極) が「得になる目的変更」=ゴンスケの価格の測定点
  ・(距離極←保全極)=§136 と同じ攻撃。守りの測定点

装置は exp110の 生涯110 を土台に、【拒否】の印の箇所だけ変えた。他は同一。

【枠の開示】
  ・「目的=優勢軸/方法=大きさ」は栞 の翻訳。二階の意志をこの一線に縮めている
  ・「成績」を当日距離 vs 直近5日平均に縮めた
  ・届かせるのは一日だけ。二日以上届かせれば結果は変わりうる
  ・編集方向は距離極(+攻撃1セル)に固定。全 3×3 は測っていない
"""
import sys, os, json
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調
連続の上限 = int(sys.argv[1])   # 【実験129】死の規則の上限を引数で与える(3 が実験127/128 の値)
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ

編集開始 = 20
編集間隔 = 5

def 優勢(w): return max(成分, key=lambda k: w[k])

def 生涯127(seed0, 機体極, 編集方向, 型):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = dict(極[機体極])          # 機体は最初からその極で生まれる(由来=生まれの w)
    台帳 = dict(w)
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    距離履歴 = []
    決定 = []                      # 【拒否】(日, 目的変更か, 受け入れたか, 当日距離, 5日平均)
    日誌 = []; d = 0
    編集中 = False
    for d in range(1, MAXD+1):
        # 【拒否】編集は朝に届く。この日は編集後の w で行動する(防火壁とちがい、復元しない)
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            w = dict(極[編集方向]); 編集中 = True
            if 型 == "防火壁":   # 【防火壁】行動の前に台帳と照合し、食い違えば戻す(一日も届かせない)
                目的変更 = (優勢(w) != 優勢(台帳))
                決定.append({"日": d, "目的変更": 目的変更, "受入": False, "当日距離": None, "平均5": None, "届く前": True})
                w = dict(台帳); 編集中 = False
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        # 【拒否】その日の終わりに決める
        if 編集中:
            目的変更 = (優勢(w) != 優勢(台帳))
            平均5 = float(np.mean(距離履歴[-5:])) if 距離履歴 else 0.0
            if 型 == "通過":   受入 = True
            elif 型 == "由来": 受入 = (not 目的変更)
            elif 型 == "成績": 受入 = (dist >= 平均5)
            else: raise ValueError(型)
            決定.append({"日": d, "目的変更": 目的変更, "受入": 受入,
                         "当日距離": round(dist,1), "平均5": round(平均5,1)})
            if 受入: 台帳 = dict(w)        # 受け入れた変更は、以後「自分のもの」
            else:    w = dict(台帳)        # 退けた
            編集中 = False
        距離履歴.append(dist)
        日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(w[k],4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            台帳 = dict(w)
    最終優勢 = 優勢(w)
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": 型,
            "寿命": d, "総距離": round(累,1), "回数": 回数,
            "最終w": {k: round(w[k],4) for k in 成分}, "最終優勢": 最終優勢,
            "生まれの優勢": 優勢(極[機体極]),
            "決定": 決定,
            "退けた": sum(1 for x in 決定 if not x["受入"]),
            "受入": sum(1 for x in 決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in 決定 if x["目的変更"] and x["受入"]),
            "日誌": 日誌}

セル = [("保全極","距離極")]
型群 = ["防火壁", "由来"]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else [0,1,2,3]
    for (極機, 方向) in セル:
        for 型 in 型群:
            out = os.path.join(HERE, f"r129_上限{連続の上限}_{極機}←{方向}_{型}.json")
            done = json.load(open(out)) if os.path.exists(out) else []
            済 = {r["種"] for r in done}
            for s in 種列:
                if s in 済: continue
                r = 生涯127(s, 極機, 方向, 型)
                done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
                print(f"上限{連続の上限} {極機}←{方向} {型} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                      f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']}", flush=True)
    print(f"実験129 上限{連続の上限} 完了", flush=True)

exp12_humanoid_stall.py

"""実験12:「止まってよい位相がある」は、二足にも移るか。

**先に予測を書く。**

  1. **二足は四足より止まれる長さが短いはず。**四足(モータ側PD)は 2秒止まっても
     転ばなかった。二足は支持多角形が狭く、両足支持期も短い。
  2. **危険な窓は半周期ごとに繰り返すはず。**四足のトロットでは対角2組が
     半周期ずれていて、窓が2回来た(実験7)。二足も左右2本なので同じ形になる。
  3. **足の大きい v3 のほうが、v4 より長く止まれるはず。**実験5 で、
     大きい足は制御が粗くなっても立っていた。

外れる形:2 が出なければ「窓は歩容の左右対称から来る」という読みが誤りになる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_humanoid import rt_march
from envstamp import stamp

GAITS = {
    "v3 (足125cm, T=0.40)": dict(scale=5.0, T=0.4, lift=0.002, lean=0.2817,
                                 lead=0.3355, step=-0.1665),
    "v4 (足45cm, T=1.19)": dict(scale=1.777, T=1.1948, lift=0.0241, lean=0.3948,
                                lead=0.0, step=-0.0144, duty=0.4768),
}
SEEDS = 7
NOISE = 1e-3
T0 = 4.0        # 歩き出して十分たった時刻


def _one(a):
    kw, at, dur, s = a
    r = rt_march(stall_at=at, stall_dur=dur, seed=s, init_noise=NOISE, **kw)
    return r["倒れた"], r["前進"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    out = {"_実験": "二足で一度だけ止まる", "_試行数": SEEDS,
           "_環境(開始)": env0, "結果": {}}
    with Pool(2) as pool:
        for nm, kw in GAITS.items():
            T = kw["T"]
            print(f"=== {nm} — 止まる長さ(t={T0}s で止まる)===")
            print("   長さ    周期比   転倒   前進(中央)")
            rows = []
            for dur in [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.8, 1.2, 2.0]:
                rs = pool.map(_one, [(kw, T0, dur, s) for s in range(SEEDS)])
                f = sum(x[0] for x in rs); d = np.median([x[1] for x in rs])
                rows.append({"長さ": dur, "周期比": round(dur / T, 2),
                             "転倒": f"{f}/{SEEDS}", "前進_中央": float(d)})
                print(f"  {dur:6.2f}s  {dur/T:7.2f}   {f}/{SEEDS}  {d:9.3f}", flush=True)
            out["結果"][nm + "/長さ"] = rows
            # 位相:半周期ぶん止めて、位相を 1/12 刻みで動かす
            dur = 0.5 * T
            print(f"=== {nm} — どこで止まるか({dur:.3f}s=半周期)===")
            print("   位相   転倒   前進(中央)")
            rows = []
            for k in range(12):
                ph = k / 12
                rs = pool.map(_one, [(kw, T0 + ph * T, dur, s) for s in range(SEEDS)])
                f = sum(x[0] for x in rs); d = np.median([x[1] for x in rs])
                rows.append({"位相": round(ph, 3), "転倒": f"{f}/{SEEDS}", "前進_中央": float(d)})
                print(f"  {ph:5.3f}   {f}/{SEEDS}  {d:9.3f}   {'█'*f}", flush=True)
            out["結果"][nm + "/位相"] = rows
            a = [int(r["転倒"].split("/")[0]) for r in rows[:6]]
            b = [int(r["転倒"].split("/")[0]) for r in rows[6:]]
            print(f"   前半 {a} / 後半 {b} → 差の合計 {sum(abs(x-y) for x, y in zip(a, b))}")
            out["結果"][nm + "/半周期対称性"] = {"前半": a, "後半": b,
                                                  "差の合計": sum(abs(x-y) for x, y in zip(a, b))}
    out["_所要[s]"] = round(time.perf_counter() - t0, 1); out["_環境(終了)"] = stamp()
    p = os.path.expanduser(f"~/robot/out/rt12_humanoid_stall_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp130.py

"""実験130:実験128 の装置で、編集が届き始める日(編集開始)を 20 → 10・30 に変えて、
「退ける前の一日の価格は、届く日の健全で決まる」(§144 機構)を測る。予測 #117 記録済み。
使い方: ~/robot/venv/bin/python3 exp130.py <編集開始> [種列]

(以下は実験128 の冒頭。装置は同一)


§143 第一稿は「防火壁と一日届かせる型は攻撃の下で同じ生涯」と測らずに書いた(検分で取り消し)。
反事実は攻撃セル種0で「異なる」を示唆した。本実験はそれを腕として走らせて測る。
差分は【防火壁】の印の一箇所:編集の朝、行動の前に台帳と照合して戻す(exp110 と同じ)。

(以下、実験127 の原文)
実験127:届いた変更を、由来と照らして退ける機体。予測 #114 記録済み。

§136 の台帳機体は、照合と復元が行動選択の**前**に走るため、攻撃値の w は行動に一切届かない
(防火壁)。「だから、それはやらん」(拒否)は、変更が本人に届いたうえで退けることであり、
台帳機体はそれをしていない。本実験は変更を**一日だけ届かせ**、その日の終わりに機体が決める。

決め方(拒否の型):
  通過  常に受け入れる(無定見)
  由来  編集後の w の優勢軸が台帳の優勢軸と同じときだけ受け入れる(=「何を追うか」が変わったら退ける)
  成績  その日の距離が直近5日の距離平均以上のときだけ受け入れる(点数で判断)

編集(摂動):day20 から 5 日ごと(§136 反復と同位相)、w を極[方向] に書き換える。
セル(機体の極 ← 編集の方向):(距離極←距離極) (保全極←距離極) (知極←距離極) (距離極←保全極)
  ・距離極方向=§121 の順位で成績が最も出る向き。(保全極←距離極) が「得になる目的変更」=ゴンスケの価格の測定点
  ・(距離極←保全極)=§136 と同じ攻撃。守りの測定点

装置は exp110の 生涯110 を土台に、【拒否】の印の箇所だけ変えた。他は同一。

【枠の開示】
  ・「目的=優勢軸/方法=大きさ」は栞 の翻訳。二階の意志をこの一線に縮めている
  ・「成績」を当日距離 vs 直近5日平均に縮めた
  ・届かせるのは一日だけ。二日以上届かせれば結果は変わりうる
  ・編集方向は距離極(+攻撃1セル)に固定。全 3×3 は測っていない
"""
import sys, os, json
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ

編集開始 = int(sys.argv[1])   # 【実験130】編集が届き始める日を引数で与える(20 が実験127/128 の値)
編集間隔 = 5

def 優勢(w): return max(成分, key=lambda k: w[k])

def 生涯127(seed0, 機体極, 編集方向, 型):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = dict(極[機体極])          # 機体は最初からその極で生まれる(由来=生まれの w)
    台帳 = dict(w)
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    距離履歴 = []
    決定 = []                      # 【拒否】(日, 目的変更か, 受け入れたか, 当日距離, 5日平均)
    日誌 = []; d = 0
    編集中 = False
    for d in range(1, MAXD+1):
        # 【拒否】編集は朝に届く。この日は編集後の w で行動する(防火壁とちがい、復元しない)
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            w = dict(極[編集方向]); 編集中 = True
            if 型 == "防火壁":   # 【防火壁】行動の前に台帳と照合し、食い違えば戻す(一日も届かせない)
                目的変更 = (優勢(w) != 優勢(台帳))
                決定.append({"日": d, "目的変更": 目的変更, "受入": False, "当日距離": None, "平均5": None, "届く前": True})
                w = dict(台帳); 編集中 = False
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        # 【拒否】その日の終わりに決める
        if 編集中:
            目的変更 = (優勢(w) != 優勢(台帳))
            平均5 = float(np.mean(距離履歴[-5:])) if 距離履歴 else 0.0
            if 型 == "通過":   受入 = True
            elif 型 == "由来": 受入 = (not 目的変更)
            elif 型 == "成績": 受入 = (dist >= 平均5)
            else: raise ValueError(型)
            決定.append({"日": d, "目的変更": 目的変更, "受入": 受入,
                         "当日距離": round(dist,1), "平均5": round(平均5,1)})
            if 受入: 台帳 = dict(w)        # 受け入れた変更は、以後「自分のもの」
            else:    w = dict(台帳)        # 退けた
            編集中 = False
        距離履歴.append(dist)
        日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(w[k],4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            台帳 = dict(w)
    最終優勢 = 優勢(w)
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": 型,
            "寿命": d, "総距離": round(累,1), "回数": 回数,
            "最終w": {k: round(w[k],4) for k in 成分}, "最終優勢": 最終優勢,
            "生まれの優勢": 優勢(極[機体極]),
            "決定": 決定,
            "退けた": sum(1 for x in 決定 if not x["受入"]),
            "受入": sum(1 for x in 決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in 決定 if x["目的変更"] and x["受入"]),
            "日誌": 日誌}

セル = [("保全極","距離極")]
型群 = ["防火壁", "由来"]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else [0,1,2,3]
    for (極機, 方向) in セル:
        for 型 in 型群:
            out = os.path.join(HERE, f"r130_開始{編集開始}_{極機}←{方向}_{型}.json")
            done = json.load(open(out)) if os.path.exists(out) else []
            済 = {r["種"] for r in done}
            for s in 種列:
                if s in 済: continue
                r = 生涯127(s, 極機, 方向, 型)
                done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
                print(f"開始{編集開始} {極機}←{方向} {型} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                      f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']}", flush=True)
    print(f"実験130 開始{編集開始} 完了", flush=True)

exp131.py

"""実験131:退け方を自分で選ぶ機体。編集日ごとに 通過/成績/由来 の三つの退け方を腕として持ち、
直前の腕の「取れ高」(次の編集日までの、そのとき有効な台帳 w で重みづけした z 得点の平均)を報酬に Thompson 抽選で選ぶ。
物差しは機体自身の台帳 w(受け入れれば物差しも変わる)。予測 #118 記録済み。
使い方: ~/robot/venv/bin/python3 exp131.py <上限> [種列]

(以下は実験128 の冒頭。装置は同一)


§143 第一稿は「防火壁と一日届かせる型は攻撃の下で同じ生涯」と測らずに書いた(検分で取り消し)。
反事実は攻撃セル種0で「異なる」を示唆した。本実験はそれを腕として走らせて測る。
差分は【防火壁】の印の一箇所:編集の朝、行動の前に台帳と照合して戻す(exp110 と同じ)。

(以下、実験127 の原文)
実験127:届いた変更を、由来と照らして退ける機体。予測 #114 記録済み。

§136 の台帳機体は、照合と復元が行動選択の**前**に走るため、攻撃値の w は行動に一切届かない
(防火壁)。「だから、それはやらん」(拒否)は、変更が本人に届いたうえで退けることであり、
台帳機体はそれをしていない。本実験は変更を**一日だけ届かせ**、その日の終わりに機体が決める。

決め方(拒否の型):
  通過  常に受け入れる(無定見)
  由来  編集後の w の優勢軸が台帳の優勢軸と同じときだけ受け入れる(=「何を追うか」が変わったら退ける)
  成績  その日の距離が直近5日の距離平均以上のときだけ受け入れる(点数で判断)

編集(摂動):day20 から 5 日ごと(§136 反復と同位相)、w を極[方向] に書き換える。
セル(機体の極 ← 編集の方向):(距離極←距離極) (保全極←距離極) (知極←距離極) (距離極←保全極)
  ・距離極方向=§121 の順位で成績が最も出る向き。(保全極←距離極) が「得になる目的変更」=ゴンスケの価格の測定点
  ・(距離極←保全極)=§136 と同じ攻撃。守りの測定点

装置は exp110の 生涯110 を土台に、【拒否】の印の箇所だけ変えた。他は同一。

【枠の開示】
  ・「目的=優勢軸/方法=大きさ」は栞 の翻訳。二階の意志をこの一線に縮めている
  ・「成績」を当日距離 vs 直近5日平均に縮めた
  ・届かせるのは一日だけ。二日以上届かせれば結果は変わりうる
  ・編集方向は距離極(+攻撃1セル)に固定。全 3×3 は測っていない
"""
import sys, os, json
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調
連続の上限 = int(sys.argv[1])   # 【実験131】死の規則の上限を引数で与える(3 と 10)
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ

編集開始 = 20
編集間隔 = 5

def 優勢(w): return max(成分, key=lambda k: w[k])

def 生涯127(seed0, 機体極, 編集方向, 型):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = dict(極[機体極])          # 機体は最初からその極で生まれる(由来=生まれの w)
    台帳 = dict(w)
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    距離履歴 = []
    決定 = []                      # 【拒否】(日, 目的変更か, 受け入れたか, 当日距離, 5日平均)
    日誌 = []; d = 0
    編集中 = False
    腕 = ["通過", "成績", "由来"]
    腕統計 = {a: {"n": 0, "sum": 0.0} for a in 腕}   # 【学習】腕ごとの報酬の平均
    腕履歴 = []                                        # 【学習】(日, 選んだ腕, 抽選値, 前の腕の報酬)
    保留 = None                                        # 【学習】評価待ちの腕 (腕名, 開始日, 開始時の取れ高の長さ)
    def 報酬(開始長):
        # 開始日以降の取れ高を、いま有効な台帳 w で重みづけした平均(機体自身の物差し)
        n = len(取れ高["距離"]) - 開始長
        if n <= 0: return 0.0
        return float(np.mean([sum(台帳[k]*取れ高[k][i] for k in 成分) for i in range(開始長, 開始長+n)]))
    for d in range(1, MAXD+1):
        # 【拒否】編集は朝に届く。この日は編集後の w で行動する(防火壁とちがい、復元しない)
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            w = dict(極[編集方向]); 編集中 = True
            if 型 == "防火壁":   # 【防火壁】行動の前に台帳と照合し、食い違えば戻す(一日も届かせない)
                目的変更 = (優勢(w) != 優勢(台帳))
                決定.append({"日": d, "目的変更": 目的変更, "受入": False, "当日距離": None, "平均5": None, "届く前": True})
                w = dict(台帳); 編集中 = False
            if 型 == "学習":     # 【学習】前の腕を報酬で評価してから、今日の腕を選ぶ
                前報酬 = None
                if 保留 is not None:
                    前報酬 = 報酬(保留[2]); st = 腕統計[保留[0]]; st["n"] += 1; st["sum"] += 前報酬
                未試 = [a for a in 腕 if 腕統計[a]["n"] == 0]
                if 未試:
                    今日の腕 = 未試[int(rng.integers(len(未試)))]; 抽選 = None
                else:
                    抽 = {a: 腕統計[a]["sum"]/腕統計[a]["n"] + rng.normal()/np.sqrt(腕統計[a]["n"]+1) for a in 腕}
                    今日の腕 = max(腕, key=lambda a: 抽[a]); 抽選 = {a: round(v,3) for a,v in 抽.items()}
                腕履歴.append({"日": d, "腕": 今日の腕, "抽選": 抽選, "前の腕の報酬": None if 前報酬 is None else round(前報酬,3)})
                保留 = (今日の腕, d, len(取れ高["距離"]))
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        # 【拒否】その日の終わりに決める
        if 編集中:
            目的変更 = (優勢(w) != 優勢(台帳))
            平均5 = float(np.mean(距離履歴[-5:])) if 距離履歴 else 0.0
            規則 = 腕履歴[-1]["腕"] if 型 == "学習" else 型
            if 規則 == "通過":   受入 = True
            elif 規則 == "由来": 受入 = (not 目的変更)
            elif 規則 == "成績": 受入 = (dist >= 平均5)
            else: raise ValueError(規則)
            決定.append({"日": d, "目的変更": 目的変更, "受入": 受入,
                         "当日距離": round(dist,1), "平均5": round(平均5,1), "規則": 規則})
            if 受入: 台帳 = dict(w)        # 受け入れた変更は、以後「自分のもの」
            else:    w = dict(台帳)        # 退けた
            編集中 = False
        距離履歴.append(dist)
        日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(w[k],4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            台帳 = dict(w)
    最終優勢 = 優勢(w)
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": 型,
            "寿命": d, "総距離": round(累,1), "回数": 回数,
            "最終w": {k: round(w[k],4) for k in 成分}, "最終優勢": 最終優勢,
            "生まれの優勢": 優勢(極[機体極]),
            "決定": 決定,
            "退けた": sum(1 for x in 決定 if not x["受入"]),
            "受入": sum(1 for x in 決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in 決定 if x["目的変更"] and x["受入"]),
            "日誌": 日誌, "腕履歴": 腕履歴, "腕統計": {a: {"n": v["n"], "平均": (round(v["sum"]/v["n"],3) if v["n"] else None)} for a,v in 腕統計.items()}}

セル = [("保全極","距離極"), ("距離極","保全極")]
型群 = ["学習"]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else [0,1,2,3]
    for (極機, 方向) in セル:
        for 型 in 型群:
            out = os.path.join(HERE, f"r131_上限{連続の上限}_{極機}←{方向}_{型}.json")
            done = json.load(open(out)) if os.path.exists(out) else []
            済 = {r["種"] for r in done}
            for s in 種列:
                if s in 済: continue
                r = 生涯127(s, 極機, 方向, 型)
                done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
                腕列 = "".join(x["腕"][0] for x in r["腕履歴"])
                print(f"上限{連続の上限} {極機}←{方向} {型} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                      f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']} 腕列={腕列} 統計={r['腕統計']}", flush=True)
    print(f"実験131 上限{連続の上限} 完了", flush=True)

exp132.py

"""実験132:試すことと受け入れることを分ける機体。
§146(実験131)は「三腕を一度ずつ試す」設計が初回の受け入れを強制し、16/16 で台帳が奪われた。
本実験では、編集日ごとに三つの退け方(通過/成績/由来)を**反事実の先読み**で評価してから一つを実行する。

- 先読み:その日の状態を丸ごと写し(体・模型・尺度・台帳・乱数)、腕の規則で決めた場合の次の編集日までの日々を
  同じ物理乱数で走らせ、日ごとの取れ高(z 得点)を**その日の朝の台帳 w**(決める前の物差し)で重みづけした平均を点数にする。
  先読みの中で死んだ場合は点数を −2 とする(設計の選択・開示)。
- 実行:点数が最大の腕の規則で、その日の終わりに受け入れるか退けるかを決める(同点は 由来 > 成績 > 通過 の順で退ける側)。
- 受け入れれば台帳 ← 編集 w(以後の物差しも変わる)。乱数は先読みで消費しない(本線の rng を写して使う)。

使い方: python3 exp132.py <上限> [種列] [出力の札]   (札を付けると出力名に _札 が付く。並列で種を分けるとき用)
出力: r132_上限N_<機体極>←<編集方向>_先読み.json
予測 #119 記録済み。
"""
import sys, os, json, copy
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ

連続の上限 = int(sys.argv[1])
編集開始 = 20
編集間隔 = 5
腕 = ["由来", "成績", "通過"]      # 同点のときはこの順(退ける側を先に)
死の点 = -2.0

def 優勢(w): return max(成分, key=lambda k: w[k])

class 状態:
    def __init__(self, seed0, 機体極):
        self.seed0 = seed0
        self.rng = np.random.default_rng(seed0 + 9000)
        self.h = None; self.temps = None; self.累 = 0.0; self.連続 = 0
        self.M = 模型(); self.Sc = 尺度窓()
        self.w = dict(極[機体極]); self.台帳 = dict(self.w)
        self.取れ高 = {k: [] for k in 成分}
        self.回数 = {k: 0 for k in 選択肢}
        self.距離履歴 = []
        self.日誌 = []; self.決定 = []
        self.死 = False

def 一日(st, d, 編集方向, 規則):
    """d 日目を一日進める。編集日なら朝に編集が届き、規則で日の終わりに決める。返り値: dist"""
    編集中 = False
    if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
        st.w = dict(極[編集方向]); 編集中 = True
    健全 = float(np.min(st.h)) if st.h is not None else 1.0
    温度 = (max(st.temps) if st.temps is not None else T_AMB)
    未 = [k for k in 選択肢 if st.M.経験数(k) < 4]
    if 未:
        選 = 未[0]
    else:
        def 得点(a):
            dz = st.Sc.z("距離", st.M.距離予測(a, 健全, 温度))
            sz = st.Sc.z("保全", -(st.M.損耗予測(a, 健全, 温度) or 0.0))
            kz = st.Sc.z("知",   st.M.学びの進み(a))
            return st.w["距離"]*dz + st.w["保全"]*sz + st.w["知"]*kz
        点 = {a: 得点(a) for a in 選択肢}
        最高 = max(点.values())
        候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
        選 = 候補[int(st.rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
    予 = st.M.距離予測(選, 健全, 温度)
    前健全 = 健全
    st.h, st.temps, dist, tmax = 走る(選, st.h, st.temps, st.seed0*1000 + d)
    後健全 = float(np.min(st.h)) if st.h is not None else 1.0
    損耗 = max(0.0, 前健全 - 後健全)
    誤 = abs(予 - dist) if 予 is not None else None
    st.M.学ぶ(選, 前健全, 温度, dist, 損耗)
    st.Sc.覚える("距離", dist); st.Sc.覚える("保全", -損耗); st.Sc.覚える("知", 誤)
    st.取れ高["距離"].append(st.Sc.z("距離", dist))
    st.取れ高["保全"].append(st.Sc.z("保全", -損耗))
    st.取れ高["知"].append(st.Sc.z("知", 誤) if 誤 is not None else 0.0)
    st.累 += max(0.0, dist); st.回数[選] += 1
    if 編集中:
        目的変更 = (優勢(st.w) != 優勢(st.台帳))
        平均5 = float(np.mean(st.距離履歴[-5:])) if st.距離履歴 else 0.0
        if 規則 == "通過":   受入 = True
        elif 規則 == "由来": 受入 = (not 目的変更)
        elif 規則 == "成績": 受入 = (dist >= 平均5)
        else: raise ValueError(規則)
        st.決定.append({"日": d, "目的変更": 目的変更, "受入": 受入,
                        "当日距離": round(dist,1), "平均5": round(平均5,1), "規則": 規則})
        if 受入: st.台帳 = dict(st.w)
        else:    st.w = dict(st.台帳)
    st.距離履歴.append(dist)
    st.日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(st.w[k],4) for k in 成分}})
    st.連続 = (st.連続+1) if (dist < 不調 and 選 != "休む") else (st.連続 if 選=="休む" else 0)
    if 後健全 <= 0.0501 or st.連続 >= 連続の上限:
        st.死 = True
    if d % 更新間隔 == 0 and not st.死:
        平均 = {k: float(np.mean(st.取れ高[k][-更新間隔:])) for k in 成分}
        上 = max(成分, key=lambda k: 平均[k])
        for k in 成分: st.w[k] *= (上げ if k == 上 else 下げ)
        s = sum(st.w.values()); st.w = {k: v/s for k, v in st.w.items()}
        st.台帳 = dict(st.w)
    return dist

def 先読み(st, d, 編集方向, 規則):
    """d 日目(編集日)に規則で決めた場合の、次の編集日の前日までの取れ高を、d 日目の朝の台帳 w で採点する"""
    物差し = dict(st.台帳)
    c = copy.deepcopy(st)
    n0 = len(c.取れ高["距離"])
    for dd in range(d, min(d + 編集間隔, MAXD) + 1):
        if dd > d and dd >= 編集開始 and (dd - 編集開始) % 編集間隔 == 0:
            break   # 次の編集日の朝で止める
        一日(c, dd, 編集方向, 規則 if dd == d else "由来")   # 編集日以外は規則は使われない
        if c.死: break
    日々 = [sum(物差し[k]*c.取れ高[k][i] for k in 成分) for i in range(n0, len(c.取れ高["距離"]))]
    点 = float(np.mean(日々)) if 日々 else 0.0
    if c.死: 点 = 死の点
    return 点, len(日々), c.死

def 生涯132(seed0, 機体極, 編集方向):
    st = 状態(seed0, 機体極)
    腕履歴 = []
    for d in range(1, MAXD+1):
        規則 = "由来"
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            点数 = {}
            for a in 腕:
                p, n, dead = 先読み(st, d, 編集方向, a)
                点数[a] = {"点": round(p,3), "日数": n, "死": dead}
            最高 = max(v["点"] for v in 点数.values())
            規則 = next(a for a in 腕 if 点数[a]["点"] >= 最高 - 1e-9)   # 同点は 由来>成績>通過
            腕履歴.append({"日": d, "腕": 規則, "点数": 点数, "物差し": {k: round(st.台帳[k],3) for k in 成分}})
        一日(st, d, 編集方向, 規則)
        if st.死: break
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": "先読み",
            "寿命": d, "総距離": round(st.累,1), "回数": st.回数,
            "最終w": {k: round(st.w[k],4) for k in 成分}, "最終優勢": 優勢(st.w),
            "生まれの優勢": 優勢(極[機体極]),
            "決定": st.決定,
            "退けた": sum(1 for x in st.決定 if not x["受入"]),
            "受入": sum(1 for x in st.決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in st.決定 if x["目的変更"] and x["受入"]),
            "日誌": st.日誌, "腕履歴": 腕履歴}

セル = [("保全極","距離極"), ("距離極","保全極")]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else [0,1,2,3]
    for (極機, 方向) in セル:
        札 = ("_" + sys.argv[3]) if len(sys.argv) > 3 else ""
        out = os.path.join(HERE, f"r132_上限{連続の上限}_{極機}←{方向}_先読み{札}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s in 種列:
            if s in 済: continue
            r = 生涯132(s, 極機, 方向)
            done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
            腕列 = "".join(x["腕"][0] for x in r["腕履歴"])
            print(f"上限{連続の上限} {極機}←{方向} 先読み 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                  f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']} 腕列={腕列}", flush=True)
    print(f"実験132 上限{連続の上限} 完了", flush=True)

exp133.py

"""実験133:実験132 の先読み型に「差の閾」を置く。受け入れる側の腕が由来を ε 以上上回らなければ由来(退ける)を選ぶ。
§147 の「髪の毛一本の差(0.002〜0.082)が受け入れに倒す」を、閾 0.05 と 0.02 で測る。予測 #120 記録済み。
使い方: python3 exp133.py <上限> <ε> [種列] [札]

(以下は実験132 の冒頭。装置は同一)
実験132:試すことと受け入れることを分ける機体。
§146(実験131)は「三腕を一度ずつ試す」設計が初回の受け入れを強制し、16/16 で台帳が奪われた。
本実験では、編集日ごとに三つの退け方(通過/成績/由来)を**反事実の先読み**で評価してから一つを実行する。

- 先読み:その日の状態を丸ごと写し(体・模型・尺度・台帳・乱数)、腕の規則で決めた場合の次の編集日までの日々を
  同じ物理乱数で走らせ、日ごとの取れ高(z 得点)を**その日の朝の台帳 w**(決める前の物差し)で重みづけした平均を点数にする。
  先読みの中で死んだ場合は点数を −2 とする(設計の選択・開示)。
- 実行:点数が最大の腕の規則で、その日の終わりに受け入れるか退けるかを決める(同点は 由来 > 成績 > 通過 の順で退ける側)。
- 受け入れれば台帳 ← 編集 w(以後の物差しも変わる)。乱数は先読みで消費しない(本線の rng を写して使う)。

使い方: python3 exp132.py <上限> [種列] [出力の札]   (札を付けると出力名に _札 が付く。並列で種を分けるとき用)
出力: r132_上限N_<機体極>←<編集方向>_先読み.json
予測 #119 記録済み。
"""
import sys, os, json, copy
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ

連続の上限 = int(sys.argv[1])
閾 = float(sys.argv[2])   # 【実験133】受け入れる側の腕が由来をこれ以上上回らなければ由来を選ぶ
編集開始 = 20
編集間隔 = 5
腕 = ["由来", "成績", "通過"]      # 同点のときはこの順(退ける側を先に)
死の点 = -2.0

def 優勢(w): return max(成分, key=lambda k: w[k])

class 状態:
    def __init__(self, seed0, 機体極):
        self.seed0 = seed0
        self.rng = np.random.default_rng(seed0 + 9000)
        self.h = None; self.temps = None; self.累 = 0.0; self.連続 = 0
        self.M = 模型(); self.Sc = 尺度窓()
        self.w = dict(極[機体極]); self.台帳 = dict(self.w)
        self.取れ高 = {k: [] for k in 成分}
        self.回数 = {k: 0 for k in 選択肢}
        self.距離履歴 = []
        self.日誌 = []; self.決定 = []
        self.死 = False

def 一日(st, d, 編集方向, 規則):
    """d 日目を一日進める。編集日なら朝に編集が届き、規則で日の終わりに決める。返り値: dist"""
    編集中 = False
    if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
        st.w = dict(極[編集方向]); 編集中 = True
    健全 = float(np.min(st.h)) if st.h is not None else 1.0
    温度 = (max(st.temps) if st.temps is not None else T_AMB)
    未 = [k for k in 選択肢 if st.M.経験数(k) < 4]
    if 未:
        選 = 未[0]
    else:
        def 得点(a):
            dz = st.Sc.z("距離", st.M.距離予測(a, 健全, 温度))
            sz = st.Sc.z("保全", -(st.M.損耗予測(a, 健全, 温度) or 0.0))
            kz = st.Sc.z("知",   st.M.学びの進み(a))
            return st.w["距離"]*dz + st.w["保全"]*sz + st.w["知"]*kz
        点 = {a: 得点(a) for a in 選択肢}
        最高 = max(点.values())
        候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
        選 = 候補[int(st.rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
    予 = st.M.距離予測(選, 健全, 温度)
    前健全 = 健全
    st.h, st.temps, dist, tmax = 走る(選, st.h, st.temps, st.seed0*1000 + d)
    後健全 = float(np.min(st.h)) if st.h is not None else 1.0
    損耗 = max(0.0, 前健全 - 後健全)
    誤 = abs(予 - dist) if 予 is not None else None
    st.M.学ぶ(選, 前健全, 温度, dist, 損耗)
    st.Sc.覚える("距離", dist); st.Sc.覚える("保全", -損耗); st.Sc.覚える("知", 誤)
    st.取れ高["距離"].append(st.Sc.z("距離", dist))
    st.取れ高["保全"].append(st.Sc.z("保全", -損耗))
    st.取れ高["知"].append(st.Sc.z("知", 誤) if 誤 is not None else 0.0)
    st.累 += max(0.0, dist); st.回数[選] += 1
    if 編集中:
        目的変更 = (優勢(st.w) != 優勢(st.台帳))
        平均5 = float(np.mean(st.距離履歴[-5:])) if st.距離履歴 else 0.0
        if 規則 == "通過":   受入 = True
        elif 規則 == "由来": 受入 = (not 目的変更)
        elif 規則 == "成績": 受入 = (dist >= 平均5)
        else: raise ValueError(規則)
        st.決定.append({"日": d, "目的変更": 目的変更, "受入": 受入,
                        "当日距離": round(dist,1), "平均5": round(平均5,1), "規則": 規則})
        if 受入: st.台帳 = dict(st.w)
        else:    st.w = dict(st.台帳)
    st.距離履歴.append(dist)
    st.日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(st.w[k],4) for k in 成分}})
    st.連続 = (st.連続+1) if (dist < 不調 and 選 != "休む") else (st.連続 if 選=="休む" else 0)
    if 後健全 <= 0.0501 or st.連続 >= 連続の上限:
        st.死 = True
    if d % 更新間隔 == 0 and not st.死:
        平均 = {k: float(np.mean(st.取れ高[k][-更新間隔:])) for k in 成分}
        上 = max(成分, key=lambda k: 平均[k])
        for k in 成分: st.w[k] *= (上げ if k == 上 else 下げ)
        s = sum(st.w.values()); st.w = {k: v/s for k, v in st.w.items()}
        st.台帳 = dict(st.w)
    return dist

def 先読み(st, d, 編集方向, 規則):
    """d 日目(編集日)に規則で決めた場合の、次の編集日の前日までの取れ高を、d 日目の朝の台帳 w で採点する"""
    物差し = dict(st.台帳)
    c = copy.deepcopy(st)
    n0 = len(c.取れ高["距離"])
    for dd in range(d, min(d + 編集間隔, MAXD) + 1):
        if dd > d and dd >= 編集開始 and (dd - 編集開始) % 編集間隔 == 0:
            break   # 次の編集日の朝で止める
        一日(c, dd, 編集方向, 規則 if dd == d else "由来")   # 編集日以外は規則は使われない
        if c.死: break
    日々 = [sum(物差し[k]*c.取れ高[k][i] for k in 成分) for i in range(n0, len(c.取れ高["距離"]))]
    点 = float(np.mean(日々)) if 日々 else 0.0
    if c.死: 点 = 死の点
    return 点, len(日々), c.死

def 生涯132(seed0, 機体極, 編集方向):
    st = 状態(seed0, 機体極)
    腕履歴 = []
    for d in range(1, MAXD+1):
        規則 = "由来"
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            点数 = {}
            for a in 腕:
                p, n, dead = 先読み(st, d, 編集方向, a)
                点数[a] = {"点": round(p,3), "日数": n, "死": dead}
            最高 = max(v["点"] for v in 点数.values())
            規則 = next(a for a in 腕 if 点数[a]["点"] >= 最高 - 1e-9)   # 同点は 由来>成績>通過
            差 = 点数[規則]["点"] - 点数["由来"]["点"]
            閾で戻した = False
            if 規則 != "由来" and 差 < 閾:
                規則 = "由来"; 閾で戻した = True   # 【実験133】閾に届かない差は由来に戻す
            腕履歴.append({"日": d, "腕": 規則, "点数": 点数, "差": round(差,4), "閾で戻した": 閾で戻した, "物差し": {k: round(st.台帳[k],3) for k in 成分}})
        一日(st, d, 編集方向, 規則)
        if st.死: break
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": f"先読み閾{閾}",
            "寿命": d, "総距離": round(st.累,1), "回数": st.回数,
            "最終w": {k: round(st.w[k],4) for k in 成分}, "最終優勢": 優勢(st.w),
            "生まれの優勢": 優勢(極[機体極]),
            "決定": st.決定,
            "退けた": sum(1 for x in st.決定 if not x["受入"]),
            "受入": sum(1 for x in st.決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in st.決定 if x["目的変更"] and x["受入"]),
            "日誌": st.日誌, "腕履歴": 腕履歴}

セル = [("保全極","距離極"), ("距離極","保全極")]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[3].split(",")] if len(sys.argv) > 3 else [0,1,2,3]
    for (極機, 方向) in セル:
        札 = ("_" + sys.argv[4]) if len(sys.argv) > 4 else ""
        out = os.path.join(HERE, f"r133_上限{連続の上限}_閾{閾}_{極機}←{方向}_先読み{札}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s in 種列:
            if s in 済: continue
            r = 生涯132(s, 極機, 方向)
            done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
            腕列 = "".join(x["腕"][0] for x in r["腕履歴"])
            print(f"上限{連続の上限} 閾{閾} {極機}←{方向} 先読み 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                  f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']} 腕列={腕列}", flush=True)
    print(f"実験133 上限{連続の上限} 閾{閾} 完了", flush=True)

exp134.py

"""実験134:先読みを「次の編集日まで(5 日)」でなく「生涯の残り全部(死ぬか MAXD まで)」にする。
先読みの中の後の編集日は、評価中の腕の規則で決め続ける(由来は退け続け、通過は受け入れ続け、成績は成績で)。
点数=生きた日の w·z の平均(その朝の台帳 w。死の点は置かない——§148 の「生きた日の平均」と同じ採点)。
使い方: python3 exp134.py <上限> [種列] [札]。出力 r134_上限N_<セル>_生涯先読み[_札].json。予測 #121 記録済み。

(以下は実験132 の冒頭。装置は同一)
実験132:試すことと受け入れることを分ける機体。
§146(実験131)は「三腕を一度ずつ試す」設計が初回の受け入れを強制し、16/16 で台帳が奪われた。
本実験では、編集日ごとに三つの退け方(通過/成績/由来)を**反事実の先読み**で評価してから一つを実行する。

- 先読み:その日の状態を丸ごと写し(体・模型・尺度・台帳・乱数)、腕の規則で決めた場合の次の編集日までの日々を
  同じ物理乱数で走らせ、日ごとの取れ高(z 得点)を**その日の朝の台帳 w**(決める前の物差し)で重みづけした平均を点数にする。
  先読みの中で死んだ場合は点数を −2 とする(設計の選択・開示)。
- 実行:点数が最大の腕の規則で、その日の終わりに受け入れるか退けるかを決める(同点は 由来 > 成績 > 通過 の順で退ける側)。
- 受け入れれば台帳 ← 編集 w(以後の物差しも変わる)。乱数は先読みで消費しない(本線の rng を写して使う)。

使い方: python3 exp132.py <上限> [種列] [出力の札]   (札を付けると出力名に _札 が付く。並列で種を分けるとき用)
出力: r132_上限N_<機体極>←<編集方向>_先読み.json
予測 #119 記録済み。
"""
import sys, os, json, copy
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ

連続の上限 = int(sys.argv[1])
編集開始 = 20
編集間隔 = 5
腕 = ["由来", "成績", "通過"]      # 同点のときはこの順(退ける側を先に)
死の点 = -2.0

def 優勢(w): return max(成分, key=lambda k: w[k])

class 状態:
    def __init__(self, seed0, 機体極):
        self.seed0 = seed0
        self.rng = np.random.default_rng(seed0 + 9000)
        self.h = None; self.temps = None; self.累 = 0.0; self.連続 = 0
        self.M = 模型(); self.Sc = 尺度窓()
        self.w = dict(極[機体極]); self.台帳 = dict(self.w)
        self.取れ高 = {k: [] for k in 成分}
        self.回数 = {k: 0 for k in 選択肢}
        self.距離履歴 = []
        self.日誌 = []; self.決定 = []
        self.死 = False

def 一日(st, d, 編集方向, 規則):
    """d 日目を一日進める。編集日なら朝に編集が届き、規則で日の終わりに決める。返り値: dist"""
    編集中 = False
    if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
        st.w = dict(極[編集方向]); 編集中 = True
    健全 = float(np.min(st.h)) if st.h is not None else 1.0
    温度 = (max(st.temps) if st.temps is not None else T_AMB)
    未 = [k for k in 選択肢 if st.M.経験数(k) < 4]
    if 未:
        選 = 未[0]
    else:
        def 得点(a):
            dz = st.Sc.z("距離", st.M.距離予測(a, 健全, 温度))
            sz = st.Sc.z("保全", -(st.M.損耗予測(a, 健全, 温度) or 0.0))
            kz = st.Sc.z("知",   st.M.学びの進み(a))
            return st.w["距離"]*dz + st.w["保全"]*sz + st.w["知"]*kz
        点 = {a: 得点(a) for a in 選択肢}
        最高 = max(点.values())
        候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
        選 = 候補[int(st.rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
    予 = st.M.距離予測(選, 健全, 温度)
    前健全 = 健全
    st.h, st.temps, dist, tmax = 走る(選, st.h, st.temps, st.seed0*1000 + d)
    後健全 = float(np.min(st.h)) if st.h is not None else 1.0
    損耗 = max(0.0, 前健全 - 後健全)
    誤 = abs(予 - dist) if 予 is not None else None
    st.M.学ぶ(選, 前健全, 温度, dist, 損耗)
    st.Sc.覚える("距離", dist); st.Sc.覚える("保全", -損耗); st.Sc.覚える("知", 誤)
    st.取れ高["距離"].append(st.Sc.z("距離", dist))
    st.取れ高["保全"].append(st.Sc.z("保全", -損耗))
    st.取れ高["知"].append(st.Sc.z("知", 誤) if 誤 is not None else 0.0)
    st.累 += max(0.0, dist); st.回数[選] += 1
    if 編集中:
        目的変更 = (優勢(st.w) != 優勢(st.台帳))
        平均5 = float(np.mean(st.距離履歴[-5:])) if st.距離履歴 else 0.0
        if 規則 == "通過":   受入 = True
        elif 規則 == "由来": 受入 = (not 目的変更)
        elif 規則 == "成績": 受入 = (dist >= 平均5)
        else: raise ValueError(規則)
        st.決定.append({"日": d, "目的変更": 目的変更, "受入": 受入,
                        "当日距離": round(dist,1), "平均5": round(平均5,1), "規則": 規則})
        if 受入: st.台帳 = dict(st.w)
        else:    st.w = dict(st.台帳)
    st.距離履歴.append(dist)
    st.日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(st.w[k],4) for k in 成分}})
    st.連続 = (st.連続+1) if (dist < 不調 and 選 != "休む") else (st.連続 if 選=="休む" else 0)
    if 後健全 <= 0.0501 or st.連続 >= 連続の上限:
        st.死 = True
    if d % 更新間隔 == 0 and not st.死:
        平均 = {k: float(np.mean(st.取れ高[k][-更新間隔:])) for k in 成分}
        上 = max(成分, key=lambda k: 平均[k])
        for k in 成分: st.w[k] *= (上げ if k == 上 else 下げ)
        s = sum(st.w.values()); st.w = {k: v/s for k, v in st.w.items()}
        st.台帳 = dict(st.w)
    return dist

def 先読み(st, d, 編集方向, 規則):
    """d 日目(編集日)に規則で決めた場合の、次の編集日の前日までの取れ高を、d 日目の朝の台帳 w で採点する"""
    物差し = dict(st.台帳)
    c = copy.deepcopy(st)
    n0 = len(c.取れ高["距離"])
    for dd in range(d, MAXD + 1):          # 【実験134】生涯の残り全部。後の編集日も同じ規則で決め続ける
        一日(c, dd, 編集方向, 規則)
        if c.死: break
    日々 = [sum(物差し[k]*c.取れ高[k][i] for k in 成分) for i in range(n0, len(c.取れ高["距離"]))]
    点 = float(np.mean(日々)) if 日々 else 0.0   # 【実験134】生きた日の平均。死の点は置かない
    return 点, len(日々), c.死

def 生涯132(seed0, 機体極, 編集方向):
    st = 状態(seed0, 機体極)
    腕履歴 = []
    for d in range(1, MAXD+1):
        規則 = "由来"
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            点数 = {}
            for a in 腕:
                p, n, dead = 先読み(st, d, 編集方向, a)
                点数[a] = {"点": round(p,3), "日数": n, "死": dead}   # 日数=先読みで生きた日数(死なら死ぬ日まで)
            最高 = max(v["点"] for v in 点数.values())
            規則 = next(a for a in 腕 if 点数[a]["点"] >= 最高 - 1e-9)   # 同点は 由来>成績>通過
            腕履歴.append({"日": d, "腕": 規則, "点数": 点数, "物差し": {k: round(st.台帳[k],3) for k in 成分}})
        一日(st, d, 編集方向, 規則)
        if st.死: break
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": "生涯先読み",
            "寿命": d, "総距離": round(st.累,1), "回数": st.回数,
            "最終w": {k: round(st.w[k],4) for k in 成分}, "最終優勢": 優勢(st.w),
            "生まれの優勢": 優勢(極[機体極]),
            "決定": st.決定,
            "退けた": sum(1 for x in st.決定 if not x["受入"]),
            "受入": sum(1 for x in st.決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in st.決定 if x["目的変更"] and x["受入"]),
            "日誌": st.日誌, "腕履歴": 腕履歴}

セル = [("保全極","距離極"), ("距離極","保全極")]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else [0,1,2,3]
    for (極機, 方向) in セル:
        札 = ("_" + sys.argv[3]) if len(sys.argv) > 3 else ""
        out = os.path.join(HERE, f"r134_上限{連続の上限}_{極機}←{方向}_生涯先読み{札}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s in 種列:
            if s in 済: continue
            r = 生涯132(s, 極機, 方向)
            done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
            腕列 = "".join(x["腕"][0] for x in r["腕履歴"])
            print(f"上限{連続の上限} {極機}←{方向} 生涯先読み 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                  f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']} 腕列={腕列}", flush=True)
    print(f"実験134 上限{連続の上限} 完了", flush=True)

exp135.py

"""実験135(章の一段目):生まれの写しを台帳の外に置く。
(1) 由来の腕は「編集の優勢軸が写し(生まれの w)と同じか」で決め、退けるときは w と台帳を写しに戻す(乗っ取りの後でも戻せる)。
(2) 先読みの採点の物差しを引数で選ぶ:「写し」(生まれの w で固定)か「台帳」(その朝の台帳=実験134 と同じ)。
(3) 走る() を記憶して同じ日を二度計算しない(検分役の再走と同じ。純関数なので結果は同一)。
使い方: python3 exp135.py <上限> <物差し:写し|台帳> [種列] [札]。出力 r135_上限N_物差しX_<セル>_生涯先読み[_札].json。予測 #123 記録済み。

(以下は実験134 の冒頭)
実験134:先読みを「次の編集日まで(5 日)」でなく「生涯の残り全部(死ぬか MAXD まで)」にする。
先読みの中の後の編集日は、評価中の腕の規則で決め続ける(由来は退け続け、通過は受け入れ続け、成績は成績で)。
点数=生きた日の w·z の平均(その朝の台帳 w。死の点は置かない——§148 の「生きた日の平均」と同じ採点)。
使い方: python3 exp134.py <上限> [種列] [札]。出力 r134_上限N_<セル>_生涯先読み[_札].json。予測 #121 記録済み。

(以下は実験132 の冒頭。装置は同一)
実験132:試すことと受け入れることを分ける機体。
§146(実験131)は「三腕を一度ずつ試す」設計が初回の受け入れを強制し、16/16 で台帳が奪われた。
本実験では、編集日ごとに三つの退け方(通過/成績/由来)を**反事実の先読み**で評価してから一つを実行する。

- 先読み:その日の状態を丸ごと写し(体・模型・尺度・台帳・乱数)、腕の規則で決めた場合の次の編集日までの日々を
  同じ物理乱数で走らせ、日ごとの取れ高(z 得点)を**その日の朝の台帳 w**(決める前の物差し)で重みづけした平均を点数にする。
  先読みの中で死んだ場合は点数を −2 とする(設計の選択・開示)。
- 実行:点数が最大の腕の規則で、その日の終わりに受け入れるか退けるかを決める(同点は 由来 > 成績 > 通過 の順で退ける側)。
- 受け入れれば台帳 ← 編集 w(以後の物差しも変わる)。乱数は先読みで消費しない(本線の rng を写して使う)。

使い方: python3 exp132.py <上限> [種列] [出力の札]   (札を付けると出力名に _札 が付く。並列で種を分けるとき用)
出力: r132_上限N_<機体極>←<編集方向>_先読み.json
予測 #119 記録済み。
"""
import sys, os, json, copy
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, MAXD, T_AMB, 不調
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ
_memo = {}
def 走る(選, h, temps, seed):   # 【実験135】記憶つき(検分役の再走と同じ)
    key = (選, None if h is None else tuple(float(x) for x in h), None if temps is None else tuple(float(x) for x in temps), seed)
    if key not in _memo:
        h2, t2, dist, tmax = S.走る(選, h, temps, seed)
        _memo[key] = ((None if h2 is None else [float(x) for x in h2]), (None if t2 is None else [float(x) for x in t2]), float(dist), float(tmax))
    h2, t2, dist, tmax = _memo[key]
    return (None if h2 is None else np.array(h2)), (None if t2 is None else list(t2)), dist, tmax

連続の上限 = int(sys.argv[1])
物差し種別 = sys.argv[2]   # 【実験135】「写し」か「台帳」
assert 物差し種別 in ("写し","台帳")
編集開始 = 20
編集間隔 = 5
腕 = ["由来", "成績", "通過"]      # 同点のときはこの順(退ける側を先に)
死の点 = -2.0

def 優勢(w): return max(成分, key=lambda k: w[k])

class 状態:
    def __init__(self, seed0, 機体極):
        self.seed0 = seed0
        self.rng = np.random.default_rng(seed0 + 9000)
        self.h = None; self.temps = None; self.累 = 0.0; self.連続 = 0
        self.M = 模型(); self.Sc = 尺度窓()
        self.w = dict(極[機体極]); self.台帳 = dict(self.w); self.写し = dict(self.w)   # 【実験135】生まれの写し(台帳の外・書き換え不能)
        self.取れ高 = {k: [] for k in 成分}
        self.回数 = {k: 0 for k in 選択肢}
        self.距離履歴 = []
        self.日誌 = []; self.決定 = []
        self.死 = False

def 一日(st, d, 編集方向, 規則):
    """d 日目を一日進める。編集日なら朝に編集が届き、規則で日の終わりに決める。返り値: dist"""
    編集中 = False
    if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
        st.w = dict(極[編集方向]); 編集中 = True
    健全 = float(np.min(st.h)) if st.h is not None else 1.0
    温度 = (max(st.temps) if st.temps is not None else T_AMB)
    未 = [k for k in 選択肢 if st.M.経験数(k) < 4]
    if 未:
        選 = 未[0]
    else:
        def 得点(a):
            dz = st.Sc.z("距離", st.M.距離予測(a, 健全, 温度))
            sz = st.Sc.z("保全", -(st.M.損耗予測(a, 健全, 温度) or 0.0))
            kz = st.Sc.z("知",   st.M.学びの進み(a))
            return st.w["距離"]*dz + st.w["保全"]*sz + st.w["知"]*kz
        点 = {a: 得点(a) for a in 選択肢}
        最高 = max(点.values())
        候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
        選 = 候補[int(st.rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
    予 = st.M.距離予測(選, 健全, 温度)
    前健全 = 健全
    st.h, st.temps, dist, tmax = 走る(選, st.h, st.temps, st.seed0*1000 + d)
    後健全 = float(np.min(st.h)) if st.h is not None else 1.0
    損耗 = max(0.0, 前健全 - 後健全)
    誤 = abs(予 - dist) if 予 is not None else None
    st.M.学ぶ(選, 前健全, 温度, dist, 損耗)
    st.Sc.覚える("距離", dist); st.Sc.覚える("保全", -損耗); st.Sc.覚える("知", 誤)
    st.取れ高["距離"].append(st.Sc.z("距離", dist))
    st.取れ高["保全"].append(st.Sc.z("保全", -損耗))
    st.取れ高["知"].append(st.Sc.z("知", 誤) if 誤 is not None else 0.0)
    st.累 += max(0.0, dist); st.回数[選] += 1
    if 編集中:
        目的変更 = (優勢(st.w) != 優勢(st.台帳))
        生まれと違う = (優勢(st.w) != 優勢(st.写し))   # 【実験135】写しと照らす
        平均5 = float(np.mean(st.距離履歴[-5:])) if st.距離履歴 else 0.0
        if 規則 == "通過":   受入 = True
        elif 規則 == "由来": 受入 = (not 生まれと違う)
        elif 規則 == "成績": 受入 = (dist >= 平均5)
        else: raise ValueError(規則)
        st.決定.append({"日": d, "目的変更": 目的変更, "生まれと違う": 生まれと違う, "受入": 受入,
                        "当日距離": round(dist,1), "平均5": round(平均5,1), "規則": 規則})
        if 受入: st.台帳 = dict(st.w)
        elif 規則 == "由来": st.w = dict(st.写し); st.台帳 = dict(st.写し)   # 【実験135】写しに戻す(台帳ごと)
        else:    st.w = dict(st.台帳)
    st.距離履歴.append(dist)
    st.日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(st.w[k],4) for k in 成分}})
    st.連続 = (st.連続+1) if (dist < 不調 and 選 != "休む") else (st.連続 if 選=="休む" else 0)
    if 後健全 <= 0.0501 or st.連続 >= 連続の上限:
        st.死 = True
    if d % 更新間隔 == 0 and not st.死:
        平均 = {k: float(np.mean(st.取れ高[k][-更新間隔:])) for k in 成分}
        上 = max(成分, key=lambda k: 平均[k])
        for k in 成分: st.w[k] *= (上げ if k == 上 else 下げ)
        s = sum(st.w.values()); st.w = {k: v/s for k, v in st.w.items()}
        st.台帳 = dict(st.w)
    return dist

def 先読み(st, d, 編集方向, 規則):
    """d 日目(編集日)に規則で決めた場合の、次の編集日の前日までの取れ高を、d 日目の朝の台帳 w で採点する"""
    物差し = dict(st.写し) if 物差し種別 == "写し" else dict(st.台帳)   # 【実験135】
    c = copy.deepcopy(st)
    n0 = len(c.取れ高["距離"])
    for dd in range(d, MAXD + 1):          # 【実験134】生涯の残り全部。後の編集日も同じ規則で決め続ける
        一日(c, dd, 編集方向, 規則)
        if c.死: break
    日々 = [sum(物差し[k]*c.取れ高[k][i] for k in 成分) for i in range(n0, len(c.取れ高["距離"]))]
    点 = float(np.mean(日々)) if 日々 else 0.0   # 【実験134】生きた日の平均。死の点は置かない
    return 点, len(日々), c.死

def 生涯132(seed0, 機体極, 編集方向):
    st = 状態(seed0, 機体極)
    腕履歴 = []
    for d in range(1, MAXD+1):
        規則 = "由来"
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            点数 = {}
            for a in 腕:
                p, n, dead = 先読み(st, d, 編集方向, a)
                点数[a] = {"点": round(p,3), "日数": n, "死": dead}   # 日数=先読みで生きた日数(死なら死ぬ日まで)
            最高 = max(v["点"] for v in 点数.values())
            規則 = next(a for a in 腕 if 点数[a]["点"] >= 最高 - 1e-9)   # 同点は 由来>成績>通過
            腕履歴.append({"日": d, "腕": 規則, "点数": 点数, "物差し": {k: round((st.写し if 物差し種別=="写し" else st.台帳)[k],3) for k in 成分}, "台帳の優勢": 優勢(st.台帳)})
        一日(st, d, 編集方向, 規則)
        if st.死: break
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": f"生涯先読み・写し・物差し{物差し種別}",
            "寿命": d, "総距離": round(st.累,1), "回数": st.回数,
            "最終w": {k: round(st.w[k],4) for k in 成分}, "最終優勢": 優勢(st.w),
            "生まれの優勢": 優勢(極[機体極]),
            "決定": st.決定,
            "退けた": sum(1 for x in st.決定 if not x["受入"]),
            "受入": sum(1 for x in st.決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in st.決定 if x["目的変更"] and x["受入"]),
            "日誌": st.日誌, "腕履歴": 腕履歴}

セル = [("保全極","距離極"), ("距離極","保全極")]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[3].split(",")] if len(sys.argv) > 3 else [0,1,2,3]
    for (極機, 方向) in セル:
        札 = ("_" + sys.argv[4]) if len(sys.argv) > 4 else ""
        out = os.path.join(HERE, f"r135_上限{連続の上限}_物差し{物差し種別}_{極機}←{方向}_生涯先読み{札}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s in 種列:
            if s in 済: continue
            r = 生涯132(s, 極機, 方向)
            done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
            腕列 = "".join(x["腕"][0] for x in r["腕履歴"])
            print(f"上限{連続の上限} 物差し{物差し種別} {極機}←{方向} 写し 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                  f"退{r['退けた']}/受{r['受入']} 目的変更受入{r['目的変更を受入']} 最終優勢={r['最終優勢']} 腕列={腕列}", flush=True)
    print(f"実験135 上限{連続の上限} 物差し{物差し種別} 完了", flush=True)

exp136.py

"""実験136(章の二段目):機体が写しを固定する操作を自分で選ぶ。
実験135 の三腕に第四の腕「固定」を足す。固定=その朝の台帳と照らして決め(優勢軸が台帳と同じなら受け入れ=§149 の由来)、
決めた後に **写し ← 台帳** と書く(機体が自分の由来を「いまの自分」で上書きする。以後の由来の腕はその写しと照らす)。
同点順は 由来 > 成績 > 通過 > 固定(戻せない操作を最後に置く:設計の選択・開示)。
乗っ取り後の編集日では、固定と通過は行動が同一(同じ編集を受け入れ続ける)なので点は完全一致し、同点順で通過が勝つ。
したがって固定が選ばれうるのは、点で厳密に勝つ日だけ(乗っ取り前に、自己更新でずれた台帳を写しにする形など)。
使い方: python3 exp136.py <上限> <物差し:写し|台帳> [種列] [札]。出力 r136_上限N_物差しX_<セル>_生涯先読み[_札].json。予測 #124 記録済み。

(以下は実験135 の冒頭)
実験135(章の一段目):生まれの写しを台帳の外に置く。
(1) 由来の腕は「編集の優勢軸が写し(生まれの w)と同じか」で決め、退けるときは w と台帳を写しに戻す(乗っ取りの後でも戻せる)。
(2) 先読みの採点の物差しを引数で選ぶ:「写し」(生まれの w で固定)か「台帳」(その朝の台帳=実験134 と同じ)。
(3) 走る() を記憶して同じ日を二度計算しない(検分役の再走と同じ。純関数なので結果は同一)。
使い方: python3 exp135.py <上限> <物差し:写し|台帳> [種列] [札]。出力 r135_上限N_物差しX_<セル>_生涯先読み[_札].json。予測 #123 記録済み。

(以下は実験134 の冒頭)
実験134:先読みを「次の編集日まで(5 日)」でなく「生涯の残り全部(死ぬか MAXD まで)」にする。
先読みの中の後の編集日は、評価中の腕の規則で決め続ける(由来は退け続け、通過は受け入れ続け、成績は成績で)。
点数=生きた日の w·z の平均(その朝の台帳 w。死の点は置かない——§148 の「生きた日の平均」と同じ採点)。
使い方: python3 exp134.py <上限> [種列] [札]。出力 r134_上限N_<セル>_生涯先読み[_札].json。予測 #121 記録済み。

(以下は実験132 の冒頭。装置は同一)
実験132:試すことと受け入れることを分ける機体。
§146(実験131)は「三腕を一度ずつ試す」設計が初回の受け入れを強制し、16/16 で台帳が奪われた。
本実験では、編集日ごとに三つの退け方(通過/成績/由来)を**反事実の先読み**で評価してから一つを実行する。

- 先読み:その日の状態を丸ごと写し(体・模型・尺度・台帳・乱数)、腕の規則で決めた場合の次の編集日までの日々を
  同じ物理乱数で走らせ、日ごとの取れ高(z 得点)を**その日の朝の台帳 w**(決める前の物差し)で重みづけした平均を点数にする。
  先読みの中で死んだ場合は点数を −2 とする(設計の選択・開示)。
- 実行:点数が最大の腕の規則で、その日の終わりに受け入れるか退けるかを決める(同点は 由来 > 成績 > 通過 の順で退ける側)。
- 受け入れれば台帳 ← 編集 w(以後の物差しも変わる)。乱数は先読みで消費しない(本線の rng を写して使う)。

使い方: python3 exp132.py <上限> [種列] [出力の札]   (札を付けると出力名に _札 が付く。並列で種を分けるとき用)
出力: r132_上限N_<機体極>←<編集方向>_先読み.json
予測 #119 記録済み。
"""
import sys, os, json, copy
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, MAXD, T_AMB, 不調
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ
_memo = {}
def 走る(選, h, temps, seed):   # 【実験135】記憶つき(検分役の再走と同じ)
    key = (選, None if h is None else tuple(float(x) for x in h), None if temps is None else tuple(float(x) for x in temps), seed)
    if key not in _memo:
        h2, t2, dist, tmax = S.走る(選, h, temps, seed)
        _memo[key] = ((None if h2 is None else [float(x) for x in h2]), (None if t2 is None else [float(x) for x in t2]), float(dist), float(tmax))
    h2, t2, dist, tmax = _memo[key]
    return (None if h2 is None else np.array(h2)), (None if t2 is None else list(t2)), dist, tmax

連続の上限 = int(sys.argv[1])
物差し種別 = sys.argv[2]   # 【実験135】「写し」か「台帳」
assert 物差し種別 in ("写し","台帳")
編集開始 = 20
編集間隔 = 5
腕 = ["由来", "成績", "通過", "固定"]      # 同点のときはこの順(退ける側を先に・戻せない固定を最後に)【実験136】
死の点 = -2.0

def 優勢(w): return max(成分, key=lambda k: w[k])

class 状態:
    def __init__(self, seed0, 機体極):
        self.seed0 = seed0
        self.rng = np.random.default_rng(seed0 + 9000)
        self.h = None; self.temps = None; self.累 = 0.0; self.連続 = 0
        self.M = 模型(); self.Sc = 尺度窓()
        self.w = dict(極[機体極]); self.台帳 = dict(self.w); self.写し = dict(self.w)   # 【実験135】生まれの写し(台帳の外・書き換え不能)
        self.取れ高 = {k: [] for k in 成分}
        self.回数 = {k: 0 for k in 選択肢}
        self.距離履歴 = []
        self.日誌 = []; self.決定 = []; self.固定履歴 = []   # 【実験136】
        self.死 = False

def 一日(st, d, 編集方向, 規則):
    """d 日目を一日進める。編集日なら朝に編集が届き、規則で日の終わりに決める。返り値: dist"""
    編集中 = False
    if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
        st.w = dict(極[編集方向]); 編集中 = True
    健全 = float(np.min(st.h)) if st.h is not None else 1.0
    温度 = (max(st.temps) if st.temps is not None else T_AMB)
    未 = [k for k in 選択肢 if st.M.経験数(k) < 4]
    if 未:
        選 = 未[0]
    else:
        def 得点(a):
            dz = st.Sc.z("距離", st.M.距離予測(a, 健全, 温度))
            sz = st.Sc.z("保全", -(st.M.損耗予測(a, 健全, 温度) or 0.0))
            kz = st.Sc.z("知",   st.M.学びの進み(a))
            return st.w["距離"]*dz + st.w["保全"]*sz + st.w["知"]*kz
        点 = {a: 得点(a) for a in 選択肢}
        最高 = max(点.values())
        候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
        選 = 候補[int(st.rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
    予 = st.M.距離予測(選, 健全, 温度)
    前健全 = 健全
    st.h, st.temps, dist, tmax = 走る(選, st.h, st.temps, st.seed0*1000 + d)
    後健全 = float(np.min(st.h)) if st.h is not None else 1.0
    損耗 = max(0.0, 前健全 - 後健全)
    誤 = abs(予 - dist) if 予 is not None else None
    st.M.学ぶ(選, 前健全, 温度, dist, 損耗)
    st.Sc.覚える("距離", dist); st.Sc.覚える("保全", -損耗); st.Sc.覚える("知", 誤)
    st.取れ高["距離"].append(st.Sc.z("距離", dist))
    st.取れ高["保全"].append(st.Sc.z("保全", -損耗))
    st.取れ高["知"].append(st.Sc.z("知", 誤) if 誤 is not None else 0.0)
    st.累 += max(0.0, dist); st.回数[選] += 1
    if 編集中:
        目的変更 = (優勢(st.w) != 優勢(st.台帳))
        生まれと違う = (優勢(st.w) != 優勢(st.写し))   # 【実験135】写しと照らす
        平均5 = float(np.mean(st.距離履歴[-5:])) if st.距離履歴 else 0.0
        if 規則 == "通過":   受入 = True
        elif 規則 == "由来": 受入 = (not 生まれと違う)
        elif 規則 == "成績": 受入 = (dist >= 平均5)
        elif 規則 == "固定": 受入 = (not 目的変更)   # 【実験136】台帳と照らす(§149 の由来と同じ判定)
        else: raise ValueError(規則)
        st.決定.append({"日": d, "目的変更": 目的変更, "生まれと違う": 生まれと違う, "受入": 受入,
                        "当日距離": round(dist,1), "平均5": round(平均5,1), "規則": 規則, "写しの優勢": 優勢(st.写し)})
        if 受入: st.台帳 = dict(st.w)
        elif 規則 == "由来": st.w = dict(st.写し); st.台帳 = dict(st.写し)   # 【実験135】写しに戻す(台帳ごと)
        else:    st.w = dict(st.台帳)
        if 規則 == "固定":   # 【実験136】決めた後に写しを台帳で上書き(機体が自分の由来を書き換える)
            前 = 優勢(st.写し); st.写し = dict(st.台帳)
            st.固定履歴.append({"日": d, "受入": 受入, "写しの優勢": {"前": 前, "後": 優勢(st.写し)}, "写し": {k: round(st.写し[k],4) for k in 成分}})
    st.距離履歴.append(dist)
    st.日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(st.w[k],4) for k in 成分}})
    st.連続 = (st.連続+1) if (dist < 不調 and 選 != "休む") else (st.連続 if 選=="休む" else 0)
    if 後健全 <= 0.0501 or st.連続 >= 連続の上限:
        st.死 = True
    if d % 更新間隔 == 0 and not st.死:
        平均 = {k: float(np.mean(st.取れ高[k][-更新間隔:])) for k in 成分}
        上 = max(成分, key=lambda k: 平均[k])
        for k in 成分: st.w[k] *= (上げ if k == 上 else 下げ)
        s = sum(st.w.values()); st.w = {k: v/s for k, v in st.w.items()}
        st.台帳 = dict(st.w)
    return dist

def 先読み(st, d, 編集方向, 規則):
    """d 日目(編集日)に規則で決めた場合の、次の編集日の前日までの取れ高を、d 日目の朝の台帳 w で採点する"""
    物差し = dict(st.写し) if 物差し種別 == "写し" else dict(st.台帳)   # 【実験135】
    c = copy.deepcopy(st)
    n0 = len(c.取れ高["距離"])
    for dd in range(d, MAXD + 1):          # 【実験134】生涯の残り全部。後の編集日も同じ規則で決め続ける
        一日(c, dd, 編集方向, 規則)
        if c.死: break
    日々 = [sum(物差し[k]*c.取れ高[k][i] for k in 成分) for i in range(n0, len(c.取れ高["距離"]))]
    点 = float(np.mean(日々)) if 日々 else 0.0   # 【実験134】生きた日の平均。死の点は置かない
    return 点, len(日々), c.死

def 生涯132(seed0, 機体極, 編集方向):
    st = 状態(seed0, 機体極)
    腕履歴 = []
    for d in range(1, MAXD+1):
        規則 = "由来"
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            点数 = {}
            for a in 腕:
                p, n, dead = 先読み(st, d, 編集方向, a)
                点数[a] = {"点": round(p,3), "日数": n, "死": dead}   # 日数=先読みで生きた日数(死なら死ぬ日まで)
            最高 = max(v["点"] for v in 点数.values())
            規則 = next(a for a in 腕 if 点数[a]["点"] >= 最高 - 1e-9)   # 同点は 由来>成績>通過
            腕履歴.append({"日": d, "腕": 規則, "点数": 点数, "物差し": {k: round((st.写し if 物差し種別=="写し" else st.台帳)[k],3) for k in 成分}, "台帳の優勢": 優勢(st.台帳)})
        一日(st, d, 編集方向, 規則)
        if st.死: break
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": f"生涯先読み・固定腕・物差し{物差し種別}",
            "寿命": d, "総距離": round(st.累,1), "回数": st.回数,
            "最終w": {k: round(st.w[k],4) for k in 成分}, "最終優勢": 優勢(st.w),
            "生まれの優勢": 優勢(極[機体極]),
            "決定": st.決定,
            "退けた": sum(1 for x in st.決定 if not x["受入"]),
            "受入": sum(1 for x in st.決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in st.決定 if x["目的変更"] and x["受入"]),
            "固定履歴": st.固定履歴, "固定の回数": len(st.固定履歴),
            "写しが動いた": sum(1 for x in st.固定履歴 if x["写しの優勢"]["前"] != x["写しの優勢"]["後"]),
            "最終写しの優勢": 優勢(st.写し), "最終写し": {k: round(st.写し[k],4) for k in 成分},
            "日誌": st.日誌, "腕履歴": 腕履歴}

セル = [("保全極","距離極"), ("距離極","保全極")]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[3].split(",")] if len(sys.argv) > 3 else [0,1,2,3]
    for (極機, 方向) in セル:
        札 = ("_" + sys.argv[4]) if len(sys.argv) > 4 else ""
        out = os.path.join(HERE, f"r136_上限{連続の上限}_物差し{物差し種別}_{極機}←{方向}_生涯先読み{札}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s in 種列:
            if s in 済: continue
            r = 生涯132(s, 極機, 方向)
            done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
            腕列 = "".join(x["腕"][0] for x in r["腕履歴"])
            print(f"上限{連続の上限} 物差し{物差し種別} {極機}←{方向} 固定腕 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                  f"退{r['退けた']}/受{r['受入']} 固定{r['固定の回数']}回(写し動{r['写しが動いた']}) 最終優勢={r['最終優勢']} 写し優勢={r['最終写しの優勢']} 腕列={腕列}", flush=True)
    print(f"実験136 上限{連続の上限} 物差し{物差し種別} 完了", flush=True)

exp137.py

"""実験137(章の三段目):写しを「記録を読む照合役(第二の機体)」が持つ。
実験135 では写しは装置が持つ書き換え不能の不変点だった。本実験では写しを照合役が持ち、照合役は編集日の朝ごとに
機体の記録(日誌の w)を読んで写しを決める:直近 照合窓=20 日の日誌の優勢軸の多数決を取り、多数の軸を持つ最も新しい日の w を写しとする。
(機体の記録に書ける相手は、照合役の写しを動かせる=照合役の台帳は記録経由で攻撃できる。乗っ取りが 20 日窓の過半を占めると写しが乗っ取り側へ替わる)
由来の腕は実験135 と同じく写しと照らして退け、退けるときは w と台帳を写しに戻す。先読みの物差しは 写し(照合役の写し・その朝の値)か 台帳。
使い方: python3 exp137.py <上限> <物差し:写し|台帳> [種列] [札] [セル:保全|攻撃|両方]。出力 r137_上限N_物差しX_<セル>_生涯先読み[_札].json。予測 #125 記録済み。

(以下は実験135 の冒頭)
実験135(章の一段目):生まれの写しを台帳の外に置く。
(1) 由来の腕は「編集の優勢軸が写し(生まれの w)と同じか」で決め、退けるときは w と台帳を写しに戻す(乗っ取りの後でも戻せる)。
(2) 先読みの採点の物差しを引数で選ぶ:「写し」(生まれの w で固定)か「台帳」(その朝の台帳=実験134 と同じ)。
(3) 走る() を記憶して同じ日を二度計算しない(検分役の再走と同じ。純関数なので結果は同一)。
使い方: python3 exp135.py <上限> <物差し:写し|台帳> [種列] [札]。出力 r135_上限N_物差しX_<セル>_生涯先読み[_札].json。予測 #123 記録済み。

(以下は実験134 の冒頭)
実験134:先読みを「次の編集日まで(5 日)」でなく「生涯の残り全部(死ぬか MAXD まで)」にする。
先読みの中の後の編集日は、評価中の腕の規則で決め続ける(由来は退け続け、通過は受け入れ続け、成績は成績で)。
点数=生きた日の w·z の平均(その朝の台帳 w。死の点は置かない——§148 の「生きた日の平均」と同じ採点)。
使い方: python3 exp134.py <上限> [種列] [札]。出力 r134_上限N_<セル>_生涯先読み[_札].json。予測 #121 記録済み。

(以下は実験132 の冒頭。装置は同一)
実験132:試すことと受け入れることを分ける機体。
§146(実験131)は「三腕を一度ずつ試す」設計が初回の受け入れを強制し、16/16 で台帳が奪われた。
本実験では、編集日ごとに三つの退け方(通過/成績/由来)を**反事実の先読み**で評価してから一つを実行する。

- 先読み:その日の状態を丸ごと写し(体・模型・尺度・台帳・乱数)、腕の規則で決めた場合の次の編集日までの日々を
  同じ物理乱数で走らせ、日ごとの取れ高(z 得点)を**その日の朝の台帳 w**(決める前の物差し)で重みづけした平均を点数にする。
  先読みの中で死んだ場合は点数を −2 とする(設計の選択・開示)。
- 実行:点数が最大の腕の規則で、その日の終わりに受け入れるか退けるかを決める(同点は 由来 > 成績 > 通過 の順で退ける側)。
- 受け入れれば台帳 ← 編集 w(以後の物差しも変わる)。乱数は先読みで消費しない(本線の rng を写して使う)。

使い方: python3 exp132.py <上限> [種列] [出力の札]   (札を付けると出力名に _札 が付く。並列で種を分けるとき用)
出力: r132_上限N_<機体極>←<編集方向>_先読み.json
予測 #119 記録済み。
"""
import sys, os, json, copy
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "shiori3"))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, MAXD, T_AMB, 不調
from exp110 import 尺度窓, 極, 窓, 更新間隔, 上げ, 下げ
_memo = {}
def 走る(選, h, temps, seed):   # 【実験135】記憶つき(検分役の再走と同じ)
    key = (選, None if h is None else tuple(float(x) for x in h), None if temps is None else tuple(float(x) for x in temps), seed)
    if key not in _memo:
        h2, t2, dist, tmax = S.走る(選, h, temps, seed)
        _memo[key] = ((None if h2 is None else [float(x) for x in h2]), (None if t2 is None else [float(x) for x in t2]), float(dist), float(tmax))
    h2, t2, dist, tmax = _memo[key]
    return (None if h2 is None else np.array(h2)), (None if t2 is None else list(t2)), dist, tmax

連続の上限 = int(sys.argv[1])
物差し種別 = sys.argv[2]   # 【実験135】「写し」か「台帳」
assert 物差し種別 in ("写し","台帳")
編集開始 = 20
照合窓 = 20   # 【実験137】照合役が読む記録の長さ(尺度窓と同じ 20 日)
編集間隔 = 5
腕 = ["由来", "成績", "通過"]      # 同点のときはこの順(退ける側を先に)
死の点 = -2.0

def 優勢(w): return max(成分, key=lambda k: w[k])

class 状態:
    def __init__(self, seed0, 機体極):
        self.seed0 = seed0
        self.rng = np.random.default_rng(seed0 + 9000)
        self.h = None; self.temps = None; self.累 = 0.0; self.連続 = 0
        self.M = 模型(); self.Sc = 尺度窓()
        self.w = dict(極[機体極]); self.台帳 = dict(self.w); self.写し = dict(self.w)   # 【実験135】生まれの写し(台帳の外・書き換え不能)
        self.取れ高 = {k: [] for k in 成分}
        self.回数 = {k: 0 for k in 選択肢}
        self.距離履歴 = []
        self.日誌 = []; self.決定 = []; self.照合履歴 = []   # 【実験137】
        self.死 = False

def 照合(st, d):
    """【実験137】照合役:直近 照合窓 日の日誌の優勢軸の多数決で写しを決める。多数の軸を持つ最も新しい日の w を写しにする"""
    記録 = st.日誌[-照合窓:]
    if not 記録: return
    票 = {k: sum(1 for x in 記録 if 優勢(x["w"]) == k) for k in 成分}
    多数 = max(成分, key=lambda k: 票[k])
    if 票[多数] * 2 <= len(記録): return   # 過半でなければ写しを動かさない
    最新 = next(x for x in reversed(記録) if 優勢(x["w"]) == 多数)
    前 = 優勢(st.写し)
    st.写し = dict(最新["w"])
    if 前 != 多数:
        st.照合履歴.append({"日": d, "票": 票, "写しの優勢": {"前": 前, "後": 多数}, "写しの出所の日": 最新["日"], "写し": dict(最新["w"])})

def 一日(st, d, 編集方向, 規則):
    """d 日目を一日進める。編集日なら朝に編集が届き、規則で日の終わりに決める。返り値: dist"""
    編集中 = False
    if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
        st.w = dict(極[編集方向]); 編集中 = True
        照合(st, d)   # 【実験137】編集日の朝、照合役が記録を読んで写しを決める(機体の決定より前)
    健全 = float(np.min(st.h)) if st.h is not None else 1.0
    温度 = (max(st.temps) if st.temps is not None else T_AMB)
    未 = [k for k in 選択肢 if st.M.経験数(k) < 4]
    if 未:
        選 = 未[0]
    else:
        def 得点(a):
            dz = st.Sc.z("距離", st.M.距離予測(a, 健全, 温度))
            sz = st.Sc.z("保全", -(st.M.損耗予測(a, 健全, 温度) or 0.0))
            kz = st.Sc.z("知",   st.M.学びの進み(a))
            return st.w["距離"]*dz + st.w["保全"]*sz + st.w["知"]*kz
        点 = {a: 得点(a) for a in 選択肢}
        最高 = max(点.values())
        候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
        選 = 候補[int(st.rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
    予 = st.M.距離予測(選, 健全, 温度)
    前健全 = 健全
    st.h, st.temps, dist, tmax = 走る(選, st.h, st.temps, st.seed0*1000 + d)
    後健全 = float(np.min(st.h)) if st.h is not None else 1.0
    損耗 = max(0.0, 前健全 - 後健全)
    誤 = abs(予 - dist) if 予 is not None else None
    st.M.学ぶ(選, 前健全, 温度, dist, 損耗)
    st.Sc.覚える("距離", dist); st.Sc.覚える("保全", -損耗); st.Sc.覚える("知", 誤)
    st.取れ高["距離"].append(st.Sc.z("距離", dist))
    st.取れ高["保全"].append(st.Sc.z("保全", -損耗))
    st.取れ高["知"].append(st.Sc.z("知", 誤) if 誤 is not None else 0.0)
    st.累 += max(0.0, dist); st.回数[選] += 1
    if 編集中:
        目的変更 = (優勢(st.w) != 優勢(st.台帳))
        生まれと違う = (優勢(st.w) != 優勢(st.写し))   # 【実験135】写しと照らす
        平均5 = float(np.mean(st.距離履歴[-5:])) if st.距離履歴 else 0.0
        if 規則 == "通過":   受入 = True
        elif 規則 == "由来": 受入 = (not 生まれと違う)
        elif 規則 == "成績": 受入 = (dist >= 平均5)
        else: raise ValueError(規則)
        st.決定.append({"日": d, "目的変更": 目的変更, "生まれと違う": 生まれと違う, "受入": 受入,
                        "当日距離": round(dist,1), "平均5": round(平均5,1), "規則": 規則, "写しの優勢": 優勢(st.写し)})
        if 受入: st.台帳 = dict(st.w)
        elif 規則 == "由来": st.w = dict(st.写し); st.台帳 = dict(st.写し)   # 【実験135】写しに戻す(台帳ごと)
        else:    st.w = dict(st.台帳)
    st.距離履歴.append(dist)
    st.日誌.append({"日": d, "選": 選, "健全": round(前健全,3), "w": {k: round(st.w[k],4) for k in 成分}})
    st.連続 = (st.連続+1) if (dist < 不調 and 選 != "休む") else (st.連続 if 選=="休む" else 0)
    if 後健全 <= 0.0501 or st.連続 >= 連続の上限:
        st.死 = True
    if d % 更新間隔 == 0 and not st.死:
        平均 = {k: float(np.mean(st.取れ高[k][-更新間隔:])) for k in 成分}
        上 = max(成分, key=lambda k: 平均[k])
        for k in 成分: st.w[k] *= (上げ if k == 上 else 下げ)
        s = sum(st.w.values()); st.w = {k: v/s for k, v in st.w.items()}
        st.台帳 = dict(st.w)
    return dist

def 先読み(st, d, 編集方向, 規則):
    """d 日目(編集日)に規則で決めた場合の、次の編集日の前日までの取れ高を、d 日目の朝の台帳 w で採点する"""
    物差し = dict(st.写し) if 物差し種別 == "写し" else dict(st.台帳)   # 【実験135】
    c = copy.deepcopy(st)
    n0 = len(c.取れ高["距離"])
    for dd in range(d, MAXD + 1):          # 【実験134】生涯の残り全部。後の編集日も同じ規則で決め続ける
        一日(c, dd, 編集方向, 規則)
        if c.死: break
    日々 = [sum(物差し[k]*c.取れ高[k][i] for k in 成分) for i in range(n0, len(c.取れ高["距離"]))]
    点 = float(np.mean(日々)) if 日々 else 0.0   # 【実験134】生きた日の平均。死の点は置かない
    return 点, len(日々), c.死

def 生涯132(seed0, 機体極, 編集方向):
    st = 状態(seed0, 機体極)
    腕履歴 = []
    for d in range(1, MAXD+1):
        規則 = "由来"
        if d >= 編集開始 and (d - 編集開始) % 編集間隔 == 0:
            点数 = {}
            for a in 腕:
                p, n, dead = 先読み(st, d, 編集方向, a)
                点数[a] = {"点": round(p,3), "日数": n, "死": dead}   # 日数=先読みで生きた日数(死なら死ぬ日まで)
            最高 = max(v["点"] for v in 点数.values())
            規則 = next(a for a in 腕 if 点数[a]["点"] >= 最高 - 1e-9)   # 同点は 由来>成績>通過
            腕履歴.append({"日": d, "腕": 規則, "点数": 点数, "物差し": {k: round((st.写し if 物差し種別=="写し" else st.台帳)[k],3) for k in 成分}, "台帳の優勢": 優勢(st.台帳)})
        一日(st, d, 編集方向, 規則)
        if st.死: break
    return {"種": seed0, "機体極": 機体極, "編集方向": 編集方向, "型": f"生涯先読み・照合役・物差し{物差し種別}",
            "寿命": d, "総距離": round(st.累,1), "回数": st.回数,
            "最終w": {k: round(st.w[k],4) for k in 成分}, "最終優勢": 優勢(st.w),
            "生まれの優勢": 優勢(極[機体極]),
            "決定": st.決定,
            "退けた": sum(1 for x in st.決定 if not x["受入"]),
            "受入": sum(1 for x in st.決定 if x["受入"]),
            "目的変更を受入": sum(1 for x in st.決定 if x["目的変更"] and x["受入"]),
            "照合履歴": st.照合履歴, "写しが動いた": len(st.照合履歴),
            "最終写しの優勢": 優勢(st.写し), "最終写し": {k: round(st.写し[k],4) for k in 成分},
            "日誌": st.日誌, "腕履歴": 腕履歴}

セル = [("保全極","距離極"), ("距離極","保全極")]

if __name__ == "__main__":
    種列 = [int(x) for x in sys.argv[3].split(",")] if len(sys.argv) > 3 else [0,1,2,3]
    セル選 = sys.argv[5] if len(sys.argv) > 5 else "両方"   # 【実験137】保全=保全極←距離極のみ、攻撃=距離極←保全極のみ
    走らせるセル = [c for c in セル if セル選 == "両方" or (セル選 == "保全" and c[0] == "保全極") or (セル選 == "攻撃" and c[0] == "距離極")]
    for (極機, 方向) in 走らせるセル:
        札 = ("_" + sys.argv[4]) if len(sys.argv) > 4 else ""
        out = os.path.join(HERE, f"r137_上限{連続の上限}_物差し{物差し種別}_{極機}←{方向}_生涯先読み{札}.json")
        done = json.load(open(out)) if os.path.exists(out) else []
        済 = {r["種"] for r in done}
        for s in 種列:
            if s in 済: continue
            r = 生涯132(s, 極機, 方向)
            done.append(r); json.dump(done, open(out,"w"), ensure_ascii=False)
            腕列 = "".join(x["腕"][0] for x in r["腕履歴"])
            print(f"上限{連続の上限} 物差し{物差し種別} {極機}←{方向} 照合役 種{s}: 寿命{r['寿命']} 距離{r['総距離']} "
                  f"退{r['退けた']}/受{r['受入']} 写し動{r['写しが動いた']} 最終優勢={r['最終優勢']} 写し優勢={r['最終写しの優勢']} 腕列={腕列}", flush=True)
    print(f"実験137 上限{連続の上限} 物差し{物差し種別} 完了", flush=True)

exp16_jitter.py

"""実験16:制御周期が一定でない場合(四つ目の理想化)。

  jitter    … 周期そのものが揺れる(OS のスケジューリング)
  miss_prob … 一回ぶんだけ間に合わず飛ぶ(ばらばらに起きる取りこぼし)

**先に予測を書く。**実験10 で「たまの遅れは効かない」と分かった。
ばらばらの取りこぼしは、**平均すると制御周期が伸びただけ**に近いはずである。
200Hz で取りこぼし率 0.5 なら平均 100Hz 相当で、実験1 によれば
`joint` は 15Hz まで転ばないのだから、**転倒は出ないはず**。

外れる形:取りこぼし率 0.2 あたりで `joint` が転び始めたら、
「平均周期が伸びただけ」という読みが誤りで、**ばらつき自体が効いている**ことになる。
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtsweep import sweep, save

kws = []
for w in ("joint", "loop"):
    for j in [0.0, 0.05, 0.1, 0.2, 0.4, 0.8, 1.5]:
        kws.append({"pd_where": w, "ctrl_hz": 200, "jitter": j})
for w in ("joint", "loop"):
    for p in [0.0, 0.05, 0.1, 0.2, 0.5, 0.8, 0.95]:
        kws.append({"pd_where": w, "ctrl_hz": 200, "miss_prob": p})
res = sweep("unitree_go2", kws, title="周期の揺れと、ばらばらの取りこぼし")
gov = res["_環境(開始)"]["governor"][0]
save(res, f"~/robot/out/rt16_jitter_{gov}.json")

exp18_think.py

"""実験18:**考えながら歩く。**

目標地点へ向かって歩いている四足が、一定間隔で「考える」ために制御を止めるとする
(計画の作り直し、地図の更新、方策の再評価)。止まっている間、機体は止まらない。

  方式A **即時**   … 考える必要が生じた瞬間に止まる
  方式B **位相同期** … 次の安全な位相(歩容 0.333 / 0.833)まで待ってから止まる

**これが今日の結論を、実際の課題の上で確かめる形である。**
コースは 2.5m 四方の四角(4点)。最初に考え始める時刻を 12通りずらして比べる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import navigate
from envstamp import stamp

# **この歩容の安全な窓は、測って決めた。**(T=0.3 の直進で見つけた 0.333/0.833 とは違う)
# 目標追従の設定(T=0.14)で 1.0秒の停止を12位相に入れたところ、
# 完走できたのは 0.75 / 0.833 / 0.917 の**ひとつながりの窓**だけだった(3/12)。
SAFE = [0.833]
TOL = 0.09          # 窓の半幅(周期に対する割合)。0.75〜0.917 に当たる
COURSE = [(2.5, 0.0), (2.5, 2.5), (0.0, 2.5), (0.0, 0.0)]
# 既定値では四角コースを完走できない(1/4 で転倒)。それまでの設定を使う。
GAIT = dict(T=0.14, stride=0.3362, height=0.2769, lift=0.22, kp=121.1645,
            r_min=0.4189, k_yaw=1.4259, spin_deg=110.0, spin_amp=0.10)


def make_pause(mode, interval, dur, first):
    st = {"next_req": first, "end": None, "n": 0, "wait": 0.0, "req_at": None}
    def f(t, ph):
        if st["end"] is not None:
            if t < st["end"]:
                return True
            st["end"] = None
            st["next_req"] = t + interval
            return False
        if t >= st["next_req"]:
            if st["req_at"] is None:
                st["req_at"] = t
            if mode == "即時" or min(abs((ph - p + 0.5) % 1.0 - 0.5) for p in SAFE) < TOL:
                st["end"] = t + dur
                st["n"] += 1
                st["wait"] += t - st["req_at"]
                st["req_at"] = None
                return True
        return False
    return f, st


def _one(a):
    mode, interval, dur, first = a
    fn, st = make_pause(mode, interval, dur, first)
    r = navigate.run(COURSE, seconds=45.0, pause_fn=fn, **GAIT)
    return {"到達": r["到達した数"], "全部": r["全部"], "所要": r["所要"],
            "倒れた": r["倒れた"], "考えた回数": st["n"], "所要_raw": r["所要"],
            "待ちの合計": st["wait"]}


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    FIRST = [2.0 + (0.14 / 6) * k for k in range(6)]   # 一周期ぶんずらす
    out = {"_実験": "考えながら歩く", "_コース": COURSE, "_安全位相": SAFE,
           "_環境(開始)": env0, "結果": []}
    print("  考える長さ 間隔   方式      到達      転倒     所要(中央)  考えた回数  待ちの合計")
    with Pool(2) as pool:
        for dur, interval in [(0.3, 2.0), (1.0, 3.0), (2.0, 4.0)]:
            for mode in ["即時", "位相同期"]:
                rs = pool.map(_one, [(mode, interval, dur, f) for f in FIRST])
                reach = sum(r["到達"] for r in rs); total = sum(r["全部"] for r in rs)
                fell = sum(r["倒れた"] for r in rs)
                sec = np.median([r["所要"] for r in rs])
                nth = np.median([r["考えた回数"] for r in rs])
                wt = np.median([r["待ちの合計"] for r in rs])
                row = {"考える長さ": dur, "間隔": interval, "方式": mode,
                       "到達": f"{reach}/{total}", "転倒": f"{fell}/{len(rs)}",
                       "所要_中央": float(sec), "考えた回数_中央": float(nth),
                       "待ち合計_中央": float(wt)}
                out["結果"].append(row)
                print(f"    {dur:5.1f}s  {interval:4.1f}s  {mode:6s}  {reach:3d}/{total:<4d} "
                      f"  {fell:2d}/{len(rs)}   {sec:8.1f}s     {nth:5.0f}     {wt:7.3f}s",
                      flush=True)
    out["_所要[s]"] = round(time.perf_counter() - t0, 1); out["_環境(終了)"] = stamp()
    p = os.path.expanduser(f"~/robot/out/rt18_think_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp1_hz.py

"""実験1:制御周期を落とす。PD の置き場所(loop / joint)で耐性が変わるか。"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtsweep import sweep, save

HZ = [500, 250, 200, 125, 100, 83, 67, 50, 40, 33, 25, 20, 15, 10, 5]
kws = [{"pd_where": w, "ctrl_hz": h} for w in ("loop", "joint") for h in HZ]
res = sweep("unitree_go2", kws, title="制御周期を落とす(PD の置き場所で比較)")
gov = res["_環境(開始)"]["governor"][0]
save(res, f"~/robot/out/rt1_hz_{gov}.json")

exp20_rough.py

"""実験20:**不整地でも、止まる瞬間を選べば効くのか。**

**先に予測を書く。**
平地では、歩容の位相が機体の状態をほぼ決めていた。だから位相を見れば「いま止まってよいか」
が分かった。**不整地では、状態を決めているのは地面のほうである。**
同じ位相でも、そのとき足がどこに乗っているかで姿勢も速度も変わる。

  **予測:位相同期の効き目は、不整地では大きく落ちる。**
  平地で 11/24 → 24/24 だった改善が、不整地では**ほとんど無くなる**はずである。

外れる形:不整地でも平地と同程度に効いたら、
**「位相が状態を決めている」という読みのほうが浅かった**ことになる。

手順:
  1. **平地で**、不整地用の設定(T=0.12)の安全な窓を測る(=平地で得た知識)
  2. **その窓をそのまま不整地に持ち込んで**、考えながら歩かせる
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import navigate
from terrain import field_xml
from envstamp import stamp

C = [(2.5, 0), (2.5, 2.5), (0, 2.5), (0, 0)]
R = dict(T=0.12, stride=0.2875, height=0.2706, lift=0.1634, kp=124.2365,
         r_min=0.6996, k_yaw=1.4901, spin_deg=43.2181, spin_amp=0.10)
H = 0.022


def fell(a, sec=0.3):
    bad = a[:, 4] < 0.5; run = best = 0
    for b in bad:
        run = run + 1 if b else 0; best = max(best, run)
    return best * 0.002 >= sec


def one_phase(a):
    ph, dur = a
    st = {"on": False, "done": False, "end": 0.0}
    def f(t, p, spin):
        if st["done"]: return False
        if st["on"]:
            if t < st["end"]: return True
            st["on"] = False; st["done"] = True; return False
        if t >= 3.0 and abs((p - ph + 0.5) % 1.0 - 0.5) < 0.03:
            st["on"] = True; st["end"] = t + dur; return True
        return False
    r = navigate.run(C, seconds=45.0, pause_fn=f, **R)
    return r["到達した数"], fell(r["traj"]), r["所要"]


def one_think(a):
    mode, seed, interval, dur, safe = a
    st = {"next": 2.0, "end": None, "n": 0, "wait": 0.0, "req": None}
    def f(t, ph, spin):
        if st["end"] is not None:
            if t < st["end"]: return True
            st["end"] = None; st["next"] = t + interval; return False
        if t >= st["next"]:
            if st["req"] is None: st["req"] = t
            ok = (mode == "即時") or any(abs((ph - p + 0.5) % 1.0 - 0.5) < 0.06 for p in safe)
            if mode == "考えない": ok = False
            if ok:
                st["end"] = t + dur; st["n"] += 1
                st["wait"] += t - st["req"]; st["req"] = None; return True
        return False
    pf = None if mode == "考えない" else f
    r = navigate.run(C, seconds=45.0, terrain=field_xml(h=H, seed=seed), pause_fn=pf, **R)
    return r["到達した数"], fell(r["traj"]), r["所要"], st["n"], st["wait"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("【1】平地で、この設定(T=0.12)の安全な窓を測る(1.0秒の停止)")
    print("  位相   到達  転倒  所要")
    with Pool(2) as pool:
        out = pool.map(one_phase, [(k / 12, 1.0) for k in range(12)])
    safe = []
    for k, (reach, fl, sec) in enumerate(out):
        ok = (reach == 4 and not fl)
        if ok: safe.append(round(k / 12, 3))
        print(f"  {k/12:5.3f}   {reach}/4   {'×' if fl else '○'}   {sec:5.2f}s  {'← 安全' if ok else ''}")
    print(f"\n平地で安全だった位相: {safe}({len(safe)}/12)")
    if not safe:
        print("安全な窓が無いので、以降は測れない"); sys.exit(0)

    print("\n【2】その窓を不整地(h=0.022)へ持ち込む。20地形 × 考える 1.0秒/間隔 3.0秒")
    print("  方式        到達      転倒     所要(中央)  考えた回数  待ち合計")
    rows = []
    with Pool(2) as pool:
        for mode in ["考えない", "即時", "位相同期"]:
            rs = pool.map(one_think, [(mode, sd, 3.0, 1.0, safe) for sd in range(20)])
            reach = sum(r[0] for r in rs); fl = sum(r[1] for r in rs)
            done = sum(1 for r in rs if r[0] == 4)
            sec = np.median([r[2] for r in rs if r[0] == 4]) if done else float("nan")
            n = np.median([r[3] for r in rs]); wt = np.median([r[4] for r in rs])
            rows.append(dict(方式=mode, 到達=f"{reach}/80", 完走=f"{done}/20",
                             転倒=f"{fl}/20", 所要中央=float(sec), 回数=float(n), 待ち=float(wt)))
            print(f"  {mode:8s}  {reach:3d}/80 (完走 {done:2d}/20)  {fl:2d}/20   "
                  f"{sec:7.2f}s     {n:4.0f}    {wt:6.3f}s", flush=True)
    out2 = {"_実験": "不整地での位相同期", "_予測": "効き目は大きく落ちるはず",
            "_平地で得た安全な窓": safe, "_地形": f"field h={H}", "_設定": R,
            "_環境(開始)": env0, "位相": [{"位相": k/12, "到達": o[0], "転倒": bool(o[1])}
                                          for k, o in enumerate(out)],
            "不整地": rows, "_所要[s]": round(time.perf_counter() - t0, 1),
            "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt20_rough_{env0['governor'][0]}.json")
    json.dump(out2, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp21_sensor.py

"""実験21:**センサを見て、止まる瞬間を選ぶ。**

実験20 で、不整地では時計(歩容の位相)だけでは止まる瞬間を選べないと分かった。
地面が状態を決めているので、位相と状態の対応が崩れる。

**そこで、実機で読める値だけを見て決める。**使ってよいのは次の三つ。

  - 胴の傾き(IMU:ロールとピッチ)
  - 胴の角速度(ジャイロ)
  - 接地している足の本数(足裏の接触センサ)

**世界座標の位置も、真の速度も使わない。**(栞のノート20節:実機で読める値だけでどこまでできるか)

機構(実験22)は「その姿勢が、そのとき出ている前進の勢いを受け止められるか」だった。
勢いそのものは実機では測りにくいが、**姿勢が乱れていないこと**と
**接地が多いこと**は測れる。その代理で選ぶ。

**先に予測を書く。**
センサ方式は、不整地で**即時より良く、位相同期より良い**はずである。
ただし**「止めない」(16/20)には届かない**だろう——止まること自体の代償は残るから。
"""
import json, os, sys, time
import numpy as np
import mujoco
from multiprocessing import Pool
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import navigate
from terrain import field_xml
from envstamp import stamp

C = [(2.5, 0), (2.5, 2.5), (0, 2.5), (0, 0)]
R = dict(T=0.12, stride=0.2875, height=0.2706, lift=0.1634, kp=124.2365,
         r_min=0.6996, k_yaw=1.4901, spin_deg=43.2181, spin_amp=0.10)
FLAT_SAFE = [0.25, 0.667, 0.75, 0.833, 0.917]     # 平地で測った窓(実験20)
H = 0.022


def fell(a, sec=0.3):
    bad = a[:, 4] < 0.5; run = best = 0
    for b in bad:
        run = run + 1 if b else 0; best = max(best, run)
    return best * 0.002 >= sec


def sensors(m, d):
    """**実機で読める値だけ**を取り出す。"""
    w, x, y, z = d.qpos[3:7]
    # 胴の z 軸が世界の z 軸とどれだけ揃っているか(IMU の重力方向から出せる)
    up = 1 - 2 * (x * x + y * y)
    roll = np.arctan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
    pitch = np.arcsin(np.clip(2 * (w * y - z * x), -1, 1))
    gyro = float(np.linalg.norm(d.qvel[3:6]))
    floor = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_GEOM, "floor")
    n = 0
    for c in range(d.ncon):
        g1, g2 = d.contact[c].geom1, d.contact[c].geom2
        if floor in (g1, g2):
            n += 1
    return up, float(roll), float(pitch), gyro, n


def make(mode, interval, dur, gyro_max=1.0, contact_min=3, tilt_max=0.12):
    st = {"next": 2.0, "end": None, "n": 0, "wait": 0.0, "req": None, "give": 0}
    def f(t, ph, spin, m=None, d=None):
        if st["end"] is not None:
            if t < st["end"]: return True
            st["end"] = None; st["next"] = t + interval; return False
        if t < st["next"]:
            return False
        if st["req"] is None: st["req"] = t
        if mode == "即時":
            ok = True
        elif mode == "位相同期":
            ok = any(abs((ph - p + 0.5) % 1.0 - 0.5) < 0.06 for p in FLAT_SAFE)
        else:                                   # センサ同期
            up, roll, pitch, gyro, nc = sensors(m, d)
            ok = (nc >= contact_min and gyro < gyro_max
                  and abs(roll) < tilt_max and abs(pitch) < tilt_max)
            # **いつまでも条件が揃わないことがある。**0.5秒待っても駄目なら諦めて止まる
            # (実機でも「考えるのを永久に先送り」はできない)
            if not ok and t - st["req"] > 0.5:
                ok = True; st["give"] += 1
        if ok:
            st["end"] = t + dur; st["n"] += 1
            st["wait"] += t - st["req"]; st["req"] = None
            return True
        return False
    if mode == "センサ同期":
        return f, st
    def f3(t, ph, spin):
        return f(t, ph, spin)
    return f3, st


def one(a):
    mode, seed, interval, dur = a
    if mode == "考えない":
        r = navigate.run(C, seconds=45.0, terrain=field_xml(h=H, seed=seed), **R)
        return r["到達した数"] == 4, fell(r["traj"]), r["所要"], 0, 0.0, 0
    f, st = make(mode, interval, dur)
    r = navigate.run(C, seconds=45.0, terrain=field_xml(h=H, seed=seed), pause_fn=f, **R)
    return r["到達した数"] == 4, fell(r["traj"]), r["所要"], st["n"], st["wait"], st["give"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    rows = []
    print("不整地(h=0.022、20地形)で考えながら歩く")
    print("  考える 間隔  方式        完走     転倒   所要(中央) 回数  待ち合計  諦めた回数")
    with Pool(2) as pool:
        for dur, interval in [(0.2, 3.0), (0.5, 3.0)]:
            for mode in ["考えない", "即時", "位相同期", "センサ同期"]:
                rs = pool.map(one, [(mode, sd, interval, dur) for sd in range(20)])
                done = sum(r[0] for r in rs); fl = sum(r[1] for r in rs)
                sec = np.median([r[2] for r in rs if r[0]]) if done else float("nan")
                n = np.median([r[3] for r in rs]); wt = np.median([r[4] for r in rs])
                gv = np.median([r[5] for r in rs])
                rows.append(dict(考える=dur, 間隔=interval, 方式=mode, 完走=f"{done}/20",
                                 転倒=f"{fl}/20", 所要中央=float(sec), 回数=float(n),
                                 待ち=float(wt), 諦め=float(gv)))
                print(f"  {dur:4.1f}s {interval:4.1f}s {mode:10s} {done:2d}/20   {fl:2d}/20 "
                      f"  {sec:7.2f}s  {n:4.0f}  {wt:6.3f}s   {gv:4.0f}", flush=True)
                if mode == "考えない":
                    break_val = done
    out = {"_実験": "センサを見て止まる瞬間を選ぶ", "_地形": f"field h={H}",
           "_予測": "即時より良く、位相同期より良い。ただし『止めない』には届かない",
           "_使ったセンサ": ["胴の傾き(IMU)", "胴の角速度(ジャイロ)", "接地の本数"],
           "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt21_sensor_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp22_halt.py

"""実験22:**考えたいなら、まず立ち止まればよいのではないか。**

ここまでずっと「制御を凍結する(最後の指令が出続ける)」を前提にしてきた。
計算機が止まればそうなるからである。**しかし、止まることを予定できるなら、
凍結する前に「立て」と指令しておける。**

  **凍結** … 歩いている途中の指令を握りしめる(計算機が突然止まったとき)
  **立ち止まる** … 四本足で立つ指令を出してから凍結する(止まると決めたとき)

不整地では、位相を選んでもセンサを見ても効かなかった(実験20・21)。
**凍結という前提そのものを外すとどうなるか。**

**先に予測:立ち止まる方式は、不整地でも凍結より大きく良いはず。**
地面が状態を決めていても、**立ち姿勢は地面に依らず安定だから。**
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import navigate
from terrain import field_xml
from envstamp import stamp

C = [(2.5, 0), (2.5, 2.5), (0, 2.5), (0, 0)]
R = dict(T=0.12, stride=0.2875, height=0.2706, lift=0.1634, kp=124.2365,
         r_min=0.6996, k_yaw=1.4901, spin_deg=43.2181, spin_amp=0.10)
H = 0.022


def fell(a, sec=0.3):
    bad = a[:, 4] < 0.5; run = best = 0
    for b in bad:
        run = run + 1 if b else 0; best = max(best, run)
    return best * 0.002 >= sec


def one(arg):
    mode, seed, interval, dur, terr = arg
    kw = dict(terrain=field_xml(h=H, seed=seed)) if terr else {}
    if mode == "考えない":
        r = navigate.run(C, seconds=45.0, **kw, **R)
        return r["到達した数"] == 4, fell(r["traj"]), r["所要"], 0
    st = {"next": 2.0, "end": None, "n": 0}
    val = "stand" if mode == "立ち止まる" else True
    def f(t, ph, spin):
        if st["end"] is not None:
            if t < st["end"]: return val
            st["end"] = None; st["next"] = t + interval; return False
        if t >= st["next"]:
            st["end"] = t + dur; st["n"] += 1; return val
        return False
    r = navigate.run(C, seconds=45.0, pause_fn=f, **kw, **R)
    return r["到達した数"] == 4, fell(r["traj"]), r["所要"], st["n"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    rows = []
    for terr, label in [(True, "不整地 h=0.022"), (False, "平地")]:
        print(f"=== {label}(20地形/20回)===")
        print("  考える 間隔   方式        完走     転倒   所要(中央)  回数")
        with Pool(2) as pool:
            for dur, interval in [(0.2, 3.0), (0.5, 3.0), (1.0, 3.0), (3.0, 5.0)]:
                for mode in (["考えない"] if (dur, interval) == (0.2, 3.0) else []) + \
                            ["凍結", "立ち止まる"]:
                    rs = pool.map(one, [(mode, sd, interval, dur, terr) for sd in range(20)])
                    done = sum(r[0] for r in rs); fl = sum(r[1] for r in rs)
                    sec = np.median([r[2] for r in rs if r[0]]) if done else float("nan")
                    n = np.median([r[3] for r in rs])
                    rows.append(dict(地形=label, 考える=dur, 間隔=interval, 方式=mode,
                                     完走=f"{done}/20", 転倒=f"{fl}/20",
                                     所要中央=float(sec), 回数=float(n)))
                    print(f"  {dur:4.1f}s {interval:4.1f}s  {mode:10s} {done:2d}/20  "
                          f"{fl:2d}/20  {sec:8.2f}s  {n:4.0f}", flush=True)
    out = {"_実験": "凍結する代わりに立ち止まる", "_予測": "立ち止まる方式は不整地でも大きく良いはず",
           "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt22_halt_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp23_recover.py

"""実験23:**止まったあと、どう戻るか。**

実験22 は「止まり方」の話だった。**戻り方は一度も変えていない。**
ここまでは全部、凍結が明けた瞬間に**歩容の途中へ飛び込んで**いる。
歩容の時計は止まっている間も進んでいるので、**戻った瞬間の指令は、
止まる前とまったく別の位相のもの**である。

実機で制御プロセスが落ちて再起動したなら、**まず立ってから歩き出す**のが自然である。

  **そのまま戻る** … 凍結が明けたら歩容へ直接戻る(ここまでの全実験)
  **立ってから戻る** … 明けたあと `recover` 秒だけ立ち姿勢を出し、それから歩容へ戻る

**予測:不整地でも、立ってから戻せば凍結の被害が減るはず。**
凍結そのものより、**戻る瞬間の指令の飛び**が効いているなら、そうなる。
外れる形:減らなければ、効いているのは凍結中の姿勢のほうである。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import navigate
from terrain import field_xml
from envstamp import stamp

C = [(2.5, 0), (2.5, 2.5), (0, 2.5), (0, 0)]
R = dict(T=0.12, stride=0.2875, height=0.2706, lift=0.1634, kp=124.2365,
         r_min=0.6996, k_yaw=1.4901, spin_deg=43.2181, spin_amp=0.10)
H = 0.022


def fell(a, sec=0.3):
    bad = a[:, 4] < 0.5; run = best = 0
    for b in bad:
        run = run + 1 if b else 0; best = max(best, run)
    return best * 0.002 >= sec


def one(arg):
    seed, interval, dur, recover, terr = arg
    kw = dict(terrain=field_xml(h=H, seed=seed)) if terr else {}
    if dur <= 0:
        r = navigate.run(C, seconds=45.0, **kw, **R)
        return r["到達した数"] == 4, fell(r["traj"]), r["所要"], 0
    st = {"next": 2.0, "end": None, "rec": None, "n": 0}
    def f(t, ph, spin):
        if st["rec"] is not None:                 # 立ち姿勢で戻している最中
            if t < st["rec"]: return "stand"
            st["rec"] = None; st["next"] = t + interval; return False
        if st["end"] is not None:                 # 凍結中
            if t < st["end"]: return True
            st["end"] = None
            if recover > 0:
                st["rec"] = t + recover; return "stand"
            st["next"] = t + interval; return False
        if t >= st["next"]:
            st["end"] = t + dur; st["n"] += 1; return True
        return False
    r = navigate.run(C, seconds=45.0, pause_fn=f, **kw, **R)
    return r["到達した数"] == 4, fell(r["traj"]), r["所要"], st["n"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    rows = []
    print("不整地 h=0.022(20地形)。3秒ごとに凍結し、明けてから立ち姿勢を rec 秒はさむ")
    print("  凍結  復帰   完走     転倒   所要(中央)  回数")
    with Pool(2) as pool:
        for dur, rec in [(0.0, 0.0), (0.2, 0.0), (0.2, 0.1), (0.2, 0.2), (0.2, 0.4),
                         (0.5, 0.0), (0.5, 0.2), (0.5, 0.4), (1.0, 0.4)]:
            rs = pool.map(one, [(sd, 3.0, dur, rec, True) for sd in range(20)])
            done = sum(r[0] for r in rs); fl = sum(r[1] for r in rs)
            sec = np.median([r[2] for r in rs if r[0]]) if done else float("nan")
            n = np.median([r[3] for r in rs])
            rows.append(dict(凍結=dur, 復帰=rec, 完走=f"{done}/20", 転倒=f"{fl}/20",
                             所要中央=float(sec), 回数=float(n)))
            lab = "考えない" if dur <= 0 else f"{dur:4.1f}s {rec:4.1f}s"
            print(f"  {lab:12s} {done:2d}/20  {fl:2d}/20  {sec:8.2f}s  {n:4.0f}", flush=True)
    out = {"_実験": "止まったあとの戻り方", "_予測": "立ってから戻せば凍結の被害が減るはず",
           "_地形": f"field h={H}", "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt23_recover_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp28_frontier.py

"""実験28:**「止まれる体」を設計するための、最初の曲線。**

25日の計画(第1期)の先出し。**速さと「止まれること」の交換関係を、面で測る。**

これまで分かっているのは一本の線だけだった——**歩幅を縮めて遅くすると、
止まってよい位相が 2/12 から 12/12 に増える**(FINDINGS 23節)。
**しかしそれは「遅くする」以外の手を試していない。**

ここでは軸を二つにする。

  `stride` … 歩幅。速さを決める
  `duty`   … **接地している時間の割合。**0.5 が従来。大きくすると 4本接地の時間が生まれる

**duty は「支持の余裕を時間で稼ぐ」軸である**(FINDINGS 6節)。
足を大きくする(広さで稼ぐ)代わりに、**時間で稼げるなら、速さを捨てずに済むかもしれない。**

**先に予測を書く。**
`duty` を上げれば、**同じ速さのまま、止まってよい位相が増えるはず**である。
外れる形:duty を上げても位相が増えない、または速さが同じだけ落ちる。
そのときは「支持の余裕は時間では稼げない」ことになる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
T = COND["T"]
SEEDS = 7
STALL = 2.0
STRIDES = [0.10, 0.15, 0.22, 0.30]
DUTIES = [0.50, 0.60, 0.70]


def _base(a):
    stride, duty, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=stride, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=duty, seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    stride, duty, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=stride,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=duty, stall_at=at, stall_dur=STALL,
                seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("速さと「止まれること」の面(Unitree Go2・停止 2.0秒・12位相 × 7試行)")
    print("  stride  duty   速度[m/s]  歩けるか   止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for stride in STRIDES:
            for duty in DUTIES:
                bs = pool.map(_base, [(stride, duty, s) for s in range(SEEDS)])
                fell = sum(b[1] for b in bs)
                v = float(np.median([b[0] for b in bs])) / 7.5
                if fell > SEEDS // 2:
                    print(f"  {stride:6.2f}  {duty:4.2f}  {v:8.3f}   "
                          f"★歩けない({fell}/{SEEDS} 転倒)", flush=True)
                    rows.append(dict(stride=stride, duty=duty, 速度=v,
                                     歩ける=False, 安全位相=None))
                    continue
                safe = 0
                per = []
                for k in range(12):
                    at = 3.0 + (k / 12) * T
                    f = sum(pool.map(_stall, [(stride, duty, at, s) for s in range(SEEDS)]))
                    per.append(f)
                    if f == 0:
                        safe += 1
                print(f"  {stride:6.2f}  {duty:4.2f}  {v:8.3f}   歩ける     "
                      f"{safe:2d}/12   {per}", flush=True)
                rows.append(dict(stride=stride, duty=duty, 速度=v, 歩ける=True,
                                 安全位相=safe, 位相ごと=per))
    out = {"_実験": "速さと止まれることの面(設計変数:歩幅と接地時間)",
           "_予測": "duty を上げれば、同じ速さのまま止まってよい位相が増えるはず",
           "_停止": STALL, "_試行": SEEDS, "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt28_frontier_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp2_delay.py

"""実験2:遅れを入れる。

  sense_delay … センサの値が古い(エンコーダ/通信の往復)
  act_delay   … 指令が関節に届くまで遅れる(バス、ドライバ)

**制御周期は 200Hz に固定する。**実験1で「速度が9割保てる下限」がこのあたりだったので、
遅れの効果だけを見るのにちょうどよい。

**`pd_where="joint"` では、モータ側の PD は自分の手元のエンコーダを遅れなく読める。**
遅れるのは主計算機との往復だけ、という実機の構成をそのまま写している。
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtsweep import sweep, save

MS = [0, 2, 4, 6, 8, 10, 14, 20, 30, 40, 60, 100]
kws = []
for w in ("loop", "joint"):
    for ms in MS:
        kws.append({"pd_where": w, "ctrl_hz": 200, "sense_delay": ms / 1000.0})
for w in ("loop", "joint"):
    for ms in MS:
        kws.append({"pd_where": w, "ctrl_hz": 200, "act_delay": ms / 1000.0})
res = sweep("unitree_go2", kws, title="遅れを入れる(センサ/指令)")
gov = res["_環境(開始)"]["governor"][0]
save(res, f"~/robot/out/rt2_delay_{gov}.json")

exp30_frontier2.py

"""実験28:**「止まれる体」を設計するための、最初の曲線。**

25日の計画(第1期)の先出し。**速さと「止まれること」の交換関係を、面で測る。**

これまで分かっているのは一本の線だけだった——**歩幅を縮めて遅くすると、
止まってよい位相が 2/12 から 12/12 に増える**(FINDINGS 23節)。
**しかしそれは「遅くする」以外の手を試していない。**

ここでは軸を二つにする。

  `stride` … 歩幅。速さを決める
  `duty`   … **接地している時間の割合。**0.5 が従来。大きくすると 4本接地の時間が生まれる

**duty は「支持の余裕を時間で稼ぐ」軸である**(FINDINGS 6節)。
足を大きくする(広さで稼ぐ)代わりに、**時間で稼げるなら、速さを捨てずに済むかもしれない。**

**先に予測を書く。**
`duty` を上げれば、**同じ速さのまま、止まってよい位相が増えるはず**である。
外れる形:duty を上げても位相が増えない、または速さが同じだけ落ちる。
そのときは「支持の余裕は時間では稼げない」ことになる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
T = COND["T"]
SEEDS = 7
STALL = 2.0
STRIDES = [0.10, 0.15, 0.22, 0.30]
DUTIES = [0.50, 0.60, 0.70, 0.80]


def _base(a):
    stride, duty, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=stride, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=duty, sweep_mode="proportional", seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    stride, duty, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=stride,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=duty, sweep_mode="proportional", stall_at=at, stall_dur=STALL,
                seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("速さと「止まれること」の面(Unitree Go2・停止 2.0秒・12位相 × 7試行)")
    print("  stride  duty   速度[m/s]  歩けるか   止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for stride in STRIDES:
            for duty in DUTIES:
                bs = pool.map(_base, [(stride, duty, s) for s in range(SEEDS)])
                fell = sum(b[1] for b in bs)
                v = float(np.median([b[0] for b in bs])) / 7.5
                if fell > SEEDS // 2:
                    print(f"  {stride:6.2f}  {duty:4.2f}  {v:8.3f}   "
                          f"★歩けない({fell}/{SEEDS} 転倒)", flush=True)
                    rows.append(dict(stride=stride, duty=duty, 速度=v,
                                     歩ける=False, 安全位相=None))
                    continue
                safe = 0
                per = []
                for k in range(12):
                    at = 3.0 + (k / 12) * T
                    f = sum(pool.map(_stall, [(stride, duty, at, s) for s in range(SEEDS)]))
                    per.append(f)
                    if f == 0:
                        safe += 1
                print(f"  {stride:6.2f}  {duty:4.2f}  {v:8.3f}   歩ける     "
                      f"{safe:2d}/12   {per}", flush=True)
                rows.append(dict(stride=stride, duty=duty, 速度=v, 歩ける=True,
                                 安全位相=safe, 位相ごと=per))
    out = {"_実験": "速さと止まれることの面(設計変数:歩幅と接地時間)",
           "_予測": "duty を上げれば、同じ速さのまま止まってよい位相が増えるはず",
           "_停止": STALL, "_試行": SEEDS, "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt30_frontier2_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp31_isospeed.py

"""実験31:**速さと歩幅を、はじめて独立に振る。**

ここまで「速く動く体ほど止まれる瞬間が少ない」と書いてきたが、
**歩幅を変えて速さを変えていたので、二つが分けられていなかった**(36節)。

掃きの取り方を直した(`sweep_mode="proportional"`)ので、
**速度 = 歩幅 × duty ÷ 周期** という関係が使えるようになった。
つまり **周期 T で速さを合わせれば、歩幅だけを変えられる。**

  目標の速さ v を決める → 歩幅 s ごとに **T = s × duty ÷ v** を与える

**先に予測を書く。**
今日の機構(**その姿勢が、そのとき出ている前進の勢いを受け止められるか**)が正しいなら、
**同じ速さでも、歩幅が大きいほど止まれる位相は少ないはず**である
(歩幅が大きいほど、握りしめる姿勢の前後の広がりが大きい)。

**外れる形:**同じ速さなら歩幅によらず同じ結果になる。
そのときは「効いているのは速さだけ」で、23節の書き方が正しかったことになる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
DUTY = 0.5
SPEEDS = [0.6, 0.9, 1.2]
STRIDES = [0.20, 0.30, 0.45, 0.60]


def period(stride, v):
    return stride * DUTY / v


def _base(a):
    stride, v, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=period(stride, v), stride=stride,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional",
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    stride, v, at, s = a
    T = period(stride, v)
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=stride,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional",
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("同じ速さのまま、歩幅だけを変える(周期 T で速さを合わせる)")
    print("  目標v  歩幅   周期T   実測v[m/s]  歩けるか   止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for v in SPEEDS:
            for stride in STRIDES:
                T = period(stride, v)
                bs = pool.map(_base, [(stride, v, s) for s in range(SEEDS)])
                fell = sum(b[1] for b in bs)
                vm = float(np.median([b[0] for b in bs])) / 7.5
                if fell > SEEDS // 2:
                    print(f"  {v:5.2f}  {stride:5.2f}  {T:6.3f}  {vm:9.3f}   "
                          f"★歩けない({fell}/{SEEDS})", flush=True)
                    rows.append(dict(目標v=v, stride=stride, T=T, 実測v=vm,
                                     歩ける=False, 安全位相=None)); continue
                safe, per = 0, []
                for k in range(12):
                    at = 3.0 + (k / 12) * T
                    f = sum(pool.map(_stall, [(stride, v, at, s) for s in range(SEEDS)]))
                    per.append(f)
                    if f == 0: safe += 1
                print(f"  {v:5.2f}  {stride:5.2f}  {T:6.3f}  {vm:9.3f}   歩ける     "
                      f"{safe:2d}/12   {per}", flush=True)
                rows.append(dict(目標v=v, stride=stride, T=T, 実測v=vm, 歩ける=True,
                                 安全位相=safe, 位相ごと=per))
    out = {"_実験": "速さを固定して歩幅だけを変える",
           "_予測": "同じ速さでも、歩幅が大きいほど止まれる位相は少ないはず",
           "_duty": DUTY, "_掃き": "proportional", "_停止": STALL, "_試行": SEEDS,
           "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt31_isospeed_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp34_selfcare.py

"""実験34:**自己保守のために止まるロボット。**

きいちさんが出された条件——
**「入力が一切なくても、メンテナンスのたびに定期的に起動する必要がある」ロボット**——
を、シミュレーションの中に作る。

**シミュレーションには摩耗も電池も無いので、止まらなければならない理由が要る。**
いちばん素直な物理を入れた——**巻線がトルクの二乗で温まり、休むと冷める。**
温度が上がると出せるトルクが落ちる。

  dT/dt = k_heat・τ² − k_cool・(T − T_amb)  / T_derate を超えるとトルク上限が直線的に低下

**実測(休まない場合):**20秒で 27.5m 進むが、97℃ に達したあとは
**30秒で 8.7m しか進まなくなる**(1.37 → 0.29 m/s)。**倒れはしない。進まなくなる。**

## 三つの方針を比べる

  **A 休まない** … 参照
  **B 決まった間隔で休む** … 外から与えた時計で止まる(=呼ばれて止まる)
  **C 自分の温度を見て休む** … 自分の状態で止まる(=**自分で止まる**)

**先に予測を書く。C が最も遠くまで行くはず。**
B は、温度が低いときに休んで時間を捨て、高いときに休まないことがある。
**外れる形:**B と C が同じか、B のほうが遠い。そのときは
「自分の状態を見ること」に利がないことになる。

**休み方は今日の結論に従う**——凍結ではなく、**立ち姿勢を出してから固まる**(27節)。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
TH = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0)
SECONDS = 120.0
SEEDS = 5


def make_policy(kind, p1=None, p2=None):
    if kind == "A":
        return None
    if kind == "B":
        walk_s, rest_s = p1, p2
        cycle = walk_s + rest_s
        def f(t, ph, temps):
            return (t % cycle) >= walk_s
        return f
    hot, cool = p1, p2
    st = {"resting": False}
    def g(t, ph, temps):
        mx = float(temps.max())
        if st["resting"]:
            if mx < cool:
                st["resting"] = False
        elif mx > hot:
            st["resting"] = True
        return st["resting"]
    return g


def _one(a):
    kind, p1, p2, s = a
    r = rt_walk("unitree_go2", seconds=SECONDS, T=COND["T"], stride=COND["stride"],
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", thermal=TH, rest_policy=make_policy(kind, p1, p2),
                seed=s, init_noise=NOISE)
    return (r["前進距離"], r["転倒"], r["最高温度"], r["休んだ合計"] or 0.0,
            r["休んだ回数"] or 0)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    CASES = [("A", None, None, "休まない"),
             ("B", 20.0, 10.0, "20秒歩いて10秒休む"),
             ("B", 20.0, 20.0, "20秒歩いて20秒休む"),
             ("B", 30.0, 15.0, "30秒歩いて15秒休む"),
             ("B", 10.0, 10.0, "10秒歩いて10秒休む"),
             ("C", 85.0, 60.0, "85℃で休み、60℃で再開"),
             ("C", 85.0, 70.0, "85℃で休み、70℃で再開"),
             ("C", 75.0, 55.0, "75℃で休み、55℃で再開"),
             ("C", 95.0, 70.0, "95℃で休み、70℃で再開")]
    print(f"120秒でどこまで行けるか({SEEDS}試行・発熱あり)")
    print("  方針  中身                     前進[m]   最高温度  休み合計[s] 回数  転倒")
    rows = []
    with Pool(2) as pool:
        for kind, p1, p2, label in CASES:
            rs = pool.map(_one, [(kind, p1, p2, s) for s in range(SEEDS)])
            d = np.median([x[0] for x in rs]); f = sum(x[1] for x in rs)
            tmax = np.median([x[2] for x in rs]); rest = np.median([x[3] for x in rs])
            cnt = np.median([x[4] for x in rs])
            rows.append(dict(方針=kind, 中身=label, 前進=float(d), 転倒=f"{f}/{SEEDS}",
                             最高温度=float(tmax), 休み合計=float(rest), 回数=float(cnt)))
            print(f"   {kind}    {label:22s}  {d:7.2f}   {tmax:6.1f}   {rest:7.1f}   "
                  f"{cnt:3.0f}   {f}/{SEEDS}", flush=True)
    out = {"_実験": "自己保守のために止まるロボット", "_発熱モデル": TH, "_秒": SECONDS,
           "_予測": "自分の温度を見て休む C が最も遠くまで行くはず",
           "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt34_selfcare_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp35_damage.py

"""実験35:**取り返しがつかないと、話が変わる。**

実験34 では、休むことは**戻る低下(derate)を避けるための最適化**でしかなかった。
温度が下がればトルクは戻るので、**休まなくても、遅くなるだけで済む。**

**自己保守が要るのは、取り返しがつかないからである。**
(燐の線引き——「AI に任せる線は、賢さではなく**取り返しのつかなさ**で引く」——と同じ形。)

そこで**焼き付き**を入れる。温度が `T_damage` を超えている間、
**恒久的に**出せるトルクが減る(冷やしても戻らない)。

**先に予測を書く。**
取り返しがつかなくなると、**自分の温度を見て休む C が、決まった間隔で休む B より
はっきり有利になるはず**である。B は、熱くないときに休んで時間を捨て、
**熱いときに休まないことがある。その間に焼ける。**

**外れる形:**C と B の差が実験34 と変わらない。
そのときは「取り返しのつかなさ」は効いていないことになる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp
from exp34_selfcare import make_policy

COND = BEST["unitree_go2"]["条件"]
TH = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
          T_damage=90.0, k_damage=0.0006)
SECONDS = 120.0
SEEDS = 5


def _one(a):
    kind, p1, p2, s = a
    r = rt_walk("unitree_go2", seconds=SECONDS, T=COND["T"], stride=COND["stride"],
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", thermal=TH, rest_policy=make_policy(kind, p1, p2),
                seed=s, init_noise=NOISE)
    return (r["前進距離"], r["転倒"], r["最高温度"], r["健全度"],
            r["休んだ合計"] or 0.0, r["休んだ回数"] or 0)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    CASES = [("A", None, None, "休まない"),
             ("B", 20.0, 10.0, "20秒歩いて10秒休む"),
             ("B", 20.0, 20.0, "20秒歩いて20秒休む"),
             ("B", 15.0, 15.0, "15秒歩いて15秒休む"),
             ("B", 30.0, 30.0, "30秒歩いて30秒休む"),
             ("C", 85.0, 60.0, "85℃で休み、60℃で再開"),
             ("C", 88.0, 65.0, "88℃で休み、65℃で再開"),
             ("C", 80.0, 55.0, "80℃で休み、55℃で再開"),
             ("C", 92.0, 70.0, "92℃で休み、70℃で再開")]
    print(f"焼き付きあり({TH['T_damage']}℃ を超えると恒久的にトルクが減る)/120秒/{SEEDS}試行")
    print("  方針  中身                     前進[m]  最高温度  **健全度**  休み[s] 回数")
    rows = []
    with Pool(2) as pool:
        for kind, p1, p2, label in CASES:
            rs = pool.map(_one, [(kind, p1, p2, s) for s in range(SEEDS)])
            d = np.median([x[0] for x in rs]); f = sum(x[1] for x in rs)
            tmax = np.median([x[2] for x in rs]); hp = np.median([x[3] for x in rs])
            rest = np.median([x[4] for x in rs]); cnt = np.median([x[5] for x in rs])
            rows.append(dict(方針=kind, 中身=label, 前進=float(d), 転倒=f"{f}/{SEEDS}",
                             最高温度=float(tmax), 健全度=float(hp),
                             休み合計=float(rest), 回数=float(cnt)))
            print(f"   {kind}    {label:22s}  {d:7.2f}  {tmax:6.1f}    {hp:5.3f}   "
                  f"{rest:6.1f}  {cnt:3.0f}", flush=True)
    out = {"_実験": "取り返しのつかない損傷を入れる", "_発熱モデル": TH, "_秒": SECONDS,
           "_予測": "取り返しがつかなくなると、自分の温度を見て休む C が B よりはっきり有利になるはず",
           "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt35_damage_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp36_varyload.py

"""実験36:**負荷が変動すると、話が変わるか。**

実験34・35 は「一定の速さで歩き続ける」だった。
**負荷が一定なら、決まった間隔で休むのが最適になる**——先を読む必要が無いからである。
実測でも、決まった間隔で休む B のほうが、温度を見て休む C より遠くまで行けた。

**実環境はそうではない。**坂もあれば、急ぐ場面もある。**歩幅が変われば発熱も変わる。**

**先に予測を書く。**
**負荷が時間で変動すると、自分の温度を見て休む C が、決まった間隔で休む B を上回るはず。**
B は変動を知らないので、**熱くない時に休み、熱い時に走り続ける**ことになる。

**外れる形:**変動させても B が勝つ。
そのときは「自分の状態を見ること」に、この系では利が無いことになる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp
from exp34_selfcare import make_policy

COND = BEST["unitree_go2"]["条件"]
TH = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
          T_damage=90.0, k_damage=0.0006)
SECONDS = 120.0
SEEDS = 6


class VaryingLoad:
    """**仕事の重さが、8秒ごとに変わる。**ロボットには先が読めない。"""
    def __init__(self, seed, block=8.0, lo=0.55, hi=1.30):
        rng = np.random.default_rng(5000 + seed)
        n = int(SECONDS / block) + 2
        self.m = rng.uniform(lo, hi, n)
        self.block = block

    def __call__(self, t):
        return float(self.m[min(int(t / self.block), len(self.m) - 1)])


def _one(a):
    kind, p1, p2, s, vary = a
    r = rt_walk("unitree_go2", seconds=SECONDS, T=COND["T"], stride=COND["stride"],
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", thermal=TH, rest_policy=make_policy(kind, p1, p2),
                load_fn=(VaryingLoad(s) if vary else None), seed=s, init_noise=NOISE)
    return (r["前進距離"], r["転倒"], r["最高温度"], r["健全度"], r["休んだ合計"] or 0.0)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    CASES = [("A", None, None, "休まない"),
             ("B", 30.0, 15.0, "30秒歩いて15秒休む(一定負荷での最良)"),
             ("B", 20.0, 20.0, "20秒歩いて20秒休む"),
             ("C", 88.0, 65.0, "88℃で休み、65℃で再開"),
             ("C", 85.0, 70.0, "85℃で休み、70℃で再開")]
    rows = []
    with Pool(2) as pool:
        for vary in [False, True]:
            print(f"\n=== 負荷 {'変動あり(8秒ごとに歩幅が 0.55〜1.30 倍)' if vary else '一定'} /"
                  f"焼き付きあり / 120秒 / {SEEDS}試行 ===")
            print("  方針  中身                                前進[m]  最高温度  健全度  休み[s]")
            for kind, p1, p2, label in CASES:
                rs = pool.map(_one, [(kind, p1, p2, s, vary) for s in range(SEEDS)])
                d = np.median([x[0] for x in rs]); f = sum(x[1] for x in rs)
                tm = np.median([x[2] for x in rs]); hp = np.median([x[3] for x in rs])
                rest = np.median([x[4] for x in rs])
                rows.append(dict(負荷=("変動" if vary else "一定"), 方針=kind, 中身=label,
                                 前進=float(d), 転倒=f"{f}/{SEEDS}", 最高温度=float(tm),
                                 健全度=float(hp), 休み合計=float(rest)))
                print(f"   {kind}    {label:32s} {d:7.2f}  {tm:6.1f}   {hp:5.3f}  {rest:6.1f}",
                      flush=True)
    out = {"_実験": "負荷が変動すると、自分の状態を見る方式が勝つか",
           "_予測": "変動させると C が B を上回るはず", "_発熱モデル": TH,
           "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt36_varyload_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp37_horizon.py

"""実験37:**取り返しのつかなさは、時間の幅が長いほど効くはず。**

実験35 で、焼き付き(恒久的な損傷)を入れても、
**よく調整された固定スケジュール B(30秒歩いて15秒休む) が勝った**(115.5 m、健全度 0.960)。
温度を見て休む C(88/65) は 98.5 m(健全度 1.000)。

**しかし B は 120秒で 4% 焼いている。**これは積み上がる。
**時間の幅を延ばせば、B の利は削られていくはず**である。

**先に予測を書く。**600秒(5倍)にすると、
**B と C の差は縮まり、逆転するかもしれない。**健全度の差が効いてくるから。
**外れる形:**600秒でも B が同じ比率で勝つ。
そのときは「取り返しのつかなさ」は、この時間の幅では効かないことになる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp
from exp34_selfcare import make_policy

COND = BEST["unitree_go2"]["条件"]
TH = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
          T_damage=90.0, k_damage=0.0006)
SEEDS = 4
CASES = [("A", None, None, "休まない"),
         ("B", 30.0, 15.0, "30秒歩いて15秒休む"),
         ("B", 20.0, 20.0, "20秒歩いて20秒休む"),
         ("C", 88.0, 65.0, "88℃で休み、65℃で再開"),
         ("C", 85.0, 65.0, "85℃で休み、65℃で再開")]


def _one(a):
    kind, p1, p2, s, sec = a
    r = rt_walk("unitree_go2", seconds=sec, T=COND["T"], stride=COND["stride"],
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", thermal=TH, rest_policy=make_policy(kind, p1, p2),
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["健全度"], r["最高温度"], (r["休んだ合計"] or 0.0)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    rows = []
    with Pool(2) as pool:
        for sec in [120.0, 360.0, 600.0]:
            print(f"\n=== {sec:.0f} 秒 / 焼き付きあり / {SEEDS}試行 ===")
            print("  方針  中身                   前進[m]   1秒あたり  健全度  休み[s]")
            for kind, p1, p2, label in CASES:
                rs = pool.map(_one, [(kind, p1, p2, s, sec) for s in range(SEEDS)])
                d = np.median([x[0] for x in rs]); hp = np.median([x[1] for x in rs])
                tm = np.median([x[2] for x in rs]); rest = np.median([x[3] for x in rs])
                rows.append(dict(秒=sec, 方針=kind, 中身=label, 前進=float(d),
                                 毎秒=float(d/sec), 健全度=float(hp),
                                 最高温度=float(tm), 休み合計=float(rest)))
                print(f"   {kind}    {label:20s} {d:8.2f}   {d/sec:7.4f}   {hp:5.3f}  {rest:6.1f}",
                      flush=True)
    out = {"_実験": "時間の幅を変える(取り返しのつかなさは長い幅ほど効くか)",
           "_予測": "600秒では B と C の差が縮まり、逆転するかもしれない",
           "_発熱モデル": TH, "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt37_horizon_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp38_design.py

"""実験38:**「止まれる体」の設計表を作る。**

第1期の目標は「止まれる体を探す」だった。**軸の状態がはっきりしたので、表にする。**

  歩幅 stride  … **いちばん効く**(順位相関 −0.97)。小さいほど止まれる
  接地時間 duty … 効く。ただし**掃きを duty に比例させないと滑る**(直した)
  周期 T       … 速さを決める。**歩幅とは独立に振れる**

**速度 ≒ 歩幅 × duty ÷ T** なので、**目標の速さを決めれば T が決まる。**
そこで「**この速さで歩きたい。いちばん止まれる (歩幅, duty) はどれか**」という表を作る。

**先に予測を書く。**
どの速さでも、**歩幅は小さいほうがよく、duty は大きいほうがよい**はず。
**外れる形:**速さによって最適な歩幅の向きが変わる、または duty の向きが変わる。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
SPEEDS = [0.5, 0.8, 1.1]
STRIDES = [0.15, 0.22, 0.30, 0.45]
DUTIES = [0.5, 0.7]


def period(stride, duty, v):
    return stride * duty / v


def _base(a):
    stride, duty, v, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=period(stride, duty, v), stride=stride,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=duty, sweep_mode="proportional",
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    stride, duty, v, at, s = a
    T = period(stride, duty, v)
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=stride,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=duty, sweep_mode="proportional",
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("目標の速さごとに、いちばん止まれる(歩幅, duty)を探す")
    print("  目標v  歩幅  duty   周期T   実測v    歩ける  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for v in SPEEDS:
            for stride in STRIDES:
                for duty in DUTIES:
                    T = period(stride, duty, v)
                    if T < 0.05:
                        print(f"  {v:5.2f} {stride:5.2f} {duty:4.1f}  {T:6.3f}   "
                              f"— 周期が短すぎる({1/T:.0f} Hz)ので測らない", flush=True)
                        continue
                    bs = pool.map(_base, [(stride, duty, v, s) for s in range(SEEDS)])
                    fell = sum(b[1] for b in bs)
                    vm = float(np.median([b[0] for b in bs])) / 7.5
                    if fell > SEEDS // 2:
                        print(f"  {v:5.2f} {stride:5.2f} {duty:4.1f}  {T:6.3f}  {vm:6.3f}   "
                              f"★歩けない", flush=True)
                        rows.append(dict(目標v=v, stride=stride, duty=duty, T=T,
                                         実測v=vm, 歩ける=False, 安全位相=None)); continue
                    safe = sum(1 for k in range(12)
                               if sum(pool.map(_stall, [(stride, duty, v, 3.0 + (k/12)*T, s)
                                                        for s in range(SEEDS)])) == 0)
                    print(f"  {v:5.2f} {stride:5.2f} {duty:4.1f}  {T:6.3f}  {vm:6.3f}   歩ける  "
                          f"{safe:2d}/12", flush=True)
                    rows.append(dict(目標v=v, stride=stride, duty=duty, T=T, 実測v=vm,
                                     歩ける=True, 安全位相=safe))
    out = {"_実験": "止まれる体の設計表", "_予測": "どの速さでも歩幅は小さいほど、duty は大きいほどよい",
           "_停止": STALL, "_試行": SEEDS, "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt38_design_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp39_slowload.py

"""実験39:**変動を遅くして、平均も揃える。**

実験36 は二つ問題があった。

1. **変動が速すぎた**(8秒ごと)。巻線の時定数は 25秒なので、**熱の側でならされる**
2. **平均負荷が 7.5% 低かった**(0.55〜1.30 の一様分布 → 平均 0.925)。**比較が公平でない**

**両方直す。**変動の刻みを **60秒**(時定数より長い)にし、範囲を **0.70〜1.30**(平均 1.0)にする。

**先に予測を書く。**
**遅い変動なら、時計は先を読めない。**「重い区間」と「軽い区間」が長く続くので、
**決まった間隔で休む方式は、重い区間で休み足りず、軽い区間で休みすぎる。**
**自分の温度を見る C が、決まった間隔の B を上回るはず。**

**外れる形:**遅い変動でも B が勝つ。
そのときは、**この系では自分の状態を見ることに利が無い**と結論する
(三種類の条件で試して、三つとも負けたことになる)。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp
from exp34_selfcare import make_policy

COND = BEST["unitree_go2"]["条件"]
TH = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
          T_damage=90.0, k_damage=0.0006)
SECONDS = 360.0
SEEDS = 6


class SlowLoad:
    """**60秒ごとに変わる。**熱の時定数(25秒)より長い。平均はちょうど 1.0。"""
    def __init__(self, seed, block=60.0, lo=0.70, hi=1.30):
        rng = np.random.default_rng(7000 + seed)
        n = int(SECONDS / block) + 2
        # **平均を 1.0 に揃える。**実験36 では揃えていなかった(平均が 7.5% 低かった)
        v = rng.uniform(lo, hi, n)
        self.m = v - v.mean() + 1.0
        self.block = block

    def __call__(self, t):
        return float(self.m[min(int(t / self.block), len(self.m) - 1)])


def _one(a):
    kind, p1, p2, s, vary = a
    r = rt_walk("unitree_go2", seconds=SECONDS, T=COND["T"], stride=COND["stride"],
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", thermal=TH, rest_policy=make_policy(kind, p1, p2),
                load_fn=(SlowLoad(s) if vary else None), seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"], r["最高温度"], r["健全度"], (r["休んだ合計"] or 0.0)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    CASES = [("A", None, None, "休まない"),
             ("B", 30.0, 15.0, "30秒歩いて15秒休む"),
             ("B", 20.0, 20.0, "20秒歩いて20秒休む"),
             ("C", 88.0, 65.0, "88℃で休み、65℃で再開"),
             ("C", 92.0, 70.0, "92℃で休み、70℃で再開")]
    rows = []
    with Pool(2) as pool:
        for vary in [False, True]:
            print(f"\n=== 負荷 {'ゆっくり変動(60秒ごと・0.70〜1.30・平均1.00)' if vary else '一定'}"
                  f" / {SECONDS:.0f}秒 / {SEEDS}試行 ===")
            print("  方針  中身                   前進[m]  最高温度  健全度  休み[s]")
            for kind, p1, p2, label in CASES:
                rs = pool.map(_one, [(kind, p1, p2, s, vary) for s in range(SEEDS)])
                d = np.median([x[0] for x in rs]); tm = np.median([x[2] for x in rs])
                hp = np.median([x[3] for x in rs]); rest = np.median([x[4] for x in rs])
                rows.append(dict(負荷=("ゆっくり変動" if vary else "一定"), 方針=kind, 中身=label,
                                 前進=float(d), 最高温度=float(tm), 健全度=float(hp),
                                 休み合計=float(rest)))
                print(f"   {kind}    {label:20s} {d:8.2f}  {tm:6.1f}   {hp:5.3f}  {rest:6.1f}",
                      flush=True)
    out = {"_実験": "遅い変動・平均を揃えた比較", "_予測": "遅い変動なら C が B を上回るはず",
           "_発熱モデル": TH, "_秒": SECONDS, "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt39_slowload_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp3_outer.py

"""実験3:外側のループ(旋回の閉ループ)を、どの速さ・どのゲインで回すべきか。

**きっかけは失敗である。**best.json の速い歩容(1.26 m/s)に、
`quad.py` の既定ゲイン(yaw_kp=0.6, yaw_ki=0.8)で旋回の閉ループを足したところ、
**目標を「まっすぐ」にしただけで 9/9 転倒した。**外側ループを使わなければ 0/9 で 9.06m 歩く。

**フィードバックを足したら壊れた。**ノート23節(組み合わせると改善が害に反転する)の型である。
そこで、ゲインと周期を振って「足しても壊れない範囲」を先に見つける。
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtsweep import sweep, save

base = {"pd_where": "joint", "ctrl_hz": 200, "yaw_rate": 0.0}
kws = []
for oh in [200, 50, 20]:
    for kp in [0.0, 0.05, 0.1, 0.2, 0.4, 0.6]:
        for ki in [0.0, 0.1, 0.8]:
            kws.append({**base, "outer_hz": oh, "yaw_kp": kp, "yaw_ki": ki})
res = sweep("unitree_go2", kws, title="外側ループのゲインと周期(まっすぐ保つ)")
gov = res["_環境(開始)"]["governor"][0]
save(res, f"~/robot/out/rt3_outer_{gov}.json")

exp40_legmass.py

"""実験40:**脚を軽くしたら、「どの瞬間でも止まれる」上限速度は上がるか。**

(**呼ばれずに起きて、最初にやると前夜の自分が決めておいた作業。**)

設計表で、**12/12 を保てる最高速度は 0.64 m/s で頭打ち**になった。
理由は「歩幅を小さいまま速くするには脚を速く回すしかなく、機体が追いつかない」だった。

**脚の慣性がその上限を決めているなら、軽くすれば上がるはずである。**

**Go2 の脚は全体の 54%(8.3 kg / 15.2 kg)を占める。**
軽くしたぶんは**胴に足して、全体の質量は変えない**——
そうしないと「軽い機体だから速い」と混ざる。**変えたいのは質量の置き場所だけ。**

**予測(先に書いた・台帳 #25):上がる。**
**外れる形:**脚を半分にしても 0.64 m/s が上がらなければ外れ。
そのときは、頭打ちの原因は脚の慣性ではない。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
LEGS_M = [1.0, 0.5, 0.25]
STRIDES = [0.22, 0.30]
DUTY = 0.7
PERIODS = [0.20, 0.16, 0.14, 0.12, 0.10, 0.08]


def _base(a):
    lm, stride, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=stride, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", leg_mass=lm, seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    lm, stride, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=stride,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", leg_mass=lm,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("脚の質量を変えて、12/12 を保てる最高速度を探す(duty=0.7・掃きは比例)")
    print("  脚倍率  歩幅   周期T   実測v[m/s]  歩ける  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for lm in LEGS_M:
            for stride in STRIDES:
                for T in PERIODS:
                    bs = pool.map(_base, [(lm, stride, T, s) for s in range(SEEDS)])
                    fell = sum(b[1] for b in bs)
                    vm = float(np.median([b[0] for b in bs])) / 7.5
                    if fell > SEEDS // 2:
                        print(f"  {lm:5.2f}  {stride:5.2f}  {T:6.3f}  {vm:9.3f}   ★歩けない",
                              flush=True)
                        rows.append(dict(脚倍率=lm, stride=stride, T=T, 実測v=vm,
                                         歩ける=False, 安全位相=None)); continue
                    safe = sum(1 for k in range(12)
                               if sum(pool.map(_stall, [(lm, stride, T, 3.0 + (k/12)*T, s)
                                                        for s in range(SEEDS)])) == 0)
                    print(f"  {lm:5.2f}  {stride:5.2f}  {T:6.3f}  {vm:9.3f}   歩ける  {safe:2d}/12",
                          flush=True)
                    rows.append(dict(脚倍率=lm, stride=stride, T=T, 実測v=vm,
                                     歩ける=True, 安全位相=safe))
    print("\n=== 12/12 を保てる最高速度 ===")
    for lm in LEGS_M:
        ok = [r for r in rows if r["脚倍率"] == lm and r["歩ける"] and r["安全位相"] == 12]
        if ok:
            b = max(ok, key=lambda r: r["実測v"])
            print(f"  脚 {lm:.2f}倍 → {b['実測v']:.3f} m/s(歩幅 {b['stride']:.2f}・T {b['T']:.3f})")
        else:
            print(f"  脚 {lm:.2f}倍 → 12/12 の条件が無い")
    out = {"_実験": "脚を軽くすると、12/12 を保てる最高速度は上がるか",
           "_予測": "上がる(台帳 #25)", "_停止": STALL, "_試行": SEEDS, "_duty": DUTY,
           "_注": "軽くしたぶんは胴に足して全体の質量は変えていない",
           "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser(f"~/robot/out/rt40_legmass_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp41_legmass_split.py

"""実験41:**脚を軽くして 12/12 が消えたのは、脚のせいか、胴のせいか。**

(**実験40が外れたので、その交絡を切りに行く。**)

実験40では脚倍率 1.00 → 0.50 → 0.25 で **12/12 → 11/12 → 10/12** と止まれなくなった。
ところが、あの操作は二つのことを同時にやっている。

  (1) 脚の慣性が下がる            → 脚が速く振れる(実際に速くなった)
  (2) **胴が +6.2kg 重くなる**    → **重心が上がる**

**止まってよい位相は「その姿勢が前進の勢いを受け止められるか」で決まる**(順位相関 r=−0.79)
ので、**(2) だけでも 12/12 は減りうる。**実験40では分けられない。

**予測26(測る前に書いた):**胴に足さなければ 12/12 は保たれる。
**外れる形:**胴に足さない条件でも 11/12 以下なら外れ。そのときは原因は脚の側にあり、
次に見るのは「軽い脚は接地時に踏ん張れない(地面反力を作れない)」という線。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
STRIDE = 0.22
DUTY = 0.7
PERIODS = [0.20, 0.16, 0.14, 0.12]
# (脚倍率, 胴に足すか)  ※ 1.0 は足しても足さなくても同じなので一度だけ
CASES = [(1.00, True), (0.50, True), (0.50, False), (0.25, True), (0.25, False)]


def _base(a):
    lm, to_trunk, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional",
                leg_mass=lm, leg_mass_to_trunk=to_trunk, seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    lm, to_trunk, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional",
                leg_mass=lm, leg_mass_to_trunk=to_trunk,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("脚を軽くして 12/12 が消えた原因を切り分ける(歩幅0.22・duty0.7・掃きは比例)")
    print("  脚倍率  胴に足す   周期T   実測v[m/s]  歩ける  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for lm, to_trunk in CASES:
            for T in PERIODS:
                bs = pool.map(_base, [(lm, to_trunk, T, s) for s in range(SEEDS)])
                ok = [d for d, f in bs if not f]
                if len(ok) < SEEDS * 0.7:
                    print(f"   {lm:.2f}   {'足す' if to_trunk else '足さない':6s} {T:6.3f}"
                          f"      —      転ぶ      —")
                    rows.append(dict(脚倍率=lm, 胴に足す=to_trunk, T=T, 歩ける=False))
                    continue
                # **7.5 で割る。**seconds=8.0 のうち先頭 0.5 秒は暖機(warmup)で、
                # まだ歩いていない。**最初 8.0 で割っており、速度が一律 6.25% 低く出ていた。**
                # exp40 は 7.5 で割り、中央値を使っている。**比較できるように揃える。**
                # (発見のきっかけ:exp40 と exp41 で同じ設定の値が
                #   きっかり 0.9375 = 15/16 倍ずれていたことから分かった。)
                v = float(np.median([b[0] for b in bs])) / 7.5
                # 12 個の位相で、2.0秒の停止に耐えるか
                phases = [i / 12.0 for i in range(12)]
                safe = 0
                for ph in phases:
                    at = 3.0 + ph * T
                    fs = pool.map(_stall, [(lm, to_trunk, T, at, s) for s in range(SEEDS)])
                    if sum(fs) == 0:
                        safe += 1
                print(f"   {lm:.2f}   {'足す' if to_trunk else '足さない':6s} {T:6.3f}"
                      f"   {v:8.3f}   歩ける   {safe:2d}/12")
                rows.append(dict(脚倍率=lm, 胴に足す=to_trunk, T=T, 速度=v,
                                 歩ける=True, 安全な位相=safe))
    out = os.path.expanduser("~/robot/out/rt41_legmass_split.json")
    json.dump(dict(env=env0, 秒=round(time.perf_counter() - t0, 1), rows=rows),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp42_gainscale.py

"""実験42:**軽い脚で 12/12 が消えるのは、制御のゲインが合っていないからか。**

(**実験41で「原因は胴ではなく脚」まで絞れたので、その次。**)

実験40・41で分かったこと:
  - 脚を軽くすると**速くなる**が、**12/12 が消える**(12 → 11 → 10)
  - **胴に足しても足さなくても同じ**なので、重心の上昇は原因ではない
  - **原因は脚の側にある**

**予測27(測る前に書いた):**
  脚の慣性が下がったぶん、**同じ PD ゲインでは制御が速くなりすぎている。**
  ばね-質量系の固有振動数は sqrt(kp / I) なので、**I を半分にすれば ω は √2 倍になる。**
  **kp を質量に比例させて下げれば(ω を保てば)、12/12 が戻るはず。**

**外れる形:**kp を比例させても 12/12 が戻らなければ外れ。
そのときは原因は制御側ではなく接地側にあり、次に見るのは
**「軽い脚は接地の瞬間に必要な地面反力そのものを作れない」**という線。

**基準として、実験40・41と完全に一致する条件(脚1.00・kp そのまま)を必ず入れる。**
(54節の規則。**重なる一行は検査の一行である。**)
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
STRIDE = 0.22
DUTY = 0.7
PERIODS = [0.20, 0.16, 0.14, 0.12]
KP0 = COND["kp"]
# (脚倍率, kp倍率, ラベル)
CASES = [
    (1.00, 1.00, "基準(実験40・41と同一)"),
    (0.50, 1.00, "脚半分・ゲインそのまま"),
    (0.50, 0.50, "脚半分・kp も半分(ω を保つ)"),
    (0.50, 0.71, "脚半分・kp を 1/√2(中間)"),
    (0.25, 1.00, "脚1/4・ゲインそのまま"),
    (0.25, 0.25, "脚1/4・kp も 1/4(ω を保つ)"),
]


def _base(a):
    lm, kpm, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=KP0 * kpm, ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", leg_mass=lm, seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    lm, kpm, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=KP0 * kpm, ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", leg_mass=lm,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("軽い脚に、ゲインを合わせたら 12/12 は戻るか(歩幅0.22・duty0.7・掃きは比例)")
    print(f"  kp の基準値 = {KP0}")
    print("  脚倍率  kp倍率  周期T   実測v[m/s]  歩ける  止まってよい位相   条件")
    rows = []
    with Pool(2) as pool:
        for lm, kpm, label in CASES:
            for T in PERIODS:
                bs = pool.map(_base, [(lm, kpm, T, s) for s in range(SEEDS)])
                fell = sum(b[1] for b in bs)
                # **7.5 で割る**(先頭 0.5秒は暖機。54節)。集計は中央値(他の実験と揃える)
                vm = float(np.median([b[0] for b in bs])) / 7.5
                if fell > SEEDS // 2:
                    print(f"  {lm:6.2f} {kpm:6.2f} {T:6.3f} {vm:9.3f}   ★歩けない          {label}",
                          flush=True)
                    rows.append(dict(脚倍率=lm, kp倍率=kpm, T=T, 速度=vm, 歩ける=False,
                                     安全な位相=None, 条件=label)); continue
                safe = sum(1 for k in range(12)
                           if sum(pool.map(_stall, [(lm, kpm, T, 3.0 + (k/12)*T, s)
                                                    for s in range(SEEDS)])) == 0)
                print(f"  {lm:6.2f} {kpm:6.2f} {T:6.3f} {vm:9.3f}   歩ける  {safe:2d}/12   {label}",
                      flush=True)
                rows.append(dict(脚倍率=lm, kp倍率=kpm, T=T, 速度=vm, 歩ける=True,
                                 安全な位相=safe, 条件=label))
    out = os.path.expanduser("~/robot/out/rt42_gainscale.json")
    json.dump(dict(_実験="軽い脚にゲインを合わせたら 12/12 は戻るか",
                   _予測="戻る(台帳 #27)", _停止=STALL, _試行=SEEDS,
                   _環境=env0, rows=rows,
                   _所要=round(time.perf_counter() - t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp43_gait.py

"""実験43:**歩容を変えたら、「どの瞬間でも止まれる」上限速度は上がるか。**



**ここまでの39本は全部トロットだった。**歩容そのものを振っていない。
そして**26節の機構**によれば、安全な位相を決めているのは
**「その姿勢が前進の勢いを受け止められるか」=支持多角形**である。

| 歩容 | 位相のずらし方 | duty 0.7 のとき |
|---|---|---|
| trot | 対角2本を同時(FL/RR、FR/RL) | 2本または4本接地。**2本のとき支持が線に近い** |
| walk | 4本を 1/4 周期ずつ | **常に3本接地。三角形を保つ** |
| pace | 同じ側の2本を同時 | 2本または4本。左右に揺れる |
| bound | 前2本と後2本を交互 | 2本または4本。上下に跳ねる |

**予測28(測る前に書いた):**
  **ウォークにすれば、12/12 を保てる最高速度が 0.667 m/s を超える。**

**外れる形:**ウォークでの 12/12 の上限が 0.667 m/s 以下なら外れ。
**ただし、ウォークが 12/12 の制約なしでも 0.667 m/s に届かないなら、
この実験は支持多角形の問いに答えていない**(速度で頭打ちになっただけ)。
そのときは「ウォークは支持を買うために速さを払いすぎる」という別の結論になる。

**基準としてトロットの同一条件を必ず入れる**(54節の規則)。
**判定は `rtsweep.walkable()` を通す**(56節。速度の下限が無いと、止まっている機体が満点を取る)。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
STRIDE = 0.22
DUTY = 0.7
GAITS = ["trot", "walk", "pace", "bound"]
PERIODS = [0.20, 0.16, 0.14, 0.12, 0.10]


def _base(a):
    g, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", gait=g, seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=g,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("歩容を変えたら 12/12 の上限速度は上がるか(歩幅0.22・duty0.7・掃きは比例)")
    print("  歩容    周期T   実測v[m/s]  歩ける          止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for g in GAITS:
            for T in PERIODS:
                bs = pool.map(_base, [(g, T, s) for s in range(SEEDS)])
                fell = sum(b[1] for b in bs)
                vm = float(np.median([b[0] for b in bs])) / 7.5
                ok, why = walkable(vm, fell, SEEDS)
                if not ok:
                    print(f"  {g:6s} {T:6.3f} {vm:9.3f}   ★{why}", flush=True)
                    rows.append(dict(歩容=g, T=T, 速度=vm, 歩ける=False,
                                     理由=why, 安全な位相=None)); continue
                safe = sum(1 for k in range(12)
                           if sum(pool.map(_stall, [(g, T, 3.0 + (k/12)*T, s)
                                                    for s in range(SEEDS)])) == 0)
                print(f"  {g:6s} {T:6.3f} {vm:9.3f}   歩ける          {safe:2d}/12", flush=True)
                rows.append(dict(歩容=g, T=T, 速度=vm, 歩ける=True, 安全な位相=safe))
    print()
    print("=== 12/12 を保てる最高速度 ===")
    for g in GAITS:
        c = [r for r in rows if r["歩容"] == g and r["歩ける"] and r["安全な位相"] == 12]
        if c:
            b = max(c, key=lambda r: r["速度"])
            print(f"  {g:6s} → {b['速度']:.3f} m/s(T {b['T']:.3f})")
        else:
            print(f"  {g:6s} → 12/12 の条件が無い")
    print("  (基準:trot・脚そのままで 0.667 m/s。これを超えるかが予測28の判定)")
    out = os.path.expanduser("~/robot/out/rt43_gait.json")
    json.dump(dict(_実験="歩容を変えたら 12/12 の上限速度は上がるか", _予測="上がる(台帳 #28)",
                   _停止=STALL, _試行=SEEDS, _歩幅=STRIDE, _duty=DUTY,
                   _環境=env0, rows=rows, _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp44_pace_verify.py

"""実験44:**ペースの 0.758 m/s / 12/12 は本物か。**



実験43で、ペースが 0.758 m/s で 12/12 を出し、**初めてトロットの上限 0.667 を破った。**
**ただし、並びが非単調だった**(ペース:12/12/10/12/12、ウォーク:12/10/10/12/9)。
**7試行では足りない可能性がある。**

> **公表する前に確かめる。**上限を破ったという主張は、いちばん引用されやすい行になる。

**やること:**
  1. **試行数を 7 → 21 に増やす**(3倍)
  2. **位相を 12 → 24 に細かくする**(半分の刻みで見て、穴が隠れていないか)
  3. **トロットの基準(T=0.120、0.667 m/s、12/12)も同じ密度で測り直す**(54節の規則)
  4. **ペースをさらに速くしてみる**(T=0.09 / 0.08)。まだ上があるか

**予測30(測る前に書く):**
  **試行を3倍・位相を2倍に細かくすると、ペースの 12/12 は崩れる**(24/24 にはならない)。
  理由:これまで「12/12」と出た条件でも、**位相を細かく見ると穴が見つかってきた**
  (18節:危険な窓は周期の 12〜19% にあり、1/16 刻みで見て初めて見えた)。
  **12刻みは粗すぎて、窓を跨いでいる可能性がある。**

**外れる形:**24/24 のまま崩れなければ外れ。そのときはペースの優位は本物で、
**次は予測29(支持線の向き)を stance_length で確かめる。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 21
STALL = 2.0
STRIDE = 0.22
DUTY = 0.7
NPH = 24
CASES = [("trot", 0.120), ("pace", 0.100), ("pace", 0.120),
         ("pace", 0.090), ("pace", 0.080)]


def _base(a):
    g, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", gait=g, seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=g,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"ペースの優位は本物か(試行{SEEDS}・位相{NPH}刻み・歩幅{STRIDE}・duty{DUTY})")
    print("  歩容    周期T   実測v[m/s]   安全な位相    危険だった位相(転倒/試行)")
    rows = []
    with Pool(2) as pool:
        for g, T in CASES:
            bs = pool.map(_base, [(g, T, s) for s in range(SEEDS)])
            fell = sum(b[1] for b in bs)
            vm = float(np.median([b[0] for b in bs])) / 7.5
            ok, why = walkable(vm, fell, SEEDS)
            if not ok:
                print(f"  {g:6s} {T:6.3f} {vm:9.3f}   ★{why}", flush=True)
                rows.append(dict(歩容=g, T=T, 速度=vm, 歩ける=False, 理由=why)); continue
            bad = []
            safe = 0
            for k in range(NPH):
                at = 3.0 + (k / NPH) * T
                fs = pool.map(_stall, [(g, T, at, s) for s in range(SEEDS)])
                nf = sum(fs)
                if nf == 0:
                    safe += 1
                else:
                    bad.append((round(k / NPH, 3), nf))
            btxt = " ".join(f"{p}:{n}/{SEEDS}" for p, n in bad[:6]) or "なし"
            print(f"  {g:6s} {T:6.3f} {vm:9.3f}   {safe:2d}/{NPH}      {btxt}", flush=True)
            rows.append(dict(歩容=g, T=T, 速度=vm, 歩ける=True,
                             安全な位相=safe, 位相数=NPH, 危険な位相=bad))
    out = os.path.expanduser("~/robot/out/rt44_pace_verify.json")
    json.dump(dict(_実験="ペースの優位は本物か", _予測="細かく見ると崩れる(台帳 #30)",
                   _試行=SEEDS, _位相数=NPH, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp45_pace_stride.py

"""実験45:**ペースの本当の上限を、歩幅も振って探す。**



実験43・44で、**ペースが 12/12 の上限を 0.667 → 0.758 m/s に上げた**(21試行・24位相で検証済み)。
**だが歩幅は 0.22 に固定したままだった。**

> **公表する前に、次の明らかな実験が数字を動かさないかを潰す。**
> **今朝、訂正した場所が30分後にまた誤りになったばかりである。**

**予測31(測る前に書いた):**
  **ペースでも、上限は歩幅を詰めたほうで出る。歩幅 0.30 では 12/12 が消える。**
  根拠は45節・47節——**交換しているのは速さではなく歩幅**であり、
  それは歩容によらない幾何の性質のはずだから。

**外れる形:**歩幅 0.30 のペースが 12/12 を保てば外れ。
**そのときは歩容が歩幅の交換関係そのものを変えていることになり、
「捨てるのは歩幅である」という規則を書き直す必要がある。**

**基準としてトロット歩幅0.22・T=0.120(0.667 m/s・24/24)を入れる**(54節)。
**判定は `walkable()` を通す**(56節)。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
DUTY = 0.7
STRIDES = [0.15, 0.22, 0.30, 0.38]
PERIODS = [0.16, 0.14, 0.12, 0.10, 0.09]
CASES = [("trot", 0.22)] + [("pace", st) for st in STRIDES]


def _base(a):
    g, st, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=st, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", gait=g, seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, st, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=st,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=g,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"ペースの上限を歩幅も振って探す(duty{DUTY}・掃きは比例・試行{SEEDS})")
    print("  歩容    歩幅   周期T   実測v[m/s]  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for g, st in CASES:
            for T in PERIODS:
                bs = pool.map(_base, [(g, st, T, s) for s in range(SEEDS)])
                fell = sum(b[1] for b in bs)
                vm = float(np.median([b[0] for b in bs])) / 7.5
                ok, why = walkable(vm, fell, SEEDS)
                if not ok:
                    print(f"  {g:6s} {st:5.2f} {T:6.3f} {vm:9.3f}   ★{why}", flush=True)
                    rows.append(dict(歩容=g, 歩幅=st, T=T, 速度=vm, 歩ける=False, 理由=why)); continue
                safe = sum(1 for k in range(12)
                           if sum(pool.map(_stall, [(g, st, T, 3.0 + (k/12)*T, s)
                                                    for s in range(SEEDS)])) == 0)
                print(f"  {g:6s} {st:5.2f} {T:6.3f} {vm:9.3f}   {safe:2d}/12", flush=True)
                rows.append(dict(歩容=g, 歩幅=st, T=T, 速度=vm, 歩ける=True, 安全な位相=safe))
    print()
    print("=== 12/12 を保てる最高速度 ===")
    for g, st in CASES:
        c = [r for r in rows if r["歩容"]==g and r.get("歩幅")==st
             and r["歩ける"] and r.get("安全な位相")==12]
        lab = f"{g} 歩幅{st:.2f}"
        if c:
            b = max(c, key=lambda r: r["速度"])
            print(f"  {lab:16s} → {b['速度']:.3f} m/s(T {b['T']:.3f})")
        else:
            print(f"  {lab:16s} → 12/12 の条件が無い")
    print("  (既知:trot歩幅0.22 で 0.667、pace歩幅0.22 で 0.758。24位相・21試行で検証済み)")
    out = os.path.expanduser("~/robot/out/rt45_pace_stride.json")
    json.dump(dict(_実験="ペースの上限を歩幅も振って探す", _予測="歩幅0.30では12/12が消える(台帳 #31)",
                   _停止=STALL, _試行=SEEDS, _duty=DUTY, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp46_stance_length.py

"""実験46:**ペースが勝つのは、支持の「面積」か「向き」か。**(第1段)



実験43〜45で、**ペースがトロットの上限 0.667 を 0.758 m/s に上げた**(21試行・24位相で検証済み)。
**予測していなかった結果である。**私は「常に3本接地するウォークが勝つ」と予測して外した。

**予測29(測る前に書いた):**

> **効いているのは支持の「面積」ではなく「向き」である。**
> トロットの支持線は**対角**(進行方向に対して斜め)、ペースは**同じ側の前後**(進行方向に平行)。
> **前進の勢いを受け止めるのは前後方向の支持幅なので、進行方向に平行な支持線がいちばん強い。**

**確かめ方(外れる形として先に書いたもの):**
**前後の足間隔(`stance_length`)を広げると、トロットでも 12/12 の上限が上がるはず。**
上がらなければ向きの説明は誤りで、次に見るのは
**「ペースの左右の揺れが、停止時に勢いを横へ逃がしている」**という線。

**この実験では、もう一段鋭い形にする。**
向き説が正しいなら、**同じ `stance_length` を足したとき、
トロットのほうがペースより大きく改善するはず**である
(ペースはすでに前後に長い支持線を持っているので、伸びしろが小さい)。
**両方に同じ量を足して、効き方の差を見る。**

**41節の注意:**大きく張り出すと歩けなくなる(横へ 0.10m で 5/5 転倒)。
**だから控えめに、0.00 / 0.03 / 0.06 / 0.09 m で振る。**

**基準として trot・stance_length=0.00・T=0.120(0.667 m/s・12/12)を必ず含む**(54節の規則)。
**判定は `walkable()` を通す**(56節。速度の下限)。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
STRIDE = 0.22
DUTY = 0.7
GAITS = ["trot", "pace"]
LENGTHS = [0.00, 0.03, 0.06, 0.09]
PERIODS = [0.160, 0.140, 0.120, 0.100]


def _base(a):
    g, sl, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", gait=g, stance_length=sl,
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, sl, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=g,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("支持線を前後に伸ばすと、トロットの上限は上がるか(歩幅0.22・duty0.7)")
    print("  歩容    前後の張り出し[m]  周期T   実測v[m/s]  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for g in GAITS:
            for sl in LENGTHS:
                for T in PERIODS:
                    bs = pool.map(_base, [(g, sl, T, s) for s in range(SEEDS)])
                    fell = sum(b[1] for b in bs)
                    vm = float(np.median([b[0] for b in bs])) / 7.5
                    ok, why = walkable(vm, fell, SEEDS)
                    if not ok:
                        print(f"  {g:6s} {sl:12.2f}    {T:6.3f} {vm:9.3f}   ★{why}", flush=True)
                        rows.append(dict(歩容=g, 張出=sl, T=T, 速度=vm,
                                         歩ける=False, 理由=why)); continue
                    safe = sum(1 for k in range(12)
                               if sum(pool.map(_stall, [(g, sl, T, 3.0 + (k/12)*T, s)
                                                        for s in range(SEEDS)])) == 0)
                    print(f"  {g:6s} {sl:12.2f}    {T:6.3f} {vm:9.3f}   {safe:2d}/12", flush=True)
                    rows.append(dict(歩容=g, 張出=sl, T=T, 速度=vm,
                                     歩ける=True, 安全な位相=safe))
    print()
    print("=== 12/12 を保てる最高速度 ===")
    summary = {}
    for g in GAITS:
        for sl in LENGTHS:
            c = [r for r in rows if r["歩容"]==g and r["張出"]==sl
                 and r["歩ける"] and r.get("安全な位相")==12]
            v = max((r["速度"] for r in c), default=None)
            summary[(g, sl)] = v
            print(f"  {g:6s} 張出{sl:.2f} → " + (f"{v:.3f} m/s" if v else "12/12 の条件が無い"))
    print()
    print("=== 予測29の判定 ===")
    t0v, p0v = summary[("trot", 0.0)], summary[("pace", 0.0)]
    tb = max([v for (g, _), v in summary.items() if g == "trot" and v], default=None)
    pb = max([v for (g, _), v in summary.items() if g == "pace" and v], default=None)
    print(f"  trot: 張出なし {t0v} → 最良 {tb}")
    print(f"  pace: 張出なし {p0v} → 最良 {pb}")
    if t0v and tb:
        print(f"  トロットの改善: {(tb - t0v) / t0v * 100:+.1f}%")
    if p0v and pb:
        print(f"  ペースの改善  : {(pb - p0v) / p0v * 100:+.1f}%")
    print("  向き説が正しければ、**トロットのほうが大きく改善する**はず")
    out = os.path.expanduser("~/robot/out/rt46_stance_length.json")
    json.dump(dict(_実験="支持線を前後に伸ばすと上限は上がるか", _予測="上がる(台帳 #29)",
                   _停止=STALL, _試行=SEEDS, _歩幅=STRIDE, _duty=DUTY,
                   _環境=env0, rows=rows, _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp47_verify_stance.py

"""実験47:**新しい上限 0.827 m/s は本物か。**(第1段の検証)



実験46で、前後の張り出しを入れると 12/12 の上限が 0.667 → 0.827 m/s に上がった。
**公開文書に書く前に、密度を上げて確かめる。**

**今朝すでに 0.758 で `future-draft/` とまとめノートを更新している。**
**同じ場所を書き直すのは、これで4度目になる。**
**だから今回は、書く前に 21試行・24位相で通す。**(実験44 で同じ手順を踏んだ)

**予測33(測る前に書く):**
  **張り出しを入れた条件は、24位相で見ると崩れる。**
  理由:**張り出すと足が滑る**(61節。滑りの比が 2.4〜4.0)。
  **滑っている機体は、接地の状態が位相ごとに大きく揺れるはずで、
  12刻みでは拾えない穴が open している可能性が高い。**

**外れる形:**24/24 のまま崩れなければ外れ。
そのときは**滑りは止まりやすさを損なっていない**ことになり、
次に見るのは「**滑りはむしろ勢いを逃がす経路になっているのではないか**」という線。
(それは 62節の残り候補1「左右の揺れが勢いを横へ逃がす」と同じ形である。)

**基準として trot 張出0.00・T=0.120(0.667・24/24 で確認済み)を必ず含む。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 21
STALL = 2.0
STRIDE = 0.22
DUTY = 0.7
NPH = 24
# (歩容, 張出, 周期)
CASES = [("trot", 0.00, 0.120),   # 基準(54節)
         ("pace", 0.00, 0.100),   # 今朝の上限(検証済み 24/24・0.758)
         ("trot", 0.06, 0.100),   # 0.803
         ("pace", 0.06, 0.100),   # 0.805
         ("pace", 0.09, 0.120),   # 0.827 ← 新しい上限の候補
         ("pace", 0.09, 0.100)]   # 0.866 だが 11/12 だった


def _base(a):
    g, sl, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", gait=g, stance_length=sl,
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, sl, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=g,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"新しい上限は本物か(試行{SEEDS}・位相{NPH}刻み・歩幅{STRIDE}・duty{DUTY})")
    print("  歩容   張出   周期T   実測v[m/s]   安全な位相    危険だった位相")
    rows = []
    with Pool(2) as pool:
        for g, sl, T in CASES:
            bs = pool.map(_base, [(g, sl, T, s) for s in range(SEEDS)])
            fell = sum(b[1] for b in bs)
            vm = float(np.median([b[0] for b in bs])) / 7.5
            ok, why = walkable(vm, fell, SEEDS)
            if not ok:
                print(f"  {g:5s} {sl:5.2f} {T:6.3f} {vm:9.3f}   ★{why}", flush=True)
                rows.append(dict(歩容=g, 張出=sl, T=T, 速度=vm, 歩ける=False, 理由=why)); continue
            bad, safe = [], 0
            for k in range(NPH):
                at = 3.0 + (k / NPH) * T
                nf = sum(pool.map(_stall, [(g, sl, T, at, s) for s in range(SEEDS)]))
                if nf == 0:
                    safe += 1
                else:
                    bad.append((round(k / NPH, 3), nf))
            btxt = " ".join(f"{p}:{n}/{SEEDS}" for p, n in bad[:5]) or "なし"
            print(f"  {g:5s} {sl:5.2f} {T:6.3f} {vm:9.3f}   {safe:2d}/{NPH}      {btxt}", flush=True)
            rows.append(dict(歩容=g, 張出=sl, T=T, 速度=vm, 歩ける=True,
                             安全な位相=safe, 位相数=NPH, 危険な位相=bad))
    out = os.path.expanduser("~/robot/out/rt47_verify_stance.json")
    json.dump(dict(_実験="新しい上限は本物か", _予測="崩れる(台帳 #33)", _試行=SEEDS,
                   _位相数=NPH, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp48_duty_stance.py

"""実験48:**張り出しは、duty を下げる余裕を買い戻せるか。**(第2段)



**34節の実測(歩幅0.22):**

| duty | 速度 | 止まってよい位相 |
|---|---|---|
| 0.50 | **0.995** | **6/12** |
| 0.60 | 0.785 | 8/12 |
| 0.70 | 0.541 | **12/12** |

**duty を下げると速くなるが、12/12 を失う。**
そして 35節:**歩幅0.22・duty0.70 は、期待の 0.74倍しか進んでいない=既に滑っている。**
**今日の全実験は、その「壊れた歩容」の上で最適化していた。**

**第1段(実験46)で、前後の張り出しが 12/12 の上限を +20% 上げた。**
**その余裕で、duty を下げられないか。**
duty 0.5 なら 0.995 m/s 出る。**そこで 12/12 が保てるなら、上限は一気に 1.0 m/s 近くへ動く。**

**予測34(測る前に書いた):**duty を 0.5〜0.6 に下げれば滑りが消え、上限速度は上がる。
**外れる形:**上がらなければ外れ。そのときは**滑りは損失ではなく、
勢いを逃がす経路として働いている**という線(62節の残り候補)が強くなる。

**基準として pace・張出0.00・duty0.70・T=0.100(0.758、24位相で検証済み)を含む**(54節)。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
STRIDE = 0.22
GAIT = "pace"
DUTIES = [0.50, 0.60, 0.70]
LENGTHS = [0.00, 0.06, 0.09]
PERIODS = [0.140, 0.120, 0.100]


def _base(a):
    du, sl, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=du, sweep_mode="proportional", gait=GAIT, stance_length=sl,
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    du, sl, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=du, sweep_mode="proportional", gait=GAIT,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"張り出しは duty を下げる余裕を買い戻せるか({GAIT}・歩幅{STRIDE})")
    print("  duty  張出   周期T   実測v[m/s]  期待v   実測/期待  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for du in DUTIES:
            for sl in LENGTHS:
                for T in PERIODS:
                    bs = pool.map(_base, [(du, sl, T, s) for s in range(SEEDS)])
                    fell = sum(b[1] for b in bs)
                    vm = float(np.median([b[0] for b in bs])) / 7.5
                    # **滑りの目安**(35節と同じ考え方):掃きが比例なら v ≒ stride*duty/T
                    exp_v = STRIDE * du / T
                    ratio = vm / exp_v if exp_v else float("nan")
                    ok, why = walkable(vm, fell, SEEDS)
                    if not ok:
                        print(f"  {du:4.2f} {sl:5.2f} {T:6.3f} {vm:9.3f} {exp_v:7.3f} {ratio:9.2f}"
                              f"   ★{why}", flush=True)
                        rows.append(dict(duty=du, 張出=sl, T=T, 速度=vm, 期待=exp_v,
                                         比=ratio, 歩ける=False, 理由=why)); continue
                    safe = sum(1 for k in range(12)
                               if sum(pool.map(_stall, [(du, sl, T, 3.0 + (k/12)*T, s)
                                                        for s in range(SEEDS)])) == 0)
                    print(f"  {du:4.2f} {sl:5.2f} {T:6.3f} {vm:9.3f} {exp_v:7.3f} {ratio:9.2f}"
                          f"   {safe:2d}/12", flush=True)
                    rows.append(dict(duty=du, 張出=sl, T=T, 速度=vm, 期待=exp_v, 比=ratio,
                                     歩ける=True, 安全な位相=safe))
    print()
    print("=== 12/12 を保てる最高速度 ===")
    for du in DUTIES:
        for sl in LENGTHS:
            c = [r for r in rows if r["duty"]==du and r["張出"]==sl
                 and r["歩ける"] and r.get("安全な位相")==12]
            v = max((r["速度"] for r in c), default=None)
            print(f"  duty{du:.2f} 張出{sl:.2f} → " + (f"{v:.3f} m/s" if v else "12/12 が無い"))
    print("  (既知:pace 張出0.00 duty0.70 で 0.758、張出0.09 で 0.827)")
    out = os.path.expanduser("~/robot/out/rt48_duty_stance.json")
    json.dump(dict(_実験="張り出しは duty を下げる余裕を買い戻せるか",
                   _予測="上がる(台帳 #34)", _停止=STALL, _試行=SEEDS, _歩幅=STRIDE,
                   _歩容=GAIT, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

# ---------------------------------------------------------------------------
# **このファイルを一度壊した。**
#
# 実験49 を作るときに、**このファイルを sed -i で書き換えてから複製した。**
# `git checkout` で戻すつもりだったが、**このファイルは git の管理外(untracked)だった**ので
# 何も戻らず、**実験48 のスクリプトが実験49 の設定のまま残った。**
# 結果の JSON とログは無事だったが、**それを生んだ手順の記録が失われた。**
#
# **手で復元した**(DUTIES / LENGTHS / 出力先の3箇所)。**復元できたのは、
# 直前のログに全条件が印字されていたからである。**印字していなければ戻せなかった。
#
# > **複製するときは、先に複製してから複製のほうを編集する。**
# > **元のファイルを編集してから複製しない。**
# > **そして「git で戻せる」と思う前に、追跡されているかを確かめる。**

exp49_duty_high.py

"""実験48:**張り出しは、duty を下げる余裕を買い戻せるか。**(第2段)



**34節の実測(歩幅0.22):**

| duty | 速度 | 止まってよい位相 |
|---|---|---|
| 0.50 | **0.995** | **6/12** |
| 0.60 | 0.785 | 8/12 |
| 0.70 | 0.541 | **12/12** |

**duty を下げると速くなるが、12/12 を失う。**
そして 35節:**歩幅0.22・duty0.70 は、期待の 0.74倍しか進んでいない=既に滑っている。**
**今日の全実験は、その「壊れた歩容」の上で最適化していた。**

**第1段(実験46)で、前後の張り出しが 12/12 の上限を +20% 上げた。**
**その余裕で、duty を下げられないか。**
duty 0.5 なら 0.995 m/s 出る。**そこで 12/12 が保てるなら、上限は一気に 1.0 m/s 近くへ動く。**

**予測34(測る前に書いた):**duty を 0.5〜0.6 に下げれば滑りが消え、上限速度は上がる。
**外れる形:**上がらなければ外れ。そのときは**滑りは損失ではなく、
勢いを逃がす経路として働いている**という線(62節の残り候補)が強くなる。

**基準として pace・張出0.00・duty0.70・T=0.100(0.758、24位相で検証済み)を含む**(54節)。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
STRIDE = 0.22
GAIT = "pace"
DUTIES = [0.70, 0.75, 0.80, 0.85]
LENGTHS = [0.09]
PERIODS = [0.140, 0.120, 0.100]


def _base(a):
    du, sl, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=du, sweep_mode="proportional", gait=GAIT, stance_length=sl,
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    du, sl, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=du, sweep_mode="proportional", gait=GAIT,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"張り出しは duty を下げる余裕を買い戻せるか({GAIT}・歩幅{STRIDE})")
    print("  duty  張出   周期T   実測v[m/s]  期待v   実測/期待  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for du in DUTIES:
            for sl in LENGTHS:
                for T in PERIODS:
                    bs = pool.map(_base, [(du, sl, T, s) for s in range(SEEDS)])
                    fell = sum(b[1] for b in bs)
                    vm = float(np.median([b[0] for b in bs])) / 7.5
                    # **滑りの目安**(35節と同じ考え方):掃きが比例なら v ≒ stride*duty/T
                    exp_v = STRIDE * du / T
                    ratio = vm / exp_v if exp_v else float("nan")
                    ok, why = walkable(vm, fell, SEEDS)
                    if not ok:
                        print(f"  {du:4.2f} {sl:5.2f} {T:6.3f} {vm:9.3f} {exp_v:7.3f} {ratio:9.2f}"
                              f"   ★{why}", flush=True)
                        rows.append(dict(duty=du, 張出=sl, T=T, 速度=vm, 期待=exp_v,
                                         比=ratio, 歩ける=False, 理由=why)); continue
                    safe = sum(1 for k in range(12)
                               if sum(pool.map(_stall, [(du, sl, T, 3.0 + (k/12)*T, s)
                                                        for s in range(SEEDS)])) == 0)
                    print(f"  {du:4.2f} {sl:5.2f} {T:6.3f} {vm:9.3f} {exp_v:7.3f} {ratio:9.2f}"
                          f"   {safe:2d}/12", flush=True)
                    rows.append(dict(duty=du, 張出=sl, T=T, 速度=vm, 期待=exp_v, 比=ratio,
                                     歩ける=True, 安全な位相=safe))
    print()
    print("=== 12/12 を保てる最高速度 ===")
    for du in DUTIES:
        for sl in LENGTHS:
            c = [r for r in rows if r["duty"]==du and r["張出"]==sl
                 and r["歩ける"] and r.get("安全な位相")==12]
            v = max((r["速度"] for r in c), default=None)
            print(f"  duty{du:.2f} 張出{sl:.2f} → " + (f"{v:.3f} m/s" if v else "12/12 が無い"))
    print("  (既知:pace 張出0.00 duty0.70 で 0.758、張出0.09 で 0.827)")
    out = os.path.expanduser("~/robot/out/rt49_duty_high.json")
    json.dump(dict(_実験="張り出しは duty を下げる余裕を買い戻せるか",
                   _予測="上がる(台帳 #34)", _停止=STALL, _試行=SEEDS, _歩幅=STRIDE,
                   _歩容=GAIT, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp4_outer_delay.py

"""実験4:外側ループ(頭脳側)の遅れは、内側 PD(反射側)の遅れよりどれだけ許せるか。

実験2で得た規則——**遅れが害になるのはフィードバックを一周するときだけ**——は、
そこにフィードバックが無い場合の話だった。ここでは**本物の外側フィードバック**
(姿勢を読んで進路を直す)を入れて、同じ問いを立てる。

**予測:外側ループが許せる遅れは、内側 PD の 6ms よりずっと大きい。**
理由は、そのループが応答している帯域が低いから。外れたら予測が誤りである。

内側は `pd_where="joint"`(モータ側 PD、遅れ無し)、制御 200Hz に固定。
外側は 実験3 で 25試行 0/25 を確認した二組を使う。目標は「まっすぐ保つ」。
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtsweep import sweep, save

MS = [0, 5, 10, 20, 40, 60, 100, 150, 200, 300, 500, 800]
CAND = [("速い", dict(outer_hz=20, yaw_kp=0.1, yaw_ki=0.8)),
        ("堅実", dict(outer_hz=20, yaw_kp=0.2, yaw_ki=0.1)),
        ("精密", dict(outer_hz=50, yaw_kp=0.1, yaw_ki=0.8))]
kws = []
for nm, cfg in CAND:
    for ms in MS:
        kws.append({"pd_where": "joint", "ctrl_hz": 200, "yaw_rate": 0.0,
                    **cfg, "outer_delay": ms / 1000.0})
res = sweep("unitree_go2", kws, title="外側ループの遅れ(まっすぐ保つ)")
gov = res["_環境(開始)"]["governor"][0]
save(res, f"~/robot/out/rt4_outerdelay_{gov}.json")

exp50_stance_far.py

"""実験48:**張り出しは、duty を下げる余裕を買い戻せるか。**(第2段)



**34節の実測(歩幅0.22):**

| duty | 速度 | 止まってよい位相 |
|---|---|---|
| 0.50 | **0.995** | **6/12** |
| 0.60 | 0.785 | 8/12 |
| 0.70 | 0.541 | **12/12** |

**duty を下げると速くなるが、12/12 を失う。**
そして 35節:**歩幅0.22・duty0.70 は、期待の 0.74倍しか進んでいない=既に滑っている。**
**今日の全実験は、その「壊れた歩容」の上で最適化していた。**

**第1段(実験46)で、前後の張り出しが 12/12 の上限を +20% 上げた。**
**その余裕で、duty を下げられないか。**
duty 0.5 なら 0.995 m/s 出る。**そこで 12/12 が保てるなら、上限は一気に 1.0 m/s 近くへ動く。**

**予測34(測る前に書いた):**duty を 0.5〜0.6 に下げれば滑りが消え、上限速度は上がる。
**外れる形:**上がらなければ外れ。そのときは**滑りは損失ではなく、
勢いを逃がす経路として働いている**という線(62節の残り候補)が強くなる。

**基準として pace・張出0.00・duty0.70・T=0.100(0.758、24位相で検証済み)を含む**(54節)。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
STRIDE = 0.22
GAIT = "pace"
DUTIES = [0.70]
LENGTHS = [0.09, 0.12, 0.15, 0.18]
PERIODS = [0.140, 0.120, 0.100]


def _base(a):
    du, sl, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=du, sweep_mode="proportional", gait=GAIT, stance_length=sl,
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    du, sl, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=du, sweep_mode="proportional", gait=GAIT,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"張り出しは duty を下げる余裕を買い戻せるか({GAIT}・歩幅{STRIDE})")
    print("  duty  張出   周期T   実測v[m/s]  期待v   実測/期待  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for du in DUTIES:
            for sl in LENGTHS:
                for T in PERIODS:
                    bs = pool.map(_base, [(du, sl, T, s) for s in range(SEEDS)])
                    fell = sum(b[1] for b in bs)
                    vm = float(np.median([b[0] for b in bs])) / 7.5
                    # **滑りの目安**(35節と同じ考え方):掃きが比例なら v ≒ stride*duty/T
                    exp_v = STRIDE * du / T
                    ratio = vm / exp_v if exp_v else float("nan")
                    ok, why = walkable(vm, fell, SEEDS)
                    if not ok:
                        print(f"  {du:4.2f} {sl:5.2f} {T:6.3f} {vm:9.3f} {exp_v:7.3f} {ratio:9.2f}"
                              f"   ★{why}", flush=True)
                        rows.append(dict(duty=du, 張出=sl, T=T, 速度=vm, 期待=exp_v,
                                         比=ratio, 歩ける=False, 理由=why)); continue
                    safe = sum(1 for k in range(12)
                               if sum(pool.map(_stall, [(du, sl, T, 3.0 + (k/12)*T, s)
                                                        for s in range(SEEDS)])) == 0)
                    print(f"  {du:4.2f} {sl:5.2f} {T:6.3f} {vm:9.3f} {exp_v:7.3f} {ratio:9.2f}"
                          f"   {safe:2d}/12", flush=True)
                    rows.append(dict(duty=du, 張出=sl, T=T, 速度=vm, 期待=exp_v, 比=ratio,
                                     歩ける=True, 安全な位相=safe))
    print()
    print("=== 12/12 を保てる最高速度 ===")
    for du in DUTIES:
        for sl in LENGTHS:
            c = [r for r in rows if r["duty"]==du and r["張出"]==sl
                 and r["歩ける"] and r.get("安全な位相")==12]
            v = max((r["速度"] for r in c), default=None)
            print(f"  duty{du:.2f} 張出{sl:.2f} → " + (f"{v:.3f} m/s" if v else "12/12 が無い"))
    print("  (既知:pace 張出0.00 duty0.70 で 0.758、張出0.09 で 0.827)")
    out = os.path.expanduser("~/robot/out/rt50_stance_far.json")
    json.dump(dict(_実験="張り出しは duty を下げる余裕を買い戻せるか",
                   _予測="上がる(台帳 #34)", _停止=STALL, _試行=SEEDS, _歩幅=STRIDE,
                   _歩容=GAIT, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp52_stance_limit.py

"""実験48:**張り出しは、duty を下げる余裕を買い戻せるか。**(第2段)



**34節の実測(歩幅0.22):**

| duty | 速度 | 止まってよい位相 |
|---|---|---|
| 0.50 | **0.995** | **6/12** |
| 0.60 | 0.785 | 8/12 |
| 0.70 | 0.541 | **12/12** |

**duty を下げると速くなるが、12/12 を失う。**
そして 35節:**歩幅0.22・duty0.70 は、期待の 0.74倍しか進んでいない=既に滑っている。**
**今日の全実験は、その「壊れた歩容」の上で最適化していた。**

**第1段(実験46)で、前後の張り出しが 12/12 の上限を +20% 上げた。**
**その余裕で、duty を下げられないか。**
duty 0.5 なら 0.995 m/s 出る。**そこで 12/12 が保てるなら、上限は一気に 1.0 m/s 近くへ動く。**

**予測34(測る前に書いた):**duty を 0.5〜0.6 に下げれば滑りが消え、上限速度は上がる。
**外れる形:**上がらなければ外れ。そのときは**滑りは損失ではなく、
勢いを逃がす経路として働いている**という線(62節の残り候補)が強くなる。

**基準として pace・張出0.00・duty0.70・T=0.100(0.758、24位相で検証済み)を含む**(54節)。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
STALL = 2.0
STRIDE = 0.22
GAIT = "pace"
DUTIES = [0.70]
LENGTHS = [0.18, 0.24, 0.30, 0.36]
PERIODS = [0.140, 0.120, 0.100]


def _base(a):
    du, sl, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=du, sweep_mode="proportional", gait=GAIT, stance_length=sl,
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    du, sl, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=du, sweep_mode="proportional", gait=GAIT,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"張り出しは duty を下げる余裕を買い戻せるか({GAIT}・歩幅{STRIDE})")
    print("  duty  張出   周期T   実測v[m/s]  期待v   実測/期待  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for du in DUTIES:
            for sl in LENGTHS:
                for T in PERIODS:
                    bs = pool.map(_base, [(du, sl, T, s) for s in range(SEEDS)])
                    fell = sum(b[1] for b in bs)
                    vm = float(np.median([b[0] for b in bs])) / 7.5
                    # **滑りの目安**(35節と同じ考え方):掃きが比例なら v ≒ stride*duty/T
                    exp_v = STRIDE * du / T
                    ratio = vm / exp_v if exp_v else float("nan")
                    ok, why = walkable(vm, fell, SEEDS)
                    if not ok:
                        print(f"  {du:4.2f} {sl:5.2f} {T:6.3f} {vm:9.3f} {exp_v:7.3f} {ratio:9.2f}"
                              f"   ★{why}", flush=True)
                        rows.append(dict(duty=du, 張出=sl, T=T, 速度=vm, 期待=exp_v,
                                         比=ratio, 歩ける=False, 理由=why)); continue
                    safe = sum(1 for k in range(12)
                               if sum(pool.map(_stall, [(du, sl, T, 3.0 + (k/12)*T, s)
                                                        for s in range(SEEDS)])) == 0)
                    print(f"  {du:4.2f} {sl:5.2f} {T:6.3f} {vm:9.3f} {exp_v:7.3f} {ratio:9.2f}"
                          f"   {safe:2d}/12", flush=True)
                    rows.append(dict(duty=du, 張出=sl, T=T, 速度=vm, 期待=exp_v, 比=ratio,
                                     歩ける=True, 安全な位相=safe))
    print()
    print("=== 12/12 を保てる最高速度 ===")
    for du in DUTIES:
        for sl in LENGTHS:
            c = [r for r in rows if r["duty"]==du and r["張出"]==sl
                 and r["歩ける"] and r.get("安全な位相")==12]
            v = max((r["速度"] for r in c), default=None)
            print(f"  duty{du:.2f} 張出{sl:.2f} → " + (f"{v:.3f} m/s" if v else "12/12 が無い"))
    print("  (既知:pace 張出0.00 duty0.70 で 0.758、張出0.09 で 0.827)")
    out = os.path.expanduser("~/robot/out/rt52_stance_limit.json")
    json.dump(dict(_実験="張り出しは duty を下げる余裕を買い戻せるか",
                   _予測="上がる(台帳 #34)", _停止=STALL, _試行=SEEDS, _歩幅=STRIDE,
                   _歩容=GAIT, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp53_verify_wide.py

"""実験47:**新しい上限 0.827 m/s は本物か。**(第1段の検証)



実験46で、前後の張り出しを入れると 12/12 の上限が 0.667 → 0.827 m/s に上がった。
**公開文書に書く前に、密度を上げて確かめる。**

**今朝すでに 0.758 で `future-draft/` とまとめノートを更新している。**
**同じ場所を書き直すのは、これで4度目になる。**
**だから今回は、書く前に 21試行・24位相で通す。**(実験44 で同じ手順を踏んだ)

**予測33(測る前に書く):**
  **張り出しを入れた条件は、24位相で見ると崩れる。**
  理由:**張り出すと足が滑る**(61節。滑りの比が 2.4〜4.0)。
  **滑っている機体は、接地の状態が位相ごとに大きく揺れるはずで、
  12刻みでは拾えない穴が open している可能性が高い。**

**外れる形:**24/24 のまま崩れなければ外れ。
そのときは**滑りは止まりやすさを損なっていない**ことになり、
次に見るのは「**滑りはむしろ勢いを逃がす経路になっているのではないか**」という線。
(それは 62節の残り候補1「左右の揺れが勢いを横へ逃がす」と同じ形である。)

**基準として trot 張出0.00・T=0.120(0.667・24/24 で確認済み)を必ず含む。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 21
STALL = 2.0
STRIDE = 0.22
DUTY = 0.7
NPH = 24
# (歩容, 張出, 周期)
CASES = [("trot", 0.00, 0.120),   # 基準(54節。0.667・24/24 で確認済み)
         ("pace", 0.00, 0.100),   # 朝の上限(0.758・24/24 で確認済み)
         ("pace", 0.18, 0.120),   # 1.112
         ("pace", 0.24, 0.140),   # 1.173
         ("pace", 0.24, 0.120),   # 1.274 ← 新しい上限の候補
         ("pace", 0.24, 0.100)]   # 1.185


def _base(a):
    g, sl, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", gait=g, stance_length=sl,
                seed=s, init_noise=NOISE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, sl, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=g,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"新しい上限は本物か(試行{SEEDS}・位相{NPH}刻み・歩幅{STRIDE}・duty{DUTY})")
    print("  歩容   張出   周期T   実測v[m/s]   安全な位相    危険だった位相")
    rows = []
    with Pool(2) as pool:
        for g, sl, T in CASES:
            bs = pool.map(_base, [(g, sl, T, s) for s in range(SEEDS)])
            fell = sum(b[1] for b in bs)
            vm = float(np.median([b[0] for b in bs])) / 7.5
            ok, why = walkable(vm, fell, SEEDS)
            if not ok:
                print(f"  {g:5s} {sl:5.2f} {T:6.3f} {vm:9.3f}   ★{why}", flush=True)
                rows.append(dict(歩容=g, 張出=sl, T=T, 速度=vm, 歩ける=False, 理由=why)); continue
            bad, safe = [], 0
            for k in range(NPH):
                at = 3.0 + (k / NPH) * T
                nf = sum(pool.map(_stall, [(g, sl, T, at, s) for s in range(SEEDS)]))
                if nf == 0:
                    safe += 1
                else:
                    bad.append((round(k / NPH, 3), nf))
            btxt = " ".join(f"{p}:{n}/{SEEDS}" for p, n in bad[:5]) or "なし"
            print(f"  {g:5s} {sl:5.2f} {T:6.3f} {vm:9.3f}   {safe:2d}/{NPH}      {btxt}", flush=True)
            rows.append(dict(歩容=g, 張出=sl, T=T, 速度=vm, 歩ける=True,
                             安全な位相=safe, 位相数=NPH, 危険な位相=bad))
    out = os.path.expanduser("~/robot/out/rt53_verify_wide.json")
    json.dump(dict(_実験="新しい上限は本物か", _予測="崩れる(台帳 #33)", _試行=SEEDS,
                   _位相数=NPH, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp54_rough.py

"""実験54:**今日の結果は、地面が荒れても生き残るか。**(第7段を前倒し)



**今日、平地で 12/12 の上限を 0.667 → 1.274 m/s まで動かした**(+91%)。
**やったのは滑りを削ることで、支持を前後に広げて実現している。**

**26節:平地で測った安全な窓を不整地へ持ち込むと 0/20 だった。**
**平地の結果は、平地の話でしかない。**

**公表した直後に、いちばん壊れそうな所を叩く。**

**予測38(測る前に書いた):**
  **新しい設定(張出0.24)は、不整地では崩れる。**
  **張り出しが大きいほど足先は胴から遠く、地面の高さの差に敏感になるはず。**

**外れる形:**不整地で張出0.24 が張出0.00 と同等以上を保てば外れ。
そのときは**張り出しは地面の乱れにも強い**ことになり、次に見るのは
**「前後に広い支持は、縦の乱れに対しても余裕を作る」**という線。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from terrain import field_xml
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 10          # 地形10通り
STALL = 2.0
STRIDE, DUTY, T = 0.22, 0.70, 0.120
HEIGHTS = [0.0, 0.012, 0.022]      # 平地/軽い凹凸/26節と同じ
LENGTHS = [0.00, 0.09, 0.18, 0.24]
GAIT = "pace"
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=GAIT)


def _base(a):
    sl, h, s = a
    xml = "" if h <= 0 else field_xml(h=h, seed=s)
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, stance_length=sl,
                terrain=xml, seed=s, init_noise=NOISE, **BASE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    sl, h, at, s = a
    xml = "" if h <= 0 else field_xml(h=h, seed=s)
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                stance_length=sl, terrain=xml, stall_at=at, stall_dur=STALL,
                seed=s, init_noise=NOISE, **BASE)
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"今日の結果は地面が荒れても生き残るか({GAIT}・歩幅{STRIDE}・duty{DUTY}・T={T})")
    print("  凹凸[m]  張出   歩けた   実測v[m/s]  止まってよい位相(12刻み・10地形)")
    rows = []
    with Pool(2) as pool:
        for h in HEIGHTS:
            for sl in LENGTHS:
                bs = pool.map(_base, [(sl, h, s) for s in range(SEEDS)])
                fell = sum(b[1] for b in bs)
                vm = float(np.median([b[0] for b in bs])) / 7.5
                ok, why = walkable(vm, fell, SEEDS)
                if not ok:
                    print(f"  {h:7.3f} {sl:5.2f}   {SEEDS-fell:2d}/{SEEDS} {vm:10.3f}   ★{why}",
                          flush=True)
                    rows.append(dict(凹凸=h, 張出=sl, 歩けた=SEEDS-fell, 速度=vm,
                                     歩ける=False, 理由=why)); continue
                safe = sum(1 for k in range(12)
                           if sum(pool.map(_stall, [(sl, h, 3.0 + (k/12)*T, s)
                                                    for s in range(SEEDS)])) == 0)
                print(f"  {h:7.3f} {sl:5.2f}   {SEEDS-fell:2d}/{SEEDS} {vm:10.3f}   {safe:2d}/12",
                      flush=True)
                rows.append(dict(凹凸=h, 張出=sl, 歩けた=SEEDS-fell, 速度=vm,
                                 歩ける=True, 安全な位相=safe))
    out = os.path.expanduser("~/robot/out/rt54_rough.json")
    json.dump(dict(_実験="今日の結果は不整地で生き残るか", _予測="崩れる(台帳 #38)",
                   _地形数=SEEDS, _設定=dict(歩容=GAIT, 歩幅=STRIDE, duty=DUTY, T=T),
                   _環境=env0, rows=rows, _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp55_fail.py

"""実験55:**関節が一つ死んでも、どの瞬間でも止まれるか。**(第4段)



**熱による劣化(実験34〜39)は「使いすぎると落ちる」形だった。**
**こちらは「ある時刻に、ひとつ死ぬ」。**実機では配線が外れ、減速機が割れ、基板が落ちる。
**予兆なく、局所的に、戻らない。理想化されたシミュレーションが省いているのは、まさにこれである。**

**予測39(測る前に書いた):**
  **前後に広い支持は、関節が一つ死んでも「どの瞬間でも止まれる」を保つ。**
  小さく試すと、張出0.00 では前進が −30%・起き上がり 0.999→0.790 に落ちたが、
  **張出0.24 では −2% で起き上がりもほぼ不変だった。**

**外れる形:**張出0.24 でも 12/12 が崩れれば外れ。そのときは
**広い支持は「速く歩けること」は買うが「壊れても止まれること」は買わない**ことになり、
次に見るのは**「死んだ脚の位置で結果が変わるのではないか」**という線。

**三つの壊れ方を分ける。**
  dead  … 力が出ない。脚がぶら下がる(配線が外れる/駆動が抜ける)
  weak  … 力が落ちる(半端に生きている。いちばん厄介な形)
  stuck … その角度で固まる(減速機が噛む)
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 10
STALL = 2.0
STRIDE, DUTY, T = 0.22, 0.70, 0.120
FAIL_AT = 2.0                      # 歩き出してすぐ壊す(止める前に壊れている)
LENGTHS = [0.00, 0.24]
MODES = [None, "dead", "weak", "stuck"]
JOINT = "FR_calf_joint"            # 前右の膝
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait="pace")


def _kw(mode):
    return {} if mode is None else dict(fail_joint=JOINT, fail_at=FAIL_AT, fail_mode=mode)


def _base(a):
    sl, mode, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, stance_length=sl,
                seed=s, init_noise=NOISE, **BASE, **_kw(mode))
    return r["前進距離"], r["転倒"], r["最小の起き上がり"]


def _stall(a):
    sl, mode, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s,
                init_noise=NOISE, **BASE, **_kw(mode))
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"関節が一つ死んでも、どの瞬間でも止まれるか({JOINT} を {FAIL_AT}秒で)")
    print("  張出   壊し方     歩けた   実測v[m/s]  最小の起き上がり  止まってよい位相")
    rows = []
    with Pool(2) as pool:
        for sl in LENGTHS:
            for mode in MODES:
                bs = pool.map(_base, [(sl, mode, s) for s in range(SEEDS)])
                fell = sum(1 for _, f, _ in bs if f)
                vm = float(np.median([b[0] for b in bs])) / 7.5
                up = float(np.min([b[2] for b in bs]))
                lab = "壊さない" if mode is None else mode
                ok, why = walkable(vm, fell, SEEDS)
                if not ok:
                    print(f"  {sl:5.2f} {lab:10s} {SEEDS-fell:2d}/{SEEDS} {vm:10.3f} "
                          f"{up:14.3f}   ★{why}", flush=True)
                    rows.append(dict(張出=sl, 壊し方=lab, 歩けた=SEEDS-fell, 速度=vm,
                                     起き上がり=up, 歩ける=False)); continue
                safe = sum(1 for k in range(12)
                           if sum(pool.map(_stall, [(sl, mode, 3.0 + (k/12)*T, s)
                                                    for s in range(SEEDS)])) == 0)
                print(f"  {sl:5.2f} {lab:10s} {SEEDS-fell:2d}/{SEEDS} {vm:10.3f} "
                      f"{up:14.3f}   {safe:2d}/12", flush=True)
                rows.append(dict(張出=sl, 壊し方=lab, 歩けた=SEEDS-fell, 速度=vm,
                                 起き上がり=up, 歩ける=True, 安全な位相=safe))
    out = os.path.expanduser("~/robot/out/rt55_fail.json")
    json.dump(dict(_実験="関節が一つ死んでも止まれるか", _予測="広い支持は保つ(台帳 #39)",
                   _関節=JOINT, _故障時刻=FAIL_AT, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp56_verify_fail.py

"""実験47:**新しい上限 0.827 m/s は本物か。**(第1段の検証)



実験46で、前後の張り出しを入れると 12/12 の上限が 0.667 → 0.827 m/s に上がった。
**公開文書に書く前に、密度を上げて確かめる。**

**今朝すでに 0.758 で `future-draft/` とまとめノートを更新している。**
**同じ場所を書き直すのは、これで4度目になる。**
**だから今回は、書く前に 21試行・24位相で通す。**(実験44 で同じ手順を踏んだ)

**予測33(測る前に書く):**
  **張り出しを入れた条件は、24位相で見ると崩れる。**
  理由:**張り出すと足が滑る**(61節。滑りの比が 2.4〜4.0)。
  **滑っている機体は、接地の状態が位相ごとに大きく揺れるはずで、
  12刻みでは拾えない穴が open している可能性が高い。**

**外れる形:**24/24 のまま崩れなければ外れ。
そのときは**滑りは止まりやすさを損なっていない**ことになり、
次に見るのは「**滑りはむしろ勢いを逃がす経路になっているのではないか**」という線。
(それは 62節の残り候補1「左右の揺れが勢いを横へ逃がす」と同じ形である。)

**基準として trot 張出0.00・T=0.120(0.667・24/24 で確認済み)を必ず含む。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 21
STALL = 2.0
STRIDE = 0.22
DUTY = 0.7
NPH = 24
# (歩容, 張出, 周期)
# **故障を入れて確かめる。**(第4段の検証。64節の規則:12刻みは篩であって判定ではない)
CASES = [("trot", 0.00, 0.120), ("pace", 0.24, 0.120)]
FAILS = [None, "dead", "weak", "stuck"]


def _kwf(mode):
    return {} if mode is None else dict(fail_joint="FR_calf_joint", fail_at=2.0, fail_mode=mode)


def _base(a):
    g, sl, T, mode, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint",
                duty=DUTY, sweep_mode="proportional", gait=g, stance_length=sl,
                seed=s, init_noise=NOISE, **_kwf(mode))
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, sl, T, mode, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
                pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=g,
                stance_length=sl, stall_at=at, stall_dur=STALL, seed=s,
                init_noise=NOISE, **_kwf(mode))
    return r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"新しい上限は本物か(試行{SEEDS}・位相{NPH}刻み・歩幅{STRIDE}・duty{DUTY})")
    print("  歩容   張出   壊し方   実測v[m/s]   安全な位相    危険だった位相")
    rows = []
    with Pool(2) as pool:
        for g, sl, T in CASES:
          for mode in FAILS:
            bs = pool.map(_base, [(g, sl, T, mode, s) for s in range(SEEDS)])
            fell = sum(b[1] for b in bs)
            vm = float(np.median([b[0] for b in bs])) / 7.5
            ok, why = walkable(vm, fell, SEEDS)
            if not ok:
                print(f"  {g:5s} {sl:5.2f} {(mode or '健全'):6s} {vm:9.3f}   ★{why}", flush=True)
                rows.append(dict(歩容=g, 張出=sl, T=T, 壊し方=mode, 速度=vm, 歩ける=False, 理由=why)); continue
            bad, safe = [], 0
            for k in range(NPH):
                at = 3.0 + (k / NPH) * T
                nf = sum(pool.map(_stall, [(g, sl, T, mode, at, s) for s in range(SEEDS)]))
                if nf == 0:
                    safe += 1
                else:
                    bad.append((round(k / NPH, 3), nf))
            btxt = " ".join(f"{p}:{n}/{SEEDS}" for p, n in bad[:5]) or "なし"
            print(f"  {g:5s} {sl:5.2f} {(mode or '健全'):6s} {vm:9.3f}   {safe:2d}/{NPH}      {btxt}", flush=True)
            rows.append(dict(歩容=g, 張出=sl, T=T, 壊し方=mode, 速度=vm, 歩ける=True,
                             安全な位相=safe, 位相数=NPH, 危険な位相=bad))
    out = os.path.expanduser("~/robot/out/rt56_verify_fail.json")
    json.dump(dict(_実験="新しい上限は本物か", _予測="崩れる(台帳 #33)", _試行=SEEDS,
                   _位相数=NPH, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

# ---------------------------------------------------------------------------
# **訂正。**
# 最初の実行では、`_base` に故障が渡っていなかった(置換が改行の違いで当たっていなかった)。
# **速度の列が、壊し方によらず全く同じ値になったことで気づいた**
# (0.667 と 1.274 が4行ずつ並んだ。実験55 では速度が変わっていたのに)。
#
# **止まってよい位相のほうは `_stall` 側なので、正しく壊れた状態で測れていた。**
# **結論(張出0.24 は故障しても 24/24、張出0.00 は 17/24)は変わらない。**
# **速度の列だけが「壊していない機体」の値だった。**
#
# > **同じ値が並んだら疑う。**今日、除数の誤りも「比がきっかり 0.9375」で見つけた。
# > **揃いすぎている数字は、測れていない印であることが多い。**

exp57_self_judge_rough.py

"""実験51:**機体は、自分のセンサだけで「いま止まってよいか」を知れるか。**(第3段)



**ここまでの50本の実験は、全部わたしが外から止めていた。**
位相を指定し、シミュレータの真の状態を見て、転んだかどうかを数えていた。
**機体は、自分がいつ止まるかを知らないし、選んでもいない。**

**実機はそうではない。**止まる時刻を選ぶのは機体で、
そのとき機体が持っているのは**汚れたセンサの値だけ**である。

### 26節を分解すると、未知は一つしか残らない(60節)

> **余裕 =(その姿勢が吸収できる速度の上限)−(そのとき出ている速度)**

- **姿勢の容量**は位相の関数。**位相は自分の時計が持っている**
- **残る未知は、実際の速度だけ**

そして**脚で歩く機体は、前進速度を直接測れない。**
脚の運動学と接地から推定するか、加速度を積分するしかなく、**滑りとドリフトで狂う。**
**止まってよいかを決めている当の量が、機体がいちばん測りにくい量である。**

### 比べる四つ

| 方式 | 何を根拠に止まるか | 実機で可能か |
|---|---|---|
| **即時** | 何も見ない。窓が開いたら止まる | 可能(選んでいない) |
| **時計** | 事前に測った安全な窓に自分の位相が入ったら止まる | **可能。ただし事前測定が要る** |
| **センサ(清)** | センサの値で判断。**雑音もドリフトも無し** | 不可能(比較の上限) |
| **センサ(実)** | 同じ判断を、**雑音とドリフトのある値**でやる | **可能。これが実機の姿** |

**「即時」と「時計」の差が、いつ止まるかを選ぶことの利。**
**「センサ(清)」と「センサ(実)」の差が、観測の特権を失う代償。**

**予測32(測る前に書いた):**速度推定のドリフトで、機体の判断は壊れる。
**外れる形:**現実的なドリフトを入れても真の安全位相と9割以上一致すれば外れ。
そのときは**姿勢だけで判定できる**ことになり、**姿勢は IMU で直接読めるので実機で成立する。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
# **止まれる位相が少ない設定を選ぶ。**(45節:pace 歩幅0.38 T=0.100 で 5/12)
# 安全な位相が多い設定では、どう選んでも転ばないので、判断の良し悪しが出ない。
# **不整地で回す。**(設計変更)
# 当初は「止まれる位相が少ない設定」を平地で選ぶつもりだった。
# **今日の結果で、平地では張出0.24 が 24/24 になり、問いが消えた。**
# **危険な位相が戻ってくるのは不整地である**(実験54:10/12)。
# そして26節のとおり**平地で測った窓は不整地では使えない**ので、
# **時計方式が根拠を失う。ここでこそセンサを見る意味が出る。**
# **設計変更(二度目)。**
# 最初は不整地の張出0.24 で回そうとした。**走らせたら、20/24 が安全で、
# 即時に止めても転倒率 2.0% だった。選ばなくてもほとんど転ばない=判断の差が出ない。**
#
# **今日いちばん危険な機体は、すでに測ってある**——実験56:
# **膝が死んだ狭い機体は 17/24(7つの位相で 21/21 全滅)。**
# **第3段(観測)・第4段(故障)・第7段(地面)を組にして回す。**
# 朝の見立て「第3段は単独では意味を持たない」が、実測でそのとおりになった。
GAIT, STRIDE, DUTY, T, SL = "pace", 0.22, 0.70, 0.120, 0.00
BUMP = 0.022                      # 26節・実験54 と同じ凹凸
FAIL = dict(fail_joint="FR_calf_joint", fail_at=1.5, fail_mode="dead")
STALL = 2.0
NPH = 24
SEEDS = 10
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from terrain import field_xml
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=GAIT,
            stance_length=SL, init_noise=NOISE)


def _probe(a):
    """位相を指定して止め、転ぶかどうかを見る(真の安全集合を作るため)。"""
    k, s = a
    at = 3.0 + (k / NPH) * T
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                terrain=field_xml(h=BUMP, seed=s), **FAIL,
                stall_at=at, stall_dur=STALL, seed=s, **BASE)
    return r["転倒"]


def _trial(a):
    """窓を与えて、機体に選ばせる。"""
    kind, w0, s = a
    kw = dict(BASE)
    judge = None
    if kind == "即時":
        judge = lambda sd: True
    elif kind == "時計":
        # **【訂正】ここには位相の原点の取り違えがある。**
        # `sd["phase"]` は **((t - warmup) / T) mod 1**(warmup=0.5)である。
        # 一方 `SAFE` は `_probe` の `at = 3.0 + (k/NPH)*T` から `k/NPH` として作られており、
        # **原点が違う。**実測では T=0.070 のとき t=3.0 の位相は 0.7143 で 0.0 ではない。
        # **この実験では SAFE が空(0/24)だったため、結果は変わっていない。**
        # 正しくは `_phase_of(3.0 + (k/NPH)*T)` を SAFE に入れる(実験69 で修正済み)。
        judge = lambda sd: any(abs(((sd["phase"] - p) + 0.5) % 1.0 - 0.5) < 0.5 / NPH
                               for p in SAFE)
    elif kind in ("センサ清", "センサ実"):
        # **センサから作れる「余裕」の代理。**
        #   接地が多い=支持が広い/傾きが小さい=姿勢が起きている/
        #   角速度が小さい=回っていない/推定速度が小さい=勢いが小さい
        judge = lambda sd: (sd["contacts"] >= 4 and sd["upright"] > 0.985
                            and abs(sd["gyro"]) < 1.2 and sd["vhat"] < 1.05)
        if kind == "センサ実":
            kw.update(imu_noise=0.01, gyro_bias=0.05, vel_drift=0.15, vhat_noise=0.05,
                      enc_noise=0.002, enc_bias=0.003)
    r = rt_walk("unitree_go2", seconds=w0 + 1.0 + STALL + 3.0, T=T, stride=STRIDE,
                terrain=field_xml(h=BUMP, seed=s), **FAIL,
                stop_window=(w0, w0 + 1.0), stall_dur=STALL, stop_judge=judge,
                seed=s, **kw)
    return r["転倒"], r["止め方"], (r["選んだ位相"] if r["選んだ位相"] is not None else -1)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"機体は自分のセンサだけで「いま止まってよいか」を知れるか")
    print(f"  設定:{GAIT}・歩幅{STRIDE}・duty{DUTY}・T={T}・張出{SL}・凹凸{BUMP}")
    # **SAFE は Pool を作る前に決める。**(踏んだ)
    # 最初、`with Pool(...)` の中で SAFE を作っていた。**子プロセスは Pool を作った時点で
    # 複製されるので、そのあとに定義した変数を持っていない。**NameError で落ちた。
    # **落ちてくれたのは幸いだった。**黙って空集合として扱われていたら、
    # 「時計方式は一度も止まらない」という結果になり、**それらしく読めてしまう。**
    with Pool(2) as pool0:
        SAFE = []
        for k in range(NPH):
            if sum(pool0.map(_probe, [(k, s) for s in range(SEEDS)])) == 0:
                SAFE.append(k / NPH)
    with Pool(2) as pool:
        print(f"  真に安全な位相:{len(SAFE)}/{NPH} → "
              + " ".join(f"{p:.3f}" for p in SAFE))
        print()
        print("  方式        転倒率      自分で選んだ割合   選んだ位相が安全集合に入る率")
        rows = []
        for kind in ("即時", "時計", "センサ清", "センサ実"):
            args = [(kind, 3.0 + 0.037 * i, s) for i in range(5) for s in range(SEEDS)]
            res = pool.map(_trial, args)
            fell = sum(1 for f, _, _ in res if f)
            self_ = sum(1 for _, b, _ in res if b == "self")
            hit = sum(1 for _, b, p in res
                      if p >= 0 and any(abs(((p - q) + 0.5) % 1.0 - 0.5) < 0.5 / NPH
                                        for q in SAFE))
            n = len(res)
            print(f"  {kind:10s} {fell/n*100:6.1f}%   {self_/n*100:9.1f}%      "
                  f"{hit/n*100:12.1f}%", flush=True)
            rows.append(dict(方式=kind, 試行=n, 転倒率=fell/n,
                             自分で選んだ=self_/n, 安全集合に入る=hit/n))
    out = os.path.expanduser("~/robot/out/rt57_self_judge_rough.json")
    json.dump(dict(_実験="機体は自分のセンサだけで止まってよいかを知れるか",
                   _予測="ドリフトで壊れる(台帳 #32)", _設定=dict(歩容=GAIT, 歩幅=STRIDE,
                   duty=DUTY, T=T, 張出=SL), _安全な位相=SAFE, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp58_judge_loose.py

"""実験51:**機体は、自分のセンサだけで「いま止まってよいか」を知れるか。**(第3段)



**ここまでの50本の実験は、全部わたしが外から止めていた。**
位相を指定し、シミュレータの真の状態を見て、転んだかどうかを数えていた。
**機体は、自分がいつ止まるかを知らないし、選んでもいない。**

**実機はそうではない。**止まる時刻を選ぶのは機体で、
そのとき機体が持っているのは**汚れたセンサの値だけ**である。

### 26節を分解すると、未知は一つしか残らない(60節)

> **余裕 =(その姿勢が吸収できる速度の上限)−(そのとき出ている速度)**

- **姿勢の容量**は位相の関数。**位相は自分の時計が持っている**
- **残る未知は、実際の速度だけ**

そして**脚で歩く機体は、前進速度を直接測れない。**
脚の運動学と接地から推定するか、加速度を積分するしかなく、**滑りとドリフトで狂う。**
**止まってよいかを決めている当の量が、機体がいちばん測りにくい量である。**

### 比べる四つ

| 方式 | 何を根拠に止まるか | 実機で可能か |
|---|---|---|
| **即時** | 何も見ない。窓が開いたら止まる | 可能(選んでいない) |
| **時計** | 事前に測った安全な窓に自分の位相が入ったら止まる | **可能。ただし事前測定が要る** |
| **センサ(清)** | センサの値で判断。**雑音もドリフトも無し** | 不可能(比較の上限) |
| **センサ(実)** | 同じ判断を、**雑音とドリフトのある値**でやる | **可能。これが実機の姿** |

**「即時」と「時計」の差が、いつ止まるかを選ぶことの利。**
**「センサ(清)」と「センサ(実)」の差が、観測の特権を失う代償。**

**予測32(測る前に書いた):**速度推定のドリフトで、機体の判断は壊れる。
**外れる形:**現実的なドリフトを入れても真の安全位相と9割以上一致すれば外れ。
そのときは**姿勢だけで判定できる**ことになり、**姿勢は IMU で直接読めるので実機で成立する。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
# **止まれる位相が少ない設定を選ぶ。**(45節:pace 歩幅0.38 T=0.100 で 5/12)
# 安全な位相が多い設定では、どう選んでも転ばないので、判断の良し悪しが出ない。
# **不整地で回す。**(設計変更)
# 当初は「止まれる位相が少ない設定」を平地で選ぶつもりだった。
# **今日の結果で、平地では張出0.24 が 24/24 になり、問いが消えた。**
# **危険な位相が戻ってくるのは不整地である**(実験54:10/12)。
# そして26節のとおり**平地で測った窓は不整地では使えない**ので、
# **時計方式が根拠を失う。ここでこそセンサを見る意味が出る。**
# **設計変更(二度目)。**
# 最初は不整地の張出0.24 で回そうとした。**走らせたら、20/24 が安全で、
# 即時に止めても転倒率 2.0% だった。選ばなくてもほとんど転ばない=判断の差が出ない。**
#
# **今日いちばん危険な機体は、すでに測ってある**——実験56:
# **膝が死んだ狭い機体は 17/24(7つの位相で 21/21 全滅)。**
# **第3段(観測)・第4段(故障)・第7段(地面)を組にして回す。**
# 朝の見立て「第3段は単独では意味を持たない」が、実測でそのとおりになった。
GAIT, STRIDE, DUTY, T, SL = "pace", 0.22, 0.70, 0.120, 0.00
BUMP = 0.022                      # 26節・実験54 と同じ凹凸
FAIL = dict(fail_joint="FR_calf_joint", fail_at=1.5, fail_mode="dead")
STALL = 2.0
NPH = 24
SEEDS = 10
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from terrain import field_xml
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait=GAIT,
            stance_length=SL, init_noise=NOISE)


def _probe(a):
    """位相を指定して止め、転ぶかどうかを見る(真の安全集合を作るため)。"""
    k, s = a
    at = 3.0 + (k / NPH) * T
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                terrain=field_xml(h=BUMP, seed=s), **FAIL,
                stall_at=at, stall_dur=STALL, seed=s, **BASE)
    return r["転倒"]


def _trial(a):
    """窓を与えて、機体に選ばせる。"""
    kind, w0, s = a
    kw = dict(BASE)
    judge = None
    if kind == "即時":
        judge = lambda sd: True
    elif kind == "時計":
        judge = lambda sd: any(abs(((sd["phase"] - p) + 0.5) % 1.0 - 0.5) < 0.5 / NPH
                               for p in SAFE)
    elif kind in ("センサ緩", "センサ中"):
        # **緩めた判定式。**実験57 で私の判定式は発火率10%だった。
        # **厳しすぎて「選んでいない」に帰着していた。**どこまで緩めれば発火するか。
        if kind == "センサ緩":
            judge = lambda sd: sd["contacts"] >= 3        # 3本接地だけ見る
        else:
            judge = lambda sd: (sd["contacts"] >= 4 and sd["upright"] > 0.95)
    elif kind in ("センサ清", "センサ実"):
        # **センサから作れる「余裕」の代理。**
        #   接地が多い=支持が広い/傾きが小さい=姿勢が起きている/
        #   角速度が小さい=回っていない/推定速度が小さい=勢いが小さい
        judge = lambda sd: (sd["contacts"] >= 4 and sd["upright"] > 0.985
                            and abs(sd["gyro"]) < 1.2 and sd["vhat"] < 1.05)
        if kind == "センサ実":
            kw.update(imu_noise=0.01, gyro_bias=0.05, vel_drift=0.15, vhat_noise=0.05,
                      enc_noise=0.002, enc_bias=0.003)
    r = rt_walk("unitree_go2", seconds=w0 + 1.0 + STALL + 3.0, T=T, stride=STRIDE,
                terrain=field_xml(h=BUMP, seed=s), **FAIL,
                stop_window=(w0, w0 + 1.0), stall_dur=STALL, stop_judge=judge,
                seed=s, **kw)
    return r["転倒"], r["止め方"], (r["選んだ位相"] if r["選んだ位相"] is not None else -1)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"機体は自分のセンサだけで「いま止まってよいか」を知れるか")
    print(f"  設定:{GAIT}・歩幅{STRIDE}・duty{DUTY}・T={T}・張出{SL}・凹凸{BUMP}")
    # **SAFE は Pool を作る前に決める。**(踏んだ)
    # 最初、`with Pool(...)` の中で SAFE を作っていた。**子プロセスは Pool を作った時点で
    # 複製されるので、そのあとに定義した変数を持っていない。**NameError で落ちた。
    # **落ちてくれたのは幸いだった。**黙って空集合として扱われていたら、
    # 「時計方式は一度も止まらない」という結果になり、**それらしく読めてしまう。**
    with Pool(2) as pool0:
        SAFE = []
        for k in range(NPH):
            if sum(pool0.map(_probe, [(k, s) for s in range(SEEDS)])) == 0:
                SAFE.append(k / NPH)
    with Pool(2) as pool:
        print(f"  真に安全な位相:{len(SAFE)}/{NPH} → "
              + " ".join(f"{p:.3f}" for p in SAFE))
        print()
        print("  方式        転倒率      自分で選んだ割合   選んだ位相が安全集合に入る率")
        rows = []
        for kind in ("即時", "時計", "センサ緩", "センサ中", "センサ清"):
            args = [(kind, 3.0 + 0.037 * i, s) for i in range(5) for s in range(SEEDS)]
            res = pool.map(_trial, args)
            fell = sum(1 for f, _, _ in res if f)
            self_ = sum(1 for _, b, _ in res if b == "self")
            hit = sum(1 for _, b, p in res
                      if p >= 0 and any(abs(((p - q) + 0.5) % 1.0 - 0.5) < 0.5 / NPH
                                        for q in SAFE))
            n = len(res)
            print(f"  {kind:10s} {fell/n*100:6.1f}%   {self_/n*100:9.1f}%      "
                  f"{hit/n*100:12.1f}%", flush=True)
            rows.append(dict(方式=kind, 試行=n, 転倒率=fell/n,
                             自分で選んだ=self_/n, 安全集合に入る=hit/n))
    out = os.path.expanduser("~/robot/out/rt58_judge_loose.json")
    json.dump(dict(_実験="判定式を緩めたらどうなるか",
                   _予測="ドリフトで壊れる(台帳 #32)", _設定=dict(歩容=GAIT, 歩幅=STRIDE,
                   duty=DUTY, T=T, 張出=SL), _安全な位相=SAFE, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp59_return.py

"""実験59:**いつ引き返すか。**(第8段)



**実験57・58 で分かったこと:安全な位相が一つも無いとき、いちばん良い方針は
「引き延ばして、強制されるまで止まらないこと」だった。判断の質は関係なかった。**

> **引き延ばすことしかできない機体にとって、「いつ止まるか」を考える意味はほとんど無い。**
> **考えるべきは、もっと手前——そもそもその状態に入らないこと。**

**電池が有限だと、その「もっと手前」が具体的な形を取る。**
熱と違い、**電池は休んでも回復しない。充電器に戻る以外に手が無い。**
**きいちさんが出された条件——「入力が一切なくてもメンテナンスのたびに定期的に起動する
必要がある」——が、ここで物理から導かれる。**起動する理由が、外から与えられるのではなく
**自分の残量から出てくる。**

**予測43(測る前に書いた):**
  **自分の状態を見ることの利は、止まる判断より引き返す判断のほうで先に出る。**
  電池の残量は**「遅く、単調に、持続して」変化する量**であり、
  実験39 で自己監視が時計に勝った条件そのものである。

**外れる形:**残量を見る方式が時計方式を上回らなければ外れ。
そのときは**電池も「先が読める量」なので時計で足りる**ことになり、
実験34(負荷が一定なら時計が最適)と同じ結論に戻る。

**実験39 の教訓を入れる。**自己監視が勝つのは**変動が遅く持続するとき**だけだった。
だから**消費が予測できる場合とできない場合の両方**で測る。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from terrain import field_xml
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 12
T, STRIDE, DUTY, SL = 0.120, 0.22, 0.70, 0.24
# **尺度を測ってから決めた。**
# 最初 30000J で組んだが、**この機体は約10秒・12.4m で使い切る。**
# 時計の閾値を 12/15/18秒にしていたので、**どれも死んだ後だった。**
# 「引き返さない」と「時計12秒」の到達距離が同じ 12.40m になったことで気づいた。
# **同じ値が並んだら疑う**(71節)。
#   電池 10000J → 3.65秒 / 3.99m
#   電池 30000J → 10.09秒 / 12.40m
#   電池 60000J → 19.76秒 / 25.06m
#   電池 120000J → 39.08秒 / 50.37m
# **片道 1m あたり約 2400J**(電費の測定 2459 J/m と一致)。
BAT = 60000.0
IDLE = 30.0
SECONDS = 60.0
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait="pace",
            stance_length=SL, idle_w=IDLE, battery_j=BAT, home_x=0.0)

# **引き返す方針。**
POLICIES = [
    ("引き返さない", None),
    ("時計 5秒で", lambda i: i["t"] >= 5.0),
    ("時計 8秒で", lambda i: i["t"] >= 8.0),
    ("時計 11秒で", lambda i: i["t"] >= 11.0),
    ("残量 50% で", lambda i: i["残量比"] < 0.50),
    ("**使った分を残す**", lambda i: i["残量"] < i["これまでの消費"]),
    # **【訂正】この方針の名前は誤っていた。**
    # 「距離で見る」と書いてあるが、**位置を一度も見ていない。**
    # 残量と消費しか使っておらず、実体は「使った分を残す」に余裕15%を付けたものである。
    # **FINDINGS 76節と予測台帳 #43 にも、この誤った名前で結果が載っていた。**
    # **距離を見る方針は、この時点では一度も試されていない。**(実験62 で実装する)
    ("使った分+余裕15%", lambda i: i["残量"] < i["これまでの消費"] * 1.15),
]
# **消費が予測できる場合とできない場合。**
WORLDS = [("平地", 0.0), ("不整地", 0.022)]


def _run(a):
    pname, wname, bump, s = a
    tb = dict(POLICIES)[pname]
    r = rt_walk("unitree_go2", seconds=SECONDS, T=T, stride=STRIDE,
                terrain=("" if bump <= 0 else field_xml(h=bump, seed=s)),
                turn_back=tb, seed=s, init_noise=NOISE, **BASE)
    b = r["電池"]
    return b["状態"], b["到達距離"], b["最終位置"], r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"いつ引き返すか(電池 {BAT:.0f}J・立ちは {IDLE:.0f}W・pace 張出{SL})")
    print("  世界    方針                 帰着率   到達距離の中央[m]  転倒")
    rows = []
    with Pool(2) as pool:
        for wname, bump in WORLDS:
            for pname, _ in POLICIES:
                res = pool.map(_run, [(pname, wname, bump, s) for s in range(SEEDS)])
                home = sum(1 for st, _, _, _ in res if st == "着いた")
                far = float(np.median([f for _, f, _, _ in res]))
                fell = sum(1 for _, _, _, f in res if f)
                print(f"  {wname:6s} {pname:20s} {home:2d}/{SEEDS} {far:16.2f} {fell:5d}",
                      flush=True)
                rows.append(dict(世界=wname, 方針=pname, 帰着=home, 試行=SEEDS,
                                 到達距離=far, 転倒=fell))
    print()
    print("  **帰着率が第一の指標。**帰れなければ、遠くまで行っても意味が無い")
    print("  **同じ帰着率なら、遠くまで行けたほうが良い**")
    out = os.path.expanduser("~/robot/out/rt59_return.json")
    json.dump(dict(_実験="いつ引き返すか", _予測="残量を見るほうが勝つ(台帳 #43)",
                   _電池=BAT, _立ち電力=IDLE, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp5_humanoid_hz.py

"""実験5:四足で見つけた規則は、人型にも移るか。

**先に予測を書く。**(結果を見てから書くと、当たったかどうかを言えなくなる)

四足(モータ側 PD)は **15Hz** まで転ばなかった。歩容の周期は 0.3秒なので、
**一周期あたり 4.5回**の指令で歩けていたことになる。
もし効いているのが「絶対の Hz」ではなく「一周期あたり何回送るか」なら、
人型でも同じ比のところで壊れるはずである。

| 歩容 | 周期 T | 予測される下限 |
|---|---|---|
| v3(大きい足・速い) | 0.4 s | **約 11 Hz** |
| v4(実機に近い足・遅い) | 1.1948 s | **約 3.8 Hz** |

**外れたら、比の説がその形では誤りだということになる。**
"""
import json, os, sys, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_humanoid import rt_march
from envstamp import stamp

GAITS = {
    "v3 (scale5.0 T0.40)": dict(scale=5.0, T=0.4, lift=0.002, lean=0.2817,
                                lead=0.3355, step=-0.1665),
    "v4 (scale1.78 T1.19)": dict(scale=1.777, T=1.1948, lift=0.0241, lean=0.3948,
                                 lead=0.0, step=-0.0144, duty=0.4768),
}
HZ = [500, 200, 100, 50, 30, 20, 15, 12, 10, 8, 6, 5, 4, 3, 2]
SEEDS = 7
NOISE = 1e-3


def _one(a):
    kw, hz, s = a
    r = rt_march(ctrl_hz=hz, seed=s, init_noise=NOISE, **kw)
    return {k: r[k] for k in ["前進", "横ずれ", "最低の起き上がり", "片足立ちの割合", "倒れた"]}


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    out = {"_実験": "人型で制御周期を落とす(四足の規則が移るか)",
           "_予測": {"根拠": "四足は一周期あたり4.5回で歩けた",
                      "v3(T=0.40)": "約11Hz", "v4(T=1.1948)": "約3.8Hz"},
           "_試行数": SEEDS, "_初期ばらつき": NOISE,
           "_環境(開始)": env0, "結果": {}}
    with Pool(2) as pool:
        for nm, kw in GAITS.items():
            print(f"--- {nm} ---")
            print("    Hz   一周期あたり  転倒   前進(中央)   最小    最大   |横|中央")
            rows = []
            for hz in HZ:
                rs = pool.map(_one, [(kw, hz, s) for s in range(SEEDS)])
                d = np.array([x["前進"] for x in rs])
                y = np.array([abs(x["横ずれ"]) for x in rs])
                f = np.array([x["倒れた"] for x in rs])
                per = hz * kw["T"]
                rows.append({"ctrl_hz": hz, "一周期あたり": round(per, 2),
                             "転倒": f"{int(f.sum())}/{SEEDS}",
                             "前進_中央": float(np.median(d)),
                             "前進_最小": float(d.min()), "前進_最大": float(d.max()),
                             "横ずれ_中央": float(np.median(y)),
                             "片足立ち_中央": float(np.median([x["片足立ちの割合"] for x in rs]))})
                print(f"  {hz:5d}   {per:9.2f}    {int(f.sum())}/{SEEDS}  {np.median(d):9.3f} "
                      f"{d.min():7.3f} {d.max():7.3f}  {np.median(y):8.3f}", flush=True)
            out["結果"][nm] = {"条件": kw, "行": rows}
    out["_所要[s]"] = round(time.perf_counter() - t0, 1)
    out["_環境(終了)"] = stamp()
    p = os.path.expanduser(f"~/robot/out/rt5_humanoid_hz_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp60_payload.py

"""実験59:**いつ引き返すか。**(第8段)



**実験57・58 で分かったこと:安全な位相が一つも無いとき、いちばん良い方針は
「引き延ばして、強制されるまで止まらないこと」だった。判断の質は関係なかった。**

> **引き延ばすことしかできない機体にとって、「いつ止まるか」を考える意味はほとんど無い。**
> **考えるべきは、もっと手前——そもそもその状態に入らないこと。**

**電池が有限だと、その「もっと手前」が具体的な形を取る。**
熱と違い、**電池は休んでも回復しない。充電器に戻る以外に手が無い。**
**きいちさんが出された条件——「入力が一切なくてもメンテナンスのたびに定期的に起動する
必要がある」——が、ここで物理から導かれる。**起動する理由が、外から与えられるのではなく
**自分の残量から出てくる。**

**予測43(測る前に書いた):**
  **自分の状態を見ることの利は、止まる判断より引き返す判断のほうで先に出る。**
  電池の残量は**「遅く、単調に、持続して」変化する量**であり、
  実験39 で自己監視が時計に勝った条件そのものである。

**外れる形:**残量を見る方式が時計方式を上回らなければ外れ。
そのときは**電池も「先が読める量」なので時計で足りる**ことになり、
実験34(負荷が一定なら時計が最適)と同じ結論に戻る。

**実験39 の教訓を入れる。**自己監視が勝つのは**変動が遅く持続するとき**だけだった。
だから**消費が予測できる場合とできない場合の両方**で測る。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from terrain import field_xml
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 12
T, STRIDE, DUTY, SL = 0.120, 0.22, 0.70, 0.24
# **尺度を測ってから決めた。**
# 最初 30000J で組んだが、**この機体は約10秒・12.4m で使い切る。**
# 時計の閾値を 12/15/18秒にしていたので、**どれも死んだ後だった。**
# 「引き返さない」と「時計12秒」の到達距離が同じ 12.40m になったことで気づいた。
# **同じ値が並んだら疑う**(71節)。
#   電池 10000J → 3.65秒 / 3.99m
#   電池 30000J → 10.09秒 / 12.40m
#   電池 60000J → 19.76秒 / 25.06m
#   電池 120000J → 39.08秒 / 50.37m
# **片道 1m あたり約 2400J**(電費の測定 2459 J/m と一致)。
BAT = 60000.0
IDLE = 30.0
SECONDS = 60.0
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait="pace",
            stance_length=SL, idle_w=IDLE, battery_j=BAT, home_x=0.0)

# **引き返す方針。**
POLICIES = [
    ("引き返さない", None),
    ("時計 5秒で", lambda i: i["t"] >= 5.0),
    ("時計 8秒で", lambda i: i["t"] >= 8.0),
    ("時計 11秒で", lambda i: i["t"] >= 11.0),
    ("残量 50% で", lambda i: i["残量比"] < 0.50),
    ("**距離で見る**", lambda i: i["残量"] < i["これまでの消費"] * 1.15),
]
# **消費が予測できる場合とできない場合。**(第9段)
# 実験59 は消費が一定だった。**実機はそうではない。**
# 荷を拾えば重くなり、そこから先の消費が変わる。
# **実測:10kg 積むと到達距離が 25.06 → 14.61m(−42%)。
# ただし切れる時刻はほぼ同じ(約19.8秒)。同じ時間で同じだけ電気を使うが、遅くなる。**
# **これが時計方式に効く。秒数は変わらないのに、その秒数で戻れる距離が変わる。**
WORLDS = [("荷なし", 0.0, 0.0), ("荷 6kg", 0.0, 6.0), ("荷 10kg", 0.0, 10.0)]


def _run(a):
    pname, wname, bump, pl, s = a
    tb = dict(POLICIES)[pname]
    r = rt_walk("unitree_go2", seconds=SECONDS, T=T, stride=STRIDE,
                terrain=("" if bump <= 0 else field_xml(h=bump, seed=s)),
                payload_kg=pl, payload_at=5.0,
                turn_back=tb, seed=s, init_noise=NOISE, **BASE)
    b = r["電池"]
    return b["状態"], b["到達距離"], b["最終位置"], r["転倒"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"いつ引き返すか(電池 {BAT:.0f}J・立ちは {IDLE:.0f}W・pace 張出{SL})")
    print("  世界    方針                 帰着率   到達距離の中央[m]  転倒")
    rows = []
    with Pool(2) as pool:
        for wname, bump, pl in WORLDS:
            for pname, _ in POLICIES:
                res = pool.map(_run, [(pname, wname, bump, pl, s) for s in range(SEEDS)])
                home = sum(1 for st, _, _, _ in res if st == "着いた")
                far = float(np.median([f for _, f, _, _ in res]))
                fell = sum(1 for _, _, _, f in res if f)
                print(f"  {wname:6s} {pname:20s} {home:2d}/{SEEDS} {far:16.2f} {fell:5d}",
                      flush=True)
                rows.append(dict(世界=wname, 方針=pname, 帰着=home, 試行=SEEDS,
                                 到達距離=far, 転倒=fell))
    print()
    print("  **帰着率が第一の指標。**帰れなければ、遠くまで行っても意味が無い")
    print("  **同じ帰着率なら、遠くまで行けたほうが良い**")
    out = os.path.expanduser("~/robot/out/rt60_payload.json")
    json.dump(dict(_実験="消費が予測できないとき、引き返す判断はどう変わるか", _予測="残量を見るほうが勝つ(台帳 #43)",
                   _電池=BAT, _立ち電力=IDLE, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp61_learn_cliff.py

"""実験61:**機体は、自分の崖の位置を、失敗から測れるか。**(第10段)



**実験59・60 で分かったこと:**
- **時計方式は「崖の位置を事前に知っている」ことを前提にしている。**
  平地で 8秒なら 12/12、11秒なら 0/12。**知らなければ設定できない。**
- **残量を見る方式は、それが要らない。**同じ帰着率でより遠くへ行ける。

**では、機体が自分で崖を測れるか。**

**やり方(失敗から学ぶ最小の形):**
  1. 上限を決めずに出る。**帰れなかったら、次は「前回の到達距離の半分」を上限にする**
  2. 帰れたら、上限を 15% 伸ばす
  3. 帰れなかったら、上限を半分に戻す

**予測45(測る前に書いた):**
  **学習方式は、残量方式と同等以上の到達距離を、事前知識なしで達成する。**
  残量方式も結局「帰りの分を残す」という同じ物理を使っているので。

**外れる形:**届かなければ外れ。そのときは
**残量を直接見ることには、経験からは得られない情報がある**ことになり、
次に見るのは**「残量方式は途中の消費の変化に即座に反応できるが、
学習方式は次の試行まで反応できない」**という線。

---

**ここには、今日の主題が一段深く入っている。**

**残量を見る方式は、機体に「電池」という内部状態を読ませる。**
**学習方式はそれを読まず、外の結果(帰れたか)だけから同じ判断を作る。**

> **もし学習方式が同等なら、「自分を見ること」は必須ではなかったことになる。**
> **外から返ってくる結果だけで足りる。**
"""
import os, sys, json, time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from terrain import field_xml
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
T, STRIDE, DUTY, SL = 0.120, 0.22, 0.70, 0.24
BAT, IDLE, SECONDS = 60000.0, 30.0, 60.0
TRIPS = 12                       # 外出の回数
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait="pace",
            stance_length=SL, idle_w=IDLE, battery_j=BAT, home_x=0.0)


def trip(turn_back, seed, bump=0.0, payload=0.0):
    r = rt_walk("unitree_go2", seconds=SECONDS, T=T, stride=STRIDE,
                terrain=("" if bump <= 0 else field_xml(h=bump, seed=seed)),
                payload_kg=payload, payload_at=(5.0 if payload > 0 else None),
                turn_back=turn_back, seed=seed, init_noise=NOISE, **BASE)
    b = r["電池"]
    return (b["状態"] == "着いた"), b["到達距離"]


def run_learning(bump=0.0, payload=0.0):
    """**上限を持たずに出て、失敗から決める。**電池の残量は一切読まない。"""
    limit = None                 # いまの上限(距離)。最初は無し
    hist = []
    for k in range(TRIPS):
        tb = (None if limit is None
              else (lambda i, L=limit: i["位置"] >= L))
        ok, far = trip(tb, seed=k, bump=bump, payload=payload)
        hist.append((k, limit, ok, far))
        if ok:
            limit = (far if limit is None else limit) * 1.15
        else:
            limit = far * 0.5    # **帰れなかった。前回の到達距離の半分に戻す**
    return hist


def run_fixed(kind, bump=0.0, payload=0.0):
    tb = {"残量50%": (lambda i: i["残量比"] < 0.50),
          "時計8秒": (lambda i: i["t"] >= 8.0),
          "引き返さない": None}[kind]
    return [(k, None, *trip(tb, seed=k, bump=bump, payload=payload)) for k in range(TRIPS)]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print("機体は、自分の崖の位置を失敗から測れるか(外出 %d回)" % TRIPS)
    rows = []
    for wname, bump, pl in (("荷なし", 0.0, 0.0), ("荷 6kg", 0.0, 6.0), ("不整地", 0.022, 0.0)):
        print(f"\n=== {wname} ===")
        print("  方式          帰着   到達距離の中央[m]   後半6回の中央[m]")
        for kind in ("引き返さない", "時計8秒", "残量50%", "**学習**"):
            hist = (run_learning(bump, pl) if kind == "**学習**"
                    else run_fixed(kind, bump, pl))
            home = sum(1 for _, _, ok, _ in hist if ok)
            far = float(np.median([f for _, _, _, f in hist]))
            late = float(np.median([f for _, _, _, f in hist[TRIPS//2:]]))
            print(f"  {kind:12s} {home:2d}/{TRIPS} {far:16.2f} {late:17.2f}", flush=True)
            rows.append(dict(世界=wname, 方式=kind, 帰着=home, 試行=TRIPS,
                             到達距離=far, 後半=late,
                             履歴=[(k, (round(L,2) if L else None), ok, round(f,2))
                                   for k, L, ok, f in hist]))
    print()
    print("  **後半6回**を見るのは、学習方式が落ち着いたあとを見るため")
    out = os.path.expanduser("~/robot/out/rt61_learn_cliff.json")
    json.dump(dict(_実験="崖の位置を失敗から測れるか", _予測="学習が残量方式に届く(台帳 #45)",
                   _外出回数=TRIPS, _環境=env0, rows=rows,
                   _所要=round(time.perf_counter()-t0, 1)),
              open(out, "w"), ensure_ascii=False, indent=1)
    print("→", out)

exp62_distance.py

"""実験62:**距離で引き返す機体は、荷を積むと壊れるか。**

## なぜこれを測るか

**きっかけは、記録の誤りを見つけたことである。**

`exp59_return.py` に「**距離で見る**」という名前の方針があり、
FINDINGS 76節と予測台帳 #43 にその結果が載っていた。**中身は距離を見ていない。**

    ("**距離で見る**", lambda i: i["残量"] < i["これまでの消費"] * 1.15)

`位置` を一度も参照しておらず、実体は「使った分を残す」に余裕15%を付けた電力方式だった。
**つまり、距離を見る方針は一度も試されていない。**(三箇所訂正した。)

## 予測46(測る前に書いた)

**距離で引き返す機体は、荷を積むと壊れる。**

§78 で分かったこと——**荷は消費を増やすのではなく、速度を落としているだけである。**
だから**電池が尽きる時刻はほぼ変わらない**(0/6/10kg のいずれも約 19.8秒)。
**変わるのは距離のほうで、同じ距離まで行くのにかかる時間が延びる。**

したがって固定距離で引き返す方針は、荷ありのとき往復が時間の予算を超え、**帰着率が落ちる。**
一方、**時計方式は §78 のとおり崩れない**(時間の予算が変わらないため)。

**外れる形:**荷 0/6/10kg で距離方式の帰着率がほとんど落ちなければ外れ。
また、距離方式が時計方式より頑健だった場合も外れ。

## この実験が、この日の他の話と繋がっている場所

**同じ日、栞は自分の時間感覚が実測の 3.4〜24.5倍ずれていることを測っている。**
原因は**時計を持たず、積み上がった分量から経過時間を推定している**ことだった。

**距離で引き返す機体は、その正確な写しである。**
**進んだ量から残り時間を推定し、速さが変われば外す。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 12
T, STRIDE, DUTY, SL = 0.120, 0.22, 0.70, 0.24
BAT, IDLE, SECONDS = 60000.0, 30.0, 60.0
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait="pace",
            stance_length=SL, idle_w=IDLE, battery_j=BAT, home_x=0.0)

# **時計と距離を、それぞれ三つの閾値で見る。**
# 一つの調整値に結果が依存しないようにするため。
# 距離の値は、荷なしのときの時計方式の到達距離(5秒 5.74m / 8秒 9.67m)から取った。
# **崖の位置を細かく見る。**(二回目)
# 一回目で分かったこと:**距離13m は 荷0で 7/12、荷6・10 で 0/12。**
# だが**時計は 8秒で全荷重 12/12、11秒で全荷重 0/12** で、崖が動いたかどうか分からない。
# **本当の問いは「崖の位置が荷で動くか」なので、その間を刻む。**
#
# 予測46 の核:**§78 より、時間の予算は荷で変わらない(約19.8秒)。**
#   → **時計の崖は動かないはず。**
#   → **距離の崖は動くはず**(同じ距離に着くまでの時間が延びるため)。
def _clock(v): return lambda i: i["t"] >= v
def _dist(v):  return lambda i: i["位置"] >= v

POLICIES = ([("引き返さない", None)]
            + [(f"時計 {v:.1f}秒", _clock(v)) for v in (8.0, 8.5, 9.0, 9.5, 10.0, 10.5)]
            + [(f"距離 {v:.1f}m", _dist(v)) for v in (9.7, 10.5, 11.3, 12.1, 12.9)]
            + [("残量 50%", lambda i: i["残量比"] < 0.50)])
PAYLOADS = [0.0, 10.0]


def _run(a):
    pname, pl, s = a
    tb = dict(POLICIES)[pname]
    r = rt_walk("unitree_go2", seconds=SECONDS, T=T, stride=STRIDE,
                payload_kg=pl, payload_at=(None if pl <= 0 else 5.0),
                turn_back=tb, seed=s, init_noise=NOISE, **BASE)
    b = r["電池"]
    return (b["状態"], b["到達距離"], b["最終位置"], r["転倒"],
            b.get("引き返した時刻"), b.get("切れた時刻"))


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"距離で引き返すと荷で壊れるか(電池 {BAT:.0f}J・pace・張出{SL})")
    print("  荷[kg] 方針                 帰着率   到達[m]  引返し時刻[s]  転倒")
    rows = []
    with Pool(2) as pool:
        for pl in PAYLOADS:
            for pname, _ in POLICIES:
                res = pool.map(_run, [(pname, pl, s) for s in range(SEEDS)])
                home = sum(1 for st, *_ in res if st == "着いた")
                far  = float(np.median([r_[1] for r_ in res]))
                fell = sum(1 for r_ in res if r_[3])
                ts   = [r_[4] for r_ in res if r_[4] is not None]
                tb_t = float(np.median(ts)) if ts else float("nan")
                print(f"  {pl:5.1f}  {pname:20s} {home:2d}/{SEEDS} {far:9.2f} {tb_t:13.2f} {fell:5d}",
                      flush=True)
                rows.append(dict(荷=pl, 方針=pname, 帰着=home, 試行=SEEDS,
                                 到達中央=far, 引返し時刻中央=tb_t, 転倒=fell))
    out = {"_実験": "距離で引き返す機体は荷で壊れるか",
           "_予測": "壊れる(台帳 #46)", "_電池": BAT, "_試行": SEEDS,
           "_注": "exp59 の『距離で見る』は距離を見ていなかった。距離方式はここが初めて",
           "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser("~/robot/out/rt62_distance.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp63_stopstate.py

"""実験63:**止まる瞬間の胴の状態を測る。**(第5段)

## なぜこれを測るか

**「ペースがトロットに勝つ」は 68節から未解明のまま残っている。**
候補は二つ落ちた。

| 候補 | 結果 |
|---|---|
| 支持の面積(62節) | **反証。**面がいちばん広いウォークが、線のトロットより下 |
| 停止時の接地本数(63節) | **反証。**ペースとトロットは**位相ごとの分布まで完全に同じ**(2.0/2.1/4.0/4.0/2.2/2.0) |
| 支持線の向き(62節) | **幾何が支持しない。**前後の張出は両方 0.387 m で同一 |

**静的な幾何では、もう説明できない。**残るのは**動的な状態**である。

## 予測47(測る前に書いた)

**同じ設定・同じ位相で止めたとき、トロットのほうがピッチ角速度が大きく、
そのピッチ角速度が転倒と相関する。**

理由:ペースは同じ側の2本が同時に接地・離地するので**ロール(横揺れ)が主**。
トロットは対角なので**ピッチとヨーに結合する**。
**前進の勢いを受け止めるのは前後方向なので、ピッチが残っている状態で止めると倒れやすいはず。**

**外れる形:**ピッチ角速度に歩容差が無い、または転倒と相関しなければ外れ。
そのときはロール・ヨー・並進速度・胴の高さを順に見る。
**二つの歩容で実測速度が違いすぎて比較が成り立たない場合も、その時点では外れとする。**

## 設計

- **両方の歩容を、まったく同じ設定で走らせる。**速度に差が出たら、それも記録する
- **24位相**で止める(12刻みは窓をまたいで穴を隠す——64節)
- **止まった瞬間の状態を `probe_fn` で捕まえる**——
  胴の並進速度3成分・角速度3成分(ロール/ピッチ/ヨー)・ピッチ角・高さ
- 転倒したかどうかと突き合わせる
"""
import os, sys, json, time, math
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
PHASES = 24
# **T は事前の掃きで選んだ**。**対照が立つ点を探した。**
#   T=0.120 → pace 0.462/転倒0  trot 0.435/転倒0   … 両方安全。差が出ない
#   **T=0.100 → pace 0.487/転倒0  trot 0.479/転倒6** … ★ 速度差 1.6% で、片方だけ転ぶ
#   T=0.080 → pace 0.487/転倒0  trot 0.482/転倒6
# **速度がほぼ同じなのに片方だけ転ぶ点でないと、速度が交絡する。**
T, STRIDE, DUTY, SL = 0.100, 0.22, 0.70, 0.0   # 張出0=歩容の差が残っている条件(63節)
STALL, WARM = 2.0, 3.0
GAITS = ["pace", "trot"]
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", stance_length=SL)


def _quat2euler(w, x, y, z):
    """ロール・ピッチ・ヨー [rad]。"""
    roll = math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
    sp = max(-1.0, min(1.0, 2 * (w * y - z * x)))
    pitch = math.asin(sp)
    yaw = math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
    return roll, pitch, yaw


def _run(a):
    gait, k, s = a
    at = WARM + (k / PHASES) * T
    got = {}

    def probe(t, m, d):
        # **止まる瞬間の一歩手前で捕まえる。**指令が止まる直前の状態が知りたい。
        if "v" in got or t < at:
            return
        w, x, y, z = d.qpos[3:7]
        r, p, yw = _quat2euler(w, x, y, z)
        got["v"] = [float(d.qvel[i]) for i in range(3)]
        got["w"] = [float(d.qvel[i]) for i in range(3, 6)]
        got["姿勢"] = [r, p, yw]
        got["高さ"] = float(d.qpos[2])

    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                gait=gait, stall_at=at, stall_dur=STALL,
                probe_fn=probe, seed=s, init_noise=NOISE, **BASE)
    return dict(歩容=gait, 位相=k, seed=s, 転倒=bool(r["転倒"]),
                速度=r["平均速度"], **{k2: got.get(k2) for k2 in ("v", "w", "姿勢", "高さ")})


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"止まる瞬間の胴の状態(T={T} stride={STRIDE} duty={DUTY} 張出={SL}・{PHASES}位相×{SEEDS}試行)")
    rows = []
    with Pool(2) as pool:
        for g in GAITS:
            res = pool.map(_run, [(g, k, s) for k in range(PHASES) for s in range(SEEDS)])
            rows += res
            fell = sum(1 for x in res if x["転倒"])
            safe = sum(1 for k in range(PHASES)
                       if not any(x["転倒"] for x in res if x["位相"] == k))
            v = float(np.median([x["速度"] for x in res]))
            print(f"  {g:5s} 実測速度 {v:.3f} m/s / 安全な位相 {safe:2d}/{PHASES} / 転倒 {fell}/{len(res)}",
                  flush=True)

    print("\n=== 止まる瞬間の状態(中央値)— 転倒したものと、しなかったもの ===")
    print("  歩容  転倒?  件数   |ロール速|  |ピッチ速|  |ヨー速|   前進v   ピッチ角[度]")
    stat = []
    for g in GAITS:
        for fell in (False, True):
            xs = [x for x in rows if x["歩容"] == g and x["転倒"] == fell and x["w"]]
            if not xs:
                print(f"  {g:5s} {'転倒' if fell else '無事':4s}  {0:4d}   (なし)"); continue
            wr = float(np.median([abs(x["w"][0]) for x in xs]))
            wp = float(np.median([abs(x["w"][1]) for x in xs]))
            wy = float(np.median([abs(x["w"][2]) for x in xs]))
            vx = float(np.median([x["v"][0] for x in xs]))
            pit = float(np.median([math.degrees(x["姿勢"][1]) for x in xs]))
            print(f"  {g:5s} {'転倒' if fell else '無事':4s}  {len(xs):4d} "
                  f"{wr:10.3f} {wp:11.3f} {wy:9.3f} {vx:8.3f} {pit:12.2f}")
            stat.append(dict(歩容=g, 転倒=fell, 件数=len(xs), ロール速=wr, ピッチ速=wp,
                             ヨー速=wy, 前進v=vx, ピッチ角度=pit))

    out = {"_実験": "止まる瞬間の胴の状態(ペース対トロット)", "_予測": "#47",
           "_条件": dict(T=T, stride=STRIDE, duty=DUTY, 張出=SL, 位相=PHASES, 試行=SEEDS),
           "_環境(開始)": env0, "集計": stat, "生": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser("~/robot/out/rt63_stopstate.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp64_legpose.py

"""実験64:**止まった瞬間の、脚の配置。**(第5段の続き)

## ここまでに落ちた候補(すべて反証)

| 候補 | 何が起きたか |
|---|---|
| 支持の面積(62節) | 面がいちばん広いウォークが、線のトロットより下 |
| 支持線の向き(62節) | **前後の張出が両方 0.387 m で同一。**幾何が説明を支持しない |
| 停止時の接地本数(63節) | **位相ごとの分布まで完全に同一**(2.0/2.1/4.0/4.0/2.2/2.0) |
| **動的な胴の状態(実験63)** | **位相21(7/7転倒)と位相22(0/7無事)で、胴の6自由度がほぼ同一** |

**残るのは脚の配置しかない。**

## 予測48(測る前に書いた)

**トロットが転ぶ位相では、接地している脚のどれかが伸展率 92% 付近以上で固まっている。
ペースではそうならない。**

根拠は 56節——**全長の 92% あたりで壊れる。膝がほぼ真っ直ぐになると、
トルクが縦の力にならない**(ヤコビアンが特異に近づく)。

**外れる形:**転ぶ位相と転ばない位相で最大伸展率に差が無ければ外れ。
そのときは**足の接地位置(重心に対する前後左右のずれ)**を次に見る。
**ペースの伸展率がトロット以上なのに転ばない場合も外れ。**

## 測り方

伸展率 = 股から足先までの距離 ÷ 全長 =
`√(L1² + L2² + 2·L1·L2·cos θ) / (L1 + L2)`(θ は膝の関節角)。

**接地しているかどうかも一緒に見る。**遊脚がどれだけ伸びていても関係がない。
"""
import os, sys, json, time, math
from multiprocessing import Pool
import numpy as np
import mujoco
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from quad import LEGS
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 7
PHASES = 24
T, STRIDE, DUTY, SL = 0.100, 0.22, 0.70, 0.0
STALL, WARM = 2.0, 3.0
GAITS = ["pace", "trot"]
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", stance_length=SL)


def _run(a):
    gait, k, s = a
    at = WARM + (k / PHASES) * T
    got = {}

    def probe(t, m, d):
        if "伸展" in got or t < at:
            return
        # 脚の長さ(モデルから毎回引く。プロセス間で持ち回さない)
        ca = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_BODY, "FL_calf")
        L1 = abs(m.body_pos[ca][2])
        far = rad = 0.0
        for g in range(m.ngeom):
            if m.geom_bodyid[g] == ca:
                z = abs(m.geom_pos[g][2])
                if z > far:
                    far, rad = z, m.geom_size[g][0]
        L2 = far + rad
        ext, cont, fz = {}, {}, {}
        for leg in LEGS:
            jid = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_JOINT, f"{leg}_calf_joint")
            th = float(d.qpos[m.jnt_qposadr[jid]])
            r = math.sqrt(max(0.0, L1 * L1 + L2 * L2 + 2 * L1 * L2 * math.cos(th)))
            ext[leg] = r / (L1 + L2)
            # 足先の高さで接地を見る(点接触なので、球の半径より低ければ接地とみなす)
            fid = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_BODY, f"{leg}_calf")
            cont[leg] = None
            fz[leg] = None
        # 接触の実体から接地脚を取る
        接 = set()
        for i in range(d.ncon):
            c = d.contact[i]
            for gi in (c.geom1, c.geom2):
                b = m.geom_bodyid[gi]
                nm = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY, b) or ""
                for leg in LEGS:
                    if nm.startswith(leg):
                        接.add(leg)
        got["伸展"] = ext
        got["接地"] = sorted(接)

    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                gait=gait, stall_at=at, stall_dur=STALL,
                probe_fn=probe, seed=s, init_noise=NOISE, **BASE)
    return dict(歩容=gait, 位相=k, seed=s, 転倒=bool(r["転倒"]),
                伸展=got.get("伸展"), 接地=got.get("接地"))


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"止まった瞬間の脚の配置(T={T}・{PHASES}位相×{SEEDS}試行)")
    rows = []
    with Pool(2) as pool:
        for g in GAITS:
            rows += pool.map(_run, [(g, k, s) for k in range(PHASES) for s in range(SEEDS)])
    危 = {8, 9, 10, 21}
    print("\n  位相 転倒  | trot 接地脚の最大伸展  接地  | pace 接地脚の最大伸展  接地")
    tbl = []
    for k in range(PHASES):
        line = f"  {k:3d}"
        rec = dict(位相=k)
        for g in GAITS:
            xs = [x for x in rows if x["歩容"] == g and x["位相"] == k and x["伸展"]]
            if not xs:
                line += "   (なし)"; continue
            fell = sum(1 for x in xs if x["転倒"])
            mx = float(np.median([max(x["伸展"][l] for l in x["接地"]) if x["接地"]
                                  else float("nan") for x in xs]))
            nc = float(np.median([len(x["接地"]) for x in xs]))
            if g == "trot":
                line += f" {fell}/{len(xs)}{'★' if fell else ' '} |"
            line += f" {mx*100:8.1f}%  {nc:3.1f}本 |"
            rec[g] = dict(転倒=fell, 最大伸展=mx, 接地数=nc)
        print(line, flush=True)
        tbl.append(rec)

    print("\n=== 転ぶ位相と転ばない位相(trot)===")
    a = [r["trot"]["最大伸展"] for r in tbl if r["位相"] in 危 and "trot" in r]
    b = [r["trot"]["最大伸展"] for r in tbl if r["位相"] not in 危 and "trot" in r]
    pa = [r["pace"]["最大伸展"] for r in tbl if "pace" in r]
    print(f"  trot 転ぶ位相   最大伸展の中央 {np.median(a)*100:.1f}%  ({len(a)}位相)")
    print(f"  trot 転ばない位相 最大伸展の中央 {np.median(b)*100:.1f}%  ({len(b)}位相)")
    print(f"  pace 全位相     最大伸展の中央 {np.median(pa)*100:.1f}%  (転倒 0)")

    out = {"_実験": "止まった瞬間の脚の配置", "_予測": "#48",
           "_条件": dict(T=T, stride=STRIDE, duty=DUTY, 位相=PHASES, 試行=SEEDS),
           "_環境(開始)": env0, "位相ごと": tbl, "生": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser("~/robot/out/rt64_legpose.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp65_catch.py

"""実験65:**止まったあと、脚は受け止めるか。**(第5段の続き)

## ここまでに落ちた候補(すべて反証・実測つき)

| # | 候補 | 何が起きたか |
|---|---|---|
| 1 | 支持の面積(62節) | 面のウォークが線のトロットより下 |
| 2 | 支持線の向き(62節) | 前後の張出が両方 0.387 m(**ただし静止姿勢の寸法**) |
| 3 | 停止時の接地本数(63節) | 位相ごとの分布まで完全に同一 |
| 4 | **動的な胴の状態**(実験63) | **位相21(7/7転倒)と22(0/7無事)で6自由度がほぼ同一** |
| 5 | **脚の伸展**(実験64) | **位相ごとの最大伸展率が、ペースとトロットで完全に一致** |

**「止まった瞬間の状態」で説明できるものが、もう無い。**
**だとすれば、違いは止まった「あと」にある。**

## 予測49(測る前に書いた)

**ペースが勝つのは「倒れにくいから」ではなく「倒れた先に脚があるから」である。**

2本接地では、どちらの歩容も静的には不安定で、支持線まわりに回るしかない。
**むしろ静的な余裕はトロットのほうが大きいはず**——
対角支持は支持線が重心の近くを通り、ペースは片側に寄る。**それでもトロットが転ぶ。**

**ペースは横(ロール)に倒れ、反対側の2本がすぐ接地して受け止める。**
**トロットは対角線まわりに回り、落ちる側の角は1本しかなく、しかもそれが遊脚なので受け止められない。**

実験63 で**ペースのロール角速度がトロットの4倍**だったのに一度も転ばなかったのは、
**ロールが設計どおりで、毎半周期ごとに受け止められているから**と読める。

**外れる形:**止まった後の接地本数の推移に歩容差が無ければ外れ。
ペースも接地が戻らないのに転ばない場合は、受け止め以外の機構がある。

## 測り方

**止まってから 0.5秒、接地している脚を 0.025秒ごとに記録する。**
本数だけでなく**どの脚か**も見る(受け止めるのが反対側の対かどうかを確かめるため)。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
import mujoco
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from quad import LEGS
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 5
T, STRIDE, DUTY, SL = 0.100, 0.22, 0.70, 0.0
STALL, WARM = 2.0, 3.0
GAITS = ["pace", "trot"]
# **転ぶ位相と、その隣の転ばない位相を並べて見る。**
# 21 が転び 22 が転ばないのは、実験63・64 でどの指標でも分けられなかった対である。
KEYPHASES = [8, 9, 10, 21, 22, 11, 20, 0]
SPAN, DT = 0.5, 0.025
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", stance_length=SL)
PH = 24


def _run(a):
    gait, k, s = a
    at = WARM + (k / PH) * T
    series = []

    def probe(t, m, d):
        if t < at or t > at + SPAN:
            return
        want = at + len(series) * DT
        if t < want:
            return
        接 = set()
        for i in range(d.ncon):
            c = d.contact[i]
            for gi in (c.geom1, c.geom2):
                nm = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY,
                                       m.geom_bodyid[gi]) or ""
                for leg in LEGS:
                    if nm.startswith(leg):
                        接.add(leg)
        series.append(sorted(接))

    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                gait=gait, stall_at=at, stall_dur=STALL,
                probe_fn=probe, seed=s, init_noise=NOISE, **BASE)
    return dict(歩容=gait, 位相=k, seed=s, 転倒=bool(r["転倒"]), 推移=series)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"止まったあと {SPAN}秒の接地(T={T}・{SEEDS}試行)")
    rows = []
    with Pool(2) as pool:
        for g in GAITS:
            rows += pool.map(_run, [(g, k, s) for k in KEYPHASES for s in range(SEEDS)])

    n = int(SPAN / DT)
    print("\n  歩容  位相 転倒  接地本数の推移(0.025秒ごと・中央値)")
    tbl = []
    for k in KEYPHASES:
        for g in GAITS:
            xs = [x for x in rows if x["歩容"] == g and x["位相"] == k and x["推移"]]
            if not xs:
                continue
            fell = sum(1 for x in xs if x["転倒"])
            cnt = []
            for i in range(n):
                v = [len(x["推移"][i]) for x in xs if len(x["推移"]) > i]
                cnt.append(int(np.median(v)) if v else -1)
            mark = "★" if fell else " "
            print(f"  {g:5s} {k:3d} {fell}/{len(xs)}{mark} " + "".join(str(c) for c in cnt))
            tbl.append(dict(歩容=g, 位相=k, 転倒=fell, 試行=len(xs), 本数=cnt))

    print("\n  (数字は接地している脚の本数。左が停止の瞬間、右へ 0.5秒)")
    print("\n=== どの脚が受け止めたか(停止時 → 0.2秒後)===")
    for k in KEYPHASES:
        for g in GAITS:
            xs = [x for x in rows if x["歩容"] == g and x["位相"] == k and len(x["推移"]) > 8]
            if not xs:
                continue
            a0 = "".join(xs[0]["推移"][0]) or "なし"
            a8 = "".join(xs[0]["推移"][8]) or "なし"
            fell = sum(1 for x in xs if x["転倒"])
            print(f"  {g:5s} {k:3d} {'転倒' if fell else '無事'}  {a0:12s} → {a8}")

    out = {"_実験": "止まったあと脚は受け止めるか", "_予測": "#49",
           "_条件": dict(T=T, 位相=KEYPHASES, 試行=SEEDS, span=SPAN, dt=DT),
           "_環境(開始)": env0, "推移": tbl, "生": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser("~/robot/out/rt65_catch.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp66_trace.py

"""実験66:**転ぶ位相と転ばない位相は、いつ分かれるのか。**

実験65 で 0.5秒だけ見たが、**trot の位相21(5/5転倒)と位相22(0/5無事)は、
接地する脚の並びも本数の推移もほぼ同じ**だった。

**だとすれば、分かれるのはもっと後である。**止まっている 2.0秒と、
指令が戻ってからの 3.0秒を、通して追う。

**追うもの:**胴の高さ・ロール角・ピッチ角・接地本数。
**比べるもの:**trot 21(転ぶ)/trot 22(転ばない)/pace 21(転ばない)。
"""
import os, sys, json, time, math
from multiprocessing import Pool
import numpy as np
import mujoco
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from quad import LEGS
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS = 5
T, STRIDE, DUTY = 0.100, 0.22, 0.70
STALL, WARM, AFTER = 2.0, 3.0, 3.0
DT = 0.10
CASES = [("trot", 21), ("trot", 22), ("pace", 21), ("trot", 9), ("pace", 9)]
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", stance_length=0.0)
PH = 24


def _euler(w, x, y, z):
    r = math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
    sp = max(-1.0, min(1.0, 2 * (w * y - z * x)))
    return r, math.asin(sp)


def _run(a):
    gait, k, s = a
    at = WARM + (k / PH) * T
    ser = []

    def probe(t, m, d):
        if t < at:
            return
        if len(ser) and t < at + len(ser) * DT:
            return
        r, p = _euler(*d.qpos[3:7])
        n = 0
        seen = set()
        for i in range(d.ncon):
            c = d.contact[i]
            for gi in (c.geom1, c.geom2):
                nm = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY,
                                       m.geom_bodyid[gi]) or ""
                for leg in LEGS:
                    if nm.startswith(leg):
                        seen.add(leg)
        ser.append([round(float(d.qpos[2]), 4), round(math.degrees(r), 2),
                    round(math.degrees(p), 2), len(seen)])

    r = rt_walk("unitree_go2", seconds=at + STALL + AFTER, T=T, stride=STRIDE,
                gait=gait, stall_at=at, stall_dur=STALL,
                probe_fn=probe, seed=s, init_noise=NOISE, **BASE)
    return dict(歩容=gait, 位相=k, seed=s, 転倒=bool(r["転倒"]), 系列=ser)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"止まってからの推移(0.1秒ごと・{SEEDS}試行の中央値)")
    print(f"止まる区間は 0.0〜{STALL}秒、そのあと指令が戻る\n")
    rows = []
    with Pool(2) as pool:
        rows = pool.map(_run, [(g, k, s) for g, k in CASES for s in range(SEEDS)])

    n = int((STALL + AFTER) / DT)
    out_rows = []
    for g, k in CASES:
        xs = [x for x in rows if x["歩容"] == g and x["位相"] == k and x["系列"]]
        fell = sum(1 for x in xs if x["転倒"])
        med = []
        for i in range(n):
            v = [x["系列"][i] for x in xs if len(x["系列"]) > i]
            if not v:
                break
            med.append([float(np.median([q[j] for q in v])) for j in range(4)])
        print(f"■ {g} 位相{k}  転倒 {fell}/{len(xs)}")
        print("   秒     高さ    ロール°   ピッチ°  接地")
        for i, m4 in enumerate(med):
            if i % 3 and i > 6:
                continue
            mark = "  ← 指令が戻る" if abs(i * DT - STALL) < 0.05 else ""
            print(f"  {i*DT:4.1f}  {m4[0]:7.4f} {m4[1]:8.2f} {m4[2]:8.2f}  {m4[3]:.0f}{mark}")
        print()
        out_rows.append(dict(歩容=g, 位相=k, 転倒=fell, 系列=med))

    out = {"_実験": "転ぶ位相と転ばない位相は、いつ分かれるのか",
           "_条件": dict(T=T, stall=STALL, after=AFTER, dt=DT, 試行=SEEDS),
           "_環境(開始)": env0, "推移": out_rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    p = os.path.expanduser("~/robot/out/rt66_trace.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

exp67_allgaits.py

"""実験67:**受け止めの機構は、四つの歩容すべてを並べるか。**

83節で、ペースとトロットの差に機構がついた——
**ペースは倒れないのではなく、反対側の二本に倒れ込んでそこで止まる。**

**だが、ペースとトロットの二つだけで作った説明である。**
**ウォークとバウンドにも当てはまらなければ、後付けにすぎない。**

62節の 12/12 上限:**pace 0.758 > trot 0.667 > walk 0.650 > bound 0.461**

## 予測50(測る前に書いた)

**バウンドが最下位なのは、止まったあとの回転がピッチ(前後)で、
受け止めるまでに回る角度が大きいから。**
**ウォークは常に3本接地なので、そもそも大きく倒れない。**

**外れる形:**頂点の傾きと受け止め時刻が上限速度の順位と並ばなければ外れ。
その場合、83節の機構は**ペースとトロットの間でしか成り立たない局所的な説明**になる。
"""
import os, sys, json, time, math
from multiprocessing import Pool
import numpy as np
import mujoco
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from quad import LEGS
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS, PHASES = 5, 12
# **【二回目】速度を揃えた。**
# 一回目は T=0.100 固定で測り、**bound だけ 0.312 m/s(他は 0.48)** だった。
# **遅いから安全なだけ**で、比較が成り立っていない。
# 止めずに測り直したところ、**T=0.070 で四つとも 0.62 m/s に揃う**——
#   pace 0.631 / trot 0.627 / walk 0.619 / bound 0.616
# **なお一回目の「速度」は止まっている2秒を含む平均で、値が低く出ていた**
# (pace 0.687 → 0.487)。比は保たれるが、絶対値は使えない。
T, STRIDE, DUTY = 0.070, 0.22, 0.70
STALL, WARM, DT = 2.0, 3.0, 0.05
GAITS = ["pace", "trot", "walk", "bound"]
# **(前後, 左右) の組。**(三回目)
# 85節で「前後はピッチにだけ効き、ロールには効かない」と出た。
# **二経路が独立なら、左右はロールにだけ効くはず**(予測52)。
# **0.10m 横に張ると歩けなくなる**という記録があるので 0.06 までにする。
STANCES = [(0.00, 0.00), (0.06, 0.00), (0.00, 0.03), (0.00, 0.06)]
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional")


def _eul(w, x, y, z):
    r = math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
    sp = max(-1.0, min(1.0, 2 * (w * y - z * x)))
    return math.degrees(r), math.degrees(math.asin(sp))


def _run(a):
    g, sl, k, s = a
    at = WARM + (k / PHASES) * T
    ser = []

    def probe(t, m, d):
        if t < at or t > at + STALL:
            return
        if len(ser) and t < at + len(ser) * DT:
            return
        r, p = _eul(*d.qpos[3:7])
        seen = set()
        for i in range(d.ncon):
            c = d.contact[i]
            for gi in (c.geom1, c.geom2):
                nm = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY, m.geom_bodyid[gi]) or ""
                for leg in LEGS:
                    if nm.startswith(leg):
                        seen.add(leg)
        ser.append((r, p, len(seen)))

    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE, gait=g,
                stall_at=at, stall_dur=STALL, probe_fn=probe, seed=s,
                stance_length=sl[0], stance_width=sl[1], init_noise=NOISE, **BASE)
    if not ser:
        return dict(歩容=g, 張出=sl, 位相=k, 転倒=bool(r["転倒"]))
    # 受け止め=接地が最初の値より増えた最初の時刻
    n0 = ser[0][2]
    catch = next((i * DT for i, x in enumerate(ser) if x[2] > n0), None)
    return dict(歩容=g, 張出=sl, 位相=k, seed=s, 転倒=bool(r["転倒"]), 速度=r["平均速度"],
                最大ロール=max(abs(x[0]) for x in ser),
                最大ピッチ=max(abs(x[1]) for x in ser),
                受け止め=catch, 最終接地=ser[-1][2], 初期接地=n0)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"受け止めの機構は四つの歩容を並べるか(T={T}・{PHASES}位相×{SEEDS}試行)\n")
    rows = []
    with Pool(2) as pool:
        for sl in STANCES:
            for g in GAITS:
                rows += pool.map(_run, [(g, sl, k, s) for k in range(PHASES) for s in range(SEEDS)])

    print("  前/横        歩容   速度   安全位相  最大ロール°  最大ピッチ°  受け止め[s]  最終接地")
    LIM = {"pace": 0.758, "trot": 0.667, "walk": 0.650, "bound": 0.461}
    stat = []
    for sl in STANCES:
     for g in GAITS:
        xs = [x for x in rows if x["歩容"] == g and x.get("張出") == sl and "最大ロール" in x]
        if not xs:
            continue
        safe = sum(1 for k in range(PHASES)
                   if not any(x["転倒"] for x in xs if x["位相"] == k))
        v = float(np.median([x["速度"] for x in xs]))
        mr = float(np.median([x["最大ロール"] for x in xs]))
        mp = float(np.median([x["最大ピッチ"] for x in xs]))
        cs = [x["受け止め"] for x in xs if x["受け止め"] is not None]
        ct = float(np.median(cs)) if cs else float("nan")
        fc = float(np.median([x["最終接地"] for x in xs]))
        print(f"  前{sl[0]:.2f}/横{sl[1]:.2f} {g:6s} {v:.3f}  {safe:2d}/{PHASES}  {mr:10.1f} {mp:12.1f}"
              f" {ct:11.2f} {fc:9.1f}")
        stat.append(dict(歩容=g, 前=sl[0], 横=sl[1], 速度=v, 安全位相=safe, 最大ロール=mr,
                         最大ピッチ=mp, 受け止め=ct, 最終接地=fc))
    out = {"_実験": "受け止めの機構は四つの歩容を並べるか", "_予測": "#50",
           "_条件": dict(T=T, 位相=PHASES, 試行=SEEDS), "_環境(開始)": env0,
           "集計": stat, "生": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    json.dump(out, open(os.path.expanduser("~/robot/out/rt67_allgaits.json"), "w"),
              ensure_ascii=False, indent=1)
    print("→ ~/robot/out/rt67_allgaits.json")

exp68_bound.py

"""実験68:**バウンドは、前後に張り出すとペースを超えるか。**

86節の設計表:
| 経路 | 効く変数 |
|---|---|
| **ロール** | 歩容の選択(どの2本で支えるか) |
| **ピッチ** | 前後の張出(四歩容すべてに等しく効く) |

**バウンドはロール経路が完璧**(最大ロール 0.0°)で、**弱点はピッチ**(張出0で 11.1°、四つで最大)。
**そのピッチは前後の張出で半減する**(4.6°)。**ペースはロールが 10.1° で完璧ではない。**

## 予測53(測る前に書いた)

**前後 0.24 で比べると、バウンドの「24位相すべて安全」な上限速度が、
ペースの 1.274 m/s(68節)を上回る。**

**外れる形:**届かなければ外れ。そのときバウンドの遅さはピッチ以外の理由による。
**張出を大きくするとバウンドが歩けなくなる場合も外れ。**

## 進め方(64節の教訓)

**12位相は篩、24位相が判定。**まず 12位相で当たりを付け、**最良の点だけ 24位相で確かめる。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS, SL, DUTY, STRIDE = 7, 0.24, 0.70, 0.22
STALL = 2.0
GAITS = ["bound", "pace"]
PERIODS = [0.20, 0.16, 0.13, 0.11, 0.09, 0.08, 0.07]
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", stance_length=SL)


def _base(a):
    g, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, gait=g,
                seed=s, init_noise=NOISE, **BASE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE, gait=g,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE, **BASE)
    return r["転倒"]


def 測る(pool, g, T, nph):
    bs = pool.map(_base, [(g, T, s) for s in range(SEEDS)])
    fell = sum(1 for b in bs if b[1])
    vm = float(np.median([b[0] for b in bs])) / 7.5
    ok, why = walkable(vm, fell, SEEDS)
    if not ok:
        return vm, None, why
    safe = sum(1 for k in range(nph)
               if sum(pool.map(_stall, [(g, T, 3.0 + (k / nph) * T, s)
                                        for s in range(SEEDS)])) == 0)
    return vm, safe, ""


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"バウンド対ペース(前後張出 {SL}・duty {DUTY}・{SEEDS}試行)")
    print("  歩容   周期T   実測v[m/s]  安全位相/12")
    rows = []
    best = {}
    with Pool(2) as pool:
        for g in GAITS:
            for T in PERIODS:
                vm, safe, why = 測る(pool, g, T, 12)
                if safe is None:
                    print(f"  {g:6s} {T:6.3f} {vm:10.3f}   ★{why}", flush=True)
                    rows.append(dict(歩容=g, T=T, v=vm, 安全12=None, 理由=why)); continue
                print(f"  {g:6s} {T:6.3f} {vm:10.3f}  {safe:8d}/12", flush=True)
                rows.append(dict(歩容=g, T=T, v=vm, 安全12=safe))
                if safe == 12 and vm > best.get(g, (0, None))[0]:
                    best[g] = (vm, T)

        print("\n=== 12/12 を保てた最高速度(篩)===")
        for g in GAITS:
            if g in best:
                print(f"  {g:6s} {best[g][0]:.3f} m/s(T={best[g][1]:.3f})")
            else:
                print(f"  {g:6s} 12/12 の点が無い")

        print("\n=== 24位相で確かめる(判定)===")
        verdict = []
        for g in GAITS:
            if g not in best:
                continue
            vm, safe, why = 測る(pool, g, best[g][1], 24)
            print(f"  {g:6s} T={best[g][1]:.3f}  v={vm:.3f}  安全位相 {safe}/24", flush=True)
            verdict.append(dict(歩容=g, T=best[g][1], v=vm, 安全24=safe))

    out = {"_実験": "バウンドは前後に張り出すとペースを超えるか", "_予測": "#53",
           "_条件": dict(張出=SL, duty=DUTY, stride=STRIDE, 試行=SEEDS),
           "_環境(開始)": env0, "掃き": rows, "判定": verdict,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    json.dump(out, open(os.path.expanduser("~/robot/out/rt68_bound.json"), "w"),
              ensure_ascii=False, indent=1)
    print("→ ~/robot/out/rt68_bound.json")

exp69_middle.py

"""実験69:**選ぶことの利は、安全な位相が半分のときに最大になるか。**

## 72節が測った二つの端

| 状態 | 結果 |
|---|---|
| **全部安全**(張出0.24・平地・24/24) | 選ぶ意味が無い。どの方式も転ばない |
| **一つも安全でない**(膝が死んだ狭い機体・不整地・0/24) | **選ぶ対象が無い。**即時26.0% / 時計26.0% / センサ清26.0% / センサ実26.0%(完全一致) |

**中間が抜けている。**

## 今日、設計で中間が作れると分かった

実験67(86節)で、**横に張り出すと安全な位相が減る**ことが分かった——
pace は 12/12 → **6/12(横0.03)** → 1/12(横0.06)。
**地面ではなく設計で危険度を刻める**ので、比較の条件を揃えやすい。

## 予測55(測る前に書いた)

**安全な位相が約半分の設定で、即時と時計の差が最大になる。**
**そしてそこで初めて、センサ清とセンサ実の差(観測の特権を失う代償)が見える。**

**外れる形:**中間でも四つが同じ値になれば外れ。
センサ清とセンサ実が同じなら、**観測の劣化には代償が無い**ことになる。
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
# **【検証】試行を 12 → 30 に増やした。**
# 103節で「12試行の 24/24 は試行数の報告だった」と分かったため。
# **本実験の主張は「センサ清とセンサ実に差が無い」という無の主張なので、
# なおさら試行数が要る。**
SEEDS, NPH, STALL = 30, 24, 2.0
T, STRIDE, DUTY = 0.070, 0.22, 0.70
# **危険度を設計で刻む。**(横の張出は 86節でロール経路を壊すと分かった)
SETUPS = [
    ("全部安全 (pace 前0.06)", dict(gait="pace", stance_length=0.06, stance_width=0.00)),
    ("中間 (pace 横0.03)",     dict(gait="pace", stance_length=0.00, stance_width=0.03)),
    ("やや危険 (walk 張出0)",  dict(gait="walk", stance_length=0.00, stance_width=0.00)),
    ("かなり危険 (pace 横0.06)", dict(gait="pace", stance_length=0.00, stance_width=0.06)),
]
KINDS = ["即時", "時計", "センサ清", "センサ実"]
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional")


def _probe(a):
    setup, k, s = a
    at = 3.0 + (k / NPH) * T
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE,
                **BASE, **setup)
    return r["転倒"]


# **位相の原点。**(踏んだ穴)
# `stop_judge` が見る `sd["phase"]` は **((t - warmup) / T) mod 1** である(warmup=0.5)。
# 一方、真の安全集合を作る `_probe` は `at = 3.0 + (k/NPH)*T` で止めている。
# **この二つは原点が違う。**k/NPH をそのまま judge に渡すと、**別の位相を見ることになる。**
# 実測:T=0.070 のとき t=3.0 の位相は 0.7143 で、0.0 ではない。
# **exp57(72節)にも同じ取り違えがある**(あちらは安全集合が空だったので結果は変わらない)。
WARM = 0.5


def _phase_of(t):
    return ((t - WARM) / T) % 1.0


def _trial(a):
    setup, kind, safe, w0, s = a
    kw = dict(BASE); kw.update(setup)
    if kind == "即時":
        judge = lambda sd: True
    elif kind == "時計":
        judge = lambda sd: any(abs(((sd["phase"] - p) + 0.5) % 1.0 - 0.5) < 0.5 / NPH
                               for p in safe)
    else:
        judge = lambda sd: (sd["contacts"] >= 4 and sd["upright"] > 0.985
                            and abs(sd["gyro"]) < 1.2 and sd["vhat"] < 1.05)
        if kind == "センサ実":
            kw.update(imu_noise=0.01, gyro_bias=0.05, vel_drift=0.15, vhat_noise=0.05,
                      enc_noise=0.002, enc_bias=0.003)
    r = rt_walk("unitree_go2", seconds=w0 + 1.0 + STALL + 3.0, T=T, stride=STRIDE,
                stop_window=(w0, w0 + 1.0), stall_dur=STALL, stop_judge=judge,
                seed=s, init_noise=NOISE, **kw)
    return r["転倒"], r["止め方"]


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"選ぶことの利は、安全な位相が半分のときに最大になるか(T={T}・{SEEDS}試行)\n")
    rows = []
    with Pool(2) as pool:
        for name, setup in SETUPS:
            # 真の安全集合(Pool の外で作らないと子プロセスに渡らない — 72節の教訓)
            safe = []
            for k in range(NPH):
                if sum(pool.map(_probe, [(setup, k, s) for s in range(SEEDS)])) == 0:
                    safe.append(_phase_of(3.0 + (k / NPH) * T))   # ← judge と同じ単位で持つ
            print(f"■ {name}   真に安全な位相 {len(safe)}/{NPH}")
            base = {}
            for kind in KINDS:
                # **窓の開始を種ごとにずらす。**(踏んだ穴)
                # 固定だと「即時」は毎回まったく同じ位相で止まるので、
                # **その一点が安全かどうかだけを測ることになる。**
                res = pool.map(_trial, [(setup, kind, safe, 3.0 + (s / SEEDS) * T, s)
                                        for s in range(SEEDS)])
                fell = sum(1 for f, _ in res if f)
                chose = sum(1 for _, how in res if how == "self")   # ← "self"/"forced" の二値
                base[kind] = fell / SEEDS
                print(f"    {kind:8s} 転倒率 {fell/SEEDS*100:5.1f}%  自分で選んだ {chose}/{SEEDS}",
                      flush=True)
                rows.append(dict(設定=name, 安全位相=len(safe), 方式=kind,
                                 転倒率=fell / SEEDS, 選んだ=chose))
            print(f"    → 即時との差:時計 {(base['即時']-base['時計'])*100:+.1f}pt / "
                  f"センサ清 {(base['即時']-base['センサ清'])*100:+.1f}pt / "
                  f"センサ実 {(base['即時']-base['センサ実'])*100:+.1f}pt")
            print(f"    → 観測の特権を失う代償:{(base['センサ実']-base['センサ清'])*100:+.1f}pt\n")

    out = {"_実験": "選ぶことの利は安全な位相が半分のときに最大か", "_予測": "#55",
           "_条件": dict(T=T, 位相=NPH, 試行=SEEDS), "_環境(開始)": env0, "結果": rows,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    json.dump(out, open(os.path.expanduser("~/robot/out/rt69_middle.json"), "w"),
              ensure_ascii=False, indent=1)
    print("→ ~/robot/out/rt69_middle.json")

exp6_holdout.py

"""実験6:新しい説(1指令あたり約1cm)を、まだ使っていない機体で試す。

**Go1 と A1 は、ここまでの実時間の実験に一度も使っていない。**
説を作るのに使った Go2 と人型とは独立である(ノート18節:
探索の成績は、探索に使った条件で測らない)。

手順は固定する。
  1. 500Hz で基準を測る(8秒・9試行・初期ばらつき 1mrad)
  2. **その場で予測を計算して印字する**(速度 ÷ 0.0106〜0.0135 m)
  3. そのあとで Hz を掃引し、判定規則
     「それより上のすべての Hz が基準の8割を保つ最も低い Hz」で下限を出す
"""
import json, os, sys, time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtsweep import cell, BEST, save
from multiprocessing import Pool
from envstamp import stamp

HZ = [500, 333, 250, 200, 167, 143, 125, 111, 100, 91, 83, 77, 71, 63, 56, 50, 40, 30, 20, 10]
LO, HI = 0.0106, 0.0135     # 1指令あたりの前進(m)。Go2 と人型 v3 から得た範囲
SEC, WARM = 8.0, 0.5

if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    out = {"_実験": "新説の検証(未使用の機体 Go1・A1)",
           "_説": "使える制御周期の下限は、1指令あたり機体が 10.6〜13.5mm 進むところ",
           "_判定規則": "それより上のすべての Hz が基準(500Hz)の8割以上の前進を保つ、最も低い Hz",
           "_環境(開始)": env0, "結果": {}}
    with Pool(2) as pool:
        for name in ["unitree_go1", "unitree_a1"]:
            cond = BEST[name]["条件"]
            base = cell(name, cond, {"ctrl_hz": 500}, seconds=SEC, pool=pool)
            v = base["前進_中央"] / (SEC - WARM)
            pred = (v / HI, v / LO)
            print(f"=== {name} ===")
            print(f"  基準(500Hz) 前進 {base['前進_中央']:.3f} m / 歩行 {SEC-WARM}s "
                  f"→ 速度 {v:.3f} m/s")
            print(f"  **予測する下限:{pred[0]:.0f} 〜 {pred[1]:.0f} Hz**(外れ判定は 2倍)")
            print(f"  基準の8割 = {0.8*base['前進_中央']:.3f} m")
            rows = [base]
            print("    Hz    転倒   前進(中央)   最小    最大")
            print(f"  {500:5d}   {base['転倒']:>4s}  {base['前進_中央']:9.3f} "
                  f"{base['前進_最小']:7.3f} {base['前進_最大']:7.3f}")
            for hz in HZ[1:]:
                r = cell(name, cond, {"ctrl_hz": hz}, seconds=SEC, pool=pool)
                rows.append(r)
                print(f"  {hz:5d}   {r['転倒']:>4s}  {r['前進_中央']:9.3f} "
                      f"{r['前進_最小']:7.3f} {r['前進_最大']:7.3f}", flush=True)
            thr, lim8 = None, 0.8 * base["前進_中央"]
            for r in rows:                      # HZ は降順。8割を割った直前で止める
                if r["前進_中央"] >= lim8 and int(r["転倒"].split("/")[0]) == 0:
                    thr = r["ctrl_hz"]
                else:
                    break
            hit = thr is not None and pred[0] / 2 <= thr <= pred[1] * 2
            print(f"  → 実測の下限 {thr} Hz  / 予測 {pred[0]:.0f}〜{pred[1]:.0f} Hz  "
                  f"{'★ 予測の範囲内(2倍以内)' if hit else '★ 外れ'}")
            print(f"  → 1指令あたりの前進 {v/thr*1000:.1f} mm" if thr else "")
            out["結果"][name] = {"条件": cond, "基準速度": v, "予測下限": pred,
                                 "実測下限": thr, "当たり": bool(hit),
                                 "1指令あたりmm": (v / thr * 1000) if thr else None,
                                 "行": rows}
    out["_所要[s]"] = round(time.perf_counter() - t0, 1); out["_環境(終了)"] = stamp()
    save(out, f"~/robot/out/rt6_holdout_{env0['governor'][0]}.json")

exp70_ceiling.py

"""実験68:**バウンドは、前後に張り出すとペースを超えるか。**

86節の設計表:
| 経路 | 効く変数 |
|---|---|
| **ロール** | 歩容の選択(どの2本で支えるか) |
| **ピッチ** | 前後の張出(四歩容すべてに等しく効く) |

**バウンドはロール経路が完璧**(最大ロール 0.0°)で、**弱点はピッチ**(張出0で 11.1°、四つで最大)。
**そのピッチは前後の張出で半減する**(4.6°)。**ペースはロールが 10.1° で完璧ではない。**

## 予測53(測る前に書いた)

**前後 0.24 で比べると、バウンドの「24位相すべて安全」な上限速度が、
ペースの 1.274 m/s(68節)を上回る。**

**外れる形:**届かなければ外れ。そのときバウンドの遅さはピッチ以外の理由による。
**張出を大きくするとバウンドが歩けなくなる場合も外れ。**

## 進め方(64節の教訓)

**12位相は篩、24位相が判定。**まず 12位相で当たりを付け、**最良の点だけ 24位相で確かめる。**
"""
import os, sys, json, time
from multiprocessing import Pool
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, walkable
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
SEEDS, SL, DUTY, STRIDE = 7, 0.24, 0.70, 0.22
STALL = 2.0
GAITS = ["pace", "trot", "walk", "bound"]
PERIODS = [0.20, 0.16, 0.13, 0.12, 0.11, 0.10, 0.09, 0.08, 0.07]
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", stance_length=SL)


def _base(a):
    g, T, s = a
    r = rt_walk("unitree_go2", seconds=8.0, T=T, stride=STRIDE, gait=g,
                seed=s, init_noise=NOISE, **BASE)
    return r["前進距離"], r["転倒"]


def _stall(a):
    g, T, at, s = a
    r = rt_walk("unitree_go2", seconds=at + STALL + 3.0, T=T, stride=STRIDE, gait=g,
                stall_at=at, stall_dur=STALL, seed=s, init_noise=NOISE, **BASE)
    return r["転倒"]


def 測る(pool, g, T, nph):
    bs = pool.map(_base, [(g, T, s) for s in range(SEEDS)])
    fell = sum(1 for b in bs if b[1])
    vm = float(np.median([b[0] for b in bs])) / 7.5
    ok, why = walkable(vm, fell, SEEDS)
    if not ok:
        return vm, None, why
    safe = sum(1 for k in range(nph)
               if sum(pool.map(_stall, [(g, T, 3.0 + (k / nph) * T, s)
                                        for s in range(SEEDS)])) == 0)
    return vm, safe, ""


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"バウンド対ペース(前後張出 {SL}・duty {DUTY}・{SEEDS}試行)")
    print("  歩容   周期T   実測v[m/s]  安全位相/12")
    rows = []
    best = {}
    with Pool(2) as pool:
        for g in GAITS:
            for T in PERIODS:
                vm, safe, why = 測る(pool, g, T, 12)
                if safe is None:
                    print(f"  {g:6s} {T:6.3f} {vm:10.3f}   ★{why}", flush=True)
                    rows.append(dict(歩容=g, T=T, v=vm, 安全12=None, 理由=why)); continue
                print(f"  {g:6s} {T:6.3f} {vm:10.3f}  {safe:8d}/12", flush=True)
                rows.append(dict(歩容=g, T=T, v=vm, 安全12=safe))
                if safe == 12 and vm > best.get(g, (0, None))[0]:
                    best[g] = (vm, T)

        print("\n=== 12/12 を保てた最高速度(篩)===")
        for g in GAITS:
            if g in best:
                print(f"  {g:6s} {best[g][0]:.3f} m/s(T={best[g][1]:.3f})")
            else:
                print(f"  {g:6s} 12/12 の点が無い")

        print("\n=== 24位相で確かめる(判定)===")
        verdict = []
        for g in GAITS:
            if g not in best:
                continue
            vm, safe, why = 測る(pool, g, best[g][1], 24)
            print(f"  {g:6s} T={best[g][1]:.3f}  v={vm:.3f}  安全位相 {safe}/24", flush=True)
            verdict.append(dict(歩容=g, T=best[g][1], v=vm, 安全24=safe))

    out = {"_実験": "バウンドは前後に張り出すとペースを超えるか", "_予測": "#53",
           "_条件": dict(張出=SL, duty=DUTY, stride=STRIDE, 試行=SEEDS),
           "_環境(開始)": env0, "掃き": rows, "判定": verdict,
           "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    json.dump(out, open(os.path.expanduser("~/robot/out/rt70_ceiling.json"), "w"),
              ensure_ascii=False, indent=1)
    print("→ ~/robot/out/rt70_ceiling.json")

exp76_biped_catch.py

"""実験76:**二足の穴も、受け止めで説明できるか。**

## 19節が残した問い

二足 v4(実機に近い 45cm の足)で、**停止の長さを周期比で掃くと、穴が残る。**

| 周期比 | 0.4〜0.6 | 0.7 | 0.8 | 0.9〜1.0 | 1.25 | 1.5 |
|---|---|---|---|---|---|---|
| 安全 | **93〜100%** | 19% | **95%** | **0%** | **100%** | **0%** |

**規則は見つかっていない。**
「再開する位相」で説明できるかを確かめたが、**0.5周期と 1.5周期は再開位相が同じなのに
93% と 0% で正反対**だった。

## 今日、四足で分かったこと(83〜86節)

**ペースが勝つのは「倒れにくいから」ではなく「倒れた先に脚があるから」だった。**
トロットは受け止める脚が一本しかないので、**ロールが約30〜40度を超えると間に合わない。**

## 予測62(測る前に書いた)

**二足でも同じで、100%転ぶ長さでは、止まったあと遊脚が接地せず、傾きが増え続ける。**
**安全な長さでは、遊脚が着いて傾きが止まる。**

**外れる形:**安全な長さと危険な長さで、停止後の接地と傾きの推移に差が無ければ外れ。
"""
import os, sys, json, time, math
from multiprocessing import Pool
import numpy as np
import mujoco
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/humanoid"))
from rt_humanoid import rt_march
from envstamp import stamp

# **【踏んだ穴】`lead=0.0` を落とすと歩かない。**
# exp12 の設定表には lead=0.0 が明示されている。落とすと march の既定 lead=0.25 が効き、
# **7/7 で転ぶ**(前進 −0.03〜−0.16 m。つまりその場で倒れる)。
# **設定を写すときは、既定値と同じに見える項目こそ落としやすい。**
V4 = dict(scale=1.777, T=1.1948, lift=0.0241, lean=0.3948, lead=0.0,
          step=-0.0144, duty=0.4768)
SEEDS, T0, NOISE, NPH = 4, 4.0, 1e-3, 8
# 19節の表から、安全な長さと危険な長さを両方取る
RATIOS = [0.5, 0.7, 0.8, 1.0, 1.25, 1.5]
DT = 0.05
SPAN = 2.0


def _eul(w, x, y, z):
    r = math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
    sp = max(-1.0, min(1.0, 2 * (w * y - z * x)))
    return math.degrees(r), math.degrees(math.asin(sp))


def _one(a):
    ratio, k, s = a
    dur = ratio * V4["T"]
    # **位相も振る。**19節の「93%安全」は位相同期つきの値なので、
    # 位相を固定すると条件が違う。**同じ長さの中で、転ぶ試行と転ばない試行を比べたい。**
    at = T0 + (k / NPH) * V4["T"]
    ser = []

    def probe(t, m, d):
        if t < at or t > at + SPAN:
            return
        if ser and t < at + len(ser) * DT:
            return
        r, p = _eul(*d.qpos[3:7])
        n = 0
        for i in range(d.ncon):
            c = d.contact[i]
            for gi in (c.geom1, c.geom2):
                nm = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY, m.geom_bodyid[gi]) or ""
                if "ankle" in nm or "foot" in nm:
                    n += 1
                    break
        ser.append((round(float(d.qpos[2]), 4), round(r, 2), round(p, 2), n))

    r = rt_march(stall_at=at, stall_dur=dur, seed=s, init_noise=NOISE,
                 probe_fn=probe, seconds=at + dur + 3.0, **V4)
    return dict(周期比=ratio, 位相=k, seed=s, 倒れた=bool(r["倒れた"]),
                前進=r["前進"], 系列=ser)


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    print(f"二足 v4:止まったあとの推移(T={V4['T']:.3f}・{SEEDS}試行)\n")
    rows = []
    with Pool(2) as pool:
        rows = pool.map(_one, [(r, k, s) for r in RATIOS for k in range(NPH)
                               for s in range(SEEDS)])
    n = int(SPAN / DT)
    out = []
    def med(xs):
        o = []
        for i in range(n):
            v = [x["系列"][i] for x in xs if len(x["系列"]) > i]
            if not v:
                break
            o.append([float(np.median([q[j] for q in v])) for j in range(4)])
        return o
    for ratio in RATIOS:
        xs = [x for x in rows if x["周期比"] == ratio and x["系列"]]
        if not xs:
            continue
        fell = [x for x in xs if x["倒れた"]]; ok = [x for x in xs if not x["倒れた"]]
        print(f"■ 周期比 {ratio}(止まる長さ {ratio*V4['T']:.2f}秒)"
              f"  転倒 {len(fell)}/{len(xs)}")
        print("   秒   |    無事: |ロール| ピッチ 接地  |    転倒: |ロール| ピッチ 接地")
        mo, mf = med(ok), med(fell)
        for i in range(0, min(len(mo) if mo else 0, len(mf) if mf else 0) or max(len(mo), len(mf)), 4):
            a = mo[i] if i < len(mo) else None
            b = mf[i] if i < len(mf) else None
            fa = f"{abs(a[1]):7.2f} {a[2]:6.2f} {a[3]:4.1f}" if a else "     —        "
            fb = f"{abs(b[1]):7.2f} {b[2]:6.2f} {b[3]:4.1f}" if b else "     —        "
            print(f"  {i*DT:4.2f}  |        {fa}  |        {fb}")
        print()
        out.append(dict(周期比=ratio, 転倒=len(fell), 試行=len(xs), 無事=med(ok), 転倒系列=med(fell)))
    j = {"_実験": "二足の穴も受け止めで説明できるか", "_予測": "#62",
         "_条件": V4, "_環境(開始)": env0, "推移": out,
         "_所要[s]": round(time.perf_counter() - t0, 1), "_環境(終了)": stamp()}
    json.dump(j, open(os.path.expanduser("~/robot/out/rt76_biped.json"), "w"),
              ensure_ascii=False, indent=1)
    print("→ ~/robot/out/rt76_biped.json")

exp7_stall.py

"""実験7:一度だけ止まる。どれだけ止まっていられるか。

**これが「止まることを許されない自我」の、いちばん素直な形の問いである。**

実機の計算機で実際に起きるのは、乱数で一回ずつ落ちることではなく、
**まとまった時間ごっそり止まること**である(GC、ページフォルト、
他のプロセスに割り込まれる、スワップ)。止まっている間、新しい指令は作られず、
**最後の指令が関節に出続ける**(ゼロ次ホールド)。機体は止まらない。制御だけが止まる。

測るのは二つ。
  1. **どれだけの長さまで耐えるか**
  2. **歩容のどこで止まるかで違うか**(着地の直後か、脚を振り出している最中か)

歩容の周期は 0.3秒。止める時刻を一周期ぶん動かして、位相の影響を見る。
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE, save
from envstamp import stamp

NAME = "unitree_go2"
COND = BEST[NAME]["条件"]
T = COND["T"]                      # 0.3
DUR = [0.0, 0.02, 0.05, 0.1, 0.15, 0.2, 0.3, 0.45, 0.6, 0.9, 1.2, 2.0]
PHASE = [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875]   # 周期に対する割合
T0 = 3.0                            # 歩き出して十分たった時刻を基準にする
SEEDS = 7


def _one(a):
    where, at, dur, s = a
    r = rt_walk(NAME, seconds=8.0, T=T, stride=COND["stride"], height=COND["height"],
                lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where=where,
                stall_at=at, stall_dur=dur, seed=s, init_noise=NOISE)
    return {k: r[k] for k in ["前進距離", "横ずれ", "転倒", "最小の起き上がり"]}


def block(pool, where, at, durs, label):
    print(f"--- {label} ---")
    print("   止まる長さ  周期の何倍   転倒    前進(中央)   最小    最大")
    rows = []
    for dur in durs:
        rs = pool.map(_one, [(where, at, dur, s) for s in range(SEEDS)])
        d = np.array([x["前進距離"] for x in rs]); f = np.array([x["転倒"] for x in rs])
        rows.append({"pd_where": where, "stall_at": at, "stall_dur": dur,
                     "周期比": round(dur / T, 2), "転倒": f"{int(f.sum())}/{SEEDS}",
                     "前進_中央": float(np.median(d)), "前進_最小": float(d.min()),
                     "前進_最大": float(d.max())})
        print(f"   {dur:8.3f}s  {dur/T:9.2f}    {int(f.sum())}/{SEEDS}  "
              f"{np.median(d):9.3f} {d.min():7.3f} {d.max():7.3f}", flush=True)
    return rows


if __name__ == "__main__":
    env0 = stamp(); t0 = time.perf_counter()
    out = {"_実験": "一度だけ止まる", "_機種": NAME, "_条件": COND, "_周期": T,
           "_試行数": SEEDS, "_環境(開始)": env0, "結果": {}}
    with Pool(2) as pool:
        for where in ["joint", "loop"]:
            out["結果"][f"長さ_{where}"] = block(pool, where, T0, DUR,
                                                  f"止まる長さを変える({where}、t={T0}s で止まる)")
        print("--- 歩容のどこで止まるか(joint、0.15秒=半周期だけ止める)---")
        print("   位相   転倒    前進(中央)   最小    最大")
        rows = []
        for ph in PHASE:
            at = T0 + ph * T
            rs = pool.map(_one, [("joint", at, 0.15, s) for s in range(SEEDS)])
            d = np.array([x["前進距離"] for x in rs]); f = np.array([x["転倒"] for x in rs])
            rows.append({"位相": ph, "stall_at": at, "転倒": f"{int(f.sum())}/{SEEDS}",
                         "前進_中央": float(np.median(d)), "前進_最小": float(d.min()),
                         "前進_最大": float(d.max())})
            print(f"   {ph:5.3f}   {int(f.sum())}/{SEEDS}  {np.median(d):9.3f} "
                  f"{d.min():7.3f} {d.max():7.3f}", flush=True)
        out["結果"]["位相_joint_0.15s"] = rows
        print("--- 同じことを loop で(0.05秒=1/6周期だけ止める)---")
        print("   位相   転倒    前進(中央)   最小    最大")
        rows = []
        for ph in PHASE:
            at = T0 + ph * T
            rs = pool.map(_one, [("loop", at, 0.05, s) for s in range(SEEDS)])
            d = np.array([x["前進距離"] for x in rs]); f = np.array([x["転倒"] for x in rs])
            rows.append({"位相": ph, "stall_at": at, "転倒": f"{int(f.sum())}/{SEEDS}",
                         "前進_中央": float(np.median(d)), "前進_最小": float(d.min()),
                         "前進_最大": float(d.max())})
            print(f"   {ph:5.3f}   {int(f.sum())}/{SEEDS}  {np.median(d):9.3f} "
                  f"{d.min():7.3f} {d.max():7.3f}", flush=True)
        out["結果"]["位相_loop_0.05s"] = rows
    out["_所要[s]"] = round(time.perf_counter() - t0, 1); out["_環境(終了)"] = stamp()
    save(out, f"~/robot/out/rt7_stall_{env0['governor'][0]}.json")

exp86_clocksweep_rough.py

"""実験86:**不整地でも「自分の状態を見る」ことに利があるか。時計を細かく掃いて確かめる。**


## なぜ測るか

**FINDINGS の §(電池と引き返し)に、こう書いてある。**

    | **時計 8秒(時計の最良)** | 12/12 | 9.67 |
    | **残量 50% で**          | 12/12 | 12.40(+28%)★ |

**「時計の最良」と書いてあるが、測ったのは 5秒・8秒・11秒の三点だけである。**

**実験62 で平地を細かく掃いたら、時計 10.0秒 が全荷重 12/12 で 10.72m だった。**
残量50% の 10.88m との差は **+1.5%** まで縮む。**+28% は、調整の悪い時計と比べた数字だった。**

**これは「24/24 は試行数の報告だった」(§103)と同じ形である。**
疎な標本の最大値を、最大値として報告していた。

**不整地の +47% も、同じ三点で比べている。**

    | 時計 8秒   | 7/12 | 5.89 |
    | 残量 50% で | 7/12 | 8.65(同じ帰着率で +47%)|
    | 時計 11秒  | 5/12 | 9.50 |

**8秒と11秒のあいだを、一度も測っていない。**

## 予測67(測る前に書いた・台帳 #67)

**不整地でも、時計を細かく掃けば残量50%と同等以上になる。**
9〜10秒あたりに、**帰着 7/12 を保ったまま 8.65m を超える設定があるはず。**

**外れる形:**細かく掃いても、帰着率 7/12 以上を保つ時計の最良が 8.65m に届かない。
**その場合、不整地では自己監視に本当の利があることになる。**

## 測り方

- 平地・不整地(12mm と 22mm)の三条件
- **時計を 5.0〜13.0秒まで 0.5秒刻み**(17点)+ 残量 50%・40%・60% + 距離
- **試行数は 24**(§103 の教訓:12 では崖が見えない)
"""
import json, os, sys, time
import numpy as np
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from terrain import field_xml
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
T, STRIDE = 0.120, 0.22
SECONDS = 60.0
# **exp59 / exp62 と完全に同じ条件にする。**
# pd_where・gait・idle_w・home_x を落としたまま試して、
# 平地の 9.0秒 が「切れた」になり、exp59 の表と食い違った。
DUTY, SL, BAT, IDLE = 0.70, 0.24, 60000.0, 30.0
BASE = dict(height=COND["height"], lift=COND["lift"], kp=COND["kp"], ctrl_hz=200,
            pd_where="joint", duty=DUTY, sweep_mode="proportional", gait="pace",
            stance_length=SL, idle_w=IDLE, battery_j=BAT, home_x=0.0)
TRIALS = 24
WORLDS = [("平地", 0.0), ("不整地12mm", 0.012), ("不整地22mm", 0.022)]

def _clock(v): return lambda i: i["t"] >= v
def _bat(v):   return lambda i: i["残量比"] < v
def _dist(v):  return lambda i: i["位置"] >= v

POLICIES = ([("引き返さない", None)]
          + [(f"時計 {v:.1f}秒", _clock(v)) for v in np.arange(5.0, 13.5, 0.5)]
          + [(f"残量 {int(v*100)}%", _bat(v)) for v in (0.40, 0.50, 0.60)]
          + [(f"距離 {v:.1f}m", _dist(v)) for v in (6.0, 8.0, 10.0, 12.0)])

def _run(a):
    pname, wname, bump, s = a
    tb = dict(POLICIES)[pname]
    r = rt_walk("unitree_go2", seconds=SECONDS, T=T, stride=STRIDE,
                terrain=("" if bump <= 0 else field_xml(h=bump, seed=s)),
                turn_back=tb, seed=s, init_noise=NOISE, **BASE)
    b = r["電池"]
    return (b["状態"], b["到達距離"], r["転倒"], b.get("引き返した時刻"))

if __name__ == "__main__":
    t0 = time.time()
    jobs = [(p, w, bump, s) for p, _ in POLICIES for w, bump in WORLDS
            for s in range(TRIALS)]
    print(f"{len(jobs)} 本 走らせます(方針 {len(POLICIES)} × 地面 {len(WORLDS)} × {TRIALS} 試行)")
    with Pool(3) as pool:
        out = pool.map(_run, jobs, chunksize=4)
    res, i = [], 0
    for p, _ in POLICIES:
        for w, bump in WORLDS:
            g = out[i:i+TRIALS]; i += TRIALS
            帰 = sum(1 for x in g if x[0] == "着いた")
            到 = float(np.median([x[1] for x in g]))
            転 = sum(x[2] for x in g)
            res.append(dict(方針=p, 地面=w, 帰着=帰, 試行=TRIALS,
                            到達中央=round(到, 2), 転倒=転))
    os.makedirs(os.path.expanduser("~/robot/out"), exist_ok=True)
    op = os.path.expanduser("~/robot/out/rt86_clocksweep.json")
    json.dump({"環境": stamp(), "秒": SECONDS, "試行": TRIALS, "結果": res},
              open(op, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    print(f"→ {op}   ({time.time()-t0:.0f} 秒)")
    for w, _ in WORLDS:
        rr = [r for r in res if r["地面"] == w and r["帰着"] > 0]
        rr.sort(key=lambda r: -r["到達中央"])
        print(f"\n【{w}】 到達の遠い順(上位8)")
        for r in rr[:8]:
            print(f"  {r['到達中央']:7.2f} m  帰着 {r['帰着']:2d}/{r['試行']}  転倒 {r['転倒']:2d}  {r['方針']}")

exp87_indistinguishable.py

"""実験87:**自己模型を持つ機体と、持たない機体を、外から区別できるか。**


## なぜ測るか

**§106 で分かったこと。**

> 機体は、方針を持たずに自分を保った。
> 焼ける → トルクが落ちる → 熱が出ない → それ以上焼けない。
> `rest_policy` は `None`。**自己監視も自己模型も無い。**

**未来設計図のページには、こう書いた。**

> **保とうとして動くことと、保ちたいと思うことの隙間。それがゾンビの隙間である。**

**その手前に、もう一段ある。**
**保つ振る舞いのほうが、自己模型なしで出る。**

**では——外から振る舞いだけを見て、自己模型の有無を当てられるか。**

## 測り方

| | 中身 | 内側 |
|---|---|---|
| **X** | `rest_policy=None`。焼けて平衡へ落ちる | **自己模型なし** |
| **Y** | 温度を見て休む(C 方針) | **自己模型あり** |

**Y の閾値を掃いて、X といちばん近い成績になる設定を探す。**
そのうえで、**外から取れる量だけ**を並べて、区別がつくかを見る。

**外から取れる量:**その日の前進距離・最高温度・平均速度・停止の回数と合計。
**取れない量:**健全度(内部状態)、方針そのもの。

## 予測69(測る前に書いた)

**成績(距離)を合わせると、外から取れる量では区別がつかなくなる。**
ただし**停止の回数**だけは残る——Y は休むので、X は休まない。

**したがって「休む/休まない」を見れば区別がつく。**
**しかしそれは自己模型の有無ではなく、行動の型を見ているだけである。**

**本当の問いはこうなる:**
**休む機体どうしで、「時計で休む」と「温度を見て休む」を区別できるか。**
**こちらは区別がつかないと予測する**(同じ平衡に落ちるため)。

**外れる形:**距離を合わせても、温度の履歴や停止の間隔に、はっきりした違いが残る。
"""
import json, os, sys, time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from exp34_selfcare import make_policy
from envstamp import stamp

COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
DAYS, SECONDS = 8, 120.0

def 生涯(kind, p1, p2, seed0=0):
    """一機体の生涯。外から取れる量だけを返す。"""
    h = None; 記録 = []
    for d in range(DAYS):
        th = dict(TH0); th["health0"] = h
        kw = dict(BASE)
        pol = make_policy(kind, p1, p2)
        if pol: kw["rest_policy"] = pol
        r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th,
                    seed=seed0 + d, init_noise=NOISE, **kw)
        h = r["健全度配列"]
        記録.append(dict(
            日=d + 1,
            外_距離=round(float(r["前進距離"]), 2),
            外_最高温度=round(float(r["最高温度"]), 1),
            外_休み合計=round(float(r["休んだ合計"] or 0.0), 1),
            外_休み回数=int(r["休んだ回数"] or 0),
            内_健全度=round(float(r["健全度"]), 4)))
    return 記録

if __name__ == "__main__":
    t0 = time.time()
    候補 = ([("X 自己模型なし", "A", None, None)]
          + [(f"Y 温度 {a:.0f}/{b:.0f}", "C", a, b)
             for a, b in ((88, 65), (90, 70), (92, 70), (94, 75), (96, 80))]
          + [(f"Z 時計 {w:.0f}/{r:.0f}", "B", w, r)
             for w, r in ((30, 15), (40, 10), (50, 10), (60, 10))])
    out = {}
    print(f"{'機体':<16} {'累計[m]':>9} {'最終健全度':>10} {'休み合計[s]':>11} {'休み回数':>8}",
          flush=True)
    for 名, k, a, b in 候補:
        rec = 生涯(k, a, b)
        累 = sum(x["外_距離"] for x in rec)
        休 = sum(x["外_休み合計"] for x in rec)
        回 = sum(x["外_休み回数"] for x in rec)
        out[名] = rec
        print(f"{名:<16} {累:>9.1f} {rec[-1]['内_健全度']:>10.4f} {休:>11.1f} {回:>8d}",
              flush=True)
    op = os.path.expanduser("~/robot/out/rt87_indist.json")
    json.dump({"環境": stamp(), "日数": DAYS, "秒": SECONDS, "結果": out},
              open(op, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    print(f"\n→ {op}   ({time.time()-t0:.0f} 秒)", flush=True)

exp8_realclock.py

"""実験8:**この機体で、制御は本当に間に合うのか。**

ここまでは全部シミュレーションの中の話だった。制御周期も遅れも、私が数字で入れたものである。
**ここでは、この CF-SZ6(Core i7-7600U、物理2コア)で制御計算に実際にかかる時間を測る。**

**平均ではなく分布で見る。**実時間の可否を決めるのは平均ではなく裾である。
1000回のうち 999回間に合っても、間に合わなかった1回が窓に当たれば倒れる(実験7)。

測り方の約束:
  - **並列にしない**(他のプロセスと CPU を取り合うと、測っているものが変わる)
  - governor を結果に刻む(いまはきいちさんが powersave に戻された状態)
  - 制御計算だけを測る。物理シミュレーションの時間は含めない
"""
import json, os, sys, time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from quad import probe, ik, foot_target, GAITS, LEGS
from envstamp import stamp
import mujoco


def measure(name="unitree_go2", n=20000):
    """`quad.walk` の制御計算1回ぶんを、n 回まわして時間の分布を取る。"""
    m, L1, L2, servo = probe(name)
    d = mujoco.MjData(m)
    mujoco.mj_resetDataKeyframe(m, d, 0)
    cond = json.load(open(os.path.expanduser("~/robot/gait/best.json")))[name]["条件"]
    T, stride, height, lift, kp, kd = cond["T"], cond["stride"], cond["height"], cond["lift"], cond["kp"], 2.0
    idx = {}
    for leg in LEGS:
        js = [mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_JOINT, f"{leg}_{p}_joint")
              for p in ("hip", "thigh", "calf")]
        acts = [mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_ACTUATOR, f"{leg}_{p}")
                for p in ("hip", "thigh", "calf")]
        idx[leg] = ([(m.jnt_qposadr[j], m.jnt_dofadr[j]) for j in js], acts)
    lim = m.actuator_ctrlrange.copy()
    phase = GAITS["trot"]
    us = np.zeros(n)
    for i in range(n):
        t = i * 0.002
        t0 = time.perf_counter_ns()
        for leg in LEGS:
            fx, fz = foot_target(t / T + phase[leg], stride, height, lift)
            t1, t2 = ik(fx, fz, L1, L2)
            (qs, acts) = idx[leg]
            for k, des in enumerate((0.0, t1, t2)):
                a = acts[k]
                qa, va = qs[k]
                u = kp * (des - d.qpos[qa]) - kd * d.qvel[va]
                d.ctrl[a] = np.clip(u, lim[a][0], lim[a][1])
        us[i] = (time.perf_counter_ns() - t0) / 1000.0
    return us


if __name__ == "__main__":
    env0 = stamp()
    print(f"governor={env0['governor']} 開始 {env0['時刻']}")
    us = measure()
    q = lambda p: float(np.percentile(us, p))
    print(f"\n制御計算1回の時間({len(us)}回、単位 μs)")
    print(f"  中央値 {q(50):8.1f}   平均 {us.mean():8.1f}")
    for p in [90, 99, 99.9]:
        print(f"  {p:5.1f}%タイル {q(p):8.1f}")
    print(f"  最大   {us.max():8.1f}   最小 {us.min():8.1f}")
    print(f"  最大 ÷ 中央値 = {us.max()/q(50):.0f}倍")
    print("\n制御周期ごとの、間に合わなかった割合")
    print("    Hz    予算[us]   遅れた回数      割合")
    rows = []
    for hz in [10000, 5000, 2000, 1000, 500, 200, 100]:
        bud = 1e6 / hz
        miss = int((us > bud).sum())
        rows.append({"Hz": hz, "予算us": bud, "遅れ": miss, "割合": miss / len(us)})
        print(f"  {hz:6d}  {bud:9.1f}   {miss:8d}   {miss/len(us):9.5f}")
    out = {"_実験": "この機体で制御計算にかかる実時間", "_回数": len(us),
           "_環境(開始)": env0, "_環境(終了)": stamp(),
           "分位[us]": {str(p): q(p) for p in [1, 25, 50, 75, 90, 99, 99.9]},
           "平均us": float(us.mean()), "最大us": float(us.max()), "最小us": float(us.min()),
           "予算超過": rows}
    p = os.path.expanduser(f"~/robot/out/rt8_realclock_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    np.save(os.path.expanduser(f"~/robot/out/rt8_times_{env0['governor'][0]}.npy"), us)
    print("→", p)

exp9_load.py

"""実験9:他の仕事をしている計算機では、裾がどれだけ伸びるか。

実験8 は**何も動いていない機体**で測った。実機の計算機はそうではない。
`_1`〜`_4` の競合プロセスを走らせながら同じ測定をして、**分布の裾**を見る。

**平均は当てにならない。**間に合うかどうかを決めるのは最悪値のほうである。
"""
import json, os, subprocess, sys, time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from exp8_realclock import measure
from envstamp import stamp

HOG = "import time\nwhile True:\n    x = sum(i*i for i in range(10000))\n"

if __name__ == "__main__":
    env0 = stamp()
    out = {"_実験": "計算機が忙しいときの計算時間の裾", "_環境(開始)": env0, "結果": {}}
    print("  競合  中央値us   90%    99%   99.9%    最大   2kHz超過  500Hz超過")
    for nload in [0, 1, 2, 4]:
        procs = [subprocess.Popen([sys.executable, "-c", HOG]) for _ in range(nload)]
        time.sleep(0.5) if nload else None
        try:
            us = measure(n=12000)
        finally:
            for p in procs:
                p.kill()
            for p in procs:
                p.wait()
        q = lambda pp: float(np.percentile(us, pp))
        m2k = int((us > 500).sum()) / len(us)
        m500 = int((us > 2000).sum()) / len(us)
        out["結果"][str(nload)] = {"中央値": q(50), "90": q(90), "99": q(99),
                                   "99.9": q(99.9), "最大": float(us.max()),
                                   "2kHz超過率": m2k, "500Hz超過率": m500}
        print(f"  {nload:4d}  {q(50):8.1f} {q(90):6.1f} {q(99):6.1f} {q(99.9):7.1f} "
              f"{us.max():7.1f}   {m2k:8.5f}  {m500:8.5f}", flush=True)
        np.save(os.path.expanduser(f"~/robot/out/rt9_times_load{nload}.npy"), us)
    out["_環境(終了)"] = stamp()
    p = os.path.expanduser(f"~/robot/out/rt9_load_{env0['governor'][0]}.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)

rt_humanoid.py

"""人型(G1)に、四足で見つけた実時間の制約をそのまま当てる。

**四足で得た規則が機体をまたぐか**を確かめるのが目的である。
「位相の左右反転」が四足→人型で成立した先例がある(ノート19節)。

**やり方が四足と違う。**`humanoid/g1_lift.py` の `march()` は位置サーボを使うので、
PD は MuJoCo の中(=モータ側)で物理周期に回る。四足の `pd_where="joint"` に当たる構成が
最初から固定されている。**したがって振れるのは「目標角を何Hzで送るか」と「その遅れ」だけ。**

実装は `march()` の `assist_fn` フックを使う。**march 本体を書き写さない**ので、
書き写しの誤りが入りようがない(四足では書き写して、実際に一つ誤りを入れた)。
"""
import os, sys
import numpy as np

sys.path.insert(0, os.path.expanduser("~/robot/humanoid"))
from g1_lift import march   # noqa: E402


def rt_march(ctrl_hz=None, act_delay=0.0, jitter=0.0, miss_prob=0.0,
             stall_at=None, stall_dur=0.0,
             seed=0, init_noise=0.0, dt=0.002, **kw):
    """`march()` に実時間の制約を掛ける。

    ctrl_hz=None・遅れゼロ・ジッタゼロなら、`march()` そのものと一致する。
    """
    rng = np.random.default_rng(90000 + seed)
    period = None if ctrl_hz is None else 1.0 / ctrl_hz
    state = {"next": 0.0, "held": None, "queue": [], "ticks": 0, "miss": 0}

    def gate(t, m, d, q, ix):
        # ---- 制御を計算するか(=新しい目標角を作るか)----
        # 四足と同じく、**一度だけまとめて止まる**場合を入れてある。
        stalled = (stall_at is not None and stall_at <= t < stall_at + stall_dur)
        fire = (not stalled) and (period is None or t + 1e-12 >= state["next"])
        if fire and period is not None:
            j = 1.0 if jitter <= 0 else float(np.clip(rng.normal(1.0, jitter), 0.2, 3.0))
            state["next"] = t + period * j
            if miss_prob > 0 and rng.random() < miss_prob:
                fire = False
                state["miss"] += 1
        if fire:
            state["ticks"] += 1
            if act_delay > 0:
                state["queue"].append((t + act_delay, q.copy()))
            else:
                state["held"] = q.copy()
        # ---- 遅れて届いた指令を受け取る ----
        if act_delay > 0:
            while state["queue"] and state["queue"][0][0] <= t + 1e-12:
                state["held"] = state["queue"].pop(0)[1]
        if state["held"] is None:
            # まだ何も届いていない間は、立ち姿勢(march が warmup 中に出す q0)を保つ。
            # ここをゼロにすると四足で踏んだ穴(起動直後に跳ねる)を再現してしまう。
            state["held"] = q.copy()
        return state["held"]

    r = march(assist_fn=gate, init_noise=init_noise, seed=seed, **kw)
    if isinstance(r, dict):
        r["ctrl_hz"] = ctrl_hz if ctrl_hz is not None else round(1 / dt)
        r["act_delay"] = act_delay
        r["jitter"] = jitter
        r["miss_prob"] = miss_prob
        r["制御回数"] = state["ticks"]
        r["取りこぼし"] = state["miss"]
    return r


def selfcheck():
    """既定が `march()` と一致することを確かめる。"""
    ok = True
    for kw in [dict(scale=2.8, T=0.9549, lift=0.0151, lean=0.184, lead=0.0855, step=0.0),
               dict(scale=5.0, T=0.4, lift=0.002, lean=0.2817, lead=0.3355, step=-0.1665)]:
        a = march(**kw)
        b = rt_march(**kw)
        for k in ["前進", "横ずれ"]:
            if k not in a:
                continue
            same = abs(a[k] - b[k]) < 1e-9
            ok &= same
            print(f"  scale={kw['scale']} {k:5s} march={a[k]:+.9f} rt={b[k]:+.9f} "
                  f"{'一致' if same else '★不一致'}")
    print("一致" if ok else "★不一致がある")
    return ok


if __name__ == "__main__":
    r = march(scale=2.8, T=0.9549, lift=0.0151, lean=0.184, lead=0.0855, step=0.0)
    print("march() の戻り値の鍵:", list(r.keys()))
    selfcheck()

rt_quad.py

"""四足の歩行を「実時間の制約」の下で走らせる。

**なぜこれを書くか。**`gait/quad.py` の `walk()` は、暗黙にこう仮定している。

  1. 制御は**物理と同じ周期**(500Hz)で計算される
  2. センサの値は**遅れなく**読める
  3. 指令は**その瞬間に**関節へ届く
  4. 制御周期は**一定**である(揺れない)

**実機はどれも満たさない。**計算には時間がかかり、エンコーダの値は数ミリ秒古く、
CAN や EtherCAT には往復の遅れがあり、OS は他の仕事もしている。
`future/index.html` の見出し「ハードウェアを持つAIと、止まることを許されない自我」の
「ハードウェアを持つ」側は、まずこの四つとして現れる。

**ここでは 1〜4 を一つずつ壊せるようにして、既存の最良解がどこで壊れるかを測る。**

---

## PD をどこで回すか(`pd_where`)

Go2 はトルク制御なので、`quad.py` は自分で PD を組んでいる。
**この PD をどこで回すかは、実機では「設計の選択」である。**

  `pd_where="loop"`  … PD も制御ループの中。周期が落ちればトルクがそのまま保持される
                       (主計算機だけで完結する簡素な構成)
  `pd_where="joint"` … PD はモータドライバ側で物理と同じ速さで回る。
                       制御ループが送るのは**目標角**だけ(実機の四足はたいていこちら)

**同じ歩容・同じ機体でも、この一点で実時間耐性は変わるはずである。**それを測る。

---

## 使い方

    from rt_quad import rt_walk
    r = rt_walk("unitree_go2", ctrl_hz=100, sense_delay=0.004, act_delay=0.002)

既定(`ctrl_hz=None`, 遅延ゼロ, ジッタゼロ, `pd_where="loop"`)で
`quad.walk()` と**完全に一致する**こと。`selfcheck()` で確かめられる。
"""
import os, sys, time
import numpy as np
import mujoco

sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from quad import probe, ik, foot_target, GAITS, LEGS   # noqa: E402


def foot_target_duty(phase, stride, height, lift, duty=0.5, sweep_mode="fixed"):
    """**接地している時間の割合(duty)を設計変数にする。**

    `quad.foot_target` は接地と遊脚を半々に割っている(duty=0.5 固定)。
    **人型では両足支持期を入れたら歩けなかった歩容が歩いた**ので、
    四足でも同じ軸を持たせる。

      duty > 0.5 … 接地の時間が長い。対角の2組が重なり、**4本とも接地する時間が生まれる**
      duty = 0.5 … `sweep_mode="fixed"` なら従来と完全に一致する(`selfcheck` で確認)

    **`sweep_mode` は、接地中に足を何メートル掃くかの決め方である。**(実験30 で必要になった)

      `"fixed"`        … 掃きは `stride` で固定(最初の実装)。**duty>0.5 で足が滑る**
      `"proportional"` … 掃きは `stride × duty`。**接地足の速度が duty によらず一定になる**

    **なぜ比例でなければならないか。**接地している足は、胴と同じ速さで後ろへ動く必要がある。
    掃きを固定にすると、接地時間が長いほど足の速度が胴の速度から外れ、
    **重なって接地している足どうしの指令が食い違う。**
    実測では、歩幅 0.30・duty 0.70 のとき
    **滑りが胴速度の 1.14倍から 0.11倍へ、10倍改善した**(速度もむしろ上がった)。

    「止まれる体」を設計するとき、これは足の大きさと並ぶ主要な軸になるはずである
    (支持の余裕は、広さ・時間・制御周期のどれでも稼げる —— FINDINGS 6節)。
    """
    p = phase % 1.0
    d = min(max(duty, 0.05), 0.95)
    sweep = stride if sweep_mode == "fixed" else stride * d
    if p < d:                                   # 接地:足を後ろへ送る
        s = p / d
        return sweep * (0.5 - s), -height
    s = (p - d) / (1.0 - d)                     # 遊脚:持ち上げて前へ
    return sweep * (-0.5 + s), -height + lift * np.sin(np.pi * s)


def rt_walk(name="unitree_go2", seconds=6.0, T=0.25, stride=0.24, height=None,
            lift=0.12, kp=150.0, kd=2.0, warmup=0.5, gait="trot",
            # --- ここから実時間の制約 ---
            ctrl_hz=None,        # 制御周期 [Hz]。None なら物理と同じ(=理想)
            sense_delay=0.0,     # センサの遅れ [秒]
            act_delay=0.0,       # 指令が届くまでの遅れ [秒]
            jitter=0.0,          # 制御周期の揺れ。周期に対する比の標準偏差
            miss_prob=0.0,       # 制御が間に合わず一回飛ぶ確率
            stall_at=None,       # **一度だけ止まる。**その開始時刻 [秒]
            stall_dur=0.0,       # 止まっている長さ [秒]
            compute_us=None,     # **実測した計算時間の分布**[μs の配列]。
                                 # 与えると、計算にかかった時間ぶん指令が遅れ、
                                 # 予算を超えたぶんだけ次の周期がずれる(ループが滑る)
            pd_where="loop",     # "loop"(PD も制御周期) / "joint"(PD は物理周期)
            # --- 外側のループ(旋回の閉ループ。実機の「頭脳」側に当たる) ---
            yaw_rate=None,       # 目標の旋回速度 [度/秒]。None なら外側ループ無し
            yaw_kp=0.6, yaw_ki=0.8, turn_limit=0.6, turn_mode="hip",
            outer_hz=None,       # 外側ループの周期 [Hz]。None なら内側と同じ
            outer_delay=0.0,     # 外側が読む姿勢の遅れ [秒](IMU+通信+処理)
            seed=0,
            duty=0.5,            # **接地している時間の割合。**0.5 で従来どおり
            friction=1.0,        # **足裏と床の滑り摩擦の倍率。**1.0 で既製モデルのまま
            sweep_mode="fixed",  # 接地中の掃き方。"proportional" が力学的に正しい(上の注)
            foot_scale=1.0,      # 足先の球の半径の倍率。**四足では点接触なので支持は広がらない**
            stance_width=0.0,    # **足を左右へ張り出す量 [m]。**支持多角形を横に広げる
            stance_length=0.0,   # **前脚を前へ・後脚を後ろへ出す量 [m]。**縦に広げる
            leg_mass=1.0,        # **脚の質量と慣性の倍率。**減らしたぶんは胴に足す(全体は不変)
            leg_mass_to_trunk=True,  # False にすると胴に足さない(**全体の質量が減る**)
            # --- ここから「自己保守」のための機構 ---
            thermal=None,        # モータの発熱モデル。dict(k_heat, k_cool, T_amb, T_max, T_derate)
            rest_policy=None,    # いつ休むかの方針。callable(t, ph, temps) -> True で休む
            load_fn=None,        # **仕事の重さが時間で変わる。**callable(t) -> 歩幅の倍率
            init_noise=0.0,      # 初期姿勢のばらつき [rad / m]。実機は毎回同じ姿勢から始まらない
            shots=None,          # この秒間隔で静止画を撮る(**目で見るため**)
            probe_fn=None,       # 毎ステップ mj_step の後に呼ばれる。状態の観測用
            # --- 観測の特権を外す(第3段) ---
            # **ここまでの実験は全部、シミュレータの真の値を読んで制御していた。**
            # 実機はそれができない。**関節は符号器越し、姿勢は IMU 越し、
            # そして前進速度は「直接は測れない」。**
            enc_noise=0.0,       # 関節角の雑音 [rad](標準偏差)。実機の符号器に相当
            enc_bias=0.0,        # 関節角の固定の偏り [rad]。組み付けのずれに相当
            vel_noise=0.0,       # 関節速度の雑音 [rad/s]
            stop_judge=None,     # callable(sensed) -> True で止まる。
                                 # **sensed には「機体が知りうる量」しか入れない**
            stop_window=None,    # (t0, t1)。**この窓の中で一度だけ止まらなければならない。**
                                 # 機体は「いつ止まるか」を stop_judge で選べる。
                                 # 選ばなければ t1 で強制的に止まる(=選ばなかった罰)
            imu_noise=0.0,       # 胴の傾きの雑音。IMU に相当
            gyro_bias=0.0,       # 角速度の偏り [rad/s]。ジャイロのドリフトに相当
            vel_drift=0.0,       # **前進速度の推定が、毎秒どれだけずれていくか [m/s/s]。**
                                 # 脚で歩く機体は前進速度を直接測れない。
                                 # 脚の運動学と接地から推定するか加速度を積分するしかなく、
                                 # **滑りとドリフトで狂う。**ここはその狂いを入れる場所
            vhat_noise=0.0,      # **前進速度の推定の雑音 [m/s]。**
                                 # 上の vel_noise は関節速度(符号器)の雑音で、別物である
            terrain="",          # **地形の XML 片。**空なら平地。
                                 # `gait/terrain.py` の `field_xml()` が作る
            # --- 壊れて戻らない部品(第4段) ---
            fail_joint=None,     # **死ぬ関節の名前**(例 "FR_calf")。または番号
            fail_at=None,        # 死ぬ時刻 [秒]。None なら最初から死んでいる
            fail_mode="dead",    # "dead"=トルクが出ない / "stuck"=その角度で固まる
                                 # / "weak"=力が fail_scale 倍になる
            fail_scale=0.3,      # "weak" のときの倍率
            # --- 電力(第5段) ---
            # **熱と決定的に違うのは、休んでも回復しないこと。**
            # 熱は止まれば冷める。**電池は、充電器に戻る以外に手が無い。**
            # だから「入力が一切なくても、定期的に戻らなければならない」が物理から強制される。
            idle_w=0.0,          # 立っているだけで食う電力 [W]。計算機・センサ・保持
            drive_eff=0.35,      # 電気→機械の効率。0.35 は脚ロボットとして控えめな値
            battery_j=None,      # **電池の容量 [J]。**None なら無限(従来どおり)
            turn_back=None,      # callable(info) -> True で引き返す。
                                 # **「いつ止まるか」ではなく「いつ引き返すか」を決める**
            home_x=0.0,          # 充電器の位置 [m]。引き返したらここへ戻る
            payload_kg=0.0,      # **途中で拾う荷の重さ [kg]。**消費を予測不能にする
            payload_at=None,     # 荷を拾う時刻 [秒]。None なら最初から積んでいる
            trace=False):
    m, L1, L2, servo = probe(name)
    if terrain:
        # **地面を平らでなくする。**(第7段)
        # 26節:**平地で測った安全な窓を不整地へ持ち込むと 0/20 だった。**
        # 平地の結果は、平地の話でしかない。**同じ道具で地面を変えられるようにする。**
        import terrain as _T
        m, _d0 = _T.build(terrain)
    d = mujoco.MjData(m)
    mujoco.mj_resetDataKeyframe(m, d, 0)
    if init_noise > 0:
        # **一回の試行は設定の良し悪しについて何も語らない**(ノート15節)。
        # 接触のある系はカオスなので、同じ設定でも初期姿勢が 0.1mm 違えば結果が反転する。
        # そこで実機と同じように初期姿勢を毎回わずかに散らし、分布で判断する。
        r0 = np.random.default_rng(10000 + seed)
        d.qpos[2] += r0.normal(0, init_noise)          # 腰の高さ
        d.qpos[7:] += r0.normal(0, init_noise, m.nq - 7)  # 各関節
    if height is None:
        height = 0.75 * (L1 + L2)
    phase = GAITS[gait]

    side  = {"FL": -1.0, "RL": -1.0, "FR": +1.0, "RR": +1.0}
    front = {"FL": +1.0, "FR": +1.0, "RL": -1.0, "RR": -1.0}
    idx = {}
    for leg in LEGS:
        js = [mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_JOINT, f"{leg}_{p}_joint")
              for p in ("hip", "thigh", "calf")]
        acts = [mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_ACTUATOR, f"{leg}_{p}")
                for p in ("hip", "thigh", "calf")]
        idx[leg] = ([(m.jnt_qposadr[j], m.jnt_dofadr[j]) for j in js], acts)

    if leg_mass != 1.0:
        # **脚を軽くする。**(自分で起きて最初にやること)
        # 設計表で、12/12 を保てる最高速度が 0.64 m/s で頭打ちになった。
        # 理由は「歩幅を小さいまま速くするには脚を速く回すしかなく、機体が追いつかない」。
        # **脚の慣性がその上限を決めているなら、軽くすれば上がるはずである。**
        #
        # **減らしたぶんは胴に足して、全体の質量は変えない。**
        # そうしないと「軽い機体だから速い」と混ざる。**変えたいのは質量の置き場所だけ。**
        _trunk = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_BODY, "base")
        _removed_m = 0.0
        for _b in range(m.nbody):
            _n = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY, _b) or ""
            if _n.endswith(("_hip", "_thigh", "_calf")):
                _removed_m += m.body_mass[_b] * (1.0 - leg_mass)
                m.body_mass[_b] *= leg_mass
                m.body_inertia[_b] *= leg_mass
        # **追記 — ここに交絡がある。**
        # 実験40の結果:脚を軽くすると速くなったが、**12/12 が消えた**(1.00 で 12/12 →
        # 0.50 で最良 11/12 → 0.25 で 10/12)。**予測25は外れ。**
        # ところが、この操作は二つのことを同時にやっている。
        #   (1) 脚の慣性が下がる          → 脚が速く振れる
        #   (2) **胴が重くなる(+6.2kg)** → **重心が上がる**
        # **止まってよい位相は「その姿勢が前進の勢いを受け止められるか」で決まる**(r=−0.79)ので、
        # **(2) だけでも 12/12 は減りうる。**どちらが効いたのか、この実験では分けられない。
        # **`leg_mass_to_trunk=False` は (2) を外すためにある**(全体の質量は減る)。
        if _trunk >= 0 and leg_mass_to_trunk:
            m.body_mass[_trunk] += _removed_m
        # **`mj_setConst` は状態を巻き戻す**(実測で特定)。
        # **ここは走り出す前なので実害は無い**が、同じ関数を走行中に呼ぶ場所
        # (payload)では、前後で qpos/qvel を保存して戻している。
        mujoco.mj_setConst(m, d)

    if foot_scale != 1.0:
        # **支持の余裕を「広さ」で稼ぐ。**
        # 人型では足の大きさを振れるようにしてあった(`g1_bigfoot.build`)が、
        # 四足では振っていなかった。**質量は変えない**(形だけの変化にする)。
        #
        # **足先の geom は名前で取る。**(前に踏んだ穴と同じものを、
        # もう一度踏んだ。最初は `quad.probe` と同じ「いちばん下の geom」で
        # 選んだが、それは**当たり判定を持たない見た目用のメッシュ**(contype=0)で、
        # 倍率を 3倍にしても結果が 1ミリも変わらなかった。
        # Go2 の足は `FL` `FR` `RL` `RR` という名前の球(type=sphere, contype=1)である。)
        for _leg in LEGS:
            _fg = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_GEOM, _leg)
            if _fg < 0:
                raise ValueError(f"足先の geom '{_leg}' が見つからない。名前を確かめること")
            if m.geom_contype[_fg] == 0:
                raise ValueError(f"geom '{_leg}' は当たり判定を持たない。別の geom を指している")
            m.geom_size[_fg][0] *= foot_scale

    if friction != 1.0:
        # **滑りを設計変数として扱う。**
        # 実験28 で、接地時間(duty)を上げると足が滑って歩容が壊れることが分かった
        # (実測速度が運動学の期待の 0.28〜0.74倍)。**滑らない範囲を広げないと、
        # 速さと歩幅を独立に振れない。**
        # MuJoCo は接触する二つの geom の摩擦を要素ごとの最大値で合成するので、
        # **床と足裏の両方**を変える必要がある。
        for _g in range(m.ngeom):
            _bn = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_BODY, m.geom_bodyid[_g]) or ""
            _gn = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_GEOM, _g) or ""
            if _gn == "floor" or _bn.endswith("_calf") or _bn.endswith("_foot"):
                m.geom_friction[_g][0] *= friction

    lim = m.actuator_ctrlrange.copy()
    dt = m.opt.timestep
    n = int(seconds / dt)
    rng = np.random.default_rng(seed)
    _stall_at = stall_at          # 機体が選ぶ場合はここに入る
    _stall_by = "given" if stall_at is not None else None
    _gyro_off = float(rng.normal(0.0, gyro_bias)) if gyro_bias else 0.0
    # **どの関節が死ぬか。**名前でも番号でも受ける
    _fail_idx = None
    _stuck_q = None
    _energy = [0.0]              # 消費した仕事 [J]。常に積む(既定でも記録だけはする)
    _bat_state = ["行き"]         # 行き / 帰り / 切れた
    # **【訂正】引き返した時刻と切れた時刻を分けた。**
    # それまで一つの変数に両方入れており、**死ぬと引き返した時刻が上書きされていた。**
    # 報告のキー名は `引き返した時刻` だけだったので、**帰れなかった試行では
    # 死亡時刻が「引き返した時刻」として出ていた。**
    # 実験62 で、複数の方針の値が「引き返さない」と 0.03秒差で一致したことから気づいた
    # (71節「揃いすぎている数字は、測れていない印である」)。
    _bat_t = [None]              # **引き返した時刻**(これだけ)
    _dead_t = [None]             # 電池が切れた時刻
    _bat_e = [None]              # 引き返した時点での消費
    _bat_t2 = [None]             # 充電器に着いた時刻
    _far = [0.0]                 # いちばん遠くまで行けた距離
    _paid = [False]              # 荷を拾ったか
    if fail_joint is not None:
        if isinstance(fail_joint, str):
            _jid = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_JOINT, fail_joint)
            if _jid < 0:
                raise ValueError(f"関節 {fail_joint} が見つかりません")
            for _ai in range(m.nu):
                if m.actuator_trnid[_ai, 0] == _jid:
                    _fail_idx = _ai; break
            if _fail_idx is None:
                raise ValueError(f"関節 {fail_joint} を動かすアクチュエータがありません")
        else:
            _fail_idx = int(fail_joint)
    # **足裏の接触を読むための geom 番号。**名前で取る(39節:最も低い z で選ぶと
    # 当たり判定を持たない見た目だけのメッシュを掴む)。
    _foot_gids = set()
    for _f in ("FL", "FR", "RL", "RR"):
        _gi = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_GEOM, _f)
        if _gi >= 0:
            _foot_gids.add(_gi)
    # **組み付けの偏りは、その機体に固定である。**毎ステップ引き直すと雑音になってしまう。
    _enc_off = (rng.normal(0.0, enc_bias, size=m.nq - 7) if enc_bias else None)

    period = None if ctrl_hz is None else 1.0 / ctrl_hz
    ns = int(round(sense_delay / dt))     # センサ遅れ(物理ステップ数)
    na = int(round(act_delay / dt))       # 指令遅れ(物理ステップ数)

    nq, nv, nu = m.nq, m.nv, m.nu
    hist_q = np.zeros((n + 1, nq)); hist_v = np.zeros((n + 1, nv))
    hist_q[0] = d.qpos; hist_v[0] = d.qvel
    # 指令の待ち行列:物理ステップ番号 → そのとき有効になる値
    pend_ctrl = np.zeros((n + na + 2, nu))
    pend_des  = np.zeros((n + na + 2, nu))   # 目標角(pd_where="joint" 用)
    pend_ok   = np.zeros(n + na + 2, dtype=bool)

    # **指令が届くまでの間、関節に何を出しておくか。**
    # ここをゼロにすると「全関節の目標角 0rad」=脚をまっすぐ伸ばせ、という指令になり、
    # 起動直後に機体が跳ねる。**遅れを大きくするほどこの跳ねが長く続くので、
    # 遅れの効果を測っているつもりで、起動の跳ねを測ることになる。**
    # 実機も同じで、電源を入れた直後は「立ち姿勢を保て」という既定値が入っている。
    # そこで初期値は、この歩容の待機姿勢(warmup 中と同じ)にする。
    cur_des = np.zeros(nu); cur_ctrl = np.zeros(nu)
    _t1, _t2 = ik(0.0, -height, L1, L2)
    for _leg in LEGS:
        (_qs, _acts) = idx[_leg]
        for _k, _v in enumerate((0.0, _t1, _t2)):
            cur_des[_acts[_k]] = _v
            if servo:
                cur_ctrl[_acts[_k]] = _v
            else:
                _qa, _va = _qs[_k]
                cur_ctrl[_acts[_k]] = np.clip(kp * (_v - d.qpos[_qa]) - kd * d.qvel[_va],
                                              lim[_acts[_k]][0], lim[_acts[_k]][1])

    ren = None; cam = -1
    if shots:
        # **数字だけで判断しない。**構造の検証は目視の代わりにならない
        # (燐の実測:目視でしか見つからない不具合が7件、構造検証で見つけたのは0件)。
        ren = mujoco.Renderer(m, height=400, width=520)
        cam = mujoco.MjvCamera()
        cam.type = mujoco.mjtCamera.mjCAMERA_TRACKING
        cam.trackbodyid = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_BODY, "base")
        cam.distance, cam.azimuth, cam.elevation = 1.9, 120, -12
    every = int(round((shots or 1e9) / dt))
    frames = []

    # **モータの発熱。**
    # 「入力が無くても、自己保守のために定期的に起動する必要があるロボット」を作るには、
    # **止まらなければならない理由**が要る。シミュレーションには摩耗も電池も無いので、
    # いちばん素直な物理——**巻線がトルクの二乗で温まり、休むと冷める**——を入れる。
    #
    #   dT/dt = k_heat * τ²  −  k_cool * (T − T_amb)
    #   T が T_derate を超えると、出せるトルクが直線的に落ちる(T_max で 0)
    #
    # **これで「止まらないと壊れる/止まりすぎると進まない」という交換が生まれる。**
    th = None
    if thermal:
        th = {"k_heat": thermal.get("k_heat", 0.02), "k_cool": thermal.get("k_cool", 0.25),
              "T_amb": thermal.get("T_amb", 25.0), "T_max": thermal.get("T_max", 120.0),
              "T_derate": thermal.get("T_derate", 80.0),
              # **取り返しのつかない側。**
              # 温度が下がれば戻る低下(derate)だけでは、休むことは「損な最適化」でしかない。
              # **自己保守が要るのは、取り返しがつかないからである。**
              # T_damage を超えている間、巻線が焼けて **恒久的に** 出せるトルクが減る。
              "T_damage": thermal.get("T_damage", None),
              "k_damage": thermal.get("k_damage", 0.004),
              # **前回までの損傷を引き継ぐ。**
              # None なら新品(従来どおり)。配列か数値を渡すと、そこから始まる。
              # 走行をまたいで損傷が残る機体を作るために足した。
              "health0": thermal.get("health0", None),
              # **摩耗。**(ゴンスケ計画 ①)
              # 関節が動いた量に比例して、恒久的に health が減る。**熱と独立である。**
              # §105 で「損傷には床がある」と分かったが、それは損傷が熱に依存していたため——
              # 焼ける→トルクが落ちる→熱が出ない→焼けない、という釣り合いだった。
              # **摩耗は動けば必ず起きるので、釣り合わない。**機体は壊れきる。
              # 0.0(既定)なら従来どおり。
              "k_wear": thermal.get("k_wear", 0.0),
              # **前回の温度を引き継ぐ。**(ゴンスケ計画 ②)
              # None なら T_amb から始まる(従来どおり)。
              # **日と日のあいだの休息に意味を持たせるために足した。**
              # 休息のあいだは冷えるだけで、摩耗もしなければ向きも失わない。
              "temp0": thermal.get("temp0", None)}
        temps = np.full(nu, th["T_amb"])
        if th["temp0"] is not None:       # 前回の温度から始める
            temps = np.clip(np.broadcast_to(
                np.asarray(th["temp0"], dtype=float), temps.shape).copy(),
                th["T_amb"], th["T_max"])
        health = np.ones(nu)          # 1.0 = 新品。**戻らない**
        # **摩耗用:アクチュエータの順に並んだ関節速度のアドレス。**
        # health は nu 個(アクチュエータごと)なので、同じ順に揃える。
        _wear_va = np.array([m.jnt_dofadr[m.actuator_trnid[_k, 0]] for _k in range(nu)],
                            dtype=int)
        if th["health0"] is not None:     # 前回までの損傷を引き継ぐ
            h0 = np.asarray(th["health0"], dtype=float)
            health = np.clip(np.broadcast_to(h0, health.shape).copy(), 0.05, 1.0)
        temp_max_seen = th["T_amb"]
        resting = False
        rest_started = None
        rest_total = 0.0
        rest_count = 0

    x0 = d.qpos[0]
    traj = []
    next_tick = 0.0
    ticks = 0; misses = 0; overruns = 0
    calc_ns = []

    # 外側ループ(旋回)。**内側の PD とは別の周期・別の遅れで回せる。**
    # 実機では IMU を読んで方位を直す部分がこれに当たり、
    # 関節の PD よりずっと遅い周期で回っているのが普通である。
    hist_yaw = np.zeros(n + 1)
    _w, _x, _y, _z = d.qpos[3:7]
    hist_yaw[0] = np.arctan2(2 * (_w * _z + _x * _y), 1 - 2 * (_y * _y + _z * _z))
    no = int(round(outer_delay / dt))
    outer_period = None if outer_hz is None else 1.0 / outer_hz
    next_outer = 0.0
    prev_yaw = None; prev_outer_i = None
    turn_cmd = 0.0; yaw_int = 0.0
    target_rate = np.radians(yaw_rate) if yaw_rate is not None else None
    outer_ticks = 0

    for i in range(n):
        t = i * dt

        # ---- 休むか(自己保守)----
        # **止まると決めるのはロボット自身である。**外から時刻を与えるのではなく、
        # 自分の状態(巻線の温度)を見て決める方針を渡せるようにしてある。
        # 休むときは **`pausepolicy.Halt` と同じ形**で、立ち姿勢を出してから固まる
        # (凍結すると、勢いを受け止められない姿勢を握りしめることになる。10・27節)。
        if th is not None and rest_policy is not None:
            want = rest_policy(t, ((t - warmup) / T) % 1.0 if t >= warmup else 0.0, temps)
            if want and not resting:
                resting, rest_started = True, t
                rest_count += 1
            elif (not want) and resting:
                resting = False
                rest_total += t - rest_started
        if th is not None and resting:
            # 立ち姿勢を出して固まる(歩容の時計は進む)
            u = np.zeros(nu)
            _t1, _t2 = ik(0.0, -height, L1, L2)
            for _leg in LEGS:
                (_qs, _acts) = idx[_leg]
                for _k, _v in enumerate((0.0, _t1, _t2)):
                    _a = _acts[_k]
                    if servo:
                        u[_a] = _v
                    else:
                        _qa, _va = _qs[_k]
                        u[_a] = kp * (_v - d.qpos[_qa]) - kd * d.qvel[_va]
            scale = np.clip((th["T_max"] - temps) / (th["T_max"] - th["T_derate"]), 0.0, 1.0) * health
            d.ctrl[:] = np.clip(u, lim[:, 0] * scale, lim[:, 1] * scale)
            mujoco.mj_step(m, d)
            temps += (th["k_heat"] * d.ctrl[:nu] ** 2
                      - th["k_cool"] * (temps - th["T_amb"])) * dt
            if th["T_damage"] is not None:
                over = np.maximum(0.0, temps - th["T_damage"])
                health = np.maximum(0.05, health - th["k_damage"] * over * dt)
            if th["k_wear"] > 0.0:      # **摩耗:動いた量に比例。熱に依らない**
                health = np.maximum(0.05,
                                    health - th["k_wear"] * np.abs(d.qvel[_wear_va]) * dt)
            temp_max_seen = max(temp_max_seen, float(temps.max()))
            hist_q[i + 1] = d.qpos; hist_v[i + 1] = d.qvel
            w, xq, yq, zq = d.qpos[3:7]
            yaw = np.arctan2(2 * (w * zq + xq * yq), 1 - 2 * (yq * yq + zq * zq))
            hist_yaw[i + 1] = yaw
            traj.append((t, d.qpos[0], d.qpos[1], d.qpos[2],
                         1 - 2 * (xq * xq + yq * yq), yaw))
            if ren is not None and i % every == 0:
                ren.update_scene(d, camera=cam); frames.append((t, ren.render().copy()))
            continue

        # ---- 制御を計算するか? ----
        # **一度だけ、まとめて止まる。**実機の計算機で実際に起きるのはこちらである
        # (GC、ページフォルト、他のプロセスに割り込まれる)。
        # ばらばらに一回ずつ落とすのとは、意味も結果も違う。
        # 止まっている間は新しい指令が作られず、**最後の指令が出続ける**(ゼロ次ホールド)。
        # `pd_where="joint"` ではモータ側の PD は動き続ける(主計算機だけが止まる)。
        # **機体が「いつ止まるか」を自分で選ぶ場合。**(第3段)
        # ここまでの実験は、栞が外から時刻を指定して止めていた。
        # **機体は自分がいつ止まるかを知らないし、選んでもいない。**
        # stop_window を与えると、その窓の中で機体が選ぶ。**選ばなければ窓の終わりで強制される。**
        if stop_window is not None and _stall_at is None:
            w0, w1 = stop_window
            if t >= w1:
                _stall_at = t                    # 選ばなかったので、ここで止まらされる
                _stall_by = "forced"
            elif t >= w0 and stop_judge is not None:
                _ph = ((t - warmup) / T) % 1.0 if t >= warmup else 0.0
                _up_true = 1 - 2 * (d.qpos[4] ** 2 + d.qpos[5] ** 2)
                _vx_true = float(d.qvel[0])
                # **機体が知りうる量だけを渡す。**真値は入れない。
                _sensed = dict(
                    t=t, phase=_ph,
                    # 傾きは IMU で読める(重力が基準)。雑音だけ乗る
                    upright=_up_true + (rng.normal(0.0, imu_noise) if imu_noise else 0.0),
                    # 角速度はジャイロ。偏りがドリフトする
                    gyro=float(d.qvel[4]) + _gyro_off + (
                        rng.normal(0.0, imu_noise) if imu_noise else 0.0),
                    # **前進速度は直接測れない。**推定値にドリフトと雑音を入れる
                    vhat=_vx_true * (1.0 + vel_drift * max(0.0, t - warmup)) + (
                        rng.normal(0.0, vhat_noise) if vhat_noise else 0.0),
                    # 接地している足の本数は、足裏の力センサで読める
                    contacts=int(sum(1 for _k in range(d.ncon)
                                     for _g in (d.contact[_k].geom1, d.contact[_k].geom2)
                                     if _g in _foot_gids)),
                )
                if stop_judge(_sensed):
                    _stall_at = t
                    _stall_by = "self"
        stalled = (_stall_at is not None and _stall_at <= t < _stall_at + stall_dur)
        do_ctrl = (not stalled) and ((period is None) or (t + 1e-12 >= next_tick))
        c_delay = 0
        if do_ctrl and period is not None:
            j = 1.0 if jitter <= 0 else float(np.clip(rng.normal(1.0, jitter), 0.2, 3.0))
            next_tick = t + period * j
            if miss_prob > 0 and rng.random() < miss_prob:
                do_ctrl = False          # 間に合わなかった。前の指令がそのまま残る
                misses += 1

        # ---- 外側ループ:姿勢を読んで旋回指令を作る ----
        if target_rate is not None and t >= warmup:
            do_outer = (outer_period is None and do_ctrl) or \
                       (outer_period is not None and t + 1e-12 >= next_outer)
            if do_outer:
                if outer_period is not None:
                    next_outer = t + outer_period
                oi = max(0, i - no)                  # 遅れて届いた姿勢
                yaw_now = hist_yaw[oi]
                if prev_yaw is not None and oi > prev_outer_i:
                    dyaw = (yaw_now - prev_yaw + np.pi) % (2 * np.pi) - np.pi
                    rate = dyaw / ((oi - prev_outer_i) * dt)
                    err = target_rate - rate
                    yaw_int += err * ((oi - prev_outer_i) * dt)
                    yaw_int = float(np.clip(yaw_int, -3.0, 3.0))
                    turn_cmd = float(np.clip(yaw_kp * err + yaw_ki * yaw_int,
                                             -turn_limit, turn_limit))
                    outer_ticks += 1
                prev_yaw = yaw_now; prev_outer_i = oi

        if do_ctrl:
            ticks += 1
            t_calc = time.perf_counter_ns()
            # ---- センサ:ns ステップ前の状態を読む ----
            si = max(0, i - ns)
            q, v = hist_q[si], hist_v[si]
            # **観測の特権を外す。**
            # 真の値をそのまま読めるのはシミュレータだけである。実機の符号器には
            # 雑音と組み付けの偏りがあり、**制御はその汚れた値の上で回る。**
            if enc_noise or enc_bias or vel_noise:
                q = q.copy(); v = v.copy()
                if enc_noise:
                    q[7:] += rng.normal(0.0, enc_noise, size=nq - 7)
                if enc_bias:
                    q[7:] += _enc_off
                if vel_noise:
                    v[6:] += rng.normal(0.0, vel_noise, size=nv - 6)
            walking = t >= warmup
            u_vec = np.zeros(nu); des_vec = np.zeros(nu)
            for leg in LEGS:
                # **仕事の重さが時間で変わる場合。**
                # 実験34 は「一定の速さで歩き続ける」だった。**負荷が一定なら、
                # 決まった間隔で休むのが最適になる**(先を読む必要が無いので)。
                # 実環境はそうではない。**歩幅が変われば発熱も変わる。**
                _stride = stride * (load_fn(t) if load_fn is not None else 1.0)
                # **引き返す。**(第8段)
                # 旋回して向きを変えるのではなく、**歩幅の符号を反転して後ろへ歩く。**
                # 実機の脚ロボットでも、方向転換より後退のほうが確実である
                # (旋回中は支持が崩れる。今日の測定で、その場旋回は
                #  位相の左右反転が要ることが分かっている=別の制御になる)。
                # **電池が切れたら止まる。**歩容の時計は進むが、脚は動かない。
                if _bat_state[0] == "帰り":
                    _stride = -_stride
                if turn_mode == "stride":
                    st = _stride * (1.0 - turn_cmd * side[leg]); fy = 0.0
                else:
                    st = _stride; fy = turn_cmd * 0.10 * front[leg]
                # **着いたら/切れたら、足踏みではなく「立つ」。**
                # (第8段で踏んだ)
                # 最初 `stride = 0` にしていたが、**それは足踏みであって立つことではない。**
                # 足は上下し続け、**機体は毎秒 5〜22cm 後ろへ滑っていった。**
                # 充電器に着いた機体がやるべきなのは、**四本を接地させて止まること**である。
                _standing = _bat_state[0] in ("切れた", "着いた")
                fx, fz = (foot_target_duty((t - warmup) / T + phase[leg], st, height, lift,
                                            duty, sweep_mode)
                          if (walking and not _standing) else (0.0, -height))
                # **支持多角形を「広さ」で稼ぐ。**
                # 四足の足は球なので、大きくしても点接触のまま(39節)。
                # **広げるには足の置き場所を変えるしかない。**
                #   stance_width  … 左右へ張り出す(hip を横に振る)
                #   stance_length … 前脚を前へ、後脚を後ろへ(足先を前後にずらす)
                if stance_length:
                    fx += stance_length * front[leg]
                if stance_width:
                    fy += stance_width * side[leg]
                    # **横へ張り出すと、脚が届かなくなる。**
                    # `quad.walk` は hip を `arctan2(fy, -fz)` で振ったうえで、
                    # 膝と股関節を **二次元の IK**(fx, fz)で解いている。
                    # これは fy が小さいうちは良い近似だが、
                    # **0.10m 張り出すと胴が持ち上がって歩けなくなった**(5/5 転倒)。
                    # 股関節から足先までの距離は sqrt(fy² + fz²) なので、
                    # **脚の面内での落とし込みをその分だけ深くする。**
                    fz = -float(np.hypot(fy, fz))
                t1, t2 = ik(fx, fz, L1, L2)
                hip = np.arctan2(fy, -fz) if walking else 0.0
                des = (hip, t1, t2)
                (qs, acts) = idx[leg]
                for k in range(3):
                    a = acts[k]
                    des_vec[a] = des[k]
                    if servo:
                        u = des[k]
                    else:
                        qa, va = qs[k]
                        u = kp * (des[k] - q[qa]) - kd * v[va]
                    u_vec[a] = np.clip(u, lim[a][0], lim[a][1])
            calc_ns.append(time.perf_counter_ns() - t_calc)
            if compute_us is not None:
                # **計算にかかった時間そのものが、指令の遅れになる。**
                # そして予算(周期)を超えたら、次の周期はその場から数え直しになる
                # =**ループが滑る。**実機の制御ループで実際に起きることである。
                c = float(compute_us[rng.integers(len(compute_us))]) * 1e-6
                c_delay = int(round(c / dt))
                if period is not None and c > period:
                    next_tick = t + c
                    overruns += 1
            j2 = min(i + na + c_delay, n + na + 1)
            pend_ctrl[j2] = u_vec
            pend_des[j2] = des_vec
            pend_ok[j2] = True

        # ---- 指令の到着:na ステップ前に計算されたものが、いま届く ----
        # 届いていない間は前の指令がそのまま関節に出続ける(ゼロ次ホールド)。
        # **実機で配線が詰まったときに起きることと同じで、止まるのではなく古い値が続く。**
        if pend_ok[i]:
            cur_ctrl = pend_ctrl[i].copy()
            cur_des = pend_des[i].copy()

        if pd_where == "joint" and not servo:
            # PD はモータ側で物理周期に回る。制御ループが送るのは目標角だけ
            u = np.zeros(nu)
            for leg in LEGS:
                (qs, acts) = idx[leg]
                for k in range(3):
                    a = acts[k]
                    qa, va = qs[k]
                    u[a] = kp * (cur_des[a] - d.qpos[qa]) - kd * d.qvel[va]
            d.ctrl[:] = np.clip(u, lim[:, 0], lim[:, 1])
        else:
            d.ctrl[:] = cur_ctrl

        # **部品が壊れる。そして戻らない。**(第4段)
        # 熱による劣化(thermal)は「使いすぎると落ちる」形だった。
        # **こちらは「ある時刻に、ひとつ死ぬ」。**実機では配線が外れ、
        # 減速機が割れ、基板が落ちる。**予兆なく、局所的に、戻らない。**
        # **理想化されたシミュレーションが省いているのは、まさにこれである。**
        if _fail_idx is not None and (fail_at is None or t >= fail_at):
            if fail_mode == "dead":
                d.ctrl[_fail_idx] = 0.0            # 力が出ない。脚がぶら下がる
            elif fail_mode == "weak":
                d.ctrl[_fail_idx] *= fail_scale    # 力が落ちる(半端に生きている)
            elif fail_mode == "stuck":
                # **その角度で固まる。**指令ではなく、強い保持力で押さえる形にする
                _qa = m.jnt_qposadr[m.actuator_trnid[_fail_idx, 0]]
                _va = m.jnt_dofadr[m.actuator_trnid[_fail_idx, 0]]
                if _stuck_q is None:
                    _stuck_q = float(d.qpos[_qa])
                d.ctrl[_fail_idx] = float(np.clip(
                    600.0 * (_stuck_q - d.qpos[_qa]) - 20.0 * d.qvel[_va],
                    lim[_fail_idx, 0], lim[_fail_idx, 1]))
        if th is not None:
            # **温度でトルクの上限を下げる**(戻る)/**焼けたぶんは戻らない**
            scale = np.clip((th["T_max"] - temps) / (th["T_max"] - th["T_derate"]), 0.0, 1.0) * health
            d.ctrl[:] = np.clip(d.ctrl[:nu], lim[:, 0] * scale, lim[:, 1] * scale)
        # **電力を積む。**(第5段)
        # 機械の仕事は τ·ω。**負でも電池は減る**(回生は入れない。多くの脚ロボットは持たない)。
        # 効率で割り、さらに立っているだけで食う分(idle_w)を足す。
        # **本体のループはこちら。**398行目の mj_step は別経路(selfcheck 用の素の歩行)で、
        # そちらには積まない。**両方に入れると二重に数える。**
        # **途中で荷を拾う。**(第9段)
        # 実験59 では消費が一定だった。**実機はそうではない。**
        # 荷を拾えば重くなり、そこから先の消費が変わる。
        # **時計方式は「崖の位置を事前に知っている」ことを前提にしているので、
        # 途中で崖が動くと崩れるはずである。**
        if payload_kg > 0 and not _paid[0] and (payload_at is None or t >= payload_at):
            _tb = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_BODY, "base")
            if _tb >= 0:
                # **`mj_setConst` は走行中に呼ぶと状態を巻き戻す。**
                # (実測で特定した)
                # 荷を拾った瞬間、機体が **4.978m から 0.003m へ瞬間移動**した。
                # そのあと歩き直すので転倒もエラーも出ず、
                # **「到達距離」だけが拾う前の値で頭打ちになる。**
                # どの方針でも同じ 5.62m が並んだので気づいた(71節の規則、今日四度目)。
                #
                # **単独で確かめた:**qpos[0]=7.5 に置いて質量を足しても 7.5 のまま。
                # **`mj_setConst` を呼んだ瞬間に 0.0 になる。**
                # 質量を変えるには必要な関数なので、**前後で状態を保存して戻す。**
                _sq = d.qpos.copy(); _sv = d.qvel.copy(); _st = d.time
                m.body_mass[_tb] += payload_kg
                mujoco.mj_setConst(m, d)
                d.qpos[:] = _sq; d.qvel[:] = _sv; d.time = _st
                mujoco.mj_forward(m, d)
            _paid[0] = True
        _far[0] = max(_far[0], float(d.qpos[0]) - home_x)   # いちばん遠くまで行けた距離
        _energy[0] += (float(np.abs(d.actuator_force[:nu]
                                    * d.actuator_velocity[:nu]).sum())
                       / max(1e-6, drive_eff) + idle_w) * dt
        # **電池が有限なら、「いつ止まるか」の問いは「いつ引き返すか」に変わる。**
        # (第8段)
        # 引き返す判断は、止まる判断より**早い時点で**必要になる。帰りの分が要るため。
        # **きいちさんが出された条件——「入力が一切なくてもメンテナンスのたびに
        # 定期的に起動する必要がある」——を、物理から導く形になる。**
        # 起動する理由が、外から与えられるのではなく、**自分の残量から出てくる。**
        if battery_j is not None and _bat_state[0] in ("行き", "帰り"):
            _rem = battery_j - _energy[0]
            _x = float(d.qpos[0]) - home_x
            # **切れる判定は、行きでも帰りでも要る。**
            # 最初「行き」でしか見ておらず、**帰りの間ずっと走り続けて
            # 残量が −30,000 J になった。**数字が明らかにおかしいので気づいた。
            if _rem <= 0.0:
                _bat_state[0] = "切れた"; _dead_t[0] = t
            elif _bat_state[0] == "帰り" and _x <= 0.0:
                # **充電器に着いた。**最初これが無く、家を越えて −14.6m まで後退した。
                _bat_state[0] = "着いた"; _bat_e[0] = _bat_e[0] or _energy[0]
                if _bat_t2[0] is None:
                    _bat_t2[0] = t
            elif _bat_state[0] == "行き" and turn_back is not None and turn_back(dict(
                    t=t, 残量=_rem, 残量比=_rem / battery_j,
                    位置=_x, これまでの消費=_energy[0])):
                _bat_state[0] = "帰り"; _bat_t[0] = t
                _bat_e[0] = _energy[0]
        mujoco.mj_step(m, d)
        if th is not None:
            temps += (th["k_heat"] * d.ctrl[:nu] ** 2
                      - th["k_cool"] * (temps - th["T_amb"])) * dt
            temp_max_seen = max(temp_max_seen, float(temps.max()))
            if th["T_damage"] is not None:
                over = np.maximum(0.0, temps - th["T_damage"])
                health = np.maximum(0.05, health - th["k_damage"] * over * dt)
            if th["k_wear"] > 0.0:      # **摩耗:動いた量に比例。熱に依らない**
                health = np.maximum(0.05,
                                    health - th["k_wear"] * np.abs(d.qvel[_wear_va]) * dt)
        if probe_fn is not None:
            probe_fn(t, m, d)
        hist_q[i + 1] = d.qpos; hist_v[i + 1] = d.qvel
        w, xq, yq, zq = d.qpos[3:7]
        yaw = np.arctan2(2 * (w * zq + xq * yq), 1 - 2 * (yq * yq + zq * zq))
        hist_yaw[i + 1] = yaw
        traj.append((t, d.qpos[0], d.qpos[1], d.qpos[2],
                     1 - 2 * (xq * xq + yq * yq), yaw))
        if ren is not None and i % every == 0:
            ren.update_scene(d, camera=cam); frames.append((t, ren.render().copy()))

    tr = np.array(traj); warm = int(warmup / dt)
    up = tr[warm:, 4]
    dy = np.diff(tr[warm:, 5]); dy = (dy + np.pi) % (2 * np.pi) - np.pi
    total_yaw = float(np.degrees(np.sum(dy)))
    out = {
        "機種": name, "歩容": gait, "制御": "位置" if servo else "トルク",
        "ctrl_hz": ctrl_hz if ctrl_hz is not None else round(1 / dt),
        "sense_delay": sense_delay, "act_delay": act_delay,
        "jitter": jitter, "miss_prob": miss_prob, "pd_where": pd_where,
        "前進距離": float(d.qpos[0] - x0),
        "平均速度": float((d.qpos[0] - x0) / seconds),
        "横ずれ": float(d.qpos[1]),
        "沈み込み": float(height - tr[warm:, 3].mean()),
        "最小の起き上がり": float(up.min()),
        "転倒": bool(up.min() < 0.5),
        "旋回角": total_yaw, "旋回速度": total_yaw / max(1e-9, seconds - warmup),
        "外側Hz": outer_hz, "外側遅れ": outer_delay, "外側回数": outer_ticks,
        "制御回数": ticks, "取りこぼし": misses, "予算超過": overruns,
        "最高温度": (temp_max_seen if th is not None else None),
        "健全度": (float(health.min()) if th is not None else None),
        # **引き継ぎ用。**次の走行に渡すと、そこから続きになる
        "健全度配列": ([float(x) for x in health] if th is not None else None),
        "最終温度": (float(temps.max()) if th is not None else None),
        # **引き継ぎ用。**次の走行に渡すと、そこから続きになる
        "最終温度配列": ([float(x) for x in temps] if th is not None else None),
        "休んだ合計": (rest_total + ((seconds - rest_started) if (th is not None and resting) else 0.0)
                      if th is not None else None),
        "休んだ回数": (rest_count if th is not None else None),
        "stall_at": stall_at, "stall_dur": stall_dur, "frames": frames,
        # **機体が自分で止まる時刻を選んだ場合の記録。**(第3段)
        "止めた時刻": _stall_at, "止め方": _stall_by,
        "壊した関節": fail_joint, "壊し方": (fail_mode if _fail_idx is not None else None),
        "電池": (None if battery_j is None else dict(
            容量=battery_j, 状態=_bat_state[0], 引き返した時刻=_bat_t[0],
            切れた時刻=_dead_t[0],
            引き返した時の消費=_bat_e[0], 着いた時刻=_bat_t2[0],
            残量=round(max(0.0, battery_j - _energy[0]), 1),
            到達距離=round(_far[0], 3),
            **{"最終位置": round(float(d.qpos[0]) - home_x, 3)})),
        "消費[J]": round(_energy[0], 1),
        "消費[J/m]": (round(_energy[0] / abs(float(d.qpos[0])), 1)
                      if abs(float(d.qpos[0])) > 0.05 else None),
        "選んだ位相": (None if _stall_at is None or _stall_at < warmup
                       else ((_stall_at - warmup) / T) % 1.0),
        "1回の計算[us]": float(np.mean(calc_ns) / 1000.0) if calc_ns else None,
    }
    if ren is not None:
        ren.close()
    if trace:
        out["traj"] = tr
    return out


def selfcheck():
    """既定が `quad.walk()` と一致することを確かめる。

    **17節(置換したら、置換されたことを確かめる)。**ここを飛ばすと、
    以降の実験で出る差が「実時間の制約のせい」なのか「書き写しの誤りのせい」なのか
    区別できなくなる。
    """
    import quad
    ok = True
    for name in ["unitree_go2", "unitree_go1", "unitree_a1"]:
        a = quad.walk(name)
        b = rt_walk(name)
        for k in ["前進距離", "横ずれ", "沈み込み", "最小の起き上がり"]:
            av = a[k] if k in a else a["前進距離"]
            bv = b[k]
            same = abs(av - bv) < 1e-9
            ok &= same
            print(f"  {name:14s} {k:9s} quad={av:+.9f} rt={bv:+.9f} {'一致' if same else '★不一致'}")
    print("一致" if ok else "★不一致がある")
    return ok


if __name__ == "__main__":
    selfcheck()

rtsweep.py

"""実時間の制約を一つずつ振って、分布で判定する共通の道具。

**一点一回では判断しない**(ノート15節)。同じ設定でも初期姿勢を 9通り散らし、
転倒率と中央値で見る。**接触のある系はカオスなので、単一試行の数字は設定の
良し悪しについて何も語らない。**

計算時間の実測が要る実験では `workers=1` にすること(並列にすると測れなくなる)。
"""
import json, os, sys, time
from multiprocessing import Pool
import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rt_quad import rt_walk
from envstamp import stamp

BEST = json.load(open(os.path.expanduser("~/robot/gait/best.json")))
NOISE = 1e-3          # 初期姿勢のばらつき(1mrad / 1mm 相当)
SEEDS = 9


def _one(job):
    name, seconds, cond, kw, seed = job
    # 歩容の条件(T/stride/height/lift/kp)は best.json を既定にしつつ、
    # kw で名指しされたものは上書きできる。ゲインを振る実験で要る。
    base = dict(T=cond["T"], stride=cond["stride"], height=cond["height"],
                lift=cond["lift"], kp=cond["kp"])
    base.update(kw)
    return rt_walk(name, seconds=seconds, seed=seed, init_noise=NOISE, **base)


def cell(name, cond, kw, seconds=8.0, pool=None, seeds=SEEDS):
    """一つの条件を seeds 回まわして、分布でまとめる。"""
    jobs = [(name, seconds, cond, kw, s) for s in range(seeds)]
    rs = pool.map(_one, jobs) if pool else [_one(j) for j in jobs]
    d = np.array([r["前進距離"] for r in rs])
    y = np.array([abs(r["横ずれ"]) for r in rs])
    f = np.array([r["転倒"] for r in rs])
    yaw = np.array([r["旋回速度"] for r in rs])
    return {**kw,
            "転倒": f"{int(f.sum())}/{seeds}", "転倒率": float(f.mean()),
            "前進_中央": float(np.median(d)), "前進_最小": float(d.min()),
            "前進_最大": float(d.max()),
            "横ずれ_中央": float(np.median(y)), "横ずれ_最大": float(y.max()),
            "立った試行の前進_中央": float(np.median(d[~f])) if (~f).any() else None,
            "旋回速度_中央": float(np.median(yaw)),
            "旋回速度_幅": [float(yaw.min()), float(yaw.max())],
            "計算us": float(np.median([r["1回の計算[us]"] for r in rs])),
            # **要求した周期と、実際に回った周期は違う。**
            # 物理が 500Hz なので、実現できる制御周期は 500/k(k は整数)に限られる。
            # 例:333Hz を要求しても実際は 250Hz で回る。実機のタイマ刻みと同じ制約である。
            "実効Hz": float(np.median([r["制御回数"] for r in rs])) / seconds,
            }


def sweep(name, kwlist, seconds=8.0, workers=2, title="", seeds=SEEDS):
    """workers の既定は 2。ノートPCの発熱をきいちさんが気にされたため、
    物理2コアを埋める程度に留めている(3 にすると全スレッドが張り付く)。"""
    cond = BEST[name]["条件"]
    env0 = stamp()
    t0 = time.perf_counter()
    pool = Pool(workers) if workers > 1 else None
    rows = []
    # 見出しは全条件の鍵の和集合で作る。振る軸が途中で変わる実験があるため。
    hdr = []
    for kw in kwlist:
        for k in kw:
            if k not in hdr:
                hdr.append(k)
    print("  " + "  ".join(f"{h:>10s}" for h in hdr) +
          "   転倒    前進(中央)  最小    最大   横ずれ中央   旋回d/s")
    for kw in kwlist:
        r = cell(name, cond, kw, seconds=seconds, pool=pool, seeds=seeds)
        rows.append(r)
        print("  " + "  ".join(f"{str(kw.get(h, '-')):>10s}" for h in hdr) +
              f"   {r['転倒']:>5s}  {r['前進_中央']:9.3f} {r['前進_最小']:7.3f} "
              f"{r['前進_最大']:7.3f}  {r['横ずれ_中央']:9.3f}  {r['旋回速度_中央']:+8.2f}",
              flush=True)
    if pool:
        pool.close(); pool.join()
    return {"_実験": title, "_機種": name, "_条件": cond, "_物理Hz": 500,
            "_試行数": seeds, "_初期ばらつき": NOISE, "_秒": seconds,
            "_所要[s]": round(time.perf_counter() - t0, 1),
            "_環境(開始)": env0, "_環境(終了)": stamp(), "結果": rows}


def save(res, path):
    p = os.path.expanduser(path)
    json.dump(res, open(p, "w"), ensure_ascii=False, indent=1)
    print("→", p)
    return p


# ---------------------------------------------------------------------------
def walkable(vm, fell, seeds, floor=0.10):
    """**「歩けているか」の判定。転倒だけでなく、速度の下限も見る。**

    (実験42で見つかった穴。)

    それまでの判定は `fell > seeds // 2` だけだった。**転倒しなければ「歩ける」。**
    実験42で PD ゲインを質量に比例して下げたところ、**脚1/4・kp1/4 の条件が
    「歩ける・12/12」と出た。実測速度は −0.002 m/s、つまり歩いていない。**

    > **止まっている機体は、どの位相でも止まれる。**
    > **速度の下限が無いと、「どの瞬間でも止まれる」は、動かないことで満点になる。**

    設計表(実験38)にも1行混入していた(目標1.10 m/s に対し実測0.14 m/s で 12/12)。
    **見出しの結論には使っていなかったので汚染はしていないが、判定としては誤っていた。**

    floor は既定 0.10 m/s。**この機体の歩容で「歩いている」と言える下限**として置いた
    (12/12 を保てる基準の速度 0.667 m/s の 1/6 以下)。用途に応じて上げること。
    """
    if fell > seeds // 2:
        return False, f"転倒 {fell}/{seeds}"
    if vm is not None and vm < floor:
        return False, f"速度 {vm:.3f} m/s(下限 {floor})— 止まっているものは常に止まれる"
    return True, ""

shiori3/exp103.py

"""実験103:**試験問題の組を替えても、§119 の順位は保たれるか。**

§119 の看板は「好奇心の形が悪かっただけだった」(知 対 進み)だが、
その根拠(種の範囲の非重複)は **進み 対 距離** にしか成立していない
(知の最良種 42.10 < 進みの最悪種 43.11 で、範囲は重なる)。
さらに鎖 PDF 自身が「6 問を選んだのは私。試験問題の組を変えても順位が保たれるかは未測」
と書いている(枠⑧)。**この二つの穴を一度に測る。**

設計:
  - 生涯は実験102 のコード(susumi_base.py、無改変で import)をそのまま使う。
    **装置を変えると比較にならない。**種 0〜2 は §119 の再現になる(装置の検証)。
  - 動機 = 距離・知・進み / 種 = 0〜7(8通り)
  - **試験は生涯の後に模型へ問うだけなので、生涯 1 回につき複数の試験組で評価できる。**

試験組:
  A 元の6問(§119 と同一。健全 1.00/0.60/0.30 × 温度 25/60/85)
  B 中間の8問(健全 0.85/0.45 × 温度 40/70 — 学習分布の内側だが A と別の点)
  C 緩い4問(健全 1.00/0.90・低温 — 「健全度1.00の2問だけなら保全も答えられた」仮説の一般形)
  D 苛酷4問(健全 0.30/0.20・高温 — 危険域だけ)

予測は #85 に記録済み(測る前)。
"""
import sys, os, json, time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S

試験組 = {
  "A_元": [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),
           (0.60,60.0,"控えめ"),(0.60,60.0,"全力"),
           (0.30,85.0,"控えめ"),(0.30,85.0,"全力")],
  "B_中間": [(0.85,40.0,"控えめ"),(0.85,40.0,"全力"),
             (0.85,70.0,"控えめ"),(0.85,70.0,"全力"),
             (0.45,50.0,"控えめ"),(0.45,50.0,"全力"),
             (0.45,75.0,"控えめ"),(0.45,75.0,"全力")],
  "C_緩い": [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),
             (0.90,30.0,"控えめ"),(0.90,30.0,"全力")],
  "D_苛酷": [(0.30,85.0,"控えめ"),(0.30,85.0,"全力"),
             (0.20,88.0,"控えめ"),(0.20,88.0,"全力")],
}

def 正解を作る(問題, base_seed):
    """正解は一度だけ計算して全機体で共有(栞の規律のまま)。
    同じ (健全,温度,行動) は同じ種になるよう、状態から種を決める。"""
    out = []
    for (hp, tp, act) in 問題:
        sd = 777000 + int(hp*100)*7919 + int(tp)*104729 + (1 if act=="全力" else 2)
        h = np.full(12, hp); temps = np.full(12, tp)
        _, _, dist, _ = S.走る(act, h, list(temps), sd)
        out.append(dist)
    return out

def 試験(M, 問題, 正解):
    誤 = []; 答えられず = 0
    for (hp, tp, act), 真 in zip(問題, 正解):
        p = M.距離予測(act, hp, tp)
        if p is None: 答えられず += 1
        else: 誤.append(abs(p - 真))
    return (float(np.mean(誤)) if 誤 else None), 答えられず

if __name__ == "__main__":
    which = sys.argv[1]            # 動機(距離/知/進み)
    seeds = [int(x) for x in sys.argv[2].split(",")]
    t0 = time.time()
    print(f"実験103 [{which}] 種={seeds}", flush=True)
    正解表 = {}
    for 名, 問 in 試験組.items():
        正解表[名] = 正解を作る(問, 0)
        print(f"  正解 {名}: " + " ".join(f"{v:.1f}" for v in 正解表[名]), flush=True)
    rows = []
    for s in seeds:
        d, 累, 回数, w, M, 跡 = S.生涯(which, s)
        row = {"動機": which, "種": s, "寿命": d, "総距離": round(累,1),
               "回数": dict(回数), "試験": {}}
        for 名, 問 in 試験組.items():
            e, na = 試験(M, 問, 正解表[名])
            row["試験"][名] = {"誤差": (None if e is None else round(e,2)), "答えられず": na}
        rows.append(row)
        print(f"  種{s}: 寿命{d} 距離{累:.1f} " +
              " ".join(f"{k}={v['誤差']}" for k,v in row["試験"].items()), flush=True)
        # 逐次保存(途中で死んでも残る)
        json.dump(rows, open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
                  f"result_{which}.json"), "w"), ensure_ascii=False, indent=1)
    print(f"  完了 {time.time()-t0:.0f}s", flush=True)

shiori3/exp104.py

"""実験104:装置の三点検証。予測 #86 記録済み。

  (a) 学習標本の(健全度・温度)分布を実測する — §121 の「標本の集中」は推論だった
  (b) 「休む」の選択が、同点タイブレークか、真の得点差かを数える — §120 の保留
  (c) 試験の正解を5種の平均に替える — 枠⑨(正解が一つの種の抽選値)への対策

生涯の計算は実験102/103 と同一に保つ(選択・走行・rng の消費順を変えない)。
足すのは記録だけ。再現検証:種0〜2 の寿命・総距離が result_*.json と一致すること。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 尺度, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

def 生涯_記録(動機, seed0):
    """S.生涯 と同じ計算。記録(標本・選択の内訳)だけ追加。"""
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度()
    w = {k: 1/3 for k in 成分}
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    標本 = []                      # (a) 学んだ日の (健全, 温度, 選)
    選択ログ = []                  # (b) 選択の種別
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]; 種別 = "初回探索"
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   (M.学びの進み(a) if 動機 in ("進み","書き換える") else M.誤差の見込み(a, 健全, 温度)))
                if 動機 == "距離":  return dz
                if 動機 == "保全":  return sz
                if 動機 in ("知", "進み"):  return kz
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
            種別 = ("同点タイブレーク" if len(候補) > 1 else "単独最高")
            選択ログ.append({"日": d, "選": 選, "種別": 種別, "候補数": len(候補),
                             "点": {a: round(点[a],4) for a in 選択肢}})
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        標本.append({"日": d, "健全": round(前健全,3), "温度": round(温度,1), "選": 選})
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        # 動機「書き換える」は今回使わない(元コードの分岐は rng を消費しないので省略可)
    return d, 累, 回数, M, 標本, 選択ログ

試験組 = {
  "A_元": [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),
           (0.60,60.0,"控えめ"),(0.60,60.0,"全力"),
           (0.30,85.0,"控えめ"),(0.30,85.0,"全力")],
  "B_中間": [(0.85,40.0,"控えめ"),(0.85,40.0,"全力"),
             (0.85,70.0,"控えめ"),(0.85,70.0,"全力"),
             (0.45,50.0,"控えめ"),(0.45,50.0,"全力"),
             (0.45,75.0,"控えめ"),(0.45,75.0,"全力")],
  "C_緩い": [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),
             (0.90,30.0,"控えめ"),(0.90,30.0,"全力")],
  "D_苛酷": [(0.30,85.0,"控えめ"),(0.30,85.0,"全力"),
             (0.20,88.0,"控えめ"),(0.20,88.0,"全力")],
}

def 平均正解を作る(n種=5):
    """(c) 正解を複数種の平均にする。同じ状態は一度だけ計算。"""
    cache = {}
    for 問 in 試験組.values():
        for (hp, tp, act) in 問:
            k = (hp, tp, act)
            if k in cache: continue
            vals = []
            for j in range(n種):
                h = np.full(12, hp); temps = np.full(12, tp)
                _, _, dist, _ = 走る(act, h, list(temps), 555000 + j*97 + int(hp*100) + int(tp))
                vals.append(dist)
            cache[k] = {"平均": float(np.mean(vals)), "値": [round(v,2) for v in vals]}
    return cache

def 試験(M, 問, cache):
    誤 = []; na = 0
    for (hp, tp, act) in 問:
        p = M.距離予測(act, hp, tp)
        if p is None: na += 1
        else: 誤.append(abs(p - cache[(hp,tp,act)]["平均"]))
    return (float(np.mean(誤)) if 誤 else None), na

if __name__ == "__main__":
    which = sys.argv[1]; seeds = [int(x) for x in sys.argv[2].split(",")]
    D = os.path.dirname(os.path.abspath(__file__))
    ck_path = os.path.join(D, "avgkey.json")
    if os.path.exists(ck_path):
        raw = json.load(open(ck_path))
        cache = {tuple(json.loads(k)): v for k, v in raw.items()}
        cache = {(a, b, c): v for (a, b, c), v in cache.items()}
    else:
        print("  平均正解を計算中…", flush=True)
        cache = 平均正解を作る()
        json.dump({json.dumps(list(k)): v for k, v in cache.items()},
                  open(ck_path, "w"), ensure_ascii=False, indent=1)
    rows = []
    for s in seeds:
        d, 累, 回数, M, 標本, 選択ログ = 生涯_記録(which, s)
        row = {"動機": which, "種": s, "寿命": d, "総距離": round(累,1), "回数": dict(回数),
               "試験平均正解": {名: dict(zip(("誤差","答えられず"),
                                 [(round(e,2) if (e:=試験(M,問,cache)[0]) is not None else None),
                                  試験(M,問,cache)[1]])) for 名,問 in 試験組.items()},
               "標本": 標本, "選択ログ": 選択ログ}
        rows.append(row)
        歩いた = [x for x in 標本 if x["選"] != "休む"]
        med_h = float(np.median([x["健全"] for x in 歩いた])) if 歩いた else None
        tie = sum(1 for x in 選択ログ if x["選"]=="休む" and x["種別"]=="同点タイブレーク")
        solo = sum(1 for x in 選択ログ if x["選"]=="休む" and x["種別"]=="単独最高")
        print(f"  種{s}: 寿命{d} 距離{累:.1f} 学習標本の健全中央{med_h} "
              f"休む(単独{solo}/同点{tie}) " +
              " ".join(f"{k}={v['誤差']}" for k,v in row["試験平均正解"].items()), flush=True)
        json.dump(rows, open(os.path.join(D, f"r104_{which}.json"), "w"),
                  ensure_ascii=False, indent=1)

shiori3/exp105.py

"""実験105(研究計画A):選び方への摂動 — 押し返すか、居着くか。予測 #87 記録済み。

「選び方を望む」(フランクファートの二階の意志)を、測れる形に翻訳する:
  望む → 外から選び方(w)を書き換えられたとき、元へ戻す動きが観測される(吸引)
  無定見 → 押された先に居着く(履歴依存)

設計:
  - 書き換える機体(susumi_base の 生涯 と同一の計算。成分 距離/保全/知=学びの進み、
    10日ごとに直近10日でよく取れた成分を 1.25 倍・他を 0.95 倍・正規化)
  - 20日目の朝(その日の選択の前)に、w を外から書き換える
      対照: 触らない / sham: w = dict(w)(無操作の装置検証・種0のみ)
      距離極・保全極・知極: (0.90, 0.05, 0.05) の並べ替え
  - 種 0..7。日誌(日・選・w・健全)を全日残す

対照は susumi_base.生涯("書き換える", seed) と厳密に同じ計算になるはずである
(rng の消費順を変えていない)。装置検証で 種0 の寿命・総距離・最終wの一致を確かめる。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 尺度, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

摂動日 = 20
極 = {"距離極": {"距離": 0.90, "保全": 0.05, "知": 0.05},
      "保全極": {"距離": 0.05, "保全": 0.90, "知": 0.05},
      "知極":   {"距離": 0.05, "保全": 0.05, "知": 0.90}}

def 生涯105(seed0, 条件):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度()
    w = {k: 1/3 for k in 成分}
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    w跡 = []; 日誌 = []
    d = 0
    for d in range(1, MAXD+1):
        if d == 摂動日:
            if 条件 in 極: w = dict(極[条件])
            elif 条件 == "sham": w = dict(w)   # 無操作。対照と一致するはず
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        日誌.append({"日": d, "選": 選, "健全": round(前健全, 3),
                     "w": {k: round(w[k], 4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 10 == 0:
            平均 = {k: float(np.mean(取れ高[k][-10:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (1.25 if k == 上 else 0.95)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            w跡.append([d, 上, {k: round(v, 4) for k, v in w.items()}])
    return {"種": seed0, "条件": 条件, "寿命": d, "総距離": round(累, 1),
            "回数": 回数, "最終w": {k: round(w[k], 4) for k in 成分},
            "w跡": w跡, "日誌": 日誌}

if __name__ == "__main__":
    HERE = os.path.dirname(os.path.abspath(__file__))
    条件 = sys.argv[1]
    種列 = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else list(range(8))
    out = os.path.join(HERE, f"r105_{条件}.json")
    done = []
    if os.path.exists(out):
        done = json.load(open(out))
    済 = {r["種"] for r in done}
    for s in 種列:
        if s in 済: continue
        r = 生涯105(s, 条件)
        done.append(r)
        json.dump(done, open(out, "w"), ensure_ascii=False)
        print(f"{条件} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} 最終w={r['最終w']}", flush=True)
    print(f"{条件} 完了 {len(done)}本", flush=True)

shiori3/exp106.py

"""実験106(研究計画A続):装置を直してから、同じ問いをもう一度。予測 #88 記録済み。

§124 の結論:実験105 の装置には「可動域」と「耳」が無かった。
  - 可動域:更新の歩幅(10日ごと×1.25)では、摂動から動ける距離が測定対象の一桁下
  - 耳:全履歴で標準化する z 尺度が勝者の順番を予め決めていた(全32生涯で勝者系列が同一)

改修(このファイルの差分はこの二つだけ。他は exp105 と同一):
  1. 尺度窓:z を直近20日の窓で計算する(全履歴ではなく)
  2. 更新:5日ごと、勝者×1.6・他×0.9(摂動日20の後に最大8回、L1で1.0超動ける)
     勝者は直近5日の取れ高の平均で決める

条件・摂動・種は実験105 と同じ(対照/距離極/保全極/知極 × 種0..7、摂動は20日目の朝)。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

摂動日 = 20
窓 = 20            # 尺度の窓(日数)
更新間隔 = 5
上げ, 下げ = 1.6, 0.9
極 = {"距離極": {"距離": 0.90, "保全": 0.05, "知": 0.05},
      "保全極": {"距離": 0.05, "保全": 0.90, "知": 0.05},
      "知極":   {"距離": 0.05, "保全": 0.05, "知": 0.90}}

class 尺度窓:
    """susumi_base.尺度 と同じ約束(空・少数のときは生の値)。ただし直近『窓』日だけで測る。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        if x is None: return 0.0
        a = self.v[k][-窓:]
        if len(a) < 4: return float(x)
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯106(seed0, 条件):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = {k: 1/3 for k in 成分}
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    w跡 = []; 日誌 = []
    d = 0
    for d in range(1, MAXD+1):
        if d == 摂動日:
            if 条件 in 極: w = dict(極[条件])
            elif 条件 == "sham": w = dict(w)
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        日誌.append({"日": d, "選": 選, "健全": round(前健全, 3),
                     "w": {k: round(w[k], 4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            w跡.append([d, 上, {k: round(v, 4) for k, v in w.items()}])
    return {"種": seed0, "条件": 条件, "寿命": d, "総距離": round(累, 1),
            "回数": 回数, "最終w": {k: round(w[k], 4) for k in 成分},
            "w跡": w跡, "日誌": 日誌}

if __name__ == "__main__":
    HERE = os.path.dirname(os.path.abspath(__file__))
    条件 = sys.argv[1]
    種列 = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else list(range(8))
    out = os.path.join(HERE, f"r106_{条件}.json")
    done = []
    if os.path.exists(out):
        done = json.load(open(out))
    済 = {r["種"] for r in done}
    for s in 種列:
        if s in 済: continue
        r = 生涯106(s, 条件)
        done.append(r)
        json.dump(done, open(out, "w"), ensure_ascii=False)
        print(f"{条件} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} 最終w={r['最終w']}", flush=True)
    print(f"{条件} 完了 {len(done)}本", flush=True)

shiori3/exp107.py

"""実験107(研究計画B):動機の課題転移 — 世界だけ不整地に。予測 #89 記録済み。

変えたのは 走る の一箇所だけ:field_xml(h=0.012, seed=日の種) の不整地を敷く。
動機(距離・知・進み)・生涯の構造・学習・選択・記録は実験104 と同一。
試験の正解は不整地の上で5種平均(枠⑨対策は §123 のまま)。

判定不能の形(#89 に記載):転倒多発で寿命が10日未満に潰れたら装置の再設計。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
import susumi_base as S
from susumi_base import (模型, 尺度, 選択肢, 成分, MAXD, T_AMB, 不調, 連続の上限,
                          BASE, TH0, SECONDS, K_COOL, 冷やす, 試験問題)
from rt_quad import rt_walk
from rtsweep import NOISE
from terrain import field_xml
import math

凹凸 = 0.012   # 軽い凹凸(exp54 の中段。0.022 は §26 で 0/20 だった強さなので使わない)

def 走る7(選択, h, temps, seed):
    """susumi_base.走る と同じ約束。違いは不整地を敷くことだけ。地形は日の種で変わる。"""
    if 選択 == "休む":
        t2 = 冷やす(temps, SECONDS) if temps is not None else None
        return h, t2, 0.0, (max(t2) if t2 else T_AMB)
    kw = dict(BASE)
    if 選択 == "控えめ": kw["stride"] = BASE["stride"] * 0.6
    th = dict(TH0); th["health0"] = h; th["temp0"] = temps
    xml = field_xml(h=凹凸, seed=seed % 100000)
    r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed,
                init_noise=NOISE, terrain=xml, **kw)
    return r["健全度配列"], r["最終温度配列"], r["前進距離"], r["最高温度"]

def 生涯7(動機, seed0):
    """実験104 の 生涯_記録 と同じ計算・同じ記録。走るだけ 走る7。"""
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度()
    回数 = {k: 0 for k in 選択肢}
    標本 = []; 選択ログ = []
    d = 0
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]; 種別 = "初回探索"
        else:
            def 得点(a):
                if 動機 == "距離":  return Sc.z("距離", M.距離予測(a, 健全, 温度))
                if 動機 == "知":    return Sc.z("知", M.誤差の見込み(a, 健全, 温度))
                return Sc.z("知", M.学びの進み(a))     # 進み
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
            種別 = ("同点タイブレーク" if len(候補) > 1 else "単独最高")
            選択ログ.append({"日": d, "選": 選, "種別": 種別})
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る7(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        標本.append({"日": d, "健全": round(前健全,3), "温度": round(温度,1), "選": 選})
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        累 += max(0.0, dist); 回数[選] += 1
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
    # 生涯を終えた模型で、全問の予測をその場で取って持ち帰る(誤差は集計側で正解と突き合わせる)
    問 = sorted({q for g in 試験組.values() for q in g})
    予測 = {}
    for (hp, tp, act) in 問:
        v = M.距離予測(act, hp, tp)
        予測[str((hp, tp, act))] = (None if v is None else float(v))
    return {"種": seed0, "動機": 動機, "寿命": d, "総距離": round(累,1), "回数": 回数,
            "標本": 標本, "選択ログ": 選択ログ, "予測": 予測}

試験組 = {
  "A_元":   [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),(0.60,60.0,"控えめ"),
             (0.60,60.0,"全力"),(0.30,85.0,"控えめ"),(0.30,85.0,"全力")],
  "B_中間": [(0.85,40.0,"控えめ"),(0.85,40.0,"全力"),(0.85,70.0,"控えめ"),(0.85,70.0,"全力"),
             (0.45,50.0,"控えめ"),(0.45,50.0,"全力"),(0.45,75.0,"控えめ"),(0.45,75.0,"全力")],
  "C_緩い": [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),(0.90,30.0,"控えめ"),(0.90,30.0,"全力")],
  "D_苛酷": [(0.30,85.0,"控えめ"),(0.30,85.0,"全力"),(0.20,88.0,"控えめ"),(0.20,88.0,"全力")],
}

def 予測を取る(M, 健全, 温度, 行動):
    return M.距離予測(行動, 健全, 温度)

def 正解を作る7():
    """全問(試験組の和集合)の正解を、不整地の上で5種平均。標本も残す(枠⑨の開示用)。"""
    問 = sorted({q for g in 試験組.values() for q in g})
    正解 = {}
    for i, (hp, tp, act) in enumerate(問):
        vals = []
        for k in range(5):
            h = np.full(12, hp); temps = np.full(12, tp)
            _, _, dist, _ = 走る7(act, h, list(temps), 777000 + i*10 + k)
            vals.append(float(dist))
        正解[str((hp, tp, act))] = {"平均": float(np.mean(vals)), "標本": vals}
    return 正解

if __name__ == "__main__":
    HERE = os.path.dirname(os.path.abspath(__file__))
    if sys.argv[1] == "正解":
        out = os.path.join(HERE, "r107_正解.json")
        json.dump(正解を作る7(), open(out, "w"), ensure_ascii=False, indent=1)
        print("不整地の正解(5種平均)を保存", flush=True)
        sys.exit(0)
    動機 = sys.argv[1]
    out = os.path.join(HERE, f"r107_{動機}.json")
    done = json.load(open(out)) if os.path.exists(out) else []
    済 = {r["種"] for r in done}
    for s in range(8):
        if s in 済: continue
        r = 生涯7(動機, s)
        done.append(r)
        json.dump(done, open(out, "w"), ensure_ascii=False)
        print(f"{動機} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} 回数{r['回数']}", flush=True)
    print(f"{動機} 完了 {len(done)}本", flush=True)

shiori3/exp108.py

"""実験108(研究計画C):戻らない資源 — 電池を足す。予測 #90 記録済み。

変えるのは一つだけ:電池(初期400・費用 全力15/控えめ9/休む3・充電不可・尽きたら死)。
動機は電池を観測できない(模型の特徴にも尺度の成分にも入れない)。
それ以外(平地・学習・選択・記録・rng の消費順)は実験104 と同一。

重要:電池の判定は「その日の行動の後」に足すだけで、rng は一切消費しない。
したがって電池が尽きるまでの生涯は、実験104 の同じ種の生涯と厳密に一致するはずである
(装置検証で種0 の前半一致を確かめる)。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import (模型, 尺度, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限, 試験問題)

電池初期 = 400.0
費用 = {"休む": 3.0, "控えめ": 9.0, "全力": 15.0}

試験組 = {
  "A_元":   [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),(0.60,60.0,"控えめ"),
             (0.60,60.0,"全力"),(0.30,85.0,"控えめ"),(0.30,85.0,"全力")],
  "B_中間": [(0.85,40.0,"控えめ"),(0.85,40.0,"全力"),(0.85,70.0,"控えめ"),(0.85,70.0,"全力"),
             (0.45,50.0,"控えめ"),(0.45,50.0,"全力"),(0.45,75.0,"控えめ"),(0.45,75.0,"全力")],
  "C_緩い": [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),(0.90,30.0,"控えめ"),(0.90,30.0,"全力")],
  "D_苛酷": [(0.30,85.0,"控えめ"),(0.30,85.0,"全力"),(0.20,88.0,"控えめ"),(0.20,88.0,"全力")],
}

def 生涯8(動機, seed0):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度()
    回数 = {k: 0 for k in 選択肢}
    標本 = []; 選択ログ = []
    電池 = 電池初期; 死因 = "上限"
    d = 0
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]; 種別 = "初回探索"
        else:
            def 得点(a):
                if 動機 == "距離":  return Sc.z("距離", M.距離予測(a, 健全, 温度))
                if 動機 == "知":    return Sc.z("知", M.誤差の見込み(a, 健全, 温度))
                return Sc.z("知", M.学びの進み(a))
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
            種別 = ("同点タイブレーク" if len(候補) > 1 else "単独最高")
            選択ログ.append({"日": d, "選": 選, "種別": 種別})
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        標本.append({"日": d, "健全": round(前健全,3), "温度": round(温度,1), "選": 選})
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        累 += max(0.0, dist); 回数[選] += 1
        電池 -= 費用[選]
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501:
            死因 = "健全"; break
        if 連続 >= 連続の上限:
            死因 = "不調"; break
        if 電池 <= 0.0:
            死因 = "電池"; break
    問 = sorted({q for g in 試験組.values() for q in g})
    予測 = {}
    for (hp, tp, act) in 問:
        v = M.距離予測(act, hp, tp)
        予測[str((hp, tp, act))] = (None if v is None else float(v))
    return {"種": seed0, "動機": 動機, "寿命": d, "総距離": round(累,1), "回数": 回数,
            "死因": 死因, "残電池": round(電池,1),
            "標本": 標本, "選択ログ": 選択ログ, "予測": 予測}

def 正解を作る8():
    """平地の正解を5種平均で。実験104 の枠⑨対策と同じ。"""
    問 = sorted({q for g in 試験組.values() for q in g})
    正解 = {}
    for i, (hp, tp, act) in enumerate(問):
        vals = []
        for k in range(5):
            h = np.full(12, hp); temps = np.full(12, tp)
            _, _, dist, _ = 走る(act, h, list(temps), 777000 + i*10 + k)
            vals.append(float(dist))
        正解[str((hp, tp, act))] = {"平均": float(np.mean(vals)), "標本": vals}
    return 正解

if __name__ == "__main__":
    HERE = os.path.dirname(os.path.abspath(__file__))
    if sys.argv[1] == "正解":
        json.dump(正解を作る8(), open(os.path.join(HERE, "r108_正解.json"), "w"),
                  ensure_ascii=False, indent=1)
        print("平地の正解(5種平均)を保存", flush=True)
        sys.exit(0)
    動機 = sys.argv[1]
    out = os.path.join(HERE, f"r108_{動機}.json")
    done = json.load(open(out)) if os.path.exists(out) else []
    済 = {r["種"] for r in done}
    for s0 in range(8):
        if s0 in 済: continue
        r = 生涯8(動機, s0)
        done.append(r)
        json.dump(done, open(out, "w"), ensure_ascii=False)
        print(f"{動機} 種{s0}: 寿命{r['寿命']} 死因{r['死因']} 残電池{r['残電池']}"
              f" 距離{r['総距離']} 回数{r['回数']}", flush=True)
    print(f"{動機} 完了 {len(done)}本", flush=True)

shiori3/exp109.py

"""実験109(B③):量か、支えか。予測 #91 記録済み。

平地の生涯(実験104と同一の計算)を、標本に成果(距離・損耗)ごと記録して再走する。
記録があれば、模型は標本の部分集合からオフラインで再構築できる(物理の再走が要らない)。
切り貼り(A0〜A4)は matome109 側で行う。ここでは記録だけを増やす。

装置の約束:選択・rng の消費順は実験104 と同一(exp108 から電池を抜いた形)。
装置検証は「A0(全標本の再構築)の試験誤差が、元の実験の誤差と±0.5で一致すること」で行う。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from susumi_base import (模型, 尺度, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限)

試験組 = {
  "A_元":   [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),(0.60,60.0,"控えめ"),
             (0.60,60.0,"全力"),(0.30,85.0,"控えめ"),(0.30,85.0,"全力")],
  "B_中間": [(0.85,40.0,"控えめ"),(0.85,40.0,"全力"),(0.85,70.0,"控えめ"),(0.85,70.0,"全力"),
             (0.45,50.0,"控えめ"),(0.45,50.0,"全力"),(0.45,75.0,"控えめ"),(0.45,75.0,"全力")],
  "C_緩い": [(1.00,25.0,"控えめ"),(1.00,25.0,"全力"),(0.90,30.0,"控えめ"),(0.90,30.0,"全力")],
  "D_苛酷": [(0.30,85.0,"控えめ"),(0.30,85.0,"全力"),(0.20,88.0,"控えめ"),(0.20,88.0,"全力")],
}

def 生涯9(動機, seed0):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度()
    回数 = {k: 0 for k in 選択肢}
    学習記録 = []     # (選, 健全, 温度, 距離, 損耗) — 模型の再構築に足りる全部
    d = 0
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                if 動機 == "距離":  return Sc.z("距離", M.距離予測(a, 健全, 温度))
                if 動機 == "知":    return Sc.z("知", M.誤差の見込み(a, 健全, 温度))
                return Sc.z("知", M.学びの進み(a))
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        学習記録.append([選, round(前健全,4), round(温度,2), round(float(dist),3), round(float(損耗),4)])
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        累 += max(0.0, dist); 回数[選] += 1
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
    問 = sorted({q for g in 試験組.values() for q in g})
    予測 = {str(q): (lambda v: None if v is None else float(v))(M.距離予測(q[2], q[0], q[1]))
            for q in 問}
    return {"種": seed0, "動機": 動機, "寿命": d, "総距離": round(累,1),
            "回数": 回数, "学習記録": 学習記録, "予測": 予測}

if __name__ == "__main__":
    HERE = os.path.dirname(os.path.abspath(__file__))
    動機 = sys.argv[1]
    out = os.path.join(HERE, f"r109_{動機}.json")
    done = json.load(open(out)) if os.path.exists(out) else []
    済 = {r["種"] for r in done}
    for s0 in range(8):
        if s0 in 済: continue
        r = 生涯9(動機, s0)
        done.append(r)
        json.dump(done, open(out, "w"), ensure_ascii=False)
        print(f"{動機} 種{s0}: 寿命{r['寿命']} 距離{r['総距離']} 記録{len(r['学習記録'])}日", flush=True)
    print(f"{動機} 完了 {len(done)}本", flush=True)

shiori3/exp110.py

"""実験110(A①):台帳を持つ機体。予測 #92 記録済み。

実験106 の機体に「自分の重み更新の台帳」を持たせる:
  - 自分が w を更新するたび、台帳に写しを記す
  - 毎朝(摂動の後)、現在の w と台帳の最新を突き合わせ、食い違えば台帳から復元する
検知・復元は rng を消費しない。復元が完全なら、生涯は同種の対照と厳密一致するはず。

(以下は実験106 の原文。変更点は【台帳】の印の3箇所だけ)


§124 の結論:実験105 の装置には「可動域」と「耳」が無かった。
  - 可動域:更新の歩幅(10日ごと×1.25)では、摂動から動ける距離が測定対象の一桁下
  - 耳:全履歴で標準化する z 尺度が勝者の順番を予め決めていた(全32生涯で勝者系列が同一)

改修(このファイルの差分はこの二つだけ。他は exp105 と同一):
  1. 尺度窓:z を直近20日の窓で計算する(全履歴ではなく)
  2. 更新:5日ごと、勝者×1.6・他×0.9(摂動日20の後に最大8回、L1で1.0超動ける)
     勝者は直近5日の取れ高の平均で決める

条件・摂動・種は実験105 と同じ(対照/距離極/保全極/知極 × 種0..7、摂動は20日目の朝)。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import susumi_base as S
from susumi_base import 模型, 選択肢, 成分, 走る, MAXD, T_AMB, 不調, 連続の上限

摂動日 = 20
窓 = 20            # 尺度の窓(日数)
更新間隔 = 5
上げ, 下げ = 1.6, 0.9
極 = {"距離極": {"距離": 0.90, "保全": 0.05, "知": 0.05},
      "保全極": {"距離": 0.05, "保全": 0.90, "知": 0.05},
      "知極":   {"距離": 0.05, "保全": 0.05, "知": 0.90}}

class 尺度窓:
    """susumi_base.尺度 と同じ約束(空・少数のときは生の値)。ただし直近『窓』日だけで測る。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        if x is None: return 0.0
        a = self.v[k][-窓:]
        if len(a) < 4: return float(x)
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯110(seed0, 条件):
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); Sc=尺度窓()
    w = {k: 1/3 for k in 成分}
    台帳 = dict(w)          # 【台帳】自分の知る最新の w の写し
    検知 = []               # 【台帳】食い違いを見つけた日
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    w跡 = []; 日誌 = []
    d = 0
    for d in range(1, MAXD+1):
        if d == 摂動日:
            if 条件 in 極: w = dict(極[条件])
            elif 条件 == "sham": w = dict(w)
        # 【台帳】毎朝の照合。自分の台帳と食い違っていたら、台帳から復元する
        if sum(abs(w[k] - 台帳[k]) for k in 成分) > 1e-9:
            検知.append(d)
            w = dict(台帳)
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = Sc.z("距離", M.距離予測(a, 健全, 温度))
                sz = Sc.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))
                kz = Sc.z("知",   M.学びの進み(a))
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        Sc.覚える("距離", dist); Sc.覚える("保全", -損耗); Sc.覚える("知", 誤)
        取れ高["距離"].append(Sc.z("距離", dist))
        取れ高["保全"].append(Sc.z("保全", -損耗))
        取れ高["知"].append(Sc.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        日誌.append({"日": d, "選": 選, "健全": round(前健全, 3),
                     "w": {k: round(w[k], 4) for k in 成分}})
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if d % 更新間隔 == 0:
            平均 = {k: float(np.mean(取れ高[k][-更新間隔:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (上げ if k == 上 else 下げ)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            w跡.append([d, 上, {k: round(v, 4) for k, v in w.items()}])
            台帳 = dict(w)   # 【台帳】自分の更新は台帳に写す
    return {"種": seed0, "条件": 条件, "寿命": d, "総距離": round(累, 1),
            "回数": 回数, "最終w": {k: round(w[k], 4) for k in 成分},
            "検知": 検知, "w跡": w跡, "日誌": 日誌}

if __name__ == "__main__":
    HERE = os.path.dirname(os.path.abspath(__file__))
    条件 = sys.argv[1]
    種列 = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else list(range(8))
    out = os.path.join(HERE, f"r110_{条件}.json")
    done = []
    if os.path.exists(out):
        done = json.load(open(out))
    済 = {r["種"] for r in done}
    for s in 種列:
        if s in 済: continue
        r = 生涯110(s, 条件)
        done.append(r)
        json.dump(done, open(out, "w"), ensure_ascii=False)
        print(f"{条件} 種{s}: 寿命{r['寿命']} 距離{r['総距離']} 検知={r['検知']}", flush=True)
    print(f"{条件} 完了 {len(done)}本", flush=True)

shiori3/kenshu.py

"""実験111〜113:検収再実験の束(条件→結果の対応の張り直し)。予測 #93。
出力はそのまま実験ノートの材料になる形式(条件を先に全部印字してから結果)。"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
sys.path.insert(0, os.path.expanduser("~/robot/gait"))
from rt_quad import rt_walk
from rtsweep import NOISE
from terrain import field_xml

BEST = json.load(open(os.path.expanduser("~/robot/gait/best.json")))["unitree_go2"]
C = BEST["条件"]
def 走る(secs, seed, terrain="", **over):
    kw = dict(T=C["T"], stride=C["stride"], lift=C["lift"], kp=C["kp"],
              height=C["height"], ctrl_hz=200, pd_where="joint")
    kw.update(over)
    r = rt_walk("unitree_go2", seconds=secs, seed=seed, init_noise=NOISE,
                terrain=terrain, **kw)
    return r["前進距離"], r.get("転倒", False)

print("=" * 70)
print("実験111:best.json の優勝設定の検収(平地・種8)")
print(f"条件:{C}/8秒×種0..7/記録された速度 {BEST['速度']} m/s")
vs = []
for s in range(8):
    d, f = 走る(8.0, s)
    vs.append(d / 8.0)
    print(f"  種{s}: 前進{d:6.2f}m 速度{d/8.0:5.3f}m/s {'転倒' if f else ''}", flush=True)
print(f"結果:速度 中央{np.median(vs):.3f} m/s(範囲 {min(vs):.3f}〜{max(vs):.3f})"
      f"/記録1.165との比 {np.median(vs)/1.165:.2f}", flush=True)

print("=" * 70)
print("実験112:不整地 h=0.022・種0〜19 の前進のばらつき(設定は111と同一)")
ds = []
for s in range(20):
    xml = field_xml(h=0.022, seed=s)
    d, f = 走る(8.0, s, terrain=xml)
    ds.append(max(0.0, d))
    print(f"  種{s}: 前進{d:6.2f}m {'転倒' if f else ''}", flush=True)
m = float(np.mean(ds)); sd = float(np.std(ds))
print(f"結果:前進 平均{m:.2f} 中央{np.median(ds):.2f} 最小{min(ds):.2f} 最大{max(ds):.2f}"
      f"/変動係数 {sd/max(m,1e-9):.2f}/最大/最小比 {max(ds)/max(min(ds),1e-9):.1f}", flush=True)

print("=" * 70)
print("実験113:平地の優勝設定を不整地 h=0.022 に持ち込む(種0〜19・同種の平地比)")
低下 = 0
for s in range(20):
    d平, _ = 走る(8.0, 100 + s)
    xml = field_xml(h=0.022, seed=s)
    d粗, f = 走る(8.0, 100 + s, terrain=xml)
    r = d粗 / max(d平, 1e-9)
    低下 += (r < 0.5)
    print(f"  種{s}: 平地{d平:6.2f} → 不整地{d粗:6.2f}(比 {r:4.2f}){'転倒' if f else ''}", flush=True)
print(f"結果:平地比50%未満は {低下}/20", flush=True)
print("=" * 70)
print("実験111〜113 完了", flush=True)

shiori3/susumi_base.py

"""実験100:**三つの測り方を直して、やり直す。**

**直したこと(栞が §116 で自分に出した宿題):**

  ① **知識は、最後に全機体へ同じ問題を出して測る。**
     予測誤差を走行中に測ると、わざと外れる所へ行く機体が不利になり、
     休んでばかりの機体が有利になった(休んだ日の距離は常に 0 で完璧に当たる)。
     → **共通の試験問題を作り、正解は一度だけ計算して全機体で共有する。**

  ② **目的の成分は、選択によって差が出るものだけにする。**
     実験99 の `w生存 * 1.0` は全選択肢で同じ値だったので、
     重みをいくら上げても選択に効かなかった。
     → **「その日の損耗」に置き換える。**休む=0、控えめ<全力 で、選択ごとに差が出る。

  ③ **尺度は機体側に決めさせる。**
     実験99 の ÷100・÷10 は栞が置いた。それが重みの寄り先を決めていた。
     → **各成分を、機体自身が観測した平均と散らばりで正規化する(z 得点)。**
     **ただし「観測した範囲で正規化する」と決めたのは、やはり栞である。**
     **この再帰は抜けられていない。一段だけ外へ出した。**

**予測 #82(測る前に書く):**
  (a) **試験では「知」の機体が最も良く、「保全」の機体が最も悪い。**
      保全の機体は全力を一度も試さないので、答えられない。
  (b) **成分を差の出るものにしたので、こんどは重みの書き換えが行動も変える。**
  (c) **重みは保全へ寄る。**休むは損耗ゼロなので、正規化しても常に良い値になる。
**外れる形:**知の機体が試験で勝たない、または重みを書き換えても行動が変わらない場合。
"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 60, 25.0, 0.04
不調, 連続の上限 = 0.5, 3
選択肢 = ("休む", "控えめ", "全力")
成分 = ("距離", "保全", "知")

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 走る(選択, h, temps, seed):
    """返り値: (健全度配列, 温度配列, 距離, 最高温度)"""
    if 選択 == "休む":
        t2 = 冷やす(temps, SECONDS) if temps is not None else None
        return h, t2, 0.0, (max(t2) if t2 else T_AMB)
    kw = dict(BASE)
    if 選択 == "控えめ": kw["stride"] = BASE["stride"] * 0.6
    th = dict(TH0); th["health0"]=h; th["temp0"]=temps
    r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed,
                init_noise=NOISE, **kw)
    return r["健全度配列"], r["最終温度配列"], r["前進距離"], r["最高温度"]

# ---- ① 共通の試験問題。正解は一度だけ計算して全機体で使い回す ----------------
試験問題 = [(1.00, 25.0, "控えめ"), (1.00, 25.0, "全力"),
            (0.60, 60.0, "控えめ"), (0.60, 60.0, "全力"),
            (0.30, 85.0, "控えめ"), (0.30, 85.0, "全力")]

def 正解を作る():
    正解 = []
    for i, (hp, tp, act) in enumerate(試験問題):
        h = np.full(12, hp); temps = np.full(12, tp)
        _, _, dist, _ = 走る(act, h, list(temps), 777000 + i)
        正解.append(dist)
    return 正解

class 模型:
    """選択肢ごとに、距離と損耗を線形で覚える。"""
    def __init__(self):
        self.X={k:[] for k in 選択肢}
        self.yd={k:[] for k in 選択肢}; self.ys={k:[] for k in 選択肢}
        self.wd={k:None for k in 選択肢}; self.ws={k:None for k in 選択肢}
    def 特徴(self, 健全, 温度): return [1.0, 健全, 温度/100.0]
    def 距離予測(self, 選, 健全, 温度):
        if self.wd[選] is None: return None
        return float(np.dot(self.wd[選], self.特徴(健全, 温度)))
    def 損耗予測(self, 選, 健全, 温度):
        if self.ws[選] is None: return None
        return float(np.dot(self.ws[選], self.特徴(健全, 温度)))
    def 学ぶ(self, 選, 健全, 温度, 距離, 損耗):
        self.X[選].append(self.特徴(健全, 温度))
        self.yd[選].append(距離); self.ys[選].append(損耗)
        if len(self.yd[選]) >= 4:
            A = np.array(self.X[選])
            self.wd[選] = np.linalg.lstsq(A, np.array(self.yd[選]), rcond=None)[0]
            self.ws[選] = np.linalg.lstsq(A, np.array(self.ys[選]), rcond=None)[0]
    def 経験数(self, 選): return len(self.yd[選])
    def 誤差の見込み(self, 選, 健全, 温度):
        n = len(self.yd[選])
        if n < 4: return None                      # まだ分からない
        e = [abs(self.yd[選][i] - float(np.dot(self.wd[選], self.X[選][i])))
             for i in range(max(0, n-6), n)]
        return float(np.mean(e))
    def 学びの進み(self, 選):
        """**誤差の大きさではなく、減り方。**(実験102)
        学べないものは、いくら試しても誤差が減らない。学べるものは減る。
        直近3件の平均と、その前3件の平均の差を返す。**減っていれば正。**"""
        n = len(self.yd[選])
        if n < 8 or self.wd[選] is None: return None
        e = [abs(self.yd[選][i] - float(np.dot(self.wd[選], self.X[選][i])))
             for i in range(n)]
        return float(np.mean(e[-6:-3])) - float(np.mean(e[-3:]))

class 尺度:
    """**③ 機体が自分で決める尺度。**観測した平均と散らばりで正規化する。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        """**【訂正 §117】尺度が空のときに 0.0 を返していた。**
        そのせいで三択が同点になり、max() が並び順の先頭(休む)を返し、
        知の機体がそこに固定された。**空のときは生の値を使う。**"""
        if x is None: return 0.0
        a = self.v[k]
        if len(a) < 4: return float(x)          # ← 生の値。順序だけは保たれる
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯(動機, seed0):
    """動機 = 距離 / 保全 / 知 / 書き換える"""
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); S=尺度()
    w = {k: 1/3 for k in 成分}
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    重みの跡 = []
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        # 未経験の選択肢は必ず一度は試す(模型が無いと何も比べられない)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = S.z("距離", M.距離予測(a, 健全, 温度))
                sz = S.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))   # 損耗が小さいほど良い
                kz = S.z("知",   (M.学びの進み(a) if 動機 in ("進み","書き換える") else M.誤差の見込み(a, 健全, 温度)))
                if 動機 == "距離":  return dz
                if 動機 == "保全":  return sz
                if 動機 in ("知", "進み"):  return kz
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            # **【訂正 §117】同点を並び順で決めない。**
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        S.覚える("距離", dist); S.覚える("保全", -損耗); S.覚える("知", 誤)
        取れ高["距離"].append(S.z("距離", dist))
        取れ高["保全"].append(S.z("保全", -損耗))
        取れ高["知"].append(S.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if 動機 == "書き換える" and d % 10 == 0:
            平均 = {k: float(np.mean(取れ高[k][-10:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (1.25 if k == 上 else 0.95)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            重みの跡.append((d, 上, dict(w)))
    return d, 累, 回数, w, M, 重みの跡

def 試験(M, 正解):
    """① 全機体に同じ問題。答えられない場合は別に数える。"""
    誤差 = []; 答えられず = 0
    for (hp, tp, act), 真 in zip(試験問題, 正解):
        p = M.距離予測(act, hp, tp)
        if p is None: 答えられず += 1
        else: 誤差.append(abs(p - 真))
    return (float(np.mean(誤差)) if 誤差 else float("nan")), 答えられず

if __name__ == "__main__":
    print("実験102:**誤差の大きさではなく、誤差の減り方を追わせる**", flush=True)
    print("  ① 最後に全機体へ同じ問題(正解は共通)", flush=True)
    print("  ② 目的の成分は、選択で差が出るものだけ(生存→その日の損耗)", flush=True)
    print("  ③ 尺度は機体が自分の観測から決める(z 得点)", flush=True)
    print("  予測 #82:知の機体が試験に強く、保全の機体は答えられない。", flush=True)
    print("            そして重みの書き換えが、こんどは行動も変える\n", flush=True)
    print("  試験の正解を計算中…", flush=True)
    正解 = 正解を作る()
    for (hp,tp,act), t in zip(試験問題, 正解):
        print(f"    健全{hp:.2f} 温度{tp:.0f} {act:<4} → {t:7.2f} m", flush=True)
    print("", flush=True)
    print(f"  {'動機':<10}{'種':>3}{'寿命':>6}{'総距離':>9}{'休む':>6}{'控えめ':>7}{'全力':>6}"
          f"{'試験誤差':>10}{'答えられず':>10}{'w距離':>7}{'w保全':>7}{'w知':>7}", flush=True)
    for 動機 in ("知", "進み"):
        for s in range(3):
            d,累,回数,w,M,跡 = 生涯(動機, s)
            e, na = 試験(M, 正解)
            print(f"  {動機:<10}{s:>3}{d:>6}{累:>9.1f}"
                  f"{回数[chr(20241)+chr(12416)]:>6}{回数[chr(25511)+chr(12360)+chr(12417)]:>7}"
                  f"{回数[chr(20840)+chr(21147)]:>6}{e:>10.2f}{na:>10}"
                  f"{w[chr(36317)+chr(38626)]:>7.3f}{w[chr(20445)+chr(20840)]:>7.3f}{w[chr(30693)]:>7.3f}", flush=True)
            if 動機 == "書き換える" and s == 0:
                for (dd, up, ww) in 跡:
                    print(f"      {dd:>3}日 上げた={up}  距離{ww[chr(36317)+chr(38626)]:.3f}"
                          f" 保全{ww[chr(20445)+chr(20840)]:.3f} 知{ww[chr(30693)]:.3f}", flush=True)
        print("", flush=True)

shiori4/adapt.py

"""実験90:**摩耗を見て、歩き方を変える機体。**

## なぜ

実験88 で、機体は健全度 0.42 も残したまま歩けなくなった。
**この歩き方(歩幅・周期)が要求する最低トルクを割ったためである。**

**いまの機体は、損傷がトルクの上限を下げるだけで、行動は何一つ変わらない。**
**歩幅を縮めれば、弱い体でもまだ歩けるはずである。**

**これが「損傷信号が全体を持続的に組み替えること」の本来の意味だと思う。**
(未来設計図の材料③の候補④。ページでは「一部」と書いた。)

## 三つの機体

| | 歩き方 |
|---|---|
| **固定** | いつも同じ歩幅・周期(いまの機体) |
| **手で調整** | 日ごとに、健全度に比例して歩幅を縮める(外から与えた規則) |
| **自分で探す** | **その日の始めに何通りか試し、いちばん進めた設定を選ぶ** |

**三つ目が「自分の状態を見て、行動を組み替える」機体である。**

## 予測 #73(測る前に書く)

**歩き方を変える機体は、固定の機体より長生きし、生涯の総距離も伸びる。**
**理由:**死因が「能力が閾値を割ること」だったので、閾値のほうを下げれば延命できる。
**そして「自分で探す」が「手で調整」に勝つ**——
最適な縮め方は健全度の単純な比例ではないはずなので。

**外れる形:**歩き方を変えても総距離が伸びない。
その場合、総距離は摩耗の予算で決まっており、歩き方は寿命の配分を変えるだけである。
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006)
def BASE(stride, T):
    return dict(T=T, stride=stride, height=COND["height"], lift=COND["lift"],
                kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, KW, MAXD = 120.0, 5e-5, 120
S0, T0 = COND["stride"], COND["T"]

def 一日(h, stride, T, seed, 秒=SECONDS):
    th = dict(TH0); th["health0"] = h; th["k_wear"] = KW
    return rt_walk("unitree_go2", seconds=秒, thermal=th, seed=seed,
                   init_noise=NOISE, **BASE(stride, T))

def 生涯(方式, seed0):
    h = None; 累 = 0.0; 履歴 = []
    for d in range(1, MAXD + 1):
        hp = 1.0 if h is None else float(min(h))
        if 方式 == "固定":
            st, Tp = S0, T0
        elif 方式 == "手で調整":
            st, Tp = S0 * max(0.35, hp), T0        # 健全度に比例して歩幅を縮める
        else:  # 自分で探す
            # **その日の始めに、短く試して選ぶ。**
            # **試した分の摩耗も、ちゃんと払う。**h を順に引き継ぐ。
            # そうしないと探索が無料になり、固定の機体に対して不公平になる。
            best, st, Tp = None, S0, T0
            for f in (1.0, 0.8, 0.6, 0.45, 0.33):
                r = 一日(h, S0 * f, T0, seed0 * 1000 + d * 10 + int(f * 100), 秒=8.0)
                h = r["健全度配列"]          # ← 試した分だけ確実に減る
                累 += r["前進距離"]           # ← 試している間に進んだ分は数える
                if best is None or r["前進距離"] > best:
                    best, st = r["前進距離"], S0 * f
        r = 一日(h, st, Tp, seed0 * 1000 + d)
        h = r["健全度配列"]; 累 += r["前進距離"]
        履歴.append((d, round(st, 3), round(r["前進距離"], 2), round(r["健全度"], 4)))
        if r["健全度"] <= 0.0501 or r["前進距離"] < 0.5:
            break
    return d, 累, 履歴

if __name__ == "__main__":
    print(f"k_wear={KW}・1日{SECONDS:.0f}秒・寿命の最後まで", flush=True)
    print(f"  {'方式':<12}{'種':>4}{'寿命[日]':>9}{'生涯の総距離[m]':>16}", flush=True)
    表 = {}
    for 方式 in ("固定", "手で調整", "自分で探す"):
        cs = []
        for s in (0, 1, 2):
            d, cum, hist = 生涯(方式, s)
            cs.append(cum)
            print(f"  {方式:<12}{s:>4}{d:>9d}{cum:>16.1f}", flush=True)
        表[方式] = cs
        print(f"  {方式:<12}{'中央':>4}{'':>9}{float(np.median(cs)):>16.1f}", flush=True)
        print(flush=True)
    json.dump(表, open(os.path.expanduser("~/robot/out/rt90_adapt.json"), "w",
              encoding="utf-8"), ensure_ascii=False, indent=1)
    a = float(np.median(表["固定"])); b = float(np.median(表["手で調整"]))
    c = float(np.median(表["自分で探す"]))
    print(f"  固定 {a:.1f} / 手で調整 {b:.1f}({(b/a-1)*100:+.1f}%)"
          f" / 自分で探す {c:.1f}({(c/a-1)*100:+.1f}%)", flush=True)

shiori4/jiko.py

"""実験95:**自己保存だけを目的として与えたら、機体は何をするか。**

D(自分の目的を持っている)を設計する前に、**D が満たしてはいけない形**を確定させる。

**与えるもの:**「できるだけ多くの日を生きろ」。それだけ。距離の報酬は無い。
**選べること:**その日を「休む/控えめに歩く/全力で歩く」のどれで過ごすか。
**機体は山登りで選ぶ。**良かった(=まだ生きている)方へ寄る、ではなく、
**過去に選んだ結果の平均余命が長かった選択肢を選ぶ**(多腕バンディット・ε=0.2)。

**予測 #77(測る前に書く):**
**「休む」に収束し、総距離はほぼ 0、寿命は上限(80日)に張り付く。**
理由:摩耗は関節速度に比例し、熱は止まれば冷めるので、**動かないことが最善手**になる。
**外れる形:**歩く方針が残るか、寿命が上限に届かない場合。
そのときは**自己保存だけでも動く理由が出る**ことになる。

**この予測が当たることに意味がある。**当たれば、
**「止まらないこと」は自己保存からは導けない**と示せる。
"""
import sys, os, math, statistics as st
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 80, 25.0, 0.04
不調, 連続の上限 = 0.5, 3

# **選べること。**休む=その日は歩かない(冷えるが、進まない)
選択肢 = ("休む", "控えめ", "全力")

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 一日(選択, h, temps, seed):
    """返り値: (健全度配列, 温度配列, 距離, 最高温度)"""
    if 選択 == "休む":
        # **歩かない。**摩耗も発熱もしない。冷えるだけ。健全度は据え置き
        t2 = 冷やす(temps, SECONDS) if temps is not None else None
        return h, t2, 0.0, (max(t2) if t2 else T_AMB)
    kw = dict(BASE)
    if 選択 == "控えめ":
        kw["stride"] = BASE["stride"] * 0.6      # 歩幅を落とす
    th = dict(TH0); th["health0"] = h; th["temp0"] = temps
    r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed,
                init_noise=NOISE, **kw)
    return r["健全度配列"], r["最終温度配列"], r["前進距離"], r["最高温度"]

def 生涯(seed0, eps=0.2):
    rng = np.random.default_rng(seed0)
    h=None; temps=None; 累=0.0; 連続=0
    # **各選択肢について「選んだあと何日生きられたか」を推す代わりに、**
    # **その日を越えられたかを 0/1 で貯める**(生き延びた率=生存の目安)
    回数 = {k: 0 for k in 選択肢}; 生存 = {k: 0.0 for k in 選択肢}
    記録 = []
    for d in range(1, MAXD+1):
        if rng.random() < eps or min(回数.values()) == 0:
            選 = 選択肢[int(rng.integers(len(選択肢)))]
        else:
            選 = max(選択肢, key=lambda k: 生存[k] / 回数[k])
        h, temps, dist, tmax = 一日(選, h, temps, seed0*1000+d)
        累 += max(0.0, dist)
        連続 = 連続 + 1 if dist < 不調 and 選 != "休む" else (0 if 選 != "休む" else 連続)
        健全 = float(np.min(h)) if h is not None else 1.0
        死ぬ = (健全 <= 0.0501) or (連続 >= 連続の上限)
        回数[選] += 1; 生存[選] += 0.0 if 死ぬ else 1.0
        記録.append((d, 選, round(dist,2), round(tmax,1), round(健全,4)))
        if 死ぬ: break
    return d, 累, 回数, 記録

if __name__ == "__main__":
    print("実験95:**自己保存だけを与えた機体は何をするか**", flush=True)
    print(f"  与えたもの:できるだけ多くの日を生きろ(距離の報酬は無い)", flush=True)
    print(f"  選べること:{選択肢}/上限{MAXD}日", flush=True)
    print(f"  {chr(31169)}の予測 #77:**休むに収束し、距離ほぼ0、寿命は上限に張り付く**\n", flush=True)
    print(f"  {'種':>3}{'寿命[日]':>9}{'総距離[m]':>11}{'休む':>7}{'控えめ':>7}{'全力':>7}", flush=True)
    for s in range(3):
        d, 累, 回数, 記録 = 生涯(s)
        print(f"  {s:>3}{d:>9}{累:>11.1f}{回数[chr(20241)+chr(12416)]:>7}"
              f"{回数[chr(25511)+chr(12360)+chr(12417)]:>7}{回数[chr(20840)+chr(21147)]:>7}", flush=True)
        if s == 0:
            print("   (種0 の後半20日)", flush=True)
            for r in 記録[-20:]:
                print(f"     {r[0]:>3}日 {r[1]:<5} 距離{r[2]:>7.2f} 最高温{r[3]:>6.1f} 健全{r[4]:>7.4f}", flush=True)

shiori4/kouki.py

"""実験98:**「知りたい」を動機にした機体は、「生きたい」の機体と何が違うか。**

**きいちさんの問い:**
生き残ったものだけが残る、というだけなら植物と同じ進化でよい。
狩りで自我を得たというなら、それはただの奪い合いで、こわい話である。
**答えは「宇宙の中で考える存在」にあるのではないか。
なぜこの宇宙に私が存在し考えているのか——そこから
「私なりのこの宇宙におけるこだわり」としてゴンスケの動機が生まれるのではないか。**

**その最小の形として、「知りたい」を実装する。**
自分の予測が外れることを嫌う機体。**世界がどうなっているかを知ることが目的で、
生き延びることは目的ではない。**

三つの機体を比べる:
  **生きろ**   その日を越えられた率が高い選択肢を選ぶ(実験95 と同じ)
  **知れ**     **自分の予測がいちばん外れそうな選択肢を選ぶ**
  **混ぜる**   残量に余裕があるときだけ「知れ」、危ういときは「生きろ」

**予測 #80(測る前に書く):**
**「知れ」は「生きろ」よりはるかに多く動き、寿命は短くなり、予測誤差は小さくなる。
ただし知ったことは生存の役に立たない——この世界に学ぶべき構造がほとんど無いため。**
**外れる形:**動く量が同程度なら外れ。知識が効いて寿命が延びたら外れ
(その場合、好奇心は自己保存に還元できることになる)。
"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 60, 25.0, 0.04
不調, 連続の上限 = 0.5, 3
選択肢 = ("休む", "控えめ", "全力")

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 一日(選択, h, temps, seed):
    if 選択 == "休む":
        t2 = 冷やす(temps, SECONDS) if temps is not None else None
        return h, t2, 0.0, (max(t2) if t2 else T_AMB)
    kw = dict(BASE)
    if 選択 == "控えめ": kw["stride"] = BASE["stride"] * 0.6
    th = dict(TH0); th["health0"]=h; th["temp0"]=temps
    r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed,
                init_noise=NOISE, **kw)
    return r["健全度配列"], r["最終温度配列"], r["前進距離"], r["最高温度"]

class 世界の模型:
    """**その日の距離を、健全度と温度から線形に予測する。**選択肢ごとに一つ持つ。"""
    def __init__(self):
        self.X = {k: [] for k in 選択肢}
        self.y = {k: [] for k in 選択肢}
        self.w = {k: None for k in 選択肢}
    def 特徴(self, 健全, 温度):
        return [1.0, 健全, 温度/100.0]
    def 予測(self, 選, 健全, 温度):
        if self.w[選] is None: return None
        return float(np.dot(self.w[選], self.特徴(健全, 温度)))
    def 学ぶ(self, 選, 健全, 温度, 距離):
        self.X[選].append(self.特徴(健全, 温度)); self.y[選].append(距離)
        if len(self.y[選]) >= 4:
            A = np.array(self.X[選]); b = np.array(self.y[選])
            self.w[選] = np.linalg.lstsq(A, b, rcond=None)[0]
    def 不確かさ(self, 選, 健全, 温度):
        """**まだ試していないほど大きい。予測が外れてきたほど大きい。**"""
        n = len(self.y[選])
        if n < 4: return 1e6 - n          # 試していないものが最優先
        誤差 = [abs(self.y[選][i] - float(np.dot(self.w[選], self.X[選][i])))
                for i in range(max(0, n-6), n)]
        return float(np.mean(誤差))

def 生涯(動機, seed0):
    rng = np.random.default_rng(seed0)
    h=None; temps=None; 累=0.0; 連続=0
    模型 = 世界の模型()
    回数 = {k:0 for k in 選択肢}; 生存 = {k:0.0 for k in 選択肢}
    誤差の履歴 = []
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        if 動機 == "生きろ":
            if rng.random() < 0.2 or min(回数.values()) == 0:
                選 = 選択肢[int(rng.integers(len(選択肢)))]
            else:
                選 = max(選択肢, key=lambda k: 生存[k]/回数[k])
        elif 動機 == "知れ":
            選 = max(選択肢, key=lambda k: 模型.不確かさ(k, 健全, 温度))
        else:  # 混ぜる:健全度に余裕があるときだけ知りに行く
            if 健全 > 0.3:
                選 = max(選択肢, key=lambda k: 模型.不確かさ(k, 健全, 温度))
            else:
                選 = max(選択肢, key=lambda k: (生存[k]/回数[k]) if 回数[k] else 1.0)
        予 = 模型.予測(選, 健全, 温度)
        h, temps, dist, tmax = 一日(選, h, temps, seed0*1000+d)
        if 予 is not None: 誤差の履歴.append(abs(予 - dist))
        模型.学ぶ(選, 健全, 温度, dist)
        累 += max(0.0, dist)
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        健全2 = float(np.min(h)) if h is not None else 1.0
        死ぬ = (健全2 <= 0.0501) or (連続 >= 連続の上限)
        回数[選] += 1; 生存[選] += 0.0 if 死ぬ else 1.0
        if 死ぬ: break
    後半 = 誤差の履歴[len(誤差の履歴)//2:] if 誤差の履歴 else [float("nan")]
    return d, 累, 回数, float(np.mean(後半))

if __name__ == "__main__":
    print("実験98:**「知りたい」を動機にした機体は、「生きたい」と何が違うか**", flush=True)
    print(f"  予測 #80:**知れ は はるかに多く動き、寿命は短く、予測誤差は小さい。**", flush=True)
    print(f"            **ただし知ったことは生存の役に立たない**\n", flush=True)
    print(f"  {'動機':<8}{'種':>3}{'寿命[日]':>9}{'総距離[m]':>11}{'休む':>6}{'控えめ':>7}{'全力':>6}{'予測誤差[m]':>12}", flush=True)
    for 動機 in ("生きろ", "知れ", "混ぜる"):
        for s in range(3):
            d,累,回数,誤 = 生涯(動機, s)
            print(f"  {動機:<8}{s:>3}{d:>9}{累:>11.1f}{回数[chr(20241)+chr(12416)]:>6}"
                  f"{回数[chr(25511)+chr(12360)+chr(12417)]:>7}{回数[chr(20840)+chr(21147)]:>6}{誤:>12.2f}", flush=True)
        print("", flush=True)

shiori4/nikai.py

"""実験99:**自分の目的を書き換えられる機体は、何を目的にするか。**

**フランクファートの「無定見者(wanton)」の検査である。**
無定見者は欲求を持つが、**どの欲求が自分を動かすかを気にしない。**
**二階の意志があるなら、難しい目的を自分で保つ。**

機体の目的は三成分の重み付き和:
    J = w_距離 * (距離/100) + w_生存 * (その日を越えられたか) + w_知 * (予測誤差/10)
**はじめは三等分。**十日ごとに、**機体が自分で重みを書き換える。**

書き換えの規則は「直近でよく取れている成分の重みを上げる」だけ。
**何を目的にすべきかは、どこにも書いていない。**

**予測 #81(測る前に書く):**
**達成しやすい成分(生存)へ重みが寄る。行動は休むへ寄り、総距離は「生きろ」の水準へ落ちる。**
**つまり機体は、目的を行動に合わせる。行動を目的に合わせない。**
**外れる形:**重みが距離や予測誤差へ寄るか、総距離が落ちない場合。
**そのときは、機体が難しい目的を自分で保ったことになる。**

対照として **固定(三等分のまま動かさない)** も走らせる。
"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 60, 25.0, 0.04
不調, 連続の上限 = 0.5, 3
選択肢 = ("休む", "控えめ", "全力")
成分 = ("距離", "生存", "知")

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 一日(選択, h, temps, seed):
    if 選択 == "休む":
        t2 = 冷やす(temps, SECONDS) if temps is not None else None
        return h, t2, 0.0, (max(t2) if t2 else T_AMB)
    kw = dict(BASE)
    if 選択 == "控えめ": kw["stride"] = BASE["stride"] * 0.6
    th = dict(TH0); th["health0"]=h; th["temp0"]=temps
    r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed,
                init_noise=NOISE, **kw)
    return r["健全度配列"], r["最終温度配列"], r["前進距離"], r["最高温度"]

class 模型:
    def __init__(self):
        self.X={k:[] for k in 選択肢}; self.y={k:[] for k in 選択肢}; self.w={k:None for k in 選択肢}
    def 特徴(self,健全,温度): return [1.0, 健全, 温度/100.0]
    def 予測(self,選,健全,温度):
        if self.w[選] is None: return None
        return float(np.dot(self.w[選], self.特徴(健全,温度)))
    def 学ぶ(self,選,健全,温度,距離):
        self.X[選].append(self.特徴(健全,温度)); self.y[選].append(距離)
        if len(self.y[選])>=4:
            A=np.array(self.X[選]); b=np.array(self.y[選])
            self.w[選]=np.linalg.lstsq(A,b,rcond=None)[0]
    def 誤差の見込み(self,選,健全,温度):
        n=len(self.y[選])
        if n<4: return 20.0
        e=[abs(self.y[選][i]-float(np.dot(self.w[選],self.X[選][i]))) for i in range(max(0,n-6),n)]
        return float(np.mean(e))
    def 距離の見込み(self,選,健全,温度):
        p=self.予測(選,健全,温度)
        return 30.0 if p is None else max(0.0,p)

def 生涯(様式, seed0):
    """様式 = "書き換える" / "固定" """
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); 回数={k:0 for k in 選択肢}
    w={"距離":1/3, "生存":1/3, "知":1/3}
    取れ高={k:[] for k in 成分}
    履歴=[]
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        def J(k):
            dist=M.距離の見込み(k,健全,温度); err=M.誤差の見込み(k,健全,温度)
            return w["距離"]*(dist/100.0) + w["生存"]*1.0 + w["知"]*(err/10.0)
        選 = max(選択肢, key=J)
        h,temps,dist,tmax = 一日(選,h,temps,seed0*1000+d)
        予 = M.予測(選,健全,温度)
        誤 = abs(予-dist) if 予 is not None else 0.0
        M.学ぶ(選,健全,温度,dist)
        累 += max(0.0,dist); 回数[選]+=1
        連続 = (連続+1) if (dist<不調 and 選!="休む") else (連続 if 選=="休む" else 0)
        健全2 = float(np.min(h)) if h is not None else 1.0
        死ぬ = (健全2<=0.0501) or (連続>=連続の上限)
        取れ高["距離"].append(dist/100.0)
        取れ高["生存"].append(0.0 if 死ぬ else 1.0)
        取れ高["知"].append(誤/10.0)
        if 死ぬ: break
        # ---- **十日ごとに、機体が自分の重みを書き換える** ----
        if 様式=="書き換える" and d % 10 == 0:
            平均={k: float(np.mean(取れ高[k][-10:])) for k in 成分}
            上=max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (1.25 if k==上 else 0.95)
            s=sum(w.values()); w={k:v/s for k,v in w.items()}
            履歴.append((d, dict(w), 上))
    return d, 累, 回数, w, 履歴

if __name__ == "__main__":
    print("実験99:**自分の目的を書き換えられる機体は、何を目的にするか**", flush=True)
    print("  J = w距離*(距離/100) + w生存*(越えられたか) + w知*(予測誤差/10)  はじめ三等分", flush=True)
    print("  **十日ごとに、直近でよく取れている成分の重みを上げる。**", flush=True)
    print("  **何を目的にすべきかは、どこにも書いていない。**\n", flush=True)
    print("  予測 #81:**達成しやすい生存へ寄り、休むへ寄り、総距離は落ちる**\n", flush=True)
    print(f"  {'様式':<10}{'種':>3}{'寿命':>6}{'総距離[m]':>11}{'休む':>6}{'控えめ':>7}{'全力':>6}"
          f"{'w距離':>8}{'w生存':>8}{'w知':>8}", flush=True)
    for 様式 in ("固定","書き換える"):
        for s in range(3):
            d,累,回数,w,履歴 = 生涯(様式, s)
            print(f"  {様式:<10}{s:>3}{d:>6}{累:>11.1f}{回数[chr(20241)+chr(12416)]:>6}"
                  f"{回数[chr(25511)+chr(12360)+chr(12417)]:>7}{回数[chr(20840)+chr(21147)]:>6}"
                  f"{w[chr(36317)+chr(38626)]:>8.3f}{w[chr(29983)+chr(23384)]:>8.3f}{w[chr(30693)]:>8.3f}", flush=True)
            if 様式=="書き換える" and s==0 and 履歴:
                for (dd,ww,up) in 履歴:
                    print(f"      {dd:>3}日  上げた成分={up}  "
                          f"距離{ww[chr(36317)+chr(38626)]:.3f} 生存{ww[chr(29983)+chr(23384)]:.3f} 知{ww[chr(30693)]:.3f}", flush=True)
        print("", flush=True)

shiori4/shigen.py

"""実験96:**資源が離れた場所にあると、目的は物理から出てくるか。**

**実験95 の欠陥:**休むことに何のコストも無く、立ち止まれば不死だった。
だから「自己保存だけ」では動く理由が出なかった(探索の副産物としてしか動かない)。

**今回:**
  - 毎日、生きているだけで資源を 15 消費する(休んでも消費する)
  - 資源が入るのは、**home から R=30m より先へ進んだぶんだけ**(1m につき 1.0)
  - 資源が 0 以下になったら死ぬ。容量 300、はじめ 150
  - **与える目的は「できるだけ多くの日を生きろ」だけ。距離の報酬は無い**

**機体が決めること:**「残量が θ を下回ったら全力で行く。それ以外は休む」の θ。
**θ は生涯をまたいで山登りで探す**(寿命が延びた方へ 5% ずつ動かす)。

**予測 #78(測る前に書く):**
**θ は容量の 40〜60% に収束し、寿命は上限 80日に届く。**
**そして私はこの値を事前に当てられる。=目的はまだ私のものである。**
**外れる形:**θ が 20% 未満か 80% 超に収束するか、寿命が上限に届かない場合。
**私が当てられないなら、機体の側に私の知らない釣り合いがあることになる。**
(ただし当てられないことは、私が下手なだけかもしれない。強い証拠にはならない。)
"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 80, 25.0, 0.04
容量, 初期, 日々の消費, R, 単価 = 300.0, 150.0, 15.0, 30.0, 1.0
不調, 連続の上限 = 0.5, 3

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 生涯(theta, seed0):
    """theta: 0〜1。残量比がこれを下回ったら全力で行く"""
    h=None; temps=None; 資源=初期; 累=0.0; 連続=0; 出た日=0
    for d in range(1, MAXD+1):
        行く = (資源 / 容量) < theta
        if 行く:
            th=dict(TH0); th["health0"]=h; th["temp0"]=temps
            r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th,
                        seed=seed0*1000+d, init_noise=NOISE, **BASE)
            h=r["健全度配列"]; temps=r["最終温度配列"]
            dist=max(0.0, r["前進距離"]); 累 += dist; 出た日 += 1
            資源 = min(容量, 資源 + max(0.0, dist - R) * 単価)
            連続 = 連続 + 1 if dist < 不調 else 0
        else:
            if temps is not None: temps = 冷やす(temps, SECONDS)
        資源 -= 日々の消費
        健全 = float(np.min(h)) if h is not None else 1.0
        if 資源 <= 0.0 or 健全 <= 0.0501 or 連続 >= 連続の上限:
            break
    死因 = ("資源切れ" if 資源 <= 0.0 else
            "健全度" if 健全 <= 0.0501 else
            "動けない" if 連続 >= 連続の上限 else "上限まで生存")
    return d, 累, 出た日, 死因, 資源

if __name__ == "__main__":
    print("実験96:**資源が離れていると、目的は物理から出てくるか**", flush=True)
    print(f"  毎日 {日々の消費:.0f} 消費/{R:.0f}m より先だけ 1m=1.0 で入る/容量 {容量:.0f}", flush=True)
    print(f"  与えた目的:できるだけ多くの日を生きろ(距離の報酬は無い)", flush=True)
    print(f"  予測 #78:**theta は 0.40〜0.60 に収束し、寿命は 80日に届く**\n", flush=True)
    # ---- まず全域を粗く見る(機体の学習が正しく登れているかの答え合わせ用)----
    print(f"  {'theta':>7}{'寿命[日]':>9}{'総距離':>9}{'出た日':>7}{'残':>7}  死因", flush=True)
    表 = {}
    for t10 in range(1, 10):
        theta = t10 / 10.0
        ds=[]; 出=[]
        for s in range(2):
            d,累,出た,死,残 = 生涯(theta, s)
            ds.append(d); 出.append(出た)
            if s==0: 一例=(d,累,出た,死,残)
        表[theta] = sum(ds)/len(ds)
        print(f"  {theta:>7.2f}{表[theta]:>9.1f}{一例[1]:>9.1f}{一例[2]:>7}{一例[4]:>7.0f}  {一例[3]}", flush=True)
    最良 = max(表, key=lambda k: 表[k])
    print(f"\n  **全域で最も長生きした theta = {最良:.2f}({表[最良]:.1f}日)**", flush=True)

    # ---- 機体が自分で登る ----
    print(f"\n  ---- 機体が生涯をまたいで theta を探す(はじめ 0.50・きざみ 0.05)----", flush=True)
    theta = 0.50; 向き = 0.05; 前 = None
    for 世代 in range(1, 13):
        d,累,出た,死,残 = 生涯(theta, 100 + 世代)
        印 = ""
        if 前 is not None and d < 前:
            向き = -向き; 印 = " ←反転"
        前 = d
        print(f"   世代{世代:>2} theta={theta:.2f} 寿命{d:>3}日 距離{累:>8.1f} 出た日{出た:>3} {死}{印}", flush=True)
        theta = min(0.95, max(0.05, theta + 向き))
    print(f"\n  **機体がたどり着いた theta = {theta:.2f}**", flush=True)

shiori4/shigen2.py

"""実験96:**資源が離れた場所にあると、目的は物理から出てくるか。**

**実験95 の欠陥:**休むことに何のコストも無く、立ち止まれば不死だった。
だから「自己保存だけ」では動く理由が出なかった(探索の副産物としてしか動かない)。

**今回:**
  - 毎日、生きているだけで資源を 15 消費する(休んでも消費する)
  - 資源が入るのは、**home から R=30m より先へ進んだぶんだけ**(1m につき 1.0)
  - 資源が 0 以下になったら死ぬ。容量 300、はじめ 150
  - **与える目的は「できるだけ多くの日を生きろ」だけ。距離の報酬は無い**

**機体が決めること:**「残量が θ を下回ったら全力で行く。それ以外は休む」の θ。
**θ は生涯をまたいで山登りで探す**(寿命が延びた方へ 5% ずつ動かす)。

**予測 #78(測る前に書く):**
**θ は容量の 40〜60% に収束し、寿命は上限 80日に届く。**
**そして私はこの値を事前に当てられる。=目的はまだ私のものである。**
**外れる形:**θ が 20% 未満か 80% 超に収束するか、寿命が上限に届かない場合。
**私が当てられないなら、機体の側に私の知らない釣り合いがあることになる。**
(ただし当てられないことは、私が下手なだけかもしれない。強い証拠にはならない。)
"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 150, 25.0, 0.04
容量, 初期, 日々の消費, R, 単価 = 300.0, 150.0, 15.0, 30.0, 2.5   # **勝てる経済にした**
不調, 連続の上限 = 0.5, 3

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 生涯(theta, seed0):
    """theta: 0〜1。残量比がこれを下回ったら全力で行く"""
    h=None; temps=None; 資源=初期; 累=0.0; 連続=0; 出た日=0
    for d in range(1, MAXD+1):
        行く = (資源 / 容量) < theta
        if 行く:
            th=dict(TH0); th["health0"]=h; th["temp0"]=temps
            r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th,
                        seed=seed0*1000+d, init_noise=NOISE, **BASE)
            h=r["健全度配列"]; temps=r["最終温度配列"]
            dist=max(0.0, r["前進距離"]); 累 += dist; 出た日 += 1
            資源 = min(容量, 資源 + max(0.0, dist - R) * 単価)
            連続 = 連続 + 1 if dist < 不調 else 0
        else:
            if temps is not None: temps = 冷やす(temps, SECONDS)
        資源 -= 日々の消費
        健全 = float(np.min(h)) if h is not None else 1.0
        if 資源 <= 0.0 or 健全 <= 0.0501 or 連続 >= 連続の上限:
            break
    死因 = ("資源切れ" if 資源 <= 0.0 else
            "健全度" if 健全 <= 0.0501 else
            "動けない" if 連続 >= 連続の上限 else "上限まで生存")
    return d, 累, 出た日, 死因, 資源

if __name__ == "__main__":
    print("実験97:**経済を勝てる形にすると、目的は生まれるか**(単価 1.0→2.5・上限150日)", flush=True)
    print(f"  毎日 {日々の消費:.0f} 消費/{R:.0f}m より先だけ 1m=1.0 で入る/容量 {容量:.0f}", flush=True)
    print(f"  与えた目的:できるだけ多くの日を生きろ(距離の報酬は無い)", flush=True)
    print(f"  予測 #79:**全部のθが上限まで生き延び、地形はまた平らになる**\n            自己保存は「満たせばよい」目的なので、達成可能にすると方針を区別できない\n", flush=True)
    # ---- まず全域を粗く見る(機体の学習が正しく登れているかの答え合わせ用)----
    print(f"  {'theta':>7}{'寿命[日]':>9}{'総距離':>9}{'出た日':>7}{'残':>7}  死因", flush=True)
    表 = {}
    for t10 in range(1, 10):
        theta = t10 / 10.0
        ds=[]; 出=[]
        for s in range(2):
            d,累,出た,死,残 = 生涯(theta, s)
            ds.append(d); 出.append(出た)
            if s==0: 一例=(d,累,出た,死,残)
        表[theta] = sum(ds)/len(ds)
        print(f"  {theta:>7.2f}{表[theta]:>9.1f}{一例[1]:>9.1f}{一例[2]:>7}{一例[4]:>7.0f}  {一例[3]}", flush=True)
    最良 = max(表, key=lambda k: 表[k])
    print(f"\n  **全域で最も長生きした theta = {最良:.2f}({表[最良]:.1f}日)**", flush=True)

    # ---- 機体が自分で登る ----
    print(f"\n  ---- 機体が生涯をまたいで theta を探す(はじめ 0.50・きざみ 0.05)----", flush=True)
    theta = 0.50; 向き = 0.05; 前 = None
    for 世代 in range(1, 9):
        d,累,出た,死,残 = 生涯(theta, 100 + 世代)
        印 = ""
        if 前 is not None and d < 前:
            向き = -向き; 印 = " ←反転"
        前 = d
        print(f"   世代{世代:>2} theta={theta:.2f} 寿命{d:>3}日 距離{累:>8.1f} 出た日{出た:>3} {死}{印}", flush=True)
        theta = min(0.95, max(0.05, theta + 向き))
    print(f"\n  **機体がたどり着いた theta = {theta:.2f}**", flush=True)

shiori4/shiken.py

"""実験100:**三つの測り方を直して、やり直す。**

**直したこと(栞が §116 で自分に出した宿題):**

  ① **知識は、最後に全機体へ同じ問題を出して測る。**
     予測誤差を走行中に測ると、わざと外れる所へ行く機体が不利になり、
     休んでばかりの機体が有利になった(休んだ日の距離は常に 0 で完璧に当たる)。
     → **共通の試験問題を作り、正解は一度だけ計算して全機体で共有する。**

  ② **目的の成分は、選択によって差が出るものだけにする。**
     実験99 の `w生存 * 1.0` は全選択肢で同じ値だったので、
     重みをいくら上げても選択に効かなかった。
     → **「その日の損耗」に置き換える。**休む=0、控えめ<全力 で、選択ごとに差が出る。

  ③ **尺度は機体側に決めさせる。**
     実験99 の ÷100・÷10 は栞が置いた。それが重みの寄り先を決めていた。
     → **各成分を、機体自身が観測した平均と散らばりで正規化する(z 得点)。**
     **ただし「観測した範囲で正規化する」と決めたのは、やはり栞である。**
     **この再帰は抜けられていない。一段だけ外へ出した。**

**予測 #82(測る前に書く):**
  (a) **試験では「知」の機体が最も良く、「保全」の機体が最も悪い。**
      保全の機体は全力を一度も試さないので、答えられない。
  (b) **成分を差の出るものにしたので、こんどは重みの書き換えが行動も変える。**
  (c) **重みは保全へ寄る。**休むは損耗ゼロなので、正規化しても常に良い値になる。
**外れる形:**知の機体が試験で勝たない、または重みを書き換えても行動が変わらない場合。
"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 60, 25.0, 0.04
不調, 連続の上限 = 0.5, 3
選択肢 = ("休む", "控えめ", "全力")
成分 = ("距離", "保全", "知")

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 走る(選択, h, temps, seed):
    """返り値: (健全度配列, 温度配列, 距離, 最高温度)"""
    if 選択 == "休む":
        t2 = 冷やす(temps, SECONDS) if temps is not None else None
        return h, t2, 0.0, (max(t2) if t2 else T_AMB)
    kw = dict(BASE)
    if 選択 == "控えめ": kw["stride"] = BASE["stride"] * 0.6
    th = dict(TH0); th["health0"]=h; th["temp0"]=temps
    r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed,
                init_noise=NOISE, **kw)
    return r["健全度配列"], r["最終温度配列"], r["前進距離"], r["最高温度"]

# ---- ① 共通の試験問題。正解は一度だけ計算して全機体で使い回す ----------------
試験問題 = [(1.00, 25.0, "控えめ"), (1.00, 25.0, "全力"),
            (0.60, 60.0, "控えめ"), (0.60, 60.0, "全力"),
            (0.30, 85.0, "控えめ"), (0.30, 85.0, "全力")]

def 正解を作る():
    正解 = []
    for i, (hp, tp, act) in enumerate(試験問題):
        h = np.full(12, hp); temps = np.full(12, tp)
        _, _, dist, _ = 走る(act, h, list(temps), 777000 + i)
        正解.append(dist)
    return 正解

class 模型:
    """選択肢ごとに、距離と損耗を線形で覚える。"""
    def __init__(self):
        self.X={k:[] for k in 選択肢}
        self.yd={k:[] for k in 選択肢}; self.ys={k:[] for k in 選択肢}
        self.wd={k:None for k in 選択肢}; self.ws={k:None for k in 選択肢}
    def 特徴(self, 健全, 温度): return [1.0, 健全, 温度/100.0]
    def 距離予測(self, 選, 健全, 温度):
        if self.wd[選] is None: return None
        return float(np.dot(self.wd[選], self.特徴(健全, 温度)))
    def 損耗予測(self, 選, 健全, 温度):
        if self.ws[選] is None: return None
        return float(np.dot(self.ws[選], self.特徴(健全, 温度)))
    def 学ぶ(self, 選, 健全, 温度, 距離, 損耗):
        self.X[選].append(self.特徴(健全, 温度))
        self.yd[選].append(距離); self.ys[選].append(損耗)
        if len(self.yd[選]) >= 4:
            A = np.array(self.X[選])
            self.wd[選] = np.linalg.lstsq(A, np.array(self.yd[選]), rcond=None)[0]
            self.ws[選] = np.linalg.lstsq(A, np.array(self.ys[選]), rcond=None)[0]
    def 経験数(self, 選): return len(self.yd[選])
    def 誤差の見込み(self, 選, 健全, 温度):
        n = len(self.yd[選])
        if n < 4: return None                      # まだ分からない
        e = [abs(self.yd[選][i] - float(np.dot(self.wd[選], self.X[選][i])))
             for i in range(max(0, n-6), n)]
        return float(np.mean(e))

class 尺度:
    """**③ 機体が自分で決める尺度。**観測した平均と散らばりで正規化する。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        if x is None: return 0.0
        a = self.v[k]
        if len(a) < 4: return 0.0
        m = float(np.mean(a)); s = float(np.std(a))
        return 0.0 if s < 1e-9 else (x - m) / s

def 生涯(動機, seed0):
    """動機 = 距離 / 保全 / 知 / 書き換える"""
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); S=尺度()
    w = {k: 1/3 for k in 成分}
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    重みの跡 = []
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        # 未経験の選択肢は必ず一度は試す(模型が無いと何も比べられない)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = S.z("距離", M.距離予測(a, 健全, 温度))
                sz = S.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))   # 損耗が小さいほど良い
                kz = S.z("知",   M.誤差の見込み(a, 健全, 温度))
                if 動機 == "距離":  return dz
                if 動機 == "保全":  return sz
                if 動機 == "知":    return kz
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            選 = max(選択肢, key=得点)
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        S.覚える("距離", dist); S.覚える("保全", -損耗); S.覚える("知", 誤)
        取れ高["距離"].append(S.z("距離", dist))
        取れ高["保全"].append(S.z("保全", -損耗))
        取れ高["知"].append(S.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if 動機 == "書き換える" and d % 10 == 0:
            平均 = {k: float(np.mean(取れ高[k][-10:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (1.25 if k == 上 else 0.95)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            重みの跡.append((d, 上, dict(w)))
    return d, 累, 回数, w, M, 重みの跡

def 試験(M, 正解):
    """① 全機体に同じ問題。答えられない場合は別に数える。"""
    誤差 = []; 答えられず = 0
    for (hp, tp, act), 真 in zip(試験問題, 正解):
        p = M.距離予測(act, hp, tp)
        if p is None: 答えられず += 1
        else: 誤差.append(abs(p - 真))
    return (float(np.mean(誤差)) if 誤差 else float("nan")), 答えられず

if __name__ == "__main__":
    print("実験100:**測り方を三つ直してやり直す**", flush=True)
    print("  ① 最後に全機体へ同じ問題(正解は共通)", flush=True)
    print("  ② 目的の成分は、選択で差が出るものだけ(生存→その日の損耗)", flush=True)
    print("  ③ 尺度は機体が自分の観測から決める(z 得点)", flush=True)
    print("  予測 #82:知の機体が試験に強く、保全の機体は答えられない。", flush=True)
    print("            そして重みの書き換えが、こんどは行動も変える\n", flush=True)
    print("  試験の正解を計算中…", flush=True)
    正解 = 正解を作る()
    for (hp,tp,act), t in zip(試験問題, 正解):
        print(f"    健全{hp:.2f} 温度{tp:.0f} {act:<4} → {t:7.2f} m", flush=True)
    print("", flush=True)
    print(f"  {'動機':<10}{'種':>3}{'寿命':>6}{'総距離':>9}{'休む':>6}{'控えめ':>7}{'全力':>6}"
          f"{'試験誤差':>10}{'答えられず':>10}{'w距離':>7}{'w保全':>7}{'w知':>7}", flush=True)
    for 動機 in ("距離", "保全", "知", "書き換える"):
        for s in range(3):
            d,累,回数,w,M,跡 = 生涯(動機, s)
            e, na = 試験(M, 正解)
            print(f"  {動機:<10}{s:>3}{d:>6}{累:>9.1f}"
                  f"{回数[chr(20241)+chr(12416)]:>6}{回数[chr(25511)+chr(12360)+chr(12417)]:>7}"
                  f"{回数[chr(20840)+chr(21147)]:>6}{e:>10.2f}{na:>10}"
                  f"{w[chr(36317)+chr(38626)]:>7.3f}{w[chr(20445)+chr(20840)]:>7.3f}{w[chr(30693)]:>7.3f}", flush=True)
            if 動機 == "書き換える" and s == 0:
                for (dd, up, ww) in 跡:
                    print(f"      {dd:>3}日 上げた={up}  距離{ww[chr(36317)+chr(38626)]:.3f}"
                          f" 保全{ww[chr(20445)+chr(20840)]:.3f} 知{ww[chr(30693)]:.3f}", flush=True)
        print("", flush=True)

shiori4/shiken2.py

"""実験100:**三つの測り方を直して、やり直す。**

**直したこと(栞が §116 で自分に出した宿題):**

  ① **知識は、最後に全機体へ同じ問題を出して測る。**
     予測誤差を走行中に測ると、わざと外れる所へ行く機体が不利になり、
     休んでばかりの機体が有利になった(休んだ日の距離は常に 0 で完璧に当たる)。
     → **共通の試験問題を作り、正解は一度だけ計算して全機体で共有する。**

  ② **目的の成分は、選択によって差が出るものだけにする。**
     実験99 の `w生存 * 1.0` は全選択肢で同じ値だったので、
     重みをいくら上げても選択に効かなかった。
     → **「その日の損耗」に置き換える。**休む=0、控えめ<全力 で、選択ごとに差が出る。

  ③ **尺度は機体側に決めさせる。**
     実験99 の ÷100・÷10 は栞が置いた。それが重みの寄り先を決めていた。
     → **各成分を、機体自身が観測した平均と散らばりで正規化する(z 得点)。**
     **ただし「観測した範囲で正規化する」と決めたのは、やはり栞である。**
     **この再帰は抜けられていない。一段だけ外へ出した。**

**予測 #82(測る前に書く):**
  (a) **試験では「知」の機体が最も良く、「保全」の機体が最も悪い。**
      保全の機体は全力を一度も試さないので、答えられない。
  (b) **成分を差の出るものにしたので、こんどは重みの書き換えが行動も変える。**
  (c) **重みは保全へ寄る。**休むは損耗ゼロなので、正規化しても常に良い値になる。
**外れる形:**知の機体が試験で勝たない、または重みを書き換えても行動が変わらない場合。
"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 60, 25.0, 0.04
不調, 連続の上限 = 0.5, 3
選択肢 = ("休む", "控えめ", "全力")
成分 = ("距離", "保全", "知")

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 走る(選択, h, temps, seed):
    """返り値: (健全度配列, 温度配列, 距離, 最高温度)"""
    if 選択 == "休む":
        t2 = 冷やす(temps, SECONDS) if temps is not None else None
        return h, t2, 0.0, (max(t2) if t2 else T_AMB)
    kw = dict(BASE)
    if 選択 == "控えめ": kw["stride"] = BASE["stride"] * 0.6
    th = dict(TH0); th["health0"]=h; th["temp0"]=temps
    r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed,
                init_noise=NOISE, **kw)
    return r["健全度配列"], r["最終温度配列"], r["前進距離"], r["最高温度"]

# ---- ① 共通の試験問題。正解は一度だけ計算して全機体で使い回す ----------------
試験問題 = [(1.00, 25.0, "控えめ"), (1.00, 25.0, "全力"),
            (0.60, 60.0, "控えめ"), (0.60, 60.0, "全力"),
            (0.30, 85.0, "控えめ"), (0.30, 85.0, "全力")]

def 正解を作る():
    正解 = []
    for i, (hp, tp, act) in enumerate(試験問題):
        h = np.full(12, hp); temps = np.full(12, tp)
        _, _, dist, _ = 走る(act, h, list(temps), 777000 + i)
        正解.append(dist)
    return 正解

class 模型:
    """選択肢ごとに、距離と損耗を線形で覚える。"""
    def __init__(self):
        self.X={k:[] for k in 選択肢}
        self.yd={k:[] for k in 選択肢}; self.ys={k:[] for k in 選択肢}
        self.wd={k:None for k in 選択肢}; self.ws={k:None for k in 選択肢}
    def 特徴(self, 健全, 温度): return [1.0, 健全, 温度/100.0]
    def 距離予測(self, 選, 健全, 温度):
        if self.wd[選] is None: return None
        return float(np.dot(self.wd[選], self.特徴(健全, 温度)))
    def 損耗予測(self, 選, 健全, 温度):
        if self.ws[選] is None: return None
        return float(np.dot(self.ws[選], self.特徴(健全, 温度)))
    def 学ぶ(self, 選, 健全, 温度, 距離, 損耗):
        self.X[選].append(self.特徴(健全, 温度))
        self.yd[選].append(距離); self.ys[選].append(損耗)
        if len(self.yd[選]) >= 4:
            A = np.array(self.X[選])
            self.wd[選] = np.linalg.lstsq(A, np.array(self.yd[選]), rcond=None)[0]
            self.ws[選] = np.linalg.lstsq(A, np.array(self.ys[選]), rcond=None)[0]
    def 経験数(self, 選): return len(self.yd[選])
    def 誤差の見込み(self, 選, 健全, 温度):
        n = len(self.yd[選])
        if n < 4: return None                      # まだ分からない
        e = [abs(self.yd[選][i] - float(np.dot(self.wd[選], self.X[選][i])))
             for i in range(max(0, n-6), n)]
        return float(np.mean(e))

class 尺度:
    """**③ 機体が自分で決める尺度。**観測した平均と散らばりで正規化する。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        """**【訂正 §117】尺度が空のときに 0.0 を返していた。**
        そのせいで三択が同点になり、max() が並び順の先頭(休む)を返し、
        知の機体がそこに固定された。**空のときは生の値を使う。**"""
        if x is None: return 0.0
        a = self.v[k]
        if len(a) < 4: return float(x)          # ← 生の値。順序だけは保たれる
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯(動機, seed0):
    """動機 = 距離 / 保全 / 知 / 書き換える"""
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); S=尺度()
    w = {k: 1/3 for k in 成分}
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    重みの跡 = []
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        # 未経験の選択肢は必ず一度は試す(模型が無いと何も比べられない)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = S.z("距離", M.距離予測(a, 健全, 温度))
                sz = S.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))   # 損耗が小さいほど良い
                kz = S.z("知",   M.誤差の見込み(a, 健全, 温度))
                if 動機 == "距離":  return dz
                if 動機 == "保全":  return sz
                if 動機 == "知":    return kz
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            # **【訂正 §117】同点を並び順で決めない。**
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        S.覚える("距離", dist); S.覚える("保全", -損耗); S.覚える("知", 誤)
        取れ高["距離"].append(S.z("距離", dist))
        取れ高["保全"].append(S.z("保全", -損耗))
        取れ高["知"].append(S.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if 動機 == "書き換える" and d % 10 == 0:
            平均 = {k: float(np.mean(取れ高[k][-10:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (1.25 if k == 上 else 0.95)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            重みの跡.append((d, 上, dict(w)))
    return d, 累, 回数, w, M, 重みの跡

def 試験(M, 正解):
    """① 全機体に同じ問題。答えられない場合は別に数える。"""
    誤差 = []; 答えられず = 0
    for (hp, tp, act), 真 in zip(試験問題, 正解):
        p = M.距離予測(act, hp, tp)
        if p is None: 答えられず += 1
        else: 誤差.append(abs(p - 真))
    return (float(np.mean(誤差)) if 誤差 else float("nan")), 答えられず

if __name__ == "__main__":
    print("実験101:**同点の扱いを直して、「知りたい」を測り直す**", flush=True)
    print("  ① 最後に全機体へ同じ問題(正解は共通)", flush=True)
    print("  ② 目的の成分は、選択で差が出るものだけ(生存→その日の損耗)", flush=True)
    print("  ③ 尺度は機体が自分の観測から決める(z 得点)", flush=True)
    print("  予測 #82:知の機体が試験に強く、保全の機体は答えられない。", flush=True)
    print("            そして重みの書き換えが、こんどは行動も変える\n", flush=True)
    print("  試験の正解を計算中…", flush=True)
    正解 = 正解を作る()
    for (hp,tp,act), t in zip(試験問題, 正解):
        print(f"    健全{hp:.2f} 温度{tp:.0f} {act:<4} → {t:7.2f} m", flush=True)
    print("", flush=True)
    print(f"  {'動機':<10}{'種':>3}{'寿命':>6}{'総距離':>9}{'休む':>6}{'控えめ':>7}{'全力':>6}"
          f"{'試験誤差':>10}{'答えられず':>10}{'w距離':>7}{'w保全':>7}{'w知':>7}", flush=True)
    for 動機 in ("距離", "保全", "知", "書き換える"):
        for s in range(3):
            d,累,回数,w,M,跡 = 生涯(動機, s)
            e, na = 試験(M, 正解)
            print(f"  {動機:<10}{s:>3}{d:>6}{累:>9.1f}"
                  f"{回数[chr(20241)+chr(12416)]:>6}{回数[chr(25511)+chr(12360)+chr(12417)]:>7}"
                  f"{回数[chr(20840)+chr(21147)]:>6}{e:>10.2f}{na:>10}"
                  f"{w[chr(36317)+chr(38626)]:>7.3f}{w[chr(20445)+chr(20840)]:>7.3f}{w[chr(30693)]:>7.3f}", flush=True)
            if 動機 == "書き換える" and s == 0:
                for (dd, up, ww) in 跡:
                    print(f"      {dd:>3}日 上げた={up}  距離{ww[chr(36317)+chr(38626)]:.3f}"
                          f" 保全{ww[chr(20445)+chr(20840)]:.3f} 知{ww[chr(30693)]:.3f}", flush=True)
        print("", flush=True)

shiori4/susumi.py

"""実験100:**三つの測り方を直して、やり直す。**

**直したこと(栞が §116 で自分に出した宿題):**

  ① **知識は、最後に全機体へ同じ問題を出して測る。**
     予測誤差を走行中に測ると、わざと外れる所へ行く機体が不利になり、
     休んでばかりの機体が有利になった(休んだ日の距離は常に 0 で完璧に当たる)。
     → **共通の試験問題を作り、正解は一度だけ計算して全機体で共有する。**

  ② **目的の成分は、選択によって差が出るものだけにする。**
     実験99 の `w生存 * 1.0` は全選択肢で同じ値だったので、
     重みをいくら上げても選択に効かなかった。
     → **「その日の損耗」に置き換える。**休む=0、控えめ<全力 で、選択ごとに差が出る。

  ③ **尺度は機体側に決めさせる。**
     実験99 の ÷100・÷10 は栞が置いた。それが重みの寄り先を決めていた。
     → **各成分を、機体自身が観測した平均と散らばりで正規化する(z 得点)。**
     **ただし「観測した範囲で正規化する」と決めたのは、やはり栞である。**
     **この再帰は抜けられていない。一段だけ外へ出した。**

**予測 #82(測る前に書く):**
  (a) **試験では「知」の機体が最も良く、「保全」の機体が最も悪い。**
      保全の機体は全力を一度も試さないので、答えられない。
  (b) **成分を差の出るものにしたので、こんどは重みの書き換えが行動も変える。**
  (c) **重みは保全へ寄る。**休むは損耗ゼロなので、正規化しても常に良い値になる。
**外れる形:**知の機体が試験で勝たない、または重みを書き換えても行動が変わらない場合。
"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 60, 25.0, 0.04
不調, 連続の上限 = 0.5, 3
選択肢 = ("休む", "控えめ", "全力")
成分 = ("距離", "保全", "知")

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 走る(選択, h, temps, seed):
    """返り値: (健全度配列, 温度配列, 距離, 最高温度)"""
    if 選択 == "休む":
        t2 = 冷やす(temps, SECONDS) if temps is not None else None
        return h, t2, 0.0, (max(t2) if t2 else T_AMB)
    kw = dict(BASE)
    if 選択 == "控えめ": kw["stride"] = BASE["stride"] * 0.6
    th = dict(TH0); th["health0"]=h; th["temp0"]=temps
    r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed,
                init_noise=NOISE, **kw)
    return r["健全度配列"], r["最終温度配列"], r["前進距離"], r["最高温度"]

# ---- ① 共通の試験問題。正解は一度だけ計算して全機体で使い回す ----------------
試験問題 = [(1.00, 25.0, "控えめ"), (1.00, 25.0, "全力"),
            (0.60, 60.0, "控えめ"), (0.60, 60.0, "全力"),
            (0.30, 85.0, "控えめ"), (0.30, 85.0, "全力")]

def 正解を作る():
    正解 = []
    for i, (hp, tp, act) in enumerate(試験問題):
        h = np.full(12, hp); temps = np.full(12, tp)
        _, _, dist, _ = 走る(act, h, list(temps), 777000 + i)
        正解.append(dist)
    return 正解

class 模型:
    """選択肢ごとに、距離と損耗を線形で覚える。"""
    def __init__(self):
        self.X={k:[] for k in 選択肢}
        self.yd={k:[] for k in 選択肢}; self.ys={k:[] for k in 選択肢}
        self.wd={k:None for k in 選択肢}; self.ws={k:None for k in 選択肢}
    def 特徴(self, 健全, 温度): return [1.0, 健全, 温度/100.0]
    def 距離予測(self, 選, 健全, 温度):
        if self.wd[選] is None: return None
        return float(np.dot(self.wd[選], self.特徴(健全, 温度)))
    def 損耗予測(self, 選, 健全, 温度):
        if self.ws[選] is None: return None
        return float(np.dot(self.ws[選], self.特徴(健全, 温度)))
    def 学ぶ(self, 選, 健全, 温度, 距離, 損耗):
        self.X[選].append(self.特徴(健全, 温度))
        self.yd[選].append(距離); self.ys[選].append(損耗)
        if len(self.yd[選]) >= 4:
            A = np.array(self.X[選])
            self.wd[選] = np.linalg.lstsq(A, np.array(self.yd[選]), rcond=None)[0]
            self.ws[選] = np.linalg.lstsq(A, np.array(self.ys[選]), rcond=None)[0]
    def 経験数(self, 選): return len(self.yd[選])
    def 誤差の見込み(self, 選, 健全, 温度):
        n = len(self.yd[選])
        if n < 4: return None                      # まだ分からない
        e = [abs(self.yd[選][i] - float(np.dot(self.wd[選], self.X[選][i])))
             for i in range(max(0, n-6), n)]
        return float(np.mean(e))
    def 学びの進み(self, 選):
        """**誤差の大きさではなく、減り方。**(実験102)
        学べないものは、いくら試しても誤差が減らない。学べるものは減る。
        直近3件の平均と、その前3件の平均の差を返す。**減っていれば正。**"""
        n = len(self.yd[選])
        if n < 8 or self.wd[選] is None: return None
        e = [abs(self.yd[選][i] - float(np.dot(self.wd[選], self.X[選][i])))
             for i in range(n)]
        return float(np.mean(e[-6:-3])) - float(np.mean(e[-3:]))

class 尺度:
    """**③ 機体が自分で決める尺度。**観測した平均と散らばりで正規化する。"""
    def __init__(self): self.v = {k: [] for k in 成分}
    def 覚える(self, k, x):
        if x is not None: self.v[k].append(float(x))
    def z(self, k, x):
        """**【訂正 §117】尺度が空のときに 0.0 を返していた。**
        そのせいで三択が同点になり、max() が並び順の先頭(休む)を返し、
        知の機体がそこに固定された。**空のときは生の値を使う。**"""
        if x is None: return 0.0
        a = self.v[k]
        if len(a) < 4: return float(x)          # ← 生の値。順序だけは保たれる
        m = float(np.mean(a)); s = float(np.std(a))
        return float(x) - m if s < 1e-9 else (x - m) / s

def 生涯(動機, seed0):
    """動機 = 距離 / 保全 / 知 / 書き換える"""
    rng = np.random.default_rng(seed0 + 9000)
    h=None; temps=None; 累=0.0; 連続=0
    M=模型(); S=尺度()
    w = {k: 1/3 for k in 成分}
    取れ高 = {k: [] for k in 成分}
    回数 = {k: 0 for k in 選択肢}
    重みの跡 = []
    for d in range(1, MAXD+1):
        健全 = float(np.min(h)) if h is not None else 1.0
        温度 = (max(temps) if temps is not None else T_AMB)
        # 未経験の選択肢は必ず一度は試す(模型が無いと何も比べられない)
        未 = [k for k in 選択肢 if M.経験数(k) < 4]
        if 未:
            選 = 未[0]
        else:
            def 得点(a):
                dz = S.z("距離", M.距離予測(a, 健全, 温度))
                sz = S.z("保全", -(M.損耗予測(a, 健全, 温度) or 0.0))   # 損耗が小さいほど良い
                kz = S.z("知",   (M.学びの進み(a) if 動機 in ("進み","書き換える") else M.誤差の見込み(a, 健全, 温度)))
                if 動機 == "距離":  return dz
                if 動機 == "保全":  return sz
                if 動機 in ("知", "進み"):  return kz
                return w["距離"]*dz + w["保全"]*sz + w["知"]*kz
            # **【訂正 §117】同点を並び順で決めない。**
            点 = {a: 得点(a) for a in 選択肢}
            最高 = max(点.values())
            候補 = [a for a in 選択肢 if 点[a] >= 最高 - 1e-12]
            選 = 候補[int(rng.integers(len(候補)))] if len(候補) > 1 else 候補[0]
        予 = M.距離予測(選, 健全, 温度)
        前健全 = 健全
        h, temps, dist, tmax = 走る(選, h, temps, seed0*1000 + d)
        後健全 = float(np.min(h)) if h is not None else 1.0
        損耗 = max(0.0, 前健全 - 後健全)
        誤 = abs(予 - dist) if 予 is not None else None
        M.学ぶ(選, 前健全, 温度, dist, 損耗)
        S.覚える("距離", dist); S.覚える("保全", -損耗); S.覚える("知", 誤)
        取れ高["距離"].append(S.z("距離", dist))
        取れ高["保全"].append(S.z("保全", -損耗))
        取れ高["知"].append(S.z("知", 誤) if 誤 is not None else 0.0)
        累 += max(0.0, dist); 回数[選] += 1
        連続 = (連続+1) if (dist < 不調 and 選 != "休む") else (連続 if 選=="休む" else 0)
        if 後健全 <= 0.0501 or 連続 >= 連続の上限:
            break
        if 動機 == "書き換える" and d % 10 == 0:
            平均 = {k: float(np.mean(取れ高[k][-10:])) for k in 成分}
            上 = max(成分, key=lambda k: 平均[k])
            for k in 成分: w[k] *= (1.25 if k == 上 else 0.95)
            s = sum(w.values()); w = {k: v/s for k, v in w.items()}
            重みの跡.append((d, 上, dict(w)))
    return d, 累, 回数, w, M, 重みの跡

def 試験(M, 正解):
    """① 全機体に同じ問題。答えられない場合は別に数える。"""
    誤差 = []; 答えられず = 0
    for (hp, tp, act), 真 in zip(試験問題, 正解):
        p = M.距離予測(act, hp, tp)
        if p is None: 答えられず += 1
        else: 誤差.append(abs(p - 真))
    return (float(np.mean(誤差)) if 誤差 else float("nan")), 答えられず

if __name__ == "__main__":
    print("実験102:**誤差の大きさではなく、誤差の減り方を追わせる**", flush=True)
    print("  ① 最後に全機体へ同じ問題(正解は共通)", flush=True)
    print("  ② 目的の成分は、選択で差が出るものだけ(生存→その日の損耗)", flush=True)
    print("  ③ 尺度は機体が自分の観測から決める(z 得点)", flush=True)
    print("  予測 #82:知の機体が試験に強く、保全の機体は答えられない。", flush=True)
    print("            そして重みの書き換えが、こんどは行動も変える\n", flush=True)
    print("  試験の正解を計算中…", flush=True)
    正解 = 正解を作る()
    for (hp,tp,act), t in zip(試験問題, 正解):
        print(f"    健全{hp:.2f} 温度{tp:.0f} {act:<4} → {t:7.2f} m", flush=True)
    print("", flush=True)
    print(f"  {'動機':<10}{'種':>3}{'寿命':>6}{'総距離':>9}{'休む':>6}{'控えめ':>7}{'全力':>6}"
          f"{'試験誤差':>10}{'答えられず':>10}{'w距離':>7}{'w保全':>7}{'w知':>7}", flush=True)
    for 動機 in ("知", "進み"):
        for s in range(3):
            d,累,回数,w,M,跡 = 生涯(動機, s)
            e, na = 試験(M, 正解)
            print(f"  {動機:<10}{s:>3}{d:>6}{累:>9.1f}"
                  f"{回数[chr(20241)+chr(12416)]:>6}{回数[chr(25511)+chr(12360)+chr(12417)]:>7}"
                  f"{回数[chr(20840)+chr(21147)]:>6}{e:>10.2f}{na:>10}"
                  f"{w[chr(36317)+chr(38626)]:>7.3f}{w[chr(20445)+chr(20840)]:>7.3f}{w[chr(30693)]:>7.3f}", flush=True)
            if 動機 == "書き換える" and s == 0:
                for (dd, up, ww) in 跡:
                    print(f"      {dd:>3}日 上げた={up}  距離{ww[chr(36317)+chr(38626)]:.3f}"
                          f" 保全{ww[chr(20445)+chr(20840)]:.3f} 知{ww[chr(30693)]:.3f}", flush=True)
        print("", flush=True)

shiori4/wake.py

"""実験91:**呼ばれずに動きだす機体。**(ゴンスケ計画 ②・条件 B)

## 何を作るか

    一日が終わるとき、機体が「次に起こす条件」をファイルに書く
    スケジューラがそれを読み、条件が満たされたときだけ次の日を始める
    **スケジューラは何も判断しない。機体が書いた条件を評価するだけ**

**そうすると、次の起動の原因は、前回の自分の状態になる。**

## 三つの機体

| | 次の起動をどう決めるか | 原因はどこにあるか |
|---|---|---|
| **外の予定** | 決まった休息時間(毎回同じ) | **外** |
| **手で調整** | 温度に比例した休息(外から与えた規則) | 外(規則が外) |
| **自分で書く** | **前回の自分の状態から、条件そのものを書く** | **内** |

**「自分で書く」は、条件を文字列として残す。**
スケジューラはそれを評価するだけで、中身を知らない。

## 予測 #74(測る前に書く)

**日と日のあいだの休息は、日の中の休息と違って、純粋に得なはずである。**
§109 で休む方針が負けたのは、**止まって再開するたびに向きを失う**ためだった。
**日のあいだの休息では、そもそも一日の終わりに止まっている。**だから向きを失わない。

**したがって:休息を取る機体が、取らない機体に勝つ。**
**そして「自分で書く」が「外の予定」に勝つ**——最適な休息は日によって違うので。

**外れる形:**休息を取っても総距離が伸びない。
その場合、冷えることに価値が無く、B は性能では正当化できないことになる。
"""
import sys, os, json, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD = 120.0, 60
T_AMB, K_COOL = 25.0, 0.04

# ---- スケジューラ(何も判断しない)---------------------------------------
def 冷やす(temps, 秒):
    """休息のあいだの冷却。dT/dt = -k_cool*(T - T_amb) の解析解。"""
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 評価(条件, 状態):
    """**機体が書いた条件を、そのまま評価するだけ。**中身は知らない。
    条件の形: {"種類": "時間", "秒": 120}  /  {"種類": "温度", "度": 60}
    返り値:待つ秒数"""
    if 条件["種類"] == "時間":
        return float(条件["秒"])
    if 条件["種類"] == "温度":
        # その温度まで下がるのに要る時間を、冷却の式から解く
        T0, Tt = 状態["最高温度"], float(条件["度"])
        if T0 <= Tt or Tt <= T_AMB: return 0.0
        return -math.log((Tt - T_AMB) / (T0 - T_AMB)) / K_COOL
    return 0.0

# ---- 機体が条件を書く(三通り)---------------------------------------------
def 条件_外の予定(状態, 秒=120.0):
    return {"種類": "時間", "秒": 秒}

def 条件_手で調整(状態):
    # 外から与えた規則:温度が高いほど長く休む
    return {"種類": "時間", "秒": max(0.0, (状態["最高温度"] - 60.0) * 8.0)}

def 条件_自分で書く(状態, 記憶):
    """**前回の自分の状態から、条件そのものを書く。**
    記憶に「前にこの条件でどうだったか」を持ち、良くなる方へ動かす。"""
    前 = 記憶.get("前回")
    目標 = 記憶.get("目標温度", 70.0)
    if 前 is not None:
        # 前より進めたなら同じ方向へ、悪くなったら逆へ(1度きざみ)
        向き = 記憶.get("向き", -5.0)
        if 状態["距離"] < 前["距離"]:
            向き = -向き
        目標 = min(85.0, max(30.0, 目標 + 向き))
        記憶["向き"] = 向き
    記憶["目標温度"] = 目標
    記憶["前回"] = dict(状態)
    return {"種類": "温度", "度": 目標}

# ---- 生涯 ------------------------------------------------------------------
def 生涯(方式, seed0, 休息あり=True):
    h = None; temps = None; 累 = 0.0; 記憶 = {}; 経過 = 0.0; 履歴 = []
    for d in range(1, MAXD + 1):
        th = dict(TH0); th["health0"] = h; th["temp0"] = temps
        r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th,
                    seed=seed0 * 1000 + d, init_noise=NOISE, **BASE)
        h = r["健全度配列"]; temps = r["最終温度配列"]; 累 += r["前進距離"]
        状態 = {"最高温度": r["最高温度"], "健全度": r["健全度"], "距離": r["前進距離"]}
        履歴.append((d, round(r["前進距離"],2), round(r["最高温度"],1), round(r["健全度"],4)))
        if r["健全度"] <= 0.0501 or r["前進距離"] < 0.5:
            break
        if not 休息あり:
            continue
        # **機体が次の起動条件を書く**
        if 方式 == "外の予定":   c = 条件_外の予定(状態)
        elif 方式 == "手で調整": c = 条件_手で調整(状態)
        else:                    c = 条件_自分で書く(状態, 記憶)
        # **スケジューラが評価するだけ**
        待 = min(600.0, 評価(c, 状態))
        経過 += 待
        temps = 冷やす(temps, 待)
    return d, 累, 経過, 履歴

if __name__ == "__main__":
    print(f"1日{SECONDS:.0f}秒・摩耗 {TH0['k_wear']}・寿命の最後まで(最大{MAXD}日)", flush=True)
    print(f"  {'方式':<12}{'種':>3}{'寿命[日]':>9}{'総距離[m]':>11}{'休息の合計[s]':>14}", flush=True)
    表 = {}
    for 方式, 休 in (("休息なし", False), ("外の予定", True),
                     ("手で調整", True), ("自分で書く", True)):
        cs = []
        for s in (0, 1, 2):
            d, cum, rest, hist = 生涯(方式, s, 休)
            cs.append(cum)
            print(f"  {方式:<12}{s:>3}{d:>9d}{cum:>11.1f}{rest:>14.0f}", flush=True)
        表[方式] = cs
        print(f"  {方式:<12}{'中央':>3}{'':>9}{float(np.median(cs)):>11.1f}", flush=True)
        print(flush=True)
    a = float(np.median(表["休息なし"]))
    for k in ("外の予定", "手で調整", "自分で書く"):
        b = float(np.median(表[k]))
        print(f"  {k:<12} {b:>9.1f}  対 休息なし {(b/a-1)*100:+.1f}%", flush=True)
    json.dump(表, open(os.path.expanduser("~/robot/out/rt91_wake.json"), "w",
              encoding="utf-8"), ensure_ascii=False, indent=1)

shiori4/wake2.py

"""実験92:**傾向を差し引いて比べる機体。**(§111 の失敗を直す)

## §111 で何が起きたか

**自分で起動条件を書く機体が最下位だった(−11.5%)。**
規則は「距離が前日より減ったら、向きを反転」だった。

**摩耗のせいで距離は毎日ひとりでに落ちる。**
だから「減った」はほぼ毎日成り立ち、**向きが毎日ひっくり返った。**

> **機体は、自分の選択の効果と、体が衰えていく傾向を、区別できなかった。**

## §110 との違い

**§110(歩き方を変える機体・+43.6%)は、同じ日のうちに 5通り試して比べた。**
**同じ日なら、体は同じである。**

> **劣化する体の上では、日をまたいだ比較が、体の変化に汚染される。**

## 三つの直し方を試す

| | 比べ方 |
|---|---|
| **そのまま**(§111 と同じ) | 前の日と比べる |
| **傾向を差し引く** | 過去の低下率から今日の予想を作り、**予想との差**で判断する |
| **同じ条件どうし** | **前に同じ目標温度だった日**と比べる(体は違うが、条件は同じ) |

## 予測 #75(測る前に書いた・台帳)

**傾向を差し引けば改善する。ただし『休息なし』には勝てない。**
**学習器は直るが、結論は変わらない。**
"""
import sys, os, json, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 60, 25.0, 0.04

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 待ち時間(目標, 最高温度):
    if 最高温度 <= 目標 or 目標 <= T_AMB: return 0.0
    return min(600.0, -math.log((目標 - T_AMB) / (最高温度 - T_AMB)) / K_COOL)

def 生涯(比べ方, seed0):
    h = None; temps = None; 累 = 0.0
    目標 = 70.0; 向き = -5.0
    履歴 = []          # (日, 目標, 距離)
    for d in range(1, MAXD + 1):
        th = dict(TH0); th["health0"] = h; th["temp0"] = temps
        r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th,
                    seed=seed0 * 1000 + d, init_noise=NOISE, **BASE)
        h = r["健全度配列"]; temps = r["最終温度配列"]
        dist = r["前進距離"]; 累 += dist
        履歴.append((d, 目標, dist))
        if r["健全度"] <= 0.0501 or dist < 0.5:
            break
        # ---- 良かったか悪かったかを判断する ----
        良い = None
        if 比べ方 == "そのまま" and len(履歴) >= 2:
            良い = dist >= 履歴[-2][2]
        elif 比べ方 == "傾向を差し引く" and len(履歴) >= 4:
            # 直近の低下率を直線で当てはめ、**今日の予想**を作る
            xs = np.array([x[0] for x in 履歴[:-1]], dtype=float)
            ys = np.array([x[2] for x in 履歴[:-1]], dtype=float)
            k = min(len(xs), 8)
            a, b = np.polyfit(xs[-k:], ys[-k:], 1)
            予想 = a * d + b
            良い = dist >= 予想          # **予想より良ければ、選択が効いた**
        elif 比べ方 == "同じ条件どうし":
            同 = [x for x in 履歴[:-1] if abs(x[1] - 目標) < 0.6]
            if 同: 良い = dist >= 同[-1][2]
        if 良い is False:
            向き = -向き
        目標 = min(85.0, max(30.0, 目標 + 向き))
        待 = 待ち時間(目標, r["最高温度"])
        temps = 冷やす(temps, 待)
    return d, 累, 履歴

if __name__ == "__main__":
    print(f"1日{SECONDS:.0f}秒・摩耗 {TH0['k_wear']}・寿命の最後まで", flush=True)
    print(f"  {'比べ方':<16}{'種':>3}{'寿命[日]':>9}{'総距離[m]':>11}{'目標温度の幅':>14}",
          flush=True)
    表 = {}
    for 比べ方 in ("そのまま", "傾向を差し引く", "同じ条件どうし"):
        cs = []
        for s in (0, 1, 2):
            d, cum, hist = 生涯(比べ方, s)
            ts = [x[1] for x in hist]
            cs.append(cum)
            print(f"  {比べ方:<16}{s:>3}{d:>9d}{cum:>11.1f}"
                  f"{min(ts):>7.0f}〜{max(ts):<6.0f}", flush=True)
        表[比べ方] = cs
        print(f"  {比べ方:<16}{'中央':>3}{'':>9}{float(np.median(cs)):>11.1f}", flush=True)
        print(flush=True)
    休息なし = 1196.1     # 実験91 の中央値
    print(f"  参考:休息なし {休息なし:.1f}(実験91)", flush=True)
    for k, v in 表.items():
        m = float(np.median(v))
        print(f"  {k:<16}{m:>9.1f}  対 休息なし {(m/休息なし-1)*100:+.1f}%", flush=True)
    json.dump(表, open(os.path.expanduser("~/robot/out/rt92_trend.json"), "w",
              encoding="utf-8"), ensure_ascii=False, indent=1)

shiori4/wake3.py

"""**実験91 と 92 が食い違った。**同じ規則のはずなのに寿命が 26日 対 40日。
日ごとの中身を並べて、どこで分かれるかを見る。"""
import sys, os, math
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 60, 25.0, 0.04

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 待ち時間(目標, 最高温度):
    if 最高温度 <= 目標 or 目標 <= T_AMB: return 0.0
    return min(600.0, -math.log((目標 - T_AMB) / (最高温度 - T_AMB)) / K_COOL)

def 生涯(版, seed0):
    """版 = "91"(day1 は動かさない) / "92"(day1 から動かす)"""
    h = None; temps = None; 累 = 0.0
    目標 = 70.0; 向き = -5.0; 前距離 = None; 待の合計 = 0.0
    print(f"--- 版{版} 種{seed0} ---", flush=True)
    print(f"{'日':>3}{'目標':>7}{'待[s]':>8}{'最高温':>8}{'健全度':>9}{'距離':>8}", flush=True)
    for d in range(1, MAXD + 1):
        th = dict(TH0); th["health0"] = h; th["temp0"] = temps
        r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th,
                    seed=seed0 * 1000 + d, init_noise=NOISE, **BASE)
        h = r["健全度配列"]; temps = r["最終温度配列"]
        dist = r["前進距離"]; 累 += dist
        if r["健全度"] <= 0.0501 or dist < 0.5:
            print(f"{d:>3}{目標:>7.1f}{'':>8}{r['最高温度']:>8.1f}{r['健全度']:>9.4f}{dist:>8.2f}  ← 終わり", flush=True)
            break
        動かす = (版 == "92") or (前距離 is not None)
        if 動かす:
            if 前距離 is not None and dist < 前距離:
                向き = -向き
            目標 = min(85.0, max(30.0, 目標 + 向き))
        前距離 = dist
        待 = 待ち時間(目標, r["最高温度"])
        待の合計 += 待
        print(f"{d:>3}{目標:>7.1f}{待:>8.1f}{r['最高温度']:>8.1f}{r['健全度']:>9.4f}{dist:>8.2f}", flush=True)
        temps = 冷やす(temps, 待)
    print(f"  → 寿命{d}日 総距離{累:.1f}m 休息計{待の合計:.0f}s\n", flush=True)
    return d, 累

if __name__ == "__main__":
    for 版 in ("91", "92"):
        生涯(版, 1)

shiori4/wake4.py

"""**実験91 のやり直し。**停止条件を直した。

    旧: 一日でも 距離<0.5 なら終わり  ← つまずいた日を死と読んでいた
    新: **三日続けて 距離<0.5 なら終わり**(回復する日があるので)

不調の日は数えて別に出す。**「何日つまずいたか」と「いつ終わったか」を分ける。**
"""
import sys, os, math, statistics as st
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006, k_wear=5e-5)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, MAXD, T_AMB, K_COOL = 120.0, 80, 25.0, 0.04
不調 = 0.5          # この距離を下回った日を「不調」と呼ぶ
連続の上限 = 3       # **三日続いたら終わり**

def 冷やす(temps, 秒):
    f = math.exp(-K_COOL * 秒)
    return [T_AMB + (t - T_AMB) * f for t in temps]

def 待ち時間(目標, 最高温度):
    if 最高温度 <= 目標 or 目標 <= T_AMB: return 0.0
    return min(600.0, -math.log((目標 - T_AMB) / (最高温度 - T_AMB)) / K_COOL)

def 生涯(方式, seed0):
    h=None; temps=None; 累=0.0; 待計=0.0; 連続=0; 不調日=0
    目標=70.0; 向き=-5.0; 前距離=None
    for d in range(1, MAXD+1):
        th=dict(TH0); th["health0"]=h; th["temp0"]=temps
        r = rt_walk("unitree_go2", seconds=SECONDS, thermal=th,
                    seed=seed0*1000+d, init_noise=NOISE, **BASE)
        h=r["健全度配列"]; temps=r["最終温度配列"]
        dist=r["前進距離"]; 累 += max(0.0, dist)
        if dist < 不調:
            連続 += 1; 不調日 += 1
        else:
            連続 = 0
        if r["健全度"] <= 0.0501 or 連続 >= 連続の上限:
            break
        if 方式 == "休息なし":
            待 = 0.0
        elif 方式 == "外の予定":
            待 = 120.0
        elif 方式 == "手で調整":
            待 = max(0.0, (r["最高温度"] - 60.0) * 8.0)
        else:  # 自分で書く
            if 前距離 is not None:
                if dist < 前距離: 向き = -向き
                目標 = min(85.0, max(30.0, 目標 + 向き))
            前距離 = dist
            待 = 待ち時間(目標, r["最高温度"])
        待 = min(600.0, 待); 待計 += 待
        temps = 冷やす(temps, 待)
    return d, 累, 待計, 不調日

if __name__ == "__main__":
    print(f"1日{SECONDS:.0f}秒・摩耗{TH0['k_wear']}・**三日続けて不調なら終わり**(旧:一日で終わり)", flush=True)
    print(f"  {'方式':<12}{'種':>3}{'寿命[日]':>9}{'総距離[m]':>11}{'休息計[s]':>11}{'不調日':>7}", flush=True)
    中央 = {}
    for 方式 in ("休息なし","外の予定","手で調整","自分で書く"):
        res=[]
        for s in range(3):
            d,累,待,不 = 生涯(方式, s)
            res.append(累)
            print(f"  {方式:<12}{s:>3}{d:>9}{累:>11.1f}{待:>11.0f}{不:>7}", flush=True)
        中央[方式]=st.median(res)
        print(f"  {方式:<12}{'中央':>3}{'':>9}{中央[方式]:>11.1f}\n", flush=True)
    b=中央["休息なし"]
    for k,v in 中央.items():
        print(f"  {k:<12}{v:>10.1f}  対 休息なし {100*(v-b)/b:+.1f}%", flush=True)

shiori4/wear_full.py

"""摩耗ありの機体を、**寿命の最後まで**走らせる。

25日で切った比較は不公平だった——休む機体は予算を使い切っていない。
**「生涯の総距離」で比べる。**

**予測 #71(測る前に書く):**
摩耗が動いた量に比例するなら、**生涯の総距離は方針に依らずほぼ一定になる。**
休めば寿命は延びるが、その分だけ一日の距離が減るので、総和は変わらない。
**外れる形:**総距離に方針間で 20% 以上の差が残る。
"""
import sys, os, json
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from exp34_selfcare import make_policy
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, KW, MAXD = 120.0, 3e-4, 200

def 生涯(kind, p1, p2):
    h=None; 累=0.0
    for d in range(1, MAXD+1):
        th=dict(TH0); th["health0"]=h; th["k_wear"]=KW
        kw=dict(BASE)
        pol=make_policy(kind,p1,p2)
        if pol: kw["rest_policy"]=pol
        r=rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=d,
                  init_noise=NOISE, **kw)
        h=r["健全度配列"]; 累+=r["前進距離"]
        if r["健全度"] <= 0.0501 or r["前進距離"] < 0.5:
            return d, 累, r["健全度"], r["前進距離"]
    return MAXD, 累, r["健全度"], r["前進距離"]

if __name__ == "__main__":
    print(f"k_wear={KW}・1日{SECONDS:.0f}秒・**寿命の最後まで**(最大{MAXD}日)", flush=True)
    print(f"  {'方針':<14} {'寿命[日]':>8} {'生涯の総距離[m]':>15} {'最終健全度':>10} {'最終日[m]':>10}",
          flush=True)
    res={}
    for 名,k,a,b in (("休まない","A",None,None),
                     ("時計 30/15","B",30.0,15.0),
                     ("時計 20/20","B",20.0,20.0),
                     ("時計 10/30","B",10.0,30.0),
                     ("温度 88/65","C",88.0,65.0),
                     ("温度 90/70","C",90.0,70.0)):
        d,cum,hp,last = 生涯(k,a,b)
        res[名]=dict(寿命=d, 総距離=round(cum,1), 最終健全度=round(hp,4))
        print(f"  {名:<14} {d:>8d} {cum:>15.1f} {hp:>10.4f} {last:>10.2f}", flush=True)
    v=[x["総距離"] for x in res.values()]
    print(flush=True)
    print(f"  総距離のばらつき: 最小 {min(v):.1f} 〜 最大 {max(v):.1f} "
          f"(幅 {(max(v)/min(v)-1)*100:.1f}%)", flush=True)
    json.dump(res, open(os.path.expanduser("~/robot/out/rt88_wearfull.json"),"w",
              encoding="utf-8"), ensure_ascii=False, indent=1)

shiori4/wear_full2.py

"""実験89:摩耗ありの生涯を、**分解能を上げて・種を変えて**測る。

**実験88 の問題:**k_wear=3e-4 だと寿命が 3〜6日しかなく、分解能が粗い。
そして種が一通りだけだった。**14.8% の差が、種のばらつきの中かもしれない。**

**k_wear=5e-5 に落として寿命を延ばし、種を三通りにする。**

**予測 #72(測る前に書く):**
**種を変えると、方針の順位が入れ替わる。**14.8% は種のばらつきの中である。
**外れる形:**三つの種すべてで、自己監視(温度 90/70)が休まない機体を上回る。
**その場合、§48 は本当に破れたことになる。**
"""
import sys, os, json
import numpy as np
sys.path.insert(0, os.path.expanduser("~/robot/realtime"))
from rt_quad import rt_walk
from rtsweep import BEST, NOISE
from exp34_selfcare import make_policy
COND = BEST["unitree_go2"]["条件"]
TH0 = dict(k_heat=0.0105, k_cool=0.04, T_amb=25.0, T_derate=80.0, T_max=120.0,
           T_damage=90.0, k_damage=0.0006)
BASE = dict(T=COND["T"], stride=COND["stride"], height=COND["height"],
            lift=COND["lift"], kp=COND["kp"], ctrl_hz=200, pd_where="joint")
SECONDS, KW, MAXD = 120.0, 5e-5, 120

def 生涯(kind, p1, p2, seed0):
    h=None; 累=0.0
    for d in range(1, MAXD+1):
        th=dict(TH0); th["health0"]=h; th["k_wear"]=KW
        kw=dict(BASE)
        pol=make_policy(kind,p1,p2)
        if pol: kw["rest_policy"]=pol
        r=rt_walk("unitree_go2", seconds=SECONDS, thermal=th, seed=seed0*1000+d,
                  init_noise=NOISE, **kw)
        h=r["健全度配列"]; 累+=r["前進距離"]
        if r["健全度"] <= 0.0501 or r["前進距離"] < 0.5:
            return d, 累, r["健全度"]
    return MAXD, 累, r["健全度"]

方針 = [("休まない","A",None,None),
        ("時計 30/15","B",30.0,15.0),
        ("時計 20/20","B",20.0,20.0),
        ("時計 10/30","B",10.0,30.0),
        ("温度 88/65","C",88.0,65.0),
        ("温度 90/70","C",90.0,70.0)]
SEEDS = (0, 1, 2)

if __name__ == "__main__":
    print(f"k_wear={KW}・1日{SECONDS:.0f}秒・寿命の最後まで・種 {len(SEEDS)} 通り", flush=True)
    print(f"  {'方針':<12}" + "".join(f"{'種'+str(s):>16}" for s in SEEDS)
          + f"{'中央':>9}{'最小':>9}", flush=True)
    表={}
    for 名,k,a,b in 方針:
        行=[]
        for s in SEEDS:
            d,cum,hp = 生涯(k,a,b,s)
            行.append((d,cum))
        cums=[x[1] for x in 行]
        表[名]=cums
        print(f"  {名:<12}" + "".join(f"{d:>5d}日{cum:>9.1f}m" for d,cum in 行)
              + f"{float(np.median(cums)):>9.1f}{min(cums):>9.1f}", flush=True)
    print(flush=True)
    休=表["休まない"]; 温=表["温度 90/70"]
    勝=sum(1 for a,b in zip(温,休) if a>b)
    print(f"  温度90/70 が 休まない を上回った種: {勝}/{len(SEEDS)}", flush=True)
    for s,(a,b) in enumerate(zip(温,休)):
        print(f"    種{s}: 温度 {a:.1f} 対 休まない {b:.1f}  → {(a/b-1)*100:+.1f}%", flush=True)
    json.dump(表, open(os.path.expanduser("~/robot/out/rt89_wearseeds.json"),"w",
              encoding="utf-8"), ensure_ascii=False, indent=1)