# -*- coding: utf-8 -*- """サービスロボット導入シミュレーション 病院の搬送ロボット(検体・薬剤、同じ階)試算 版の記録 h0(2026-11-09):最初の版 h2(2026-11-09):青の道の決め方(route_plan:廊下だけ/裏口だけ/時間で切替)を足した。時間で切替は back_door_hours(外来の混む 8〜11時)の間だけロボットが裏口から積む(Unity 依頼134 で、混む時間は裏口で 問題がほぼ半分、空いている時間は今の道の方が少なかったため)。人が歩くときはいつも表の短い道。 ロボットの便の時刻表(trip_log)を残し、Unity に渡せるようにした h1(2026-11-09):青の依頼の数を1時間8件 → 1.5件(慈恵柏の利用回数 1日4〜9回に合わせた。案1)。 依頼1件は、ロボットに1回で預けるひとまとまり(採血管の袋など)と定義した。 道の長さを何倍にするか(layout_scale)と、人が運ぶときの歩く以外の手間(staff_extra_s)を 動かせるようにした(どちらも標準では効かない値:1.0倍・0秒) 前提の値はこのファイルの隣の assumptions.json(前提の表)から、フロアの形は ../hospital_layouts/hospital_h0.json から読む。コードの中に前提の値を書かない。 何を回すか 1日(7:30〜16:30)、3ルート(緑・青・赤)に依頼が出る。依頼は各ルートの運行の時間帯の間だけ、 1時間あたりの数(requests_per_h)でばらばらに出る(ポアソン到着)。至急の割合は urgent_share。 ロボット0台:看護補助者が歩いて運ぶ(比べるもと)。まとめるために staff_hold_min 分まで待ち、至急ならすぐ出る。 ロボット1台・2台:出発の決まり(rules)どおりに運ぶ。 - いま運行の時間帯にあるルートの出発地で待つ(時間帯が切り替わったら、次のルートの出発地へ移る) - 満杯(収納の数)か、いちばん古い依頼が max_hold_min 分待ったら出発。至急はすぐ。時間帯が終わって残った依頼もすぐ - 積む(load_s)→ 走る(距離/速さ+一時停止・立ち往生)→ 受け取る人を待つ(pickup_wait_s の平均の指数分布) → 取り出す(unload_s)→ 戻る - 至急の依頼が出たとき、その出発地に空いているロボットがいなければ、看護補助者が歩いて運ぶ 依頼の出方は、乱数の種ごとに台数によらず同じ(台数の違いだけを比べるため)。 出すもの(ルートごと・1日) 届くまでの時間(依頼が出てから受け取る人が取り出すまで)の平均・90%・最大、至急の平均、 運んだ回数(ロボットの便・人が歩いた回数)、看護補助者などが搬送に使った時間 (人が歩く往復と渡す時間・ロボットに積む時間・取り出す時間・立ち往生を解く時間)。 ロボットが走った時間と距離、山場(8〜11時の青)の届くまでの時間。 判定:届くまでの平均が judge_mean_min 分以上、または至急の平均が judge_urgent_min 分以上で「間に合わない」。 使い方 python3 hospital_sim.py # 標準の条件(0/1/2台)と、仮置きの値を動かした条件を回す python3 hospital_sim.py --out 出力先 # results.json と summary.txt を書く """ import argparse import json import math import random import statistics from pathlib import Path import simpy HERE = Path(__file__).resolve().parent def hm(s): h, m = s.split(":") return int(h) * 3600 + int(m) * 60 def load_inputs(layout=None): A = json.loads((HERE / "assumptions.json").read_text(encoding="utf-8")) L = json.loads((HERE / (layout or A["layout"])).read_text(encoding="utf-8")) return A, L def params(A, over=None): c = {k: v["value"] for k, v in A["common"].items()} r = {k: v["value"] for k, v in A["rules"].items()} p = {"c": c, "r": r, "req_mult": 1.0} if over: for k, v in over.items(): if k == "req_mult": p["req_mult"] = v elif k.startswith("rph_"): p.setdefault("rph", {})[k[4:]] = v elif k in c: c[k] = v else: r[k] = v return p def make_requests(A, p, seed): """依頼の出方。台数によらず同じになるよう、専用の乱数で作る""" rng = random.Random(f"req-{seed}") day0 = hm(A["rules"]["day"]["value"][0]) out = [] for key in sorted(A["routes"]): R = A["routes"][key] lam = p.get("rph", {}).get(key, R["requests_per_h"]["value"]) * p["req_mult"] / 3600.0 for a, b in R["windows"]: t, end = hm(a) - day0, hm(b) - day0 while True: t += rng.expovariate(lam) if t >= end: break out.append({"route": key, "t": t, "urgent": rng.random() < p["c"]["urgent_share"]}) out.sort(key=lambda q: q["t"]) for i, q in enumerate(out): q["id"] = i return out class Day: def __init__(self, A, L, p, n_robots, seed): self.A, self.L, self.p, self.n = A, L, p, n_robots self.c, self.r = p["c"], p["r"] self.env = simpy.Environment() self.rng = random.Random(f"run-{seed}-{n_robots}") self.day0 = hm(A["rules"]["day"]["value"][0]) self.day_end = hm(A["rules"]["day"]["value"][1]) - self.day0 # 積む所・届け先の点はフロアの形のルートに合わせる(h0b は青が裏口から出る) self.routes = {k: dict(R, from_point=L["routes"][k]["from"], to_point=L["routes"][k]["to"]) for k, R in A["routes"].items()} self.win = {k: [(hm(a) - self.day0, hm(b) - self.day0) for a, b in R["windows"]] for k, R in self.routes.items()} self.queue = {k: [] for k in self.routes} self.done = [] self.staff_s = {k: 0.0 for k in self.routes} # 人が搬送に使った秒 self.staff_walks = {k: 0 for k in self.routes} self.robot_trips = {k: 0 for k in self.routes} self.robot_run_s = 0.0 self.robot_m = 0.0 self.stuck_n = 0 self.idle_at = {} # ロボット番号 → 待っている出発地のルート(走っているときは None) self.reqs = make_requests(A, p, seed) # 青の道の決め方(h2):廊下だけ/裏口だけ/時間で切替(back_door_hours の間だけ裏口) self.plan = self.r.get("route_plan", "廊下だけ") if self.plan != "廊下だけ" and "内科外来の処置室(裏口)" not in str(self.L["distances"].keys()): self.L = json.loads((HERE / "../hospital_layouts/hospital_h0b.json").read_text(encoding="utf-8")) self.back_hours = [(hm(a) - self.day0, hm(b) - self.day0) for a, b in self.r.get("back_door_hours", [])] self.trip_log = [] # ロボットの便(Unity に渡す時刻表) def robot_from(self, k, t): """ロボットが積む所。青だけ、決め方により裏口になる。人が歩くときはいつも表(routes の from_point)""" if k == "blue" and (self.plan == "裏口だけ" or (self.plan == "時間で切替" and any(a <= t < b for a, b in self.back_hours))): return "内科外来の処置室(裏口)" return self.routes[k]["from_point"] # 距離 def dist(self, a, b): if a == b: return 0.0 return self.L["distances"][f"{a}|{b}"] * self.c["layout_scale"] def route_dist(self, k): R = self.routes[k] return self.dist(R["from_point"], R["to_point"]) def active(self, t): return [k for k, ws in self.win.items() if any(a <= t < b for a, b in ws)] def next_route(self, t): best = None for k, ws in self.win.items(): for a, b in ws: if a > t and (best is None or a < best[0]): best = (a, k) return best[1] if best else None # 依頼が出る def arrivals(self): for q in self.reqs: yield self.env.timeout(max(0.0, q["t"] - self.env.now)) k = q["route"] if self.n > 0 and q["urgent"] and k not in self.idle_at.values(): self.env.process(self.staff_walk([q], k)) # 決まり:空いているロボットがいなければ人が運ぶ else: self.queue[k].append(q) # 人が歩いて運ぶ def staff_walk(self, items, k): d = self.route_dist(k) v = self.c["staff_speed"] extra = self.c["staff_extra_s"] yield self.env.timeout(d / v + extra + self.c["staff_handoff_s"]) for q in items: self.finish(q, "staff") self.staff_s[k] += 2 * d / v + extra + self.c["staff_handoff_s"] self.staff_walks[k] += 1 def staff_route(self, k): """0台のとき:まとめるために staff_hold_min 分まで待ち、至急ならすぐ。時間帯が終わって残ればすぐ""" hold = self.c["staff_hold_min"] * 60 while True: yield self.env.timeout(5) Q = self.queue[k] if not Q: if self.env.now > self.day_end + 3600: return continue now = self.env.now if any(q["urgent"] for q in Q) or now - Q[0]["t"] >= hold or k not in self.active(now): items, self.queue[k] = Q[:], [] self.env.process(self.staff_walk(items, k)) # ロボット def leg(self, d): """片道。距離・速さ・一時停止・立ち往生""" s = d / self.c["robot_speed"] if d > 0 and self.rng.random() < self.c["pause_rate"]: s += self.rng.uniform(*self.c["pause_s"]) stuck = 0.0 if d > 0 and self.rng.random() < self.c["stuck_rate"]: stuck = self.c["stuck_resolve_s"] self.stuck_n += 1 self.robot_m += d self.robot_run_s += s + stuck return s + stuck, stuck def robot(self, i): cap = self.c["compartments"] hold = self.r["max_hold_min"] * 60 loc = None # 最初は1つ目のルートの出発地にいる first = self.next_route(-1) or sorted(self.routes)[0] loc = self.routes[first]["from_point"] while True: now = self.env.now act = self.active(now) waiting = [k for k in self.routes if self.queue[k]] if not act and not waiting and self.next_route(now) is None: self.idle_at[i] = None return # 行き先のルート:残った依頼(時間帯の外)→ いま運行中で古い依頼があるもの → いま運行中 → 次の時間帯 leftover = [k for k in waiting if k not in act] if leftover: k = min(leftover, key=lambda x: self.queue[x][0]["t"]) elif act: k = min(act, key=lambda x: self.queue[x][0]["t"] if self.queue[x] else math.inf) else: k = self.next_route(now) home = self.robot_from(k, now) if loc != home: self.idle_at[i] = None s, _ = self.leg(self.dist(loc, home)) yield self.env.timeout(s) loc = home continue Q = self.queue[k] go = bool(Q) and (len(Q) >= cap or any(q["urgent"] for q in Q) or now - Q[0]["t"] >= hold or k not in act) if not go: self.idle_at[i] = k yield self.env.timeout(5) continue self.idle_at[i] = None Q.sort(key=lambda q: (not q["urgent"], q["t"])) items, self.queue[k] = Q[:cap], Q[cap:] yield self.env.timeout(self.c["load_s"]) dest = self.routes[k]["to_point"] self.trip_log.append({"route": k, "depart_s": round(self.env.now, 1), "from": home, "to": dest, "items": len(items), "robot": i}) s, stuck = self.leg(self.dist(home, dest)) yield self.env.timeout(s) pw = self.rng.expovariate(1 / self.c["pickup_wait_s"]) if self.c["pickup_wait_s"] > 0 else 0.0 yield self.env.timeout(pw + self.c["unload_s"]) for q in items: self.finish(q, "robot") self.robot_trips[k] += 1 self.staff_s[k] += self.c["load_s"] + self.c["unload_s"] + stuck loc = dest def finish(self, q, by): q2 = dict(q) q2["done"] = self.env.now q2["by"] = by self.done.append(q2) def run(self): self.env.process(self.arrivals()) if self.n == 0: for k in self.routes: self.env.process(self.staff_route(k)) else: for i in range(self.n): self.env.process(self.robot(i)) self.env.run(until=self.day_end + 3 * 3600) return self.summary() def summary(self): pk0, pk1 = (hm(x) - self.day0 for x in self.r["peak"]) out = {"routes": {}, "robot_run_min": self.robot_run_s / 60, "robot_km": self.robot_m / 1000, "stuck": self.stuck_n, "not_done": len(self.reqs) - len(self.done)} for k in self.routes: D = [(q["done"] - q["t"]) / 60 for q in self.done if q["route"] == k] U = [(q["done"] - q["t"]) / 60 for q in self.done if q["route"] == k and q["urgent"]] P = [(q["done"] - q["t"]) / 60 for q in self.done if q["route"] == k and pk0 <= q["t"] < pk1] out["routes"][k] = { "n": len(D), "mean_min": statistics.mean(D) if D else None, "p90_min": sorted(D)[int(0.9 * (len(D) - 1))] if D else None, "max_min": max(D) if D else None, "urgent_n": len(U), "urgent_mean_min": statistics.mean(U) if U else None, "peak_mean_min": statistics.mean(P) if P else None, "robot_trips": self.robot_trips[k], "staff_walks": self.staff_walks[k], "staff_min": self.staff_s[k] / 60, } return out def run_condition(A, L, n, over=None, seeds=None): p = params(A, over) seeds = seeds or p["r"]["seeds"] runs = [Day(A, L, p, n, s).run() for s in range(seeds)] def avg(get): v = [get(x) for x in runs] v = [x for x in v if x is not None] return statistics.mean(v) if v else None res = {"robots": n, "over": over or {}, "seeds": seeds, "routes": {}, "robot_run_min": avg(lambda x: x["robot_run_min"]), "robot_km": avg(lambda x: x["robot_km"]), "stuck": avg(lambda x: x["stuck"]), "not_done": avg(lambda x: x["not_done"])} for k in A["routes"]: res["routes"][k] = {f: avg(lambda x, f=f: x["routes"][k][f]) for f in runs[0]["routes"][k]} res["staff_min"] = sum(res["routes"][k]["staff_min"] for k in A["routes"]) jm, ju = p["r"]["judge_mean_min"], p["r"]["judge_urgent_min"] res["late"] = {k: bool((r["mean_min"] or 0) >= jm or (r["urgent_mean_min"] or 0) >= ju) for k, r in res["routes"].items()} return res SWEEPS = [ # 仮置きの値を動かす(ロボット1台・2台で) ("pickup_wait_s", [0, 60, 180, 300]), ("req_mult", [0.5, 1.0, 1.5, 2.0]), ("robot_speed", [0.4, 0.5, 0.8]), ("max_hold_min", [5, 10, 20]), ("stuck_rate", [0.0, 0.01, 0.05]), ("layout_scale", [1.0, 1.5, 2.0, 2.5]), # 案2 の当たり:道を何倍にすると発表に届くか ("staff_extra_s", [0, 60, 120, 180, 240]), # 案3 の当たり:人が運ぶときの歩く以外の手間 ("route_plan", ["廊下だけ", "時間で切替", "裏口だけ"]), # h2:青の道の決め方 ] ZERO_TOO = ("req_mult", "layout_scale", "staff_extra_s") # 0台(比べるもと)も同じ値で回し直す def main(): ap = argparse.ArgumentParser() ap.add_argument("--out", default=None) ap.add_argument("--layout", default=None, help="フロアの形を差し替える(例 ../hospital_layouts/hospital_h0b.json)") args = ap.parse_args() A, L = load_inputs(args.layout) if args.layout: A["model"] = A["model"] + "_" + L["meta"].get("variant", "x") base = [run_condition(A, L, n) for n in A["rules"]["robots"]["value"]] zero = base[0] sweeps = [] for key, vals in SWEEPS: for v in vals: o = {key: v} z = run_condition(A, L, 0, o) if key in ZERO_TOO else zero for n in (1, 2): r = run_condition(A, L, n, o) r["saved_staff_min"] = z["staff_min"] - r["staff_min"] r["saved_by_route"] = {k: z["routes"][k]["staff_min"] - r["routes"][k]["staff_min"] for k in A["routes"]} sweeps.append(r) for r in base: r["saved_staff_min"] = zero["staff_min"] - r["staff_min"] r["saved_by_route"] = {k: zero["routes"][k]["staff_min"] - r["routes"][k]["staff_min"] for k in A["routes"]} result = {"model": A["model"], "layout": L["meta"]["name"], "base": base, "sweeps": sweeps} text = report(A, result) print(text) if args.out: out = Path(args.out) out.mkdir(parents=True, exist_ok=True) (out / f"results_{A['model']}.json").write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding="utf-8") (out / f"summary_{A['model']}.txt").write_text(text, encoding="utf-8") def f(x, d=1): return "-" if x is None else f"{x:.{d}f}" def report(A, R): names = {k: v["name"] for k, v in A["routes"].items()} L = [f"試算 {R['model']}({R['layout']})乱数の種 {R['base'][0]['seeds']} 個の平均", ""] L.append("■ 標準の条件") for r in R["base"]: L.append(f"ロボット{r['robots']}台 人が搬送に使った時間 {f(r['staff_min'])}分/日(0台より {f(r['saved_staff_min'])}分減)" + (f" ロボットが走った {f(r['robot_run_min'],0)}分・{f(r['robot_km'],2)}km" if r["robots"] else "")) for k in ["green", "blue", "red"]: x = r["routes"][k] L.append(f" {names[k]} 依頼{f(x['n'],0)}件 届くまで 平均{f(x['mean_min'])}分 90%{f(x['p90_min'])}分 最大{f(x['max_min'])}分" f" 至急{f(x['urgent_mean_min'])}分 山場{f(x['peak_mean_min'])}分" f" 便{f(x['robot_trips'])} 人が歩いた{f(x['staff_walks'])}回 人の時間{f(x['staff_min'])}分" + (" ← 間に合わない" if r["late"][k] else "")) L.append("") L.append("■ 仮置きの値を動かす(青のルートの届くまでの平均・至急・人の時間の減り)") for r in R["sweeps"]: (k, v), = r["over"].items() b = r["routes"]["blue"] sb = r.get("saved_by_route", {}) L.append(f"{k}={v} ロボット{r['robots']}台 減り 青{f(sb.get('blue'))}分 赤{f(sb.get('red'))}分(1便あたり 青{f(sb.get('blue',0)/max(r['routes']['blue']['robot_trips'] or 1,1e-9))}分 赤{f(sb.get('red',0)/max(r['routes']['red']['robot_trips'] or 1,1e-9))}分) 青 平均{f(b['mean_min'])}分 至急{f(b['urgent_mean_min'])}分 山場{f(b['peak_mean_min'])}分" f" 人の時間 {f(r['saved_staff_min'])}分減" + (" ← 間に合わない:" + "・".join(names[x] for x, y in r["late"].items() if y) if any(r["late"].values()) else "")) return "\n".join(L) if __name__ == "__main__": main()