---
title: "サービスロボット導入シミュレーション Log#13 | 隠れていた待ちが見えて、次は「どこで詰まるか」へ"
slug: serving-robot-hidden-waits
date: 2026-10-25
day: 379
meta_description: "Unity の3Dのファミレスで、配膳ロボットを入れても入店待ちが縮まなかった原因は、レジの決まりと片付け待ちの打ち切りだった。試算にレジを入れ、Unity を直すと向きはそろい、詰まる場所の違いが残った。"
tags: [サービスロボット導入シミュレーション, 配膳ロボット, Unity, ファミレス, シミュレーション, レジ, 片付け, 段階B, Claude Code]
series: "導入シミュレーション"
---

配膳ロボットを入れたのに、お客さんが店に来てから席に着くまでの待ち時間（入店待ち）が、ロボットの台数でまったく変わらない。Unity（建物や人を3Dで置いて動かせるソフト）の中に作ったファミレスで、ロボット0台・1台・2台を回すと、そんな結果が出ていました。サイトに置いている計算の試算（ https://pixelstomotors.com/sim/serving-robot ）では、ロボットを入れるほど入店待ちは短くなります。どちらかが店の動きを取りこぼしています。

調べると、原因は2つでした。1つは**レジ**です。Unity の店はレジの内側に店員が1人だけで、会計は1組ずつしかできません。一方、試算では会計を4人のスタッフのだれでも同時にできていました。もう1つは、Unity に入れていた**逃がし弁**です。片付けや会計が10分止まった卓を、片付けをしないまま空いたことにする仕組みで、だれもまだ手を付けていない「ふつうの片付け待ち」まで打ち切っていました。そのため、席は空いているのにお客さんが待たされている時間が、Unity では実際より小さく出ていました。

そこで、試算の方に「会計はレジ1つにつき1組ずつ」を入れ（試算 v2.8）、レジの数を1つ・2つから選べるようにしました。レジ1つ・会計90秒だと、1時間にさばけるのは40組までです。この店の「普通」の混み具合は1時間42組なので、ロボットの前にレジが詰まります。Unity の方は、逃がし弁がスタッフが仕事を取ってから止まった時間だけを数えるように直しました。

**直したあと、③の店では、ロボットを入れると入店待ちが縮む向きが試算とそろいました**（3.90分 → 3.30分 → 2.03分。試算は 3.30分 → 2.60分 → 1.18分）。一方で、店のどこで詰まるかは食い違ったままです。試算ではレジに列ができ、Unity では片付けが追いつかずに卓が空きません。スタッフ1人が1組に使う時間が、Unity の方が7〜18%長いためです。試算はピークでスタッフの手が95%ふさがっているので、1割に満たない差でも人手が足りなくなります。

| やったこと | なぜ | 結果 |
|---|---|---|
| Unity の18回の記録を読み直した | 入店待ちがロボットの台数で変わらない原因を探すため | スタッフの仕事は軽くなかった。原因はレジと逃がし弁 |
| 試算に「会計はレジで1組ずつ」を入れ、レジの数を選べるようにした（v2.8） | 実際の店はレジが1つで、Unity もそう作っているため | レジ1つだと、ロボットより先にレジが詰まる条件がはっきりした |
| 会計を10分以上待つ条件に「レジが詰まる」の印を付けた | レジがせき止めると「店が回らない」がかえって減って見えたため | 「店が回らない」とは別に、レジの詰まりを読めるようにした |
| Unity の逃がし弁を、スタッフが仕事を取ってから数えるようにした | ふつうの片付け待ちまで打ち切っていたため | 卓を空けた回数 24 → 0。隠れていた片付け待ちが表に出た |
| 時間帯ごとの記録を足して、18回を回し直した | どこで詰まるかを試算と並べるため | ③は向きがそろった。詰まる場所は、試算がレジ・Unity が片付け |

下の図で、ピークのあいだに「片付けが済んでいない卓」と「会計を待っている組」がどう増えるかを、試算と Unity で並べて見られます。店とロボットの台数を切り替えられます。

【図解】LOG379_where_it_jams.html


## 作業ログ ― この記事で扱う範囲と、Unity・試算の版

前の記事（LOG374）で、ロボットに店の地図を渡して道を自分で引かせる形にし、2つの店 × ロボット0・1・2台 × 来る客の並び3通り（種0・1・2）の18回を「基準データ v1」として区切りました。そのとき残ったいちばん大きいずれが、入店待ちがロボットの台数で変わらないことでした。この記事は、その原因を調べるところから、直した版で18回を回し直して「基準データ v2」を取るところまでです。

| もの | 始め | 終わり |
|---|---|---|
| Unity のコード（Unity Version Control） | cs:143 | cs:144（逃がし弁の数え方・試算 v2.8 を読む・時間帯ごとの記録・台帳の列） |
| 試算（serving-robot.html） | v2.7 | v2.8（会計はレジで1組ずつ。レジの数を1つ・2つから選べる。p2m-site の commit f26f27c） |
| Unity が読む条件（serving-robot-params.json） | v2.7 | v2.8（Unity の6条件はレジ1つ） |
| 店の形（p2m-site の public/sim/layouts/） | 8a5b2f6a016a | 8a5b2f6a016a（変えていない） |
| 段階Bの18回 | cs:143（基準データ v1） | cs:144（基準データ v2） |

店は2つです。③は「効率重視・入口が角」（42卓）、⑦は「③の通路を95cmに」（42卓）。どちらもスタッフ4人、来客は1時間42組（普通）、ロボットは料理を運ぶだけ、1往復で最大3卓です。種は来る客の並びを決める番号で、種0・1・2で来る組は64・83・102組になります。試算の平均は約84組なので、試算と比べるときは種1（83組）を主に見ます。

この記事の「ねらい・変わったこと・見込める効果」のうち、効果の数字はすべて Unity と試算で実際に回して測った値です。


## 段階Bの18回（cs:143）を読み直した：スタッフの仕事は軽くなかった

LOG374 の時点では、Unity のスタッフの仕事が試算より軽いのではないかと疑っていました。ロボット0台でも、席は空いているのに待った時間が試算より短かったためです（種0・1で0.3〜0.7分、試算は3.36分）。18回の記録だけを読んで（Unity は動かさずに）、これを確かめました。

来た組が試算に近い種1（83組）で比べると、スタッフの手がふさがっている割合は ③0台で0.77（試算0.75）、⑦0台で0.82（試算0.74）です。スタッフの仕事は軽くありません。代わりに見つかったのが、次の2つです。

### b5_register_and_valve.py（KIOXIA の service_sim/unity/stageB_cs143/）

```python
# 段階Cの2回目（2026-10-25）：入店待ちがロボットの台数で変わらない原因を調べる。
# 18回（cs:143）の記録だけを読む。Unity は触らない。
#  1) レジ：会計1組あたりのレジの時間と、1時間にさばける組の上限（Unity はレジの内側に1人だけ）
#  2) 逃がし弁「卓を空けた」（会計・片付けで600秒止まった卓を、片付けずに空ける）の数と、それで消えたスタッフの仕事
#  3) スタッフの手のふさがり（試算と比べる。来た組の数が近い種1で見る）
import csv, json, sys, os, statistics as st
B = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
P = json.load(open(sys.argv[2])) if len(sys.argv) > 2 else None
site = {}
if P:
    for u in P["unity_conditions"]:
        c = u["condition"]; site[(c["store"], c["robots"])] = u["site_result"]
print("店 台 種 組 | 手ふさがり 試算 | 回転(分) 試算 | 席空待ち 試算 | レジ1組(秒) 1時間の上限(組) | 卓を空けた 会計/片付け | 消えた仕事(スタッフ分) 全仕事に対して")
for stn in ("fam_p1", "fam_p5"):
    for r in (0, 1, 2):
        for s in (0, 1, 2):
            d = f"{B}/20261024_stageB_cs143_{stn}_r{r}_s{s}"
            j = json.load(open(d + "/summary.json"))
            m, sl, t = j["site_metrics"], j["sim_like"], j["staff_time_s"]
            pays = sl["register_pay_count"]; reg = t["会計"] / max(pays, 1)
            gu = list(csv.DictReader(open(d + "/give_ups.csv")))
            fp = sum(1 for g in gu if g["action"] == "卓を空けた" and g["job"] == "会計")
            fc = sum(1 for g in gu if g["action"] == "卓を空けた" and g["job"] == "片付け")
            clears_done = max(m["groups"] - fc, 1)
            per_clear = t["片付け"] / clears_done
            lost = (fc * per_clear + fp * reg) / 60
            work = sum(v for k, v in t.items() if k != "待機") / 60
            sr = site.get((stn, r), {})
            print(stn, r, s, m["groups"], "|", round(m["staff_util"], 3), sr.get("staff_util"), "|",
                  round(m["turn_mean"], 2), sr.get("turn_mean"), "|", round(m["staff_wait_mean"], 2), sr.get("staff_wait_mean"), "|",
                  round(reg), round(3600 / reg, 1), "|", fp, fc, "|", round(lost), f"{lost / work:.0%}")
```

### b5_register_and_valve_output.txt

```text
店 台 種 組 | 手ふさがり 試算 | 回転(分) 試算 | 席空待ち 試算 | レジ1組(秒) 1時間の上限(組) | 卓を空けた 会計/片付け | 消えた仕事(スタッフ分) 全仕事に対して
fam_p1 0 0 64 | 0.667 0.7454 | 3.81 9.7179 | 0.32 3.3554 | 95 37.8 | 0 0 | 0 0%
fam_p1 0 1 83 | 0.773 0.7454 | 4.95 9.7179 | 0.55 3.3554 | 95 37.9 | 7 17 | 51 10%
fam_p1 0 2 102 | 0.809 0.7454 | 5.99 9.7179 | 4.22 3.3554 | 93 38.7 | 8 29 | 81 13%
fam_p1 1 0 64 | 0.598 0.6428 | 3.09 4.2104 | 0.18 1.2201 | 98 36.7 | 1 0 | 2 0%
fam_p1 1 1 83 | 0.711 0.6428 | 3.85 4.2104 | 0.44 1.2201 | 95 37.7 | 7 1 | 13 3%
fam_p1 1 2 102 | 0.76 0.6428 | 4.57 4.2104 | 3.49 1.2201 | 94 38.3 | 13 15 | 57 9%
fam_p1 2 0 64 | 0.622 0.6257 | 4.38 3.7762 | 0.3 0.7468 | 101 35.6 | 2 3 | 12 3%
fam_p1 2 1 83 | 0.697 0.6257 | 3.74 3.7762 | 0.74 0.7468 | 102 35.4 | 10 6 | 30 6%
fam_p1 2 2 102 | 0.801 0.6257 | 5.37 3.7762 | 3.4 0.7468 | 97 37.1 | 12 24 | 86 14%
fam_p5 0 0 64 | 0.692 0.7444 | 6.15 9.9907 | 0.6 3.9497 | 95 37.8 | 1 11 | 34 7%
fam_p5 0 1 83 | 0.817 0.7444 | 5.95 9.9907 | 0.67 3.9497 | 93 38.7 | 6 19 | 60 11%
fam_p5 0 2 102 | 0.831 0.7444 | 6.58 9.9907 | 4.54 3.9497 | 93 38.6 | 8 47 | 159 24%
fam_p5 1 0 64 | 0.64 0.6682 | 4.87 4.7822 | 0.42 1.3606 | 99 36.5 | 1 8 | 29 6%
fam_p5 1 1 83 | 0.787 0.6682 | 5.22 4.7822 | 0.53 1.3606 | 94 38.2 | 6 13 | 48 8%
fam_p5 1 2 102 | 0.785 0.6682 | 5.94 4.7822 | 4.59 1.3606 | 94 38.3 | 10 30 | 97 15%
fam_p5 2 0 64 | 0.643 0.6208 | 3.98 3.845 | 0.3 0.8016 | 99 36.5 | 1 3 | 12 3%
fam_p5 2 1 83 | 0.767 0.6208 | 5.36 3.845 | 0.72 0.8016 | 94 38.3 | 6 14 | 51 9%
fam_p5 2 2 102 | 0.765 0.6208 | 5.37 3.845 | 4.38 0.8016 | 97 37.2 | 13 29 | 106 17%
```

### 原因1：Unity のレジは1時間に約38組しかさばけない

Unity の店は、レジの内側（客と反対側）に店員が1人だけ立つ作りです。会計は1組ずつで、1組あたり93〜102秒かかっています（会計の90秒と、レジまで歩く時間）。1時間にさばけるのは約36〜39組で、来客の1時間42組より少なくなります。ロボットは料理を運ぶだけなので、レジの詰まりは何台入れても減りません。試算の方は、会計（90秒）を4人のだれでも同時にできる作りでした。

### 原因2：逃がし弁「卓を空けた」が、ふつうの片付け待ちを打ち切っていた

逃がし弁は、会計・片付けが600秒（10分）止まった卓を、片付けをしないまま空いたことにする仕組みです。人が通れない店の作りで回し続けられなくなるのを防ぐために入れたものでした。ところが、だれもまだ取っていない片付けの仕事が板（スタッフがやる仕事の一覧）に載ったまま待っている時間まで数えていました。種1の③0台では、83組のうち24卓（会計7・片付け17）が空けられ、スタッフの仕事が約1割消えていました。試算ではロボット0台のとき、客が帰ってから卓が使えるまで平均9.7分かかるのがふつうです。Unity はそこを10分で打ち切るので、席は空いているのに待った時間が0.55分（試算3.36分）と小さく出ていました。

### 来る客の並び3通りは、平均84組に近いのが種1だけだった

来た組は種0・1・2で64・83・102組でした。試算の平均84組に近いのは種1だけで、種0と種2は大きく外れています。種2（102組）は満席で待つ時間が長く、入店待ちの平均を引き上げます。3回の平均で試算と比べるのはやめ、種1を主に見ることにしました。

### 決めたこと：会計は90秒のまま、レジは試算に入れる、逃がし弁は Unity を直す

調べた結果をもとに、次の3つを決めました。

| 問い | 決めたこと |
|---|---|
| ファミレスで、レジの会計は1組あたりどれくらいかかるか | 90秒くらい（今の試算のまま） |
| レジの扱いはどちらに合わせるか | 試算にレジを入れる。会計は1組ずつ（実際の店に近い。試算の条件をすべて回し直す） |
| 逃がし弁の数え方 | スタッフが仕事を取ってから止まった時間だけ数える（Unity の直し） |


## 試算 v2.8：会計はレジで1組ずつにし、レジの数を選べるようにした

試算は SimPy（店の中の出来事を時間の順に計算する Python の道具）で書いています。会計の部分を、次のように変えました。

- 食べ終えた組は、卓に座ったままレジの順番を待つ（その間、卓はふさがったまま）
- 順番が来たら、スタッフの手が空くのを待って会計する（90秒。スタッフの手はこれまでどおりふさがる）
- 食べ終えてから会計が始まるまでを、新しい数字 pay_wait_mean として出す

乱数の使い方は変えていません。レジの数を無限（999）にすると、v2.7 の数字と完全に一致することを確かめました。

```text
fam_p1 0 999 3.99 3.3554 9.7179 0.521
fam_p1 0 1 3.301 0.9547 2.4872 12.278
fam_p1 0 2 3.7247 3.2872 8.9789 0.951
fam_p1 2 999 1.0758 0.7468 3.7762 0.293
fam_p1 2 1 1.1788 0.3853 2.0672 7.984
fam_p1 2 2 1.1018 0.7531 3.1456 0.712
32850 {'store': 'small', 'staff': 1, 'robots': 0, 'arrivals_per_h': 15, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1}
```

（列は 店・ロボットの台数・レジの数・入店待ち・席は空いているのに待った・客が帰ってから卓が使えるまで・会計待ち。最後の行は全条件の数と最初の条件）

### 下書きで③⑦を回すと、レジ1つでは会計を8〜12分待った

全条件を回す前に、③⑦だけ下書きで回しました（スタッフ4人・上げ膳だけ）。

```text
店 台 来客 | v2.7: 入店 席空待ち 会計待ち | レジ1つ: 入店 席空待ち 満席 会計待ち 回転
fam_p1 0 30 | 0.3165 0.1391 0.18 | 0.401 0.145 0.0783 2.49 2.1839
fam_p1 1 30 | 0.2683 0.0905 0.11 | 0.2512 0.0611 0.013 2.35 2.0755
fam_p1 2 30 | 0.2718 0.0933 0.12 | 0.2275 0.0483 0.0 2.2 2.0727
fam_p1 0 42 | 3.99 3.3554 0.52 | 3.301 0.9547 2.1671 12.28 2.4872
fam_p1 1 42 | 1.8213 1.2201 0.31 | 2.5992 0.572 1.8492 7.99 2.1338
fam_p1 2 42 | 1.0758 0.7468 0.29 | 1.1788 0.3853 0.6185 7.98 2.0672
fam_p1 0 54 | 12.8018 8.7527 0.58 | 13.0272 1.5483 11.3023 15.44 2.3904
fam_p1 1 54 | 11.4181 5.1923 0.46 | 11.9538 1.2742 10.5012 12.05 2.1086
fam_p1 2 54 | 7.5274 3.3804 0.4 | 11.1021 1.1728 9.7529 11.45 2.0594
fam_p5 0 30 | 0.3535 0.1741 0.19 | 0.2835 0.1041 0.0 2.55 2.1864
fam_p5 1 30 | 0.2764 0.094 0.11 | 0.232 0.0497 0.0 2.23 2.091
fam_p5 2 30 | 0.2756 0.0944 0.11 | 0.2212 0.0409 0.0 2.07 2.0712
fam_p5 0 42 | 4.7827 3.9497 0.51 | 2.7562 0.8929 1.6822 11.74 2.5036
fam_p5 1 42 | 2.142 1.3606 0.35 | 1.815 0.4851 1.1464 8.06 2.1404
fam_p5 2 42 | 1.1434 0.8016 0.27 | 1.9669 0.4479 1.3379 7.96 2.0967
fam_p5 0 54 | 13.7486 9.5684 0.59 | 12.3263 1.6042 10.5429 15.33 2.4291
fam_p5 1 54 | 11.0493 5.1199 0.45 | 13.7382 1.3233 12.2326 12.25 2.1291
fam_p5 2 54 | 9.0093 3.7496 0.42 | 10.9851 1.1374 9.6675 11.84 2.0793
```

```text
fam_p1 0 レジ2つ 3.7247 3.2872 0.2584 0.95
fam_p1 1 レジ2つ 1.8796 1.0966 0.6048 0.67
fam_p1 2 レジ2つ 1.1018 0.7531 0.1716 0.71
fam_p5 0 レジ2つ 3.8334 3.2564 0.3962 1.02
fam_p5 1 レジ2つ 1.7417 1.0922 0.4659 0.84
fam_p5 2 レジ2つ 0.969 0.6526 0.134 0.69
```

レジ1つ・会計90秒だと、1時間にさばけるのは40組が上限です。③⑦の「普通」は1時間42組なので、レジがいちばんの詰まりになります。食べ終えた客が席で平均8〜12分会計を待ち、その間卓がふさがるので、入店待ちの中身が「片付けが追いつかない」から「満席」に入れ替わります。ロボットを入れても会計待ちはほとんど減りません。レジ2つなら、数字は v2.7 とほぼ同じに戻ります。平均8〜12分は長めに見えたので、全部の条件を回す前に進め方を決めました。

| 問い | 決めたこと |
|---|---|
| レジ1つ・90秒だと「普通」でもレジが詰まる。どう進めるか | レジの数を選べるようにする（1つ・2つ）。最初に見せるのは1つ |
| レジの決まりを入れる店 | 全部の店。大型店は最初からレジ2つ |

### 32,850条件を、クラウドと Mac に分けて回した

レジの数が条件に加わったので、条件の数は16,425から32,850に倍になりました。1条件は40回の平均です。クラウドの作業環境（2コア）だけでは5時間ほどかかる見込みだったので、途中から Mac（4コア）にも分けて回しました。Mac 側は1回の呼び出しが3分までなので、160秒ずつ区切って回し、途中経過をファイルに書いて続きから回す形にしています（run_timed.py）。

重なって両方で回した37,581条件は、数字がすべて一致しました（クラウドは simpy 4.1.2、Mac は 4.1.1）。乱数の種で結果が決まる作りなので、どこで回しても同じになります。

### レジ1つにすると「店が回らない」がかえって減った

全条件を回すと、レジ1つでは「店が回らない」の条件が2,956から1,937に減りました。「店が回らない」は、席は空いているのに片付けや案内が追いつかず、平均10分以上待つ条件です。レジが客の流れをせき止めるので、片付け・案内の手は追いつくようになり、そのぶん食べ終えた客が卓で会計を待つ形になるためです。

```text
レジ1: 店が回らない 1937/16425（v2.7 2956）  入店待ちの差 平均+0.09 最小-15.06 最大+17.46  会計待ち 平均8.97 最大87.3
レジ2: 店が回らない 2907/16425（v2.7 2956）  入店待ちの差 平均-0.06 最小-4.85 最大+7.93  会計待ち 平均1.39 最大57.4
```

```text
③ 2 人 0 台 入店 33.0 席空待ち 23.1 会計待ち 17.4 満席 9.7
③ 2 人 2 台 入店 14.8 席空待ち 8.3 会計待ち 24.2 満席 6.3
③ 3 人 0 台 入店 8.1 席空待ち 4.8 会計待ち 17.6 満席 3.2
③ 3 人 2 台 入店 3.3 席空待ち 0.9 会計待ち 13.2 満席 2.3
③ 4 人 0 台 入店 3.3 席空待ち 1.0 会計待ち 12.3 満席 2.2
③ 4 人 2 台 入店 1.2 席空待ち 0.4 会計待ち 8.0 満席 0.6
レジ 1 会計待ち10分以上なのに「回る」 3835 ／会計待ちも入れた「回らない」 5772 / 16425
レジ 2 会計待ち10分以上なのに「回る」 3 ／会計待ちも入れた「回らない」 2910 / 16425
```

いまの決め方のままだと、③の2人・ロボット2台も「回る」になります。会計を24分待たせている状態です。会計待ちを「店が回らない」に入れるか、別の印にするかを考え、別の印「レジが詰まる」を付けることにしました。

| 問い | 決めたこと |
|---|---|
| 「店が回らない」の決め方に会計待ちを入れるか | 入れない。会計待ちが平均10分以上なら、別の印「レジが詰まる」を出す。LP の「店舗が回る人数」も今の決め方のまま |

- ねらい：レジがせき止めて片付けが回って見える条件でも、会計で長く待たせていることが画面で分かるようにする
- 変わったこと：試算のページの条件に「レジ 1つ／2つ」を足し、結果に「食べ終えてから会計が始まるまで」を足した。10分以上なら「レジが詰まる」と出る。レジ1つで5,263条件、レジ2つで493条件に印が付く
- 見込める効果：レジを1つから2つにしたときに何が変わるかを、ロボットの台数と同じ画面で比べられる。③の4人・0台では、会計待ちが12.3分から1.0分になる

【画像】LOG379_01_register_switch.png 試算のページで③・スタッフ3人・ロボット2台・普通・レジ1つを選んだ画面。食べ終えてから会計が始まるまで13.2分に「レジが詰まる」の印が付いている

### 試算のページ・LP・Unity が読む条件を v2.8 にした

公開中の試算のページに、結果とレジの切り替えを入れるスクリプト（build_page_v28.py）を書き、全37,800画面を描いて、欠け・undefined・NaN がないことを確かめました。

```text
rendered 37800 / レジが詰まるが出た画面 6955 / 回らないが出た画面 5944 / 5台以上の注意 10800
```

ページのデータは倍になり、圧縮したあとの重さは約0.6MBから約1.3MBになりました。LP（ https://pixelstomotors.com/serving-robot-one-less-staff ）は、試算のページからビルドのときに数字を読む作りなので、読み方にレジの数を足しました（src/lib/servingSim.ts）。LP の最初の画面の「店舗が回る人数」は、4人 → 3人 から 3人 → 2人 に変わりました。

Unity が読む条件のファイル（serving-robot-params.json）も v2.8 で書き出しました。Unity の6条件はレジ1つです。

```text
/home/claude/p2m-site/public/sim/serving-robot-params.json 6 conditions / generation 8a5b2f6a016a
  {'store': 'fam_p1', 'staff': 4, 'robots': 0, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 3.301, 'staff_wait_mean': 0.9547, 'dish_wait_mean': 0.459, 'pay_wait_mean': 12.2785}
  {'store': 'fam_p1', 'staff': 4, 'robots': 1, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.5992, 'staff_wait_mean': 0.572, 'dish_wait_mean': 1.9487, 'pay_wait_mean': 7.9893}
  {'store': 'fam_p1', 'staff': 4, 'robots': 2, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.1788, 'staff_wait_mean': 0.3853, 'dish_wait_mean': 0.9488, 'pay_wait_mean': 7.9844}
  {'store': 'fam_p5', 'staff': 4, 'robots': 0, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.7562, 'staff_wait_mean': 0.8929, 'dish_wait_mean': 0.4478, 'pay_wait_mean': 11.7376}
  {'store': 'fam_p5', 'staff': 4, 'robots': 1, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.815, 'staff_wait_mean': 0.4851, 'dish_wait_mean': 1.9698, 'pay_wait_mean': 8.0624}
  {'store': 'fam_p5', 'staff': 4, 'robots': 2, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.9669, 'staff_wait_mean': 0.4479, 'dish_wait_mean': 0.9839, 'pay_wait_mean': 7.9555}
```

### compare_v27_v28.txt（v2.7 と v2.8 の比べ）

```text
v2.7（レジの数を数えない）と v2.8（会計はレジ1つにつき1組ずつ）の比較。2026-10-25
v2.7 の店が回らない 2956 / 16425 条件
レジ1つ：店が回らない 1937・レジが詰まる 5263 / 16425 条件　入店待ちの差 平均+0.09 最小-15.06 最大+17.46分　会計待ち 平均8.97分
レジ2つ：店が回らない 2907・レジが詰まる 493 / 16425 条件　入店待ちの差 平均-0.06 最小-4.85 最大+7.93分　会計待ち 平均1.39分
店ごと（真ん中の人数・普通・上げ膳だけ・最初に見せるレジ）：入店待ち v2.7 0台→2台 ／ v2.8 0台→2台 ／ 会計待ち 0台→2台
  small（レジ1）2.78→3.32 ／ 3.12→2.64 ／ 1.6→1.3
  family（レジ1）8.58→3.16 ／ 7.86→2.56 ／ 10.2→6.7
  large（レジ2）19.68→7.38 ／ 17.52→7.20 ／ 3.4→2.4
  hotel（レジ1）2.90→1.44 ／ 3.87→0.84 ／ 4.2→2.2
  buffet（レジ1）9.22→1.65 ／ 8.47→1.84 ／ 3.5→1.4
  fam_p0（レジ1）2.74→1.76 ／ 2.68→1.60 ／ 1.3→1.3
  fam_p1（レジ1）3.99→1.08 ／ 3.30→1.18 ／ 12.3→8.0
  fam_p2（レジ1）10.76→3.66 ／ 9.72→3.17 ／ 4.2→4.9
  fam_p3（レジ1）7.56→2.15 ／ 5.44→2.40 ／ 18.0→11.2
  fam_p4（レジ1）7.80→3.49 ／ 6.31→2.34 ／ 11.7→7.7
  fam_p5（レジ1）4.78→1.14 ／ 2.76→1.97 ／ 11.7→8.0
```


## Unity v10：逃がし弁は、スタッフが仕事を取ってから数える（cs:144）

Claude Code（Unity のコードを書く AI）に、逃がし弁の数え方を変える依頼（v10）を出しました。依頼文はこの記事の後ろに全文を載せています。

- 数えるのは、その組の仕事をスタッフが取っている間だけ。板に載ったまま誰も取っていない間と、レジの列が空くのを席で待っている間は数えない
- Unity が試算 v2.8 の条件を読み、試算と同じ会計待ち（pay_wait_mean）を出す
- 試算の版が v2.8 でない、またはレジの数が1でない条件は回さない

### 確かめ：③・4人・0台・種1で、卓を空けたが24から0になった

| | 直す前（cs:143） | 直したあと | 試算 v2.8 |
|---|---|---|---|
| 卓を空けた | 24 | 0 | − |
| 入店待ち | 1.74分 | 3.91分 | 3.30分 |
| 席は空いているのに待った | 0.55分 | 2.71分 | 0.95分 |
| 客が帰ってから卓が使えるまで | 4.95分 | 10.76分 | 2.49分 |
| 食べ終えてから会計まで | − | 4.71分 | 12.28分 |
| スタッフの手のふさがり | 0.77 | 0.78 | 0.72 |

- ねらい：ふつうの片付け待ちを打ち切らず、試算と同じ意味で「席は空いているのに待った」を数える
- 変わったこと：stuckS を、スタッフが仕事を取っている間だけ足す形にした（GroupManager.cs の1行）
- 見込める効果：卓を空けた回数は 24 → 0。隠れていた片付け待ちが表に出て、片付けの仕事が取られるまで平均511秒・最長1,145秒、10分を超えたのが37回だった

入店待ちの大きさは試算に近づきました（3.91分と3.30分）。ただし詰まる場所が違います。試算 v2.8 はレジで詰まり（会計待ち12分・片付けは2.5分で回る）、Unity は片付けで詰まりました（卓が使えるまで10.8分・会計待ち4.7分）。Unity の形は、レジを入れる前の v2.7（卓が使えるまで9.7分）に近い形です。


## v10b：台帳に会計待ちを足し、60秒ごとの記録 timeline.csv を出した

どの時間帯に、誰が何をしていて、何が板に残っていたかを試算と並べるため、記録を足す依頼（v10b）を出しました。店の回し方は1つも変えていません。

- 段階Bの台帳（unity_ledger_v1.csv）に10列を足した（会計待ち・片付け待ち・客が帰ってから卓が使えるまでを、Unity と試算の両方の値で）。前の36行は書き換えず、台帳の1行目だけを差し替えた（差し替え前の写しは unity_ledger_v1_schema1_backup_20261025.csv）
- 新しいファイル timeline.csv：店の中の時間で60秒ごとに1行。入口で待つ組・席で会計を待つ組・レジの列・片付けを誰も取っていない卓・板に残った仕事の数・スタッフ4人がそれぞれ何をしているか・走っているロボットの台数
- 各行で、卓の合計が42、スタッフの合計が4になるかを、回すたびに自動で数える（summary.json の timeline_check）

18回の1回目（③・ロボット2台・種0）が終わった時点で、timeline_check を読みました。173行すべてで卓の合計が42、スタッフの合計が4で、合わない行は0でした。

```text
timeline_check {'rows': 173, 'step_s': 60, 'tables': 42, 'staff': 4, 'bad_table_rows': 0, 'bad_staff_rows': 0, 'first_bad': '', 'note': 'timeline.csv の各行で、seated_groups + tables_free + tables_dirty_untaken + tables_clearing が tables と同じか、staff_* の合計が staff と同じかを数えたもの。合わない行があれば bad_* が 0 より大きくなり、Console にも出る'}
```


## 基準データ v2：段階Bの18回（cs:144）

2026-10-25 の11時ごろから15時までで18回を回しました。18回とも最後まで回り切り、台帳に cs:144 の18行（台帳の版2・59列）が書かれています。

### c1_compare.py（KIOXIA の service_sim/unity/stageB_cs144/）

```python
# 基準データ v2（段階Bの18回・cs:144・逃がし弁を直し試算 v2.8 を読む版）を、cs:143（基準データ v1）と試算 v2.8 に並べる（2026-10-25）
import json, csv, sys, os, statistics as st, collections as C
B = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def run(cs, day, store, r, s):
    d = f"{B}/{day}_stageB_{cs}_{store}_r{r}_s{s}"
    j = json.load(open(d + "/summary.json")); m = j["site_metrics"]
    gu = list(csv.DictReader(open(d + "/give_ups.csv")))
    a = C.Counter(g["action"] for g in gu)
    return dict(m, freed=a["卓を空けた"], gave=a["あきらめた"], moved=a["移した"], site=j["site_result"],
                clear_max=(j.get("job_wait_max_s") or {}).get("片付け"), over=(j.get("job_wait_over_count") or {}).get("片付け", 0),
                stopped=json.load(open(d + "/run_info.json")).get("stopped_early"))
K = [("entry_wait_mean", "入店待ち"), ("staff_wait_mean", "席空待ち"), ("full_wait_mean", "満席"), ("turn_mean", "卓が使えるまで"),
     ("pay_wait_mean", "会計待ち"), ("dish_wait_mean", "料理の待ち"), ("robot_share", "ロボの割合"), ("staff_util", "手")]
for store in ("fam_p1", "fam_p5"):
    for r in (0, 1, 2):
        new = [run("cs144", "20261025", store, r, s) for s in (0, 1, 2)]
        old = [run("cs143", "20261024", store, r, s) for s in (0, 1, 2)]
        site = new[0]["site"]
        print(f"== {store} ロボット{r}台  組 {[x['groups'] for x in new]}  途中で止めた {[x['stopped'] for x in new]}")
        for k, lab in K:
            nv = [x.get(k) for x in new]; ov = [x.get(k) for x in old]
            f = lambda v: "—" if v is None else f"{v:.2f}"
            print(f"  {lab:6s} 種0・1・2 {' '.join(f(v) for v in nv)} ｜種1 cs143 {f(ov[1])} → cs144 {f(nv[1])} ｜試算 {f(site.get(k))}")
        print(f"  卓を空けた {[x['freed'] for x in old]}→{[x['freed'] for x in new]}  あきらめた {[x['gave'] for x in new]}  移した {[x['moved'] for x in new]}  片付けの板待ち最大 {[round(x['clear_max'] or 0) for x in new]}秒 600超 {[x['over'] for x in new]}")
```

### c1_compare_output.txt（条件ごとの比べ）

```text
== fam_p1 ロボット0台  組 [64, 83, 102]  途中で止めた [False, False, False]
  入店待ち   種0・1・2 1.56 3.90 13.13 ｜種1 cs143 1.74 → cs144 3.90 ｜試算 3.30
  席空待ち   種0・1・2 0.37 2.69 8.07 ｜種1 cs143 0.55 → cs144 2.69 ｜試算 0.95
  満席     種0・1・2 0.00 0.00 3.84 ｜種1 cs143 0.00 → cs144 0.00 ｜試算 2.17
  卓が使えるまで 種0・1・2 3.37 11.16 11.20 ｜種1 cs143 4.95 → cs144 11.16 ｜試算 2.49
  会計待ち   種0・1・2 2.24 4.63 5.46 ｜種1 cs143 — → cs144 4.63 ｜試算 12.28
  料理の待ち  種0・1・2 0.84 0.96 0.94 ｜種1 cs143 0.94 → cs144 0.96 ｜試算 0.46
  ロボの割合  種0・1・2 0.00 0.00 0.00 ｜種1 cs143 0.00 → cs144 0.00 ｜試算 0.00
  手      種0・1・2 0.66 0.77 0.83 ｜種1 cs143 0.77 → cs144 0.77 ｜試算 0.72
  卓を空けた [0, 24, 37]→[0, 0, 0]  あきらめた [10, 7, 18]  移した [9, 10, 7]  片付けの板待ち最大 [337, 1244, 1163]秒 600超 [0, 39, 49]
== fam_p1 ロボット1台  組 [64, 83, 102]  途中で止めた [False, False, False]
  入店待ち   種0・1・2 1.51 3.30 11.37 ｜種1 cs143 1.67 → cs144 3.30 ｜試算 2.60
  席空待ち   種0・1・2 0.26 2.04 4.96 ｜種1 cs143 0.44 → cs144 2.04 ｜試算 0.57
  満席     種0・1・2 0.00 0.00 5.17 ｜種1 cs143 0.00 → cs144 0.00 ｜試算 1.85
  卓が使えるまで 種0・1・2 4.31 5.99 6.38 ｜種1 cs143 3.85 → cs144 5.99 ｜試算 2.13
  会計待ち   種0・1・2 2.31 6.92 7.50 ｜種1 cs143 — → cs144 6.92 ｜試算 7.99
  料理の待ち  種0・1・2 2.04 2.50 2.39 ｜種1 cs143 2.51 → cs144 2.50 ｜試算 1.95
  ロボの割合  種0・1・2 0.79 0.68 0.72 ｜種1 cs143 0.69 → cs144 0.68 ｜試算 0.72
  手      種0・1・2 0.61 0.75 0.77 ｜種1 cs143 0.71 → cs144 0.75 ｜試算 0.62
  卓を空けた [1, 8, 28]→[0, 0, 0]  あきらめた [35, 46, 59]  移した [48, 42, 48]  片付けの板待ち最大 [760, 778, 744]秒 600超 [1, 7, 6]
== fam_p1 ロボット2台  組 [64, 83, 102]  途中で止めた [False, False, False]
  入店待ち   種0・1・2 1.47 2.03 16.64 ｜種1 cs143 2.01 → cs144 2.03 ｜試算 1.18
  席空待ち   種0・1・2 0.20 0.77 8.27 ｜種1 cs143 0.74 → cs144 0.77 ｜試算 0.39
  満席     種0・1・2 0.00 0.00 7.09 ｜種1 cs143 0.00 → cs144 0.00 ｜試算 0.62
  卓が使えるまで 種0・1・2 3.10 3.02 10.61 ｜種1 cs143 3.74 → cs144 3.02 ｜試算 2.07
  会計待ち   種0・1・2 3.19 12.50 9.96 ｜種1 cs143 — → cs144 12.50 ｜試算 7.98
  料理の待ち  種0・1・2 1.03 1.98 1.43 ｜種1 cs143 1.53 → cs144 1.98 ｜試算 0.95
  ロボの割合  種0・1・2 0.99 0.88 0.89 ｜種1 cs143 0.90 → cs144 0.88 ｜試算 0.99
  手      種0・1・2 0.60 0.72 0.80 ｜種1 cs143 0.70 → cs144 0.72 ｜試算 0.59
  卓を空けた [5, 16, 36]→[0, 1, 3]  あきらめた [41, 56, 93]  移した [46, 63, 88]  片付けの板待ち最大 [237, 494, 1582]秒 600超 [0, 0, 64]
== fam_p5 ロボット0台  組 [64, 83, 102]  途中で止めた [False, False, False]
  入店待ち   種0・1・2 1.70 6.16 18.07 ｜種1 cs143 1.87 → cs144 6.16 ｜試算 2.76
  席空待ち   種0・1・2 0.48 4.95 12.50 ｜種1 cs143 0.67 → cs144 4.95 ｜試算 0.89
  満席     種0・1・2 0.00 0.00 4.33 ｜種1 cs143 0.00 → cs144 0.00 ｜試算 1.68
  卓が使えるまで 種0・1・2 8.79 16.42 16.83 ｜種1 cs143 5.95 → cs144 16.42 ｜試算 2.50
  会計待ち   種0・1・2 2.02 4.45 5.61 ｜種1 cs143 — → cs144 4.45 ｜試算 11.74
  料理の待ち  種0・1・2 1.08 1.03 1.16 ｜種1 cs143 1.05 → cs144 1.03 ｜試算 0.45
  ロボの割合  種0・1・2 0.00 0.00 0.00 ｜種1 cs143 0.00 → cs144 0.00 ｜試算 0.00
  手      種0・1・2 0.74 0.80 0.87 ｜種1 cs143 0.82 → cs144 0.80 ｜試算 0.72
  卓を空けた [12, 25, 55]→[0, 1, 0]  あきらめた [37, 37, 66]  移した [30, 30, 41]  片付けの板待ち最大 [897, 1893, 1745]秒 600超 [18, 54, 74]
== fam_p5 ロボット1台  組 [64, 83, 102]  途中で止めた [False, False, False]
  入店待ち   種0・1・2 1.77 8.22 16.22 ｜種1 cs143 1.75 → cs144 8.22 ｜試算 1.81
  席空待ち   種0・1・2 0.50 6.98 9.92 ｜種1 cs143 0.53 → cs144 6.98 ｜試算 0.49
  満席     種0・1・2 0.00 0.00 5.06 ｜種1 cs143 0.00 → cs144 0.00 ｜試算 1.15
  卓が使えるまで 種0・1・2 7.07 16.33 13.46 ｜種1 cs143 5.22 → cs144 16.33 ｜試算 2.14
  会計待ち   種0・1・2 2.77 4.04 6.61 ｜種1 cs143 — → cs144 4.04 ｜試算 8.06
  料理の待ち  種0・1・2 2.06 2.27 2.53 ｜種1 cs143 2.68 → cs144 2.27 ｜試算 1.97
  ロボの割合  種0・1・2 0.82 0.70 0.72 ｜種1 cs143 0.63 → cs144 0.70 ｜試算 0.72
  手      種0・1・2 0.69 0.76 0.84 ｜種1 cs143 0.79 → cs144 0.76 ｜試算 0.62
  卓を空けた [9, 19, 40]→[4, 1, 3]  あきらめた [75, 67, 118]  移した [65, 68, 107]  片付けの板待ち最大 [926, 2088, 1601]秒 600超 [15, 56, 70]
== fam_p5 ロボット2台  組 [64, 83, 102]  途中で止めた [False, False, False]
  入店待ち   種0・1・2 2.08 4.06 16.21 ｜種1 cs143 2.04 → cs144 4.06 ｜試算 1.97
  席空待ち   種0・1・2 0.82 2.81 9.85 ｜種1 cs143 0.72 → cs144 2.81 ｜試算 0.45
  満席     種0・1・2 0.00 0.00 5.09 ｜種1 cs143 0.00 → cs144 0.00 ｜試算 1.34
  卓が使えるまで 種0・1・2 12.41 10.46 14.07 ｜種1 cs143 5.36 → cs144 10.46 ｜試算 2.10
  会計待ち   種0・1・2 2.96 5.18 6.73 ｜種1 cs143 — → cs144 5.18 ｜試算 7.96
  料理の待ち  種0・1・2 2.55 1.45 1.27 ｜種1 cs143 1.53 → cs144 1.45 ｜試算 0.98
  ロボの割合  種0・1・2 0.82 0.92 0.96 ｜種1 cs143 0.92 → cs144 0.92 ｜試算 0.99
  手      種0・1・2 0.72 0.77 0.81 ｜種1 cs143 0.77 → cs144 0.77 ｜試算 0.59
  卓を空けた [4, 20, 42]→[1, 2, 4]  あきらめた [94, 80, 141]  移した [85, 80, 105]  片付けの板待ち最大 [1460, 1500, 1635]秒 600超 [44, 30, 85]
```

### ③はロボットで入店待ちが縮む向きがそろい、⑦は外れた

種1（83組）の入店待ち（ロボット0台 → 1台 → 2台）を並べます。

| | Unity 直す前（cs:143） | Unity 直したあと（cs:144） | 試算 v2.8 |
|---|---|---|---|
| ③ | 1.74 → 1.67 → 2.01分 | 3.90 → 3.30 → 2.03分 | 3.30 → 2.60 → 1.18分 |
| ⑦ | 1.87 → 1.75 → 2.04分 | 6.16 → 8.22 → 4.06分 | 2.76 → 1.81 → 1.97分 |

卓を空けた数は、どの条件も0〜4になりました（③0台の種0・1・2は 0・24・37 → 0・0・0）。③では、前回いちばん大きかったずれ（ロボットの台数で入店待ちが変わらない）が解けました。⑦は試算から大きく外れ、客が帰ってから卓が使えるまで16分かかっています。

### ピーク後半の店の様子：試算はレジに列、Unity は片付けがたまる

来客が始まって60〜120分（ピークの後半）の平均を、Unity の timeline.csv から出しました。試算にも同じ見方の記録を付けて回し（40回の平均）、並べています。

### c2_timeline_peak.py

```python
# 基準データ v2 の timeline.csv から、ピークの後半（来客が始まって60〜120分）の平均を条件ごとに出す（2026-10-25）
import csv, sys, os, statistics as st
B = sys.argv[1]
cols = ["waiting_groups", "seated_groups", "pay_waiting_seat", "pay_in_line", "tables_free", "tables_dirty_untaken", "tables_clearing",
        "board_guide", "board_serve", "board_call", "board_pay", "board_clear",
        "staff_idle", "staff_guide", "staff_serve", "staff_call", "staff_pay", "staff_clear", "staff_rescue", "robots_busy"]
print("店 台 種 | " + " ".join(cols))
for store in ("fam_p1", "fam_p5"):
    for r in (0, 1, 2):
        for s in (0, 1, 2):
            T = list(csv.DictReader(open(f"{B}/20261025_stageB_cs144_{store}_r{r}_s{s}/timeline.csv")))
            t0 = float(T[0]["t_s"])
            P = [x for x in T if 3600 <= float(x["t_s"]) - t0 < 7200]
            print(store, r, s, "|", " ".join(f"{st.mean(float(x[c]) for x in P):.1f}" for c in cols))
```

### c2_timeline_peak_output.txt（Unity・ピーク後半の平均）

```text
店 台 種 | waiting_groups seated_groups pay_waiting_seat pay_in_line tables_free tables_dirty_untaken tables_clearing board_guide board_serve board_call board_pay board_clear staff_idle staff_guide staff_serve staff_call staff_pay staff_clear staff_rescue robots_busy
fam_p1 0 0 | 0.0 28.2 0.4 2.2 11.5 1.0 1.4 0.3 0.9 0.1 1.4 1.0 0.0 0.6 0.8 0.3 0.9 1.4 0.0 0.0
fam_p1 0 1 | 2.4 33.9 2.9 3.0 0.5 6.6 1.1 0.5 1.2 0.3 1.9 6.6 0.0 0.8 0.9 0.2 1.0 1.1 0.0 0.0
fam_p1 0 2 | 12.5 34.3 4.5 2.8 0.4 6.1 1.2 0.2 1.1 0.2 1.9 6.1 0.0 0.7 0.9 0.2 1.0 1.2 0.0 0.0
fam_p1 1 0 | 0.0 28.9 0.4 2.0 10.6 1.2 1.3 0.2 0.3 0.2 1.1 1.2 0.3 0.6 0.4 0.3 0.9 1.3 0.1 1.0
fam_p1 1 1 | 2.0 36.2 3.7 2.8 1.4 3.1 1.3 0.3 0.4 0.1 1.8 3.1 0.0 0.9 0.4 0.3 1.0 1.3 0.0 0.9
fam_p1 1 2 | 12.0 36.4 4.6 2.9 0.4 3.8 1.5 0.2 0.6 0.3 1.9 3.8 0.1 0.7 0.4 0.3 1.0 1.5 0.0 1.0
fam_p1 2 0 | 0.0 29.0 1.0 2.2 11.1 0.2 1.7 0.1 0.0 0.1 1.3 0.2 0.4 0.7 0.0 0.3 0.9 1.7 0.0 1.7
fam_p1 2 1 | 0.5 38.9 3.7 3.0 0.7 0.7 1.7 0.1 0.0 0.2 2.0 0.7 0.1 0.9 0.0 0.3 1.0 1.7 0.0 1.6
fam_p1 2 2 | 16.0 34.2 7.1 3.0 0.2 5.9 1.7 0.1 0.2 0.4 1.9 5.9 0.1 0.5 0.2 0.4 1.0 1.8 0.1 1.6
fam_p5 0 0 | 0.0 28.2 0.2 2.1 8.3 4.3 1.2 0.2 1.1 0.3 1.2 4.3 0.0 0.7 0.9 0.3 0.9 1.2 0.0 0.0
fam_p5 0 1 | 3.7 32.5 2.6 2.9 0.4 7.8 1.2 0.4 1.0 0.3 1.9 7.8 0.0 0.7 0.9 0.2 1.0 1.2 0.0 0.0
fam_p5 0 2 | 16.0 31.3 4.5 2.9 0.3 9.1 1.3 0.5 1.3 0.5 1.9 9.1 0.0 0.5 0.8 0.4 1.0 1.3 0.0 0.0
fam_p5 1 0 | 0.0 29.2 0.5 2.2 8.2 3.0 1.6 0.3 0.1 0.2 1.4 3.0 0.2 0.7 0.2 0.4 0.9 1.6 0.0 1.0
fam_p5 1 1 | 5.2 31.6 2.3 2.8 0.6 8.2 1.5 0.2 0.8 0.3 1.8 8.2 0.0 0.7 0.3 0.4 1.0 1.6 0.0 0.9
fam_p5 1 2 | 14.5 33.5 4.0 2.9 0.3 6.6 1.6 0.3 0.6 0.3 1.9 6.6 0.0 0.6 0.4 0.3 1.0 1.7 0.0 1.0
fam_p5 2 0 | 0.0 30.2 0.5 2.2 4.9 5.7 1.2 0.6 1.1 0.5 1.4 5.7 0.0 0.7 0.7 0.3 0.8 1.3 0.2 1.8
fam_p5 2 1 | 1.8 34.9 2.7 3.0 1.6 4.1 1.4 0.3 0.4 0.4 2.0 4.1 0.0 0.6 0.5 0.3 1.0 1.4 0.1 1.6
fam_p5 2 2 | 13.8 34.0 4.8 2.8 0.4 5.8 1.8 0.1 0.1 0.3 1.8 5.8 0.0 0.7 0.1 0.3 1.0 1.8 0.1 1.6
```

### timeline_site.py（試算 v2.8 に同じ見方の記録を付ける）

```python
# 試算 v2.8 にも Unity の timeline.csv と同じ見方の記録を付けて、ピークの後半（60〜120分）の平均を出す（2026-10-25）
# 数えるもの：入口で待つ組・卓にいる組・片付け待ちの卓（客が帰ってから片付けが終わるまで）・スタッフの手の空き・
# 板（スタッフの順番待ち）に残った仕事を優先度ごとに（1＝案内・配膳・呼び出し・会計、2＝片付け）・レジの列
import sys, statistics as st, simpy, random
import haizen_sim as h

def run(params, seed):
    store = h.STORES[params["store"]]; p = {**h.COMMON, **store, **params}
    env = simpy.Environment(); s = h.Store(env, random.Random(seed), p)
    env.process(s.arrivals()); rows = []
    def sampler():
        while True:
            q = s.staff.queue
            rows.append(dict(t=env.now, waiting=len(s.tables.queue), seated=s.tables.count - s.dirty, dirty=s.dirty,
                             staff_busy=s.staff.count, board_p1=sum(1 for r in q if r.priority == 1), board_p2=sum(1 for r in q if r.priority == 2),
                             reg_queue=len(s.register.queue), reg_busy=s.register.count))
            yield env.timeout(60)
    env.process(sampler()); env.run(until=10 * 3600)
    return [r for r in rows if 3600 <= r["t"] < 7200]

for store, r_, tr in (("fam_p1", 0, 1), ("fam_p1", 1, 3), ("fam_p1", 2, 3), ("fam_p5", 0, 1), ("fam_p5", 1, 3), ("fam_p5", 2, 3)):
    c = {"store": store, "staff": 4, "robots": r_, "arrivals_per_h": 42, "mode": "serve", "trays": tr, "fragile_by": "any", "registers": 1}
    R = [x for sd in range(40) for x in run(c, sd)]
    print(store, r_, " ".join(f"{k} {st.mean(x[k] for x in R):.1f}" for k in R[0] if k != "t"))
```

### timeline_site_output.txt（試算・ピーク後半の平均。スタッフ4人・普通・レジ1つ）

```text
fam_p1 0 waiting 3.4 seated 38.1 dirty 1.6 staff_busy 3.8 board_p1 1.3 board_p2 0.5 reg_queue 6.8 reg_busy 1.0
fam_p1 1 waiting 2.8 seated 36.9 dirty 1.3 staff_busy 3.4 board_p1 0.4 board_p2 0.1 reg_queue 4.3 reg_busy 0.9
fam_p1 2 waiting 1.2 seated 37.1 dirty 1.3 staff_busy 3.2 board_p1 0.2 board_p2 0.0 reg_queue 5.0 reg_busy 1.0
fam_p5 0 waiting 2.6 seated 37.4 dirty 1.6 staff_busy 3.8 board_p1 1.2 board_p2 0.5 reg_queue 6.7 reg_busy 1.0
fam_p5 1 waiting 1.9 seated 36.6 dirty 1.3 staff_busy 3.4 board_p1 0.4 board_p2 0.1 reg_queue 4.8 reg_busy 1.0
fam_p5 2 waiting 2.1 seated 36.4 dirty 1.3 staff_busy 3.2 board_p1 0.2 board_p2 0.0 reg_queue 4.9 reg_busy 0.9
```

③0台・種1で比べると、こうなります。

| ピーク後半の平均 | Unity | 試算 v2.8 |
|---|---|---|
| ふさがっているスタッフ（4人中） | 4.0人 | 3.8人 |
| 片付けが済んでいない卓 | 7.7卓（誰も取っていない6.6・片付け中1.1） | 1.6卓 |
| 板に残った片付けの仕事 | 6.6件 | 0.5件 |
| 会計を待つ・レジに並ぶ組 | 5.8組 | 7.8組（列6.8・会計中1.0） |

試算ではスタッフに少し空きがあり、片付けは追いつき、レジに列ができます。Unity ではスタッフ4人がずっとふさがり、いちばん後回しの片付けがたまります。卓が空かないので客が席に着く流れが遅れ、レジの列はかえって短くなります。

### 仕事1件あたりのスタッフの時間が、Unity の方が7〜18%長い

1組あたりにスタッフが使った時間を、仕事ごとに並べました（種1・ロボット0台）。

```text
fam_p1 0 組 83 皿 206 | 1件あたり(秒) 案内 73 配膳 37 会計 93 片付け 134 呼び出し(1組あたり) 24 | 合計/組 415
   スタッフの時間 {'待機': 169, '案内': 101, '料理': 126, '呼び出し': 34, '会計': 129, '片付け': 185}
fam_p5 0 組 83 皿 206 | 1件あたり(秒) 案内 77 配膳 41 会計 93 片付け 161 呼び出し(1組あたり) 25 | 合計/組 459
   スタッフの時間 {'待機': 156, '案内': 107, '料理': 142, '呼び出し': 35, '会計': 128, '片付け': 223}
```

```text
d平均 10.65
案内 66.29285714285714 配膳1皿 31.292857142857144 片付け 122.58571428571429 会計 90
```

| 1件あたり（秒） | 試算 v2.8（③） | Unity ③ | Unity ⑦ |
|---|---|---|---|
| 案内 | 66 | 73 | 77 |
| 配膳（1皿） | 31 | 37 | 41 |
| 会計 | 90 | 93 | 93 |
| 片付け | 123 | 134 | 161 |
| 1組あたりの合計 | 約388 | 415（+7%） | 459（+18%） |

試算は、案内も片付けも「受け渡し口から卓まで」の距離で歩く計算です。Unity は実際に、入口から卓へ、卓から洗い場へ、人をよけながら歩いています。⑦は通路が狭いので、その差が大きくなります。**試算はピークでスタッフの手が95%ふさがっているので、1割に満たない差でも人手が足りなくなり、いちばん後回しの片付けがたまります。** 店の回り方が、ほんの少しの歩く距離で別の形に切り替わる、ぎりぎりの所にいるということです。

### 決めたこと：歩く距離の差は、今は試算に入れない

| 問い | 決めたこと |
|---|---|
| スタッフが歩く距離の差をどう扱うか | 今は試算に入れず、ずれとして記録だけにする。この18回を基準データ v2 として区切る |

直すときの候補は2つ残しています。案内は入口から卓、片付けは卓から洗い場の距離を平面図から取る形と、人をよける分の遅れに Unity の実測を入れる形です。夜のあいだも条件を回し続ける仕組みは別に作っていて、その答え合わせは基準データ v2（KIOXIA の service_sim/unity/stageB_cs144/）と比べます。


## データとコードの置き場所

| もの | 場所 |
|---|---|
| 試算 v2.8 のコード・全32,850条件の結果・ページを作るスクリプト | iCloud の サービスロボット導入シミュレーション/simulation/v2_8/（README.md が入口） |
| 試算のページ・Unity が読む条件 | p2m-site の public/sim/serving-robot.html・serving-robot-params.json（commit f26f27c） |
| Unity の18回（基準データ v2） | KIOXIA の r2r2r-data/service_sim/unity/20261025_stageB_cs144_*（各回に timeline.csv） |
| 18回の集計 | KIOXIA の r2r2r-data/service_sim/unity/stageB_cs144/（c1・c2 と README.md） |
| 18回（cs:143）を読み直した集計 | KIOXIA の r2r2r-data/service_sim/unity/stageB_cs143/b5_register_and_valve.py |
| v10 の確かめ B | KIOXIA の r2r2r-data/service_sim/unity/20261025_p1_efficient_corner_fam_p1_s4_r0_a42_s1_1013/ |
| 段階Bの台帳 | KIOXIA の r2r2r-data/service_sim/unity/unity_ledger_v1.csv |
| Claude Code への依頼文と報告 | iCloud の サービスロボット導入シミュレーション/unity/ と unity/claude_code_reports/ |
| ずれの一覧 | iCloud の サービスロボット導入シミュレーション/unity/unity_stageC_gaps_v1.md |


## Claude Code への依頼文

### 依頼 v10：claude_code_request_v10_valve_v28.md

````markdown
# 依頼 v10：逃がし弁「卓を空けた」の数え方を直し、試算 v2.8 を読む（2026-10-25）

段階Bの18回（cs:143）を調べると、入店待ちがロボットの台数で変わらない原因が2つあった（unity_stageC_gaps_v1.md の「段階Cの2回目」）。

1. レジ：Unity はレジの内側に店員1人で、会計は1組ずつ。試算はだれでも同時に会計できた → **試算の方を直した**（v2.8。会計はレジ1つにつき1組ずつ。公開済み）。Unity のレジはそのまま
2. 逃がし弁：会計・片付けで600秒たった卓を、片付けずに空けている。だれもまだ仕事を取っていない「ふつうの片付け待ち」も数えているので、③0台・種1では83組のうち24卓が空けられ、スタッフの仕事が約1割消えていた → **Unity を直す**（エイイチと決めた：スタッフが仕事を取ってから止まった時間だけ数える）

## 変えないもの

- レジの内側に1人・会計は1組ずつ（payAtRegisterByOne）。会計90秒・片付けの時間など ServiceSpec の値（サイトから読む）
- 600秒（tableStuckFreeSec）と、卓を空けるときの流れ（ForceFreeTable）
- 仕事の順番・料理はロボット優先・ロボットの動き（v9）・人のよけ方
- 店の形（版 8a5b2f6a016a のまま）

## 0. 試算 v2.8 の条件を写す

ネットから取らず、Mac のここから Experiment/serving-robot-params.json に写す。

```
~/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/unity/site_copy_v28/serving-robot-params.json
```

- model は v2.8、layout_generation は 8a5b2f6a016a（店の形は変わっていない）
- unity_conditions の6条件に `"registers": 1` が足してある。site_result に `pay_wait_mean`（食べ終えてから会計が始まるまで・分）が足してある
- 読むときに model が v2.8 で、6条件の registers が 1 であることを確かめる。違えば回さない

## 1. 逃がし弁の数え方（GroupManager）

いまは Paying・Clearing の間ずっと `g.stuckS += dt` している。これを次に変える。

- 数えるのは、その組の仕事（g.job）を**スタッフが取っている間だけ**（`g.job != null && g.job.taken`）
- 板に載ったまま誰も取っていない間（ふつうの片付け待ち）と、会計の並ぶ点が空くのを席で待っている間（g.job がまだない）は数えない
- スタッフがあきらめて仕事を板に戻したとき（AbandonJob）は、それまでに数えた分を0に戻さない（何度も着けない卓を見つけるため）
- 600秒に達したら今までどおり ForceFreeTable。give_ups.csv の waited_s は、この数え方の秒数にする

## 2. 試算と同じ数字を出す（TripLogger の site_metrics）

- `pay_wait_mean` を足す：組ごとに（会計が終わった時刻 − 食べ終えた時刻 − pay_s）÷60 の平均。groups.csv の pay_s 列（会計が終わった時刻）と eat_end_s を使う。卓を空けられた組は入れない。Unity はレジまで歩く時間が入るので、試算より少し長く出てよい
- summary.json に足す：片付けの仕事が板に載ってから取られるまでの最大（秒）と、そのうち600秒を超えた数。会計も同じ。直す前は「卓を空けた」で消えていた長い待ちが、ここに出るようになるはず

## 3. 確かめ

**A. Claude Code が見る**
- コンパイルが通る。直した所の前後を読み、数えない場面（板に載ったまま・並ぶ点待ち）で stuckS が増えないこと

**B. 回す（エイイチが押す）**
ServiceSim / Site Run：③ ロボット0台・種1 を1回。比べる相手は `20261024_stageB_cs143_fam_p1_r0_s1`（83組）：
- 卓を空けた（直す前：会計7・片付け17）。0に近くなるはず
- 入店待ち・席は空いているのに待った・客が帰ってから卓が使えるまで・スタッフの手がふさがっている割合（直す前：1.74分・0.55分・4.95分・0.77）
- pay_wait_mean と、試算 v2.8 の同じ条件（入店待ち 3.30分・席は空いているのに待った 0.95分・回転 2.49分・会計待ち 12.3分）
- 2 の片付け・会計の待ちの最大と、600秒を超えた数
- 最後まで回り切る（全員が店を出る）。来た組は直す前と同じ83組

## 報告

- 直したファイルと、変えた所の説明
- 確かめ A・B の結果（数字つき）と、出力フォルダ名
- この依頼どおりにすると困る所があれば、直す前に書いて止める
- B が最後まで回り切ったら Check-in して番号を報告

段階Bの18回の回し直しは、私（Claude）が B の結果を見てから頼む。
````

### 依頼 v10b：claude_code_request_v10b_stageB_timeline.md

```markdown
# 依頼 v10b：段階Bの台帳に会計待ちを足し、時間帯ごとの記録を出してから18回を回し直す（2026-10-25）

v10 の確かめ B（`20261025_p1_efficient_corner_fam_p1_s4_r0_a42_s1_1013`）で、逃がし弁は直った（卓を空けた 24 → 0）。入店待ちは試算 v2.8 に近づいた（3.91分・試算 3.30分）が、詰まる場所が違う。

- 試算 v2.8：レジで詰まる（会計待ち 12.3分・客が帰ってから卓が使えるまで 2.5分）
- Unity：片付けで詰まる（会計待ち 4.7分・卓が使えるまで 10.8分。片付けの仕事が取られるまで平均511秒）

仕事1件あたりの時間は試算とそろっているので、ピークのどの時間帯に、誰が何をしていて、何が板に残っていたかを、試算と並べて調べる。そのための記録を足し、逃がし弁を直した版で段階Bの18回を回し直す（基準データ v2）。

## 変えないもの

- v10 までの動き（逃がし弁の数え方・レジは1人で1組ずつ・ロボットの動き・人のよけ方・仕事の順番）。今回は記録を足すだけで、店の回し方は1つも変えない
- 段階Bの18回の中身（③⑦ × ロボット0・1・2台 × 種0・1・2）と順番、出力フォルダの名前の付け方
- 前の版の台帳の行（cs:140・cs:143 の36行）は消さない・書き換えない

## 1. 段階Bの台帳に列を足す（unity_ledger_v1.csv・ServiceSimStageB・ServiceSimLedger）

v10 の報告の 7 章 1 で相談のあった件。足すことに決めた。

- 足す列（いちばん後ろ、ledger_schema_version の前でなく後ろでもよいが、前の行と列の位置がずれないように）：`pay_wait_mean`・`site_pay_wait_mean`・`blocked_wait_mean`・`site_blocked_wait_mean`・`turn_mean`・`site_turn_mean`・`clear_wait_mean_s`・`clear_wait_max_s`・`clear_wait_over_count`・`pay_job_wait_mean_s`
- ledger_schema_version を1つ上げる。前の行は新しい列が空のままでよい（書き換えない）
- 新しい列を読むのは私（Claude）の集計（KIOXIA の stageB_cs143/b1_collect.py の次の版）なので、Unity 側の集計は直さなくてよい

## 2. 時間帯ごとの記録（新しいファイル timeline.csv。1回の出力フォルダに1つ）

店の中の時間で60秒ごとに1行。来客が始まってから全員が店を出るまで。

| 列 | 中身 |
|---|---|
| t_s | 店の中の時刻（秒） |
| arrived | ここまでに来た組の数 |
| waiting_groups | 入口で待っている組 |
| seated_groups | 卓にいる組（食事中・会計待ちを含む） |
| pay_waiting_seat | 食べ終えて、レジの並ぶ点が空くのを席で待っている組 |
| pay_in_line | レジの並ぶ点に立っている組（会計中を含む） |
| tables_free | すぐ使える卓 |
| tables_dirty_untaken | 客が帰ったが、片付けの仕事をまだ誰も取っていない卓 |
| tables_clearing | 片付けの仕事をスタッフが取っている卓 |
| board_guide・board_serve・board_call・board_pay・board_clear | 板に載っていて誰も取っていない仕事の数（種類ごと） |
| staff_idle・staff_guide・staff_serve・staff_call・staff_pay・staff_clear・staff_rescue | いまその仕事をしているスタッフの人数（待機は idle） |
| robots_busy | 走っている・卓で待っているロボットの台数 |

- 数え方は、そのコマの状態をそのまま数える（平均にしない）
- 同じ記録を Site Run の1回でも出す（段階Bと同じ作り）

## 3. 確かめ

**A. Claude Code が見る**
- コンパイルが通る。timeline.csv の各行で、seated_groups ＋ tables_free ＋ tables_dirty_untaken ＋ tables_clearing が卓の数（③42）と同じになること、スタッフの人数の合計が4になることを、v10 の B と同じ回の短い試し（Site Run・種1・店の中の時間30分で止めてよい）で確かめる

**B. 回す（エイイチが押す）**
1. Check-in して番号を報告（この番号が段階Bの unity_cs になる）
2. ServiceSim → Stage B → 押す前の確かめ（回さない）で、版が v2.8・店の形 8a5b2f6a016a・レジ1つで、18回とも「まだ」になっていること
3. ServiceSim → Stage B → Run All（18回）。夜にまとめて回してよい

## 報告

- 直したファイルと、変えた所の説明
- 確かめ A の結果（timeline.csv の最初の数行と、合計の確かめ）
- この依頼どおりにすると困る所があれば、直す前に書いて止める
- Check-in の番号

18回が終わったら、集計と試算との並べは私（Claude）がやる。
```


## Claude Code の報告

### 10:04 v10 の報告（claude_code_reports/20261025_1004_v10_valve_v28.txt）

```text
====================================================================
依頼 v10 の報告：逃がし弁「卓を空けた」の数え方を直し、試算 v2.8 を写した
2026-10-25 10:04
====================================================================

結論：0・1・2 章ぜんぶ入れました。組み立ては通っています（53 本・エラー 0）。
B（回す）はエイイチが押す番です。押す前に 1 つだけ決めてほしいことがあります
（7 章の「種1 で回すために .asset を 1 つ変えました」）。


====================================================================
0. 試算 v2.8 を写した
====================================================================

写した：
  unity/site_copy_v28/serving-robot-params.json
  → Assets/ServiceSim/Experiment/serving-robot-params.json
ネットからは取っていません。

読む前に確かめたこと（ぜんぶ合っています）:
  meta.model            = v2.8
  meta.layout_generation = 8a5b2f6a016a（店の形は変わっていない）
  unity_conditions       = 6 件。registers はぜんぶ 1
  site_result            = 6 件とも pay_wait_mean がある
                           （③0台 12.2785 / ③1台 7.9893 / ③2台 7.9844 /
                             ⑦0台 11.7376 / ⑦1台 8.0624 / ⑦2台 7.9555）

v2.7 からの変わり方（③0台）:
  entry_wait 3.99 → 3.30 ／ staff_wait 3.36 → 0.95 ／ turn 9.72 → 2.49
  blocked_wait 2.95 → 0.73 ／ full_wait 0.46 → 2.17
  レジが詰まる分が pay_wait（12.3 分）に移ったので、席の待ちが短くなっています。

「違えば回さない」を Unity 側にも入れました（SimParams）:
  ・meta.model が expectModel（.asset の値。いま v2.8）と違えば回さない
  ・condition.registers が registersSupported（いま 1）と違えば回さない
    Unity はレジの内側に店員1人・会計は1組ずつしか作っていないので、2 は回せません
  版の番号（layout_generation）の突き合わせは前からあるので、そのままです。

  版を手で書かずに済むように、v2.8 という字は C# に埋めず SimParams.asset に置きました
  （expectModel: v2.8）。次に版が上がったら、.asset のこの 1 行だけ直してください。


====================================================================
1. 逃がし弁の数え方（直したファイル：Service/GroupManager.cs）
====================================================================

Group.stuckS を増やす所を、これだけ変えました。

  前）  if (g.state == Paying || g.state == Clearing)
        {
            g.stuckS += dt;                       ← ずっと数えていた
            if (600 秒に達したら) ForceFreeTable(g);
        }

  後）  if (g.state == Paying || g.state == Clearing)
        {
            if (g.job != null && g.job.taken) g.stuckS += dt;   ← 取られている間だけ
            if (600 秒に達したら) ForceFreeTable(g);             ← ここは変えていない
        }

数える／数えないの分かれ方:
  g.job == null        … 会計の並ぶ点が空くのを席で待っている間。数えない
  g.job.taken == false … 板に載ったまま誰も取っていない間
                         （ふつうの片付け待ち・会計待ち）。数えない
  g.job.taken == true  … スタッフが取ったのに着けない。これだけ数える

あきらめて板に戻したとき（ops.AbandonJob が taken を false に戻す）は、
数えるのを止めるだけで 0 には戻しません。何度も着けない卓はこれで見つかります。

600 秒に達したあとの流れ（ForceFreeTable）と tableStuckFreeSec は変えていません。
give_ups.csv の waited_s は g.stuckS をそのまま書いているので、
自動で新しい数え方の秒数になります。

ついでに直したもの（動きには効きません）:
  ・ForceFreeTable の説明文が UpdateSeatedBodies の上に落ちていたので、本体の上に戻した
  ・Group に tableForceFreed を足した（2 章の pay_wait_mean から外すため）


====================================================================
2. 試算と同じ数字を出す
====================================================================

(a) pay_wait_mean（summary.json の site_metrics に足した）

  直したファイル：Experiment/ExperimentRunner.cs（CollectPayWait）
                  Experiment/TripLogger.cs（SimLike・site_metrics）

  組ごとに（会計が終わった時刻 − 食べ終えた時刻 − pay_s）÷ 60 の平均（分）。
  groups.csv の pay_s 列と eat_end_s から出る値と同じで、引く pay_s は
  ServiceSpec.paySec（サイトから読んだ 90 秒）です。

  入れない組:
    ・逃がし弁で卓を空けられた組（tableForceFreed）
    ・会計が終わっていない組（pay_s が -1。打ち切りで店に残った組）
    ・慣らしで最初から座っていた組（サイトの条件では慣らしを使わないので 0 件）

  site_metrics に 2 つ出ます:
    pay_wait_mean    … 上の平均（分）
    pay_wait_groups  … その平均に入れた組の数

(b) 片付け・会計が板で待った最大と、長びいた数（summary.json）

  直したファイル：Service/ServiceOps.cs（RecordJobWait に最大と超えた数を足した）
                  Experiment/ExperimentRunner.cs・TripLogger.cs（書き出し）

  新しい欄（仕事の名前ごと。案内・料理・呼び出し・会計・片付け・立ち往生）:
    job_wait_max_s        … 板に載ってから取られるまでの、いちばん長かった秒数
    job_wait_over_count   … そのうち 600 秒を超えた数
    job_wait_note.over_s  … 600 秒（tableStuckFreeSec と同じ値を書く）

  数え方の但し書き（job_wait_note.note にも書いてあります）:
    ・取られないまま終わった仕事（卓を空けたときに板から外した分）は入りません。
      「板に載ってから取られるまで」なので、取られなければ値が出ません。
      直したあとは卓を空けるのが減るので、長い待ちはここに出てきます
    ・あきらめて板に戻った仕事は、取られるたびに 1 回ずつ数えます
      （板に出た時刻からは数え直しません）


====================================================================
3. 確かめ A（Claude Code が見た）
====================================================================

(1) 組み立て

Unity Editor がプロジェクトを押さえているので、Unity を立てずに
Unity 同梱の csc（Roslyn・2022.3.62f3）で ServiceSim の .cs を 53 本ぜんぶ
組み立てました。エラー 0・警告 0 です。
Unity に切り替えたら Console は見てください（.meta の作られ方は Unity 側でしか分かりません）。

(2) 数えない場面で stuckS が増えないこと（読んで確かめた）

g.job に書き込む所は 8 か所だけで、Paying・Clearing の間は
「null」か「その組の会計／片付けの仕事」のどちらかしかありません。

  SeatWaiting   g.job = 案内の仕事   → 状態は Guided（数える所に入らない）
  OnGuided      g.job = null         → 状態は Seated。このまま Eating → Paying に進む
  Paying に入った時点              → g.job は null のまま。数えない ○
  SendPayerIfPossible              → 並ぶ点が取れなかったら index < 0 で return。
                                      g.job は null のまま。数えない ○
                                      取れたときだけ会計の仕事を出して g.job に入れる
  Left → Clearing                  → 片付けの仕事を出して g.job に入れる。
                                      出した瞬間は taken = false。数えない ○
  OnPaid / OnCleared / ForceFreeTable → g.job = null

ops.AbandonJob は job.taken を false に戻すだけで板からは外さないので、
戻された仕事は同じ StaffJob のまま板に残り、g.job も指したままです。
だから「戻っている間は数えない・取られたら続きから数える・0 には戻さない」で合っています。

(3) 直したあと「卓を空けた」が 0 に近くなるはずの裏づけ

スタッフが仕事を取ってから止まっていられる時間には、もう上限があります。
PeopleSpec の staffGiveUpSec = 60 秒・staffGiveUpMaxTries = 3 なので、
行き先に着けないまま 60 秒たつと板に戻し、3 回めで「移した」になって仕事が済みます。
ふつうに着ければ、片付けは clear_s 40 + wipe_s 40 = 80 秒、会計は pay_s 90 秒。
どの道でも 600 秒には届きにくいので、卓を空けるのは
「同じ卓で何度も戻されて積み上がった」ときだけになります。

(4) 直す前の回から、新しい欄の値を先に出してみた

比べる相手の回（20261024_stageB_cs143_fam_p1_r0_s1・83 組）の
groups.csv を、新しい pay_wait_mean の決まりのまま計算しました。

  卓を空けた 24 卓（会計 7・片付け 17）。依頼にある数と同じです
  groups.csv の table_id が 0 の 24 行が、その卓を空けられた組です
  （ForceFreeTable が g.table を外すので 0 で残ります）

  pay_wait_mean（卓を空けた 24 組を外す・59 組）  = 2.90 分
  （外さずに会計の済んだ 76 組でみると         = 3.20 分）
  試算 v2.8 の同じ条件                          = 12.28 分

  → 直す前の Unity は、レジ待ちの長い組を「卓を空けた」で切っていたので、
     会計待ちが試算の 4 分の 1 しか出ていません。
     直したあとはここが伸びるはずです（伸びなければ別の原因です）。

  おまけ：いまの job_wait_mean_s は 会計 135.3 秒・片付け 176.5 秒。
  600 秒のうち半分近くが「板で順番待ち」だったことが分かります。

(5) 1 つだけ、依頼の書き方で迷った所（勝手に変えてはいません）

依頼の 2 章は「食べ終えた時刻」に groups.csv の eat_end_s を使うと書いてあるので、
そのとおりにしました。ただ Unity の eat_end_s は「食べ始めに決めた食べ終わりの時刻」で、
そのあと遅れて届いた皿やドリンクバー・トイレの人を待ってから会計に入ります
（会計に入った時刻は pay_start_s）。その待ちが pay_wait_mean に混ざります。

混ざった分の大きさが見えるように、summary.json の job_wait_note に
pay_wait_from_pay_start_mean（pay_start_s から数えた同じ値）も出しました。
上の回で比べると 2.90 分 と 2.84 分で、差は 0.06 分です。
小さいので、site_metrics に出す pay_wait_mean は依頼どおり eat_end_s のままにしました。


====================================================================
4. 回す前にしてほしいこと（B はエイイチが押す）
====================================================================

1. Unity に切り替える（新しい .cs と serving-robot-params.json が取り込まれる）。
   Console に赤い字が無いか見る。

2. ServiceSim/Site Run/条件を一覧する（回さない）を押す。
   報告に次が出るはずです。出なければ回さないでください。
     試算の版 (meta.model): v2.8（待っている版: v2.8）
       / 店の形の版 (meta.layout_generation): 8a5b2f6a016a
     条件: 6 件 … どれも「レジ 1 つ」
     合わないもの: なし
     出す数字の名前と意味に pay_wait_mean が並ぶ

3. ServiceSim/Site Run/③ スタッフ4人・ロボット0台 を 1 回。

   ★ 種 1 で回すために、ExperimentPlan.asset の trialSeed を 0 → 1 にしました。
     Site Run の種はここから来ます（0 のままだと種 0・64 組になって、
     比べる相手（種 1・83 組）と合いません）。
     trialSeed は Site Run と Stage 2 の単発だけが読みます。
     段階Bの18回は自分の種の一覧を持っているので影響しません。
     種 0 の単発に戻したいときは、ここを 0 に戻してください。

   出力フォルダは
     /Volumes/KIOXIA/r2r2r-data/service_sim/unity/
       20261025_p1_efficient_corner_fam_p1_s4_r0_a42_s1_{HHMM}/
   になります（KIOXIA をつないでから押してください）。


====================================================================
5. B で見るもの（比べる相手：20261024_stageB_cs143_fam_p1_r0_s1・83 組）
====================================================================

                                   直す前      直したあとの見込み    試算 v2.8
  卓を空けた                       24（会計7・片付け17）  0 に近い     −
  あきらめた / 移した              7 / 13      増えてよい（仕事が消えないので） −
  入店待ち entry_wait_mean         1.74 分     伸びる                3.30 分
  席は空いているのに staff_wait    0.55 分     伸びる                0.95 分
  客が帰ってから turn_mean         4.95 分     伸びる                2.49 分
  スタッフの手 staff_util          0.77        そのあたり            0.72
  pay_wait_mean                    2.90 分※   伸びる                12.28 分
  来た組                           83 組       83 組（変わらないはず） −
  最後まで回り切る                 回り切った  回り切る              −

  ※ 直す前の回の groups.csv から、新しい決まりで計算した値（3 章 (4)）

  新しい欄（直す前は無いので、出た値をそのまま見ます）:
    job_wait_max_s の 片付け・会計 … 板で待った最大（秒）
    job_wait_over_count の 片付け・会計 … そのうち 600 秒を超えた数
    job_wait_note.pay_wait_from_pay_start_mean … 照らし合わせ用

  来た組が 83 組から変わっていたら、動きのずれで来客が変わったしるしです
  （依頼 v8b の B で乱数を分けてあるので、変わらないはずです）。


====================================================================
6. 直したファイルの一覧
====================================================================

  Assets/ServiceSim/Experiment/serving-robot-params.json   v2.8 を写した
  Assets/ServiceSim/Experiment/SimParams.cs                版とレジの数の突き合わせ・
                                                           condition.registers を読む
  Assets/ServiceSim/Experiment/SimParams.asset             expectModel: v2.8 /
                                                           registersSupported: 1 /
                                                           写した記録を今日の分に直した
  Assets/ServiceSim/Service/GroupManager.cs                stuckS の数え方（1 章）・
                                                           tableForceFreed
  Assets/ServiceSim/Service/ServiceOps.cs                  板で待った最大と 600 秒超えの数
  Assets/ServiceSim/Experiment/ExperimentRunner.cs         pay_wait_mean を集める・
                                                           新しい欄を渡す
  Assets/ServiceSim/Experiment/TripLogger.cs               summary.json に書く
  Assets/ServiceSim/Experiment/ExperimentPlan.asset        trialSeed: 0 → 1（4 章の★）


====================================================================
7. 入れていないこと（決めてほしいこと）
====================================================================

1. 段階Bの台帳（unity_ledger_v1.csv・ServiceSimStageB）に pay_wait_mean と
   site_pay_wait_mean の列を足していません。
   列を足すと台帳の形が変わって、KIOXIA の集計（b1_collect.py など）を
   直す必要が出ます。18 回の回し直しを頼むときに一緒に決めたいです。
   summary.json には入っているので、集計側で読むことはできます。

2. 「移した」が増えるはずです（仕事を卓を空けて消さなくなるので、
   着けない仕事が板に残って回り続けます）。
   増え方が大きいようなら、そこを次に見る所として扱いたいです。

B が最後まで回り切ったら Check-in して番号を報告します。
段階Bの18回の回し直しは、B の結果を見てから相談します。
```

### 10:48 v10b の報告（claude_code_reports/20261025_1048_v10b_stageB_timeline.txt）

```text
====================================================================
依頼 v10b の報告：台帳に会計待ちを足し、時間帯ごとの記録 timeline.csv を足した
2026-10-25 10:48
====================================================================

結論：1・2 章を入れました。組み立ては通っています（54 本・エラー 0）。
Check-in は済みで、**番号は cs:144** です（これが段階Bの unity_cs になります）。
店の回し方は 1 つも変えていません。記録だけ足しました。

確かめ A は、依頼の「Site Run の短い試し」が **できませんでした**
（Unity Editor がプロジェクトを押さえていて、私からは Play に入れません）。
代わりに、数え方のコードを Unity 抜きで走らせて合計が崩れないことを確かめ、
同じ確かめを **回すたびに自動で走る形** にして summary.json と Console に出しました。
3 章 (2)・(3) に書いてあります。


====================================================================
1. 段階Bの台帳に 10 列足した（台帳の版 1 → 2）
====================================================================

直したファイル：Editor/ServiceSimLedger.cs（列の名前と並び・SchemaVersion）
                Editor/ServiceSimStageB.cs（値を入れる所）

足した列（**ledger_schema_version より後ろ**。前の行の列の位置はずれません）:

  pay_wait_mean           Unity の会計待ち（分。site_metrics から）
  site_pay_wait_mean      試算の会計待ち（分。site_result から）
  blocked_wait_mean       Unity の「卓は空いたのに片付けが終わらず待った」（分）
  site_blocked_wait_mean  試算の同じ値
  turn_mean               Unity の「客が帰ってから卓が使えるまで」（分）
  site_turn_mean          試算の同じ値
  clear_wait_mean_s       片付けの仕事が板に載ってから取られるまで（秒・平均）
  clear_wait_max_s        同じく、いちばん長かった秒数
  clear_wait_over_count   同じく、600 秒を超えた数
  pay_job_wait_mean_s     会計の仕事が板に載ってから取られるまで（秒・平均）

  49 列 → 59 列。clear_* / pay_job_* の仕事の名前は StaffAgent.JobKindName から
  取るので、「片付け」「会計」という字をコードに手で書いていません。
  600 秒を超えた片付けが 1 件も無い回は、summary.json にその名前が出ないので 0 を書きます
  （空欄と区別できるように）。

★ 台帳の実体（KIOXIA）の 1 行めも差し替えました。

  追記ガードが「実体のヘッダとコードの列が 1 字でも違えば追記しない」決まりなので、
  実体のヘッダを直さないと 18 回とも台帳に残りません。
  直したのは **1 行めだけ**で、前の 36 行は 49 列のまま触っていません。
  念のため写しを置いてあります。

    /Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv            （1 行めを 59 列に）
    /Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1_schema1_backup_20261025.csv（差し替える前の写し）

  前の 36 行は新しい 10 列が無いまま（短い行）です。pandas で読むと空欄（NaN）になります。
  集計の次の版でそこだけ気をつけてください。

あわせて「押す前の確かめ（回さない）」を、依頼 3 章 B 2 のとおりに出るようにしました。
いま押すと、こう出ます:

  台帳: /Volumes/KIOXIA/.../unity_ledger_v1.csv
  台帳にある回（ぜんぶの版）: 36 件
  列（59 列・台帳の版 2）: timestamp,run_folder,…,pay_job_wait_mean_s
  試算の版 (meta.model): v2.8 / 店の形の版 (meta.layout_generation): 8a5b2f6a016a
  条件: 6 件 / レジ: 1 つ（6条件とも同じ）
  合わないもの: なし
  Unity の版: cs:144（…）
  この版の18回: まだ 18 回 / 済 0 回
    01. fam_p1・ロボット 2 台・種 0 … まだ
    02. fam_p5・ロボット 2 台・種 0 … まだ
    …（18 行）


====================================================================
2. 時間帯ごとの記録（新しいファイル timeline.csv）
====================================================================

新しいファイル：Experiment/TimelineLogger.cs
直したファイル：Experiment/ExperimentRunner.cs（60 秒ごとに数えて書き出す）
                Service/ServiceOps.cs（仕事板を読むだけの Jobs を足した）
                Experiment/TripLogger.cs（summary.json に timeline_check を足した）

店の中の時間で 60 秒ごとに 1 行。来客が始まってから（サイトの条件では t_s = 0）
全員が店を出るまで。そのコマの様子をそのまま数えます（平均にしません）。
段階Bと Site Run で同じ作りで、1 回の出力フォルダに 1 つできます。

列（24 列。condition_id と seed を先頭に付けて、ほかの CSV とそろえました）:

  condition_id,seed,t_s,arrived,waiting_groups,seated_groups,
  pay_waiting_seat,pay_in_line,
  tables_free,tables_dirty_untaken,tables_clearing,
  board_guide,board_serve,board_call,board_pay,board_clear,
  staff_idle,staff_guide,staff_serve,staff_call,staff_pay,staff_clear,staff_rescue,
  robots_busy

数え方の決め方:

  卓は TableSlot の中身で 4 つに分けます（取りこぼし・二重数えが出ない形）
    tables_free          groupId が無く、片付け中でもない（TableSlot.Free）
    tables_dirty_untaken 片付け中で、片付けの仕事を **誰も取っていない**
                         ＝ふつうの片付け待ち。v10 の B で平均 511 秒だった所
    tables_clearing      片付け中で、片付けの仕事をスタッフが取っている
    seated_groups        groupId があり、片付け中でない（組が卓にいる）
  この 4 つを足すと、いつでも卓の数（③42・⑦48）になります。

  会計待ちは 2 つに分けます
    pay_waiting_seat  食べ終えたが、レジの並ぶ点が取れず **席で** 待っている
    pay_in_line       並ぶ点に立っている（会計の最中も入る）

  board_* は「板に載っていて、まだ誰も取っていない」仕事の数。
  staff_* は StaffAgent.All を、仕事なし（待機）と仕事の種類 6 つに分けた人数。
  robots_busy は robot_util と同じ見方（待機場所の Idle と、往復を終えた Done は数えない）。

  seated_groups を「組」ではなく「卓」から数えているのは、
  合計がいつでも卓の数になる形にしておきたかったからです。
  組と卓は 1 対 1 なので数は同じです（案内の途中の組も、卓を押さえているので入ります）。


====================================================================
3. 確かめ A
====================================================================

(1) 組み立て

Unity 同梱の csc（Roslyn・2022.3.62f3）で ServiceSim の .cs を 54 本ぜんぶ組み立てました。
エラー 0・警告 0。Unity に切り替えたら Console は見てください。

(2) 合計の確かめ（Unity 抜きで走らせた）

依頼の「Site Run の短い試し」は、Unity Editor がプロジェクトを押さえていて
私からは Play に入れないので、できませんでした。かわりに、
**ServiceSim を組み立てた dll の数え方をそのまま呼ぶ試し** を書いて走らせました。
数える所は MonoBehaviour を触らない作りにしてあるので、Unity 無しで動きます。

  試しのファイル（残してあります）:
    /private/tmp/claude-501/-Users-shimazueiichi/d0d60c2a-d756-4c07-a05b-13221e3d700f/scratchpad/TimelineCheck.cs … 卓・板・スタッフの数え方と、timeline.csv の書き出し
    /private/tmp/claude-501/-Users-shimazueiichi/d0d60c2a-d756-4c07-a05b-13221e3d700f/scratchpad/LedgerCheck.cs   … 台帳の追記ガードと、前の行が読めるか

  TimelineCheck の結果（32 件ぜんぶ OK）:
    1. 卓の4つの分け方の合計（42卓を、起こりうる4つの形にばらして入れた）
         座 16 / 空 10 / 片付け待ち 9 / 片付け中 7 = 42        ← 卓の数と同じ
       ・片付け中なのに仕事が板に無い卓（卓を空けた直後の形）も、片付け待ちに入って
         取りこぼしません
       ・同じ卓に片付けの仕事が 2 件ある形（あきらめて戻ったあとに取られた形）でも
         二重に数えません
       ・案内・料理・呼び出し・会計・立ち往生の仕事は、卓の数え方に混ざりません
    2. 板の数え方：取られている仕事は数えない（案内1・料理1・呼び出し1・会計1・片付け9）
    3. スタッフの合計：待機＋仕事の種類 6 つで、1 人ごとに合計がちょうど 1 増える。
       StaffJobKind の範囲の外の値（6〜9）を入れても崩れない
       （仕事の種類が増えても合計が合うように、知らない種類は待機に入れています）
       スタッフ4人の形でも合計 4
    4. timeline.csv を 6 行 書いて、6 行ぜんぶ 卓 42・スタッフ 4 で合った。
       列は 24・1 行めは TimelineLogger.Header と同じ

  書いた timeline.csv（**中身は私が作った数字。実際に回した値ではありません**。
  列の形と、合計が合うことを見るためのものです）:

    condition_id,seed,t_s,arrived,waiting_groups,seated_groups,pay_waiting_seat,pay_in_line,tables_free,tables_dirty_untaken,tables_clearing,board_guide,board_serve,board_call,board_pay,board_clear,staff_idle,staff_guide,staff_serve,staff_call,staff_pay,staff_clear,staff_rescue,robots_busy
    fam_p1_s4_r0_a42,1,0,0,0,0,0,0,42,0,0,1,0,0,0,0,4,0,0,0,0,0,0,0
    fam_p1_s4_r0_a42,1,60,14,1,8,0,1,33,1,0,1,0,0,0,1,2,1,1,0,0,0,0,0
    fam_p1_s4_r0_a42,1,120,28,2,20,1,1,18,3,1,1,1,0,0,3,1,1,1,0,0,1,0,1
    fam_p1_s4_r0_a42,1,180,42,3,34,2,1,0,6,2,1,1,0,0,6,0,1,1,0,1,1,0,1
    fam_p1_s4_r0_a42,1,240,56,4,37,3,1,0,4,1,1,1,0,0,4,0,1,1,0,1,1,0,1
    fam_p1_s4_r0_a42,1,300,70,5,30,4,1,9,2,1,1,1,0,0,2,0,1,1,0,1,1,0,1

  この試しは空まわりではありません。はじめに書いた試しのデータが
  座 38 ＋ 片付け待ち 4 ＋ 片付け中 1 = 43 になっていて、確かめがその 1 行を
  「合わない」と見つけました（直してから上の結果になっています）。
  「卓の数を 41 と言うと 6 行ぜんぶ合わないと出る」ことも確かめました。

  LedgerCheck の結果（9 件ぜんぶ OK）:
    ・台帳の 1 行めが ServiceSimLedger.HeaderLine と 1 字も違わない → 追記ガードが通る
    ・前の行は 36 行・ぜんぶ 49 列のまま（書き換えていない）
    ・列が 10 増えても、前の行の照合キーを読める
      （cs:143 の ③0台・種1 を引ける ／ cs:144 は引けない＝18回とも「まだ」になる）

(3) 回すたびに自動で走る確かめ（短い試しの代わり）

同じ合計の確かめを、回すたびに走るようにしました。
summary.json に timeline_check が出ます:

  "timeline_check": {
    "rows": 行数, "step_s": 60, "tables": 42, "staff": 4,
    "bad_table_rows": 0, "bad_staff_rows": 0, "first_bad": "",
    "note": "…"
  }

  bad_table_rows・bad_staff_rows は **どちらも 0 になるはず** です。
  0 でなければ Console に警告を出すので、夜にまとめて回したあとでも気づけます。
  first_bad に、合わなかったいちばん早い行の時刻と中身が入ります。

  ★ お願い：18 回を回したあと、どれか 1 回の summary.json の timeline_check を見て、
    bad_table_rows と bad_staff_rows が 0 であることを確かめてください。
    依頼の「30 分の短い試し」を私がやれなかった分の埋め合わせです。
    先に確かめておきたいときは、Run All の前に
    ServiceSim/Site Run/③ スタッフ4人・ロボット0台 を 1 回だけ押してください
    （trialSeed は v10 で 1 にしてあるので、種1 で回ります）。


====================================================================
4. Check-in
====================================================================

  番号：**cs:144**（これが段階Bの unity_cs になります）
  入れたもの（v10 の分がまだ入っていなかったので、v10 と v10b を一緒に入れました）:
    Service/GroupManager.cs        逃がし弁の数え方（v10 の 1）・tableForceFreed
    Service/ServiceOps.cs         板で待った最大と 600 秒超え（v10 の 2）・Jobs（v10b の 2）
    Experiment/SimParams.cs       版とレジの数の突き合わせ（v10 の 0）
    Experiment/SimParams.asset    expectModel: v2.8 / registersSupported: 1
    Experiment/serving-robot-params.json  試算 v2.8
    Experiment/ExperimentRunner.cs pay_wait_mean・timeline の数え取りと書き出し
    Experiment/TripLogger.cs      summary.json（pay_wait_mean・job_wait_*・timeline_check）
    Experiment/TimelineLogger.cs  新しい（＋ .cs.meta も作って一緒に入れました）
    Experiment/ExperimentPlan.asset trialSeed: 1（v10 の B のため）
    Editor/ServiceSimLedger.cs    台帳の 10 列・版 2
    Editor/ServiceSimStageB.cs    新しい列の値・押す前の確かめを詳しくした

  入れなかったもの（私の分ではないので触っていません）:
    Assets/ServiceSim/Store/Materials/Mat_MonitorBoard.mat（と .meta）… 置いてあるだけ 2 件
    このまま Run All を押すと、台帳の pendingNote が
    「保留あり 2 件（管理下 0 件・置いてあるだけ 2 件）」になります。
    回り方には効きませんが、気になるなら先に入れるか消すかしてください。

  TimelineLogger.cs.meta は私が作りました（TripLogger.cs.meta と同じ形・新しい guid）。
  Unity に切り替えたとき、この .meta がそのまま使われます
  （Unity が作り直すと guid が変わって、入れ直しになるので先に作っています）。


====================================================================
5. エイイチにお願いすること
====================================================================

1. Unity に切り替える（新しい .cs が取り込まれる）。Console に赤い字が無いか見る。

2. ServiceSim → Stage B → 押す前の確かめ（回さない）。
   1 章の終わりに書いた形で出るはず。次を確かめてください。
     試算の版 v2.8 ／ 店の形 8a5b2f6a016a ／ レジ 1 つ（6条件とも同じ）
     合わないもの: なし
     Unity の版: cs:144
     この版の18回: まだ 18 回 / 済 0 回（18 行とも「まだ」）
     列（59 列・台帳の版 2）
   ここで「回せません」と出たら押さないで、そのまま見せてください。

3.（できれば）ServiceSim → Site Run → ③ スタッフ4人・ロボット0台 を 1 回。
   出力の timeline.csv と summary.json の timeline_check を見て、
   bad_table_rows・bad_staff_rows が 0 であることを確かめる。
   ※ これを飛ばして 4 に進んでもよいです。そのときは 18 回のあとで見てください

4. ServiceSim → Stage B → Run All（18回）。夜にまとめて回して大丈夫です。
   1 回ごとに Play を出入りし、caffeinate でスリープを止めます。
   1 回の実時間の上限は 40 分。途中で止めても、もう一度押せば続きから回ります。


====================================================================
6. 直したファイルの一覧
====================================================================

  Assets/ServiceSim/Experiment/TimelineLogger.cs       新しい（時間帯ごとの記録）
  Assets/ServiceSim/Experiment/TimelineLogger.cs.meta  新しい
  Assets/ServiceSim/Experiment/ExperimentRunner.cs     60 秒ごとに数える・書き出す・合計の確かめ
  Assets/ServiceSim/Experiment/TripLogger.cs           summary.json に timeline_check
  Assets/ServiceSim/Service/ServiceOps.cs              Jobs（仕事板を読むだけ）
  Assets/ServiceSim/Editor/ServiceSimLedger.cs         台帳の 10 列・版 2
  Assets/ServiceSim/Editor/ServiceSimStageB.cs         新しい列の値・押す前の確かめ
  （KIOXIA）unity_ledger_v1.csv                        1 行めを 59 列に。写しを別名で残した

  店の回し方（GroupManager・StaffAgent・ServingRobotController の決まり）は
  1 行も変えていません。


====================================================================
7. 気づいたこと（今回は手を付けていません）
====================================================================

1. v10 の B で「片付けの仕事が取られるまで 平均 511 秒・最大 1145 秒・600 秒超え 37 件」でした。
   timeline.csv の tables_dirty_untaken が、その 511 秒を時間帯ごとにばらして見せる列です。
   ピークのどこで何卓たまっていたかが、これで並べられます。

2. job_wait_* は「板に載ってから取られるまで」なので、取られないまま終わった仕事は
   入りません。v10 で卓を空けるのが 0 になったので、いまはほぼ取りこぼしが無いはずですが、
   打ち切りで止まった回（status が stopped）では、最後まで取られなかった仕事が
   数に入らない点だけ覚えておいてください。

3. 台帳の前の 36 行が 49 列・新しい行が 59 列になります。
   集計の次の版（b1_collect.py）で、列の数で読み分けるか pandas の NaN で済ませるか
   決めてください。私は NaN で済むと思っています。

18 回が終わったら、集計と試算との並べは私がやります。
```


## 確かめと18回の出力

### v10 の確かめ B の summary.json（20261025_p1_efficient_corner_fam_p1_s4_r0_a42_s1_1013）

```json
{
  "run_name": "family_v0",
  "written_at": "2026-10-25 10:27:05",
  "note": "speed_mps = distance_m / (total_s - stopped_s)。stopped_s には、動けなくなった60秒と、スタッフを待った秒数（stuck_wait_s）、通路の入口で待った秒数も入る。pause_s は stop_durations_s（自分で動き出した止まりだけ）の分布。site_metrics はサイトの meta.metrics と同じ名前・同じ定義。入店待ちの分け方もサイトの試算と同じで、blocked_wait は片付け待ちの卓の数だけ待っている列の前から順に足した分、full_wait は seat_wait − blocked_wait、staff_wait は blocked_wait + guide_wait。pay_wait は（会計が終わった時刻 − 食べ終えた時刻 − pay_s）で、逃がし弁で卓を空けられた組は入れない。Unity は会計の人とスタッフがレジまで歩く時間が入るので、試算より少し長く出る。pauses と rescues は、サイト側が unity_measures の仮の値から出した数なので出どころが違う。",
  "sim_like": {"dish_wait_mean": 0.9597, "dish_wait_p90": 1.7137, "cold_share": 0.0049, "seat_wait_mean": 2.1373, "turn_mean": 10.7628, "robot_share": 0, "robot_dishes": 0, "staff_dishes": 206, "requeued_dishes": 0, "late_staff_dishes": 0, "late_staff_share": 0, "robot_first_wait_s": 120, "yields": 0, "yields_by_robot": 0, "yields_by_staff": 0, "yields_by_guest": 0, "yields_stepped_aside": 0, "yields_in_corner": 0, "yields_passed": 0, "yield_wait_s": 0, "yield_wait_mean_s": 0, "staff_yields": 0, "staff_yield_wait_s": 0, "staff_yield_mean_s": 0, "staff_yield_share_of_staff_time": 0, "staff_time_total_s": 44241.19, "corner_stops": 0, "corner_stop_s": 0, "corner_stop_count_share": 0, "corner_stop_time_share": 0, "corner_range": "x 0-0 / z 0-0", "groups": 83, "people_jams": 13, "robot_blocked_jams": 0, "robot_blocked_jam_share": 0, "chair_pulls": 175, "robot_contacts": 0, "robot_yield_waits": 0, "robot_yield_wait_s": 0, "robot_yield_detours": 0, "register_crowd_max": 10, "register_crowd_mean": 2.8425, "register_pay_count": 83, "register_pay_not_at_back": 0, "register_pay_off_spot": 0, "register_pay_offset_mean_m": 0, "register_pay_offset_max_m": 0, "register_back_intruders": 0, "register_back_intruder_s": 0, "drink_bar_queued": 9, "drink_bar_gave_up": 7, "drink_bar_wait_mean": 0.5519, "drink_bar_wait_p90": 0.8256, "gave_up": 7, "warped": 11, "table_freed": 0, "people_jam_total_s": 13.75, "walkers_mean": 9.5261, "measured_minutes": 184.2251},
  "site_metrics": {"entry_wait_mean": 3.9104, "full_wait_mean": 0, "staff_wait_mean": 2.7121, "blocked_wait_mean": 2.1373, "guide_wait_mean": 0.5747, "seat_wait_mean": 2.1373, "dish_wait_mean": 0.9597, "dish_wait_p90": 1.7137, "cold_share": 0.0049, "turn_mean": 10.7628, "pay_wait_mean": 4.7146, "pay_wait_groups": 83, "walk_km_per_staff": 3.2313, "staff_util": 0.7778, "robot_util": 0, "robot_share": 0, "rescues": 0, "pauses": 0, "groups": 83},
  "site_run": {"site_run": true, "condition_id": "fam_p1_s4_r0_a42", "arrivals_minutes": 120, "total_minutes": 184.2251, "everyone_left": true, "aisle_deferrals": 0, "staff_first_yield": true, "robot_stuck_detours": 0, "rescues_done": 0, "rescue_wait_s": 0, "gave_up": 7, "warped": 11, "table_freed": 0},
  "site_result": {"dish_wait_mean": 0.459, "dish_wait_p90": 0.9759, "cold_share": 0, "seat_wait_mean": 2.8998, "turn_mean": 2.4872, "walk_km_per_staff": 2.8126, "staff_util": 0.7219, "robot_util": 0, "robot_share": 0, "robot_bus_share": 0, "collects": 0, "rescues": 0, "pauses": 0, "fragile_robot": 0, "groups": 85.475, "entry_wait_mean": 3.301, "blocked_wait_mean": 0.7327, "guide_wait_mean": 0.222, "staff_wait_mean": 0.9547, "full_wait_mean": 2.1671, "pay_wait_mean": 12.2785},
  "staff_time_s": {"待機": 9829.566, "案内": 5969.723, "料理": 7536.08, "呼び出し": 1994.12, "会計": 7735.797, "片付け": 11175.91},
  "dish_wait_parts": {"robot": {"dishes": 0, "pass_wait_mean_min": 0, "carry_mean_min": 0, "requeue_lost_mean_min": 0, "total_mean_min": 0, "requeued_per_dish": 0, "requeued_total": 0}, "staff": {"dishes": 206, "pass_wait_mean_min": 0.5309, "carry_mean_min": 0.4289, "requeue_lost_mean_min": 0, "total_mean_min": 0.9597, "requeued_per_dish": 0, "requeued_total": 0}, "all": {"dishes": 206, "pass_wait_mean_min": 0.5309, "carry_mean_min": 0.4289, "requeue_lost_mean_min": 0, "total_mean_min": 0.9597, "requeued_per_dish": 0, "requeued_total": 0}},
  "job_wait_mean_s": {"案内": 34.4849, "料理": 31.852, "呼び出し": 39.8627, "会計": 142.793, "片付け": 511.2367},
  "job_wait_max_s": {"案内": 163.8574, "料理": 162.1641, "呼び出し": 156.3867, "会計": 206.9385, "片付け": 1145.159},
  "job_wait_over_count": {"片付け": 37},
  "job_wait_note": {"over_s": 600, "pay_wait_from_pay_start_mean": 4.5909, "note": "job_wait_* は、仕事が板に載ってからスタッフが取るまでの秒数。取られないまま終わった仕事（卓を空けたときに板から外した分）は入らない。あきらめて板に戻った仕事は、取られるたびに1回ずつ数える。over_s は長びいたと数える境目で、tableStuckFreeSec と同じ。pay_wait_from_pay_start_mean は pay_wait_mean を食べ終えた時刻ではなく会計に入った時刻から数えたもの（分）"},
  "staff_time_share": {"待機": 0.2222, "案内": 0.1349, "料理": 0.1703, "呼び出し": 0.0451, "会計": 0.1749, "片付け": 0.2526},
  "stops": {"count": 0, "stuck_count": 0, "partial_path_count": 0, "stop_s_total": 0},
  "trips": {"trips": 0, "timeouts": 0, "stuck_trips": 0, "distance_m_total": 0, "total_s": 0, "stopped_s": 0, "robot_speed_mps": 0, "pause_s_mean": 0, "pause_count": 0, "pause_rate_note": "hop_m は role=site_only で Unity は読まないため出さない。割り出すときは stops.count ÷（distance_m_total ÷ hop_m）", "stuck_rate_note": "同じ理由で出さない。stops.stuck_count ÷（distance_m_total ÷ hop_m）"},
  "stop_causes": [
  ],
  "conditions": [
  ]
}
```

### 18回のうち③0台・種1の summary.json（20261025_stageB_cs144_fam_p1_r0_s1）

```json
{
  "run_name": "family_v0",
  "written_at": "2026-10-25 12:14:34",
  "note": "speed_mps = distance_m / (total_s - stopped_s)。stopped_s には、動けなくなった60秒と、スタッフを待った秒数（stuck_wait_s）、通路の入口で待った秒数も入る。pause_s は stop_durations_s（自分で動き出した止まりだけ）の分布。site_metrics はサイトの meta.metrics と同じ名前・同じ定義。入店待ちの分け方もサイトの試算と同じで、blocked_wait は片付け待ちの卓の数だけ待っている列の前から順に足した分、full_wait は seat_wait − blocked_wait、staff_wait は blocked_wait + guide_wait。pay_wait は（会計が終わった時刻 − 食べ終えた時刻 − pay_s）で、逃がし弁で卓を空けられた組は入れない。Unity は会計の人とスタッフがレジまで歩く時間が入るので、試算より少し長く出る。pauses と rescues は、サイト側が unity_measures の仮の値から出した数なので出どころが違う。",
  "sim_like": {"dish_wait_mean": 0.9613, "dish_wait_p90": 1.6996, "cold_share": 0.0049, "seat_wait_mean": 2.237, "turn_mean": 11.1557, "robot_share": 0, "robot_dishes": 0, "staff_dishes": 206, "requeued_dishes": 0, "late_staff_dishes": 0, "late_staff_share": 0, "robot_first_wait_s": 120, "yields": 0, "yields_by_robot": 0, "yields_by_staff": 0, "yields_by_guest": 0, "yields_stepped_aside": 0, "yields_in_corner": 0, "yields_passed": 0, "yield_wait_s": 0, "yield_wait_mean_s": 0, "staff_yields": 0, "staff_yield_wait_s": 0, "staff_yield_mean_s": 0, "staff_yield_share_of_staff_time": 0, "staff_time_total_s": 44625.41, "corner_stops": 0, "corner_stop_s": 0, "corner_stop_count_share": 0, "corner_stop_time_share": 0, "corner_range": "x 0-0 / z 0-0", "groups": 83, "people_jams": 37, "robot_blocked_jams": 0, "robot_blocked_jam_share": 0, "chair_pulls": 168, "robot_contacts": 0, "robot_yield_waits": 0, "robot_yield_wait_s": 0, "robot_yield_detours": 0, "register_crowd_max": 9, "register_crowd_mean": 2.7037, "register_pay_count": 83, "register_pay_not_at_back": 0, "register_pay_off_spot": 0, "register_pay_offset_mean_m": 0, "register_pay_offset_max_m": 0, "register_back_intruders": 0, "register_back_intruder_s": 0, "drink_bar_queued": 12, "drink_bar_gave_up": 7, "drink_bar_wait_mean": 0.5545, "drink_bar_wait_p90": 0.9257, "gave_up": 7, "warped": 10, "table_freed": 0, "people_jam_total_s": 38.7, "walkers_mean": 9.4199, "measured_minutes": 185.8255},
  "site_metrics": {"entry_wait_mean": 3.9048, "full_wait_mean": 0, "staff_wait_mean": 2.6872, "blocked_wait_mean": 2.237, "guide_wait_mean": 0.4502, "seat_wait_mean": 2.237, "dish_wait_mean": 0.9613, "dish_wait_p90": 1.6996, "cold_share": 0.0049, "turn_mean": 11.1557, "pay_wait_mean": 4.6305, "pay_wait_groups": 83, "walk_km_per_staff": 3.2327, "staff_util": 0.7722, "robot_util": 0, "robot_share": 0, "rescues": 0, "pauses": 0, "groups": 83},
  "site_run": {"site_run": true, "condition_id": "fam_p1_s4_r0_a42", "arrivals_minutes": 120, "total_minutes": 185.8255, "everyone_left": true, "aisle_deferrals": 0, "staff_first_yield": true, "robot_stuck_detours": 0, "rescues_done": 0, "rescue_wait_s": 0, "gave_up": 7, "warped": 10, "table_freed": 0},
  "timeline_check": {"rows": 186, "step_s": 60, "tables": 42, "staff": 4, "bad_table_rows": 0, "bad_staff_rows": 0, "first_bad": "", "note": "timeline.csv の各行で、seated_groups + tables_free + tables_dirty_untaken + tables_clearing が tables と同じか、staff_* の合計が staff と同じかを数えたもの。合わない行があれば bad_* が 0 より大きくなり、Console にも出る"},
  "site_result": {"dish_wait_mean": 0.459, "dish_wait_p90": 0.9759, "cold_share": 0, "seat_wait_mean": 2.8998, "turn_mean": 2.4872, "walk_km_per_staff": 2.8126, "staff_util": 0.7219, "robot_util": 0, "robot_share": 0, "robot_bus_share": 0, "collects": 0, "rescues": 0, "pauses": 0, "fragile_robot": 0, "groups": 85.475, "entry_wait_mean": 3.301, "blocked_wait_mean": 0.7327, "guide_wait_mean": 0.222, "staff_wait_mean": 0.9547, "full_wait_mean": 2.1671, "pay_wait_mean": 12.2785},
  "staff_time_s": {"待機": 10165.4, "案内": 6065.995, "料理": 7562.676, "呼び出し": 2019.695, "会計": 7716.672, "片付け": 11094.97},
  "dish_wait_parts": {"robot": {"dishes": 0, "pass_wait_mean_min": 0, "carry_mean_min": 0, "requeue_lost_mean_min": 0, "total_mean_min": 0, "requeued_per_dish": 0, "requeued_total": 0}, "staff": {"dishes": 206, "pass_wait_mean_min": 0.5271, "carry_mean_min": 0.4342, "requeue_lost_mean_min": 0, "total_mean_min": 0.9613, "requeued_per_dish": 0, "requeued_total": 0}, "all": {"dishes": 206, "pass_wait_mean_min": 0.5271, "carry_mean_min": 0.4342, "requeue_lost_mean_min": 0, "total_mean_min": 0.9613, "requeued_per_dish": 0, "requeued_total": 0}},
  "job_wait_mean_s": {"案内": 27.01, "料理": 31.6275, "呼び出し": 31.6257, "会計": 136.7386, "片付け": 535.7809},
  "job_wait_max_s": {"案内": 135.5684, "料理": 150.5596, "呼び出し": 135.5186, "会計": 204.7471, "片付け": 1243.573},
  "job_wait_over_count": {"片付け": 39},
  "job_wait_note": {"over_s": 600, "pay_wait_from_pay_start_mean": 4.503, "note": "job_wait_* は、仕事が板に載ってからスタッフが取るまでの秒数。取られないまま終わった仕事（卓を空けたときに板から外した分）は入らない。あきらめて板に戻った仕事は、取られるたびに1回ずつ数える。over_s は長びいたと数える境目で、tableStuckFreeSec と同じ。pay_wait_from_pay_start_mean は pay_wait_mean を食べ終えた時刻ではなく会計に入った時刻から数えたもの（分）"},
  "staff_time_share": {"待機": 0.2278, "案内": 0.1359, "料理": 0.1695, "呼び出し": 0.0453, "会計": 0.1729, "片付け": 0.2486},
  "stops": {"count": 0, "stuck_count": 0, "partial_path_count": 0, "stop_s_total": 0},
  "trips": {"trips": 0, "timeouts": 0, "stuck_trips": 0, "distance_m_total": 0, "total_s": 0, "stopped_s": 0, "robot_speed_mps": 0, "pause_s_mean": 0, "pause_count": 0, "pause_rate_note": "hop_m は role=site_only で Unity は読まないため出さない。割り出すときは stops.count ÷（distance_m_total ÷ hop_m）", "stuck_rate_note": "同じ理由で出さない。stops.stuck_count ÷（distance_m_total ÷ hop_m）"},
  "stop_causes": [
  ],
  "conditions": [
  ]
}
```

### 18回のうち③0台・種1の timeline.csv

```csv
condition_id,seed,t_s,arrived,waiting_groups,seated_groups,pay_waiting_seat,pay_in_line,tables_free,tables_dirty_untaken,tables_clearing,board_guide,board_serve,board_call,board_pay,board_clear,staff_idle,staff_guide,staff_serve,staff_call,staff_pay,staff_clear,staff_rescue,robots_busy
fam_p1_s4_r0_a42,1,0,0,0,0,0,0,42,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,60,1,0,1,0,0,41,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,120,1,0,1,0,0,41,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,180,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,240,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,300,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,360,2,0,2,0,0,40,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,420,3,0,3,0,0,39,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,480,4,0,4,0,0,38,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,540,4,0,4,0,0,38,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,600,4,0,4,0,0,38,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,660,5,0,5,0,0,37,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,720,5,0,5,0,0,37,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,780,6,0,6,0,0,36,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,840,6,0,6,0,0,36,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,900,8,0,8,0,0,34,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,960,8,0,8,0,0,34,0,0,0,0,0,0,0,3,0,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,1020,9,0,9,0,0,33,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,1080,11,0,11,0,0,31,0,0,0,0,0,0,0,1,3,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,1140,15,0,15,0,0,27,0,0,0,0,0,0,0,0,4,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,1200,16,0,16,0,0,26,0,0,0,0,0,0,0,0,3,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,1260,16,0,16,0,0,26,0,0,0,0,0,0,0,3,0,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,1320,17,0,17,0,0,25,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,1380,17,0,17,0,0,25,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,1440,19,0,19,0,0,23,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,1500,20,0,20,0,0,22,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,1560,21,0,21,0,0,21,0,0,0,0,0,0,0,0,1,1,2,0,0,0,0
fam_p1_s4_r0_a42,1,1620,22,0,22,0,0,20,0,0,0,0,0,0,0,1,1,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,1680,23,0,23,0,0,19,0,0,0,0,0,0,0,0,1,3,0,0,0,0,0
fam_p1_s4_r0_a42,1,1740,26,0,26,0,0,16,0,0,1,1,0,0,0,0,2,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,1800,26,0,26,0,0,16,0,0,0,2,0,0,0,0,2,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,1860,28,0,28,0,0,14,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,1920,29,0,29,0,0,13,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,1980,29,0,29,0,0,13,0,0,0,0,0,0,0,1,1,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,2040,29,0,29,0,0,13,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,2100,29,0,29,0,0,13,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,2160,32,0,32,0,0,10,0,0,0,0,0,0,0,1,3,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,2220,33,0,33,0,0,9,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,2280,33,0,33,0,0,9,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,2340,33,0,33,0,0,9,0,0,0,0,0,0,0,0,0,3,1,0,0,0,0
fam_p1_s4_r0_a42,1,2400,33,0,33,0,0,9,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,2460,34,0,34,0,0,8,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,2520,34,0,34,0,0,8,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,2580,34,0,34,0,0,8,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r0_a42,1,2640,34,0,34,0,0,8,0,0,0,0,0,0,0,2,0,1,1,0,0,0,0
fam_p1_s4_r0_a42,1,2700,35,0,35,0,0,7,0,0,0,0,0,0,0,1,1,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,2760,35,0,35,0,0,7,0,0,0,0,0,0,0,1,1,2,0,0,0,0,0
fam_p1_s4_r0_a42,1,2820,36,0,36,0,0,6,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p1_s4_r0_a42,1,2880,37,0,37,0,1,5,0,0,0,0,0,1,0,0,1,1,2,0,0,0,0
fam_p1_s4_r0_a42,1,2940,37,0,37,0,1,5,0,0,0,1,1,0,0,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,3000,37,0,36,0,1,5,0,1,0,0,0,0,0,0,0,1,1,1,1,0,0
fam_p1_s4_r0_a42,1,3060,37,0,36,0,2,5,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,3120,37,0,35,0,2,6,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,3180,37,0,35,0,2,6,0,1,0,0,1,1,0,0,0,1,1,1,1,0,0
fam_p1_s4_r0_a42,1,3240,37,0,34,0,2,7,0,1,0,0,0,1,0,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,3300,37,0,34,0,2,7,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,3360,37,0,33,0,2,8,0,1,0,0,0,1,0,0,0,1,1,1,1,0,0
fam_p1_s4_r0_a42,1,3420,37,0,32,0,1,8,0,2,0,0,0,0,0,1,0,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,3480,40,0,35,0,2,5,0,2,2,0,1,1,0,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,3540,40,0,34,0,3,6,1,1,0,2,0,2,1,0,2,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,3600,43,0,37,0,3,4,1,0,0,0,0,2,1,0,3,0,0,1,0,0,0
fam_p1_s4_r0_a42,1,3660,44,0,37,0,2,3,2,0,0,0,1,1,2,0,2,1,0,1,0,0,0
fam_p1_s4_r0_a42,1,3720,45,0,37,0,1,2,1,2,0,1,0,0,1,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,3780,47,0,39,1,3,0,1,2,2,2,1,2,1,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,3840,47,0,38,0,3,1,2,1,2,1,2,2,2,0,0,1,1,1,1,0,0
fam_p1_s4_r0_a42,1,3900,47,0,37,0,3,2,3,0,0,2,1,2,3,0,2,1,0,1,0,0,0
fam_p1_s4_r0_a42,1,3960,48,0,38,2,3,1,3,0,1,0,1,2,3,0,0,2,1,1,0,0,0
fam_p1_s4_r0_a42,1,4020,49,0,38,3,3,0,3,1,1,0,0,2,3,0,1,0,1,1,1,0,0
fam_p1_s4_r0_a42,1,4080,49,0,37,2,3,0,4,1,0,1,0,2,4,0,1,0,1,1,1,0,0
fam_p1_s4_r0_a42,1,4140,50,1,37,2,3,0,4,1,0,3,0,2,4,0,0,1,1,1,1,0,0
fam_p1_s4_r0_a42,1,4200,50,0,37,4,3,0,5,0,0,2,0,2,5,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,4260,50,0,36,3,3,0,4,2,0,1,0,2,4,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,4320,51,1,36,5,3,0,3,3,0,1,0,2,3,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,4380,52,1,36,5,3,0,4,2,1,2,0,2,4,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,4440,52,0,36,6,3,1,5,0,1,4,0,2,5,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,4500,52,0,36,8,3,1,5,0,0,4,0,2,5,0,2,1,0,1,0,0,0
fam_p1_s4_r0_a42,1,4560,53,0,36,7,3,0,6,0,0,1,0,2,6,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,4620,54,1,35,6,3,0,5,2,0,0,0,2,5,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,4680,54,1,35,7,3,0,4,3,0,2,1,2,4,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,4740,54,1,34,6,3,1,5,2,0,1,1,2,5,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,4800,54,0,34,5,3,2,6,0,0,0,0,2,6,0,1,1,1,1,0,0,0
fam_p1_s4_r0_a42,1,4860,54,0,34,5,3,2,5,1,0,0,1,2,5,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,4920,55,0,34,5,3,1,6,1,0,0,1,2,6,0,1,1,0,1,1,0,0
fam_p1_s4_r0_a42,1,4980,55,0,33,4,3,2,5,2,0,0,0,2,5,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,5040,55,0,33,6,3,2,4,3,0,0,0,2,4,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,5100,58,1,34,6,3,0,5,3,2,2,2,2,5,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,5160,59,0,35,6,3,1,6,0,3,2,1,2,6,0,1,1,1,1,0,0,0
fam_p1_s4_r0_a42,1,5220,59,0,35,7,3,1,6,0,1,3,1,2,6,0,2,1,0,1,0,0,0
fam_p1_s4_r0_a42,1,5280,61,1,35,7,3,0,7,0,1,3,0,2,7,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,5340,62,2,34,6,3,0,8,0,1,1,0,2,8,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,5400,62,2,34,6,3,0,6,2,0,0,0,2,6,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,5460,62,2,33,5,3,0,6,3,0,1,0,2,6,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,5520,63,1,34,4,3,0,7,1,2,1,0,2,7,0,0,1,1,1,1,0,0
fam_p1_s4_r0_a42,1,5580,63,0,35,4,3,0,7,0,1,2,0,2,7,0,2,1,0,1,0,0,0
fam_p1_s4_r0_a42,1,5640,63,0,34,3,3,0,8,0,0,1,1,2,8,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,5700,65,2,33,2,3,0,7,2,0,1,0,2,7,0,0,0,1,1,2,0,0
fam_p1_s4_r0_a42,1,5760,66,3,33,2,3,0,7,2,0,1,0,2,7,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,5820,69,5,33,1,3,0,8,1,1,2,0,2,8,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,5880,70,5,33,0,3,0,9,0,0,4,0,2,9,0,2,1,0,1,0,0,0
fam_p1_s4_r0_a42,1,5940,70,5,33,2,3,0,9,0,0,2,0,2,9,0,2,1,0,1,0,0,0
fam_p1_s4_r0_a42,1,6000,71,6,32,1,3,0,9,1,0,0,0,2,9,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,6060,71,6,31,1,3,0,8,3,0,2,0,2,8,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,6120,71,5,32,2,3,0,8,2,1,3,0,2,8,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,6180,72,4,33,2,3,0,9,0,2,2,0,2,9,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,6240,73,5,32,1,3,0,10,0,1,3,0,2,10,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,6300,73,5,32,1,3,0,10,0,0,1,0,2,10,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,6360,73,5,31,1,3,0,9,2,0,0,0,2,9,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,6420,73,5,30,0,3,0,9,3,0,1,0,2,9,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,6480,74,5,31,1,3,0,9,2,1,0,0,2,9,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,6540,74,3,32,1,3,0,10,0,0,0,0,2,10,0,3,0,0,1,0,0,0
fam_p1_s4_r0_a42,1,6600,75,4,31,1,3,0,9,2,0,0,0,2,9,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,6660,77,6,31,1,3,0,9,2,0,2,0,2,9,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,6720,78,5,32,1,3,0,10,0,1,0,0,2,10,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,6780,79,6,31,1,3,0,10,1,0,0,0,2,10,0,1,0,1,1,1,0,0
fam_p1_s4_r0_a42,1,6840,79,6,31,2,3,0,9,2,0,0,0,2,9,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,6900,79,5,31,1,3,0,10,1,1,3,1,2,10,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,6960,81,7,30,0,3,0,11,1,0,1,0,2,11,0,1,1,0,1,1,0,0
fam_p1_s4_r0_a42,1,7020,81,6,31,1,3,0,11,0,0,1,0,2,11,0,2,1,0,1,0,0,0
fam_p1_s4_r0_a42,1,7080,82,7,30,1,3,0,11,1,0,0,1,2,11,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,7140,82,7,29,0,3,0,11,2,0,0,0,2,11,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,7200,83,7,30,0,3,0,11,1,0,0,0,2,11,0,1,0,1,1,1,0,0
fam_p1_s4_r0_a42,1,7260,83,6,30,0,2,0,10,2,0,0,0,1,10,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,7320,83,5,30,0,2,0,11,1,1,2,0,1,11,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,7380,83,4,31,0,2,0,11,0,0,0,0,1,11,0,2,0,1,1,0,0,0
fam_p1_s4_r0_a42,1,7440,83,4,30,1,3,0,12,0,0,1,0,2,12,0,1,1,1,1,0,0,0
fam_p1_s4_r0_a42,1,7500,83,4,29,1,3,0,11,2,0,1,0,2,11,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,7560,83,4,29,2,3,0,11,2,0,5,0,2,11,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,7620,83,2,30,1,3,0,12,0,1,0,0,2,12,0,1,2,0,1,0,0,0
fam_p1_s4_r0_a42,1,7680,83,2,29,0,3,0,12,1,0,2,0,2,12,0,2,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,7740,83,2,29,0,3,0,12,1,0,0,1,2,12,0,0,1,1,1,1,0,0
fam_p1_s4_r0_a42,1,7800,83,1,29,1,3,0,11,2,0,0,0,2,11,0,1,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,7860,83,1,28,0,3,0,12,2,0,2,0,2,12,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,7920,83,0,29,0,3,0,12,1,1,1,2,2,12,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,7980,83,0,28,0,3,1,13,0,0,1,2,2,13,0,1,1,1,1,0,0,0
fam_p1_s4_r0_a42,1,8040,83,0,27,0,3,1,14,0,0,2,0,2,14,0,0,2,1,1,0,0,0
fam_p1_s4_r0_a42,1,8100,83,0,27,0,3,1,11,3,0,0,0,2,11,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,8160,83,0,26,2,3,1,12,3,0,0,0,2,12,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,8220,83,0,25,1,3,4,12,1,0,0,0,2,12,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,8280,83,0,25,1,3,4,10,3,0,2,0,2,10,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,8340,83,0,24,0,3,6,11,1,0,0,1,2,11,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,8400,83,0,23,0,2,6,10,3,0,0,0,1,10,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,8460,83,0,23,0,2,7,9,3,0,1,0,1,9,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,8520,83,0,22,0,2,9,10,1,0,1,0,1,10,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,8580,83,0,21,0,2,9,10,2,0,0,0,1,10,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,8640,83,0,21,0,2,10,8,3,0,1,0,1,8,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,8700,83,0,20,0,2,11,9,2,0,1,0,1,9,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,8760,83,0,19,0,1,13,9,1,0,0,0,0,9,0,0,2,0,1,1,0,0
fam_p1_s4_r0_a42,1,8820,83,0,19,0,1,13,7,3,0,0,0,0,7,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,8880,83,0,18,0,1,14,7,3,0,0,0,0,7,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,8940,83,0,17,0,0,15,6,4,0,0,0,0,6,0,0,0,0,0,4,0,0
fam_p1_s4_r0_a42,1,9000,83,0,17,0,1,17,5,3,0,0,0,0,5,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,9060,83,0,17,0,2,17,5,3,0,0,2,1,5,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,9120,83,0,16,0,1,20,5,1,0,0,0,0,5,0,0,0,2,1,1,0,0
fam_p1_s4_r0_a42,1,9180,83,0,16,0,1,20,3,3,0,0,0,0,3,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,9240,83,0,15,0,1,20,3,4,0,0,0,1,3,0,0,0,0,0,4,0,0
fam_p1_s4_r0_a42,1,9300,83,0,15,0,2,22,3,2,0,0,0,1,3,0,0,1,0,1,2,0,0
fam_p1_s4_r0_a42,1,9360,83,0,14,0,1,24,1,3,0,0,0,0,1,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,9420,83,0,14,0,2,24,1,3,0,0,0,1,1,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,9480,83,0,13,0,1,27,0,2,0,0,0,0,0,1,0,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,9540,83,0,12,0,2,27,0,3,0,0,0,1,0,0,0,0,0,1,3,0,0
fam_p1_s4_r0_a42,1,9600,83,0,12,0,2,28,0,2,0,0,0,1,0,1,0,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,9660,83,0,11,0,2,30,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,9720,83,0,11,0,2,30,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,9780,83,0,10,0,1,31,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,9840,83,0,9,0,0,31,0,2,0,0,0,0,0,2,0,0,0,0,2,0,0
fam_p1_s4_r0_a42,1,9900,83,0,9,0,0,32,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p1_s4_r0_a42,1,9960,83,0,9,0,0,32,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p1_s4_r0_a42,1,10020,83,0,9,0,1,33,0,0,0,0,0,0,0,3,0,0,0,1,0,0,0
fam_p1_s4_r0_a42,1,10080,83,0,9,0,2,33,0,0,0,0,0,1,0,2,0,0,1,1,0,0,0
fam_p1_s4_r0_a42,1,10140,83,0,8,0,1,33,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10200,83,0,7,0,0,34,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p1_s4_r0_a42,1,10260,83,0,7,0,1,34,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10320,83,0,7,0,1,34,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10380,83,0,6,0,0,35,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p1_s4_r0_a42,1,10440,83,0,6,0,1,36,0,0,0,0,0,0,0,3,0,0,0,1,0,0,0
fam_p1_s4_r0_a42,1,10500,83,0,5,0,3,36,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10560,83,0,5,0,3,36,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10620,83,0,4,1,3,36,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r0_a42,1,10680,83,0,4,1,3,37,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10740,83,0,3,0,3,38,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10800,83,0,2,0,2,39,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10860,83,0,2,0,2,39,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10920,83,0,1,0,1,40,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,10980,83,0,1,0,1,40,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p1_s4_r0_a42,1,11040,83,0,0,0,0,41,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p1_s4_r0_a42,1,11100,83,0,0,0,0,41,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
```

### 18回のうち⑦0台・種1の timeline.csv

```csv
condition_id,seed,t_s,arrived,waiting_groups,seated_groups,pay_waiting_seat,pay_in_line,tables_free,tables_dirty_untaken,tables_clearing,board_guide,board_serve,board_call,board_pay,board_clear,staff_idle,staff_guide,staff_serve,staff_call,staff_pay,staff_clear,staff_rescue,robots_busy
fam_p5_s4_r0_a42,1,0,0,0,0,0,0,42,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,60,1,0,1,0,0,41,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,120,1,0,1,0,0,41,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,180,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,240,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,300,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,360,2,0,2,0,0,40,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,420,3,0,3,0,0,39,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,480,4,0,4,0,0,38,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,540,4,0,4,0,0,38,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,600,4,0,4,0,0,38,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,660,5,0,5,0,0,37,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,720,5,0,5,0,0,37,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,780,6,0,6,0,0,36,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,840,6,0,6,0,0,36,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,900,8,0,8,0,0,34,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,960,8,0,8,0,0,34,0,0,0,0,0,0,0,3,0,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,1020,9,0,9,0,0,33,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,1080,11,0,11,0,0,31,0,0,0,0,0,0,0,1,3,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,1140,15,0,15,0,0,27,0,0,0,0,0,0,0,0,4,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,1200,16,0,16,0,0,26,0,0,0,0,0,0,0,1,2,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,1260,16,0,16,0,0,26,0,0,0,0,0,0,0,3,0,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,1320,17,0,17,0,0,25,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,1380,17,0,17,0,0,25,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,1440,19,0,19,0,0,23,0,0,0,0,0,0,0,0,2,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,1500,20,0,20,0,0,22,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,1560,21,0,21,0,0,21,0,0,0,0,0,0,0,0,1,1,2,0,0,0,0
fam_p5_s4_r0_a42,1,1620,22,0,22,0,0,20,0,0,0,0,0,0,0,1,1,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,1680,23,0,23,0,0,19,0,0,0,0,0,0,0,0,1,3,0,0,0,0,0
fam_p5_s4_r0_a42,1,1740,26,0,26,0,0,16,0,0,1,2,0,0,0,0,3,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,1800,26,0,26,0,0,16,0,0,0,2,0,0,0,0,2,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,1860,28,0,28,0,0,14,0,0,0,2,0,0,0,0,2,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,1920,29,0,29,0,0,13,0,0,1,1,0,0,0,0,2,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,1980,29,0,29,0,0,13,0,0,0,0,0,0,0,1,1,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,2040,29,0,29,0,0,13,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,2100,29,0,29,0,0,13,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,2160,32,0,32,0,0,10,0,0,0,0,0,0,0,0,3,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,2220,33,0,33,0,0,9,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,2280,33,0,33,0,0,9,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,2340,33,0,33,0,0,9,0,0,0,1,1,0,0,0,0,4,0,0,0,0,0
fam_p5_s4_r0_a42,1,2400,33,0,33,0,0,9,0,0,0,0,0,0,0,0,0,4,0,0,0,0,0
fam_p5_s4_r0_a42,1,2460,34,0,34,0,0,8,0,0,0,0,0,0,0,0,1,3,0,0,0,0,0
fam_p5_s4_r0_a42,1,2520,34,0,34,0,0,8,0,0,0,0,0,0,0,0,1,3,0,0,0,0,0
fam_p5_s4_r0_a42,1,2580,34,0,34,0,0,8,0,0,0,0,0,0,0,2,0,2,0,0,0,0,0
fam_p5_s4_r0_a42,1,2640,34,0,34,0,0,8,0,0,0,0,0,0,0,2,0,1,1,0,0,0,0
fam_p5_s4_r0_a42,1,2700,35,0,35,0,0,7,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,2760,35,0,35,0,0,7,0,0,0,1,0,0,0,0,1,3,0,0,0,0,0
fam_p5_s4_r0_a42,1,2820,36,0,36,0,0,6,0,0,0,0,0,0,0,2,1,1,0,0,0,0,0
fam_p5_s4_r0_a42,1,2880,37,0,37,0,1,5,0,0,1,0,0,1,0,0,0,2,2,0,0,0,0
fam_p5_s4_r0_a42,1,2940,37,0,37,0,1,5,0,0,0,1,0,0,0,0,1,2,0,1,0,0,0
fam_p5_s4_r0_a42,1,3000,37,0,36,0,1,5,1,0,0,0,0,0,1,0,0,2,1,1,0,0,0
fam_p5_s4_r0_a42,1,3060,37,0,36,0,2,5,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,3120,37,0,35,0,2,6,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,3180,37,0,35,0,2,6,0,1,0,0,1,1,0,0,0,1,1,1,1,0,0
fam_p5_s4_r0_a42,1,3240,37,0,34,0,2,7,1,0,0,0,0,1,1,0,0,1,2,1,0,0,0
fam_p5_s4_r0_a42,1,3300,37,0,33,0,1,7,0,2,0,0,0,0,0,1,0,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,3360,37,0,33,0,2,8,0,1,0,0,0,1,0,0,0,1,1,1,1,0,0
fam_p5_s4_r0_a42,1,3420,37,0,32,0,1,8,0,2,0,0,0,0,0,1,0,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,3480,40,0,35,0,2,6,0,1,1,0,1,1,0,0,2,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,3540,40,0,34,0,3,7,1,0,0,2,0,2,1,0,2,0,1,1,0,0,0
fam_p5_s4_r0_a42,1,3600,43,0,36,0,2,4,2,0,0,0,0,1,2,0,3,0,0,1,0,0,0
fam_p5_s4_r0_a42,1,3660,44,0,37,0,2,3,2,0,0,1,1,1,2,0,3,0,0,1,0,0,0
fam_p5_s4_r0_a42,1,3720,45,0,37,0,1,2,1,2,0,0,0,0,1,0,1,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,3780,47,0,38,0,3,0,2,2,2,1,1,2,2,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,3840,47,0,38,0,3,1,2,1,2,1,1,2,2,0,0,1,1,1,1,0,0
fam_p5_s4_r0_a42,1,3900,47,0,37,0,3,2,3,0,0,2,1,2,3,0,2,1,0,1,0,0,0
fam_p5_s4_r0_a42,1,3960,48,0,38,2,3,1,2,1,1,0,1,2,2,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,4020,49,0,38,3,3,0,3,1,1,0,1,2,3,0,1,0,1,1,1,0,0
fam_p5_s4_r0_a42,1,4080,49,0,37,2,3,0,4,1,0,1,1,2,4,0,1,1,0,1,1,0,0
fam_p5_s4_r0_a42,1,4140,50,0,38,2,3,0,4,0,1,3,1,2,4,0,0,2,1,1,0,0,0
fam_p5_s4_r0_a42,1,4200,50,0,37,4,3,0,5,0,0,2,0,2,5,0,1,1,1,1,0,0,0
fam_p5_s4_r0_a42,1,4260,50,0,36,3,3,0,3,3,0,0,0,2,3,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,4320,51,1,36,4,3,0,3,3,0,3,0,2,3,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,4380,52,1,36,4,3,0,4,2,1,5,0,2,4,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,4440,52,0,36,4,3,1,5,0,2,3,0,2,5,0,0,3,0,1,0,0,0
fam_p5_s4_r0_a42,1,4500,52,0,36,7,3,1,5,0,0,5,0,2,5,0,2,1,0,1,0,0,0
fam_p5_s4_r0_a42,1,4560,53,0,36,7,3,0,6,0,1,3,0,2,6,0,0,3,0,1,0,0,0
fam_p5_s4_r0_a42,1,4620,54,1,35,6,3,0,7,0,0,0,0,2,7,0,1,2,0,1,0,0,0
fam_p5_s4_r0_a42,1,4680,54,1,35,6,3,0,5,2,0,1,1,2,5,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,4740,54,1,34,6,3,1,5,2,0,0,0,2,5,0,0,0,1,1,2,0,0
fam_p5_s4_r0_a42,1,4800,54,1,33,5,3,1,5,3,0,1,0,2,5,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,4860,54,0,34,5,3,2,5,1,0,0,1,2,5,0,1,1,0,1,1,0,0
fam_p5_s4_r0_a42,1,4920,55,0,34,5,3,2,6,0,0,0,0,2,6,0,1,1,1,1,0,0,0
fam_p5_s4_r0_a42,1,4980,55,0,33,4,3,2,6,1,0,0,0,2,6,0,0,1,1,1,1,0,0
fam_p5_s4_r0_a42,1,5040,55,0,33,5,3,2,5,2,0,0,1,2,5,0,0,0,1,1,2,0,0
fam_p5_s4_r0_a42,1,5100,58,0,35,6,3,0,6,1,3,1,0,2,6,0,0,1,1,1,1,0,0
fam_p5_s4_r0_a42,1,5160,59,1,34,5,3,0,7,1,1,1,0,2,7,0,2,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,5220,59,0,35,7,3,0,7,0,1,1,1,2,7,0,3,0,0,1,0,0,0
fam_p5_s4_r0_a42,1,5280,61,2,34,6,3,0,8,0,0,2,0,2,8,0,1,2,0,1,0,0,0
fam_p5_s4_r0_a42,1,5340,62,3,33,6,3,0,8,1,0,0,0,2,8,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,5400,62,3,33,6,3,0,6,3,0,0,0,2,6,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,5460,62,3,32,5,3,0,7,3,0,1,0,2,7,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,5520,63,3,32,4,3,0,8,2,1,0,1,2,8,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,5580,63,1,34,4,3,0,8,0,0,1,1,2,8,0,3,0,0,1,0,0,0
fam_p5_s4_r0_a42,1,5640,63,1,33,3,3,0,9,0,0,2,0,2,9,0,0,2,1,1,0,0,0
fam_p5_s4_r0_a42,1,5700,65,3,32,2,3,0,8,2,0,0,0,2,8,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,5760,66,4,32,2,3,0,8,2,0,0,0,2,8,0,0,0,1,1,2,0,0
fam_p5_s4_r0_a42,1,5820,69,7,31,1,3,0,9,2,0,3,0,2,9,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,5880,70,7,31,0,3,0,11,0,1,3,0,2,11,0,0,3,0,1,0,0,0
fam_p5_s4_r0_a42,1,5940,70,7,31,2,3,0,11,0,0,1,0,2,11,0,1,2,0,1,0,0,0
fam_p5_s4_r0_a42,1,6000,71,8,30,1,3,0,10,2,0,0,0,2,10,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,6060,71,8,29,1,3,0,10,3,0,1,0,2,10,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,6120,71,8,29,2,3,0,12,1,0,1,0,2,12,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,6180,72,9,28,2,3,0,13,1,0,1,0,2,13,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,6240,73,9,28,1,3,0,13,1,0,0,0,2,13,0,1,1,0,1,1,0,0
fam_p5_s4_r0_a42,1,6300,73,9,28,1,3,0,11,3,0,0,0,2,11,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,6360,73,9,27,0,3,0,12,3,0,2,0,2,12,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,6420,73,8,27,1,3,0,13,2,1,2,0,2,13,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,6480,74,7,29,1,3,0,13,0,1,0,0,2,13,0,2,1,0,1,0,0,0
fam_p5_s4_r0_a42,1,6540,74,7,28,0,3,0,13,1,0,0,0,2,13,0,2,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,6600,75,8,27,1,3,0,12,3,0,0,0,2,12,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,6660,77,9,28,1,3,0,12,2,0,2,1,2,12,0,1,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,6720,78,9,28,0,3,0,13,1,1,1,0,2,13,0,0,1,1,1,1,0,0
fam_p5_s4_r0_a42,1,6780,79,9,28,0,3,0,14,0,0,0,1,2,14,0,2,1,0,1,0,0,0
fam_p5_s4_r0_a42,1,6840,79,9,28,1,3,0,12,2,0,0,0,2,12,0,0,0,1,1,2,0,0
fam_p5_s4_r0_a42,1,6900,79,9,27,1,3,0,12,3,0,0,0,2,12,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,6960,81,10,27,0,3,0,13,2,1,0,0,2,13,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,7020,81,8,29,1,3,0,13,0,1,1,0,2,13,0,2,1,0,1,0,0,0
fam_p5_s4_r0_a42,1,7080,82,9,28,1,3,0,14,0,0,0,2,2,14,0,2,1,0,1,0,0,0
fam_p5_s4_r0_a42,1,7140,82,9,27,0,3,0,14,1,0,0,0,2,14,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,7200,83,10,27,0,3,0,13,2,0,3,0,2,13,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,7260,83,9,27,0,3,0,14,1,1,3,0,2,14,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,7320,83,9,26,0,3,0,15,1,1,3,0,2,15,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,7380,83,9,26,0,3,0,16,0,0,5,0,2,16,0,1,2,0,1,0,0,0
fam_p5_s4_r0_a42,1,7440,83,9,25,0,3,0,17,0,0,1,1,2,17,0,0,3,0,1,0,0,0
fam_p5_s4_r0_a42,1,7500,83,9,24,0,2,0,15,3,0,0,0,1,15,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,7560,83,9,23,1,3,0,16,3,0,1,0,2,16,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,7620,83,7,25,1,3,0,17,0,1,0,0,2,17,0,1,2,0,1,0,0,0
fam_p5_s4_r0_a42,1,7680,83,7,24,0,3,0,17,1,0,1,0,2,17,0,2,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,7740,83,7,23,0,2,0,18,1,0,0,0,1,18,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,7800,83,7,23,0,3,0,18,1,0,0,0,2,18,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,7860,83,6,23,0,3,0,19,0,1,1,0,2,19,0,0,2,1,1,0,0,0
fam_p5_s4_r0_a42,1,7920,83,6,22,0,2,0,18,2,0,0,1,1,18,0,1,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,7980,83,6,22,0,2,0,18,2,0,0,1,1,18,0,1,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,8040,83,4,23,0,1,0,19,0,1,1,1,0,19,0,2,1,0,1,0,0,0
fam_p5_s4_r0_a42,1,8100,83,4,22,0,2,0,20,0,0,0,0,2,20,0,2,1,1,0,0,0,0
fam_p5_s4_r0_a42,1,8160,83,4,22,0,3,0,20,0,0,0,1,2,20,0,2,0,1,1,0,0,0
fam_p5_s4_r0_a42,1,8220,83,4,22,2,3,0,20,0,0,0,0,2,20,0,1,2,0,1,0,0,0
fam_p5_s4_r0_a42,1,8280,83,4,21,1,3,0,20,1,0,0,0,2,20,0,1,0,1,1,1,0,0
fam_p5_s4_r0_a42,1,8340,83,4,20,0,3,0,21,1,0,0,0,2,21,0,0,0,2,1,1,0,0
fam_p5_s4_r0_a42,1,8400,83,3,21,0,3,0,19,2,0,0,0,2,19,0,1,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,8460,83,3,20,0,2,0,19,3,0,1,0,1,19,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,8520,83,1,21,0,1,0,20,1,2,0,0,0,20,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,8580,83,1,21,0,1,0,20,1,0,1,0,0,20,0,2,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,8640,83,0,21,0,0,0,18,3,0,2,0,0,18,0,1,0,0,0,3,0,0
fam_p5_s4_r0_a42,1,8700,83,0,21,0,0,0,17,4,0,0,0,0,17,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,8760,83,0,21,0,0,3,14,4,0,0,0,0,14,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,8820,83,0,21,0,0,4,13,4,0,1,0,0,13,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,8880,83,0,21,0,0,6,11,4,0,0,0,0,11,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,8940,83,0,21,0,1,7,10,4,0,0,1,1,10,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,9000,83,0,21,0,1,8,10,3,0,4,1,0,10,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,9060,83,0,20,0,1,11,11,0,0,3,0,1,11,0,0,3,1,0,0,0,0
fam_p5_s4_r0_a42,1,9120,83,0,20,0,1,11,10,1,0,0,0,0,10,0,0,2,0,1,1,0,0
fam_p5_s4_r0_a42,1,9180,83,0,20,0,2,11,9,2,0,1,0,1,9,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,9240,83,0,19,0,1,11,9,3,0,0,0,0,9,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,9300,83,0,18,0,1,12,10,2,0,0,0,0,10,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,9360,83,0,18,0,2,14,8,2,0,1,0,1,8,0,0,0,1,1,2,0,0
fam_p5_s4_r0_a42,1,9420,83,0,17,0,2,14,9,2,0,0,0,1,9,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,9480,83,0,17,0,2,16,7,2,0,0,0,1,7,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,9540,83,0,16,0,2,16,7,3,0,0,0,1,7,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,9600,83,0,15,0,1,18,6,3,0,0,0,0,6,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,9660,83,0,15,0,2,18,6,3,0,0,0,1,6,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,9720,83,0,14,0,1,21,5,2,0,0,0,0,5,0,0,1,0,1,2,0,0
fam_p5_s4_r0_a42,1,9780,83,0,13,0,2,21,5,3,0,0,1,1,5,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,9840,83,0,13,0,3,22,5,2,0,0,0,2,5,0,0,0,1,1,2,0,0
fam_p5_s4_r0_a42,1,9900,83,0,12,0,2,23,4,3,0,0,0,1,4,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,9960,83,0,11,0,1,24,4,3,0,0,0,0,4,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,10020,83,0,11,0,2,24,4,3,0,0,0,1,4,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,10080,83,0,10,0,1,24,5,3,0,0,0,0,5,0,0,0,0,1,3,0,0
fam_p5_s4_r0_a42,1,10140,83,0,9,0,0,24,5,4,0,0,0,0,5,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,10200,83,0,9,0,0,25,4,4,0,0,0,0,4,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,10260,83,0,9,0,0,27,2,4,0,0,0,0,2,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,10320,83,0,9,0,0,27,2,4,0,1,0,0,2,0,0,0,0,0,4,0,0
fam_p5_s4_r0_a42,1,10380,83,0,9,0,0,28,2,3,0,0,0,0,2,0,0,1,0,0,3,0,0
fam_p5_s4_r0_a42,1,10440,83,0,9,0,0,30,0,3,0,0,0,0,0,1,0,0,0,0,3,0,0
fam_p5_s4_r0_a42,1,10500,83,0,9,0,0,31,0,2,0,0,0,0,0,1,0,0,0,0,3,0,0
fam_p5_s4_r0_a42,1,10560,83,0,9,0,1,32,0,1,0,0,0,0,0,1,0,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,10620,83,0,8,0,0,33,0,1,0,0,0,0,0,2,0,0,0,0,2,0,0
fam_p5_s4_r0_a42,1,10680,83,0,8,0,0,33,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p5_s4_r0_a42,1,10740,83,0,8,0,0,33,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p5_s4_r0_a42,1,10800,83,0,8,0,0,34,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,10860,83,0,8,0,0,34,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,10920,83,0,8,0,0,34,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r0_a42,1,10980,83,0,8,0,1,34,0,0,0,0,0,0,0,3,0,0,0,1,0,0,0
fam_p5_s4_r0_a42,1,11040,83,0,8,0,3,34,0,0,0,0,0,2,0,3,0,0,0,1,0,0,0
fam_p5_s4_r0_a42,1,11100,83,0,7,0,2,34,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11160,83,0,6,0,1,35,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11220,83,0,6,0,2,35,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11280,83,0,5,1,3,36,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11340,83,0,5,2,3,36,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11400,83,0,4,1,3,37,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11460,83,0,3,0,3,38,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11520,83,0,3,0,3,38,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11580,83,0,2,0,2,39,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11640,83,0,2,0,2,39,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r0_a42,1,11700,83,0,1,0,1,39,0,2,0,0,0,0,0,1,0,0,0,1,2,0,0
fam_p5_s4_r0_a42,1,11760,83,0,0,0,0,41,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p5_s4_r0_a42,1,11820,83,0,0,0,0,41,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
```

### 18回のうち③2台・種1の timeline.csv

```csv
condition_id,seed,t_s,arrived,waiting_groups,seated_groups,pay_waiting_seat,pay_in_line,tables_free,tables_dirty_untaken,tables_clearing,board_guide,board_serve,board_call,board_pay,board_clear,staff_idle,staff_guide,staff_serve,staff_call,staff_pay,staff_clear,staff_rescue,robots_busy
fam_p1_s4_r2_a42,1,0,0,0,0,0,0,42,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,60,1,0,1,0,0,41,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,120,1,0,1,0,0,41,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,180,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,240,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,300,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,360,2,0,2,0,0,40,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,420,3,0,3,0,0,39,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,480,4,0,4,0,0,38,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,540,4,0,4,0,0,38,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,600,4,0,4,0,0,38,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,660,5,0,5,0,0,37,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,720,5,0,5,0,0,37,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,780,6,0,6,0,0,36,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,840,6,0,6,0,0,36,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,900,8,0,8,0,0,34,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0
fam_p1_s4_r2_a42,1,960,8,0,8,0,0,34,0,0,0,0,0,0,0,4,0,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1020,9,0,9,0,0,33,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1080,11,0,11,0,0,31,0,0,0,0,0,0,0,1,3,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1140,15,0,15,0,0,27,0,0,0,0,0,0,0,0,4,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1200,16,0,16,0,0,26,0,0,0,0,0,0,0,2,2,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1260,16,0,16,0,0,26,0,0,0,0,0,0,0,4,0,0,0,0,0,0,2
fam_p1_s4_r2_a42,1,1320,17,0,17,0,0,25,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1380,17,0,17,0,0,25,0,0,0,0,0,0,0,4,0,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1440,19,0,19,0,0,23,0,0,0,0,0,0,0,2,2,0,0,0,0,0,2
fam_p1_s4_r2_a42,1,1500,20,0,20,0,0,22,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1560,21,0,21,0,0,21,0,0,0,0,0,0,0,1,1,0,2,0,0,0,2
fam_p1_s4_r2_a42,1,1620,22,0,22,0,0,20,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p1_s4_r2_a42,1,1680,23,0,23,0,0,19,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p1_s4_r2_a42,1,1740,26,0,26,0,0,16,0,0,0,0,0,0,0,0,4,0,0,0,0,0,2
fam_p1_s4_r2_a42,1,1800,26,0,26,0,0,16,0,0,0,0,0,0,0,2,1,1,0,0,0,0,2
fam_p1_s4_r2_a42,1,1860,28,0,28,0,0,14,0,0,0,0,0,0,0,1,2,1,0,0,0,0,2
fam_p1_s4_r2_a42,1,1920,29,0,29,0,0,13,0,0,0,0,0,0,0,1,2,1,0,0,0,0,2
fam_p1_s4_r2_a42,1,1980,29,0,29,0,0,13,0,0,0,0,0,0,0,3,0,1,0,0,0,0,2
fam_p1_s4_r2_a42,1,2040,29,0,29,0,0,13,0,0,0,0,0,0,0,3,0,1,0,0,0,0,2
fam_p1_s4_r2_a42,1,2100,29,0,29,0,0,13,0,0,0,0,0,0,0,2,0,2,0,0,0,0,2
fam_p1_s4_r2_a42,1,2160,32,0,32,0,0,10,0,0,0,0,0,0,0,1,3,0,0,0,0,0,2
fam_p1_s4_r2_a42,1,2220,33,0,33,0,0,9,0,0,0,0,0,0,0,2,1,1,0,0,0,0,2
fam_p1_s4_r2_a42,1,2280,33,0,33,0,0,9,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p1_s4_r2_a42,1,2340,33,0,33,0,0,9,0,0,0,0,0,0,0,2,0,0,1,0,0,1,2
fam_p1_s4_r2_a42,1,2400,33,0,33,0,0,9,0,0,0,0,0,0,0,0,0,3,0,0,0,1,2
fam_p1_s4_r2_a42,1,2460,34,0,34,0,0,8,0,0,1,3,0,0,0,0,0,4,0,0,0,0,2
fam_p1_s4_r2_a42,1,2520,34,0,34,0,0,8,0,0,1,7,0,0,0,0,0,4,0,0,0,0,2
fam_p1_s4_r2_a42,1,2580,34,0,34,0,0,8,0,0,1,7,0,0,0,0,0,4,0,0,0,0,2
fam_p1_s4_r2_a42,1,2640,34,0,34,0,0,8,0,0,1,9,1,0,0,0,0,4,0,0,0,0,2
fam_p1_s4_r2_a42,1,2700,35,0,35,0,0,7,0,0,1,8,1,0,0,0,1,3,0,0,0,0,2
fam_p1_s4_r2_a42,1,2760,35,0,35,0,0,7,0,0,1,6,1,0,0,0,0,4,0,0,0,0,2
fam_p1_s4_r2_a42,1,2820,36,0,36,0,0,6,0,0,2,4,1,0,0,0,0,4,0,0,0,0,2
fam_p1_s4_r2_a42,1,2880,37,0,37,0,1,5,0,0,3,3,0,1,0,0,0,4,0,0,0,0,2
fam_p1_s4_r2_a42,1,2940,37,0,37,0,1,5,0,0,3,5,0,1,0,0,0,4,0,0,0,0,2
fam_p1_s4_r2_a42,1,3000,37,0,37,0,3,5,0,0,2,6,2,3,0,0,1,3,0,0,0,0,2
fam_p1_s4_r2_a42,1,3060,37,0,37,0,3,5,0,0,1,6,2,3,0,0,2,2,0,0,0,0,2
fam_p1_s4_r2_a42,1,3120,37,0,37,0,3,5,0,0,0,5,2,2,0,0,2,1,0,1,0,0,2
fam_p1_s4_r2_a42,1,3180,37,0,37,0,3,5,0,0,0,2,2,2,0,0,1,2,0,1,0,0,2
fam_p1_s4_r2_a42,1,3240,37,0,36,0,3,5,1,0,0,2,0,2,1,0,0,1,2,1,0,0,2
fam_p1_s4_r2_a42,1,3300,37,0,36,0,3,5,0,1,0,0,0,2,0,1,0,1,0,1,1,0,1
fam_p1_s4_r2_a42,1,3360,37,0,35,0,3,5,0,2,0,0,0,2,0,1,0,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,3420,37,0,34,0,2,6,0,2,0,0,0,1,0,0,0,0,1,1,2,0,2
fam_p1_s4_r2_a42,1,3480,40,0,37,1,3,4,0,1,1,0,2,2,0,0,2,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,3540,40,0,36,0,3,5,1,0,0,0,2,2,1,0,1,0,2,1,0,0,1
fam_p1_s4_r2_a42,1,3600,43,0,38,1,3,2,2,0,0,0,1,2,2,0,3,0,0,1,0,0,2
fam_p1_s4_r2_a42,1,3660,44,1,38,1,3,2,2,0,0,0,1,2,2,0,2,0,1,1,0,0,1
fam_p1_s4_r2_a42,1,3720,45,1,38,0,3,1,1,2,0,0,0,2,1,0,1,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,3780,47,2,38,0,3,0,2,2,0,0,0,2,2,0,1,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,3840,47,2,38,1,3,0,2,2,0,0,0,2,2,0,1,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,3900,47,0,39,2,3,0,2,1,0,0,0,2,2,0,2,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,3960,48,0,39,2,3,0,0,3,1,0,0,2,0,0,0,0,0,1,3,0,1
fam_p1_s4_r2_a42,1,4020,49,1,39,4,3,0,0,3,1,0,0,2,0,0,0,0,0,1,3,0,1
fam_p1_s4_r2_a42,1,4080,49,0,39,3,3,1,1,1,1,0,0,2,1,0,1,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,4140,50,0,40,3,3,1,0,1,0,0,0,2,0,0,2,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,4200,50,0,39,2,3,1,1,1,0,0,0,2,1,0,1,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,4260,50,0,38,1,3,1,0,3,0,0,0,2,0,0,0,0,0,1,3,0,1
fam_p1_s4_r2_a42,1,4320,51,0,39,2,3,1,0,2,0,0,0,2,0,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,4380,52,0,39,1,3,2,0,1,0,0,0,2,0,0,1,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,4440,52,0,39,1,3,2,0,1,0,0,1,2,0,0,1,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,4500,52,0,38,1,3,3,0,1,0,0,0,2,0,0,0,0,2,1,1,0,2
fam_p1_s4_r2_a42,1,4560,53,0,38,0,3,2,0,2,0,0,0,2,0,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,4620,54,0,39,1,3,1,0,2,0,0,0,2,0,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,4680,54,0,38,1,3,1,0,3,0,0,0,2,0,0,0,0,0,1,3,0,2
fam_p1_s4_r2_a42,1,4740,54,0,37,2,3,2,0,3,0,0,0,2,0,0,0,0,0,1,3,0,2
fam_p1_s4_r2_a42,1,4800,54,0,37,4,3,2,1,2,0,0,0,2,1,0,0,1,0,1,2,0,1
fam_p1_s4_r2_a42,1,4860,54,0,37,4,3,2,0,3,0,0,0,2,0,0,0,0,0,1,3,0,2
fam_p1_s4_r2_a42,1,4920,55,0,38,4,3,2,0,2,0,0,1,2,0,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,4980,55,0,37,5,3,2,1,2,0,0,1,2,1,0,0,0,1,1,2,0,1
fam_p1_s4_r2_a42,1,5040,55,0,36,5,3,3,1,2,0,0,0,2,1,0,0,0,1,1,2,0,2
fam_p1_s4_r2_a42,1,5100,58,0,39,6,3,0,1,2,2,0,0,2,1,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,5160,59,0,39,6,3,0,2,1,1,0,2,2,2,0,2,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,5220,59,0,38,5,3,1,3,0,0,0,2,2,3,0,1,0,2,1,0,0,2
fam_p1_s4_r2_a42,1,5280,61,1,39,5,3,0,2,1,0,0,0,2,2,0,1,0,1,1,1,0,1
fam_p1_s4_r2_a42,1,5340,62,2,38,5,3,0,1,3,0,0,0,2,1,0,0,0,0,1,3,0,1
fam_p1_s4_r2_a42,1,5400,62,1,38,5,3,0,2,2,0,0,0,2,2,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,5460,62,0,39,6,3,0,2,1,0,0,0,2,2,0,2,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,5520,63,0,39,7,3,0,1,2,0,0,0,2,1,0,1,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,5580,63,0,38,7,3,0,1,3,0,0,1,2,1,0,0,0,0,1,3,0,1
fam_p1_s4_r2_a42,1,5640,63,0,38,7,3,1,1,2,0,0,0,2,1,0,0,0,1,1,2,0,1
fam_p1_s4_r2_a42,1,5700,65,0,39,6,3,1,0,2,1,0,0,2,0,0,1,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,5760,66,0,39,5,3,0,1,2,1,0,0,2,1,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,5820,69,3,39,5,3,0,1,2,0,0,1,2,1,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,5880,70,2,40,5,3,0,2,0,0,0,0,2,2,0,3,0,0,1,0,0,2
fam_p1_s4_r2_a42,1,5940,70,2,39,4,3,0,1,2,0,1,0,2,1,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,6000,71,3,39,4,3,0,0,3,0,0,0,2,0,0,0,0,0,1,3,0,2
fam_p1_s4_r2_a42,1,6060,71,3,38,3,3,0,1,3,0,0,0,2,1,0,0,0,0,1,3,0,2
fam_p1_s4_r2_a42,1,6120,71,1,39,4,3,0,2,1,0,0,0,2,2,0,2,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,6180,72,1,40,4,3,0,0,2,0,0,0,2,0,0,1,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,6240,73,2,39,4,3,0,0,3,0,1,0,2,0,0,0,0,0,1,3,0,2
fam_p1_s4_r2_a42,1,6300,73,0,40,4,3,0,1,1,0,0,0,2,1,0,2,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,6360,73,0,40,5,3,1,0,1,0,0,0,2,0,1,1,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,6420,73,0,39,4,3,1,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,6480,74,0,39,4,3,0,1,2,0,0,1,2,1,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,6540,74,0,39,4,3,2,0,1,0,0,0,2,0,1,0,0,1,1,1,0,1
fam_p1_s4_r2_a42,1,6600,75,0,39,5,3,1,0,2,0,0,1,2,0,0,1,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,6660,77,0,41,5,3,0,0,1,0,0,0,2,0,0,2,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,6720,78,0,41,5,3,0,0,1,0,0,1,2,0,0,2,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,6780,79,1,40,4,3,0,0,2,0,0,0,2,0,0,1,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,6840,79,0,41,5,3,0,0,1,0,0,0,2,0,1,1,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,6900,79,0,40,5,3,1,0,1,0,0,0,2,0,2,0,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,6960,81,1,41,6,3,0,0,1,0,0,0,2,0,1,1,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,7020,81,0,41,5,3,0,0,1,0,0,0,2,0,0,1,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,7080,82,1,40,4,3,0,0,2,0,0,0,2,0,0,0,0,1,1,2,0,2
fam_p1_s4_r2_a42,1,7140,82,0,41,5,3,0,0,1,0,0,0,2,0,1,1,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,7200,83,0,41,4,3,0,0,1,0,0,0,2,0,1,1,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,7260,83,0,41,7,3,0,0,1,0,0,0,2,0,0,1,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,7320,83,0,40,8,3,1,1,0,0,0,0,2,1,0,0,3,0,1,0,0,2
fam_p1_s4_r2_a42,1,7380,83,0,39,7,3,1,0,2,0,0,0,2,0,1,0,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,7440,83,0,39,7,3,2,0,1,0,0,0,2,0,1,0,0,1,1,1,0,1
fam_p1_s4_r2_a42,1,7500,83,0,38,6,3,2,0,2,0,0,0,2,0,1,0,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,7560,83,0,38,7,3,3,0,1,0,0,0,2,0,1,0,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,7620,83,0,37,6,3,4,0,1,0,0,0,2,0,1,0,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,7680,83,0,36,6,3,4,0,2,0,0,0,2,0,1,0,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,7740,83,0,36,6,3,4,0,2,0,0,0,2,0,0,0,0,1,1,2,0,2
fam_p1_s4_r2_a42,1,7800,83,0,35,6,3,6,0,1,0,0,0,2,0,0,0,0,2,1,1,0,2
fam_p1_s4_r2_a42,1,7860,83,0,35,6,3,6,0,1,0,0,0,2,0,2,0,0,0,1,1,0,2
fam_p1_s4_r2_a42,1,7920,83,0,34,6,3,7,0,1,0,0,0,2,0,1,0,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,7980,83,0,33,6,3,7,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,8040,83,0,33,6,3,8,0,1,0,0,0,2,0,2,0,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,8100,83,0,32,6,3,8,0,2,0,0,0,2,0,0,0,0,0,1,2,1,1
fam_p1_s4_r2_a42,1,8160,83,0,32,7,3,9,0,1,0,0,0,2,0,1,0,0,0,1,1,1,2
fam_p1_s4_r2_a42,1,8220,83,0,31,6,3,9,0,2,0,0,0,2,0,1,0,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,8280,83,0,31,6,3,9,0,2,0,0,0,2,0,1,0,0,0,1,2,0,2
fam_p1_s4_r2_a42,1,8340,83,0,31,7,3,9,0,2,0,0,0,2,0,0,0,0,0,1,2,1,1
fam_p1_s4_r2_a42,1,8400,83,0,31,7,3,10,0,1,0,0,0,2,0,1,0,0,0,1,1,1,1
fam_p1_s4_r2_a42,1,8460,83,0,30,7,3,11,0,1,0,0,0,2,0,1,0,0,1,1,1,0,2
fam_p1_s4_r2_a42,1,8520,83,0,29,9,3,11,0,2,0,0,0,2,0,0,0,0,1,1,2,0,1
fam_p1_s4_r2_a42,1,8580,83,0,29,9,3,12,0,1,0,0,0,2,0,2,0,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,8640,83,0,28,8,3,12,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,8700,83,0,28,8,3,13,0,1,0,0,0,2,0,2,0,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,8760,83,0,27,10,3,13,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,8820,83,0,26,9,3,14,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,8880,83,0,26,9,3,14,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,8940,83,0,25,8,3,15,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,9000,83,0,25,9,3,16,0,1,0,0,0,2,0,2,0,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,9060,83,0,24,10,3,16,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,9120,83,0,23,9,3,17,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,9180,83,0,23,10,3,18,0,1,0,0,0,2,0,2,0,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,9240,83,0,22,9,3,18,0,2,0,0,0,2,0,0,0,0,0,1,2,1,1
fam_p1_s4_r2_a42,1,9300,83,0,22,10,3,19,0,1,0,0,0,2,0,2,0,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,9360,83,0,21,9,3,19,0,2,0,0,0,2,0,0,0,0,1,1,2,0,1
fam_p1_s4_r2_a42,1,9420,83,0,20,9,3,20,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,9480,83,0,20,9,3,21,0,1,0,0,0,2,0,1,0,0,0,1,1,1,1
fam_p1_s4_r2_a42,1,9540,83,0,19,9,3,21,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p1_s4_r2_a42,1,9600,83,0,19,9,3,22,0,1,0,0,0,2,0,2,0,0,0,1,1,0,1
fam_p1_s4_r2_a42,1,9660,83,0,18,9,3,22,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,9720,83,0,18,11,3,23,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,9780,83,0,17,10,3,23,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,9840,83,0,16,10,3,24,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,9900,83,0,16,11,3,25,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,9960,83,0,15,10,3,25,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,10020,83,0,15,12,3,26,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10080,83,0,14,11,3,26,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,10140,83,0,13,10,3,27,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,10200,83,0,13,10,3,28,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10260,83,0,12,9,3,28,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,10320,83,0,12,9,3,29,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10380,83,0,11,8,3,30,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10440,83,0,10,7,3,30,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,10500,83,0,10,7,3,31,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10560,83,0,9,6,3,32,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10620,83,0,9,6,3,32,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10680,83,0,8,5,3,33,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10740,83,0,8,5,3,33,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10800,83,0,7,4,3,34,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10860,83,0,6,3,3,34,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,10920,83,0,6,3,3,35,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,10980,83,0,5,2,3,36,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,11040,83,0,5,2,3,36,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,11100,83,0,4,1,3,37,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,11160,83,0,3,0,3,37,0,2,0,0,0,2,0,1,0,0,0,1,2,0,0
fam_p1_s4_r2_a42,1,11220,83,0,3,0,3,38,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,11280,83,0,2,0,2,39,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,11340,83,0,2,0,2,39,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,11400,83,0,1,0,1,40,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p1_s4_r2_a42,1,11460,83,0,0,0,0,40,0,2,0,0,0,0,0,2,0,0,0,0,2,0,0
fam_p1_s4_r2_a42,1,11520,83,0,0,0,0,41,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
```

### 18回のうち⑦2台・種1の timeline.csv

```csv
condition_id,seed,t_s,arrived,waiting_groups,seated_groups,pay_waiting_seat,pay_in_line,tables_free,tables_dirty_untaken,tables_clearing,board_guide,board_serve,board_call,board_pay,board_clear,staff_idle,staff_guide,staff_serve,staff_call,staff_pay,staff_clear,staff_rescue,robots_busy
fam_p5_s4_r2_a42,1,0,0,0,0,0,0,42,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,60,1,0,1,0,0,41,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,120,1,0,1,0,0,41,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,180,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,240,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,300,1,0,1,0,0,41,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,360,2,0,2,0,0,40,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,420,3,0,3,0,0,39,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,480,4,0,4,0,0,38,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,540,4,0,4,0,0,38,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,600,4,0,4,0,0,38,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,660,5,0,5,0,0,37,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,720,5,0,5,0,0,37,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,780,6,0,6,0,0,36,0,0,0,0,0,0,0,3,1,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,840,6,0,6,0,0,36,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,900,8,0,8,0,0,34,0,0,0,0,0,0,0,2,2,0,0,0,0,0,0
fam_p5_s4_r2_a42,1,960,8,0,8,0,0,34,0,0,0,0,0,0,0,4,0,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,1020,9,0,9,0,0,33,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,1080,11,0,11,0,0,31,0,0,0,0,0,0,0,1,3,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,1140,15,0,15,0,0,27,0,0,0,0,0,0,0,0,4,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,1200,16,0,16,0,0,26,0,0,0,0,0,0,0,2,2,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,1260,16,0,16,0,0,26,0,0,0,0,0,0,0,4,0,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,1320,17,0,17,0,0,25,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,1380,17,0,17,0,0,25,0,0,0,0,0,0,0,4,0,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,1440,19,0,19,0,0,23,0,0,0,0,0,0,0,2,2,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,1500,20,0,20,0,0,22,0,0,0,0,0,0,0,3,1,0,0,0,0,0,1
fam_p5_s4_r2_a42,1,1560,21,0,21,0,0,21,0,0,0,0,0,0,0,1,1,0,2,0,0,0,2
fam_p5_s4_r2_a42,1,1620,22,0,22,0,0,20,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,1680,23,0,23,0,0,19,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,1740,26,0,26,0,0,16,0,0,0,0,0,0,0,0,4,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,1800,26,0,26,0,0,16,0,0,0,0,0,0,0,0,3,1,0,0,0,0,2
fam_p5_s4_r2_a42,1,1860,28,0,28,0,0,14,0,0,2,0,0,0,0,0,2,2,0,0,0,0,2
fam_p5_s4_r2_a42,1,1920,29,0,29,0,0,13,0,0,1,0,0,0,0,0,2,2,0,0,0,0,2
fam_p5_s4_r2_a42,1,1980,29,0,29,0,0,13,0,0,0,0,0,0,0,0,2,2,0,0,0,0,2
fam_p5_s4_r2_a42,1,2040,29,0,29,0,0,13,0,0,0,0,0,0,0,3,0,1,0,0,0,0,2
fam_p5_s4_r2_a42,1,2100,29,0,29,0,0,13,0,0,0,0,0,0,0,2,0,2,0,0,0,0,2
fam_p5_s4_r2_a42,1,2160,32,0,32,0,0,10,0,0,0,0,0,0,0,1,3,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2220,33,0,33,0,0,9,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2280,33,0,33,0,0,9,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2340,33,0,33,0,0,9,0,0,0,0,0,0,0,2,1,0,1,0,0,0,2
fam_p5_s4_r2_a42,1,2400,33,0,33,0,0,9,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2460,34,0,34,0,0,8,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2520,34,0,34,0,0,8,0,0,0,0,0,0,0,2,1,1,0,0,0,0,2
fam_p5_s4_r2_a42,1,2580,34,0,34,0,0,8,0,0,0,0,0,0,0,1,0,3,0,0,0,0,2
fam_p5_s4_r2_a42,1,2640,34,0,34,0,0,8,0,0,0,0,0,0,0,4,0,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2700,35,0,35,0,0,7,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2760,35,0,35,0,0,7,0,0,0,0,0,0,0,4,0,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2820,36,0,36,0,0,6,0,0,0,0,0,0,0,3,1,0,0,0,0,0,2
fam_p5_s4_r2_a42,1,2880,37,0,37,0,1,5,0,0,0,0,0,0,0,2,1,0,0,1,0,0,2
fam_p5_s4_r2_a42,1,2940,37,0,37,0,1,5,0,0,0,0,0,0,0,2,1,0,0,1,0,0,2
fam_p5_s4_r2_a42,1,3000,37,0,36,0,2,5,0,1,0,0,0,1,0,1,0,0,1,1,1,0,2
fam_p5_s4_r2_a42,1,3060,37,0,36,0,2,5,0,1,0,0,0,1,0,2,0,0,0,1,1,0,2
fam_p5_s4_r2_a42,1,3120,37,0,35,0,1,6,0,1,0,0,0,0,0,2,0,0,0,1,1,0,2
fam_p5_s4_r2_a42,1,3180,37,0,35,0,1,6,0,1,0,0,0,0,0,1,0,0,1,1,1,0,2
fam_p5_s4_r2_a42,1,3240,37,0,34,0,1,7,0,1,0,0,0,0,0,1,0,0,1,1,1,0,2
fam_p5_s4_r2_a42,1,3300,37,0,33,0,0,8,0,1,0,0,0,0,0,1,0,0,2,0,1,0,2
fam_p5_s4_r2_a42,1,3360,37,0,33,0,1,8,0,1,0,0,0,0,0,2,0,0,0,1,1,0,2
fam_p5_s4_r2_a42,1,3420,37,0,32,0,0,8,0,2,0,0,0,0,0,1,0,0,1,0,2,0,1
fam_p5_s4_r2_a42,1,3480,40,0,35,0,2,6,0,1,1,0,1,1,0,0,2,0,0,1,1,0,1
fam_p5_s4_r2_a42,1,3540,40,0,35,0,2,6,0,1,0,0,0,1,0,0,1,0,1,1,1,0,2
fam_p5_s4_r2_a42,1,3600,43,0,37,0,3,3,1,1,1,0,1,2,1,0,2,0,0,1,1,0,1
fam_p5_s4_r2_a42,1,3660,44,0,37,0,2,3,2,0,0,0,0,1,2,0,2,0,1,1,0,0,1
fam_p5_s4_r2_a42,1,3720,45,0,38,0,2,2,0,2,1,0,0,1,0,0,1,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,3780,47,0,39,0,2,0,1,2,2,0,0,1,1,0,1,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,3840,47,0,38,0,3,1,2,1,0,0,1,2,2,0,2,0,0,1,1,0,1
fam_p5_s4_r2_a42,1,3900,47,0,38,1,3,2,1,1,0,0,0,2,1,0,1,0,1,1,1,0,2
fam_p5_s4_r2_a42,1,3960,48,0,38,0,3,1,1,2,1,0,1,2,1,0,0,0,1,1,2,0,2
fam_p5_s4_r2_a42,1,4020,49,0,38,2,3,1,2,1,1,0,0,2,2,0,1,0,1,1,1,0,1
fam_p5_s4_r2_a42,1,4080,49,0,38,3,3,1,2,1,0,0,1,2,2,0,2,0,0,1,1,0,2
fam_p5_s4_r2_a42,1,4140,50,0,38,2,3,1,1,2,1,0,0,2,1,0,0,0,1,1,2,0,2
fam_p5_s4_r2_a42,1,4200,50,0,37,1,3,1,2,2,0,0,0,2,2,0,1,0,0,1,2,0,2
fam_p5_s4_r2_a42,1,4260,50,0,37,2,3,2,0,3,0,0,0,2,0,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,4320,51,0,37,2,3,1,1,3,1,0,2,2,1,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,4380,52,0,37,1,3,2,2,1,1,0,1,2,2,0,1,0,1,1,1,0,2
fam_p5_s4_r2_a42,1,4440,52,0,37,4,3,3,1,1,0,0,0,2,1,0,1,0,1,1,1,0,2
fam_p5_s4_r2_a42,1,4500,52,0,36,4,3,3,0,3,0,0,0,2,0,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,4560,53,0,36,4,3,2,1,3,1,0,0,2,1,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,4620,54,0,37,6,3,3,1,1,0,0,0,2,1,0,2,0,0,1,1,0,2
fam_p5_s4_r2_a42,1,4680,54,0,36,6,3,4,1,1,0,0,0,2,1,0,1,0,1,1,1,0,2
fam_p5_s4_r2_a42,1,4740,54,0,35,5,3,4,0,3,0,0,0,2,0,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,4800,54,0,35,5,3,4,0,3,0,0,0,2,0,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,4860,54,0,34,4,3,6,0,2,0,0,0,2,0,1,0,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,4920,55,0,34,4,3,6,1,1,0,0,1,2,1,0,1,1,0,1,1,0,1
fam_p5_s4_r2_a42,1,4980,55,0,34,5,3,6,0,2,0,0,2,2,0,0,0,0,1,1,2,0,1
fam_p5_s4_r2_a42,1,5040,55,0,33,4,3,6,1,2,0,0,0,2,1,0,0,0,1,1,2,0,1
fam_p5_s4_r2_a42,1,5100,58,0,35,4,3,4,1,2,2,0,0,2,1,0,1,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,5160,59,0,36,5,3,4,1,1,1,0,1,2,1,0,2,0,0,1,1,0,2
fam_p5_s4_r2_a42,1,5220,59,0,35,6,3,5,0,2,0,0,0,2,0,1,0,0,0,1,2,0,2
fam_p5_s4_r2_a42,1,5280,61,0,36,6,3,3,1,2,1,0,0,2,1,0,1,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,5340,62,0,37,6,3,2,1,2,1,0,0,2,1,0,1,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,5400,62,0,36,6,3,2,3,1,0,0,1,2,3,0,1,0,0,1,1,1,2
fam_p5_s4_r2_a42,1,5460,62,0,35,5,3,2,3,2,0,0,0,2,3,0,1,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,5520,63,0,36,5,3,1,3,2,1,0,0,2,3,0,1,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,5580,63,0,35,4,3,1,4,2,0,0,1,2,4,0,1,0,0,1,2,0,1
fam_p5_s4_r2_a42,1,5640,63,0,34,3,3,2,3,3,0,0,0,2,3,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,5700,65,0,36,3,3,0,3,3,2,0,0,2,3,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,5760,66,0,36,2,3,1,4,1,1,0,1,2,4,0,2,0,0,1,1,0,2
fam_p5_s4_r2_a42,1,5820,69,1,37,1,3,0,5,0,1,0,0,2,5,0,2,0,1,1,0,0,2
fam_p5_s4_r2_a42,1,5880,70,2,37,2,3,0,4,1,0,1,0,2,4,0,2,0,0,1,1,0,1
fam_p5_s4_r2_a42,1,5940,70,2,36,2,3,0,5,1,0,0,0,2,5,0,0,2,0,1,1,0,2
fam_p5_s4_r2_a42,1,6000,71,2,36,2,3,0,4,2,0,0,0,2,4,0,1,0,0,1,2,0,2
fam_p5_s4_r2_a42,1,6060,71,2,36,3,3,0,3,3,0,0,0,2,3,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,6120,71,2,35,3,3,0,4,3,0,0,0,2,4,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,6180,72,2,35,2,3,0,5,2,0,0,0,2,5,0,1,0,0,1,2,0,2
fam_p5_s4_r2_a42,1,6240,73,2,36,2,3,0,5,1,0,2,0,2,5,0,1,0,0,1,1,1,2
fam_p5_s4_r2_a42,1,6300,73,2,35,2,3,0,6,1,0,3,0,2,6,0,0,1,0,1,1,1,2
fam_p5_s4_r2_a42,1,6360,73,2,35,2,3,0,7,0,0,1,0,2,7,0,0,3,0,1,0,0,2
fam_p5_s4_r2_a42,1,6420,73,2,34,2,3,0,8,0,0,0,0,2,8,0,0,3,0,1,0,0,2
fam_p5_s4_r2_a42,1,6480,74,3,33,1,3,0,9,0,0,0,0,2,9,0,0,2,1,1,0,0,2
fam_p5_s4_r2_a42,1,6540,74,3,33,3,3,0,7,2,0,1,0,2,7,0,0,0,1,1,2,0,2
fam_p5_s4_r2_a42,1,6600,75,4,32,2,3,0,9,1,0,3,1,2,9,0,0,0,1,1,1,1,2
fam_p5_s4_r2_a42,1,6660,77,6,31,2,3,0,11,0,0,4,1,2,11,0,0,1,1,1,0,1,1
fam_p5_s4_r2_a42,1,6720,78,7,31,2,3,0,11,0,0,3,0,2,11,0,0,2,0,1,0,1,2
fam_p5_s4_r2_a42,1,6780,79,8,30,2,3,0,12,0,0,2,1,2,12,0,0,3,0,1,0,0,2
fam_p5_s4_r2_a42,1,6840,79,8,29,1,3,0,13,0,0,2,1,2,13,0,0,3,0,1,0,0,1
fam_p5_s4_r2_a42,1,6900,79,8,29,2,3,0,13,0,0,2,1,2,13,0,0,3,0,1,0,0,1
fam_p5_s4_r2_a42,1,6960,81,10,28,2,3,0,14,0,0,0,1,3,14,0,0,4,0,0,0,0,2
fam_p5_s4_r2_a42,1,7020,81,10,28,2,3,0,14,0,0,0,1,2,14,0,0,2,1,1,0,0,2
fam_p5_s4_r2_a42,1,7080,82,11,27,1,3,0,14,1,0,0,1,2,14,0,0,1,1,1,1,0,2
fam_p5_s4_r2_a42,1,7140,82,11,27,1,3,0,13,2,0,0,0,2,13,0,0,0,1,1,2,0,1
fam_p5_s4_r2_a42,1,7200,83,12,26,2,3,0,13,3,0,0,0,2,13,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,7260,83,11,26,3,3,0,14,2,0,0,0,2,14,0,1,0,0,1,2,0,2
fam_p5_s4_r2_a42,1,7320,83,9,28,4,3,0,13,1,0,0,0,2,13,0,2,0,0,1,1,0,1
fam_p5_s4_r2_a42,1,7380,83,9,27,4,3,0,12,3,0,0,0,2,12,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,7440,83,9,26,3,3,0,13,3,0,0,1,2,13,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,7500,83,6,29,3,3,0,13,0,1,0,0,2,13,0,2,0,1,1,0,0,1
fam_p5_s4_r2_a42,1,7560,83,6,28,2,3,0,14,0,0,0,0,2,14,0,2,0,1,1,0,0,1
fam_p5_s4_r2_a42,1,7620,83,6,27,3,3,0,13,2,0,0,1,2,13,0,0,0,1,1,2,0,1
fam_p5_s4_r2_a42,1,7680,83,6,27,3,3,0,13,2,0,0,0,2,13,0,0,0,1,1,2,0,1
fam_p5_s4_r2_a42,1,7740,83,4,28,2,3,0,14,0,0,0,0,2,14,0,2,0,1,1,0,0,1
fam_p5_s4_r2_a42,1,7800,83,4,27,2,3,0,13,2,0,0,0,2,13,0,0,0,1,1,2,0,1
fam_p5_s4_r2_a42,1,7860,83,4,27,3,3,0,13,2,0,0,0,2,13,0,0,0,1,1,2,0,1
fam_p5_s4_r2_a42,1,7920,83,2,28,2,3,0,13,1,0,0,1,2,13,0,2,0,0,1,1,0,2
fam_p5_s4_r2_a42,1,7980,83,2,27,1,3,0,13,2,0,0,0,2,13,0,0,0,1,1,2,0,2
fam_p5_s4_r2_a42,1,8040,83,1,28,2,3,0,12,2,0,0,0,2,12,0,1,0,0,1,2,0,2
fam_p5_s4_r2_a42,1,8100,83,0,28,1,3,0,12,2,0,0,0,2,12,0,1,0,0,1,2,0,2
fam_p5_s4_r2_a42,1,8160,83,0,27,0,3,1,11,3,0,0,0,2,11,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,8220,83,0,27,0,3,3,9,3,0,0,0,2,9,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,8280,83,0,26,1,3,3,10,3,0,0,0,2,10,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,8340,83,0,25,0,3,6,8,3,0,0,0,2,8,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,8400,83,0,25,0,3,6,8,3,0,0,0,2,8,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,8460,83,0,24,0,3,8,7,3,0,0,0,2,7,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,8520,83,0,23,0,2,9,7,3,0,0,0,1,7,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,8580,83,0,23,0,3,10,6,3,0,0,0,2,6,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,8640,83,0,22,0,2,11,6,3,0,0,0,1,6,0,0,0,0,1,3,0,2
fam_p5_s4_r2_a42,1,8700,83,0,21,0,1,12,7,2,0,0,0,0,7,0,0,0,1,1,2,0,2
fam_p5_s4_r2_a42,1,8760,83,0,21,0,3,14,5,2,0,0,0,2,5,0,0,0,1,1,2,0,2
fam_p5_s4_r2_a42,1,8820,83,0,20,0,2,14,5,3,0,0,0,1,5,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,8880,83,0,19,0,1,14,6,3,0,0,0,0,6,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,8940,83,0,19,0,2,16,4,3,0,0,0,1,4,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9000,83,0,18,0,2,17,4,3,0,0,0,1,4,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9060,83,0,18,0,3,18,3,3,0,0,0,2,3,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9120,83,0,17,0,3,18,4,3,0,0,0,2,4,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9180,83,0,16,0,3,20,4,2,0,0,0,2,4,0,0,0,0,1,2,1,1
fam_p5_s4_r2_a42,1,9240,83,0,16,1,3,21,2,3,0,0,0,2,2,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9300,83,0,15,0,3,21,3,3,0,0,0,2,3,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9360,83,0,14,0,2,22,5,1,0,0,0,1,5,0,0,0,1,1,1,1,1
fam_p5_s4_r2_a42,1,9420,83,0,14,0,2,22,3,3,0,0,0,1,3,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9480,83,0,13,0,1,22,4,3,0,0,0,0,4,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9540,83,0,12,0,0,22,4,4,0,0,0,0,4,0,0,0,0,0,4,0,2
fam_p5_s4_r2_a42,1,9600,83,0,12,0,0,23,3,4,0,0,1,0,3,0,0,0,0,0,4,0,1
fam_p5_s4_r2_a42,1,9660,83,0,12,0,0,24,3,3,0,0,0,0,3,0,0,0,1,0,3,0,1
fam_p5_s4_r2_a42,1,9720,83,0,12,0,1,25,2,3,0,0,0,1,2,0,0,0,0,0,3,1,1
fam_p5_s4_r2_a42,1,9780,83,0,12,0,3,25,2,3,0,0,0,2,2,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9840,83,0,11,0,2,26,3,2,0,0,0,1,3,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9900,83,0,11,0,2,26,3,2,0,0,0,1,3,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,9960,83,0,10,0,1,27,2,3,0,0,0,0,2,0,0,0,0,1,3,0,1
fam_p5_s4_r2_a42,1,10020,83,0,9,0,0,27,3,3,0,0,0,0,3,0,0,0,0,0,3,1,1
fam_p5_s4_r2_a42,1,10080,83,0,9,0,0,27,2,4,0,0,0,0,2,0,0,0,0,0,4,0,1
fam_p5_s4_r2_a42,1,10140,83,0,9,0,0,27,2,4,0,0,0,0,2,0,0,0,0,0,4,0,1
fam_p5_s4_r2_a42,1,10200,83,0,9,0,0,28,1,4,0,0,1,0,1,0,0,0,0,0,4,0,0
fam_p5_s4_r2_a42,1,10260,83,0,9,0,0,31,0,2,0,0,0,0,0,2,0,0,0,0,2,0,0
fam_p5_s4_r2_a42,1,10320,83,0,9,0,1,31,0,2,0,0,0,0,0,0,0,0,1,1,2,0,0
fam_p5_s4_r2_a42,1,10380,83,0,8,0,0,33,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p5_s4_r2_a42,1,10440,83,0,8,0,1,33,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,10500,83,0,8,0,1,33,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,10560,83,0,7,0,1,33,0,2,0,0,0,0,0,1,0,0,0,1,2,0,0
fam_p5_s4_r2_a42,1,10620,83,0,6,0,2,34,0,2,0,0,0,1,0,1,0,0,0,1,2,0,0
fam_p5_s4_r2_a42,1,10680,83,0,6,0,2,35,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,10740,83,0,5,0,2,36,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,10800,83,0,5,0,3,36,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,10860,83,0,4,1,3,37,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,10920,83,0,4,1,3,37,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,10980,83,0,3,0,3,38,0,1,0,0,0,2,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,11040,83,0,2,0,2,39,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,11100,83,0,2,0,2,39,0,1,0,0,0,1,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,11160,83,0,1,0,1,40,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,11220,83,0,1,0,1,40,0,1,0,0,0,0,0,2,0,0,0,1,1,0,0
fam_p5_s4_r2_a42,1,11280,83,0,0,0,0,41,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
fam_p5_s4_r2_a42,1,11340,83,0,0,0,0,41,0,1,0,0,0,0,0,3,0,0,0,0,1,0,0
```

### 段階Bの台帳 unity_ledger_v1.csv（cs:140・143・144 の54行）

```csv
timestamp,run_folder,status,unity_cs,layout_generation,params_model,store,staff,robots,arrivals_per_h,mode,trays,fragile_by,seed,staff_first_yield,yield_order_enabled,one_robot_per_aisle,robot_first_wait_s,everyone_left,total_minutes,wall_seconds,entry_wait_mean,staff_wait_mean,dish_wait_mean,cold_share,robot_share,staff_util,robot_util,walk_km_per_staff,site_entry_wait_mean,site_staff_wait_mean,site_dish_wait_mean,site_robot_share,robot_speed_mps,pause_rate,pause_s_mean,stuck_rate,trips,timeouts,stuck_count,stop_s_total,corner_stop_time_share,gave_up,warped,table_freed,rescues,late_staff_dishes,staff_yield_share_of_staff_time,ledger_schema_version,pay_wait_mean,site_pay_wait_mean,blocked_wait_mean,site_blocked_wait_mean,turn_mean,site_turn_mean,clear_wait_mean_s,clear_wait_max_s,clear_wait_over_count,pay_job_wait_mean_s
2026-10-24T00:03:37,20261023_stageB_fam_p1_r2_s0,completed,cs:140,82023936eb99,v2.7,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,179.5543,787.0753,2.892,1.6328,4.2197,0.5507,0.7053,0.8095,0.7073,2.5281,0.8336,0.559,1.0102,0.9914,0.5734,,5.1598,,99,7,55,5118.954,0.2212,100,59,35,55,61,0.0616,1
2026-10-24T00:21:58,20261023_stageB_fam_p5_r2_s0,completed,cs:140,82023936eb99,v2.7,fam_p5,4,2,42,serve,3,any,0,1,1,1,120,1,216.8588,968.6135,15.0673,3.9569,3.9381,0.5882,0.7463,0.7428,0.7209,3.0054,1.5915,1.0166,1.0497,0.9898,0.5776,,4.794,,116,9,61,5484.75,0.1488,106,107,45,61,69,0.0676,1
2026-10-24T00:33:37,20261023_stageB_fam_p1_r0_s0,completed,cs:140,82023936eb99,v2.7,fam_p1,4,0,42,serve,1,any,0,1,1,1,120,1,179.9344,694.309,1.9311,0.7289,0.9608,0,0,0.742,0,3.0918,3.1814,2.8545,0.653,0,0,,0,,0,0,0,0,0,10,11,22,0,0,0,1
2026-10-24T00:42:09,20261023_stageB_fam_p1_r0_s1,completed,cs:140,82023936eb99,v2.7,fam_p1,4,0,42,serve,1,any,1,1,1,1,120,1,171.637,507.5364,2.2816,1.0721,0.8046,0,0,0.758,0,3.1089,3.1814,2.8545,0.653,0,0,,0,,0,0,0,0,0,9,11,22,0,0,0,1
2026-10-24T00:51:32,20261023_stageB_fam_p1_r0_s2,completed,cs:140,82023936eb99,v2.7,fam_p1,4,0,42,serve,1,any,2,1,1,1,120,1,181.8777,558.8922,3.9575,2.1577,0.913,0,0,0.7676,0,3.2595,3.1814,2.8545,0.653,0,0,,0,,0,0,0,0,0,7,14,22,0,0,0,1
2026-10-24T01:01:20,20261023_stageB_fam_p1_r1_s0,completed,cs:140,82023936eb99,v2.7,fam_p1,4,1,42,serve,3,any,0,1,1,1,120,1,190.4947,583.2244,3.5292,1.6372,3.8528,0.6376,0.5688,0.7286,0.7901,2.7416,1.6401,1.189,2.0398,0.7252,0.5796,,6.1226,,57,1,20,2254.078,0.0157,58,43,23,20,94,0.0446,1
2026-10-24T01:10:23,20261023_stageB_fam_p1_r1_s1,completed,cs:140,82023936eb99,v2.7,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,179.0147,538.8367,1.5759,0.3513,3.1393,0.5056,0.75,0.6575,0.7625,2.5104,1.6401,1.189,2.0398,0.7252,0.5802,,4.3264,,70,0,5,1117.942,0.2593,37,37,6,5,45,0.0375,1
2026-10-24T01:19:33,20261023_stageB_fam_p1_r1_s2,completed,cs:140,82023936eb99,v2.7,fam_p1,4,1,42,serve,3,any,2,1,1,1,120,1,180.2972,546.3296,5.9371,2.3347,3.8241,0.6262,0.5935,0.7325,0.7872,2.5653,1.6401,1.189,2.0398,0.7252,0.5791,,2.8966,,61,1,16,1522.736,0.1318,36,36,23,16,87,0.0226,1
2026-10-24T01:28:36,20261023_stageB_fam_p1_r2_s1,completed,cs:140,82023936eb99,v2.7,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,178.2278,538.7911,2.3919,1.1051,4.4348,0.623,0.6448,0.7517,0.6753,2.4884,0.8336,0.559,1.0102,0.9914,0.5724,,5.2564,,82,10,62,5389.347,0.0621,74,46,21,62,65,0.0345,1
2026-10-24T01:38:34,20261023_stageB_fam_p1_r2_s2,completed,cs:140,82023936eb99,v2.7,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,194.9398,593.7455,9.0378,4.8814,4.7154,0.6019,0.6825,0.7672,0.7023,2.6938,0.8336,0.559,1.0102,0.9914,0.5661,,5.3837,,91,7,55,5854.606,0.0381,113,73,33,55,67,0.0763,1
2026-10-24T01:47:16,20261023_stageB_fam_p5_r0_s0,completed,cs:140,82023936eb99,v2.7,fam_p5,4,0,42,serve,1,any,0,1,1,1,120,1,172.4048,517.3931,4.8394,2.1071,1.1201,0.0225,0,0.7948,0,3.2197,4.3607,3.9717,0.6572,0,0,,0,,0,0,0,0,0,9,23,31,0,0,0,1
2026-10-24T01:55:26,20261023_stageB_fam_p5_r0_s1,completed,cs:140,82023936eb99,v2.7,fam_p5,4,0,42,serve,1,any,1,1,1,1,120,1,163.5246,485.9518,1.6157,0.4105,1.0004,0.0116,0,0.7173,0,2.8176,4.3607,3.9717,0.6572,0,0,,0,,0,0,0,0,0,10,12,14,0,0,0,1
2026-10-24T02:04:21,20261023_stageB_fam_p5_r0_s2,completed,cs:140,82023936eb99,v2.7,fam_p5,4,0,42,serve,1,any,2,1,1,1,120,1,174.9274,530.9297,1.6289,0.4422,1.1089,0.0266,0,0.7291,0,2.9547,4.3607,3.9717,0.6572,0,0,,0,,0,0,0,0,0,6,19,24,0,0,0,1
2026-10-24T02:13:29,20261023_stageB_fam_p5_r1_s0,completed,cs:140,82023936eb99,v2.7,fam_p5,4,1,42,serve,3,any,0,1,1,1,120,1,174.6253,543.262,3.0575,1.7612,3.829,0.6406,0.5899,0.7726,0.7943,2.7368,1.3295,0.9827,2.097,0.7197,0.5794,,3.1777,,58,1,9,1147.391,0.055,43,49,23,9,89,0.041,1
2026-10-24T02:24:10,20261023_stageB_fam_p5_r1_s1,completed,cs:140,82023936eb99,v2.7,fam_p5,4,1,42,serve,3,any,1,1,1,1,120,1,204.855,636.6443,9.0647,4.7009,3.8111,0.6463,0.6179,0.7322,0.7773,2.8305,1.3295,0.9827,2.097,0.7197,0.5805,,3.5446,,71,1,9,1267.09,0.1584,56,60,35,9,94,0.0481,1
2026-10-24T02:34:41,20261023_stageB_fam_p5_r1_s2,completed,cs:140,82023936eb99,v2.7,fam_p5,4,1,42,serve,3,any,2,1,1,1,120,1,200.0514,626.5444,11.4668,3.353,3.5781,0.6626,0.6585,0.7539,0.7679,3.0049,1.3295,0.9827,2.097,0.7197,0.5828,,3.6716,,70,0,2,791.9954,0.0852,40,56,22,2,84,0.0367,1
2026-10-24T02:44:09,20261023_stageB_fam_p5_r2_s1,completed,cs:140,82023936eb99,v2.7,fam_p5,4,2,42,serve,3,any,1,1,1,1,120,1,178.8562,563.2718,1.7955,0.5522,3.0426,0.3923,0.7569,0.6973,0.6137,2.5108,1.5915,1.0166,1.0497,0.9898,0.5709,,3.7393,,102,6,32,3503.318,0.216,57,46,4,32,44,0.0448,1
2026-10-24T02:54:55,20261023_stageB_fam_p5_r2_s2,completed,cs:140,82023936eb99,v2.7,fam_p5,4,2,42,serve,3,any,2,1,1,1,120,1,210.9038,641.7984,12.6712,4.2409,3.6097,0.5118,0.7953,0.7384,0.7056,2.8439,1.5915,1.0166,1.0497,0.9898,0.5708,,4.1918,,118,6,49,5197.614,0.0325,69,84,33,49,52,0.0546,1
2026-10-24T16:44:48,20261024_stageB_cs143_fam_p1_r2_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,174.2833,699.3919,1.5964,0.2997,1.1661,0.0313,0.975,0.6221,0.6511,2.1231,1.0758,0.7468,1.0095,0.9918,0.5676,,1.8264,,120,2,24,3610.899,0,45,73,5,24,4,0.0752,1
2026-10-24T17:03:52,20261024_stageB_cs143_fam_p5_r2_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,2,42,serve,3,any,0,1,1,1,120,1,174.2858,709.9011,1.5844,0.305,1.0312,0.0375,0.975,0.6431,0.6478,2.1893,1.1434,0.8016,1.0105,0.9917,0.5696,,1.5677,,128,2,25,3449.849,0,58,83,4,25,4,0.0313,1
2026-10-24T17:36:01,20261024_stageB_cs143_fam_p1_r0_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,0,42,serve,1,any,0,1,1,1,120,1,172.3251,1094.92,1.5149,0.3214,0.846,0,0,0.6674,0,2.8311,3.99,3.3554,0.6466,0,0,,0,,0,0,0,0,0,8,6,0,0,0,0,1
2026-10-24T18:00:23,20261024_stageB_cs143_fam_p1_r0_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,0,42,serve,1,any,1,1,1,1,120,1,173.3096,765.7284,1.7438,0.5525,0.9386,0,0,0.7728,0,3.1201,3.99,3.3554,0.6466,0,0,,0,,0,0,0,0,0,7,13,24,0,0,0,1
2026-10-24T18:18:27,20261024_stageB_cs143_fam_p1_r0_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,0,42,serve,1,any,2,1,1,1,120,1,197.7529,751.3111,7.1696,4.2236,1.0143,0.0039,0,0.8091,0,3.6539,3.99,3.3554,0.6466,0,0,,0,,0,0,0,0,0,24,12,37,0,0,0,1
2026-10-24T18:35:45,20261024_stageB_cs143_fam_p1_r1_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,1,42,serve,3,any,0,1,1,1,120,1,172.8132,934.1382,1.4148,0.1777,2.2135,0.2438,0.8188,0.5981,0.7368,2.1527,1.8213,1.2201,2.0323,0.7275,0.5829,,2.9667,,65,0,5,763.0456,0,42,34,1,5,29,0.0337,1
2026-10-24T19:03:53,20261024_stageB_cs143_fam_p1_r1_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,180.7106,826.8843,1.665,0.4383,2.5071,0.2816,0.6893,0.7113,0.7452,2.5239,1.8213,1.2201,2.0323,0.7275,0.5781,,3.2266,,64,1,4,846.7955,0,33,31,8,4,64,0.0351,1
2026-10-24T19:21:17,20261024_stageB_cs143_fam_p1_r1_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,1,42,serve,3,any,2,1,1,1,120,1,197.4997,990.956,7.5011,3.4909,2.5064,0.3228,0.6614,0.7597,0.7951,2.954,1.8213,1.2201,2.0323,0.7275,0.5801,,3.0639,,75,0,4,914.2462,0,61,50,28,4,86,0.0431,1
2026-10-24T20:04:55,20261024_stageB_cs143_fam_p1_r2_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,175.4362,808.5378,2.0113,0.7377,1.5257,0.1311,0.9029,0.6969,0.6666,2.3056,1.0758,0.7468,1.0095,0.9918,0.5671,,1.5955,,124,3,23,3367.549,0,43,51,16,23,20,0.0692,1
2026-10-24T20:26:21,20261024_stageB_cs143_fam_p1_r2_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,195.7798,1255.075,7.372,3.3987,1.8354,0.185,0.8189,0.8006,0.7573,2.8058,1.0758,0.7468,1.0095,0.9918,0.5667,,1.8337,,135,2,42,4737.136,0,96,64,36,42,46,0.0573,1
2026-10-24T20:48:50,20261024_stageB_cs143_fam_p5_r0_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,0,42,serve,1,any,0,1,1,1,120,1,175.7102,965.9666,1.7959,0.6049,1.0693,0.025,0,0.6921,0,2.8811,4.7827,3.9497,0.6502,0,0,,0,,0,0,0,0,0,30,37,12,0,0,0,1
2026-10-24T21:04:46,20261024_stageB_cs143_fam_p5_r0_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,0,42,serve,1,any,1,1,1,1,120,1,174.1521,951.7297,1.8685,0.6678,1.0547,0.0194,0,0.8168,0,3.1138,4.7827,3.9497,0.6502,0,0,,0,,0,0,0,0,0,41,28,25,0,0,0,1
2026-10-24T21:21:43,20261024_stageB_cs143_fam_p5_r0_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,0,42,serve,1,any,2,1,1,1,120,1,199.2553,1012.086,8.0689,4.5439,1.2286,0.0197,0,0.8308,0,3.7975,4.7827,3.9497,0.6502,0,0,,0,,0,0,0,0,0,58,38,55,0,0,0,1
2026-10-24T21:59:44,20261024_stageB_cs143_fam_p5_r1_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,1,42,serve,3,any,0,1,1,1,120,1,176.7278,834.7532,1.6539,0.4188,2.1487,0.1813,0.8125,0.6404,0.7226,2.2656,2.142,1.3606,2.1584,0.7081,0.5822,,2.1804,,61,0,5,666.5468,0,56,67,9,5,30,0.0342,1
2026-10-24T22:11:50,20261024_stageB_cs143_fam_p5_r1_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,1,42,serve,3,any,1,1,1,1,120,1,178.9682,720.2909,1.7516,0.5287,2.6766,0.4029,0.6262,0.7866,0.7195,2.6362,2.142,1.3606,2.1584,0.7081,0.5782,,3.7388,,61,0,7,1070.893,0,58,48,19,7,77,0.0427,1
2026-10-24T22:51:50,20261024_stageB_cs143_fam_p5_r1_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,1,42,serve,3,any,2,1,1,1,120,1,201.2002,1105.354,8.4937,4.5939,2.758,0.4016,0.6299,0.7848,0.7783,2.9888,2.142,1.3606,2.1584,0.7081,0.5797,,3.7535,,69,0,9,1287.391,0,93,81,40,9,94,0.0354,1
2026-10-24T23:02:59,20261024_stageB_cs143_fam_p5_r2_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,2,42,serve,3,any,1,1,1,1,120,1,175.5425,663.6535,2.0392,0.7209,1.5284,0.1456,0.9223,0.7671,0.6621,2.3699,1.1434,0.8016,1.0105,0.9917,0.5618,,1.1702,,129,2,13,3226.386,0,75,84,20,13,16,0.0629,1
2026-10-24T23:13:42,20261024_stageB_cs143_fam_p5_r2_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,2,42,serve,3,any,2,1,1,1,120,1,199.8904,638.7806,7.8111,4.3815,1.5885,0.1142,0.9449,0.7654,0.7277,2.6593,1.1434,0.8016,1.0105,0.9917,0.5712,,2.1535,,152,2,26,3977.503,0,141,108,42,26,14,0.0647,1
2026-10-25T11:08:30,20261025_stageB_cs144_fam_p1_r2_s0,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,172.3708,760.9659,1.4732,0.2047,1.0308,0,0.9875,0.6021,0.635,2.123,1.1788,0.3853,0.9488,0.9912,0.565,,1.5982,,121,1,19,3477.493,0,41,46,0,19,2,0.0453,2,3.1883,7.9844,0,0.322,3.0994,2.0672,32.3971,236.7217,0,104.2948
2026-10-25T11:21:44,20261025_stageB_cs144_fam_p5_r2_s0,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,2,42,serve,3,any,0,1,1,1,120,1,174.7166,789.9523,2.0812,0.8153,2.5534,0.3,0.8188,0.7231,0.6661,2.2064,1.9669,0.4479,0.9839,0.991,0.5702,,1.892,,93,7,48,4382.701,0,94,85,1,48,29,0.0417,2,2.9576,7.9555,0,0.3816,12.4052,2.0967,607.6856,1459.576,44,116.7523
2026-10-25T12:01:08,20261025_stageB_cs144_fam_p1_r0_s0,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,0,42,serve,1,any,0,1,1,1,120,1,172.3243,640.1329,1.5572,0.3703,0.8354,0,0,0.6624,0,2.8102,3.301,0.9547,0.459,0,0,,0,,0,0,0,0,0,10,9,0,0,0,0,2,2.2416,12.2785,0,0.7327,3.367,2.4872,64.324,337.1279,0,97.0322
2026-10-25T12:21:44,20261025_stageB_cs144_fam_p1_r0_s1,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,0,42,serve,1,any,1,1,1,1,120,1,185.8255,802.3925,3.9048,2.6872,0.9613,0.0049,0,0.7722,0,3.2327,3.301,0.9547,0.459,0,0,,0,,0,0,0,0,0,7,10,0,0,0,0,2,4.6305,12.2785,2.237,0.7327,11.1557,2.4872,535.7809,1243.573,39,136.7386
2026-10-25T12:46:20,20261025_stageB_cs144_fam_p1_r0_s2,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,0,42,serve,1,any,2,1,1,1,120,1,213.2513,971.2404,13.1341,8.071,0.9353,0.0079,0,0.8321,0,4.0273,3.301,0.9547,0.459,0,0,,0,,0,0,0,0,0,18,7,0,0,0,0,2,5.4609,12.2785,7.7384,0.7327,11.2013,2.4872,535.8037,1162.99,49,137.0442
2026-10-25T12:57:53,20261025_stageB_cs144_fam_p1_r1_s0,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,0,1,1,1,120,1,175.7077,688.629,1.513,0.2605,2.035,0.1563,0.7875,0.6091,0.7225,2.2703,2.5992,0.572,1.9487,0.7204,0.581,,2.9088,,63,1,2,634.9476,0,35,48,0,2,34,0.041,2,2.3111,7.9893,0,0.4827,4.3074,2.1338,124.5727,759.5713,1,89.6476
2026-10-25T13:06:46,20261025_stageB_cs144_fam_p1_r1_s1,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,185.4935,528.3881,3.2965,2.04,2.4977,0.3252,0.6796,0.7523,0.7118,2.7339,2.5992,0.572,1.9487,0.7204,0.5777,,3.4484,,66,0,6,1012.044,0,46,42,0,6,66,0.0618,2,6.9156,7.9893,1.6683,0.4827,5.9868,2.1338,215.7105,777.999,7,174.5153
2026-10-25T13:17:39,20261025_stageB_cs144_fam_p1_r1_s2,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,2,1,1,1,120,1,209.8886,649.4019,11.3746,4.9633,2.3928,0.2717,0.7165,0.7716,0.7875,3.2273,2.5992,0.572,1.9487,0.7204,0.5787,,2.9104,,80,0,4,941.5961,0,59,48,0,4,72,0.0343,2,7.5006,7.9893,4.7668,0.4827,6.3827,2.1338,241.913,743.8828,6,161.3688
2026-10-25T13:26:59,20261025_stageB_cs144_fam_p1_r2_s1,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,192.9094,555.2418,2.0251,0.7707,1.9755,0.1748,0.8835,0.7157,0.5939,2.6925,1.1788,0.3853,0.9488,0.9912,0.5582,,1.0352,,122,2,19,3552.871,0,56,63,1,19,24,0.0529,2,12.4953,7.9844,0.3886,0.322,3.019,2.0672,44.7147,493.8135,0,204.8882
2026-10-25T13:38:16,20261025_stageB_cs144_fam_p1_r2_s2,completed,cs:144,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,221.4226,672.9969,16.6388,8.2715,1.4296,0.0906,0.8937,0.8026,0.7203,3.159,1.1788,0.3853,0.9488,0.9912,0.5642,,1.8058,,164,5,46,5528.79,0,93,88,3,46,27,0.0723,2,9.9573,7.9844,8.0982,0.322,10.6109,2.0672,520.962,1582.494,64,173.0782
2026-10-25T13:46:32,20261025_stageB_cs144_fam_p5_r0_s0,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,0,1,1,1,120,1,173.5586,491.444,1.6964,0.4811,1.0793,0.0063,0,0.7367,0,3.0208,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,37,30,0,0,0,0,2,2.0236,11.7376,0,0.6821,8.7902,2.5036,348.1431,896.8828,18,93.8679
2026-10-25T13:55:41,20261025_stageB_cs144_fam_p5_r0_s1,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,1,1,1,1,120,1,197.635,545.23,6.1586,4.9498,1.0342,0.0097,0,0.8026,0,3.4559,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,37,30,1,0,0,0,2,4.4451,11.7376,4.4184,0.6821,16.4164,2.5036,827.5909,1893.176,54,135.9633
2026-10-25T14:06:26,20261025_stageB_cs144_fam_p5_r0_s2,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,2,1,1,1,120,1,222.9673,640.2668,18.0661,12.496,1.1648,0.0197,0,0.8746,0,4.2588,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,66,41,0,0,0,0,2,5.6138,11.7376,11.9372,0.6821,16.8269,2.5036,887.8472,1744.857,74,142.7157
2026-10-25T14:14:54,20261025_stageB_cs144_fam_p5_r1_s0,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,1,42,serve,3,any,0,1,1,1,120,1,175.0104,503.4814,1.7728,0.502,2.0627,0.2125,0.8188,0.6874,0.7619,2.2764,1.815,0.4851,1.9698,0.721,0.5829,,2.8895,,66,1,8,960.0447,0,75,65,4,8,29,0.0317,2,2.7728,8.0624,0,0.3915,7.0669,2.1404,265.0432,925.6201,15,105.378
2026-10-25T14:24:53,20261025_stageB_cs144_fam_p5_r1_s1,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,1,42,serve,3,any,1,1,1,1,120,1,201.4849,595.7761,8.2228,6.9815,2.265,0.2476,0.699,0.7551,0.7049,2.8463,1.815,0.4851,1.9698,0.721,0.5782,,3.1389,,77,0,7,1085.794,0,67,68,1,7,62,0.0446,2,4.0382,8.0624,6.5045,0.3915,16.3312,2.1404,821.5815,2088.111,56,123.154
2026-10-25T14:36:22,20261025_stageB_cs144_fam_p5_r1_s2,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,1,42,serve,3,any,2,1,1,1,120,1,220.6182,684.4041,16.2193,9.9229,2.5322,0.2992,0.7165,0.8368,0.8242,3.334,1.815,0.4851,1.9698,0.721,0.5808,,3.6043,,82,0,10,1429.491,0,118,107,3,10,72,0.0639,2,6.6079,8.0624,9.5369,0.3915,13.461,2.1404,674.8959,1601.37,70,158.9349
2026-10-25T14:45:49,20261025_stageB_cs144_fam_p5_r2_s1,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,2,42,serve,3,any,1,1,1,1,120,1,189.2454,562.6676,4.0575,2.8079,1.4513,0.1019,0.9175,0.7694,0.6279,2.4746,1.9669,0.4479,0.9839,0.991,0.5686,,1.8405,,140,1,21,3112.568,0,80,80,2,21,17,0.0535,2,5.1809,7.9555,2.4273,0.3816,10.4639,2.0967,466.5379,1500.018,30,146.1281
2026-10-25T15:03:33,20261025_stageB_cs144_fam_p5_r2_s2,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,2,42,serve,3,any,2,1,1,1,120,1,223.7393,933.7357,16.2146,9.8482,1.2676,0.0669,0.9646,0.8141,0.6812,3.0099,1.9669,0.4479,0.9839,0.991,0.5648,,1.3246,,166,1,15,3867.828,0,141,105,4,15,9,0.064,2,6.734,7.9555,9.4085,0.3816,14.0696,2.0967,739.1896,1635.237,85,156.016
```

### stageB_cs144/README.md（基準データ v2 の入口）

```markdown
# 基準データ v2（段階Bの18回・cs:144）

逃がし弁を直し（スタッフが仕事を取ってから止まった時間だけ数える）、試算 v2.8（会計はレジで1組ずつ）を読む版。2026-10-25 11:00ごろ〜15:00 に Stage B / Run All（18回）で回した。18回とも最後まで回り切り、台帳 unity_ledger_v1.csv の unity_cs = cs:144 の18行（台帳の版2・59列）。各回に timeline.csv（60秒ごと）がある。

- c1_compare.py → c1_compare_output.txt：条件ごとに 種0・1・2、種1 の cs:143 → cs:144、試算 v2.8 を並べる
- c2_timeline_peak.py → c2_timeline_peak_output.txt：timeline.csv のピーク後半（来客が始まって60〜120分）の平均
- 試算 v2.8 の同じ見方の数字は、Claude のクラウドで haizen_sim.py に記録を付けて出した（下の「分かったこと」に書いた）

## 分かったこと（2026-10-25）
- 卓を空けた：③0台 種0・1・2 で 0・24・37 → 0・0・0。どの条件も0〜4に減った
- ③は、ロボットを入れると入店待ちが短くなる向きが試算と同じになった（種1：3.90 → 3.30 → 2.03分。試算 3.30 → 2.60 → 1.18分）
- ⑦は試算から大きく外れる（種1：6.16 → 8.22 → 4.06分。試算 2.76 → 1.81 → 1.97分）。客が帰ってから卓が使えるまで16分
- 詰まる場所が違う。ピーク後半の平均（③0台・種1）：Unity はスタッフ4人がすべてふさがり、片付けを誰も取っていない卓が6.6卓。試算はスタッフ3.8人がふさがり、片付けが済んでいない卓は1.6卓、レジの列が6.8組
- 原因の見立て：仕事1件あたりのスタッフの時間が Unity の方が長い（1組あたり ③415秒・⑦459秒。試算は388秒。片付け ③134・⑦161秒／試算123秒、配膳1皿 37・41秒／31秒、案内 73・77秒／66秒）。試算はピークでスタッフの手が95%ふさがっているので、1割弱長いだけで手が足りなくなり、いちばん後回しの片付けがたまる。⑦は通路が狭く、長さの差が大きい
- 試算は、案内・片付けの歩く距離にも「受け渡し口から卓まで」の距離を使っている。実際は入口から卓、卓から洗い場で、Unity はその道を人をよけながら歩く

## 決めたこと（2026-10-25）
- 歩く距離の差は試算に入れず、ずれとして記録だけにする。この18回を「基準データ v2」とし、夜に回す改良（自動の判定・画面なしで回す・ケースの型・次の条件を選ぶ仕組み）の答え合わせはここと比べる（基準データ v1 は stageB_cs143）
```

### ずれの一覧 unity_stageC_gaps_v1.md

```markdown
# 段階C：サイトの試算と Unity のずれの一覧 v1（2026-10-24）

元のデータ：段階Bの18回（cs:140・店の形 82023936eb99・試算 v2.7）。③⑦ × ロボット0・1・2台 × 種0・1・2。
分析のコードと結果：KIOXIA `r2r2r-data/service_sim/unity/stageC_v1/`（a1〜a4.py・rows.json・carry.json・*_output.txt）。
理由の分け方は設計書 0.5 章：1 数え方の違い／2 サイトにない動き（すれ違い・混雑）／3 Unity の作りの問題。
対処を実際に入れるのは次の版。この一覧では決めるところまで。

## 1. 合っていたもの

- ロボットが1往復で走る距離：Unity 26〜36m、試算の計算式（卓までの距離＋卓から卓へ3m）25〜35m。ほぼ同じ
- 片付けを後回しにする順番（片付けは優先2、案内・配膳・会計は1、立ち往生は0）：同じ
- ロボットなしの料理の待ち：差0.2〜0.4分。ほとんどが下の表の7（スタッフが今いる所から歩く）で説明がつく

## 2. ロボットが運んだ皿の待ちの内訳（3回平均・分）

| 条件 | 受け渡し口で待った | 運ぶ（記録の値） | うち戻るまでを数えた分（表の1） | 卓に着くまで（見積もり） | 試算の式で卓に着くまで | 1往復で止まった秒 | 立ち往生した往復 |
|---|---|---|---|---|---|---|---|
| ③ 1台 | 1.20 | 2.55 | 1.24 | 1.31 | 0.72 | 33秒 | 10% |
| ③ 2台 | 1.11 | 3.04 | 1.04 | 2.01 | 0.58 | 86秒 | 18% |
| ⑦ 1台 | 1.44 | 2.59 | 1.32 | 1.27 | 0.77 | 22秒 | 6% |
| ⑦ 2台 | 0.76 | 2.60 | 1.09 | 1.51 | 0.61 | 58秒 | 11% |

試算の前提：1往復で止まるのは30%の確率で3〜10秒（平均約2秒）、立ち往生は1%。
「卓に着くまで（見積もり）」は、記録の値から、着いたあとの受け渡し25秒と残りの道のり（Unity の速さで）を引いたもの。止まりは全部着く前に起きたとして引いているので、表の1の大きさは小さめに見積もっている。

## 3. ずれの一覧

| # | ずれ | 大きさ | 理由 | 対処（次の版） |
|---|---|---|---|---|
| 1 | ロボットが運んだ皿の「届いた時刻」が、卓に着いた時刻ではなく、往復を終えて受け渡し口に戻った時刻になっている。客が食べ始めるのもその時刻 | 料理の待ち +1.0〜1.3分/皿。食事の始まりが遅れ、卓が空くのも遅れる（入店待ちにも効く） | 3。ロボットは卓に着いた時点で「届いた」と数えている（ServingRobotController の StopsCompleted）が、皿を届いたにするのは往復の終わり（ServiceOps.OnRobotRunDone）だけ | Unity：卓に着いた時点（StopsCompleted が増えたとき）でその卓の皿を届いたにする。試算と同じ（受け渡し25秒の前に届いた） |
| 2 | ロボットが1往復で止まる時間が長い。立ち往生（60秒止まる）も多い | 止まり 22〜86秒/往復（試算 約2秒）。立ち往生 6〜18%の往復（試算 1%） | 1台：2。止まりの理由は人とのすれ違い・人の立ち止まり（客席の通路が4〜6割）。Unity が測るべき値そのもの。2台：止まる時間の5〜6割と、60秒止まりのほとんどが「もう1台のロボット」。試算には台どうしの邪魔がない。ただし実機の配膳ロボットは台どうしで道を調整する仕組みを持つものが多く、Unity の2台が60秒向かい合うのは作りの問題（3）の疑いが強い | 1台の止まりの値：表の1を直して測り直してから、サイトの pause・stuck に入れるかを決める。2台の台どうしの止まり：Unity の次の版で見直す |
| 3 | ロボットが運んだ料理の割合が低い | 62〜80%（試算 72〜99%） | 2の結果。往復が長いので、2分待っても戻らず人が運ぶ皿が増える | 単独では直さない。1・2の結果を見る |
| 4 | ロボットが走っているときの速さ | 毎秒0.57〜0.58m（試算 0.5m） | 1。Unity の最高速度の設定が0.6m。試算の0.5は仮の値 | どちらにそろえるか決める（Unity の速さは測った値ではなく設定） |
| 5 | ロボットなしで、卓が空くまでが短い | 片付け 5.0〜5.5分（試算 9.5〜10.2分）、席が空くのを待った時間 0.5〜0.9分（試算 2.5〜3.6分） | 未確定。仕事の順番は同じ。疑い：逃がし弁「卓を空けた」が1回に14〜31卓あり、片付けを飛ばして卓を空けている（3） | 記録：「卓を空けた」で片付けを省いた卓を、片付けの時間と分けて数える。そのうえで理由を決める |
| 6 | 逃がし弁がまだ働いている | ロボットなしでも あきらめた6〜10・移した11〜23・卓を空けた14〜31。2台で 57〜113・46〜107・4〜45 | 3。段階Aの終わりの条件（ほぼ0）は満たさずに進めた | 次の版の直す対象として残す。どの仕事で出ているかは give_ups.csv |
| 7 | スタッフが運ぶ時間が長い | 0.43〜0.62分（試算 約0.18分） | 2。試算はスタッフが受け渡し口にいる前提、Unity は今いる所から受け渡し口へ歩く | 直さない（サイトにない動き） |
| 8 | スタッフの歩く距離が長い | 1人 2.6〜3.2km（試算 1.6〜2.9km） | 1と2。道のりの測り方の違い、よける・回り道 | 直さない |
| 9 | 入店待ちが回によって大きくぶれる | 同じ条件で1.6〜15.1分 | 回数が足りない | 次の段階Bで乱数の種を増やす |
| 10 | 往復の記録（trips.csv）の皿の欄（dishes・dish_ready_s・dish_arrived_s）が、同じロボットの次の往復の行に書かれている。往復の始まりの時刻と、卓ごとに着いた時刻の欄がない | 記録だけ。動きには効かない | 3（記録） | Unity：記録を直し、始まりの時刻と卓ごとに着いた時刻を足す |

## 4. 決めること（エイイチ）

- 次の版で Unity に入れる直し：1（届いた時刻）・10（記録）を先に入れるか
- 2の2台の台どうしの止まりを、Unity の作りの問題として直すか、サイトにない動きとして残すか
- 4の速さを、試算の0.5にそろえるか、Unity の0.6にそろえるか、そのままにするか

## 5. エイイチの決定（2026-10-24）

- 1（届いた時刻）と10（往復の記録）を次の版で先に入れる。依頼は claude_code_request_v8_dish_arrival.md（v7b の押し直しの直しも、照らし合わせに Unity の版を入れて同じ依頼に入れた。v7b は送らない）。直したら段階Bの18回を回し直す
- 2の2台の台どうしの止まり（60秒向かい合う）は、Unity の作りの問題（3）として扱う。手を付けるのは、v8 で回し直したあと（どちらの直しが効いたかを分けるため）
- 4の速さは今のまま（Unity の最高 0.6m/秒、試算 0.5m/秒）。手を付けない。帳尻合わせで Unity やサイトの値を動かさない。サイトの数値が正
- v8 の確かめの1回（2026-10-24 08:59・③2台・種0）で店が止まった。原因は cs:140 からある打ち切りの戻し方（2台とも受け渡し口の同じ点に置かれて重なり、止まり続けた）。あわせて、同じ種でも動きがずれると来る客が変わる（83組→105組）ことが分かった。エイイチと決めて、打ち切りで重ねない（A）・来客と組の中身を別の乱数にする（B）を v8b として v8 に足した（claude_code_request_v8b_timeout_rng.md）。行き止まりでの台どうしの向かい合いは、回し直したあとに扱うまま
- 2の台どうしの止まりの根本：ロボットが決まった線の上しか走れない作り（routes）。現実の配膳ロボットは地図の上で自分で道を引き、回り込む。次の版で動き方を変える（unity_design_v0.md の 2026-10-24 の項）
- 動き方を変える依頼 v9（claude_code_request_v9_map_planner.md、2026-10-24）：サイトの地図の上でロボットが自分で道を引く。料理を載せている方が先・3秒で回り込む・通路の真ん中寄り（エイイチの決定）。確かめは③2台・種0を1回、v8b の同じ回（1004）と比べる。18回の回し直しはその結果を見てから

## v9 の確かめ B（2026-10-24）
- 3回の比べ（③2台・種0・64組・どれも回り切る）：v8b（1004）／v9・ドリンクバー前（1304、店の形 2fa10547a2b7）／v9・ドリンクバー後（1559、8a5b2f6a016a）
  - 60秒の止まり 57（ロボットどうし51）／59（36）／20（15）。1往復で止まった秒 110／83／33。打ち切り 11／10／1
  - ロボットが運んだ割合 0.64／0.76／0.99。料理の待ち 2.90／2.12／1.17分（試算 v2.7 は1.01）。入店待ち 1.67／1.61／1.47分（試算1.08）
  - 1304 の60秒止まりの43回は右下の行き止まり（エイイチの指摘でドリンクバーを動かし、1559 で0に）
- 残り（段階Bの18回と合わせて扱う）
  - 受け渡し口の左下（x14.3・z5.3）で1号機が2号機に7回続けて止められた（152〜160分）。止まっているロボットが受け渡し口の近くをふさいでいる疑い
  - スタッフが右下の通路の端（x20.0・z2.7、ドリンクバーの右の抜け道の入口）に4分以上立ち止まり、2号機が通れなかった
  - 逃がし弁92回のうち70回がドリンクバーの客（移した51・あきらめた19）。ドリンクバーを動かす前（1304）も72回で、人の動きの側の問題
- 進め方（エイイチ了承）：Check-in → 段階Bの18回を回し直す（⑦は新しい動き方で初めて回す）→ 残りは段階Cで扱う

## 段階Bの18回の回し直し（cs:143・店の形 8a5b2f6a016a・試算 v2.7。2026-10-24 16:33ごろ〜23:13）
集計は KIOXIA service_sim/unity/stageB_cs143/（b1_collect.py → runs.txt。cs:140 の18回も同じ形で並べた）
- 18回とも全員が店を出た。来る客は種ごとに台数に関係なく同じ（種0 64組・種1 83組・種2 102組。平均83組で試算の約84組とほぼ同じ）
- ロボットありの回は大きく良くなった（3回平均。cs:140 → cs:143、試算）
  - 2台：料理の待ち ③4.46→1.51・⑦3.53→1.38（試算1.01）。ロボットが運んだ割合 ③0.68→0.90・⑦0.77→0.95（試算0.99）。60秒止まりのロボットどうし ③94→61・⑦111→35（3回の合計）。打ち切り ③24→7・⑦21→6
  - 1台：料理の待ち ③3.61→2.41・⑦3.74→2.53（試算2.03・2.16）。割合 ③0.64→0.72・⑦0.62→0.69（試算0.73・0.71）
- 残ったずれ
  1. 入店待ちがロボットの台数で変わらない（Unity ③3.48・3.53・3.66分／⑦3.91・3.97・3.81分。試算 ③3.99→1.82→1.08・⑦4.78→2.14→1.14）。種0・1は1.4〜2.0分、種2（102組）は7〜8.5分で、平均は種2で決まる。席が空いているのに待った分（staff_wait）は種0・1で0.2〜0.7分しかなく、ロボット0台でもスタッフが回っている。試算ではロボット0台のスタッフが足りない（③3.36分）。疑い：Unity のスタッフの仕事が試算より軽い（前回の5：片付けが短い）と、逃がし弁「卓を空けた」が片付けを飛ばしている（ロボット0台で ③61・⑦92 回）
  2. ドリンクバーの逃がし弁が増えた：⑦0台で移した・あきらめた 69→148、③2台で 184→237（3回の合計）。ドリンクバーを動かしたとき、並ぶ点が2つから1つに減った（2つめが待機場所に近すぎる）のが疑い。人の動きの側
  3. v9 の確かめで見た受け渡し口の左下の止まり・抜け道の入口でのスタッフの立ち止まりは、18回で数えて確かめる

## 段階Cの2回目：入店待ちがロボットの台数で変わらない原因（2026-10-25・調べただけ。Unity は触っていない）
- 記録：KIOXIA service_sim/unity/stageB_cs143/b5_register_and_valve.py・_output.txt（cs:143 の18回）
- スタッフの仕事は軽くない。来た組が試算に近い種1（83組）で、手のふさがりは ③0台 0.77（試算0.75）・⑦0台 0.82（試算0.74）
- 原因1 レジ：Unity はレジの内側に1人だけで、会計は1組ずつ（1組あたり93〜102秒）。1時間にさばけるのは約36〜39組で、試算の来客42組/時より少ない。ロボットを足してもこの詰まりは減らない。試算は会計（pay_s 90秒）をスタッフのだれでも同時にできる
- 原因2 逃がし弁「卓を空けた」：会計・片付けで600秒止まった卓を、片付けずに空ける。片付けの仕事を待っている間（まだだれも取っていない）も数えている。種1の ③0台で83組のうち24卓（会計7・片付け17）、消えたスタッフの仕事は全体の10%。試算では片付けを10分以上待つのは普通（③0台の回転9.7分）なので、Unity はその待ちを打ち切り、席は空いているのに待った時間が小さく出る（種1で0.55分、試算3.36分）
- 種の偏り：来た組は種0・1・2で64・83・102組。試算の平均84組に近いのは種1だけで、種0と種2は大きく外れている。3回の平均で試算と比べない
- 決めること（エイイチ）：会計の時間とレジの数をどちらに合わせるか／逃がし弁の数え方を変えるか
- 決めたこと（2026-10-25）：会計の時間は実感どおり90秒のまま。試算にレジを入れ、レジ1つ・2つを選べるようにした（全部の店。大型店は2つを最初に見せる）→ 試算 v2.8 として公開（p2m-site f26f27c・simulation/v2_8/）。会計待ちが10分以上なら「レジが詰まる」の印を別に出し、「店が回らない」の決め方は変えない。逃がし弁はスタッフが仕事を取ってから止まった時間だけ数える（Unity の直し。まだ Claude Code に出していない）
- v2.8 の③⑦・普通・4人・レジ1つ（Unity の6条件と同じ）：入店待ち ③ 3.30→2.60→1.18分・⑦ 2.76→1.82→1.97分、会計待ち 12.3→8.0→8.0分・11.7→8.1→8.0分。Unity の会計（会計に立ってから退店まで3.5〜5分）とは、逃がし弁を直してから比べる

## v10 の確かめ B（2026-10-25・20261025_p1_efficient_corner_fam_p1_s4_r0_a42_s1_1013・③4人0台・種1・83組）
- 逃がし弁の直しは効いた：卓を空けた 24 → 0。あきらめた7・移した11（ほぼ変わらず）。最後まで回り切った
- 隠れていた片付け待ちが出た：片付けの仕事が取られるまで平均511秒・最大1,145秒・600秒超え37回。客が帰ってから卓が使えるまで 4.95 → 10.76分
- 入店待ち 1.74 → 3.91分（試算 v2.8 3.30・v2.7 3.99）。席は空いているのに待った 0.55 → 2.71分（v2.8 0.95・v2.7 3.36）。会計待ち 4.71分（v2.8 12.28）。手のふさがり 0.78（v2.8 0.72）
- 読み：入店待ちの大きさは試算に近づいたが、詰まる場所が違う。試算 v2.8 はレジ（会計待ち12分・片付けは2.5分で回る）、Unity は片付け（回転10.8分・会計待ち4.7分）で、v2.7 の形に近い。仕事1件あたりの時間（案内67秒・配膳34秒・会計93秒・片付け2.1分）は試算とそろっているので、仕事の取り方か、ピークの時間帯の手の空き方の違いを疑う。段階Bの18回の回し直しと合わせて、時間帯ごとに並べて調べる

## 基準データ v2（段階Bの18回・cs:144・2026-10-25）
- KIOXIA の service_sim/unity/stageB_cs144/README.md に、比べ（c1）と時間帯（c2）と見立てをまとめた
- 要点：卓を空けたはほぼ0に。③はロボットで入店待ちが縮む向きが試算と同じになった。⑦は外れる。詰まる場所は Unity が片付け・試算がレジ。仕事1件あたりのスタッフの時間が Unity の方が ③7%・⑦18% 長く、ピークで手が95%ふさがっている試算では、その差で片付けがたまる
- 決めたこと（2026-10-25）：スタッフの歩く距離の差は、今は試算に入れず、ずれとして記録だけにする。基準データ v2 で区切り、夜に回す仕組みづくりに移る。候補として残す直し方：案内は入口→卓、片付けは卓→洗い場の距離を平面図から取る（図面のある店だけ回し直し）／人をよける分の遅れは Unity の実測を入れる
```


## 試算 v2.8 のコード

### simulation/v2_8/README.md

````markdown
# 配膳ロボットの試算 v2.8（2026-10-25 回す・公開）

会計をレジで1組ずつにした版。レジの数（1つ・2つ）を条件に足した。大型店は2つ、ほかの店は1つを最初に見せる。

## なぜ直したか

Unity の段階Bの18回（cs:143）で、入店待ちがロボットの台数で変わらなかった。調べると、Unity はレジの内側に店員が1人だけで会計が1組ずつ（1時間に約38組が上限）、試算は会計をスタッフのだれでも同時にできた。実店舗に近いのはレジ1つなので、試算の方にレジを入れた（エイイチと決めた。会計の時間は実感どおり90秒のまま）。記録は KIOXIA の service_sim/unity/stageB_cs143/b5_register_and_valve.py と、unity/unity_stageC_gaps_v1.md の「段階Cの2回目」。

## 決めたこと（2026-10-25）

- 食べ終えた組は卓に座ったままレジの順番を待ち、順番が来たらスタッフの手が空くのを待って会計する（pay_s 90秒。スタッフの手はこれまでどおりふさがる）
- レジ1つ・2つを選べる。対象は全部の店
- 結果に pay_wait_mean（食べ終えてから会計が始まるまで）を足した。平均10分以上なら「レジが詰まる」の印を出す。「店が回らない」の決め方（席は空いているのに10分以上待つ）は変えていない。LP の「店舗が回る人数」もこの決め方のまま
- Unity が読む serving-robot-params.json の6条件はレジ1つ

## 確かめたこと

- レジを無限（registers=999）にすると v2.7 と同じ数字になる（乱数の使い方は変えていない）
- クラウドと Mac で分けて回し、重なった37,581条件の数字はすべて一致した（simpy 4.1.1 と 4.1.2）
- 全37,800画面を描いて、欠け・undefined・NaN がない（test_page_v28.js）
- v2.7 との違いは compare_v27_v28.txt。レジ1つでは「店が回らない」が 2,956 → 1,937条件に減り、「レジが詰まる」が5,263条件。③⑦の普通（42組/時）は会計待ちが12分前後

## ファイル

- haizen_sim.py：試算 v2.8（layouts/ は p2m-site の public/sim/layouts/ の写し、店の形 8a5b2f6a016a）
- results.json：全32,850条件（40回の平均）
- run_shard.py・run_timed.py・run_timed_rev.py：計算機を分けて回すための道具（Mac 側は1回の呼び出しが3分までなので区切って回した）
- build_page_v28.py：公開中のページに結果とレジの切り替えを入れる → serving-robot.v28.html（p2m-site の public/sim/serving-robot.html）
- export_params.py → serving-robot-params.json（p2m-site の public/sim/serving-robot-params.json）
- test_page_v28.js：全組み合わせを描いて確かめる
- p2m-site の commit f26f27c で公開。LP の find にもレジの数を足した（src/lib/servingSim.ts）

## 作り方

```
python3 haizen_sim.py                                    # 全条件（2コアで約5時間。分けるなら run_shard.py）
python3 build_page_v28.py <公開中のページ> results.json serving-robot.v28.html
python3 export_params.py results.json serving-robot-params.json
node test_page_v28.js serving-robot.v28.html
```

- 公開済みの記事の数字は前の版のまま
````

### simulation/v2_8/haizen_sim.py

```python
"""
サービスロボット導入シミュレーション #1 配膳ロボット（試作 v2.4）

v2.4 で直したこと（2026-10-22。動画の台本を codex で検証したときに見つかった2点。直し方はエイイチと決めた）
  1. 入店待ちの終わりを「案内されて卓に着いたとき」にした。v2.3 までは、案内したスタッフが説明（guide_s）を
     終えて受け渡し口まで戻るときまで数えていた。説明の時間と戻りの時間は、スタッフの手がふさがる時間には
     これまでどおり入れ、お客さんの流れ（注文を待つ）もこれまでどおりスタッフが戻ってから進める。
  2. 「下げ膳にも使う」（bus）で、ロボットがスタッフより先に卓へ行き、皿を載せる前に帰っていたのを直した。
     スタッフが片付けに向かうとき、空いているロボットがいれば一緒に卓へ向かう。ロボットはスタッフが皿を
     載せ終えるまで卓で待ち、それから洗い場へ戻る（待っている間もロボットはふさがる）。空いていなければ人だけで下げる。
  上げ膳だけ（serve）・食事中に集めて回る（patrol）は、入店待ち（と、その内訳の案内）のほかは v2.3 と同じ数字になる（図面のある店も同じ）。

飲食店のピーク時間を SimPy で再現する。

図面のある店（v2.3）
  標準ファミレスの中に、店の形（図面）から作った店を6つ足した（store が fam_p0〜fam_p5）。
  卓の数と、卓ごとの厨房（料理の受け渡し口）から卓までの距離は店の形から取る（layouts/ の *_analysis.json。
  正は p2m-site の public/sim/layouts/）。来た組には、卓ごとの距離の中から1つを選んで使う。
  来客のペースとスタッフの人数は、標準ファミレス（35卓）を卓の数に合わせて増減させる。それ以外は標準ファミレスと同じ。
  今までの5つの店は、乱数の使い方も含めて何も変えていない（数字は v2.2 と同じ）。

入店待ちの内訳と「店が回らない」（v2.2）
  入店待ちは、店に来てから案内されて卓に着くまで（entry_wait_mean。v2.4 で終わりを直した）。
  そのうち、満席で待った時間（full_wait_mean）と、席は空いているのに片付け（下げ膳・卓拭き）や
  案内が終わらず待った時間（staff_wait_mean）に分ける。staff_wait_mean が NG_MIN 分以上なら「店が回らない」。
  片付け待ちは、客が帰ってから下げ膳が終わるまでの卓の数だけ、入口の列の前から数えた組が
  「席はあるのに待っている」とみなして数える。乱数の使い方は変えていないので、ほかの数字は v2 と同じ。

時間の区切り方（v2.1）
  ページに出す数字（ピークの回し方）：空の店に peak_min 分だけ選んだペースで来客し、全員が帰るまで回す。
    ランチ・ディナーのように、混む時間は限られている前提。v2 と同じで、数字も v2 と同じになる。
  回るかの確かめ（--sustain。v2.2 のページでは使わない）：空の店から始め、来客は選んだペースのまま止めない。
    最初の warmup_min 分は数えず、そのあとの peak_min 分に来た組を、卓が空くまで追いかける。
    その時間のスタッフの手がふさがっている割合（staff_util）を出し、STAFF_FULL 以上なら「店が回らない」とする。
    入店待ちの延び方（seat_wait_trend）は、卓の数を超える客が来ると人手に関係なく延びるので、判定には使わない
    （混雑は多くの店で卓の数を超えている。そのときの行列は、ピーク2時間の入店待ちの数字に出ている）。
店のタイプ（5種）、ロボットの使い方（3種）、1往復で回る卓の数（段の数）、
倒れやすい物（デザート・ドリンク）を誰が運ぶか、を切り替えて比べられる。
数字はすべて仮置き。公開事例・メーカー仕様・観察で順に置き換える。

SimPy が扱うのは「店の図面がなくても答えられる問い」（時間・順番・数）だけ。
通路での鉢合わせ・接触・揺れ・こぼれは Unity で扱い、その結果を
「一時停止」「立ち往生」の数字や、ロボットに載せる皿の決まりとしてここに入れる。

ロボットの使い方
  serve   : 上げ膳だけ。下げ膳は人がやる（ガスト型）
  bus     : 下げ膳にも使う。皿はスタッフがロボットに載せる（サイゼリヤ・ブロンコビリー型）
  patrol  : 食事中に席を回り、客が空いた皿を載せる（焼肉の和民・かみむら牧場型）

使い方:
    pip install simpy
    python haizen_sim.py            # 総当たりを回して results.json を書く
    python haizen_sim.py --main     # よく使う人数・台数（0〜3台）だけ先に回す
    途中経過は results_partial.json に保存され、止まっても続きから回る
    python haizen_sim.py --one      # 1条件だけ回して結果を表示

    python haizen_sim.py --sustain  # 回るかの確かめを総当たりで回して results_sustain.json を書く

依存: Python 3.10 以降 / simpy 4.1
"""
import json
import random
import statistics
import sys
from itertools import product
from multiprocessing import Pool

import simpy

# ---------------------------------------------------------------
# 共通の前提（すべて仮）
# ---------------------------------------------------------------
COMMON = {
    "staff_speed": 1.0,       # スタッフが歩く速さ（m/秒）
    "robot_speed": 0.5,       # ロボットが店内を走る速さ（m/秒）
    "guide_s": 45,            # 案内（歩く時間は別）
    "staff_place_s": 10,      # スタッフが皿を置く
    "robot_handoff_s": 25,    # 客がロボットから皿を取る
    "pay_s": 90,              # 会計
    "call_s": 30,             # 呼び出し対応（歩く時間は別）
    "clear_s": 40,            # テーブルの皿をまとめる
    "wipe_s": 40,             # 卓を拭いてセットする
    "load_s": 15,             # まとめた皿をロボットに載せる
    "guest_load_s": 20,       # 客が空いた皿をロボットに載せる（巡回回収）
    "pause_rate": 0.3,        # 一時停止：1往復で、人が前を横切って止まる確率（自然に解ける）
    "pause_s": (3, 10),       # 一時停止の長さ（秒）
    "stuck_rate": 0.01,       # 立ち往生：1往復で、スタッフが動かしに行かないと解けない確率
    "rescue_s": 15,           # スタッフがロボットを動かす作業
    "hop_m": 3,               # 1往復で複数卓を回るとき、卓から次の卓への寄り道（m）
    "fragile_cook": (2, 4, 7),  # デザート・ドリンクができるまで（分）
    "cold_min": 3,            # これを超えて届いた皿を「待たせた皿」とする
    "dish_extra_m": 0,        # 洗い場までの追加距離（店のタイプで上書き）
    "sustain": False,         # True：回るかの確かめ（来客を止めない回し方）
    "warmup_min": 60,         # 回るかの確かめで、数えない慣らしの時間（分）
    "util_sample_s": 10,      # 手がふさがっている人数・台数を数える間隔（秒）
    "max_extra_min": 600,     # 数える時間のあと、追いかけるのを打ち切る上限（分）。店が回らない条件の保険
    "robot_first_wait_s": 120,  # v2.7 ロボット優先：料理はロボットを待つ。これだけ待っても来なければスタッフが運ぶ（秒）
    "model": "v2.8",          # モデルの版（前の版の結果を使い回さないための印）
}

# ---------------------------------------------------------------
# 店のタイプ（すべて仮）
#   dist_m        : 厨房（洗い場）から卓までの距離の幅
#   first_dishes  : 最初の注文の皿。料理ができるまで（最短, 最頻, 最長 分）
#   extra_orders  : 食事中の追加注文の回数（食べ放題）
#   bus_trips     : 下げ膳を人だけでやるときの往復回数（皿の量）
#   dish_extra_m  : 洗い場が厨房の受け渡し口よりさらに奥にあるときの追加距離（ホテル小倉の事例）
#   fragile       : 1組あたりの倒れやすい物（デザート・パフェ・ドリンク）の数の平均
#   staff         : 最初に見せる人数
#   staff_all     : 「その他」で選べる人数（総当たりはこちら）
#   arrivals      : 来客ペース（空いている・普通・混雑）
#   registers     : 最初に見せるレジの数（v2.8）。registers_all は選べる数（総当たりはこちら）
# ---------------------------------------------------------------
STORES = {
    "small": {
        "label": "小型店", "tables": 18, "dist_m": (4, 12), "peak_min": 120,
        "first_dishes": [(4, 7, 11), (6, 9, 14)], "extra_orders": 0, "extra_cook": (3, 5, 8),
        "eat_min": (15, 25, 40), "call_rate": 0.4, "bus_trips": 1, "fragile": 0.3,
        "staff": [1, 2, 3], "staff_all": [1, 2, 3, 4, 5], "arrivals": [15, 22, 30],
        "registers": 1, "registers_all": [1, 2],
    },
    "family": {
        "label": "標準ファミレス", "tables": 35, "dist_m": (6, 22), "peak_min": 120,
        "first_dishes": [(5, 8, 12), (8, 12, 18)], "extra_orders": 0, "extra_cook": (3, 5, 8),
        "eat_min": (20, 30, 45), "call_rate": 0.6, "bus_trips": 2, "fragile": 0.5,
        "staff": [2, 3, 4], "staff_all": [1, 2, 3, 4, 5, 6, 7], "arrivals": [25, 35, 45],
        "registers": 1, "registers_all": [1, 2],
    },
    "large": {
        "label": "大型店", "tables": 60, "dist_m": (8, 35), "peak_min": 120,
        "first_dishes": [(5, 8, 12), (8, 12, 18)], "extra_orders": 0, "extra_cook": (3, 5, 8),
        "eat_min": (20, 30, 45), "call_rate": 0.6, "bus_trips": 2, "fragile": 0.5,
        "staff": [4, 5, 6], "staff_all": [2, 3, 4, 5, 6, 7, 8, 9, 10], "arrivals": [45, 60, 75],
        "registers": 2, "registers_all": [1, 2],
    },
    "hotel": {
        "label": "洗い場が遠い店", "tables": 30, "dist_m": (6, 20), "dish_extra_m": 30, "peak_min": 120,
        "first_dishes": [(5, 8, 12), (8, 12, 18)], "extra_orders": 2, "extra_cook": (4, 6, 10),
        "eat_min": (30, 45, 60), "call_rate": 0.6, "bus_trips": 2, "fragile": 0.6,
        "staff": [2, 3, 4], "staff_all": [1, 2, 3, 4, 5, 6, 7], "arrivals": [16, 22, 28],
        "registers": 1, "registers_all": [1, 2],
    },
    "buffet": {
        "label": "焼肉食べ放題", "tables": 35, "dist_m": (6, 22), "peak_min": 150,
        "first_dishes": [(2, 4, 8), (3, 5, 9), (4, 7, 12)], "extra_orders": 5, "extra_cook": (2, 4, 7),
        "eat_min": (60, 80, 90), "call_rate": 0.8, "bus_trips": 3, "fragile": 1.5,
        "staff": [2, 3, 4], "staff_all": [1, 2, 3, 4, 5, 6, 7], "arrivals": [14, 20, 26],
        "registers": 1, "registers_all": [1, 2],
    },
}

# ---------------------------------------------------------------
# 図面のある店（v2.3）：標準ファミレスの中の、店の形から作った店
# ---------------------------------------------------------------
import os as _os
LAYOUT_DIR = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "layouts")
LAYOUT_LABELS = {
    "p0": "② 定番の郊外型", "p1": "③ 効率重視・入口が角", "p2": "④ 効率重視・小さめの店",
    "p3": "⑤ ゆったり・入口が正面", "p4": "⑥ ゆったり・入口が横", "p5": "⑦ ③の通路を95cmに",
}


def _scale(vals, t, base_t):
    return [max(1, round(v * t / base_t)) for v in vals]


def layout_stores():
    """layouts/ の店の形から、標準ファミレスをもとにした店を作る"""
    out = {}
    base = STORES["family"]
    names = sorted(f for f in _os.listdir(LAYOUT_DIR) if f.endswith("_analysis.json")) if _os.path.isdir(LAYOUT_DIR) else []
    for f in names:
        a = json.load(open(_os.path.join(LAYOUT_DIR, f), encoding="utf-8"))
        nm = f.split("_")[0]
        dists = [t["dist"] for t in a["tables"]]
        if any(d is None for d in dists):
            raise ValueError(f"{f}: ロボットが届かない卓がある（人が運ぶ卓はまだ試算に入れていない）")
        t = len(dists)
        c = max(2, round(base["staff"][1] * t / base["tables"]))      # 真ん中の人数
        st = {**base}
        st.update({
            "label": LAYOUT_LABELS.get(nm, nm), "base": "family", "layout": nm,
            "tables": t, "dist_m": (round(min(dists), 1), round(max(dists), 1)), "dist_list": dists,
            "arrivals": _scale(base["arrivals"], t, base["tables"]),
            "staff": [c - 1, c, c + 1], "staff_all": list(range(max(1, c - 2), c + 5)),
        })
        out["fam_" + nm] = st
    return out


STORES.update(layout_stores())

ROBOTS = [0, 1, 2, 3]          # 最初に見せる台数
ROBOTS_ALL = list(range(7))    # 「その他」で選べる台数（総当たりはこちら）
MODES = ["serve", "bus", "patrol"]
TRAYS = [1, 3]                 # 1往復で回る卓の数（1卓ずつ／最大3卓）
FRAGILE_BY = ["any", "staff"]  # 倒れやすい物を ロボットも運ぶ／人だけが運ぶ
REPS = 40


def tri(rng, lmh):
    lo, mode, hi = lmh
    return rng.triangular(lo, hi, mode)


class Store:
    def __init__(self, env, rng, p):
        self.env, self.rng, self.p = env, rng, p
        self.tables = simpy.Resource(env, capacity=p["tables"])
        self.staff = simpy.PriorityResource(env, capacity=p["staff"])
        self.register = simpy.Resource(env, capacity=p["registers"])   # v2.8 レジ。会計はレジ1つにつき1組ずつ
        self.pay_waits = []             # 組ごと：食べ終えてから会計が始まるまで（分）（v2.8）
        self.robots = simpy.PriorityResource(env, capacity=p["robots"]) if p["robots"] else None
        self.dish_waits, self.seat_waits, self.turns = [], [], []
        self.staff_walk_m = self.staff_busy_s = self.robot_busy_s = 0.0
        self.robot_trips = self.staff_trips = self.rescues = self.collects = self.done = 0
        self.robot_bus = self.staff_bus = 0
        self.fragile_robot = self.pauses = 0
        self.pass_q = []  # 受け渡し口でロボットを待っている料理
        # 回るかの確かめ（sustain）で数える時間。ピークの回し方では全部の組を数える
        self.t0 = p["warmup_min"] * 60 if p["sustain"] else 0
        self.t1 = self.t0 + p["peak_min"] * 60
        self.counted = self.counted_done = 0
        self.window_closed = False
        self.finished = env.event()
        self.capped = False
        self.seat_wait_by_arrival = []  # (来た時刻, 入店待ち 分)。待ちが伸び続けるかを見る
        self.staff_busy_n = []          # 数える時間のあいだ、一定間隔で数えた手がふさがっている人数
        self.robot_busy_n = []
        self.snap0 = self.snap1 = None
        # 入店待ちの内訳（v2.2）
        #   片付けが終わらず使えない卓（客は帰ったが下げ膳・卓拭きが済んでいない）の数
        #   入口の列の前から数えて、その数までの組は「席はあるのに待っている」とみなす
        self.dirty = 0
        self._acc_t = 0.0
        self.blocked = {}               # 卓の順番待ちの request → 片付け待ちで待った秒数
        self.blocked_waits = []         # 組ごと：席はあるのに片付けが終わらず待った時間（分）
        self.guide_waits = []           # 組ごと：卓が決まってから、案内のスタッフの手が空くまで（分）
        self.entry_waits = []           # 組ごと：店に来てから、案内されて席に着くまで（分）

    def _acc(self):
        """前回からの時間を、列の前から片付け待ちの卓の数までの組に足す（列や卓の数が変わる直前に呼ぶ）"""
        now = self.env.now
        dt = now - self._acc_t
        if dt > 0 and self.dirty > 0:
            for req in self.tables.queue[:self.dirty]:
                self.blocked[req] = self.blocked.get(req, 0.0) + dt
        self._acc_t = now

    SNAP = ("staff_walk_m", "robot_trips", "staff_trips", "rescues", "collects",
            "robot_bus", "staff_bus", "fragile_robot", "pauses")

    def _snap(self):
        return {k: getattr(self, k) for k in self.SNAP}

    def window(self):
        """数える時間の始めと終わりに数を控え、そのあいだは手がふさがっている人数・台数を数える"""
        env, p = self.env, self.p
        yield env.timeout(self.t0)
        self.snap0 = self._snap()
        while env.now < self.t1:
            self.staff_busy_n.append(self.staff.count)
            if self.robots is not None:
                self.robot_busy_n.append(self.robots.count)
            yield env.timeout(p["util_sample_s"])
        self.snap1 = self._snap()
        self.window_closed = True
        self._check_finished()
        yield env.timeout(p["max_extra_min"] * 60)
        if not self.finished.triggered:   # 店が回らず、数える組が出きらない
            self.capped = True
            self.finished.succeed()

    def _check_finished(self):
        if self.window_closed and self.counted_done >= self.counted and not self.finished.triggered:
            self.finished.succeed()

    # ---- スタッフの仕事（priority 0=ロボット救出, 1=配膳・案内・会計, 2=下げ膳）
    def staff_job(self, priority, walk_m, work_s, waited=None):
        t0 = self.env.now
        with self.staff.request(priority=priority) as req:
            yield req
            if waited is not None:
                waited.append(self.env.now - t0)   # 手が空くのを待った時間（案内待ち）
            t = walk_m / self.p["staff_speed"] + work_s
            self.staff_walk_m += walk_m
            self.staff_busy_s += t
            yield self.env.timeout(t)

    # ---- ロボットが途中で止まる（1往復に1回判定）
    def maybe_stop(self, d, helper_waiting=False):
        p, rng = self.p, self.rng
        if rng.random() < p["pause_rate"]:      # 一時停止：数秒で自然に解ける
            self.pauses += 1
            yield self.env.timeout(rng.uniform(*p["pause_s"]))
        if rng.random() < p["stuck_rate"]:      # 立ち往生：スタッフが動かしに行く
            self.rescues += 1
            if helper_waiting:                  # 卓でロボットを待っているスタッフがそのまま動かす
                yield self.env.timeout(d / p["staff_speed"] + p["rescue_s"])
            else:
                yield self.env.process(self.staff_job(0, d, p["rescue_s"]))

    # ---- 配膳
    # v2.7 ロボット優先（2026-10-23 エイイチと決めた）：料理ができたら、まずロボットを待つ。スタッフは料理を持たず、
    # ほかの仕事（案内・片付け・会計）を続ける。robot_first_wait_s（2分）待ってもロボットが空かなければ、
    # そこからはロボットとスタッフのうち先に空いた方が運ぶ（料理が冷める前の逃げ道）。
    # 割れ物を人が運ぶ設定（fragile_by=staff）と、ロボット0台のときは、今までどおりスタッフが運ぶ。
    # v2.6 までは、はじめから「先に空いた方」だったため、受け渡し口の横で待つスタッフが多く運んでいた。
    # ロボットが出るときは、受け渡し口で待っている料理を段の数までまとめて載せ、近い卓から回る。
    def deliver(self, d, fragile=False, counted=True):
        env, p = self.env, self.p
        rec = {"d": d, "ready": env.now, "fragile": fragile, "counted": counted,
               "taken": env.event(), "arrived": env.event()}
        robot_ok = self.robots is not None and not (fragile and p["fragile_by"] == "staff")
        if not robot_ok:
            sreq = self.staff.request(priority=1)
            yield sreq
            yield env.process(self._staff_carry(rec, sreq))
            return
        rreq = self.robots.request(priority=0)
        rec["sreq"], rec["rreq"] = None, rreq
        self.pass_q.append(rec)
        yield rreq | rec["taken"] | env.timeout(p["robot_first_wait_s"])   # まずロボットだけを待つ
        if not (rreq.triggered or rec["taken"].triggered):                # 2分たった：ここからはスタッフも運べる
            sreq = self.staff.request(priority=1)
            rec["sreq"] = sreq
            yield rreq | sreq | rec["taken"]
        sreq = rec["sreq"]
        if rec["taken"].triggered:          # ほかの料理と一緒にロボットに載った
            yield rec["arrived"]
            return
        self.pass_q.remove(rec)
        if rreq.triggered:                  # ロボットが運ぶ
            if sreq is not None:
                self._drop(sreq, self.staff)
            batch = [rec]
            for o in list(self.pass_q):
                if len(batch) >= p["trays"]:
                    break
                self.pass_q.remove(o)
                self._drop(o["rreq"], self.robots)
                if o["sreq"] is not None:
                    self._drop(o["sreq"], self.staff)
                o["taken"].succeed()
                batch.append(o)
            env.process(self._robot_run(batch, rreq))
            yield rec["arrived"]
            return
        rreq.cancel()                       # スタッフが運ぶ
        yield env.process(self._staff_carry(rec, sreq))

    @staticmethod
    def _drop(req, res):
        if req.triggered:
            res.release(req)
        else:
            req.cancel()

    def _robot_run(self, batch, rreq):
        env, p = self.env, self.p
        start = env.now
        batch.sort(key=lambda r: r["d"])
        pos = 0.0
        for i, rec in enumerate(batch):
            leg = rec["d"] - pos + (p["hop_m"] if i else 0)
            yield env.timeout(max(leg, 0) / p["robot_speed"])
            if i == 0:
                yield env.process(self.maybe_stop(rec["d"]))
            if rec["counted"]:
                self.dish_waits.append((env.now - rec["ready"]) / 60)
            self.robot_trips += 1
            self.fragile_robot += rec["fragile"]
            rec["arrived"].succeed()
            yield env.timeout(p["robot_handoff_s"])
            pos = rec["d"]
        yield env.timeout(pos / p["robot_speed"])
        self.robot_busy_s += env.now - start
        self.robots.release(rreq)

    def _staff_carry(self, rec, sreq):
        env, p = self.env, self.p
        self.staff_trips += 1
        go = rec["d"] / p["staff_speed"]
        yield env.timeout(go)
        if rec["counted"]:
            self.dish_waits.append((env.now - rec["ready"]) / 60)
        rec["arrived"].succeed()
        yield env.timeout(p["staff_place_s"] + go)
        self.staff_walk_m += 2 * rec["d"]
        self.staff_busy_s += 2 * go + p["staff_place_s"]
        self.staff.release(sreq)

    # ---- 巡回回収（食事中にロボットが来て、客が空いた皿を載せる）
    def collect_visit(self, d, deadline, box):
        env, p = self.env, self.p
        req = self.robots.request(priority=2)
        timer = env.timeout(max(deadline - env.now, 0))
        res = yield req | timer
        if req not in res:
            req.cancel()
            return
        start = env.now
        t = d / p["robot_speed"]
        back = (d + p["dish_extra_m"]) / p["robot_speed"]
        yield env.timeout(t)
        yield env.process(self.maybe_stop(d))
        yield env.timeout(p["guest_load_s"] + back)
        self.robot_busy_s += env.now - start
        self.robots.release(req)
        self.collects += 1
        box[0] += 1

    # ---- 下げ膳（客が帰ってから卓が使えるようになるまで）
    def bus(self, d, collected, counted=True):
        env, p = self.env, self.p
        start = env.now
        trips = max(p["bus_trips"] - collected, 0)
        if p["mode"] == "bus" and self.robots is not None and trips > 0:
            # v2.4: スタッフの手が空いて片付けに向かうときに、空いているロボットがいれば一緒に卓へ向かわせる
            with self.staff.request(priority=2) as sreq:
                yield sreq
                s0 = env.now
                rreq = self.robots.request(priority=1)
                if rreq.triggered:
                    self.robot_bus += 1
                    arrive, loaded = env.event(), env.event()
                    env.process(self._robot_bus_trip(d, rreq, arrive, loaded))
                    # スタッフは卓まで行き、皿をまとめ、ロボットが着いたら載せ、卓を拭いて戻る
                    yield env.timeout(d / p["staff_speed"] + p["clear_s"])
                    yield arrive
                    yield env.timeout(p["load_s"])
                    loaded.succeed()                 # 載せ終えた。ロボットは洗い場へ
                    yield env.timeout(p["wipe_s"])
                    done_at = env.now
                    yield env.timeout(d / p["staff_speed"])
                    self.staff_walk_m += 2 * d
                    self.staff_busy_s += env.now - s0
                    if counted:
                        self.turns.append((done_at - start) / 60)
                    return
                rreq.cancel()
                # 空いているロボットがいない：そのまま人だけで下げる（皿の量に応じて往復）
                self.staff_bus += 1
                walk = 2 * (d + p["dish_extra_m"]) * trips
                tt = walk / p["staff_speed"] + p["clear_s"] + p["wipe_s"]
                self.staff_walk_m += walk
                self.staff_busy_s += tt
                yield env.timeout(tt)
            if counted:
                self.turns.append((env.now - start) / 60)
            return
        # 人だけで下げる（皿の量に応じて往復）。皿がもうなければ拭きに行くだけ
        self.staff_bus += 1
        walk = 2 * (d + p["dish_extra_m"]) * trips if trips > 0 else 2 * d
        work = (p["clear_s"] if trips > 0 else 0) + p["wipe_s"]
        yield env.process(self.staff_job(2, walk, work))
        if counted:
            self.turns.append((env.now - start) / 60)

    def _robot_bus_trip(self, d, rreq, arrive, loaded):
        env, p = self.env, self.p
        start = env.now
        t = d / p["robot_speed"]
        yield env.timeout(t)
        yield env.process(self.maybe_stop(d, helper_waiting=True))
        arrive.succeed()
        yield loaded                                  # v2.4: スタッフが載せ終えるまで卓で待つ
        back = (d + p["dish_extra_m"]) / p["robot_speed"]
        yield env.timeout(back + 15)  # 洗い場で降ろすのは厨房側
        self.robot_busy_s += env.now - start
        self.robots.release(rreq)

    # ---- 1組の流れ
    def group(self):
        env, p, rng = self.env, self.p, self.rng
        arrive = env.now
        counted = (self.t0 <= arrive < self.t1) if p["sustain"] else True   # 数える時間に来た組だけを記録する
        if counted:
            self.counted += 1
        # 図面のある店は卓ごとの距離から選ぶ。ほかの店は今までどおり（乱数の使い方も同じ）
        d = rng.choice(p["dist_list"]) if p.get("dist_list") else rng.uniform(*p["dist_m"])
        self._acc()
        table = self.tables.request()
        yield table
        blocked = self.blocked.pop(table, 0.0)
        if counted:
            w = (env.now - arrive) / 60
            self.seat_waits.append(w)
            self.seat_wait_by_arrival.append((arrive, w))
        # 案内（v2.4）: スタッフの手が空いたら卓まで一緒に歩く。卓に着いた時点を「席に着いた」とする。
        # 説明（guide_s）と受け渡し口への戻りは、スタッフの手がふさがる時間には入れ、お客さんの注文はそのあとに進む（v2.2 と同じ）
        g0 = env.now
        with self.staff.request(priority=1) as greq:
            yield greq
            gwait = env.now - g0
            yield env.timeout(d / p["staff_speed"])
            seated_at = env.now
            yield env.timeout(p["guide_s"] + d / p["staff_speed"])
            self.staff_walk_m += 2 * d
            self.staff_busy_s += 2 * d / p["staff_speed"] + p["guide_s"]
        if counted:
            self.blocked_waits.append(blocked / 60)
            self.guide_waits.append(gwait / 60)
            self.entry_waits.append((seated_at - arrive) / 60)
        firsts = [env.process(self._cook_then_deliver(c, d, False, counted)) for c in p["first_dishes"]]
        yield simpy.AllOf(env, firsts)
        eat = tri(rng, p["eat_min"]) * 60
        end = env.now + eat
        # 食事中の追加注文・呼び出し・巡回回収
        extras = []
        for _ in range(p["extra_orders"]):
            extras.append(env.process(self._order_later(rng.uniform(0, eat * 0.7), d, p["extra_cook"], False, counted)))
        n_fragile = int(p["fragile"]) + (rng.random() < p["fragile"] - int(p["fragile"]))
        for _ in range(n_fragile):
            extras.append(env.process(self._order_later(rng.uniform(0.3, 0.85) * eat, d, p["fragile_cook"], True, counted)))
        calls = sum(rng.random() < p["call_rate"] / 2 for _ in range(2))
        for _ in range(calls):
            env.process(self._call_later(rng.uniform(0, eat), d))
        collected = [0]
        if p["mode"] == "patrol" and self.robots is not None:
            for k in range(p["bus_trips"]):
                at = eat * (k + 1) / (p["bus_trips"] + 1)
                env.process(self._collect_later(at, d, end, collected))
        yield env.timeout(eat)
        if extras:
            yield simpy.AllOf(env, extras)
        # v2.8 会計：卓に座ったままレジの順番を待ち、順番が来たらスタッフの手が空くのを待って会計する
        p0 = env.now
        with self.register.request() as rq:
            yield rq
            yield env.process(self.staff_job(1, 0, p["pay_s"]))
        if counted:
            self.pay_waits.append((env.now - p0 - p["pay_s"]) / 60)
        self._acc()
        self.dirty += 1                      # 客は帰った。片付けが済むまで卓は使えない
        yield env.process(self.bus(d, collected[0], counted))
        self._acc()
        self.dirty -= 1
        self.tables.release(table)
        self.done += 1
        if counted:
            self.counted_done += 1
            self._check_finished()

    def _cook_then_deliver(self, cook, d, fragile=False, counted=True):
        yield self.env.timeout(tri(self.rng, cook) * 60)
        yield self.env.process(self.deliver(d, fragile, counted))

    def _order_later(self, delay, d, cook, fragile, counted=True):
        yield self.env.timeout(delay)
        yield self.env.process(self._cook_then_deliver(cook, d, fragile, counted))

    def _call_later(self, delay, d):
        yield self.env.timeout(delay)
        yield self.env.process(self.staff_job(1, 2 * d, self.p["call_s"]))

    def _collect_later(self, delay, d, deadline, box):
        yield self.env.timeout(delay)
        yield self.env.process(self.collect_visit(d, deadline, box))

    def arrivals(self):
        env, p, rng = self.env, self.p, self.rng
        rate = p["arrivals_per_h"] / 3600
        while not self.finished.triggered:
            yield env.timeout(rng.expovariate(rate))
            if not p["sustain"] and env.now > p["peak_min"] * 60:
                return                       # ピークの回し方：peak_min 分で来客が止まる
            env.process(self.group())        # 回るかの確かめ：来客は止めない（数える組が出きるまで）


UNSTABLE_MIN = 5    # 参考：数える時間のうちに入店待ちがこれ以上延びていく条件（判定には使わない。卓が足りない店の見分けに使う）
NG_MIN = 10         # 席は空いているのに待った時間（staff_wait_mean）がこれ以上なら「店が回らない」とする（分）
STAFF_FULL = 0.95   # 回るかの確かめで、スタッフの手がこれ以上ふさがっていたら「店が回らない」とする


def run_once(params, seed):
    store = STORES[params["store"]]
    p = {**COMMON, **store, **params}
    env = simpy.Environment()
    s = Store(env, random.Random(seed), p)
    env.process(s.arrivals())
    if p["sustain"]:
        return _sustain_result(env, s, p)
    env.run()
    end = env.now
    w = s.dish_waits
    return {
        "dish_wait_mean": statistics.mean(w),
        "dish_wait_p90": sorted(w)[int(0.9 * (len(w) - 1))],
        "cold_share": sum(x > p["cold_min"] for x in w) / len(w),
        "seat_wait_mean": statistics.mean(s.seat_waits),
        "turn_mean": statistics.mean(s.turns) if s.turns else 0.0,
        "walk_km_per_staff": s.staff_walk_m / p["staff"] / 1000,
        "staff_util": s.staff_busy_s / (p["staff"] * end),
        "robot_util": (s.robot_busy_s / (p["robots"] * end)) if p["robots"] else 0.0,
        "robot_share": s.robot_trips / max(s.robot_trips + s.staff_trips, 1),
        "robot_bus_share": s.robot_bus / max(s.robot_bus + s.staff_bus, 1),
        "collects": s.collects,
        "rescues": s.rescues,
        "pauses": s.pauses,
        "fragile_robot": s.fragile_robot,
        "groups": s.done,
        # 入店待ちの内訳（v2.2）
        "entry_wait_mean": statistics.mean(s.entry_waits),            # 来てから案内されて卓に着くまで（v2.4）
        "blocked_wait_mean": statistics.mean(s.blocked_waits),        # 席はあるのに片付けが終わらず待った
        "guide_wait_mean": statistics.mean(s.guide_waits),            # 卓は決まったが案内の手が空かず待った
        "staff_wait_mean": statistics.mean(b + g for b, g in zip(s.blocked_waits, s.guide_waits)),  # 席はあるのに待った（合計）
        "full_wait_mean": statistics.mean(w - b for w, b in zip(s.seat_waits, s.blocked_waits)),    # 席が埋まっていて待った
        "pay_wait_mean": statistics.mean(s.pay_waits),                # v2.8 食べ終えてから会計が始まるまで（レジの順番とスタッフの手）
    }


def _sustain_result(env, s, p):
    """回るかの確かめ：来客を止めずに回し、数える時間の結果を出す"""
    env.process(s.window())
    env.run(until=s.finished)
    win_s = p["peak_min"] * 60
    q = win_s / 4
    first = [x for t, x in s.seat_wait_by_arrival if t < s.t0 + q]
    last = [x for t, x in s.seat_wait_by_arrival if t >= s.t1 - q]
    trend = (statistics.mean(last) - statistics.mean(first)) if first and last else 0.0
    return {
        "seat_wait_mean": statistics.mean(s.seat_waits),
        "seat_wait_trend": trend,
        "staff_util": statistics.mean(s.staff_busy_n) / p["staff"],
        "capped": 1.0 if s.capped else 0.0,
    }


def conditions(main_only=False):
    for key, st in STORES.items():
        staffs = st["staff"] if main_only else st["staff_all"]
        robots_list = ROBOTS if main_only else ROBOTS_ALL
        for staff, robots, arr, mode, trays, fb, reg in product(staffs, robots_list, st["arrivals"], MODES, TRAYS, FRAGILE_BY,
                                                                st["registers_all"]):
            if robots == 0 and (mode != "serve" or trays != 1 or fb != "any"):
                continue  # ロボットなしなら、使い方・段の数・使い分けは関係ない
            yield {"store": key, "staff": staff, "robots": robots, "arrivals_per_h": arr,
                   "mode": mode, "trays": trays, "fragile_by": fb, "registers": reg}


def run_condition(params):
    runs = [run_once(params, seed) for seed in range(REPS)]
    agg = {k: round(statistics.mean(r[k] for r in runs), 4) for k in runs[0]}
    return {**params, **agg}


def key(c):
    return (c["store"], c["staff"], c["robots"], c["arrivals_per_h"], c["mode"], c["trays"], c["fragile_by"], c["registers"])


def model_sig(common, stores):
    """結果に効く前提だけを取り出す（人数・台数の選択肢や名前は含めない）"""
    skip = {"label", "staff", "staff_all", "arrivals", "registers", "registers_all"}
    return json.loads(json.dumps({"common": common, "reps": REPS,
                                  "stores": {k: {a: b for a, b in v.items() if a not in skip} for k, v in stores.items()}}))


if __name__ == "__main__":
    if "--one" in sys.argv:
        for mode in MODES:
            for trays, fb in product(TRAYS, FRAGILE_BY):
                r = run_condition({"store": "family", "staff": 3, "robots": 2, "arrivals_per_h": 35,
                                   "mode": mode, "trays": trays, "fragile_by": fb})
                print(mode, trays, fb, {k: round(v, 2) for k, v in r.items() if isinstance(v, float)})
    elif "--sustain" in sys.argv:
        # 回るかの確かめ：全条件を、来客を止めない回し方で回す
        conds = [{**c, "sustain": True} for c in conditions()]
        done = {}
        try:
            with open("results_sustain_partial.json", encoding="utf-8") as f:
                old = json.load(f)
            if old.get("common") == COMMON and old.get("reps") == REPS and old.get("unstable_min") == UNSTABLE_MIN:
                done = {key(r): r for r in old["rows"]}
        except (FileNotFoundError, KeyError, TypeError, json.JSONDecodeError):
            pass

        def save_s(path, rows):
            out = {"common": COMMON, "reps": REPS, "unstable_min": UNSTABLE_MIN, "rows": rows}
            with open(path + ".tmp", "w", encoding="utf-8") as f:
                json.dump(out, f, ensure_ascii=False)
            import os
            os.replace(path + ".tmp", path)

        todo = [c for c in conds if key(c) not in done]
        print(f"{len(conds)} conditions, {len(conds) - len(todo)} reused, {len(todo)} to run", flush=True)
        with Pool() as pool:
            for i, r in enumerate(pool.imap_unordered(run_condition, todo, chunksize=2), 1):
                done[key(r)] = r
                if i % 100 == 0:
                    save_s("results_sustain_partial.json", list(done.values()))
                    print(f"  {i}/{len(todo)}", flush=True)
        save_s("results_sustain_partial.json", list(done.values()))
        save_s("results_sustain.json", [done[key(c)] for c in conds])
        print(len(conds), "conditions written", flush=True)
    else:
        conds = list(conditions(main_only="--main" in sys.argv))
        sig = model_sig(COMMON, STORES)
        # 前提が同じなら、前回の結果と途中経過を使い回して足りない条件だけ回す
        cache = {}
        for path in ("results.json", "results_partial.json"):
            try:
                with open(path, encoding="utf-8") as f:
                    old = json.load(f)
                if old.get("reps") == REPS:
                    osig = model_sig(old["common"], old["stores"])
                    same = {k for k in sig["stores"] if osig["common"] == sig["common"] and osig["stores"].get(k) == sig["stores"][k]}
                    cache.update({key(r): r for r in old["rows"] if r["store"] in same})
            except (FileNotFoundError, KeyError, TypeError, json.JSONDecodeError):
                pass

        def save(path, rows):
            out = {"common": COMMON, "stores": STORES, "robots": ROBOTS, "robots_all": ROBOTS_ALL,
                   "modes": MODES, "trays": TRAYS, "fragile_by": FRAGILE_BY, "reps": REPS, "rows": rows}
            with open(path + ".tmp", "w", encoding="utf-8") as f:
                json.dump(out, f, ensure_ascii=False)
            import os
            os.replace(path + ".tmp", path)

        todo = [c for c in conds if key(c) not in cache]
        print(f"{len(conds)} conditions, {len(conds) - len(todo)} reused, {len(todo)} to run", flush=True)
        with Pool() as pool:
            for i, r in enumerate(pool.imap_unordered(run_condition, todo, chunksize=2), 1):
                cache[key(r)] = r
                if i % 100 == 0:
                    save("results_partial.json", list(cache.values()))
                    print(f"  {i}/{len(todo)}", flush=True)
        save("results_partial.json", list(cache.values()))
        save("results.json", [cache[key(c)] for c in conds])
        print(len(conds), "conditions written", flush=True)
```

### simulation/v2_8/run_shard.py

```python
"""v2.8 の全条件を、番号で分けて回す（2026-10-25。計算機を2台に分けて速くするため）。
使い方: python3 run_shard.py <k> <n> <出力.json> [使い回す結果.json ...]
- 条件の並び（haizen_sim.conditions()）の番号を n で割った余りが k のものだけ回す
- 使い回す結果に同じ条件（同じ前提）があれば回さない。100条件ごとに出力へ途中経過を書く"""
import json, os, sys
from multiprocessing import Pool
import haizen_sim as h

k, n, out = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
common = json.loads(json.dumps(h.COMMON))
done = {}
for path in sys.argv[4:] + [out]:
    try:
        old = json.load(open(path, encoding="utf-8"))
        if old.get("common") == common and old.get("reps") == h.REPS:
            done.update({h.key(r): r for r in old["rows"]})
    except FileNotFoundError:
        pass
conds = [c for i, c in enumerate(h.conditions()) if i % n == k]
todo = [c for c in conds if h.key(c) not in done]
print(f"shard {k}/{n}: {len(conds)} conditions, {len(todo)} to run", flush=True)

def save():
    rows = [done[h.key(c)] for c in conds if h.key(c) in done]
    with open(out + ".tmp", "w", encoding="utf-8") as f:
        json.dump({"common": h.COMMON, "reps": h.REPS, "shard": [k, n], "rows": rows}, f, ensure_ascii=False)
    os.replace(out + ".tmp", out)

with Pool() as pool:
    for i, r in enumerate(pool.imap_unordered(h.run_condition, todo, chunksize=2), 1):
        done[h.key(r)] = r
        if i % 100 == 0:
            save(); print(f"  {i}/{len(todo)}", flush=True)
save(); print("done", flush=True)
```

### simulation/v2_8/run_timed.py

```python
"""run_shard.py と同じ分け方で、決めた秒数だけ回して途中経過を書く（Mac 側は1回の呼び出しが3分までなので、区切って回す）。
使い方: python3 run_timed.py <k> <n> <出力.json> <秒> [使い回す結果.json ...]"""
import json, os, sys, time
from multiprocessing import Pool
import haizen_sim as h
k, n, out, limit = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3], float(sys.argv[4])
t0 = time.time()
common = json.loads(json.dumps(h.COMMON))
done = {}
for path in sys.argv[5:] + [out]:
    try:
        old = json.load(open(path, encoding="utf-8"))
        if old.get("common") == common and old.get("reps") == h.REPS:
            done.update({h.key(r): r for r in old["rows"]})
    except FileNotFoundError:
        pass
conds = [c for i, c in enumerate(h.conditions()) if i % n == k]
todo = [c for c in conds if h.key(c) not in done]
def save():
    rows = [done[h.key(c)] for c in conds if h.key(c) in done]
    with open(out + ".tmp", "w", encoding="utf-8") as f:
        json.dump({"common": h.COMMON, "reps": h.REPS, "shard": [k, n], "rows": rows}, f, ensure_ascii=False)
    os.replace(out + ".tmp", out)
got = 0
pool = Pool()
for r in pool.imap_unordered(h.run_condition, todo, chunksize=1):
    done[h.key(r)] = r; got += 1
    if time.time() - t0 > limit:
        break
pool.terminate()
save()
print(f"shard {k}/{n}: +{got} / left {len(todo) - got}", flush=True)
```

### simulation/v2_8/run_timed_rev.py

```python
"""run_shard.py と同じ分け方で、決めた秒数だけ回して途中経過を書く（Mac 側は1回の呼び出しが3分までなので、区切って回す）。
使い方: python3 run_timed.py <k> <n> <出力.json> <秒> [使い回す結果.json ...]"""
import json, os, sys, time
from multiprocessing import Pool
import haizen_sim as h
k, n, out, limit = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3], float(sys.argv[4])
t0 = time.time()
common = json.loads(json.dumps(h.COMMON))
done = {}
for path in sys.argv[5:] + [out]:
    try:
        old = json.load(open(path, encoding="utf-8"))
        if old.get("common") == common and old.get("reps") == h.REPS:
            done.update({h.key(r): r for r in old["rows"]})
    except FileNotFoundError:
        pass
conds = [c for i, c in enumerate(h.conditions()) if i % n == k]
todo = [c for c in conds if h.key(c) not in done][::-1]   # 後ろから回す（クラウドは前から回している）
def save():
    rows = [done[h.key(c)] for c in conds if h.key(c) in done]
    with open(out + ".tmp", "w", encoding="utf-8") as f:
        json.dump({"common": h.COMMON, "reps": h.REPS, "shard": [k, n], "rows": rows}, f, ensure_ascii=False)
    os.replace(out + ".tmp", out)
got = 0
pool = Pool()
for r in pool.imap_unordered(h.run_condition, todo, chunksize=1):
    done[h.key(r)] = r; got += 1
    if time.time() - t0 > limit:
        break
pool.terminate()
save()
print(f"shard {k}/{n}: +{got} / left {len(todo) - got}", flush=True)
```

### simulation/v2_8/build_page_v28.py

```python
"""試算 v2.8（レジの数を条件に足した版）の結果を、公開中の試算のページに入れる（2026-10-25）。
使い方: python3 build_page_v28.py <公開中のページ> <v2.8 の results.json> <出力するページ>
- const D：条件に registers（レジの数）を足し、結果に pay_wait_mean（食べ終えてから会計まで）を足す。店の情報・平面図は公開中のまま、レジの数だけ足す
- 画面：条件に「レジ」を足す。結果に「食べ終えてから会計まで」を足す。前提に会計とレジの決まりを足す。見出しの版を v2.8 にする
- 費用の欄・LP 用の色・使われ方の記録は公開中のまま"""
import json, re, sys
import haizen_sim as h

live, res, out = sys.argv[1:4]
s = open(live, encoding="utf-8").read()
N = json.load(open(res, encoding="utf-8"))
assert N["common"] == json.loads(json.dumps(h.COMMON)), "結果が今の haizen_sim.py の前提と違う"
assert N["common"]["model"] == "v2.8"

getD = lambda t: [l for l in t.split("\n") if l.startswith("const D = ")]
ol = getD(s); assert len(ol) == 1
D = json.loads(ol[0][len("const D = "):].rstrip(";"))
old_k = D["k"]
assert old_k[:7] == ["store", "staff", "robots", "arrivals_per_h", "mode", "trays", "fragile_by"] and old_k[-1] == "ng"
cond = old_k[:7] + ["registers"]
mets = old_k[7:-1] + ["pay_wait_mean"]
D["k"] = cond + mets + ["ng"]
conds = list(h.conditions())
assert len(N["rows"]) == len(conds)
rows = []
for c, r in zip(conds, N["rows"]):
    assert all(r[k] == c[k] for k in cond), ("条件の並びが違う", c, r)
    rows.append([r[k] for k in cond] + [round(r[k], 3) for k in mets] + [1 if r["staff_wait_mean"] >= h.NG_MIN else 0])
D["r"] = rows
for k, st in D["stores"].items():
    st["registers"] = h.STORES[k]["registers"]
    st["registers_all"] = h.STORES[k]["registers_all"]
D["common"] = N["common"]
s = s.replace(ol[0], "const D = " + json.dumps(D, ensure_ascii=False, separators=(",", ":")) + ";")


def rep(a, b, n=1):
    global s
    assert s.count(a) == n, (a[:80], s.count(a))
    s = s.replace(a, b)


# 見出しの版
assert len(re.findall(r"試作 v2\.\d+", s)) == 1
s = re.sub(r"試作 v2\.\d+", "試作 v2.8", s)

# 条件の欄に「レジ」
rep('''        <div class="seg" id="seg-arr"></div>
        <p class="note" id="arr-note"></p>
      </fieldset>''', '''        <div class="seg" id="seg-arr"></div>
        <p class="note" id="arr-note"></p>
      </fieldset>
      <fieldset class="ctl">
        <legend>レジ</legend>
        <div class="seg" id="seg-reg"></div>
        <p class="note" id="reg-note"></p>
      </fieldset>''')

# 選んでいる条件・データの引き方
rep('''const state = { store: "family", staff: 3, robots: 2, mode: "serve", crowd: 1, trays: 3, fb: "any", cbase: 3, crob: 2 };''',
    '''const state = { store: "family", staff: 3, robots: 2, mode: "serve", crowd: 1, trays: 3, fb: "any", cbase: 3, crob: 2, reg: D.stores.family.registers };''')
rep('''const IDX = new Map(ROWS.map(r => [[r.store, r.staff, r.robots, r.arrivals_per_h, r.mode, r.trays, r.fragile_by].join("|"), r]));
function find(store, staff, robots, arr, mode, trays = state.trays, fb = state.fb) {
  const k = robots === 0 ? [store, staff, 0, arr, "serve", 1, "any"] : [store, staff, robots, arr, mode, trays, fb];''',
    '''const IDX = new Map(ROWS.map(r => [[r.store, r.staff, r.robots, r.arrivals_per_h, r.mode, r.trays, r.fragile_by, r.registers].join("|"), r]));
// レジの数（v2.8）。指定がなければ左の条件のまま
function find(store, staff, robots, arr, mode, trays = state.trays, fb = state.fb, reg = state.reg) {
  const k = robots === 0 ? [store, staff, 0, arr, "serve", 1, "any", reg] : [store, staff, robots, arr, mode, trays, fb, reg];''')
# 店を選び直したら、レジはその店の最初の数に戻す
rep('''state.cbase = S().staff[1];''', '''state.cbase = S().staff[1]; state.reg = S().registers;''', 2)
rep('''  seg("seg-arr", "arr", s.arrivals.map((v, i) => ({ v: i, l: `${CROWD[i]}` })), () => state.crowd, v => state.crowd = v);''',
    '''  seg("seg-arr", "arr", s.arrivals.map((v, i) => ({ v: i, l: `${CROWD[i]}` })), () => state.crowd, v => state.crowd = v);
  if (!s.registers_all.includes(state.reg)) state.reg = s.registers;
  seg("seg-reg", "reg", s.registers_all.map(v => ({ v, l: `${v}つ` })), () => state.reg, v => state.reg = v);''')

# 結果
rep('''const METRICS = [
  { key: "entry_wait_mean", label: "入店待ち（平均）", plain: plainMin },
  { key: "turn_mean", label: "客が帰ってから卓が使えるまで", plain: plainMin },
  { key: "dish_wait_mean", label: "料理ができてから届くまで", plain: plainSec },
  { key: "walk_km_per_staff", label: "スタッフ1人が歩く距離", plain: v => `${v.toFixed(2)}km` },
  { key: "staff_util", label: "スタッフの手がふさがっている割合", plain: plainPct },
];''', '''const METRICS = [
  { key: "entry_wait_mean", label: "入店待ち（平均）", plain: plainMin },
  { key: "turn_mean", label: "客が帰ってから卓が使えるまで", plain: plainMin },
  { key: "dish_wait_mean", label: "料理ができてから届くまで", plain: plainSec },
  { key: "walk_km_per_staff", label: "スタッフ1人が歩く距離", plain: v => `${v.toFixed(2)}km` },
  { key: "staff_util", label: "スタッフの手がふさがっている割合", plain: plainPct },
  { key: "pay_wait_mean", label: "食べ終えてから会計が始まるまで", plain: plainMin },
];''')
# 「レジが詰まる」の印（v2.8）：食べ終えてから会計が始まるまでが NG_MIN 分以上。「店が回らない」とは別に出す（2026-10-25 エイイチと決めた）
rep('''const cellOf = (m, r) => m.key === "entry_wait_mean" ? seatCell(r) : m.plain(r[m.key]);''',
    '''const regJam = r => r.pay_wait_mean >= NG_MIN;
const regTag = r => `<span class="ng">レジが詰まる</span>`;
const cellOf = (m, r) => m.key === "entry_wait_mean" ? seatCell(r)
  : m.key === "pay_wait_mean" ? m.plain(r[m.key]) + (regJam(r) ? `<span class="sm">${regTag(r)}</span>` : "")
  : m.plain(r[m.key]);''')
rep('''    `${s.label}では、空いている ${s.arrivals[0]}組/時・普通 ${s.arrivals[1]}組/時・混雑 ${s.arrivals[2]}組/時。混む時間は${s.peak_min}分だけ続く前提です（ランチ・ディナーのピーク）。`;''',
    '''    `${s.label}では、空いている ${s.arrivals[0]}組/時・普通 ${s.arrivals[1]}組/時・混雑 ${s.arrivals[2]}組/時。混む時間は${s.peak_min}分だけ続く前提です（ランチ・ディナーのピーク）。`;
  document.getElementById("reg-note").textContent =
    `会計はレジ1つにつき1組ずつ（1組${D.common.pay_s}秒）。レジ${state.reg}つなら1時間に${Math.floor(3600 * state.reg / D.common.pay_s)}組までです。${s.label}は${s.registers}つを最初に見せています。`;''')
rep('''  document.getElementById("cond-sub").textContent = `${CROWD[state.crowd]}（${arr}組/時）` + ''',
    '''  document.getElementById("cond-sub").textContent = `${CROWD[state.crowd]}（${arr}組/時）・レジ${state.reg}つ` + ''')
rep('''    { k: "スタッフの手がふさがっている割合", v: pct(r.staff_util), h: "9割を超えると呼び出しに出られない" },''',
    '''    { k: "スタッフの手がふさがっている割合", v: pct(r.staff_util), h: "9割を超えると呼び出しに出られない" },
    { k: "食べ終えてから会計が始まるまで", v: `${fmtMin(r.pay_wait_mean)}${regJam(r) ? regTag(r) : ""}`, h: `レジの順番とスタッフの手が空くのを待つ。そのあいだ卓はふさがったまま（レジ${state.reg}つ）` + (regJam(r) ? `。平均${NG_MIN}分以上待つので「レジが詰まる」` : "") },''')

# 店ごとの比べ：レジは各店の最初の数
rep('''    `各店のスタッフは真ん中の人数・混み具合は普通・運び方は左の条件のままで、ロボット2台を入れたときの入店待ち（上）と、スタッフ1人が歩く距離（下）の変化です。`;''',
    '''    `各店のスタッフは真ん中の人数・混み具合は普通・レジは各店の最初の数（大型店は2つ、ほかは1つ）・運び方は左の条件のままで、ロボット2台を入れたときの入店待ち（上）と、スタッフ1人が歩く距離（下）の変化です。`;''')
rep('''    const z = find(k, st, 0, arr, "serve");
    const rs = MODES.map(o => find(k, st, 2, arr, o.v));''',
    '''    const z = find(k, st, 0, arr, "serve", undefined, undefined, s.registers);
    const rs = MODES.map(o => find(k, st, 2, arr, o.v, undefined, undefined, s.registers));''')

# 前提
rep('''    ["案内・会計・呼び出し対応", `${c.guide_s}秒・${c.pay_s}秒・${c.call_s}秒（歩く時間は別）`],''',
    '''    ["案内・会計・呼び出し対応", `${c.guide_s}秒・${c.pay_s}秒・${c.call_s}秒（歩く時間は別）`],
    ["「レジが詰まる」の決め方", `食べ終えてから会計が始まるまでが、平均${NG_MIN}分以上の条件。「店が回らない」とは別の印で、「店が回らない」の決め方は変えていない（v2.8）`],
    ["会計とレジ", `会計はレジ1つにつき1組ずつ。食べ終えた組は卓に座ったままレジの順番を待ち、順番が来たらスタッフの手が空くのを待って会計する。レジ1つなら1時間に${Math.floor(3600 / c.pay_s)}組まで。レジの数は1つ・2つを選べる（v2.8 から。それまでは、会計をスタッフのだれでも同時にできた）`],''')

open(out, "w", encoding="utf-8").write(s)
print(len(s), "bytes", len(rows), "rows")
```

### simulation/v2_8/export_params.py

```python
"""試算（haizen_sim.py）の条件と店の回し方の値を、Unity が読む JSON に書き出す（2026-10-22。v2.8 で2026-10-25 にレジの数を足した）。

「何について正か」（unity_design_v0.md の 0 章）で、条件と店の回し方の決まり・時間・数字の定義はサイトが正。
Unity はこの JSON を読み、値を手で写さない（LOG359 のずれ9「値の手写し」をなくすため）。

使い方: python3 export_params.py results.json <出力先>   （出力先は p2m-site の public/sim/serving-robot-params.json）
- common：店に共通の値。role が "unity_measures" の値は Unity で測ってサイトに戻すための仮の値なので、Unity は読まない
- stores：Unity で回す店（fam_p1・fam_p5）の値。registers はレジの数（v2.8）
- unity_conditions：Unity で回す6条件と、同じ条件の試算の結果（40回の平均）。Unity の店はレジの内側に店員が1人だけなので、レジは1つ
"""
import json
import sys

import haizen_sim as h

res_path, out = sys.argv[1], sys.argv[2]
R = json.load(open(res_path, encoding="utf-8"))
gen = {g["generation"] for g in json.load(open(h.LAYOUT_DIR + "/summary.json", encoding="utf-8"))}
assert len(gen) == 1, gen

ROLE = {
    "robot_speed": "unity_measures", "pause_rate": "unity_measures", "pause_s": "unity_measures", "stuck_rate": "unity_measures",
    "hop_m": "site_only",          # 試算の近似（1往復で複数卓を回るときの寄り道）。Unity は実際の道を走るので使わない
    "sustain": "site_only", "warmup_min": "site_only", "util_sample_s": "site_only", "model": "site_only",
}
NOTE = {
    "unity_measures": "Unity で測ってサイトに戻すための仮の値。Unity は読まない",
    "site_only": "試算の中だけの設定。Unity は使わない",
    "site_rule": "サイトが正。Unity はこの値を使う",
}
common = {k: {"value": v, "role": ROLE.get(k, "site_rule")} for k, v in h.COMMON.items()}

UNITY_STORES = ["fam_p1", "fam_p5"]
UNITY_REGISTERS = 1
stores = {}
for k in UNITY_STORES:
    s = dict(h.STORES[k])
    stores[k] = s

conds = []
rows = {h.key(r): r for r in R["rows"]}
for k in UNITY_STORES:
    s = h.STORES[k]
    staff = s["staff"][1]           # 真ん中の人数
    arr = s["arrivals"][1]          # 普通
    for robots in (0, 1, 2):
        c = {"store": k, "staff": staff, "robots": robots, "arrivals_per_h": arr, "mode": "serve",
             "trays": 3 if robots else 1, "fragile_by": "any",   # ロボット0台の試算は trays=1 で持っている（使い方・段の数は関係ない）
             "registers": UNITY_REGISTERS}
        r = rows[h.key(c)]
        conds.append({"condition": c, "site_result": {m: r[m] for m in r if m not in c}})

D = {
    "meta": {
        "schema_version": "serving-robot-params/0.2",
        "model": h.COMMON["model"], "reps": h.REPS, "ng_min": h.NG_MIN, "layout_generation": gen.pop(),
        "source": "p2m-site の public/sim/serving-robot.html と同じ試算（haizen_sim.py）と結果（results.json）",
        "roles": NOTE,
        "time_window": "空の店から始め、peak_min 分だけ選んだペースで来客し、全員が帰るまで回す（max_extra_min で打ち切り）",
        "registers": "会計はレジ1つにつき1組ずつ（pay_s 秒）。食べ終えた組は卓に座ったまま順番を待つ。Unity の6条件はレジ1つ（v2.8）",
        "metrics": {
            "entry_wait_mean": "入店待ち：店に来てから案内されて卓に着くまで（分）",
            "full_wait_mean": "そのうち満席で待った時間（分）",
            "staff_wait_mean": "そのうち席は空いているのに片付け・案内が追いつかず待った時間（分）。ng_min 以上なら「店が回らない」",
            "dish_wait_mean": "料理ができてから卓に届くまで（分）", "dish_wait_p90": "同じく上位10%の境目（分）",
            "cold_share": "cold_min 分を超えて届いた皿の割合", "turn_mean": "客が帰ってから卓が使えるまで（分）",
            "walk_km_per_staff": "スタッフ1人が歩いた距離（km）", "staff_util": "スタッフの手がふさがっている割合",
            "robot_util": "ロボットがふさがっている割合", "robot_share": "ロボットが運んだ皿の割合",
            "rescues": "立ち往生でスタッフが動かしに行った回数", "pauses": "一時停止の回数", "groups": "入れた組の数",
            "pay_wait_mean": "食べ終えてから会計が始まるまで（分）。レジの順番とスタッフの手が空くのを待つ（v2.8）",
        },
    },
    "common": common,
    "stores": stores,
    "unity_conditions": conds,
}
json.dump(D, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(out, len(conds), "conditions / generation", D["meta"]["layout_generation"])
for c in conds:
    print(" ", c["condition"], {m: c["site_result"].get(m) for m in ("entry_wait_mean", "staff_wait_mean", "dish_wait_mean", "pay_wait_mean")})
```

### simulation/v2_8/test_page_v28.js

```javascript
// 作り直したページを、簡易の DOM で全組み合わせ描画して、データ欠けや例外がないか確かめる
// v2.8：レジの数（1つ・2つ）も切り替える
// 使い方: node test_page_v28.js <ページ>
const fs = require("fs");
const h = fs.readFileSync(process.argv[2], "utf8");
const scripts = h.split("<script>").slice(1).map(x => x.split("</script>")[0]);
let js = scripts.find(x => x.includes("const D ="));
js = js.replace(/buildStores\(\);\s*buildLayouts\(\);\s*buildControls\(\);\s*render\(\);\s*$/, "");
js += `;let n=0, ng=0, notes=0;
for (const st of Object.keys(STORES)) { state.store = st; const s = STORES[st];
  for (const sf of s.staff_all) for (const rb of ROBOTS_ALL) for (const md of ["serve","bus","patrol"]) for (const c of [0,1,2]) for (const tr of [1,3]) for (const fb of ["any","staff"]) for (const rg of s.registers_all) {
    Object.assign(state, {staff: sf, robots: rb, mode: md, crowd: c, trays: tr, fb: fb, reg: rg, cbase: s.staff_all[1 + (n % (s.staff_all.length - 1))], crob: 1 + (n % 6)});
    buildStores(); buildLayouts(); buildControls(); render(); n++;
    if (/レジが詰まる/.test(document.getElementById("kpis").innerHTML)) globalThis.rj=(globalThis.rj||0)+1;
    if (/店が回らない/.test(document.getElementById("kpis").innerHTML)) ng++;
    if (!document.getElementById("robot-note").hidden) notes++;
    if (/undefined|NaN/.test(document.getElementById("kpis").innerHTML + document.getElementById("cond-sub").textContent + document.getElementById("reg-note").textContent)) throw new Error("undefined/NaN " + JSON.stringify(state));
  } }
console.log("rendered", n, "/ レジが詰まるが出た画面", globalThis.rj, "/ 回らないが出た画面", ng, "/ 5台以上の注意", notes);`;
const els = {};
const mk = () => ({ innerHTML: "", textContent: "", onchange: null, onclick: null, hidden: false, style: {}, className: "",
  after() {}, querySelector() { return null; }, setAttribute() {}, addEventListener() {}, appendChild() {}, classList: { add() {}, remove() {}, toggle() {} } });
global.document = { getElementById: id => els[id] || (els[id] = mk()), querySelectorAll: () => [], querySelector: () => null,
  createElement: () => mk(), addEventListener() {} };
new Function(js)();
```

### site_series.py（図解に使った試算の分ごとの平均）

```python
# 図解用：試算 v2.8 の分ごとの平均（40回）を、Unity の timeline.csv と同じ時刻で出す（2026-10-25）
import json, sys, random, statistics as st, simpy
sys.path.insert(0, "/home/claude/sim28/v2_8")
import haizen_sim as h
def series(c):
    acc = {}
    for sd in range(40):
        store = h.STORES[c["store"]]; p = {**h.COMMON, **store, **c}
        env = simpy.Environment(); s = h.Store(env, random.Random(sd), p); env.process(s.arrivals())
        def samp():
            while True:
                k = int(env.now // 60)
                a = acc.setdefault(k, {"dirty": [], "pay": [], "wait": [], "busy": []})
                a["dirty"].append(s.dirty); a["pay"].append(len(s.register.queue) + s.register.count)
                a["wait"].append(len(s.tables.queue)); a["busy"].append(s.staff.count)
                yield env.timeout(60)
        env.process(samp()); env.run(until=200 * 60)
    return {k: [round(st.mean(acc[t][k]), 2) for t in sorted(acc)][:190] for k in ("dirty", "pay", "wait", "busy")}
out = {}
for store in ("fam_p1", "fam_p5"):
    for r, tr in ((0, 1), (2, 3)):
        out[f"{store}_r{r}"] = series({"store": store, "staff": 4, "robots": r, "arrivals_per_h": 42, "mode": "serve", "trays": tr, "fragile_by": "any", "registers": 1})
json.dump(out, open("/home/claude/log379/data/site_series.json", "w"))
print({k: max(v["dirty"]) for k, v in out.items()})
```

### p2m-site の src/lib/servingSim.ts の差分（commit f26f27c）

```diff
commit f26f27cd05e0f8de36798110b2ccb39d0a8f714e
Author: Claude <noreply@anthropic.com>
Date:   Sun Oct 25 09:33:26 2026 +0900

    配膳ロボットの試算 v2.8：会計はレジで1組ずつ。レジの数（1つ・2つ）を選べるようにした
    
    - 全32,850条件を回し直した（レジを無限にすると v2.7 と同じ数字になることを確かめた）
    - 大型店はレジ2つ、ほかの店は1つを最初に見せる
    - 結果に「食べ終えてから会計が始まるまで」と、10分以上なら「レジが詰まる」の印を足した（「店が回らない」の決め方は変えていない）
    - Unity が読む条件（serving-robot-params.json）も v2.8 で書き出した。Unity の6条件はレジ1つ
    - LP の数字も試算から読み直すため、find にレジの数を足した
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_019NnEBnbQXFrCEXBxuWxmqB

diff --git a/src/lib/servingSim.ts b/src/lib/servingSim.ts
index c13a7d6..8e59738 100644
--- a/src/lib/servingSim.ts
+++ b/src/lib/servingSim.ts
@@ -25,12 +25,12 @@ const D = readJson(html, at);
 const K: string[] = D.k;
 const ROWS: Row[] = D.r.map((r: any[]) => Object.fromEntries(K.map((k, i) => [k, r[i]])));
 export const STORES: Record<string, Store> = D.stores;
-const IDX = new Map(ROWS.map((r) => [[r.store, r.staff, r.robots, r.arrivals_per_h, r.mode, r.trays, r.fragile_by].join('|'), r]));
+const IDX = new Map(ROWS.map((r) => [[r.store, r.staff, r.robots, r.arrivals_per_h, r.mode, r.trays, r.fragile_by, r.registers].join('|'), r]));
 
-// 試算のページの既定（1往復で3卓まで・壊れやすい皿は誰が運んでもよい）
+// 試算のページの既定（1往復で3卓まで・壊れやすい皿は誰が運んでもよい・レジは店の最初の数。v2.8 からレジの数も条件）
 const TRAYS = 3, FB = 'any';
-export function find(store: string, staff: number, robots: number, arr: number, mode = 'serve'): Row | undefined {
-  const k = robots === 0 ? [store, staff, 0, arr, 'serve', 1, 'any'] : [store, staff, robots, arr, mode, TRAYS, FB];
+export function find(store: string, staff: number, robots: number, arr: number, mode = 'serve', reg: number = STORES[store].registers): Row | undefined {
+  const k = robots === 0 ? [store, staff, 0, arr, 'serve', 1, 'any', reg] : [store, staff, robots, arr, mode, TRAYS, FB, reg];
   return IDX.get(k.join('|'));
 }
 export const unstable = (r: Row) => r.ng === 1;
```


## Unity のコード（cs:144）の全文

v10・v10b で直したファイルです。

### Assets/ServiceSim/Service/GroupManager.cs

```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 客を「1組ごとに、入ってから出るまで」で動かす（試算の group() と同じ流れ）。
    ///
    ///   入店 → 卓が空くまで待合（あふれたら店の外） → 案内 → 着席 → 料理 → 食事
    ///   （食事中にドリンクバー・トイレ・呼び出し） → 会計 → 退店 → 片付け
    ///
    /// 座っている人は人として置かない（通路に出ないため）。
    /// 立ち上がった人だけを、使い回しの体（Guest.prefab）で歩かせる。
    ///
    /// この script は人だけを扱う。卓の空きと料理の待ち行列は ServiceOps に任せる。
    /// 数字は ServiceSpec と PeopleSpec の .asset に置く。
    /// </summary>
    public class GroupManager : MonoBehaviour
    {
        /// <summary>1組の今の状態</summary>
        public enum GState { Waiting, Guided, Seated, Eating, Paying, Left, Clearing, Done }

        /// <summary>1組</summary>
        public class Group
        {
            public int id;
            public int size;
            public GState state = GState.Waiting;
            public TableSlot table;

            public float arriveS = -1f;
            public float guideStartS = -1f;
            public float seatedS = -1f;

            /// <summary>
            /// その組だけの乱数（依頼 v8b の B）。
            /// 種と組の番号から作るので、**いつ引いても同じ組なら同じ値**になる。
            /// 人数・料理ができるまで・食べる時間・割れ物の数・呼び出し・
            /// ドリンクバー・トイレは、ぜんぶここから決まった順に引く。
            /// 動きが1か所ずれても、ほかの組の中身は変わらない
            /// </summary>
            public System.Random rng;

            /// <summary>食べ始めた時刻（最初の料理が全部届いた時刻, 秒）。まだなら -1（依頼 v8 の 2）</summary>
            public float eatStartS = -1f;
            public float eatEndS = -1f;
            public float payS = -1f;
            public float leftS = -1f;
            public bool visibleWaiting;
            public bool warmup;              // 慣らしのために最初から座っている組

            /// <summary>
            /// 逃がし弁で卓を空けられた組（B1 の ForceFreeTable を通った）。
            /// 会計も片付けも途中で切れているので、pay_wait_mean には入れない（依頼 v10 の 2）
            /// </summary>
            public bool tableForceFreed;

            public readonly List<DishOrder> dishes = new List<DishOrder>();
            public readonly List<float> cookAt = new List<float>();      // 受け渡し口に出す時刻
            public readonly List<bool> cookFragile = new List<bool>();
            public readonly List<float> errandAt = new List<float>();    // 立ち上がる時刻
            public readonly List<int> errandKind = new List<int>();      // 0=ドリンクバー 1=トイレ
            public readonly List<float> callAt = new List<float>();
            public int activeErrands;
            public int dishesOrdered;

            /// <summary>何人目が立ったか。いつも同じ人が同じ椅子を引くようにする（B6）</summary>
            public int chairTurn;

            /// <summary>
            /// 会計・片付けで止まったままの秒数（B1。長びいたら卓を空ける）。
            ///
            /// 依頼 v10 の 1 で数え方を変えた：**スタッフがその組の仕事を取っている間だけ**数える。
            /// 板に載ったまま誰も取っていない「ふつうの片付け待ち」と、
            /// 会計の並ぶ点が空くのを席で待っている間（まだ仕事を出していない）は数えない。
            /// 前は Paying・Clearing の間ずっと数えていたので、
            /// 混んでいて片付けの順番が来ないだけの卓まで空けてしまい、
            /// スタッフの仕事が消えていた（③0台・種1で83組のうち24卓・仕事の約1割）。
            ///
            /// スタッフがあきらめて仕事を板に戻しても（AbandonJob）0 には戻さない。
            /// 何度も着けない卓を見つけるため
            /// </summary>
            public float stuckS;

            /// <summary>
            /// 卓は空いたのに片付けが終わらず待った秒数（v5 3章・blocked_wait_mean）。
            /// サイトの試算（haizen_sim.py の _acc）と同じ数え方で、
            /// 「片付け待ちの卓の数だけ、待っている列の前から順に、そのコマの時間を足す」。
            /// </summary>
            public float blockedWaitS;

            public GuestAgent waitingBody;   // 待合に立たせている1人

            // ---- 会計（レジに立つのは組の1人だけ）----
            /// <summary>会計に立つ1人の体。ほかの人は席に座ったまま</summary>
            public GuestAgent payerBody;
            /// <summary>取ったレジの並ぶ点。-1 なら取れていない（席で待つ）</summary>
            public int payerSpot = -1;
            /// <summary>会計の人をレジへ送ったか</summary>
            public bool payerSent;
            /// <summary>会計を始めた時刻（食べ終わって Paying に入った時刻）</summary>
            public float payStartS = -1f;
            public StaffJob job;             // いま出している仕事
        }

        [Header("設定")]
        public ServiceSpec spec;
        public PeopleSpec people;
        public ServiceOps ops;
        public StoreBuilder store;

        [Tooltip("卓ごとの椅子の出し入れを読む先（B6）。無くても動く")]
        public PeopleSpawner spawner;

        [Header("部品")]
        [Tooltip("歩く客の体。待合の客にも使い回す")]
        public GameObject guestPrefab;

        [Tooltip("使っていない（段階4の場面の再現用に残してある）")]
        public GameObject waitingGuestPrefab;

        [Tooltip("歩く人を置く親")]
        public Transform bodyRoot;

        [Header("いまの様子（読むだけ）")]
        [SerializeField] int _groupsArrived;
        [SerializeField] int _groupsSeated;
        [SerializeField] int _groupsDone;
        [SerializeField] int _waitingGroups;
        [SerializeField] int _walkingBodies;

        readonly List<Group> _groups = new List<Group>();
        readonly List<Group> _waiting = new List<Group>();
        readonly List<GuestAgent> _pool = new List<GuestAgent>();
        readonly List<GuestAgent> _live = new List<GuestAgent>();

        PeopleTargets _targets;
        /// <summary>
        /// 客の中身でないもの（待つ場所を選ぶなど）に使う乱数。
        /// 来客の間隔は _arrivalRng、組ごとのことは Group.rng（依頼 v8b の B）
        /// </summary>
        System.Random _rng;

        /// <summary>
        /// 来客の間隔だけの乱数（依頼 v8b の B）。
        /// ほかの引きと混ぜないので、動きがずれても **来る組と来る時刻は変わらない**
        /// </summary>
        System.Random _arrivalRng;

        /// <summary>組ごとの乱数を作るもとの種</summary>
        int _seed;
        float _nextArrivalS;
        int _nextGroupId = 1;
        CrowdLevel _crowd;

        public IList<Group> Groups { get { return _groups; } }
        public int GroupsArrived { get { return _groupsArrived; } }
        public int GroupsSeated { get { return _groupsSeated; } }
        public int GroupsDone { get { return _groupsDone; } }
        public int WaitingGroups { get { return _waitingGroups; } }

        /// <summary>いま通路を歩いている人の数（スタッフを除く）</summary>
        public int WalkingBodies { get { return _walkingBodies; } }

        // ---------------- 用意 ----------------

        /// <summary>
        /// 来客を受け付ける長さ（店の中の秒）。これを過ぎたら新しい組は来ない（v5 3章）。
        /// サイトの決まりは「空の店から始め、peak_min だけ来客し、全員が帰るまで回す」。
        /// 0 以下なら、これまでどおり止めずに来客し続ける。
        /// </summary>
        public float arrivalsEndS;

        /// <summary>
        /// 慣らしのために、始めから食事中の組で卓を埋めるか（v5 3章）。
        /// サイトと同じ「空の店から」にするときは false。
        /// </summary>
        public bool seedWarmupGroups = true;

        /// <summary>まだ店の中にいる組があるか（全員が帰ったかの判定。v5 3章）</summary>
        public bool AnyGroupInStore
        {
            get
            {
                if (_waiting.Count > 0) return true;
                for (int i = 0; i < _groups.Count; i++)
                    if (_groups[i].state != GState.Done) return true;
                return false;
            }
        }

        public void Setup(CrowdLevel crowd, int seed, PeopleTargets targets)
        {
            _crowd = crowd;
            _seed = seed;
            _rng = new System.Random(seed * 7919 + 13);
            _arrivalRng = new System.Random(seed * 7919 + 101);   // 来客だけの列（依頼 v8b の B）
            _targets = targets;

            _groups.Clear();
            _waiting.Clear();
            ReturnAllBodies();

            _groupsArrived = 0;
            _groupsSeated = 0;
            _groupsDone = 0;
            _nextGroupId = 1;

            float rate = spec.ArrivalsPerHour(crowd) / 3600f;
            _nextArrivalS = ServiceSpec.ExponentialSec(_arrivalRng, rate);

            if (seedWarmupGroups) SeedWarmupGroups();
        }

        /// <summary>
        /// 慣らしのために、卓の一部を「食事の途中の組」で埋めて始める。
        /// 空の店から始めると、はじめの数十分が本番と違う形になるため。
        /// </summary>
        void SeedWarmupGroups()
        {
            if (people == null || ops == null) return;
            int tables = ops.Tables.Count;
            int want = Mathf.RoundToInt(tables * Mathf.Clamp01(people.warmupOccupancy));
            int want0 = want;
            int placed = 0;

            for (int i = 0; i < tables && want > 0; i++)
            {
                TableSlot slot = ops.TakeTableAt(i, _nextGroupId);
                if (slot == null) continue;

                Group g = new Group();
                g.id = _nextGroupId++;
                g.rng = NewGroupRng(g.id);      // その組だけの乱数（依頼 v8b の B）
                // 2人席に3人以上を座らせない。卓の席数で頭を切る
                g.size = Mathf.Min(spec.PickGroupSize(g.rng), Mathf.Max(1, slot.seatCount));
                g.table = slot;
                g.warmup = true;
                g.arriveS = 0f;
                g.seatedS = 0f;

                // 食事のどの時点かを決める（B3）。
                //   0.00〜0.25：座ったばかりで料理を待っている（1皿めも2皿めもまだ）
                //   0.25〜0.50：1皿めは届いたが2皿めを待っている
                //   0.50〜1.00：受け取って食べている〜食べ終わりかけ
                // でたらめにすると食べ終わりかけが固まり、そのぶんの会計がいちどに来て
                // レジが詰まるので、均等にばらす
                double phase = (placed + 0.5) / Mathf.Max(1, want0);
                if (phase < 0.25) SeedWaitingFirstDishes(g, 2);
                else if (phase < 0.5) SeedWaitingFirstDishes(g, 1);
                else SeedEating(g, (float)((phase - 0.5) / 0.5));
                placed++;

                _groups.Add(g);
                _groupsArrived++;
                _groupsSeated++;
                want--;
            }
        }

        /// <summary>
        /// 慣らし：座ったばかりで料理を待っている組（B3）。
        /// まだ届いていない皿は、作る時間の残りをでたらめに決めて受け渡し口の待ち行列に入る。
        /// </summary>
        void SeedWaitingFirstDishes(Group g, int pending)
        {
            g.state = GState.Seated;
            g.dishesOrdered = 2;

            // 1皿め・2皿めの作る時間。pending が 1 なら1皿めは届いた扱いにする
            float cook1 = ServiceSpec.TriangularSec(g.rng, spec.firstDish1CookMin);
            float cook2 = ServiceSpec.TriangularSec(g.rng, spec.firstDish2CookMin);

            if (pending >= 2)
            {
                g.cookAt.Add((float)g.rng.NextDouble() * cook1);
                g.cookFragile.Add(false);
            }
            else
            {
                // 1皿めは届いている
                g.dishes.Add(ops.SeedArrivedDish(g.id, g.table));
            }

            g.cookAt.Add((float)g.rng.NextDouble() * cook2);
            g.cookFragile.Add(false);
        }

        /// <summary>
        /// 慣らし：もう食べている組（B3）。
        /// done は食事の進み具合（0 なら食べ始め、1 なら食べ終わりかけ）。
        /// 均等にばらすので、食べ終わりがいちどに来ない。
        /// </summary>
        void SeedEating(Group g, float done)
        {
            g.state = GState.Eating;
            g.dishesOrdered = 2;

            // 最初の料理は届いている扱い
            for (int k = 0; k < 2; k++) g.dishes.Add(ops.SeedArrivedDish(g.id, g.table));

            float eat = ServiceSpec.TriangularSec(g.rng, spec.eatMin);
            g.eatEndS = eat * (1f - Mathf.Clamp01(done));
            ScheduleDuringMeal(g, g.eatEndS);
        }

        // ---------------- 毎コマ ----------------

        public void Tick(float dt)
        {
            if (spec == null || ops == null || _rng == null) return;
            float now = ops.NowS;

            Arrive(now);
            CountBlockedWait(dt);
            SeatWaiting(now);
            for (int i = 0; i < _groups.Count; i++) Advance(_groups[i], now, dt);

            _waitingGroups = _waiting.Count;
            _walkingBodies = _live.Count;
        }

        /// <summary>
        /// 「卓は空いたのに片付けが終わらず待った」分を数える（v5 3章・blocked_wait_mean）。
        ///
        /// サイトの試算（haizen_sim.py の _acc）と同じ数え方にそろえた：
        ///   片付け待ちの卓の数だけ、待っている列の**前から順に**、そのコマの時間を足す。
        /// 片付けが終わっていれば、その卓の数だけ先頭の組が座れていたはず、という数え方。
        ///
        /// full_wait は「席待ち − blocked_wait」で出す（サイトと同じ）。
        /// 卓は空いているのに案内が追いつかない分（guide_wait）は、
        /// 案内の仕事が板に出てから取られるまでで測る（OnGuided で記録する）。
        ///
        /// サイトの試算には卓の大きさが無いので、ここも席数を見ずに列の前から数える
        /// （2人席・4人席は Unity だけのもの）。
        /// </summary>
        void CountBlockedWait(float dt)
        {
            int clearing = ops.ClearingTableCount;
            if (clearing <= 0) return;
            int n = Mathf.Min(clearing, _waiting.Count);
            for (int i = 0; i < n; i++) _waiting[i].blockedWaitS += dt;
        }

        /// <summary>来客（指数分布。試算の arrivals() と同じ）</summary>
        void Arrive(float now)
        {
            // サイトの決まり：peak_min だけ来客し、そのあとは新しい組は来ない（v5 3章）
            if (arrivalsEndS > 0f && now >= arrivalsEndS) return;
            if (now < _nextArrivalS) return;

            float rate = spec.ArrivalsPerHour(_crowd) / 3600f;
            _nextArrivalS = now + ServiceSpec.ExponentialSec(_arrivalRng, rate);

            Group g = new Group();
            g.id = _nextGroupId++;
            g.rng = NewGroupRng(g.id);      // その組だけの乱数（依頼 v8b の B）
            g.size = spec.PickGroupSize(g.rng);
            g.arriveS = now;
            g.state = GState.Waiting;
            _groups.Add(g);
            _waiting.Add(g);
            _groupsArrived++;

            // 待合に立たせるのは上限まで。あふれた組は店の外で待つ扱い（人として置かない）
            if (CountVisibleWaiting() < spec.waitingVisibleMax)
            {
                g.visibleWaiting = true;
                g.waitingBody = BorrowBody(_targets.RandomWaitingSpot(_rng), true);
                if (g.waitingBody != null)
                    g.waitingBody.Begin(people, GuestAgent.Errand.Waiting, g.waitingBody.transform.position, 0f,
                                        false, Vector3.zero, null);
            }
        }

        int CountVisibleWaiting()
        {
            int n = 0;
            for (int i = 0; i < _waiting.Count; i++) if (_waiting[i].visibleWaiting) n++;
            return n;
        }

        /// <summary>
        /// その組だけの乱数を作る（依頼 v8b の B）。
        ///
        /// 種と組の番号だけで決まるので、**いつ作っても同じ組なら同じ列**になる。
        /// 引く順番も組ごとに決まっている（人数 → 最初の2皿の作る時間 → 食べる時間 →
        /// 割れ物 → 呼び出し → ドリンクバー → トイレ）ので、
        /// 動きが1か所ずれても、ほかの組の中身は変わらない。
        ///
        /// 引く数の分布・決まり（ServiceSpec の値）は変えていない。
        /// 変わったのは「どの乱数の列から引くか」だけ。
        /// </summary>
        System.Random NewGroupRng(int groupId)
        {
            unchecked
            {
                int h = _seed * 73856093;
                h ^= groupId * 19349663;
                h = h * 83492791 + 0x5bd1e995;
                return new System.Random(h & 0x7fffffff);   // 負の種を避ける
            }
        }

        /// <summary>
        /// 空いた卓に、待っている組を先着順で入れる。
        /// 2人席と4人席があるので、先頭の組が入れる卓が無いときは、
        /// その組を飛ばして次の組を見る（4人の組が先頭にいるあいだ、
        /// 2人席が空いているのに誰も座れない、を避ける）。
        /// </summary>
        void SeatWaiting(float now)
        {
            while (_waiting.Count > 0 && ops.FreeTableCount > 0)
            {
                int pick = -1;
                for (int i = 0; i < _waiting.Count; i++)
                    if (ops.HasFreeTableFor(_waiting[i].size)) { pick = i; break; }
                if (pick < 0) break;

                Group g = _waiting[pick];
                TableSlot slot = ops.TakeFreeTable(g.id, g.size);
                if (slot == null) break;

                _waiting.RemoveAt(pick);
                g.table = slot;
                g.guideStartS = now;
                g.state = GState.Guided;
                ops.RecordSeatWait(g.arriveS);

                // スタッフが待合へ迎えに行き、卓まで案内する
                Group captured = g;
                StaffJob job = ops.PostJob(StaffJobKind.Guide, g.id, slot, null, 1);
                job.onDone = delegate { OnGuided(captured); };
                g.job = job;
            }
        }

        /// <summary>
        /// その組の卓の椅子を1脚引く（B6）。人が動く瞬間だけ呼ぶ。
        /// 何人目かを順に進めて、同じ人がいつも同じ椅子を引くようにする。
        /// </summary>
        void PullChair(Group g, string reason)
        {
            if (spawner == null || g == null || g.table == null) return;
            SeatedGuest s = spawner.SeatedAt(g.table.index);
            if (s == null) return;
            s.PullFor(reason, g.chairTurn++);
        }

        void OnGuided(Group g)
        {
            // 入店待ちの内訳を1組ぶん残す（v5 3章。分け方はサイトの試算と同じ）。
            //   entry   … 店に来てから卓に着くまで（案内の歩きと guide_s を含む）
            //   seat    … 店に来てから案内が始まるまで
            //   blocked … そのうち、卓は空いたのに片付けが終わらず待った分
            //   guide   … 案内の仕事が板に出てから、スタッフが取るまで
            // full = seat − blocked、staff = blocked + guide は ServiceOps で出す
            if (g.arriveS >= 0f && !g.warmup)
            {
                float entry = ops.NowS - g.arriveS;
                float seat = g.guideStartS >= 0f ? g.guideStartS - g.arriveS : 0f;
                float guideWait = g.job != null && g.job.takenS >= 0f
                    ? Mathf.Max(0f, g.job.takenS - g.job.postedS) : 0f;
                ops.RecordEntryWait(entry, seat, g.blockedWaitS, guideWait);
            }

            g.job = null;
            g.state = GState.Seated;
            g.seatedS = ops.NowS;

            // 席に着くので椅子を引く（B6）
            PullChair(g, SeatedGuest.ReasonSit);

            // 案内された組は席に着く（待合の体を返す）
            if (g.waitingBody != null) { ReturnBody(g.waitingBody); g.waitingBody = null; }
            g.visibleWaiting = false;

            // 最初の注文（試算の first_dishes と同じ数・同じ作る時間）
            g.cookAt.Add(g.seatedS + ServiceSpec.TriangularSec(g.rng, spec.firstDish1CookMin));
            g.cookFragile.Add(false);
            g.cookAt.Add(g.seatedS + ServiceSpec.TriangularSec(g.rng, spec.firstDish2CookMin));
            g.cookFragile.Add(false);
            g.dishesOrdered = 2;
        }

        /// <summary>食事中に起きること（デザート・呼び出し・ドリンクバー・トイレ）を先に決める</summary>
        void ScheduleDuringMeal(Group g, float eatEndS)
        {
            float now = ops.NowS;
            float eat = Mathf.Max(1f, eatEndS - now);

            int nFragile = ServiceSpec.PickCount(g.rng, spec.fragilePerGroup);
            for (int i = 0; i < nFragile; i++)
            {
                float at = now + (0.3f + 0.55f * (float)g.rng.NextDouble()) * eat;
                g.cookAt.Add(at + ServiceSpec.TriangularSec(g.rng, spec.fragileCookMin));
                g.cookFragile.Add(true);
                g.dishesOrdered++;
            }

            // 呼び出し（試算は2回ぶん call_rate/2 で判定）
            for (int i = 0; i < 2; i++)
                if (g.rng.NextDouble() < spec.callRate / 2f)
                    g.callAt.Add(now + (float)g.rng.NextDouble() * eat);

            // ドリンクバー（1人あたり）とトイレ（1組あたり）
            int drinks = ServiceSpec.PickCount(g.rng, spec.drinkTripsPerPerson * g.size);
            for (int i = 0; i < drinks; i++)
            {
                g.errandAt.Add(now + (float)g.rng.NextDouble() * eat);
                g.errandKind.Add(0);
            }
            int toilets = ServiceSpec.PickCount(g.rng, spec.toiletTripsPerGroup);
            for (int i = 0; i < toilets; i++)
            {
                g.errandAt.Add(now + (float)g.rng.NextDouble() * eat);
                g.errandKind.Add(1);
            }
        }

        void Advance(Group g, float now, float dt)
        {
            // できた料理を受け渡し口に出す
            for (int i = g.cookAt.Count - 1; i >= 0; i--)
            {
                if (now < g.cookAt[i]) continue;
                if (g.table != null) g.dishes.Add(ops.PutDish(g.id, g.table, g.cookFragile[i]));
                g.cookAt.RemoveAt(i);
                g.cookFragile.RemoveAt(i);
            }

            // 呼び出し
            for (int i = g.callAt.Count - 1; i >= 0; i--)
            {
                if (now < g.callAt[i]) continue;
                if (g.table != null) ops.PostJob(StaffJobKind.Call, g.id, g.table, null, 1);
                g.callAt.RemoveAt(i);
            }

            // 立ち上がってドリンクバー・トイレへ
            for (int i = g.errandAt.Count - 1; i >= 0; i--)
            {
                if (now < g.errandAt[i]) continue;
                StartErrand(g, g.errandKind[i]);
                g.errandAt.RemoveAt(i);
                g.errandKind.RemoveAt(i);
            }

            switch (g.state)
            {
                case GState.Seated:
                    // 最初の料理が全部届いたら食事が始まる（試算の AllOf(firsts) と同じ）
                    if (AllFirstDishesArrived(g))
                    {
                        g.state = GState.Eating;
                        g.eatStartS = now;          // 食べ始めた時刻（依頼 v8 の 2）
                        g.eatEndS = now + ServiceSpec.TriangularSec(g.rng, spec.eatMin);
                        ScheduleDuringMeal(g, g.eatEndS);
                    }
                    break;

                case GState.Eating:
                    if (now < g.eatEndS) break;
                    if (g.cookAt.Count > 0 || !AllDishesArrived(g) || g.activeErrands > 0) break;
                    g.state = GState.Paying;
                    g.stuckS = 0f;
                    g.payStartS = now;
                    g.payerSent = false;
                    g.payerSpot = -1;
                    break;

                case GState.Paying:
                    // 会計に立つのは組の1人だけ。レジの並ぶ点が空くまでは席で待つ
                    SendPayerIfPossible(g, now);
                    break;

                case GState.Left:
                    // 退店したら、スタッフが片付けに行く（優先はいちばん後）
                    g.state = GState.Clearing;
                    g.stuckS = 0f;
                    if (g.table != null) g.table.clearing = true;
                    {
                        Group captured = g;
                        StaffJob job = ops.PostJob(StaffJobKind.Clear, g.id, g.table, null, 2);
                        job.onDone = delegate { OnCleared(captured); };
                        g.job = job;
                    }
                    break;
            }

            // 座っている客の形を、いま席にいる人数に合わせる（見た目だけ）
            UpdateSeatedBodies(g);

            // 会計・片付けで止まって卓が空かないままなら、時間を見て卓を空ける（B1）。
            //
            // 数えるのは「スタッフがこの組の仕事を取ってから止まっている時間」だけ（依頼 v10 の 1）。
            //   ・g.job == null        … 会計の並ぶ点が空くのを席で待っている間。数えない
            //   ・g.job.taken == false … 板に載ったまま誰も取っていない間
            //                            （ふつうの片付け待ち・会計待ち）。数えない
            //   ・g.job.taken == true  … スタッフが向かっているのに着けない。これを数える
            // あきらめて板に戻されたら（AbandonJob で taken が false に戻る）数えるのを止めるが、
            // それまでに数えた分は残す（0 に戻さない）。
            // 600 秒に達したときの流れ（ForceFreeTable）は変えていない。
            // 取られていない間も、すでに 600 秒ぶん数えてあれば今までどおり卓を空ける
            if (g.state == GState.Paying || g.state == GState.Clearing)
            {
                if (g.job != null && g.job.taken) g.stuckS += dt;
                if (spec.tableStuckFreeSec > 0f && g.stuckS >= spec.tableStuckFreeSec)
                    ForceFreeTable(g);
            }
        }

        /// <summary>
        /// 座っている客の形を、いま席にいる人数に合わせる（見た目だけ）。
        /// 席にいるのは「組の人数 − 立って歩いている人 − 会計に立っている人」。
        /// 席を立つ・卓を空けると 0 になる。
        /// </summary>
        void UpdateSeatedBodies(Group g)
        {
            if (spawner == null || g.table == null) return;
            SeatedGuest seated = spawner.SeatedAt(g.table.index);
            if (seated == null) return;

            bool sitting = g.state == GState.Seated || g.state == GState.Eating ||
                           g.state == GState.Paying;
            int n = 0;
            if (sitting)
            {
                n = g.size - g.activeErrands - (g.payerBody != null ? 1 : 0);
                if (n < 0) n = 0;
            }
            seated.ShowSeated(n);
        }

        /// <summary>
        /// 会計・片付けが進まないまま時間が過ぎた組の卓を、強引に空ける（B1）。
        /// これが多いときは、人が通れない店になっているしるし。
        /// </summary>
        void ForceFreeTable(Group g)
        {
            g.tableForceFreed = true;   // pay_wait_mean から外す（依頼 v10 の 2）

            // 卓を空ける前に、座っている客の形を消す
            if (spawner != null && g.table != null)
            {
                SeatedGuest seated = spawner.SeatedAt(g.table.index);
                if (seated != null) seated.ShowSeated(0);
            }

            Vector3 at = g.table != null ? g.table.deliveryPoint : Vector3.zero;
            ops.AddGiveUp("組", g.state == GState.Paying ? "会計" : "片付け",
                          g.table != null ? g.table.tableId : 0,
                          at, at, g.stuckS, "卓を空けた");

            // 出していた仕事を板から外す
            if (g.job != null) { ops.FinishJobSilently(g.job); g.job = null; }
            ReleasePayer(g);

            if (g.leftS < 0f) g.leftS = ops.NowS;
            ops.ReleaseTable(g.table, g.leftS);
            g.table = null;
            g.state = GState.Done;
            g.stuckS = 0f;
            _groupsDone++;
        }

        /// <summary>
        /// 会計に立つ1人をレジへ送る。
        ///   ・レジの並ぶ点が空いていなければ、席に座ったまま待つ（レジに団子にならないように）
        ///   ・送れたときだけ、スタッフの会計の仕事を板に出す（誰も居ないレジで会計しないように）
        /// 残りの人は会計が終わってから、全員で立って店を出る（OnLeft）。
        /// </summary>
        void SendPayerIfPossible(Group g, float now)
        {
            if (g.payerSent) return;

            // 前のとおり（誰もレジに立たず、スタッフだけで会計）に戻せるようにしておく。
            // レジの前の人数を直す前・直したあとで比べるため
            if (!spec.payAtRegisterByOne)
            {
                g.payerSent = true;
                PullChair(g, SeatedGuest.ReasonPay);
                Group old = g;
                StaffJob j = ops.PostJob(StaffJobKind.Pay, g.id, g.table, null, 1);
                j.onDone = delegate { OnPaid(old); };
                g.job = j;
                return;
            }

            Vector3 spot;
            int index = ops.TakeRegisterQueueSpot(out spot);
            if (index < 0) return;      // 並ぶ点が埋まっている。席で待つ

            g.payerSpot = index;
            g.payerSent = true;

            // 会計で立ち上がるので椅子を引く（B6）
            PullChair(g, SeatedGuest.ReasonPay);

            Vector3 from = g.table != null ? g.table.deliveryPoint : _targets.entrance;
            g.payerBody = BorrowBody(from, false);
            if (g.payerBody != null)
            {
                g.payerBody.SetTable(g.table != null ? g.table.tableId : 0);
                g.payerBody.Begin(people, GuestAgent.Errand.Pay, spot, float.MaxValue,
                                  false, from, null);
            }

            Group captured = g;
            StaffJob job = ops.PostJob(StaffJobKind.Pay, g.id, g.table, null, 1);
            job.onDone = delegate { OnPaid(captured); };
            g.job = job;
        }

        /// <summary>会計の人を席へ戻し、レジの並ぶ点を空ける</summary>
        void ReleasePayer(Group g)
        {
            if (g.payerBody != null) { ReturnBody(g.payerBody); g.payerBody = null; }
            if (g.payerSpot >= 0) { ops.ReleaseRegisterQueueSpot(g.payerSpot); g.payerSpot = -1; }
            g.payerSent = false;
        }

        void OnPaid(Group g)
        {
            ReleasePayer(g);
            g.job = null;
            g.payS = ops.NowS;
            g.leftS = ops.NowS;
            g.state = GState.Left;
            g.stuckS = 0f;

            // 退店するので椅子を引く（B6）
            PullChair(g, SeatedGuest.ReasonLeave);

            // 組の全員が立って入口から出る
            int tableId = g.table != null ? g.table.tableId : 0;
            for (int i = 0; i < g.size; i++)
            {
                Vector3 from = g.table != null ? g.table.deliveryPoint : _targets.entrance;
                GuestAgent a = BorrowBody(from, false);
                if (a == null) break;
                GuestAgent captured = a;
                a.SetTable(tableId);
                a.Begin(people, GuestAgent.Errand.Leave, _targets.entrance, 0f, false, Vector3.zero,
                        delegate { ReturnBody(captured); });
            }
        }

        void OnCleared(Group g)
        {
            g.job = null;
            ops.ReleaseTable(g.table, g.leftS);
            g.state = GState.Done;
            _groupsDone++;
        }

        void StartErrand(Group g, int kind)
        {
            if (g.table == null) return;
            Vector3 home = g.table.deliveryPoint;
            Vector3 target = kind == 1 ? _targets.toilet : _targets.drinkBar;
            float dwell = kind == 1 ? spec.toiletSec : spec.drinkBarSec;

            // ドリンクバーは、空いている機械の前へ行く。埋まっていたら端に並ぶ。
            // それも埋まっていたら、立ち上がらずにあきらめる（B4）
            int token = -1;
            if (kind == 0 && ops.HasDrinkBarSpots)
            {
                Vector3 spot;
                bool queued;
                token = ops.TakeDrinkBarSpot(out spot, out queued);
                if (token < 0)
                {
                    ops.AddGiveUp("客", "ドリンクバー", g.table.tableId, target, home, 0f, "あきらめた");
                    return;
                }
                target = spot;
            }

            GuestAgent a = BorrowBody(home, false);
            if (a == null) { ops.ReleaseDrinkBarSpot(token, 0f); return; }

            // 立ち上がるので椅子を引く（B6）
            PullChair(g, kind == 1 ? SeatedGuest.ReasonToilet : SeatedGuest.ReasonDrinkBar);

            g.activeErrands++;
            Group cg = g;
            GuestAgent ca = a;
            int ct = token;
            float startS = ops.NowS;
            a.SetTable(g.table.tableId);
            a.Begin(people, kind == 1 ? GuestAgent.Errand.Toilet : GuestAgent.Errand.DrinkBar,
                    target, dwell, true, home,
                    delegate
                    {
                        cg.activeErrands--;
                        ops.ReleaseDrinkBarSpot(ct, ops.NowS - startS - dwell);
                        PullChair(cg, SeatedGuest.ReasonSitBack);   // 戻って座り直す（B6）
                        ReturnBody(ca);
                    });
        }

        bool AllFirstDishesArrived(Group g)
        {
            if (g.cookAt.Count > 0) return false;
            if (g.dishes.Count < 2) return false;
            for (int i = 0; i < 2 && i < g.dishes.Count; i++)
                if (g.dishes[i].arrivedS < 0f) return false;
            return true;
        }

        bool AllDishesArrived(Group g)
        {
            for (int i = 0; i < g.dishes.Count; i++)
                if (g.dishes[i].arrivedS < 0f) return false;
            return true;
        }

        // ---------------- 歩く体の使い回し ----------------

        GuestAgent BorrowBody(Vector3 at, bool waitingKind)
        {
            // 待合の客も同じ体を使う（GuestAgent が「待っている」用事を持っている）
            GameObject prefab = guestPrefab;
            if (prefab == null || bodyRoot == null) return null;

            GuestAgent a = null;
            for (int i = _pool.Count - 1; i >= 0; i--)
            {
                GuestAgent c = _pool[i];
                _pool.RemoveAt(i);
                if (c == null) continue;
                a = c;
                break;
            }
            if (a == null)
            {
                GameObject go = Instantiate(prefab, bodyRoot);
                a = go.GetComponent<GuestAgent>();
                if (a == null) { Destroy(go); return null; }
            }

            a.gameObject.SetActive(true);
            a.ops = ops;             // あきらめたことを残す先（B1）

            // 人がロボットをよける仕掛け（prefab に無くても付ける）
            if (spawner != null) spawner.AttachRobotYield(a.gameObject, _nextGroupId * 31 + _live.Count);

            a.PlaceAt(at);
            _live.Add(a);
            return a;
        }

        void ReturnBody(GuestAgent a)
        {
            if (a == null) return;
            _live.Remove(a);
            a.Stop();
            a.gameObject.SetActive(false);
            _pool.Add(a);
        }

        void ReturnAllBodies()
        {
            for (int i = _live.Count - 1; i >= 0; i--) ReturnBody(_live[i]);
            _live.Clear();
        }

        public void ClearAll()
        {
            ReturnAllBodies();
            for (int i = 0; i < _pool.Count; i++)
                if (_pool[i] != null) Destroy(_pool[i].gameObject);
            _pool.Clear();
            _groups.Clear();
            _waiting.Clear();
        }
    }
}
```

### Assets/ServiceSim/Service/ServiceOps.cs

```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>料理1皿</summary>
    public class DishOrder
    {
        public int id;
        public int groupId;
        public int tableIndex;      // StoreBuilder.Tables の番号
        public int tableId;         // 卓の番号（報告用）
        public float readyS = -1f;  // 受け渡し口に出た時刻（店の中, 秒）
        public float arrivedS = -1f;// 卓に届いた時刻

        /// <summary>はじめて誰かが持ち上げた時刻（受け渡し口で待った時間を出すため）</summary>
        public float firstTakenS = -1f;

        /// <summary>最後に持ち上げられた時刻（運んでいた時間を出すため）</summary>
        public float lastTakenS = -1f;
        public bool fragile;        // デザート・ドリンク
        public bool taken;          // 誰かが持っている
        public bool byRobot;        // ロボットが運んだ
        public int requeued;        // 受け渡し口に戻された回数

        /// <summary>
        /// 同じ通路に2台を集めないために、後に回した皿か（v5・A）。
        /// 数を1皿につき1回だけ数えるための印（毎コマ数えないため）
        /// </summary>
        public bool deferredForAisle;

        /// <summary>
        /// スタッフも運べるようになった時刻（店の中, 秒）。まだなら -1（サイトの試算 v2.7）。
        ///
        /// 料理ができてから robot_first_wait_s のあいだは、ロボットだけが運ぶ。
        /// その時間を過ぎたら、ここに時刻が入り、スタッフの仕事板にも出る
        /// （そこからはロボットとスタッフの先に空いた方が運ぶ）。
        /// </summary>
        public float staffEligibleS = -1f;

        /// <summary>
        /// はじめからスタッフも運べる皿か（サイトの試算 v2.7）。
        /// 割れ物を人が運ぶ設定（fragile_by=staff）の倒れやすい料理と、ロボット0台のとき。
        /// この皿は「ロボットを待っていた皿」として数えない
        /// （待つ決まりがはじめから当てはまらないため）
        /// </summary>
        public bool staffFromStart;

        /// <summary>
        /// ロボットだけを待つ時間を過ぎたか（サイトの試算 v2.7）。
        /// staffFromStart の皿では立たない
        /// </summary>
        public bool robotWaitExpired;
    }

    /// <summary>スタッフの仕事。優先は試算の staff_job と同じ（1=案内・配膳・呼び出し・会計、2=片付け）</summary>
    /// <summary>
    /// スタッフの仕事の種類。
    /// Rescue（v5 3章）は、動けなくなったロボットのところへ歩いて行って rescue_s 動かす仕事。
    /// サイトの決まりで「ほかの仕事より先」なので、優先度は 0（ほかは 1 と 2）。
    /// </summary>
    public enum StaffJobKind { Guide, Serve, Call, Pay, Clear, Rescue }

    public class StaffJob
    {
        public StaffJobKind kind;
        public int priority;
        public int groupId;
        public int tableIndex;
        public int tableId;
        public DishOrder dish;      // Serve のときだけ
        public ServingRobotController robot;  // Rescue のときだけ（動けなくなった台）
        public bool taken;
        public float postedS;

        /// <summary>スタッフが取った時刻（店の中, 秒）。まだなら -1。入店待ちの内訳に使う</summary>
        public float takenS = -1f;

        /// <summary>
        /// この仕事を何回あきらめたか（v5 3章）。**仕事ごと**に数える。
        /// 人ごとに数えていたときは、仕事が人から人へ回ると誰も上限（staffGiveUpMaxTries）に
        /// 届かず、「3回で移して済ませる」逃がし弁が働かないまま回り続けた。
        /// </summary>
        public int tries;
        /// <summary>仕事が終わったときに呼ばれる（出した側が入れる）</summary>
        public System.Action onDone;
    }

    /// <summary>
    /// あきらめた・移した・卓を空けた、の1回ぶん（give_ups.csv に1行）。
    /// 「移した」「卓を空けた」が多いときは、人が通れない店になっているしるし。
    /// </summary>
    public struct GiveUpRow
    {
        public float tS;         // 店の中の時刻（秒）
        public string who;       // 客 / スタッフ / 組
        public string job;       // 何をしていたか
        public int tableId;      // 関わる卓（無ければ 0）
        public float toX, toZ;   // 向かっていた先
        public float x, z;       // そのときの位置
        public string aisle;     // そのときいた通路
        public float waitedS;    // 着けないまま待った秒数
        public string action;    // あきらめた / 移した / 卓を空けた
    }

    /// <summary>
    /// 人がロボットに近づきすぎた1回ぶん（contacts.csv に1行）。
    /// ロボットの外周から PeopleSpec.robotContactGapM 以内に入ったとき。
    /// </summary>
    public struct ContactRow
    {
        public float tS;         // 店の中の時刻（秒）
        public float x, z;       // そのときの位置
        public string aisle;     // そのときいた通路
        public string place;     // そのときいた場所（レジ・待合・ドリンクバーなど。無ければ空）
        public string who;       // 誰が（種類:名前）
        public bool careless;    // よけ方を守らない人だったか
        public int robotId;      // 相手の台
        public float gapM;       // ロボットの外周からの空き（m。負なら重なった）
    }

    /// <summary>
    /// 届いた料理1皿ぶん（dishes.csv に1行）。サイトの試算 v2.7 の割り当ての決まりを確かめるため。
    /// 「誰が運んだか」「受け渡し口で待った秒数」「2分を過ぎて人が拾ったか」を残す。
    /// </summary>
    public struct DishRow
    {
        public int dishId;
        public int tableId;
        public int groupId;
        public bool fragile;

        /// <summary>料理ができた時刻（店の中, 秒）</summary>
        public float readyS;

        /// <summary>はじめて誰かが持ち上げた時刻</summary>
        public float firstTakenS;

        /// <summary>卓に届いた時刻</summary>
        public float arrivedS;

        /// <summary>誰が運んだか（ロボット / スタッフ）</summary>
        public bool byRobot;

        /// <summary>受け渡し口で待った秒数（できてから、はじめて持ち上げられるまで）</summary>
        public float passWaitS;

        /// <summary>運んでいた秒数（最後に持ち上げられてから、卓に届くまで）</summary>
        public float carryS;

        /// <summary>途中で戻されて失った秒数</summary>
        public float requeueS;

        /// <summary>受け渡し口に戻された回数</summary>
        public int requeued;

        /// <summary>はじめからスタッフも運べる皿だったか（fragile_by=staff・ロボット0台）</summary>
        public bool staffFromStart;

        /// <summary>ロボットだけを待つ時間（robot_first_wait_s）を過ぎたか</summary>
        public bool robotWaitExpired;

        /// <summary>
        /// 2分（robot_first_wait_s）を過ぎて人が拾った皿か。
        /// robotWaitExpired かつスタッフが運んだもの（staffFromStart の皿は数えない）
        /// </summary>
        public bool lateStaffPickup;

        /// <summary>同じ通路に2台を集めない決まりで後に回された皿か（v5・A）</summary>
        public bool deferredForAisle;
    }

    /// <summary>
    /// 誰が誰に譲った1回ぶん（yields.csv に1行。2026-10-23）。
    /// 譲る順番は RobotSpec（ロボット＞スタッフ＞客）。
    /// 譲り終わったとき（相手が通り過ぎた・待ちすぎてやめた）に1行足す。
    /// </summary>
    public struct YieldRow
    {
        /// <summary>店の中の時刻（秒）。譲り**始めた**時刻</summary>
        public float tS;

        /// <summary>譲った側（ロボット1号機 / スタッフ:名前 / 客:名前）</summary>
        public string who;

        /// <summary>譲った側の順番（0=ロボット・1=スタッフ・2=客）</summary>
        public int rank;

        /// <summary>相手（ロボット2号機 など）</summary>
        public string toWhom;

        /// <summary>譲り始めた位置</summary>
        public float x, z;

        /// <summary>そのときいた通路（無ければ空）</summary>
        public string aisle;

        /// <summary>そのときいた場所（レジ・待合など。無ければ空）</summary>
        public string place;

        /// <summary>受け渡し口と客席をつなぐ角の中だったか</summary>
        public bool inCorner;

        /// <summary>譲って待った秒数</summary>
        public float waitedS;

        /// <summary>相手の道から横へどけたか（false なら来た道へ戻って待った）</summary>
        public bool steppedAside;

        /// <summary>相手が通り過ぎて終わったか（false なら待ちすぎてやめた）</summary>
        public bool passed;
    }

    /// <summary>届けられずに受け渡し口へ戻した1回ぶん（requeues.csv に1行）</summary>
    public struct RequeueRow
    {
        public float tS;
        public int dishId;
        public int tableId;
        public int robotId;
        public bool fragile;
        public float readyS;      // 料理ができた時刻
        public int requeuedSoFar; // この皿が戻された回数（この回を含む）
        public string why;        // 動けなくなった / 打ち切り / 取りやめ / 着かなかった
    }

    /// <summary>卓1つの状態</summary>
    public class TableSlot
    {
        public int index;
        public int tableId;
        public Vector3 deliveryPoint;
        public Vector3 aisleEntrance;
        public Bounds aisleZone;

        /// <summary>この卓へ行くときに通る細い通路の番号（店の形の aisles）。-1 なら通らない</summary>
        public int aisleIndex = -1;
        public Vector3 seatPoint;

        /// <summary>何人かけの卓か（2人席なら2、4人席なら4）</summary>
        public int seatCount = 4;

        public int groupId = -1;    // -1 なら空き
        public bool clearing;       // 片付け中（まだ使えない）
        public bool Free { get { return groupId < 0 && !clearing; } }
    }

    /// <summary>
    /// 店の回し方の中心。卓の空き、受け渡し口の料理の待ち行列、スタッフの仕事板を持ち、
    /// 空いたロボットに料理をまとめて渡す（試算の deliver / _robot_run と同じ決まり）。
    ///
    /// この script は店の運営だけを扱う。人の歩き方はそれぞれの script、
    /// ロボットの止まる決まりは ServingRobotController に任せる。
    /// 数字は ServiceSpec の .asset に置く。
    /// </summary>
    public class ServiceOps : MonoBehaviour
    {
        [Header("設定")]
        public ServiceSpec spec;
        public RobotSpec robotSpec;

        [Tooltip("卓と通路を読む先")]
        public StoreBuilder store;

        [Header("いまの様子（読むだけ）")]
        [SerializeField] int _freeTables;
        [SerializeField] int _dishesWaiting;

        /// <summary>受け渡し口でロボットを待っている皿（サイトの試算 v2.7）</summary>
        [SerializeField] int _dishesWaitingForRobot;
        [SerializeField] int _jobsWaiting;

        readonly List<TableSlot> _tables = new List<TableSlot>();
        readonly List<DishOrder> _pass = new List<DishOrder>();
        readonly List<StaffJob> _jobs = new List<StaffJob>();
        readonly List<ServingRobotController> _robots = new List<ServingRobotController>();
        readonly Dictionary<ServingRobotController, List<DishOrder>> _carrying =
            new Dictionary<ServingRobotController, List<DishOrder>>();
        readonly Dictionary<ServingRobotController, List<DishOrder>> _lastBatch =
            new Dictionary<ServingRobotController, List<DishOrder>>();

        /// <summary>
        /// 止まる所の番号 → その所で降ろす皿（依頼 v8 の 1）。
        ///
        /// StartRun に渡す stops を作るループは SlotOf が null の皿を飛ばすので、
        /// 「止まる所の番号＝batch の番号」とは限らない。だからこの対応表で引く。
        /// </summary>
        readonly Dictionary<ServingRobotController, List<DishOrder>> _stopDishes =
            new Dictionary<ServingRobotController, List<DishOrder>>();

        int _nextDishId = 1;

        // ---- 記録（TripLogger と ExperimentRunner が読む）----
        public readonly List<float> dishWaitMin = new List<float>();   // 料理ができてから届くまで（分）
        public readonly List<float> seatWaitMin = new List<float>();   // 入店から案内が始まるまで（分）
        public readonly List<float> turnMin = new List<float>();       // 退店から卓が空くまで（分）

        // ---- 入店待ちの内訳（v5 3章・サイトの試算（haizen_sim.py）と同じ分け方）----
        //   seat_wait   … 店に来てから案内が始まるまで（seatWaitMin）
        //   blocked_wait… そのうち、卓は空いたのに片付けが終わらず待った分
        //   full_wait   … seat_wait − blocked_wait（ほんとうに満席で待った分）
        //   guide_wait  … 案内の仕事が板に出てから、スタッフが取るまで
        //   staff_wait  … blocked_wait ＋ guide_wait（店側が追いつかなかった分）
        //   entry_wait  … 店に来てから卓に着くまで（案内の歩きと guide_s を含む）
        /// <summary>entry_wait_mean：店に来てから案内されて卓に着くまで（分）</summary>
        public readonly List<float> entryWaitMin = new List<float>();

        /// <summary>full_wait_mean：席待ち − blocked_wait（分）</summary>
        public readonly List<float> fullWaitMin = new List<float>();

        /// <summary>blocked_wait_mean：卓は空いたが片付けが終わらず待った分（分）</summary>
        public readonly List<float> blockedWaitMin = new List<float>();

        /// <summary>guide_wait_mean：案内の仕事が板に出てから取られるまで（分）</summary>
        public readonly List<float> guideWaitMin = new List<float>();

        /// <summary>staff_wait_mean：blocked_wait ＋ guide_wait（分）</summary>
        public readonly List<float> staffWaitMin = new List<float>();
        public int robotDishes { get; private set; }
        public int staffDishes { get; private set; }
        public int requeuedDishes { get; private set; }

        // ---- あきらめた記録（B1）----
        readonly List<GiveUpRow> _giveUps = new List<GiveUpRow>();
        public IReadOnlyList<GiveUpRow> GiveUps { get { return _giveUps; } }

        // ---- 人がロボットをよけた記録 ----
        readonly List<ContactRow> _contacts = new List<ContactRow>();

        /// <summary>人がロボットに近づきすぎた1回ずつ（contacts.csv）</summary>
        public IReadOnlyList<ContactRow> Contacts { get { return _contacts; } }

        // ---- 譲り合いの記録（yields.csv・2026-10-23）----
        readonly List<YieldRow> _yields = new List<YieldRow>();

        /// <summary>誰が誰に譲った1回ずつ（yields.csv）</summary>
        public IReadOnlyList<YieldRow> Yields { get { return _yields; } }

        /// <summary>
        /// 譲り1回ぶんを残す。譲り**終わった**ときに呼ぶ（待った秒数が決まってから）。
        /// startS は譲り始めた時刻、at は譲り始めた位置。
        /// </summary>
        public void AddYield(float startS, Vector3 at, string who, int rank, string toWhom,
                             float waitedS, bool steppedAside, bool passed)
        {
            YieldRow r = new YieldRow();
            r.tS = startS;
            r.who = who;
            r.rank = rank;
            r.toWhom = toWhom;
            r.x = at.x;
            r.z = at.z;
            r.aisle = AisleNameAt(at);
            r.place = store != null ? store.PlaceNameAt(at) : "";
            // 依頼 v9：角の箱は SiteRoutes のときだけ働く
            r.inCorner = robotSpec != null && !robotSpec.UseMapPlanner && robotSpec.InCorner(at);
            r.waitedS = Mathf.Max(0f, waitedS);
            r.steppedAside = steppedAside;
            r.passed = passed;
            _yields.Add(r);
        }

        /// <summary>人がロボットを待った回数（入口で待つ＋通路の中で下がる）</summary>
        public int robotYieldWaits { get; private set; }

        /// <summary>人がロボットを待っていた秒数の合計</summary>
        public float robotYieldWaitSeconds { get; private set; }

        /// <summary>人が回り道した回数</summary>
        public int robotYieldDetours { get; private set; }

        // ---- レジの前の混み具合（調べるための記録）----
        /// <summary>レジの前にいた人数のいちばん多いとき</summary>
        public int registerCrowdMax { get; private set; }

        float _registerCrowdSum;
        int _registerCrowdSamples;

        // ---- 会計のときスタッフが店員側（レジの内側）にいたか ----
        /// <summary>会計をした回数</summary>
        public int registerPayCount { get; private set; }

        /// <summary>そのうち、店員側にいなかった回数（0 になるはず）</summary>
        public int registerPayNotAtBackCount { get; private set; }

        /// <summary>立つ点から 0.1 m より離れて会計した回数（0 になるはず）</summary>
        public int registerPayOffSpotCount { get; private set; }

        /// <summary>立つ点と実際に立った位置の距離のいちばん大きいもの（m）</summary>
        public float registerPayMaxOffsetM { get; private set; }

        /// <summary>立つ点からの距離の合計（平均を出すため, m）</summary>
        float _registerPayOffsetSum;

        /// <summary>
        /// いま店員側（レジの内側）で会計しているスタッフがいるか。
        /// 店員側は 0.8 × 1.2 m で1人ぶんなので、会計の仕事は同時に1つだけ取れる。
        /// </summary>
        public bool RegisterBackBusy { get; private set; }

        /// <summary>店員側に、会計でないスタッフがいた秒数（0 になるはず）</summary>
        public float registerBackIntruderSeconds { get; private set; }

        /// <summary>店員側に、会計でないスタッフがいた回数（0 になるはず）</summary>
        public int registerBackIntruderCount { get; private set; }

        /// <summary>会計でないスタッフが店員側にいたことを残す（StaffAgent が毎コマ呼ぶ）</summary>
        public void AddRegisterBackIntruder(float dt, bool firstFrame)
        {
            registerBackIntruderSeconds += dt;
            if (firstFrame) registerBackIntruderCount++;
        }

        /// <summary>立つ点と実際に立った位置の距離の平均（m）</summary>
        public float RegisterPayMeanOffsetM
        {
            get { return registerPayCount > 0 ? _registerPayOffsetSum / registerPayCount : 0f; }
        }

        /// <summary>
        /// 会計1回ぶんを数える。
        /// atBack が false なら店員側にいなかった。offsetM は立つ点からの距離。
        /// どちらも 0 になるはず（ならなければ、スタッフが店員側に入れていない）。
        /// </summary>
        public void AddRegisterPay(bool atBack, float offsetM)
        {
            registerPayCount++;
            if (!atBack) registerPayNotAtBackCount++;

            _registerPayOffsetSum += offsetM;
            if (offsetM > registerPayMaxOffsetM) registerPayMaxOffsetM = offsetM;
            float limit = spec != null ? spec.registerPayOffSpotM : 0.1f;
            if (offsetM > limit) registerPayOffSpotCount++;
        }

        /// <summary>レジの前にいた人数の平均</summary>
        public float RegisterCrowdMean
        {
            get { return _registerCrowdSamples > 0 ? _registerCrowdSum / _registerCrowdSamples : 0f; }
        }

        // ---- スタッフの時間の使い方（仕事ごとの秒数）----
        readonly Dictionary<string, float> _staffTime = new Dictionary<string, float>();

        /// <summary>スタッフが何にどれだけ時間を使ったか（仕事の名前 → 秒）</summary>
        public IDictionary<string, float> StaffTime { get { return _staffTime; } }

        // ---- 料理が届くまでの内訳（運んだのがロボットか人かで分ける）----
        /// <summary>届いた料理1皿ぶんの内訳（秒）</summary>
        public struct DishParts
        {
            public bool byRobot;
            public float passWaitS;   // 受け渡し口で待った（できてから、はじめて持ち上げられるまで）
            public float carryS;      // 運んでいた（最後に持ち上げられてから、卓に届くまで）
            public float requeueS;    // 途中で戻されて失った（残り）
            public int requeued;      // 戻された回数
        }

        readonly List<DishParts> _dishParts = new List<DishParts>();

        /// <summary>届いた料理の、届くまでの内訳（summary.json に出す）</summary>
        public IReadOnlyList<DishParts> DishPartsList { get { return _dishParts; } }

        // ---- 皿ごとの記録（dishes.csv・サイトの試算 v2.7）----
        readonly List<DishRow> _dishRows = new List<DishRow>();

        /// <summary>届いた料理1皿ずつの記録（dishes.csv）</summary>
        public IReadOnlyList<DishRow> DishRows { get { return _dishRows; } }

        /// <summary>
        /// 2分（robot_first_wait_s）を過ぎて人が拾った皿の数（summary.json に出す）。
        /// ロボットだけを待つ時間を過ぎてから、スタッフが運んだ皿
        /// </summary>
        public int lateStaffDishes { get; private set; }

        /// <summary>
        /// いま受け渡し口でロボットを待っている皿の数（キッチンのモニターに出す）。
        /// ロボットだけを待つ時間（robot_first_wait_s）の中にいて、まだ誰も持っていない皿。
        /// はじめからスタッフも運べる皿（fragile_by=staff・ロボット0台）は数えない
        /// </summary>
        public int DishesWaitingForRobot { get { return _dishesWaitingForRobot; } }

        // ---- 仕事が板で待った秒数（仕事ごと）----
        readonly Dictionary<string, float> _jobWaitSum = new Dictionary<string, float>();
        readonly Dictionary<string, int> _jobWaitCount = new Dictionary<string, int>();
        readonly Dictionary<string, float> _jobWaitMax = new Dictionary<string, float>();
        readonly Dictionary<string, int> _jobWaitOverCount = new Dictionary<string, int>();

        /// <summary>仕事が板に出てから取られるまでの秒数の合計（仕事の名前 → 秒）</summary>
        public IDictionary<string, float> JobWaitSum { get { return _jobWaitSum; } }

        /// <summary>仕事が取られた回数（仕事の名前 → 回）</summary>
        public IDictionary<string, int> JobWaitCount { get { return _jobWaitCount; } }

        /// <summary>
        /// 仕事が板に出てから取られるまでの、いちばん長かった秒数（仕事の名前 → 秒。依頼 v10 の 2）。
        /// 逃がし弁の数え方を直す前は、片付け・会計の長い待ちが「卓を空けた」で消えていた
        /// </summary>
        public IDictionary<string, float> JobWaitMax { get { return _jobWaitMax; } }

        /// <summary>
        /// そのうち JobWaitOverSec（600 秒）を超えた数（仕事の名前 → 回。依頼 v10 の 2）
        /// </summary>
        public IDictionary<string, int> JobWaitOverCount { get { return _jobWaitOverCount; } }

        /// <summary>長びいたと数える境目（秒）。ServiceSpec.tableStuckFreeSec と同じ</summary>
        public float JobWaitOverSec
        {
            get { return spec != null && spec.tableStuckFreeSec > 0f ? spec.tableStuckFreeSec : 600f; }
        }

        void RecordJobWait(StaffJob j)
        {
            if (j == null) return;
            string k = StaffAgent.JobKindName(j.kind);
            float waited = Mathf.Max(0f, NowS - j.postedS);
            float v; int n;
            _jobWaitSum.TryGetValue(k, out v);
            _jobWaitCount.TryGetValue(k, out n);
            _jobWaitSum[k] = v + waited;
            _jobWaitCount[k] = n + 1;

            // いちばん長かった待ちと、長びいた数（依頼 v10 の 2）
            float max;
            _jobWaitMax.TryGetValue(k, out max);
            if (waited > max) _jobWaitMax[k] = waited;
            if (waited >= JobWaitOverSec)
            {
                int over;
                _jobWaitOverCount.TryGetValue(k, out over);
                _jobWaitOverCount[k] = over + 1;
            }
        }

        /// <summary>スタッフの時間を足す（StaffAgent が毎コマ呼ぶ）</summary>
        public void AddStaffTime(string what, float dt)
        {
            if (string.IsNullOrEmpty(what)) return;
            float v;
            _staffTime.TryGetValue(what, out v);
            _staffTime[what] = v + dt;
        }

        // ---- レジの前に並ぶ点（会計に立つ人が1人ずつ）----
        readonly List<Vector3> _regQueues = new List<Vector3>();
        readonly List<bool> _regQueueUsed = new List<bool>();

        /// <summary>レジの並ぶ点を1つ取る。無ければ -1（会計に立つ人も席で待つ）</summary>
        public int TakeRegisterQueueSpot(out Vector3 spot)
        {
            for (int i = 0; i < _regQueueUsed.Count; i++)
            {
                if (_regQueueUsed[i]) continue;
                _regQueueUsed[i] = true;
                spot = _regQueues[i];
                return i;
            }
            spot = RegisterPointOrPass();
            return -1;
        }

        /// <summary>取ったレジの並ぶ点を空ける</summary>
        public void ReleaseRegisterQueueSpot(int index)
        {
            if (index < 0 || index >= _regQueueUsed.Count) return;
            _regQueueUsed[index] = false;
        }

        Vector3 RegisterPointOrPass()
        {
            return store != null ? store.RegisterPoint : Vector3.zero;
        }
        public int gaveUpCount { get; private set; }      // あきらめた
        public int warpedCount { get; private set; }      // 移した
        public int tableFreedCount { get; private set; }  // 卓を空けた

        // ---- 立ち往生でスタッフを呼んだ回数（v5 3章・meta.metrics の rescues）----
        /// <summary>
        /// 同じ通路に2台を集めないために、料理を後に回した回数（v5・A）。
        /// 多いときは、2台が同じ通路の卓ばかりを受け持とうとしているしるし
        /// </summary>
        public int aisleDeferrals { get; private set; }

        /// <summary>動けなくなって、スタッフを呼んだ回数</summary>
        public int rescueCalls { get; private set; }

        /// <summary>そのうち、スタッフが着いて動かした回数</summary>
        public int rescueDone { get; private set; }

        /// <summary>スタッフが着くまでロボットが待った秒数の合計</summary>
        public float rescueWaitSeconds { get; private set; }

        // ---- ロボットのふさがっている割合（robot_util）----
        float _robotBusyS, _robotAliveS;

        /// <summary>robot_util：ロボットが空いていなかった時間の割合</summary>
        public float RobotUtil { get { return _robotAliveS > 0.01f ? _robotBusyS / _robotAliveS : 0f; } }

        // ---- スタッフが歩いた距離（walk_km_per_staff）----
        float _staffWalkM;
        int _staffCount;

        /// <summary>スタッフ1人が歩いた距離（km）</summary>
        public float WalkKmPerStaff
        {
            get { return _staffCount > 0 ? _staffWalkM / 1000f / _staffCount : 0f; }
        }

        /// <summary>スタッフの人数を伝える（walk_km_per_staff を出すため）</summary>
        public void SetStaffCount(int n) { _staffCount = n; }

        /// <summary>スタッフが1コマで歩いた距離を足す（StaffAgent が呼ぶ）</summary>
        public void AddStaffWalk(float m)
        {
            if (m > 0f && m < 1f) _staffWalkM += m;   // 1 m を超える跳びは「移した」ぶんなので数えない
        }

        // ---- 戻した皿の記録（B8）----
        readonly List<RequeueRow> _requeues = new List<RequeueRow>();
        public IReadOnlyList<RequeueRow> Requeues { get { return _requeues; } }

        // ---- 椅子を引いた回数（B6）----
        readonly Dictionary<string, int> _chairPullsByReason = new Dictionary<string, int>();
        readonly Dictionary<int, int> _chairPullsByTable = new Dictionary<int, int>();
        public int chairPulls { get; private set; }
        public IDictionary<string, int> ChairPullsByReason { get { return _chairPullsByReason; } }
        public IDictionary<int, int> ChairPullsByTable { get { return _chairPullsByTable; } }

        // ---- ドリンクバーの場所取り（B4）----
        readonly List<Vector3> _dbStands = new List<Vector3>();
        readonly List<Vector3> _dbQueues = new List<Vector3>();
        readonly List<bool> _dbStandUsed = new List<bool>();
        readonly List<bool> _dbQueueUsed = new List<bool>();

        /// <summary>並ぶ点に回された回数</summary>
        public int drinkBarQueued { get; private set; }

        /// <summary>立つ点も並ぶ点も空いていなくて、あきらめた回数</summary>
        public int drinkBarGaveUp { get; private set; }

        /// <summary>並んで待った秒数（分で記録）</summary>
        public readonly List<float> drinkBarWaitMin = new List<float>();

        public IList<ServingRobotController> Robots { get { return _robots; } }
        public IList<TableSlot> Tables { get { return _tables; } }
        public int FreeTableCount { get { return _freeTables; } }
        public int DishesWaiting { get { return _dishesWaiting; } }

        /// <summary>
        /// 仕事板に載っている仕事（読むだけ。依頼 v10b の 2）。
        /// 時間帯ごとの記録（TimelineLogger）が、取られていない仕事の数と
        /// 片付けの仕事が取られているかを数えるのに使う。書き換えないこと
        /// </summary>
        public IList<StaffJob> Jobs { get { return _jobs; } }

        /// <summary>店の中の時刻（秒）。ExperimentRunner が進める</summary>
        public float NowS { get; private set; }

        // ---------------- 用意 ----------------

        public void Setup()
        {
            _tables.Clear();
            _pass.Clear();
            _jobs.Clear();
            _carrying.Clear();
            _stopDishes.Clear();        // 止まる所と皿の対応表（依頼 v8 の 1）
            _lastBatch.Clear();
            dishWaitMin.Clear();
            seatWaitMin.Clear();
            turnMin.Clear();
            entryWaitMin.Clear();
            fullWaitMin.Clear();
            blockedWaitMin.Clear();
            guideWaitMin.Clear();
            staffWaitMin.Clear();
            aisleDeferrals = 0;
            rescueCalls = 0;
            rescueDone = 0;
            rescueWaitSeconds = 0f;
            _robotBusyS = 0f;
            _robotAliveS = 0f;
            _staffWalkM = 0f;
            robotDishes = 0;
            staffDishes = 0;
            requeuedDishes = 0;
            _giveUps.Clear();
            _requeues.Clear();
            _contacts.Clear();
            _yields.Clear();
            _staffTime.Clear();
            _dishParts.Clear();
            _dishRows.Clear();
            lateStaffDishes = 0;
            _dishesWaitingForRobot = 0;
            _jobWaitSum.Clear();
            _jobWaitCount.Clear();
            _jobWaitMax.Clear();
            _jobWaitOverCount.Clear();
            registerCrowdMax = 0;
            registerPayCount = 0;
            registerPayNotAtBackCount = 0;
            registerPayOffSpotCount = 0;
            registerPayMaxOffsetM = 0f;
            _registerPayOffsetSum = 0f;
            RegisterBackBusy = false;
            registerBackIntruderSeconds = 0f;
            registerBackIntruderCount = 0;
            _registerCrowdSum = 0f;
            _registerCrowdSamples = 0;
            _regQueues.Clear();
            _regQueueUsed.Clear();
            robotYieldWaits = 0;
            robotYieldWaitSeconds = 0f;
            robotYieldDetours = 0;
            robotStuckDetours = 0;
            _chairPullsByReason.Clear();
            _chairPullsByTable.Clear();
            chairPulls = 0;
            gaveUpCount = 0;
            warpedCount = 0;
            tableFreedCount = 0;
            _nextDishId = 1;
            NowS = 0f;

            drinkBarQueued = 0;
            drinkBarGaveUp = 0;
            drinkBarWaitMin.Clear();
            _dbStands.Clear();
            _dbQueues.Clear();
            _dbStandUsed.Clear();
            _dbQueueUsed.Clear();

            if (store == null) return;
            IList<StoreBuilder.TableInfo> t = store.Tables;
            for (int i = 0; i < t.Count; i++)
            {
                TableSlot s = new TableSlot();
                s.index = i;
                s.tableId = t[i].tableId;
                s.deliveryPoint = t[i].deliveryPoint;
                s.aisleEntrance = t[i].aisleEntrance;
                s.aisleZone = t[i].aisleZone;
                s.aisleIndex = t[i].aisleIndex;
                s.seatPoint = t[i].center;
                s.seatCount = t[i].seatCount;
                _tables.Add(s);
            }
            // ドリンクバーの立つ点・並ぶ点を読む（B4）
            SetupDrinkBar(store.PointsOf(PlaceKind.DrinkBarStand),
                          store.PointsOf(PlaceKind.DrinkBarQueue));

            // レジの前に並ぶ点を読む
            IList<Vector3> rq = store.RegisterQueuePoints;
            for (int i = 0; i < rq.Count; i++) { _regQueues.Add(rq[i]); _regQueueUsed.Add(false); }

            Recount();
        }

        // ---------------- ドリンクバーの場所取り（B4）----------------

        void SetupDrinkBar(IList<Vector3> stands, IList<Vector3> queues)
        {
            for (int i = 0; i < stands.Count; i++) { _dbStands.Add(stands[i]); _dbStandUsed.Add(false); }
            for (int i = 0; i < queues.Count; i++) { _dbQueues.Add(queues[i]); _dbQueueUsed.Add(false); }
        }

        /// <summary>
        /// ドリンクバーの場所を1つ取る（B4）。
        ///   ・空いている立つ点があれば、そこへ
        ///   ・埋まっていれば、端の並ぶ点へ
        ///   ・それも埋まっていれば取れない（客はあきらめて席に戻る）
        /// spot は向かう点、queued は並ぶ点だったか。
        /// 返した番号を ReleaseDrinkBarSpot に渡して空ける。
        /// </summary>
        public int TakeDrinkBarSpot(out Vector3 spot, out bool queued)
        {
            for (int i = 0; i < _dbStandUsed.Count; i++)
            {
                if (_dbStandUsed[i]) continue;
                _dbStandUsed[i] = true;
                spot = _dbStands[i];
                queued = false;
                return i;
            }

            for (int i = 0; i < _dbQueueUsed.Count; i++)
            {
                if (_dbQueueUsed[i]) continue;
                _dbQueueUsed[i] = true;
                spot = _dbQueues[i];
                queued = true;
                drinkBarQueued++;
                return 1000 + i;      // 1000 以上は並ぶ点
            }

            spot = _dbStands.Count > 0 ? _dbStands[0] : Vector3.zero;
            queued = false;
            drinkBarGaveUp++;
            return -1;
        }

        /// <summary>取った場所を空ける。waitedS は並んで待った秒数</summary>
        public void ReleaseDrinkBarSpot(int token, float waitedS)
        {
            if (token < 0) return;
            if (token >= 1000)
            {
                int i = token - 1000;
                if (i < _dbQueueUsed.Count) _dbQueueUsed[i] = false;
                drinkBarWaitMin.Add(Mathf.Max(0f, waitedS / 60f));
                return;
            }
            if (token < _dbStandUsed.Count) _dbStandUsed[token] = false;
        }

        /// <summary>ドリンクバーに立つ点があるか（無ければ今までどおり1点へ行く）</summary>
        public bool HasDrinkBarSpots { get { return _dbStands.Count > 0; } }

        public void RegisterRobot(ServingRobotController r)
        {
            if (r == null || _robots.Contains(r)) return;
            _robots.Add(r);
            _carrying[r] = new List<DishOrder>();
            r.ops = this;               // 立ち往生したときスタッフを呼ぶ先（v5 3章）
            // 待機場所を先に教える（1往復めの戻りから、戻りの線を使えるように。v5）
            if (store != null)
            {
                IList<Vector3> spots = store.RobotStandbyPoints;
                if (spots.Count > 0) r.SetStandbyPoint(spots[(_robots.Count - 1) % spots.Count]);
            }
            r.TripCompleted += OnRobotRunDone;
            r.StopArrived += OnRobotStopArrived;     // 卓に着いた時点で届いたにする（依頼 v8 の 1）
        }

        public void ClearRobots()
        {
            for (int i = 0; i < _robots.Count; i++)
            {
                if (_robots[i] == null) continue;
                _robots[i].TripCompleted -= OnRobotRunDone;
                _robots[i].StopArrived -= OnRobotStopArrived;
            }
            _robots.Clear();
            _carrying.Clear();
            _stopDishes.Clear();
            _lastBatch.Clear();
        }

        /// <summary>店の中の時刻を進める（ExperimentRunner が毎コマ呼ぶ）</summary>
        public void Advance(float dt)
        {
            NowS += dt;
            AssignRobots();
            ReleaseDishesToStaff();
            ParkIdleRobots();
            SampleRegisterCrowd();
            SampleRobotUtil(dt);
            Recount();
        }

        /// <summary>
        /// ロボットがふさがっていた時間を数える（robot_util）。
        /// 待機場所へ下がっているあいだ・受け渡し口で空のまま待っているあいだは Idle / Done なので
        /// 「空いている」。動けなくなって人を待っているあいだ（StuckWait）は「ふさがっている」。
        /// </summary>
        void SampleRobotUtil(float dt)
        {
            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController r = _robots[i];
                if (r == null) continue;
                _robotAliveS += dt;
                ServingRobotController.Phase p = r.CurrentPhase;
                if (p != ServingRobotController.Phase.Idle &&
                    p != ServingRobotController.Phase.Done) _robotBusyS += dt;
            }
        }

        /// <summary>
        /// レジの前にいた人数を数える（調べるための記録）。
        /// 待合に立つ客も、通路を歩く人も、スタッフも数える。
        /// </summary>
        void SampleRegisterCrowd()
        {
            if (store == null || spec == null) return;
            if (!store.HasPoint(PlaceKind.Register)) return;

            Vector3 reg = store.RegisterPoint;
            float r2 = spec.registerCrowdRadiusM * spec.registerCrowdRadiusM;

            int n = 0;
            IReadOnlyList<PersonBody> all = PersonBody.All;
            for (int i = 0; i < all.Count; i++)
            {
                PersonBody b = all[i];
                if (b == null || b.kind == PersonKind.Seated) continue;
                Vector3 d = b.transform.position - reg;
                d.y = 0f;
                if (d.sqrMagnitude <= r2) n++;
            }

            if (n > registerCrowdMax) registerCrowdMax = n;
            _registerCrowdSum += n;
            _registerCrowdSamples++;
        }

        /// <summary>
        /// 仕事のないロボットを待機場所へ下げる（B2）。
        /// 受け渡し口の前の点にはスタッフも料理を取りに来るので、空の台を置かない。
        /// 料理を載せるときは AssignRobots が受け渡し口から走り出させる。
        ///
        /// v5：料理が待っていても下げる。
        /// 前は「料理が待っているなら受け渡し口で待つ」にしていたが、そのせいで
        /// 出発する台と戻ってきた台が受け渡し口の前の1点に重なり、ふさぎ合っていた
        /// （20261023 0913 の回で、受け渡し口の前の止まりが「もう1台のロボット」23 回）。
        /// 待機場所は1台に1つなので、重ならない。
        /// 料理の割り当て（AssignRobots）はこの前に走るので、載せる台はそのまま走り出す。
        /// </summary>
        void ParkIdleRobots()
        {
            if (store == null) return;
            IList<Vector3> spots = store.RobotStandbyPoints;
            if (spots.Count == 0) return;

            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController r = _robots[i];
                if (r == null) continue;
                if (r.CurrentPhase != ServingRobotController.Phase.Idle &&
                    r.CurrentPhase != ServingRobotController.Phase.Done) continue;

                Vector3 spot = spots[i % spots.Count];
                r.SetStandbyPoint(spot);   // 戻りの線の終わりに使う（v5）
                r.GoStandby(spot);
            }
        }

        void Recount()
        {
            int free = 0;
            for (int i = 0; i < _tables.Count; i++) if (_tables[i].Free) free++;
            _freeTables = free;

            int d = 0;
            for (int i = 0; i < _pass.Count; i++) if (!_pass[i].taken) d++;
            _dishesWaiting = d;

            int j = 0;
            for (int i = 0; i < _jobs.Count; i++) if (!_jobs[i].taken) j++;
            _jobsWaiting = j;
        }

        // ---------------- 卓 ----------------

        /// <summary>
        /// ほかの台が向かっている、または中にいる細い通路の番号（v5・A）。
        ///   ・まだ届けていない卓の通路（往復の途中のもの）
        ///   ・いま体がその通路の区画の中にある台の通路（戻り道で通っているときも押さえる）
        /// </summary>
        HashSet<int> AislesTakenByOthers(ServingRobotController self)
        {
            HashSet<int> busy = new HashSet<int>();

            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController o = _robots[i];
                if (o == null || o == self) continue;
                if (o.CurrentPhase == ServingRobotController.Phase.Idle ||
                    o.CurrentPhase == ServingRobotController.Phase.Done) continue;

                IReadOnlyList<int> ids = o.TableIds;
                for (int k = o.StopsCompleted; k < ids.Count; k++)
                {
                    TableSlot s = SlotOfTableId(ids[k]);
                    if (s != null && s.aisleIndex >= 0) busy.Add(s.aisleIndex);
                }

                if (store == null) continue;
                IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
                Vector3 p = o.transform.position;
                for (int a = 0; a < aisles.Count; a++)
                {
                    Bounds b = aisles[a].zone;
                    if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                    busy.Add(a);
                }
            }
            return busy;
        }

        TableSlot SlotOfTableId(int tableId)
        {
            for (int i = 0; i < _tables.Count; i++) if (_tables[i].tableId == tableId) return _tables[i];
            return null;
        }

        /// <summary>
        /// その人数が座れる空き卓を1つ取る。無ければ null。
        /// 「入るうちでいちばん小さい卓」を選ぶので、
        ///   ・1〜2人の組は空いている2人席を先に使い、無ければ4人席
        ///   ・3〜4人の組は4人席だけ
        /// になる（2人席に3人以上は座らせない）。
        /// </summary>
        public TableSlot TakeFreeTable(int groupId, int size)
        {
            TableSlot slot = FindFreeTable(size);
            if (slot == null) return null;

            slot.groupId = groupId;
            Recount();
            return slot;
        }

        /// <summary>その人数が入るうちで、いちばん席数の少ない空き卓。無ければ null</summary>
        TableSlot FindFreeTable(int size)
        {
            int want = Mathf.Max(1, size);
            TableSlot best = null;
            int bestSeats = int.MaxValue;

            for (int i = 0; i < _tables.Count; i++)
            {
                if (!_tables[i].Free) continue;
                int seats = Mathf.Max(1, _tables[i].seatCount);
                if (seats < want) continue;
                if (seats >= bestSeats) continue;
                best = _tables[i];
                bestSeats = seats;
                if (bestSeats == want) break;      // ちょうどの大きさが見つかった
            }
            return best;
        }

        /// <summary>その人数が座れる空き卓があるか</summary>
        public bool HasFreeTableFor(int size)
        {
            return FindFreeTable(size) != null;
        }

        /// <summary>慣らしのために、卓を使っている状態で始める</summary>
        public TableSlot TakeTableAt(int index, int groupId)
        {
            if (index < 0 || index >= _tables.Count) return null;
            if (!_tables[index].Free) return null;
            _tables[index].groupId = groupId;
            Recount();
            return _tables[index];
        }

        /// <summary>片付けが終わって卓が空く</summary>
        public void ReleaseTable(TableSlot slot, float leftS)
        {
            if (slot == null) return;
            slot.groupId = -1;
            slot.clearing = false;
            if (leftS >= 0f) turnMin.Add((NowS - leftS) / 60f);
            Recount();
        }

        public void RecordSeatWait(float arriveS)
        {
            seatWaitMin.Add(Mathf.Max(0f, (NowS - arriveS) / 60f));
        }

        /// <summary>
        /// 入店待ちの内訳を1組ぶん残す（v5 3章）。卓に着いた時に GroupManager が呼ぶ。
        /// 分け方はサイトの試算（haizen_sim.py）と同じ。
        ///   entryWaitS   … 店に来てから卓に着くまで（案内に歩いた時間と guide_s を含む）
        ///   seatWaitS    … 店に来てから案内が始まるまで
        ///   blockedWaitS … そのうち、卓は空いたのに片付けが終わらず待った分
        ///   guideWaitS   … 案内の仕事が板に出てから、スタッフが取るまで
        /// full_wait は seat_wait − blocked_wait、staff_wait は blocked_wait ＋ guide_wait。
        /// </summary>
        public void RecordEntryWait(float entryWaitS, float seatWaitS,
                                    float blockedWaitS, float guideWaitS)
        {
            float blocked = Mathf.Clamp(blockedWaitS, 0f, Mathf.Max(0f, seatWaitS));
            float full = Mathf.Max(0f, seatWaitS - blocked);
            float guide = Mathf.Max(0f, guideWaitS);

            entryWaitMin.Add(Mathf.Max(0f, entryWaitS / 60f));
            fullWaitMin.Add(full / 60f);
            blockedWaitMin.Add(blocked / 60f);
            guideWaitMin.Add(guide / 60f);
            staffWaitMin.Add((blocked + guide) / 60f);
        }

        /// <summary>
        /// 片付け待ちの卓の数（v5 3章・サイトの _acc と同じ blocked_wait を数えるのに使う）。
        /// 客が帰ってから、拭き終わって使えるようになるまでの卓
        /// </summary>
        public int ClearingTableCount
        {
            get
            {
                int n = 0;
                for (int i = 0; i < _tables.Count; i++) if (_tables[i].clearing) n++;
                return n;
            }
        }

        // ---------------- 立ち往生でスタッフを呼ぶ（v5 3章）----------------

        /// <summary>
        /// 動けなくなったロボットのところへスタッフを呼ぶ。
        /// サイトの決まりで「ほかの仕事より先」なので、優先度 0 で板の先頭に出す。
        /// 受け渡し口へ移す（warp）のは、もうしない。
        /// </summary>
        public StaffJob PostRescue(ServingRobotController robot)
        {
            if (robot == null) return null;
            StaffJob j = NewJob(StaffJobKind.Rescue, 0, null, null, 0);
            j.robot = robot;
            _jobs.Insert(0, j);
            rescueCalls++;
            Recount();
            return j;
        }

        /// <summary>スタッフが着いて rescue_s 動かし終わった（ロボットが呼ぶ）</summary>
        public void RescueFinished(float waitedS)
        {
            rescueDone++;
            rescueWaitSeconds += Mathf.Max(0f, waitedS);
        }

        /// <summary>
        /// そのロボットに出ている Rescue の仕事を板から外す（往復を打ち切ったときなど）。
        /// まだ誰も取っていないものは板から消す。
        /// もう誰かが向かっている途中のものは消さず、相手の台だけ外す
        /// （歩き出したスタッフはそのまま着いて終える。消すとその人の仕事が宙に浮く）。
        /// </summary>
        public void CancelRescue(ServingRobotController robot)
        {
            for (int i = _jobs.Count - 1; i >= 0; i--)
            {
                StaffJob j = _jobs[i];
                if (j.kind != StaffJobKind.Rescue || j.robot != robot) continue;
                if (j.taken) j.robot = null;
                else _jobs.RemoveAt(i);
            }
            Recount();
        }

        // ---------------- あきらめた記録（B1）----------------

        /// <summary>あきらめた・移した・卓を空けたことを1行残す</summary>
        public void AddGiveUp(string who, string job, int tableId, Vector3 to, Vector3 at,
                              float waitedS, string action)
        {
            GiveUpRow r = new GiveUpRow();
            r.tS = NowS;
            r.who = who;
            r.job = job;
            r.tableId = tableId;
            r.toX = to.x;
            r.toZ = to.z;
            r.x = at.x;
            r.z = at.z;
            r.aisle = AisleNameAt(at);
            r.waitedS = waitedS;
            r.action = action;
            _giveUps.Add(r);

            if (action == "移した") warpedCount++;
            else if (action == "卓を空けた") tableFreedCount++;
            else gaveUpCount++;
        }

        // ---------------- 人がロボットをよけた記録 ----------------

        /// <summary>人がロボットの外周の近くまで来た1回を残す（接触に近い）</summary>
        public void AddRobotContact(Vector3 at, string who, bool careless, int robotId, float gapM)
        {
            ContactRow r = new ContactRow();
            r.tS = NowS;
            r.x = at.x;
            r.z = at.z;
            r.aisle = AisleNameAt(at);
            r.place = store != null ? store.PlaceNameAt(at) : "";
            r.who = who;
            r.careless = careless;
            r.robotId = robotId;
            r.gapM = gapM;
            _contacts.Add(r);
        }

        /// <summary>人がロボットを待ち始めた（入口で待つ・通路の中で下がる）</summary>
        public void AddRobotYieldWait() { robotYieldWaits++; }

        /// <summary>人がロボットを待っていた秒数を足す</summary>
        public void AddRobotYieldWaitSeconds(float dt) { robotYieldWaitSeconds += dt; }

        /// <summary>人が回り道した</summary>
        public void AddRobotYieldDetour() { robotYieldDetours++; }

        /// <summary>
        /// 店員優先の版の逃げ道：相手のロボットが別の理由で止まったまま
        /// staffFirstRobotStuckSec（60 秒）たって、店員が回り道した回数（v5）
        /// </summary>
        public int robotStuckDetours { get; private set; }

        public void AddRobotStuckDetour() { robotStuckDetours++; }

        // ---------------- 椅子を引いた回数（B6）----------------

        /// <summary>椅子を1脚引いた。きっかけごと・卓ごとに数える</summary>
        public void AddChairPull(string reason, int tableId)
        {
            chairPulls++;

            string key = string.IsNullOrEmpty(reason) ? "その他" : reason;
            int n;
            _chairPullsByReason[key] = _chairPullsByReason.TryGetValue(key, out n) ? n + 1 : 1;
            _chairPullsByTable[tableId] = _chairPullsByTable.TryGetValue(tableId, out n) ? n + 1 : 1;
        }

        /// <summary>その点がどの通路の上かを名前で返す。細い通路を先に見る</summary>
        public string AisleNameAt(Vector3 p)
        {
            if (store == null) return "";
            string wide = "";
            IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
            for (int i = 0; i < aisles.Count; i++)
            {
                Bounds b = aisles[i].zone;
                if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                if (aisles[i].narrow) return aisles[i].name;
                if (wide.Length == 0) wide = aisles[i].name;
            }
            return wide;
        }

        // ---------------- 料理 ----------------

        /// <summary>
        /// 料理が1皿できて、受け渡し口に出る。
        ///
        /// 割り当ての決まりはサイトの試算 v2.7 にそろえる:
        ///   ・まずロボットを待つ。そのあいだスタッフは料理を持たず、案内・片付け・会計を続ける
        ///     （＝スタッフの仕事板に Serve を出さない。ロボットは _pass を直に見るので受け持てる）
        ///   ・robot_first_wait_s（common.robot_first_wait_s）を過ぎてもロボットが受け持たなければ、
        ///     そこからはロボットとスタッフのうち先に空いた方が運ぶ（ReleaseDishesToStaff が出す）
        ///   ・割れ物を人が運ぶ設定（fragile_by=staff）の倒れやすい料理と、ロボット0台のときは、
        ///     今までどおり、はじめからスタッフが運ぶ
        /// </summary>
        public DishOrder PutDish(int groupId, TableSlot slot, bool fragile)
        {
            DishOrder d = new DishOrder();
            d.id = _nextDishId++;
            d.groupId = groupId;
            d.tableIndex = slot.index;
            d.tableId = slot.tableId;
            d.readyS = NowS;
            d.fragile = fragile;
            d.staffFromStart = StaffCarriesFromStart(d);
            _pass.Add(d);

            if (d.staffFromStart)
            {
                // ロボットが受け持てない皿。待たせる意味がないので、はじめから仕事板に出す
                d.staffEligibleS = NowS;
                PostJob(StaffJobKind.Serve, groupId, slot, d, 1);
            }
            Recount();
            return d;
        }

        /// <summary>
        /// はじめからスタッフも運べる皿か（サイトの試算 v2.7）。
        ///   ・割れ物を人が運ぶ設定（fragile_by=staff）の、倒れやすい料理
        ///   ・ロボットが1台もいないとき
        ///   ・robot_first_wait_s が 0 以下のとき（v2.7 より前と同じ回し方に戻せる逃がし口）
        /// </summary>
        bool StaffCarriesFromStart(DishOrder d)
        {
            if (spec == null) return true;
            if (spec.robotFirstWaitSec <= 0f) return true;
            if (!spec.fragileByRobot && d.fragile) return true;
            return RobotCount == 0;
        }

        /// <summary>動いているロボットの数（null の枠は数えない）</summary>
        public int RobotCount
        {
            get
            {
                int n = 0;
                for (int i = 0; i < _robots.Count; i++) if (_robots[i] != null) n++;
                return n;
            }
        }

        /// <summary>
        /// ロボットだけを待つ時間を過ぎた皿を、スタッフの仕事板にも出す（サイトの試算 v2.7）。
        ///
        /// 待った時間は **料理ができた時（readyS）から**数える。
        /// 同じ通路に2台を集めない決まり（oneRobotPerAisle）で後に回した皿も、
        /// 受け渡し口に戻された皿も、この数え方は変えない
        /// （後に回された分だけ待ちが延びるのを、そのまま出すため）。
        /// </summary>
        void ReleaseDishesToStaff()
        {
            if (spec == null) return;
            float wait = spec.robotFirstWaitSec;

            int waitingForRobot = 0;
            for (int i = 0; i < _pass.Count; i++)
            {
                DishOrder d = _pass[i];
                if (d.arrivedS >= 0f) continue;

                // もうスタッフも運べる皿（はじめから運べる皿も含む）
                if (d.staffEligibleS >= 0f) continue;

                if (NowS - d.readyS < wait)
                {
                    // まだロボットだけを待つ時間の中。誰も持っていなければ「ロボットを待っている皿」
                    if (!d.taken) waitingForRobot++;
                    continue;
                }

                d.staffEligibleS = NowS;
                d.robotWaitExpired = true;

                // 誰かが持っている皿（ロボットが載せた・運んでいる途中）は、板に出さない。
                // 戻されたときに RequeueDish が出し直す
                if (d.taken) continue;

                TableSlot slot = SlotOf(d.tableIndex);
                if (slot != null) PostJob(StaffJobKind.Serve, d.groupId, slot, d, 1);
            }
            _dishesWaitingForRobot = waitingForRobot;
        }

        /// <summary>
        /// 慣らしのために、もう届いている皿として1枚作る（B3）。
        /// 受け渡し口にも仕事板にも出さず、待ち時間の記録にも入れない
        /// （慣らしの皿を 0 分として数えると dish_wait が小さく出てしまう）。
        /// </summary>
        public DishOrder SeedArrivedDish(int groupId, TableSlot slot)
        {
            DishOrder d = new DishOrder();
            d.id = _nextDishId++;
            d.groupId = groupId;
            d.tableIndex = slot != null ? slot.index : -1;
            d.tableId = slot != null ? slot.tableId : 0;
            d.readyS = NowS;
            d.arrivedS = NowS;
            d.fragile = false;
            return d;
        }

        /// <summary>届いた（ロボットでもスタッフでも）</summary>
        /// <summary>料理を持ち上げた。受け渡し口で待った時間・運んでいた時間を出すために時刻を残す</summary>
        void MarkTaken(DishOrder d)
        {
            if (d == null) return;
            d.taken = true;
            if (d.firstTakenS < 0f) d.firstTakenS = NowS;
            d.lastTakenS = NowS;
        }

        public void DishArrived(DishOrder d, bool byRobot)
        {
            if (d == null || d.arrivedS >= 0f) return;
            d.arrivedS = NowS;
            d.byRobot = byRobot;
            dishWaitMin.Add(Mathf.Max(0f, (d.arrivedS - d.readyS) / 60f));
            if (byRobot) robotDishes++; else staffDishes++;
            AddDishParts(d);
            _pass.Remove(d);
            RemoveServeJob(d);
            Recount();
        }

        /// <summary>
        /// 届けられなかった料理を、受け渡し口の待ち行列の先頭に戻す。
        /// 次に空いたロボットかスタッフが運ぶ。
        /// </summary>
        /// <summary>
        /// 届いた1皿の、届くまでの内訳を足す。
        ///   受け渡し口で待った ＋ 運んでいた ＋ 途中で戻されて失った ＝ できてから届くまで
        /// </summary>
        void AddDishParts(DishOrder d)
        {
            if (d == null || d.arrivedS < 0f || d.readyS < 0f) return;

            float total = Mathf.Max(0f, d.arrivedS - d.readyS);
            float passWait = d.firstTakenS >= 0f ? Mathf.Max(0f, d.firstTakenS - d.readyS) : total;
            float carry = d.lastTakenS >= 0f ? Mathf.Max(0f, d.arrivedS - d.lastTakenS) : 0f;

            DishParts p = new DishParts();
            p.byRobot = d.byRobot;
            p.passWaitS = passWait;
            p.carryS = carry;
            p.requeueS = Mathf.Max(0f, total - passWait - carry);
            p.requeued = d.requeued;
            _dishParts.Add(p);

            // ---- 皿ごとの記録（dishes.csv・サイトの試算 v2.7）----
            // 「2分を過ぎて人が拾った皿」は、ロボットだけを待つ時間を過ぎてから
            // スタッフが運んだもの。はじめからスタッフが運ぶ皿
            // （fragile_by=staff・ロボット0台）は、待つ決まりが当てはまらないので数えない
            bool late = d.robotWaitExpired && !d.staffFromStart && !d.byRobot;

            DishRow row = new DishRow();
            row.dishId = d.id;
            row.tableId = d.tableId;
            row.groupId = d.groupId;
            row.fragile = d.fragile;
            row.readyS = d.readyS;
            row.firstTakenS = d.firstTakenS;
            row.arrivedS = d.arrivedS;
            row.byRobot = d.byRobot;
            row.passWaitS = passWait;
            row.carryS = carry;
            row.requeueS = p.requeueS;
            row.requeued = d.requeued;
            row.staffFromStart = d.staffFromStart;
            row.robotWaitExpired = d.robotWaitExpired;
            row.lateStaffPickup = late;
            row.deferredForAisle = d.deferredForAisle;
            _dishRows.Add(row);

            if (late) lateStaffDishes++;
        }

        public void RequeueDish(DishOrder d)
        {
            RequeueDish(d, 0, "");
        }

        /// <summary>戻した理由も残す（B8）。robotId 0 はロボット以外</summary>
        public void RequeueDish(DishOrder d, int robotId, string why)
        {
            if (d == null || d.arrivedS >= 0f) return;
            d.taken = false;
            d.requeued++;
            requeuedDishes++;

            RequeueRow r = new RequeueRow();
            r.tS = NowS;
            r.dishId = d.id;
            r.tableId = d.tableId;
            r.robotId = robotId;
            r.fragile = d.fragile;
            r.readyS = d.readyS;
            r.requeuedSoFar = d.requeued;
            r.why = string.IsNullOrEmpty(why) ? "着かなかった" : why;
            _requeues.Add(r);

            _pass.Remove(d);
            _pass.Insert(0, d);

            TableSlot slot = SlotOf(d.tableIndex);
            RemoveServeJob(d);

            // サイトの試算 v2.7：まだロボットだけを待つ時間の中なら、板に出さずロボットを待つ。
            // 待った時間は料理ができた時から数えるので、戻されても数え直さない
            // （過ぎていれば ReleaseDishesToStaff が staffEligibleS を入れている）
            if (slot != null && d.staffEligibleS >= 0f)
                PostJobFront(StaffJobKind.Serve, d.groupId, slot, d, 1);
            Recount();
        }

        TableSlot SlotOf(int index)
        {
            return index >= 0 && index < _tables.Count ? _tables[index] : null;
        }

        // ---------------- スタッフの仕事板 ----------------

        public StaffJob PostJob(StaffJobKind kind, int groupId, TableSlot slot, DishOrder dish, int priority)
        {
            StaffJob j = NewJob(kind, groupId, slot, dish, priority);
            _jobs.Add(j);
            Recount();
            return j;
        }

        StaffJob PostJobFront(StaffJobKind kind, int groupId, TableSlot slot, DishOrder dish, int priority)
        {
            StaffJob j = NewJob(kind, groupId, slot, dish, priority);
            _jobs.Insert(0, j);
            Recount();
            return j;
        }

        StaffJob NewJob(StaffJobKind kind, int groupId, TableSlot slot, DishOrder dish, int priority)
        {
            StaffJob j = new StaffJob();
            j.kind = kind;
            j.priority = priority;
            j.groupId = groupId;
            j.tableIndex = slot != null ? slot.index : -1;
            j.tableId = slot != null ? slot.tableId : 0;
            j.dish = dish;
            j.postedS = NowS;
            return j;
        }

        /// <summary>
        /// 次にやる仕事を1つ取る。優先度が小さいものが先、同じなら出た順。
        /// 配膳の仕事は、料理がまだ誰も持っていないものだけ。
        /// 立ち往生（Rescue）は優先度 0 なので、ほかの仕事より先に取られる。
        /// </summary>
        public StaffJob TakeNextJob()
        {
            StaffJob best = null;
            for (int i = 0; i < _jobs.Count; i++)
            {
                StaffJob j = _jobs[i];
                if (j.taken) continue;
                if (j.kind == StaffJobKind.Serve && (j.dish == null || j.dish.taken || j.dish.arrivedS >= 0f)) continue;
                // 店員側（レジの内側）は1人ぶんなので、会計はひとつずつ
                if (j.kind == StaffJobKind.Pay && RegisterBackBusy) continue;
                if (best == null || j.priority < best.priority) best = j;
                if (best != null && best.priority <= 0) break;   // 優先0（立ち往生）が見つかれば、それが先
            }
            if (best == null) return null;
            best.taken = true;
            best.takenS = NowS;
            if (best.kind == StaffJobKind.Pay) RegisterBackBusy = true;
            RecordJobWait(best);
            if (best.kind == StaffJobKind.Serve && best.dish != null) MarkTaken(best.dish);
            Recount();
            return best;
        }

        /// <summary>仕事が終わった</summary>
        public void FinishJob(StaffJob job)
        {
            if (job == null) return;
            if (job.kind == StaffJobKind.Pay) RegisterBackBusy = false;   // 店員側を空ける
            _jobs.Remove(job);
            Recount();
            if (job.onDone != null) job.onDone();
        }

        /// <summary>
        /// 仕事を板から外すだけ（終わったときの呼び出しはしない）。
        /// 卓を強引に空けたときなど、出した側がもう待っていないときに使う。
        /// </summary>
        public void FinishJobSilently(StaffJob job)
        {
            if (job == null) return;
            if (job.kind == StaffJobKind.Pay) RegisterBackBusy = false;   // 店員側を空ける
            job.onDone = null;
            _jobs.Remove(job);
            if (job.kind == StaffJobKind.Serve && job.dish != null) job.dish.taken = false;
            Recount();
        }

        /// <summary>仕事を手放す（届けられなかったときなど）</summary>
        public void AbandonJob(StaffJob job)
        {
            if (job == null) return;
            job.taken = false;
            if (job.kind == StaffJobKind.Pay) RegisterBackBusy = false;   // 店員側を空ける
            if (job.kind == StaffJobKind.Serve && job.dish != null) job.dish.taken = false;
            Recount();
        }

        void RemoveServeJob(DishOrder d)
        {
            for (int i = _jobs.Count - 1; i >= 0; i--)
                if (_jobs[i].kind == StaffJobKind.Serve && _jobs[i].dish == d) _jobs.RemoveAt(i);
        }

        // ---------------- ロボットに渡す ----------------

        /// <summary>
        /// 空いているロボットに、受け渡し口の料理を段の数までまとめて渡す。
        /// 近い卓から回る（試算の _robot_run と同じ）。
        /// </summary>
        void AssignRobots()
        {
            if (spec == null || store == null) return;

            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController r = _robots[i];
                if (r == null) continue;
                if (r.CurrentPhase != ServingRobotController.Phase.Idle &&
                    r.CurrentPhase != ServingRobotController.Phase.Done) continue;

                List<DishOrder> batch = PickBatch(r);
                if (batch.Count == 0) continue;

                // 止まる所と皿を、同じ並びで組む（依頼 v8 の 1）。
                // SlotOf が null の皿は止まる所を作れないので、どちらからも外す
                List<ServingRobotController.RunStop> stops = new List<ServingRobotController.RunStop>();
                List<DishOrder> stopDishes = new List<DishOrder>();
                for (int k = 0; k < batch.Count; k++)
                {
                    TableSlot s = SlotOf(batch[k].tableIndex);
                    if (s == null) continue;
                    ServingRobotController.RunStop stop = new ServingRobotController.RunStop();
                    stop.tableId = s.tableId;
                    stop.point = s.deliveryPoint;
                    stop.aisleEntrance = s.aisleEntrance;
                    stop.aisleZone = s.aisleZone;
                    stops.Add(stop);
                    stopDishes.Add(batch[k]);
                }
                if (stops.Count == 0) continue;

                _carrying[r] = batch;
                _stopDishes[r] = stopDishes;
                r.StartRun(stops, store.PassPoint, NowS);
            }
        }

        /// <summary>段の数までまとめる。近い卓から順に並べる</summary>
        List<DishOrder> PickBatch(ServingRobotController self)
        {
            List<DishOrder> batch = new List<DishOrder>();
            Vector3 pass = store.PassPoint;
            int trays = Mathf.Max(1, spec.trays);

            // 同じ通路に2台を集めない（v5・A）。
            // ほかの台が向かっている・中にいる通路の卓は、この回では受け持たない。
            // 1往復で3卓まとめるときも入れない（この for で毎回見る）
            bool oneEach = robotSpec != null && robotSpec.oneRobotPerAisle;
            HashSet<int> busy = oneEach ? AislesTakenByOthers(self) : null;

            for (int i = 0; i < _pass.Count && batch.Count < trays; i++)
            {
                DishOrder d = _pass[i];
                if (d.taken || d.arrivedS >= 0f) continue;
                if (!spec.fragileByRobot && d.fragile) continue;   // 人だけが運ぶ料理
                if (busy != null)
                {
                    TableSlot slot = SlotOf(d.tableIndex);
                    if (slot != null && slot.aisleIndex >= 0 && busy.Contains(slot.aisleIndex))
                    {
                        // 数えるのは1皿につき1回だけ（毎コマ数えると意味のない大きさになる）
                        if (!d.deferredForAisle) { d.deferredForAisle = true; aisleDeferrals++; }
                        continue;
                    }
                }
                batch.Add(d);
            }

            batch.Sort(delegate (DishOrder a, DishOrder b)
            {
                TableSlot sa = SlotOf(a.tableIndex), sb = SlotOf(b.tableIndex);
                float da = sa != null ? Flat(sa.deliveryPoint - pass) : 0f;
                float db = sb != null ? Flat(sb.deliveryPoint - pass) : 0f;
                return da.CompareTo(db);
            });

            for (int i = 0; i < batch.Count; i++)
            {
                MarkTaken(batch[i]);
                RemoveServeJob(batch[i]);
            }
            return batch;
        }

        static float Flat(Vector3 d) { d.y = 0f; return d.magnitude; }

        /// <summary>
        /// ロボットが止まる所（卓）に着いた（依頼 v8 の 1）。
        ///
        /// サイトの試算と同じで、**卓に着いた時点でその皿は届いた**。
        /// 受け渡しの 25 秒（RobotSpec.tableDwellSec）はこのあとなので、
        /// 客は受け渡し口へ戻るのを待たずに食べ始められる
        /// （前は往復の最後にまとめて届いたにしていて、料理の待ちが
        ///  1皿あたり 1.0〜1.3 分長く出ていた）。
        /// </summary>
        void OnRobotStopArrived(ServingRobotController r, int stopIndex)
        {
            List<DishOrder> stopDishes;
            if (!_stopDishes.TryGetValue(r, out stopDishes) || stopDishes == null) return;
            if (stopIndex < 0 || stopIndex >= stopDishes.Count) return;

            DishArrived(stopDishes[stopIndex], true);
        }

        /// <summary>
        /// ロボットの1走りぶんの片づけが終わった（依頼 v8 の 2）。
        ///
        /// ExperimentRunner は trips.csv を書くときに LastBatchOf を読むので、
        /// ServingRobotController.TripCompleted を直に受けると、呼ばれる順番によって
        /// **1つ前の往復の皿**を読んでしまっていた。
        /// ここで片づけを終えてから出すこのイベントを受ければ、順番に頼らずに済む。
        /// </summary>
        public event System.Action<ServingRobotController> RunRecorded;

        /// <summary>
        /// ロボットの1走りが終わった。
        /// 卓に着いた皿はもう届いているので何もしない。
        /// まだ届いていない皿（arrivedS &lt; 0）だけを受け渡し口の先頭へ戻す。
        /// </summary>
        void OnRobotRunDone(ServingRobotController r)
        {
            List<DishOrder> batch;
            if (!_carrying.TryGetValue(r, out batch) || batch == null) return;

            _lastBatch[r] = batch;      // trips.csv を書くために残す
            _carrying[r] = new List<DishOrder>();
            _stopDishes[r] = new List<DishOrder>();

            // なぜ届けられなかったか。ロボットが持っている結果から決める。
            // v5 3章：動けなくなってもスタッフが動かせば走りは続くので、
            // 「動けなくなった」は打ち切り・取りやめより後ろに置く（往復が終わった理由が先）
            string why = r.TimedOut ? "打ち切り"
                       : r.Aborted ? "取りやめ"
                       : r.Stuck ? "動けなくなった"
                       : "着かなかった";

            for (int i = 0; i < batch.Count; i++)
            {
                // 卓に着いた時点で DishArrived してあるので、arrivedS で見分ける
                // （StopsCompleted の数で数えない。止まる所と皿の並びは対応表で持っている）
                if (batch[i].arrivedS >= 0f) continue;
                RequeueDish(batch[i], r.robotId, why);
            }

            // 片づけが終わってから知らせる（ここで LastBatchOf を読んでよい）
            if (RunRecorded != null) RunRecorded(r);
        }

        /// <summary>そのロボットがいま載せている料理</summary>
        public IList<DishOrder> CarriedBy(ServingRobotController r)
        {
            List<DishOrder> batch;
            if (r != null && _carrying.TryGetValue(r, out batch)) return batch;
            return new List<DishOrder>();
        }

        /// <summary>直前の1走りで載せていた料理（trips.csv を書くのに使う）</summary>
        public IList<DishOrder> LastBatchOf(ServingRobotController r)
        {
            List<DishOrder> batch;
            if (r != null && _lastBatch.TryGetValue(r, out batch) && batch != null) return batch;
            return new List<DishOrder>();
        }
    }
}
```

### Assets/ServiceSim/Experiment/SimParams.cs

```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// サイトの試算の値（serving-robot-params.json）を読む .asset。
    ///
    /// 2026-10-22 にエイイチと決めた「何について正か」（unity_design_v0.md の 0 章）:
    ///   条件（スタッフ・来客・ロボットの台数）と、店の回し方の決まりと時間は **サイトが正**。
    /// なので、店の回し方の数字は .asset に手で写さず、この JSON から読む。
    ///
    /// role（meta.roles）の扱い:
    ///   site_rule      … サイトが正。Unity はこの値を使う
    ///   unity_measures … **読まない**（Unity で測ってサイトに戻すための仮の値）
    ///   site_only      … 使わない（試算の中だけの設定）
    /// unity_measures の名前（robot_speed / pause_rate / pause_s / stuck_rate）は、
    /// まちがって使わないように、読もうとしたら例外ではなく「読まない」として弾く。
    ///
    /// 版の突き合わせ: meta.layout_generation が、店の形の JSON の meta.generation と
    /// 違ったら回さない（Ready が false になる）。
    ///
    /// 試算の版（meta.model）と、条件のレジの数（condition.registers）も突き合わせる（依頼 v10 の 0）。
    /// 版は expectModel（.asset に置く）と比べる。Unity はレジ1つ・店員1人しか作っていないので、
    /// registers が 1 でなければ回さない。
    /// </summary>
    [CreateAssetMenu(menuName = "ServiceSim/Sim Params", fileName = "SimParams")]
    public class SimParams : ScriptableObject
    {
        /// <summary>Unity が読まない値（unity_measures）の名前</summary>
        public static readonly string[] UnityMeasureKeys =
            { "robot_speed", "pause_rate", "pause_s", "stuck_rate" };

        [Header("読む JSON（サイトから写したもの）")]
        public TextAsset paramsJson;

        [Header("写した記録（報告に出す）")]
        public string fetchedAt = "";
        public string sourceUrl = "";

        [Header("突き合わせ（違えば回さない）")]
        [Tooltip("待っている試算の版（meta.model）。空なら見ない")]
        public string expectModel = "v2.8";

        [Tooltip("Unity が作れるレジの数。condition.registers がこれと違えば回さない")]
        public int registersSupported = 1;

        // ---- 読んだ中身（Load のたびに作り直す）----
        JsonValue _root;
        readonly List<string> _problems = new List<string>();
        string _layoutGeneration = "";
        string _model = "";

        public string LayoutGeneration { get { return _layoutGeneration; } }
        public string Model { get { return _model; } }
        public bool Ready { get { return _root != null && _problems.Count == 0; } }
        public IList<string> Problems { get { return _problems; } }

        /// <summary>1 つの条件（unity_conditions の 1 件）</summary>
        public class Condition
        {
            public string store;          // fam_p1 / fam_p5
            public int staff;
            public int robots;
            public int arrivalsPerHour;
            public string mode;           // serve（上げ膳だけ）
            public int trays;             // 1 往復で運ぶ卓の数
            public string fragileBy;      // any（倒れやすい物もロボットが運ぶ）

            /// <summary>
            /// レジの数（v2.8 で足された）。Unity はレジ1つ・店員1人なので 1 だけ回せる。
            /// 無ければ 0 になるので、古い JSON も「違う」として弾かれる
            /// </summary>
            public int registers;
            public readonly Dictionary<string, float> siteResult = new Dictionary<string, float>();

            /// <summary>書き出しのフォルダ名に使う名前</summary>
            public string Id
            {
                get { return store + "_s" + staff + "_r" + robots + "_a" + arrivalsPerHour; }
            }
        }

        readonly List<Condition> _conditions = new List<Condition>();
        public IList<Condition> Conditions { get { return _conditions; } }

        /// <summary>
        /// JSON を読む。storeGeneration に店の形の JSON の meta.generation を渡すと、
        /// meta.layout_generation と突き合わせる（違えば Ready が false）。
        /// </summary>
        public bool Load(string storeGeneration)
        {
            _problems.Clear();
            _conditions.Clear();
            _root = null;
            _layoutGeneration = "";
            _model = "";

            if (paramsJson == null)
            {
                _problems.Add("serving-robot-params.json（paramsJson）が入っていません");
                return false;
            }

            string err;
            JsonValue root = JsonValue.Parse(paramsJson.text, out err);
            if (root == null || !root.IsObject)
            {
                _problems.Add("serving-robot-params.json を読めません: " + err);
                return false;
            }
            _root = root;

            JsonValue meta = root["meta"];
            _layoutGeneration = meta["layout_generation"].S("");
            _model = meta["model"].S("");

            if (string.IsNullOrEmpty(_layoutGeneration))
                _problems.Add("meta.layout_generation がありません");
            else if (!string.IsNullOrEmpty(storeGeneration) && _layoutGeneration != storeGeneration)
                _problems.Add("版の番号が店の形の JSON と違います: 試算 " + _layoutGeneration +
                              " / 店の形 " + storeGeneration + "（写し直してください）");

            // 試算の版（依頼 v10 の 0）。待っている版と違えば回さない
            if (!string.IsNullOrEmpty(expectModel) && _model != expectModel)
                _problems.Add("試算の版が違います: meta.model " +
                              (string.IsNullOrEmpty(_model) ? "（なし）" : _model) +
                              " / 待っている版 " + expectModel + "（写し直してください）");

            // ---- 条件 ----
            JsonValue list = root["unity_conditions"];
            for (int i = 0; i < list.Count; i++)
            {
                JsonValue c = list[i]["condition"];
                Condition cond = new Condition();
                cond.store = c["store"].S("");
                cond.staff = c["staff"].I(0);
                cond.robots = c["robots"].I(0);
                cond.arrivalsPerHour = c["arrivals_per_h"].I(0);
                cond.mode = c["mode"].S("serve");
                cond.trays = c["trays"].I(1);
                cond.fragileBy = c["fragile_by"].S("any");
                cond.registers = c["registers"].I(0);
                JsonValue sr = list[i]["site_result"];
                foreach (string k in sr.Keys) cond.siteResult[k] = sr[k].F(0f);
                if (string.IsNullOrEmpty(cond.store) || cond.staff <= 0)
                    _problems.Add("unity_conditions[" + i + "] の中身が足りません");
                // レジの数（依頼 v10 の 0）。Unity はレジの内側に店員1人・会計は1組ずつ
                // （payAtRegisterByOne）しか作っていないので、1 以外は回せない
                if (cond.registers != registersSupported)
                    _problems.Add("unity_conditions[" + i + "] の registers が " + cond.registers +
                                  " です。Unity は " + registersSupported +
                                  " つしか作っていないので回せません（v2.8 の JSON を写し直してください）");
                _conditions.Add(cond);
            }
            if (_conditions.Count == 0) _problems.Add("unity_conditions が空です");

            return Ready;
        }

        // ---------------- 値の読み出し ----------------

        /// <summary>
        /// common の値（role=site_rule のものだけ）。
        /// unity_measures / site_only を読もうとしたら、使わずに fallback を返して問題に残す。
        /// </summary>
        public float SiteRule(string key, float fallback)
        {
            JsonValue v = Common(key);
            if (v == null) return fallback;
            return v["value"].F(fallback);
        }

        public float[] SiteRuleArray(string key, float[] fallback)
        {
            JsonValue v = Common(key);
            if (v == null) return fallback;
            JsonValue a = v["value"];
            if (!a.IsArray) return fallback;
            float[] r = new float[a.Count];
            for (int i = 0; i < a.Count; i++) r[i] = a[i].F(0f);
            return r;
        }

        /// <summary>common の 1 件。role が site_rule でなければ null（使わない）</summary>
        JsonValue Common(string key)
        {
            if (_root == null) return null;
            JsonValue v = _root["common"][key];
            if (v.IsNull)
            {
                _problems.Add("common." + key + " がありません");
                return null;
            }
            string role = v["role"].S("");
            if (role == "site_rule") return v;
            _problems.Add("common." + key + " は role=" + role + " なので Unity は読みません");
            return null;
        }

        /// <summary>stores.{key} の 1 件（fam_p1 / fam_p5）</summary>
        public JsonValue Store(string storeKey)
        {
            if (_root == null) return null;
            JsonValue s = _root["stores"][storeKey];
            if (s.IsNull)
            {
                _problems.Add("stores." + storeKey + " がありません");
                return null;
            }
            return s;
        }

        public float StoreValue(string storeKey, string key, float fallback)
        {
            JsonValue s = Store(storeKey);
            if (s == null) return fallback;
            JsonValue v = s[key];
            return v.kind == JsonValue.Kind.Number ? v.F(fallback) : fallback;
        }

        /// <summary>stores.{key} の配列（[最短, 最頻, 最長] など）</summary>
        public float[] StoreArray(string storeKey, string key, float[] fallback)
        {
            JsonValue s = Store(storeKey);
            if (s == null) return fallback;
            JsonValue a = s[key];
            if (!a.IsArray) return fallback;
            float[] r = new float[a.Count];
            for (int i = 0; i < a.Count; i++) r[i] = a[i].F(0f);
            return r;
        }

        /// <summary>
        /// 配列の配列の、index 番めを [最短, 最頻, 最長] で取る
        /// （first_dishes は [[5,8,12],[8,12,18]] の形）
        /// </summary>
        public Vector3 StoreTriangleAt(string storeKey, string key, int index, Vector3 fallback)
        {
            JsonValue s = Store(storeKey);
            if (s == null) return fallback;
            JsonValue a = s[key];
            if (!a.IsArray || index < 0 || index >= a.Count) return fallback;
            JsonValue t = a[index];
            if (!t.IsArray || t.Count < 3) return fallback;
            return new Vector3(t[0].F(0f), t[1].F(0f), t[2].F(0f));
        }

        /// <summary>[最短, 最頻, 最長] を Vector3 で</summary>
        public Vector3 StoreTriangle(string storeKey, string key, Vector3 fallback)
        {
            float[] a = StoreArray(storeKey, key, null);
            if (a == null || a.Length < 3) return fallback;
            return new Vector3(a[0], a[1], a[2]);
        }

        /// <summary>真ん中の値（arrivals・staff の「普通」）</summary>
        public int StoreMiddle(string storeKey, string key, int fallback)
        {
            float[] a = StoreArray(storeKey, key, null);
            if (a == null || a.Length == 0) return fallback;
            return Mathf.RoundToInt(a[a.Length / 2]);
        }

        /// <summary>何番めの条件（_conditions の並びのまま）。範囲外なら null</summary>
        public Condition ConditionAt(int index)
        {
            return index >= 0 && index < _conditions.Count ? _conditions[index] : null;
        }

        /// <summary>metrics の名前と意味（meta.metrics）</summary>
        public string MetricNote(string key)
        {
            if (_root == null) return "";
            return _root["meta"]["metrics"][key].S("");
        }

        public List<string> MetricKeys()
        {
            List<string> keys = new List<string>();
            if (_root == null) return keys;
            foreach (string k in _root["meta"]["metrics"].Keys) keys.Add(k);
            return keys;
        }

        /// <summary>報告に出す覚え書き</summary>
        public string Report()
        {
            System.Text.StringBuilder sb = new System.Text.StringBuilder();
            sb.AppendLine("サイトの試算の値（serving-robot-params.json）");
            sb.AppendLine("  ファイル: " + (paramsJson != null ? paramsJson.name : "（未設定）"));
            sb.AppendLine("  写した日時: " + (string.IsNullOrEmpty(fetchedAt) ? "（未記入）" : fetchedAt));
            sb.AppendLine("  写し元: " + (string.IsNullOrEmpty(sourceUrl) ? "（未記入）" : sourceUrl));
            sb.AppendLine("  試算の版 (meta.model): " + _model +
                          "（待っている版: " + (string.IsNullOrEmpty(expectModel) ? "見ない" : expectModel) +
                          "） / 店の形の版 (meta.layout_generation): " + _layoutGeneration);
            sb.AppendLine("  条件: " + _conditions.Count + " 件");
            for (int i = 0; i < _conditions.Count; i++)
            {
                Condition c = _conditions[i];
                sb.AppendLine("    " + c.Id + "  スタッフ " + c.staff + " / ロボット " + c.robots +
                              " / " + c.arrivalsPerHour + " 組毎時 / " + c.mode +
                              " / 1往復 " + c.trays + " 卓 / 倒れやすい物 " + c.fragileBy +
                              " / レジ " + c.registers + " つ");
            }
            sb.AppendLine("  Unity が読まない値 (unity_measures): " +
                          string.Join(" / ", UnityMeasureKeys));
            if (_problems.Count == 0) sb.AppendLine("  合わないもの: なし");
            else
            {
                sb.AppendLine("  合わないもの: " + _problems.Count + " 件");
                for (int i = 0; i < _problems.Count; i++) sb.AppendLine("    - " + _problems[i]);
            }
            return sb.ToString().TrimEnd();
        }
    }
}
```

### Assets/ServiceSim/Experiment/SimParams.asset

```yaml
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
  m_ObjectHideFlags: 0
  m_CorrespondingSourceObject: {fileID: 0}
  m_PrefabInstance: {fileID: 0}
  m_PrefabAsset: {fileID: 0}
  m_GameObject: {fileID: 0}
  m_Enabled: 1
  m_EditorHideFlags: 0
  m_Script: {fileID: 11500000, guid: edb06b3fbd714f289d7487e59b3a49b3, type: 3}
  m_Name: SimParams
  m_EditorClassIdentifier: 
  paramsJson: {fileID: 4900000, guid: 7c1a9b5e4d2f4a6ca1b3e8d0f5c27a94, type: 3}
  fetchedAt: 2026-10-25 JST（試算 v2.8・店の形 8a5b2f6a016a）
  sourceUrl: unity/site_copy_v28/serving-robot-params.json（Mac から写した。ネットからは取っていない）
  expectModel: v2.8
  registersSupported: 1
```

### Assets/ServiceSim/Experiment/ExperimentPlan.asset

```yaml
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
  m_ObjectHideFlags: 0
  m_CorrespondingSourceObject: {fileID: 0}
  m_PrefabInstance: {fileID: 0}
  m_PrefabAsset: {fileID: 0}
  m_GameObject: {fileID: 0}
  m_Enabled: 1
  m_EditorHideFlags: 0
  m_Script: {fileID: 11500000, guid: 9d7ca72301ae84dbd8937d681f3df43d, type: 3}
  m_Name: ExperimentPlan
  m_EditorClassIdentifier: 
  runName: family_v0
  aisleWidthIndices: 000000000100000002000000
  crowdLevels: 000000000100000002000000
  robotCounts: 0100000002000000
  tripsPerCondition: 100
  seeds: 0000000001000000020000000300000004000000
  timeScale: 1
  kioxiaVolumePath: /Volumes/KIOXIA
  kioxiaOutputRoot: /Volumes/KIOXIA/r2r2r-data/service_sim/unity
  trialAisleWidthIndex: 1
  trialCrowd: 1
  trialRobots: 2
  trialSeed: 1
  trialTrips: 20
  trialTimeScaleFast: 10
  trialCaptureDeltaTime: 0.02
  trialMaxWallSeconds: 2400
  trialOutputRoot: /Users/shimazueiichi/ServiceSim_tmp
  measureMinutes: 120
  warmupMinutes: 15
  measureCaptureDeltaTime: 0.05
  referenceCaptureDeltaTime: 0.02
  disableRenderingWhileRunning: 0
  showLabelsWhileRunning: 0
  showKitchenMonitor: 1
  stage3OutputRoot: /Users/shimazueiichi/ServiceSim_tmp/stage3
  stage3Resume: 1
  screenshotDir: "/Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/\u30C7\u30B9\u30AF\u30C8\u30C3\u30D7/ADAPT
    DESIGN/\u3058\u3076\u3093\u306E/PixelstoMotors/\u30C7\u30B8\u30BF\u30EB\u30C4\u30A4\u30F3\u5B66\u7FD2R2R2R/\u30B5\u30FC\u30D3\u30B9\u30ED\u30DC\u30C3\u30C8\u5C0E\u5165\u30B7\u30DF\u30E5\u30EC\u30FC\u30B7\u30E7\u30F3/unity/screens"
  screenshotWidth: 1920
  screenshotHeight: 1080
```

### Assets/ServiceSim/Experiment/TimelineLogger.cs

```csharp
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 時間帯ごとの記録（依頼 v10b の 2。2026-10-25）。
    ///
    /// 店の中の時間で 60 秒ごとに、そのコマの様子をそのまま 1 行書く（平均にしない）。
    /// 「ピークのどの時間帯に、誰が何をしていて、何が板に残っていたか」を
    /// 試算と並べて調べるための記録で、店の回し方は 1 つも変えない。
    ///
    /// 1 回の出力フォルダに timeline.csv が 1 つできる（段階Bと Site Run で同じ作り）。
    ///
    /// 卓の数え方は TableSlot の中身での場合分けなので、取りこぼし・二重数えが出ない:
    ///   tables_free          groupId &lt; 0 かつ clearing でない（TableSlot.Free）
    ///   tables_dirty_untaken clearing で、片付けの仕事を誰も取っていない
    ///   tables_clearing      clearing で、片付けの仕事をスタッフが取っている
    ///   seated_groups        groupId ≧ 0 かつ clearing でない（組が座っている卓）
    /// この 4 つを足すと、いつでも卓の数になる（③42・⑦48）。CheckSums で確かめる。
    ///
    /// スタッフも同じで、StaffAgent.All を「仕事なし（待機）」と仕事の種類 6 つに分ける
    /// （Guide・Serve・Call・Pay・Clear・Rescue。StaffJobKind のぜんぶ）。
    /// 足すとスタッフの人数になる。
    /// </summary>
    public class TimelineLogger
    {
        /// <summary>60 秒ごとの 1 行</summary>
        public struct Row
        {
            public float tS;
            public int arrived, waitingGroups, seatedGroups, payWaitingSeat, payInLine;
            public int tablesFree, tablesDirtyUntaken, tablesClearing;
            public int boardGuide, boardServe, boardCall, boardPay, boardClear;
            public int staffIdle, staffGuide, staffServe, staffCall, staffPay, staffClear, staffRescue;
            public int robotsBusy;

            /// <summary>卓の 4 つの数え方の合計（卓の数と同じになるはず）</summary>
            public int TableSum
            {
                get { return seatedGroups + tablesFree + tablesDirtyUntaken + tablesClearing; }
            }

            /// <summary>スタッフの人数の合計（スタッフの人数と同じになるはず）</summary>
            public int StaffSum
            {
                get
                {
                    return staffIdle + staffGuide + staffServe + staffCall +
                           staffPay + staffClear + staffRescue;
                }
            }
        }

        public const string Header =
            "condition_id,seed,t_s,arrived,waiting_groups,seated_groups," +
            "pay_waiting_seat,pay_in_line," +
            "tables_free,tables_dirty_untaken,tables_clearing," +
            "board_guide,board_serve,board_call,board_pay,board_clear," +
            "staff_idle,staff_guide,staff_serve,staff_call,staff_pay,staff_clear,staff_rescue," +
            "robots_busy";

        readonly List<Row> _rows = new List<Row>();

        public int RowCount { get { return _rows.Count; } }
        public IReadOnlyList<Row> Rows { get { return _rows; } }
        public void Clear() { _rows.Clear(); }

        // ---------------- 1 行ぶん数える ----------------

        /// <summary>
        /// そのコマの様子を 1 行足す。ExperimentRunner が 60 秒ごとに呼ぶ。
        /// 数えるだけで、何も書き換えない
        /// </summary>
        public void Sample(float tS, GroupManager groups, ServiceOps ops)
        {
            if (groups == null || ops == null) return;

            Row r = Build(tS, groups.GroupsArrived, groups.WaitingGroups,
                          ops.Tables, ops.Jobs, groups.Groups, CountRobotsBusy(ops));
            CountStaff(StaffAgent.All, ref r);
            _rows.Add(r);
        }

        /// <summary>
        /// 1 行ぶんを組み立てる（スタッフ以外）。
        /// MonoBehaviour を触らないので、Unity 抜きでも試せる（確かめ用に public）。
        /// スタッフの数は、呼んだ側が CountStaff か AddStaff で足す
        /// </summary>
        public static Row Build(float tS, int arrived, int waitingGroups,
                                IList<TableSlot> tables, IList<StaffJob> jobs,
                                IList<GroupManager.Group> groups, int robotsBusy)
        {
            Row r = new Row();
            r.tS = tS;
            r.arrived = arrived;
            r.waitingGroups = waitingGroups;
            r.robotsBusy = robotsBusy;

            CountTables(tables, jobs, ref r);
            CountGroups(groups, ref r);
            CountBoard(jobs, ref r);
            return r;
        }

        /// <summary>組み立てた 1 行を足す（Sample と、確かめの試しが使う）</summary>
        public void AddRow(Row r) { _rows.Add(r); }

        /// <summary>
        /// 卓を 4 つに分ける。clearing の卓は、片付けの仕事が取られているかで分ける
        /// （取られていない＝ふつうの片付け待ち。これが長いと卓が回らない）。
        ///
        /// MonoBehaviour を触らないので、Unity 抜きでも試せる（確かめ用に public）
        /// </summary>
        public static void CountTables(IList<TableSlot> tables, IList<StaffJob> jobs, ref Row r)
        {
            for (int i = 0; i < tables.Count; i++)
            {
                TableSlot t = tables[i];
                if (t == null) continue;

                if (t.clearing)
                {
                    if (ClearJobTaken(jobs, t.index)) r.tablesClearing++;
                    else r.tablesDirtyUntaken++;
                }
                else if (t.groupId >= 0) r.seatedGroups++;
                else r.tablesFree++;
            }
        }

        /// <summary>その卓の片付けの仕事を、スタッフが取っているか</summary>
        static bool ClearJobTaken(IList<StaffJob> jobs, int tableIndex)
        {
            for (int i = 0; i < jobs.Count; i++)
            {
                StaffJob j = jobs[i];
                if (j == null || j.kind != StaffJobKind.Clear) continue;
                if (j.tableIndex != tableIndex) continue;
                if (j.taken) return true;
            }
            return false;
        }

        /// <summary>
        /// 会計を待っている組を 2 つに分ける。
        ///   pay_waiting_seat  食べ終えたが、レジの並ぶ点が取れず席で待っている
        ///   pay_in_line       並ぶ点に立っている（会計の最中も入る）
        /// レジの前に人を立たせない版（payAtRegisterByOne が false）では
        /// 並ぶ点を取らないので、ぜんぶ pay_waiting_seat に入る
        /// </summary>
        static void CountGroups(IList<GroupManager.Group> gs, ref Row r)
        {
            if (gs == null) return;
            for (int i = 0; i < gs.Count; i++)
            {
                GroupManager.Group g = gs[i];
                if (g == null || g.state != GroupManager.GState.Paying) continue;
                if (g.payerSpot >= 0) r.payInLine++;
                else r.payWaitingSeat++;
            }
        }

        /// <summary>板に載っていて、まだ誰も取っていない仕事の数（種類ごと。確かめ用に public）</summary>
        public static void CountBoard(IList<StaffJob> jobs, ref Row r)
        {
            for (int i = 0; i < jobs.Count; i++)
            {
                StaffJob j = jobs[i];
                if (j == null || j.taken) continue;
                switch (j.kind)
                {
                    case StaffJobKind.Guide: r.boardGuide++; break;
                    case StaffJobKind.Serve: r.boardServe++; break;
                    case StaffJobKind.Call: r.boardCall++; break;
                    case StaffJobKind.Pay: r.boardPay++; break;
                    case StaffJobKind.Clear: r.boardClear++; break;
                }
            }
        }

        /// <summary>いまその仕事をしているスタッフの人数（仕事を持っていない人は待機）</summary>
        static void CountStaff(IReadOnlyList<StaffAgent> all, ref Row r)
        {
            for (int i = 0; i < all.Count; i++)
            {
                StaffAgent a = all[i];
                if (a == null) continue;
                AddStaff(a.CurrentJob, ref r);
            }
        }

        /// <summary>
        /// スタッフ1人を、持っている仕事の種類の欄に1つ足す（仕事が無ければ待機）。
        /// StaffJobKind のどの値でも必ず1つだけ増えるので、
        /// staff_* の合計はスタッフの人数と同じになる（確かめ用に public）
        /// </summary>
        public static void AddStaff(StaffJob j, ref Row r)
        {
            if (j == null) { r.staffIdle++; return; }
            switch (j.kind)
            {
                case StaffJobKind.Guide: r.staffGuide++; break;
                case StaffJobKind.Serve: r.staffServe++; break;
                case StaffJobKind.Call: r.staffCall++; break;
                case StaffJobKind.Pay: r.staffPay++; break;
                case StaffJobKind.Clear: r.staffClear++; break;
                case StaffJobKind.Rescue: r.staffRescue++; break;
                default: r.staffIdle++; break;   // 種類が増えたら待機に入れて、合計を崩さない
            }
        }

        /// <summary>
        /// 走っている・卓で待っているロボットの台数。
        /// robot_util と同じ見方（待機場所に下がっている Idle と、
        /// 往復を終えた Done は「空いている」）
        /// </summary>
        static int CountRobotsBusy(ServiceOps ops)
        {
            int n = 0;
            IList<ServingRobotController> rs = ops.Robots;
            for (int i = 0; i < rs.Count; i++)
            {
                ServingRobotController rb = rs[i];
                if (rb == null) continue;
                ServingRobotController.Phase p = rb.CurrentPhase;
                if (p != ServingRobotController.Phase.Idle &&
                    p != ServingRobotController.Phase.Done) n++;
            }
            return n;
        }

        // ---------------- 確かめ（依頼 v10b の 3 A）----------------

        /// <summary>
        /// どの行でも、卓の 4 つの合計が卓の数と同じで、
        /// スタッフの人数の合計がスタッフの人数と同じになっているか。
        /// 合わない行があれば、いちばん早い行を why に書く
        /// </summary>
        public bool CheckSums(int tables, int staff, out int badTableRows, out int badStaffRows,
                              out string why)
        {
            badTableRows = 0;
            badStaffRows = 0;
            why = "";

            string firstBad = "";
            for (int i = 0; i < _rows.Count; i++)
            {
                Row r = _rows[i];
                bool tOk = tables <= 0 || r.TableSum == tables;
                bool sOk = staff <= 0 || r.StaffSum == staff;
                if (!tOk) badTableRows++;
                if (!sOk) badStaffRows++;
                if ((!tOk || !sOk) && firstBad.Length == 0)
                    firstBad = "t_s " + F(r.tS) +
                               " で 卓 " + r.TableSum + "（座 " + r.seatedGroups +
                               "・空 " + r.tablesFree + "・片付け待ち " + r.tablesDirtyUntaken +
                               "・片付け中 " + r.tablesClearing + "）/ " + tables +
                               "、スタッフ " + r.StaffSum + " / " + staff;
            }

            why = firstBad;
            return badTableRows == 0 && badStaffRows == 0;
        }

        // ---------------- 書き出し ----------------

        public void Write(string path, string conditionId, int seed)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(Header).Append('\n');
            for (int i = 0; i < _rows.Count; i++)
            {
                Row r = _rows[i];
                sb.Append(conditionId).Append(',').Append(seed).Append(',')
                  .Append(F(r.tS)).Append(',')
                  .Append(r.arrived).Append(',')
                  .Append(r.waitingGroups).Append(',')
                  .Append(r.seatedGroups).Append(',')
                  .Append(r.payWaitingSeat).Append(',')
                  .Append(r.payInLine).Append(',')
                  .Append(r.tablesFree).Append(',')
                  .Append(r.tablesDirtyUntaken).Append(',')
                  .Append(r.tablesClearing).Append(',')
                  .Append(r.boardGuide).Append(',')
                  .Append(r.boardServe).Append(',')
                  .Append(r.boardCall).Append(',')
                  .Append(r.boardPay).Append(',')
                  .Append(r.boardClear).Append(',')
                  .Append(r.staffIdle).Append(',')
                  .Append(r.staffGuide).Append(',')
                  .Append(r.staffServe).Append(',')
                  .Append(r.staffCall).Append(',')
                  .Append(r.staffPay).Append(',')
                  .Append(r.staffClear).Append(',')
                  .Append(r.staffRescue).Append(',')
                  .Append(r.robotsBusy).Append('\n');
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static string F(float v)
        {
            return v.ToString("0.###", CultureInfo.InvariantCulture);
        }
    }
}
```

### Assets/ServiceSim/Experiment/ExperimentRunner.cs

```csharp
using System.Collections.Generic;
using System.IO;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 条件を回して記録する。1回の Play の中で、条件を順に通せる（段階3の Run All）。
    /// 数字と場所は ExperimentPlan の .asset に置く。回し方だけメニューが決める。
    ///
    /// この script は実験だけを扱う。店の運営は ServiceOps、人は GroupManager に任せる。
    /// </summary>
    public class ExperimentRunner : MonoBehaviour
    {
        /// <summary>時間の進め方</summary>
        public enum TimeMode { RealTime, FastTimeScale, FixedStep }

        /// <summary>回す条件1つ</summary>
        [System.Serializable]
        public class Job
        {
            public int aisleWidthIndex;
            public CrowdLevel crowd;
            public int robots;
            public int seed;
            public string folder;
            public string conditionId;

            /// <summary>
            /// 卓の停止点を通路の中心からずらす距離（m）。メニューが決める（C1）。
            /// applyDeliveryOffset が false のときは、.asset の値をそのまま使う。
            /// true のときも、使うのはこの回だけ。回し終わりに .asset の値へ戻すので、
            /// .asset は書き換わらない（run_info.json の delivery_offset_used_m に実際の値が出る）。
            /// </summary>
            public bool applyDeliveryOffset;
            public float deliveryOffset;

            // ---- サイトの条件で回す（v5 3章）----
            /// <summary>
            /// サイトの条件（unity_conditions）で回すか。
            /// true なら、店の回し方の数字は serving-robot-params.json から読み、
            /// 始め方もサイトと同じ「空の店から peak_min 分だけ来客し、全員が帰るまで」にする。
            /// </summary>
            public bool siteRun;

            /// <summary>unity_conditions の何番めか（siteRun のとき）</summary>
            public int conditionIndex = -1;

            /// <summary>試算の stores の名前（fam_p1 / fam_p5）。報告とフォルダ名に使う</summary>
            public string storeKey = "";
        }

        [Header("参照（Setup が入れる）")]
        public ExperimentPlan plan;
        public ServiceSpec service;

        [Tooltip("サイトの試算の値（v5 3章）。サイトの条件で回すときに読む")]
        public SimParams simParams;

        public StoreBuilder store;
        public NavMeshBaker baker;
        public PeopleSpawner people;
        public ServiceOps ops;
        public GroupManager groups;
        public JamWatcher jams;

        [Tooltip("キッチンの屋根のモニター（2026-10-23）。測る回でも出す")]
        public KitchenMonitor monitor;
        public RobotSpec robotSpec;
        public GameObject robotPrefab;
        public Transform robotsRoot;

        [Header("回し方（メニューが決める）")]
        public bool runOnPlay;

        /// <summary>
        /// 見る用の回か。字幕と描画をこの回だけ出し、時間は1倍速で進める。
        /// 設定ファイル（ExperimentPlan）は書き換えない。計測の比べには使わない。
        /// </summary>
        public bool viewMode;
        public TimeMode timeMode = TimeMode.FixedStep;
        public string runLabel = "v2";
        public float captureDeltaTimeOverride;

        [Tooltip("1回の実時間の上限（秒）。0 なら上限なし。" +
                 "超えたらその回を書き出して止める（段階B。台帳の status は stopped）。" +
                 "シミュレーションの動きは変えない")]
        public float maxWallSecondsPerRun;

        [Tooltip("回し始めの Unity Version Control の changeset（段階Bのメニューが入れる）。run_info に残す")]
        public string unityCs = "";

        [Tooltip("回し始めに保留中の変更があったか（段階Bのメニューが入れる）。run_info に残す")]
        public string pendingNote = "";

        [Header("いまの様子（読むだけ）")]
        [SerializeField] int _jobIndex;
        [SerializeField] int _jobCount;
        [SerializeField] float _simMinutes;
        [SerializeField] float _wallSeconds;
        [SerializeField] int _runsDone;
        [SerializeField] bool _running;

        // Play に入るとき、Unity はいちど script を読み直す（Domain Reload）。
        // そのときに消えないように、回す条件は .unity に書き出して持たせる。
        // メニューが入れた条件が Start() まで残らないと「回す条件がありません」で止まる。
        [SerializeField] List<Job> _queue = new List<Job>();

        readonly TripLogger _logger = new TripLogger();
        readonly List<ServingRobotController> _robots = new List<ServingRobotController>();
        readonly TripLogger.RunInfo _info = new TripLogger.RunInfo();
        readonly List<int> _walkerSamples = new List<int>();

        /// <summary>時間帯ごとの記録（依頼 v10b の 2。timeline.csv）</summary>
        readonly TimelineLogger _timeline = new TimelineLogger();

        /// <summary>次に timeline の1行を取る時刻（店の中, 秒）</summary>
        float _nextTimelineS;

        /// <summary>timeline の間（秒）。店の中の時間で 60 秒ごと</summary>
        const float TimelineStepS = 60f;

        /// <summary>計測が始まってからこの回数走っても記録が0なら、取りこぼしとみて止める</summary>
        const int TripRecordAlarmRuns = 3;

        Job _job;

        // 停止点は、メニューが決めた値をこの回だけ使う（C1）。
        // .asset を書き換えたままにしないよう、もとの値を覚えて回し終わりに戻す。
        float _savedDeliveryOffset;
        bool _deliveryOffsetSaved;

        // サイトの試算の値も、この回だけ入れて回し終わりに戻す（v5 3章）
        readonly SiteValues _siteValues = new SiteValues();
        SimParams.Condition _condition;

        /// <summary>来客を受け付け終わる時刻（店の中, 秒）。siteRun のときだけ使う</summary>
        float _arrivalsEndS;

        /// <summary>全員が帰るのを待つ上限（店の中, 秒）。これを超えたら打ち切る</summary>
        float _hardEndS;

        /// <summary>全員が帰って終わったか（打ち切りでないか）</summary>
        bool _everyoneLeft;

        float _endS, _measureStartS, _wallStart, _lastLog, _timeOffset;
        int _tripSeq;
        int _runsFinished;        // ロボットが走り終わった回数（慣らしも含む）
        int _runsAfterMeasure;    // 計測が始まってから走り終わった回数
        bool _stoppedEarly;
        bool _jobRunning;

        /// <summary>この条件ぶんを書き出したか（途中で止めたときに二重に書かないため。v5 3章）</summary>
        bool _outputsWritten;

        public bool Running { get { return _running; } }

        /// <summary>回す条件を1つだけ入れる（段階2のやり方）</summary>
        public void SetSingleJob(Job job)
        {
            _queue.Clear();
            _queue.Add(job);
        }

        /// <summary>回す条件をまとめて入れる（段階3の Run All）</summary>
        public void SetQueue(IList<Job> jobs)
        {
            _queue.Clear();
            if (jobs != null) for (int i = 0; i < jobs.Count; i++) _queue.Add(jobs[i]);
        }

        void Start()
        {
            if (!runOnPlay) return;
            if (!Resolve()) return;
            if (_queue.Count == 0) { Debug.LogError("[ServiceSim] 回す条件がありません"); return; }

            Application.runInBackground = true;
            ApplyTimeMode();
            // 見る用の回は、描画を止めない（字幕も ServiceSimLabels が出す）
            if (plan.disableRenderingWhileRunning && !viewMode) DisableCameras();

            _jobIndex = 0;
            _jobCount = _queue.Count;
            _runsDone = 0;
            _wallStart = Time.realtimeSinceStartup;
            _running = true;
            StartJob(_queue[0]);
        }

        bool Resolve()
        {
            if (store == null) store = FindObjectOfType<StoreBuilder>();
            if (baker == null) baker = FindObjectOfType<NavMeshBaker>();
            if (people == null) people = FindObjectOfType<PeopleSpawner>();
            if (ops == null) ops = FindObjectOfType<ServiceOps>();
            if (groups == null) groups = FindObjectOfType<GroupManager>();
            if (jams == null) jams = FindObjectOfType<JamWatcher>();
            if (monitor == null) monitor = FindObjectOfType<KitchenMonitor>();
            if (robotsRoot == null && store != null && store.transform.parent != null)
                robotsRoot = store.transform.parent.Find("Robots");

            if (plan == null || service == null || store == null || baker == null || people == null ||
                ops == null || groups == null || jams == null || robotPrefab == null ||
                robotsRoot == null || store.layout == null)
            {
                Debug.LogError("[ServiceSim] ExperimentRunner の参照が足りません。ServiceSim/Setup を実行してください");
                return false;
            }
            return true;
        }

        void ApplyTimeMode()
        {
            Time.captureDeltaTime = 0f;
            switch (timeMode)
            {
                case TimeMode.RealTime: Time.timeScale = 1f; break;
                case TimeMode.FastTimeScale: Time.timeScale = plan.trialTimeScaleFast; break;
                default:
                    Time.timeScale = 1f;
                    Time.captureDeltaTime = captureDeltaTimeOverride > 0f
                        ? captureDeltaTimeOverride : plan.measureCaptureDeltaTime;
                    break;
            }
        }

        void DisableCameras()
        {
            Camera[] cams = FindObjectsOfType<Camera>();
            for (int i = 0; i < cams.Length; i++) cams[i].enabled = false;
        }

        // ---------------- 1条件ぶん ----------------

        void StartJob(Job job)
        {
            _job = job;
            _logger.Clear();
            _info.frameDeltas.Clear();
            _info.robotSteps.Clear();
            _info.peopleSteps.Clear();
            _info.chairPullsByReason.Clear();
            _info.chairPulls = 0;
            // 依頼 v9：道探しの時間と、寄せた回数・引けなかった回数は回ごとに数え直す
            _info.planMs.Clear();
            _info.mapSnapStarts = 0;
            _info.planFails = 0;
            _walkerSamples.Clear();
            _timeline.Clear();              // 時間帯ごとの記録（依頼 v10b の 2）
            _tripSeq = 0;
            _runsFinished = 0;
            _runsAfterMeasure = 0;
            _stoppedEarly = false;
            _everyoneLeft = false;
            _outputsWritten = false;
            _condition = null;

            StoreLayout layout = store.layout;
            layout.aisleWidthIndex = job.aisleWidthIndex;

            // 停止点は条件の1つ（C1）。メニューが決めた値を、組み立てる前に入れる。
            // .asset のもとの値は覚えておき、ぜんぶ終わったら戻す（.asset は書き換えない）。
            if (!_deliveryOffsetSaved)
            {
                _savedDeliveryOffset = layout.deliveryOffsetFromAisleCenter;
                _deliveryOffsetSaved = true;
            }
            layout.deliveryOffsetFromAisleCenter = job.applyDeliveryOffset
                ? job.deliveryOffset : _savedDeliveryOffset;

            job.conditionId = ExperimentPlan.ConditionId(layout.AisleWidth, job.crowd, job.robots);

            // サイトの条件で回すとき（v5 3章）。
            // 建てる前に値を入れる（卓の数・来客のペース・時間の決まりが組み立てと来客に効く）。
            // 1つでも読めない値があれば、回さずに止める（Unity の .asset で埋めない）
            if (job.siteRun && !ApplySiteValues(job))
            {
                AbortRun();
                return;
            }

            store.Build();

            // 建てた卓の数が、試算の stores.tables と違ったら回さない（v5 3章）。
            // 店の形（JSON）が合わないときは Unity で埋めずに止める
            if (job.siteRun && !TablesMatchSite(job))
            {
                AbortRun();
                return;
            }

            baker.Bake();

            // 見る用の回は、床の塗り分けを Build Store と同じに作り直す。
            // 計測の回では作らない（見た目だけのものなので、速さを落とさない）
            if (viewMode) store.PaintFloor(baker, people.spec != null ? people.spec.personRadius : 0.2f);

            ops.spec = service;
            ops.robotSpec = robotSpec;
            ops.store = store;
            ops.Setup();

            people.service = service;
            people.ops = ops;
            people.Spawn(job.seed);

            groups.spec = service;
            groups.people = people.spec;
            groups.ops = ops;
            groups.store = store;
            groups.spawner = people;     // 人の動きに合わせて椅子を引く（B6）
            groups.bodyRoot = transform;

            jams.spec = people.spec;
            jams.ops = ops;
            jams.store = store;
            jams.Setup();

            // キッチンのモニター（測る回でも出す。切り替えは ExperimentPlan）
            if (monitor != null)
            {
                monitor.store = store;
                monitor.ops = ops;
                monitor.groups = groups;
                monitor.people = people;
                monitor.show = plan.showKitchenMonitor;
            }

            SpawnRobots(job.robots);

            // 始め方（v5 3章）。
            //   サイトの条件 … 空の店から、peak_min 分だけ来客し、全員が帰るまで回す
            //                  （max_extra_min で打ち切り）。慣らしは無し
            //   それ以外     … これまでどおり、8割埋まった店＋慣らし 15 分
            if (job.siteRun)
            {
                groups.seedWarmupGroups = false;
                _arrivalsEndS = service.peakMinutes * 60f;
                groups.arrivalsEndS = _arrivalsEndS;
                _hardEndS = _arrivalsEndS + service.maxExtraMin * 60f;
                _measureStartS = 0f;
                _endS = _hardEndS;
            }
            else
            {
                groups.seedWarmupGroups = true;
                groups.arrivalsEndS = 0f;
                _arrivalsEndS = 0f;
                _hardEndS = 0f;
                _measureStartS = plan.warmupMinutes * 60f;
                _endS = (plan.warmupMinutes + plan.measureMinutes) * 60f;
            }

            // 時間帯ごとの記録は、来客が始まるところから取る（依頼 v10b の 2）。
            // サイトの条件では _measureStartS が 0（空の店から）なので t_s = 0 が1行め
            _nextTimelineS = _measureStartS;

            groups.Setup(job.crowd, job.seed, people.Targets);
            ops.SetStaffCount(people.StaffSpawned);

            _timeOffset = Time.time;
            _lastLog = 0f;
            _jobRunning = true;

            // 回し始めの行に、Unity だけの決まりの切り替えを必ず出す
            string how = "\n  人のよけ方: " +
                         (people.spec != null && people.spec.staffFirstYield ? "店員優先の版" : "今のよけ方") +
                         " / 同じ通路に2台を集めない: " +
                         (robotSpec != null && robotSpec.oneRobotPerAisle ? "入れた" : "入れない") +
                         " / 戻りの線: " +
                         (robotSpec != null && robotSpec.useReturnLine ? "使う" : "使わない") +
                         " / キッチンのモニター: " + (plan.showKitchenMonitor ? "出す" : "出さない");

            Debug.Log("[ServiceSim] 回し始め " + (_jobIndex + 1) + "/" + _jobCount +
                      "：" + job.conditionId + " 種" + job.seed + how +
                      (job.siteRun
                        ? "（サイトの条件。空の店から来客 " + service.peakMinutes +
                          " 分 ＋ 全員が帰るまで（上限 " + service.maxExtraMin + " 分）" +
                          "・スタッフ " + people.StaffSpawned + " 人・ロボット " + job.robots + " 台）"
                        : "（慣らし " + plan.warmupMinutes + " 分 ＋ 計測 " + plan.measureMinutes + " 分）"));
        }

        /// <summary>
        /// 合わない値があって回せないとき。書き出さずに Play を抜ける（v5 3章）。
        /// .asset に入れた値は、OnDisable と同じようにもとへ戻す
        /// </summary>
        void AbortRun()
        {
            _running = false;
            _jobRunning = false;
            RestoreDeliveryOffset();
            RestoreSiteValues();
            Time.captureDeltaTime = 0f;
            Time.timeScale = 1f;
            Debug.LogError("[ServiceSim] 回しませんでした。Console の上の行を見てください");
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#endif
        }

        /// <summary>
        /// 建てた卓の数が、試算の stores.{店}.tables と合っているか（v5 3章）。
        /// 合わなければ、どちらかが古い。Unity で埋めずに止める
        /// </summary>
        bool TablesMatchSite(Job job)
        {
            JsonStoreLayout js = store.layout as JsonStoreLayout;
            if (js == null || simParams == null) return true;

            // ops.Setup() はこのあとなので、建てた数は店の形（JSON）から数えたものを見る
            int want = Mathf.RoundToInt(simParams.StoreValue(job.storeKey, "tables", -1f));
            int got = js.TableCount;
            if (want < 0 || want == got) return true;

            Debug.LogError("[ServiceSim] 卓の数が合いません（回しません）：試算 stores." + job.storeKey +
                           ".tables = " + want + " / 建てた店 = " + got +
                           "\n  どちらかが古いので、サイトから写し直してください");
            return false;
        }

        /// <summary>
        /// サイトの試算の値を、この回だけ ServiceSpec / PeopleSpec / RobotSpec に入れる（v5 3章）。
        /// 版の番号（店の形の meta.generation ↔ 試算の meta.layout_generation）が合わなければ回さない。
        /// 読めない値が1つでもあれば回さない（Unity の .asset で埋めない）。
        /// </summary>
        bool ApplySiteValues(Job job)
        {
            if (simParams == null)
            {
                Debug.LogError("[ServiceSim] SimParams が入っていません（ServiceSim/Setup を実行してください）");
                return false;
            }

            JsonStoreLayout js = store.layout as JsonStoreLayout;
            if (js == null)
            {
                Debug.LogError("[ServiceSim] サイトの条件で回すには、JSON から建てた店（JsonStoreLayout）が要ります。" +
                               "いまの店: " + store.layout);
                return false;
            }

            string storeKey = string.IsNullOrEmpty(job.storeKey) ? js.ParamsStoreKey : job.storeKey;
            job.storeKey = storeKey;

            if (!simParams.Load(js.ReadGeneration()))
            {
                string why = "";
                for (int i = 0; i < simParams.Problems.Count; i++) why += "\n  - " + simParams.Problems[i];
                Debug.LogError("[ServiceSim] 試算の値を読めません（回しません）" + why);
                return false;
            }

            _condition = job.conditionIndex >= 0 ? simParams.ConditionAt(job.conditionIndex) : null;
            if (_condition == null)
            {
                Debug.LogError("[ServiceSim] 条件 " + job.conditionIndex + " が unity_conditions にありません");
                return false;
            }
            if (_condition.store != storeKey)
            {
                Debug.LogError("[ServiceSim] 条件の店（" + _condition.store + "）と、建てる店（" +
                               storeKey + "）が違います");
                return false;
            }

            if (!_siteValues.Apply(simParams, storeKey, _condition, service, people.spec, robotSpec))
            {
                string why = "";
                for (int i = 0; i < _siteValues.Problems.Count; i++) why += "\n  - " + _siteValues.Problems[i];
                Debug.LogError("[ServiceSim] 試算の値が足りません（回しません）" + why);
                return false;
            }

            job.robots = _condition.robots;
            job.conditionId = _condition.Id;
            Debug.Log("[ServiceSim] " + _siteValues.Report());
            return true;
        }

        void SpawnRobots(int count)
        {
            // 往復の記録は、ServiceOps が片づけを終えてから書く（依頼 v8 の 2）。
            // ロボットの TripCompleted を直に受けると、呼ばれる順番によって
            // LastBatchOf が1つ前の往復の皿を返していた
            ops.RunRecorded -= OnRunDone;
            ops.RunRecorded += OnRunDone;

            ops.ClearRobots();
            for (int i = robotsRoot.childCount - 1; i >= 0; i--) Destroy(robotsRoot.GetChild(i).gameObject);
            _robots.Clear();

            // 置き始めは、1台に1つの待機場所へ（v5）。
            // 受け渡し口の前に重ねて置くと、はじめからふさぎ合う
            Vector3 pass = store.PassPoint;
            IList<Vector3> standby = store.RobotStandbyPoints;
            for (int i = 0; i < count; i++)
            {
                GameObject go = Instantiate(robotPrefab, robotsRoot);
                go.name = "ServingRobot" + (i + 1).ToString("00");
                ServingRobotController c = go.GetComponent<ServingRobotController>();
                if (c == null) { Destroy(go); continue; }
                c.robotId = i + 1;
                Vector3 start = standby.Count > 0
                    ? standby[i % standby.Count]
                    : pass + new Vector3(0f, 0f, -0.8f * i);
                c.PlaceAt(start, Quaternion.LookRotation(Vector3.back, Vector3.up));
                _robots.Add(c);
                ops.RegisterRobot(c);
            }
        }

        void Update()
        {
            if (!_running || !_jobRunning) return;

            float dt = Time.deltaTime;
            _wallSeconds = Time.realtimeSinceStartup - _wallStart;
            _info.frameDeltas.Add(dt);

            // 実時間の上限（段階B）。超えたら書き出して止める。
            // シミュレーションの動きは変えない。回しっぱなしで1回に居座らないための保険
            if (maxWallSecondsPerRun > 0f && _wallSeconds >= maxWallSecondsPerRun)
            {
                Debug.LogWarning("[ServiceSim] 実時間の上限を超えたので、この回を止めます: " +
                                 (_wallSeconds / 60f).ToString("0.0") + " 分 ≥ " +
                                 (maxWallSecondsPerRun / 60f).ToString("0.0") + " 分" +
                                 "\n  店の中 " + (ops != null ? (ops.NowS / 60f).ToString("0.0") : "?") + " 分" +
                                 "\n  書き出してから次へ進みます（台帳の status は stopped）");
                StopEarly();
                return;
            }

            float step = 0f;
            for (int i = 0; i < _robots.Count; i++)
                if (_robots[i] != null) step = Mathf.Max(step, _robots[i].LastStepM);
            _info.robotSteps.Add(step);

            // 人の1コマの移動量も記録する（B10）。通路を歩く人のうちいちばん大きいもの
            float peopleStep = 0f;
            IReadOnlyList<PersonBody> bodies = PersonBody.All;
            for (int i = 0; i < bodies.Count; i++)
            {
                PersonBody b = bodies[i];
                if (b == null || !b.walksInAisle) continue;
                if (b.LastStepM > peopleStep) peopleStep = b.LastStepM;
            }
            _info.peopleSteps.Add(peopleStep);

            ops.Advance(dt);
            groups.Tick(dt);
            jams.Tick(dt);
            _simMinutes = ops.NowS / 60f;

            if (ops.NowS >= _measureStartS) _walkerSamples.Add(groups.WalkingBodies + people.StaffSpawned);

            // 時間帯ごとの記録（依頼 v10b の 2）。店の中の時間で 60 秒ごとに1行。
            // 1コマは 0.02 秒なので飛ぶことは無いが、万一飛んだときに
            // 同じ様子を何行も書かないよう、時刻だけ先へ進める
            if (ops.NowS >= _nextTimelineS)
            {
                _timeline.Sample(_nextTimelineS, groups, ops);
                do { _nextTimelineS += TimelineStepS; } while (ops.NowS >= _nextTimelineS);
            }

            if (_wallSeconds - _lastLog >= 60f)
            {
                _lastLog = _wallSeconds;

                // 「走り」はロボットが走り終わった回数、「記録」は trips.csv に書いた行数。
                // 走りがあるのに記録が増えないときは、記録の取りこぼしなのでその場で止める
                Debug.Log("[ServiceSim] とちゅう " + (_jobIndex + 1) + "/" + _jobCount + "：" +
                          _simMinutes.ToString("0.0") + " / " +
                          (_job.siteRun
                            ? "来客 " + (_arrivalsEndS / 60f).ToString("0") + " 分・上限 " +
                              (_hardEndS / 60f).ToString("0") + " 分"
                            : (_endS / 60f).ToString("0") + " 分") + "、" +
                          "組 " + groups.GroupsArrived + "（待ち " + groups.WaitingGroups +
                          "・帰った " + groups.GroupsDone + "）、" +
                          "走り " + _runsFinished + " 回・記録 " + _logger.RowCount + " 行、" +
                          "椅子 " + ops.chairPulls + " 回、" +
                          "あきらめ " + (ops.gaveUpCount + ops.warpedCount + ops.tableFreedCount) + " 回、" +
                          "立ち往生 " + ops.rescueCalls + " 回、" +
                          "実時間 " + (_wallSeconds / 60f).ToString("0.0") + " 分");

                CheckTripsAreRecorded();
            }

            if (ShouldFinish()) FinishJob();
        }

        /// <summary>
        /// 1条件を終える時。
        ///   サイトの条件（v5 3章）… 来客を受け付け終わって、全員が帰ったら終わり。
        ///                            帰らないまま max_extra_min を過ぎたら打ち切る
        ///   それ以外               … これまでどおり、慣らし＋計測の長さで終わり
        /// </summary>
        bool ShouldFinish()
        {
            if (!_job.siteRun) return ops.NowS >= _endS;

            if (ops.NowS >= _arrivalsEndS && !groups.AnyGroupInStore)
            {
                _everyoneLeft = true;
                return true;
            }
            return ops.NowS >= _hardEndS;
        }

        /// <summary>
        /// 走りがあるのに trips.csv の行が増えないときは、記録の取りこぼし。
        /// 気づかないまま何時間も回さないように、その場で止めて知らせる。
        /// 慣らしの間に始まった走りは数えないので、計測が始まってから見る。
        /// </summary>
        void CheckTripsAreRecorded()
        {
            if (ops.NowS < _measureStartS) return;
            if (_runsAfterMeasure <= 0) return;
            if (_logger.RowCount > 0) return;

            // 計測が始まってから走り終わった回数がこれを超えても記録が0なら、おかしい
            if (_runsAfterMeasure < TripRecordAlarmRuns) return;

            Debug.LogError("[ServiceSim] 走りが " + _runsAfterMeasure +
                           " 回あるのに trips.csv の行が 0 です。記録を取りこぼしています。" +
                           "\n  止めました。ExperimentRunner.OnRunDone と " +
                           "ServingRobotController.RunStartS を確かめてください" +
                           "\n  店の中 " + (ops.NowS / 60f).ToString("0.0") + " 分 / 計測の始まり " +
                           (_measureStartS / 60f).ToString("0.0") + " 分");
            StopEarly();
        }

        /// <summary>途中で止める（書き出しはする）</summary>
        void StopEarly()
        {
            _stoppedEarly = true;
            FinishJob();
            AllDone();
        }

        void OnRunDone(ServingRobotController r)
        {
            _runsFinished++;
            if (ops.NowS >= _measureStartS) _runsAfterMeasure++;

            // 走り始めた時刻はロボットが持っている（ServingRobotController.RunStartS）。
            // ここで覚えておくと、走り終わってすぐ次の仕事が入ったとき、
            // 「空いている」状態を一度も見ないまま時刻が古いままになり、全部の走りを取りこぼす。
            float startS = r.RunStartS >= 0f ? r.RunStartS : ops.NowS - r.TotalS;
            if (startS < _measureStartS) return;

            IList<DishOrder> batch = ops.LastBatchOf(r);
            _tripSeq++;

            TripLogger.Row row = new TripLogger.Row();
            row.conditionId = _job.conditionId;
            row.aisleWidthM = store.layout.AisleWidth;
            row.crowd = ExperimentPlan.CrowdName(_job.crowd);
            row.robots = _job.robots;
            row.seed = _job.seed;
            row.robotId = r.robotId;
            row.tripId = _tripSeq;
            row.tableId = r.TableIds.Count > 0 ? r.TableIds[0] : 0;
            row.distanceM = r.DistanceM;
            row.totalS = r.TotalS;
            row.stoppedS = r.StoppedS;
            row.nStops = r.StopCount;
            row.stopDurationsS = ToArray(r.StopDurationsS);
            row.stuck = r.Stuck;
            row.timeout = r.TimedOut;
            row.waitAisleS = r.WaitAisleS;
            row.maxDecelMps2 = r.MaxDecelMps2;
            row.minClearanceM = r.MinClearanceM;
            row.metRobot = r.MetRobot;

            int[] ids = new int[r.TableIds.Count];
            for (int i = 0; i < ids.Length; i++) ids[i] = r.TableIds[i];
            row.tableIds = ids;
            row.dishes = batch.Count;

            float[] ready = new float[batch.Count], arrived = new float[batch.Count];
            int undelivered = 0;
            for (int i = 0; i < batch.Count; i++)
            {
                ready[i] = batch[i].readyS;
                arrived[i] = batch[i].arrivedS;
                if (batch[i].arrivedS < 0f) undelivered++;
            }
            row.dishReadyS = ready;
            row.dishArrivedS = arrived;
            row.undelivered = undelivered;

            // ---- 後ろに足した列（依頼 v8 の 2。今ある列の並びは変えない）----
            row.runStartS = startS;

            int[] dishIds = new int[batch.Count];
            for (int i = 0; i < batch.Count; i++) dishIds[i] = batch[i].id;
            row.dishIds = dishIds;

            float[] stopArrived = new float[r.StopArrivedS.Count];
            for (int i = 0; i < stopArrived.Length; i++) stopArrived[i] = r.StopArrivedS[i];
            row.stopArrivedS = stopArrived;
            row.resetTo = r.ResetTo;        // 打ち切りで置き直した先（依頼 v8b の A）

            // ---- 往復ごとの測り（v5 3章・設計書2章の4つ）----
            // 実際に走れた速さは、止まっていた分を除いた時間で出す（止まり込みの平均は別物）
            float movingS = r.TotalS - r.StoppedS;
            row.speedMps = movingS > 0.01f ? r.DistanceM / movingS : 0f;
            row.stuckCount = r.StuckCount;
            row.stuckWaitS = r.StuckWaitS;
            row.viaNarrow = r.LegWasNarrow;
            SplitStopsByAisle(r, ref row);

            // ---- 依頼 v9 の 5 章：地図の上で自分で道を引く ----
            row.tripEndS = r.TripEndS >= 0f ? r.TripEndS : ops.NowS;
            row.pathMode = r.ActivePathMode.ToString();
            row.replans = r.Replans;
            row.detours = r.Detours;
            row.robotYields = r.RobotYields;
            row.siteRouteM = r.SiteRouteM;
            row.detourExtraM = r.DistanceM - r.SiteRouteM;
            row.narrowUsed = r.LegWasNarrow;
            row.planMsMax = r.PlanMsMax;

            _info.planMs.Add(r.PlanMsMax);
            _info.mapSnapStarts += r.MapSnapStarts;
            _info.planFails += r.PlanFails;

            _logger.Add(row);

            AddStopRows(r, _tripSeq);
        }

        void AddStopRows(ServingRobotController robot, int tripId)
        {
            float normal = NormalWidthM();
            IReadOnlyList<ServingRobotController.StopEvent> events = robot.StopEvents;
            for (int i = 0; i < events.Count; i++)
            {
                ServingRobotController.StopEvent e = events[i];
                TripLogger.StopRow s = new TripLogger.StopRow();
                s.conditionId = _job.conditionId;
                s.seed = _job.seed;
                s.robotId = robot.robotId;
                s.tripId = tripId;
                s.tS = e.startTimeS - _timeOffset;
                s.x = e.position.x;
                s.z = e.position.z;
                s.aisle = AisleNameAt(e.position);
                s.inNarrow = InNarrowAisle(e.position, normal);
                s.cause = TripLogger.CauseLabel(e.cause);
                s.causeDetail = e.causeDetail;
                s.pathBlocked = e.pathBlocked;
                s.durationS = e.durationS;
                s.resolved = TripLogger.ResolveLabel(e.resolve);
                s.clearanceLeftCm = e.clearanceLeftM * 100f;
                s.clearanceRightCm = e.clearanceRightM * 100f;
                s.minGapCm = Mathf.Min(s.clearanceLeftCm, s.clearanceRightCm);

                // 何で動けなかったかの内訳（B7）
                s.blockedS = e.blockedS;
                s.noPathS = e.noPathS;
                s.yieldS = e.yieldS;
                s.clearWaitS = e.clearWaitS;
                s.replans = e.replans;

                // 道を引いたときの中身（調べるためだけ）
                s.solveFrom = Xz(e.solveFrom);
                s.solveTo = Xz(e.solveTo);
                s.solveStatus = string.IsNullOrEmpty(e.solveStatus) ? "なし" : e.solveStatus;
                s.solveCorners = e.solveCorners;
                s.offMeshM = e.offMeshM;

                _logger.AddStop(s);
            }
        }

        /// <summary>
        /// 止まりを、狭い通路の中と外に分けて数える（v5 3章。⑦の読み分け）。
        /// 「狭い」はサイトの決まりのまま、幅が meta.settings.normal_width（1.0 m）未満の通路。
        /// ③の通路は 1.2 m なので、ぜんぶ「外」になる。
        /// </summary>
        void SplitStopsByAisle(ServingRobotController robot, ref TripLogger.Row row)
        {
            float normal = NormalWidthM();
            IReadOnlyList<ServingRobotController.StopEvent> events = robot.StopEvents;
            for (int i = 0; i < events.Count; i++)
            {
                if (InNarrowAisle(events[i].position, normal))
                {
                    row.stopsInNarrow++;
                    row.stoppedInNarrowS += events[i].durationS;
                }
                else
                {
                    row.stopsOutNarrow++;
                    row.stoppedOutNarrowS += events[i].durationS;
                }
            }
        }

        /// <summary>サイトの「ふだん走らせる道の幅」（m）。JSON に無ければ 0（＝ぜんぶ「外」）</summary>
        float NormalWidthM()
        {
            JsonStoreLayout js = store != null ? store.layout as JsonStoreLayout : null;
            return js != null ? js.NormalWidth : 0f;
        }

        bool InNarrowAisle(Vector3 p, float normalWidth)
        {
            if (normalWidth <= 0f) return false;
            IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
            for (int i = 0; i < aisles.Count; i++)
            {
                if (aisles[i].width >= normalWidth) continue;
                Bounds b = aisles[i].zone;
                if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                return true;
            }
            return false;
        }

        /// <summary>点を "x;z" の1つの欄にする（stops.csv の solve_from / solve_to）</summary>
        static string Xz(Vector3 p)
        {
            return p.x.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + ";" +
                   p.z.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
        }

        string AisleNameAt(Vector3 p)
        {
            string wide = "";
            IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
            for (int i = 0; i < aisles.Count; i++)
            {
                Bounds b = aisles[i].zone;
                if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                if (aisles[i].narrow) return aisles[i].name;
                if (wide.Length == 0) wide = aisles[i].name;
            }
            return wide;
        }

        static float[] ToArray(IReadOnlyList<float> list)
        {
            if (list == null || list.Count == 0) return new float[0];
            float[] a = new float[list.Count];
            for (int i = 0; i < a.Length; i++) a[i] = list[i];
            return a;
        }

        // ---------------- 書き出して次へ ----------------

        void FinishJob()
        {
            _jobRunning = false;
            AddGroupRows();
            WriteOutputs(_job.folder, _stoppedEarly);

            _jobIndex++;
            if (_jobIndex < _queue.Count) { StartJob(_queue[_jobIndex]); return; }
            AllDone();
        }

        /// <summary>
        /// 1条件ぶんの記録を書き出す（v5 3章で切り出した）。
        /// 途中で Play を止めたときも、ここまでの分を同じ形で書き出せるようにするため、
        /// 置き場所と「途中で止めたか」を渡す形にした。
        /// 書き出しの中で、オブジェクトを探したり消したりしないこと
        /// （Play を抜ける最中にも呼ばれるため）。
        /// </summary>
        void WriteOutputs(string dir, bool stoppedEarly)
        {
            _outputsWritten = true;
            try
            {
                Directory.CreateDirectory(dir);

                TripLogger.SimLike sim = new TripLogger.SimLike();
                sim.dishWaitMin.AddRange(ops.dishWaitMin);
                sim.seatWaitMin.AddRange(ops.seatWaitMin);
                sim.turnMin.AddRange(ops.turnMin);
                CollectPayWait(sim);                // pay_wait_mean（依頼 v10 の 2）
                sim.coldMin = service.coldMin;
                sim.robotDishes = ops.robotDishes;
                sim.staffDishes = ops.staffDishes;
                sim.requeuedDishes = ops.requeuedDishes;
                sim.groups = groups.GroupsDone;
                sim.jamCount = jams.JamCount;
                sim.jamTotalS = jams.TotalJamSeconds;
                sim.robotBlockedJams = jams.RobotBlockedJamCount;   // B9
                sim.chairPulls = ops.chairPulls;                     // B6
                sim.robotContacts = ops.Contacts.Count;              // 人がロボットをよける
                sim.robotYieldWaits = ops.robotYieldWaits;
                sim.robotYieldWaitSeconds = ops.robotYieldWaitSeconds;
                sim.robotYieldDetours = ops.robotYieldDetours;
                sim.registerCrowdMax = ops.registerCrowdMax;         // レジの前の人数
                sim.registerCrowdMean = ops.RegisterCrowdMean;
                sim.registerPayCount = ops.registerPayCount;
                sim.registerPayNotAtBackCount = ops.registerPayNotAtBackCount;
                sim.registerPayOffSpotCount = ops.registerPayOffSpotCount;
                sim.registerPayMeanOffsetM = ops.RegisterPayMeanOffsetM;
                sim.registerPayMaxOffsetM = ops.registerPayMaxOffsetM;
                sim.registerBackIntruderCount = ops.registerBackIntruderCount;
                sim.registerBackIntruderSeconds = ops.registerBackIntruderSeconds;
                sim.staffTime.Clear();
                foreach (KeyValuePair<string, float> kv in ops.StaffTime) sim.staffTime.Add(kv);
                sim.dishParts.Clear();
                sim.dishParts.AddRange(ops.DishPartsList);
                // サイトの試算 v2.7：2分を過ぎて人が拾った皿と、その回の robot_first_wait_s
                sim.lateStaffDishes = ops.lateStaffDishes;
                sim.robotFirstWaitS = service.robotFirstWaitSec;
                // 譲る順番 v6：譲りの記録と、角の範囲（RobotSpec の1か所から写す）
                sim.yields.Clear();
                sim.yields.AddRange(ops.Yields);
                // 依頼 v9：角の箱（cornerMin/Max）は SiteRoutes のときだけ働く。
                // MapPlanner では範囲を入れないので、summary.json の corner_stops は 0 になる
                if (robotSpec != null && !robotSpec.UseMapPlanner)
                {
                    sim.cornerMinX = robotSpec.cornerMinX;
                    sim.cornerMaxX = robotSpec.cornerMaxX;
                    sim.cornerMinZ = robotSpec.cornerMinZ;
                    sim.cornerMaxZ = robotSpec.cornerMaxZ;
                }
                sim.jobWaitMean.Clear();
                foreach (KeyValuePair<string, float> kv in ops.JobWaitSum)
                {
                    int n;
                    ops.JobWaitCount.TryGetValue(kv.Key, out n);
                    sim.jobWaitMean.Add(new KeyValuePair<string, float>(kv.Key, n > 0 ? kv.Value / n : 0f));
                }
                // 依頼 v10 の 2：板で待った最大と、長びいた数（片付け・会計を見る）
                sim.jobWaitMax.Clear();
                foreach (KeyValuePair<string, float> kv in ops.JobWaitMax) sim.jobWaitMax.Add(kv);
                sim.jobWaitOver.Clear();
                foreach (KeyValuePair<string, int> kv in ops.JobWaitOverCount) sim.jobWaitOver.Add(kv);
                sim.jobWaitOverSec = ops.JobWaitOverSec;
                sim.drinkBarQueued = ops.drinkBarQueued;             // B4
                sim.drinkBarGaveUp = ops.drinkBarGaveUp;
                sim.drinkBarWaitMin.Clear();
                sim.drinkBarWaitMin.AddRange(ops.drinkBarWaitMin);
                sim.gaveUp = ops.gaveUpCount;                        // B1
                sim.warped = ops.warpedCount;
                sim.tableFreed = ops.tableFreedCount;
                sim.walkersMean = MeanInt(_walkerSamples);
                sim.measuredMinutes = _job.siteRun ? ops.NowS / 60f : plan.measureMinutes;

                // ---- サイトと同じ名前で出す値（v5 3章・meta.metrics）----
                sim.entryWaitMin.AddRange(ops.entryWaitMin);
                sim.fullWaitMin.AddRange(ops.fullWaitMin);
                sim.blockedWaitMin.AddRange(ops.blockedWaitMin);
                sim.guideWaitMin.AddRange(ops.guideWaitMin);
                sim.staffWaitMin.AddRange(ops.staffWaitMin);
                sim.walkKmPerStaff = ops.WalkKmPerStaff;
                sim.staffUtil = StaffUtil();
                sim.robotUtil = ops.RobotUtil;
                sim.aisleDeferrals = ops.aisleDeferrals;
                sim.robotStuckDetours = ops.robotStuckDetours;
                sim.staffFirstYield = people.spec != null && people.spec.staffFirstYield;
                sim.rescues = ops.rescueCalls;
                sim.rescuesDone = ops.rescueDone;
                sim.rescueWaitS = ops.rescueWaitSeconds;
                sim.pauses = _logger.TotalStopCount();
                sim.siteRun = _job.siteRun;
                sim.siteConditionId = _job.conditionId;
                sim.siteResult.Clear();
                if (_condition != null)
                    foreach (KeyValuePair<string, float> kv in _condition.siteResult)
                        sim.siteResult.Add(kv);
                sim.arrivalsMinutes = _job.siteRun ? _arrivalsEndS / 60f : 0f;
                sim.totalMinutes = ops.NowS / 60f;
                sim.everyoneLeft = _everyoneLeft;
                FillTimelineCheck(sim);         // 時間帯ごとの記録の確かめ（依頼 v10b の 3 A）
                _logger.SetSimLike(sim);

                _logger.WriteTripsCsv(Path.Combine(dir, "trips.csv"));
                _logger.WriteStopsCsv(Path.Combine(dir, "stops.csv"));
                _logger.WriteGroupsCsv(Path.Combine(dir, "groups.csv"));
                TripLogger.WriteJamsCsv(Path.Combine(dir, "people_jams.csv"),
                                        _job.conditionId, _job.seed, jams.Rows);
                TripLogger.WriteGiveUpsCsv(Path.Combine(dir, "give_ups.csv"),
                                        _job.conditionId, _job.seed, ops.GiveUps);
                // dishes.csv は皿ごとの記録（誰が運んだか・受け渡し口で待った秒数・
                // 2分を過ぎて人が拾ったか）。戻した1回ずつは requeues.csv に分けた
                TripLogger.WriteDishesCsv(Path.Combine(dir, "dishes.csv"),
                                        _job.conditionId, _job.seed, ops.DishRows);
                TripLogger.WriteRequeuesCsv(Path.Combine(dir, "requeues.csv"),
                                        _job.conditionId, _job.seed, ops.Requeues);
                TripLogger.WriteContactsCsv(Path.Combine(dir, "contacts.csv"),
                                        _job.conditionId, _job.seed, ops.Contacts);
                // 誰が誰に譲ったか（譲る順番 v6）
                TripLogger.WriteYieldsCsv(Path.Combine(dir, "yields.csv"),
                                        _job.conditionId, _job.seed, ops.Yields);
                // 時間帯ごとの記録（依頼 v10b の 2）。段階Bと Site Run で同じ作り
                _timeline.Write(Path.Combine(dir, "timeline.csv"),
                                        _job.conditionId, _job.seed);
                _logger.WriteSummaryJson(Path.Combine(dir, "summary.json"), plan.runName);

                _info.runLabel = runLabel;
                _info.conditionId = _job.conditionId;
                _info.timeMode = timeMode.ToString();
                _info.tripsRequested = 0;
                _info.tripsDone = _logger.RowCount;
                _info.robots = _job.robots;
                _info.seed = _job.seed;
                _info.aisleWidthM = store.layout.AisleWidth;
                _info.deliveryOffsetM = store.layout.deliveryOffsetFromAisleCenter;
                _info.deliveryOffsetUsedM = store.layout.DeliveryOffset();
                _info.crowd = ExperimentPlan.CrowdName(_job.crowd);
                _info.simSeconds = ops.NowS;
                _info.wallSeconds = _wallSeconds;
                _info.timeScale = timeMode == TimeMode.FastTimeScale ? plan.trialTimeScaleFast : 1f;
                _info.captureDeltaTime = Time.captureDeltaTime;

                // 椅子を引いた回数（B6）
                _info.chairPulls = ops.chairPulls;
                _info.chairPullsByReason.Clear();
                foreach (KeyValuePair<string, int> kv in ops.ChairPullsByReason)
                    _info.chairPullsByReason.Add(kv);
                _info.stoppedEarly = stoppedEarly;
                _info.oneRobotPerAisle = robotSpec != null && robotSpec.oneRobotPerAisle;
                _info.yieldOrderEnabled = robotSpec != null && robotSpec.yieldOrderEnabled;
                _info.unityCs = unityCs;
                _info.pendingNote = pendingNote;
                _info.staffFirstYield = people.spec != null && people.spec.staffFirstYield;
                _info.storeKind = store.layout.name;

                // 依頼 v9 の 5 章：道の決め方と、地図の版・道の引き方の数字
                if (robotSpec != null)
                {
                    _info.narrowCellCost = robotSpec.narrowCellCost;
                    _info.centerWeight = robotSpec.centerWeight;
                    _info.preferClearM = robotSpec.preferClearM;
                    _info.detourAfterSec = robotSpec.detourAfterSec;
                    _info.pathMode = robotSpec.pathMode.ToString();
                }
                // 実際に使った決め方（地図が無ければ SiteRoutes に落ちる）は台から読む
                IList<ServingRobotController> rs = ops != null ? ops.Robots : null;
                if (rs != null && rs.Count > 0 && rs[0] != null)
                    _info.pathMode = rs[0].ActivePathMode.ToString();
                JsonStoreLayout jsMap = store.layout as JsonStoreLayout;
                _info.mapGeneration = jsMap != null ? jsMap.MapGeneration : "";
                TripLogger.WriteRunInfoJson(Path.Combine(dir, "run_info.json"), _info);

                TripLogger.WriteConditionsJson(Path.Combine(dir, "conditions.json"),
                    plan.runName, store.layout, robotSpec, people.spec, plan,
                    plan.BuildConditions(store.layout));

                _runsDone++;
                Debug.Log("[ServiceSim] 書き出し " + (_jobIndex + 1) + "/" + _jobCount + "：" + _job.conditionId +
                          " 種" + _job.seed +
                          "\n  走り " + _runsFinished + " 回 / 記録 " + _logger.RowCount + " 行 / 止まり " +
                          _logger.StopCount + " 回 / 組 " + _logger.GroupRowCount +
                          "\n  椅子を引いた " + ops.chairPulls + " 回 / 人どうしの詰まり " + jams.JamCount +
                          " 回（うちロボットが近くで止まっていた " + jams.RobotBlockedJamCount + " 回）" +
                          "\n  会計 " + ops.registerPayCount + " 回（店員側にいなかった " +
                          ops.registerPayNotAtBackCount + " 回・立つ点から離れた " +
                          ops.registerPayOffSpotCount + " 回・いちばん離れた " +
                          ops.registerPayMaxOffsetM.ToString("0.00") + " m・会計でないのに店員側にいた " +
                          ops.registerBackIntruderCount + " 回）/ レジの前の人数 最大 " +
                          ops.registerCrowdMax + "・平均 " + ops.RegisterCrowdMean.ToString("0.0") +
                          "\n  人がロボットを待った " + ops.robotYieldWaits + " 回・" +
                          ops.robotYieldWaitSeconds.ToString("0") + " 秒 / 回り道 " +
                          ops.robotYieldDetours + " 回 / 近づきすぎ " + ops.Contacts.Count + " 回" +
                          "\n  人のよけ方: " +
                          (people.spec != null && people.spec.staffFirstYield ? "店員優先の版" : "いまの版") +
                          "（店員が止まったロボットを回り道した " + ops.robotStuckDetours + " 回）" +
                          "\n  同じ通路を避けて後に回した " + ops.aisleDeferrals + " 回" +
                          "（同じ通路に2台を集めない: " +
                          (robotSpec != null && robotSpec.oneRobotPerAisle ? "入れた" : "入れない") + "）" +
                          "\n  戻りの線を使えなかった " + ReturnLineFallbacks() + " 回" +
                          "（使えないときは受け渡し口の前へ戻る）" +
                          "\n  立ち往生で呼んだ " + ops.rescueCalls + " 回（動かした " + ops.rescueDone +
                          " 回・待った " + ops.rescueWaitSeconds.ToString("0") + " 秒）" +
                          "\n  入店待ち " + Mean(ops.entryWaitMin).ToString("0.00") + " 分（満席 " +
                          Mean(ops.fullWaitMin).ToString("0.00") + "・片付け待ち " +
                          Mean(ops.blockedWaitMin).ToString("0.00") + "・案内待ち " +
                          Mean(ops.guideWaitMin).ToString("0.00") + "）/ スタッフの歩き " +
                          ops.WalkKmPerStaff.ToString("0.00") + " km・手がふさがり " +
                          (StaffUtil() * 100f).ToString("0") + "%・ロボット " +
                          (ops.RobotUtil * 100f).ToString("0") + "%" +
                          "\n  あきらめた " + ops.gaveUpCount + " / 移した " + ops.warpedCount +
                          " / 卓を空けた " + ops.tableFreedCount +
                          " / 戻した皿 " + ops.requeuedDishes + "（届いた " + ops.robotDishes + "）" +
                          "\n  " + TripLogger.FrameStatsText(_info) +
                          "\n  " + dir);
            }
            catch (System.Exception e)
            {
                Debug.LogError("[ServiceSim] 書き出しに失敗: " + dir + "\n" + e);
            }
        }

        /// <summary>
        /// 時間帯ごとの記録の確かめ（依頼 v10b の 3 A）。
        ///
        /// どの行でも
        ///   seated_groups ＋ tables_free ＋ tables_dirty_untaken ＋ tables_clearing ＝ 卓の数
        ///   staff_idle ＋ …（仕事の種類 6 つ）＝ スタッフの人数
        /// になっているかを数えて、summary.json の timeline_check に出す。
        /// 合わない行があれば Console にも出す（夜にまとめて回したあとでも分かるように）
        /// </summary>
        void FillTimelineCheck(TripLogger.SimLike sim)
        {
            int tables = ops != null ? ops.Tables.Count : 0;
            int staff = people != null ? people.StaffSpawned : 0;

            int badTables, badStaff;
            string why;
            bool ok = _timeline.CheckSums(tables, staff, out badTables, out badStaff, out why);

            sim.timelineRows = _timeline.RowCount;
            sim.timelineTables = tables;
            sim.timelineStaff = staff;
            sim.timelineBadTableRows = badTables;
            sim.timelineBadStaffRows = badStaff;
            sim.timelineFirstBad = why;

            if (!ok)
                Debug.LogWarning("[ServiceSim] timeline.csv の合計が合いません（" +
                                 "卓 " + badTables + " 行・スタッフ " + badStaff + " 行 / " +
                                 _timeline.RowCount + " 行）\n  いちばん早い行: " + why);
        }

        /// <summary>
        /// pay_wait_mean：食べ終えてから会計が始まるまで（分。依頼 v10 の 2）。
        ///
        /// 組ごとに（会計が終わった時刻 − 食べ終えた時刻 − pay_s）。
        /// groups.csv の pay_s 列（会計が終わった時刻）と eat_end_s から出せる値と同じで、
        /// 引く pay_s は ServiceSpec.paySec（会計そのものにかかる 90 秒）。
        ///
        /// 入れない組:
        ///   ・逃がし弁で卓を空けられた組（会計が途中で切れている）
        ///   ・会計が終わっていない組（payS が -1。打ち切りで店に残った組）
        ///   ・慣らしで最初から座っていた組（ほかの site_metrics と同じ扱い）
        ///
        /// サイトの試算は「レジの順番とスタッフの手が空くのを待った時間」だけだが、
        /// Unity は会計に立つ人がレジまで歩く時間と、スタッフがレジへ歩く時間も入るので、
        /// 試算より少し長く出る
        /// </summary>
        void CollectPayWait(TripLogger.SimLike sim)
        {
            if (groups == null || service == null) return;
            IList<GroupManager.Group> gs = groups.Groups;
            for (int i = 0; i < gs.Count; i++)
            {
                GroupManager.Group g = gs[i];
                if (g.warmup || g.tableForceFreed) continue;
                if (g.payS < 0f || g.eatEndS < 0f) continue;
                sim.payWaitMin.Add(Mathf.Max(0f, g.payS - g.eatEndS - service.paySec) / 60f);

                // 照らし合わせ用：食べ終えた時刻ではなく、会計に入った時刻（pay_start_s）から数えた分。
                // Unity は eat_end_s（食べ始めに決めた食べ終わりの時刻）のあとも、
                // 遅れて届いた皿やドリンクバー・トイレの人を待ってから会計に入るので、
                // その待ちが pay_wait_mean に混ざる。混ざった分の大きさが見えるように両方出す
                if (g.payStartS >= 0f)
                    sim.payWaitFromStartMin.Add(
                        Mathf.Max(0f, g.payS - g.payStartS - service.paySec) / 60f);
            }
        }

        void AddGroupRows()
        {
            IList<GroupManager.Group> gs = groups.Groups;
            for (int i = 0; i < gs.Count; i++)
            {
                GroupManager.Group g = gs[i];
                TripLogger.GroupRow r = new TripLogger.GroupRow();
                r.conditionId = _job.conditionId;
                r.seed = _job.seed;
                r.groupId = g.id;
                r.size = g.size;
                r.tableId = g.table != null ? g.table.tableId : 0;
                r.tableSeats = g.table != null ? g.table.seatCount : 0;   // 2人席／4人席
                r.arriveS = g.arriveS;
                r.guideStartS = g.guideStartS;
                r.seatedS = g.seatedS;
                r.eatStartS = g.eatStartS;      // 食べ始めた時刻（依頼 v8 の 2）
                r.eatEndS = g.eatEndS;
                r.payStartS = g.payStartS;
                r.payS = g.payS;
                r.leftS = g.leftS;
                r.waitedMin = g.seatedS >= 0f && g.arriveS >= 0f ? (g.seatedS - g.arriveS) / 60f : -1f;
                r.warmup = g.warmup;
                r.state = g.state.ToString();
                _logger.AddGroup(r);
            }
        }

        /// <summary>
        /// staff_util：スタッフの手がふさがっていた割合（v5 3章）。
        /// ops.StaffTime が仕事ごとの秒を持っているので、「待機」以外の割合で出す。
        /// </summary>
        float StaffUtil()
        {
            float total = 0f, idle = 0f;
            foreach (KeyValuePair<string, float> kv in ops.StaffTime)
            {
                total += kv.Value;
                if (kv.Key == "待機") idle += kv.Value;
            }
            return total > 0.01f ? (total - idle) / total : 0f;
        }

        /// <summary>
        /// 戻りの線が使えず、今までどおり受け渡し口の前へ戻った回数（v5）。
        /// 0 でなければ、線が通れる床に乗っていないか、出ていく線・ドリンクバーの列に近すぎる
        /// </summary>
        int ReturnLineFallbacks()
        {
            int n = 0;
            for (int i = 0; i < _robots.Count; i++)
                if (_robots[i] != null) n += _robots[i].ReturnLineFallbacks;
            return n;
        }

        static float Mean(List<float> v)
        {
            if (v == null || v.Count == 0) return 0f;
            float s = 0f;
            for (int i = 0; i < v.Count; i++) s += v[i];
            return s / v.Count;
        }

        static float MeanInt(List<int> v)
        {
            if (v == null || v.Count == 0) return 0f;
            long s = 0;
            for (int i = 0; i < v.Count; i++) s += v[i];
            return (float)s / v.Count;
        }

        /// <summary>
        /// メニューが決めた停止点を、.asset のもとの値へ戻す（C1）。
        /// 書き出しが終わったあとに呼ぶ。.asset を書き換えたままにしないため。
        /// </summary>
        void RestoreDeliveryOffset()
        {
            if (!_deliveryOffsetSaved) return;
            _deliveryOffsetSaved = false;
            if (store != null && store.layout != null)
                store.layout.deliveryOffsetFromAisleCenter = _savedDeliveryOffset;
        }

        /// <summary>
        /// サイトの試算から入れた値を、.asset のもとの値へ戻す（v5 3章）。
        /// 停止点（C1）と同じ扱い。.asset を書き換えたままにしないため
        /// </summary>
        void RestoreSiteValues()
        {
            _siteValues.Restore();
        }

        void AllDone()
        {
            _running = false;
            RestoreDeliveryOffset();
            RestoreSiteValues();
            Time.captureDeltaTime = 0f;
            Time.timeScale = 1f;
            Debug.Log("[ServiceSim] ぜんぶ終わりました。" + _runsDone + " / " + _jobCount + " 条件、" +
                      "実時間 " + (_wallSeconds / 60f).ToString("0.0") + " 分");
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#endif
        }

        /// <summary>
        /// 途中で Play を止めたとき、そこまでの記録を書き出す（v5 3章）。
        ///
        /// 回している最中に止めると、これまでは何も残らなかった。
        /// 詰まった回でも give_ups.csv と summary.json を見られるように、同じ形で書き出す。
        ///   ・置き場所は、ふだんのフォルダ名に _stopped を付けたもの
        ///   ・run_info.json の stopped_early が true、summary.json の
        ///     site_run.everyone_left は false のまま
        /// 書き出しに失敗しても Play を抜けられるよう、まるごと try/catch で包む。
        /// Play を抜ける最中なので、ここから新しくオブジェクトを探したり消したりしない。
        /// </summary>
        void WriteOnStop()
        {
            if (!_running || !_jobRunning || _outputsWritten || _job == null) return;

            try
            {
                _stoppedEarly = true;
                _jobRunning = false;
                AddGroupRows();
                WriteOutputs(_job.folder + "_stopped", true);
                Debug.LogWarning("[ServiceSim] 途中で止めました。ここまでの記録を書き出しました" +
                                 "（計測には使えません）\n  店の中 " +
                                 (ops != null ? (ops.NowS / 60f).ToString("0.0") : "?") + " 分\n  " +
                                 _job.folder + "_stopped");
            }
            catch (System.Exception e)
            {
                Debug.LogError("[ServiceSim] 途中で止めたときの書き出しに失敗しました\n" + e);
            }
        }

        void OnApplicationQuit()
        {
            WriteOnStop();
        }

        void OnDisable()
        {
            // 回している最中に止めたときは、ここまでの記録を書き出す（v5 3章）
            WriteOnStop();

            // 途中で Play を抜けたときも、停止点とサイトの値を .asset のもとの値へ戻す
            RestoreDeliveryOffset();
            RestoreSiteValues();
            Time.captureDeltaTime = 0f;
            if (_running) Time.timeScale = 1f;
        }
    }
}
```

### Assets/ServiceSim/Experiment/TripLogger.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// trips.csv・conditions.json・summary.json を書く。
    /// この script は実験だけを扱う。店・ロボット・人は動かさない。
    /// 1往復ぶんの値は ExperimentRunner が Row に詰めて渡す。
    /// </summary>
    public class TripLogger
    {
        /// <summary>trips.csv の1行</summary>
        public struct Row
        {
            public string conditionId;
            public float aisleWidthM;
            public string crowd;
            public int robots;
            public int seed;
            public int robotId;
            public int tripId;
            public int tableId;
            public float distanceM;
            public float totalS;
            public float stoppedS;
            public int nStops;
            public float[] stopDurationsS;
            public bool stuck;
            public bool timeout;
            public float waitAisleS;
            public float maxDecelMps2;
            public float minClearanceM;
            public bool metRobot;
            // v2：1行＝ロボットの1走り
            public int[] tableIds;          // 回った卓
            public int dishes;              // 載せた料理の数
            public float[] dishReadyS;      // 各料理ができた時刻
            public float[] dishArrivedS;    // 各料理が届いた時刻（-1 は届かなかった）
            public int undelivered;         // 届けられず受け渡し口に戻した数

            // ---- 往復ごとの測り（v5 3章・設計書2章の4つ）----
            /// <summary>その往復で実際に走れた速さ（m/秒）＝ distance_m /（total_s − stopped_s）</summary>
            public float speedMps;

            /// <summary>動けなくなってスタッフを呼んだ回数と、スタッフを待った秒数</summary>
            public int stuckCount;
            public float stuckWaitS;

            /// <summary>その往復でサイトの道順が 0.8 m の道を使ったか（routes の use が narrow）</summary>
            public bool viaNarrow;

            /// <summary>止まりの回数と秒数を、狭い通路の中と外に分けたもの（⑦の読み分け用）</summary>
            public int stopsInNarrow, stopsOutNarrow;
            public float stoppedInNarrowS, stoppedOutNarrowS;

            // ---- 後ろに足した列（依頼 v8 の 2。今ある列の並びは変えない）----
            /// <summary>その往復を始めた時刻（店の中, 秒。ほかの _s 列と同じ基準）</summary>
            public float runStartS;

            /// <summary>その往復で載せた皿の番号（dishes.csv の dish_id と同じ）</summary>
            public int[] dishIds;

            /// <summary>止まる所ごとに着いた時刻（着かなかった所は -1）</summary>
            public float[] stopArrivedS;

            /// <summary>
            /// 打ち切りで置き直した先（依頼 v8b の A）。pass / standby1 / standby2 …。
            /// 打ち切りでない往復は空
            /// </summary>
            public string resetTo;

            // ---- 後ろに足した列（依頼 v9 の 5 章。今ある列の並びは変えない）----

            /// <summary>往復が実際に終わった店の中の時刻（卓での25秒を含む, 秒）。total_s はそのまま</summary>
            public float tripEndS;

            /// <summary>道の決め方（MapPlanner / SiteRoutes）</summary>
            public string pathMode;

            /// <summary>道を引き直した回数（回り込み・よけを含む）</summary>
            public int replans;

            /// <summary>3 章の回り込みで道を変えた回数</summary>
            public int detours;

            /// <summary>
            /// 4 章でよけた回数。よける場所へ動いた回数 ＋
            /// 相手の道をよけた道に引き直した回数
            /// </summary>
            public int robotYields;

            /// <summary>その往復でサイトの道順をたどったときの長さ（routes の length の和, m）</summary>
            public float siteRouteM;

            /// <summary>走った距離 − site_route_m（m）</summary>
            public float detourExtraM;

            /// <summary>狭い道（free_class 1 のマス）を通ったか</summary>
            public bool narrowUsed;

            /// <summary>その往復で 1 回の道探しにかかった最大の時間（ミリ秒）</summary>
            public float planMsMax;
        }

        /// <summary>groups.csv の1行（1組ぶん）</summary>
        public struct GroupRow
        {
            public string conditionId;
            public int seed;
            public int groupId;
            public int size;
            public int tableId;

            /// <summary>座った卓の大きさ（2 なら2人席、4 なら4人席。0 は座れなかった）</summary>
            public int tableSeats;
            public float arriveS, guideStartS, seatedS, eatEndS, payS, leftS;

            /// <summary>食べ始めた時刻（秒）。食べ始めなかった組は -1（依頼 v8 の 2）</summary>
            public float eatStartS;

            /// <summary>会計を始めた時刻（食べ終わって会計待ちに入った時刻, 秒）</summary>
            public float payStartS;
            public float waitedMin;
            public bool warmup;
            public string state;
        }

        /// <summary>stops.csv の1行（止まり1回ぶん）</summary>
        public struct StopRow
        {
            public string conditionId;
            public int seed;
            public int robotId;
            public int tripId;
            public float tS;            // 店の中の時刻
            public float x, z;
            public string aisle;        // 通路の名前

            /// <summary>
            /// サイトの決まりで「狭い通路」（幅が meta.settings.normal_width 未満）の中で
            /// 止まったか（v5 3章。⑦の読み分け。③は 1.2 m なのでいつも 0）
            /// </summary>
            public bool inNarrow;
            public string cause;
            public string causeDetail;
            public bool pathBlocked;    // 行き先への道が引けていなかったか
            public float durationS;
            public string resolved;
            public float clearanceLeftCm;
            public float clearanceRightCm;
            public float minGapCm;

            // 止まっている間、何で動けなかったかの内訳（秒）。B7 を調べるため
            public float blockedS;
            public float noPathS;
            public float yieldS;
            public float clearWaitS;
            public int replans;

            // 道を引いたときの中身（調べるためだけ。動きは変えない）
            /// <summary>寄せたあとの出発点・行き先（"x;z"）</summary>
            public string solveFrom, solveTo;
            /// <summary>CalculatePath の結果と、引けた角の数</summary>
            public string solveStatus;
            public int solveCorners;
            /// <summary>ロボットの位置から、いちばん近い焼いた面までの距離（m）。-1 は見つからない</summary>
            public float offMeshM;
        }

        /// <summary>
        /// 止まった原因の名前。観察シート（serving-robot-observation.html）の
        /// 「止まった原因」の選択肢にそろえる。Unity にしかない原因は足した。
        /// </summary>
        public static string CauseLabel(ServingRobotController.StopCause cause)
        {
            switch (cause)
            {
                case ServingRobotController.StopCause.Guest: return "お客さんとすれ違った";
                case ServingRobotController.StopCause.Staff: return "スタッフとすれ違った";
                case ServingRobotController.StopCause.PersonStanding: return "人が立ち止まっていた";
                case ServingRobotController.StopCause.Chair: return "椅子・荷物・ベビーカー";
                case ServingRobotController.StopCause.NoPath: return "通路の幅が足りない";
                case ServingRobotController.StopCause.OtherRobot: return "もう1台のロボット";
                case ServingRobotController.StopCause.AisleQueue: return "通路の順番待ち";
                default: return "分からない";
            }
        }

        /// <summary>どう動き出したかの名前</summary>
        public static string ResolveLabel(ServingRobotController.StopResolve r)
        {
            switch (r)
            {
                case ServingRobotController.StopResolve.SelfResume: return "自分で動き出した";
                case ServingRobotController.StopResolve.Replanned: return "経路を引き直した";
                case ServingRobotController.StopResolve.Stuck: return "動けなくなった";
                // 依頼 v9 の 5 章
                case ServingRobotController.StopResolve.Detoured: return "回り込んで動き出した";
                case ServingRobotController.StopResolve.YieldedToRobot: return "よける場所へ動いた";
                default: return "入口で待った";
            }
        }

        readonly List<Row> _rows = new List<Row>();
        readonly List<StopRow> _stops = new List<StopRow>();

        public int StopCount { get { return _stops.Count; } }

        /// <summary>
        /// 一時停止の回数の合計（pauses）。
        /// 往復ごとの n_stops（自分で動き出した止まり）を足す。
        /// 通路の入口で待った分と、動けなくなった分は入らない
        /// </summary>
        public int TotalStopCount()
        {
            int n = 0;
            for (int i = 0; i < _rows.Count; i++) n += _rows[i].nStops;
            return n;
        }
        public IReadOnlyList<StopRow> Stops { get { return _stops; } }

        public void AddStop(StopRow row) { _stops.Add(row); }

        public int RowCount { get { return _rows.Count; } }
        public IReadOnlyList<Row> Rows { get { return _rows; } }

        public void Add(Row row)
        {
            _rows.Add(row);
        }

        public void Clear()
        {
            _rows.Clear();
            _stops.Clear();
            _groups.Clear();
        }

        // ---------------- trips.csv ----------------

        public const string Header =
            "condition_id,aisle_width_m,crowd,robots,seed,robot_id,trip_id,table_id," +
            "distance_m,total_s,stopped_s,n_stops,stop_durations_s,stuck,timeout,wait_aisle_s," +
            "max_decel_mps2,min_clearance_m,met_robot," +
            "table_ids,dishes,dish_ready_s,dish_arrived_s,undelivered," +
            // v5 3章：往復ごとの測り（設計書2章の4つ）。⑦は狭い通路の中と外を分ける
            "speed_mps,stuck_count,stuck_wait_s,via_narrow," +
            "stops_in_narrow,stops_out_narrow,stopped_in_narrow_s,stopped_out_narrow_s," +
            // 依頼 v8 の 2：後ろに足した列
            "run_start_s,dish_ids,stop_arrived_s," +
            // 依頼 v8b の A：打ち切りで置き直した先
            "reset_to," +
            // 依頼 v9 の 5 章：地図の上で自分で道を引く
            "trip_end_s,path_mode,replans,detours,robot_yields," +
            "site_route_m,detour_extra_m,narrow_used,plan_ms_max";

        public void WriteTripsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(Header).Append('\n');
            for (int i = 0; i < _rows.Count; i++) AppendRow(sb, _rows[i]);
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static void AppendRow(StringBuilder sb, Row r)
        {
            sb.Append(r.conditionId).Append(',')
              .Append(F(r.aisleWidthM)).Append(',')
              .Append(r.crowd).Append(',')
              .Append(r.robots).Append(',')
              .Append(r.seed).Append(',')
              .Append(r.robotId).Append(',')
              .Append(r.tripId).Append(',')
              .Append(r.tableId).Append(',')
              .Append(F(r.distanceM)).Append(',')
              .Append(F(r.totalS)).Append(',')
              .Append(F(r.stoppedS)).Append(',')
              .Append(r.nStops).Append(',')
              .Append(Join(r.stopDurationsS)).Append(',')
              .Append(r.stuck ? 1 : 0).Append(',')
              .Append(r.timeout ? 1 : 0).Append(',')
              .Append(F(r.waitAisleS)).Append(',')
              .Append(F(r.maxDecelMps2)).Append(',')
              .Append(F(r.minClearanceM)).Append(',')
              .Append(r.metRobot ? 1 : 0).Append(',')
              .Append(JoinInt(r.tableIds)).Append(',')
              .Append(r.dishes).Append(',')
              .Append(Join(r.dishReadyS)).Append(',')
              .Append(Join(r.dishArrivedS)).Append(',')
              .Append(r.undelivered).Append(',')
              .Append(F(r.speedMps)).Append(',')
              .Append(r.stuckCount).Append(',')
              .Append(F(r.stuckWaitS)).Append(',')
              .Append(r.viaNarrow ? 1 : 0).Append(',')
              .Append(r.stopsInNarrow).Append(',')
              .Append(r.stopsOutNarrow).Append(',')
              .Append(F(r.stoppedInNarrowS)).Append(',')
              .Append(F(r.stoppedOutNarrowS)).Append(',')
              .Append(F(r.runStartS)).Append(',')
              .Append(JoinInt(r.dishIds)).Append(',')
              .Append(Join(r.stopArrivedS)).Append(',')
              .Append(r.resetTo ?? "").Append(',')
              // 依頼 v9 の 5 章
              .Append(F(r.tripEndS)).Append(',')
              .Append(r.pathMode ?? "").Append(',')
              .Append(r.replans).Append(',')
              .Append(r.detours).Append(',')
              .Append(r.robotYields).Append(',')
              .Append(F(r.siteRouteM)).Append(',')
              .Append(F(r.detourExtraM)).Append(',')
              .Append(r.narrowUsed ? 1 : 0).Append(',')
              .Append(F(r.planMsMax))
              .Append('\n');
        }

        static string Join(float[] values)
        {
            if (values == null || values.Length == 0) return "";
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(';');
                sb.Append(F(values[i]));
            }
            return sb.ToString();
        }

        static string JoinInt(int[] values)
        {
            if (values == null || values.Length == 0) return "";
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(';');
                sb.Append(values[i]);
            }
            return sb.ToString();
        }

        static string F(float v)
        {
            return v.ToString("0.####", CultureInfo.InvariantCulture);
        }

        // ---------------- groups.csv ----------------

        readonly List<GroupRow> _groups = new List<GroupRow>();
        public int GroupRowCount { get { return _groups.Count; } }
        public void AddGroup(GroupRow r) { _groups.Add(r); }

        /// <summary>table_seats は座った卓の大きさ（2＝2人席・4＝4人席・0＝座れなかった）</summary>
        public const string GroupHeader =
            "condition_id,seed,group_id,size,table_id,table_seats,arrive_s,guide_start_s,seated_s," +
            "eat_end_s,pay_start_s,pay_s,left_s,waited_min,warmup,state," +
            // 依頼 v8 の 2：後ろに足した列
            "eat_start_s";

        public void WriteGroupsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(GroupHeader).Append('\n');
            for (int i = 0; i < _groups.Count; i++)
            {
                GroupRow r = _groups[i];
                sb.Append(r.conditionId).Append(',').Append(r.seed).Append(',')
                  .Append(r.groupId).Append(',').Append(r.size).Append(',').Append(r.tableId).Append(',')
                  .Append(r.tableSeats).Append(',')
                  .Append(F(r.arriveS)).Append(',').Append(F(r.guideStartS)).Append(',')
                  .Append(F(r.seatedS)).Append(',').Append(F(r.eatEndS)).Append(',')
                  .Append(F(r.payStartS)).Append(',')
                  .Append(F(r.payS)).Append(',').Append(F(r.leftS)).Append(',')
                  .Append(F(r.waitedMin)).Append(',').Append(r.warmup ? 1 : 0).Append(',')
                  .Append(r.state).Append(',')
                  .Append(F(r.eatStartS)).Append('\n');
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- people_jams.csv ----------------

        public const string JamHeader =
            "condition_id,seed,t_s,x,z,aisle,people,duration_s,resolved,robot_blocked,robot_id";

        public static void WriteJamsCsv(string path, string conditionId, int seed,
                                        IReadOnlyList<JamWatcher.JamRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(JamHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    JamWatcher.JamRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(r.people).Append(',')
                      .Append(F(r.durationS)).Append(',').Append(r.resolved).Append(',')
                      .Append(r.robotBlocked ? 1 : 0).Append(',').Append(r.robotId).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- give_ups.csv（B1）----------------

        public const string GiveUpHeader =
            "condition_id,seed,t_s,who,job,table_id,to_x,to_z,x,z,aisle,waited_s,action";

        /// <summary>あきらめた・移した・卓を空けたことを1回1行で書く</summary>
        public static void WriteGiveUpsCsv(string path, string conditionId, int seed,
                                           IReadOnlyList<GiveUpRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(GiveUpHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    GiveUpRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(r.who).Append(',').Append(r.job).Append(',')
                      .Append(r.tableId).Append(',')
                      .Append(F(r.toX)).Append(',').Append(F(r.toZ)).Append(',')
                      .Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(F(r.waitedS)).Append(',')
                      .Append(r.action).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- contacts.csv（人がロボットに近づきすぎた）----------------

        public const string ContactHeader =
            "condition_id,seed,t_s,x,z,aisle,place,who,careless,robot_id,gap_cm";

        /// <summary>人がロボットの外周の近くまで来た1回を1行で書く</summary>
        public static void WriteContactsCsv(string path, string conditionId, int seed,
                                            IReadOnlyList<ContactRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(ContactHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    ContactRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(r.place).Append(',')
                      .Append(r.who).Append(',')
                      .Append(r.careless ? 1 : 0).Append(',').Append(r.robotId).Append(',')
                      .Append(F(r.gapM * 100f)).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- 往復のまとめ（段階Bの台帳が写す・2026-10-23）----------------

        /// <summary>止まっていた秒数の合計（stops.csv の duration_s の合計）</summary>
        float StopSecondsTotal()
        {
            float s = 0f;
            for (int i = 0; i < _stops.Count; i++) s += _stops[i].durationS;
            return s;
        }

        /// <summary>
        /// 往復のまとめを summary.json に出す（段階Bの台帳が写す）。
        ///
        /// robot_speed_mps は、サイトの common.robot_speed（role=unity_measures
        /// ＝「Unity で測ってサイトに戻すための仮の値」）と同じ意味で Unity が測った値。
        ///   走れた速さ ＝ 走った距離の合計 ÷（かかった秒数 − 止まっていた秒数）
        /// pause_s_mean は、サイトの common.pause_s（同じ role）と同じ意味で、
        /// 自分で動き出した止まり1回の長さの平均（秒）。
        ///
        /// サイトの pause_rate・stuck_rate は「hop_m（3 m）進むごとの起きやすさ」だが、
        /// hop_m は role=site_only（試算の中だけの設定）で Unity は読まない決まりなので、
        /// ここでは出さない。かわりに、割り出すのに要る素の数を出す
        /// （pauses ＝ stops.count、stuck_count、distance_m_total）。
        /// </summary>
        void AppendTripSummary(StringBuilder sb)
        {
            int trips = _rows.Count;
            int timeouts = 0, stuckTrips = 0;
            float distance = 0f, total = 0f, stopped = 0f;

            for (int i = 0; i < _rows.Count; i++)
            {
                Row r = _rows[i];
                if (r.timeout) timeouts++;
                if (r.stuck) stuckTrips++;
                distance += r.distanceM;
                total += r.totalS;
                stopped += r.stoppedS;
            }

            float moving = Mathf.Max(0f, total - stopped);

            // 自分で動き出した止まりの長さの平均（pause_s と同じ意味）
            float pauseSum = 0f;
            int pauseN = 0;
            for (int i = 0; i < _rows.Count; i++)
            {
                float[] d = _rows[i].stopDurationsS;
                if (d == null) continue;
                for (int k = 0; k < d.Length; k++) { pauseSum += d[k]; pauseN++; }
            }

            sb.Append("  \"trips\": {");
            sb.Append("\"trips\": ").Append(trips).Append(", ");
            sb.Append("\"timeouts\": ").Append(timeouts).Append(", ");
            sb.Append("\"stuck_trips\": ").Append(stuckTrips).Append(", ");
            sb.Append("\"distance_m_total\": ").Append(F(distance)).Append(", ");
            sb.Append("\"total_s\": ").Append(F(total)).Append(", ");
            sb.Append("\"stopped_s\": ").Append(F(stopped)).Append(", ");
            sb.Append("\"robot_speed_mps\": ")
              .Append(F(moving > 0.001f ? distance / moving : 0f)).Append(", ");
            sb.Append("\"pause_s_mean\": ")
              .Append(F(pauseN > 0 ? pauseSum / pauseN : 0f)).Append(", ");
            sb.Append("\"pause_count\": ").Append(pauseN).Append(", ");
            sb.Append("\"pause_rate_note\": ")
              .Append(Str("hop_m は role=site_only で Unity は読まないため出さない。" +
                          "割り出すときは stops.count ÷（distance_m_total ÷ hop_m）")).Append(", ");
            sb.Append("\"stuck_rate_note\": ")
              .Append(Str("同じ理由で出さない。stops.stuck_count ÷（distance_m_total ÷ hop_m）"));
            sb.Append("},\n");
        }

        // ---------------- summary に出す譲り合いと角の止まり（2026-10-23）----------------

        /// <summary>
        /// 譲った回数を summary に出す。譲った側の種類ごと（ロボット・スタッフ・客）と、
        /// そのうち相手の道から横へどけた回数・角で起きた回数・待った秒数の合計。
        /// </summary>
        void AppendYields(StringBuilder sb)
        {
            int all = 0, robot = 0, staff = 0, guest = 0, stepped = 0, inCorner = 0, passed = 0;
            float waited = 0f, staffWaited = 0f;

            if (_sim != null)
            {
                for (int i = 0; i < _sim.yields.Count; i++)
                {
                    YieldRow y = _sim.yields[i];
                    all++;
                    if (y.rank == RobotSpec.RankRobot) robot++;
                    else if (y.rank == RobotSpec.RankStaff) { staff++; staffWaited += y.waitedS; }
                    else guest++;
                    if (y.steppedAside) stepped++;
                    if (y.inCorner) inCorner++;
                    if (y.passed) passed++;
                    waited += y.waitedS;
                }
            }

            sb.Append("\"yields\": ").Append(all).Append(", ");
            sb.Append("\"yields_by_robot\": ").Append(robot).Append(", ");
            sb.Append("\"yields_by_staff\": ").Append(staff).Append(", ");
            sb.Append("\"yields_by_guest\": ").Append(guest).Append(", ");
            sb.Append("\"yields_stepped_aside\": ").Append(stepped).Append(", ");
            sb.Append("\"yields_in_corner\": ").Append(inCorner).Append(", ");
            sb.Append("\"yields_passed\": ").Append(passed).Append(", ");
            sb.Append("\"yield_wait_s\": ").Append(F(waited)).Append(", ");
            sb.Append("\"yield_wait_mean_s\": ").Append(F(all > 0 ? waited / all : 0f)).Append(", ");

            // スタッフが譲った回数・合計秒数と、スタッフの時間に占める割合（v6b）。
            // 分母はスタッフの時間の合計（staff_time_s の全部。待機もふくむ
            // ＝ スタッフの人数 × 回した秒数）
            float staffTotal = 0f;
            if (_sim != null)
                for (int i = 0; i < _sim.staffTime.Count; i++) staffTotal += _sim.staffTime[i].Value;

            sb.Append("\"staff_yields\": ").Append(staff).Append(", ");
            sb.Append("\"staff_yield_wait_s\": ").Append(F(staffWaited)).Append(", ");
            sb.Append("\"staff_yield_mean_s\": ")
              .Append(F(staff > 0 ? staffWaited / staff : 0f)).Append(", ");
            sb.Append("\"staff_yield_share_of_staff_time\": ")
              .Append(F(staffTotal > 0.001f ? staffWaited / staffTotal : 0f)).Append(", ");
            sb.Append("\"staff_time_total_s\": ").Append(F(staffTotal)).Append(", ");
        }

        /// <summary>
        /// 受け渡し口と客席をつなぐ角（RobotSpec の範囲）での、ロボットの止まりの
        /// 回数・秒数と、止まり全体に対する割合を summary に出す。
        /// 1311・1536 の「止まった時間の約3分の1がこの角」と比べるため
        /// </summary>
        void AppendCornerStops(StringBuilder sb)
        {
            int n = 0, allN = 0;
            float secs = 0f, allSecs = 0f;

            bool haveRange = _sim != null && _sim.cornerMaxX > _sim.cornerMinX;
            for (int i = 0; i < _stops.Count; i++)
            {
                StopRow r = _stops[i];
                allN++;
                allSecs += r.durationS;
                if (!haveRange) continue;
                if (r.x < _sim.cornerMinX || r.x > _sim.cornerMaxX) continue;
                if (r.z < _sim.cornerMinZ || r.z > _sim.cornerMaxZ) continue;
                n++;
                secs += r.durationS;
            }

            sb.Append("\"corner_stops\": ").Append(n).Append(", ");
            sb.Append("\"corner_stop_s\": ").Append(F(secs)).Append(", ");
            sb.Append("\"corner_stop_count_share\": ")
              .Append(F(allN > 0 ? (float)n / allN : 0f)).Append(", ");
            sb.Append("\"corner_stop_time_share\": ")
              .Append(F(allSecs > 0.001f ? secs / allSecs : 0f)).Append(", ");
            sb.Append("\"corner_range\": \"x ")
              .Append(F(_sim != null ? _sim.cornerMinX : 0f)).Append("-")
              .Append(F(_sim != null ? _sim.cornerMaxX : 0f)).Append(" / z ")
              .Append(F(_sim != null ? _sim.cornerMinZ : 0f)).Append("-")
              .Append(F(_sim != null ? _sim.cornerMaxZ : 0f)).Append("\", ");
        }

        // ---------------- yields.csv（誰が誰に譲ったか・2026-10-23）----------------

        public const string YieldHeader =
            "condition_id,seed,t_s,who,rank,to_whom,x,z,aisle,place,in_corner," +
            "waited_s,stepped_aside,passed";

        /// <summary>
        /// 譲り1回を1行で書く（yields.csv）。
        /// rank は譲った側の順番（0=ロボット・1=スタッフ・2=客）、
        /// stepped_aside は相手の道から横へ 0.6 m 以上どけたか、
        /// passed は相手が通り過ぎて終わったか（0 なら待ちすぎてやめた）。
        /// </summary>
        public static void WriteYieldsCsv(string path, string conditionId, int seed,
                                          IReadOnlyList<YieldRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(YieldHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    YieldRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(r.who).Append(',')
                      .Append(r.rank).Append(',').Append(r.toWhom).Append(',')
                      .Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(r.place).Append(',')
                      .Append(r.inCorner ? 1 : 0).Append(',')
                      .Append(F(r.waitedS)).Append(',')
                      .Append(r.steppedAside ? 1 : 0).Append(',')
                      .Append(r.passed ? 1 : 0).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- dishes.csv（皿ごと・サイトの試算 v2.7）----------------

        public const string DishHeader =
            "condition_id,seed,dish_id,table_id,group_id,fragile," +
            "ready_s,first_taken_s,arrived_s,carried_by,pass_wait_s,carry_s,requeue_lost_s," +
            "requeued,staff_from_start,robot_wait_expired,late_staff_pickup,deferred_for_aisle";

        /// <summary>
        /// 届いた料理1皿を1行で書く（dishes.csv）。
        /// carried_by は「ロボット」か「スタッフ」、pass_wait_s は受け渡し口で待った秒数、
        /// late_staff_pickup は robot_first_wait_s を過ぎて人が拾った皿（1 なら該当）。
        /// </summary>
        public static void WriteDishesCsv(string path, string conditionId, int seed,
                                          IReadOnlyList<DishRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(DishHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    DishRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(r.dishId).Append(',').Append(r.tableId).Append(',')
                      .Append(r.groupId).Append(',').Append(r.fragile ? 1 : 0).Append(',')
                      .Append(F(r.readyS)).Append(',').Append(F(r.firstTakenS)).Append(',')
                      .Append(F(r.arrivedS)).Append(',')
                      .Append(r.byRobot ? "ロボット" : "スタッフ").Append(',')
                      .Append(F(r.passWaitS)).Append(',').Append(F(r.carryS)).Append(',')
                      .Append(F(r.requeueS)).Append(',').Append(r.requeued).Append(',')
                      .Append(r.staffFromStart ? 1 : 0).Append(',')
                      .Append(r.robotWaitExpired ? 1 : 0).Append(',')
                      .Append(r.lateStaffPickup ? 1 : 0).Append(',')
                      .Append(r.deferredForAisle ? 1 : 0).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- requeues.csv（B8）----------------

        public const string RequeueHeader =
            "condition_id,seed,t_s,dish_id,table_id,robot_id,fragile,ready_s,waited_s,requeued_so_far,why";

        /// <summary>届けられずに受け渡し口へ戻した1回を1行で書く</summary>
        public static void WriteRequeuesCsv(string path, string conditionId, int seed,
                                            IReadOnlyList<RequeueRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(RequeueHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    RequeueRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(r.dishId).Append(',').Append(r.tableId).Append(',')
                      .Append(r.robotId).Append(',').Append(r.fragile ? 1 : 0).Append(',')
                      .Append(F(r.readyS)).Append(',').Append(F(r.tS - r.readyS)).Append(',')
                      .Append(r.requeuedSoFar).Append(',').Append(r.why).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- stops.csv ----------------

        public const string StopHeader =
            "condition_id,seed,robot_id,trip_id,t_s,x,z,aisle,in_narrow,cause,cause_detail,path_blocked," +
            "duration_s,resolved,clearance_left_cm,clearance_right_cm,min_gap_cm," +
            "blocked_s,no_path_s,yield_s,clear_wait_s,replans," +
            // 道が引けない原因を追うための列（記録だけ。動きは変えない）
            "solve_from,solve_to,solve_status,solve_corners,off_mesh_m";

        public void WriteStopsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(StopHeader).Append('\n');
            for (int i = 0; i < _stops.Count; i++)
            {
                StopRow r = _stops[i];
                sb.Append(r.conditionId).Append(',')
                  .Append(r.seed).Append(',')
                  .Append(r.robotId).Append(',')
                  .Append(r.tripId).Append(',')
                  .Append(F(r.tS)).Append(',')
                  .Append(F(r.x)).Append(',')
                  .Append(F(r.z)).Append(',')
                  .Append(r.aisle).Append(',')
                  .Append(r.inNarrow ? 1 : 0).Append(',')
                  .Append(r.cause).Append(',')
                  .Append(r.causeDetail).Append(',')
                  .Append(r.pathBlocked ? 1 : 0).Append(',')
                  .Append(F(r.durationS)).Append(',')
                  .Append(r.resolved).Append(',')
                  .Append(F(r.clearanceLeftCm)).Append(',')
                  .Append(F(r.clearanceRightCm)).Append(',')
                  .Append(F(r.minGapCm)).Append(',')
                  .Append(F(r.blockedS)).Append(',')
                  .Append(F(r.noPathS)).Append(',')
                  .Append(F(r.yieldS)).Append(',')
                  .Append(F(r.clearWaitS)).Append(',')
                  .Append(r.replans).Append(',')
                  .Append(r.solveFrom).Append(',')
                  .Append(r.solveTo).Append(',')
                  .Append(r.solveStatus).Append(',')
                  .Append(r.solveCorners).Append(',')
                  .Append(F(r.offMeshM))
                  .Append('\n');
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- run_info.json（1回ぶんの回し方とコマの粗さ）----------------

        /// <summary>1回ぶんの回し方と、コマの粗さの記録</summary>
        public class RunInfo
        {
            public string runLabel = "";
            public string conditionId = "";
            public string timeMode = "";
            public string storeKind = "";
            public string crowd = "";
            public int tripsRequested;
            public int tripsDone;
            public int robots;
            public int seed;
            public float aisleWidthM;

            /// <summary>卓の停止点を通路の中心からずらす距離（m）。.asset に入れた値（C1）</summary>
            public float deliveryOffsetM;

            /// <summary>そのうち、実際に使った分（通路が狭いときは NavMesh に乗る範囲に抑える）</summary>
            public float deliveryOffsetUsedM;

            public float simSeconds;
            public float wallSeconds;
            public float timeScale;
            public float captureDeltaTime;
            public bool stoppedEarly;

            /// <summary>
            /// 同じ通路に2台を集めない決まり（v5・A）で回したか。
            /// Unity だけの決まりなので、どちらで回したかを残す
            /// </summary>
            public bool oneRobotPerAisle;

            /// <summary>譲る順番の決まりで回したか（v6。RobotSpec.yieldOrderEnabled）</summary>
            public bool yieldOrderEnabled;

            /// <summary>回し始めの Unity Version Control の changeset（段階B。メニューが入れる）</summary>
            public string unityCs = "";

            /// <summary>
            /// 回し始めに保留中の変更があったか（段階B）。
            /// あれば「保留あり N 件」。status に関係なく残す（依頼 v7 の 2）
            /// </summary>
            public string pendingNote = "";

            /// <summary>店員優先の版で回したか（v5。人のよけ方の切り替え）</summary>
            public bool staffFirstYield;

            /// <summary>1コマの時間（秒）。Time.deltaTime をそのまま</summary>
            public readonly List<float> frameDeltas = new List<float>();

            /// <summary>1コマでロボットが進んだ距離（m）。台のうちいちばん大きいもの</summary>
            public readonly List<float> robotSteps = new List<float>();

            /// <summary>
            /// 1コマで人が進んだ距離（m）。通路を歩く人のうちいちばん大きいもの（B10）。
            /// スタッフは 1.0 m/秒 なので、1コマ 0.05 秒だと 0.05 m でちょうど境目になる。
            /// </summary>
            public readonly List<float> peopleSteps = new List<float>();

            /// <summary>椅子を引いた回数（B6）</summary>
            public int chairPulls;

            /// <summary>椅子を引いたきっかけごとの回数（B6）</summary>
            public readonly List<KeyValuePair<string, int>> chairPullsByReason =
                new List<KeyValuePair<string, int>>();

            // ---- 依頼 v9 の 5 章：地図の上で自分で道を引く ----

            /// <summary>実際に使った道の決め方（MapPlanner / SiteRoutes）</summary>
            public string pathMode = "";

            /// <summary>地図の版の番号（map.json の meta.generation）</summary>
            public string mapGeneration = "";

            /// <summary>道の引き方の数字</summary>
            public float narrowCellCost, centerWeight, preferClearM, detourAfterSec;

            /// <summary>1 回の道探しにかかった時間（ミリ秒。往復ごとの最大を集めたもの）</summary>
            public readonly List<float> planMs = new List<float>();

            /// <summary>
            /// 出発点が地図では通れないマス（0）だったために寄せた回数の合計。
            /// 通路の入口で待つ点・横へどいた点は free_class では 0 になるので、ここに出る
            /// </summary>
            public int mapSnapStarts;

            /// <summary>道が引けなかった回数の合計</summary>
            public int planFails;
        }

        /// <summary>コマの粗さを1行にまとめる（Console 用）</summary>
        public static string FrameStatsText(RunInfo info)
        {
            if (info == null || info.frameDeltas.Count == 0) return "コマの記録なし";
            int over = 0;
            for (int i = 0; i < info.robotSteps.Count; i++) if (info.robotSteps[i] > 0.05f) over++;
            return "コマ " + info.frameDeltas.Count +
                   " / 1コマの時間 中央 " + F(Percentile(info.frameDeltas, 0.5f)) +
                   "s・90% " + F(Percentile(info.frameDeltas, 0.9f)) +
                   "s・最大 " + F(Percentile(info.frameDeltas, 1f)) + "s" +
                   " / 1コマで進む距離 最大 " + F(Percentile(info.robotSteps, 1f)) +
                   "m・90% " + F(Percentile(info.robotSteps, 0.9f)) + "m" +
                   " / 0.05 m を超えたコマ ロボット " + over + "（" +
                   (info.robotSteps.Count > 0 ? (100f * over / info.robotSteps.Count).ToString("0.0") : "0") +
                   "%）・人 " + CountOver(info.peopleSteps, 0.05f) + "（" +
                   (info.peopleSteps.Count > 0
                        ? (100f * CountOver(info.peopleSteps, 0.05f) / info.peopleSteps.Count).ToString("0.0")
                        : "0") + "%）";
        }

        public static void WriteRunInfoJson(string path, RunInfo info)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_label\": ").Append(Str(info.runLabel)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"condition_id\": ").Append(Str(info.conditionId)).Append(",\n");
            sb.Append("  \"store_kind\": ").Append(Str(info.storeKind)).Append(",\n");
            sb.Append("  \"time_mode\": ").Append(Str(info.timeMode)).Append(",\n");
            sb.Append("  \"time_scale\": ").Append(F(info.timeScale)).Append(",\n");
            sb.Append("  \"capture_delta_time\": ").Append(F(info.captureDeltaTime)).Append(",\n");
            sb.Append("  \"aisle_width_m\": ").Append(F(info.aisleWidthM)).Append(",\n");
            sb.Append("  \"delivery_offset_m\": ").Append(F(info.deliveryOffsetM)).Append(",\n");
            sb.Append("  \"delivery_offset_used_m\": ").Append(F(info.deliveryOffsetUsedM)).Append(",\n");
            sb.Append("  \"crowd\": ").Append(Str(info.crowd)).Append(",\n");
            sb.Append("  \"robots\": ").Append(info.robots).Append(",\n");
            sb.Append("  \"seed\": ").Append(info.seed).Append(",\n");
            sb.Append("  \"trips_requested\": ").Append(info.tripsRequested).Append(",\n");
            sb.Append("  \"trips_done\": ").Append(info.tripsDone).Append(",\n");
            sb.Append("  \"stopped_early\": ").Append(info.stoppedEarly ? "true" : "false").Append(",\n");
            sb.Append("  \"one_robot_per_aisle\": ")
              .Append(info.oneRobotPerAisle ? "true" : "false").Append(",\n");
            sb.Append("  \"yield_order_enabled\": ")
              .Append(info.yieldOrderEnabled ? "true" : "false").Append(",\n");
            sb.Append("  \"unity_cs\": ").Append(Str(info.unityCs)).Append(",\n");
            sb.Append("  \"pending_changes\": ").Append(Str(info.pendingNote)).Append(",\n");
            sb.Append("  \"staff_first_yield\": ")
              .Append(info.staffFirstYield ? "true" : "false").Append(",\n");
            sb.Append("  \"sim_seconds\": ").Append(F(info.simSeconds)).Append(",\n");
            sb.Append("  \"wall_seconds\": ").Append(F(info.wallSeconds)).Append(",\n");
            sb.Append("  \"speedup\": ").Append(F(info.wallSeconds > 0.01f ? info.simSeconds / info.wallSeconds : 0f)).Append(",\n");

            sb.Append("  \"frame_delta_s\": ");
            AppendStats(sb, info.frameDeltas);
            sb.Append(",\n");

            sb.Append("  \"robot_step_m\": ");
            AppendStats(sb, info.robotSteps);
            sb.Append(",\n");

            sb.Append("  \"people_step_m\": ");
            AppendStats(sb, info.peopleSteps);
            sb.Append(",\n");

            // すり抜けの確かめ（検査3）。ロボットと人を分けて出す
            int over = CountOver(info.robotSteps, 0.05f);
            int overPeople = CountOver(info.peopleSteps, 0.05f);
            sb.Append("  \"frames_over_005m\": ").Append(over).Append(",\n");
            sb.Append("  \"frames_over_005m_ratio\": ")
              .Append(F(info.robotSteps.Count > 0 ? (float)over / info.robotSteps.Count : 0f)).Append(",\n");
            sb.Append("  \"people_frames_over_005m\": ").Append(overPeople).Append(",\n");
            sb.Append("  \"people_frames_over_005m_ratio\": ")
              .Append(F(info.peopleSteps.Count > 0 ? (float)overPeople / info.peopleSteps.Count : 0f)).Append(",\n");

            // 椅子を引いた回数（B6）
            sb.Append("  \"chair_pulls\": ").Append(info.chairPulls).Append(",\n");
            sb.Append("  \"chair_pulls_by_reason\": {");
            for (int i = 0; i < info.chairPullsByReason.Count; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(Str(info.chairPullsByReason[i].Key)).Append(": ")
                  .Append(info.chairPullsByReason[i].Value);
            }
            sb.Append("},\n");

            // 依頼 v9 の 5 章：地図の上で自分で道を引く
            sb.Append("  \"path_mode\": ").Append(Str(info.pathMode)).Append(",\n");
            sb.Append("  \"map_generation\": ").Append(Str(info.mapGeneration)).Append(",\n");
            sb.Append("  \"narrow_cell_cost\": ").Append(F(info.narrowCellCost)).Append(",\n");
            sb.Append("  \"center_weight\": ").Append(F(info.centerWeight)).Append(",\n");
            sb.Append("  \"prefer_clear_m\": ").Append(F(info.preferClearM)).Append(",\n");
            sb.Append("  \"detour_after_sec\": ").Append(F(info.detourAfterSec)).Append(",\n");
            sb.Append("  \"plan_ms_max_per_trip\": ");
            AppendStats(sb, info.planMs);
            sb.Append(",\n");
            sb.Append("  \"map_snap_starts\": ").Append(info.mapSnapStarts).Append(",\n");
            sb.Append("  \"plan_fails\": ").Append(info.planFails).Append("\n");
            sb.Append("}\n");

            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        /// <summary>しきい値を超えたコマの数</summary>
        static int CountOver(List<float> values, float limit)
        {
            if (values == null) return 0;
            int n = 0;
            for (int i = 0; i < values.Count; i++) if (values[i] > limit) n++;
            return n;
        }

        static void AppendStats(StringBuilder sb, List<float> values)
        {
            if (values == null || values.Count == 0) { sb.Append("null"); return; }
            float sum = 0f;
            for (int i = 0; i < values.Count; i++) sum += values[i];
            sb.Append("{\"count\": ").Append(values.Count);
            sb.Append(", \"mean\": ").Append(F(sum / values.Count));
            sb.Append(", \"min\": ").Append(F(Percentile(values, 0f)));
            sb.Append(", \"p50\": ").Append(F(Percentile(values, 0.5f)));
            sb.Append(", \"p90\": ").Append(F(Percentile(values, 0.9f)));
            sb.Append(", \"p99\": ").Append(F(Percentile(values, 0.99f)));
            sb.Append(", \"max\": ").Append(F(Percentile(values, 1f)));
            sb.Append("}");
        }

        // ---------------- conditions.json ----------------

        /// <summary>
        /// 18条件の中身と、設定ファイル（.asset）の値すべてを書く。
        /// 値は実行したときのものを JsonUtility でそのまま出す。
        /// </summary>
        public static void WriteConditionsJson(string path, string runName,
                                               StoreLayout layout, RobotSpec robotSpec,
                                               PeopleSpec peopleSpec, ExperimentPlan plan,
                                               List<ExperimentPlan.Condition> conditions)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_name\": ").Append(Str(runName)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"unity_version\": ").Append(Str(Application.unityVersion)).Append(",\n");
            sb.Append("  \"trips_csv_columns\": ").Append(Str(Header)).Append(",\n");
            sb.Append("  \"groups_csv_columns\": ").Append(Str(GroupHeader)).Append(",\n");
            sb.Append("  \"dishes_csv_columns\": ").Append(Str(DishHeader)).Append(",\n");

            sb.Append("  \"settings\": {\n");
            sb.Append("    \"store_layout\": ").Append(Dump(layout)).Append(",\n");
            sb.Append("    \"robot_spec\": ").Append(Dump(robotSpec)).Append(",\n");
            sb.Append("    \"people_spec\": ").Append(Dump(peopleSpec)).Append(",\n");
            sb.Append("    \"experiment_plan\": ").Append(Dump(plan)).Append("\n");
            sb.Append("  },\n");

            // 組み立てた形から出る値（conditions.json だけ見れば店の大きさが分かるように）
            sb.Append("  \"derived\": {\n");
            if (layout != null)
            {
                StorePlan sp = layout.NewPlan();
                sb.Append("    \"store_kind\": ").Append(Str(layout.StoreKindName)).Append(",\n");
                sb.Append("    \"aisle_width_m\": ").Append(F(layout.AisleWidth)).Append(",\n");
                if (sp != null)
                {
                    sb.Append("    \"floor_min_x\": ").Append(F(sp.floor.xMin)).Append(",\n");
                    sb.Append("    \"floor_max_x\": ").Append(F(sp.floor.xMax)).Append(",\n");
                    sb.Append("    \"floor_min_z\": ").Append(F(sp.floor.zMin)).Append(",\n");
                    sb.Append("    \"floor_max_z\": ").Append(F(sp.floor.zMax)).Append(",\n");
                    sb.Append("    \"outer_size_x_m\": ").Append(F(sp.floor.SizeX)).Append(",\n");
                    sb.Append("    \"outer_size_z_m\": ").Append(F(sp.floor.SizeZ)).Append(",\n");
                    sb.Append("    \"table_count\": ").Append(sp.seats.Count).Append(",\n");
                    sb.Append("    \"aisle_count\": ").Append(sp.aisles.Count).Append(",\n");
                    sb.Append("    \"narrow_aisle_count\": ").Append(sp.NarrowAisleCount).Append("\n");
                }
                else
                {
                    sb.Append("    \"plan\": null\n");
                }
            }
            sb.Append("  },\n");

            sb.Append("  \"conditions\": [\n");
            if (conditions != null)
            {
                for (int i = 0; i < conditions.Count; i++)
                {
                    ExperimentPlan.Condition c = conditions[i];
                    sb.Append("    {");
                    sb.Append("\"condition_id\": ").Append(Str(c.id)).Append(", ");
                    sb.Append("\"aisle_width_m\": ").Append(F(c.aisleWidthM)).Append(", ");
                    sb.Append("\"aisle_width_index\": ").Append(c.aisleWidthIndex).Append(", ");
                    sb.Append("\"crowd\": ").Append(Str(ExperimentPlan.CrowdName(c.crowd))).Append(", ");
                    sb.Append("\"walkers\": ").Append(peopleSpec != null ? peopleSpec.WalkerCount(c.crowd) : 0).Append(", ");
                    sb.Append("\"robots\": ").Append(c.robots).Append(", ");
                    sb.Append("\"trips\": ").Append(plan != null ? plan.tripsPerCondition : 0).Append(", ");
                    sb.Append("\"seeds\": ").Append(IntArray(plan != null ? plan.seeds : null)).Append(", ");
                    sb.Append("\"aisle_yielding\": ").Append(robotSpec != null && robotSpec.aisleYielding ? "true" : "false");
                    sb.Append('}');
                    if (i < conditions.Count - 1) sb.Append(',');
                    sb.Append('\n');
                }
            }
            sb.Append("  ]\n");
            sb.Append("}\n");

            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static string Dump(ScriptableObject asset)
        {
            if (asset == null) return "null";
            return JsonUtility.ToJson(asset, false);
        }

        static string IntArray(int[] values)
        {
            if (values == null || values.Length == 0) return "[]";
            StringBuilder sb = new StringBuilder("[");
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(values[i]);
            }
            return sb.Append(']').ToString();
        }

        static string Str(string s)
        {
            if (s == null) return "null";
            StringBuilder sb = new StringBuilder("\"");
            for (int i = 0; i < s.Length; i++)
            {
                char c = s[i];
                if (c == '"' || c == '\\') sb.Append('\\').Append(c);
                else if (c == '\n') sb.Append("\\n");
                else if (c == '\r') sb.Append("\\r");
                else if (c == '\t') sb.Append("\\t");
                else sb.Append(c);
            }
            return sb.Append('"').ToString();
        }

        // ---------------- summary.json ----------------

        /// <summary>条件ごとにまとめる</summary>
        /// <summary>試算（haizen_sim.py）と同じ意味の数字。名前も同じにする</summary>
        public class SimLike
        {
            public List<float> dishWaitMin = new List<float>();
            public List<float> seatWaitMin = new List<float>();
            public List<float> turnMin = new List<float>();
            public float coldMin = 3f;
            public int robotDishes, staffDishes, requeuedDishes, groups;

            /// <summary>あきらめた・移した・卓を空けた回数（B1）</summary>
            public int gaveUp, warped, tableFreed;

            /// <summary>人どうしの詰まりのうち、近くでロボットが止まっていたもの（B9）</summary>
            public int robotBlockedJams;

            /// <summary>人がロボットに近づきすぎた回数・待った回数と秒数・回り道した回数</summary>
            public int robotContacts, robotYieldWaits, robotYieldDetours;
            public float robotYieldWaitSeconds;

            /// <summary>レジの前にいた人数（いちばん多いとき・平均）</summary>
            public int registerCrowdMax;
            public float registerCrowdMean;

            /// <summary>会計の回数と、そのうちスタッフが店員側にいなかった回数（0 になるはず）</summary>
            public int registerPayCount, registerPayNotAtBackCount;

            /// <summary>立つ点から離れて会計した回数（0 になるはず）と、その距離</summary>
            public int registerPayOffSpotCount;
            public float registerPayMeanOffsetM, registerPayMaxOffsetM;

            /// <summary>会計でないスタッフが店員側にいた回数と秒数（どちらも 0 になるはず）</summary>
            public int registerBackIntruderCount;
            public float registerBackIntruderSeconds;

            /// <summary>スタッフの時間の使い方（仕事の名前 → 秒）</summary>
            public readonly List<KeyValuePair<string, float>> staffTime =
                new List<KeyValuePair<string, float>>();

            /// <summary>仕事が板に出てから取られるまでの秒数の平均（仕事の名前 → 秒）</summary>
            public readonly List<KeyValuePair<string, float>> jobWaitMean =
                new List<KeyValuePair<string, float>>();

            /// <summary>届いた料理の、届くまでの内訳（ロボット・スタッフ別に分ける）</summary>
            public readonly List<ServiceOps.DishParts> dishParts = new List<ServiceOps.DishParts>();

            /// <summary>
            /// 2分を過ぎて人が拾った皿の数（サイトの試算 v2.7）。
            /// ロボットだけを待つ時間（robot_first_wait_s）を過ぎてからスタッフが運んだ皿
            /// </summary>
            public int lateStaffDishes;

            /// <summary>その回に使った robot_first_wait_s（秒）。サイトから読んだ値</summary>
            public float robotFirstWaitS;

            // ---- 譲り合い（2026-10-23・譲る順番 v6）----
            /// <summary>誰が誰に譲った1回ずつ（yields.csv と同じもの）</summary>
            public readonly List<YieldRow> yields = new List<YieldRow>();

            /// <summary>
            /// 受け渡し口と客席をつなぐ角の範囲（RobotSpec の1か所から写す）。
            /// 角での止まりの回数・秒数を summary に出すのに使う
            /// </summary>
            public float cornerMinX, cornerMaxX, cornerMinZ, cornerMaxZ;

            /// <summary>椅子を引いた回数（B6）</summary>
            public int chairPulls;

            /// <summary>ドリンクバー：並んだ回数・あきらめた回数・並んで待った分（B4）</summary>
            public int drinkBarQueued, drinkBarGaveUp;
            public readonly List<float> drinkBarWaitMin = new List<float>();
            public int jamCount; public float jamTotalS;
            public float walkersMean;
            public float measuredMinutes;

            // ---- サイトと同じ名前で出す値（v5 3章・meta.metrics）----
            /// <summary>入店待ちの内訳のもと（分）。分け方はサイトの試算と同じ</summary>
            public readonly List<float> entryWaitMin = new List<float>();
            public readonly List<float> fullWaitMin = new List<float>();
            public readonly List<float> blockedWaitMin = new List<float>();
            public readonly List<float> guideWaitMin = new List<float>();
            public readonly List<float> staffWaitMin = new List<float>();

            /// <summary>
            /// pay_wait_mean のもと（分。依頼 v10 の 2）。
            /// 組ごとに（会計が終わった時刻 − 食べ終えた時刻 − pay_s）。
            /// 逃がし弁で卓を空けられた組と、会計が終わっていない組は入れない
            /// </summary>
            public readonly List<float> payWaitMin = new List<float>();

            /// <summary>
            /// 同じものを、食べ終えた時刻ではなく会計に入った時刻（pay_start_s）から数えた分。
            /// 遅れて届いた皿・立ち歩きの人を待った分が pay_wait_mean にどれだけ混ざっているかを見る
            /// </summary>
            public readonly List<float> payWaitFromStartMin = new List<float>();

            /// <summary>
            /// 片付け・会計の仕事が板に載ってから取られるまでの最大（秒）と、
            /// そのうち長びいた数（依頼 v10 の 2）。仕事の名前 → 値
            /// </summary>
            public readonly List<KeyValuePair<string, float>> jobWaitMax =
                new List<KeyValuePair<string, float>>();
            public readonly List<KeyValuePair<string, int>> jobWaitOver =
                new List<KeyValuePair<string, int>>();

            /// <summary>長びいたと数える境目（秒）。tableStuckFreeSec と同じ 600 秒</summary>
            public float jobWaitOverSec;

            /// <summary>walk_km_per_staff・staff_util・robot_util</summary>
            public float walkKmPerStaff, staffUtil, robotUtil;

            /// <summary>
            /// 同じ通路に2台を集めないために、料理を後に回した回数（v5・A）
            /// </summary>
            public int aisleDeferrals;

            /// <summary>
            /// 店員優先の版の逃げ道：相手が止まったままで店員が回り道した回数（v5）
            /// </summary>
            public int robotStuckDetours;

            /// <summary>店員優先の版で回したか（v5）</summary>
            public bool staffFirstYield;

            /// <summary>rescues：立ち往生でスタッフを呼んだ回数（着いて動かした回数も残す）</summary>
            public int rescues, rescuesDone;
            public float rescueWaitS;

            /// <summary>pauses：一時停止の回数（自分で動き出した止まりの合計）</summary>
            public int pauses;

            /// <summary>サイトの条件で回したか（v5 3章）と、その条件の名前</summary>
            public bool siteRun;
            public string siteConditionId = "";

            /// <summary>
            /// サイトの試算の答え（unity_conditions の site_result）。
            /// 同じ summary.json に入れておけば、並べた表をあとから手で写さずに作れる
            /// </summary>
            public readonly List<KeyValuePair<string, float>> siteResult =
                new List<KeyValuePair<string, float>>();

            /// <summary>来客を受け付けた分と、全員が帰るまでの分（打ち切りなら everyoneLeft が false）</summary>
            public float arrivalsMinutes, totalMinutes;
            public bool everyoneLeft;

            // ---- 時間帯ごとの記録の確かめ（依頼 v10b の 3 A）----
            /// <summary>timeline.csv の行数と、そのときの卓・スタッフの数</summary>
            public int timelineRows, timelineTables, timelineStaff;

            /// <summary>合計が合わなかった行の数（どちらも 0 になるはず）</summary>
            public int timelineBadTableRows, timelineBadStaffRows;

            /// <summary>合わなかった行のうち、いちばん早いもの（合っていれば空）</summary>
            public string timelineFirstBad = "";
        }

        SimLike _sim;
        public void SetSimLike(SimLike s) { _sim = s; }

        public void WriteSummaryJson(string path, string runName)
        {
            // 条件ごとに行を分ける（出てきた順を保つ）
            List<string> order = new List<string>();
            Dictionary<string, List<Row>> byCondition = new Dictionary<string, List<Row>>();
            for (int i = 0; i < _rows.Count; i++)
            {
                string id = _rows[i].conditionId;
                if (!byCondition.ContainsKey(id)) { byCondition[id] = new List<Row>(); order.Add(id); }
                byCondition[id].Add(_rows[i]);
            }

            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_name\": ").Append(Str(runName)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"note\": ").Append(Str(
                "speed_mps = distance_m / (total_s - stopped_s)。" +
                "stopped_s には、動けなくなった60秒と、スタッフを待った秒数（stuck_wait_s）、" +
                "通路の入口で待った秒数も入る。" +
                "pause_s は stop_durations_s（自分で動き出した止まりだけ）の分布。" +
                "site_metrics はサイトの meta.metrics と同じ名前・同じ定義。" +
                "入店待ちの分け方もサイトの試算と同じで、" +
                "blocked_wait は片付け待ちの卓の数だけ待っている列の前から順に足した分、" +
                "full_wait は seat_wait − blocked_wait、" +
                "staff_wait は blocked_wait + guide_wait。" +
                "pay_wait は（会計が終わった時刻 − 食べ終えた時刻 − pay_s）で、" +
                "逃がし弁で卓を空けられた組は入れない。" +
                "Unity は会計の人とスタッフがレジまで歩く時間が入るので、試算より少し長く出る。" +
                "pauses と rescues は、サイト側が unity_measures の仮の値から出した数なので出どころが違う。"))
              .Append(",\n");
            if (_sim != null)
            {
                sb.Append("  \"sim_like\": {");
                sb.Append("\"dish_wait_mean\": ").Append(F(Mean(_sim.dishWaitMin))).Append(", ");
                sb.Append("\"dish_wait_p90\": ").Append(F(Percentile(_sim.dishWaitMin, 0.9f))).Append(", ");
                sb.Append("\"cold_share\": ").Append(F(Share(_sim.dishWaitMin, _sim.coldMin))).Append(", ");
                sb.Append("\"seat_wait_mean\": ").Append(F(Mean(_sim.seatWaitMin))).Append(", ");
                sb.Append("\"turn_mean\": ").Append(F(Mean(_sim.turnMin))).Append(", ");
                sb.Append("\"robot_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0
                        ? (float)_sim.robotDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", ");
                sb.Append("\"robot_dishes\": ").Append(_sim.robotDishes).Append(", ");
                sb.Append("\"staff_dishes\": ").Append(_sim.staffDishes).Append(", ");
                sb.Append("\"requeued_dishes\": ").Append(_sim.requeuedDishes).Append(", ");
                // サイトの試算 v2.7：2分（robot_first_wait_s）を過ぎて人が拾った皿
                sb.Append("\"late_staff_dishes\": ").Append(_sim.lateStaffDishes).Append(", ");
                sb.Append("\"late_staff_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0
                        ? (float)_sim.lateStaffDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", ");
                sb.Append("\"robot_first_wait_s\": ").Append(F(_sim.robotFirstWaitS)).Append(", ");
                // 譲る順番 v6：譲った回数（譲った側の種類ごと）と、角での止まり
                AppendYields(sb);
                AppendCornerStops(sb);
                sb.Append("\"groups\": ").Append(_sim.groups).Append(", ");
                sb.Append("\"people_jams\": ").Append(_sim.jamCount).Append(", ");
                sb.Append("\"robot_blocked_jams\": ").Append(_sim.robotBlockedJams).Append(", ");
                sb.Append("\"robot_blocked_jam_share\": ").Append(F(_sim.jamCount > 0
                        ? (float)_sim.robotBlockedJams / _sim.jamCount : 0f)).Append(", ");
                sb.Append("\"chair_pulls\": ").Append(_sim.chairPulls).Append(", ");
                sb.Append("\"robot_contacts\": ").Append(_sim.robotContacts).Append(", ");
                sb.Append("\"robot_yield_waits\": ").Append(_sim.robotYieldWaits).Append(", ");
                sb.Append("\"robot_yield_wait_s\": ").Append(F(_sim.robotYieldWaitSeconds)).Append(", ");
                sb.Append("\"robot_yield_detours\": ").Append(_sim.robotYieldDetours).Append(", ");
                sb.Append("\"register_crowd_max\": ").Append(_sim.registerCrowdMax).Append(", ");
                sb.Append("\"register_crowd_mean\": ").Append(F(_sim.registerCrowdMean)).Append(", ");
                sb.Append("\"register_pay_count\": ").Append(_sim.registerPayCount).Append(", ");
                sb.Append("\"register_pay_not_at_back\": ").Append(_sim.registerPayNotAtBackCount).Append(", ");
                sb.Append("\"register_pay_off_spot\": ").Append(_sim.registerPayOffSpotCount).Append(", ");
                sb.Append("\"register_pay_offset_mean_m\": ").Append(F(_sim.registerPayMeanOffsetM)).Append(", ");
                sb.Append("\"register_pay_offset_max_m\": ").Append(F(_sim.registerPayMaxOffsetM)).Append(", ");
                sb.Append("\"register_back_intruders\": ").Append(_sim.registerBackIntruderCount).Append(", ");
                sb.Append("\"register_back_intruder_s\": ").Append(F(_sim.registerBackIntruderSeconds)).Append(", ");
                sb.Append("\"drink_bar_queued\": ").Append(_sim.drinkBarQueued).Append(", ");
                sb.Append("\"drink_bar_gave_up\": ").Append(_sim.drinkBarGaveUp).Append(", ");
                sb.Append("\"drink_bar_wait_mean\": ").Append(F(Mean(_sim.drinkBarWaitMin))).Append(", ");
                sb.Append("\"drink_bar_wait_p90\": ").Append(F(Percentile(_sim.drinkBarWaitMin, 0.9f))).Append(", ");
                sb.Append("\"gave_up\": ").Append(_sim.gaveUp).Append(", ");
                sb.Append("\"warped\": ").Append(_sim.warped).Append(", ");
                sb.Append("\"table_freed\": ").Append(_sim.tableFreed).Append(", ");
                sb.Append("\"people_jam_total_s\": ").Append(F(_sim.jamTotalS)).Append(", ");
                sb.Append("\"walkers_mean\": ").Append(F(_sim.walkersMean)).Append(", ");
                sb.Append("\"measured_minutes\": ").Append(F(_sim.measuredMinutes));
                sb.Append("},\n");

                AppendSiteMetrics(sb);

                // スタッフの時間の使い方（割合も出す）
                float staffTotal = 0f;
                for (int i = 0; i < _sim.staffTime.Count; i++) staffTotal += _sim.staffTime[i].Value;
                sb.Append("  \"staff_time_s\": {");
                for (int i = 0; i < _sim.staffTime.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.staffTime[i].Key)).Append(": ").Append(F(_sim.staffTime[i].Value));
                }
                sb.Append("},\n");
                // 料理ができてから届くまでの内訳（受け渡し口で待った／運んでいた／戻されて失った）
                sb.Append("  \"dish_wait_parts\": {");
                AppendDishParts(sb, "robot", true);
                sb.Append(", ");
                AppendDishParts(sb, "staff", false);
                sb.Append(", ");
                AppendDishParts(sb, "all", null);
                sb.Append("},\n");

                sb.Append("  \"job_wait_mean_s\": {");
                for (int i = 0; i < _sim.jobWaitMean.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.jobWaitMean[i].Key)).Append(": ").Append(F(_sim.jobWaitMean[i].Value));
                }
                sb.Append("},\n");

                // 依頼 v10 の 2：仕事が板に載ってから取られるまでの最大と、長びいた数。
                // 直す前は、片付け・会計の長い待ちが逃がし弁「卓を空けた」で消えていたので、
                // ここに出てくるようになるはず
                sb.Append("  \"job_wait_max_s\": {");
                for (int i = 0; i < _sim.jobWaitMax.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.jobWaitMax[i].Key)).Append(": ").Append(F(_sim.jobWaitMax[i].Value));
                }
                sb.Append("},\n");
                sb.Append("  \"job_wait_over_count\": {");
                for (int i = 0; i < _sim.jobWaitOver.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.jobWaitOver[i].Key)).Append(": ").Append(_sim.jobWaitOver[i].Value);
                }
                sb.Append("},\n");
                sb.Append("  \"job_wait_note\": {");
                sb.Append("\"over_s\": ").Append(F(_sim.jobWaitOverSec)).Append(", ");
                sb.Append("\"pay_wait_from_pay_start_mean\": ")
                  .Append(F(Mean(_sim.payWaitFromStartMin))).Append(", ");
                sb.Append("\"note\": ").Append(Str(
                    "job_wait_* は、仕事が板に載ってからスタッフが取るまでの秒数。" +
                    "取られないまま終わった仕事（卓を空けたときに板から外した分）は入らない。" +
                    "あきらめて板に戻った仕事は、取られるたびに1回ずつ数える。" +
                    "over_s は長びいたと数える境目で、tableStuckFreeSec と同じ。" +
                    "pay_wait_from_pay_start_mean は pay_wait_mean を" +
                    "食べ終えた時刻ではなく会計に入った時刻から数えたもの（分）"));
                sb.Append("},\n");
                sb.Append("  \"staff_time_share\": {");
                for (int i = 0; i < _sim.staffTime.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.staffTime[i].Key)).Append(": ")
                      .Append(F(staffTotal > 0.01f ? _sim.staffTime[i].Value / staffTotal : 0f));
                }
                sb.Append("},\n");
            }

            // 止まり全体のまとめ（B7 の前後を並べるため）
            sb.Append("  \"stops\": {");
            sb.Append("\"count\": ").Append(_stops.Count).Append(", ");
            sb.Append("\"stuck_count\": ").Append(StuckStopCount()).Append(", ");
            sb.Append("\"partial_path_count\": ").Append(PartialPathStopCount()).Append(", ");
            sb.Append("\"stop_s_total\": ").Append(F(StopSecondsTotal()));
            sb.Append("},\n");

            // 往復のまとめ（段階Bの台帳が写す）
            AppendTripSummary(sb);

            sb.Append("  \"stop_causes\": [\n");
            AppendCauseSummary(sb);
            sb.Append("  ],\n");

            sb.Append("  \"conditions\": [\n");

            for (int i = 0; i < order.Count; i++)
            {
                List<Row> rows = byCondition[order[i]];
                AppendConditionSummary(sb, order[i], rows);
                if (i < order.Count - 1) sb.Append(',');
                sb.Append('\n');
            }

            sb.Append("  ]\n");
            sb.Append("}\n");
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        /// <summary>
        /// サイトと同じ名前・同じ定義で出す欄（v5 3章・meta.metrics）。
        /// site_result と並べるときは、この欄だけを見れば足りるようにする。
        ///
        /// 入店待ちの分け方は、サイトの試算（haizen_sim.py）と同じ:
        ///   seat_wait    店に来てから案内が始まるまで
        ///   blocked_wait そのうち、卓は空いたのに片付けが終わらず待った分
        ///                （片付け待ちの卓の数だけ、待っている列の前から順に足す。サイトの _acc と同じ）
        ///   full_wait    seat_wait − blocked_wait
        ///   guide_wait   案内の仕事が板に出てから、スタッフが取るまで
        ///   staff_wait   blocked_wait ＋ guide_wait
        ///   entry_wait   店に来てから卓に着くまで（案内の歩きと guide_s を含む）
        ///
        /// pay_wait_mean は v2.8 で足された欄（依頼 v10 の 2）:
        ///   組ごとに（会計が終わった時刻 − 食べ終えた時刻 − pay_s）の平均（分）。
        ///   逃がし弁で卓を空けられた組と、会計が終わっていない組は入れない。
        ///   pay_wait_groups は、その平均に入れた組の数
        /// </summary>
        void AppendSiteMetrics(StringBuilder sb)
        {
            sb.Append("  \"site_metrics\": {");
            sb.Append("\"entry_wait_mean\": ").Append(F(Mean(_sim.entryWaitMin))).Append(", ");
            sb.Append("\"full_wait_mean\": ").Append(F(Mean(_sim.fullWaitMin))).Append(", ");
            sb.Append("\"staff_wait_mean\": ").Append(F(Mean(_sim.staffWaitMin))).Append(", ");
            sb.Append("\"blocked_wait_mean\": ").Append(F(Mean(_sim.blockedWaitMin))).Append(", ");
            sb.Append("\"guide_wait_mean\": ").Append(F(Mean(_sim.guideWaitMin))).Append(", ");
            sb.Append("\"seat_wait_mean\": ").Append(F(Mean(_sim.seatWaitMin))).Append(", ");
            sb.Append("\"dish_wait_mean\": ").Append(F(Mean(_sim.dishWaitMin))).Append(", ");
            sb.Append("\"dish_wait_p90\": ").Append(F(Percentile(_sim.dishWaitMin, 0.9f))).Append(", ");
            sb.Append("\"cold_share\": ").Append(F(Share(_sim.dishWaitMin, _sim.coldMin))).Append(", ");
            sb.Append("\"turn_mean\": ").Append(F(Mean(_sim.turnMin))).Append(", ");
            // 依頼 v10 の 2：食べ終えてから会計が始まるまで（分）。
            // Unity はレジまで歩く時間が入るので、試算より少し長く出てよい
            sb.Append("\"pay_wait_mean\": ").Append(F(Mean(_sim.payWaitMin))).Append(", ");
            sb.Append("\"pay_wait_groups\": ").Append(_sim.payWaitMin.Count).Append(", ");
            sb.Append("\"walk_km_per_staff\": ").Append(F(_sim.walkKmPerStaff)).Append(", ");
            sb.Append("\"staff_util\": ").Append(F(_sim.staffUtil)).Append(", ");
            sb.Append("\"robot_util\": ").Append(F(_sim.robotUtil)).Append(", ");
            sb.Append("\"robot_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0
                    ? (float)_sim.robotDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", ");
            sb.Append("\"rescues\": ").Append(_sim.rescues).Append(", ");
            sb.Append("\"pauses\": ").Append(_sim.pauses).Append(", ");
            sb.Append("\"groups\": ").Append(_sim.groups);
            sb.Append("},\n");

            sb.Append("  \"site_run\": {");
            sb.Append("\"site_run\": ").Append(_sim.siteRun ? "true" : "false").Append(", ");
            sb.Append("\"condition_id\": ").Append(Str(_sim.siteConditionId)).Append(", ");
            sb.Append("\"arrivals_minutes\": ").Append(F(_sim.arrivalsMinutes)).Append(", ");
            sb.Append("\"total_minutes\": ").Append(F(_sim.totalMinutes)).Append(", ");
            sb.Append("\"everyone_left\": ").Append(_sim.everyoneLeft ? "true" : "false").Append(", ");
            sb.Append("\"aisle_deferrals\": ").Append(_sim.aisleDeferrals).Append(", ");
            sb.Append("\"staff_first_yield\": ").Append(_sim.staffFirstYield ? "true" : "false").Append(", ");
            sb.Append("\"robot_stuck_detours\": ").Append(_sim.robotStuckDetours).Append(", ");
            sb.Append("\"rescues_done\": ").Append(_sim.rescuesDone).Append(", ");
            sb.Append("\"rescue_wait_s\": ").Append(F(_sim.rescueWaitS)).Append(", ");
            // 逃がし弁（0 回に近いことを確かめる。仕組みは消さない）
            sb.Append("\"gave_up\": ").Append(_sim.gaveUp).Append(", ");
            sb.Append("\"warped\": ").Append(_sim.warped).Append(", ");
            sb.Append("\"table_freed\": ").Append(_sim.tableFreed);
            sb.Append("},\n");

            // 時間帯ごとの記録の確かめ（依頼 v10b の 3 A）。
            // bad_table_rows・bad_staff_rows はどちらも 0 になるはず
            sb.Append("  \"timeline_check\": {");
            sb.Append("\"rows\": ").Append(_sim.timelineRows).Append(", ");
            sb.Append("\"step_s\": 60, ");
            sb.Append("\"tables\": ").Append(_sim.timelineTables).Append(", ");
            sb.Append("\"staff\": ").Append(_sim.timelineStaff).Append(", ");
            sb.Append("\"bad_table_rows\": ").Append(_sim.timelineBadTableRows).Append(", ");
            sb.Append("\"bad_staff_rows\": ").Append(_sim.timelineBadStaffRows).Append(", ");
            sb.Append("\"first_bad\": ").Append(Str(_sim.timelineFirstBad)).Append(", ");
            sb.Append("\"note\": ").Append(Str(
                "timeline.csv の各行で、seated_groups + tables_free + tables_dirty_untaken + " +
                "tables_clearing が tables と同じか、staff_* の合計が staff と同じかを数えたもの。" +
                "合わない行があれば bad_* が 0 より大きくなり、Console にも出る"));
            sb.Append("},\n");

            // サイトの試算の答え（並べる表のもと）
            sb.Append("  \"site_result\": {");
            for (int i = 0; i < _sim.siteResult.Count; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(Str(_sim.siteResult[i].Key)).Append(": ").Append(F(_sim.siteResult[i].Value));
            }
            sb.Append("},\n");
        }

        /// <summary>
        /// 原因ごとの回数と秒数の合計。
        /// stuck_count は「動けなくなった」で終わった止まり（止まりの上限に達した回数）。
        /// partial_path_count は「途中までの経路」に縛られて引き直して出た止まり（B7）。
        /// この2つを、停止点を変えた2回で並べれば「椅子が原因で上限に達した回数」の差が出る。
        /// </summary>
        void AppendCauseSummary(StringBuilder sb)
        {
            string stuckLabel = ResolveLabel(ServingRobotController.StopResolve.Stuck);

            List<string> order = new List<string>();
            Dictionary<string, int> count = new Dictionary<string, int>();
            Dictionary<string, float> secs = new Dictionary<string, float>();
            Dictionary<string, float> longest = new Dictionary<string, float>();
            Dictionary<string, int> blocked = new Dictionary<string, int>();
            Dictionary<string, int> stuck = new Dictionary<string, int>();
            Dictionary<string, int> partial = new Dictionary<string, int>();
            Dictionary<string, float> noPath = new Dictionary<string, float>();

            for (int i = 0; i < _stops.Count; i++)
            {
                string c = _stops[i].cause;
                if (!count.ContainsKey(c))
                {
                    order.Add(c); count[c] = 0; secs[c] = 0f; longest[c] = 0f; blocked[c] = 0;
                    stuck[c] = 0; partial[c] = 0; noPath[c] = 0f;
                }
                count[c]++;
                secs[c] += _stops[i].durationS;
                if (_stops[i].durationS > longest[c]) longest[c] = _stops[i].durationS;
                if (_stops[i].pathBlocked) blocked[c]++;
                if (_stops[i].resolved == stuckLabel) stuck[c]++;
                if (HasPartialPathMark(_stops[i].causeDetail)) partial[c]++;
                noPath[c] += _stops[i].noPathS;
            }

            for (int i = 0; i < order.Count; i++)
            {
                string c = order[i];
                sb.Append("    {\"cause\": ").Append(Str(c))
                  .Append(", \"count\": ").Append(count[c])
                  .Append(", \"total_s\": ").Append(F(secs[c]))
                  .Append(", \"mean_s\": ").Append(F(count[c] > 0 ? secs[c] / count[c] : 0f))
                  .Append(", \"longest_s\": ").Append(F(longest[c]))
                  .Append(", \"path_blocked_count\": ").Append(blocked[c])
                  .Append(", \"stuck_count\": ").Append(stuck[c])
                  .Append(", \"partial_path_count\": ").Append(partial[c])
                  .Append(", \"no_path_total_s\": ").Append(F(noPath[c]))
                  .Append('}');
                if (i < order.Count - 1) sb.Append(',');
                sb.Append('\n');
            }
        }

        /// <summary>
        /// 届くまでの内訳を1つぶん書く。byRobot が null なら全部。
        /// 秒は「分」に直して出す（dish_wait_mean と同じ単位にそろえる）。
        /// </summary>
        void AppendDishParts(StringBuilder sb, string name, bool? byRobot)
        {
            int n = 0, requeued = 0;
            float pass = 0f, carry = 0f, requeue = 0f;

            if (_sim != null)
            {
                for (int i = 0; i < _sim.dishParts.Count; i++)
                {
                    ServiceOps.DishParts p = _sim.dishParts[i];
                    if (byRobot.HasValue && p.byRobot != byRobot.Value) continue;
                    n++;
                    pass += p.passWaitS;
                    carry += p.carryS;
                    requeue += p.requeueS;
                    requeued += p.requeued;
                }
            }

            float d = n > 0 ? n : 1f;
            sb.Append(Str(name)).Append(": {");
            sb.Append("\"dishes\": ").Append(n).Append(", ");
            sb.Append("\"pass_wait_mean_min\": ").Append(F(pass / d / 60f)).Append(", ");
            sb.Append("\"carry_mean_min\": ").Append(F(carry / d / 60f)).Append(", ");
            sb.Append("\"requeue_lost_mean_min\": ").Append(F(requeue / d / 60f)).Append(", ");
            sb.Append("\"total_mean_min\": ").Append(F((pass + carry + requeue) / d / 60f)).Append(", ");
            sb.Append("\"requeued_per_dish\": ").Append(F(requeued / d)).Append(", ");
            sb.Append("\"requeued_total\": ").Append(requeued);
            sb.Append("}");
        }

        /// <summary>この止まりが「途中までの経路」に縛られたものか（B7）</summary>
        static bool HasPartialPathMark(string causeDetail)
        {
            return !string.IsNullOrEmpty(causeDetail) &&
                   causeDetail.Contains(ServingRobotController.PartialPathMark);
        }

        /// <summary>「動けなくなった」で終わった止まりの数（止まりの上限に達した回数）</summary>
        int StuckStopCount()
        {
            string label = ResolveLabel(ServingRobotController.StopResolve.Stuck);
            int n = 0;
            for (int i = 0; i < _stops.Count; i++) if (_stops[i].resolved == label) n++;
            return n;
        }

        /// <summary>「途中までの経路」から引き直して出た止まりの数（B7）</summary>
        int PartialPathStopCount()
        {
            int n = 0;
            for (int i = 0; i < _stops.Count; i++) if (HasPartialPathMark(_stops[i].causeDetail)) n++;
            return n;
        }

        static void AppendConditionSummary(StringBuilder sb, string id, List<Row> rows)
        {
            List<float> speeds = new List<float>();
            List<float> stopped = new List<float>();
            List<float> pauses = new List<float>();
            List<float> waits = new List<float>();
            int stuckCount = 0, timeoutCount = 0, pausedCount = 0, metRobotCount = 0;
            float aisleWidth = 0f; int robots = 0; string crowd = "";

            for (int i = 0; i < rows.Count; i++)
            {
                Row r = rows[i];
                aisleWidth = r.aisleWidthM; robots = r.robots; crowd = r.crowd;

                float moving = r.totalS - r.stoppedS;
                if (moving > 0.01f) speeds.Add(r.distanceM / moving);
                stopped.Add(r.stoppedS);
                waits.Add(r.waitAisleS);
                if (r.stopDurationsS != null)
                    for (int k = 0; k < r.stopDurationsS.Length; k++) pauses.Add(r.stopDurationsS[k]);
                if (r.stuck) stuckCount++;
                if (r.timeout) timeoutCount++;
                if (r.nStops >= 1) pausedCount++;
                if (r.metRobot) metRobotCount++;
            }

            int n = rows.Count;
            sb.Append("    {");
            sb.Append("\"condition_id\": ").Append(Str(id)).Append(", ");
            sb.Append("\"aisle_width_m\": ").Append(F(aisleWidth)).Append(", ");
            sb.Append("\"crowd\": ").Append(Str(crowd)).Append(", ");
            sb.Append("\"robots\": ").Append(robots).Append(", ");
            sb.Append("\"trips\": ").Append(n).Append(", ");
            sb.Append("\"robot_speed_mps_median\": ").Append(F(Percentile(speeds, 0.5f))).Append(", ");
            sb.Append("\"stopped_s_median\": ").Append(F(Percentile(stopped, 0.5f))).Append(", ");
            sb.Append("\"stopped_s_p90\": ").Append(F(Percentile(stopped, 0.9f))).Append(", ");
            sb.Append("\"pause_s_median\": ").Append(F(Percentile(pauses, 0.5f))).Append(", ");
            sb.Append("\"pause_s_p90\": ").Append(F(Percentile(pauses, 0.9f))).Append(", ");
            sb.Append("\"pause_count\": ").Append(pauses.Count).Append(", ");
            sb.Append("\"wait_aisle_s_median\": ").Append(F(Percentile(waits, 0.5f))).Append(", ");
            sb.Append("\"stuck_rate\": ").Append(F(n > 0 ? stuckCount / (float)n : 0f)).Append(", ");
            sb.Append("\"stuck_count\": ").Append(stuckCount).Append(", ");
            sb.Append("\"timeout_rate\": ").Append(F(n > 0 ? timeoutCount / (float)n : 0f)).Append(", ");
            sb.Append("\"timeout_count\": ").Append(timeoutCount).Append(", ");
            sb.Append("\"pause_rate\": ").Append(F(n > 0 ? pausedCount / (float)n : 0f)).Append(", ");
            sb.Append("\"met_robot_rate\": ").Append(F(n > 0 ? metRobotCount / (float)n : 0f));
            sb.Append('}');
        }

        static float Mean(List<float> v)
        {
            if (v == null || v.Count == 0) return -1f;
            float s = 0f;
            for (int i = 0; i < v.Count; i++) s += v[i];
            return s / v.Count;
        }

        static float Share(List<float> v, float over)
        {
            if (v == null || v.Count == 0) return -1f;
            int n = 0;
            for (int i = 0; i < v.Count; i++) if (v[i] > over) n++;
            return (float)n / v.Count;
        }

        /// <summary>小さい順に並べて p の位置の値を返す（0〜1）。空なら -1</summary>
        public static float Percentile(List<float> values, float p)
        {
            if (values == null || values.Count == 0) return -1f;
            List<float> v = new List<float>(values);
            v.Sort();
            if (v.Count == 1) return v[0];
            float pos = Mathf.Clamp01(p) * (v.Count - 1);
            int lo = Mathf.FloorToInt(pos);
            int hi = Mathf.Min(lo + 1, v.Count - 1);
            float t = pos - lo;
            return Mathf.Lerp(v[lo], v[hi], t);
        }
    }
}
```

### Assets/ServiceSim/Editor/ServiceSimLedger.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// 段階Bの通し台帳（2026-10-23 エイイチと決めた。依頼 v7）。
    ///
    /// 実体: {kioxiaOutputRoot}/unity_ledger_v1.csv
    ///       （MASTER の保存先「service_sim/unity/」の中。新しい置き場所は作らない）
    ///
    /// 決まり:
    ///   ・1回 = 1行・**追記のみ**。既存の行は書き換えない・消さない
    ///   ・同じ出力フォルダの行を2度書かない（run_folder が照合キー）
    ///   ・**追記ガード**：実体のヘッダとこのコードの列の並びが1字でも違えば追記しない
    ///   ・止めた回・失敗した回も status を付けて1行残す
    ///
    /// 列の名前と並びを変えるときは、先にエイイチに聞く（依頼 v7 の 2）。
    /// </summary>
    public static class ServiceSimLedger
    {
        /// <summary>
        /// 台帳の版。列を足したら 1 つ上げる。
        ///   1 … 依頼 v7（49 列）
        ///   2 … 依頼 v10b（会計待ち・片付け待ちの 10 列を後ろに足した。59 列）
        /// </summary>
        public const int SchemaVersion = 2;
        public const string FileName = "unity_ledger_v1.csv";

        /// <summary>
        /// 列（この順。名前を変えるときは先にエイイチに聞く）。
        ///
        /// 足すときは **いちばん後ろ**（ledger_schema_version より後ろ）に足す。
        /// 途中に入れると前の行の列の位置がずれて、古い行が読めなくなる（依頼 v10b の 1）。
        /// 前の行は新しい列が無いまま（短い行）で置いておく。書き換えない
        /// </summary>
        public static readonly string[] Columns =
        {
            "timestamp", "run_folder", "status", "unity_cs", "layout_generation", "params_model",
            "store", "staff", "robots", "arrivals_per_h", "mode", "trays", "fragile_by", "seed",
            "staff_first_yield", "yield_order_enabled", "one_robot_per_aisle", "robot_first_wait_s",
            "everyone_left", "total_minutes", "wall_seconds",
            "entry_wait_mean", "staff_wait_mean", "dish_wait_mean", "cold_share", "robot_share",
            "staff_util", "robot_util", "walk_km_per_staff",
            "site_entry_wait_mean", "site_staff_wait_mean", "site_dish_wait_mean", "site_robot_share",
            "robot_speed_mps", "pause_rate", "pause_s_mean", "stuck_rate",
            "trips", "timeouts", "stuck_count", "stop_s_total", "corner_stop_time_share",
            "gave_up", "warped", "table_freed", "rescues",
            "late_staff_dishes", "staff_yield_share_of_staff_time",
            "ledger_schema_version",
            // ---- 依頼 v10b の 1（2026-10-25）。ここから後ろは schema 2 で足した列 ----
            // 会計待ち・片付け待ちを試算と並べるための 10 列。
            // clear_* / pay_job_* は「仕事が板に載ってから取られるまで」の秒数
            "pay_wait_mean", "site_pay_wait_mean",
            "blocked_wait_mean", "site_blocked_wait_mean",
            "turn_mean", "site_turn_mean",
            "clear_wait_mean_s", "clear_wait_max_s", "clear_wait_over_count",
            "pay_job_wait_mean_s"
        };

        public static string HeaderLine { get { return string.Join(",", Columns); } }

        /// <summary>台帳の実体の場所</summary>
        public static string PathOf(ExperimentPlan plan)
        {
            string root = plan != null && !string.IsNullOrEmpty(plan.kioxiaOutputRoot)
                ? plan.kioxiaOutputRoot
                : "/Volumes/KIOXIA/r2r2r-data/service_sim/unity";
            return Path.Combine(root, FileName);
        }

        // ---------------- 追記ガード ----------------

        /// <summary>
        /// 追記していいか。
        ///   ・実体が無い … いい（header を書いて作る）
        ///   ・1行めがコードの列と同じ … いい
        ///   ・違う … だめ（why に理由）
        /// </summary>
        public static bool CanAppend(string path, out string why)
        {
            why = "";
            if (!File.Exists(path)) return true;

            string first;
            try
            {
                using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
                    first = r.ReadLine();
            }
            catch (Exception e)
            {
                why = "台帳を読めません: " + path + "\n  " + e.Message;
                return false;
            }

            if (first == null)
            {
                why = "台帳が空です（ヘッダがありません）: " + path +
                      "\n  いちど消すか、ヘッダを書いてください";
                return false;
            }

            string have = first.TrimEnd('\r', '\n');
            if (have == HeaderLine) return true;

            why = "台帳のヘッダがコードの列と違うので追記しません（追記ガード）: " + path +
                  "\n  実体: " + have +
                  "\n  コード: " + HeaderLine +
                  "\n  列をそろえるか、エイイチに聞いてから直してください";
            return false;
        }

        /// <summary>その出力フォルダの行がもうあるか（run_folder が照合キー）</summary>
        public static bool HasRow(string path, string runFolder)
        {
            if (!File.Exists(path) || string.IsNullOrEmpty(runFolder)) return false;

            int col = IndexOf("run_folder");
            try
            {
                using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
                {
                    r.ReadLine();                       // ヘッダ
                    string line;
                    while ((line = r.ReadLine()) != null)
                    {
                        if (line.Length == 0) continue;
                        string[] f = line.Split(',');
                        if (col < f.Length && f[col] == runFolder) return true;
                    }
                }
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] 台帳を読めませんでした（照合を飛ばします）: " + e.Message);
            }
            return false;
        }

        /// <summary>もう台帳にある出力フォルダの名前をぜんぶ返す（2度書きの歯止めに使う）</summary>
        public static HashSet<string> DoneFolders(string path)
        {
            HashSet<string> done = new HashSet<string>();
            if (!File.Exists(path)) return done;

            int col = IndexOf("run_folder");
            int statusCol = IndexOf("status");
            try
            {
                using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
                {
                    r.ReadLine();
                    string line;
                    while ((line = r.ReadLine()) != null)
                    {
                        if (line.Length == 0) continue;
                        string[] f = line.Split(',');
                        if (col >= f.Length) continue;
                        // status に関係なく、1行あるものは「回した」とみなす
                        // （止めた回・失敗した回を何度も回し直さないため）
                        if (statusCol < f.Length && f[statusCol].Length == 0) continue;
                        done.Add(f[col]);
                    }
                }
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message);
            }
            return done;
        }

        /// <summary>
        /// 回した回を照らし合わせるキー（依頼 v8 の 3）。
        ///
        /// **店・ロボットの台数・乱数の種・Unity の版** でそろえる。
        /// 出力フォルダ名（日付入り）では照らし合わせない。
        ///   ・同じ版・同じ条件・同じ種の行があれば、日付が違っても回さない
        ///   ・版が違えば回す（直したあとに同じ18回をもう一度回すため）
        /// </summary>
        public static string RunKey(string store, int robots, int seed, string unityCs)
        {
            return (store ?? "") + "|r" + robots + "|s" + seed + "|" + (unityCs ?? "");
        }

        /// <summary>台帳にある回のキーをぜんぶ返す（続きから回すのに使う）</summary>
        public static HashSet<string> DoneKeys(string path)
        {
            HashSet<string> done = new HashSet<string>();
            if (!File.Exists(path)) return done;

            int cStore = IndexOf("store");
            int cRobots = IndexOf("robots");
            int cSeed = IndexOf("seed");
            int cCs = IndexOf("unity_cs");
            int cStatus = IndexOf("status");

            try
            {
                using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
                {
                    r.ReadLine();
                    string line;
                    while ((line = r.ReadLine()) != null)
                    {
                        if (line.Length == 0) continue;
                        string[] f = line.Split(',');
                        int need = Mathf.Max(Mathf.Max(cStore, cRobots), Mathf.Max(cSeed, cCs));
                        if (need >= f.Length) continue;
                        if (cStatus < f.Length && f[cStatus].Length == 0) continue;

                        int robots, seed;
                        if (!int.TryParse(f[cRobots], out robots)) continue;
                        if (!int.TryParse(f[cSeed], out seed)) continue;
                        done.Add(RunKey(f[cStore], robots, seed, f[cCs]));
                    }
                }
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message);
            }
            return done;
        }

        static int IndexOf(string name)
        {
            for (int i = 0; i < Columns.Length; i++) if (Columns[i] == name) return i;
            return -1;
        }

        // ---------------- 1行足す ----------------

        /// <summary>
        /// 1行追記する。ヘッダが合わない・同じ出力フォルダの行がもうある場合は足さない。
        /// row は列名 → 値。足りない列は空欄にする
        /// </summary>
        public static bool Append(string path, IDictionary<string, string> row, out string why)
        {
            why = "";
            if (!CanAppend(path, out why)) return false;

            string folder = Get(row, "run_folder");
            if (string.IsNullOrEmpty(folder))
            {
                why = "run_folder が空なので足しません（照合キーです）";
                return false;
            }
            if (HasRow(path, folder))
            {
                why = "もう台帳にあるので足しません（2度書かない）: " + folder;
                return false;
            }

            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < Columns.Length; i++)
            {
                if (i > 0) sb.Append(',');
                sb.Append(Clean(Get(row, Columns[i])));
            }

            try
            {
                string dir = Path.GetDirectoryName(path);
                if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);

                bool makeHeader = !File.Exists(path);
                using (StreamWriter w = new StreamWriter(path, true, new UTF8Encoding(false)))
                {
                    if (makeHeader) w.Write(HeaderLine + "\n");
                    w.Write(sb.ToString() + "\n");
                }
            }
            catch (Exception e)
            {
                why = "台帳に書けません: " + path + "\n  " + e.Message;
                return false;
            }
            return true;
        }

        static string Get(IDictionary<string, string> row, string key)
        {
            string v;
            return row != null && row.TryGetValue(key, out v) && v != null ? v : "";
        }

        /// <summary>CSV を壊さないように、カンマ・改行・引用符は取り除く（値は数と短い名前だけ）</summary>
        static string Clean(string s)
        {
            if (string.IsNullOrEmpty(s)) return "";
            s = s.Replace(',', ' ').Replace('"', ' ');
            s = s.Replace("\r", " ").Replace("\n", " ");
            return s.Trim();
        }

        // ---------------- summary.json から1行を作る ----------------

        /// <summary>数を台帳の書き方にそろえる（小数4桁まで・指数表記にしない）</summary>
        public static string Num(float v)
        {
            return v.ToString("0.####", CultureInfo.InvariantCulture);
        }

        public static string Num(int v)
        {
            return v.ToString(CultureInfo.InvariantCulture);
        }

        public static string Flag(bool v) { return v ? "1" : "0"; }

        /// <summary>summary.json を読む（読めなければ null）</summary>
        public static JsonValue ReadSummary(string runDir)
        {
            string p = Path.Combine(runDir, "summary.json");
            if (!File.Exists(p)) return null;
            try
            {
                string err;
                JsonValue v = JsonValue.Parse(File.ReadAllText(p), out err);
                if (v == null || !v.IsObject)
                {
                    Debug.LogWarning("[ServiceSim] summary.json を読めません: " + p + "\n  " + err);
                    return null;
                }
                return v;
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] summary.json を読めません: " + p + "\n  " + e.Message);
                return null;
            }
        }
    }
}
```

### Assets/ServiceSim/Editor/ServiceSimStageB.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
// System.Diagnostics をまるごと using すると Debug が UnityEngine.Debug と
// ぶつかるので、要る型だけ別名で入れる
using Process = System.Diagnostics.Process;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// 段階B（依頼 v7。2026-10-23）：18回まとめて回して、台帳に1行ずつ残す。
    ///
    /// 回すもの:
    ///   サイトの条件 6 つ（③ fam_p1・⑦ fam_p5 × ロボット 0・1・2 台）× 種 0・1・2 ＝ 18 回。
    ///   決まりは固定版のまま。条件ごとに .asset へ入れた値は、ExperimentRunner と SiteValues が
    ///   回し終わりに必ず元へ戻す（途中で止めても戻す）。
    ///
    /// 回す順番:
    ///   ③ロボット2台 種0 → ⑦ロボット2台 種0 → 残りを条件・種の順。
    ///   **最初の2回で失敗したら、残りは回さずに止める**。
    ///
    /// 回しっぱなしにするための作り:
    ///   ・1回ごとに Play を出入りする（前の回の人・ロボット・NavMesh を持ち越さない）。
    ///     Play をまたいで消えないよう、進みは EditorPrefs に置く
    ///   ・1回の実時間の上限は ExperimentRunner.maxWallSecondsPerRun（40 分）。
    ///     超えた回は書き出して stopped で台帳に残し、次へ進む
    ///   ・回しているあいだは caffeinate で Mac のスリープを止める（終わったら戻す）
    ///   ・途中で止めても、もう一度押せば **台帳を見て** まだ回していない回から続ける
    ///
    /// シミュレーションの動きは1行も変えない。入れたのは「まとめて回す仕組み」と
    /// 「台帳への書き出し」だけ。
    /// </summary>
    public static class ServiceSimStageB
    {
        const string P1Path = "Assets/ServiceSim/Store/StoreLayout_p1_efficient_corner.asset";
        const string P5Path = "Assets/ServiceSim/Store/StoreLayout_p5_crammed.asset";

        /// <summary>1回の実時間の上限（秒）。依頼 v7 の 3</summary>
        const float MaxWallSecondsPerRun = 40f * 60f;

        /// <summary>最初の何回かが失敗したら、残りを回さずに止める</summary>
        const int FailFastRuns = 2;

        const string LogFileName = "run_all_log.txt";

        /// <summary>EditorPrefs にためるログの区切り（1文字。行の中に出ない字を使う）</summary>
        const char LogSep = '\u001f';

        // ---- Play をまたいで持つ進み（EditorPrefs）----
        const string KeyActive = "ServiceSim.StageB.active";
        const string KeyIndex = "ServiceSim.StageB.index";
        const string KeyConds = "ServiceSim.StageB.conds";     // "2:0;5:0;..."
        const string KeyFolders = "ServiceSim.StageB.folders";  // ";" 区切り
        const string KeyUnityCs = "ServiceSim.StageB.unityCs";
        const string KeyPending = "ServiceSim.StageB.pending";
        const string KeyLog = "ServiceSim.StageB.log";
        const string KeyRunStart = "ServiceSim.StageB.runStart";
        const string KeyCaffeinate = "ServiceSim.StageB.caffeinate";
        const string KeyBatchStart = "ServiceSim.StageB.batchStart";

        // ---------------- メニュー ----------------

        [MenuItem("ServiceSim/Stage B/Run All（18回）", false, 200)]
        public static void RunAll()
        {
            ExperimentRunner runner; ExperimentPlan plan;
            if (!ServiceSimRunSupport.Prepare(out runner, out plan)) return;

            // --- 押す前の確かめ（合わなければ回し始めない）---
            if (!CheckBeforeRun(plan, out string why))
            {
                Debug.LogError("[ServiceSim] 段階B：回し始めません\n" + why);
                return;
            }

            SimParams sp = LoadParams();
            if (sp == null) return;
            if (sp.Conditions.Count != 6)
            {
                Debug.LogError("[ServiceSim] 段階B：unity_conditions が 6 件ではありません（" +
                               sp.Conditions.Count + " 件）。params を確かめてください");
                return;
            }

            // --- 18回の並びを作る ---
            List<int> condIdx; List<int> seeds;
            BuildOrder(out condIdx, out seeds);

            string ledger = ServiceSimLedger.PathOf(plan);
            string day = DateTime.Now.ToString("yyyyMMdd");

            // --- 回し始めの版（照合キーに入れるので先に読む。依頼 v8 の 3）---
            string cs, pendingNote;
            ReadChangeset(out cs, out pendingNote);

            // 照合は「店・台数・種・版」。出力フォルダ名（日付入り）では照らし合わせない
            HashSet<string> done = ServiceSimLedger.DoneKeys(ledger);

            List<string> conds = new List<string>();
            List<string> folders = new List<string>();
            int skipped = 0;
            for (int i = 0; i < condIdx.Count; i++)
            {
                SimParams.Condition c = sp.ConditionAt(condIdx[i]);
                if (done.Contains(ServiceSimLedger.RunKey(c.store, c.robots, seeds[i], cs)))
                { skipped++; continue; }
                conds.Add(condIdx[i] + ":" + seeds[i]);
                folders.Add(FreeFolderName(plan, day, cs, c, seeds[i]));
            }

            if (conds.Count == 0)
            {
                Debug.Log("[ServiceSim] 段階B：この版（" + cs + "）の18回はぜんぶ台帳にあります（" +
                          skipped + " 回を飛ばしました）。" +
                          "\n  やり直すときは台帳の行を見て、エイイチに相談してください" +
                          "\n  台帳: " + ledger);
                return;
            }

            EditorPrefs.SetBool(KeyActive, true);
            EditorPrefs.SetInt(KeyIndex, 0);
            EditorPrefs.SetString(KeyConds, string.Join(";", conds.ToArray()));
            EditorPrefs.SetString(KeyFolders, string.Join(";", folders.ToArray()));
            EditorPrefs.SetString(KeyUnityCs, cs);
            EditorPrefs.SetString(KeyPending, pendingNote);
            EditorPrefs.SetString(KeyLog, "");
            EditorPrefs.SetString(KeyBatchStart, day);

            StartCaffeinate();

            Debug.Log("[ServiceSim] 段階B：" + conds.Count + " / 18 回を回します" +
                      (skipped > 0 ? "（台帳にある " + skipped + " 回は飛ばします）" : "") +
                      "\n  Unity の版: " + cs + (pendingNote.Length > 0 ? "（" + pendingNote + "）" : "") +
                      "\n  台帳: " + ledger +
                      "\n  1回の実時間の上限: " + (MaxWallSecondsPerRun / 60f).ToString("0") + " 分" +
                      "\n  1回ごとに Play を出入りします。止めたいときは、Play を抜けたところで" +
                      " ServiceSim/Stage B/いまの回で止める を押してください");

            StartNext();
        }

        [MenuItem("ServiceSim/Stage B/押す前の確かめ（回さない）", false, 201)]
        public static void CheckOnly()
        {
            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);
            if (plan == null) { Debug.LogError("[ServiceSim] ExperimentPlan が見つかりません"); return; }

            string why;
            bool ok = CheckBeforeRun(plan, out why);
            string ledger = ServiceSimLedger.PathOf(plan);
            HashSet<string> doneFolders = ServiceSimLedger.DoneFolders(ledger);

            StringBuilder sb = new StringBuilder();
            sb.AppendLine("[ServiceSim] 段階B：押す前の確かめ " + (ok ? "→ 回せます" : "→ 回せません"));
            if (!ok) sb.AppendLine(why);
            sb.AppendLine("  台帳: " + ledger);
            sb.AppendLine("  台帳にある回（ぜんぶの版）: " + doneFolders.Count + " 件");
            sb.AppendLine("  列（" + ServiceSimLedger.Columns.Length + " 列・台帳の版 " +
                          ServiceSimLedger.SchemaVersion + "）: " + ServiceSimLedger.HeaderLine);

            // --- 試算の版・店の形・レジの数（依頼 v10b の 3 B 2）---
            SimParams sp = LoadParams();
            if (sp == null)
            {
                sb.AppendLine("  試算の値を読めませんでした（回せません）");
                Debug.LogError(sb.ToString().TrimEnd());
                return;
            }
            sb.AppendLine("  試算の版 (meta.model): " + sp.Model +
                          " / 店の形の版 (meta.layout_generation): " + sp.LayoutGeneration);
            sb.AppendLine("  条件: " + sp.Conditions.Count + " 件 / レジ: " + RegistersNote(sp));
            if (sp.Problems.Count > 0)
            {
                ok = false;
                sb.AppendLine("  合わないもの: " + sp.Problems.Count + " 件");
                for (int i = 0; i < sp.Problems.Count; i++)
                    sb.AppendLine("    - " + sp.Problems[i]);
            }
            else sb.AppendLine("  合わないもの: なし");

            // --- 18回が「済」か「まだ」か（この版での照合。Run All と同じ見方）---
            string cs, pendingNote;
            ReadChangeset(out cs, out pendingNote);
            HashSet<string> doneKeys = ServiceSimLedger.DoneKeys(ledger);

            List<int> condIdx; List<int> seeds;
            BuildOrder(out condIdx, out seeds);

            int todo = 0;
            StringBuilder list = new StringBuilder();
            for (int i = 0; i < condIdx.Count; i++)
            {
                SimParams.Condition c = sp.ConditionAt(condIdx[i]);
                if (c == null) continue;
                bool had = doneKeys.Contains(
                    ServiceSimLedger.RunKey(c.store, c.robots, seeds[i], cs));
                if (!had) todo++;
                list.AppendLine("    " + (i + 1).ToString("00") + ". " + c.store +
                                "・ロボット " + c.robots + " 台・種 " + seeds[i] +
                                " … " + (had ? "済（台帳にあるので飛ばします）" : "まだ"));
            }

            sb.AppendLine("  Unity の版: " + cs +
                          (pendingNote.Length > 0 ? "（" + pendingNote + "）" : "（保留なし）"));
            sb.AppendLine("  この版の18回: まだ " + todo + " 回 / 済 " + (condIdx.Count - todo) + " 回");
            sb.Append(list.ToString().TrimEnd('\n'));

            if (ok) Debug.Log(sb.ToString().TrimEnd()); else Debug.LogError(sb.ToString().TrimEnd());
        }

        /// <summary>6条件のレジの数を短く書く（ぜんぶ同じなら1つだけ）</summary>
        static string RegistersNote(SimParams sp)
        {
            if (sp.Conditions.Count == 0) return "（条件なし）";
            int first = sp.Conditions[0].registers;
            bool same = true;
            for (int i = 1; i < sp.Conditions.Count; i++)
                if (sp.Conditions[i].registers != first) { same = false; break; }
            if (same) return first + " つ（6条件とも同じ）";

            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < sp.Conditions.Count; i++)
            {
                if (i > 0) sb.Append(" / ");
                sb.Append(sp.Conditions[i].registers);
            }
            return "ばらばら: " + sb;
        }

        [MenuItem("ServiceSim/Stage B/いまの回で止める", false, 202)]
        public static void StopBatch()
        {
            if (!EditorPrefs.GetBool(KeyActive, false))
            {
                Debug.Log("[ServiceSim] 段階B：いま回していません");
                return;
            }
            Finish("エイイチが止めました");
        }

        [MenuItem("ServiceSim/Stage B/台帳を開く", false, 210)]
        public static void OpenLedger()
        {
            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);
            if (plan == null) return;
            string p = ServiceSimLedger.PathOf(plan);
            if (!File.Exists(p)) { Debug.LogWarning("[ServiceSim] まだありません: " + p); return; }
            EditorUtility.RevealInFinder(p);
        }

        // ---------------- 押す前の確かめ ----------------

        static bool CheckBeforeRun(ExperimentPlan plan, out string why)
        {
            StringBuilder sb = new StringBuilder();

            if (!Directory.Exists(plan.kioxiaVolumePath))
                sb.AppendLine("  ・KIOXIA がつながっていません: " + plan.kioxiaVolumePath);

            if (!Directory.Exists(plan.kioxiaOutputRoot))
                sb.AppendLine("  ・置き場所がありません: " + plan.kioxiaOutputRoot +
                              "（MASTER の保存先です。新しく作らずに確かめてください）");

            string ledger = ServiceSimLedger.PathOf(plan);
            string headerWhy;
            if (!ServiceSimLedger.CanAppend(ledger, out headerWhy))
                sb.AppendLine("  ・" + headerWhy.Replace("\n", "\n  "));

            StoreBuilder builder = UnityEngine.Object.FindObjectOfType<StoreBuilder>();
            NavMeshBaker baker = UnityEngine.Object.FindObjectOfType<NavMeshBaker>();
            if (builder == null || baker == null)
                sb.AppendLine("  ・StoreBuilder / NavMeshBaker が見つかりません。ServiceSim/Setup を実行してください");

            why = sb.ToString().TrimEnd();
            return why.Length == 0;
        }

        // ---------------- 18回の並び ----------------

        /// <summary>
        /// ③ロボット2台 種0 → ⑦ロボット2台 種0 → 残りを条件・種の順。
        /// 条件の番号は unity_conditions の並び（0..5）
        /// </summary>
        static void BuildOrder(out List<int> conds, out List<int> seeds)
        {
            conds = new List<int>();
            seeds = new List<int>();

            // 最初の2回（この2回で失敗したら残りを回さない）
            Add(conds, seeds, 2, 0);   // ③ ロボット2台 種0
            Add(conds, seeds, 5, 0);   // ⑦ ロボット2台 種0

            int[] allSeeds = { 0, 1, 2 };
            for (int c = 0; c < 6; c++)
                for (int s = 0; s < allSeeds.Length; s++)
                {
                    if (c == 2 && allSeeds[s] == 0) continue;   // もう入れた
                    if (c == 5 && allSeeds[s] == 0) continue;
                    Add(conds, seeds, c, allSeeds[s]);
                }
        }

        static void Add(List<int> conds, List<int> seeds, int c, int s)
        {
            conds.Add(c);
            seeds.Add(s);
        }

        /// <summary>
        /// 出力フォルダの名前（依頼 v8 の 3）。
        /// {日付}_stageB_cs{版}_{店}_r{台数}_s{種}。
        /// 版が分かるようにしておき、照合は台帳のキー（店・台数・種・版）で行う
        /// </summary>
        static string FolderName(string day, string cs, SimParams.Condition c, int seed)
        {
            // "cs:139" の ":" はフォルダ名に向かないので外す
            string ver = (cs ?? "").Replace("cs:", "").Replace(":", "").Replace("（", "").Replace("）", "");
            if (ver.Length == 0) ver = "unknown";
            return day + "_stageB_cs" + ver + "_" + c.store + "_r" + c.robots + "_s" + seed;
        }

        /// <summary>
        /// 書き出す先が既にあるときは上書きせず、末尾に _2・_3 … を付けた
        /// 新しいフォルダ名を返す（依頼 v8 の 3）
        /// </summary>
        static string FreeFolderName(ExperimentPlan plan, string day, string cs,
                                     SimParams.Condition c, int seed)
        {
            string baseName = FolderName(day, cs, c, seed);
            string name = baseName;
            for (int n = 2; n < 100; n++)
            {
                if (!Directory.Exists(Path.Combine(plan.kioxiaOutputRoot, name))) return name;
                name = baseName + "_" + n;
            }
            return name;
        }

        // ---------------- 1回ずつ回す ----------------

        static void StartNext()
        {
            if (!EditorPrefs.GetBool(KeyActive, false)) return;

            string[] conds = Split(EditorPrefs.GetString(KeyConds, ""));
            string[] folders = Split(EditorPrefs.GetString(KeyFolders, ""));
            int index = EditorPrefs.GetInt(KeyIndex, 0);

            if (index >= conds.Length || index >= folders.Length) { Finish("ぜんぶ終わりました"); return; }

            ExperimentRunner runner; ExperimentPlan plan;
            if (!ServiceSimRunSupport.Prepare(out runner, out plan))
            {
                Finish("Play の下ごしらえができませんでした");
                return;
            }

            SimParams sp = LoadParams();
            if (sp == null) { Finish("params を読めませんでした"); return; }
            if (runner.simParams == null) runner.simParams = sp;

            string[] parts = conds[index].Split(':');
            int condIndex = int.Parse(parts[0], CultureInfo.InvariantCulture);
            int seed = int.Parse(parts[1], CultureInfo.InvariantCulture);

            SimParams.Condition cond = sp.ConditionAt(condIndex);
            if (cond == null) { Finish("条件 " + condIndex + " がありません"); return; }

            string assetPath = cond.store == "fam_p1" ? P1Path : cond.store == "fam_p5" ? P5Path : null;
            JsonStoreLayout layout = assetPath != null
                ? AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(assetPath) : null;
            if (layout == null) { Finish("店の形が見つかりません: " + cond.store); return; }

            StoreBuilder builder = UnityEngine.Object.FindObjectOfType<StoreBuilder>();
            NavMeshBaker baker = UnityEngine.Object.FindObjectOfType<NavMeshBaker>();
            if (builder == null || baker == null) { Finish("StoreBuilder / NavMeshBaker がありません"); return; }

            builder.layout = layout;
            baker.layout = layout;
            EditorUtility.SetDirty(builder);
            EditorUtility.SetDirty(baker);

            ExperimentRunner.Job job = new ExperimentRunner.Job();
            job.siteRun = true;
            job.conditionIndex = condIndex;
            job.storeKey = cond.store;
            job.robots = cond.robots;
            job.seed = seed;
            job.aisleWidthIndex = layout.aisleWidthIndex;
            job.crowd = CrowdLevel.Normal;
            job.applyDeliveryOffset = false;
            job.folder = Path.Combine(plan.kioxiaOutputRoot, folders[index]);

            runner.SetSingleJob(job);
            runner.timeMode = ExperimentRunner.TimeMode.FixedStep;
            runner.captureDeltaTimeOverride = 0f;
            runner.maxWallSecondsPerRun = MaxWallSecondsPerRun;
            // 回し始めの版と、保留中の変更があったかを run_info に残す（依頼 v7 の 2）
            runner.unityCs = EditorPrefs.GetString(KeyUnityCs, "");
            runner.pendingNote = EditorPrefs.GetString(KeyPending, "");
            runner.runLabel = "stageB";
            runner.viewMode = false;
            runner.runOnPlay = true;
            EditorUtility.SetDirty(runner);
            EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());

            EditorPrefs.SetString(KeyRunStart, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture));

            Debug.Log("[ServiceSim] 段階B " + (index + 1) + "/" + conds.Length + " を回します" +
                      "\n  条件 " + condIndex + "：" + cond.Id + "（" + cond.store +
                      "・ロボット " + cond.robots + " 台）/ 種 " + seed +
                      "\n  置き場所: " + job.folder);

            EditorApplication.EnterPlaymode();
        }

        /// <summary>Play を抜けたら、その回を台帳に足して次へ</summary>
        [InitializeOnLoadMethod]
        static void Hook()
        {
            EditorApplication.playModeStateChanged -= OnPlayModeChanged;
            EditorApplication.playModeStateChanged += OnPlayModeChanged;
        }

        static void OnPlayModeChanged(PlayModeStateChange state)
        {
            if (state != PlayModeStateChange.EnteredEditMode) return;
            if (!EditorPrefs.GetBool(KeyActive, false)) return;

            // Play を抜けた直後にそのまま Play へ入れないので、1 コマ置く
            EditorApplication.delayCall += AfterRun;
        }

        static void AfterRun()
        {
            if (!EditorPrefs.GetBool(KeyActive, false)) return;

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);
            if (plan == null) { Finish("ExperimentPlan が見つかりません"); return; }

            string[] conds = Split(EditorPrefs.GetString(KeyConds, ""));
            string[] folders = Split(EditorPrefs.GetString(KeyFolders, ""));
            int index = EditorPrefs.GetInt(KeyIndex, 0);
            if (index >= folders.Length) { Finish("ぜんぶ終わりました"); return; }

            string folder = folders[index];
            string runDir = Path.Combine(plan.kioxiaOutputRoot, folder);

            float wallS = 0f;
            long ticks;
            if (long.TryParse(EditorPrefs.GetString(KeyRunStart, "0"),
                              NumberStyles.Integer, CultureInfo.InvariantCulture, out ticks) && ticks > 0)
                wallS = (float)(DateTime.UtcNow - new DateTime(ticks, DateTimeKind.Utc)).TotalSeconds;

            string status;
            string line = WriteLedgerRow(plan, conds[index], folder, runDir, wallS, out status);

            // ログに1行ためる
            string log = EditorPrefs.GetString(KeyLog, "");
            EditorPrefs.SetString(KeyLog, log.Length > 0 ? log + LogSep + line : line);

            Debug.Log("[ServiceSim] 段階B " + (index + 1) + "/" + conds.Length + " おわり: " + line);

            // 最初の2回で失敗したら、残りは回さない
            if (status == "failed" && index + 1 <= FailFastRuns)
            {
                Finish("最初の " + FailFastRuns + " 回で失敗したので、残りは回しません（" + folder + "）");
                return;
            }

            EditorPrefs.SetInt(KeyIndex, index + 1);
            if (index + 1 >= conds.Length) { Finish("ぜんぶ終わりました"); return; }

            StartNext();
        }

        // ---------------- 台帳に1行 ----------------

        /// <summary>
        /// その回の summary.json / run_info.json から台帳の1行を作って足す。
        /// 戻り値はログ用の短い1行。status も返す
        /// </summary>
        static string WriteLedgerRow(ExperimentPlan plan, string condSeed, string folder,
                                     string runDir, float wallS, out string status)
        {
            SimParams sp = LoadParams();
            string[] parts = condSeed.Split(':');
            int condIndex = int.Parse(parts[0], CultureInfo.InvariantCulture);
            int seed = int.Parse(parts[1], CultureInfo.InvariantCulture);
            SimParams.Condition cond = sp != null ? sp.ConditionAt(condIndex) : null;

            JsonValue sum = ServiceSimLedger.ReadSummary(runDir);
            JsonValue info = ReadJson(Path.Combine(runDir, "run_info.json"));

            bool stoppedEarly = info != null && info["stopped_early"].B(false);
            status = sum == null ? "failed" : (stoppedEarly ? "stopped" : "completed");

            RobotSpec rs = AssetDatabase.LoadAssetAtPath<RobotSpec>(ServiceSimSetup.RobotSpecPath);
            PeopleSpec ps = AssetDatabase.LoadAssetAtPath<PeopleSpec>(ServiceSimSetup.PeopleSpecPath);

            JsonValue simLike = sum != null ? sum["sim_like"] : null;
            JsonValue siteM = sum != null ? sum["site_metrics"] : null;
            JsonValue siteRun = sum != null ? sum["site_run"] : null;
            JsonValue trips = sum != null ? sum["trips"] : null;
            JsonValue stops = sum != null ? sum["stops"] : null;

            Dictionary<string, string> row = new Dictionary<string, string>();
            row["timestamp"] = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss");
            row["run_folder"] = folder;
            row["status"] = status;
            row["unity_cs"] = EditorPrefs.GetString(KeyUnityCs, "");
            row["layout_generation"] = sp != null ? sp.LayoutGeneration : "";
            row["params_model"] = sp != null ? sp.Model : "";

            if (cond != null)
            {
                row["store"] = cond.store;
                row["staff"] = ServiceSimLedger.Num(cond.staff);
                row["robots"] = ServiceSimLedger.Num(cond.robots);
                row["arrivals_per_h"] = ServiceSimLedger.Num(cond.arrivalsPerHour);
                row["mode"] = cond.mode;
                row["trays"] = ServiceSimLedger.Num(cond.trays);
                row["fragile_by"] = cond.fragileBy;
            }
            row["seed"] = ServiceSimLedger.Num(seed);

            // 決まり（回したときの設定）。run_info にあればそれを、無ければ .asset から
            row["staff_first_yield"] = info != null && !info["staff_first_yield"].IsNull
                ? ServiceSimLedger.Flag(info["staff_first_yield"].B(false))
                : ServiceSimLedger.Flag(ps != null && ps.staffFirstYield);
            row["yield_order_enabled"] = info != null && !info["yield_order_enabled"].IsNull
                ? ServiceSimLedger.Flag(info["yield_order_enabled"].B(false))
                : ServiceSimLedger.Flag(rs != null && rs.yieldOrderEnabled);
            row["one_robot_per_aisle"] = info != null && !info["one_robot_per_aisle"].IsNull
                ? ServiceSimLedger.Flag(info["one_robot_per_aisle"].B(false))
                : ServiceSimLedger.Flag(rs != null && rs.oneRobotPerAisle);
            row["robot_first_wait_s"] = Val(simLike, "robot_first_wait_s");

            row["everyone_left"] = siteRun != null && !siteRun["everyone_left"].IsNull
                ? ServiceSimLedger.Flag(siteRun["everyone_left"].B(false)) : "";
            row["total_minutes"] = Val(siteRun, "total_minutes");
            row["wall_seconds"] = info != null && !info["wall_seconds"].IsNull
                ? Val(info, "wall_seconds") : ServiceSimLedger.Num(wallS);

            row["entry_wait_mean"] = Val(siteM, "entry_wait_mean");
            row["staff_wait_mean"] = Val(siteM, "staff_wait_mean");
            row["dish_wait_mean"] = Val(siteM, "dish_wait_mean");
            row["cold_share"] = Val(siteM, "cold_share");
            row["robot_share"] = Val(siteM, "robot_share");
            row["staff_util"] = Val(siteM, "staff_util");
            row["robot_util"] = Val(siteM, "robot_util");
            row["walk_km_per_staff"] = Val(siteM, "walk_km_per_staff");

            // サイトの試算（同じ条件の site_result）
            if (cond != null)
            {
                row["site_entry_wait_mean"] = Site(cond, "entry_wait_mean");
                row["site_staff_wait_mean"] = Site(cond, "staff_wait_mean");
                row["site_dish_wait_mean"] = Site(cond, "dish_wait_mean");
                row["site_robot_share"] = Site(cond, "robot_share");
            }

            // サイトの unity_measures と同じ意味で Unity が測った値。
            // pause_rate・stuck_rate は hop_m（role=site_only）が要るので空欄にする
            row["robot_speed_mps"] = Val(trips, "robot_speed_mps");
            row["pause_rate"] = "";
            row["pause_s_mean"] = Val(trips, "pause_s_mean");
            row["stuck_rate"] = "";

            row["trips"] = Val(trips, "trips");
            row["timeouts"] = Val(trips, "timeouts");
            row["stuck_count"] = Val(stops, "stuck_count");
            row["stop_s_total"] = Val(stops, "stop_s_total");
            row["corner_stop_time_share"] = Val(simLike, "corner_stop_time_share");

            row["gave_up"] = Val(simLike, "gave_up");
            row["warped"] = Val(simLike, "warped");
            row["table_freed"] = Val(simLike, "table_freed");
            row["rescues"] = Val(siteM, "rescues");

            row["late_staff_dishes"] = Val(simLike, "late_staff_dishes");
            row["staff_yield_share_of_staff_time"] = Val(simLike, "staff_yield_share_of_staff_time");

            row["ledger_schema_version"] = ServiceSimLedger.Num(ServiceSimLedger.SchemaVersion);

            // ---- 依頼 v10b の 1：会計待ち・片付け待ちを試算と並べる（schema 2）----
            // 詰まる場所が試算（レジ）と Unity（片付け）で違うので、
            // 会計待ち・片付け待ちの卓・卓が使えるまでを、Unity とサイトの両方で並べる
            row["pay_wait_mean"] = Val(siteM, "pay_wait_mean");
            row["blocked_wait_mean"] = Val(siteM, "blocked_wait_mean");
            row["turn_mean"] = Val(siteM, "turn_mean");
            if (cond != null)
            {
                row["site_pay_wait_mean"] = Site(cond, "pay_wait_mean");
                row["site_blocked_wait_mean"] = Site(cond, "blocked_wait_mean");
                row["site_turn_mean"] = Site(cond, "turn_mean");
            }

            // 仕事が板に載ってから取られるまで（依頼 v10 の 2 で summary.json に入れた欄）。
            // 仕事の名前は StaffAgent.JobKindName と同じものを使う（字を手で書かない）
            JsonValue jobMean = sum != null ? sum["job_wait_mean_s"] : null;
            JsonValue jobMax = sum != null ? sum["job_wait_max_s"] : null;
            JsonValue jobOver = sum != null ? sum["job_wait_over_count"] : null;
            string clearName = StaffAgent.JobKindName(StaffJobKind.Clear);
            string payName = StaffAgent.JobKindName(StaffJobKind.Pay);
            row["clear_wait_mean_s"] = Val(jobMean, clearName);
            row["clear_wait_max_s"] = Val(jobMax, clearName);
            // 600 秒を超えた片付けが1件も無いときは、summary.json にその名前が出ないので 0 を書く
            row["clear_wait_over_count"] = ValOrZero(jobOver, clearName);
            row["pay_job_wait_mean_s"] = Val(jobMean, payName);

            string ledger = ServiceSimLedger.PathOf(plan);
            string why;
            if (!ServiceSimLedger.Append(ledger, row, out why))
                Debug.LogWarning("[ServiceSim] 台帳に足しませんでした: " + why);

            return folder + " / " + status +
                   " / 実時間 " + (wallS / 60f).ToString("0.0") + " 分" +
                   " / 入店待ち " + Or(row, "entry_wait_mean") + " 分" +
                   " / ロボットが運んだ割合 " + Or(row, "robot_share");
        }

        static string Val(JsonValue v, string key)
        {
            if (v == null || v.IsNull) return "";
            JsonValue x = v[key];
            if (x.IsNull) return "";
            return ServiceSimLedger.Num(x.F(0f));
        }

        /// <summary>
        /// 数を返すが、入れ物はあって名前が無いときは 0 を返す（空欄にしない）。
        /// job_wait_over_count は「1件も無い」と「読めなかった」を分けたいので使う
        /// </summary>
        static string ValOrZero(JsonValue v, string key)
        {
            if (v == null || v.IsNull) return "";
            JsonValue x = v[key];
            return ServiceSimLedger.Num(x.IsNull ? 0f : x.F(0f));
        }

        static string Site(SimParams.Condition c, string key)
        {
            float f;
            return c != null && c.siteResult.TryGetValue(key, out f) ? ServiceSimLedger.Num(f) : "";
        }

        static string Or(Dictionary<string, string> row, string key)
        {
            string v;
            return row.TryGetValue(key, out v) && v.Length > 0 ? v : "-";
        }

        static JsonValue ReadJson(string path)
        {
            if (!File.Exists(path)) return null;
            try
            {
                string err;
                JsonValue v = JsonValue.Parse(File.ReadAllText(path), out err);
                return v != null && v.IsObject ? v : null;
            }
            catch (Exception) { return null; }
        }

        // ---------------- 終わり ----------------

        static void Finish(string whyDone)
        {
            string[] conds = Split(EditorPrefs.GetString(KeyConds, ""));
            int index = EditorPrefs.GetInt(KeyIndex, 0);
            string log = EditorPrefs.GetString(KeyLog, "");

            StopCaffeinate();

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);

            StringBuilder sb = new StringBuilder();
            sb.AppendLine("[ServiceSim] 段階B：" + whyDone);
            sb.AppendLine("  回した: " + index + " / " + conds.Length + " 回");
            sb.AppendLine("  台帳: " + (plan != null ? ServiceSimLedger.PathOf(plan) : "-"));
            sb.AppendLine("  一覧（出力フォルダ / status / 実時間 / 入店待ち / ロボットが運んだ割合）:");
            string[] lines = log.Length > 0
                ? log.Split(LogSep) : new string[0];
            for (int i = 0; i < lines.Length; i++) sb.AppendLine("    " + (i + 1) + ". " + lines[i]);

            string text = sb.ToString().TrimEnd();
            Debug.Log(text);

            // run_all_log.txt（service_sim/unity/ の中）
            if (plan != null && Directory.Exists(plan.kioxiaOutputRoot))
            {
                try
                {
                    string p = Path.Combine(plan.kioxiaOutputRoot, LogFileName);
                    string head = "=== " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") +
                                  " 段階B（Unity " + EditorPrefs.GetString(KeyUnityCs, "") + "）===\n";
                    File.AppendAllText(p, head + text + "\n\n", new UTF8Encoding(false));
                    Debug.Log("[ServiceSim] 一覧を書きました: " + p);
                }
                catch (Exception e)
                {
                    Debug.LogWarning("[ServiceSim] run_all_log.txt を書けませんでした: " + e.Message);
                }
            }

            EditorPrefs.SetBool(KeyActive, false);
            EditorPrefs.DeleteKey(KeyConds);
            EditorPrefs.DeleteKey(KeyFolders);
            EditorPrefs.DeleteKey(KeyIndex);
            EditorPrefs.DeleteKey(KeyLog);
            EditorPrefs.DeleteKey(KeyRunStart);
        }

        // ---------------- スリープを止める ----------------

        static void StartCaffeinate()
        {
            StopCaffeinate();
            try
            {
                Process p = new Process();
                p.StartInfo.FileName = "/usr/bin/caffeinate";
                p.StartInfo.Arguments = "-dimsu";
                p.StartInfo.UseShellExecute = false;
                p.StartInfo.CreateNoWindow = true;
                p.Start();
                EditorPrefs.SetInt(KeyCaffeinate, p.Id);
                Debug.Log("[ServiceSim] スリープを止めました（caffeinate pid " + p.Id + "）");
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] caffeinate を起動できませんでした（スリープしないよう手で設定してください）: "
                                 + e.Message);
            }
        }

        static void StopCaffeinate()
        {
            int pid = EditorPrefs.GetInt(KeyCaffeinate, 0);
            if (pid <= 0) return;
            EditorPrefs.DeleteKey(KeyCaffeinate);
            try
            {
                Process p = Process.GetProcessById(pid);
                if (p != null && !p.HasExited) { p.Kill(); Debug.Log("[ServiceSim] スリープの設定を戻しました"); }
            }
            catch (Exception)
            {
                // もう終わっている
            }
        }

        // ---------------- 小さな道具 ----------------

        /// <summary>
        /// 回し始めの changeset 番号と、保留中の変更があるか。
        ///
        /// cm status --machinereadable の1行めが
        ///   STATUS 139 Robotics_UX_New/... studio_unity148@unity
        /// なので、そこから版の番号を取り、2行めから先を数える
        /// （PR＝置いてあるだけ は数えない）。
        /// </summary>
        static void ReadChangeset(out string cs, out string pendingNote)
        {
            cs = "";
            pendingNote = "";

            string outp = RunCm("status --machinereadable");
            if (string.IsNullOrEmpty(outp))
            {
                // cm を呼べなかった。数が分からないので「不明」と書く（依頼 v8 の 3）
                cs = "不明";
                pendingNote = "不明（cm status を読めませんでした）";
                return;
            }

            // 保留の数は Unity Version Control の「保留中の変更」と同じ数え方にする。
            // 置いてあるだけ（PR＝Private）も、あの一覧には出るので **数に入れる**
            // （前は PR を外していたので、2件あったのに空になっていた）
            int controlled = 0, privateItems = 0;
            string[] lines = outp.Split('\n');
            for (int i = 0; i < lines.Length; i++)
            {
                string l = lines[i].Trim();
                if (l.Length == 0) continue;
                if (l.StartsWith("STATUS "))
                {
                    string[] f = l.Split(' ');
                    if (f.Length > 1) cs = "cs:" + f[1];
                    continue;
                }
                if (l.StartsWith("PR ")) privateItems++;
                else controlled++;
            }

            int all = controlled + privateItems;
            if (all > 0)
                pendingNote = "保留あり " + all + " 件（管理下 " + controlled +
                              " 件・置いてあるだけ " + privateItems + " 件）";
            if (cs.Length == 0) cs = "不明";
        }

        static string RunCm(string args)
        {
            string[] paths = { "/usr/local/bin/cm", "/opt/homebrew/bin/cm", "cm" };
            for (int i = 0; i < paths.Length; i++)
            {
                try
                {
                    Process p = new Process();
                    p.StartInfo.FileName = paths[i];
                    p.StartInfo.Arguments = args;
                    p.StartInfo.WorkingDirectory = Directory.GetCurrentDirectory();
                    p.StartInfo.UseShellExecute = false;
                    p.StartInfo.RedirectStandardOutput = true;
                    p.StartInfo.RedirectStandardError = true;
                    p.StartInfo.CreateNoWindow = true;
                    p.Start();
                    string outp = p.StandardOutput.ReadToEnd();
                    p.WaitForExit(15000);
                    if (!string.IsNullOrEmpty(outp)) return outp;
                }
                catch (Exception)
                {
                    // 次の場所を試す
                }
            }
            return null;
        }

        static string[] Split(string s)
        {
            if (string.IsNullOrEmpty(s)) return new string[0];
            return s.Split(';');
        }

        static SimParams LoadParams()
        {
            SimParams sp = AssetDatabase.LoadAssetAtPath<SimParams>(ServiceSimSetup.SimParamsPath);
            if (sp == null)
            {
                Debug.LogError("[ServiceSim] SimParams が見つかりません: " + ServiceSimSetup.SimParamsPath);
                return null;
            }
            sp.Load("");
            if (sp.Conditions.Count == 0)
            {
                Debug.LogError("[ServiceSim] unity_conditions を読めません");
                return null;
            }
            return sp;
        }
    }
}
```
