#!/usr/bin/env python3 """judge_night.py — 夜に回した Unity の結果を自動で振り分ける(読むだけ。Unity も台帳も変えない) 振り分け(2026-10-25 エイイチ決定) 回の失敗 : 最後まで回っていない・台帳に行がない・ロボットが1コマで0.05m超え動いた Unity の作り : 立ち往生(ロボットが30秒以上止まった)などが、基準データと比べて外れた 試算を見直す候補 : ロボットを増やしたときの向きが、種の過半数で試算と逆 または Unity ÷ 試算 の大きさが、基準データの同じ条件の範囲から外れた 正常 : どれにも当たらない 試算との差の大きさそのものは、記録するだけで印にしない(歩く距離の差などは既知のずれとして扱う) 決めた線 STUCK_S = 30 立ち往生とみなす1回の止まりの長さ(Unity の『動けなくなった』60秒とは別の、判定だけの線) DEAD_BAND = 5% と 0.2分 これより小さい変化は「変わらない」として向きに数えない 基準データ 段階B・ServiceSim/cs:7 の18回(③⑦ × 0/1/2台 × 種0〜2)。cs:144 の18回は使わない 使い方(service_sim/unity/ で) python3 judge_night.py --runs '20261026_night_*' --out night/20261026 python3 judge_night.py --runs '20261025_stageB_cs144_*' --out /tmp/self (基準どうしの確かめ) 標準ライブラリだけで動く。 """ import argparse, csv, fnmatch, glob, json, os, statistics as st, sys, collections STUCK_S = 30.0 DEAD_REL, DEAD_ABS = 0.05, 0.2 BASELINE_DEFAULT = '20261025_stageB_cs7_*' # 2026-10-26:新しいプロジェクト(ServiceSim/cs:7)の18回に入れ替え。cs:144 の18回は当時のエディタの状態に引きずられていた(⑦) LEDGER = 'unity_ledger_v1.csv' # pass_by は台帳の列にないので run_info.json から取る(無い回 = 切)。robots は必ず最後(向きの比べで使う) COND_KEYS = ('store', 'staff', 'arrivals_per_h', 'mode', 'trays', 'pass_by', 'robots') # 向きと大きさを見る数字(Unity の列, 試算の列, 表示名) PAIRS = [('entry_wait_mean', 'site_entry_wait_mean', '入店待ち'), ('dish_wait_mean', 'site_dish_wait_mean', '料理の待ち'), ('staff_wait_mean', 'site_staff_wait_mean', '席は空いているのに待った')] # Unity の作りを見る数字(1回ごと)。多いほど悪い BUILD_METRICS = [('stuck30_per_trip', '30秒以上の止まり(1往復あたり)'), ('robot_robot30', '30秒以上の止まりのうち、もう1台のロボットが原因'), ('stuck_count', '動けなくなった(60秒)'), ('table_freed', '卓を空けた(逃がし弁)'), ('warped', '瞬間移動'), ('timeouts', '打ち切り')] def fnum(x): try: return float(x) except (TypeError, ValueError): return None def load_ledger(): rows = {} with open(LEDGER, encoding='utf-8') as f: for r in csv.DictReader(f): rows[r['run_folder']] = r # 同じ出力フォルダは2度書かない決まりなので後勝ちでよい return rows def run_record(folder, ledger): """1回分の数字をまとめる。足りないものは失敗の理由に入れる""" rec = {'run_folder': folder, 'fail': []} lr = ledger.get(folder) if lr is None: rec['fail'].append('台帳に行がない') return rec rec['ledger'] = lr rec['seed'] = lr.get('seed') pass_by = '切' if lr.get('status') != 'completed': rec['fail'].append(f"status={lr.get('status')}") if lr.get('everyone_left') not in ('1', '1.0', 'True', 'true'): rec['fail'].append('全員が帰っていない') ri_path = os.path.join(folder, 'run_info.json') if os.path.exists(ri_path): ri = json.load(open(ri_path, encoding='utf-8')) if (ri.get('pass_by') or {}).get('enabled'): pass_by = '入' if (ri.get('frames_over_005m') or 0) > 0: rec['fail'].append(f"ロボットが1コマで0.05m超え {ri['frames_over_005m']}コマ") else: rec['fail'].append('run_info.json がない') vals = dict(lr) vals['pass_by'] = pass_by rec['cond'] = tuple(vals.get(k, '') for k in COND_KEYS) # 止まり stops_path = os.path.join(folder, 'stops.csv') long_n = rr_n = 0 if os.path.exists(stops_path): with open(stops_path, encoding='utf-8') as f: for s in csv.DictReader(f): d = fnum(s.get('duration_s')) or 0 if d >= STUCK_S: long_n += 1 if s.get('cause') == 'もう1台のロボット': rr_n += 1 trips = fnum(lr.get('trips')) or 0 rec['m'] = { 'stuck30': long_n, 'stuck30_per_trip': (long_n / trips) if trips else 0.0, 'robot_robot30': rr_n, 'stuck_count': fnum(lr.get('stuck_count')) or 0, 'table_freed': fnum(lr.get('table_freed')) or 0, 'warped': fnum(lr.get('warped')) or 0, 'timeouts': fnum(lr.get('timeouts')) or 0, } for u, s, _ in PAIRS: rec['m'][u] = fnum(lr.get(u)) rec['m'][s] = fnum(lr.get(s)) return rec def collect(pattern, ledger, superseded=None): """出力フォルダがある回と、台帳にだけ行がある回(failed / stopped で フォルダが無いもの)の両方を拾う。やり直し(_try2)があるときは1つにまとめる: 最後まで回った方を使い、どちらも失敗なら後の方を使う""" names = {p for p in glob.glob(pattern) if os.path.isdir(p)} names |= {k for k in ledger if fnmatch.fnmatch(k, pattern)} recs = [run_record(f, ledger) for f in sorted(names)] by_key = collections.OrderedDict() for r in recs: key = r['run_folder'][:-len('_try2')] if r['run_folder'].endswith('_try2') else r['run_folder'] by_key.setdefault(key, []).append(r) out = [] for key, rs in by_key.items(): rs.sort(key=lambda r: r['run_folder'].endswith('_try2')) # 元 → やり直し の順 ok = [r for r in rs if not r['fail']] pick = ok[-1] if ok else rs[-1] out.append(pick) if superseded is not None: superseded.extend(r['run_folder'] for r in rs if r is not pick) return out def by_cond(recs): g = collections.defaultdict(list) for r in recs: if 'cond' in r and not r['fail']: g[r['cond']].append(r) return g def sign(a, b): """a→b の向き。小さい変化は 0""" if a is None or b is None: return None d = b - a if abs(d) < max(DEAD_ABS, DEAD_REL * max(abs(a), abs(b))): return 0 return 1 if d > 0 else -1 def direction_flags(groups): """台数を1つ増やしたときの向きを、種ごとに試算と比べる。過半数が逆なら印""" out = [] # (条件の頭, 台数a→b, 数字, 逆の種の数, 比べられた種の数, 試算の向き) heads = collections.defaultdict(dict) for cond, rs in groups.items(): heads[cond[:-1]][cond[-1]] = rs for head, byr in heads.items(): counts = sorted(byr, key=lambda x: float(x)) for a, b in zip(counts, counts[1:]): sa = {r['seed']: r for r in byr[a]} sb = {r['seed']: r for r in byr[b]} seeds = sorted(set(sa) & set(sb), key=lambda x: int(x)) for u, s, name in PAIRS: site_a = [sa[k]['m'][s] for k in seeds if sa[k]['m'][s] is not None] site_b = [sb[k]['m'][s] for k in seeds if sb[k]['m'][s] is not None] if not site_a or not site_b: continue site_dir = sign(st.mean(site_a), st.mean(site_b)) if site_dir in (None, 0): continue # 試算が変わらないところは向きを比べない opp = n = 0 for k in seeds: d = sign(sa[k]['m'][u], sb[k]['m'][u]) if d is None: continue n += 1 if d == -site_dir: opp += 1 if n and opp * 2 > n: out.append((head, f'{a}→{b}台', name, opp, n, '短くなる' if site_dir < 0 else '長くなる')) return out def ratio_values(rs, u, s): v = [] for r in rs: a, b = r['m'][u], r['m'][s] if a is not None and b: v.append(a / b) return v def compare_to_base(groups, base_groups): """条件ごとに、種の中央値が基準の同じ条件の最小〜最大から外れたか""" build, site, nobase = [], [], [] for cond, rs in groups.items(): brs = base_groups.get(cond) if not brs: nobase.append(cond) # 比べる相手がないので判定しない(Unity の作りにしない) continue for key, name in BUILD_METRICS: now = st.median(r['m'][key] for r in rs) bv = [r['m'][key] for r in brs] if now > max(bv) and now - max(bv) >= (0.01 if key == 'stuck30_per_trip' else 1): build.append((cond, name, round(now, 3), round(min(bv), 3), round(max(bv), 3))) for u, s, name in PAIRS: nv, bv = ratio_values(rs, u, s), ratio_values(brs, u, s) if not nv or not bv: continue now = st.median(nv) if now < min(bv) or now > max(bv): site.append((cond, f'{name}(Unity÷試算)', round(now, 2), round(min(bv), 2), round(max(bv), 2))) return build, site, nobase def cond_label(c): d = dict(zip(COND_KEYS, c)) pb = '・すれ違い入' if d.get('pass_by') == '入' else '' return f"{d['store']} スタッフ{d['staff']} ロボット{d['robots']}台{pb}" def head_label(h): d = dict(zip(COND_KEYS[:-1], h)) pb = '・すれ違い入' if d.get('pass_by') == '入' else '' return f"{d['store']} スタッフ{d['staff']}{pb}" def main(): ap = argparse.ArgumentParser() ap.add_argument('--runs', required=True, help='振り分ける回の出力フォルダ(glob)') ap.add_argument('--baseline', default=BASELINE_DEFAULT, help='基準データの出力フォルダ(glob)') ap.add_argument('--out', required=True, help='judge.csv と judge.md を書くフォルダ') a = ap.parse_args() if not os.path.exists(LEDGER): sys.exit(f'{LEDGER} がない。service_sim/unity/ で動かす(KIOXIA がつながっているか確かめる)') ledger = load_ledger() superseded = [] recs = collect(a.runs, ledger, superseded) base = [r for r in collect(a.baseline, ledger) if not r['fail']] if not recs: sys.exit('振り分ける回が見つからない') if not base: sys.exit(f'基準データが見つからない({a.baseline})。--baseline を確かめる') groups, bgroups = by_cond(recs), by_cond(base) build, site_mag, nobase = compare_to_base(groups, bgroups) dirs = direction_flags(groups) known_dirs = {(d[0], d[1], d[2]) for d in direction_flags(bgroups)} # 1回ごとの判定(回の失敗だけは1回ごと。ほかは条件ごとの印を、その条件の回に付ける) build_conds = {b[0] for b in build} site_conds = {c[0] for c in site_mag} for d in dirs: if (d[0], d[1], d[2]) not in known_dirs: for cond in groups: if cond[:-1] == d[0]: site_conds.add(cond) os.makedirs(a.out, exist_ok=True) counts = collections.Counter() with open(os.path.join(a.out, 'judge.csv'), 'w', encoding='utf-8', newline='') as f: w = csv.writer(f) w.writerow(['run_folder', 'store', 'staff', 'robots', 'seed', 'judge', 'reasons', 'stuck30', 'stuck30_per_trip', 'robot_robot30']) for r in recs: if r['fail']: j, why = '回の失敗', ' / '.join(r['fail']) elif r['cond'] in nobase: j, why = '基準なし', '基準データに同じ条件がない(判定していない)' elif r['cond'] in build_conds: j, why = 'Unity の作り', '条件ごとの印(judge.md)' elif r['cond'] in site_conds: j, why = '試算を見直す候補', '条件ごとの印(judge.md)' else: j, why = '正常', '' counts[j] += 1 d = dict(zip(COND_KEYS, r.get('cond', ('',) * len(COND_KEYS)))) m = r.get('m', {}) w.writerow([r['run_folder'], d['store'], d['staff'], d['robots'], r.get('seed', ''), j, why, m.get('stuck30', ''), round(m.get('stuck30_per_trip', 0), 4) if m else '', m.get('robot_robot30', '')]) L = [] L.append(f'# 夜の結果の振り分け({a.runs})\n') L.append(f'基準データ: {a.baseline}({len(base)}回)/振り分けた回: {len(recs)}回\n') pm_now = sorted({r['ledger'].get('params_model', '') for r in recs if 'ledger' in r} - {''}) pm_base = sorted({r['ledger'].get('params_model', '') for r in base if 'ledger' in r} - {''}) if pm_now != pm_base: L.append(f'**注意:試算の版が基準と違う(今回 {", ".join(pm_now)}/基準 {", ".join(pm_base)})。試算との比べは参考にとどめる**\n') L.append('| 振り分け | 回数 |\n|---|---|') for k in ('正常', '試算を見直す候補', 'Unity の作り', '回の失敗', '基準なし'): L.append(f'| {k} | {counts.get(k, 0)} |') L.append('\n## 回の失敗\n') fails = [r for r in recs if r['fail']] L += [f"- {r['run_folder']}: {' / '.join(r['fail'])}" for r in fails] or ['- なし'] if superseded: L.append('\nやり直しで置き換えた回(判定に入れていない): ' + ', '.join(superseded)) if nobase: L.append('\n## 基準なし(基準データに同じ条件がないので判定していない)\n') L += [f'- {cond_label(c)}' for c in sorted(nobase)] L.append('\n## Unity の作り(種の中央値が、基準の同じ条件の最大を超えた)\n') L += [f'- {cond_label(c)}: {name} {now}(基準 {lo}〜{hi})' if now is not None else f'- {cond_label(c)}: {name}' for c, name, now, lo, hi in build] or ['- なし'] L.append('\n## 試算を見直す候補\n') L.append('### ロボットを増やしたときの向きが、種の過半数で試算と逆\n') if dirs: for h, step, name, opp, n, sd in dirs: tag = '基準 v2 でも同じ(既知)' if (h, step, name) in known_dirs else '新しく出た' L.append(f'- {head_label(h)} {step}: {name}(試算は{sd})— 逆 {opp}/{n} 種 — {tag}') else: L.append('- なし') L.append('\n### Unity÷試算 の大きさが、基準の同じ条件の範囲から外れた\n') L += [f'- {cond_label(c)}: {name} {now}倍(基準 {lo}〜{hi}倍)' for c, name, now, lo, hi in site_mag] or ['- なし'] L.append('\n## 記録(印にしない):試算との差の大きさ(条件ごとの種の中央値・Unity÷試算)\n') L.append('| 条件 | 種の数 | ' + ' | '.join(p[2] for p in PAIRS) + ' |') L.append('|---|---|' + '---|' * len(PAIRS)) for cond in sorted(groups): rs = groups[cond] cells = [] for u, s, _ in PAIRS: v = ratio_values(rs, u, s) cells.append(f'{st.median(v):.2f}' if v else '-') L.append(f'| {cond_label(cond)} | {len(rs)} | ' + ' | '.join(cells) + ' |') # すれ違い 入 の条件は、同じ夜の 切 の同じ条件と並べる(記録。印にしない) pb_i = COND_KEYS.index('pass_by') pairs = [(c, c[:pb_i] + ('切',) + c[pb_i + 1:]) for c in groups if c[pb_i] == '入'] if pairs: L.append('\n## すれ違い 入 と 切 の比べ(同じ夜・同じ条件・種の中央値。印にしない)\n') cols = [('entry_wait_mean', '入店待ち'), ('dish_wait_mean', '料理の待ち'), ('staff_wait_mean', '席は空いているのに待った'), ('stuck_count', '動けなくなった'), ('gave_up', 'あきらめた'), ('robot_share', 'ロボットが運んだ割合')] L.append('| 条件 | ' + ' | '.join(n for _, n in cols) + ' |') L.append('|---|' + '---|' * len(cols)) for on, off in pairs: for c in (off, on): rs = groups.get(c, []) cells = [] for k, _ in cols: v = [fnum(r['ledger'].get(k)) for r in rs if fnum(r['ledger'].get(k)) is not None] cells.append(f'{st.median(v):.2f}' if v else '-') L.append(f'| {cond_label(c)}({len(rs)}回) | ' + ' | '.join(cells) + ' |') L.append(f'\n線: 立ち往生 {STUCK_S:.0f}秒以上の止まり/向きは {int(DEAD_REL*100)}%・{DEAD_ABS}分より小さい変化を数えない/外れは基準の同じ条件の最小〜最大') open(os.path.join(a.out, 'judge.md'), 'w', encoding='utf-8').write('\n'.join(L) + '\n') print('\n'.join(L)) if __name__ == '__main__': main()