サービスロボット導入シミュレーション Log#3 | ロボットが人を呼ぶ回数を、5つの現場で試算する

配膳ロボットはファミレスでよく見かけるようになりましたが、その次にロボットが入りそうな現場はどこでしょうか。国内の導入事例、業種ごとの人手不足の調査、国の施策を5つの方向から調べると、候補は4つに絞れました。病院の検体と薬剤の搬送、ビルや商業施設の夜間清掃、ホテルの客室への配送、オフィスビルの夜間警備です。どれも人手不足が重い業種で、ロボットの仕事が「決まった区間を運ぶ」「広い床を拭く」「決まった道を見回る」の1つに絞れていて、日本で1年以上動いている例があります。これに、法律が変わって歩道を走れるようになった配送ロボットを加え、5つの現場を、配膳ロボットのとき(LOG351)と同じ方法でパソコンの中に再現し、試算しました。

調べて分かったのは、どの現場でも、公開されている効果は「1日66分減った」「清掃時間が60%減った」のような結果だけだということです。ロボットが途中で何回止まり、そのたびに人が何回呼ばれたのかは、どの会社も出していません。一方で、どの現場のロボットも人を呼んでいます。警備ロボットは異常を見つけると警備員を呼び、清掃ロボットは水が空になると人を呼び、歩道の配送ロボットは道をふさがれると遠隔のオペレーター(離れた場所で画面を見ながらロボットを助ける人)を呼びます。ロボットを入れて現場が楽になるかどうかは、ロボットの速さより、人を何回・何分呼ぶかで決まります。 そこで試算では、この「呼ぶ回数」に効く値を、変えられる条件として前に出しました。

試算に使ったのは SimPy(Python で「何が、いつ、どの順番で起きるか」を再現する道具)です。仕事が出る時刻、ロボットと人の数、手が空くまで待つ時間を1日分再現し、条件ごとに30回回して平均を出しています。5つの現場の標準の条件では、次の結果になりました。

現場ロボットの仕事標準の条件で出たこと
病院(中規模・約660床)
外来や病棟の検体、薬剤部の抗がん剤を、決まった時間帯に運ぶ
ロボット1台で、人が運ぶ時間は1日3時間24分から21分に減る。荷物が届くまでは8.2分から11.9分に延びる
商業施設の夜間清掃(6,000㎡)
閉店後の広い床を拭く
ロボット2台で、清掃スタッフの作業は15.6時間から7.8時間に減る。1晩に4.5回、人が呼ばれる
ホテル(200室)
客室から頼まれたアメニティを届ける
ロボット1台で、フロントの人がフロントを離れる時間は4時間39分から42分に減る。届くまでは9.6分から30.3分に延びる
オフィスビルの夜間警備(20階)
巡回して、異常を見つけたら知らせる
ロボット1台で、異常が出てから人が着くまでは61.7分から49.9分に縮む。1晩に11.2回警備員が呼ばれ、うち4.9回は異常ではない空振り
歩道の配送(片道約7分のエリア)
店で荷物を載せ、家まで届ける
注文60件・ロボット4台で、届くまで17.3分。今の日本の注文の数では、オペレーターより先にロボットの台数が足りなくなる

試算は、シリーズのページ(pixelstomotors.com/series8 )に置きました。上の「配膳|飲食店」「搬送|病院」のようなタブで業種と仕事を選び、施設の広さ、人数、台数、公開されていない値を切り替えると、結果が変わります。数字の多くは仮置きで、公開資料で埋まらなかった値は、何段階か動かして結果の変わり方を見られるようにしています。

ここから下は作業ログです。調べた内容、試算の組み方と結果の全表、サイトに置くまでのやり取り、使ったコードの全文を、作業した順に載せています。

作業ログ ― 配膳の次の導入先を、5つの方向から調べた

この回は、次の一言から始まりました。

今は配膳ロボットだけなので、他のも作ってみて。サービスロボットの導入が考えられるのはどんなところ?よく調査してください。

調べる範囲が広いので、互いに重ならない5つの方向に分け、それぞれを別の調べ役(Claude の中で並行して動く調査の担当)に任せました。どの方向にも、導入事例(会社・施設・機種・台数・年)、測られた効果、うまくいかなかった例、そして試算に使える数(作業1回の時間、1時間あたりの件数、ロボットの速さ、電池、人が手を出す頻度)を集めるよう頼んでいます。数字には1つずつ出典の URL を付け、日本と海外、企業の発表と第三者の調査を分けて書く決まりにしました。

方向調べたこと
飲食・小売・調理
配膳以外の飲食(下げ膳・調理ロボット・皿洗い)、コンビニとスーパー(品出し・棚の確認・清掃)
清掃・警備・案内
商業施設・空港・駅・オフィスの清掃ロボット、警備ロボット、案内と受付のロボット
病院・介護
院内の搬送(検体・薬剤・物品)、看護師の時間の使い方、介護施設の見守りと移乗
ホテル・ビル・屋外配送
客室への配送、オフィスとマンションの館内配送、エレベーターとの連携、歩道を走る配送ロボット
市場・人手不足・制度
市場の大きさ、業種ごとの人手不足、国の施策と補助金、導入をやめた理由

5つの調べ役が書いたメモを、もう1つの担当が1本の調査メモにまとめました。調査メモは iCloud の research/research_service_robot_sites_20261020.md に置いています。出典は104本です。

調べた10の現場を、人手不足・作業の向き不向き・試算しやすさで並べた

調査メモでは、調べた10の現場を5つの観点で並べています。◎は強い・向いている、○は中くらい、△は弱い・課題が多い、×は向いていない、です。順位は5つの単純な合計ではなく、「次に試算する題材として扱いやすいか」を基準に、作業の向き不向きと試算しやすさを重めに見ています。表は調査メモからそのまま載せます。

順位現場(ロボットに渡す作業)人手不足の重さ作業の向き不向き政策の後押し導入の実績(日本)SimPy で試算しやすいか
1病院(検体・薬剤の院内搬送)
○ 看護職員は2025年に6〜27万人不足の推計(2019年公表)。実際に運んでいるのは看護補助者
◎ 決まった区間を、決まった時間帯に往復する
○ 経産省の施設管理分野で病院の搬送を採択
○ 慈恵医大柏病院で約1年、パナソニック HOSPI は松下記念病院で12年無事故。台数は少ない
◎ ルート・時間帯・1日の便数・人の移動時間まで公表
2ビル・空港・商業施設の床清掃
◎ ビルメンテナンス業の88.5%が「現場の人が集まりにくい」
◎ 広く開けた床の、毎日同じ清掃
◎ 経産省の重点分野、省力化補助金のカタログに清掃ロボット
○ 羽田空港・森トラスト・ホテル・スーパー。効果は施設全体で5%〜対象エリアで60%【企業発表】
○ 清掃の速さ・電池・タンク容量はカタログで埋まる。止まる回数は仮置き
3
ホテル(アメニティの客室配送・レストランの下げ膳)
○ 旅館・ホテルの非正社員不足は59.0%(2025年10月)→38.5%(2026年4月)
○ 運ぶだけの仕事は残っている
○ ロボットフレンドリー施設の事例に複数
○ 1日40〜50回の配送、1日約100往復の下げ膳。止めた施設もある
○ 1日の回数は公表。エレベーター待ちは仮置き
4ビル・駅の警備(巡回・立哨)
◎ 保安職の有効求人倍率6.41倍、警備員の約47%が60歳以上
○ 見回りと異常の検知まで。判断と対応は人
◎ 道路交通法の改正、警察庁の省力化プラン、建物設備との連携規格
○ SQ-2 が官庁・病院・駅・ビルに。ugo は12物件17台
△ 電池は公表。巡回ルートの長さ・回数・駆けつけ時間は非公表
5外食(調理・下げ膳)
○ 飲食店の非正社員不足59.1%。ただし前年より6.2ポイント改善
○ 調理は1回の時間が決まった処理
△ 省力化補助金・業務改善助成金(ロボット専用ではない)
△ 調理ロボットは1店〜数十店の規模
◎ 炒め物2分以内、パスタ45秒/食、そば150食/時が公表
6
コンビニ・スーパー(飲料の品出し・床清掃)
○ 飲食料品小売の非正社員不足50.0%(前年より5.6ポイント悪化)
△〜○ 飲料は自動化済み、菓子類は実証中。失敗すると遠隔で人が操作
○ 経産省の重点分野に小売
○ ファミリーマートが300店展開を発表(達成は未確認)
○ 1日最大1,000本。遠隔で人が直す確率は仮置き
7
屋外配送(歩道を走る小型ロボット)
◎ 2030年に荷物の約34%が運べない推計
△ 信号・人混み・雪
◎ 2023年の道路交通法改正、経産省のロードマップ
△ 1エリアで1日数十件の規模
○ 速さ・遠隔監視の比率・ロボットだけで届いた割合は公表
8
オフィス・マンションの館内配送
△ 配達員がビルの中を歩く手間(数値なし)
○ ロビーから各階へ運ぶだけ
◎ エレベーター連携の規格化が最も進んでいる
△ オフィスは実用、マンションは実証
△ 件数・エレベーター待ちが非公表で、仮置きが多い
9
介護施設(見守り・夜間巡回・移乗)
◎ 介護職員は2040年度までに約57万人の上積みが必要
△ 動くロボットは少数。主役は見守りセンサー
◎ 加算と人員配置基準の緩和
◎ 見守り機器は47.2%(ただし動くロボットではない)
○ 通知と駆けつけの順番待ちとして組める(ロボットではなくセンサーの試算)
10案内・受付△
× 会話と判断が要る仕事で失敗が続いた
△
× 変なホテル・Pepper が縮小。遠隔アバターへ移行
△ 遠隔オペレーター2人で3台を受け持つ形なら組める

上位4つ(病院・清掃・ホテル・警備)と、制度の後押しが一番強い屋外配送を、この回の試算の対象にしました。外食の調理ロボットは数字がそろっていますが、配膳と同じ店の話なので、配膳の試算の続きとして別の回に回します。介護施設は、広がっているのが動くロボットではなく見守りのセンサーなので、今回は外しました。

定着したのは「運ぶだけ」「拭くだけ」で、話す・判断する仕事は縮小していた

縮小・撤退した例は、同じ形をしていました。変なホテルは、243体まで増やしたロボットを2019年に半数以上減らしています。客室の音声アシスタントがいびきを命令と取り違えて夜中に客を起こし、フロントのロボットは簡単な観光の質問に答えられず、荷物運びのロボットは廊下ですれ違えずに止まり、故障のたびに人が残業して直していました。Pepper は2020年に生産が止まっています。案内の仕事は今、遠隔のオペレーターが画面越しに話す形に移っていて、中部国際空港では2023年に、オペレーター2人で3台を受け持つ形を試しています。

残っているのは、1つの作業に絞って、業務の流れに組み込んだものです。すかいらーくは配膳ロボット BellaBot を約2,100店に約3,000台入れ、ガストでは料理を運ぶことだけに使いました。東京慈恵会医科大学附属柏病院は、検体と抗がん剤を、決まった時間帯に決まったルートで運んでいます。ザ・サウザンド京都は、客室へのアメニティの配送を1日40〜50回、ロボットでこなしています。

公開されている効果の数字は、ほぼすべて企業の発表だった

調査メモでは、数字の出どころを【企業発表】【公的調査】【第三者】【海外】の4つに分けました。並べると偏りがはっきりします。病院の「1日66〜172分の削減」、清掃の「60%減」、ガストの「回転率+7.5%」、警備の「夜間巡回1名減」は、すべて企業の発表です。第三者がロボットの現場を見た日本の資料は、ホテルを泊まり歩いて調べた法政大学の論文が1本見つかっただけでした。

どの現場でも公開されていない値が4つありました。ロボットが止まって人が助けに行く頻度、エレベーターの待ち時間、故障の頻度、そして「1卓の下げ膳」のような作業1回の時間です。公開資料は「何分減った」という結果を出し、そこに至るまでに何回止まり何分待ったかという過程を出していません。試算では、この値を3段階に動かし、結果がどれだけ変わるかを見られるようにしました。

出典の数字14件を、別の担当が元のページと照らし合わせた

試算に使った数字のうち14件を、調査に関わっていない別の担当に渡し、元のページを開いて書いてあるとおりかを確かめてもらいました。10件は一致、4件は一部一致でした。

番号確かめた数字判定ページにあった記述
1
慈恵医大柏病院:KEENON W3 を2025年6月に導入、3ルート、内科ルート1日4〜9便・薬剤ルート5〜15便、削減は1日66分10秒〜172分、年330〜860時間
一致
「2025年6月より搬送ロボット「KEENON W3」を本格導入」。ルートは朝・外来・薬剤の3つ。内科外来↔中央検査部は1日4〜9便、薬剤部↔外来化学療法室は5〜15便。削減は合計で1日66分10秒〜172分00秒、年330〜860時間(月25日×12か月で計算)
2
同院の実証:スタッフ1人の運搬回数9.5回→4.8回、1回の移動時間9分39秒→4分57秒、1回最大4か所
一部一致
「スタッフの1日あたりの平均運搬回数は9.5回から4.8回に、平均移動時間は9分39秒から4分57秒に削減」。「1度に最大4か所への搬送に対応」。移動時間はページでは「平均移動時間」で、「1回の」とは書かれていない
3
Phantas:1時間に約350〜700㎡(または400〜700㎡)、水1回で1,200㎡、水洗い約5時間
一部一致
「約400〜700㎡/時間」、タンク満水で清掃できる面積「1200㎡」、スクラブモード「5時間」。350㎡はページに無い
4
SQ-2:1回の充電で5〜6時間稼働、充電は最大1.5時間、レンタルのみ
一致
稼働時間「5~6時間 / 1充電」、充電時間「最大1.5時間」、「原則、レンタル契約のみです。」
5
パナソニック ハコボ:遠隔オペレーター1人で4台(2022年)→10台(2025年)
一致
2022年4月「1人のオペレーターが小型車4台を同時に遠隔操作」。2025年は「オペレーター1人が遠隔操作する…計10台同時の公道走行」「同時運行できるロボットを10台に拡大」
6
ザ・サウザンド京都:1日40〜50回のアメニティ配送、3名分をほぼロボットで、エレベーター連携は半日で取り付け
一致
「1日、40回~50回の配送業務」「これまで3名のスタッフが対応していたアメニティ配送業務はほぼロボットで実施」「エレベーター連携を半日という短期間かつ低価格で」
7
森トラスト:清掃の1日の総時間が約5%減(11施設に6台)
一致
「東京マリオットホテルなど11施設」「清掃ロボット6台、配膳ロボット6台」「日常清掃全体の業務時間を5%程度削減」
8
ホテルバリタワー大阪天王寺:PUDU CC1 でロビーと客室階の廊下の清掃時間が60%減
一致
「フロントロビー、各客室階廊下」に PUDU CC1 を導入、「清掃時間を6割削減」
9
大成 × ugo:12物件に17台、品川シーズンテラスで夜間巡回の要員を1名減
一致
「12物件において17台の警備ロボット」。品川シーズンテラスは「夜間巡回…「ugo」が対応することで、1名人員を削減」
10
Uber Eats × 三菱電機:日本橋で片道約7分、時速5.4km
一部一致
「東京・日本橋エリア」「片道7分かかるルート」「最高速度5.4km」。ページでは最高速度で、時速の単位も書かれていない
11
丸の内の実証(パナソニック):注文1日約20件、信号のある横断約50回で現場の介入0回
一部一致
「パナソニックホールディングス」「約20件/日の購入実績」「横断回数は約50回で保安要員介入なし」。約50回が信号のある横断だけの回数とは書かれていない。また「タクシー乗降停止で遠隔操作介入があった」とあり、遠隔からの介入はあった
12
帝国データバンク2026年4月:正社員不足 運輸・倉庫65.9%、メンテナンス・警備・検査65.9%
一致
調査は2026年4月(4月16〜30日)。運輸・倉庫「65.9%」(前年同月比+1.9pt)、メンテナンス・警備・検査「65.9%」(同−3.5pt)
13
全国ビルメンテナンス協会:88.5%が「現場の従業員が集まりにくい」
一致
「現場従業員が集まりにくい」88.5%で最多、13年連続1位(2025年度調査)。ページの語は「現場従業員」
14
すかいらーく:BellaBot 約2,100店に約3,000台、ガストでランチピークの回転率+7.5%、片付け時間−35%、歩数−42%、下げ膳はさせず料理を運ぶことに使った
一致
「2022年12月には全国2100店舗での大規模導入を完了」。見出しは「3000台導入」。「ガストではランチピーク回転率が7.5%向上、片付け時間が35%削減、歩行数も42%削減」「特にガストでは上げ膳のみに使っている」

確かめた元のページ:1 https://prtimes.jp/main/html/rd/p/000000111.000028572.html / 2 https://www.logi-today.com/764565 / 3 https://www.irisohyama.co.jp/b2b/robotics/products/phantas/ / 4 https://www.irisohyama.co.jp/b2b/robotics/products/sq-2/ / 5 https://monoist.itmedia.co.jp/mn/spv/2502/18/news098.html / 6 https://jara.jp/various/koubo/img/2024/facility_04.pdf / 7 https://www.meti.go.jp/policy/mono_info_service/mono/robot/2503_facilitybook.pdf / 8 https://news.livedoor.com/article/detail/24690169/ / 9 https://www.taisei-bm.co.jp/wp-content/uploads/2024/10/news20240325.pdf / 10 https://monoist.itmedia.co.jp/mn/spv/2403/06/news082.html / 11 https://www.meti.go.jp/shingikai/mono_info_service/jidosoko_robot/pdf/007_07_00.pdf / 12 https://prtimes.jp/main/html/rd/p/000001341.000043465.html / 13 https://cdn.j-bma.or.jp/wp-content/uploads/2026/04/16115006/2026_bmiy56_report.pdf / 14 https://www.businessinsider.jp/article/267599/

一部一致の4件は、言い方を直しました。Phantas は「1時間に約400〜700㎡」で、350㎡はページにありませんでした。慈恵柏の9分39秒は「1回の移動時間」ではなく「平均移動時間」です。Uber Eats の時速5.4kmは「最高速度」です。丸の内の実証で介入がなかったのは現場の保安要員で、遠隔操作の介入はタクシーの乗り降りで1回ありました。調査メモ・試算のコードの出典・試算のページの3か所を直しています。

5つの試算は「ロボットの仕事」と「呼ばれる人の順番待ち」の2段で組んだ

配膳の試算(LOG351)と同じく、SimPy で1日分の仕事を再現しました。SimPy で扱うのは、図面がなくても答えられる「時間・順番・数」の問いです。仕事がいつ出るか、ロボットと人が何台・何人いるか、手が空くまで何分待つか。通路でのすれ違いや人とぶつかるかといった空間と動きの問いは扱わず、Unity で別に扱います。

5つとも、組み方は同じ2段です。1段目はロボットの仕事(運ぶ・拭く・見回る・届ける)、2段目はロボットに呼ばれる人の順番待ちです。ロボットが止まった、水が空になった、異常を見つけた、道をふさがれた、というときにロボットは人を呼び、人はほかの仕事の合間にそれに応えます。人の手がふさがっていれば、ロボットは待ちます。

現場再現した時間公開資料から取った形仮置きの値(動かせる)
病院搬送7:30〜16:30
慈恵医大柏病院の3ルートと運行の時間帯、1日の便数、同じ階で運ぶ
途中で止まる割合、ロボットの速さ、出発地の人の忙しさ、病院の広さ
夜間清掃22:00〜6:00
清掃の速さ(1時間約400〜700㎡)、水1回で1,200㎡、水洗い約5時間・充電2時間
引っかかる回数、実際の速さ、ロボットが拭ける広い床の割合、施設の広さ
ホテル配送15:00〜24:00
1日40〜50回の配送、エレベーターのつなぎ方2通り
途中で止まる割合、エレベーターの待ち、客が出てこない割合、ホテルの広さ
夜間警備19:00〜7:00
5〜6時間動いて充電1.5時間、ロボットが見つけて人が駆けつける流れ
空振りの知らせ、1晩の異常の数、1周の時間、ビルの広さ
屋外配送
11:00〜20:00(注文の受付)
片道約7分、オペレーター1人で4台・10台
介入の回数、注文の数、配達エリアの広さ

どの試算も、条件ごとに30回(乱数の種0〜29)回した平均です。下の表は、どれも summary.py(コードの全文は後ろに載せています)で results_*.json から出したものです。

病院搬送:人が運ぶ時間は大きく減るが、1台では荷物が届くのが遅れた

慈恵医大柏病院の3つのルート(救急・病棟→検査部が7:30〜8:00、内科外来→検査部が8:00〜11:00・13:00〜14:00・15:00〜16:30、薬剤部→外来化学療法室が11:00〜13:00・14:00〜15:00)を、そのまま時間帯として入れました。3つのルートの時間帯は重ならないので、1台で順に回せます。荷物は時間帯の中でばらばらに出ます。

ロボットがいないときは、出発地の人(看護補助者など)が、ほかの仕事の合間に、たまった荷物をまとめて運びます。片道の距離は、実証で出ていたスタッフの平均移動時間9分39秒に近くなるように、200m・250m・180mと置きました。ロボットがいるときは、出発地の人が荷物を載せ、ロボットが運び、着いた先の人が受け取ります。ロボットが途中で止まったら、半分は自然に動き出し、残りの半分は出発地の人が助けに行きます。

表:病院搬送:ロボットの台数(荷物の量 普通・同じ階・すぐ出す・速さ0.9m/秒・忙しさ6割・止まる5%)

台数届くまで(分)遅い方の1割(分)15分超え人が搬送に使った分/日ほかの仕事の待ち(分)便数/日人を呼んだ回数
08.211.20%20416.20.00.0
111.918.024%217.123.40.9
29.212.74%247.126.51.1

人が搬送に使う時間は、1日204分から21分になりました。慈恵柏が発表した削減は1日66〜172分なので、仮置きの距離と荷物の量は、やや多めの側にあります。一方、荷物が出てから届くまでは8.2分から11.9分に延び、15分を超えた荷物が24%ありました。ロボットの速さを秒速0.9m、人を1.2mと置いたことと、ロボットが戻ってくるまで次の荷物が待つことが効いています。

表:病院搬送:ロボットの速さ(1台・ほかは上と同じ)

速さ(m/秒)届くまで(分)15分超え人が搬送に使った分/日
0.616.550%21
0.911.924%21
1.210.114%24

表:病院搬送:途中で止まる割合(1台)

止まる割合届くまで(分)人が搬送に使った分/日人を呼んだ回数
0%11.5180.0
5%11.9210.9
15%12.9313.2

表:病院搬送:階をまたぐ(エレベーター)

台数届くまで(分)15分超え人が搬送に使った分/日
013.734%333
120.968%21
215.041%25

表:病院搬送:病院の広さ(1台・ほかは標準)

広さ台数届くまで(分)15分超え人が搬送に使った分/日人を呼んだ回数
small06.10%920.0
small18.13%140.8
small27.20%140.7
mid08.20%2040.0
mid111.924%210.9
mid29.24%241.1
large011.519%3670.0
large118.762%301.3
large213.329%392.1

大学病院の広さ(距離1.6倍・荷物1.5倍)では、1台だと62%の荷物が15分を超えました。2台にすると29%まで下がります。

夜間清掃:施設全体で5%減と60%減の差は、ロボットが拭ける床の割合で出た

施設の床を、ロボットが拭ける広い床と、人が拭く隅・壁際・狭い所に分けました。ロボットは広い床を拭き、水が空になる・電池が切れる・引っかかると止まります。水の入れ替えと引っかかりは人を呼びます(自動で給排水する充電台があれば、水では呼ばない)。人は自分の担当を5分ずつ進め、合間にロボットの呼び出しに応えます。自分の担当が終わったら、ロボットだけでは6:00に終わらない見込みのときだけ、広い床を手伝います。

表:夜間清掃:office・スタッフ1人(速さ8割・引っかかり0.5回/時・水は人が入れ替える)

広い床の割合ロボットなし1台2台3台
30%10.2時間・8:1217%減・呼んだ0.5回17%減・呼んだ0.5回17%減・呼んだ0.5回
50%9.0時間・7:0030%減・呼んだ1.8回32%減・呼んだ0.8回32%減・呼んだ0.8回
70%7.8時間・5:4850%減・呼んだ2.2回52%減・呼んだ1.2回52%減・呼んだ1.2回
90%6.6時間・4:3660%減・呼んだ2.2回74%減・呼んだ3.4回80%減・呼んだ1.4回

表:夜間清掃:mall・スタッフ3人(速さ8割・引っかかり0.5回/時・水は人が入れ替える)

広い床の割合ロボットなし1台2台3台
30%20.4時間・4:4616%減・呼んだ2.0回17%減・呼んだ1.0回17%減・呼んだ1.0回
50%18.0時間・4:0022%減・呼んだ2.2回30%減・呼んだ3.7回32%減・呼んだ1.7回
70%15.6時間・3:1025%減・呼んだ2.2回50%減・呼んだ4.5回49%減・呼んだ5.5回
90%13.2時間・2:2330%減・呼んだ2.2回60%減・呼んだ4.6回75%減・呼んだ6.5回

表:夜間清掃:large・スタッフ4人(速さ8割・引っかかり0.5回/時・水は人が入れ替える)

広い床の割合ロボットなし1台2台3台
30%34.0時間・6:3012%減・呼んだ2.2回16%減・呼んだ3.7回17%減・呼んだ1.7回
50%30.0時間・5:3013%減・呼んだ2.2回26%減・呼んだ4.5回30%減・呼んだ6.1回
70%26.0時間・4:3015%減・呼んだ2.2回30%減・呼んだ4.5回46%減・呼んだ7.2回
90%22.0時間・3:3018%減・呼んだ2.2回36%減・呼んだ4.5回54%減・呼んだ7.2回

人の作業時間の減り方は、12%から80%まで、ロボットが拭ける広い床の割合と台数で大きく動きました。調査で見つけた森トラストの「施設全体で約5%減」と、ホテルバリタワーの「ロビーと客室階の廊下で60%減」は、同じロボットでも、施設のうちロボットが担える床がどれだけあるかで説明できます。

表:夜間清掃:引っかかる回数と充電台(商業施設・2人・2台・広い床70%・速さ8割)

引っかかり/時充電台人の作業(時間)人を呼んだ回数呼ばれて使った分ロボットが待った分
0.0人7.62.02333
0.0自動7.20.000
0.5人7.84.53848
0.5自動7.42.51418
1.5人8.69.76885
1.5自動8.27.74456

引っかかる回数を1時間0.5回から1.5回にすると、人が呼ばれる回数は4.5回から9.7回に、呼ばれて使う時間は38分から68分に増えました。自動の充電台は、水の入れ替えで呼ばれる分(1晩2回・約23分)をなくします。

ホテル配送:フロントを離れる時間は減るが、夜の山でロボットの順番待ちができた

フロントの人は、チェックインの客の対応と、客室からの依頼の配達を兼ねています。チェックインを先に受ける決まりにしました。人が配達に出るとフロントが手薄になります。ロボットの場合、フロントの人は載せるだけで、ロボットがエレベーターで上がり、部屋の前で客を呼びます。客が出てこなければ持ち帰り(10%と置いた)、あとで人が届けます。エレベーターは、建物のシステムとつなぐ方式と、ロボットの腕でボタンを押す方式(改修が要らない)の2通りを置きました。

表:ホテル:届くまで・フロントを離れた分・チェックインの待ち(止まる5%・1件ずつ)

フロント依頼/日ロボットなし1台・建物とつなぐ1台・腕でボタン2台・建物とつなぐ
225
6.8分・離れる152分・CI 1.5分
13.7分・離れる22分・CI 1.0分・呼んだ3.4回
20.1分・離れる23分・CI 1.0分・呼んだ3.6回
9.4分・離れる25分・CI 1.0分・呼んだ3.8回
245
9.6分・離れる279分・CI 1.9分
30.3分・離れる42分・CI 1.1分・呼んだ6.5回
70.5分・離れる41分・CI 1.0分・呼んだ6.3回
10.5分・離れる43分・CI 1.1分・呼んだ6.6回
280
25.5分・離れる503分・CI 2.7分
150.3分・離れる74分・CI 1.1分・呼んだ11.8回
199.8分・離れる61分・CI 1.1分・呼んだ9.6回
21.5分・離れる74分・CI 1.2分・呼んだ11.5回
325
4.2分・離れる151分・CI 0.2分
11.3分・離れる23分・CI 0.2分・呼んだ3.6回
18.0分・離れる24分・CI 0.2分・呼んだ3.7回
7.8分・離れる25分・CI 0.2分・呼んだ3.9回
345
4.4分・離れる277分・CI 0.4分
23.4分・離れる45分・CI 0.2分・呼んだ7.1回
56.1分・離れる40分・CI 0.2分・呼んだ6.2回
8.3分・離れる40分・CI 0.2分・呼んだ6.2回
380
5.0分・離れる506分・CI 0.6分
120.4分・離れる76分・CI 0.2分・呼んだ12.0回
180.7分・離れる60分・CI 0.2分・呼んだ9.7回
14.3分・離れる77分・CI 0.2分・呼んだ11.9回

表:ホテル:1回に運ぶ依頼(フロント2人・80件/日・腕でボタン・1台)

1回に運ぶ届くまで(分)遅い方の1割(分)動いている割合
1199.8358.083%
292.2156.877%

表:ホテル:ホテルの広さ(依頼 普通・建物とつなぐ・止まる5%・1件ずつ)

広さフロント台数届くまで(分)フロントを離れた分チェックインの待ち(分)人を呼んだ回数
small1034.71735.40.0
small1140.0264.04.4
small1220.9263.94.4
small204.41750.50.0
small2112.1250.34.3
small227.5260.34.4
mid209.62791.90.0
mid2130.3421.16.5
mid2210.5431.16.6
large3070.16903.90.0
large31242.9802.110.5
large3299.21042.113.5

フロントを離れる時間は、どの広さでも8割以上減りました。届くまでの時間は、ロボット1台だと延びます。依頼が夜(18:00〜22:00)に集まるので、その山でロボットの順番待ちができるためです。2台にすると、人が届けたときに近づきます。1台のままでも、1回に2件運ぶと、依頼が多い日(80件)の待ちが200分から92分に縮みました。チェックインの待ちは、チェックインを先に受ける決まりなので、ロボットを入れても大きくは変わりませんでした。

夜間警備:警備員を1人にすると、駆けつけのたびに防災センターが空いた

ロボットがいないときは、警備員が2時間ごとに建物を1周します。防災センターには常に1人残る決まりにしたので、警備員が1人だけだと、巡回の間はセンターが空きます。ロボットがいるときは、ロボットが巡回し、異常を見つけたらセンターに知らせ、警備員が駆けつけます。異常(扉の開け放し・置き去りの物・消えた照明など)は、巡回が通りかかるまで見つかりません。ロボットは異常でないものを知らせることがあり(空振り)、そのたびに警備員が見に行きます。

表:警備:異常に人が着くまで・巡回の最長の空白・センターが空いた分(異常6件/晩・空振り0.5回/時)

警備員ロボットなし1台・2時間ごと1台・回り続ける2台・2時間ごと2台・回り続ける
1
着くまで62分・空白120分・センター空き302分
73分・空白120分・空き165分・呼んだ7.6回
50分・空白140分・空き229分・呼んだ11.2回
43分・空白60分・空き215分・呼んだ10.5回
36分・空白115分・空き316分・呼んだ16.8回
2
着くまで62分・空白120分・センター空き0分
73分・空白120分・空き0分・呼んだ7.6回
50分・空白140分・空き0分・呼んだ11.2回
43分・空白60分・空き0分・呼んだ10.5回
36分・空白115分・空き0分・呼んだ16.8回
3
着くまで62分・空白120分・センター空き0分
68分・空白120分・空き0分・呼んだ7.7回
45分・空白140分・空き0分・呼んだ11.3回
39分・空白60分・空き0分・呼んだ10.6回
29分・空白115分・空き0分・呼んだ16.8回

表:警備:空振りの知らせ(警備員1人・1台・回り続ける・異常6件)

空振り/時人を呼んだ回数うち空振りセンターが空いた分着くまで(分)
0.06.30.015148.1
0.511.24.922949.9
1.523.216.941855.2

表:警備:ビルの広さ(警備員2人・異常6件・空振り0.5回/時・回り続ける)

広さ台数着くまで(分)巡回の最長の空白(分)警備員が外にいた分人を呼んだ回数
small063.61202420.0
small134.013018111.1
small226.311024016.2
mid061.71203020.0
mid149.914022911.2
mid235.911531616.8
large063.01204220.0
large175.616531311.8
large255.812843616.9

ロボット1台を「2時間ごと」に回すと、異常に人が着くまでは62分から73分に延びました。ロボットが見つけてから、警備員がセンターから駆けつける時間が足されるからです。充電中以外は回り続ける形にすると50分まで縮みます。警備員を2人から1人に減らすと、駆けつけのたびにセンターが空き、空振りが1時間1.5回あると、センターが空く時間は1晩418分になりました。

屋外配送:今の注文の数では、オペレーターより台数が先に足りなくなった

店で荷物を載せ、歩道を走って届け、客が受け取ったら店へ戻ります。走っている間に、人や車に道をふさがれるなどで止まり、遠隔のオペレーターを呼びます。オペレーターは1度に1件の介入しか扱えません。さらに、1人が見ていられる台数に上限(パナソニックのハコボの2022年の4台と、2025年の10台)を置き、上限を超えると店で出発を待ちます。注文は昼と夜に山があります。

表:屋外配送:届くまで・介入でオペレーターを待った秒・オペレーターがかかりきりの割合(オペレーター1人・1人10台まで)

注文/日介入/片道1台2台4台6台10台
200.0536分・待ち0秒・1%15分・待ち0秒・1%12分・待ち0秒・1%12分・待ち0秒・1%12分・待ち0秒・1%
200.239分・待ち0秒・3%15分・待ち1秒・3%12分・待ち2秒・3%12分・待ち3秒・3%12分・待ち3秒・3%
200.542分・待ち0秒・6%16分・待ち4秒・7%13分・待ち5秒・7%13分・待ち7秒・7%13分・待ち7秒・7%
600.05341分・待ち0秒・1%69分・待ち1秒・2%16分・待ち0秒・2%13分・待ち0秒・2%12分・待ち3秒・2%
600.2356分・待ち0秒・4%75分・待ち2秒・7%17分・待ち8秒・9%13分・待ち7秒・9%13分・待ち8秒・9%
600.5391分・待ち0秒・9%91分・待ち7秒・17%20分・待ち18秒・21%14分・待ち24秒・22%13分・待ち26秒・21%
1200.05939分・待ち0秒・1%348分・待ち0秒・2%64分・待ち2秒・4%23分・待ち2秒・4%13分・待ち2秒・4%
1200.2980分・待ち0秒・4%365分・待ち3秒・8%73分・待ち8秒・14%25分・待ち12秒・17%13分・待ち18秒・18%
1200.51043分・待ち0秒・9%401分・待ち7秒・18%90分・待ち23秒・34%31分・待ち42秒・41%15分・待ち80秒・43%
2000.051717分・待ち0秒・1%737分・待ち0秒・2%241分・待ち2秒・4%84分・待ち2秒・6%21分・待ち7秒・7%
2000.21789分・待ち0秒・4%772分・待ち3秒・8%258分・待ち8秒・15%94分・待ち15秒・22%23分・待ち30秒・28%
2000.51899分・待ち0秒・9%831分・待ち7秒・19%298分・待ち24秒・35%125分・待ち49秒・52%34分・待ち127秒・66%

表:屋外配送:届くまで・介入でオペレーターを待った秒・オペレーターがかかりきりの割合(オペレーター1人・1人4台まで)

注文/日介入/片道1台2台4台6台10台
200.0536分・待ち0秒・1%15分・待ち0秒・1%12分・待ち0秒・1%12分・待ち0秒・1%12分・待ち0秒・1%
200.239分・待ち0秒・3%15分・待ち1秒・3%12分・待ち2秒・3%12分・待ち2秒・3%12分・待ち2秒・3%
200.542分・待ち0秒・6%16分・待ち4秒・7%13分・待ち5秒・7%13分・待ち6秒・7%13分・待ち6秒・7%
600.05341分・待ち0秒・1%69分・待ち1秒・2%16分・待ち0秒・2%15分・待ち1秒・2%15分・待ち1秒・2%
600.2356分・待ち0秒・4%75分・待ち2秒・7%17分・待ち8秒・9%16分・待ち6秒・9%16分・待ち6秒・9%
600.5391分・待ち0秒・9%91分・待ち7秒・17%20分・待ち18秒・21%18分・待ち17秒・21%18分・待ち19秒・22%
1200.05939分・待ち0秒・1%348分・待ち0秒・2%64分・待ち2秒・4%47分・待ち2秒・4%47分・待ち2秒・4%
1200.2980分・待ち0秒・4%365分・待ち3秒・8%73分・待ち8秒・14%54分・待ち8秒・15%54分・待ち7秒・15%
1200.51043分・待ち0秒・9%401分・待ち7秒・18%90分・待ち23秒・34%69分・待ち25秒・36%69分・待ち25秒・36%
2000.051717分・待ち0秒・1%737分・待ち0秒・2%241分・待ち2秒・4%193分・待ち2秒・4%191分・待ち2秒・5%
2000.21789分・待ち0秒・4%772分・待ち3秒・8%258分・待ち8秒・15%210分・待ち10秒・17%210分・待ち11秒・17%
2000.51899分・待ち0秒・9%831分・待ち7秒・19%298分・待ち24秒・35%249分・待ち25秒・38%248分・待ち25秒・39%

表:屋外配送:配達エリアの広さ(オペレーター1人・1人10台まで・介入0.2回/片道)

広さ注文/日2台4台6台10台
near2010分・26%9分・13%9分・9%9分・5%
near6027分・72%10分・38%9分・26%9分・15%
near120165分・98%24分・74%11分・51%9分・31%
mid2015分・37%12分・19%12分・13%12分・8%
mid6075分・91%17分・55%13分・38%13分・23%
mid120365分・99%73分・92%25分・72%13分・45%
wide2028分・55%18分・28%18分・19%18分・11%
wide60216分・97%41分・77%22分・55%18分・34%
wide120680分・99%216分・97%77分・91%25分・65%

今の日本の実績(1エリアで1日約20件)では、2台あれば15分前後で届き、オペレーターが介入にかかりきりなのは数%でした。注文が増えると、先に足りなくなるのはロボットの台数で、オペレーターの手が詰まり始めるのは、注文200件・10台・介入が片道0.5回のように、どちらも多いときです。1人が見られる台数が4台のころの条件では、10台あっても4台分しか同時に走れず、注文120件で届くまで47〜69分かかりました。

上の図は、5つの現場の標準の条件で、公開されていない値を3段階に動かしたときの「ロボットが人を呼んだ回数」と、呼ばれた側の時間を並べたものです。上の線1本が、ロボットが人を呼んだ1回です。

同じコードで回し直すと、同じ結果になった

乱数の種を0〜29で固定しているので、同じコードなら同じ結果になるはずです。試算のコードを別のフォルダに写し、回し直して、できた results_*.json がバイト単位で同じかを cmp で比べました。途中では警備・ホテル・病院の3つ、最後のコード(人数と台数の「その他」を足したあと)では警備とホテルの2つを回し直し、どれも同じでした(最後の分の出力は、後ろの「実行したコマンドと出力」に載せています)。

試算をサイトに置き、業種とタスクのタブで切り替える形にした

試算は、まず claude.ai の Artifact(Claude が作って公開する1枚のページ)にまとめました。そのあと、シリーズのページの「Simulator | 試算を試す」を見ながら、次の提案がありました。

いろんな業種、タスクで使えるのがわかるようにタブで切り替えする?

それまでの「試算を試す」は、配膳ロボットの試算の「店を選ぶ」から始まるので、配膳専用の道具に見えていました。一番上に業種と仕事のタブを置けば、ほかの現場でも使えることが最初に伝わります。タブの名前は「タスク|場所」(例:搬送|病院)にしました。「病院」だけだと何をするロボットか分からず、「搬送」だけだと業種が分からないからです。

サイト公開してみてください。

サイト(pixelstomotors.com)は、GitHub の p2m-site に push すると約1分で公開されます。次のように作りました。

  • タブ:src/lib/records.ts の SERIES_PAGES に、タブの一覧(SimTab)を足しました。1つ目は配膳の試算(serving-robot.html)、2つ目からは5つの現場の試算(service-robot-sites.html)に #hospital のように現場を付けて開きます。
  • 5つの現場の試算:Artifact 版を、make_site_copy.py でサイトに置ける1枚の HTML に直し、public/sim/service-robot-sites.html に置きました。?embed=1 で開くと、見出しと現場の選択を外し、タブの中に収まる形になります。現場はタブで選ぶので、中では選びません。
  • リンク:/series8#sim-hospital のように開くと、そのタブから始まります。記事や SNS から、病院の試算を直接開いてもらえます。
  • 枠の高さ:中の試算の長さに合わせて、枠の高さを変えるようにしました。それまでの配膳の試算は高さ1,600px の枠の中でスクロールしていましたが、ページ全体のスクロールだけで見られるようにしました。

枠の高さは、最初、中の html の高さで測っていたため、一度大きくなると小さくならず、どのタブも3,442px のままでした。html の高さは枠の高さより小さくならないので、body の高さで測るように直しています。また、病院のタブで開いたときに、配膳のタブにしか出さないはずの観察シートの欄が出ていました。欄に display: grid が付いていて、hidden を付けても消えなかったためで、.s-obs[hidden] { display: none; } を足して直しました。

シリーズのページの「試算を試す」。上に配膳・搬送・清掃・配送(ホテル)・巡回・配送(屋外)の6つのタブが写真付きで並び、その下に選んだ試算の見出しと説明が出ている
搬送|病院のタブを開いたところ。中規模病院・ロボット1台で、荷物が届くまで11.9分、人が搬送に使った時間21分、ロボットが人を呼んだ回数0.9回が並ぶ

配膳の試算は変えていない。観察シートが下に押し出されていた

公開したあと、次の質問がありました。

あれ?配膳ロボットのシミュレーションの内容前と変わってない?止まった理由とか、機種とか場所とか選べなかったっけ?

配膳の試算のファイル(serving-robot.html)と観察シートのファイル(serving-robot-observation.html)は、この回のどのコミットでも変えていません(git diff で確かめました)。止まった理由・機種・場所を選べるのは、試算ではなく、その下に置いている観察シートのほうです(LOG353)。

見つけにくくなったのは、枠の高さを中の長さに合わせたせいでした。配膳の試算は中の長さが約3,400px あるので、観察シートがその下まで押し出されていました。配膳のタブの説明のすぐ下に「止まった理由・機種・場所を店で記録する観察シートは、試算の下にあります ↓」というリンクを置き、押すと観察シートまで移るようにしました。

配膳のタブの説明の下に置いた、観察シートへ移るリンク

タブの写真は ChatGPT で作り、4枚はロボットの色を変えた

タブを公開したあと、次の提案がありました。

どの業種、タスクかわかりやすいように写真をどこかに入れた方がいいかもね(chatGPTに依頼)

タブに写真を入れられるように SimTab に img を足し、ChatGPT に頼むための文面を書きました。6枚に共通の指定と、それぞれの場面です。

横4:3の写真風の画像。日本の施設の実際の業務の一場面。やわらかい自然光、落ち着いた色。業務用のサービスロボットは、ロゴや特定の製品だとわかる形を入れない、シンプルな白とグレーの形にする。文字・看板の文字・ロゴは入れない。人は後ろ姿か手元だけで、顔ははっきり写さない。

1. 配膳:ファミリーレストランの通路を、料理をのせた棚型の配膳ロボットが客席へ向かっている
2. 搬送:病院の明るい廊下を、ふたの付いた収納の箱型ロボットが走り、看護師が採血管のトレイを入れている
3. 清掃:閉店後の商業施設の広い床を、床洗浄ロボットが拭いている(夜、照明は半分)
4. 配送:ホテルの客室の廊下で、配送ロボットが部屋の前に止まり、扉が少し開いている
5. 巡回:夜のオフィスビルのロビーを、背の高い円柱形の警備ロボットが巡回している
6. 配送:住宅街の歩道を、小型の6輪の配送ロボットが走っている(昼)

届いた6枚は 480×360 に縮めて public/site/sim/ に置きました。そのあと、ロボットの色について次の指示が続きました。

清掃ロボットは濃いグレーにしたい 外の配送ロボットはカラーはで目がいいね(各社わかりやすいようにしてるんだね)

配送ホテルの写真も変更。シックなダークブラウンカラーロボット

配膳ロボットも色を変えよう。エメラルドグリーンとか

色を変えるときは、ChatGPT に「この画像のロボットの外装を○○に変えて。ライト・形・構図・背景はそのまま。ロゴや文字は入れない」と頼む文面を書き、届いた画像を差し替えました。差し替えた写真は、ブラウザに古い画像が残らないように、住所の末尾の ?v=1 を ?v=2 に上げています。元の大きさの画像は iCloud の サービスロボット導入シミュレーション/tab_images/ に置きました。

施設の広さ・人数・台数を、配膳の試算と同じ形で選べるようにした

記事にする前に、次の指示がありました。

その前に配膳ロボットのように、ロボットの作業範囲の広さの項目を他のシミュレーションにも追加して。それから記事化

配膳の試算には、最初に「店を選ぶ」黒い帯があり、小型店・標準ファミレス・大型店などで、卓の数と厨房からの距離が変わります。同じ形の帯を、5つの現場の試算にも足しました。広さの値は、どれも仮置きです。

現場帯の選択肢変わるもの
病院
中小病院(200床)・中規模病院(660床)・大学病院(1,100床)
距離を0.6倍・1倍・1.6倍、荷物の量を0.6倍・1倍・1.5倍
夜間清掃
オフィスビルの共用部(3,000㎡)・商業施設(6,000㎡)・大型施設(10,000㎡)
床の広さと、選べるスタッフの人数(条件の欄にあった施設の選択を、帯に移した)
ホテル
ビジネスホテル(120室・8階)・シティホテル(200室・12階)・大型ホテル(400室・25階)
チェックインと依頼の数、廊下の長さ(0.8倍・1倍・1.4倍)、エレベーターに乗っている時間(大型で1.6倍)、選べるフロントの人数
夜間警備
中規模ビル(10階)・大規模ビル(20階)・大規模複合ビル(35階)
1周の時間(警備員30・40・60分、ロボット40・50・75分)、センターから現場までの時間
屋外配送
狭いエリア・ふつうのエリア・広いエリア
片道の時間(2〜7分・4〜12分・7〜20分。時速5kmで約330m・580m・1,000m)

真ん中の広さは、それまでの試算と同じ値にしたので、真ん中を選べば結果は変わりません。このとき、ホテルの「ロボットが動いている割合」が100%を超える条件があるのに気づきました。依頼が多いと24時を過ぎても配っているのに、15:00〜24:00の9時間で割っていたためです。最後にロボットが戻った時刻までで割るように直しました。

ホテルの試算の「ホテルの広さを選ぶ」黒い帯。ビジネスホテル・シティホテル・大型ホテルのカードに、客室数・階数・フロントの人数が書いてある

続けて、人数と台数についての指示がありました。

ロボットやスタッフの数はその他で他も選べるようにして配膳ロボットと同じ仕様に!

配膳の試算では、よく使う数だけを並べ、右端の「その他」を押すと、ほかの数が出ます。同じ形を5つの試算の人数と台数に付け、選べる数を増やして計算し直しました。

現場並べている数「その他」で選べる数
病院ロボット 0〜2台3〜5台
夜間清掃
スタッフ(施設ごと)、ロボット 0〜3台
スタッフ:オフィス1〜5人・商業施設1〜7人・大型施設2〜9人、ロボット4〜6台
ホテル
フロント(広さごと)、ロボット 0〜2台
フロント:ビジネス1〜3人・シティ1〜5人・大型2〜6人、ロボット3〜5台
夜間警備
警備員 1〜3人、ロボット 0〜2台
警備員4〜6人、ロボット3〜5台
屋外配送
ロボット 1・2・4・10台、オペレーター 1・2人
ロボット3・5・6・8・12・15・20台、オペレーター3〜5人

条件の数は、病院3,276、夜間清掃5,840、ホテル2,379、夜間警備1,674、屋外配送3,960になりました。ページを開いた状態で、選べるすべての組み合わせ(病院・清掃・ホテル・警備・屋外配送)の結果が results に入っているかを、ブラウザの中で確かめています。

夜間清掃の条件の欄で、清掃スタッフの「その他」を開いたところ。1〜7人のボタンが並ぶ
夜間清掃の「人を1人減らして、ロボットで回るか」。スタッフ3人・ロボット0台と、2人・ロボット2台を並べ、人の作業時間・6:00に間に合った割合・全部終わる時刻を比べている
夜間警備の「空振りの知らせを動かすと」。空振りを0回・0.5回・1.5回にしたときの、人が着くまで・巡回の最長の空白・センターが空いた時間・ロボットが人を呼んだ回数
屋外配送の「ロボットの台数を変えると」。注文60件で、1台だと届くまで約6時間、2台で75分、4台で17分、10台で13分になる

スマホでは、業種のタブを2列のコンパクトなカードにした

スマホで見た本人から、2つの指示がありました。

SPの業種選ぶところ横スクロールだと分かりづらいので2カラムで組んでください。

spの業種カードで場所を取りすぎてしまうので、写真を小さくして左側、テキストを右側にしてコンパクトにしたいです。

最初はタブを横に並べて、スマホでは横にスクロールさせていました。横スクロールだと3つ目から先があることに気づきにくいので、幅599px 以下では2列に並べました。2列にすると写真が大きく、6枚で画面のほとんどを使っていたので、写真を52px の幅にして左に、タスクと場所を右に置きました。

ちょっと写真小さすぎかな

各カードのパディング無くして調整してみたら?

写真を76px に広げると、「商業施設・ビル」が「ビ/ル」の途中で折り返しました。指示どおりカードの余白をなくし、写真をカードの左端と上下いっぱいまで広げると、同じ幅のカードでも写真を72px にでき、文字の場所も残りました。場所の名前は「・」で区切って、折り返すなら「商業施設・/ビル」と区切りのあとで折り返すようにしています。

カード押した時に写真の下に白いラインが出てるのを修正

選んだカードは明るい色になるので、写真の下にカードの地の色が細く見えていました。写真がカードの高さいっぱいに伸びていなかったためで、カードの中を2行に分け、写真を2行にまたがらせて高さいっぱいに伸ばしました。このとき送ってもらった画面写真では、「清掃」のタブを選んでいるのに、中に警備の「ビルの広さを選ぶ」が出ていました。同じページの中で # だけを変えて現場を切り替えると、ブラウザによっては中が切り替わらないことがあるので、タブを押したら中の # も直接変えるようにしました。

スマホの幅で見たタブ。2列3段で、左に小さい写真、右に「配膳 飲食店」「搬送 病院」のような文字が並ぶ

実行したコマンドと出力

試算は Claude のクラウドの作業環境(Linux・2コア・Python 3.13.16・simpy 4.1.2)で動かしました。最初に simpy を入れています。

$ pip install --break-system-packages -q simpy==4.1.2
$ python3 -c "import simpy; print(simpy.__version__)"
4.1.2

1条件だけ回す(—one)

5つの試算の --one は、標準の条件で、ロボットの台数などを変えた数条件だけを回して表示します(最後の計算し直しのあとのコードで回したもの)。

$ python3 hospital_sim.py --one
0 {'scale': 'mid', 'robots': 0, 'demand': 1, 'floors': 'same', 'stop_rate': 0.0, 'dispatch': 'now', 'busy': 0.6, 'speed': 0.9, 'wait_mean': 8.1851, 'wait_p90': 11.212, 'late_share': 0.0, 'items': 30.9, 'staff_tr_min': 203.5613, 'other_wait': 16.1721, 'robot_trips': 0, 'staff_trips': 25.7, 'per_trip': 0.0, 'robot_util': 0.0, 'stops': 0, 'calls': 0}
1 {'scale': 'mid', 'robots': 1, 'demand': 1, 'floors': 'same', 'stop_rate': 0.05, 'dispatch': 'now', 'busy': 0.6, 'speed': 0.9, 'wait_mean': 11.9003, 'wait_p90': 17.96, 'late_share': 0.2392, 'items': 30.9, 'staff_tr_min': 21.4883, 'other_wait': 7.1149, 'robot_trips': 23.4, 'staff_trips': 0, 'per_trip': 1.32, 'robot_util': 0.4354, 'stops': 2.0333, 'calls': 0.9}
2 {'scale': 'mid', 'robots': 2, 'demand': 1, 'floors': 'same', 'stop_rate': 0.05, 'dispatch': 'now', 'busy': 0.6, 'speed': 0.9, 'wait_mean': 9.2316, 'wait_p90': 12.7386, 'late_share': 0.0391, 'items': 30.9, 'staff_tr_min': 24.1309, 'other_wait': 7.1133, 'robot_trips': 26.5333, 'staff_trips': 0, 'per_trip': 1.1653, 'robot_util': 0.2469, 'stops': 2.3, 'calls': 1.1333}
$ python3 cleaning_sim.py --one
0 0.3 {'facility': 'mall', 'staff': 3, 'robots': 0, 'share': 0.3, 'eff': 0.8, 'stuck_per_h': 0.0, 'dock': False, 'finish': 406.0, 'on_time': 1.0, 'staff_hours': 20.4, 'calls': 0, 'tank_calls': 0, 'stuck_calls': 0, 'call_min': 0.0, 'robot_wait': 0.0, 'robot_share_area': 0.0}
0 0.9 {'facility': 'mall', 'staff': 3, 'robots': 0, 'share': 0.9, 'eff': 0.8, 'stuck_per_h': 0.0, 'dock': False, 'finish': 263.0, 'on_time': 1.0, 'staff_hours': 13.2, 'calls': 0, 'tank_calls': 0, 'stuck_calls': 0, 'call_min': 0.0, 'robot_wait': 0.0, 'robot_share_area': 0.0}
1 0.3 {'facility': 'mall', 'staff': 3, 'robots': 1, 'share': 0.3, 'eff': 0.8, 'stuck_per_h': 0.5, 'dock': False, 'finish': 340.7452, 'on_time': 1.0, 'staff_hours': 17.0886, 'calls': 2.0333, 'tank_calls': 1, 'stuck_calls': 1.0333, 'call_min': 17.3146, 'robot_wait': 23.0963, 'robot_share_area': 0.3}
1 0.9 {'facility': 'mall', 'staff': 3, 'robots': 1, 'share': 0.9, 'eff': 0.8, 'stuck_per_h': 0.5, 'dock': False, 'finish': 461.1892, 'on_time': 1.0, 'staff_hours': 9.2467, 'calls': 2.2333, 'tank_calls': 1, 'stuck_calls': 1.2333, 'call_min': 18.3015, 'robot_wait': 20.7142, 'robot_share_area': 0.3549}
2 0.3 {'facility': 'mall', 'staff': 3, 'robots': 2, 'share': 0.3, 'eff': 0.8, 'stuck_per_h': 0.5, 'dock': False, 'finish': 336.508, 'on_time': 1.0, 'staff_hours': 16.8984, 'calls': 1.0333, 'tank_calls': 0, 'stuck_calls': 1.0333, 'call_min': 5.9055, 'robot_wait': 9.0162, 'robot_share_area': 0.3}
2 0.9 {'facility': 'mall', 'staff': 3, 'robots': 2, 'share': 0.9, 'eff': 0.8, 'stuck_per_h': 0.5, 'dock': False, 'finish': 463.5701, 'on_time': 1.0, 'staff_hours': 5.2491, 'calls': 4.5667, 'tank_calls': 2, 'stuck_calls': 2.5667, 'call_min': 37.9488, 'robot_wait': 41.3352, 'robot_share_area': 0.7153}
$ python3 hotel_sim.py --one
0 {'scale': 'mid', 'front': 2, 'robots': 0, 'demand': 1, 'link': 'system', 'stop_rate': 0.0, 'multi': 1, 'deliv_mean': 9.5686, 'deliv_p90': 19.8901, 'checkin_mean': 1.9137, 'checkin_p90': 4.8728, 'away_min': 278.7115, 'robot_n': 0, 'staff_n': 42.9667, 'absent': 0, 'stops': 0, 'calls': 0, 'robot_util': 0.0, 'robot_end': 0.0}
1 {'scale': 'mid', 'front': 2, 'robots': 1, 'demand': 1, 'link': 'system', 'stop_rate': 0.05, 'multi': 1, 'deliv_mean': 30.3419, 'deliv_p90': 53.1778, 'checkin_mean': 1.0783, 'checkin_p90': 3.5108, 'away_min': 42.1045, 'robot_n': 38.7, 'staff_n': 4.2667, 'absent': 4.2667, 'stops': 4.1667, 'calls': 6.5333, 'robot_util': 0.6188, 'robot_end': 569.756}
2 {'scale': 'mid', 'front': 2, 'robots': 2, 'demand': 1, 'link': 'system', 'stop_rate': 0.05, 'multi': 1, 'deliv_mean': 10.5091, 'deliv_p90': 19.46, 'checkin_mean': 1.0588, 'checkin_p90': 3.3308, 'away_min': 43.2247, 'robot_n': 38.6667, 'staff_n': 4.3, 'absent': 4.3, 'stops': 4.7667, 'calls': 6.6, 'robot_util': 0.326, 'robot_end': 539.3896}
$ python3 security_sim.py --one
2 0 sched {'scale': 'mid', 'guards': 2, 'robots': 0, 'incidents': 6, 'false_per_h': 0.0, 'rmode': 'sched', 'detect_mean': 61.7265, 'detect_p90': 86.1439, 'reach_mean': 61.7265, 'reach_p90': 86.1439, 'missed': 0.9333, 'max_gap': 120.0, 'center_empty': 0.0, 'foot_min': 302.1529, 'calls': 0, 'false_calls': 0, 'anoms': 6.2667, 'robot_laps': 0}
1 1 sched {'scale': 'mid', 'guards': 1, 'robots': 1, 'incidents': 6, 'false_per_h': 0.5, 'rmode': 'sched', 'detect_mean': 62.1009, 'detect_p90': 88.7614, 'reach_mean': 72.5488, 'reach_p90': 98.736, 'missed': 0.9667, 'max_gap': 120.0, 'center_empty': 164.735, 'foot_min': 164.735, 'calls': 7.6, 'false_calls': 2.3, 'anoms': 6.2667, 'robot_laps': 6}
1 1 cont {'scale': 'mid', 'guards': 1, 'robots': 1, 'incidents': 6, 'false_per_h': 0.5, 'rmode': 'cont', 'detect_mean': 38.7959, 'detect_p90': 52.0521, 'reach_mean': 49.9119, 'reach_p90': 64.9152, 'missed': 0, 'max_gap': 140.0, 'center_empty': 228.5931, 'foot_min': 228.5931, 'calls': 11.2, 'false_calls': 4.9333, 'anoms': 6.2667, 'robot_laps': 13}
2 1 cont {'scale': 'mid', 'guards': 2, 'robots': 1, 'incidents': 6, 'false_per_h': 0.5, 'rmode': 'cont', 'detect_mean': 38.7959, 'detect_p90': 52.0521, 'reach_mean': 49.9119, 'reach_p90': 64.9152, 'missed': 0, 'max_gap': 140.0, 'center_empty': 0.0, 'foot_min': 228.5931, 'calls': 11.2, 'false_calls': 4.9333, 'anoms': 6.2667, 'robot_laps': 13}
2 2 cont {'scale': 'mid', 'guards': 2, 'robots': 2, 'incidents': 6, 'false_per_h': 0.5, 'rmode': 'cont', 'detect_mean': 21.8718, 'detect_p90': 28.1602, 'reach_mean': 35.9385, 'reach_p90': 46.6901, 'missed': 0, 'max_gap': 115.0, 'center_empty': 0.0, 'foot_min': 316.2414, 'calls': 16.8333, 'false_calls': 10.5667, 'anoms': 6.2667, 'robot_laps': 26}
$ python3 outdoor_sim.py --one
{'scale': 'mid', 'robots': 2, 'operators': 1, 'orders': 20, 'int_rate': 0.2, 'watch': 10} {'lead_mean': 14.9584, 'lead_p90': 20.788, 'late_share': 0.0067, 'int_wait': 0.0217, 'depart_wait': 0.0, 'op_util': 0.0309, 'robot_util': 0.3729, 'end': 546.8267, 'calls': 8.4333, 'delivered': 18.8667, 'noshow': 1.1333}
{'scale': 'mid', 'robots': 4, 'operators': 1, 'orders': 150, 'int_rate': 0.2, 'watch': 10} {'lead_mean': 136.8149, 'lead_p90': 235.5011, 'late_share': 0.8685, 'int_wait': 0.1389, 'depart_wait': 0.0, 'op_util': 0.1479, 'robot_util': 0.9692, 'end': 808.9438, 'calls': 60.9667, 'delivered': 143.2667, 'noshow': 8.1667}
{'scale': 'mid', 'robots': 10, 'operators': 1, 'orders': 300, 'int_rate': 0.5, 'watch': 10} {'lead_mean': 114.758, 'lead_p90': 189.1854, 'late_share': 0.8668, 'int_wait': 2.5786, 'depart_wait': 0.0, 'op_util': 0.7949, 'robot_util': 0.9609, 'end': 760.6222, 'calls': 302.4, 'delivered': 284.9667, 'noshow': 14.0667}
{'scale': 'mid', 'robots': 10, 'operators': 2, 'orders': 300, 'int_rate': 0.5, 'watch': 10} {'lead_mean': 79.2224, 'lead_p90': 130.3616, 'late_share': 0.7542, 'int_wait': 0.2009, 'depart_wait': 0.0, 'op_util': 0.4355, 'robot_util': 0.9562, 'end': 687.102, 'calls': 299.3, 'delivered': 285.3667, 'noshow': 13.6667}
{'scale': 'mid', 'robots': 10, 'operators': 1, 'orders': 300, 'int_rate': 0.2, 'watch': 4} {'lead_mean': 438.4997, 'lead_p90': 776.5164, 'late_share': 0.9496, 'int_wait': 0.154, 'depart_wait': 25.252, 'op_util': 0.1678, 'robot_util': 0.9718, 'end': 1412.1396, 'calls': 119.2333, 'delivered': 284.1333, 'noshow': 14.9}

総当たりを回す

人数と台数の「その他」を足したあとの総当たりです。5つを順に回しました。

$ cd /home/claude/sites_sim
$ for m in security cleaning outdoor hotel hospital; do s=$(date +%s); python3 ${m}_sim.py; echo "$m $(( $(date +%s)-s ))s"; done
1674 conditions
security 20s
5840 conditions
cleaning 161s
3960 conditions
outdoor 203s
2379 conditions
hotel 232s
3276 conditions
hospital 397s
ALLDONE

記事の表を出す(summary.py)

本文の表は、すべてこの出力から取りました。

$ python3 /home/claude/LOG356/LOG356_scripts/summary.py /home/claude/sites_sim

### 病院搬送:ロボットの台数(荷物の量 普通・同じ階・すぐ出す・速さ0.9m/秒・忙しさ6割・止まる5%)
| 台数 | 届くまで(分) | 遅い方の1割(分) | 15分超え | 人が搬送に使った分/日 | ほかの仕事の待ち(分) | 便数/日 | 人を呼んだ回数 |
|---|---|---|---|---|---|---|---|
| 0 | 8.2 | 11.2 | 0% | 204 | 16.2 | 0.0 | 0.0 |
| 1 | 11.9 | 18.0 | 24% | 21 | 7.1 | 23.4 | 0.9 |
| 2 | 9.2 | 12.7 | 4% | 24 | 7.1 | 26.5 | 1.1 |

### 病院搬送:ロボットの速さ(1台・ほかは上と同じ)
| 速さ(m/秒) | 届くまで(分) | 15分超え | 人が搬送に使った分/日 |
|---|---|---|---|
| 0.6 | 16.5 | 50% | 21 |
| 0.9 | 11.9 | 24% | 21 |
| 1.2 | 10.1 | 14% | 24 |

### 病院搬送:途中で止まる割合(1台)
| 止まる割合 | 届くまで(分) | 人が搬送に使った分/日 | 人を呼んだ回数 |
|---|---|---|---|
| 0% | 11.5 | 18 | 0.0 |
| 5% | 11.9 | 21 | 0.9 |
| 15% | 12.9 | 31 | 3.2 |

### 病院搬送:階をまたぐ(エレベーター)
| 台数 | 届くまで(分) | 15分超え | 人が搬送に使った分/日 |
|---|---|---|---|
| 0 | 13.7 | 34% | 333 |
| 1 | 20.9 | 68% | 21 |
| 2 | 15.0 | 41% | 25 |

### 夜間清掃:office・スタッフ1人(速さ8割・引っかかり0.5回/時・水は人が入れ替える)
| 広い床の割合 | ロボットなし | 1台 | 2台 | 3台 |
|---|---|---|---|---|
| 30% | 10.2時間・8:12 | 17%減・呼んだ0.5回 | 17%減・呼んだ0.5回 | 17%減・呼んだ0.5回 |
| 50% | 9.0時間・7:00 | 30%減・呼んだ1.8回 | 32%減・呼んだ0.8回 | 32%減・呼んだ0.8回 |
| 70% | 7.8時間・5:48 | 50%減・呼んだ2.2回 | 52%減・呼んだ1.2回 | 52%減・呼んだ1.2回 |
| 90% | 6.6時間・4:36 | 60%減・呼んだ2.2回 | 74%減・呼んだ3.4回 | 80%減・呼んだ1.4回 |

### 夜間清掃:mall・スタッフ3人(速さ8割・引っかかり0.5回/時・水は人が入れ替える)
| 広い床の割合 | ロボットなし | 1台 | 2台 | 3台 |
|---|---|---|---|---|
| 30% | 20.4時間・4:46 | 16%減・呼んだ2.0回 | 17%減・呼んだ1.0回 | 17%減・呼んだ1.0回 |
| 50% | 18.0時間・4:00 | 22%減・呼んだ2.2回 | 30%減・呼んだ3.7回 | 32%減・呼んだ1.7回 |
| 70% | 15.6時間・3:10 | 25%減・呼んだ2.2回 | 50%減・呼んだ4.5回 | 49%減・呼んだ5.5回 |
| 90% | 13.2時間・2:23 | 30%減・呼んだ2.2回 | 60%減・呼んだ4.6回 | 75%減・呼んだ6.5回 |

### 夜間清掃:large・スタッフ4人(速さ8割・引っかかり0.5回/時・水は人が入れ替える)
| 広い床の割合 | ロボットなし | 1台 | 2台 | 3台 |
|---|---|---|---|---|
| 30% | 34.0時間・6:30 | 12%減・呼んだ2.2回 | 16%減・呼んだ3.7回 | 17%減・呼んだ1.7回 |
| 50% | 30.0時間・5:30 | 13%減・呼んだ2.2回 | 26%減・呼んだ4.5回 | 30%減・呼んだ6.1回 |
| 70% | 26.0時間・4:30 | 15%減・呼んだ2.2回 | 30%減・呼んだ4.5回 | 46%減・呼んだ7.2回 |
| 90% | 22.0時間・3:30 | 18%減・呼んだ2.2回 | 36%減・呼んだ4.5回 | 54%減・呼んだ7.2回 |

### 夜間清掃:引っかかる回数と充電台(商業施設・2人・2台・広い床70%・速さ8割)
| 引っかかり/時 | 充電台 | 人の作業(時間) | 人を呼んだ回数 | 呼ばれて使った分 | ロボットが待った分 |
|---|---|---|---|---|---|
| 0.0 | 人 | 7.6 | 2.0 | 23 | 33 |
| 0.0 | 自動 | 7.2 | 0.0 | 0 | 0 |
| 0.5 | 人 | 7.8 | 4.5 | 38 | 48 |
| 0.5 | 自動 | 7.4 | 2.5 | 14 | 18 |
| 1.5 | 人 | 8.6 | 9.7 | 68 | 85 |
| 1.5 | 自動 | 8.2 | 7.7 | 44 | 56 |

### ホテル:届くまで・フロントを離れた分・チェックインの待ち(止まる5%・1件ずつ)
| フロント | 依頼/日 | ロボットなし | 1台・建物とつなぐ | 1台・腕でボタン | 2台・建物とつなぐ |
|---|---|---|---|---|---|
| 2 | 25 | 6.8分・離れる152分・CI 1.5分 | 13.7分・離れる22分・CI 1.0分・呼んだ3.4回 | 20.1分・離れる23分・CI 1.0分・呼んだ3.6回 | 9.4分・離れる25分・CI 1.0分・呼んだ3.8回 |
| 2 | 45 | 9.6分・離れる279分・CI 1.9分 | 30.3分・離れる42分・CI 1.1分・呼んだ6.5回 | 70.5分・離れる41分・CI 1.0分・呼んだ6.3回 | 10.5分・離れる43分・CI 1.1分・呼んだ6.6回 |
| 2 | 80 | 25.5分・離れる503分・CI 2.7分 | 150.3分・離れる74分・CI 1.1分・呼んだ11.8回 | 199.8分・離れる61分・CI 1.1分・呼んだ9.6回 | 21.5分・離れる74分・CI 1.2分・呼んだ11.5回 |
| 3 | 25 | 4.2分・離れる151分・CI 0.2分 | 11.3分・離れる23分・CI 0.2分・呼んだ3.6回 | 18.0分・離れる24分・CI 0.2分・呼んだ3.7回 | 7.8分・離れる25分・CI 0.2分・呼んだ3.9回 |
| 3 | 45 | 4.4分・離れる277分・CI 0.4分 | 23.4分・離れる45分・CI 0.2分・呼んだ7.1回 | 56.1分・離れる40分・CI 0.2分・呼んだ6.2回 | 8.3分・離れる40分・CI 0.2分・呼んだ6.2回 |
| 3 | 80 | 5.0分・離れる506分・CI 0.6分 | 120.4分・離れる76分・CI 0.2分・呼んだ12.0回 | 180.7分・離れる60分・CI 0.2分・呼んだ9.7回 | 14.3分・離れる77分・CI 0.2分・呼んだ11.9回 |

### ホテル:1回に運ぶ依頼(フロント2人・80件/日・腕でボタン・1台)
| 1回に運ぶ | 届くまで(分) | 遅い方の1割(分) | 動いている割合 |
|---|---|---|---|
| 1 | 199.8 | 358.0 | 83% |
| 2 | 92.2 | 156.8 | 77% |

### 警備:異常に人が着くまで・巡回の最長の空白・センターが空いた分(異常6件/晩・空振り0.5回/時)
| 警備員 | ロボットなし | 1台・2時間ごと | 1台・回り続ける | 2台・2時間ごと | 2台・回り続ける |
|---|---|---|---|---|---|
| 1 | 着くまで62分・空白120分・センター空き302分 | 73分・空白120分・空き165分・呼んだ7.6回 | 50分・空白140分・空き229分・呼んだ11.2回 | 43分・空白60分・空き215分・呼んだ10.5回 | 36分・空白115分・空き316分・呼んだ16.8回 |
| 2 | 着くまで62分・空白120分・センター空き0分 | 73分・空白120分・空き0分・呼んだ7.6回 | 50分・空白140分・空き0分・呼んだ11.2回 | 43分・空白60分・空き0分・呼んだ10.5回 | 36分・空白115分・空き0分・呼んだ16.8回 |
| 3 | 着くまで62分・空白120分・センター空き0分 | 68分・空白120分・空き0分・呼んだ7.7回 | 45分・空白140分・空き0分・呼んだ11.3回 | 39分・空白60分・空き0分・呼んだ10.6回 | 29分・空白115分・空き0分・呼んだ16.8回 |

### 警備:空振りの知らせ(警備員1人・1台・回り続ける・異常6件)
| 空振り/時 | 人を呼んだ回数 | うち空振り | センターが空いた分 | 着くまで(分) |
|---|---|---|---|---|
| 0.0 | 6.3 | 0.0 | 151 | 48.1 |
| 0.5 | 11.2 | 4.9 | 229 | 49.9 |
| 1.5 | 23.2 | 16.9 | 418 | 55.2 |

### 屋外配送:届くまで・介入でオペレーターを待った秒・オペレーターがかかりきりの割合(オペレーター1人・1人10台まで)
| 注文/日 | 介入/片道 | 1台 | 2台 | 4台 | 6台 | 10台 |
|---|---|---|---|---|---|---|
| 20 | 0.05 | 36分・待ち0秒・1% | 15分・待ち0秒・1% | 12分・待ち0秒・1% | 12分・待ち0秒・1% | 12分・待ち0秒・1% |
| 20 | 0.2 | 39分・待ち0秒・3% | 15分・待ち1秒・3% | 12分・待ち2秒・3% | 12分・待ち3秒・3% | 12分・待ち3秒・3% |
| 20 | 0.5 | 42分・待ち0秒・6% | 16分・待ち4秒・7% | 13分・待ち5秒・7% | 13分・待ち7秒・7% | 13分・待ち7秒・7% |
| 60 | 0.05 | 341分・待ち0秒・1% | 69分・待ち1秒・2% | 16分・待ち0秒・2% | 13分・待ち0秒・2% | 12分・待ち3秒・2% |
| 60 | 0.2 | 356分・待ち0秒・4% | 75分・待ち2秒・7% | 17分・待ち8秒・9% | 13分・待ち7秒・9% | 13分・待ち8秒・9% |
| 60 | 0.5 | 391分・待ち0秒・9% | 91分・待ち7秒・17% | 20分・待ち18秒・21% | 14分・待ち24秒・22% | 13分・待ち26秒・21% |
| 120 | 0.05 | 939分・待ち0秒・1% | 348分・待ち0秒・2% | 64分・待ち2秒・4% | 23分・待ち2秒・4% | 13分・待ち2秒・4% |
| 120 | 0.2 | 980分・待ち0秒・4% | 365分・待ち3秒・8% | 73分・待ち8秒・14% | 25分・待ち12秒・17% | 13分・待ち18秒・18% |
| 120 | 0.5 | 1043分・待ち0秒・9% | 401分・待ち7秒・18% | 90分・待ち23秒・34% | 31分・待ち42秒・41% | 15分・待ち80秒・43% |
| 200 | 0.05 | 1717分・待ち0秒・1% | 737分・待ち0秒・2% | 241分・待ち2秒・4% | 84分・待ち2秒・6% | 21分・待ち7秒・7% |
| 200 | 0.2 | 1789分・待ち0秒・4% | 772分・待ち3秒・8% | 258分・待ち8秒・15% | 94分・待ち15秒・22% | 23分・待ち30秒・28% |
| 200 | 0.5 | 1899分・待ち0秒・9% | 831分・待ち7秒・19% | 298分・待ち24秒・35% | 125分・待ち49秒・52% | 34分・待ち127秒・66% |

### 屋外配送:届くまで・介入でオペレーターを待った秒・オペレーターがかかりきりの割合(オペレーター1人・1人4台まで)
| 注文/日 | 介入/片道 | 1台 | 2台 | 4台 | 6台 | 10台 |
|---|---|---|---|---|---|---|
| 20 | 0.05 | 36分・待ち0秒・1% | 15分・待ち0秒・1% | 12分・待ち0秒・1% | 12分・待ち0秒・1% | 12分・待ち0秒・1% |
| 20 | 0.2 | 39分・待ち0秒・3% | 15分・待ち1秒・3% | 12分・待ち2秒・3% | 12分・待ち2秒・3% | 12分・待ち2秒・3% |
| 20 | 0.5 | 42分・待ち0秒・6% | 16分・待ち4秒・7% | 13分・待ち5秒・7% | 13分・待ち6秒・7% | 13分・待ち6秒・7% |
| 60 | 0.05 | 341分・待ち0秒・1% | 69分・待ち1秒・2% | 16分・待ち0秒・2% | 15分・待ち1秒・2% | 15分・待ち1秒・2% |
| 60 | 0.2 | 356分・待ち0秒・4% | 75分・待ち2秒・7% | 17分・待ち8秒・9% | 16分・待ち6秒・9% | 16分・待ち6秒・9% |
| 60 | 0.5 | 391分・待ち0秒・9% | 91分・待ち7秒・17% | 20分・待ち18秒・21% | 18分・待ち17秒・21% | 18分・待ち19秒・22% |
| 120 | 0.05 | 939分・待ち0秒・1% | 348分・待ち0秒・2% | 64分・待ち2秒・4% | 47分・待ち2秒・4% | 47分・待ち2秒・4% |
| 120 | 0.2 | 980分・待ち0秒・4% | 365分・待ち3秒・8% | 73分・待ち8秒・14% | 54分・待ち8秒・15% | 54分・待ち7秒・15% |
| 120 | 0.5 | 1043分・待ち0秒・9% | 401分・待ち7秒・18% | 90分・待ち23秒・34% | 69分・待ち25秒・36% | 69分・待ち25秒・36% |
| 200 | 0.05 | 1717分・待ち0秒・1% | 737分・待ち0秒・2% | 241分・待ち2秒・4% | 193分・待ち2秒・4% | 191分・待ち2秒・5% |
| 200 | 0.2 | 1789分・待ち0秒・4% | 772分・待ち3秒・8% | 258分・待ち8秒・15% | 210分・待ち10秒・17% | 210分・待ち11秒・17% |
| 200 | 0.5 | 1899分・待ち0秒・9% | 831分・待ち7秒・19% | 298分・待ち24秒・35% | 249分・待ち25秒・38% | 248分・待ち25秒・39% |

### 病院搬送:病院の広さ(1台・ほかは標準)
| 広さ | 台数 | 届くまで(分) | 15分超え | 人が搬送に使った分/日 | 人を呼んだ回数 |
|---|---|---|---|---|---|
| small | 0 | 6.1 | 0% | 92 | 0.0 |
| small | 1 | 8.1 | 3% | 14 | 0.8 |
| small | 2 | 7.2 | 0% | 14 | 0.7 |
| mid | 0 | 8.2 | 0% | 204 | 0.0 |
| mid | 1 | 11.9 | 24% | 21 | 0.9 |
| mid | 2 | 9.2 | 4% | 24 | 1.1 |
| large | 0 | 11.5 | 19% | 367 | 0.0 |
| large | 1 | 18.7 | 62% | 30 | 1.3 |
| large | 2 | 13.3 | 29% | 39 | 2.1 |

### ホテル:ホテルの広さ(依頼 普通・建物とつなぐ・止まる5%・1件ずつ)
| 広さ | フロント | 台数 | 届くまで(分) | フロントを離れた分 | チェックインの待ち(分) | 人を呼んだ回数 |
|---|---|---|---|---|---|---|
| small | 1 | 0 | 34.7 | 173 | 5.4 | 0.0 |
| small | 1 | 1 | 40.0 | 26 | 4.0 | 4.4 |
| small | 1 | 2 | 20.9 | 26 | 3.9 | 4.4 |
| small | 2 | 0 | 4.4 | 175 | 0.5 | 0.0 |
| small | 2 | 1 | 12.1 | 25 | 0.3 | 4.3 |
| small | 2 | 2 | 7.5 | 26 | 0.3 | 4.4 |
| mid | 2 | 0 | 9.6 | 279 | 1.9 | 0.0 |
| mid | 2 | 1 | 30.3 | 42 | 1.1 | 6.5 |
| mid | 2 | 2 | 10.5 | 43 | 1.1 | 6.6 |
| large | 3 | 0 | 70.1 | 690 | 3.9 | 0.0 |
| large | 3 | 1 | 242.9 | 80 | 2.1 | 10.5 |
| large | 3 | 2 | 99.2 | 104 | 2.1 | 13.5 |

### 警備:ビルの広さ(警備員2人・異常6件・空振り0.5回/時・回り続ける)
| 広さ | 台数 | 着くまで(分) | 巡回の最長の空白(分) | 警備員が外にいた分 | 人を呼んだ回数 |
|---|---|---|---|---|---|
| small | 0 | 63.6 | 120 | 242 | 0.0 |
| small | 1 | 34.0 | 130 | 181 | 11.1 |
| small | 2 | 26.3 | 110 | 240 | 16.2 |
| mid | 0 | 61.7 | 120 | 302 | 0.0 |
| mid | 1 | 49.9 | 140 | 229 | 11.2 |
| mid | 2 | 35.9 | 115 | 316 | 16.8 |
| large | 0 | 63.0 | 120 | 422 | 0.0 |
| large | 1 | 75.6 | 165 | 313 | 11.8 |
| large | 2 | 55.8 | 128 | 436 | 16.9 |

### 屋外配送:配達エリアの広さ(オペレーター1人・1人10台まで・介入0.2回/片道)
| 広さ | 注文/日 | 2台 | 4台 | 6台 | 10台 |
|---|---|---|---|---|---|
| near | 20 | 10分・26% | 9分・13% | 9分・9% | 9分・5% |
| near | 60 | 27分・72% | 10分・38% | 9分・26% | 9分・15% |
| near | 120 | 165分・98% | 24分・74% | 11分・51% | 9分・31% |
| mid | 20 | 15分・37% | 12分・19% | 12分・13% | 12分・8% |
| mid | 60 | 75分・91% | 17分・55% | 13分・38% | 13分・23% |
| mid | 120 | 365分・99% | 73分・92% | 25分・72% | 13分・45% |
| wide | 20 | 28分・55% | 18分・28% | 18分・19% | 18分・11% |
| wide | 60 | 216分・97% | 41分・77% | 22分・55% | 18分・34% |
| wide | 120 | 680分・99% | 216分・97% | 77分・91% | 25分・65% |

同じコードで回し直して、結果がバイト単位で同じかを確かめる

$ mkdir rerun2 && cp /home/claude/sites_sim/*.py rerun2/ && cd rerun2
$ python3 security_sim.py && cmp results_security.json /home/claude/sites_sim/results_security.json && echo "security: 同じ"
1674 conditions
security: 同じ
$ python3 hotel_sim.py && cmp results_hotel.json /home/claude/sites_sim/results_hotel.json && echo "hotel: 同じ"
2379 conditions
hotel: 同じ

試算のページを1枚にまとめ、選べるすべての組み合わせの結果があるかを確かめる

$ python3 build_sites_page.py
1609985 bytes
$ python3 /home/claude/LOG356/LOG356_scripts/check_combos.py file:///home/claude/sites_sim/service_robot_sites.html <画面写真のフォルダ>
['hospital: 3888 combos checked', 'cleaning: 6720 combos checked', 'hotel: 2808 combos checked', 'security: 1944 combos checked', 'outdoor: 3960 combos checked', 'missing total 0']
errors: [] phone scrollWidth 390
$ python3 make_site_copy.py /home/claude/p2m-site/public/sim/service-robot-sites.html
/home/claude/p2m-site/public/sim/service-robot-sites.html 1610327 bytes

サイトのビルドと公開

サイトの変更は、この順にコミットして main に push しました(push すると約1分で公開される)。

$ git log --since="2026-10-20 02:00" --format="%h %ad %s" --date=format:"%H:%M" --reverse   # 時刻は日本時間
4e96409 12:02 試算をタブで切り替える形にし、配膳の次の5つの現場(病院搬送・夜間清掃・ホテル配送・夜間警備・屋外配送)を追加
8efe96c 12:11 試算のタブに写真を入れる(6枚、480×360)
6e745c8 12:13 配膳のタブに、試算の下の観察シートへ移るリンクを置く
0e80381 12:21 試算のタブの写真を2枚差し替え(清掃ロボットを濃いグレー、屋外配送ロボットをオレンジに)
c304da7 12:23 試算のタブの写真を差し替え(ホテルの配送ロボットをダークブラウンに)
41e9148 12:30 試算のタブの写真を差し替え(配膳ロボットをエメラルドグリーンに)
a940834 12:42 5つの現場の試算に、施設の広さを選ぶ黒い帯を足す(配膳の「店を選ぶ」と同じ形)
76e6749 12:44 スマホでは試算のタブを横スクロールにせず2列に並べる
db2ed70 12:50 スマホの試算のタブを、写真を小さく左・文字を右にしてコンパクトにする(エイイチの指示)
60a7932 13:03 スマホの試算のタブ:カードの余白をなくし、写真をカードの端まで広げて大きくする(エイイチの指示)
27d6452 13:04 5つの現場の試算の人数・台数に「その他」を付け、選べる数を増やして計算し直す(配膳の試算と同じ形)
0dba987 13:07 スマホの試算のタブ:選んだカードの写真の下に出ていた白い線を消す。条件の数の表記を計算し直した数にそろえる

公開したあと、公開中のページで次を確かめました。

$ curl -s "https://pixelstomotors.com/series8?p2m-notrack=1" | grep -o 'id="simtab-[a-z]*"'
id="simtab-serving"
id="simtab-hospital"
id="simtab-cleaning"
id="simtab-hotel"
id="simtab-security"
id="simtab-outdoor"
$ curl -s "https://pixelstomotors.com/series8?p2m-notrack=1" | grep -o "/site/sim/[a-z]*.jpg?v=[0-9]" | sort -u
/site/sim/cleaning.jpg?v=2
/site/sim/hospital.jpg?v=1
/site/sim/hotel.jpg?v=2
/site/sim/outdoor.jpg?v=2
/site/sim/security.jpg?v=1
/site/sim/serving.jpg?v=2
$ curl -s "https://pixelstomotors.com/series8?p2m-notrack=1" | grep -o "試作 v1・[0-9,]*条件"
試作 v1・3,276条件
試作 v1・5,840条件
試作 v1・2,379条件
試作 v1・1,674条件
試作 v1・3,960条件
$ curl -sL -o /dev/null -w "%{http_code} %{size_download}\n" "https://pixelstomotors.com/sim/service-robot-sites.html?embed=1"
200 1623641
$ curl -sL "https://pixelstomotors.com/sim/service-robot-sites.html?embed=1" | md5sum ; md5sum /home/claude/p2m-site/public/sim/service-robot-sites.html
5b3267a4f35a28ec3f23fcebc15d918b  -
5b3267a4f35a28ec3f23fcebc15d918b  /home/claude/p2m-site/public/sim/service-robot-sites.html

この回に使ったコードの全文

試算のコードは iCloud の サービスロボット導入シミュレーション/simulation_sites/、記事の表・図解・サムネイル・画面写真のスクリプトは LOG356_scripts/ に置いています。

common.py(5つの試算に共通の部品)

"""
サービスロボット導入シミュレーション:配膳の次の5つの現場(試作 v1)の共通部品

どの現場も「ロボットの仕事」と「ロボットに呼ばれる人の順番待ち」の2段で組む。
時間の単位はすべて分。数字のうち、公開資料から取ったものは各モデルの SOURCES に出典を書き、
それ以外は仮置き(ASSUMED)として分けて持つ。

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

REPS = 30  # 1条件あたりの回数。乱数の種は 0〜29 で固定


def tri(rng, lmh):
    """(最短, よくある値, 最長) の三角分布"""
    lo, mode, hi = lmh
    return rng.triangular(lo, hi, mode)


def p90(xs):
    if not xs:
        return 0.0
    s = sorted(xs)
    return s[int(0.9 * (len(s) - 1))]


def mean(xs):
    return statistics.mean(xs) if xs else 0.0


def poisson_times(rng, rate_per_min, start, end):
    """start〜end の間に、1分あたり rate_per_min で起きる出来事の時刻"""
    out, t = [], start
    if rate_per_min <= 0:
        return out
    while True:
        t += rng.expovariate(rate_per_min)
        if t >= end:
            return out
        out.append(t)


def profile_times(rng, total, windows):
    """1日の件数 total を、[(開始, 終了, 重み), ...] の時間帯に重みで割り振って時刻を作る。
    件数そのものもポアソンでばらつかせる。"""
    wsum = sum(w for _, _, w in windows)
    out = []
    for a, b, w in windows:
        lam = total * w / wsum / (b - a)
        out += poisson_times(rng, lam, a, b)
    return sorted(out)


def run_grid(run_once, conds, reps=REPS, procs=None):
    """条件の総当たりを回し、各条件 reps 回の平均を返す"""
    with Pool(procs) as pool:
        rows = pool.map(_RunCond(run_once, reps), conds, chunksize=4)
    return rows


class _RunCond:
    def __init__(self, fn, reps):
        self.fn, self.reps = fn, reps

    def __call__(self, params):
        runs = [self.fn(params, seed) for seed in range(self.reps)]
        agg = {k: round(statistics.mean(r[k] for r in runs), 4) for k in runs[0]}
        return {**params, **agg}


def grid(axes, skip=None):
    """axes = {名前: [値, ...]} の総当たり。skip(条件) が True のものは除く"""
    keys = list(axes)
    for vals in product(*(axes[k] for k in keys)):
        c = dict(zip(keys, vals))
        if skip and skip(c):
            continue
        yield c


def save(path, payload):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=1)

hospital_sim.py(病院の検体・薬剤の搬送)

"""
#2 病院の院内搬送(検体・薬剤)

東京慈恵会医科大学附属柏病院の公開事例(KEENON W3・2025年6月〜)の形をもとにする。
  3ルート・ルートごとに運行時間帯が決まっている・同じ階・1台で回している
人(看護補助者など)が運ぶ場合は、ほかの仕事の合間に、たまった荷物をまとめて持っていく。
ロボットの場合は、出発地の人が荷物を載せ、着いた先の人が受け取る。止まったら出発地の人が助けに行く。

時刻の 0 = 7:30、540 = 16:30。単位は分。

使い方:
    python hospital_sim.py          # 総当たりを回して results_hospital.json を書く
    python hospital_sim.py --one    # 1条件だけ回して表示
"""
import random
import sys

import simpy

from common import tri, p90, mean, poisson_times, run_grid, grid, save

# ---- 公開資料から取った形(出典は SOURCES)
ROUTES = {
    "r1": {"label": "救急・病棟 → 検査部", "windows": [(0, 30)]},                         # 7:30〜8:00
    "r2": {"label": "内科外来 → 検査部", "windows": [(30, 210), (330, 390), (450, 540)]},   # 8:00〜11:00・13:00〜14:00・15:00〜16:30
    "r3": {"label": "薬剤部 → 外来化学療法室", "windows": [(210, 330), (390, 450)]},       # 11:00〜13:00・14:00〜15:00
}
DAY_END = 540
ROBOT_LOAD_MAX = 6          # 1便に積む荷物の上限(仮。W3 は1回で最大4か所に届けられる)

# ---- 仮置き(公開資料にない値)
ASSUMED = {
    "dist_m": {"r1": 200, "r2": 250, "r3": 180},        # 片道の距離。人の平均移動時間 9分39秒 に近くなるように置いた
    "staff_speed": 1.2 * 60,                           # m/分
    "handover": (0.5, 1.5, 3.0),                       # 着いた先で受け取る人を見つけて渡す(分)
    "load_base": 0.5, "load_per_item": 0.2,            # ロボットに載せる手間(分)
    "unload": (0.3, 1.0, 3.0),                         # ロボットから受け取る(分)
    "elev_wait_staff": (0.5, 1.5, 4.0),                # 階をまたぐときの待ち(人)
    "elev_wait_robot": (0.5, 2.0, 6.0),                # 階をまたぐときの待ち(ロボット。人と一緒に乗れないので長め)
    "elev_peak_x": 1.5,                                # 外来の山(8:00〜11:00)はエレベーター待ちが1.5倍
    "elev_ride": 1.0,
    "self_clear": (0.2, 0.5, 1.5),                     # 止まって自然に解ける(分)
    "need_staff": 0.5,                                 # 止まったうち、人が助けに行く割合
    "rescue_work": 1.0,
    "other_job": (2, 5, 10),                           # 出発地の人のほかの仕事1件(分)
    "batch_n": 3, "batch_wait": 10,                    # 「まとめて出す」:3件たまるか、最初の荷物が10分待ったら出る
    "late_min": 15,                                    # 検査部に着くまでこれを超えたら「遅れた」
}
DEMAND = {  # 1時間あたりに出る荷物の数(少ない・普通・多い)。1日の便数 4〜9便・5〜15便 から逆に置いた
    "r1": [6, 10, 16], "r2": [2, 3.5, 5], "r3": [1.5, 2.5, 4],
}

# 病院の広さ(仮置き)。距離と荷物の量を、慈恵医大柏病院(664床)の規模を1として掛ける
SCALES = {
    "small": {"label": "中小病院", "beds": 200, "dist_x": 0.6, "demand_x": 0.6, "desc": "地域の病院。検査部も薬剤部も近い"},
    "mid": {"label": "中規模病院", "beds": 660, "dist_x": 1.0, "demand_x": 1.0, "desc": "慈恵医大柏病院(664床)の規模"},
    "large": {"label": "大学病院", "beds": 1100, "dist_x": 1.6, "demand_x": 1.5, "desc": "建物が広く、検査部まで遠い。荷物も多い"},
}

AXES = {
    "scale": list(SCALES),
    "robots": [0, 1, 2, 3, 4, 5],         # ページでは 0〜2 を並べ、3〜5 は「その他」から選ぶ
    "demand": [0, 1, 2],
    "floors": ["same", "elev"],
    "stop_rate": [0.0, 0.05, 0.15],
    "dispatch": ["now", "batch"],
    "busy": [0.3, 0.6],                 # 出発地の人が、ほかの仕事で手がふさがっている割合
    "speed": [0.6, 0.9, 1.2],           # ロボットの速さ(m/秒)。W3 の最高は 1.2m/秒。人の多い廊下では落とす
}


class Hospital:
    def __init__(self, env, rng, p):
        self.env, self.rng, self.p = env, rng, p
        A = ASSUMED
        self.staff = {r: simpy.PriorityResource(env, capacity=1) for r in ROUTES}
        self.robots = simpy.Resource(env, capacity=p["robots"]) if p["robots"] else None
        self.queue = {r: [] for r in ROUTES}          # 出発地で待っている荷物(出た時刻)
        self.pending = {r: False for r in ROUTES}
        self.waits, self.other_waits = [], []
        self.staff_tr = 0.0
        self.robot_busy = 0.0
        self.trips = self.items_robot = self.stops = self.rescues = self.staff_trips = 0
        sc = SCALES[p["scale"]]
        self.d = {r: v * sc["dist_x"] for r, v in A["dist_m"].items()}
        self.demand_x = sc["demand_x"]

    def elev(self, who):
        A, rng, env = ASSUMED, self.rng, self.env
        if self.p["floors"] != "elev":
            return 0.0
        w = tri(rng, A["elev_wait_robot" if who == "robot" else "elev_wait_staff"])
        if 30 <= env.now <= 210:
            w *= A["elev_peak_x"]
        return w + A["elev_ride"]

    # ---- 出発地の人のほかの仕事
    def other_work(self, r):
        rng, env = self.rng, self.env
        rate = self.p["busy"] / ASSUMED["other_job"][1]
        for t in poisson_times(rng, rate, 0, DAY_END):
            env.process(self._other(r, t))

    def _other(self, r, t):
        env = self.env
        yield env.timeout(t - env.now)
        t0 = env.now
        with self.staff[r].request(priority=2) as req:
            yield req
            self.other_waits.append(env.now - t0)
            yield env.timeout(tri(self.rng, ASSUMED["other_job"]))

    # ---- 荷物が出る
    def items(self, r):
        rng, env = self.rng, self.env
        lam = DEMAND[r][self.p["demand"]] * self.demand_x / 60
        ts = []
        for a, b in ROUTES[r]["windows"]:
            ts += poisson_times(rng, lam, a, b)
        for t in sorted(ts):
            yield env.timeout(t - env.now)
            self.queue[r].append(env.now)

    def window_open(self, r, t):
        return any(a <= t < b for a, b in ROUTES[r]["windows"])

    # ---- 出発の判断(0.5分ごとに見る)
    def dispatcher(self, r):
        env, p, A = self.env, self.p, ASSUMED
        while True:
            q = self.queue[r]
            if q and not self.pending[r]:
                if self.robots is None:
                    self.pending[r] = True
                    env.process(self.staff_trip(r))
                elif self.robots.count < self.robots.capacity:
                    go = p["dispatch"] == "now" or len(q) >= A["batch_n"] or env.now - q[0] >= A["batch_wait"] \
                        or not self.window_open(r, env.now)
                    if go:
                        self.pending[r] = True
                        env.process(self.robot_trip(r))
            if env.now > DAY_END and not any(self.queue.values()) and not any(self.pending.values()):
                return
            yield env.timeout(0.5)

    # ---- 人が運ぶ(行くときにたまっている荷物を全部持っていく)
    def staff_trip(self, r):
        env, rng, A = self.env, self.rng, ASSUMED
        with self.staff[r].request(priority=1) as req:
            yield req
            batch, self.queue[r] = self.queue[r], []
            self.pending[r] = False
            t0 = env.now
            walk = self.d[r] / A["staff_speed"]
            yield env.timeout(walk + self.elev("staff"))
            yield env.timeout(tri(rng, A["handover"]))
            self.waits += [env.now - t for t in batch]
            yield env.timeout(walk + self.elev("staff"))
            self.staff_tr += env.now - t0
            self.staff_trips += 1

    # ---- ロボットが運ぶ
    def leg(self, r):
        """片道。途中で止まることがある"""
        env, rng, A, p = self.env, self.rng, ASSUMED, self.p
        yield env.timeout(self.d[r] / (p["speed"] * 60) + self.elev("robot"))
        if rng.random() < p["stop_rate"]:
            self.stops += 1
            if rng.random() < A["need_staff"]:
                self.rescues += 1
                with self.staff[r].request(priority=0) as req:
                    yield req
                    t0 = env.now
                    yield env.timeout(self.d[r] / 2 / A["staff_speed"] * 2 + A["rescue_work"])
                    self.staff_tr += env.now - t0
            else:
                yield env.timeout(tri(rng, A["self_clear"]))

    def robot_trip(self, r):
        env, rng, A = self.env, self.rng, ASSUMED
        with self.robots.request() as rreq:
            yield rreq
            # 出発地の人が載せる
            with self.staff[r].request(priority=1) as sreq:
                yield sreq
                batch, self.queue[r] = self.queue[r][:ROBOT_LOAD_MAX], self.queue[r][ROBOT_LOAD_MAX:]
                self.pending[r] = False
                t = A["load_base"] + A["load_per_item"] * len(batch)
                yield env.timeout(t)
                self.staff_tr += t
            t0 = env.now
            yield env.process(self.leg(r))
            yield env.timeout(tri(rng, A["unload"]))
            self.waits += [env.now - t for t in batch]
            yield env.process(self.leg(r))
            self.robot_busy += env.now - t0
            self.trips += 1
            self.items_robot += len(batch)


def run_once(params, seed):
    env = simpy.Environment()
    h = Hospital(env, random.Random(seed), params)
    for r in ROUTES:
        h.other_work(r)
        env.process(h.items(r))
        env.process(h.dispatcher(r))
    env.run(until=DAY_END + 240)
    w = h.waits
    return {
        "wait_mean": mean(w), "wait_p90": p90(w),
        "late_share": sum(x > ASSUMED["late_min"] for x in w) / len(w) if w else 0.0,
        "items": len(w),
        "staff_tr_min": h.staff_tr,                    # 人が搬送(運ぶ・載せる・助けに行く)に使った分
        "other_wait": mean(h.other_waits),              # 出発地の人のほかの仕事が待たされた分(平均)
        "robot_trips": h.trips, "staff_trips": h.staff_trips,
        "per_trip": h.items_robot / h.trips if h.trips else 0.0,
        "robot_util": h.robot_busy / (params["robots"] * DAY_END) if params["robots"] else 0.0,
        "stops": h.stops, "calls": h.rescues,
    }


def conditions():
    return list(grid(AXES, skip=lambda c: c["robots"] == 0 and (c["stop_rate"] > 0 or c["dispatch"] != "now" or c["speed"] != 0.9)))


SOURCES = [
    ("3ルートと運行時間帯、1日の便数(内科 4〜9便・薬剤 5〜15便)、1日66〜172分の削減(病院の発表)", "https://prtimes.jp/main/html/rd/p/000000111.000028572.html"),
    ("実証でのスタッフの平均移動時間 9分39秒→4分57秒、1日の平均運搬回数 9.5回→4.8回、1度に最大4か所", "https://www.logi-today.com/764565"),
    ("同じ階だけで運行・エレベーター連携はこれから", "https://www.nikkan.co.jp/releases/view/221980"),
    ("エレベーターでは人とロボットが一緒に乗れない(HOSPI の事例)", "https://content.connect.panasonic.com/jp-ja/fai/file/769"),
]

if __name__ == "__main__":
    if "--one" in sys.argv:
        for rb in [0, 1, 2]:
            c = {"scale": "mid", "robots": rb, "demand": 1, "floors": "same", "stop_rate": 0.05 if rb else 0.0, "dispatch": "now", "busy": 0.6, "speed": 0.9}
            print(rb, run_grid(run_once, [c], procs=1)[0])
    else:
        conds = conditions()
        rows = run_grid(run_once, conds)
        save("results_hospital.json", {"scales": SCALES, "assumed": ASSUMED, "demand": DEMAND, "routes": ROUTES, "axes": AXES,
                                       "sources": SOURCES, "rows": rows})
        print(len(rows), "conditions")

cleaning_sim.py(ビル・商業施設の夜間清掃)

"""
#3 ビル・商業施設の夜間の床清掃

施設の床を「ロボットが拭ける広い床」と「人が拭く隅・壁際・狭い所」に分ける。
ロボットは広い床を拭き、水が空になる・電池が切れる・引っかかると止まる。
水の入れ替えと引っかかりは、人を呼ぶ(自動で給排水する充電台があれば、水は人を呼ばない)。
人は、自分の担当(隅・壁際)を5分ずつ進め、合間にロボットの呼び出しに応える。
自分の担当が終わったら、ロボットの残りの広い床を手伝う。

時刻の 0 = 22:00、480 = 6:00(朝の開店・始業)。単位は分。
"""
import random
import sys

import simpy

from common import tri, mean, run_grid, grid, save

NIGHT = 480

FACILITIES = {  # 床面積は仮置き
    # staff はページに並べる人数、staff_all は「その他」まで含めて選べる人数
    "office": {"label": "オフィスビルの共用部", "area": 3000, "staff": [1, 2, 3], "staff_all": [1, 2, 3, 4, 5]},
    "mall": {"label": "商業施設", "area": 6000, "staff": [2, 3, 4], "staff_all": [1, 2, 3, 4, 5, 6, 7]},
    "large": {"label": "大型施設(空港の一角など)", "area": 10000, "staff": [3, 4, 5, 6], "staff_all": [2, 3, 4, 5, 6, 7, 8, 9]},
}

SPEC = {  # カタログ値(出典は SOURCES)
    "robot_rate": 500 / 60,        # ㎡/分。カタログ 約400〜700㎡/時 の間
    "tank_area": 1200,             # 水1回分で拭ける広さ(㎡)
    "battery": 4.5 * 60,           # 水洗いで動ける時間(分)
    "charge": 2.0 * 60,            # 充電(分)
}
ASSUMED = {
    "staff_open_rate": 500 / 60,   # 人が広い床を機械で拭く(㎡/分)
    "staff_edge_rate": 250 / 60,   # 人が隅・壁際・狭い所を拭く(㎡/分)
    "chunk": 5,                    # 人は5分ずつ進め、合間に呼び出しを見る
    "walk_to_robot": (1, 3, 6),    # 呼ばれてロボットの所まで歩く(分)
    "tank_work": (5, 8, 12),       # 水の入れ替えとごみ捨て(分)
    "stuck_work": (1, 2, 4),       # 引っかかったロボットを動かす(分)
    "self_clear": 1.0,             # 自然に解ける(分)
    "need_staff": 0.5,             # 引っかかったうち、人が行く割合
    "dock_trip": 5, "dock_work": 10,  # 自動給排水の充電台まで行って戻る・給排水(分)
}

AXES = {
    "facility": list(FACILITIES),
    "staff": sorted({s for f in FACILITIES.values() for s in f["staff_all"]}),
    "robots": [0, 1, 2, 3, 4, 5, 6],      # ページでは 0〜3 を並べ、4〜6 は「その他」から選ぶ
    "share": [0.3, 0.5, 0.7, 0.9],         # 施設の床のうち、ロボットが拭ける広い床の割合
    "eff": [0.6, 0.8],                     # 実際の速さ/カタログの速さ
    "stuck_per_h": [0.0, 0.5, 1.5],        # 1時間あたり引っかかる回数(1台)
    "dock": [False, True],                 # 自動で給排水する充電台
}


class Night:
    def __init__(self, env, rng, p):
        self.env, self.rng, self.p = env, rng, p
        f = FACILITIES[p["facility"]]
        share = p["share"] if p["robots"] else p["share"]
        self.open_left = f["area"] * share        # 広い床の残り
        self.edge_left = f["area"] * (1 - share)  # 隅・壁際の残り
        self.calls = []                           # ロボットからの呼び出し(イベント, 作業時間)
        self.call_n = self.tank_calls = self.stuck_calls = 0
        self.call_min = 0.0
        self.staff_work = 0.0
        self.robot_area = 0.0
        self.done_at = None
        self.robot_wait = 0.0

    def all_done(self):
        return self.open_left <= 1e-6 and self.edge_left <= 1e-6

    def mark(self):
        if self.done_at is None and self.all_done():
            self.done_at = self.env.now

    # ---- 人
    def staff(self):
        env, rng, A = self.env, self.rng, ASSUMED
        while not self.all_done() or self.calls:
            if self.calls:
                ev, work = self.calls.pop(0)
                t = tri(rng, A["walk_to_robot"]) + work
                yield env.timeout(t)
                self.call_min += t
                self.staff_work += t
                ev.succeed()
                continue
            if self.edge_left > 0:
                take = min(self.edge_left, A["staff_edge_rate"] * A["chunk"])
                self.edge_left -= take
                t = take / A["staff_edge_rate"]
            elif self.open_left > 0 and self.need_help():
                take = min(self.open_left, A["staff_open_rate"] * A["chunk"])
                self.open_left -= take
                t = take / A["staff_open_rate"]
            else:
                # 自分の担当は終わり。ロボットの呼び出しだけ待つ
                if self.robots_running == 0:
                    return
                yield env.timeout(1)
                continue
            yield env.timeout(t)
            self.staff_work += t
            self.mark()

    def need_help(self):
        """ロボットだけでは6:00までに広い床が終わらない見込みなら、人が手伝う。
        ロボットがいなければ、人が広い床も拭く"""
        if self.robots_running == 0:
            return True
        left = max(NIGHT - self.env.now, 0)
        # 充電で止まる分を見込んで、残り時間の75%だけ拭けるとみる
        can = self.robots_running * SPEC["robot_rate"] * self.p["eff"] * left * 0.75
        return self.open_left > can

    def call(self, work):
        ev = self.env.event()
        self.calls.append((ev, work))
        self.call_n += 1
        t0 = self.env.now
        yield ev
        self.robot_wait += self.env.now - t0

    # ---- ロボット
    def robot(self):
        env, rng, A, p = self.env, self.rng, ASSUMED, self.p
        rate = SPEC["robot_rate"] * p["eff"]
        tank = SPEC["tank_area"]
        batt = SPEC["battery"]
        stuck_p = p["stuck_per_h"] / 60
        while self.open_left > 1e-6:
            # 1分ずつ拭く
            take = min(self.open_left, rate, tank)
            self.open_left -= take
            tank -= take
            batt -= take / rate
            self.robot_area += take
            yield env.timeout(take / rate)
            self.mark()
            if self.open_left <= 1e-6:
                break
            if rng.random() < stuck_p * (take / rate):
                if rng.random() < A["need_staff"]:
                    self.stuck_calls += 1
                    yield env.process(self.call(tri(rng, A["stuck_work"])))
                else:
                    yield env.timeout(A["self_clear"])
            if tank <= 1e-6:
                if p["dock"]:
                    yield env.timeout(A["dock_trip"] + A["dock_work"])
                else:
                    self.tank_calls += 1
                    yield env.process(self.call(tri(rng, A["tank_work"])))
                tank = SPEC["tank_area"]
            if batt <= 0:
                yield env.timeout(SPEC["charge"])
                batt = SPEC["battery"]
        self.robots_running -= 1


def run_once(params, seed):
    env = simpy.Environment()
    n = Night(env, random.Random(seed), params)
    n.robots_running = params["robots"]
    for _ in range(params["robots"]):
        env.process(n.robot())
    for _ in range(params["staff"]):
        env.process(n.staff())
    env.run(until=NIGHT * 3)
    done = n.done_at if n.done_at is not None else NIGHT * 3
    area = FACILITIES[params["facility"]]["area"]
    return {
        "finish": done,                                   # 全部終わった時刻(22:00 からの分)
        "on_time": 1.0 if done <= NIGHT else 0.0,         # 6:00 に間に合ったか
        "staff_hours": n.staff_work / 60,                 # 人が手を動かした時間の合計(呼び出し対応を含む)
        "calls": n.call_n, "tank_calls": n.tank_calls, "stuck_calls": n.stuck_calls,
        "call_min": n.call_min,                           # 呼ばれて使った分
        "robot_wait": n.robot_wait,                       # ロボットが人を待って止まっていた分(全台の合計)
        "robot_share_area": n.robot_area / area,          # 施設の床のうちロボットが拭いた割合
    }


def conditions():
    out = []
    for c in grid(AXES):
        f = FACILITIES[c["facility"]]
        if c["staff"] not in f["staff_all"]:
            continue
        if c["robots"] == 0 and (c["eff"] != 0.8 or c["stuck_per_h"] > 0 or c["dock"]):
            continue
        out.append(c)
    return out


SOURCES = [
    ("Phantas:1時間に約400〜700㎡、水1回で1,200㎡、水洗い約5時間(製造元は4.5時間・充電2時間)", "https://www.irisohyama.co.jp/b2b/robotics/products/phantas/"),
    ("Gausium(Phantas の製造元)の仕様と国内代理店", "https://www.logi-today.com/988770"),
    ("森トラスト:清掃の1日の総時間が約5%減(11施設に6台)", "https://www.meti.go.jp/policy/mono_info_service/mono/robot/2503_facilitybook.pdf"),
    ("ホテルバリタワー大阪天王寺:ロビーと客室階の廊下の清掃時間が60%減", "https://news.livedoor.com/article/detail/24690169/"),
    ("PUDU:ごみ回収と給排水を自動でする充電台(2025年9月)", "https://kyodonewsprwire.jp/index.php/release/202509235725"),
    ("ビルメンテナンス業の88.5%が「現場の従業員が集まりにくい」", "https://cdn.j-bma.or.jp/wp-content/uploads/2026/04/16115006/2026_bmiy56_report.pdf"),
]

if __name__ == "__main__":
    if "--one" in sys.argv:
        for rb in [0, 1, 2]:
            for sh in [0.3, 0.9]:
                c = {"facility": "mall", "staff": 3, "robots": rb, "share": sh, "eff": 0.8, "stuck_per_h": 0.5 if rb else 0.0, "dock": False}
                print(rb, sh, run_grid(run_once, [c], procs=1)[0])
    else:
        rows = run_grid(run_once, conditions())
        save("results_cleaning.json", {"facilities": FACILITIES, "spec": SPEC, "assumed": ASSUMED, "axes": AXES,
                                       "sources": SOURCES, "rows": rows})
        print(len(rows), "conditions")

hotel_sim.py(ホテルの館内配送)

"""
#4 ホテルの館内配送(アメニティを客室へ)

フロントの人は、チェックインの客の対応と、客室からの依頼の配達を兼ねている。
人が配達に出るとフロントが手薄になり、チェックインの列が延びる。
ロボットの場合は、フロントの人が載せるだけ。ロボットがエレベーターで上がり、部屋の前で客を呼ぶ。
客が出てこなければ持ち帰り、あとで人が届ける。止まったら、フロントの人が助けに行く。

時刻の 0 = 15:00、540 = 24:00。単位は分。
"""
import random
import sys

import simpy

from common import tri, p90, mean, profile_times, run_grid, grid, save

DAY_END = 540
ROOMS, OCC = 200, 0.85                            # 仮置き
CHECKIN_WINDOWS = [(0, 120, 35), (120, 240, 35), (240, 360, 20), (360, 540, 10)]  # 仮置き(15〜19時に山)
REQ_WINDOWS = [(0, 180, 20), (180, 420, 55), (420, 540, 25)]                     # 仮置き(夜に山)
DEMAND = [25, 45, 80]   # 1日の依頼数。普通は ザ・サウザンド京都 の1日40〜50回 から

ASSUMED = {
    "checkin": (1.5, 2.5, 4),                  # チェックイン1組(分)
    "elev_staff": (0.3, 1.0, 3.0),         # エレベーター待ち(人)
    "elev_system": (0.3, 1.0, 3.0),        # ロボット:建物のシステムとつないだ場合
    "elev_arm": (0.5, 2.0, 5.0),           # ロボット:腕でボタンを押す・カードをかざす場合(満員なら見送る)
    "arm_extra": 0.5,
    "peak_x": 1.3,                         # 15:00〜18:00 は客の出入りでエレベーター待ちが1.3倍
    "ride": (0.3, 0.5, 1.0),
    "walk_staff": 0.7,                     # エレベーターから部屋まで(人・片道、約50m)
    "walk_robot": 1.2,                     # 同じ距離をロボットで(秒速0.7m)
    "door_staff": (0.3, 0.7, 1.5),         # 手渡し
    "door_robot": (0.3, 0.8, 2.0),         # 電話で呼ばれて客が出てくる
    "absent": 0.10,                        # 客が出てこない割合
    "absent_wait": 3.0,                    # 出てこないとき、待ってから持ち帰る
    "load": 1.0,                           # フロントの人がロボットに載せる
    "need_staff": 0.5,
    "self_clear": (0.2, 0.5, 1.5),
    "rescue_work": 1.0,
}

# ホテルの広さ(仮置き)。客室数で、チェックインと依頼の数が変わる。walk_x は廊下の長さ、ride_x はエレベーターに乗っている時間
SCALES = {
    # front はページに並べる人数、front_all は「その他」まで含めて選べる人数
    "small": {"label": "ビジネスホテル", "rooms": 120, "floors": 8, "walk_x": 0.8, "ride_x": 0.8, "front": [1, 2], "front_all": [1, 2, 3]},
    "mid": {"label": "シティホテル", "rooms": 200, "floors": 12, "walk_x": 1.0, "ride_x": 1.0, "front": [2, 3], "front_all": [1, 2, 3, 4, 5]},
    "large": {"label": "大型ホテル", "rooms": 400, "floors": 25, "walk_x": 1.4, "ride_x": 1.6, "front": [3, 4], "front_all": [2, 3, 4, 5, 6]},
}

AXES = {
    "scale": list(SCALES),
    "front": [1, 2, 3, 4, 5, 6],          # フロントの人数(施設の広さごとに、選べる人数を SCALES の front_all に絞る)
    "robots": [0, 1, 2, 3, 4, 5],         # ページでは 0〜2 を並べ、3〜5 は「その他」から選ぶ
    "demand": [0, 1, 2],
    "link": ["system", "arm"],
    "stop_rate": [0.0, 0.05, 0.15],
    "multi": [1, 2],                       # 1回に運ぶ依頼の数(収納が2つある機種なら2)
}


class Hotel:
    def __init__(self, env, rng, p):
        self.env, self.rng, self.p = env, rng, p
        self.sc = SCALES[p["scale"]]
        self.front = simpy.PriorityResource(env, capacity=p["front"])
        self.robots = simpy.Resource(env, capacity=p["robots"]) if p["robots"] else None
        self.orders = []          # 待っている依頼(出た時刻)
        self.deliv, self.cwait = [], []
        self.away = 0.0
        self.robot_busy = 0.0
        self.robot_n = self.absent_n = self.stops = self.rescues = self.staff_n = 0
        self.pending = 0
        self.last = 0.0   # ロボットが最後に戻った時刻

    def elev(self, who):
        A, rng = ASSUMED, self.rng
        if who == "staff":
            w = tri(rng, A["elev_staff"])
        elif self.p["link"] == "system":
            w = tri(rng, A["elev_system"])
        else:
            w = tri(rng, A["elev_arm"]) + A["arm_extra"]
        if self.env.now < 180:
            w *= A["peak_x"]
        return w + tri(rng, A["ride"]) * self.sc["ride_x"]

    # ---- チェックイン
    def checkins(self):
        env, rng = self.env, self.rng
        for t in profile_times(rng, self.sc["rooms"] * OCC, CHECKIN_WINDOWS):
            yield env.timeout(t - env.now)
            env.process(self.checkin())

    def checkin(self):
        env = self.env
        t0 = env.now
        with self.front.request(priority=1) as req:
            yield req
            self.cwait.append(env.now - t0)
            yield env.timeout(tri(self.rng, ASSUMED["checkin"]))

    # ---- 依頼
    def requests(self):
        env, rng = self.env, self.rng
        # 依頼の数は 200室 のときの値を、客室数に合わせて増減する
        for t in profile_times(rng, DEMAND[self.p["demand"]] * self.sc["rooms"] / ROOMS, REQ_WINDOWS):
            yield env.timeout(t - env.now)
            self.orders.append(env.now)

    def dispatcher(self):
        env = self.env
        while env.now < DAY_END + 300:
            if self.orders and self.pending == 0:
                if self.robots is None:
                    self.pending += 1
                    env.process(self.staff_trip())
                elif self.robots.count < self.robots.capacity:
                    self.pending += 1
                    env.process(self.robot_trip())
            yield env.timeout(0.5)

    def staff_trip(self, redeliver=None):
        env, rng, A = self.env, self.rng, ASSUMED
        with self.front.request(priority=2) as req:
            yield req
            if redeliver is None:
                t_order = self.orders.pop(0)
                self.pending -= 1
            else:
                t_order = redeliver
            t0 = env.now
            yield env.timeout(self.elev("staff") + A["walk_staff"] * self.sc["walk_x"] + tri(rng, A["door_staff"]))
            self.deliv.append(env.now - t_order)
            yield env.timeout(A["walk_staff"] * self.sc["walk_x"] + self.elev("staff"))
            self.away += env.now - t0
            self.staff_n += 1

    def maybe_stop(self):
        env, rng, A = self.env, self.rng, ASSUMED
        if rng.random() < self.p["stop_rate"]:
            self.stops += 1
            if rng.random() < A["need_staff"]:
                self.rescues += 1
                with self.front.request(priority=0) as req:
                    yield req
                    t0 = env.now
                    yield env.timeout(2 * (self.elev("staff") + A["walk_staff"] * self.sc["walk_x"]) + A["rescue_work"])
                    self.away += env.now - t0
            else:
                yield env.timeout(tri(rng, A["self_clear"]))

    def robot_trip(self):
        env, rng, A = self.env, self.rng, ASSUMED
        with self.robots.request() as rreq:
            yield rreq
            with self.front.request(priority=2) as sreq:
                yield sreq
                n = min(self.p["multi"], len(self.orders))
                batch, self.orders = self.orders[:n], self.orders[n:]
                self.pending -= 1
                yield env.timeout(A["load"] * n)
            t0 = env.now
            yield env.timeout(self.elev("robot"))
            yield env.process(self.maybe_stop())
            for i, t_order in enumerate(batch):
                w = A["walk_robot"] * self.sc["walk_x"]
                yield env.timeout(w if i == 0 else w / 2)
                if rng.random() < A["absent"]:
                    self.absent_n += 1
                    yield env.timeout(A["absent_wait"])
                    env.process(self._later(t_order))
                else:
                    yield env.timeout(tri(rng, A["door_robot"]))
                    self.deliv.append(env.now - t_order)
                    self.robot_n += 1
            yield env.timeout(A["walk_robot"] * self.sc["walk_x"])
            yield env.process(self.maybe_stop())
            yield env.timeout(self.elev("robot"))
            self.robot_busy += env.now - t0
            self.last = max(self.last, env.now)

    def _later(self, t_order):
        # 持ち帰った依頼は、ロボットが戻ってから人が届ける(戻るまで約8分とみる)
        yield self.env.timeout(8)
        yield self.env.process(self.staff_trip(redeliver=t_order))


def run_once(params, seed):
    env = simpy.Environment()
    h = Hotel(env, random.Random(seed), params)
    env.process(h.checkins())
    env.process(h.requests())
    env.process(h.dispatcher())
    env.run(until=DAY_END + 300)
    return {
        "deliv_mean": mean(h.deliv), "deliv_p90": p90(h.deliv),
        "checkin_mean": mean(h.cwait), "checkin_p90": p90(h.cwait),
        "away_min": h.away,                          # フロントの人がフロントを離れた分(合計)
        "robot_n": h.robot_n, "staff_n": h.staff_n,
        "absent": h.absent_n, "stops": h.stops, "calls": h.rescues + h.absent_n,
        # 依頼が多いと24時を過ぎても配っているので、最後に戻った時刻までで割る
        "robot_util": h.robot_busy / (params["robots"] * max(DAY_END, h.last)) if params["robots"] else 0.0,
        "robot_end": h.last,                          # ロボットが最後に戻った時刻(15:00 からの分)
    }


def conditions():
    return list(grid(AXES, skip=lambda c: c["front"] not in SCALES[c["scale"]]["front_all"]
                     or (c["robots"] == 0 and (c["link"] != "system" or c["stop_rate"] > 0 or c["multi"] != 1))))


SOURCES = [
    ("ザ・サウザンド京都:1日40〜50回のアメニティ配送、3名分をほぼロボットで、エレベーター連携は半日で取り付け", "https://jara.jp/various/koubo/img/2024/facility_04.pdf"),
    ("ザ ロイヤルパークホテル 東京羽田:ロボットの腕がボタンを押しカードをかざす GAEMI(建物の改修なし)", "https://www.logi-today.com/931243"),
    ("名古屋プリンスホテル スカイタワー:1日約100往復の下げ膳で3.3時間分(この試算には入れていない)", "https://www.irisohyama.co.jp/b2b/robotics/case-studies/delivery-x1/case001/"),
    ("変なホテル:荷物運びのロボットが廊下ですれ違えずに止まった、故障のたびに人が直していた", "https://www.gizmodo.jp/2019/01/robots-ruin-robot-hotel.html"),
    ("旅館・ホテルの非正社員不足 59.0%(2025年10月)", "https://www.zaikei.co.jp/releases/3185614/"),
]

if __name__ == "__main__":
    if "--one" in sys.argv:
        for rb in [0, 1, 2]:
            c = {"scale": "mid", "front": 2, "robots": rb, "demand": 1, "link": "system", "stop_rate": 0.05 if rb else 0.0, "multi": 1}
            print(rb, run_grid(run_once, [c], procs=1)[0])
    else:
        rows = run_grid(run_once, conditions())
        save("results_hotel.json", {"scales": SCALES, "assumed": ASSUMED, "demand": DEMAND, "rooms": ROOMS, "occ": OCC,
                                    "checkin_windows": CHECKIN_WINDOWS, "req_windows": REQ_WINDOWS,
                                    "axes": AXES, "sources": SOURCES, "rows": rows})
        print(len(rows), "conditions")

security_sim.py(オフィスビルの夜間警備)

"""
#5 オフィスビルの夜間警備(巡回)

ロボットがいないときは、警備員が2時間ごとに建物を1周する(1周40分)。
防災センターには常に1人残る決まりにする。警備員が1人しかいなければ、巡回の間はセンターが空く。
ロボットがいるときは、ロボットが巡回し、異常を見つけたらセンターに知らせ、警備員が駆けつける。
ロボットは異常でないものを異常と知らせることがあり(空振り)、そのたびに警備員が見に行く。
ロボットは電池が切れると充電台に戻る(その間は巡回が止まる)。

時刻の 0 = 19:00、720 = 7:00。単位は分。
"""
import random
import sys

import simpy

from common import tri, p90, mean, poisson_times, run_grid, grid, save

NIGHT = 720
SEG = 10   # 巡回ルートを10区画に分けて、区画ごとに異常を見る

SPEC = {"battery": 5.5 * 60, "charge": 1.5 * 60}   # SQ-2:1回の充電で5〜6時間、充電は最大1.5時間
ASSUMED = {
    "human_lap": 40, "robot_lap": 50,              # 1周(分)。ロボットは遅めでエレベーターも待つ
    "interval": 120,                               # 定時巡回の間隔
    "handle": (5, 10, 20),                         # 異常1件の対応
    "travel": (3, 6, 10),                          # センターから現場まで
    "check_false": 3,                              # 空振りを確かめる
}

# ビルの広さ(仮置き)。1周の時間(人・ロボット)と、センターから現場までの時間が変わる
SCALES = {
    "small": {"label": "中規模ビル", "floors": 10, "human_lap": 30, "robot_lap": 40, "travel": (2, 4, 7)},
    "mid": {"label": "大規模ビル", "floors": 20, "human_lap": 40, "robot_lap": 50, "travel": (3, 6, 10)},
    "large": {"label": "大規模複合ビル", "floors": 35, "human_lap": 60, "robot_lap": 75, "travel": (5, 9, 15)},
}

AXES = {
    "scale": list(SCALES),
    "guards": [1, 2, 3, 4, 5, 6],         # ページでは 1〜3 を並べ、4〜6 は「その他」から選ぶ
    "robots": [0, 1, 2, 3, 4, 5],         # ページでは 0〜2 を並べ、3〜5 は「その他」から選ぶ
    "incidents": [3, 6, 12],          # 1晩に起きる異常(扉の開け放し・置き去りの物・消えた照明など)
    "false_per_h": [0.0, 0.5, 1.5],   # ロボット1台が1時間巡回するあいだの空振りの知らせ
    "rmode": ["sched", "cont"],       # 定時に回る/充電中以外は回り続ける
}


class Night:
    def __init__(self, env, rng, p):
        self.env, self.rng, self.p = env, rng, p
        self.sc = SCALES[p["scale"]]
        self.mobile_n = p["guards"] - 1 if p["guards"] >= 2 else 1
        self.guards = simpy.PriorityResource(env, capacity=self.mobile_n)
        self.anoms = []        # [出た時刻, 区画, 見つけた時刻, 人が着いた時刻]
        self.lap_starts = []
        self.center_empty = 0.0
        self.foot = 0.0
        self.dispatch_true = self.dispatch_false = self.laps_robot = 0

    def spawn_anoms(self):
        rng = self.rng
        for t in poisson_times(rng, self.p["incidents"] / NIGHT, 0, NIGHT):
            self.anoms.append([t, rng.randrange(SEG), None, None])

    def busy(self, t):
        self.foot += t
        if self.p["guards"] == 1:
            self.center_empty += t

    # ---- 人の巡回
    def human_patrol(self):
        env, A = self.env, ASSUMED
        for k in range(NIGHT // A["interval"]):
            at = k * A["interval"]
            if env.now < at:
                yield env.timeout(at - env.now)
            with self.guards.request(priority=1) as req:
                yield req
                t0 = env.now
                self.lap_starts.append(env.now)
                for s in range(SEG):
                    yield env.timeout(self.sc["human_lap"] / SEG)
                    for a in self.anoms:
                        if a[1] == s and a[2] is None and a[0] <= env.now:
                            a[2] = a[3] = env.now
                            yield env.timeout(tri(self.rng, A["handle"]))
                self.busy(env.now - t0)

    # ---- ロボットの巡回
    def robot_patrol(self, idx):
        env, A, p, rng = self.env, ASSUMED, self.p, self.rng
        batt = SPEC["battery"]
        lap = self.sc["robot_lap"]
        if p["rmode"] == "cont":
            yield env.timeout(idx * lap / max(p["robots"], 1))  # 2台目は半周ずらす
        k = 0
        while env.now < NIGHT:
            if p["rmode"] == "sched":
                at = k * A["interval"] + idx * A["interval"] / max(p["robots"], 1)
                k += 1
                if at >= NIGHT:
                    return
                if env.now < at:
                    yield env.timeout(at - env.now)
            if batt < lap:
                yield env.timeout(SPEC["charge"])
                batt = SPEC["battery"]
                if p["rmode"] == "sched":
                    continue
            self.lap_starts.append(env.now)
            self.laps_robot += 1
            for s in range(SEG):
                yield env.timeout(lap / SEG)
                batt -= lap / SEG
                for a in self.anoms:
                    if a[1] == s and a[2] is None and a[0] <= env.now:
                        a[2] = env.now
                        self.dispatch_true += 1
                        env.process(self.respond(a))
                if rng.random() < p["false_per_h"] * lap / SEG / 60:
                    self.dispatch_false += 1
                    env.process(self.respond(None))

    def respond(self, a):
        env, rng, A = self.env, self.rng, ASSUMED
        with self.guards.request(priority=0) as req:
            yield req
            t0 = env.now
            go = tri(rng, self.sc["travel"])
            yield env.timeout(go)
            if a is not None:
                a[3] = env.now
                yield env.timeout(tri(rng, A["handle"]))
            else:
                yield env.timeout(A["check_false"])
            yield env.timeout(go)
            self.busy(env.now - t0)


def run_once(params, seed):
    env = simpy.Environment()
    n = Night(env, random.Random(seed), params)
    n.spawn_anoms()
    if params["robots"] == 0:
        env.process(n.human_patrol())
    else:
        for i in range(params["robots"]):
            env.process(n.robot_patrol(i))
    env.run(until=NIGHT + 240)
    # 見つからないまま朝になった異常は、朝7時に見つかったとして数える
    det, reach = [], []
    missed = 0
    for t, s, d, r in n.anoms:
        if d is None:
            missed += 1
            d = r = max(NIGHT, t)
        if r is None:
            r = d
        det.append(d - t)
        reach.append(r - t)
    starts = sorted(n.lap_starts)
    gaps = [b - a for a, b in zip([0] + starts, starts + [NIGHT])]
    return {
        "detect_mean": mean(det), "detect_p90": p90(det),
        "reach_mean": mean(reach), "reach_p90": p90(reach),   # 異常が出てから人が現場に着くまで
        "missed": missed,                                    # 朝まで見つからなかった
        "max_gap": max(gaps) if gaps else NIGHT,             # 巡回が始まらなかった最も長い間
        "center_empty": n.center_empty,
        "foot_min": n.foot,                                  # 警備員がセンターの外にいた分(合計)
        "calls": n.dispatch_true + n.dispatch_false, "false_calls": n.dispatch_false,
        "anoms": len(n.anoms), "robot_laps": n.laps_robot,
    }


def conditions():
    return list(grid(AXES, skip=lambda c: c["robots"] == 0 and (c["false_per_h"] > 0 or c["rmode"] != "sched")))


SOURCES = [
    ("SEQSENSE SQ-2:1回の充電で5〜6時間、充電は最大1.5時間、レンタルのみ", "https://www.irisohyama.co.jp/b2b/robotics/products/sq-2/"),
    ("大成 × ugo:12物件に17台、品川シーズンテラスで夜間巡回の要員を1名減", "https://www.taisei-bm.co.jp/wp-content/uploads/2024/10/news20240325.pdf"),
    ("TIS(豊洲):警備員2名分の費用、施設スタッフの作業が週約200分減", "https://www.meti.go.jp/policy/mono_info_service/mono/robot/2503_facilitybook.pdf"),
    ("警備員の約47%が60歳以上、保安職の有効求人倍率 5.64倍(2026年5月)", "https://cdn.j-bma.or.jp/wp-content/uploads/2026/08/03135956/20260729_%E9%81%93%E8%B7%AF%E4%BA%A4%E9%80%9A%E6%B3%95%E6%94%B9%E6%AD%A3%E3%81%A7%E9%80%B2%E3%82%80%E8%AD%A6%E5%82%99%E3%83%AD%E3%83%9C%E3%83%83%E3%83%88%E3%81%AE%E5%85%AC%E9%81%93%E6%B4%BB%E7%94%A8_02%EF%BC%88%E5%B7%AE%E3%81%97%E6%9B%BF%E3%81%88%E5%88%86%EF%BC%89.pdf"),
]

if __name__ == "__main__":
    if "--one" in sys.argv:
        for g, rb, m in [(2, 0, "sched"), (1, 1, "sched"), (1, 1, "cont"), (2, 1, "cont"), (2, 2, "cont")]:
            c = {"scale": "mid", "guards": g, "robots": rb, "incidents": 6, "false_per_h": 0.5 if rb else 0.0, "rmode": m}
            print(g, rb, m, run_grid(run_once, [c], procs=1)[0])
    else:
        rows = run_grid(run_once, conditions())
        save("results_security.json", {"scales": SCALES, "spec": SPEC, "assumed": ASSUMED, "axes": AXES, "sources": SOURCES, "rows": rows})
        print(len(rows), "conditions")

outdoor_sim.py(歩道を走る屋外配送)

"""
#6 屋外配送(歩道を走る小型ロボット)

店で荷物を載せ、歩道を走って届け先へ行き、客が受け取ったら店へ戻る。
走っている間に、人や車に道をふさがれる・判断に迷うなどで止まり、遠隔のオペレーターを呼ぶ(介入)。
オペレーターは1度に1件の介入しか扱えない。さらに、1人が見ていられる台数に上限があり、
上限を超えると、店で出発を待つ。

時刻の 0 = 11:00、540 = 20:00(受付終了)。単位は分。
"""
import random
import sys

import simpy

from common import tri, p90, mean, profile_times, run_grid, grid, save

HOURS = 540
ORDER_WINDOWS = [(0, 30, 3), (30, 150, 30), (150, 390, 20), (390, 510, 30), (510, 540, 3)]  # 昼と夜に山(仮置き)

ASSUMED = {
    "one_way": (4, 7, 12),       # 片道(分)。Uber Eats × 三菱電機 の日本橋ルート 約7分 から
    "load": 2.0,                 # 店で載せる
    "pickup": (0.5, 1.5, 5.0),   # 客が出てきて受け取る
    "noshow": 0.05, "noshow_wait": 5.0,
    "fix": (0.5, 1.5, 4.0),      # 介入1件をオペレーターが片づける(分)
    "late_min": 45,              # 注文から届くまでこれを超えたら「遅れた」
}

# 配達エリアの広さ(仮置き)。片道の時間(最短, よくある値, 最長)が変わる。距離は時速5kmで歩道を走ったときの目安
SCALES = {
    "near": {"label": "狭いエリア", "one_way": (2, 4, 7), "radius_m": 330, "desc": "駅前の店から近所へ"},
    "mid": {"label": "ふつうのエリア", "one_way": (4, 7, 12), "radius_m": 580, "desc": "日本橋の実証の片道約7分"},
    "wide": {"label": "広いエリア", "one_way": (7, 12, 20), "radius_m": 1000, "desc": "郊外の住宅地へ"},
}

AXES = {
    "scale": list(SCALES),
    "robots": [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20],   # ページでは 1・2・4・10 を並べ、ほかは「その他」から選ぶ
    "operators": [1, 2, 3, 4, 5],                     # ページでは 1・2 を並べ、3〜5 は「その他」から選ぶ
    "orders": [20, 60, 120, 200],     # 1日の注文。今の日本の実績は1エリアで1日約20件
    "int_rate": [0.05, 0.2, 0.5],     # 片道1回あたりの介入の回数
    "watch": [4, 10],                 # オペレーター1人が見られる台数(ハコボ:2022年 4台 → 2025年 10台)
}


class Area:
    def __init__(self, env, rng, p):
        self.env, self.rng, self.p = env, rng, p
        self.robots = simpy.Resource(env, capacity=p["robots"])
        self.ops = simpy.Resource(env, capacity=p["operators"])
        self.watch = simpy.Resource(env, capacity=p["operators"] * p["watch"])   # 走っていてよい台数
        self.lead, self.int_waits, self.depart_waits = [], [], []
        self.op_busy = self.robot_busy = 0.0
        self.ints = self.noshow_n = 0
        self.last = 0.0

    def orders(self):
        env = self.env
        for t in profile_times(self.rng, self.p["orders"], ORDER_WINDOWS):
            yield env.timeout(t - env.now)
            env.process(self.order())

    def leg(self, minutes):
        """片道。途中で介入が起きたらオペレーターを待つ"""
        env, rng, A = self.env, self.rng, ASSUMED
        n = _poisson(rng, self.p["int_rate"])
        cuts = sorted(rng.uniform(0, minutes) for _ in range(n))
        done = 0.0
        for c in cuts:
            yield env.timeout(c - done)
            done = c
            self.ints += 1
            t0 = env.now
            with self.ops.request() as req:
                yield req
                self.int_waits.append(env.now - t0)
                f = tri(rng, A["fix"])
                yield env.timeout(f)
                self.op_busy += f
        yield env.timeout(minutes - done)

    def order(self):
        env, rng, A = self.env, self.rng, ASSUMED
        t_order = env.now
        with self.robots.request() as rreq:
            yield rreq
            t_r = env.now
            yield env.timeout(A["load"])
            t_w = env.now
            with self.watch.request() as wreq:
                yield wreq
                self.depart_waits.append(env.now - t_w)
                trip = tri(rng, SCALES[self.p["scale"]]["one_way"])
                yield env.process(self.leg(trip))
                if rng.random() < A["noshow"]:
                    self.noshow_n += 1
                    yield env.timeout(A["noshow_wait"])
                else:
                    yield env.timeout(tri(rng, A["pickup"]))
                    self.lead.append(env.now - t_order)
                yield env.process(self.leg(trip))
            self.robot_busy += env.now - t_r
            self.last = max(self.last, env.now)


def _poisson(rng, lam):
    # 小さい lam 用の簡単なポアソン乱数
    import math
    L, k, p = math.exp(-lam), 0, 1.0
    while True:
        p *= rng.random()
        if p <= L:
            return k
        k += 1


def run_once(params, seed):
    env = simpy.Environment()
    a = Area(env, random.Random(seed), params)
    env.process(a.orders())
    env.run()   # 受付の終わった注文を、全部届け終わるまで回す
    span = max(HOURS, a.last)
    L = a.lead
    return {
        "lead_mean": mean(L), "lead_p90": p90(L),
        "late_share": sum(x > ASSUMED["late_min"] for x in L) / len(L) if L else 0.0,
        "int_wait": mean(a.int_waits),                         # 止まってオペレーターを待った分(1回あたり)
        "depart_wait": mean(a.depart_waits),                   # 見られる台数の上限で、店で出発を待った分
        "op_util": a.op_busy / (params["operators"] * span),  # オペレーターが介入にかかりきりの割合
        "robot_util": a.robot_busy / (params["robots"] * span),
        "end": span,                                           # 最後の配達が終わった時刻(11:00 からの分)
        "calls": a.ints, "delivered": len(L), "noshow": a.noshow_n,
    }


def conditions():
    return list(grid(AXES))


SOURCES = [
    ("2023年4月の改正道路交通法:遠隔操作型小型車(時速6km以下・届け出制)", "https://www.meti.go.jp/policy/economy/distribution/deliveryrobot/gaiyou.pdf"),
    ("パナソニック ハコボ:遠隔オペレーター1人で4台(2022年)→10台(2025年)", "https://monoist.itmedia.co.jp/mn/spv/2502/18/news098.html"),
    ("丸の内の実証:注文は1日約20件、横断約50回で保安要員の介入なし(遠隔操作の介入はタクシーの乗り降りで1回)", "https://www.meti.go.jp/shingikai/mono_info_service/jidosoko_robot/pdf/007_07_00.pdf"),
    ("Uber Eats × 三菱電機:日本橋で片道約7分のルート、最高速度5.4km", "https://monoist.itmedia.co.jp/mn/spv/2403/06/news082.html"),
    ("甲賀市の実証:18日で450個、ロボットだけで届いたのは94%、大雪で1日運休", "https://www.logi-today.com/945458"),
]

if __name__ == "__main__":
    if "--one" in sys.argv:
        for rb, op, od, ir, w in [(2, 1, 20, 0.2, 10), (4, 1, 150, 0.2, 10), (10, 1, 300, 0.5, 10), (10, 2, 300, 0.5, 10), (10, 1, 300, 0.2, 4)]:
            c = {"scale": "mid", "robots": rb, "operators": op, "orders": od, "int_rate": ir, "watch": w}
            print(c, {k: v for k, v in run_grid(run_once, [c], procs=1)[0].items() if k not in c})
    else:
        rows = run_grid(run_once, conditions())
        save("results_outdoor.json", {"scales": SCALES, "assumed": ASSUMED, "order_windows": ORDER_WINDOWS, "axes": AXES,
                                      "sources": SOURCES, "rows": rows})
        print(len(rows), "conditions")

build_sites_page.py(results_*.json とページの部品から、試算のページを1枚にまとめる)

"""results_*.json とページの部品から、公開用の HTML を1つにまとめる"""
import json

SITES = ["hospital", "cleaning", "hotel", "security", "outdoor"]
data = {}
for s in SITES:
    d = json.load(open(f"results_{s}.json", encoding="utf-8"))
    rows = d.pop("rows")
    keys = list(rows[0].keys())
    data[s] = {
        "k": keys,
        "r": [[round(r[k], 3) if isinstance(r[k], float) else r[k] for k in keys] for r in rows],
        "meta": d,
    }
body = open("sites_page.body.html", encoding="utf-8").read()
html = body.replace("__DATA__", json.dumps(data, ensure_ascii=False, separators=(",", ":")))
open("service_robot_sites.html", "w", encoding="utf-8").write(html)
print(len(html), "bytes")

make_site_copy.py(Artifact 版を、サイトに置ける1枚の HTML に直す)

"""Artifact 用の service_robot_sites.html を、サイト(p2m-site)の public/sim に置ける1枚の HTML にする。
Artifact は公開時に <!doctype>・<head>・<body> を自動で付けるので、サイト用にはそれを自分で付ける。"""
import sys
src = open("service_robot_sites.html", encoding="utf-8").read()
head, body = src.split("</style>", 1)
html = ("<!doctype html>\n<html lang=\"ja\">\n<head>\n<meta charset=\"utf-8\">\n"
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n"
        "<!-- サービスロボット導入シミュレーション #2 配膳の次の5つの現場の試算。元は Claude の作業環境の build_sites_page.py で、"
        "results_*.json と sites_page.body.html をまとめたもの。このファイルは make_site_copy.py で作る -->\n"
        + head + "</style>\n</head>\n<body>\n" + body.strip() + "\n</body>\n</html>\n")
out = sys.argv[1] if len(sys.argv) > 1 else "service-robot-sites.site.html"
open(out, "w", encoding="utf-8").write(html)
print(out, len(html), "bytes")

sites_page.body.html(試算のページの部品。DATA に結果が入る)

<title>サービスロボット試算 5つの現場</title>
<meta name="claude:viewport-fit-sides" content="cover">
<script>if (/[?&]embed=1/.test(location.search)) document.documentElement.classList.add("embed");</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans+JP:wght@400;500;700&display=swap">
<style>
/* 配膳の試算ページ(#1)と同じ、pixelstomotors.com の白黒の見た目。サイトにダークモードがないので明るい色だけで固定する。
   レイアウト:上で現場を選ぶ → 左に条件・右に結果(狭い画面では縦に積む) */
:root {
  --bg: #f7f7f7; --panel: #ffffff; --ink: #242424; --ink-2: #555555; --ink-3: #888888;
  --line: #e6e6e6; --soft: #f0f0f0; --bar: #d6d6d6; --on-ink: #ffffff;
  --sans: Inter, "Noto Sans JP", "Hiragino Sans", "Yu Gothic", sans-serif;
  color-scheme: light;
}
* { box-sizing: border-box; }
body {
  background: var(--bg); color: var(--ink); margin: 0;
  font-family: var(--sans); font-size: 15px; line-height: 1.75; font-feature-settings: "palt";
  padding-left: max(16px, env(safe-area-inset-left, 0px));
  padding-right: max(16px, env(safe-area-inset-right, 0px));
  padding-block: 24px 56px;
}
.wrap { max-width: 1040px; margin: 0 auto; display: grid; gap: 32px; }
header { display: grid; gap: 8px; }
.series { font-size: 12px; font-weight: 500; color: var(--ink-3); }
h1 { font-weight: 600; font-size: 24px; line-height: 1.5; margin: 0; text-wrap: balance; }
.lead { margin: 0; color: var(--ink-2); font-size: 14px; max-width: 42em; }
.tag { display: inline-block; font-size: 12px; font-weight: 500; padding: 2px 10px; border-radius: 4px; background: var(--soft); color: var(--ink-2); width: fit-content; }
.step { font-size: 11px; font-weight: 600; color: var(--ink-3); letter-spacing: .04em; }
/* 施設の広さを選ぶ黒い帯(配膳の試算の「店を選ぶ」と同じ見た目) */
.scales { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 8px; padding: 8px; background: #111111; border-radius: 12px; }
.scales input { position: absolute; opacity: 0; pointer-events: none; }
.scales label { display: grid; gap: 4px; align-content: start; background: #2a2a2a; color: #f5f5f0; border-radius: 8px; padding: 14px 16px; cursor: pointer; min-width: 0; height: 100%; }
.scales label b { font-weight: 600; font-size: 15px; }
.scales label span { font-size: 13px; line-height: 1.55; color: #e6e6e0; font-variant-numeric: tabular-nums; }
.scales label em { font-style: normal; font-size: 12px; line-height: 1.55; color: #a8a8a0; }
.scales input:checked + label { background: #f5f5f0; color: #111111; }
.scales input:checked + label span { color: #333333; }
.scales input:checked + label em { color: #666666; }
.scales input:focus-visible + label { outline: 2px solid #f5f5f0; outline-offset: 2px; }

.sites { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 8px; margin-top: 8px; }
.sites input { position: absolute; opacity: 0; pointer-events: none; }
.sites label { display: grid; gap: 4px; align-content: start; background: var(--panel); border: 1px solid var(--panel); border-radius: 8px; padding: 12px 14px; cursor: pointer; min-width: 0; height: 100%; }
.sites label b { font-weight: 600; font-size: 14px; }
.sites label span { font-size: 12px; color: var(--ink-2); line-height: 1.55; }
.sites label em { font-style: normal; font-size: 11px; color: var(--ink-3); line-height: 1.55; font-variant-numeric: tabular-nums; }
.sites input:checked + label { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
.sites input:focus-visible + label { outline: 2px solid var(--ink); outline-offset: 2px; }

.main { display: grid; gap: 32px; }
@media (min-width: 860px) { .main { grid-template-columns: 300px minmax(0, 1fr); align-items: start; } .controls { position: sticky; top: calc(env(safe-area-inset-top, 0px) + 16px); } }
.controls { display: grid; gap: 18px; background: var(--panel); border-radius: 8px; padding: 20px; min-width: 0; }
.ctl { display: grid; gap: 6px; border: 0; margin: 0; padding: 0; min-width: 0; }
.ctl legend { padding: 0; font-weight: 600; font-size: 14px; margin-bottom: 6px; }
.ctl legend .ph { font-weight: 500; font-size: 11px; color: var(--ink-3); margin-left: 6px; }
.ctl .note { font-size: 12px; color: var(--ink-3); margin: 0; line-height: 1.6; }
.seg { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 4px; background: var(--soft); padding: 4px; border-radius: 8px; }
.seg input { position: absolute; opacity: 0; pointer-events: none; }
.seg label { white-space: nowrap; text-align: center; padding: 7px 4px; cursor: pointer; font-size: 13px; font-weight: 500; border-radius: 4px; color: var(--ink-2); font-variant-numeric: tabular-nums; user-select: none; line-height: 1.4; }
.seg label small { font-size: 11px; margin-left: 1px; font-weight: 400; }
.seg input:checked + label { background: var(--ink); color: var(--on-ink); }
.seg input:focus-visible + label { outline: 2px solid var(--ink); outline-offset: 2px; }
.seg input:disabled + label { opacity: .35; cursor: not-allowed; }
.seg.wide { grid-auto-flow: row; grid-auto-columns: auto; }
.seg.wide label { text-align: left; padding: 7px 10px; }

.results { display: grid; gap: 32px; min-width: 0; }
section h2 { font-size: 16px; font-weight: 600; margin: 0 0 4px; text-wrap: balance; }
section > p.sub { margin: 0 0 14px; color: var(--ink-3); font-size: 12px; }
.kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.kpis .kpi.call { grid-column: 1 / -1; }
@media (min-width: 560px) { .kpis { grid-template-columns: repeat(5, minmax(0, 1fr)); } .kpis .kpi.call { grid-column: auto; } }
.kpi { background: var(--panel); border-radius: 8px; padding: 14px 16px; display: grid; gap: 2px; align-content: start; min-width: 0; }
.kpi .k { font-size: 12px; color: var(--ink-3); line-height: 1.5; }
.kpi .v { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.35; letter-spacing: -0.01em; }
.kpi .v small { font-size: 13px; font-weight: 400; margin-left: 2px; color: var(--ink-2); }
.kpi .h { font-size: 11px; color: var(--ink-3); line-height: 1.5; }
.kpi.call { box-shadow: inset 0 0 0 1px var(--ink); }
.kpi.call .k { color: var(--ink); font-weight: 600; }
.verdict { margin: 12px 0 0; padding: 12px 14px; background: var(--panel); border-radius: 8px; font-size: 14px; }

.bars { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.chart { background: var(--panel); border-radius: 8px; padding: 16px; display: grid; gap: 10px; min-width: 0; align-content: start; }
.chart h3 { margin: 0; font-size: 13px; font-weight: 600; }
.row { display: grid; grid-template-columns: 40px minmax(0, 1fr) 84px; align-items: center; gap: 10px; font-size: 13px; }
.row .lab { color: var(--ink-3); font-variant-numeric: tabular-nums; }
.row .track { height: 10px; background: var(--soft); border-radius: 4px; overflow: hidden; }
.row .fill { display: block; height: 100%; background: var(--bar); border-radius: 4px; transition: width .35s ease; }
.row.on .fill { background: var(--ink); }
.row.on .lab, .row.on .val { color: var(--ink); font-weight: 600; }
.row .val { text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2); white-space: nowrap; }

.compare { background: var(--panel); border-radius: 8px; padding: 20px; display: grid; gap: 16px; min-width: 0; }
.tablewrap { overflow-x: auto; }
.ctable { width: 100%; border-collapse: collapse; font-size: 14px; }
.ctable th, .ctable td { padding: 10px 8px; border-bottom: 1px solid var(--line); text-align: right; vertical-align: top; }
.ctable th:first-child, .ctable td:first-child { text-align: left; padding-left: 0; }
.ctable thead th { font-size: 12px; color: var(--ink-3); font-weight: 500; }
.ctable td.n { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 500; }
.ctable td.cur { background: var(--soft); }
.ctable .sm { display: block; font-size: 11px; color: var(--ink-3); font-weight: 400; }
.chip { display: inline-block; font-size: 11px; font-weight: 500; padding: 1px 8px; border-radius: 4px; white-space: nowrap; }
.chip.good { background: var(--ink); color: var(--on-ink); }
.chip.bad { border: 1px solid var(--ink); color: var(--ink); }
.chip.flat { color: var(--ink-3); background: var(--soft); }
.verdict.in { background: var(--soft); margin: 0; }

details { background: var(--panel); border-radius: 8px; padding: 16px 20px; }
details summary { cursor: pointer; font-weight: 600; font-size: 14px; }
details summary:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
details ul { margin: 12px 0 0; padding-left: 1.2em; display: grid; gap: 4px; font-size: 13px; color: var(--ink-2); }
details h4 { margin: 14px 0 0; font-size: 12px; font-weight: 600; color: var(--ink-3); }
details a { color: var(--ink); }
.notmodeled { color: var(--ink-3); font-size: 12px; margin: 12px 0 0; }
.common { background: var(--panel); border-radius: 8px; padding: 18px 20px; display: grid; gap: 6px; font-size: 14px; }
.common p { margin: 0; color: var(--ink-2); }
.common b { color: var(--ink); }
footer { color: var(--ink-3); font-size: 11px; }
[hidden] { display: none !important; }
/* 人数・台数の「その他」(配膳の試算と同じ形) */
.seg .more { display: flex; align-items: center; justify-content: center; gap: 4px; padding: 7px 10px; border: 0; background: transparent; border-radius: 4px; font: inherit; font-size: 13px; font-weight: 500; color: var(--ink-2); cursor: pointer; white-space: nowrap; }
.seg .more small, .chip-btn small { font-size: 11px; margin-left: 2px; font-weight: 400; }
.seg .more.on { background: var(--ink); color: var(--on-ink); }
.seg .more svg { transition: transform .2s ease; }
.seg .more[aria-expanded="true"] svg { transform: rotate(180deg); }
.seg .more:focus-visible, .chip-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.more-panel { display: grid; grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); gap: 4px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
.chip-btn { padding: 7px 4px; border: 0; border-radius: 4px; background: var(--soft); font: inherit; font-size: 14px; font-weight: 500; color: var(--ink-2); cursor: pointer; font-variant-numeric: tabular-nums; }
.chip-btn:hover { color: var(--ink); }
.chip-btn.on { background: var(--ink); color: var(--on-ink); }
@media (prefers-reduced-motion: reduce) { .seg .more svg { transition: none; } }
/* シリーズのページにタブで埋め込んだとき(?embed=1)は、見出しと現場の選択を外して余白を詰める(現場はタブで選ぶ) */
html.embed body { background: #fff; padding-block: 4px 8px; padding-left: 0; padding-right: 0; }
html.embed .wrap { max-width: none; }
html.embed header, html.embed .common, html.embed #site-pick { display: none; }
html.embed .controls, html.embed .kpi, html.embed .chart, html.embed .compare, html.embed details, html.embed .verdict { background: #f7f7f7; }
html.embed .verdict.in { background: #ffffff; }
@media (prefers-reduced-motion: reduce) { .row .fill { transition: none; } }
</style>

<div class="wrap">
  <header>
    <span class="series">サービスロボット導入シミュレーション #2</span>
    <h1>配膳の次の5つの現場で、ロボットは何回人を呼ぶか</h1>
    <p class="lead">日本で導入が進んでいる5つの現場を、配膳ロボット(#1)と同じ SimPy の型で試算しました。どの現場も「ロボットの仕事」と「ロボットに呼ばれる人の順番待ち」の2段で組んでいます。条件を選ぶと、各条件30回ずつ回した平均が出ます。</p>
    <span class="tag">公開資料にない数字は仮置き(試作 v1)</span>
  </header>

  <div class="common">
    <p><b>5つに共通する読み方</b></p>
    <p>どの現場でも、公開されている効果は「1日○分減った」という結果だけで、ロボットが何回止まり、人が何回呼ばれたかは出ていません。この試算では、その「呼ぶ回数」を条件として前に出し、何段階か動かして結果がどう変わるかを見られるようにしました。枠で囲んだ「ロボットが人を呼んだ回数」が、各現場の見どころです。</p>
  </div>

  <section id="site-pick">
    <span class="step">1. 現場を選ぶ</span>
    <div class="sites" id="sites" role="radiogroup" aria-label="現場"></div>
  </section>

  <section id="scale-pick">
    <span class="step" id="scale-step"></span>
    <div class="scales" id="scales" role="radiogroup"></div>
  </section>

  <div class="main">
    <form class="controls" id="controls" onsubmit="return false" aria-label="条件">
    </form>

    <div class="results">
      <section aria-live="polite">
        <h2 id="cond-title"></h2>
        <p class="sub" id="cond-sub"></p>
        <div class="kpis" id="kpis"></div>
        <p class="verdict" id="verdict"></p>
      </section>

      <section>
        <h2 id="bars-title">ロボットの台数を変えると</h2>
        <p class="sub" id="bars-sub"></p>
        <div class="bars" id="bars"></div>
      </section>

      <section>
        <h2 id="sens-title">公表されていない値を動かすと</h2>
        <p class="sub" id="sens-sub"></p>
        <div class="compare"><div class="tablewrap"><table class="ctable" id="stable"></table></div></div>
      </section>

      <section id="cut-sec">
        <h2 id="cut-title">人を1人減らして、ロボットで回るか</h2>
        <p class="sub" id="cut-sub"></p>
        <div class="compare">
          <div class="tablewrap"><table class="ctable" id="ctable"></table></div>
          <p class="verdict in" id="cut-verdict"></p>
        </div>
      </section>

      <details id="assume-box">
        <summary>前提にした数字と出典</summary>
        <h4>公開資料から取った形と数字</h4>
        <ul id="src"></ul>
        <h4>仮置きの数字(公開資料にない)</h4>
        <ul id="assume"></ul>
        <p class="notmodeled" id="notmodeled"></p>
      </details>

      <details>
        <summary>この試算で扱う範囲(SimPy と Unity の分け方)</summary>
        <ul>
          <li>決め方は #1 と同じで、図面がなくても答えられる問いかどうか。</li>
          <li>答えられる問いは、この試算(SimPy)で扱う:何分待つか、何台要るか、人が何回呼ばれるか。</li>
          <li>答えられない問いは、Unity で扱う:どの通路で鉢合わせるか、人やカートに当たるか、狭い廊下ですれ違えるか。</li>
          <li>Unity で測った数字(止まる割合、引っかかる回数、介入の回数)を、この試算の入力に戻す。試算の形は現場ごとに同じで、変えるのは入力の数字だけ。</li>
        </ul>
      </details>

      <footer>SimPy 4.1.2 / 各条件30回の平均 / 乱数の種は0〜29で固定しているので、同じコードなら同じ結果になります。</footer>
    </div>
  </div>
</div>

<script>
const D = __DATA__;

// ---------- 表示の形
const r1 = x => (Math.round(x * 10) / 10).toFixed(1);
const fmtMin = m => m < 1 ? `${Math.round(m * 60)}<small>秒</small>` : `${r1(m)}<small>分</small>`;
const plainMin = m => m < 1 ? `${Math.round(m * 60)}秒` : `${r1(m)}分`;
const fmtDur = m => m < 60 ? `${Math.round(m)}<small>分</small>` : `${Math.floor(m / 60)}<small>時間</small>${String(Math.round(m % 60)).padStart(2, "0")}<small>分</small>`;
const plainDur = m => m < 60 ? `${Math.round(m)}分` : `${Math.floor(m / 60)}時間${String(Math.round(m % 60)).padStart(2, "0")}分`;
const fmtH = h => `${r1(h)}<small>時間</small>`;
const plainH = h => `${r1(h)}時間`;
const pct = x => `${Math.round(x * 100)}<small>%</small>`;
const plainPct = x => `${Math.round(x * 100)}%`;
const cnt = (x, u = "回") => `${r1(x)}<small>${u}</small>`;
const plainCnt = (x, u = "回") => `${r1(x)}${u}`;
const clock = (start, m) => { const t = Math.round(start * 60 + m) % 1440; return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`; };

// ---------- 現場ごとの決まり
const SITES = {
  hospital: {
    label: "病院の検体・薬剤の搬送", short: "病院搬送", unit: "台",
    desc: "外来の採血管や抗がん剤を、決まった時間帯に検査部・化学療法室へ運ぶ。7:30〜16:30",
    zero: { stop_rate: 0, dispatch: "now", speed: 0.9 },
    def: { scale: "mid", robots: 1, demand: 1, floors: "same", stop_rate: 0.05, dispatch: "now", busy: 0.6, speed: 0.9 },
    scale: { key: "scale", label: "病院の広さ", cards: () => Object.entries(D.hospital.meta.scales).map(([v, x]) => { const d = D.hospital.meta.assumed.dist_m;
      return { v, b: x.label, span: `${x.beds.toLocaleString()}床・片道${Math.round(Math.min(d.r1, d.r2, d.r3) * x.dist_x)}〜${Math.round(Math.max(d.r1, d.r2, d.r3) * x.dist_x)}m・荷物${x.demand_x}倍`, em: x.desc }; }) },
    axes: [
      { key: "robots", label: "搬送ロボット", unit: "台", more: () => [0, 1, 2, 3, 4, 5], opts: [0, 1, 2].map(v => ({ v, l: `${v}<small>台</small>` })) },
      { key: "demand", label: "荷物の出る量", opts: ["少ない", "普通", "多い"].map((l, v) => ({ v, l })), note: s => `1日に約${Math.round(hospDaily(s.demand) * D.hospital.meta.scales[s.scale].demand_x)}件。普通は、慈恵医大柏病院の1日の便数(4〜9便・5〜15便)から逆に置いた量です。` },
      { key: "floors", label: "ルート", opts: [{ v: "same", l: "同じ階" }, { v: "elev", l: "階をまたぐ" }], note: () => "階をまたぐと、人もロボットもエレベーターを待ちます。ロボットは人と一緒に乗れないので待ちが長めです。" },
      { key: "dispatch", label: "出発のしかた", robot: true, opts: [{ v: "now", l: "すぐ出す" }, { v: "batch", l: "まとめて出す" }], note: () => "まとめて出す:3件たまるか、最初の荷物が10分待ったら出る。" },
      { key: "speed", label: "ロボットの速さ", ph: "仮置き", robot: true, opts: [0.6, 0.9, 1.2].map(v => ({ v, l: `${v}<small>m/秒</small>` })), note: () => "人の歩く速さは1.2m/秒で置いています。KEENON W3 の最高は1.2m/秒です。" },
      { key: "busy", label: "出発地の人の忙しさ", ph: "仮置き", opts: [{ v: 0.3, l: "3割" }, { v: 0.6, l: "6割" }], note: () => "運ぶ以外の仕事で手がふさがっている割合。" },
      { key: "stop_rate", label: "途中で止まる割合", ph: "仮置き", robot: true, opts: [0, 0.05, 0.15].map(v => ({ v, l: `${Math.round(v * 100)}<small>%</small>` })), note: () => "止まったうち半分は、出発地の人が助けに行く。" },
    ],
    kpis: r => [
      { k: "荷物が出てから届くまで", v: fmtMin(r.wait_mean), h: `遅い方の1割は${plainMin(r.wait_p90)}以上・15分超え${plainPct(r.late_share)}` },
      { k: "人が搬送に使った時間(1日)", v: fmtDur(r.staff_tr_min), h: "運ぶ・載せる・助けに行くの合計" },
      { k: "出発地の人のほかの仕事の待ち", v: fmtMin(r.other_wait), h: "搬送で手がふさがると延びる" },
      { k: "ロボットが人を呼んだ回数", call: true, v: r.robots ? cnt(r.calls) : "—", h: r.robots ? `止まった${r1(r.stops)}回のうち、人が助けに行った回数` : "ロボットなし" },
      { k: "ロボットの便数(1日)", v: r.robots ? cnt(r.robot_trips, "便") : "—", h: r.robots ? `1便あたり${r1(r.per_trip)}件・動いている割合${plainPct(r.robot_util)}` : `人が運んだのは${r1(r.staff_trips)}回` },
    ],
    metrics: [
      { key: "wait_mean", label: "荷物が出てから届くまで", plain: plainMin },
      { key: "staff_tr_min", label: "人が搬送に使った時間(1日)", plain: plainDur },
      { key: "other_wait", label: "ほかの仕事の待ち", plain: plainMin },
      { key: "calls", label: "ロボットが人を呼んだ回数", plain: x => plainCnt(x) },
    ],
    bars: ["wait_mean", "staff_tr_min", "other_wait"],
    sens: "stop_rate",
    verdict: (r, z) => r.robots === 0 ? `人だけで運ぶと、搬送に1日${plainDur(r.staff_tr_min)}かかります。ロボットの台数を選ぶと比べられます。`
      : `人が搬送に使う時間は1日${plainDur(z.staff_tr_min)}から${plainDur(r.staff_tr_min)}に減ります。一方、荷物が届くまでは${plainMin(z.wait_mean)}から${plainMin(r.wait_mean)}に${r.wait_mean > z.wait_mean ? "延びます。ロボットが戻ってくるまで次の荷物が待つためです。台数か速さを変えると差が縮むか見てください" : "縮みます"}。`,
    cut: null,
    src: () => D.hospital.meta.sources,
    assume: () => { const A = D.hospital.meta.assumed, x = D.hospital.meta.scales[C().scale]; return [
      `片道の距離(中規模病院):救急・病棟→検査部 ${A.dist_m.r1}m、内科外来→検査部 ${A.dist_m.r2}m、薬剤部→化学療法室 ${A.dist_m.r3}m(実証で出ていたスタッフの平均移動時間 9分39秒 に近くなるように置いた)。中小病院は0.6倍、大学病院は1.6倍`,
      `荷物の量:中規模病院を1として、中小病院0.6倍、大学病院1.5倍(今は${x.label}・${x.demand_x}倍)`,
      `受け取る人を見つけて渡す:${A.handover[0]}〜${A.handover[2]}分、ロボットに載せる:${A.load_base}分+1件${A.load_per_item}分`,
      `エレベーター待ち:人 ${A.elev_wait_staff[0]}〜${A.elev_wait_staff[2]}分、ロボット ${A.elev_wait_robot[0]}〜${A.elev_wait_robot[2]}分(外来の山の8:00〜11:00は${A.elev_peak_x}倍)`,
      `15分を超えて届いたら「遅れた」と数える(目安として置いた値で、病院の基準ではない)`,
      `1便に積むのは${6}件まで`,
    ]; },
    notmodeled: "まだ入れていないもの:検査部の処理の山、至急の検体の割り込み、夜間の搬送、病棟ごとの物品の補充。",
  },
  cleaning: {
    label: "ビル・商業施設の夜間清掃", short: "床清掃", unit: "台",
    desc: "閉店後の広い床をロボットが拭き、人は隅・壁際とロボットの呼び出しに応える。22:00〜6:00",
    zero: { eff: 0.8, stuck_per_h: 0, dock: false },
    def: { facility: "mall", staff: 3, robots: 2, share: 0.7, eff: 0.8, stuck_per_h: 0.5, dock: false },
    scale: { key: "facility", label: "施設の広さ", cards: () => Object.entries(D.cleaning.meta.facilities).map(([v, f]) => ({ v, b: f.label, span: `床 ${f.area.toLocaleString()}㎡・スタッフ${f.staff[0]}〜${f.staff[f.staff.length - 1]}人`,
      em: { office: "廊下・ロビー・エレベーターホール", mall: "通路と広場。閉店後に拭く", large: "空港の一角など、広く開けた床が多い" }[v] })) },
    axes: [
      { key: "staff", label: "清掃スタッフ", people: true, unit: "人", more: c => D.cleaning.meta.facilities[c.facility].staff_all, opts: s => D.cleaning.meta.facilities[s.facility].staff.map(v => ({ v, l: `${v}<small>人</small>` })) },
      { key: "robots", label: "清掃ロボット", unit: "台", more: () => [0, 1, 2, 3, 4, 5, 6], opts: [0, 1, 2, 3].map(v => ({ v, l: `${v}<small>台</small>` })) },
      { key: "share", label: "ロボットが拭ける広い床の割合", ph: "仮置き", opts: [0.3, 0.5, 0.7, 0.9].map(v => ({ v, l: `${Math.round(v * 100)}<small>%</small>` })), note: () => "残りは隅・壁際・狭い所で、人が拭く。施設全体で5%減(森トラスト)と、対象エリアで60%減(ホテル)の差は、この割合で説明できるかを見ます。" },
      { key: "eff", label: "実際の速さ", ph: "仮置き", robot: true, opts: [{ v: 0.6, l: "カタログの6割" }, { v: 0.8, l: "8割" }], note: () => "カタログは1時間に約500㎡で置いています。" },
      { key: "stuck_per_h", label: "引っかかる回数(1台・1時間)", ph: "仮置き", robot: true, opts: [0, 0.5, 1.5].map(v => ({ v, l: `${v}<small>回</small>` })), note: () => "半分は自然に解け、半分は人が動かしに行く。" },
      { key: "dock", label: "水の入れ替え", robot: true, opts: [{ v: false, l: "人が入れ替える" }, { v: true, l: "自動の充電台" }], note: () => "水1回分で1,200㎡。空になると人を呼ぶ(自動の充電台なら呼ばない)。" },
    ],
    kpis: r => { const z = zeroRow("cleaning", r); return [
      { k: "人の作業時間(1晩)", v: fmtH(r.staff_hours), h: r.robots ? `ロボットなしの${plainH(z.staff_hours)}から${plainPct(1 - r.staff_hours / z.staff_hours)}減` : "拭く・呼び出しに応えるの合計" },
      { k: "全部終わる時刻", v: clock(22, r.finish), h: r.on_time >= 0.999 ? "6:00に間に合う" : r.on_time <= 0.001 ? "6:00に間に合わない" : `6:00に間に合ったのは${plainPct(r.on_time)}` },
      { k: "ロボットが人を呼んだ回数", call: true, v: r.robots ? cnt(r.calls) : "—", h: r.robots ? `水の入れ替え${r1(r.tank_calls)}回・引っかかり${r1(r.stuck_calls)}回、合わせて${plainDur(r.call_min)}` : "ロボットなし" },
      { k: "ロボットが人を待って止まった時間", v: r.robots ? fmtDur(r.robot_wait) : "—", h: "全台の合計" },
      { k: "ロボットが拭いた床", v: r.robots ? pct(r.robot_share_area) : "—", h: "施設全体のうち" },
    ]; },
    metrics: [
      { key: "staff_hours", label: "人の作業時間(1晩)", plain: plainH },
      { key: "finish", label: "全部終わる時刻", plain: m => clock(22, m) },
      { key: "call_min", label: "呼ばれて使った時間", plain: plainDur },
      { key: "calls", label: "ロボットが人を呼んだ回数", plain: x => plainCnt(x) },
    ],
    bars: ["staff_hours", "calls", "call_min"],
    cutMetrics: [
      { key: "staff_hours", label: "人の作業時間(1晩)", plain: plainH },
      { key: "on_time", label: "6:00に間に合った割合", plain: plainPct, higher: true },
      { key: "finish", label: "全部終わる時刻", plain: m => clock(22, m), info: true },
    ],
    sens: "stuck_per_h",
    verdict: (r, z) => r.robots === 0 ? `人だけで拭くと、${clock(22, r.finish)}に終わります。ロボットの台数を選ぶと比べられます。`
      : `人の作業時間は${plainH(z.staff_hours)}から${plainH(r.staff_hours)}に、${plainPct(1 - r.staff_hours / z.staff_hours)}減ります。そのかわり、1晩に${plainCnt(r.calls)}ロボットに呼ばれ、${plainDur(r.call_min)}を使います。減り方を一番大きく変えるのは、ロボットが拭ける広い床の割合です。`,
    cut: "staff",
    src: () => D.cleaning.meta.sources,
    assume: () => { const A = D.cleaning.meta.assumed; return [
      `人が拭く速さ:広い床 ${Math.round(A.staff_open_rate * 60)}㎡/時(機械)、隅・壁際 ${Math.round(A.staff_edge_rate * 60)}㎡/時`,
      `呼ばれてロボットまで歩く:${A.walk_to_robot[0]}〜${A.walk_to_robot[2]}分、水の入れ替えとごみ捨て:${A.tank_work[0]}〜${A.tank_work[2]}分、引っかかりを直す:${A.stuck_work[0]}〜${A.stuck_work[2]}分`,
      `人は自分の担当(隅・壁際)を先に拭き、ロボットだけでは6:00に終わらない見込みのときだけ広い床を手伝う`,
      `自動の充電台:行って戻る${A.dock_trip}分+給排水${A.dock_work}分`,
      `施設の広さ:オフィスビルの共用部 3,000㎡、商業施設 6,000㎡、大型施設 10,000㎡`,
    ]; },
    notmodeled: "まだ入れていないもの:トイレ・ごみ回収などの床以外の作業、営業中の清掃、ロボット同士の担当エリアの分け方(Unity 側の話)。",
  },
  hotel: {
    label: "ホテルの館内配送", short: "ホテル", unit: "台",
    desc: "客室からのアメニティの依頼を、フロントからロボットで届ける。15:00〜24:00",
    zero: { link: "system", stop_rate: 0, multi: 1 },
    def: { scale: "mid", front: 2, robots: 1, demand: 1, link: "system", stop_rate: 0.05, multi: 1 },
    scale: { key: "scale", label: "ホテルの広さ", cards: () => Object.entries(D.hotel.meta.scales).map(([v, x]) => ({ v, b: x.label, span: `${x.rooms}室・${x.floors}階・フロント${x.front[0]}〜${x.front[1]}人`,
      em: { small: "駅前のビジネスホテル。廊下が短い", mid: "200室前後のシティホテル", large: "宴会場もある大型ホテル。廊下とエレベーターが長い" }[v] })) },
    axes: [
      { key: "front", label: "フロントの人", people: true, unit: "人", more: c => D.hotel.meta.scales[c.scale].front_all, opts: c => D.hotel.meta.scales[c.scale].front.map(v => ({ v, l: `${v}<small>人</small>` })), note: () => "チェックインの対応と配達を兼ねる。チェックインを先に受ける。" },
      { key: "robots", label: "配送ロボット", unit: "台", more: () => [0, 1, 2, 3, 4, 5], opts: [0, 1, 2].map(v => ({ v, l: `${v}<small>台</small>` })) },
      { key: "demand", label: "1日の依頼", opts: c => D.hotel.meta.demand.map((n, v) => ({ v, l: `${Math.round(n * D.hotel.meta.scales[c.scale].rooms / D.hotel.meta.rooms)}<small>件</small>` })), note: () => "200室で45件を普通とし、客室数に合わせて増減する。45件は、ザ・サウザンド京都の1日40〜50回から。夜(18:00〜22:00)に多く出る。" },
      { key: "link", label: "エレベーターの乗り方", robot: true, opts: [{ v: "system", l: "建物とつなぐ" }, { v: "arm", l: "腕でボタン" }], note: () => "腕でボタンを押す方式は改修が要らないが、満員を見送るので待ちが長め(仮置き)。" },
      { key: "multi", label: "1回に運ぶ依頼", robot: true, opts: [{ v: 1, l: "1件ずつ" }, { v: 2, l: "2件まで" }] },
      { key: "stop_rate", label: "途中で止まる割合", ph: "仮置き", robot: true, opts: [0, 0.05, 0.15].map(v => ({ v, l: `${Math.round(v * 100)}<small>%</small>` })), note: () => "止まったうち半分は、フロントの人が助けに行く。客が出てこない割合は10%で置いています。" },
    ],
    kpis: r => [
      { k: "依頼から部屋に届くまで", v: fmtMin(r.deliv_mean), h: `遅い方の1割は${plainMin(r.deliv_p90)}以上` },
      { k: "フロントの人がフロントを離れた時間", v: fmtDur(r.away_min), h: "配達・助けに行く・持ち帰りの再配達の合計" },
      { k: "チェックインの待ち", v: fmtMin(r.checkin_mean), h: `遅い方の1割は${plainMin(r.checkin_p90)}以上` },
      { k: "ロボットが人を呼んだ回数", call: true, v: r.robots ? cnt(r.calls) : "—", h: r.robots ? `助けに行った${r1(r.calls - r.absent)}回・客が出ず持ち帰り${r1(r.absent)}回` : "ロボットなし" },
      { k: "ロボットが動いている割合", v: r.robots ? pct(r.robot_util) : "—", h: r.robots ? `ロボットが届けた${r1(r.robot_n)}件` : "" },
    ],
    metrics: [
      { key: "deliv_mean", label: "依頼から部屋に届くまで", plain: plainMin },
      { key: "away_min", label: "フロントを離れた時間", plain: plainDur },
      { key: "checkin_mean", label: "チェックインの待ち", plain: plainMin },
      { key: "calls", label: "ロボットが人を呼んだ回数", plain: x => plainCnt(x) },
    ],
    bars: ["deliv_mean", "away_min", "checkin_mean"],
    sens: "stop_rate",
    verdict: (r, z) => r.robots === 0 ? `人だけで届けると、フロントの人がフロントを${plainDur(r.away_min)}離れます。ロボットの台数を選ぶと比べられます。`
      : `フロントを離れる時間は${plainDur(z.away_min)}から${plainDur(r.away_min)}に減ります。届くまでは${plainMin(z.deliv_mean)}から${plainMin(r.deliv_mean)}に${r.deliv_mean > z.deliv_mean * 1.05 ? "延びます。夜の山でロボットの順番待ちができるためです" : "なり、大きくは変わりません"}。チェックインの待ちは${plainMin(z.checkin_mean)}から${plainMin(r.checkin_mean)}で、チェックインを先に受ける決まりなので差は小さめです。`,
    cut: "front",
    src: () => D.hotel.meta.sources,
    assume: () => { const A = D.hotel.meta.assumed, m = D.hotel.meta; return [
      `客室数:ビジネスホテル120室・シティホテル200室・大型ホテル400室、稼働${Math.round(m.occ * 100)}%、チェックインは15:00〜19:00に多い。1組${A.checkin[0]}〜${A.checkin[2]}分`,
      `エレベーター待ち:人 ${A.elev_staff[0]}〜${A.elev_staff[2]}分、建物とつないだロボット ${A.elev_system[0]}〜${A.elev_system[2]}分、腕でボタンのロボット ${A.elev_arm[0]}〜${A.elev_arm[2]}分+${A.arm_extra}分(15:00〜18:00は${A.peak_x}倍)`,
      `エレベーターから部屋まで(シティホテル):人 ${A.walk_staff}分、ロボット ${A.walk_robot}分。ビジネスホテルは0.8倍、大型ホテルは1.4倍。エレベーターに乗っている時間は大型ホテルで1.6倍`,
      `客が出てくるまで ${A.door_robot[0]}〜${A.door_robot[2]}分客が出てくるまで ${A.door_robot[0]}〜${A.door_robot[2]}分`,
      `客が出てこない割合 ${Math.round(A.absent * 100)}%(${A.absent_wait}分待って持ち帰り、あとで人が届ける)`,
    ]; },
    notmodeled: "まだ入れていないもの:レストランの下げ膳(名古屋プリンスホテルの1日約100往復)、荷物運び、ロボット同士が廊下ですれ違えない問題(Unity 側の話)。",
  },
  security: {
    label: "オフィスビルの夜間警備", short: "警備", unit: "台",
    desc: "ロボットが巡回して異常を知らせ、警備員が駆けつける。19:00〜7:00",
    zero: { false_per_h: 0, rmode: "sched" },
    def: { scale: "mid", guards: 2, robots: 1, incidents: 6, false_per_h: 0.5, rmode: "cont" },
    scale: { key: "scale", label: "ビルの広さ", cards: () => Object.entries(D.security.meta.scales).map(([v, x]) => ({ v, b: x.label, span: `${x.floors}階・1周 警備員${x.human_lap}分・ロボット${x.robot_lap}分`,
      em: `防災センターから現場まで${x.travel[0]}〜${x.travel[2]}分` })) },
    axes: [
      { key: "guards", label: "警備員", people: true, unit: "人", more: () => [1, 2, 3, 4, 5, 6], opts: [1, 2, 3].map(v => ({ v, l: `${v}<small>人</small>` })), note: () => "防災センターには常に1人残る決まり。1人だけのときは、巡回や駆けつけの間センターが空く。" },
      { key: "robots", label: "警備ロボット", unit: "台", more: () => [0, 1, 2, 3, 4, 5], opts: [0, 1, 2].map(v => ({ v, l: `${v}<small>台</small>` })), note: c => { const x = D.security.meta.scales[c.scale]; return `ロボットがいないときは、警備員が2時間ごとに1周(${x.human_lap}分)する。ロボットは1周${x.robot_lap}分、5.5時間動いて1.5時間充電。`; } },
      { key: "incidents", label: "1晩の異常", ph: "仮置き", opts: [3, 6, 12].map(v => ({ v, l: `${v}<small>件</small>` })), note: () => "扉の開け放し・置き去りの物・消えた照明など。巡回が通りかかるまで見つからない。" },
      { key: "rmode", label: "ロボットの回り方", robot: true, opts: [{ v: "sched", l: "2時間ごと" }, { v: "cont", l: "回り続ける" }], note: () => "回り続ける:充電中以外は止まらずに回る。" },
      { key: "false_per_h", label: "空振りの知らせ(1台・1時間)", ph: "仮置き", robot: true, opts: [0, 0.5, 1.5].map(v => ({ v, l: `${v}<small>回</small>` })), note: () => "異常でないものを知らせると、警備員が見に行く。" },
    ],
    kpis: r => [
      { k: "異常が出てから人が着くまで", v: fmtMin(r.reach_mean), h: `見つかるまで${plainMin(r.detect_mean)}・朝まで見つからない${r1(r.missed)}件` },
      { k: "巡回が始まらなかった最も長い間", v: fmtDur(r.max_gap), h: "充電の間は巡回が止まる" },
      { k: "防災センターが空いた時間", v: fmtDur(r.center_empty), h: r.guards === 1 ? "警備員が1人なので、外に出るとセンターが空く" : "センターには常に1人いる" },
      { k: "ロボットが人を呼んだ回数", call: true, v: r.robots ? cnt(r.calls) : "—", h: r.robots ? `うち空振り${r1(r.false_calls)}回` : "ロボットなし" },
      { k: "警備員がセンターの外にいた時間", v: fmtDur(r.foot_min), h: "巡回・駆けつけの合計" },
    ],
    metrics: [
      { key: "reach_mean", label: "異常が出てから人が着くまで", plain: plainMin },
      { key: "max_gap", label: "巡回が止まった最長", plain: plainDur },
      { key: "center_empty", label: "センターが空いた時間", plain: plainDur },
      { key: "foot_min", label: "センターの外にいた時間", plain: plainDur },
      { key: "calls", label: "ロボットが人を呼んだ回数", plain: x => plainCnt(x) },
    ],
    bars: ["reach_mean", "center_empty", "foot_min"],
    sens: "false_per_h",
    verdict: (r, z) => r.robots === 0 ? `警備員だけで2時間ごとに回ると、異常が出てから人が着くまで平均${plainMin(r.reach_mean)}です。ロボットの台数を選ぶと比べられます。`
      : `異常に人が着くまでは${plainMin(z.reach_mean)}から${plainMin(r.reach_mean)}になります。${r.reach_mean > z.reach_mean ? "ロボットが見つけてから警備員がセンターから駆けつけるので、そのぶん遅くなります。" : ""}ロボットは1晩に${plainCnt(r.calls)}警備員を呼び、うち${plainCnt(r.false_calls)}は空振りです。`,
    cut: "guards",
    src: () => D.security.meta.sources,
    assume: () => { const A = D.security.meta.assumed; return [
      `1周:中規模ビル 警備員30分・ロボット40分、大規模ビル 40分・50分、大規模複合ビル 60分・75分(ロボットはエレベーター待ちを含めて遅めに置いた)。定時巡回は${A.interval / 60}時間ごと`,
      `センターから現場まで(大規模ビル) ${A.travel[0]}〜${A.travel[2]}分、異常1件の対応 ${A.handle[0]}〜${A.handle[2]}分、空振りを確かめる ${A.check_false}分`,
      `朝7:00まで見つからなかった異常は、7:00に見つかったとして数える`,
    ]; },
    notmodeled: "まだ入れていないもの:防犯カメラ・センサーで見つかる異常、ロボットの扉やエレベーターの通り方、人とロボットで担当を分ける運用。",
  },
  outdoor: {
    label: "歩道を走る屋外配送", short: "屋外配送", unit: "台",
    desc: "店から家へ。止まったら遠隔のオペレーターを呼ぶ。11:00〜20:00",
    zero: {},
    def: { scale: "mid", robots: 4, operators: 1, orders: 60, int_rate: 0.2, watch: 10 },
    scale: { key: "scale", label: "配達エリアの広さ", cards: () => Object.entries(D.outdoor.meta.scales).map(([v, x]) => ({ v, b: x.label, span: `片道 約${x.one_way[1]}分(約${x.radius_m.toLocaleString()}m)・${x.one_way[0]}〜${x.one_way[2]}分`, em: x.desc })) },
    axes: [
      { key: "robots", label: "配送ロボット", unit: "台", more: () => [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20], opts: [1, 2, 4, 10].map(v => ({ v, l: `${v}<small>台</small>` })) },
      { key: "operators", label: "遠隔のオペレーター", unit: "人", more: () => [1, 2, 3, 4, 5], opts: [1, 2].map(v => ({ v, l: `${v}<small>人</small>` })) },
      { key: "orders", label: "1日の注文", opts: [20, 60, 120, 200].map(v => ({ v, l: `${v}<small>件</small>` })), note: () => "今の日本の実績は、1エリアで1日約20件(丸の内の実証)。昼と夜に山がある。" },
      { key: "watch", label: "1人が見られる台数", opts: [4, 10].map(v => ({ v, l: `${v}<small>台</small>` })), note: () => "パナソニックのハコボは、2022年の4台から2025年に10台へ。上限を超えると店で出発を待つ。" },
      { key: "int_rate", label: "介入の回数(片道1回あたり)", ph: "仮置き", opts: [0.05, 0.2, 0.5].map(v => ({ v, l: `${v}<small>回</small>` })), note: () => "人や車に道をふさがれる・判断に迷うなどで止まり、オペレーターが1件ずつ片づける。" },
    ],
    kpis: r => [
      { k: "注文から届くまで", v: fmtMin(r.lead_mean), h: `遅い方の1割は${plainMin(r.lead_p90)}以上・45分超え${plainPct(r.late_share)}` },
      { k: "止まってオペレーターを待つ(1回)", v: fmtMin(r.int_wait), h: "オペレーターが別の介入中だと待つ" },
      { k: "見られる台数の上限で出発を待つ", v: fmtMin(r.depart_wait), h: "1回あたり" },
      { k: "ロボットが人を呼んだ回数", call: true, v: cnt(r.calls), h: `届けた${Math.round(r.delivered)}件に対して` },
      { k: "オペレーターが介入にかかりきりの割合", v: pct(r.op_util), h: `ロボットが動いている割合${plainPct(r.robot_util)}` },
    ],
    metrics: [
      { key: "lead_mean", label: "注文から届くまで", plain: plainMin },
      { key: "int_wait", label: "オペレーターを待つ(1回)", plain: plainMin },
      { key: "op_util", label: "オペレーターがかかりきりの割合", plain: plainPct },
      { key: "calls", label: "ロボットが人を呼んだ回数", plain: x => plainCnt(x) },
    ],
    bars: ["lead_mean", "int_wait", "op_util"],
    sens: "int_rate",
    verdict: r => { const lim = r.robot_util > 0.85 ? "ロボットの台数が足りず、店で順番を待っています。" : r.int_wait > 1 ? "オペレーターの手が足りず、止まったロボットが待っています。" : r.depart_wait > 1 ? "1人が見られる台数の上限で、出発が待たされています。" : "ロボットにもオペレーターにも余裕があります。";
      return `注文から届くまで平均${plainMin(r.lead_mean)}。${lim}オペレーターが介入にかかりきりなのは${plainPct(r.op_util)}です。${r.op_util < 0.3 && r.robot_util < 0.85 ? "この注文の数では、遠隔監視の人数は上限になっていません。" : ""}`; },
    cut: null,
    src: () => D.outdoor.meta.sources,
    assume: () => { const A = D.outdoor.meta.assumed; return [
      `片道:狭いエリア 2〜7分・ふつう 4〜12分・広いエリア 7〜20分(距離は時速5kmで歩道を走ったときの目安)、店で載せる ${A.load}分、客が受け取る ${A.pickup[0]}〜${A.pickup[2]}分`,
      `介入1件をオペレーターが片づける ${A.fix[0]}〜${A.fix[2]}分。客が出てこない割合 ${Math.round(A.noshow * 100)}%`,
      `注文から45分を超えて届いたら「遅れた」と数える(目安)`,
      `電池・雪や雨での運休は入れていない`,
    ]; },
    notmodeled: "まだ入れていないもの:電池と充電、天候での運休(甲賀市の実証では18日中1日)、信号待ちの細かい時間、1件あたりの費用。",
  },
};
const ORDER = ["hospital", "cleaning", "hotel", "security", "outdoor"];
function hospDaily(dm) { const d = D.hospital.meta.demand; return d.r1[dm] * 0.5 + d.r2[dm] * 5.5 + d.r3[dm] * 3; }

// ---------- 行を引く
const IDX = {}, AX = {};
for (const s of ORDER) {
  const d = D[s]; AX[s] = Object.keys(d.meta.axes);
  IDX[s] = new Map(d.r.map(row => { const o = Object.fromEntries(d.k.map((k, i) => [k, row[i]])); return [AX[s].map(k => String(o[k])).join("|"), o]; }));
}
function find(site, cond) {
  const c = { ...cond };
  if (c.robots === 0) Object.assign(c, SITES[site].zero);
  return IDX[site].get(AX[site].map(k => String(c[k])).join("|"));
}
function zeroRow(site, r) { return find(site, { ...r, robots: 0 }); }

const state = { site: "hospital" };
const conds = Object.fromEntries(ORDER.map(s => [s, { ...SITES[s].def }]));
const C = () => conds[state.site];
const S = () => SITES[state.site];

// ---------- 現場の選択(標準の条件での一言つき)
function siteLine(s) {
  const sp = SITES[s], c = sp.def, r = find(s, c);
  if (s === "outdoor") return `注文${c.orders}件・${c.robots}台:届くまで${plainMin(r.lead_mean)}・呼んだ${plainCnt(r.calls)}`;
  const z = find(s, { ...c, robots: 0 });
  const m = { hospital: ["staff_tr_min", "人の搬送", plainDur], cleaning: ["staff_hours", "人の作業", plainH], hotel: ["away_min", "フロントを離れる", plainDur], security: ["reach_mean", "異常に着くまで", plainMin] }[s];
  return `${c.robots}台で${m[1]} ${m[2](z[m[0]])}→${m[2](r[m[0]])}・呼んだ${plainCnt(r.calls)}`;
}
function buildSites() {
  const el = document.getElementById("sites");
  el.innerHTML = ORDER.map((k, i) => `<input type="radio" name="site" id="site-${i}" value="${k}"${state.site === k ? " checked" : ""}><label for="site-${i}"><b>${SITES[k].label}</b><span>${SITES[k].desc}</span><em>${siteLine(k)}</em></label>`).join("");
  el.onchange = e => { state.site = e.target.value; buildControls(); render(); };
}

// ---------- 施設の広さ(黒い帯)
const EMBED = document.documentElement.classList.contains("embed");
function buildScales() {
  const sp = S(), c = C(), sc = sp.scale, el = document.getElementById("scales");
  document.getElementById("scale-step").textContent = `${EMBED ? 1 : 2}. ${sc.label}を選ぶ`;
  el.setAttribute("aria-label", sc.label);
  el.innerHTML = sc.cards().map((x, i) => `<input type="radio" name="scale" id="scale-${i}" value="${x.v}"${c[sc.key] === x.v ? " checked" : ""}><label for="scale-${i}"><b>${x.b}</b><span>${x.span}</span><em>${x.em}</em></label>`).join("");
  el.onchange = e => { c[sc.key] = e.target.value; buildControls(); render(); };
}

// ---------- 条件
const CHEV = '<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
function closePanels() {
  document.querySelectorAll(".more-panel").forEach(p => p.hidden = true);
  document.querySelectorAll(".seg .more").forEach(b => b.setAttribute("aria-expanded", "false"));
}
document.addEventListener("keydown", e => { if (e.key === "Escape") closePanels(); });
function buildControls() {
  const sp = S(), c = C();
  const form = document.getElementById("controls");
  buildScales();
  form.innerHTML = `<span class="step">${EMBED ? 2 : 3}. 条件を選ぶ</span>` + sp.axes.map(a => {
    const opts = typeof a.opts === "function" ? a.opts(c) : a.opts;
    const all = a.more ? a.more(c) : opts.map(o => o.v);
    if (!all.includes(c[a.key])) c[a.key] = opts[Math.min(1, opts.length - 1)].v;
    const id = `ax-${a.key}`, cur = c[a.key];
    const extra = a.more ? all.filter(v => !opts.some(o => o.v === v)) : [];
    const outside = extra.includes(cur);
    // よく使う数だけ並べ、右端の「その他」で全部の数を出す
    const moreBtn = extra.length ? `<button type="button" class="more${outside ? " on" : ""}" data-more="${a.key}" aria-expanded="false" aria-controls="${id}-panel" aria-label="ほかの${a.unit === "人" ? "人数" : "台数"}を選ぶ">${outside ? `${cur}<small>${a.unit}</small>` : "その他"}${CHEV}</button>` : "";
    const panel = extra.length ? `<div class="more-panel" id="${id}-panel" hidden>${all.map(v => `<button type="button" class="chip-btn${v === cur ? " on" : ""}" data-key="${a.key}" data-v="${v}" aria-pressed="${v === cur}">${v}<small>${a.unit}</small></button>`).join("")}</div>` : "";
    return `<fieldset class="ctl"><legend>${a.label}${a.ph ? `<span class="ph">${a.ph}</span>` : ""}</legend>
      <div class="seg${a.wide ? " wide" : ""}" id="${id}" data-key="${a.key}"${extra.length ? ` style="grid-template-columns:repeat(${opts.length}, minmax(0, 1fr)) auto;grid-auto-flow:row"` : ""}>${opts.map((o, i) =>
        `<input type="radio" name="${id}" id="${id}-${i}" value="${i}"${o.v === cur ? " checked" : ""}><label for="${id}-${i}">${o.l}</label>`).join("")}${moreBtn}</div>
      ${panel}${a.note ? `<p class="note">${a.note(c)}</p>` : ""}</fieldset>`;
  }).join("");
  form.onclick = e => {
    const btn = e.target.closest("[data-more]");
    if (btn) { const pn = document.getElementById(`ax-${btn.dataset.more}-panel`); const open = pn.hidden; closePanels(); pn.hidden = !open; btn.setAttribute("aria-expanded", String(open)); return; }
    const chip = e.target.closest(".chip-btn");
    if (chip) {
      c[chip.dataset.key] = Number(chip.dataset.v);
      buildControls(); render();
      const back = document.querySelector(`#ax-${chip.dataset.key} .more`) || document.querySelector(`#ax-${chip.dataset.key} input:checked`); if (back) back.focus();
    }
  };
  form.onchange = e => {
    const seg = e.target.closest(".seg"); if (!seg) return;
    const a = sp.axes.find(x => x.key === seg.dataset.key);
    const opts = typeof a.opts === "function" ? a.opts(c) : a.opts;
    c[a.key] = opts[Number(e.target.value)].v;
    buildControls(); render();
    const back = document.querySelector(`#ax-${a.key} input:checked`); if (back) back.focus();
  };
}

// ---------- 結果
function render() {
  const s = state.site, sp = S(), c = C(), r = find(s, c);
  const noRobot = c.robots === 0;
  document.querySelectorAll("#controls .seg").forEach(seg => {
    const a = sp.axes.find(x => x.key === seg.dataset.key);
    seg.querySelectorAll("input").forEach(i => i.disabled = !!a.robot && noRobot);
  });
  const desc = sp.axes.filter(a => a.key !== "robots").map(a => {
    const opts = typeof a.opts === "function" ? a.opts(c) : a.opts;
    const o = opts.find(o => o.v === c[a.key]);
    const txt = o ? o.l.replace(/<[^>]+>/g, "") : `${c[a.key]}${a.unit || ""}`;
    return (a.robot && noRobot) ? null : `${a.label} ${txt}`;
  }).filter(Boolean);
  document.getElementById("cond-title").textContent = `${sp.label}:ロボット${c.robots}台`;
  const scx = sp.scale.cards().find(x => x.v === c[sp.scale.key]);
  document.getElementById("cond-sub").textContent = [scx.b, ...desc].join("・") + " の結果";
  document.getElementById("kpis").innerHTML = sp.kpis(r).map(x => `<div class="kpi${x.call ? " call" : ""}"><span class="k">${x.k}</span><span class="v">${x.v}</span><span class="h">${x.h}</span></div>`).join("");
  const z = s === "outdoor" ? null : find(s, { ...c, robots: 0 });
  document.getElementById("verdict").textContent = sp.verdict(r, z);

  // 台数
  const ra = sp.axes.find(a => a.key === "robots");
  const rmain = ra.opts.map(o => o.v), rtop = Math.max(rmain[rmain.length - 1], c.robots);
  const rax = rtop === rmain[rmain.length - 1] ? rmain : ra.more(c).filter(v => v <= rtop);
  document.getElementById("bars-sub").textContent = `ほかの条件はそのまま、ロボットだけを${rax[0]}〜${rax[rax.length - 1]}台で比べています。`;
  const set = rax.map(n => [n, find(s, { ...c, robots: n })]);
  document.getElementById("bars").innerHTML = sp.bars.map(key => {
    const m = sp.metrics.find(x => x.key === key);
    const max = Math.max(...set.map(([, x]) => x[key])) || 1;
    return `<div class="chart"><h3>${m.label}</h3>` + set.map(([n, x]) =>
      `<div class="row${n === c.robots ? " on" : ""}"><span class="lab">${n}台</span><span class="track"><span class="fill" style="width:${(x[key] / max * 100).toFixed(1)}%"></span></span><span class="val">${m.plain(x[key])}</span></div>`).join("") + `</div>`;
  }).join("");

  // 公表されていない値
  const sa = sp.axes.find(a => a.key === sp.sens);
  const nR = noRobot ? rax.find(v => v > 0) : c.robots;
  document.getElementById("sens-title").textContent = `${sa.label.replace(/(.*)/, "")}を動かすと`;
  document.getElementById("sens-sub").textContent = `どの現場の公開資料にも出ていない値です。ほかの条件はそのまま${noRobot ? `(0台を選んでいるので${nR}台で)` : ""}、この値だけを変えています。`;
  const sv = sa.opts.map(o => o.v);
  const srows = sv.map(v => find(s, { ...c, robots: nR, [sp.sens]: v }));
  const head = `<thead><tr><th></th>${sa.opts.map((o, i) => `<th>${o.l.replace(/<[^>]+>/g, "")}</th>`).join("")}</tr></thead>`;
  const body = sp.metrics.map(m => `<tr><td>${m.label}</td>${srows.map((x, i) => `<td class="n${sv[i] === c[sp.sens] && !noRobot ? " cur" : ""}">${m.plain(x[m.key])}</td>`).join("")}</tr>`).join("");
  document.getElementById("stable").innerHTML = head + `<tbody>${body}</tbody>`;

  renderCut(r, nR);
  renderAssume();
}

function chip(a, b, higher = false) {
  if (Math.abs(b - a) < 0.05 || (a && Math.abs((b - a) / a) < 0.05)) return `<span class="chip flat">ほぼ同じ</span>`;
  return (higher ? b > a : b < a) ? `<span class="chip good">良くなる</span>` : `<span class="chip bad">悪くなる</span>`;
}
const worseOf = (m, a, b) => m.higher ? b < a - 0.05 : (b > a * 1.1 && b - a > 0.5);
function renderCut(r, nR) {
  const s = state.site, sp = S(), c = C(), sec = document.getElementById("cut-sec");
  if (!sp.cut) { sec.hidden = true; return; }
  const pa = sp.axes.find(a => a.key === sp.cut);
  const pv = pa.more ? pa.more(c) : (typeof pa.opts === "function" ? pa.opts(c) : pa.opts).map(o => o.v);
  let base = c[sp.cut];
  if (pv.indexOf(base) < 1) base = pv[1];
  const less = pv[pv.indexOf(base) - 1];
  if (base === undefined || less === undefined) { sec.hidden = true; return; }
  sec.hidden = false;
  const a = find(s, { ...c, robots: 0, [sp.cut]: base }), b = find(s, { ...c, robots: nR, [sp.cut]: less });
  const pname = pa.label;
  document.getElementById("cut-sub").textContent = `${pname}${base}人・ロボット0台と、${less}人・ロボット${nR}台を比べています。ほかの条件は左のままです。`;
  const head = `<thead><tr><th></th><th>${base}人・0台</th><th>${less}人・${nR}台</th><th></th></tr></thead>`;
  const cm = sp.cutMetrics || sp.metrics.filter(m => m.key !== "calls");
  const body = cm.map(m => `<tr><td>${m.label}</td><td class="n">${m.plain(a[m.key])}</td><td class="n">${m.plain(b[m.key])}</td><td>${m.info ? "" : chip(a[m.key], b[m.key], m.higher)}</td></tr>`).join("")
    + `<tr><td>ロボットが人を呼んだ回数</td><td class="n">—</td><td class="n">${plainCnt(b.calls)}</td><td></td></tr>`;
  document.getElementById("ctable").innerHTML = head + `<tbody>${body}</tbody>`;
  const worse = cm.filter(m => !m.info && worseOf(m, a[m.key], b[m.key])).map(m => m.label);
  document.getElementById("cut-verdict").textContent = worse.length === 0
    ? `この条件なら、1人減らしても大きくは悪くなりません。代わりに、残った人が1日に${plainCnt(b.calls)}ロボットに呼ばれます。`
    : `1人減らすと、${worse.join("・")}が悪くなります。ロボットが代わるのは運ぶ・拭く・見回るところまでで、${s === "security" ? "駆けつける・センターに残る" : s === "hotel" ? "チェックインと、止まったときの手助け" : "隅・壁際と、呼ばれたときの対応"}は人の仕事のまま残るためです。`;
}

function renderAssume() {
  const sp = S();
  document.getElementById("src").innerHTML = sp.src().map(([t, u]) => `<li>${t}(<a href="${u}" target="_blank" rel="noopener">出典</a>)</li>`).join("");
  document.getElementById("assume").innerHTML = sp.assume().map(t => `<li>${t}</li>`).join("");
  document.getElementById("notmodeled").textContent = sp.notmodeled;
}

// 共有用に #hospital などで現場を開ける
(function init() {
  const h = (location.hash || "").slice(1);
  if (ORDER.includes(h)) state.site = h;
  buildSites(); buildControls(); render();
  // 埋め込み先のタブで # が変わったら、その現場に切り替える
  addEventListener("hashchange", () => {
    const k = location.hash.slice(1);
    if (ORDER.includes(k) && k !== state.site) { state.site = k; buildSites(); buildControls(); render(); }
  });
})();
</script>

src/pages/[series].astro(シリーズのページ。試算のタブ)

---
// シリーズのページ(/series1〜8)。上に Studio で置いていた資料(Figma・スプレッドシートなど)、下にそのシリーズの記録
import Base from '../layouts/Base.astro';
import RecordCard from '../components/RecordCard.astro';
import LoadMore from '../components/LoadMore.astro';
import { seriesCards, SERIES_PAGES } from '../lib/records';
import { media } from '../lib/media';
import staticPages from '../data/static.json';
import { SITE, SITE_NAME } from '../lib/seo';

export function getStaticPaths() {
  return Object.keys(SERIES_PAGES).map((series) => ({ params: { series } }));
}
const { series } = Astro.params;
const conf = SERIES_PAGES[series];
const blocks = (staticPages[series]?.blocks || []).slice(1); // 先頭はページの見出し
const items = await seriesCards(conf.series);
const [enName, jaName] = conf.title.split(' | ');
const description = conf.intro ? `${SITE_NAME} のシリーズ「${jaName || conf.title}」(${enName})。${conf.intro}` : `${SITE_NAME} のシリーズ「${jaName || conf.title}」(${enName})の記録一覧。ロボットとフィジカルAIを、29年のデザイナー経験から実機とシミュレーションで試した過程を、失敗も含めて公開しています。`;
const jsonLd = {
  '@context': 'https://schema.org', '@type': 'CollectionPage', name: conf.title, description, url: `${SITE}/${series}`,
  inLanguage: 'ja',
  hasPart: items.slice(0, 50).map((c) => ({ '@type': 'BlogPosting', headline: c.title, url: `${SITE}/record/${c.slug}` })),
};
---
<Base title={`${jaName || conf.title}(${enName})| ${SITE_NAME}`} description={description} path={`/${series}`} jsonLd={jsonLd}>
  <section class="s-hero"><div class="panel">
    <h1 class="sec-h s-title">{conf.title}</h1>
    {conf.intro && <p class="s-intro">{conf.intro}</p>}
    {blocks.map((b) => (
      b.type === 'text' ? (b.link ? <a class="s-label" href={b.link} target={b.link.startsWith('/') ? undefined : '_blank'} rel={b.link.startsWith('/') ? undefined : 'noopener'} set:html={b.html} /> : <p class="s-label" set:html={b.html} />)
      : b.type === 'iframe' ? <div class="s-frame" style={`height:${b.height}px`} set:html={b.html} />
      : b.type === 'embed' ? <div class="s-embed" set:html={b.html} />
      : b.type === 'img' ? (b.link ? <a class="s-img" href={b.link} target="_blank" rel="noopener"><img src={media(b.src)} alt={b.alt || conf.title} loading="lazy" /></a> : <div class="s-img"><img src={media(b.src)} alt={b.alt || conf.title} loading="lazy" /></div>)
      : null
    ))}
  </div></section>
  {conf.sim && (
    <section class="sec s-sim" data-ev-area="sim"><div class="panel">
      <h2 class="sec-h">Simulator | 試算を試す</h2>
      {conf.sim.tabs && (
        <div class="s-tabs" role="tablist" aria-label="業種とロボットの仕事">
          {conf.sim.tabs.map((t, i) => (
            <button type="button" role="tab" class={`s-tab${t.img ? ' has-img' : ''}`} id={`simtab-${t.id}`} aria-selected={i === 0 ? 'true' : 'false'} aria-controls="sim-panel"
              data-tab={t.id} data-src={t.src} data-title={t.title || conf.sim.title} data-note={t.note || conf.sim.note} data-ev={`試算のタブ:${t.task}|${t.place}`}>
              {t.img && <img src={t.img} alt="" loading="lazy" />}
              <b>{t.task}</b><span>{t.place.split('・').map((w, k, all) => <i>{w}{k < all.length - 1 ? '・' : ''}</i>)}</span>
            </button>
          ))}
        </div>
      )}
      <div id="sim-panel" role={conf.sim.tabs ? 'tabpanel' : undefined}>
        <p class="s-sim-title" id="sim-title">{conf.sim.title}</p>
        <p class="s-sim-note" id="sim-note">{conf.sim.note}</p>
        {conf.sim.obs && <a class="s-obs-jump" id="sim-obs-jump" href="#sim-obs" data-ev="観察シートへ移る">止まった理由・機種・場所を店で記録する観察シートは、試算の下にあります ↓</a>}
        <iframe data-embed id="sim-frame" src={`${conf.sim.src}?embed=1`} title={conf.sim.title} class="s-sim-frame" loading="lazy"></iframe>
        <a class="s-sim-open" id="sim-open" href={conf.sim.src} target="_blank" rel="noopener" data-ev="試算を全画面で開く">全画面で開く ↗</a>
      </div>
      {conf.sim.obs && (
        <div class="s-obs" id="sim-obs">
          <div class="s-obs-text">
            <h3 class="s-obs-h">{conf.sim.obs.title}</h3>
            <p class="s-sim-note">{conf.sim.obs.note}</p>
            <ul class="s-obs-list">{conf.sim.obs.points.map((t) => <li>{t}</li>)}</ul>
            <a class="s-obs-btn" href={conf.sim.obs.src} target="_blank" rel="noopener" data-ev="観察シートの例を開く">{conf.sim.obs.label}</a>
          </div>
          <div class="s-obs-phone">
            <iframe src={`${conf.sim.obs.src}?preview=1`} title={conf.sim.obs.title} loading="lazy"></iframe>
            <p class="s-obs-cap">スマホの幅で表示しています。中のボタンも押せます</p>
          </div>
        </div>
      )}
    </div></section>
  )}
  <script>
    // 試算のタブ:押すと中の試算を切り替える。/series8#sim-hospital のように開くと、そのタブから始まる
    const tabs = [...document.querySelectorAll<HTMLButtonElement>('.s-tab')];
    const frame = document.getElementById('sim-frame') as HTMLIFrameElement | null;
    if (tabs.length && frame) {
      const title = document.getElementById('sim-title')!, note = document.getElementById('sim-note')!;
      const open = document.getElementById('sim-open') as HTMLAnchorElement, obs = document.getElementById('sim-obs');
      const embedUrl = (src: string) => { const [path, hash] = src.split('#'); return `${path}?embed=1${hash ? '#' + hash : ''}`; };
      // 中の高さに合わせて枠の高さを変える(同じサイトのページなので中を読める)
      let ro: ResizeObserver | null = null;
      const fit = () => {
        try {
          const d = frame.contentDocument; if (!d || !d.body) return;
          // html の高さは枠の高さより小さくならないので、body の高さで測る
          const cs = getComputedStyle(d.body);
          frame.style.height = Math.ceil(d.body.getBoundingClientRect().height + parseFloat(cs.marginTop) + parseFloat(cs.marginBottom)) + 'px';
        } catch {}
      };
      frame.addEventListener('load', () => {
        fit(); ro?.disconnect();
        try { ro = new ResizeObserver(fit); ro.observe(frame.contentDocument!.body); } catch {}
      });
      const select = (t: HTMLButtonElement, push: boolean) => {
        tabs.forEach((b) => { const on = b === t; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
        const src = t.dataset.src!;
        title.textContent = t.dataset.title!; note.textContent = t.dataset.note!;
        frame.title = t.dataset.title!; open.href = src;
        const next = embedUrl(src);
        const cur = frame.getAttribute('src') || '';
        if (cur !== next) {
          frame.setAttribute('src', next);
          // 同じページで # だけが違うとき、ブラウザによっては src を変えても中が切り替わらないので、中の # も直接変える
          const [np, nh] = next.split('#');
          if (cur.split('#')[0] === np && nh) { try { frame.contentWindow!.location.hash = nh; } catch {} }
        }
        if (obs) obs.hidden = t !== tabs[0];
        const jump = document.getElementById('sim-obs-jump'); if (jump) jump.hidden = t !== tabs[0];
        // スマホでは選んだタブが見える位置まで、タブの列を横に送る
        const strip = t.parentElement!; strip.scrollTo({ left: Math.max(t.offsetLeft - 8, 0), behavior: push ? 'smooth' : 'auto' });
        if (push) history.replaceState(null, '', t === tabs[0] ? location.pathname : `#sim-${t.dataset.tab}`);
      };
      tabs.forEach((t, i) => {
        t.tabIndex = i === 0 ? 0 : -1;
        t.addEventListener('click', () => select(t, true));
        t.addEventListener('keydown', (e) => {
          const k = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
          if (!k) return;
          e.preventDefault();
          const n = tabs[(i + k + tabs.length) % tabs.length]; n.focus(); select(n, true);
        });
      });
      const start = tabs.find((t) => location.hash === `#sim-${t.dataset.tab}`);
      if (start) { select(start, false); document.querySelector('.s-sim')?.scrollIntoView(); }
    }
  </script>
  <section class="sec"><div class="panel">
    <h2 class="sec-h">Record | 記録</h2>
    <LoadMore limit={conf.limit} total={items.length}>{items.map((c) => <RecordCard item={c} />)}</LoadMore>
  </div></section>
</Base>
<style>
  .s-hero { padding: 88px 80px 0; }
  .s-title { margin: 0; }
  .s-intro { margin: 16px 0 0; font: 400 16px/28px var(--sans); color: var(--ink-2); }
  .s-label { display: block; margin: 40px 0 16px; font: 500 16px/24px var(--sans); color: var(--ink); }
  .s-title + .s-label { margin-top: 8px; }
  a.s-label:hover { text-decoration: underline; }
  .s-frame { width: 100%; }
  .s-frame :global(iframe) { display: block; width: 100% !important; height: 100% !important; }
  .s-embed :global(.embed iframe) { display: block; width: 100%; height: 150px; border: 0; }
  .s-img img { width: 100%; height: auto; display: block; }
  .s-sim-title { margin: -12px 0 8px; font: 600 18px/28px var(--sans); color: var(--ink); }
  .s-sim-note { margin: 0 0 20px; font: 400 14px/24px var(--sans); color: var(--ink-2); }
  .s-sim-frame { display: block; width: 100%; height: 1600px; border: 0; }
  .s-tabs { position: relative; display: flex; gap: 8px; margin: -8px 0 28px; overflow-x: auto; padding: 8px; background: #111; border-radius: 12px; scrollbar-width: thin; }
  .s-tab { flex: 1 0 140px; display: grid; gap: 2px; align-content: start; text-align: left; padding: 12px 14px; border: 0; border-radius: 8px; background: #2a2a2a; color: #f5f5f0; cursor: pointer; font: inherit; }
  .s-tab img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 4px; margin-bottom: 8px; opacity: .78; transition: opacity .2s; }
  .s-tab:hover img, .s-tab[aria-selected="true"] img { opacity: 1; }
  .s-tab.has-img { padding: 8px 8px 12px; }
  .s-tab b { font: 600 16px/24px var(--sans); }
  .s-tab span { font: 400 12px/18px var(--sans); color: #b8b8b0; }
  .s-tab span i { font-style: normal; display: inline-block; } /* 「商業施設・ビル」は、折り返すなら・のあとで折り返す */
  .s-tab:hover { background: #3a3a3a; }
  .s-tab[aria-selected="true"] { background: #f5f5f0; color: #111; }
  .s-tab[aria-selected="true"] span { color: #555; }
  .s-tab:focus-visible { outline: 2px solid #f5f5f0; outline-offset: 2px; }
  /* スマホでは横スクロールにせず、2列に並べて6つとも見せる */
  @media (max-width: 599px) {
    .s-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; gap: 6px; padding: 6px; }
    /* 写真を左に、文字を右に置いて、カードの高さを抑える。カードの余白はなくし、写真をカードの端まで広げる */
    .s-tab, .s-tab.has-img { grid-template-columns: 72px minmax(0, 1fr); grid-template-rows: 1fr 1fr; align-content: stretch; column-gap: 8px; row-gap: 0; align-items: center; padding: 0; overflow: hidden; min-height: 58px; }
    .s-tab img { grid-row: 1 / span 2; align-self: stretch; width: 100%; height: 100%; aspect-ratio: auto; margin: 0; border-radius: 0; }
    .s-tab b { font-size: 15px; line-height: 20px; align-self: end; padding: 8px 4px 0 0; }
    .s-tab span { font-size: 11px; line-height: 16px; align-self: start; padding: 0 4px 8px 0; }
  }
  .s-sim-open { display: inline-block; margin-top: 16px; font: 500 14px/22px var(--sans); color: var(--ink); }
  .s-sim-open:hover { text-decoration: underline; }
  .s-obs { margin-top: 40px; padding-top: 40px; border-top: 1px solid var(--line, #e6e6e6); display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 48px; align-items: start; }
  .s-obs[hidden], .s-obs-jump[hidden] { display: none; }
  .s-obs-jump { display: inline-block; margin: -8px 0 20px; font: 500 14px/22px var(--sans); color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
  .s-obs { scroll-margin-top: 96px; }
  .s-obs-h { margin: 0 0 8px; font: 600 18px/28px var(--sans); color: var(--ink); }
  .s-obs .s-sim-note { margin: 0 0 16px; }
  .s-obs-list { margin: 0 0 24px; padding-left: 1.2em; font: 400 14px/26px var(--sans); color: var(--ink-2); }
  .s-obs-btn { display: inline-flex; align-items: center; gap: 6px; padding: 12px 20px; border-radius: 8px; background: var(--ink, #242424); color: #fff; font: 600 14px/20px var(--sans); }
  .s-obs-btn:hover { opacity: .85; }
  .s-obs-phone iframe { display: block; width: 390px; max-width: 100%; height: 720px; border: 10px solid #242424; border-radius: 32px; background: #f7f7f7; }
  .s-obs-cap { margin: 8px 0 0; text-align: center; font: 400 12px/18px var(--sans); color: var(--ink-2); }
  @media (max-width: 1023px) { .s-obs { grid-template-columns: minmax(0, 1fr); gap: 24px; } .s-obs-phone { justify-self: center; width: 100%; max-width: 390px; } .s-obs-phone iframe { height: 640px; } }
  @media (max-width: 1023px) { .s-hero { padding: 72px 24px 0; } }
  @media (max-width: 599px) { .s-hero { padding: 64px 12px 0; } }
</style>

src/lib/records.ts の SimTab と SERIES_PAGES(試算のタブの一覧)

// 試算のタブ(シリーズのページの Simulator)。task は何をするロボットか、place はどこで使うか。
// 1つ目は sim.src・sim.title・sim.note・sim.obs をそのまま使う。2つ目からは src に # で現場を付ける
// img はタブに出す写真(public/site/sim/ に置く。なければ文字だけ)
export type SimTab = { id: string; task: string; place: string; src: string; title: string; note: string; img?: string };

// シリーズのページ(URL → 記事のシリーズ名)。Studio の一覧の絞り込み条件と同じ
// intro はシリーズのページの見出しの下に出す説明(ないシリーズは出さない)
export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number; intro?: string; sim?: { src: string; title: string; note: string; obs?: { src: string; title: string; label: string; note: string; points: string[] }; tabs?: SimTab[] } }> = {
  series1: { series: 'HRI', title: 'Drug Delivery Robot | 薬剤搬送ロボットハコブくん', pageTitle: 'Series1 | 薬剤搬送ロボット', limit: 12,
    intro: '病院で薬を運ぶロボット「ハコブくん」を題材に、人とロボットの関わり(HRI)を設計している記録です。病院で使う画面(UI)の設計、Unity での自律移動、Fusion での形づくり、分身ロボット(アバター)の考察までを、4コママンガと一緒に残しています。' },
  series2: { series: 'R2R2R', title: 'R2R2R | デジタルツイン学習-R2R2R(Real to Render to Real)', pageTitle: 'Series2 | R2R2R', limit: 12,
    intro: 'iPhone で物を3Dスキャンし、Unity の中で動きを試してから、机の上のロボットアーム(mechArm 270)で実際に動かす記録です。ロボット工学を専門に学んでいないデザイナーでも、アームのつかむ・渡すといった動きを設計できるかを、失敗も含めて残しています。' },
  series3: { series: 'UV-C農業ロボット', title: 'UV-C Agricultural Robot | UV-C農業ロボット', pageTitle: 'Series3 | UV-C農業ロボット', limit: 20,
    intro: '湿気の多い日本の農業で、紫外線(UV-C)を当てて作物の病気を抑えるロボットが何を変えるかを考えた記録です。フィジカルAIを使った農業を、製造業のように管理する仕組みとして捉え直しています。' },
  series4: { series: 'ひとり研究所の裏側', title: 'Behind the Solo Lab | ひとり研究所の裏側', pageTitle: 'Series4 | ひとり研究所の裏側', limit: 12,
    intro: 'フィジカルAIの研究をひとりで続けるために、AIと一緒に作ってきた仕組みの記録です。サイトの移行、動画の制作、記録の整理など、研究の外側の作業をどう回しているかを残しています。' },
  series5: { series: '考察記事', title: 'Deep Dive | 考察記事', pageTitle: 'Series5 | 考察記事', limit: 12,
    intro: '実装の記録とは別に、フィジカルAIやロボットについて、なぜそうなるのか、何を意味するのかを考えた記事です。論文や企業の発表、市場の動きを読み、対話の形で考えたことを、外れた議論も含めてそのまま残しています。' },
  series6: { series: 'フィジカルAI研究室', title: 'Physical AI Lab | フィジカルAI研究室', pageTitle: 'Series6 | フィジカルAI研究室', limit: 12,
    intro: 'ロボットアームが動きを覚える環境を、自宅にひとりで作っている記録です。机の上のアームでお手本の動きを集め、ロボット用のAI(Physical Intelligence の π0.5 など)に学習させ、実機で動かして確かめます。借りたGPUの種類・時間・費用も含めて、誰でも同じ手順で始められる形で残しています。' },
  series7: { series: '画像認識・物体検出', title: 'Image recognition and Object detection | 画像認識と物体検出', pageTitle: 'Series7 | 画像認識と物体検出', limit: 12,
    intro: 'ロボットのカメラが物を見つける仕組みを、下の層から学び直している記録です。画素の数字、色の見分け方、しきい値、輪郭、特徴点といった昔からの手法を、机の上のアームを撮った写真で1つずつ測って確かめ、その上で SAM 3 のような新しいモデルも試します。評価の物差し(IoU・適合率・再現率など)を先に決め、結果は数字で残しています。' },
  series8: { series: '導入シミュレーション', title: 'Service Robot Simulation | サービスロボット導入シミュレーション', pageTitle: 'Series8 | サービスロボット導入シミュレーション', limit: 12,
    intro: '配膳・清掃・搬送など、店や施設で動くサービスロボットを入れたら何が変わるかを、導入前に試算する記録です。時間・順番・数は SimPy で、ぶつかる・こぼれるといった空間と動きは Unity で扱います。前提の数字は公開事例と観察から置き、結果とコードは全量で公開しています。',
    sim: { src: '/sim/serving-robot.html', title: '配膳ロボット、何台入れたら店は変わる?',
      note: '店を選び、人数・台数・ロボットの使い方を切り替えると、ピーク時の入店待ちや料理が届くまでの時間が変わります。1つの条件を40回回した平均で、数字はすべて仮の前提による試算です(試作 v2・7,665条件)。',
      obs: { src: '/sim/serving-robot-observation.html', title: '試算の仮の数字を、店で数える観察シート', label: '観察シートを開く ↗',
        note: '配膳ロボットが通路で何回止まるかは、公開データが見つかりませんでした。店で片手で押して数えるための記録シートの例です。押した記録はこのブラウザの中にだけ保存され、どこにも送られません。',
        points: ['ロボットの出発・卓に着いた・止まった・戻ったを押すと、1往復の時間と止まった割合が出る', '止まった場所と原因を選べる。選択肢は記録する人の立場(客席から・店のスタッフ・導入や保守の担当)で切り替わる', '地図やルートを変える前と後の観察を並べて比べられる'] },
      tabs: [
        { id: 'serving', img: '/site/sim/serving.jpg?v=2', task: '配膳', place: '飲食店', src: '/sim/serving-robot.html', title: '', note: '' },
        { id: 'hospital', img: '/site/sim/hospital.jpg?v=1', task: '搬送', place: '病院', src: '/sim/service-robot-sites.html#hospital', title: '検体と薬剤の搬送、ロボットに任せると何が変わる?',
          note: '慈恵医大柏病院の公開事例の形(3つのルート・運行の時間帯・同じ階)をもとに、病院の広さを選び、人が運ぶ時間と、荷物が届くまでの時間を比べます。ロボットが途中で止まる割合など、公開されていない値は仮置きで、何段階か動かせます(試作 v1・3,276条件・各30回の平均)。' },
        { id: 'cleaning', img: '/site/sim/cleaning.jpg?v=2', task: '清掃', place: '商業施設・ビル', src: '/sim/service-robot-sites.html#cleaning', title: '夜間の床清掃、人の作業はどれだけ減る?',
          note: '施設の広さ・ロボットが拭ける広い床の割合・台数・引っかかる回数を切り替えると、人の作業時間と、ロボットに呼ばれる回数が変わります。施設全体で5%減と対象エリアで60%減という公開事例の差を、床の割合で確かめられます(試作 v1・5,840条件・各30回の平均)。' },
        { id: 'hotel', img: '/site/sim/hotel.jpg?v=2', task: '配送', place: 'ホテル', src: '/sim/service-robot-sites.html#hotel', title: 'アメニティの客室配送、フロントは楽になる?',
          note: 'ホテルの広さ(120室・200室・400室)を選び、客室からの依頼をロボットで届けると、フロントを離れる時間・届くまでの時間・チェックインの待ちがどう変わるかを比べます(試作 v1・2,379条件・各30回の平均)。' },
        { id: 'security', img: '/site/sim/security.jpg?v=1', task: '巡回', place: 'オフィスビル', src: '/sim/service-robot-sites.html#security', title: '夜間の警備、巡回をロボットに任せると?',
          note: 'ビルの広さを選び、ロボットが巡回して異常を知らせ、警備員が駆けつける形で、異常に人が着くまでの時間と、防災センターが空く時間を比べます。異常でないものを知らせる空振りの回数は仮置きです(試作 v1・1,674条件・各30回の平均)。' },
        { id: 'outdoor', img: '/site/sim/outdoor.jpg?v=2', task: '配送', place: '屋外(歩道)', src: '/sim/service-robot-sites.html#outdoor', title: '歩道の配送ロボット、何件から人が足りなくなる?',
          note: '店から家へ届けるロボットと遠隔のオペレーターで、配達エリアの広さ・注文の数・台数・1人が見られる台数を切り替えます。止まってオペレーターを呼ぶ回数は仮置きです(試作 v1・3,960条件・各30回の平均)。' },
      ] } },
};

LOG356_scripts/summary.py(記事の表を出す)

"""LOG356:5つの現場の results_*.json から、記事に載せる表を出す。
- 標準の条件で、ロボットの台数を変えたとき
- 公表されていない値(止まる割合・引っかかる回数・空振り・介入の回数)を動かしたとき
- 施設の広さ(病院・ホテル・警備・屋外配送)を変えたとき(このとき以外は、真ん中の広さ)
使い方: python3 summary.py <results_*.json のあるフォルダ>
"""
import json
import sys
from pathlib import Path

D = Path(sys.argv[1] if len(sys.argv) > 1 else ".")


def rows(name):
    return json.load(open(D / f"results_{name}.json", encoding="utf-8"))["rows"]


def find(rs, zero, **kw):
    if kw.get("robots") == 0:
        kw = {**kw, **zero}
    hit = [r for r in rs if all(r[k] == v for k, v in kw.items())]
    assert len(hit) == 1, (kw, len(hit))
    return hit[0]


def table(title, head, lines):
    print(f"\n### {title}")
    print("| " + " | ".join(head) + " |")
    print("|" + "---|" * len(head))
    for l in lines:
        print("| " + " | ".join(str(x) for x in l) + " |")


f1 = lambda x: f"{x:.1f}"
f0 = lambda x: f"{x:.0f}"
pc = lambda x: f"{x * 100:.0f}%"

# ---- 病院
H = rows("hospital"); HZ = {"stop_rate": 0.0, "dispatch": "now", "speed": 0.9}
base = dict(scale="mid", demand=1, floors="same", stop_rate=0.05, dispatch="now", busy=0.6, speed=0.9)
table("病院搬送:ロボットの台数(荷物の量 普通・同じ階・すぐ出す・速さ0.9m/秒・忙しさ6割・止まる5%)",
      ["台数", "届くまで(分)", "遅い方の1割(分)", "15分超え", "人が搬送に使った分/日", "ほかの仕事の待ち(分)", "便数/日", "人を呼んだ回数"],
      [[n, f1(r["wait_mean"]), f1(r["wait_p90"]), pc(r["late_share"]), f0(r["staff_tr_min"]), f1(r["other_wait"]), f1(r["robot_trips"]), f1(r["calls"])]
       for n in [0, 1, 2] for r in [find(H, HZ, **{**base, "robots": n})]])
table("病院搬送:ロボットの速さ(1台・ほかは上と同じ)", ["速さ(m/秒)", "届くまで(分)", "15分超え", "人が搬送に使った分/日"],
      [[s, f1(r["wait_mean"]), pc(r["late_share"]), f0(r["staff_tr_min"])] for s in [0.6, 0.9, 1.2] for r in [find(H, HZ, **{**base, "robots": 1, "speed": s})]])
table("病院搬送:途中で止まる割合(1台)", ["止まる割合", "届くまで(分)", "人が搬送に使った分/日", "人を呼んだ回数"],
      [[pc(s), f1(r["wait_mean"]), f0(r["staff_tr_min"]), f1(r["calls"])] for s in [0.0, 0.05, 0.15] for r in [find(H, HZ, **{**base, "robots": 1, "stop_rate": s})]])
table("病院搬送:階をまたぐ(エレベーター)", ["台数", "届くまで(分)", "15分超え", "人が搬送に使った分/日"],
      [[n, f1(r["wait_mean"]), pc(r["late_share"]), f0(r["staff_tr_min"])] for n in [0, 1, 2] for r in [find(H, HZ, **{**base, "robots": n, "floors": "elev"})]])

# ---- 清掃
C = rows("cleaning"); CZ = {"eff": 0.8, "stuck_per_h": 0.0, "dock": False}
clk = lambda m: f"{(22 * 60 + round(m)) // 60 % 24}:{(22 * 60 + round(m)) % 60:02d}"
for fac, st in [("office", 1), ("mall", 3), ("large", 4)]:
    lines = []
    for sh in [0.3, 0.5, 0.7, 0.9]:
        z = find(C, CZ, facility=fac, staff=st, robots=0, share=sh)
        line = [pc(sh), f"{z['staff_hours']:.1f}時間・{clk(z['finish'])}"]
        for n in [1, 2, 3]:
            r = find(C, CZ, facility=fac, staff=st, robots=n, share=sh, eff=0.8, stuck_per_h=0.5, dock=False)
            line.append(f"{pc(1 - r['staff_hours'] / z['staff_hours'])}減・呼んだ{r['calls']:.1f}回")
        lines.append(line)
    table(f"夜間清掃:{fac}・スタッフ{st}人(速さ8割・引っかかり0.5回/時・水は人が入れ替える)", ["広い床の割合", "ロボットなし", "1台", "2台", "3台"], lines)
table("夜間清掃:引っかかる回数と充電台(商業施設・2人・2台・広い床70%・速さ8割)",
      ["引っかかり/時", "充電台", "人の作業(時間)", "人を呼んだ回数", "呼ばれて使った分", "ロボットが待った分"],
      [[s, "自動" if d else "人", f1(r["staff_hours"]), f1(r["calls"]), f0(r["call_min"]), f0(r["robot_wait"])]
       for s in [0.0, 0.5, 1.5] for d in [False, True] for r in [find(C, CZ, facility="mall", staff=2, robots=2, share=0.7, eff=0.8, stuck_per_h=s, dock=d)]])

# ---- ホテル
T = rows("hotel"); TZ = {"link": "system", "stop_rate": 0.0, "multi": 1}
lines = []
for fr in [2, 3]:
    for dm, n_req in zip([0, 1, 2], [25, 45, 80]):
        z = find(T, TZ, scale="mid", front=fr, robots=0, demand=dm)
        line = [fr, n_req, f"{f1(z['deliv_mean'])}分・離れる{f0(z['away_min'])}分・CI {f1(z['checkin_mean'])}分"]
        for n, lk in [(1, "system"), (1, "arm"), (2, "system")]:
            r = find(T, TZ, scale="mid", front=fr, robots=n, demand=dm, link=lk, stop_rate=0.05, multi=1)
            line.append(f"{f1(r['deliv_mean'])}分・離れる{f0(r['away_min'])}分・CI {f1(r['checkin_mean'])}分・呼んだ{f1(r['calls'])}回")
        lines.append(line)
table("ホテル:届くまで・フロントを離れた分・チェックインの待ち(止まる5%・1件ずつ)", ["フロント", "依頼/日", "ロボットなし", "1台・建物とつなぐ", "1台・腕でボタン", "2台・建物とつなぐ"], lines)
table("ホテル:1回に運ぶ依頼(フロント2人・80件/日・腕でボタン・1台)", ["1回に運ぶ", "届くまで(分)", "遅い方の1割(分)", "動いている割合"],
      [[m, f1(r["deliv_mean"]), f1(r["deliv_p90"]), pc(r["robot_util"])] for m in [1, 2] for r in [find(T, TZ, scale="mid", front=2, robots=1, demand=2, link="arm", stop_rate=0.05, multi=m)]])

# ---- 警備
S = rows("security"); SZ = {"false_per_h": 0.0, "rmode": "sched"}
lines = []
for g in [1, 2, 3]:
    z = find(S, SZ, scale="mid", guards=g, robots=0, incidents=6)
    line = [g, f"着くまで{f0(z['reach_mean'])}分・空白{f0(z['max_gap'])}分・センター空き{f0(z['center_empty'])}分"]
    for n in [1, 2]:
        for m in ["sched", "cont"]:
            r = find(S, SZ, scale="mid", guards=g, robots=n, incidents=6, false_per_h=0.5, rmode=m)
            line.append(f"{f0(r['reach_mean'])}分・空白{f0(r['max_gap'])}分・空き{f0(r['center_empty'])}分・呼んだ{f1(r['calls'])}回")
    lines.append(line)
table("警備:異常に人が着くまで・巡回の最長の空白・センターが空いた分(異常6件/晩・空振り0.5回/時)",
      ["警備員", "ロボットなし", "1台・2時間ごと", "1台・回り続ける", "2台・2時間ごと", "2台・回り続ける"], lines)
table("警備:空振りの知らせ(警備員1人・1台・回り続ける・異常6件)", ["空振り/時", "人を呼んだ回数", "うち空振り", "センターが空いた分", "着くまで(分)"],
      [[fp, f1(r["calls"]), f1(r["false_calls"]), f0(r["center_empty"]), f1(r["reach_mean"])] for fp in [0.0, 0.5, 1.5]
       for r in [find(S, SZ, scale="mid", guards=1, robots=1, incidents=6, false_per_h=fp, rmode="cont")]])

# ---- 屋外配送
O = rows("outdoor")
for w in [10, 4]:
    lines = []
    for od in [20, 60, 120, 200]:
        for ir in [0.05, 0.2, 0.5]:
            line = [od, ir]
            for n in [1, 2, 4, 6, 10]:
                r = find(O, {}, scale="mid", robots=n, operators=1, orders=od, int_rate=ir, watch=w)
                line.append(f"{f0(r['lead_mean'])}分・待ち{r['int_wait'] * 60:.0f}秒・{pc(r['op_util'])}")
            lines.append(line)
    table(f"屋外配送:届くまで・介入でオペレーターを待った秒・オペレーターがかかりきりの割合(オペレーター1人・1人{w}台まで)",
          ["注文/日", "介入/片道", "1台", "2台", "4台", "6台", "10台"], lines)

# ---- 施設の広さ(真ん中以外の広さも、標準の条件で比べる)
table("病院搬送:病院の広さ(1台・ほかは標準)", ["広さ", "台数", "届くまで(分)", "15分超え", "人が搬送に使った分/日", "人を呼んだ回数"],
      [[sc, n, f1(r["wait_mean"]), pc(r["late_share"]), f0(r["staff_tr_min"]), f1(r["calls"])]
       for sc in ["small", "mid", "large"] for n in [0, 1, 2] for r in [find(H, HZ, **{**base, "scale": sc, "robots": n})]])
table("ホテル:ホテルの広さ(依頼 普通・建物とつなぐ・止まる5%・1件ずつ)", ["広さ", "フロント", "台数", "届くまで(分)", "フロントを離れた分", "チェックインの待ち(分)", "人を呼んだ回数"],
      [[sc, fr, n, f1(r["deliv_mean"]), f0(r["away_min"]), f1(r["checkin_mean"]), f1(r["calls"])]
       for sc, fr in [("small", 1), ("small", 2), ("mid", 2), ("large", 3)] for n in [0, 1, 2]
       for r in [find(T, TZ, scale=sc, front=fr, robots=n, demand=1, link="system", stop_rate=0.05, multi=1)]])
table("警備:ビルの広さ(警備員2人・異常6件・空振り0.5回/時・回り続ける)", ["広さ", "台数", "着くまで(分)", "巡回の最長の空白(分)", "警備員が外にいた分", "人を呼んだ回数"],
      [[sc, n, f1(r["reach_mean"]), f0(r["max_gap"]), f0(r["foot_min"]), f1(r["calls"])]
       for sc in ["small", "mid", "large"] for n in [0, 1, 2] for r in [find(S, SZ, scale=sc, guards=2, robots=n, incidents=6, false_per_h=0.5, rmode="cont")]])
table("屋外配送:配達エリアの広さ(オペレーター1人・1人10台まで・介入0.2回/片道)", ["広さ", "注文/日", "2台", "4台", "6台", "10台"],
      [[sc, od] + [f"{f0(r['lead_mean'])}分・{pc(r['robot_util'])}" for n in [2, 4, 6, 10] for r in [find(O, {}, scale=sc, robots=n, operators=1, orders=od, int_rate=0.2, watch=10)]]
       for sc in ["near", "mid", "wide"] for od in [20, 60, 120]])

LOG356_scripts/make_figure.py(図解を作る)

"""LOG356 の図解(LOG356_robot_calls_human.html)を作る。
5つの現場の標準の条件で、公表されていない値(止まる割合など)を3段階に動かしたときの
「ロボットが人を呼んだ回数」と「呼ばれた側の時間」を results_*.json から取り出して、図に埋め込む。
使い方: python3 make_figure.py <results_*.json のあるフォルダ> <出力の html>
"""
import json
import sys
from pathlib import Path

D, OUT = Path(sys.argv[1]), Path(sys.argv[2])


def rows(name):
    return json.load(open(D / f"results_{name}.json", encoding="utf-8"))["rows"]


def find(rs, **kw):
    hit = [r for r in rs if all(r[k] == v for k, v in kw.items())]
    assert len(hit) == 1, (kw, len(hit))
    return hit[0]


# 試算ページ(sites_page.body.html)の標準の条件と同じ
SITES = [
    ("hospital", "病院搬送", "1日(7:30〜16:30)", "stop_rate", "途中で止まる割合", [0.0, 0.05, 0.15], lambda v: f"{round(v * 100)}%",
     dict(scale="mid", robots=1, demand=1, floors="same", dispatch="now", busy=0.6, speed=0.9),
     "staff_tr_min", "人が搬送に使った時間", 540),
    ("cleaning", "夜間清掃", "1晩(22:00〜6:00)", "stuck_per_h", "引っかかる回数(1台・1時間)", [0.0, 0.5, 1.5], lambda v: f"{v}回",
     dict(facility="mall", staff=3, robots=2, share=0.7, eff=0.8, dock=False),
     "call_min", "呼ばれて使った時間", 480),
    ("hotel", "ホテル配送", "15:00〜24:00", "stop_rate", "途中で止まる割合", [0.0, 0.05, 0.15], lambda v: f"{round(v * 100)}%",
     dict(scale="mid", front=2, robots=1, demand=1, link="system", multi=1),
     "away_min", "フロントを離れた時間", 540),
    ("security", "夜間警備", "1晩(19:00〜7:00)", "false_per_h", "空振りの知らせ(1台・1時間)", [0.0, 0.5, 1.5], lambda v: f"{v}回",
     dict(scale="mid", guards=2, robots=1, incidents=6, rmode="cont"),
     "foot_min", "警備員がセンターの外にいた時間", 720),
    ("outdoor", "屋外配送", "11:00〜20:00", "int_rate", "介入の回数(片道1回あたり)", [0.05, 0.2, 0.5], lambda v: f"{v}回",
     dict(scale="mid", robots=4, operators=1, orders=60, watch=10),
     "op_util", "オペレーターが介入にかかりきりの割合", 540),
]

data = []
for key, label, span, sens, sens_label, vals, fmt, base, hk, hlabel, minutes in SITES:
    rs = rows(key)
    levels = []
    for v in vals:
        r = find(rs, **{**base, sens: v})
        h = r[hk] * 100 if hk == "op_util" else r[hk]
        levels.append({"v": fmt(v), "calls": round(r["calls"], 1), "human": round(h, 3)})
    data.append({"key": key, "label": label, "span": span, "sens": sens_label, "hlabel": hlabel,
                 "unit": "%" if hk == "op_util" else "分", "minutes": minutes, "levels": levels})

html = """<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>ロボットが人を呼ぶ回数</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Sans+JP:wght@400;500;700&display=swap">
<style>
:root { --ink: #0d0d0d; --paper: #f5f5f0; --mid: #8a8a85; --soft: #e4e4dd; }
* { box-sizing: border-box; }
body { margin: 0; padding: 40px 16px; background: var(--paper); color: var(--ink); font-family: "Noto Sans JP", sans-serif; }
@media (max-width: 600px) { body { padding: 32px 12px; } }
.fig { max-width: 720px; margin: 0 auto; border: 1.5px solid var(--ink); background: var(--paper); padding: 20px; display: grid; gap: 18px; }
.lab { font-family: "IBM Plex Mono", monospace; font-size: 11px; letter-spacing: .08em; color: var(--mid); }
.sites { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
@media (max-width: 600px) { .sites { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.sites button { font: 500 13px/1.3 "Noto Sans JP", sans-serif; padding: 8px 4px; border: 1.5px solid var(--ink); background: var(--paper); color: var(--ink); cursor: pointer; }
.sites button[aria-pressed="true"] { background: var(--ink); color: var(--paper); }
button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.lanes { display: grid; gap: 8px; }
.lane { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; align-items: center; }
.lane .name { font-size: 12px; font-weight: 700; line-height: 1.4; }
.track { position: relative; height: 34px; border: 1.5px solid var(--ink); background: var(--paper); overflow: hidden; }
.robot .track { background: repeating-linear-gradient(135deg, var(--ink) 0 2px, transparent 2px 9px); }
.mark { position: absolute; top: -1.5px; bottom: -1.5px; width: 3px; background: var(--ink); transform: scaleY(0); transform-origin: top; transition: transform .35s ease; }
.mark.on { transform: scaleY(1); }
.hblock { position: absolute; top: 4px; bottom: 4px; background: var(--ink); transition: left .4s ease, width .4s ease; }
.axis { display: flex; justify-content: space-between; font-family: "IBM Plex Mono", monospace; font-size: 10px; color: var(--mid); margin-left: 102px; }
.slider { display: grid; gap: 6px; }
.slider .row { display: flex; justify-content: space-between; font-family: "IBM Plex Mono", monospace; font-size: 12px; }
input[type=range] { width: 100%; accent-color: #0d0d0d; }
.nums { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.num { border: 1.5px solid var(--ink); padding: 12px 14px; display: grid; gap: 2px; }
.num b { font-family: "IBM Plex Mono", monospace; font-size: 30px; font-weight: 600; line-height: 1.2; font-variant-numeric: tabular-nums; }
.num b small { font-size: 13px; font-weight: 500; margin-left: 2px; }
.num span { font-size: 12px; color: #444; line-height: 1.5; }
.note { margin: 0; font-size: 13px; line-height: 1.8; }
@media (prefers-reduced-motion: reduce) { .mark, .hblock { transition: none; } }
</style></head><body>
<div class="fig">
  <div class="lab">ROBOT WORK → HUMAN QUEUE · 5 SITES</div>
  <div class="sites" id="sites"></div>
  <div class="lanes">
    <div class="lane robot"><span class="name">ロボットの仕事</span><div class="track" id="rt"></div></div>
    <div class="lane"><span class="name">呼ばれる人</span><div class="track" id="ht"></div></div>
  </div>
  <div class="axis"><span id="a0"></span><span id="a1"></span></div>
  <div class="slider">
    <div class="row"><span id="sl">途中で止まる割合</span><span id="sv"></span></div>
    <input type="range" id="lv" min="0" max="2" step="1" value="1" aria-label="公表されていない値">
    <div class="row lab"><span id="l0"></span><span id="l1"></span><span id="l2"></span></div>
  </div>
  <div class="nums">
    <div class="num"><span>ロボットが人を呼んだ回数</span><b id="calls"></b></div>
    <div class="num"><span id="hl"></span><b id="human"></b></div>
  </div>
  <p class="note" id="note"></p>
</div>
<script>
const D = __DATA__;
let si = 0;
const rt = document.getElementById("rt"), ht = document.getElementById("ht"), lv = document.getElementById("lv");
// 呼ばれた時刻は、見やすいように決まった並びで散らす(試算の時刻そのものではない)
const spot = k => ((k * 0.618034) % 1) * 0.94 + 0.03;
function draw() {
  const s = D[si], L = s.levels[+lv.value];
  document.querySelectorAll("#sites button").forEach((b, i) => b.setAttribute("aria-pressed", String(i === si)));
  document.getElementById("sl").textContent = s.sens + "(仮置き)";
  document.getElementById("sv").textContent = L.v;
  s.levels.forEach((x, i) => document.getElementById("l" + i).textContent = x.v);
  document.getElementById("a0").textContent = s.span;
  document.getElementById("a1").textContent = "1本 = 1回";
  const n = Math.round(L.calls);
  rt.innerHTML = ""; ht.innerHTML = "";
  for (let k = 0; k < Math.min(n, 40); k++) {
    const m = document.createElement("i"); m.className = "mark"; m.style.left = (spot(k) * 100).toFixed(2) + "%"; rt.appendChild(m);
    const h = document.createElement("i"); h.className = "hblock";
    const w = s.unit === "分" ? Math.max(0.6, L.human / Math.max(n, 1) / s.minutes * 100) : 1.2;
    h.style.left = (spot(k) * 100).toFixed(2) + "%"; h.style.width = w.toFixed(2) + "%"; ht.appendChild(h);
    setTimeout(() => m.classList.add("on"), 30 + k * 25);
  }
  document.getElementById("calls").innerHTML = `${L.calls.toFixed(1)}<small>回</small>`;
  document.getElementById("hl").textContent = s.hlabel;
  document.getElementById("human").innerHTML = s.unit === "%" ? `${Math.round(L.human)}<small>%</small>` : `${Math.round(L.human)}<small>分</small>`;
  const a = s.levels[0], c = s.levels[2];
  document.getElementById("note").textContent = `${s.label}・${s.span}:${s.sens}を ${a.v} から ${c.v} にすると、人が呼ばれる回数は ${a.calls.toFixed(1)}回 から ${c.calls.toFixed(1)}回 に、${s.hlabel}は ${Math.round(a.human)}${s.unit} から ${Math.round(c.human)}${s.unit} になる。上の線1本が、ロボットが人を呼んだ1回。`;
}
document.getElementById("sites").innerHTML = D.map((s, i) => `<button type="button" data-i="${i}">${s.label}</button>`).join("");
document.getElementById("sites").onclick = e => { const b = e.target.closest("button"); if (b) { si = +b.dataset.i; draw(); } };
lv.oninput = draw;
draw();
</script>
</body></html>
"""
OUT.write_text(html.replace("__DATA__", json.dumps(data, ensure_ascii=False)), encoding="utf-8")
print(OUT, len(data), "sites")

LOG356_scripts/make_thumbnail.py(サムネイルを作る)

"""LOG356 のサムネイル(LOG356_thumbnail.html)を作る。
試算のタブの写真6枚を白黒にして埋め込み、5つの現場の「ロボットが人を呼んだ回数」(標準の条件)を添える。
使い方: python3 make_thumbnail.py <タブの写真のフォルダ(p2m-site/public/site/sim)> <results_*.json のフォルダ> <出力の html>
"""
import base64
import io
import json
import sys
from pathlib import Path

from PIL import Image, ImageOps

IMG, RES, OUT = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3])


def find(name, **kw):
    rs = json.load(open(RES / f"results_{name}.json", encoding="utf-8"))["rows"]
    hit = [r for r in rs if all(r[k] == v for k, v in kw.items())]
    assert len(hit) == 1, (name, kw, len(hit))
    return hit[0]


calls = {
    "hospital": find("hospital", scale="mid", robots=1, demand=1, floors="same", stop_rate=0.05, dispatch="now", busy=0.6, speed=0.9)["calls"],
    "cleaning": find("cleaning", facility="mall", staff=3, robots=2, share=0.7, eff=0.8, stuck_per_h=0.5, dock=False)["calls"],
    "hotel": find("hotel", scale="mid", front=2, robots=1, demand=1, link="system", stop_rate=0.05, multi=1)["calls"],
    "security": find("security", scale="mid", guards=2, robots=1, incidents=6, false_per_h=0.5, rmode="cont")["calls"],
    "outdoor": find("outdoor", scale="mid", robots=4, operators=1, orders=60, int_rate=0.2, watch=10)["calls"],
}
CARDS = [
    ("serving", "SERVING", "RESTAURANT", None, "LOG351"),
    ("hospital", "SPECIMENS", "HOSPITAL", calls["hospital"], "PER DAY"),
    ("cleaning", "FLOORS", "MALL", calls["cleaning"], "PER NIGHT"),
    ("hotel", "AMENITIES", "HOTEL", calls["hotel"], "PER DAY"),
    ("security", "PATROL", "OFFICE", calls["security"], "PER NIGHT"),
    ("outdoor", "SIDEWALK", "DELIVERY", calls["outdoor"], "PER DAY"),
]


def gray_uri(name):
    im = Image.open(IMG / f"{name}.jpg").convert("L")
    im = ImageOps.autocontrast(im, cutoff=1)
    # 墨色(#0d0d0d)から紙色(#f5f5f0)の間の色だけにする
    im = ImageOps.colorize(im, black=(13, 13, 13), white=(245, 245, 240))
    buf = io.BytesIO(); im.save(buf, "JPEG", quality=78)
    return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()


cards = "".join(
    f'<div class="card"><img src="{gray_uri(k)}" alt=""><div class="cap"><span class="t">{t}</span><span class="p">{p}</span></div>'
    + (f'<div class="n">{v:.1f}<small>CALLS {u}</small></div>' if v is not None else f'<div class="n dim">#1<small>{u}</small></div>')
    + "</div>"
    for k, t, p, v, u in CARDS)

html = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>LOG356 thumbnail</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap">
<style>
* {{ box-sizing: border-box; margin: 0; }}
html, body {{ background: #0d0d0d; }}
.thumb {{ container-type: size; width: 100%; max-width: 1920px; aspect-ratio: 16 / 9; margin: 0 auto; background: #0d0d0d; color: #f5f5f0;
  font-family: "IBM Plex Mono", monospace; position: relative; overflow: hidden; }}
.top {{ position: absolute; left: 4.5cqw; top: 6cqh; right: 4.5cqw; display: grid; gap: 1.6cqh; }}
.kick {{ font-size: 1.15cqw; letter-spacing: .22em; color: #9a9a94; }}
h1 {{ font-size: 5.6cqw; line-height: 1.02; font-weight: 700; letter-spacing: -.01em; }}
h1 span {{ display: inline-block; background: #f5f5f0; color: #0d0d0d; padding: 0 .18em; margin-top: .6cqh; }}
.sub {{ font-size: 1.15cqw; letter-spacing: .12em; color: #c9c9c2; }}
.row {{ position: absolute; left: 4.5cqw; right: 4.5cqw; bottom: 6cqh; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 1.2cqw; }}
.card {{ display: grid; gap: .9cqh; opacity: 0; transform: translateY(2cqh); animation: up .6s ease forwards; }}
.card:nth-child(2) {{ animation-delay: .12s; }} .card:nth-child(3) {{ animation-delay: .24s; }} .card:nth-child(4) {{ animation-delay: .36s; }}
.card:nth-child(5) {{ animation-delay: .48s; }} .card:nth-child(6) {{ animation-delay: .6s; }}
@keyframes up {{ to {{ opacity: 1; transform: none; }} }}
.card img {{ width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; border: .15cqw solid #f5f5f0; }}
.cap {{ display: flex; justify-content: space-between; align-items: baseline; border-bottom: .12cqw solid #4a4a46; padding-bottom: .6cqh; }}
.cap .t {{ font-size: 1.05cqw; font-weight: 600; letter-spacing: .1em; }}
.cap .p {{ font-size: .8cqw; letter-spacing: .14em; color: #9a9a94; }}
.n {{ font-size: 3.4cqw; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; display: grid; gap: .6cqh; }}
.n small {{ font-size: .78cqw; font-weight: 500; letter-spacing: .14em; color: #9a9a94; }}
.n.dim {{ color: #6a6a66; }}
.stripe {{ position: absolute; right: 4.5cqw; top: 6cqh; width: 22cqw; height: 18cqh;
  background: repeating-linear-gradient(135deg, #f5f5f0 0 .12cqw, transparent .12cqw .9cqw); opacity: .18; }}
@media (prefers-reduced-motion: reduce) {{ .card {{ animation: none; opacity: 1; transform: none; }} }}
</style></head><body>
<div class="thumb">
  <div class="stripe"></div>
  <div class="top">
    <div class="kick">SERVICE ROBOT SIMULATION · SIMPY · 5 MORE SITES</div>
    <h1>THE ROBOT<br><span>CALLS A HUMAN.</span></h1>
    <div class="sub">HOW MANY TIMES, PER SHIFT, AT DEFAULT SETTINGS</div>
  </div>
  <div class="row">{cards}</div>
</div>
</body></html>
"""
OUT.write_text(html, encoding="utf-8")
print(OUT, {k: round(v, 1) for k, v in calls.items()})

LOG356_scripts/tab_images.py(タブの写真を縮めてサイトに置く)

"""試算のタブの写真(ChatGPT で作ったもの)を 480×360 に縮めて、サイトの public/site/sim/{名前}.jpg に置く。
元の大きさの画像は iCloud の サービスロボット導入シミュレーション/tab_images/ に残す。
(作業中は同じ中身を python3 - <<'EOF' で直接動かした。差し替えのたびに、名前と画像を変えて動かした)
使い方: python3 tab_images.py <p2m-site> <名前>=<画像> [<名前>=<画像> ...]
  例: python3 tab_images.py ~/p2m-site serving=serving_original_v2.png hotel=hotel_original_v2.png
差し替えたら、src/lib/records.ts の img の ?v= を1つ上げる(ブラウザに古い画像が残らないように)
"""
import sys
from pathlib import Path

from PIL import Image, ImageOps

site = Path(sys.argv[1])
for arg in sys.argv[2:]:
    name, src = arg.split("=", 1)
    im = Image.open(src).convert("RGB")
    print(name, im.size)
    # 4:3 でない画像(1446×1087 など)は、真ん中を 4:3 に切ってから縮める
    ImageOps.fit(im, (480, 360), Image.LANCZOS).save(site / "public/site/sim" / f"{name}.jpg", quality=82, optimize=True, progressive=True)

LOG356_scripts/check_combos.py(選べる組み合わせの結果がすべてあるかを、ページの中で確かめる)

"""試算のページを開き、ページで選べるすべての組み合わせ(施設の広さ × 条件の欄の選択肢と「その他」の数)について、
results に結果が入っているかを確かめる。あわせて PC とスマホの幅で画面写真を撮り、横のはみ出しを見る。
使い方: python3 check_combos.py <service_robot_sites.html の file:// か http:// の住所> <画面写真の出力フォルダ>
"""
import asyncio
import sys
from playwright.async_api import async_playwright

P, OUT = sys.argv[1], sys.argv[2].rstrip("/") + "/"
CHECK = """() => {
  const miss = [];
  for (const s of ORDER) {
    const sp = SITES[s];
    // UI で選べる全組み合わせ
    let combos = sp.scale.cards().map(x => ({ [sp.scale.key]: x.v }));
    for (const a of sp.axes) {
      const next = [];
      for (const c of combos) {
        const opts = typeof a.opts === "function" ? a.opts({ ...sp.def, ...c }) : a.opts;
        const vals = a.more ? a.more({ ...sp.def, ...c }) : opts.map(o => o.v);
        for (const v of vals) next.push({ ...c, [a.key]: v });
      }
      combos = next;
    }
    let n = 0;
    for (const c of combos) { n++; if (!find(s, c)) miss.push(s + JSON.stringify(c)); }
    miss.push(`${s}: ${n} combos checked`);
  }
  return miss.slice(-8).concat([`missing total ${miss.filter(m=>m.includes('{')).length}`]);
}"""


async def main():
    async with async_playwright() as p:
        b = await p.chromium.launch(); errs = []
        pg = await b.new_page(viewport={"width": 1200, "height": 900})
        pg.on("pageerror", lambda e: errs.append(str(e)))
        pg.on("console", lambda m: errs.append(m.text) if m.type == "error" else None)
        await pg.goto(P); await pg.wait_for_timeout(1000)
        print(await pg.evaluate(CHECK))
        for i, s in enumerate(["hospital", "cleaning", "hotel", "security", "outdoor"]):
            await pg.click(f'label[for="site-{i}"]'); await pg.wait_for_timeout(300)
            await pg.screenshot(path=OUT + f"desk_{s}.png", full_page=True)
        m = await b.new_page(viewport={"width": 390, "height": 844}, device_scale_factor=1)
        m.on("pageerror", lambda e: errs.append(str(e)))
        await m.goto(P + "#cleaning"); await m.wait_for_timeout(1000)
        sw = await m.evaluate("document.documentElement.scrollWidth")
        await m.screenshot(path=OUT + "phone_cleaning.png", full_page=True)
        print("errors:", errs[:10], "phone scrollWidth", sw)
        await b.close()

asyncio.run(main())

LOG356_scripts/shots.py(手元のサイトで画面写真を撮る)

"""LOG356:手元で作ったサイト(p2m-site の dist を http.server 8777 で開く)で、試算のタブと結果の画面写真を撮る
使い方: python3 shots.py <画面写真の出力フォルダ>
"""
import asyncio
import sys
from playwright.async_api import async_playwright

U = "http://localhost:8777/series8.html"
O = sys.argv[1].rstrip("/") + "/"


async def main():
    async with async_playwright() as p:
        b = await p.chromium.launch()
        pg = await b.new_page(viewport={"width": 1280, "height": 900}, device_scale_factor=2)
        await pg.goto(U); await pg.wait_for_timeout(2500)
        panel = pg.locator(".s-sim .panel")
        await panel.scroll_into_view_if_needed()
        # 01:タブと、配膳の試算の見出し
        top = await pg.evaluate("document.querySelector('.s-sim .panel').getBoundingClientRect().top + scrollY")
        await pg.screenshot(path=O + "LOG356_01_tabs_desktop.png", clip={"x": 80, "y": top, "width": 1120, "height": 560}, full_page=True)
        # 07:配膳のタブの、観察シートへ移るリンク
        jy = await pg.evaluate("document.getElementById('sim-title').getBoundingClientRect().top + scrollY")
        await pg.screenshot(path=O + "LOG356_07_obs_jump.png", clip={"x": 80, "y": jy - 16, "width": 1120, "height": 170}, full_page=True)
        f = pg.frame_locator("#sim-frame")

        async def tab(tid):
            await pg.click(f"#simtab-{tid}"); await pg.wait_for_timeout(1800)

        await tab("hospital")
        await f.locator(".results > section").first.screenshot(path=O + "LOG356_02_hospital_results.png")
        await tab("cleaning")
        await f.locator("#cut-sec").screenshot(path=O + "LOG356_03_cleaning_cut.png")
        await f.locator("#ax-staff .more").click(); await pg.wait_for_timeout(300)
        await f.locator("#ax-staff").locator("xpath=ancestor::fieldset[1]").screenshot(path=O + "LOG356_08_more_panel.png")
        await tab("hotel")
        await f.locator("#scale-pick").screenshot(path=O + "LOG356_09_hotel_scale.png")
        await tab("security")
        await f.locator("#stable").locator("xpath=ancestor::section[1]").screenshot(path=O + "LOG356_04_security_false_alarm.png")
        await tab("outdoor")
        await f.locator("#bars").locator("xpath=ancestor::section[1]").screenshot(path=O + "LOG356_05_outdoor_robots.png")

        m = await b.new_page(viewport={"width": 390, "height": 844}, device_scale_factor=2)
        await m.goto(U + "#sim-hotel"); await m.wait_for_timeout(2500)
        await m.locator(".s-tabs").screenshot(path=O + "LOG356_06_phone_tabs.png")
        await b.close()

asyncio.run(main())

LOG356_scripts/assemble.py(この記事を組み立てる)

"""LOG356 の記事 MD を組み立てる。
parts/ の本文に、調査メモの順位表・出典の照合表・summary.py の表・図解の行を差し込み、
後ろに「実行したコマンドと出力」と「この回に使ったコードの全文」を付ける。
"""
import re
from pathlib import Path

W = Path("/home/claude/LOG356")
SIM = Path("/home/claude/sites_sim")
SITE = Path("/home/claude/p2m-site")
REPORT = Path("/home/claude/reports/サービスロボットの導入先 日本.md").read_text(encoding="utf-8")
SUMMARY = (W / "logs/summary.txt").read_text(encoding="utf-8")

body = "".join((W / "parts" / f).read_text(encoding="utf-8") for f in ["01_intro.md", "02_research.md", "03_models.md", "04_site.md"])

# 調査メモの順位表(「| 順位 |」の行から、空行の手前まで)
lines = REPORT.splitlines()
i = next(k for k, l in enumerate(lines) if l.startswith("| 順位 |"))
j = next(k for k in range(i, len(lines)) if not lines[k].startswith("|"))
body = body.replace("{{RANKING}}", "\n".join(lines[i:j]))
body = body.replace("{{FACTCHECK}}", (W / "parts/factcheck.md").read_text(encoding="utf-8").strip())
body = body.replace("{{FIGURE}}", "【図解】LOG356_robot_calls_human.html")

# summary.py の表(### の見出しが、指定の文字で始まるもの)
sections = re.split(r"\n(?=### )", SUMMARY.strip())


def table(prefix):
    hit = [s for s in sections if s.startswith("### " + prefix)]
    assert len(hit) == 1, (prefix, len(hit))
    head, *rest = hit[0].splitlines()
    return "表:" + head[4:] + "\n\n" + "\n".join(rest)


body = re.sub(r"\{\{T:(.+?)\}\}", lambda m: table(m.group(1)), body)
assert "{{" not in body, re.findall(r"\{\{.*?\}\}", body)

# 実行したコマンドと出力
logs = (W / "parts/05_logs.md").read_text(encoding="utf-8")
logs = re.sub(r"\{\{LOG:(.+?)\}\}", lambda m: (W / "logs" / m.group(1)).read_text(encoding="utf-8").rstrip(), logs)

# コードの全文
CODES = [
    (SIM / "common.py", "common.py(5つの試算に共通の部品)", "python"),
    (SIM / "hospital_sim.py", "hospital_sim.py(病院の検体・薬剤の搬送)", "python"),
    (SIM / "cleaning_sim.py", "cleaning_sim.py(ビル・商業施設の夜間清掃)", "python"),
    (SIM / "hotel_sim.py", "hotel_sim.py(ホテルの館内配送)", "python"),
    (SIM / "security_sim.py", "security_sim.py(オフィスビルの夜間警備)", "python"),
    (SIM / "outdoor_sim.py", "outdoor_sim.py(歩道を走る屋外配送)", "python"),
    (SIM / "build_sites_page.py", "build_sites_page.py(results_*.json とページの部品から、試算のページを1枚にまとめる)", "python"),
    (SIM / "make_site_copy.py", "make_site_copy.py(Artifact 版を、サイトに置ける1枚の HTML に直す)", "python"),
    (SIM / "sites_page.body.html", "sites_page.body.html(試算のページの部品。__DATA__ に結果が入る)", "html"),
    (SITE / "src/pages/[series].astro", "src/pages/[series].astro(シリーズのページ。試算のタブ)", "astro"),
    (W / "parts/records_excerpt.ts", "src/lib/records.ts の SimTab と SERIES_PAGES(試算のタブの一覧)", "ts"),
    (W / "LOG356_scripts/summary.py", "LOG356_scripts/summary.py(記事の表を出す)", "python"),
    (W / "LOG356_scripts/make_figure.py", "LOG356_scripts/make_figure.py(図解を作る)", "python"),
    (W / "LOG356_scripts/make_thumbnail.py", "LOG356_scripts/make_thumbnail.py(サムネイルを作る)", "python"),
    (W / "LOG356_scripts/tab_images.py", "LOG356_scripts/tab_images.py(タブの写真を縮めてサイトに置く)", "python"),
    (W / "LOG356_scripts/check_combos.py", "LOG356_scripts/check_combos.py(選べる組み合わせの結果がすべてあるかを、ページの中で確かめる)", "python"),
    (W / "LOG356_scripts/shots.py", "LOG356_scripts/shots.py(手元のサイトで画面写真を撮る)", "python"),
    (W / "LOG356_scripts/assemble.py", "LOG356_scripts/assemble.py(この記事を組み立てる)", "python"),
]
code = "\n\n## この回に使ったコードの全文\n\n試算のコードは iCloud の `サービスロボット導入シミュレーション/simulation_sites/`、記事の表・図解・サムネイル・画面写真のスクリプトは `LOG356_scripts/` に置いています。\n"
for path, title, lang in CODES:
    text = path.read_text(encoding="utf-8").rstrip()
    fence = "````" if "```" in text else "```"
    code += f"\n### {title}\n\n{fence}{lang}\n{text}\n{fence}\n"

closing = (W / "parts/06_closing.md").read_text(encoding="utf-8")
md = body.rstrip() + "\n\n" + logs.rstrip() + "\n" + code + closing
# H2 の前は空行2行
md = re.sub(r"\n+(## )", r"\n\n\n\1", md)
md = md.replace("---\n\n\n\n", "---\n\n")
(W / "LOG356_article.md").write_text(md, encoding="utf-8")
print(len(md), "chars")

保存先と、次にやること

もの場所
調査メモ(出典104本)
iCloud PixelstoMotors/research/research_service_robot_sites_20261020.md
5つの試算のコード・結果・ページ
iCloud デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/simulation_sites/
タブの写真(元の大きさ)
iCloud サービスロボット導入シミュレーション/tab_images/
記事・サムネイル・図解・SNS・画像・スクリプト
iCloud サービスロボット導入シミュレーション/ の LOG356_*・LOG356_images/・LOG356_scripts/
サイト
GitHub PixelstoMotors/p2m-site(記事 src/content/records/LOG356.md、試算 public/sim/service-robot-sites.html、タブの写真 public/site/sim/、タブ src/pages/[series].astro・src/lib/records.ts)
記録用
GitHub PixelstoMotors/r2r2r-content の articles/ と sns/

5つの試算は、どれも「ロボットが何回止まり、人が何回呼ばれるか」を仮置きしています。配膳ロボットでは、この数を店で数える観察シートを作りました(LOG353)。次は、同じ形で病院搬送か夜間清掃の観察項目を決め、仮置きの値を観察した数に置き換えられる現場を探します。