using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using UnityEngine; namespace ServiceSim.EditorTools { /// /// 段階Bの通し台帳(2026-10-23 エイイチと決めた。依頼 v7)。 /// /// 実体: {kioxiaOutputRoot}/unity_ledger_v1.csv /// (MASTER の保存先「service_sim/unity/」の中。新しい置き場所は作らない) /// /// 決まり: /// ・1回 = 1行・**追記のみ**。既存の行は書き換えない・消さない /// ・同じ出力フォルダの行を2度書かない(run_folder が照合キー) /// ・**追記ガード**:実体のヘッダとこのコードの列の並びが1字でも違えば追記しない /// ・止めた回・失敗した回も status を付けて1行残す /// /// 列の名前と並びを変えるときは、先にエイイチに聞く(依頼 v7 の 2)。 /// public static class ServiceSimLedger { public const int SchemaVersion = 1; public const string FileName = "unity_ledger_v1.csv"; /// 列(この順。名前を変えるときは先にエイイチに聞く) 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" }; 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; } /// もう台帳にある出力フォルダの名前をぜんぶ返す(続きから回すのに使う) 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; } 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 = ""; if (!CanAppend(path, out why)) return false; string folder = Get(row, "run_folder"); if (string.IsNullOrEmpty(folder)) { why = "run_folder が空なので足しません(照合キーです)"; return false; } if (HasRow(path, folder)) { why = "もう台帳にあるので足しません(2度書かない): " + 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); bool makeHeader = !File.Exists(path); using (StreamWriter w = new StreamWriter(path, true, new UTF8Encoding(false))) { if (makeHeader) w.Write(HeaderLine + "\n"); w.Write(sb.ToString() + "\n"); } } catch (Exception e) { why = "台帳に書けません: " + path + "\n " + e.Message; return false; } return true; } 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; } } } }