using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using UnityEngine; namespace ServiceSim { /// /// trips.csv・conditions.json・summary.json を書く。 /// この script は実験だけを扱う。店・ロボット・人は動かさない。 /// 1往復ぶんの値は ExperimentRunner が Row に詰めて渡す。 /// public class TripLogger { /// trips.csv の1行 public struct Row { public string conditionId; public float aisleWidthM; public string crowd; public int robots; public int seed; public int robotId; public int tripId; public int tableId; public float distanceM; public float totalS; public float stoppedS; public int nStops; public float[] stopDurationsS; public bool stuck; public bool timeout; public float waitAisleS; public float maxDecelMps2; public float minClearanceM; public bool metRobot; // v2:1行=ロボットの1走り public int[] tableIds; // 回った卓 public int dishes; // 載せた料理の数 public float[] dishReadyS; // 各料理ができた時刻 public float[] dishArrivedS; // 各料理が届いた時刻(-1 は届かなかった) public int undelivered; // 届けられず受け渡し口に戻した数 // ---- 往復ごとの測り(v5 3章・設計書2章の4つ)---- /// その往復で実際に走れた速さ(m/秒)= distance_m /(total_s − stopped_s) public float speedMps; /// 動けなくなってスタッフを呼んだ回数と、スタッフを待った秒数 public int stuckCount; public float stuckWaitS; /// その往復でサイトの道順が 0.8 m の道を使ったか(routes の use が narrow) public bool viaNarrow; /// 止まりの回数と秒数を、狭い通路の中と外に分けたもの(⑦の読み分け用) public int stopsInNarrow, stopsOutNarrow; public float stoppedInNarrowS, stoppedOutNarrowS; } /// groups.csv の1行(1組ぶん) public struct GroupRow { public string conditionId; public int seed; public int groupId; public int size; public int tableId; /// 座った卓の大きさ(2 なら2人席、4 なら4人席。0 は座れなかった) public int tableSeats; public float arriveS, guideStartS, seatedS, eatEndS, payS, leftS; /// 会計を始めた時刻(食べ終わって会計待ちに入った時刻, 秒) public float payStartS; public float waitedMin; public bool warmup; public string state; } /// stops.csv の1行(止まり1回ぶん) public struct StopRow { public string conditionId; public int seed; public int robotId; public int tripId; public float tS; // 店の中の時刻 public float x, z; public string aisle; // 通路の名前 /// /// サイトの決まりで「狭い通路」(幅が meta.settings.normal_width 未満)の中で /// 止まったか(v5 3章。⑦の読み分け。③は 1.2 m なのでいつも 0) /// public bool inNarrow; public string cause; public string causeDetail; public bool pathBlocked; // 行き先への道が引けていなかったか public float durationS; public string resolved; public float clearanceLeftCm; public float clearanceRightCm; public float minGapCm; // 止まっている間、何で動けなかったかの内訳(秒)。B7 を調べるため public float blockedS; public float noPathS; public float yieldS; public float clearWaitS; public int replans; // 道を引いたときの中身(調べるためだけ。動きは変えない) /// 寄せたあとの出発点・行き先("x;z") public string solveFrom, solveTo; /// CalculatePath の結果と、引けた角の数 public string solveStatus; public int solveCorners; /// ロボットの位置から、いちばん近い焼いた面までの距離(m)。-1 は見つからない public float offMeshM; } /// /// 止まった原因の名前。観察シート(serving-robot-observation.html)の /// 「止まった原因」の選択肢にそろえる。Unity にしかない原因は足した。 /// public static string CauseLabel(ServingRobotController.StopCause cause) { switch (cause) { case ServingRobotController.StopCause.Guest: return "お客さんとすれ違った"; case ServingRobotController.StopCause.Staff: return "スタッフとすれ違った"; case ServingRobotController.StopCause.PersonStanding: return "人が立ち止まっていた"; case ServingRobotController.StopCause.Chair: return "椅子・荷物・ベビーカー"; case ServingRobotController.StopCause.NoPath: return "通路の幅が足りない"; case ServingRobotController.StopCause.OtherRobot: return "もう1台のロボット"; case ServingRobotController.StopCause.AisleQueue: return "通路の順番待ち"; default: return "分からない"; } } /// どう動き出したかの名前 public static string ResolveLabel(ServingRobotController.StopResolve r) { switch (r) { case ServingRobotController.StopResolve.SelfResume: return "自分で動き出した"; case ServingRobotController.StopResolve.Replanned: return "経路を引き直した"; case ServingRobotController.StopResolve.Stuck: return "動けなくなった"; default: return "入口で待った"; } } readonly List _rows = new List(); readonly List _stops = new List(); public int StopCount { get { return _stops.Count; } } /// /// 一時停止の回数の合計(pauses)。 /// 往復ごとの n_stops(自分で動き出した止まり)を足す。 /// 通路の入口で待った分と、動けなくなった分は入らない /// public int TotalStopCount() { int n = 0; for (int i = 0; i < _rows.Count; i++) n += _rows[i].nStops; return n; } public IReadOnlyList Stops { get { return _stops; } } public void AddStop(StopRow row) { _stops.Add(row); } public int RowCount { get { return _rows.Count; } } public IReadOnlyList Rows { get { return _rows; } } public void Add(Row row) { _rows.Add(row); } public void Clear() { _rows.Clear(); _stops.Clear(); _groups.Clear(); } // ---------------- trips.csv ---------------- public const string Header = "condition_id,aisle_width_m,crowd,robots,seed,robot_id,trip_id,table_id," + "distance_m,total_s,stopped_s,n_stops,stop_durations_s,stuck,timeout,wait_aisle_s," + "max_decel_mps2,min_clearance_m,met_robot," + "table_ids,dishes,dish_ready_s,dish_arrived_s,undelivered," + // v5 3章:往復ごとの測り(設計書2章の4つ)。⑦は狭い通路の中と外を分ける "speed_mps,stuck_count,stuck_wait_s,via_narrow," + "stops_in_narrow,stops_out_narrow,stopped_in_narrow_s,stopped_out_narrow_s"; public void WriteTripsCsv(string path) { StringBuilder sb = new StringBuilder(); sb.Append(Header).Append('\n'); for (int i = 0; i < _rows.Count; i++) AppendRow(sb, _rows[i]); File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } static void AppendRow(StringBuilder sb, Row r) { sb.Append(r.conditionId).Append(',') .Append(F(r.aisleWidthM)).Append(',') .Append(r.crowd).Append(',') .Append(r.robots).Append(',') .Append(r.seed).Append(',') .Append(r.robotId).Append(',') .Append(r.tripId).Append(',') .Append(r.tableId).Append(',') .Append(F(r.distanceM)).Append(',') .Append(F(r.totalS)).Append(',') .Append(F(r.stoppedS)).Append(',') .Append(r.nStops).Append(',') .Append(Join(r.stopDurationsS)).Append(',') .Append(r.stuck ? 1 : 0).Append(',') .Append(r.timeout ? 1 : 0).Append(',') .Append(F(r.waitAisleS)).Append(',') .Append(F(r.maxDecelMps2)).Append(',') .Append(F(r.minClearanceM)).Append(',') .Append(r.metRobot ? 1 : 0).Append(',') .Append(JoinInt(r.tableIds)).Append(',') .Append(r.dishes).Append(',') .Append(Join(r.dishReadyS)).Append(',') .Append(Join(r.dishArrivedS)).Append(',') .Append(r.undelivered).Append(',') .Append(F(r.speedMps)).Append(',') .Append(r.stuckCount).Append(',') .Append(F(r.stuckWaitS)).Append(',') .Append(r.viaNarrow ? 1 : 0).Append(',') .Append(r.stopsInNarrow).Append(',') .Append(r.stopsOutNarrow).Append(',') .Append(F(r.stoppedInNarrowS)).Append(',') .Append(F(r.stoppedOutNarrowS)) .Append('\n'); } static string Join(float[] values) { if (values == null || values.Length == 0) return ""; StringBuilder sb = new StringBuilder(); for (int i = 0; i < values.Length; i++) { if (i > 0) sb.Append(';'); sb.Append(F(values[i])); } return sb.ToString(); } static string JoinInt(int[] values) { if (values == null || values.Length == 0) return ""; StringBuilder sb = new StringBuilder(); for (int i = 0; i < values.Length; i++) { if (i > 0) sb.Append(';'); sb.Append(values[i]); } return sb.ToString(); } static string F(float v) { return v.ToString("0.####", CultureInfo.InvariantCulture); } // ---------------- groups.csv ---------------- readonly List _groups = new List(); public int GroupRowCount { get { return _groups.Count; } } public void AddGroup(GroupRow r) { _groups.Add(r); } /// table_seats は座った卓の大きさ(2=2人席・4=4人席・0=座れなかった) public const string GroupHeader = "condition_id,seed,group_id,size,table_id,table_seats,arrive_s,guide_start_s,seated_s," + "eat_end_s,pay_start_s,pay_s,left_s,waited_min,warmup,state"; public void WriteGroupsCsv(string path) { StringBuilder sb = new StringBuilder(); sb.Append(GroupHeader).Append('\n'); for (int i = 0; i < _groups.Count; i++) { GroupRow r = _groups[i]; sb.Append(r.conditionId).Append(',').Append(r.seed).Append(',') .Append(r.groupId).Append(',').Append(r.size).Append(',').Append(r.tableId).Append(',') .Append(r.tableSeats).Append(',') .Append(F(r.arriveS)).Append(',').Append(F(r.guideStartS)).Append(',') .Append(F(r.seatedS)).Append(',').Append(F(r.eatEndS)).Append(',') .Append(F(r.payStartS)).Append(',') .Append(F(r.payS)).Append(',').Append(F(r.leftS)).Append(',') .Append(F(r.waitedMin)).Append(',').Append(r.warmup ? 1 : 0).Append(',') .Append(r.state).Append('\n'); } File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } // ---------------- people_jams.csv ---------------- public const string JamHeader = "condition_id,seed,t_s,x,z,aisle,people,duration_s,resolved,robot_blocked,robot_id"; public static void WriteJamsCsv(string path, string conditionId, int seed, IReadOnlyList rows) { StringBuilder sb = new StringBuilder(); sb.Append(JamHeader).Append('\n'); if (rows != null) { for (int i = 0; i < rows.Count; i++) { JamWatcher.JamRow r = rows[i]; sb.Append(conditionId).Append(',').Append(seed).Append(',') .Append(F(r.tS)).Append(',').Append(F(r.x)).Append(',').Append(F(r.z)).Append(',') .Append(r.aisle).Append(',').Append(r.people).Append(',') .Append(F(r.durationS)).Append(',').Append(r.resolved).Append(',') .Append(r.robotBlocked ? 1 : 0).Append(',').Append(r.robotId).Append('\n'); } } File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } // ---------------- give_ups.csv(B1)---------------- public const string GiveUpHeader = "condition_id,seed,t_s,who,job,table_id,to_x,to_z,x,z,aisle,waited_s,action"; /// あきらめた・移した・卓を空けたことを1回1行で書く public static void WriteGiveUpsCsv(string path, string conditionId, int seed, IReadOnlyList rows) { StringBuilder sb = new StringBuilder(); sb.Append(GiveUpHeader).Append('\n'); if (rows != null) { for (int i = 0; i < rows.Count; i++) { GiveUpRow r = rows[i]; sb.Append(conditionId).Append(',').Append(seed).Append(',') .Append(F(r.tS)).Append(',').Append(r.who).Append(',').Append(r.job).Append(',') .Append(r.tableId).Append(',') .Append(F(r.toX)).Append(',').Append(F(r.toZ)).Append(',') .Append(F(r.x)).Append(',').Append(F(r.z)).Append(',') .Append(r.aisle).Append(',').Append(F(r.waitedS)).Append(',') .Append(r.action).Append('\n'); } } File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } // ---------------- contacts.csv(人がロボットに近づきすぎた)---------------- public const string ContactHeader = "condition_id,seed,t_s,x,z,aisle,place,who,careless,robot_id,gap_cm"; /// 人がロボットの外周の近くまで来た1回を1行で書く public static void WriteContactsCsv(string path, string conditionId, int seed, IReadOnlyList rows) { StringBuilder sb = new StringBuilder(); sb.Append(ContactHeader).Append('\n'); if (rows != null) { for (int i = 0; i < rows.Count; i++) { ContactRow r = rows[i]; sb.Append(conditionId).Append(',').Append(seed).Append(',') .Append(F(r.tS)).Append(',').Append(F(r.x)).Append(',').Append(F(r.z)).Append(',') .Append(r.aisle).Append(',').Append(r.place).Append(',') .Append(r.who).Append(',') .Append(r.careless ? 1 : 0).Append(',').Append(r.robotId).Append(',') .Append(F(r.gapM * 100f)).Append('\n'); } } File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } // ---------------- dishes.csv(B8)---------------- public const string RequeueHeader = "condition_id,seed,t_s,dish_id,table_id,robot_id,fragile,ready_s,waited_s,requeued_so_far,why"; /// 届けられずに受け渡し口へ戻した1回を1行で書く public static void WriteRequeuesCsv(string path, string conditionId, int seed, IReadOnlyList rows) { StringBuilder sb = new StringBuilder(); sb.Append(RequeueHeader).Append('\n'); if (rows != null) { for (int i = 0; i < rows.Count; i++) { RequeueRow r = rows[i]; sb.Append(conditionId).Append(',').Append(seed).Append(',') .Append(F(r.tS)).Append(',').Append(r.dishId).Append(',').Append(r.tableId).Append(',') .Append(r.robotId).Append(',').Append(r.fragile ? 1 : 0).Append(',') .Append(F(r.readyS)).Append(',').Append(F(r.tS - r.readyS)).Append(',') .Append(r.requeuedSoFar).Append(',').Append(r.why).Append('\n'); } } File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } // ---------------- stops.csv ---------------- public const string StopHeader = "condition_id,seed,robot_id,trip_id,t_s,x,z,aisle,in_narrow,cause,cause_detail,path_blocked," + "duration_s,resolved,clearance_left_cm,clearance_right_cm,min_gap_cm," + "blocked_s,no_path_s,yield_s,clear_wait_s,replans," + // 道が引けない原因を追うための列(記録だけ。動きは変えない) "solve_from,solve_to,solve_status,solve_corners,off_mesh_m"; public void WriteStopsCsv(string path) { StringBuilder sb = new StringBuilder(); sb.Append(StopHeader).Append('\n'); for (int i = 0; i < _stops.Count; i++) { StopRow r = _stops[i]; sb.Append(r.conditionId).Append(',') .Append(r.seed).Append(',') .Append(r.robotId).Append(',') .Append(r.tripId).Append(',') .Append(F(r.tS)).Append(',') .Append(F(r.x)).Append(',') .Append(F(r.z)).Append(',') .Append(r.aisle).Append(',') .Append(r.inNarrow ? 1 : 0).Append(',') .Append(r.cause).Append(',') .Append(r.causeDetail).Append(',') .Append(r.pathBlocked ? 1 : 0).Append(',') .Append(F(r.durationS)).Append(',') .Append(r.resolved).Append(',') .Append(F(r.clearanceLeftCm)).Append(',') .Append(F(r.clearanceRightCm)).Append(',') .Append(F(r.minGapCm)).Append(',') .Append(F(r.blockedS)).Append(',') .Append(F(r.noPathS)).Append(',') .Append(F(r.yieldS)).Append(',') .Append(F(r.clearWaitS)).Append(',') .Append(r.replans).Append(',') .Append(r.solveFrom).Append(',') .Append(r.solveTo).Append(',') .Append(r.solveStatus).Append(',') .Append(r.solveCorners).Append(',') .Append(F(r.offMeshM)) .Append('\n'); } File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } // ---------------- run_info.json(1回ぶんの回し方とコマの粗さ)---------------- /// 1回ぶんの回し方と、コマの粗さの記録 public class RunInfo { public string runLabel = ""; public string conditionId = ""; public string timeMode = ""; public string storeKind = ""; public string crowd = ""; public int tripsRequested; public int tripsDone; public int robots; public int seed; public float aisleWidthM; /// 卓の停止点を通路の中心からずらす距離(m)。.asset に入れた値(C1) public float deliveryOffsetM; /// そのうち、実際に使った分(通路が狭いときは NavMesh に乗る範囲に抑える) public float deliveryOffsetUsedM; public float simSeconds; public float wallSeconds; public float timeScale; public float captureDeltaTime; public bool stoppedEarly; /// 1コマの時間(秒)。Time.deltaTime をそのまま public readonly List frameDeltas = new List(); /// 1コマでロボットが進んだ距離(m)。台のうちいちばん大きいもの public readonly List robotSteps = new List(); /// /// 1コマで人が進んだ距離(m)。通路を歩く人のうちいちばん大きいもの(B10)。 /// スタッフは 1.0 m/秒 なので、1コマ 0.05 秒だと 0.05 m でちょうど境目になる。 /// public readonly List peopleSteps = new List(); /// 椅子を引いた回数(B6) public int chairPulls; /// 椅子を引いたきっかけごとの回数(B6) public readonly List> chairPullsByReason = new List>(); } /// コマの粗さを1行にまとめる(Console 用) public static string FrameStatsText(RunInfo info) { if (info == null || info.frameDeltas.Count == 0) return "コマの記録なし"; int over = 0; for (int i = 0; i < info.robotSteps.Count; i++) if (info.robotSteps[i] > 0.05f) over++; return "コマ " + info.frameDeltas.Count + " / 1コマの時間 中央 " + F(Percentile(info.frameDeltas, 0.5f)) + "s・90% " + F(Percentile(info.frameDeltas, 0.9f)) + "s・最大 " + F(Percentile(info.frameDeltas, 1f)) + "s" + " / 1コマで進む距離 最大 " + F(Percentile(info.robotSteps, 1f)) + "m・90% " + F(Percentile(info.robotSteps, 0.9f)) + "m" + " / 0.05 m を超えたコマ ロボット " + over + "(" + (info.robotSteps.Count > 0 ? (100f * over / info.robotSteps.Count).ToString("0.0") : "0") + "%)・人 " + CountOver(info.peopleSteps, 0.05f) + "(" + (info.peopleSteps.Count > 0 ? (100f * CountOver(info.peopleSteps, 0.05f) / info.peopleSteps.Count).ToString("0.0") : "0") + "%)"; } public static void WriteRunInfoJson(string path, RunInfo info) { StringBuilder sb = new StringBuilder(); sb.Append("{\n"); sb.Append(" \"run_label\": ").Append(Str(info.runLabel)).Append(",\n"); sb.Append(" \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n"); sb.Append(" \"condition_id\": ").Append(Str(info.conditionId)).Append(",\n"); sb.Append(" \"store_kind\": ").Append(Str(info.storeKind)).Append(",\n"); sb.Append(" \"time_mode\": ").Append(Str(info.timeMode)).Append(",\n"); sb.Append(" \"time_scale\": ").Append(F(info.timeScale)).Append(",\n"); sb.Append(" \"capture_delta_time\": ").Append(F(info.captureDeltaTime)).Append(",\n"); sb.Append(" \"aisle_width_m\": ").Append(F(info.aisleWidthM)).Append(",\n"); sb.Append(" \"delivery_offset_m\": ").Append(F(info.deliveryOffsetM)).Append(",\n"); sb.Append(" \"delivery_offset_used_m\": ").Append(F(info.deliveryOffsetUsedM)).Append(",\n"); sb.Append(" \"crowd\": ").Append(Str(info.crowd)).Append(",\n"); sb.Append(" \"robots\": ").Append(info.robots).Append(",\n"); sb.Append(" \"seed\": ").Append(info.seed).Append(",\n"); sb.Append(" \"trips_requested\": ").Append(info.tripsRequested).Append(",\n"); sb.Append(" \"trips_done\": ").Append(info.tripsDone).Append(",\n"); sb.Append(" \"stopped_early\": ").Append(info.stoppedEarly ? "true" : "false").Append(",\n"); sb.Append(" \"sim_seconds\": ").Append(F(info.simSeconds)).Append(",\n"); sb.Append(" \"wall_seconds\": ").Append(F(info.wallSeconds)).Append(",\n"); sb.Append(" \"speedup\": ").Append(F(info.wallSeconds > 0.01f ? info.simSeconds / info.wallSeconds : 0f)).Append(",\n"); sb.Append(" \"frame_delta_s\": "); AppendStats(sb, info.frameDeltas); sb.Append(",\n"); sb.Append(" \"robot_step_m\": "); AppendStats(sb, info.robotSteps); sb.Append(",\n"); sb.Append(" \"people_step_m\": "); AppendStats(sb, info.peopleSteps); sb.Append(",\n"); // すり抜けの確かめ(検査3)。ロボットと人を分けて出す int over = CountOver(info.robotSteps, 0.05f); int overPeople = CountOver(info.peopleSteps, 0.05f); sb.Append(" \"frames_over_005m\": ").Append(over).Append(",\n"); sb.Append(" \"frames_over_005m_ratio\": ") .Append(F(info.robotSteps.Count > 0 ? (float)over / info.robotSteps.Count : 0f)).Append(",\n"); sb.Append(" \"people_frames_over_005m\": ").Append(overPeople).Append(",\n"); sb.Append(" \"people_frames_over_005m_ratio\": ") .Append(F(info.peopleSteps.Count > 0 ? (float)overPeople / info.peopleSteps.Count : 0f)).Append(",\n"); // 椅子を引いた回数(B6) sb.Append(" \"chair_pulls\": ").Append(info.chairPulls).Append(",\n"); sb.Append(" \"chair_pulls_by_reason\": {"); for (int i = 0; i < info.chairPullsByReason.Count; i++) { if (i > 0) sb.Append(", "); sb.Append(Str(info.chairPullsByReason[i].Key)).Append(": ") .Append(info.chairPullsByReason[i].Value); } sb.Append("}\n"); sb.Append("}\n"); File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } /// しきい値を超えたコマの数 static int CountOver(List values, float limit) { if (values == null) return 0; int n = 0; for (int i = 0; i < values.Count; i++) if (values[i] > limit) n++; return n; } static void AppendStats(StringBuilder sb, List values) { if (values == null || values.Count == 0) { sb.Append("null"); return; } float sum = 0f; for (int i = 0; i < values.Count; i++) sum += values[i]; sb.Append("{\"count\": ").Append(values.Count); sb.Append(", \"mean\": ").Append(F(sum / values.Count)); sb.Append(", \"min\": ").Append(F(Percentile(values, 0f))); sb.Append(", \"p50\": ").Append(F(Percentile(values, 0.5f))); sb.Append(", \"p90\": ").Append(F(Percentile(values, 0.9f))); sb.Append(", \"p99\": ").Append(F(Percentile(values, 0.99f))); sb.Append(", \"max\": ").Append(F(Percentile(values, 1f))); sb.Append("}"); } // ---------------- conditions.json ---------------- /// /// 18条件の中身と、設定ファイル(.asset)の値すべてを書く。 /// 値は実行したときのものを JsonUtility でそのまま出す。 /// public static void WriteConditionsJson(string path, string runName, StoreLayout layout, RobotSpec robotSpec, PeopleSpec peopleSpec, ExperimentPlan plan, List conditions) { StringBuilder sb = new StringBuilder(); sb.Append("{\n"); sb.Append(" \"run_name\": ").Append(Str(runName)).Append(",\n"); sb.Append(" \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n"); sb.Append(" \"unity_version\": ").Append(Str(Application.unityVersion)).Append(",\n"); sb.Append(" \"trips_csv_columns\": ").Append(Str(Header)).Append(",\n"); sb.Append(" \"settings\": {\n"); sb.Append(" \"store_layout\": ").Append(Dump(layout)).Append(",\n"); sb.Append(" \"robot_spec\": ").Append(Dump(robotSpec)).Append(",\n"); sb.Append(" \"people_spec\": ").Append(Dump(peopleSpec)).Append(",\n"); sb.Append(" \"experiment_plan\": ").Append(Dump(plan)).Append("\n"); sb.Append(" },\n"); // 組み立てた形から出る値(conditions.json だけ見れば店の大きさが分かるように) sb.Append(" \"derived\": {\n"); if (layout != null) { StorePlan sp = layout.NewPlan(); sb.Append(" \"store_kind\": ").Append(Str(layout.StoreKindName)).Append(",\n"); sb.Append(" \"aisle_width_m\": ").Append(F(layout.AisleWidth)).Append(",\n"); if (sp != null) { sb.Append(" \"floor_min_x\": ").Append(F(sp.floor.xMin)).Append(",\n"); sb.Append(" \"floor_max_x\": ").Append(F(sp.floor.xMax)).Append(",\n"); sb.Append(" \"floor_min_z\": ").Append(F(sp.floor.zMin)).Append(",\n"); sb.Append(" \"floor_max_z\": ").Append(F(sp.floor.zMax)).Append(",\n"); sb.Append(" \"outer_size_x_m\": ").Append(F(sp.floor.SizeX)).Append(",\n"); sb.Append(" \"outer_size_z_m\": ").Append(F(sp.floor.SizeZ)).Append(",\n"); sb.Append(" \"table_count\": ").Append(sp.seats.Count).Append(",\n"); sb.Append(" \"aisle_count\": ").Append(sp.aisles.Count).Append(",\n"); sb.Append(" \"narrow_aisle_count\": ").Append(sp.NarrowAisleCount).Append("\n"); } else { sb.Append(" \"plan\": null\n"); } } sb.Append(" },\n"); sb.Append(" \"conditions\": [\n"); if (conditions != null) { for (int i = 0; i < conditions.Count; i++) { ExperimentPlan.Condition c = conditions[i]; sb.Append(" {"); sb.Append("\"condition_id\": ").Append(Str(c.id)).Append(", "); sb.Append("\"aisle_width_m\": ").Append(F(c.aisleWidthM)).Append(", "); sb.Append("\"aisle_width_index\": ").Append(c.aisleWidthIndex).Append(", "); sb.Append("\"crowd\": ").Append(Str(ExperimentPlan.CrowdName(c.crowd))).Append(", "); sb.Append("\"walkers\": ").Append(peopleSpec != null ? peopleSpec.WalkerCount(c.crowd) : 0).Append(", "); sb.Append("\"robots\": ").Append(c.robots).Append(", "); sb.Append("\"trips\": ").Append(plan != null ? plan.tripsPerCondition : 0).Append(", "); sb.Append("\"seeds\": ").Append(IntArray(plan != null ? plan.seeds : null)).Append(", "); sb.Append("\"aisle_yielding\": ").Append(robotSpec != null && robotSpec.aisleYielding ? "true" : "false"); sb.Append('}'); if (i < conditions.Count - 1) sb.Append(','); sb.Append('\n'); } } sb.Append(" ]\n"); sb.Append("}\n"); File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } static string Dump(ScriptableObject asset) { if (asset == null) return "null"; return JsonUtility.ToJson(asset, false); } static string IntArray(int[] values) { if (values == null || values.Length == 0) return "[]"; StringBuilder sb = new StringBuilder("["); for (int i = 0; i < values.Length; i++) { if (i > 0) sb.Append(", "); sb.Append(values[i]); } return sb.Append(']').ToString(); } static string Str(string s) { if (s == null) return "null"; StringBuilder sb = new StringBuilder("\""); for (int i = 0; i < s.Length; i++) { char c = s[i]; if (c == '"' || c == '\\') sb.Append('\\').Append(c); else if (c == '\n') sb.Append("\\n"); else if (c == '\r') sb.Append("\\r"); else if (c == '\t') sb.Append("\\t"); else sb.Append(c); } return sb.Append('"').ToString(); } // ---------------- summary.json ---------------- /// 条件ごとにまとめる /// 試算(haizen_sim.py)と同じ意味の数字。名前も同じにする public class SimLike { public List dishWaitMin = new List(); public List seatWaitMin = new List(); public List turnMin = new List(); public float coldMin = 3f; public int robotDishes, staffDishes, requeuedDishes, groups; /// あきらめた・移した・卓を空けた回数(B1) public int gaveUp, warped, tableFreed; /// 人どうしの詰まりのうち、近くでロボットが止まっていたもの(B9) public int robotBlockedJams; /// 人がロボットに近づきすぎた回数・待った回数と秒数・回り道した回数 public int robotContacts, robotYieldWaits, robotYieldDetours; public float robotYieldWaitSeconds; /// レジの前にいた人数(いちばん多いとき・平均) public int registerCrowdMax; public float registerCrowdMean; /// 会計の回数と、そのうちスタッフが店員側にいなかった回数(0 になるはず) public int registerPayCount, registerPayNotAtBackCount; /// 立つ点から離れて会計した回数(0 になるはず)と、その距離 public int registerPayOffSpotCount; public float registerPayMeanOffsetM, registerPayMaxOffsetM; /// 会計でないスタッフが店員側にいた回数と秒数(どちらも 0 になるはず) public int registerBackIntruderCount; public float registerBackIntruderSeconds; /// スタッフの時間の使い方(仕事の名前 → 秒) public readonly List> staffTime = new List>(); /// 仕事が板に出てから取られるまでの秒数の平均(仕事の名前 → 秒) public readonly List> jobWaitMean = new List>(); /// 届いた料理の、届くまでの内訳(ロボット・スタッフ別に分ける) public readonly List dishParts = new List(); /// 椅子を引いた回数(B6) public int chairPulls; /// ドリンクバー:並んだ回数・あきらめた回数・並んで待った分(B4) public int drinkBarQueued, drinkBarGaveUp; public readonly List drinkBarWaitMin = new List(); public int jamCount; public float jamTotalS; public float walkersMean; public float measuredMinutes; // ---- サイトと同じ名前で出す値(v5 3章・meta.metrics)---- /// 入店待ちの内訳のもと(分)。分け方はサイトの試算と同じ public readonly List entryWaitMin = new List(); public readonly List fullWaitMin = new List(); public readonly List blockedWaitMin = new List(); public readonly List guideWaitMin = new List(); public readonly List staffWaitMin = new List(); /// walk_km_per_staff・staff_util・robot_util public float walkKmPerStaff, staffUtil, robotUtil; /// rescues:立ち往生でスタッフを呼んだ回数(着いて動かした回数も残す) public int rescues, rescuesDone; public float rescueWaitS; /// pauses:一時停止の回数(自分で動き出した止まりの合計) public int pauses; /// サイトの条件で回したか(v5 3章)と、その条件の名前 public bool siteRun; public string siteConditionId = ""; /// /// サイトの試算の答え(unity_conditions の site_result)。 /// 同じ summary.json に入れておけば、並べた表をあとから手で写さずに作れる /// public readonly List> siteResult = new List>(); /// 来客を受け付けた分と、全員が帰るまでの分(打ち切りなら everyoneLeft が false) public float arrivalsMinutes, totalMinutes; public bool everyoneLeft; } SimLike _sim; public void SetSimLike(SimLike s) { _sim = s; } public void WriteSummaryJson(string path, string runName) { // 条件ごとに行を分ける(出てきた順を保つ) List order = new List(); Dictionary> byCondition = new Dictionary>(); for (int i = 0; i < _rows.Count; i++) { string id = _rows[i].conditionId; if (!byCondition.ContainsKey(id)) { byCondition[id] = new List(); order.Add(id); } byCondition[id].Add(_rows[i]); } StringBuilder sb = new StringBuilder(); sb.Append("{\n"); sb.Append(" \"run_name\": ").Append(Str(runName)).Append(",\n"); sb.Append(" \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n"); sb.Append(" \"note\": ").Append(Str( "speed_mps = distance_m / (total_s - stopped_s)。" + "stopped_s には、動けなくなった60秒と、スタッフを待った秒数(stuck_wait_s)、" + "通路の入口で待った秒数も入る。" + "pause_s は stop_durations_s(自分で動き出した止まりだけ)の分布。" + "site_metrics はサイトの meta.metrics と同じ名前・同じ定義。" + "入店待ちの分け方もサイトの試算と同じで、" + "blocked_wait は片付け待ちの卓の数だけ待っている列の前から順に足した分、" + "full_wait は seat_wait − blocked_wait、" + "staff_wait は blocked_wait + guide_wait。" + "pauses と rescues は、サイト側が unity_measures の仮の値から出した数なので出どころが違う。")) .Append(",\n"); if (_sim != null) { sb.Append(" \"sim_like\": {"); sb.Append("\"dish_wait_mean\": ").Append(F(Mean(_sim.dishWaitMin))).Append(", "); sb.Append("\"dish_wait_p90\": ").Append(F(Percentile(_sim.dishWaitMin, 0.9f))).Append(", "); sb.Append("\"cold_share\": ").Append(F(Share(_sim.dishWaitMin, _sim.coldMin))).Append(", "); sb.Append("\"seat_wait_mean\": ").Append(F(Mean(_sim.seatWaitMin))).Append(", "); sb.Append("\"turn_mean\": ").Append(F(Mean(_sim.turnMin))).Append(", "); sb.Append("\"robot_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0 ? (float)_sim.robotDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", "); sb.Append("\"robot_dishes\": ").Append(_sim.robotDishes).Append(", "); sb.Append("\"staff_dishes\": ").Append(_sim.staffDishes).Append(", "); sb.Append("\"requeued_dishes\": ").Append(_sim.requeuedDishes).Append(", "); sb.Append("\"groups\": ").Append(_sim.groups).Append(", "); sb.Append("\"people_jams\": ").Append(_sim.jamCount).Append(", "); sb.Append("\"robot_blocked_jams\": ").Append(_sim.robotBlockedJams).Append(", "); sb.Append("\"robot_blocked_jam_share\": ").Append(F(_sim.jamCount > 0 ? (float)_sim.robotBlockedJams / _sim.jamCount : 0f)).Append(", "); sb.Append("\"chair_pulls\": ").Append(_sim.chairPulls).Append(", "); sb.Append("\"robot_contacts\": ").Append(_sim.robotContacts).Append(", "); sb.Append("\"robot_yield_waits\": ").Append(_sim.robotYieldWaits).Append(", "); sb.Append("\"robot_yield_wait_s\": ").Append(F(_sim.robotYieldWaitSeconds)).Append(", "); sb.Append("\"robot_yield_detours\": ").Append(_sim.robotYieldDetours).Append(", "); sb.Append("\"register_crowd_max\": ").Append(_sim.registerCrowdMax).Append(", "); sb.Append("\"register_crowd_mean\": ").Append(F(_sim.registerCrowdMean)).Append(", "); sb.Append("\"register_pay_count\": ").Append(_sim.registerPayCount).Append(", "); sb.Append("\"register_pay_not_at_back\": ").Append(_sim.registerPayNotAtBackCount).Append(", "); sb.Append("\"register_pay_off_spot\": ").Append(_sim.registerPayOffSpotCount).Append(", "); sb.Append("\"register_pay_offset_mean_m\": ").Append(F(_sim.registerPayMeanOffsetM)).Append(", "); sb.Append("\"register_pay_offset_max_m\": ").Append(F(_sim.registerPayMaxOffsetM)).Append(", "); sb.Append("\"register_back_intruders\": ").Append(_sim.registerBackIntruderCount).Append(", "); sb.Append("\"register_back_intruder_s\": ").Append(F(_sim.registerBackIntruderSeconds)).Append(", "); sb.Append("\"drink_bar_queued\": ").Append(_sim.drinkBarQueued).Append(", "); sb.Append("\"drink_bar_gave_up\": ").Append(_sim.drinkBarGaveUp).Append(", "); sb.Append("\"drink_bar_wait_mean\": ").Append(F(Mean(_sim.drinkBarWaitMin))).Append(", "); sb.Append("\"drink_bar_wait_p90\": ").Append(F(Percentile(_sim.drinkBarWaitMin, 0.9f))).Append(", "); sb.Append("\"gave_up\": ").Append(_sim.gaveUp).Append(", "); sb.Append("\"warped\": ").Append(_sim.warped).Append(", "); sb.Append("\"table_freed\": ").Append(_sim.tableFreed).Append(", "); sb.Append("\"people_jam_total_s\": ").Append(F(_sim.jamTotalS)).Append(", "); sb.Append("\"walkers_mean\": ").Append(F(_sim.walkersMean)).Append(", "); sb.Append("\"measured_minutes\": ").Append(F(_sim.measuredMinutes)); sb.Append("},\n"); AppendSiteMetrics(sb); // スタッフの時間の使い方(割合も出す) float staffTotal = 0f; for (int i = 0; i < _sim.staffTime.Count; i++) staffTotal += _sim.staffTime[i].Value; sb.Append(" \"staff_time_s\": {"); for (int i = 0; i < _sim.staffTime.Count; i++) { if (i > 0) sb.Append(", "); sb.Append(Str(_sim.staffTime[i].Key)).Append(": ").Append(F(_sim.staffTime[i].Value)); } sb.Append("},\n"); // 料理ができてから届くまでの内訳(受け渡し口で待った/運んでいた/戻されて失った) sb.Append(" \"dish_wait_parts\": {"); AppendDishParts(sb, "robot", true); sb.Append(", "); AppendDishParts(sb, "staff", false); sb.Append(", "); AppendDishParts(sb, "all", null); sb.Append("},\n"); sb.Append(" \"job_wait_mean_s\": {"); for (int i = 0; i < _sim.jobWaitMean.Count; i++) { if (i > 0) sb.Append(", "); sb.Append(Str(_sim.jobWaitMean[i].Key)).Append(": ").Append(F(_sim.jobWaitMean[i].Value)); } sb.Append("},\n"); sb.Append(" \"staff_time_share\": {"); for (int i = 0; i < _sim.staffTime.Count; i++) { if (i > 0) sb.Append(", "); sb.Append(Str(_sim.staffTime[i].Key)).Append(": ") .Append(F(staffTotal > 0.01f ? _sim.staffTime[i].Value / staffTotal : 0f)); } sb.Append("},\n"); } // 止まり全体のまとめ(B7 の前後を並べるため) sb.Append(" \"stops\": {"); sb.Append("\"count\": ").Append(_stops.Count).Append(", "); sb.Append("\"stuck_count\": ").Append(StuckStopCount()).Append(", "); sb.Append("\"partial_path_count\": ").Append(PartialPathStopCount()); sb.Append("},\n"); sb.Append(" \"stop_causes\": [\n"); AppendCauseSummary(sb); sb.Append(" ],\n"); sb.Append(" \"conditions\": [\n"); for (int i = 0; i < order.Count; i++) { List rows = byCondition[order[i]]; AppendConditionSummary(sb, order[i], rows); if (i < order.Count - 1) sb.Append(','); sb.Append('\n'); } sb.Append(" ]\n"); sb.Append("}\n"); File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } /// /// サイトと同じ名前・同じ定義で出す欄(v5 3章・meta.metrics)。 /// site_result と並べるときは、この欄だけを見れば足りるようにする。 /// /// 入店待ちの分け方は、サイトの試算(haizen_sim.py)と同じ: /// seat_wait 店に来てから案内が始まるまで /// blocked_wait そのうち、卓は空いたのに片付けが終わらず待った分 /// (片付け待ちの卓の数だけ、待っている列の前から順に足す。サイトの _acc と同じ) /// full_wait seat_wait − blocked_wait /// guide_wait 案内の仕事が板に出てから、スタッフが取るまで /// staff_wait blocked_wait + guide_wait /// entry_wait 店に来てから卓に着くまで(案内の歩きと guide_s を含む) /// void AppendSiteMetrics(StringBuilder sb) { sb.Append(" \"site_metrics\": {"); sb.Append("\"entry_wait_mean\": ").Append(F(Mean(_sim.entryWaitMin))).Append(", "); sb.Append("\"full_wait_mean\": ").Append(F(Mean(_sim.fullWaitMin))).Append(", "); sb.Append("\"staff_wait_mean\": ").Append(F(Mean(_sim.staffWaitMin))).Append(", "); sb.Append("\"blocked_wait_mean\": ").Append(F(Mean(_sim.blockedWaitMin))).Append(", "); sb.Append("\"guide_wait_mean\": ").Append(F(Mean(_sim.guideWaitMin))).Append(", "); sb.Append("\"seat_wait_mean\": ").Append(F(Mean(_sim.seatWaitMin))).Append(", "); sb.Append("\"dish_wait_mean\": ").Append(F(Mean(_sim.dishWaitMin))).Append(", "); sb.Append("\"dish_wait_p90\": ").Append(F(Percentile(_sim.dishWaitMin, 0.9f))).Append(", "); sb.Append("\"cold_share\": ").Append(F(Share(_sim.dishWaitMin, _sim.coldMin))).Append(", "); sb.Append("\"turn_mean\": ").Append(F(Mean(_sim.turnMin))).Append(", "); sb.Append("\"walk_km_per_staff\": ").Append(F(_sim.walkKmPerStaff)).Append(", "); sb.Append("\"staff_util\": ").Append(F(_sim.staffUtil)).Append(", "); sb.Append("\"robot_util\": ").Append(F(_sim.robotUtil)).Append(", "); sb.Append("\"robot_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0 ? (float)_sim.robotDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", "); sb.Append("\"rescues\": ").Append(_sim.rescues).Append(", "); sb.Append("\"pauses\": ").Append(_sim.pauses).Append(", "); sb.Append("\"groups\": ").Append(_sim.groups); sb.Append("},\n"); sb.Append(" \"site_run\": {"); sb.Append("\"site_run\": ").Append(_sim.siteRun ? "true" : "false").Append(", "); sb.Append("\"condition_id\": ").Append(Str(_sim.siteConditionId)).Append(", "); sb.Append("\"arrivals_minutes\": ").Append(F(_sim.arrivalsMinutes)).Append(", "); sb.Append("\"total_minutes\": ").Append(F(_sim.totalMinutes)).Append(", "); sb.Append("\"everyone_left\": ").Append(_sim.everyoneLeft ? "true" : "false").Append(", "); sb.Append("\"rescues_done\": ").Append(_sim.rescuesDone).Append(", "); sb.Append("\"rescue_wait_s\": ").Append(F(_sim.rescueWaitS)).Append(", "); // 逃がし弁(0 回に近いことを確かめる。仕組みは消さない) sb.Append("\"gave_up\": ").Append(_sim.gaveUp).Append(", "); sb.Append("\"warped\": ").Append(_sim.warped).Append(", "); sb.Append("\"table_freed\": ").Append(_sim.tableFreed); sb.Append("},\n"); // サイトの試算の答え(並べる表のもと) sb.Append(" \"site_result\": {"); for (int i = 0; i < _sim.siteResult.Count; i++) { if (i > 0) sb.Append(", "); sb.Append(Str(_sim.siteResult[i].Key)).Append(": ").Append(F(_sim.siteResult[i].Value)); } sb.Append("},\n"); } /// /// 原因ごとの回数と秒数の合計。 /// stuck_count は「動けなくなった」で終わった止まり(止まりの上限に達した回数)。 /// partial_path_count は「途中までの経路」に縛られて引き直して出た止まり(B7)。 /// この2つを、停止点を変えた2回で並べれば「椅子が原因で上限に達した回数」の差が出る。 /// void AppendCauseSummary(StringBuilder sb) { string stuckLabel = ResolveLabel(ServingRobotController.StopResolve.Stuck); List order = new List(); Dictionary count = new Dictionary(); Dictionary secs = new Dictionary(); Dictionary longest = new Dictionary(); Dictionary blocked = new Dictionary(); Dictionary stuck = new Dictionary(); Dictionary partial = new Dictionary(); Dictionary noPath = new Dictionary(); for (int i = 0; i < _stops.Count; i++) { string c = _stops[i].cause; if (!count.ContainsKey(c)) { order.Add(c); count[c] = 0; secs[c] = 0f; longest[c] = 0f; blocked[c] = 0; stuck[c] = 0; partial[c] = 0; noPath[c] = 0f; } count[c]++; secs[c] += _stops[i].durationS; if (_stops[i].durationS > longest[c]) longest[c] = _stops[i].durationS; if (_stops[i].pathBlocked) blocked[c]++; if (_stops[i].resolved == stuckLabel) stuck[c]++; if (HasPartialPathMark(_stops[i].causeDetail)) partial[c]++; noPath[c] += _stops[i].noPathS; } for (int i = 0; i < order.Count; i++) { string c = order[i]; sb.Append(" {\"cause\": ").Append(Str(c)) .Append(", \"count\": ").Append(count[c]) .Append(", \"total_s\": ").Append(F(secs[c])) .Append(", \"mean_s\": ").Append(F(count[c] > 0 ? secs[c] / count[c] : 0f)) .Append(", \"longest_s\": ").Append(F(longest[c])) .Append(", \"path_blocked_count\": ").Append(blocked[c]) .Append(", \"stuck_count\": ").Append(stuck[c]) .Append(", \"partial_path_count\": ").Append(partial[c]) .Append(", \"no_path_total_s\": ").Append(F(noPath[c])) .Append('}'); if (i < order.Count - 1) sb.Append(','); sb.Append('\n'); } } /// /// 届くまでの内訳を1つぶん書く。byRobot が null なら全部。 /// 秒は「分」に直して出す(dish_wait_mean と同じ単位にそろえる)。 /// void AppendDishParts(StringBuilder sb, string name, bool? byRobot) { int n = 0, requeued = 0; float pass = 0f, carry = 0f, requeue = 0f; if (_sim != null) { for (int i = 0; i < _sim.dishParts.Count; i++) { ServiceOps.DishParts p = _sim.dishParts[i]; if (byRobot.HasValue && p.byRobot != byRobot.Value) continue; n++; pass += p.passWaitS; carry += p.carryS; requeue += p.requeueS; requeued += p.requeued; } } float d = n > 0 ? n : 1f; sb.Append(Str(name)).Append(": {"); sb.Append("\"dishes\": ").Append(n).Append(", "); sb.Append("\"pass_wait_mean_min\": ").Append(F(pass / d / 60f)).Append(", "); sb.Append("\"carry_mean_min\": ").Append(F(carry / d / 60f)).Append(", "); sb.Append("\"requeue_lost_mean_min\": ").Append(F(requeue / d / 60f)).Append(", "); sb.Append("\"total_mean_min\": ").Append(F((pass + carry + requeue) / d / 60f)).Append(", "); sb.Append("\"requeued_per_dish\": ").Append(F(requeued / d)).Append(", "); sb.Append("\"requeued_total\": ").Append(requeued); sb.Append("}"); } /// この止まりが「途中までの経路」に縛られたものか(B7) static bool HasPartialPathMark(string causeDetail) { return !string.IsNullOrEmpty(causeDetail) && causeDetail.Contains(ServingRobotController.PartialPathMark); } /// 「動けなくなった」で終わった止まりの数(止まりの上限に達した回数) int StuckStopCount() { string label = ResolveLabel(ServingRobotController.StopResolve.Stuck); int n = 0; for (int i = 0; i < _stops.Count; i++) if (_stops[i].resolved == label) n++; return n; } /// 「途中までの経路」から引き直して出た止まりの数(B7) int PartialPathStopCount() { int n = 0; for (int i = 0; i < _stops.Count; i++) if (HasPartialPathMark(_stops[i].causeDetail)) n++; return n; } static void AppendConditionSummary(StringBuilder sb, string id, List rows) { List speeds = new List(); List stopped = new List(); List pauses = new List(); List waits = new List(); int stuckCount = 0, timeoutCount = 0, pausedCount = 0, metRobotCount = 0; float aisleWidth = 0f; int robots = 0; string crowd = ""; for (int i = 0; i < rows.Count; i++) { Row r = rows[i]; aisleWidth = r.aisleWidthM; robots = r.robots; crowd = r.crowd; float moving = r.totalS - r.stoppedS; if (moving > 0.01f) speeds.Add(r.distanceM / moving); stopped.Add(r.stoppedS); waits.Add(r.waitAisleS); if (r.stopDurationsS != null) for (int k = 0; k < r.stopDurationsS.Length; k++) pauses.Add(r.stopDurationsS[k]); if (r.stuck) stuckCount++; if (r.timeout) timeoutCount++; if (r.nStops >= 1) pausedCount++; if (r.metRobot) metRobotCount++; } int n = rows.Count; sb.Append(" {"); sb.Append("\"condition_id\": ").Append(Str(id)).Append(", "); sb.Append("\"aisle_width_m\": ").Append(F(aisleWidth)).Append(", "); sb.Append("\"crowd\": ").Append(Str(crowd)).Append(", "); sb.Append("\"robots\": ").Append(robots).Append(", "); sb.Append("\"trips\": ").Append(n).Append(", "); sb.Append("\"robot_speed_mps_median\": ").Append(F(Percentile(speeds, 0.5f))).Append(", "); sb.Append("\"stopped_s_median\": ").Append(F(Percentile(stopped, 0.5f))).Append(", "); sb.Append("\"stopped_s_p90\": ").Append(F(Percentile(stopped, 0.9f))).Append(", "); sb.Append("\"pause_s_median\": ").Append(F(Percentile(pauses, 0.5f))).Append(", "); sb.Append("\"pause_s_p90\": ").Append(F(Percentile(pauses, 0.9f))).Append(", "); sb.Append("\"pause_count\": ").Append(pauses.Count).Append(", "); sb.Append("\"wait_aisle_s_median\": ").Append(F(Percentile(waits, 0.5f))).Append(", "); sb.Append("\"stuck_rate\": ").Append(F(n > 0 ? stuckCount / (float)n : 0f)).Append(", "); sb.Append("\"stuck_count\": ").Append(stuckCount).Append(", "); sb.Append("\"timeout_rate\": ").Append(F(n > 0 ? timeoutCount / (float)n : 0f)).Append(", "); sb.Append("\"timeout_count\": ").Append(timeoutCount).Append(", "); sb.Append("\"pause_rate\": ").Append(F(n > 0 ? pausedCount / (float)n : 0f)).Append(", "); sb.Append("\"met_robot_rate\": ").Append(F(n > 0 ? metRobotCount / (float)n : 0f)); sb.Append('}'); } static float Mean(List v) { if (v == null || v.Count == 0) return -1f; float s = 0f; for (int i = 0; i < v.Count; i++) s += v[i]; return s / v.Count; } static float Share(List v, float over) { if (v == null || v.Count == 0) return -1f; int n = 0; for (int i = 0; i < v.Count; i++) if (v[i] > over) n++; return (float)n / v.Count; } /// 小さい順に並べて p の位置の値を返す(0〜1)。空なら -1 public static float Percentile(List values, float p) { if (values == null || values.Count == 0) return -1f; List v = new List(values); v.Sort(); if (v.Count == 1) return v[0]; float pos = Mathf.Clamp01(p) * (v.Count - 1); int lo = Mathf.FloorToInt(pos); int hi = Mathf.Min(lo + 1, v.Count - 1); float t = pos - lo; return Mathf.Lerp(v[lo], v[hi], t); } } }