---
title: "サービスロボット導入シミュレーション Log#27 | 店で測った数字が届けば、配膳ロボットの試算はすぐ直せる"
slug: serving-robot-sim-ready-for-store-data
date: 2026-10-31
day: 415
meta_description: "配膳ロボットの試算（ファミレスの待ち時間を計算で出すシミュレーション）の前提109個のうち79個はまだ仮の値。根拠つきの表にまとめ、店で測った数字が届けば、その行を書き換えて回し直せるようにした。"
tags: [サービスロボット導入シミュレーション, 配膳ロボット, 飲食店, ファミレス, 観察シート, シミュレーション, データ設計]
series: "導入シミュレーション"
---

配膳ロボットの試算（ファミレスで配膳ロボットを入れたときの待ち時間を、計算で出すシミュレーション）は、前提にしている値109個のうち79個が、まだ根拠のない仮の値です。その109個を、値ごとに「何をもとにした数字か」を書いた1つの表にまとめ、店で測った数字が届いたら、その行を書き換えるだけで回し直せる形にしました。

試算が出す「ロボットを2台入れたら入店待ちが何分縮むか」「スタッフを1人減らせるか」という答えは、会計1組90秒、テーブルを拭くのに40秒、といった前提の数字で決まります。ロボットを入れるか迷っている店の人がまず聞きたいのは、「その90秒はどこから来た数字なのか」のはずです。ところが今までは、前提の数字がプログラムの中に直接書いてあり、どれが実感でどれが仮置きなのかが、どこにも書かれていませんでした。版を変えるときは、フォルダを丸ごとコピーしていました（v2_1〜v2_9）。これでは、店の数字が届いても、どこをどう直せばいいかを探すところから始まります。

**前提の値を、プログラムの外にある1つの表（assumptions.json）に移し、値ごとに根拠の種類・出典・現場で測った回数・更新日を持たせた。** 試算はこの表を読んで回ります。表に移すときに数字は1つも変えておらず、600条件を回し直して、前の版と全部の数字が同じになることを確かめました。

| やったこと | なぜ | 結果 |
|---|---|---|
| 試算の前提109個を、根拠つきの表にまとめた | どの数字が実感で、どれが仮置きかを分かるようにするため | 仮置き79・決まり17・設定11・実感1・公開事例1。Unity で測った値と現場の値は、まだ0 |
| 試算のプログラムと表を GitHub に入れた | いつ・なぜ・どの値を変えたかを、記録で追えるようにするため | 前の版（v2.9）と、600条件で全部の数字が同じ |
| 観察シートの記録を貼ると、表に入れる値の案が出る道具を作った | 店から届いた LINE・メールの文を、そのまま試算に使える形にするため | 試しに測った記録7回が、表の6つの行に分かれて入った |
| 観察シートの「席へ案内」の測る区切りを変えた | シートの時間に歩く時間が入り、試算の案内の時間と比べられなかったため | 「席に着いて説明を始めたら〜テーブルを離れたら」に変えた |
| Unity の比較動画を、まとめて作る準備をした | 今夜撮り直す素材66本から、42本の比較動画を明日すぐ作り始めるため | 作る道具を新しい素材に向け、試しの1コマを作った |

下の図で、根拠の種類を押すと、その種類の前提が表のどの値かが出ます。店の数と回数を動かすと、測った値が試算に入る候補になるかが分かります。

【図解】LOG415_assumptions_table.html

配膳ロボットを自分の店に入れたらどうなるかは、このシリーズの試算をもとにした別のページで試せます。

【バナー】serving-robot-one-less-staff


## 作業ログ ― 配膳ロボットの試算の前提を、1つの表にまとめた

【ファイル一式】LOG415


### 試算・前提の表・観察シートは、このシリーズで何をしているか

このシリーズでは、配膳ロボットを入れたファミレスの待ち時間を、2つの方法で調べています。この LOG で何度も出てくる言葉を、先にまとめます。

| 言葉 | 何か |
|---|---|
| 試算 | サイトの /series8 で公開している、配膳ロボットの待ち時間の計算。プログラムは `haizen_sim.py`。お客さんが来て、案内され、料理が届き、会計して帰るまでを、[SimPy](https://simpy.readthedocs.io/)（順番待ちを計算で再現する Python の道具）で1組ずつ進める。1条件を40回回した平均を出す |
| Unity のシミュレーション | 店を3Dで建て、人とロボットを実際に歩かせるもの。通路で詰まる・止まるといった、計算では出ない動きを見る |
| 前提 | 試算が置いている数字。会計1組90秒、テーブルを拭くのに40秒、ロボットが1往復で動けなくなる確率1%など。これが変わると、試算の答えが変わる |
| 前提の表 | この LOG で作った `assumptions.json`。前提の値ごとに、根拠の種類・出典・現場で測った回数・更新日を持つ |
| 観察シート | 店の人がスマホで作業の時間を測るページ（pixelstomotors.com/sim/serving-robot-observation ）。記録は LINE かメールで、店長やロボットの担当の方へ送る |
| 中央値 | 測った値を小さい順に並べたときの、真ん中の値。たまたま長くかかった1回に引きずられにくいので、表に入れる値はこれにする |

試算のプログラムと前提の表は、[GitHub](https://github.com/)（プログラムを、直した記録と一緒に置いておくサービス）の、このサイトのリポジトリ（非公開）の `tools/serving_sim/` に置きました。

一つ断っておくと、この LOG の「見込める効果」は、すべて見込みです。店で測った数字は、まだ1つも届いていません。


### 前提の値はプログラムの中に直接書かれ、版はフォルダのコピーで分けていた

試算 v2.9 の前提は、`haizen_sim.py` の先頭に、Python の辞書として直接書かれていました。店に共通の値が24個（`COMMON`）、店のタイプごとの値が5店ぶん（`STORES`）、それに選べるロボットの台数や、1条件を回す回数、「店が回らない」とする線（`NG_MIN`）などです。値の横のコメントには「すべて仮」と書いてあるだけで、どれがファミレスで働いていた実感から来た数字で、どれが仮に置いた数字かは、読んでも分かりません。

版は、iCloud の作業フォルダの中で、フォルダごとコピーして分けていました。

```
simulation/
  v2_1/ v2_2/ v2_3/ v2_4/ v2_5/ v2_6/ v2_7/ v2_8/ v2_9/
```

いつ・なぜ・どの値を変えたかは、フォルダの中の README とコードの頭の説明を読み比べないと分かりません。店で測った数字が届いたとき、まず「どの値を置き換えるのか」「前の版で何を変えたのか」を探すところから始めることになります。

- ねらい：店で測った数字が届いたときに、どの行を書き換えればよいかがすぐ分かり、直した記録が残るようにする
- 変わったこと：前提109個をプログラムの外の表に移し、値ごとに根拠の種類・出典・回数・更新日を持たせた。試算はこの表を読んで回る。プログラムと表は git に入れ、フォルダのコピーで版を分けるのをやめた
- 見込める効果：数字が届いてから試算を回し直すまでが、表の行を書き換えて回すだけになる（見込み。最初の店の記録が届いたときに、かかった時間で確かめる）


### 前提の表の、根拠の種類7つ

表の値は、どれも次の7つの根拠のどれかを持ちます。

| 根拠の種類 | 意味 | 今の数 |
|---|---|---|
| 仮置き | まだ根拠がない仮の値。現場・公開事例・Unity の値で置き換えていく | 79 |
| 実感 | ファミレスで働いていたころの経験 | 1（会計1組90秒） |
| 公開事例 | メーカーの仕様・導入事例など、公開されている値 | 1（洗い場が遠いホテルの店の追加距離） |
| Unity | Unity で測った値 | 0 |
| 現場 | 店で測った値。測った回数も書く | 0 |
| 決まり | 試算の回し方・判定の線・運用の決まりとして決めたもの | 17 |
| 設定 | 試算の回し方の設定。店の前提ではない | 11 |

値ごとに持つ欄は、値・単位・何の値か・根拠の種類・出典・現場で測った回数（`n`）・更新日・補足です。更新日が空の行は、表に移す前からの値で、いつ置いたかの記録がないものです。

実感と決まりに分けた値は、次のとおりです。振り分けは確かめてもらい、このままにしました。

| 値 | 根拠 | 出典・決めた経緯 |
|---|---|---|
| 会計1組90秒（`pay_s`） | 実感 | ファミレスの経験で、1組90秒くらい（2026-10-25） |
| ピークの長さ120分（`peak_min`） | 決まり | ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る |
| レジは1つ・2つから選べる（`registers`） | 決まり | 試算 v2.8 で決めた。大型店は2つを最初に見せる（2026-10-25） |
| ロボットは6台まで選べる（`robots_all`） | 決まり | 5台・6台も選べるまま残す。いちばん良い条件だけを見せても納得されないため（2026-10-21） |
| 席は空いているのに10分以上待ったら「店が回らない」（`ng_min`） | 決まり | 試算 v2.2 で決めた。5分だと厳しめ（2026-10-21） |
| 料理はロボット優先。2分待っても来なければ人が運ぶ（`robot_first_wait_s`） | 決まり | 料理はロボット最優先で運ぶ運用（2026-10-23） |

ロボットの速さ（0.5m/秒）と、1往復で止まる確率・動けなくなる確率は、根拠を仮置きのままにし、補足に「Unity で測って戻す値」と書きました。Unity ではもう測っていますが、試算の値として入れるかはまだ決めていないためです（LOG405 で、動けなくなる割合は Unity の方が多いと分かったが、今は変えないと決めた）。


### 前提の表を書き出したスクリプト（make_assumptions_once.py）

v2.9 のコードから値を読み出し、根拠と説明を付けて `assumptions.json` に書き出す、1回だけ使うスクリプトです。値は手で写さず、コードの中の値をそのまま読んでいます。このあと、根拠が分からない `cold_min`（これを超えて届いた皿を「待たせた皿」とする3分の線）を「決まり」から「仮置き」に直し、共通の値の並びを v2.9 のコードと同じ順にそろえ、日付の記録がない値の更新日を空にしました。

```python
# 1回だけ使う：v2.9 の haizen_sim.py の中の値を、前提の表（assumptions.json）に書き出す
import json, sys
sys.path.insert(0, "/home/claude/v29")
import haizen_sim as h
D = "2026-10-31"
def rec(v, unit, what, basis="仮置き", source="", updated=None, note=""):
    r = {"value": v, "unit": unit, "what": what, "basis": basis, "source": source, "n": None, "updated": updated or "2026-10-19"}
    if note: r["note"] = note
    return r
C = h.COMMON
common = {
 "staff_speed": rec(C["staff_speed"], "m/秒", "スタッフが歩く速さ"),
 "robot_speed": rec(C["robot_speed"], "m/秒", "ロボットが店内を走る速さ", note="Unity で測って戻す値（Unity の最高は0.6m/秒）。2026-10-24 に今のまま手を付けないと決めた"),
 "guide_s": rec(C["guide_s"], "秒", "案内（席の説明など。歩く時間は別）"),
 "staff_place_s": rec(C["staff_place_s"], "秒", "スタッフが卓に皿を置く"),
 "robot_handoff_s": rec(C["robot_handoff_s"], "秒", "客がロボットから皿を取る"),
 "pay_s": rec(C["pay_s"], "秒", "会計1組", "実感", "エイイチのファミレスの経験（1組90秒くらい）", "2026-10-25"),
 "call_s": rec(C["call_s"], "秒", "呼び出しへの対応（歩く時間は別）"),
 "clear_s": rec(C["clear_s"], "秒", "卓の皿をまとめる"),
 "wipe_s": rec(C["wipe_s"], "秒", "卓を拭いてセットする"),
 "load_s": rec(C["load_s"], "秒", "まとめた皿をロボットに載せる"),
 "guest_load_s": rec(C["guest_load_s"], "秒", "客が空いた皿をロボットに載せる（巡回して集めるとき）"),
 "pause_rate": rec(C["pause_rate"], "確率（1往復あたり）", "一時停止：人が前を横切ってロボットが止まる", note="Unity で測って戻す値"),
 "pause_s": rec(list(C["pause_s"]), "秒（最短, 最長）", "一時停止の長さ", note="Unity で測って戻す値"),
 "stuck_rate": rec(C["stuck_rate"], "確率（1往復あたり）", "立ち往生：スタッフが動かしに行かないと解けない", note="Unity で測って戻す値。LOG405 で Unity の方が多いと分かったが、今は変えないと決めた（2026-10-30）"),
 "rescue_s": rec(C["rescue_s"], "秒", "立ち往生したロボットをスタッフが動かす作業"),
 "hop_m": rec(C["hop_m"], "m", "1往復で複数の卓を回るときの、卓から次の卓への寄り道", note="試算だけの近似。Unity は実際の道を走る"),
 "fragile_cook": rec(list(C["fragile_cook"]), "分（最短, 最頻, 最長）", "デザート・ドリンクができるまで"),
 "cold_min": rec(C["cold_min"], "分", "これを超えて届いた皿を「待たせた皿」とする線", "決まり"),
 "dish_extra_m": rec(C["dish_extra_m"], "m", "洗い場が受け渡し口よりさらに奥にあるときの追加距離（店のタイプで上書き）"),
 "robot_first_wait_s": rec(C["robot_first_wait_s"], "秒", "料理はロボット優先。これだけ待ってもロボットが来なければスタッフが運ぶ", "決まり", "エイイチと決めた運用（料理はロボット最優先・2分待って来なければ人が運ぶ）", "2026-10-23"),
 "sustain": rec(C["sustain"], "", "回るかの確かめ（来客を止めない回し方）にするか", "設定"),
 "warmup_min": rec(C["warmup_min"], "分", "回るかの確かめで、数えない慣らしの時間", "設定"),
 "util_sample_s": rec(C["util_sample_s"], "秒", "手がふさがっている人数・台数を数える間隔", "設定"),
 "max_extra_min": rec(C["max_extra_min"], "分", "数える時間のあと、追いかけるのを打ち切る上限（回らない条件の保険）", "設定"),
}
assert list(common) == list(C) [:-1] or set(common) | {"model"} == set(C), set(C) ^ set(common)
F = {  # 店のタイプの項目の意味
 "tables": ("卓", "卓の数"), "dist_m": ("m（最短, 最長）", "受け渡し口（洗い場）から卓までの距離の幅"),
 "peak_min": ("分", "ピークの長さ（この時間だけ選んだペースで来客する）"),
 "first_dishes": ("分（最短, 最頻, 最長）× 皿", "最初の注文の皿ごとの、料理ができるまで"),
 "extra_orders": ("回", "食事中の追加注文の回数（食べ放題）"), "extra_cook": ("分（最短, 最頻, 最長）", "追加注文の料理ができるまで"),
 "eat_min": ("分（最短, 最頻, 最長）", "食べている時間"), "call_rate": ("回／組", "1組あたりの呼び出しの回数の平均"),
 "bus_trips": ("往復", "下げ膳を人だけでやるときの往復回数（皿の量）"), "fragile": ("個／組", "1組あたりの倒れやすい物（デザート・ドリンク）の数の平均"),
 "dish_extra_m": ("m", "洗い場が受け渡し口よりさらに奥にあるときの追加距離"),
 "staff": ("人（少ない, 真ん中, 多い）", "最初に見せるスタッフの人数"), "staff_all": ("人", "「その他」で選べる人数（総当たりはこちら）"),
 "arrivals": ("組／時（空いている, 普通, 混んでいる）", "来客のペース"),
 "registers": ("台", "最初に見せるレジの数"), "registers_all": ("台", "選べるレジの数"),
}
stores = {}
for k in ["small", "family", "large", "hotel", "buffet"]:
    s = h.STORES[k]; vals = {}
    for f, v in s.items():
        if f == "label": continue
        v = json.loads(json.dumps(v))
        basis, src, upd, note = "仮置き", "", "2026-10-19", ""
        if f == "peak_min" and v == 120: basis, src = "決まり", "エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る"
        if f in ("registers", "registers_all"): basis, src, upd = "決まり", "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）", "2026-10-25"
        if f == "dish_extra_m": basis, src = "公開事例", "洗い場が遠いホテルの店の導入事例（ホテル小倉）。距離は事例をもとにした仮の値"
        vals[f] = rec(v, F[f][0], F[f][1], basis, src, upd, note)
    stores[k] = {"label": s["label"], "values": vals}
out = {
 "about": "サービスロボット導入シミュレーション（配膳ロボットの試算 haizen_sim.py）の前提の表。試算はこの表を読んで回す。値を直すときは、ここを直して git に残す",
 "model": C["model"],
 "basis_kinds": {
   "仮置き": "まだ根拠がない仮の値。現場・公開事例・Unity の値で置き換えていく",
   "実感": "エイイチの現場の経験（ファミレスで働いていたころ）",
   "公開事例": "メーカーの仕様・導入事例など、公開されている値",
   "Unity": "Unity（ServiceSim）で測った値",
   "現場": "現場で測った値（観察シートなど）。n に測った回数を書く",
   "決まり": "エイイチと決めた、試算の回し方・判定の線・運用の決まり",
   "設定": "試算の回し方の設定。店の前提ではない",
 },
 "record_fields": {"value": "値", "unit": "単位", "what": "何の値か", "basis": "根拠の種類（basis_kinds）", "source": "出典・決めた経緯",
                   "n": "現場で測った回数（測っていなければ null）", "updated": "値または根拠を最後に直した日", "note": "補足"},
 "common": common,
 "stores": stores,
 "layout_stores": {
   "what": "図面のある店（②〜⑦）。標準ファミレス（family）をもとに、店の形から作る",
   "layout_dir": "../../public/sim/layouts",
   "labels": h.LAYOUT_LABELS,
   "rules": [
     "卓の数と、受け渡し口から卓ごとの距離は店の形（*_analysis.json）から取る",
     "来客のペースは、標準ファミレスの3つを卓の数に比例させて丸める（最低1組/時）",
     "スタッフの真ん中の人数は、標準ファミレスの真ん中（3人）を卓の数に比例させて丸める（最低2人）。少ない・多いは ±1人、選べる幅は 真ん中-2〜真ん中+4（最低1人）",
     "それ以外は標準ファミレスと同じ",
   ],
   "basis": "決まり", "source": "エイイチと決めた（2026-10-21）", "updated": "2026-10-21",
 },
 "choices": {
   "robots": rec(h.ROBOTS, "台", "最初に見せるロボットの台数", "設定"),
   "robots_all": rec(h.ROBOTS_ALL, "台", "「その他」で選べる台数（総当たりはこちら）", "決まり", "エイイチと決めた：5台・6台も選べるまま残す（ベストな状況だけ見せても納得されないため）", "2026-10-21"),
   "modes": rec(h.MODES, "", "ロボットの使い方（serve 上げ膳だけ／bus 下げ膳にも使う／patrol 食事中に回って集める）", "設定"),
   "trays": rec(h.TRAYS, "卓", "1往復で回る卓の数（1卓ずつ／最大3卓）", "設定"),
   "fragile_by": rec(h.FRAGILE_BY, "", "倒れやすい物を ロボットも運ぶ（any）／人だけが運ぶ（staff）", "設定"),
   "reps": rec(h.REPS, "回", "1条件を回す回数（結果はこの平均）", "設定"),
 },
 "judge": {
   "ng_min": rec(h.NG_MIN, "分", "席は空いているのに待った時間がこれ以上なら「店が回らない」", "決まり", "試算 v2.2 でエイイチと決めた（5分だと厳しめ）", "2026-10-21"),
   "staff_full": rec(h.STAFF_FULL, "割合", "回るかの確かめで、スタッフの手がこれ以上ふさがっていたら「店が回らない」（ページでは使わない）", "設定"),
   "unstable_min": rec(h.UNSTABLE_MIN, "分", "参考：入店待ちがこれ以上延びていく条件（判定には使わない）", "設定"),
 },
}
json.dump(out, open("/home/claude/p2m-site/tools/serving_sim/assumptions.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("ok")
```


### 前提の表 assumptions.json（全文）

```json
{
 "about": "サービスロボット導入シミュレーション（配膳ロボットの試算 haizen_sim.py）の前提の表。試算はこの表を読んで回す。値を直すときは、ここを直して git に残す",
 "model": "v2.9",
 "basis_kinds": {
  "仮置き": "まだ根拠がない仮の値。現場・公開事例・Unity の値で置き換えていく",
  "実感": "エイイチの現場の経験（ファミレスで働いていたころ）",
  "公開事例": "メーカーの仕様・導入事例など、公開されている値",
  "Unity": "Unity（ServiceSim）で測った値",
  "現場": "現場で測った値（観察シートなど）。n に測った回数を書く",
  "決まり": "エイイチと決めた、試算の回し方・判定の線・運用の決まり",
  "設定": "試算の回し方の設定。店の前提ではない"
 },
 "record_fields": {
  "value": "値",
  "unit": "単位",
  "what": "何の値か",
  "basis": "根拠の種類（basis_kinds）",
  "source": "出典・決めた経緯",
  "n": "現場で測った回数（測っていなければ null）",
  "updated": "値または根拠を最後に直した日（空は、表に移す前からの値で日付の記録がないもの）",
  "note": "補足"
 },
 "common": {
  "staff_speed": {
   "value": 1.0,
   "unit": "m/秒",
   "what": "スタッフが歩く速さ",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "robot_speed": {
   "value": 0.5,
   "unit": "m/秒",
   "what": "ロボットが店内を走る速さ",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": "",
   "note": "Unity で測って戻す値（Unity の最高は0.6m/秒）。2026-10-24 に今のまま手を付けないと決めた"
  },
  "guide_s": {
   "value": 45,
   "unit": "秒",
   "what": "案内（席の説明など。歩く時間は別）",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "staff_place_s": {
   "value": 10,
   "unit": "秒",
   "what": "スタッフが卓に皿を置く",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "robot_handoff_s": {
   "value": 25,
   "unit": "秒",
   "what": "客がロボットから皿を取る",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "pay_s": {
   "value": 90,
   "unit": "秒",
   "what": "会計1組",
   "basis": "実感",
   "source": "エイイチのファミレスの経験（1組90秒くらい）",
   "n": null,
   "updated": "2026-10-25"
  },
  "call_s": {
   "value": 30,
   "unit": "秒",
   "what": "呼び出しへの対応（歩く時間は別）",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "clear_s": {
   "value": 40,
   "unit": "秒",
   "what": "卓の皿をまとめる",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "wipe_s": {
   "value": 40,
   "unit": "秒",
   "what": "卓を拭いてセットする",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "load_s": {
   "value": 15,
   "unit": "秒",
   "what": "まとめた皿をロボットに載せる",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "guest_load_s": {
   "value": 20,
   "unit": "秒",
   "what": "客が空いた皿をロボットに載せる（巡回して集めるとき）",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "pause_rate": {
   "value": 0.3,
   "unit": "確率（1往復あたり）",
   "what": "一時停止：人が前を横切ってロボットが止まる",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": "",
   "note": "Unity で測って戻す値"
  },
  "pause_s": {
   "value": [
    3,
    10
   ],
   "unit": "秒（最短, 最長）",
   "what": "一時停止の長さ",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": "",
   "note": "Unity で測って戻す値"
  },
  "stuck_rate": {
   "value": 0.01,
   "unit": "確率（1往復あたり）",
   "what": "立ち往生：スタッフが動かしに行かないと解けない",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": "",
   "note": "Unity で測って戻す値。LOG405 で Unity の方が多いと分かったが、今は変えないと決めた（2026-10-30）"
  },
  "rescue_s": {
   "value": 15,
   "unit": "秒",
   "what": "立ち往生したロボットをスタッフが動かす作業",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "hop_m": {
   "value": 3,
   "unit": "m",
   "what": "1往復で複数の卓を回るときの、卓から次の卓への寄り道",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": "",
   "note": "試算だけの近似。Unity は実際の道を走る"
  },
  "fragile_cook": {
   "value": [
    2,
    4,
    7
   ],
   "unit": "分（最短, 最頻, 最長）",
   "what": "デザート・ドリンクができるまで",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "cold_min": {
   "value": 3,
   "unit": "分",
   "what": "これを超えて届いた皿を「待たせた皿」とする線",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "dish_extra_m": {
   "value": 0,
   "unit": "m",
   "what": "洗い場が受け渡し口よりさらに奥にあるときの追加距離（店のタイプで上書き）",
   "basis": "仮置き",
   "source": "",
   "n": null,
   "updated": ""
  },
  "sustain": {
   "value": false,
   "unit": "",
   "what": "回るかの確かめ（来客を止めない回し方）にするか",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "warmup_min": {
   "value": 60,
   "unit": "分",
   "what": "回るかの確かめで、数えない慣らしの時間",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "util_sample_s": {
   "value": 10,
   "unit": "秒",
   "what": "手がふさがっている人数・台数を数える間隔",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "max_extra_min": {
   "value": 600,
   "unit": "分",
   "what": "数える時間のあと、追いかけるのを打ち切る上限（回らない条件の保険）",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "robot_first_wait_s": {
   "value": 120,
   "unit": "秒",
   "what": "料理はロボット優先。これだけ待ってもロボットが来なければスタッフが運ぶ",
   "basis": "決まり",
   "source": "エイイチと決めた運用（料理はロボット最優先・2分待って来なければ人が運ぶ）",
   "n": null,
   "updated": "2026-10-23"
  }
 },
 "stores": {
  "small": {
   "label": "小型店",
   "values": {
    "tables": {
     "value": 18,
     "unit": "卓",
     "what": "卓の数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "dist_m": {
     "value": [
      4,
      12
     ],
     "unit": "m（最短, 最長）",
     "what": "受け渡し口（洗い場）から卓までの距離の幅",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "peak_min": {
     "value": 120,
     "unit": "分",
     "what": "ピークの長さ（この時間だけ選んだペースで来客する）",
     "basis": "決まり",
     "source": "エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る",
     "n": null,
     "updated": ""
    },
    "first_dishes": {
     "value": [
      [
       4,
       7,
       11
      ],
      [
       6,
       9,
       14
      ]
     ],
     "unit": "分（最短, 最頻, 最長）× 皿",
     "what": "最初の注文の皿ごとの、料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_orders": {
     "value": 0,
     "unit": "回",
     "what": "食事中の追加注文の回数（食べ放題）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_cook": {
     "value": [
      3,
      5,
      8
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "追加注文の料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "eat_min": {
     "value": [
      15,
      25,
      40
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "食べている時間",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "call_rate": {
     "value": 0.4,
     "unit": "回／組",
     "what": "1組あたりの呼び出しの回数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "bus_trips": {
     "value": 1,
     "unit": "往復",
     "what": "下げ膳を人だけでやるときの往復回数（皿の量）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "fragile": {
     "value": 0.3,
     "unit": "個／組",
     "what": "1組あたりの倒れやすい物（デザート・ドリンク）の数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff": {
     "value": [
      1,
      2,
      3
     ],
     "unit": "人（少ない, 真ん中, 多い）",
     "what": "最初に見せるスタッフの人数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff_all": {
     "value": [
      1,
      2,
      3,
      4,
      5
     ],
     "unit": "人",
     "what": "「その他」で選べる人数（総当たりはこちら）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "arrivals": {
     "value": [
      15,
      22,
      30
     ],
     "unit": "組／時（空いている, 普通, 混んでいる）",
     "what": "来客のペース",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "registers": {
     "value": 1,
     "unit": "台",
     "what": "最初に見せるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    },
    "registers_all": {
     "value": [
      1,
      2
     ],
     "unit": "台",
     "what": "選べるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    }
   }
  },
  "family": {
   "label": "標準ファミレス",
   "values": {
    "tables": {
     "value": 35,
     "unit": "卓",
     "what": "卓の数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "dist_m": {
     "value": [
      6,
      22
     ],
     "unit": "m（最短, 最長）",
     "what": "受け渡し口（洗い場）から卓までの距離の幅",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "peak_min": {
     "value": 120,
     "unit": "分",
     "what": "ピークの長さ（この時間だけ選んだペースで来客する）",
     "basis": "決まり",
     "source": "エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る",
     "n": null,
     "updated": ""
    },
    "first_dishes": {
     "value": [
      [
       5,
       8,
       12
      ],
      [
       8,
       12,
       18
      ]
     ],
     "unit": "分（最短, 最頻, 最長）× 皿",
     "what": "最初の注文の皿ごとの、料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_orders": {
     "value": 0,
     "unit": "回",
     "what": "食事中の追加注文の回数（食べ放題）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_cook": {
     "value": [
      3,
      5,
      8
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "追加注文の料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "eat_min": {
     "value": [
      20,
      30,
      45
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "食べている時間",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "call_rate": {
     "value": 0.6,
     "unit": "回／組",
     "what": "1組あたりの呼び出しの回数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "bus_trips": {
     "value": 2,
     "unit": "往復",
     "what": "下げ膳を人だけでやるときの往復回数（皿の量）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "fragile": {
     "value": 0.5,
     "unit": "個／組",
     "what": "1組あたりの倒れやすい物（デザート・ドリンク）の数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff": {
     "value": [
      2,
      3,
      4
     ],
     "unit": "人（少ない, 真ん中, 多い）",
     "what": "最初に見せるスタッフの人数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff_all": {
     "value": [
      1,
      2,
      3,
      4,
      5,
      6,
      7
     ],
     "unit": "人",
     "what": "「その他」で選べる人数（総当たりはこちら）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "arrivals": {
     "value": [
      25,
      35,
      45
     ],
     "unit": "組／時（空いている, 普通, 混んでいる）",
     "what": "来客のペース",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "registers": {
     "value": 1,
     "unit": "台",
     "what": "最初に見せるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    },
    "registers_all": {
     "value": [
      1,
      2
     ],
     "unit": "台",
     "what": "選べるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    }
   }
  },
  "large": {
   "label": "大型店",
   "values": {
    "tables": {
     "value": 60,
     "unit": "卓",
     "what": "卓の数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "dist_m": {
     "value": [
      8,
      35
     ],
     "unit": "m（最短, 最長）",
     "what": "受け渡し口（洗い場）から卓までの距離の幅",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "peak_min": {
     "value": 120,
     "unit": "分",
     "what": "ピークの長さ（この時間だけ選んだペースで来客する）",
     "basis": "決まり",
     "source": "エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る",
     "n": null,
     "updated": ""
    },
    "first_dishes": {
     "value": [
      [
       5,
       8,
       12
      ],
      [
       8,
       12,
       18
      ]
     ],
     "unit": "分（最短, 最頻, 最長）× 皿",
     "what": "最初の注文の皿ごとの、料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_orders": {
     "value": 0,
     "unit": "回",
     "what": "食事中の追加注文の回数（食べ放題）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_cook": {
     "value": [
      3,
      5,
      8
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "追加注文の料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "eat_min": {
     "value": [
      20,
      30,
      45
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "食べている時間",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "call_rate": {
     "value": 0.6,
     "unit": "回／組",
     "what": "1組あたりの呼び出しの回数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "bus_trips": {
     "value": 2,
     "unit": "往復",
     "what": "下げ膳を人だけでやるときの往復回数（皿の量）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "fragile": {
     "value": 0.5,
     "unit": "個／組",
     "what": "1組あたりの倒れやすい物（デザート・ドリンク）の数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff": {
     "value": [
      4,
      5,
      6
     ],
     "unit": "人（少ない, 真ん中, 多い）",
     "what": "最初に見せるスタッフの人数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff_all": {
     "value": [
      2,
      3,
      4,
      5,
      6,
      7,
      8,
      9,
      10
     ],
     "unit": "人",
     "what": "「その他」で選べる人数（総当たりはこちら）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "arrivals": {
     "value": [
      45,
      60,
      75
     ],
     "unit": "組／時（空いている, 普通, 混んでいる）",
     "what": "来客のペース",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "registers": {
     "value": 2,
     "unit": "台",
     "what": "最初に見せるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    },
    "registers_all": {
     "value": [
      1,
      2
     ],
     "unit": "台",
     "what": "選べるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    }
   }
  },
  "hotel": {
   "label": "洗い場が遠い店",
   "values": {
    "tables": {
     "value": 30,
     "unit": "卓",
     "what": "卓の数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "dist_m": {
     "value": [
      6,
      20
     ],
     "unit": "m（最短, 最長）",
     "what": "受け渡し口（洗い場）から卓までの距離の幅",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "dish_extra_m": {
     "value": 30,
     "unit": "m",
     "what": "洗い場が受け渡し口よりさらに奥にあるときの追加距離",
     "basis": "公開事例",
     "source": "洗い場が遠いホテルの店の導入事例（ホテル小倉）。距離は事例をもとにした仮の値",
     "n": null,
     "updated": ""
    },
    "peak_min": {
     "value": 120,
     "unit": "分",
     "what": "ピークの長さ（この時間だけ選んだペースで来客する）",
     "basis": "決まり",
     "source": "エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る",
     "n": null,
     "updated": ""
    },
    "first_dishes": {
     "value": [
      [
       5,
       8,
       12
      ],
      [
       8,
       12,
       18
      ]
     ],
     "unit": "分（最短, 最頻, 最長）× 皿",
     "what": "最初の注文の皿ごとの、料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_orders": {
     "value": 2,
     "unit": "回",
     "what": "食事中の追加注文の回数（食べ放題）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_cook": {
     "value": [
      4,
      6,
      10
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "追加注文の料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "eat_min": {
     "value": [
      30,
      45,
      60
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "食べている時間",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "call_rate": {
     "value": 0.6,
     "unit": "回／組",
     "what": "1組あたりの呼び出しの回数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "bus_trips": {
     "value": 2,
     "unit": "往復",
     "what": "下げ膳を人だけでやるときの往復回数（皿の量）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "fragile": {
     "value": 0.6,
     "unit": "個／組",
     "what": "1組あたりの倒れやすい物（デザート・ドリンク）の数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff": {
     "value": [
      2,
      3,
      4
     ],
     "unit": "人（少ない, 真ん中, 多い）",
     "what": "最初に見せるスタッフの人数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff_all": {
     "value": [
      1,
      2,
      3,
      4,
      5,
      6,
      7
     ],
     "unit": "人",
     "what": "「その他」で選べる人数（総当たりはこちら）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "arrivals": {
     "value": [
      16,
      22,
      28
     ],
     "unit": "組／時（空いている, 普通, 混んでいる）",
     "what": "来客のペース",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "registers": {
     "value": 1,
     "unit": "台",
     "what": "最初に見せるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    },
    "registers_all": {
     "value": [
      1,
      2
     ],
     "unit": "台",
     "what": "選べるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    }
   }
  },
  "buffet": {
   "label": "焼肉食べ放題",
   "values": {
    "tables": {
     "value": 35,
     "unit": "卓",
     "what": "卓の数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "dist_m": {
     "value": [
      6,
      22
     ],
     "unit": "m（最短, 最長）",
     "what": "受け渡し口（洗い場）から卓までの距離の幅",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "peak_min": {
     "value": 150,
     "unit": "分",
     "what": "ピークの長さ（この時間だけ選んだペースで来客する）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "first_dishes": {
     "value": [
      [
       2,
       4,
       8
      ],
      [
       3,
       5,
       9
      ],
      [
       4,
       7,
       12
      ]
     ],
     "unit": "分（最短, 最頻, 最長）× 皿",
     "what": "最初の注文の皿ごとの、料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_orders": {
     "value": 5,
     "unit": "回",
     "what": "食事中の追加注文の回数（食べ放題）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "extra_cook": {
     "value": [
      2,
      4,
      7
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "追加注文の料理ができるまで",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "eat_min": {
     "value": [
      60,
      80,
      90
     ],
     "unit": "分（最短, 最頻, 最長）",
     "what": "食べている時間",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "call_rate": {
     "value": 0.8,
     "unit": "回／組",
     "what": "1組あたりの呼び出しの回数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "bus_trips": {
     "value": 3,
     "unit": "往復",
     "what": "下げ膳を人だけでやるときの往復回数（皿の量）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "fragile": {
     "value": 1.5,
     "unit": "個／組",
     "what": "1組あたりの倒れやすい物（デザート・ドリンク）の数の平均",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff": {
     "value": [
      2,
      3,
      4
     ],
     "unit": "人（少ない, 真ん中, 多い）",
     "what": "最初に見せるスタッフの人数",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "staff_all": {
     "value": [
      1,
      2,
      3,
      4,
      5,
      6,
      7
     ],
     "unit": "人",
     "what": "「その他」で選べる人数（総当たりはこちら）",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "arrivals": {
     "value": [
      14,
      20,
      26
     ],
     "unit": "組／時（空いている, 普通, 混んでいる）",
     "what": "来客のペース",
     "basis": "仮置き",
     "source": "",
     "n": null,
     "updated": ""
    },
    "registers": {
     "value": 1,
     "unit": "台",
     "what": "最初に見せるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    },
    "registers_all": {
     "value": [
      1,
      2
     ],
     "unit": "台",
     "what": "選べるレジの数",
     "basis": "決まり",
     "source": "試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）",
     "n": null,
     "updated": "2026-10-25"
    }
   }
  }
 },
 "layout_stores": {
  "what": "図面のある店（②〜⑦）。標準ファミレス（family）をもとに、店の形から作る",
  "layout_dir": "../../public/sim/layouts",
  "labels": {
   "p0": "② 定番の郊外型",
   "p1": "③ 効率重視・入口が角",
   "p2": "④ 効率重視・小さめの店",
   "p3": "⑤ ゆったり・入口が正面",
   "p4": "⑥ ゆったり・入口が横",
   "p5": "⑦ ③の通路を95cmに"
  },
  "rules": [
   "卓の数と、受け渡し口から卓ごとの距離は店の形（*_analysis.json）から取る",
   "来客のペースは、標準ファミレスの3つを卓の数に比例させて丸める（最低1組/時）",
   "スタッフの真ん中の人数は、標準ファミレスの真ん中（3人）を卓の数に比例させて丸める（最低2人）。少ない・多いは ±1人、選べる幅は 真ん中-2〜真ん中+4（最低1人）",
   "それ以外は標準ファミレスと同じ"
  ],
  "basis": "決まり",
  "source": "エイイチと決めた（2026-10-21）",
  "updated": "2026-10-21"
 },
 "choices": {
  "robots": {
   "value": [
    0,
    1,
    2,
    3
   ],
   "unit": "台",
   "what": "最初に見せるロボットの台数",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "robots_all": {
   "value": [
    0,
    1,
    2,
    3,
    4,
    5,
    6
   ],
   "unit": "台",
   "what": "「その他」で選べる台数（総当たりはこちら）",
   "basis": "決まり",
   "source": "エイイチと決めた：5台・6台も選べるまま残す（ベストな状況だけ見せても納得されないため）",
   "n": null,
   "updated": "2026-10-21"
  },
  "modes": {
   "value": [
    "serve",
    "bus",
    "patrol"
   ],
   "unit": "",
   "what": "ロボットの使い方（serve 上げ膳だけ／bus 下げ膳にも使う／patrol 食事中に回って集める）",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "trays": {
   "value": [
    1,
    3
   ],
   "unit": "卓",
   "what": "1往復で回る卓の数（1卓ずつ／最大3卓）",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "fragile_by": {
   "value": [
    "any",
    "staff"
   ],
   "unit": "",
   "what": "倒れやすい物を ロボットも運ぶ（any）／人だけが運ぶ（staff）",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "reps": {
   "value": 40,
   "unit": "回",
   "what": "1条件を回す回数（結果はこの平均）",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  }
 },
 "judge": {
  "ng_min": {
   "value": 10,
   "unit": "分",
   "what": "席は空いているのに待った時間がこれ以上なら「店が回らない」",
   "basis": "決まり",
   "source": "試算 v2.2 でエイイチと決めた（5分だと厳しめ）",
   "n": null,
   "updated": "2026-10-21"
  },
  "staff_full": {
   "value": 0.95,
   "unit": "割合",
   "what": "回るかの確かめで、スタッフの手がこれ以上ふさがっていたら「店が回らない」（ページでは使わない）",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  },
  "unstable_min": {
   "value": 5,
   "unit": "分",
   "what": "参考：入店待ちがこれ以上延びていく条件（判定には使わない）",
   "basis": "設定",
   "source": "",
   "n": null,
   "updated": ""
  }
 }
}
```


### 前提の表を読んで回す試算 haizen_sim.py（全文）

変えたのは、前提の値を書いていた所（`COMMON`・`STORES`・図面のある店の名前・台数などの選び方・判定の線）を、表から読む形にしたことだけです。表の JSON では組（タプル）がリストになるので、元の形に戻してから使います。店の形は、サイトに公開している `public/sim/layouts/` を直接読むようにしました。前は、試算のフォルダに店の形の写しを置いていました。

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

前提の値は、このファイルの隣の assumptions.json（前提の表）から読む（2026-10-31。エイイチと決めた）。
値ごとに根拠の種類（仮置き・実感・公開事例・Unity・現場・決まり・設定）と出典・更新日を持つ。
値を直すときは表を直し、git に残す。店の形は p2m-site の public/sim/layouts/（正）を直接読む。
表に移したときに値・式・乱数の使い方は変えていない（v2.9 と同じ結果になることを check_same.py で確かめた）。

v2.9 で直したこと（2026-10-30。エイイチと決めた：フロントの実感では、レジと案内を優先して仕事をしていた）
  会計のスタッフを頼む時刻を「客が食べ終えてレジに並んだとき」にした。v2.8 までは、レジの順番が来てから
  スタッフを頼んだので、ほかの優先1の仕事（案内・料理・呼び出し）の後ろに並び、そのあいだレジが止まっていた。
  v2.9 では、会計の頼みはレジに並んだ時刻の古さで並ぶ（優先は1のまま）。スタッフが会計に付くのは、
  これまでどおりレジが空いてから（レジ1つにつき1組ずつ・pay_s 秒）。会計が終わると先にレジを空け、
  次の組の会計の頼みが並んでからスタッフの手を放すので、レジに並ぶ組がいれば会計の係がそのまま次を取る。
  Unity（ServiceSim）の頼み方と同じ（依頼31の調べ。Unity は会計のスタッフがほぼいつも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

# ---------------------------------------------------------------
# 前提の表（assumptions.json）を読む
# ---------------------------------------------------------------
import os as _os
_HERE = _os.path.dirname(_os.path.abspath(__file__))
ASSUMPTIONS_PATH = _os.environ.get("SERVING_SIM_ASSUMPTIONS", _os.path.join(_HERE, "assumptions.json"))
A = json.load(open(ASSUMPTIONS_PATH, encoding="utf-8"))

# 表の JSON では組（タプル）がリストになるので、元の形に戻す（数字は同じ。値の比べ方をそろえるため）
_TUPLE_KEYS = {"pause_s", "fragile_cook", "dist_m", "extra_cook", "eat_min"}


def _val(k, v):
    if k in _TUPLE_KEYS:
        return tuple(v)
    if k == "first_dishes":
        return [tuple(x) for x in v]
    return v


# 共通の前提
COMMON = {k: _val(k, r["value"]) for k, r in A["common"].items()}
COMMON["model"] = A["model"]          # モデルの版（前の版の結果を使い回さないための印）

# 店のタイプ
STORES = {}
for _k, _s in A["stores"].items():
    STORES[_k] = {"label": _s["label"], **{f: _val(f, r["value"]) for f, r in _s["values"].items()}}

# 図面のある店（v2.3）：標準ファミレスの中の、店の形から作った店
LAYOUT_DIR = _os.environ.get("SERVING_SIM_LAYOUTS", _os.path.normpath(_os.path.join(_HERE, A["layout_stores"]["layout_dir"])))
LAYOUT_LABELS = dict(A["layout_stores"]["labels"])



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 = A["choices"]["robots"]["value"]          # 最初に見せる台数
ROBOTS_ALL = A["choices"]["robots_all"]["value"]  # 「その他」で選べる台数（総当たりはこちら）
MODES = A["choices"]["modes"]["value"]
TRAYS = A["choices"]["trays"]["value"]            # 1往復で回る卓の数（1卓ずつ／最大3卓）
FRAGILE_BY = A["choices"]["fragile_by"]["value"]  # 倒れやすい物を ロボットも運ぶ／人だけが運ぶ
REPS = A["choices"]["reps"]["value"]


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


class _AgedRequest(simpy.resources.resource.PriorityRequest):
    """v2.9：頼んだ時刻を since にさかのぼらせたスタッフの頼み（同じ優先の中で、since の古い順に並ぶ）"""
    def __init__(self, resource, priority, since):
        self.priority = priority
        self.preempt = False
        self.time = since
        self.usage_since = None
        self.key = (self.priority, self.time, not self.preempt)
        simpy.resources.resource.Request.__init__(self, resource)


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, since=None):
        t0 = self.env.now
        # v2.9：since があれば、その時刻に頼んだものとして並ぶ（会計は、レジに並んだ時刻の古さで並ぶ）
        req0 = _AgedRequest(self.staff, priority, since) if since is not None else self.staff.request(priority=priority)
        with req0 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 会計：卓に座ったままレジの順番を待ち、順番が来たらスタッフの手が空くのを待って会計する
        # v2.9 会計のスタッフの頼みは、レジに並んだ時刻（p0）の古さで並ぶ。会計が終わったら先にレジを空け、
        #      次にレジに入った組の頼みが並んでからスタッフの手を放す（会計の係がそのまま次の会計を取れる。Unity と同じ）
        p0 = env.now
        rq = self.register.request()
        yield rq
        sreq = _AgedRequest(self.staff, 1, p0)
        yield sreq
        self.staff_busy_s += p["pay_s"]
        yield env.timeout(p["pay_s"])
        yield self.register.release(rq)      # レジを空ける（次の組がレジに入る）
        yield env.timeout(0)                 # 次の組の会計の頼みが並ぶのを待ってから
        self.staff.release(sreq)             # スタッフの手を放す
        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 = A["judge"]["unstable_min"]["value"]  # 参考：入店待ちがこれ以上延びていく条件（判定には使わない）
NG_MIN = A["judge"]["ng_min"]["value"]              # 席は空いているのに待った時間がこれ以上なら「店が回らない」（分）
STAFF_FULL = A["judge"]["staff_full"]["value"]      # 回るかの確かめで、スタッフの手がこれ以上ふさがっていたら「店が回らない」


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)
```


### v2.9 と同じ数字が出るかの確かめ

表に移したことで、試算の答えが少しでも変わっていたら意味がありません。3つの方法で確かめました。

1つ目は、v2.9 のコードと新しいコードを両方読み込み、前提の値が型まで同じか、32,850条件の並びが同じか、前提より下の式が1文字も変わっていないかを比べました。

```python
import importlib.util, sys
def load(path, name):
    spec = importlib.util.spec_from_file_location(name, path); m = importlib.util.module_from_spec(spec)
    sys.path.insert(0, path.rsplit('/',1)[0]); spec.loader.exec_module(m); sys.path.pop(0); return m
old = load('/home/claude/v29/haizen_sim.py', 'old')
new = load('/home/claude/p2m-site/tools/serving_sim/haizen_sim.py', 'new')
def same(a, b, path=''):
    if type(a) != type(b): print('型が違う', path, type(a), type(b)); return False
    if isinstance(a, dict):
        if list(a) != list(b): print('並びが違う', path, list(a), list(b)); return False
        return all(same(a[k], b[k], path+'/'+str(k)) for k in a)
    if isinstance(a, (list, tuple)):
        return len(a) == len(b) and all(same(x, y, f'{path}[{i}]') for i, (x, y) in enumerate(zip(a, b)))
    if a != b: print('値が違う', path, a, b); return False
    return True
ok = True
for n in ['COMMON','STORES','ROBOTS','ROBOTS_ALL','MODES','TRAYS','FRAGILE_BY','REPS','NG_MIN','STAFF_FULL','UNSTABLE_MIN','LAYOUT_LABELS']:
    r = same(getattr(old, n), getattr(new, n), n); print(n, r); ok &= r
print('条件の並び', list(old.conditions()) == list(new.conditions()), len(list(new.conditions())))
print('式（前提の行より下）が同じ', open('/home/claude/v29/haizen_sim.py').read().split('def tri(rng, lmh):')[1].split('UNSTABLE_MIN')[0] == open('/home/claude/p2m-site/tools/serving_sim/haizen_sim.py').read().split('def tri(rng, lmh):')[1].split('UNSTABLE_MIN')[0])
print('ALL', ok)
```

```
COMMON True
STORES True
ROBOTS True
ROBOTS_ALL True
MODES True
TRAYS True
FRAGILE_BY True
REPS True
NG_MIN True
STAFF_FULL True
UNSTABLE_MIN True
LAYOUT_LABELS True
条件の並び True 32850
式（前提の行より下）が同じ True
ALL True
```

2つ目は、条件を全体から決まった間隔で600選び、新しいコードで回し直して、v2.9 の結果（results.json）と全部の指標を比べました。表を直していないのにずれたら、コードか店の形が変わっているということです。このスクリプトは、これからも同じ確かめに使います。

```python
"""前提の表と試算のコードで、前の結果（results.json）と同じ数字が出るかを確かめる（2026-10-31）。
使い方: python3 check_same.py <前の results.json> [確かめる条件の数（既定 60）]
- 店ごと・ロボットの台数ごとに散らばるように、全条件から決まった間隔で選んで回し、指標を全部くらべる
- 表の値を直していないのにずれたら、コードか店の形が変わっている。表の値を直したあとなら、ずれるのが当たり前
- 前提（common）が前の結果と違うときは、そう表示してから比べる"""
import json
import sys
from multiprocessing import Pool

import haizen_sim as h

res = json.load(open(sys.argv[1], encoding="utf-8"))
n = int(sys.argv[2]) if len(sys.argv) > 2 else 60
if res.get("common") != json.loads(json.dumps(h.COMMON)):
    print("注意：前の結果と前提（common）が違う。ずれが出て当たり前")
rows = {h.key(r): r for r in res["rows"]}
conds = list(h.conditions())
step = max(1, len(conds) // n)
pick = conds[::step][:n]
with Pool() as pool:
    got = pool.map(h.run_condition, pick)
bad = 0
for c, r in zip(pick, got):
    old = rows.get(h.key(c))
    if old is None:
        print("前の結果にない条件", c); bad += 1; continue
    diff = {m: (old[m], r[m]) for m in r if m not in c and old.get(m) != r[m]}
    if diff:
        bad += 1; print("ずれ", c, diff)
print(f"{len(pick)} 条件を回し、{len(pick) - bad} 条件が前の結果と同じ（ずれ {bad}）")
sys.exit(1 if bad else 0)
```

```
$ python3 check_same.py /home/claude/v29/results.json 600
600 条件を回し、600 条件が前の結果と同じ（ずれ 0）

real	3m56.918s
user	7m16.276s
sys	0m0.156s
```

店の形は、v2.9 の写し（版 08db8a0d067c）から、サイトに公開している今の店の形（版 9b8dae7ebf8c。スタッフの持ち場を足した版）に変わりました。卓ごとの受け渡し口からの距離は、6店とも同じでした。そのため、この確かめでも数字は変わっていません。

3つ目は、Unity が読む `serving-robot-params.json` を、新しいコードで書き出し直しました。公開中のものと、バイトまで同じでした。書き出しの道具は、Unity だけの回し方の決まり（入口番・持ち場など。公開中の params に直接書いて直している）を消さないよう、書き出し先の今の値を引き継ぐ形に直しています。

```
$ python3 export_params.py /home/claude/v29/results.json /tmp/p.json
/tmp/p.json 90 conditions / generation 9b8dae7ebf8c
  {'store': 'fam_p1', 'staff': 4, 'robots': 0, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 4.0915, 'staff_wait_mean': 2.6584, 'dish_wait_mean': 0.6259, 'pay_wait_mean': 4.8049}
  {'store': 'fam_p1', 'staff': 4, 'robots': 1, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.6842, 'staff_wait_mean': 0.4947, 'dish_wait_mean': 1.9664, 'pay_wait_mean': 5.6376}
  {'store': 'fam_p1', 'staff': 4, 'robots': 2, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.129, 'staff_wait_mean': 0.3856, 'dish_wait_mean': 0.9579, 'pay_wait_mean': 6.5762}
  {'store': 'fam_p5', 'staff': 4, 'robots': 0, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 3.076, 'staff_wait_mean': 2.1333, 'dish_wait_mean': 0.6299, 'pay_wait_mean': 4.8601}
  {'store': 'fam_p5', 'staff': 4, 'robots': 1, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.5758, 'staff_wait_mean': 0.5449, 'dish_wait_mean': 2.0126, 'pay_wait_mean': 5.8228}
  {'store': 'fam_p5', 'staff': 4, 'robots': 2, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.6014, 'staff_wait_mean': 0.468, 'dish_wait_mean': 1.01, 'pay_wait_mean': 6.597}
  {'store': 'fam_p2', 'staff': 2, 'robots': 0, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 10.9149, 'staff_wait_mean': 9.4095, 'dish_wait_mean': 0.9934, 'pay_wait_mean': 1.6564}
  {'store': 'fam_p2', 'staff': 2, 'robots': 1, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 4.8125, 'staff_wait_mean': 3.9187, 'dish_wait_mean': 1.2873, 'pay_wait_mean': 1.524}
  {'store': 'fam_p2', 'staff': 2, 'robots': 2, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 5.5724, 'staff_wait_mean': 4.2329, 'dish_wait_mean': 0.4736, 'pay_wait_mean': 1.5442}
  {'store': 'fam_p3', 'staff': 4, 'robots': 0, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 7.5066, 'staff_wait_mean': 5.5018, 'dish_wait_mean': 0.7664, 'pay_wait_mean': 6.0886}
  {'store': 'fam_p3', 'staff': 4, 'robots': 1, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 3.1557, 'staff_wait_mean': 1.4032, 'dish_wait_mean': 2.2804, 'pay_wait_mean': 8.5829}
  {'store': 'fam_p3', 'staff': 4, 'robots': 2, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.3708, 'staff_wait_mean': 0.6835, 'dish_wait_mean': 1.2683, 'pay_wait_mean': 9.9214}
  {'store': 'fam_p4', 'staff': 3, 'robots': 0, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 7.9052, 'staff_wait_mean': 6.5917, 'dish_wait_mean': 0.8441, 'pay_wait_mean': 2.4499}
  {'store': 'fam_p4', 'staff': 3, 'robots': 1, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 3.6758, 'staff_wait_mean': 2.4984, 'dish_wait_mean': 2.0135, 'pay_wait_mean': 2.9993}
  {'store': 'fam_p4', 'staff': 3, 'robots': 2, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.1104, 'staff_wait_mean': 1.6145, 'dish_wait_mean': 0.8234, 'pay_wait_mean': 2.982}
  {'store': 'fam_p0', 'staff': 2, 'robots': 0, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.6053, 'staff_wait_mean': 1.5482, 'dish_wait_mean': 0.6102, 'pay_wait_mean': 0.8826}
  {'store': 'fam_p0', 'staff': 2, 'robots': 1, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.7907, 'staff_wait_mean': 0.8687, 'dish_wait_mean': 0.9119, 'pay_wait_mean': 0.739}
  {'store': 'fam_p0', 'staff': 2, 'robots': 2, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.3035, 'staff_wait_mean': 0.62, 'dish_wait_mean': 0.4033, 'pay_wait_mean': 0.6508}
  {'store': 'fam_p1', 'staff': 4, 'robots': 0, 'arrivals_per_h': 30, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.2983, 'staff_wait_mean': 0.122, 'dish_wait_mean': 0.3158, 'pay_wait_mean': 1.6504}
  {'store': 'fam_p1', 'staff': 4, 'robots': 1, 'arrivals_per_h': 30, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.2384, 'staff_wait_mean': 0.0616, 'dish_wait_mean': 1.6241, 'pay_wait_mean': 1.9085}
  {'store': 'fam_p1', 'staff': 4, 'robots': 2, 'arrivals_per_h': 30, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.2395, 'staff_wait_mean': 0.0622, 'dish_wait_mean': 0.743, 'pay_wait_mean': 2.0157}
  {'store': 'fam_p1', 'staff': 4, 'robots': 0, 'arrivals_per_h': 54, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 12.9113, 'staff_wait_mean': 5.2161, 'dish_wait_mean': 0.6801, 'pay_wait_mean': 6.4765}
  {'store': 'fam_p1', 'staff': 4, 'robots': 1, 'arrivals_per_h': 54, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 11.5008, 'staff_wait_mean': 1.4122, 'dish_wait_mean': 2.0787, 'pay_wait_mean': 9.799}
  {'store': 'fam_p1', 'staff': 4, 'robots': 2, 'arrivals_per_h': 54, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 11.0525, 'staff_wait_mean': 1.2435, 'dish_wait_mean': 1.019, 'pay_wait_mean': 10.2599}
  {'store': 'fam_p5', 'staff': 4, 'robots': 0, 'arrivals_per_h': 30, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.3382, 'staff_wait_mean': 0.1586, 'dish_wait_mean': 0.328, 'pay_wait_mean': 1.6981}
  {'store': 'fam_p5', 'staff': 4, 'robots': 1, 'arrivals_per_h': 30, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.2453, 'staff_wait_mean': 0.0636, 'dish_wait_mean': 1.6531, 'pay_wait_mean': 1.9362}
  {'store': 'fam_p5', 'staff': 4, 'robots': 2, 'arrivals_per_h': 30, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.2354, 'staff_wait_mean': 0.0542, 'dish_wait_mean': 0.7456, 'pay_wait_mean': 1.8977}
  {'store': 'fam_p5', 'staff': 4, 'robots': 0, 'arrivals_per_h': 54, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 12.2673, 'staff_wait_mean': 5.1571, 'dish_wait_mean': 0.6905, 'pay_wait_mean': 6.3583}
  {'store': 'fam_p5', 'staff': 4, 'robots': 1, 'arrivals_per_h': 54, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 13.2562, 'staff_wait_mean': 1.547, 'dish_wait_mean': 2.1016, 'pay_wait_mean': 9.7263}
  {'store': 'fam_p5', 'staff': 4, 'robots': 2, 'arrivals_per_h': 54, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 11.5851, 'staff_wait_mean': 1.1822, 'dish_wait_mean': 1.0303, 'pay_wait_mean': 10.3418}
  {'store': 'fam_p2', 'staff': 2, 'robots': 0, 'arrivals_per_h': 20, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.023, 'staff_wait_mean': 0.8676, 'dish_wait_mean': 0.7398, 'pay_wait_mean': 1.1579}
  {'store': 'fam_p2', 'staff': 2, 'robots': 1, 'arrivals_per_h': 20, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.5716, 'staff_wait_mean': 0.4307, 'dish_wait_mean': 0.9936, 'pay_wait_mean': 0.8414}
  {'store': 'fam_p2', 'staff': 2, 'robots': 2, 'arrivals_per_h': 20, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.6366, 'staff_wait_mean': 0.4687, 'dish_wait_mean': 0.4271, 'pay_wait_mean': 0.9168}
  {'store': 'fam_p2', 'staff': 2, 'robots': 0, 'arrivals_per_h': 36, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 23.8003, 'staff_wait_mean': 16.3389, 'dish_wait_mean': 1.0161, 'pay_wait_mean': 1.9525}
  {'store': 'fam_p2', 'staff': 2, 'robots': 1, 'arrivals_per_h': 36, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 16.4641, 'staff_wait_mean': 9.3348, 'dish_wait_mean': 1.3579, 'pay_wait_mean': 1.8393}
  {'store': 'fam_p2', 'staff': 2, 'robots': 2, 'arrivals_per_h': 36, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 16.7718, 'staff_wait_mean': 9.5278, 'dish_wait_mean': 0.4945, 'pay_wait_mean': 1.9552}
  {'store': 'fam_p3', 'staff': 4, 'robots': 0, 'arrivals_per_h': 33, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.4666, 'staff_wait_mean': 0.2577, 'dish_wait_mean': 0.4852, 'pay_wait_mean': 2.807}
  {'store': 'fam_p3', 'staff': 4, 'robots': 1, 'arrivals_per_h': 33, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.3193, 'staff_wait_mean': 0.1117, 'dish_wait_mean': 1.8779, 'pay_wait_mean': 2.8238}
  {'store': 'fam_p3', 'staff': 4, 'robots': 2, 'arrivals_per_h': 33, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.2861, 'staff_wait_mean': 0.0766, 'dish_wait_mean': 0.9392, 'pay_wait_mean': 2.3693}
  {'store': 'fam_p3', 'staff': 4, 'robots': 0, 'arrivals_per_h': 59, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 22.7034, 'staff_wait_mean': 10.0794, 'dish_wait_mean': 0.8105, 'pay_wait_mean': 7.4454}
  {'store': 'fam_p3', 'staff': 4, 'robots': 1, 'arrivals_per_h': 59, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 15.5943, 'staff_wait_mean': 1.9893, 'dish_wait_mean': 2.2993, 'pay_wait_mean': 13.4773}
  {'store': 'fam_p3', 'staff': 4, 'robots': 2, 'arrivals_per_h': 59, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 12.3458, 'staff_wait_mean': 1.4481, 'dish_wait_mean': 1.2666, 'pay_wait_mean': 14.6901}
  {'store': 'fam_p4', 'staff': 3, 'robots': 0, 'arrivals_per_h': 27, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.6921, 'staff_wait_mean': 0.5091, 'dish_wait_mean': 0.6186, 'pay_wait_mean': 1.6403}
  {'store': 'fam_p4', 'staff': 3, 'robots': 1, 'arrivals_per_h': 27, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.4029, 'staff_wait_mean': 0.2245, 'dish_wait_mean': 1.5862, 'pay_wait_mean': 1.3862}
  {'store': 'fam_p4', 'staff': 3, 'robots': 2, 'arrivals_per_h': 27, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.3762, 'staff_wait_mean': 0.1961, 'dish_wait_mean': 0.673, 'pay_wait_mean': 1.3596}
  {'store': 'fam_p4', 'staff': 3, 'robots': 0, 'arrivals_per_h': 49, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 25.15, 'staff_wait_mean': 15.198, 'dish_wait_mean': 0.8944, 'pay_wait_mean': 3.1716}
  {'store': 'fam_p4', 'staff': 3, 'robots': 1, 'arrivals_per_h': 49, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 15.0459, 'staff_wait_mean': 7.0524, 'dish_wait_mean': 2.1087, 'pay_wait_mean': 3.4568}
  {'store': 'fam_p4', 'staff': 3, 'robots': 2, 'arrivals_per_h': 49, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 13.6254, 'staff_wait_mean': 6.0352, 'dish_wait_mean': 0.8761, 'pay_wait_mean': 4.0857}
  {'store': 'fam_p0', 'staff': 2, 'robots': 0, 'arrivals_per_h': 14, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.4572, 'staff_wait_mean': 0.2762, 'dish_wait_mean': 0.3836, 'pay_wait_mean': 0.5368}
  {'store': 'fam_p0', 'staff': 2, 'robots': 1, 'arrivals_per_h': 14, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.3401, 'staff_wait_mean': 0.184, 'dish_wait_mean': 0.7439, 'pay_wait_mean': 0.4358}
  {'store': 'fam_p0', 'staff': 2, 'robots': 2, 'arrivals_per_h': 14, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.3939, 'staff_wait_mean': 0.2049, 'dish_wait_mean': 0.3736, 'pay_wait_mean': 0.4239}
  {'store': 'fam_p0', 'staff': 2, 'robots': 0, 'arrivals_per_h': 24, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 10.6337, 'staff_wait_mean': 4.3529, 'dish_wait_mean': 0.7607, 'pay_wait_mean': 1.0554}
  {'store': 'fam_p0', 'staff': 2, 'robots': 1, 'arrivals_per_h': 24, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 7.6144, 'staff_wait_mean': 2.3528, 'dish_wait_mean': 0.9995, 'pay_wait_mean': 0.8543}
  {'store': 'fam_p0', 'staff': 2, 'robots': 2, 'arrivals_per_h': 24, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 7.3113, 'staff_wait_mean': 2.2041, 'dish_wait_mean': 0.4243, 'pay_wait_mean': 0.9011}
  {'store': 'fam_p1', 'staff': 3, 'robots': 0, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 10.6351, 'staff_wait_mean': 9.8607, 'dish_wait_mean': 0.8815, 'pay_wait_mean': 3.1745}
  {'store': 'fam_p1', 'staff': 3, 'robots': 1, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 5.6248, 'staff_wait_mean': 4.3668, 'dish_wait_mean': 2.1352, 'pay_wait_mean': 4.0122}
  {'store': 'fam_p1', 'staff': 3, 'robots': 2, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 4.0022, 'staff_wait_mean': 3.0854, 'dish_wait_mean': 0.9239, 'pay_wait_mean': 4.9499}
  {'store': 'fam_p1', 'staff': 5, 'robots': 0, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.0406, 'staff_wait_mean': 0.3066, 'dish_wait_mean': 0.2655, 'pay_wait_mean': 5.5334}
  {'store': 'fam_p1', 'staff': 5, 'robots': 1, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.0801, 'staff_wait_mean': 0.3004, 'dish_wait_mean': 1.873, 'pay_wait_mean': 6.0861}
  {'store': 'fam_p1', 'staff': 5, 'robots': 2, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.9185, 'staff_wait_mean': 0.2746, 'dish_wait_mean': 0.9588, 'pay_wait_mean': 6.3343}
  {'store': 'fam_p5', 'staff': 3, 'robots': 0, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 12.0178, 'staff_wait_mean': 11.1621, 'dish_wait_mean': 0.9049, 'pay_wait_mean': 3.3495}
  {'store': 'fam_p5', 'staff': 3, 'robots': 1, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 6.8016, 'staff_wait_mean': 5.2349, 'dish_wait_mean': 2.2046, 'pay_wait_mean': 3.7657}
  {'store': 'fam_p5', 'staff': 3, 'robots': 2, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 4.1481, 'staff_wait_mean': 2.9595, 'dish_wait_mean': 0.9381, 'pay_wait_mean': 4.6189}
  {'store': 'fam_p5', 'staff': 5, 'robots': 0, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.4351, 'staff_wait_mean': 0.4412, 'dish_wait_mean': 0.2734, 'pay_wait_mean': 6.4628}
  {'store': 'fam_p5', 'staff': 5, 'robots': 1, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.6741, 'staff_wait_mean': 0.3881, 'dish_wait_mean': 1.9111, 'pay_wait_mean': 6.2901}
  {'store': 'fam_p5', 'staff': 5, 'robots': 2, 'arrivals_per_h': 42, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.345, 'staff_wait_mean': 0.3287, 'dish_wait_mean': 0.9888, 'pay_wait_mean': 6.4864}
  {'store': 'fam_p2', 'staff': 1, 'robots': 0, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 52.3179, 'staff_wait_mean': 45.62, 'dish_wait_mean': 4.4985, 'pay_wait_mean': 5.035}
  {'store': 'fam_p2', 'staff': 1, 'robots': 1, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 33.0909, 'staff_wait_mean': 31.0084, 'dish_wait_mean': 0.9976, 'pay_wait_mean': 2.9309}
  {'store': 'fam_p2', 'staff': 1, 'robots': 2, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 34.9402, 'staff_wait_mean': 32.0268, 'dish_wait_mean': 0.4127, 'pay_wait_mean': 2.8786}
  {'store': 'fam_p2', 'staff': 3, 'robots': 0, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.4144, 'staff_wait_mean': 1.2457, 'dish_wait_mean': 0.4715, 'pay_wait_mean': 1.3137}
  {'store': 'fam_p2', 'staff': 3, 'robots': 1, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.2807, 'staff_wait_mean': 0.4953, 'dish_wait_mean': 1.3328, 'pay_wait_mean': 1.1876}
  {'store': 'fam_p2', 'staff': 3, 'robots': 2, 'arrivals_per_h': 28, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.0396, 'staff_wait_mean': 0.3745, 'dish_wait_mean': 0.4995, 'pay_wait_mean': 1.195}
  {'store': 'fam_p3', 'staff': 3, 'robots': 0, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 19.1675, 'staff_wait_mean': 17.3662, 'dish_wait_mean': 1.1229, 'pay_wait_mean': 4.0905}
  {'store': 'fam_p3', 'staff': 3, 'robots': 1, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 11.0925, 'staff_wait_mean': 8.8199, 'dish_wait_mean': 2.3162, 'pay_wait_mean': 5.0845}
  {'store': 'fam_p3', 'staff': 3, 'robots': 2, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 8.6074, 'staff_wait_mean': 6.4821, 'dish_wait_mean': 1.0941, 'pay_wait_mean': 6.1547}
  {'store': 'fam_p3', 'staff': 5, 'robots': 0, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.3376, 'staff_wait_mean': 0.8031, 'dish_wait_mean': 0.4131, 'pay_wait_mean': 10.0025}
  {'store': 'fam_p3', 'staff': 5, 'robots': 1, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.5778, 'staff_wait_mean': 0.5629, 'dish_wait_mean': 2.0498, 'pay_wait_mean': 9.9542}
  {'store': 'fam_p3', 'staff': 5, 'robots': 2, 'arrivals_per_h': 46, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 2.5442, 'staff_wait_mean': 0.4806, 'dish_wait_mean': 1.2379, 'pay_wait_mean': 10.2219}
  {'store': 'fam_p4', 'staff': 2, 'robots': 0, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 29.8228, 'staff_wait_mean': 27.3741, 'dish_wait_mean': 1.7226, 'pay_wait_mean': 2.8704}
  {'store': 'fam_p4', 'staff': 2, 'robots': 1, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 15.2881, 'staff_wait_mean': 13.9607, 'dish_wait_mean': 1.8474, 'pay_wait_mean': 2.523}
  {'store': 'fam_p4', 'staff': 2, 'robots': 2, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 14.4836, 'staff_wait_mean': 13.17, 'dish_wait_mean': 0.7008, 'pay_wait_mean': 2.5336}
  {'store': 'fam_p4', 'staff': 4, 'robots': 0, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.6428, 'staff_wait_mean': 1.0439, 'dish_wait_mean': 0.5109, 'pay_wait_mean': 3.4681}
  {'store': 'fam_p4', 'staff': 4, 'robots': 1, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.3295, 'staff_wait_mean': 0.4217, 'dish_wait_mean': 1.8417, 'pay_wait_mean': 3.4677}
  {'store': 'fam_p4', 'staff': 4, 'robots': 2, 'arrivals_per_h': 38, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.7132, 'staff_wait_mean': 0.2559, 'dish_wait_mean': 0.8701, 'pay_wait_mean': 3.4416}
  {'store': 'fam_p0', 'staff': 1, 'robots': 0, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 24.4548, 'staff_wait_mean': 21.484, 'dish_wait_mean': 2.0638, 'pay_wait_mean': 2.2605}
  {'store': 'fam_p0', 'staff': 1, 'robots': 1, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 13.5496, 'staff_wait_mean': 11.7647, 'dish_wait_mean': 0.7762, 'pay_wait_mean': 1.8068}
  {'store': 'fam_p0', 'staff': 1, 'robots': 2, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 13.4456, 'staff_wait_mean': 11.962, 'dish_wait_mean': 0.3702, 'pay_wait_mean': 1.7749}
  {'store': 'fam_p0', 'staff': 3, 'robots': 0, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 1, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.0196, 'staff_wait_mean': 0.3076, 'dish_wait_mean': 0.2302, 'pay_wait_mean': 0.4619}
  {'store': 'fam_p0', 'staff': 3, 'robots': 1, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 1.2351, 'staff_wait_mean': 0.3008, 'dish_wait_mean': 0.9203, 'pay_wait_mean': 0.6173}
  {'store': 'fam_p0', 'staff': 3, 'robots': 2, 'arrivals_per_h': 19, 'mode': 'serve', 'trays': 3, 'fragile_by': 'any', 'registers': 1} {'entry_wait_mean': 0.9928, 'staff_wait_mean': 0.2417, 'dish_wait_mean': 0.4063, 'pay_wait_mean': 0.5738}
公開中の serving-robot-params.json とバイトまで同じ（cmp の出力なし）
dda1c2ad3a2f6c6e42f394eb171f6561  /tmp/p.json
dda1c2ad3a2f6c6e42f394eb171f6561  ../../public/sim/serving-robot-params.json
```


### Unity が読む値を書き出す 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。unity_rules は出力先の今の値を引き継ぐ）
- common：店に共通の値。role が "unity_measures" の値は Unity で測ってサイトに戻すための仮の値なので、Unity は読まない
- stores：Unity で回す店（fam_p1・fam_p5・fam_p2・fam_p3・fam_p4・fam_p0）の値。registers はレジの数（v2.8）
- unity_conditions：Unity で回す条件（0〜17番は店ごとに混み具合が普通の3つ。18〜53番は2026-10-29に足した空いている・混んでいるの各3つ。54〜89番は2026-10-30に足したスタッフが少ない・多いの各3つ（混み具合は普通）。計90条件）と、同じ条件の試算の結果（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", "fam_p2", "fam_p3", "fam_p4", "fam_p0"]   # ④は2026-10-27に追加（スタッフ2人・28組/時）。⑤は2026-10-28に追加（スタッフ4人・46組/時）。⑥も2026-10-28に追加（スタッフ3人・38組/時）。②も2026-10-29に追加（スタッフ2人・19組/時。同じ日に T06 を右へ1.0m 動かして②だけ計算し直した）
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}})

# 2026-10-29 エイイチと決めた：混み具合を「空いている・混んでいる」にも広げて夜に回す。
# 前の18条件（0〜17番）は並びも中身も変えず、後ろに足す（店ごとに 空いている0・1・2台 → 混んでいる0・1・2台）
for k in UNITY_STORES:
    s = h.STORES[k]
    staff = s["staff"][1]           # 真ん中の人数のまま
    for arr in (s["arrivals"][0], s["arrivals"][2]):   # 空いている・混んでいる
        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", "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}})

# 2026-10-30 エイイチと決めた：スタッフの人数を「少ない・多い」にも広げて夜に回す（段階4の中で1回）。
# 前の54条件（0〜53番）は並びも中身も変えず、後ろに足す（店ごとに 少ない0・1・2台 → 多い0・1・2台。混み具合は普通）
for k in UNITY_STORES:
    s = h.STORES[k]
    arr = s["arrivals"][1]          # 普通のまま
    for staff in (s["staff"][0], s["staff"][2]):   # 少ない・多い
        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", "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.4",
        "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 の条件はすべてレジ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,
    # Unity だけの回し方の決まり（unity_rules）は、公開中の params に直接書いて直している（入口番・持ち場など）。
    # ここでは作らず、書き出し先にある今の値をそのまま引き継ぐ（2026-10-31。書き出し直しで消えないように）
    "unity_rules": json.load(open(out, encoding="utf-8"))["unity_rules"],
    "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")})
```


### 表計算ソフトで見るための写しを作る make_table.py と assumptions.csv

正は `assumptions.json` です。CSV は表計算ソフトで開いて眺めるための写しで、CSV を直しても試算には入りません。

```python
"""前提の表（assumptions.json）を、表計算ソフトで開ける CSV（assumptions.csv）に書き出し、根拠の種類ごとの数を出す（2026-10-31）
使い方: python3 make_table.py
- 正は assumptions.json。CSV は見るためのもので、CSV を直しても試算には入らない
- 表を直したら、これを回して CSV も一緒に git に入れる"""
import collections, csv, json, os
here = os.path.dirname(os.path.abspath(__file__))
A = json.load(open(os.path.join(here, "assumptions.json"), encoding="utf-8"))
rows = []
def add(group, store, key, r):
    v = r["value"]
    rows.append([group, store, key, json.dumps(v, ensure_ascii=False) if isinstance(v, (list, dict, bool)) else v,
                 r.get("unit", ""), r.get("what", ""), r.get("basis", ""), r.get("source", ""),
                 "" if r.get("n") is None else r["n"], r.get("updated", ""), r.get("note", "")])
for k, r in A["common"].items():
    add("共通", "", k, r)
for sk, s in A["stores"].items():
    for k, r in s["values"].items():
        add("店のタイプ", s["label"], k, r)
for g, name in (("choices", "選べる幅・回す回数"), ("judge", "判定の線")):
    for k, r in A[g].items():
        add(name, "", k, r)
with open(os.path.join(here, "assumptions.csv"), "w", encoding="utf-8-sig", newline="") as f:
    w = csv.writer(f)
    w.writerow(["区分", "店", "項目", "値", "単位", "何の値か", "根拠", "出典・決めた経緯", "現場で測った回数", "更新日", "補足"])
    w.writerows(rows)
cnt = collections.Counter(r[6] for r in rows)
print(f"{len(rows)} 行を assumptions.csv に書いた")
for k in A["basis_kinds"]:
    print(f"  {k}: {cnt.get(k, 0)}")
```

```
109 行を assumptions.csv に書いた
  仮置き: 79
  実感: 1
  公開事例: 1
  Unity: 0
  現場: 0
  決まり: 17
  設定: 11
```

#### assumptions.csv（全文）

```csv
区分,店,項目,値,単位,何の値か,根拠,出典・決めた経緯,現場で測った回数,更新日,補足
共通,,staff_speed,1.0,m/秒,スタッフが歩く速さ,仮置き,,,,
共通,,robot_speed,0.5,m/秒,ロボットが店内を走る速さ,仮置き,,,,Unity で測って戻す値（Unity の最高は0.6m/秒）。2026-10-24 に今のまま手を付けないと決めた
共通,,guide_s,45,秒,案内（席の説明など。歩く時間は別）,仮置き,,,,
共通,,staff_place_s,10,秒,スタッフが卓に皿を置く,仮置き,,,,
共通,,robot_handoff_s,25,秒,客がロボットから皿を取る,仮置き,,,,
共通,,pay_s,90,秒,会計1組,実感,エイイチのファミレスの経験（1組90秒くらい）,,2026-10-25,
共通,,call_s,30,秒,呼び出しへの対応（歩く時間は別）,仮置き,,,,
共通,,clear_s,40,秒,卓の皿をまとめる,仮置き,,,,
共通,,wipe_s,40,秒,卓を拭いてセットする,仮置き,,,,
共通,,load_s,15,秒,まとめた皿をロボットに載せる,仮置き,,,,
共通,,guest_load_s,20,秒,客が空いた皿をロボットに載せる（巡回して集めるとき）,仮置き,,,,
共通,,pause_rate,0.3,確率（1往復あたり）,一時停止：人が前を横切ってロボットが止まる,仮置き,,,,Unity で測って戻す値
共通,,pause_s,"[3, 10]","秒（最短, 最長）",一時停止の長さ,仮置き,,,,Unity で測って戻す値
共通,,stuck_rate,0.01,確率（1往復あたり）,立ち往生：スタッフが動かしに行かないと解けない,仮置き,,,,Unity で測って戻す値。LOG405 で Unity の方が多いと分かったが、今は変えないと決めた（2026-10-30）
共通,,rescue_s,15,秒,立ち往生したロボットをスタッフが動かす作業,仮置き,,,,
共通,,hop_m,3,m,1往復で複数の卓を回るときの、卓から次の卓への寄り道,仮置き,,,,試算だけの近似。Unity は実際の道を走る
共通,,fragile_cook,"[2, 4, 7]","分（最短, 最頻, 最長）",デザート・ドリンクができるまで,仮置き,,,,
共通,,cold_min,3,分,これを超えて届いた皿を「待たせた皿」とする線,仮置き,,,,
共通,,dish_extra_m,0,m,洗い場が受け渡し口よりさらに奥にあるときの追加距離（店のタイプで上書き）,仮置き,,,,
共通,,sustain,false,,回るかの確かめ（来客を止めない回し方）にするか,設定,,,,
共通,,warmup_min,60,分,回るかの確かめで、数えない慣らしの時間,設定,,,,
共通,,util_sample_s,10,秒,手がふさがっている人数・台数を数える間隔,設定,,,,
共通,,max_extra_min,600,分,数える時間のあと、追いかけるのを打ち切る上限（回らない条件の保険）,設定,,,,
共通,,robot_first_wait_s,120,秒,料理はロボット優先。これだけ待ってもロボットが来なければスタッフが運ぶ,決まり,エイイチと決めた運用（料理はロボット最優先・2分待って来なければ人が運ぶ）,,2026-10-23,
店のタイプ,小型店,tables,18,卓,卓の数,仮置き,,,,
店のタイプ,小型店,dist_m,"[4, 12]","m（最短, 最長）",受け渡し口（洗い場）から卓までの距離の幅,仮置き,,,,
店のタイプ,小型店,peak_min,120,分,ピークの長さ（この時間だけ選んだペースで来客する）,決まり,エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る,,,
店のタイプ,小型店,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,回／組,1組あたりの呼び出しの回数の平均,仮置き,,,,
店のタイプ,小型店,bus_trips,1,往復,下げ膳を人だけでやるときの往復回数（皿の量）,仮置き,,,,
店のタイプ,小型店,fragile,0.3,個／組,1組あたりの倒れやすい物（デザート・ドリンク）の数の平均,仮置き,,,,
店のタイプ,小型店,staff,"[1, 2, 3]","人（少ない, 真ん中, 多い）",最初に見せるスタッフの人数,仮置き,,,,
店のタイプ,小型店,staff_all,"[1, 2, 3, 4, 5]",人,「その他」で選べる人数（総当たりはこちら）,仮置き,,,,
店のタイプ,小型店,arrivals,"[15, 22, 30]","組／時（空いている, 普通, 混んでいる）",来客のペース,仮置き,,,,
店のタイプ,小型店,registers,1,台,最初に見せるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,小型店,registers_all,"[1, 2]",台,選べるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,標準ファミレス,tables,35,卓,卓の数,仮置き,,,,
店のタイプ,標準ファミレス,dist_m,"[6, 22]","m（最短, 最長）",受け渡し口（洗い場）から卓までの距離の幅,仮置き,,,,
店のタイプ,標準ファミレス,peak_min,120,分,ピークの長さ（この時間だけ選んだペースで来客する）,決まり,エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る,,,
店のタイプ,標準ファミレス,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,回／組,1組あたりの呼び出しの回数の平均,仮置き,,,,
店のタイプ,標準ファミレス,bus_trips,2,往復,下げ膳を人だけでやるときの往復回数（皿の量）,仮置き,,,,
店のタイプ,標準ファミレス,fragile,0.5,個／組,1組あたりの倒れやすい物（デザート・ドリンク）の数の平均,仮置き,,,,
店のタイプ,標準ファミレス,staff,"[2, 3, 4]","人（少ない, 真ん中, 多い）",最初に見せるスタッフの人数,仮置き,,,,
店のタイプ,標準ファミレス,staff_all,"[1, 2, 3, 4, 5, 6, 7]",人,「その他」で選べる人数（総当たりはこちら）,仮置き,,,,
店のタイプ,標準ファミレス,arrivals,"[25, 35, 45]","組／時（空いている, 普通, 混んでいる）",来客のペース,仮置き,,,,
店のタイプ,標準ファミレス,registers,1,台,最初に見せるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,標準ファミレス,registers_all,"[1, 2]",台,選べるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,大型店,tables,60,卓,卓の数,仮置き,,,,
店のタイプ,大型店,dist_m,"[8, 35]","m（最短, 最長）",受け渡し口（洗い場）から卓までの距離の幅,仮置き,,,,
店のタイプ,大型店,peak_min,120,分,ピークの長さ（この時間だけ選んだペースで来客する）,決まり,エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る,,,
店のタイプ,大型店,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,回／組,1組あたりの呼び出しの回数の平均,仮置き,,,,
店のタイプ,大型店,bus_trips,2,往復,下げ膳を人だけでやるときの往復回数（皿の量）,仮置き,,,,
店のタイプ,大型店,fragile,0.5,個／組,1組あたりの倒れやすい物（デザート・ドリンク）の数の平均,仮置き,,,,
店のタイプ,大型店,staff,"[4, 5, 6]","人（少ない, 真ん中, 多い）",最初に見せるスタッフの人数,仮置き,,,,
店のタイプ,大型店,staff_all,"[2, 3, 4, 5, 6, 7, 8, 9, 10]",人,「その他」で選べる人数（総当たりはこちら）,仮置き,,,,
店のタイプ,大型店,arrivals,"[45, 60, 75]","組／時（空いている, 普通, 混んでいる）",来客のペース,仮置き,,,,
店のタイプ,大型店,registers,2,台,最初に見せるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,大型店,registers_all,"[1, 2]",台,選べるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,洗い場が遠い店,tables,30,卓,卓の数,仮置き,,,,
店のタイプ,洗い場が遠い店,dist_m,"[6, 20]","m（最短, 最長）",受け渡し口（洗い場）から卓までの距離の幅,仮置き,,,,
店のタイプ,洗い場が遠い店,dish_extra_m,30,m,洗い場が受け渡し口よりさらに奥にあるときの追加距離,公開事例,洗い場が遠いホテルの店の導入事例（ホテル小倉）。距離は事例をもとにした仮の値,,,
店のタイプ,洗い場が遠い店,peak_min,120,分,ピークの長さ（この時間だけ選んだペースで来客する）,決まり,エイイチと決めた：ファミレスはランチ・ディナーで混む時間が限られるので、ピークの2時間で区切る,,,
店のタイプ,洗い場が遠い店,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,回／組,1組あたりの呼び出しの回数の平均,仮置き,,,,
店のタイプ,洗い場が遠い店,bus_trips,2,往復,下げ膳を人だけでやるときの往復回数（皿の量）,仮置き,,,,
店のタイプ,洗い場が遠い店,fragile,0.6,個／組,1組あたりの倒れやすい物（デザート・ドリンク）の数の平均,仮置き,,,,
店のタイプ,洗い場が遠い店,staff,"[2, 3, 4]","人（少ない, 真ん中, 多い）",最初に見せるスタッフの人数,仮置き,,,,
店のタイプ,洗い場が遠い店,staff_all,"[1, 2, 3, 4, 5, 6, 7]",人,「その他」で選べる人数（総当たりはこちら）,仮置き,,,,
店のタイプ,洗い場が遠い店,arrivals,"[16, 22, 28]","組／時（空いている, 普通, 混んでいる）",来客のペース,仮置き,,,,
店のタイプ,洗い場が遠い店,registers,1,台,最初に見せるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,洗い場が遠い店,registers_all,"[1, 2]",台,選べるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,焼肉食べ放題,tables,35,卓,卓の数,仮置き,,,,
店のタイプ,焼肉食べ放題,dist_m,"[6, 22]","m（最短, 最長）",受け渡し口（洗い場）から卓までの距離の幅,仮置き,,,,
店のタイプ,焼肉食べ放題,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,回／組,1組あたりの呼び出しの回数の平均,仮置き,,,,
店のタイプ,焼肉食べ放題,bus_trips,3,往復,下げ膳を人だけでやるときの往復回数（皿の量）,仮置き,,,,
店のタイプ,焼肉食べ放題,fragile,1.5,個／組,1組あたりの倒れやすい物（デザート・ドリンク）の数の平均,仮置き,,,,
店のタイプ,焼肉食べ放題,staff,"[2, 3, 4]","人（少ない, 真ん中, 多い）",最初に見せるスタッフの人数,仮置き,,,,
店のタイプ,焼肉食べ放題,staff_all,"[1, 2, 3, 4, 5, 6, 7]",人,「その他」で選べる人数（総当たりはこちら）,仮置き,,,,
店のタイプ,焼肉食べ放題,arrivals,"[14, 20, 26]","組／時（空いている, 普通, 混んでいる）",来客のペース,仮置き,,,,
店のタイプ,焼肉食べ放題,registers,1,台,最初に見せるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
店のタイプ,焼肉食べ放題,registers_all,"[1, 2]",台,選べるレジの数,決まり,試算 v2.8 でエイイチと決めた（レジ1つ・2つを選べる。大型店は2つを最初に見せる）,,2026-10-25,
選べる幅・回す回数,,robots,"[0, 1, 2, 3]",台,最初に見せるロボットの台数,設定,,,,
選べる幅・回す回数,,robots_all,"[0, 1, 2, 3, 4, 5, 6]",台,「その他」で選べる台数（総当たりはこちら）,決まり,エイイチと決めた：5台・6台も選べるまま残す（ベストな状況だけ見せても納得されないため）,,2026-10-21,
選べる幅・回す回数,,modes,"[""serve"", ""bus"", ""patrol""]",,ロボットの使い方（serve 上げ膳だけ／bus 下げ膳にも使う／patrol 食事中に回って集める）,設定,,,,
選べる幅・回す回数,,trays,"[1, 3]",卓,1往復で回る卓の数（1卓ずつ／最大3卓）,設定,,,,
選べる幅・回す回数,,fragile_by,"[""any"", ""staff""]",,倒れやすい物を ロボットも運ぶ（any）／人だけが運ぶ（staff）,設定,,,,
選べる幅・回す回数,,reps,40,回,1条件を回す回数（結果はこの平均）,設定,,,,
判定の線,,ng_min,10,分,席は空いているのに待った時間がこれ以上なら「店が回らない」,決まり,試算 v2.2 でエイイチと決めた（5分だと厳しめ）,,2026-10-21,
判定の線,,staff_full,0.95,割合,回るかの確かめで、スタッフの手がこれ以上ふさがっていたら「店が回らない」（ページでは使わない）,設定,,,,
判定の線,,unstable_min,5,分,参考：入店待ちがこれ以上延びていく条件（判定には使わない）,設定,,,,
```


### 全条件を分けて回し、1つにまとめる道具

全32,850条件を回すときは、Mac とクラウドに分けて回し、あとで1つの results.json にまとめます。v2.9 のときと同じやり方を、フォルダの名前に頼らない形にして入れました。

#### 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)
```

#### 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][::-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)
```

#### merge_shards.py

```python
"""分けて回した結果（shards/*.json）を1つの results.json にまとめる（v2.9 の merge_shards_v29.py と同じやり方）
使い方: python3 merge_shards.py <shards のフォルダ> <出力 results.json>
- 同じ前提（common・reps）の行だけを使う。条件の並びは haizen_sim.conditions() の順
- 同じ条件が2つの計算機で回っていたら、数字が同じかを確かめる"""
import glob, json, os, sys
import haizen_sim as h
src, out = sys.argv[1], sys.argv[2]
common = json.loads(json.dumps(h.COMMON))
rows, dup, diff = {}, 0, 0
for f in sorted(glob.glob(os.path.join(src, "*.json"))):
    d = json.load(open(f, encoding="utf-8"))
    if d.get("common") != common or d.get("reps") != h.REPS:
        print("前提が違うので使わない:", f); continue
    for r in d["rows"]:
        k = h.key(r)
        if k in rows:
            dup += 1
            if any(abs(rows[k][m] - r[m]) > 1e-9 for m in r if isinstance(r[m], (int, float))):
                diff += 1
        else:
            rows[k] = r
conds = list(h.conditions())
missing = [c for c in conds if h.key(c) not in rows]
print("条件", len(conds), "揃った", len(conds) - len(missing), "足りない", len(missing), "重なり", dup, "うち数字が違う", diff)
if not missing:
    json.dump({"common": h.COMMON, "reps": h.REPS, "rows": [rows[h.key(c)] for c in conds]},
              open(out, "w", encoding="utf-8"), ensure_ascii=False)
    print(out, "を書いた")
```

#### build_page_v29.py（v2.9 のときに試算のページへ結果を入れた道具）

```python
"""試算 v2.9（会計のスタッフの頼みを、レジに並んだ時刻で並べる版）の結果を、公開中の試算のページに入れる（2026-10-30）
使い方: python3 build_page_v29.py <公開中のページ> <v2.9 の results.json> <出力するページ>
- const D：結果の行（r）だけを v2.9 に入れ替える。条件の並び・列・店の情報・平面図は公開中のまま
- 画面：見出しの版を v2.9 にする。前提の「会計とレジ」に v2.9 の決まりを書く
- 費用の欄・LP 用の色・使われ方の記録は公開中のまま"""
import json, 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.9"

lines = s.split("\n")
idx = [i for i, l in enumerate(lines) if l.startswith("const D = ")]
assert len(idx) == 1
D = json.loads(lines[idx[0]][len("const D = "):].rstrip(";"))
K = D["k"]
cond = K[:8]
mets = K[8:-1]
assert cond == ["store", "staff", "robots", "arrivals_per_h", "mode", "trays", "fragile_by", "registers"] and K[-1] == "ng"
conds = list(h.conditions())
rows_by_key = {h.key(r): r for r in N["rows"]}
assert len(rows_by_key) == len(conds) == len(D["r"]), (len(rows_by_key), len(conds), len(D["r"]))
new = []
for old in D["r"]:
    c = dict(zip(cond, old[:8]))
    r = rows_by_key[h.key(c)]
    new.append(old[:8] + [round(r[k], 3) for k in mets] + [1 if r["staff_wait_mean"] >= h.NG_MIN else 0])
D["r"] = new
assert {k: v for k, v in D["common"].items() if k != "model"} == {k: v for k, v in N["common"].items() if k != "model"}
D["common"]["model"] = "v2.9"
lines[idx[0]] = "const D = " + json.dumps(D, ensure_ascii=False, separators=(",", ":")) + ";"
s = "\n".join(lines)

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

rep("数字はすべて仮置き（試作 v2.8）", "数字はすべて仮置き（試作 v2.9）")
rep("会計はレジ1つにつき1組ずつ。食べ終えた組は卓に座ったままレジの順番を待ち、順番が来たらスタッフの手が空くのを待って会計する。",
    "会計はレジ1つにつき1組ずつ。食べ終えた組は卓に座ったままレジの順番を待つ。会計のスタッフは、食べ終えてレジに並んだ順に付き、同じ優先の配膳・案内・呼び出しより先になる。会計が終わると、同じスタッフがそのまま次の組の会計に付く（v2.9 から。それまでは、レジの順番が来てからスタッフを頼んだので、ほかの仕事の後ろに並び、そのあいだレジが止まっていた）。")
open(out, "w", encoding="utf-8").write(s)
print(out, len(new), "rows")
```


### 試算の置き場所の決まり（README.md）と、保存先の決まりに足した2行

~~~~markdown
# 配膳ロボットの試算（サービスロボット導入シミュレーション）

/series8 の配膳ロボットの試算（haizen_sim.py）と、その前提の表。2026-10-31 に iCloud の `simulation/v2_9/` からここへ移した。

## 何のためにこの形にしたか

現場のデータ（観察シート・動画のメモ・店の協力で測った値）が届いたときに、試算へ正確に、すぐ入れられるようにするため。

- 前提の値は `assumptions.json`（前提の表）に1つにまとめた。試算はこの表を読んで回る。コードの中に値を書かない
- 値ごとに、根拠の種類・出典・現場で測った回数・更新日を持つ。どの値が実測で、どれがまだ仮かが表で分かる
- 直した記録は git に残る（いつ・なぜ・どの値を）。版ごとにフォルダをコピーする形（v2_1〜v2_9）はやめた

## 根拠の種類

| 種類 | 意味 |
|---|---|
| 仮置き | まだ根拠がない仮の値。現場・公開事例・Unity の値で置き換えていく |
| 実感 | エイイチの現場の経験（ファミレスで働いていたころ） |
| 公開事例 | メーカーの仕様・導入事例など、公開されている値 |
| Unity | Unity（ServiceSim）で測った値 |
| 現場 | 現場で測った値。`n` に測った回数を書く |
| 決まり | エイイチと決めた、試算の回し方・判定の線・運用の決まり |
| 設定 | 試算の回し方の設定。店の前提ではない |

## ファイル

- `assumptions.json`：前提の表（正）。共通の値（common）・店のタイプ（stores）・図面のある店の作り方（layout_stores）・選べる幅と回す回数（choices）・判定の線（judge）
- `assumptions.csv`：表計算ソフトで見るための写し（`make_table.py` で作る。CSV を直しても試算には入らない）
- `haizen_sim.py`：試算 v2.9。式と乱数の使い方は v2.9 のまま
- `check_same.py`：前の結果と同じ数字が出るかを、条件を選んで回して確かめる
- `run_shard.py`・`run_timed.py`・`merge_shards.py`：全条件を分けて回し、1つの results.json にまとめる
- `export_params.py`：Unity が読む `public/sim/serving-robot-params.json` を書き出す（unity_rules は今の値を引き継ぐ）
- `build_page_v29.py`：v2.9 のときに試算のページへ結果を入れた道具（版ごとに書き替えて使う）

店の形は、`public/sim/layouts/`（正）を直接読む。前は試算のフォルダに写しを置いていた。

## 前提の値を直す流れ

1. `assumptions.json` の値を直し、`basis`・`source`・`n`・`updated` も書く（現場の値なら basis は「現場」、n は測った回数）
2. `python3 make_table.py` で CSV を作り直す
3. 結果に効く値を直したら、`model` の版を上げる（例 v2.9 → v2.10）
4. 全条件を回す（下）→ ページと params に入れる → 公開
5. git に、直した値と理由を書いて入れる

結果（results.json・20MB）は git に入れない。iCloud の `サービスロボット導入シミュレーション/simulation/` に版の名前で置く。

## 現場の記録（観察シート）を取り込む（2026-10-31）

観察シートの記録は、店の人が LINE かメールで店の中の人へ送る。その文が担当の方から届いたら、文をそのまま .txt にして取り込む。

```
python3 field_import.py <field のフォルダ> 届いた文.txt     # field のフォルダは iCloud の サービスロボット導入シミュレーション/field/
```

店のデータは GitHub に入れない。取り込んだもの（原文・1回ずつの記録・店の条件・値の案 proposal.md）は field のフォルダにだけ置く。

| シートの記録 | 前提の表の行 | 入れ方 |
|---|---|---|
| 会計 | `pay_s` | 中央値の秒 |
| 片付け | `clear_s` | 同上 |
| テーブルを拭く | `wipe_s` | 同上 |
| 席へ案内（席に着いて説明を始めたら〜テーブルを離れたら） | `guide_s` | 同上。2026-10-31 16:08（シートの直しの公開）より前の記録は、歩く時間を含む前の区切りなので使わない |
| 呼び出し | `call_s` | 同上 |
| 料理を置く | `staff_place_s` | 同上 |
| ロボットの往復の「止まった」 | `pause_rate` | 1回以上止まった往復の割合 |
| ロボットの往復の「人が動かした」 | `stuck_rate` | 人が動かした往復の割合 |
| ロボットの往復の時間・回転率・滞在時間の目安 | 入れない | 試算の結果と比べる答え合わせ用 |
| スタッフ・卓の数・来店のペース | 入れない（今は記録だけ） | 店ごとの値で試算を回すのは、店ごとの前提を重ねる段で作る |

決まり（2026-10-31 エイイチと決めた）：
- 値は中央値。店ごとに15回に届くまでは「参考」
- 標準の値を置き換える候補にするのは、2店以上・合わせて15回以上そろったとき。表は自動で書き換えない。proposal.md の案と差を見て、エイイチが承認してから直す
- 表に入れるときは basis を「現場」、n に回数、source に店の数と期間を書く。店の名前は書かない

## 回し方

```
pip install simpy==4.1.2
python3 check_same.py <前の results.json> 60        # 表を直していないなら、ずれ 0 になる
python3 run_shard.py <k> <n> shards/<名前>.json      # 分けて回す（Mac 側は run_timed.py で区切る）
python3 merge_shards.py shards results.json
python3 export_params.py results.json ../../public/sim/serving-robot-params.json
python3 make_table.py
```

## 移したときの確かめ（2026-10-31）

- 表から読んだ値（共通・店のタイプ5つ・図面のある店6つ・選べる幅・回す回数・判定の線）が、v2.9 のコードの値と型まで同じ
- 32,850 条件の並びが同じ。前提より下の式は1文字も変えていない
- 店の形を `public/sim/layouts/`（版 9b8dae7ebf8c）から読んでも、卓ごとの距離は v2.9 の写し（08db8a0d067c）と同じ
- `check_same.py` で600条件を回し、v2.9 の results.json と全指標が同じ（ずれ 0）
- `export_params.py` で書き出した params が、公開中のものとバイトまで同じ
~~~~

データの置き場所は、データの種類ごとに1か所と決めています。今回、次の2行を足しました。店の記録は店のデータなので、GitHub には入れません。

| データの種類 | 保存先 |
|---|---|
| 配膳ロボットの試算のコードと前提の表（assumptions.json） | GitHub PixelstoMotors/p2m-site の tools/serving_sim/（結果の results.json は iCloud の simulation/ に版の名前で置く） |
| サービスロボット導入シミュレーションの現場の記録（観察シートの届いた文・1回ずつの記録・店の条件・値の案） | iCloud Drive/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/field/（GitHub に入れない） |


## 作業ログ ― 観察シートの記録を、試算の前提の表に取り込む


### 観察シートの記録は、店の中の人へ LINE・メールで送られる

観察シートは、店の人がスマホで、会計・片付け・テーブルを拭く・席へ案内・呼び出し・料理を置くの6つの作業の時間を、スタートとストップで測るページです。ロボットを入れている店では、ロボットの往復の時間と、止まった回数・人が動かした回数も測れます。

測った記録は、店長・お店の管理の方・ロボットの担当の方へ、LINE かメールで送ります。こちらに届くとしたら、店のデータをまとめた担当の方や、企業の担当の方から、その文がそのまま渡される形です。なので、取り込みの入り口は「届いた文をそのまま貼る」にしました。


### 観察シートの記録と、前提の表の行の対応

| シートの記録 | 前提の表の行 | 入れ方 |
|---|---|---|
| 会計（レジに立つ〜離れる） | `pay_s` | 中央値の秒 |
| 片付け（まとめ始め〜まとめ終わり） | `clear_s` | 同上 |
| テーブルを拭く（拭き始め〜セット終わり） | `wipe_s` | 同上 |
| 席へ案内（席に着いて説明を始めたら〜テーブルを離れたら） | `guide_s` | 同上 |
| 呼び出し（テーブルに着く〜離れる） | `call_s` | 同上 |
| 料理を置く（テーブルに着く〜離れる） | `staff_place_s` | 同上 |
| ロボットの往復の「止まった」 | `pause_rate` | 1回以上止まった往復の割合 |
| ロボットの往復の「人が動かした」 | `stuck_rate` | 人が動かした往復の割合 |
| ロボットの往復の時間・回転率・滞在時間の目安 | 入れない | 卓までの距離が分からないので、ロボットの速さは出せない。試算の結果と比べる答え合わせに使う |
| スタッフ・卓の数・来店のペース | 入れない（今は記録だけ） | 店ごとの前提を重ねる段で使う |

ロボットの2つを「割合」にしたのは、試算の `pause_rate`・`stuck_rate` が「1往復で起きる確率」だからです。1往復で3回止まっても、1回止まった往復として数えます。


### 測った値を試算に入れる決まり4つ

| 決めたこと | 決めた中身 | 理由 |
|---|---|---|
| 届いた記録の置き場所 | iCloud の `サービスロボット導入シミュレーション/field/`。届いた文の原文と、取り込んだ表を置く。GitHub には入れない | 店の記録は店のデータなので、リポジトリが非公開でも GitHub には置かない |
| 席へ案内の時間 | シートの区切りを「席に着いて説明を始めたら〜テーブルを離れたら」に変える | 試算の `guide_s` は歩く時間を含まない。店の形がない店でも、そのまま入れられる |
| 標準の値を置き換える候補にする条件 | 2店以上・合わせて15回以上 | 1店だけだと、その店のくせが全部の店に入ってしまう |
| 店ごとの値で試算を回すか | 今は記録だけ | 店ごとの前提を重ねる仕組みは、記録が届いてから作る |

あわせて、1つの値は測った回数の中央値で入れ、店ごとに15回（シートの目安と同じ）に届くまでは「参考」として扱う、としました。前提の表は自動では書き換えません。値の案と今の値との差を見て、承認してから表を直します。表に入れるときは、根拠を「現場」にして、`n` に回数、出典に店の数と期間を書きます。店の名前は書きません。


### 観察シートの「席へ案内」を、歩く時間を含まない区切りに変えた

前のシートは、案内を「お客さんに声をかけたら〜席に着いたら」で測っていました。これだと、入口から席まで歩く時間が入ります。試算の案内（`guide_s`）は、歩く時間を別に計算して足しているので、シートの時間をそのまま入れると、歩く時間を二重に数えてしまいます。

- ねらい：シートで測った案内の時間を、引き算なしで試算の `guide_s` に入れられるようにする
- 変わったこと：測る区切りを「席に着いて説明を始めたら〜テーブルを離れたら」に変えた。画面の「試算で置いている値」も「45秒（歩く時間は別）」から「45秒」にした
- 見込める効果：店の形（図面）がない店の記録でも、案内の時間を試算に入れられる（見込み）

```diff
diff --git a/public/sim/serving-robot-observation.html b/public/sim/serving-robot-observation.html
index 3566b22..e5342b8 100644
--- a/public/sim/serving-robot-observation.html
+++ b/public/sim/serving-robot-observation.html
@@ -248,7 +248,7 @@ var TASKS=[
  {k:'pay',t:'会計',s:'お客さんがレジに立ったら',e:'レジを離れたら',sim:'90秒'},
  {k:'clear',t:'片付け',s:'お皿をまとめ始めたら',e:'まとめ終わったら',sim:'40秒'},
  {k:'wipe',t:'テーブルを拭く',s:'拭き始めたら',e:'セットし終わったら',sim:'40秒'},
- {k:'guide',t:'席へ案内',s:'お客さんに声をかけたら',e:'席に着いたら',sim:'45秒（歩く時間は別）'},
+ {k:'guide',t:'席へ案内',s:'席に着いて説明を始めたら',e:'テーブルを離れたら',sim:'45秒'},
  {k:'call',t:'呼び出し',s:'テーブルに着いたら',e:'テーブルを離れたら',sim:'30秒'},
  {k:'place',t:'料理を置く',s:'テーブルに着いたら',e:'テーブルを離れたら',sim:'10秒'}
 ];
```

【画像】LOG415_01_sheet_guide.png 観察シートの「席へ案内」の測る画面。0:00 の下に「席に着いて説明を始めたら、スタート」と出ている

直しを公開したのは 2026-10-31 の16時8分ごろです。それより前に測った案内の記録は、歩く時間を含む前の区切りなので、値の案には使いません。


### 届いた文を取り込み、値の案を出す field_import.py（全文）

届いた文を `.txt` にして渡すと、`field/` に次の4つを作ります。

- `raw/`：届いた文の原文の写し（直さない）
- `observations.csv`：1回ずつの記録。同じ店・同じ日・同じ時刻・同じ作業の行は、二重に入れない
- `store_conditions.csv`：1通ごとの店の条件（スタッフ・卓の数・時間帯・ようす・来店のペースなど）
- `proposal.md`：前提の表に入れる値の案と、今の値との差。取り込むたびに作り直す

```python
"""観察シートの記録（LINE・メールで届いた文）を取り込み、試算の前提の表に入れる値の案を出す（2026-10-31）

使い方:
    python3 field_import.py <field のフォルダ> <届いた文.txt> [<届いた文.txt> ...]   # 取り込んで、案を作り直す
    python3 field_import.py <field のフォルダ>                                         # 案だけ作り直す

field のフォルダは iCloud の サービスロボット導入シミュレーション/field/（店のデータは GitHub に入れない）。
- raw/                  届いた文の原文（取り込んだときに写す。直さない）
- observations.csv      1行1回の記録（作業の時間・ロボットの往復）。同じ店・同じ日・同じ時刻・同じ作業の行は二重に入れない
- store_conditions.csv  1通の記録ごとの、その店の条件（スタッフ・卓の数・時間帯・ようす・来店のペースなど）。今は記録だけ
- proposal.md           前提の表に入れる値の案と、今の値との差（毎回作り直す）

決まり（2026-10-31 エイイチと決めた）:
- 1つの値は、測った回数の中央値で入れる
- 店ごとに15回に届くまでは「参考」
- 標準の値（assumptions.json）を置き換える候補にするのは、2店以上・合わせて15回以上そろったとき。
  自動では書き換えない。案と差を見て、エイイチが承認してから表を直す（basis を「現場」、n に回数、source に店の数と期間。店の名前は書かない）
- 店ごとの値で試算を回すのは、今はしない（記録だけ）
- 席へ案内は 2026-10-31 にシートの区切りを「席に着いて説明を始めたら〜テーブルを離れたら」に変えた（歩く時間を含まない＝guide_s と同じ定義）。
  それより前（2026-10-31 16:08 より前）の案内の記録は、歩く時間を含むので案に使わない
"""
import csv
import json
import os
import re
import shutil
import statistics
import sys
from datetime import date, datetime

HERE = os.path.dirname(os.path.abspath(__file__))
GOAL = 15          # 店ごとに「参考」を外す回数（シートの目安と同じ）
MIN_STORES = 2     # 標準の値の候補にする店の数
GUIDE_FROM = datetime(2026, 10, 31, 16, 8)   # これより前の案内の記録は、前の区切り（歩く時間を含む）。シートの直しが公開された時刻

# シートの作業の名前 → 前提の表の行（どれも中央値の秒をそのまま入れる）
TASK_TO_KEY = {
    "会計": "pay_s",
    "片付け": "clear_s",
    "テーブルを拭く": "wipe_s",
    "席へ案内": "guide_s",
    "呼び出し": "call_s",
    "料理を置く": "staff_place_s",
}
ROBOT_TASK = "ロボットの往復"
# ロボットの往復 → 前提の表の行（往復のうち、1回以上止まった・人が動かした往復の割合。試算も「1往復で起きる確率」）
ROBOT_TO_KEY = {"stops": "pause_rate", "helps": "stuck_rate"}

OBS_COLS = ["store", "date", "time", "kind", "sec", "stops", "helps", "raw_file"]
COND_COLS = ["store", "date", "purpose", "time_of_day", "tags", "staff", "robots", "tables",
             "arrived", "left", "arrive_per_h", "left_per_h", "turnover_per_h", "stay_min", "occ0", "note", "raw_file"]


def parse(text):
    """届いた文を読む。読めない行は飛ばす"""
    lines = [l.rstrip() for l in text.replace("\r\n", "\n").split("\n")]
    if not lines or "観察シートの記録" not in lines[0]:
        raise ValueError("観察シートの記録の文ではない（1行目が「観察シートの記録」でない）")
    info = {"store": "", "date": "", "purpose": "", "time_of_day": "", "tags": "", "staff": "", "robots": "",
            "tables": "", "note": "", "arrived": "", "left": "", "arrive_per_h": "", "left_per_h": "",
            "turnover_per_h": "", "stay_min": "", "occ0": ""}
    recs, sec = [], None
    for l in lines[1:]:
        if l.startswith("---"):
            sec = l
            continue
        if sec and "1回ずつの記録" in sec:
            m = re.match(r"(\d\d:\d\d:\d\d)\s+(\S+)\s+([\d.]+)秒(?:（止まった(\d+)回・人が動かした(\d+)回）)?", l)
            if m:
                recs.append({"time": m[1], "kind": m[2], "sec": float(m[3]),
                             "stops": m[4] or "", "helps": m[5] or ""})
            continue
        if sec:
            continue
        if l.startswith("日付："):
            y, mo, d = re.findall(r"\d+", l)[:3]
            info["date"] = f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
        elif l.startswith("目的："):
            info["purpose"] = l[3:]
        elif l.startswith("お店："):
            m = re.match(r"お店：(.*?)　スタッフ(\d+)人(?:　ロボット(\d+)台)?", l)
            if m:
                info["store"] = "" if m[1] == "（未入力）" else m[1].strip()
                info["staff"], info["robots"] = m[2], m[3] or ""
        elif l.startswith("卓の数："):
            info["tables"] = (re.findall(r"\d+", l) or [""])[0]
        elif l.startswith("時間帯："):
            a = l[4:].split("　", 1)
            info["time_of_day"], info["tags"] = a[0], (a[1] if len(a) > 1 else "")
        elif l.startswith("メモ："):
            info["note"] = l[3:]
        elif l.startswith("出入り："):
            n = re.findall(r"(\d+)組", l)
            if len(n) >= 2:
                info["arrived"], info["left"] = n[0], n[1]
            m = re.search(r"数えはじめに(\d+)卓", l)
            info["occ0"] = m[1] if m else ""
        elif l.startswith("1時間あたり："):
            n = re.findall(r"約(\d+)組", l)
            if len(n) >= 2:
                info["arrive_per_h"], info["left_per_h"] = n[0], n[1]
        elif l.startswith("回転率："):
            info["turnover_per_h"] = (re.findall(r"[\d.]+", l) or [""])[0]
        elif l.startswith("滞在時間の目安："):
            info["stay_min"] = (re.findall(r"\d+", l) or [""])[0]
    if not info["date"]:
        raise ValueError("日付の行がない")
    if not info["store"]:
        info["store"] = "名前なし_" + info["date"]
    return info, recs


def read_csv(path, cols):
    if not os.path.exists(path):
        return []
    with open(path, encoding="utf-8-sig", newline="") as f:
        return list(csv.DictReader(f))


def write_csv(path, cols, rows):
    with open(path + ".tmp", "w", encoding="utf-8-sig", newline="") as f:
        w = csv.DictWriter(f, fieldnames=cols)
        w.writeheader()
        w.writerows(rows)
    os.replace(path + ".tmp", path)


def take(field, src):
    text = open(src, encoding="utf-8").read()
    info, recs = parse(text)
    os.makedirs(os.path.join(field, "raw"), exist_ok=True)
    base = f"{info['date']}_{re.sub(r'[^0-9A-Za-zぁ-んァ-ヶ一-龠ー_]', '', info['store'])}"
    raw_name, i = base + ".txt", 1
    while os.path.exists(os.path.join(field, "raw", raw_name)):
        if open(os.path.join(field, "raw", raw_name), encoding="utf-8").read() == text:
            break      # 同じ文はもう取り込んである
        i += 1
        raw_name = f"{base}_{i}.txt"
    else:
        shutil.copyfile(src, os.path.join(field, "raw", raw_name))

    obs_path = os.path.join(field, "observations.csv")
    obs = read_csv(obs_path, OBS_COLS)
    seen = {(r["store"], r["date"], r["time"], r["kind"]) for r in obs}
    added = 0
    for r in recs:
        k = (info["store"], info["date"], r["time"], r["kind"])
        if k in seen:
            continue
        seen.add(k)
        obs.append({"store": info["store"], "date": info["date"], "time": r["time"], "kind": r["kind"],
                    "sec": r["sec"], "stops": r["stops"], "helps": r["helps"], "raw_file": raw_name})
        added += 1
    write_csv(obs_path, OBS_COLS, obs)

    cond_path = os.path.join(field, "store_conditions.csv")
    conds = [c for c in read_csv(cond_path, COND_COLS) if c["raw_file"] != raw_name]
    conds.append({**{c: info.get(c, "") for c in COND_COLS}, "raw_file": raw_name})
    write_csv(cond_path, COND_COLS, conds)
    print(f"{src}: {info['store']} {info['date']} → 記録 {len(recs)} 回（新しく {added} 回）・原文 raw/{raw_name}")


def fmt(v):
    return "—" if v is None else (f"{v:.3f}".rstrip("0").rstrip(".") if isinstance(v, float) else str(v))


def proposal(field):
    A = json.load(open(os.path.join(HERE, "assumptions.json"), encoding="utf-8"))
    obs = read_csv(os.path.join(field, "observations.csv"), OBS_COLS)
    L = ["# 前提の表に入れる値の案（観察シートの記録から）", "",
         f"作った日：{date.today().isoformat()}　記録 {len(obs)} 回", "",
         f"- 店ごとに {GOAL} 回に届くまでは「参考」。標準の値の候補は、{MIN_STORES}店以上・合わせて {GOAL} 回以上",
         "- 表はまだ書き換えていない。候補を表に入れるかはエイイチが決める（basis を「現場」、n に回数、source に店の数と期間。店の名前は書かない）",
         "- 中央値で比べる。席へ案内は 2026-10-31 16:08 より前の記録を使わない（歩く時間を含む前の区切り）", ""]

    def rows_for(kind):
        out = [r for r in obs if r["kind"] == kind]
        if kind == "席へ案内":
            out = [r for r in out if datetime.fromisoformat(f"{r['date']}T{r['time']}") >= GUIDE_FROM]
        return out

    L += ["## 作業の時間（秒）", "",
          "| 行 | シートの作業 | 今の値（根拠） | 全体の中央値 | 店の数 | 回数 | 判定 | 店ごと（中央値・回数） |",
          "|---|---|---|---|---|---|---|---|"]
    for task, key in TASK_TO_KEY.items():
        rs = rows_for(task)
        cur = A["common"][key]
        by = {}
        for r in rs:
            by.setdefault(r["store"], []).append(float(r["sec"]))
        allv = [float(r["sec"]) for r in rs]
        med = statistics.median(allv) if allv else None
        judge = "候補" if len(by) >= MIN_STORES and len(allv) >= GOAL else ("参考" if allv else "記録なし")
        per = "、".join(f"{s} {fmt(statistics.median(v))}秒・{len(v)}回{'' if len(v) >= GOAL else '（参考）'}" for s, v in by.items())
        L.append(f"| `{key}` | {task} | {cur['value']}（{cur['basis']}） | {fmt(med)} | {len(by)} | {len(allv)} | {judge} | {per or '—'} |")

    L += ["", "## ロボット（往復のうち、起きた往復の割合）", "",
          "| 行 | 数え方 | 今の値（根拠） | 全体 | 店の数 | 往復の回数 | 判定 | 店ごと |", "|---|---|---|---|---|---|---|---|"]
    rs = [r for r in obs if r["kind"] == ROBOT_TASK]
    for f, key in ROBOT_TO_KEY.items():
        cur = A["common"][key]
        by = {}
        for r in rs:
            by.setdefault(r["store"], []).append(1 if int(r[f] or 0) > 0 else 0)
        allv = [x for v in by.values() for x in v]
        rate = sum(allv) / len(allv) if allv else None
        judge = "候補" if len(by) >= MIN_STORES and len(allv) >= GOAL else ("参考" if allv else "記録なし")
        name = "1回以上止まった往復" if f == "stops" else "人が動かした往復"
        per = "、".join(f"{s} {fmt(sum(v) / len(v))}・{len(v)}往復" for s, v in by.items())
        L.append(f"| `{key}` | {name} | {cur['value']}（{cur['basis']}） | {fmt(rate)} | {len(by)} | {len(allv)} | {judge} | {per or '—'} |")
    secs = [float(r["sec"]) for r in rs]
    L += ["", f"ロボットの往復の時間（答え合わせ用・表には入れない）：中央値 {fmt(statistics.median(secs)) if secs else '—'} 秒・{len(secs)} 往復"]

    conds = read_csv(os.path.join(field, "store_conditions.csv"), COND_COLS)
    L += ["", "## 店の条件（今は記録だけ。店ごとの値で試算は回さない）", "",
          "| 店 | 日付 | 時間帯・ようす | スタッフ | ロボット | 卓 | 来店／時 | 回転率／時 | 滞在の目安（分） |", "|---|---|---|---|---|---|---|---|---|"]
    for c in conds:
        L.append(f"| {c['store']} | {c['date']} | {c['time_of_day']} {c['tags']} | {c['staff']} | {c['robots'] or '—'} | {c['tables'] or '—'} "
                 f"| {c['arrive_per_h'] or '—'} | {c['turnover_per_h'] or '—'} | {c['stay_min'] or '—'} |")
    open(os.path.join(field, "proposal.md"), "w", encoding="utf-8").write("\n".join(L) + "\n")
    print("proposal.md を作り直した")


if __name__ == "__main__":
    field = sys.argv[1]
    os.makedirs(field, exist_ok=True)
    for src in sys.argv[2:]:
        take(field, src)
    proposal(field)
```


### スマホで測った試しの記録7回を取り込んだ

道具ができたあと、使えるかを試しました。机の上で観察シートを開き、作業ごとに1〜2回スタートとストップを押して、記録を送る文をチャットに貼りました。店で測ったものではありません。

#### 届いた文（試しの記録）

```text
観察シートの記録
日付：2026/10/31
目的：ロボットを入れていて、改善したい
お店：（未入力）　スタッフ3人　ロボット2台
卓の数：約20卓
時間帯：昼（ランチ）　スタッフが足りない・いつもどおり
会計：だいたい13秒（1回）
テーブルを拭く：だいたい12秒（1回）
席へ案内：だいたい11秒（1回）
呼び出し：だいたい7秒（1回）
料理を置く：だいたい8秒（2回）
ロボットの往復：だいたい27秒（1回）
出入り：入ってきた15組・会計した7組（数えはじめに10卓にお客さん）

--- 1回ずつの記録 ---
16:10:41　会計　12.7秒
16:10:57　席へ案内　10.7秒
16:11:12　ロボットの往復　26.7秒（止まった0回・人が動かした1回）
16:11:56　料理を置く　5.3秒
16:12:04　料理を置く　10.1秒
16:12:18　呼び出し　6.9秒
16:12:28　テーブルを拭く　12秒

--- 入ってきた時刻 ---
16:11:44、16:11:44、16:11:45、16:11:45、16:11:45、16:11:45、16:11:46、16:11:46、16:11:50、16:11:51、16:11:51、16:11:51、16:11:51、16:11:51、16:11:52

--- 会計した時刻 ---
16:11:48、16:11:48、16:11:48、16:11:49、16:11:49、16:11:52、16:11:53
```

#### 取り込んだときの出力

```
../try_eiichi.txt: 名前なし_2026-10-31 2026-10-31 → 記録 7 回（新しく 7 回）・原文 raw/2026-10-31_名前なし_20261031.txt
proposal.md を作り直した
```

#### proposal.md（値の案）

```markdown
# 前提の表に入れる値の案（観察シートの記録から）

作った日：2026-10-31　記録 7 回

- 店ごとに 15 回に届くまでは「参考」。標準の値の候補は、2店以上・合わせて 15 回以上
- 表はまだ書き換えていない。候補を表に入れるかはエイイチが決める（basis を「現場」、n に回数、source に店の数と期間。店の名前は書かない）
- 中央値で比べる。席へ案内は 2026-10-31 16:08 より前の記録を使わない（歩く時間を含む前の区切り）

## 作業の時間（秒）

| 行 | シートの作業 | 今の値（根拠） | 全体の中央値 | 店の数 | 回数 | 判定 | 店ごと（中央値・回数） |
|---|---|---|---|---|---|---|---|
| `pay_s` | 会計 | 90（実感） | 12.7 | 1 | 1 | 参考 | 名前なし_2026-10-31 12.7秒・1回（参考） |
| `clear_s` | 片付け | 40（仮置き） | — | 0 | 0 | 記録なし | — |
| `wipe_s` | テーブルを拭く | 40（仮置き） | 12 | 1 | 1 | 参考 | 名前なし_2026-10-31 12秒・1回（参考） |
| `guide_s` | 席へ案内 | 45（仮置き） | 10.7 | 1 | 1 | 参考 | 名前なし_2026-10-31 10.7秒・1回（参考） |
| `call_s` | 呼び出し | 30（仮置き） | 6.9 | 1 | 1 | 参考 | 名前なし_2026-10-31 6.9秒・1回（参考） |
| `staff_place_s` | 料理を置く | 10（仮置き） | 7.7 | 1 | 2 | 参考 | 名前なし_2026-10-31 7.7秒・2回（参考） |

## ロボット（往復のうち、起きた往復の割合）

| 行 | 数え方 | 今の値（根拠） | 全体 | 店の数 | 往復の回数 | 判定 | 店ごと |
|---|---|---|---|---|---|---|---|
| `pause_rate` | 1回以上止まった往復 | 0.3（仮置き） | 0 | 1 | 1 | 参考 | 名前なし_2026-10-31 0・1往復 |
| `stuck_rate` | 人が動かした往復 | 0.01（仮置き） | 1 | 1 | 1 | 参考 | 名前なし_2026-10-31 1・1往復 |

ロボットの往復の時間（答え合わせ用・表には入れない）：中央値 26.7 秒・1 往復

## 店の条件（今は記録だけ。店ごとの値で試算は回さない）

| 店 | 日付 | 時間帯・ようす | スタッフ | ロボット | 卓 | 来店／時 | 回転率／時 | 滞在の目安（分） |
|---|---|---|---|---|---|---|---|---|
| 名前なし_2026-10-31 | 2026-10-31 | 昼（ランチ） スタッフが足りない・いつもどおり | 3 | 2 | 20 | — | — | — |
```

#### observations.csv

```csv
store,date,time,kind,sec,stops,helps,raw_file
名前なし_2026-10-31,2026-10-31,16:10:41,会計,12.7,,,2026-10-31_名前なし_20261031.txt
名前なし_2026-10-31,2026-10-31,16:10:57,席へ案内,10.7,,,2026-10-31_名前なし_20261031.txt
名前なし_2026-10-31,2026-10-31,16:11:12,ロボットの往復,26.7,0,1,2026-10-31_名前なし_20261031.txt
名前なし_2026-10-31,2026-10-31,16:11:56,料理を置く,5.3,,,2026-10-31_名前なし_20261031.txt
名前なし_2026-10-31,2026-10-31,16:12:04,料理を置く,10.1,,,2026-10-31_名前なし_20261031.txt
名前なし_2026-10-31,2026-10-31,16:12:18,呼び出し,6.9,,,2026-10-31_名前なし_20261031.txt
名前なし_2026-10-31,2026-10-31,16:12:28,テーブルを拭く,12.0,,,2026-10-31_名前なし_20261031.txt
```

#### store_conditions.csv

```csv
store,date,purpose,time_of_day,tags,staff,robots,tables,arrived,left,arrive_per_h,left_per_h,turnover_per_h,stay_min,occ0,note,raw_file
名前なし_2026-10-31,2026-10-31,ロボットを入れていて、改善したい,昼（ランチ）,スタッフが足りない・いつもどおり,3,2,20,15,7,,,,,10,,2026-10-31_名前なし_20261031.txt
```

7回の記録が、表の6つの行（会計・テーブルを拭く・席へ案内・呼び出し・料理を置く・人が動かした往復の割合）と、止まった往復の割合に分かれて入りました。どれも1店で15回に届いていないので、判定は全部「参考」で、前提の表は書き換えていません。来店のペースと回転率が空なのは、数えた時間が10秒ほどしかなく、シートが「1時間あたり」の行を出さなかったためです。店で15分以上数えれば出ます。

試しの記録は、本物の店の記録と混ざらないように、`field/_試し/` に入れました。


### 試しで見つけた、席へ案内の記録を使い始める日のずれ

最初の道具は、新しい区切りの案内の記録を「2026-11-01 から」使う決まりにしていました。ところが、試しの記録は 10月31日の16時10分に、もう新しい区切りで測ったものです。日付で分けると、この記録が使われません。そこで、日付ではなく、シートの直しを公開した時刻（2026-10-31 16:08）より後の記録から使う形に直しました。上の proposal.md は、直したあとの道具で作り直したものです。


## 作業ログ ― 配膳ロボットの比較動画を、まとめて作る準備


### 比較動画は、同じお客さんの来方で撮った2本を左右に並べる

比較動画は、Unity で撮った同じ店の2本（たとえばロボット0台と2台）を、左右に並べて同時に流す動画です。同じ店の録画は、どれも同じお客さんの来方で撮っているので、違いはロボットの台数やスタッフの人数だけになります。1店7本、6店で42本を作ります。

素材の66本は、スタッフの待つ場所の決まり（手が空いた人のうち1人はレジ内で入口を見て、ほかの人はパントリーのまわりに散らばる）を入れたので、今夜 Claude Code が撮り直します。21時に依頼箱へ入れる予約をしてあり、Mac の作業はありません。KIOXIA の空きは 1.8TB で、前の夜の66本は 2.6GB でした。


### 比較動画を作る make_compare_stock.py を、新しい素材に向けた

- 読む素材の夜を選べるようにした（`--night`。既定は今夜の `20261031_rec`）。できた動画は `stock/{夜の名前}/` に入れ、前の夜の素材で作った動画と混ざらないようにした
- 1本目で止まっていた原因を直した。作業用のファイルを `/tmp` の決まった名前で作っていたため、前の回の残りとぶつかって書けなかった。毎回新しい作業用のフォルダを作る形にした
- 字幕の字体を選べるようにした（`CMP_FONT`）。試しに頭の数秒だけ作れるようにした（`--max-sec`）

```python
"""比較動画のストックを、録画ストック（night/{夜の名前}）から順に作る（2026-10-31）
使い方: python3 make_compare_stock.py [--night 20261031_rec] [--out フォルダ] [--only N]   ※ できている mp4 は飛ばす
- 既定の夜は 20261031_rec（入口番・持ち場ありで撮り直した素材）。出力は stock/{夜の名前}/
- 1店7本：空いている・スタッフが多い・混んでいる・スタッフが少ない・普通（0台と2台）、普通の0台と1台・1台と2台
- 並べる順は、店 ③⑦④⑤⑥②、店の中は上の順（図の右上から）
- 片側は元の録画の半分の大きさ（幅1280）。縦は店の形に合わせる（縦横比を保つ）
- 30倍速（録画は15倍速なので1コマおき）
出力: videos/service_sim_compare/stock/{店}_{条件}_r{左}_vs_r{右}.mp4（字幕の控え .ass も）・進み具合は stock/progress.log
"""
import csv, sys, os, subprocess, bisect, time, tempfile
TMP = tempfile.mkdtemp(prefix='cmp_')   # 作業用（前の回の残りとぶつからないよう、毎回新しく作る）
FONT = os.environ.get('CMP_FONT', 'Noto Sans CJK JP')   # Mac で Noto がなければ CMP_FONT='Hiragino Sans' にする
MAXSEC = float(sys.argv[sys.argv.index('--max-sec') + 1]) if '--max-sec' in sys.argv else None   # 試しに頭の数秒だけ作る
HOME = os.path.expanduser('~')
U = os.environ.get('SIM_UNITY', HOME + '/mnt/KIOXIA/r2r2r-data/service_sim/unity')
NIGHT = sys.argv[sys.argv.index('--night') + 1] if '--night' in sys.argv else '20261031_rec'
OUTD = sys.argv[sys.argv.index('--out') + 1] if '--out' in sys.argv else os.environ.get('CMP_OUT', HOME + '/mnt/KIOXIA/r2r2r-data/videos/service_sim_compare/stock/' + NIGHT)
os.makedirs(OUTD, exist_ok=True)
NAME = {'fam_p0': '② 定番の郊外型', 'fam_p1': '③ 効率重視・入口が角', 'fam_p2': '④ 効率重視・小さめの店',
        'fam_p3': '⑤ ゆったり・入口が正面', 'fam_p4': '⑥ ゆったり・入口が横', 'fam_p5': '⑦ 効率重視・席を詰めた店'}
STORES = ['fam_p1', 'fam_p5', 'fam_p2', 'fam_p3', 'fam_p4', 'fam_p0']
CONDS = [('空いている', 'aki', 0, 2), ('スタッフが多い', 'stmany', 0, 2), ('混んでいる', 'komu', 0, 2),
         ('スタッフが少ない', 'stfew', 0, 2), ('普通・真ん中の人数', 'futsu', 0, 2), ('普通・真ん中の人数', 'futsu', 0, 1), ('普通・真ん中の人数', 'futsu', 1, 2)]
SPD = 2
rows = list(csv.DictReader(open(U + '/night/' + NIGHT + '/rec_index.csv', encoding='utf-8')))
IDX = {r['回の名前']: r for r in rows}
def find(store, cond, r):
    for x in rows:
        if store in x['条件の名前'] and x['条件の種類'] == cond and int(x['ロボット台数']) == r: return x
    raise KeyError((store, cond, r))
def ts(v):
    return f'{int(v // 3600)}:{int(v % 3600 // 60):02d}:{v % 60:05.2f}'
def clock(t):
    return f'{int(t // 3600)}:{int(t % 3600 // 60):02d}'
def info(run):
    d = f'{U}/{run}'
    T = [(float(r['t_s']), int(r['waiting_groups']), int(r['tables_dirty_untaken']) + int(r['tables_clearing'])) for r in csv.DictReader(open(d + '/timeline.csv'))]
    S = sorted(float(r['t_s']) for r in csv.DictReader(open(d + '/stops.csv')) if r['resolved'] == '動けなくなった')
    fr = [float(r[list(r.keys())[1]]) for r in csv.DictReader(open(f'{d}/recordings/{run}_whole_frames.csv'))]
    return T, S, fr
def make(L, R, out):
    W = 1280
    sw, sh = map(int, L['解像度'].split('x')); H = round(sh * W / sw / 2) * 2
    TOP, BOT = 150, 70
    store = L['条件の名前'].split('_')[1]; store = 'fam_' + store
    cap = f'{NAME[store]}　{L["条件の種類"]}：スタッフ{L["スタッフ"]}人・1時間{L["来客/時"]}組　同じお客さんの来方（種{L["種"]}）で並べた Unity の3Dシミュレーション　30倍速'
    ev = []; dur = 0
    for side, x in ((0, L), (1, R)):
        run = x['回の名前']; T, S, fr = info(run)
        vlen = len(fr) / 30 / SPD; dur = max(dur, vlen); px = side * W + 40
        ev.append((0, 99999, f'{{\\an7\\pos({px},18)\\fs46\\b1}}ロボット{x["ロボット台数"]}台'))
        for i, (t, wg, dirty) in enumerate(T):
            a = bisect.bisect_left(fr, t) / 30 / SPD
            b = (bisect.bisect_left(fr, T[i + 1][0]) / 30 / SPD) if i + 1 < len(T) else vlen
            if a >= vlen: break
            n = bisect.bisect_right(S, t)
            ev.append((a, b, f'{{\\an7\\pos({px},84)\\fs30}}店の時刻 {clock(t)}　入店待ちの組 {wg}　片付け待ちの卓 {dirty}　動けなくなった 累計{n}回'))
        ev.append((vlen, 99999, f'{{\\an7\\pos({px},84)\\fs30\\b1}}おわり　入店待ちの平均 {float(x["入店待ち（分）"]):.1f}分　動けなくなった {x["動けなくなった（回）"]}回'))
    ev.append((0, 99999, f'{{\\an1\\pos(40,{TOP + H + BOT - 18})\\fs26}}{cap}'))
    ev.append((0, 99999, f'{{\\an3\\pos({2 * W - 40},{TOP + H + BOT - 18})\\fs26}}{{\\c&HE07A2A&}}●{{\\c&HF0F5F5&}} スタッフ　{{\\c&H22C8F5&}}●{{\\c&HF0F5F5&}} お客さん　{{\\c&HFFFFFF&}}●{{\\c&HF0F5F5&}} 配膳ロボット'))
    end = dur + 4
    ass = ['[Script Info]', 'ScriptType: v4.00+', f'PlayResX: {2 * W}', f'PlayResY: {TOP + H + BOT}', '', '[V4+ Styles]',
           'Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV',
           f'Style: D,{FONT},30,&H00F0F5F5,&H000D0D0D,&H000D0D0D,0,0,1,0,0,7,0,0,0', '', '[Events]', 'Format: Layer, Start, End, Style, Text']
    ass += [f'Dialogue: 0,{ts(a)},{ts(min(b, end))},D,{t}' for a, b, t in ev]
    af = os.path.join(TMP, os.path.basename(out)[:-4] + '.ass')
    open(af, 'w', encoding='utf-8').write('\n'.join(ass) + '\n')
    open(out[:-4] + '.ass', 'w', encoding='utf-8').write('\n'.join(ass) + '\n')
    vin = [f'{U}/{x["回の名前"]}/recordings/{x["回の名前"]}_whole.mp4' for x in (L, R)]
    sel = f'select=not(mod(n\\,{SPD})),setpts=N/30/TB,'
    fc = (f'[0:v]{sel}scale={W}:{H},tpad=stop_mode=clone:stop_duration=900[a];[1:v]{sel}scale={W}:{H},tpad=stop_mode=clone:stop_duration=900[b];'
          f'[a][b]hstack=2,pad={2 * W}:{TOP + H + BOT}:0:{TOP}:color=0x0d0d0d,drawbox=x={W - 1}:y=0:w=3:h={TOP + H}:color=0xf5f5f0:t=fill,ass={af},trim=duration={(min(end, MAXSEC) if MAXSEC else end):.2f}[v]')
    tmp = os.path.join(TMP, os.path.basename(out))
    subprocess.run(['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', '-i', vin[0], '-i', vin[1], '-filter_complex', fc, '-map', '[v]', '-r', '30',
                    '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', tmp], check=True)
    subprocess.run(['cp', tmp, out], check=True); os.remove(tmp)
    return end
jobs = [(s, c, k, a, b) for s in STORES for c, k, a, b in CONDS]
only = int(sys.argv[sys.argv.index('--only') + 1]) if '--only' in sys.argv else None
log = open(OUTD + '/progress.log', 'a', encoding='utf-8')
for n, (s, c, k, a, b) in enumerate(jobs, 1):
    if only and n != only: continue
    out = f'{OUTD}/{s.replace("fam_", "")}_{k}_r{a}_vs_r{b}.mp4'
    if os.path.exists(out): continue
    t0 = time.time()
    try:
        end = make(find(s, c, a), find(s, c, b), out)
        log.write(f'{time.strftime("%m-%d %H:%M")} できた {n}/{len(jobs)} {os.path.basename(out)} 長さ{end:.0f}秒 作るのに{time.time() - t0:.0f}秒\n')
    except Exception as e:
        log.write(f'{time.strftime("%m-%d %H:%M")} 失敗 {n}/{len(jobs)} {os.path.basename(out)} {e}\n')
    log.flush()
log.write(f'{time.strftime("%m-%d %H:%M")} 全部おわり\n')
```

前の夜の素材で、1本目（③ 効率重視・入口が角、空いている、ロボット0台と2台）の頭の20秒だけを作りました。下の記録の時刻は、Mac 連携の作業環境の時計（協定世界時）で、日本時間では16時19分・16時20分です。

```
10-31 07:19 失敗 1/42 p1_aki_r0_vs_r2.mp4 [Errno 13] Permission denied: '/tmp/p1_aki_r0_vs_r2.ass'
10-31 07:19 全部おわり
10-31 07:20 できた 1/42 p1_aki_r0_vs_r2.mp4 長さ401秒 作るのに6秒
10-31 07:20 全部おわり
```

【画像】LOG415_02_compare_test_frame.jpg 試しに作った比較動画の1コマ。左がロボット0台、右がロボット2台の③の店。上に店の時刻・入店待ちの組・片付け待ちの卓・動けなくなった回数、下に店と条件の説明と色の見本

20秒を6秒で作れたので、全部で約7分の動画1本は、約2分で作れる見込みです。ただ、Mac 連携の作業環境は1回の作業が3分までで、後ろで動かし続けた処理は、作業の区切りで止まります。そのため、1回に1本ずつ作る形にしました。42本で1時間半ほどの見込みです。明日の朝7時に、撮り直しが終わったかを確かめ、終わっていれば右上（③の空いている店）から作り始める予約を入れました。


## 店ごとの前提を重ねる段は、店の記録が届いてから作る

前提の表と取り込みの道具で、店の数字を試算に入れる入り口はできました。まだないのは、標準の値を書き換えずに「この店で測った値」を重ねて、その店だけの試算を回す仕組みです。これは記録だけにしておき、店の記録が届いてから作ります。

もう一つ、表の中の「Unity」の根拠は0個のままです。Unity ではロボットの速さや止まる回数をもう測っていますが、それを試算に入れるかは、まだ決めていません。入れるときは、この表の上で、根拠を「Unity」にして、どの夜の何回分の値かを書いて入れます。
