using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using UnityEngine; namespace ServiceSim { /// /// 段階Bの通し台帳(2026-10-23 エイイチと決めた。依頼 v7)。 /// /// 実体: {kioxiaOutputRoot}/unity_ledger_v1.csv /// (MASTER の保存先「service_sim/unity/」の中。新しい置き場所は作らない) /// /// 決まり: /// ・1回 = 1行・**追記のみ**。既存の行は書き換えない・消さない /// ・同じ出力フォルダの行を2度書かない(run_folder が照合キー) /// ・**追記ガード**:実体のヘッダとこのコードの列の並びが1字でも違えば追記しない /// ・止めた回・失敗した回も status を付けて1行残す /// ・書くときは **鍵をかける**(依頼 v12 の 3 章)。夜は実行ファイルを /// 何本も同時に走らせるので、同時に追記すると行が混ざる /// /// 列の名前と並びを変えるときは、先にエイイチに聞く(依頼 v7 の 2)。 /// /// なぜ Editor/ の外にあるか(2026-10-26・依頼 v12 の 3 章) /// 画面なしの実行ファイル(BuildPipeline で作る player)には /// Editor/ の中のコードが入らない。けれど夜の回も**同じコード**で /// 台帳に書かないと、列の作り方が2通りに分かれてしまう。 /// このクラスは UnityEditor の API を1つも使っていなかったので、 /// Experiment/ に移して player からも呼べるようにした。 /// 中身(列・ガード・2度書かない決まり)は移すときに変えていない。 /// 名前空間も ServiceSim にした。呼んでいたのは ServiceSimStageB だけで、 /// そちらは ServiceSim.EditorTools の中(= ServiceSim の内側)にあるので、 /// 呼ぶ側の字は1つも変わらない。 /// public static class ServiceSimLedger { /// /// 台帳の版。列を足したら 1 つ上げる。 /// 1 … 依頼 v7(49 列) /// 2 … 依頼 v10b(会計待ち・片付け待ちの 10 列を後ろに足した。59 列) /// public const int SchemaVersion = 2; public const string FileName = "unity_ledger_v1.csv"; /// /// 鍵ファイルの名前(依頼 v12 の 3 章)。 /// tools/ledger.py の LOCK_NAME と同じ字。 /// O_EXCL / FileMode.CreateNew で作れたら持ち主、閉じるときに消す。 /// 中身は「pid ホスト名 unix秒」の1行(Python 側と同じ形) /// public const string LockFileName = "unity_ledger_v1.lock"; /// 鍵を待つ上限(秒)。これを超えたら書かずにあきらめる public const float LockTimeoutS = 60f; /// これより古い鍵は、持ち主が落ちたと見て1度だけ壊す(Python 側と同じ 120 秒) public const float LockStaleS = 120f; /// /// unity_cs の頭に付ける字(依頼 v11 の 2)。 /// /// このプロジェクト(Robotics_UX_New から分けた ServiceSim 専用)は /// changeset の番号が 1 から振り直されるので、 /// 台帳の中で前のプロジェクトの "cs:144" などと混ざらないように /// "ServiceSim/cs:1" の形で書く。 /// 台帳の列は増やさない(data_manifest_rules.md 10節) /// public const string CsPrefix = "ServiceSim/"; /// /// 列(この順。名前を変えるときは先にエイイチに聞く)。 /// /// 足すときは **いちばん後ろ**(ledger_schema_version より後ろ)に足す。 /// 途中に入れると前の行の列の位置がずれて、古い行が読めなくなる(依頼 v10b の 1)。 /// 前の行は新しい列が無いまま(短い行)で置いておく。書き換えない /// public static readonly string[] Columns = { "timestamp", "run_folder", "status", "unity_cs", "layout_generation", "params_model", "store", "staff", "robots", "arrivals_per_h", "mode", "trays", "fragile_by", "seed", "staff_first_yield", "yield_order_enabled", "one_robot_per_aisle", "robot_first_wait_s", "everyone_left", "total_minutes", "wall_seconds", "entry_wait_mean", "staff_wait_mean", "dish_wait_mean", "cold_share", "robot_share", "staff_util", "robot_util", "walk_km_per_staff", "site_entry_wait_mean", "site_staff_wait_mean", "site_dish_wait_mean", "site_robot_share", "robot_speed_mps", "pause_rate", "pause_s_mean", "stuck_rate", "trips", "timeouts", "stuck_count", "stop_s_total", "corner_stop_time_share", "gave_up", "warped", "table_freed", "rescues", "late_staff_dishes", "staff_yield_share_of_staff_time", "ledger_schema_version", // ---- 依頼 v10b の 1(2026-10-25)。ここから後ろは schema 2 で足した列 ---- // 会計待ち・片付け待ちを試算と並べるための 10 列。 // clear_* / pay_job_* は「仕事が板に載ってから取られるまで」の秒数 "pay_wait_mean", "site_pay_wait_mean", "blocked_wait_mean", "site_blocked_wait_mean", "turn_mean", "site_turn_mean", "clear_wait_mean_s", "clear_wait_max_s", "clear_wait_over_count", "pay_job_wait_mean_s" }; public static string HeaderLine { get { return string.Join(",", Columns); } } /// 台帳の実体の場所 public static string PathOf(ExperimentPlan plan) { string root = plan != null && !string.IsNullOrEmpty(plan.kioxiaOutputRoot) ? plan.kioxiaOutputRoot : "/Volumes/KIOXIA/r2r2r-data/service_sim/unity"; return Path.Combine(root, FileName); } // ---------------- 追記ガード ---------------- /// /// 追記していいか。 /// ・実体が無い … いい(header を書いて作る) /// ・1行めがコードの列と同じ … いい /// ・違う … だめ(why に理由) /// public static bool CanAppend(string path, out string why) { why = ""; if (!File.Exists(path)) return true; string first; try { using (StreamReader r = new StreamReader(path, new UTF8Encoding(false))) first = r.ReadLine(); } catch (Exception e) { why = "台帳を読めません: " + path + "\n " + e.Message; return false; } if (first == null) { why = "台帳が空です(ヘッダがありません): " + path + "\n いちど消すか、ヘッダを書いてください"; return false; } string have = first.TrimEnd('\r', '\n'); if (have == HeaderLine) return true; why = "台帳のヘッダがコードの列と違うので追記しません(追記ガード): " + path + "\n 実体: " + have + "\n コード: " + HeaderLine + "\n 列をそろえるか、エイイチに聞いてから直してください"; return false; } /// その出力フォルダの行がもうあるか(run_folder が照合キー) public static bool HasRow(string path, string runFolder) { if (!File.Exists(path) || string.IsNullOrEmpty(runFolder)) return false; int col = IndexOf("run_folder"); try { using (StreamReader r = new StreamReader(path, new UTF8Encoding(false))) { r.ReadLine(); // ヘッダ string line; while ((line = r.ReadLine()) != null) { if (line.Length == 0) continue; string[] f = line.Split(','); if (col < f.Length && f[col] == runFolder) return true; } } } catch (Exception e) { Debug.LogWarning("[ServiceSim] 台帳を読めませんでした(照合を飛ばします): " + e.Message); } return false; } /// もう台帳にある出力フォルダの名前をぜんぶ返す(2度書きの歯止めに使う) public static HashSet DoneFolders(string path) { HashSet done = new HashSet(); if (!File.Exists(path)) return done; int col = IndexOf("run_folder"); int statusCol = IndexOf("status"); try { using (StreamReader r = new StreamReader(path, new UTF8Encoding(false))) { r.ReadLine(); string line; while ((line = r.ReadLine()) != null) { if (line.Length == 0) continue; string[] f = line.Split(','); if (col >= f.Length) continue; // status に関係なく、1行あるものは「回した」とみなす // (止めた回・失敗した回を何度も回し直さないため) if (statusCol < f.Length && f[statusCol].Length == 0) continue; done.Add(f[col]); } } } catch (Exception e) { Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message); } return done; } /// /// 回した回を照らし合わせるキー(依頼 v8 の 3)。 /// /// **店・ロボットの台数・乱数の種・Unity の版** でそろえる。 /// 出力フォルダ名(日付入り)では照らし合わせない。 /// ・同じ版・同じ条件・同じ種の行があれば、日付が違っても回さない /// ・版が違えば回す(直したあとに同じ18回をもう一度回すため) /// public static string RunKey(string store, int robots, int seed, string unityCs) { return (store ?? "") + "|r" + robots + "|s" + seed + "|" + (unityCs ?? ""); } /// 台帳にある回のキーをぜんぶ返す(続きから回すのに使う) public static HashSet DoneKeys(string path) { return DoneKeys(path, null); } /// /// 台帳にある回のキーを返す(続きから回すのに使う)。 /// /// folderLabel を渡すと、run_folder に "_{名札}_" が入っている行だけを見る(依頼 v11 の 3)。 /// 名札は「まとめて回した種類」で、stageB(基準データの18回)と /// split_check(移せたかの確かめ)を分けるために使う。 /// こうしておくと、確かめで ③1台・種1 を2回回しても、 /// そのあとの Run All が同じ条件を「済」として飛ばさない。 /// 台帳の列は増やしていない(名札は run_folder の中にある) /// public static HashSet DoneKeys(string path, string folderLabel) { HashSet done = new HashSet(); if (!File.Exists(path)) return done; int cStore = IndexOf("store"); int cRobots = IndexOf("robots"); int cSeed = IndexOf("seed"); int cCs = IndexOf("unity_cs"); int cStatus = IndexOf("status"); int cFolder = IndexOf("run_folder"); string mark = string.IsNullOrEmpty(folderLabel) ? null : "_" + folderLabel + "_"; try { using (StreamReader r = new StreamReader(path, new UTF8Encoding(false))) { r.ReadLine(); string line; while ((line = r.ReadLine()) != null) { if (line.Length == 0) continue; string[] f = line.Split(','); int need = Mathf.Max(Mathf.Max(cStore, cRobots), Mathf.Max(cSeed, cCs)); if (need >= f.Length) continue; if (cStatus < f.Length && f[cStatus].Length == 0) continue; if (mark != null) { if (cFolder >= f.Length) continue; if (f[cFolder].IndexOf(mark, StringComparison.Ordinal) < 0) continue; } int robots, seed; if (!int.TryParse(f[cRobots], out robots)) continue; if (!int.TryParse(f[cSeed], out seed)) continue; done.Add(RunKey(f[cStore], robots, seed, f[cCs])); } } } catch (Exception e) { Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message); } return done; } static int IndexOf(string name) { for (int i = 0; i < Columns.Length; i++) if (Columns[i] == name) return i; return -1; } // ---------------- 1行足す ---------------- /// /// 1行追記する。ヘッダが合わない・同じ出力フォルダの行がもうある場合は足さない。 /// row は列名 → 値。足りない列は空欄にする /// public static bool Append(string path, IDictionary row, out string why) { why = ""; string folder = Get(row, "run_folder"); if (string.IsNullOrEmpty(folder)) { why = "run_folder が空なので足しません(照合キーです)"; return false; } // 書く字は鍵を取る前に作る(鍵を持っている時間を短くする) StringBuilder sb = new StringBuilder(); for (int i = 0; i < Columns.Length; i++) { if (i > 0) sb.Append(','); sb.Append(Clean(Get(row, Columns[i]))); } try { string dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); } catch (Exception e) { why = "台帳の置き場所が作れません: " + path + "\n " + e.Message; return false; } // ---- ここから鍵の中(依頼 v12 の 3 章)---- // ヘッダの確かめと「もうあるか」の確かめも **鍵の中** でやる。 // 外でやると、2本が同時に「まだ無い」と見てから両方書いてしまう string lockWhy; IDisposable key = TakeLock(path, out lockWhy); if (key == null) { why = lockWhy; return false; } try { if (!CanAppend(path, out why)) return false; if (HasRow(path, folder)) { why = "もう台帳にあるので足しません(2度書かない): " + folder; return false; } try { bool makeHeader = !File.Exists(path); using (FileStream fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read)) using (StreamWriter w = new StreamWriter(fs, new UTF8Encoding(false))) { if (makeHeader) w.Write(HeaderLine + "\n"); w.Write(sb.ToString() + "\n"); w.Flush(); fs.Flush(true); // 落ちても行が消えないよう、ディスクまで落とす } } catch (Exception e) { why = "台帳に書けません: " + path + "\n " + e.Message; return false; } return true; } finally { key.Dispose(); // 鍵ファイルを消す } } // ---------------- 鍵(依頼 v12 の 3 章)---------------- /// /// 台帳の鍵ファイルの置き場所(台帳と同じフォルダ)。 /// tools/ledger.py と同じ場所・同じ名前なので、 /// Unity と Python が同じ鍵を取り合える /// public static string LockPathOf(string ledgerPath) { string dir = Path.GetDirectoryName(ledgerPath); return string.IsNullOrEmpty(dir) ? LockFileName : Path.Combine(dir, LockFileName); } /// /// 鍵を取る。取れたら Dispose で消える入れ物を返し、取れなければ null と理由を返す。 /// /// やり方(tools/ledger.py と同じ): /// ・FileMode.CreateNew で作れたら持ち主。中身は「pid ホスト名 unix秒」 /// ・作れなければ少し待って何度も試す(上限 LockTimeoutS) /// ・鍵が LockStaleS より古ければ、持ち主が落ちたと見て **1度だけ** 壊す /// public static IDisposable TakeLock(string ledgerPath, out string why) { why = ""; string lockPath = LockPathOf(ledgerPath); DateTime end = DateTime.UtcNow.AddSeconds(LockTimeoutS); bool broke = false; while (true) { try { FileStream fs = new FileStream(lockPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); try { string body = Pid() + " " + HostName() + " " + UnixNow().ToString("0.000", CultureInfo.InvariantCulture) + "\n"; byte[] bytes = new UTF8Encoding(false).GetBytes(body); fs.Write(bytes, 0, bytes.Length); fs.Flush(); } catch (Exception) { /* 中身が書けなくても、鍵そのものは取れている */ } return new LedgerKey(fs, lockPath); } catch (IOException) { // もう誰かが持っている(か、作れない) } catch (Exception e) { why = "台帳の鍵が作れません: " + lockPath + "\n " + e.Message; return null; } if (!broke && StaleLock(lockPath)) { broke = true; Debug.LogWarning("[ServiceSim] 台帳の鍵が " + LockStaleS + " 秒より古いので壊します: " + lockPath); try { File.Delete(lockPath); } catch (Exception) { } continue; } if (DateTime.UtcNow >= end) { why = "台帳の鍵を " + LockTimeoutS + " 秒待っても取れません: " + lockPath + "\n ほかの回が書いている途中か、鍵が残っています"; return null; } System.Threading.Thread.Sleep(250); } } static bool StaleLock(string lockPath) { try { if (!File.Exists(lockPath)) return false; return (DateTime.UtcNow - File.GetLastWriteTimeUtc(lockPath)).TotalSeconds > LockStaleS; } catch (Exception) { return false; } } static int Pid() { try { return System.Diagnostics.Process.GetCurrentProcess().Id; } catch (Exception) { return 0; } } static string HostName() { // Environment.MachineName(Python 側の socket.gethostname と同じ意味)。 // SystemInfo はエンジンが要るので使わない(player でも Editor でも動く形にする) try { return Environment.MachineName; } catch (Exception) { return "unity"; } } static double UnixNow() { return (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds; } /// 取った鍵。Dispose で閉じて消す sealed class LedgerKey : IDisposable { FileStream _fs; readonly string _path; public LedgerKey(FileStream fs, string path) { _fs = fs; _path = path; } public void Dispose() { if (_fs != null) { try { _fs.Dispose(); } catch (Exception) { } _fs = null; } try { if (File.Exists(_path)) File.Delete(_path); } catch (Exception) { } } } static string Get(IDictionary row, string key) { string v; return row != null && row.TryGetValue(key, out v) && v != null ? v : ""; } /// CSV を壊さないように、カンマ・改行・引用符は取り除く(値は数と短い名前だけ) static string Clean(string s) { if (string.IsNullOrEmpty(s)) return ""; s = s.Replace(',', ' ').Replace('"', ' '); s = s.Replace("\r", " ").Replace("\n", " "); return s.Trim(); } // ---------------- summary.json から1行を作る ---------------- /// 数を台帳の書き方にそろえる(小数4桁まで・指数表記にしない) public static string Num(float v) { return v.ToString("0.####", CultureInfo.InvariantCulture); } public static string Num(int v) { return v.ToString(CultureInfo.InvariantCulture); } public static string Flag(bool v) { return v ? "1" : "0"; } /// summary.json を読む(読めなければ null) public static JsonValue ReadSummary(string runDir) { string p = Path.Combine(runDir, "summary.json"); if (!File.Exists(p)) return null; try { string err; JsonValue v = JsonValue.Parse(File.ReadAllText(p), out err); if (v == null || !v.IsObject) { Debug.LogWarning("[ServiceSim] summary.json を読めません: " + p + "\n " + err); return null; } return v; } catch (Exception e) { Debug.LogWarning("[ServiceSim] summary.json を読めません: " + p + "\n " + e.Message); return null; } } } }