#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""_exp_hull_repair.py — 볼록껍질로 대체된 23부품을 살릴 수 있는 수리 전략 실측.

오프라인 실험이다. fea_server.py 는 읽기(import)만 하고 절대 고치지 않는다.
포트 8097 서버도 건드리지 않는다(fea_server 의 serve() 는 __main__ 가드 안이라
import 만으로는 포트를 열지 않는다 — 확인함).

무엇을 재는가 (부품 x 전략):
  1) tetgen '-p' 통과 여부      → fea_server._tet_ok_p 그대로
  2) 벽두께 게이트 통과 여부     → fea_server._thickness_ok 그대로
     (emin 은 '원본' 부품의 최소 변 길이. 수리 결과로 다시 재면 살이 붙은 수리가
      스스로 기준을 늘려 게이트를 통과해 버린다.)
  3) CAD 표면 최대 이탈 mm       → fea_server.point_tri_dist_pairs (점↔삼각형 정확거리)

전략:
  E1 자기교차 수리 : open3d get_self_intersecting_triangles 로 교차 삼각형 제거
                     → fea_server._earcut_fill 로 구멍메움 → _tm_clean
  E2 gmsh 3D       : 표면을 gmsh 에 넣어 사면체 체적메시 생성 → 그 경계면을 결과 표면으로
  E3 복셀 재메시   : 벽두께/3 (하한 bbox대각/400) 해상도로 복셀화 후 marching cubes

실행:
  python _exp_hull_repair.py --baseline     # 23부품 형상 추출 + 현재 껍질 기준값
  python _exp_hull_repair.py --sweep        # 전략 전수 실행(셀마다 자식 프로세스 + 타임아웃)
  python _exp_hull_repair.py --cell --key <키> --strat E1   # 셀 1개(자식 프로세스용)
  python _exp_hull_repair.py --table        # 결과 JSON → 마크다운 표
"""
import argparse
import json
import os
import subprocess
import sys
import time

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fea_server as F   # noqa: E402  (serve 는 __main__ 가드 안 → import 안전)

# ── 볼록껍질로 대체되고 있는 23부품 (확정 사실, 재조사 금지) ──────────
HULL_PARTS = [13, 17, 18, 19, 20, 39, 41, 58, 63, 66, 90, 96, 98,
              101, 105, 110, 111, 117, 118, 119, 120, 144, 145]

HERE = os.path.dirname(os.path.abspath(__file__))
# ★ tetgen 은 한글 경로에서 실패한다 → 작업폴더는 반드시 fea_server.WORK_ROOT 아래 ASCII
EXP_ROOT = os.path.join(F.WORK_ROOT, 'exp_hull')
GEO_DIR = os.path.join(EXP_ROOT, 'geo')
BASE_JSON = os.path.join(HERE, '_exp_hull_repair_baseline.json')
RES_JSON = os.path.join(HERE, '_exp_hull_repair_result.json')
CELL_TIMEOUT_S = 120


def _mk(d):
    os.makedirs(d, exist_ok=True)
    return d


# ══════════════════════════════════════════════════════════════════
#  0. 부품 형상 추출 — fea_server 가 실제로 쓰는 경로와 똑같이
# ══════════════════════════════════════════════════════════════════
def extract_parts():
    """last_request.json(화면이 마지막으로 보낸 그 모델) → 23부품의 덩어리별 (V,F).

    fea_server.run_fea 와 동일한 순서: part_sizes 로 자르고 → weld_mesh →
    split_components. 껍질 대체는 '부품' 이 아니라 '덩어리' 단위로 일어난다.
    """
    req = os.path.join(F.WORK_ROOT, 'last_request.json')
    q = json.load(open(req, encoding='utf-8'))
    P = np.asarray(q['positions'], dtype=float).reshape(-1, 3)
    sizes = q['part_sizes']
    names = q.get('part_names') or []
    offs, off = [], 0
    for cnt in sizes:
        rows = int(cnt) // 3
        offs.append((off, off + rows))
        off += rows
    out = []
    for no in HULL_PARTS:
        pi = no - 1
        a, b = offs[pi]
        seg = P[a:b]
        Vp, Fp = F.weld_mesh(seg)
        sub = F.split_components(Vp, Fp)
        for ci, (Vc, Fc) in enumerate(sub):
            out.append({'no': no, 'comp': ci,
                        'name': names[pi] if pi < len(names) else '',
                        'V': Vc, 'F': Fc})
    return out


def cell_key(no, comp):
    return '%d_%d' % (no, comp)


def geo_path(key):
    return os.path.join(GEO_DIR, 'g%s.npz' % key)


def load_geo(key):
    z = np.load(geo_path(key))
    return np.asarray(z['V'], float), np.asarray(z['F'], np.int64)


# ══════════════════════════════════════════════════════════════════
#  1. 측정 도구
# ══════════════════════════════════════════════════════════════════
def max_dev_to_cad(Vr, Fr, Vc, Fc, k=32):
    """수리 결과 표면 → 원래 CAD 표면 최대 이탈 mm (점↔삼각형 정확거리).

    후보 삼각형을 '무게중심 최근접 k개' 로만 뽑으면 길고 얇은 삼각형에서
    과소보고된다. 그래서 ① 무게중심 KD트리 k개 ② CAD 최근접 정점에 붙은
    삼각형 전부를 합쳐 후보로 쓰고, fea_server.point_tri_dist_pairs 로
    정확거리를 잰다.
    """
    from scipy.spatial import cKDTree
    if not len(Vr) or not len(Fc) or not len(Fr):
        return None
    # ★ 정점만 찍으면 안 된다 — 볼록껍질의 정점은 CAD 정점 그 자체라 거리가 0 으로
    #   나오고, 껍질이 오목부를 가로질러 부풀어 오른 '진짜 이탈' 은 삼각형 안쪽에서
    #   생긴다. 그래서 정점 + 무게중심 + 변 중점을 표본으로 쓴다.
    Tr = Vr[Fr]
    S = np.vstack([Vr, Tr.mean(axis=1),
                   (Tr[:, 0] + Tr[:, 1]) / 2, (Tr[:, 1] + Tr[:, 2]) / 2,
                   (Tr[:, 2] + Tr[:, 0]) / 2])
    if len(S) > 60000:                           # 표본 상한 — 균일 간격으로 솎는다
        S = S[::int(np.ceil(len(S) / 60000.0))]
    Vr = S
    T = Vc[Fc]                                   # (m,3,3)
    cen = T.mean(axis=1)
    tc = cKDTree(cen)
    tv = cKDTree(Vc)
    v2f = {}
    for fi, tri in enumerate(Fc):
        for v in tri:
            v2f.setdefault(int(v), []).append(fi)
    kk = min(k, len(cen))
    _, idx = tc.query(Vr, k=kk)
    idx = np.atleast_2d(idx.reshape(len(Vr), -1))
    _, vnear = tv.query(Vr, k=min(3, len(Vc)))
    vnear = np.atleast_2d(np.asarray(vnear).reshape(len(Vr), -1))
    worst = 0.0
    for i in range(len(Vr)):
        cand = set(int(x) for x in idx[i])
        for v in vnear[i]:
            cand.update(v2f.get(int(v), ()))
        cand = np.fromiter(cand, dtype=np.int64)
        pts = np.repeat(Vr[i][None, :], len(cand), axis=0)
        d = F.point_tri_dist_pairs(pts, T[cand])
        dm = float(np.nanmin(d))
        if dm > worst:
            worst = dm
    return worst


def measure(Vr, Fr, Vc, Fc, emin, wd, tag):
    """한 수리결과를 fea_server 의 게이트로 그대로 판정한다."""
    r = {'nv': int(len(Vr)), 'nf': int(len(Fr))}
    if not len(Fr) or len(Vr) < 4:
        r.update({'tetgen': False, 'wall_gate': False, 'max_dev_mm': None,
                  'note': 'fail(빈 메시)'})
        return r
    r['tetgen'] = bool(F._tet_ok_p(Vr, Fr, _mk(os.path.join(wd, 'tg_' + tag)), timeout=60))
    r['wall_gate'] = bool(F._thickness_ok(Vr, Fr, emin))
    try:
        m = F.mesh_metrics(Vr, Fr)
        r['wall_mm'] = round(float(m['wall_mm']), 3)
        r['closed'] = bool(m['closed'])
        r['volume_mm3'] = round(float(m['volume_mm3']), 1)
    except Exception:
        pass
    try:
        r['max_dev_mm'] = round(float(max_dev_to_cad(Vr, Fr, Vc, Fc)), 3)
    except Exception as e:
        r['max_dev_mm'] = None
        r['dev_err'] = str(e)[:120]
    return r


# ══════════════════════════════════════════════════════════════════
#  2. 세 가지 수리 전략
# ══════════════════════════════════════════════════════════════════
def strat_E1(V, Fa, wd):
    """자기교차 삼각형 제거 → 구멍메움 → 정리."""
    import open3d as o3d
    m = o3d.geometry.TriangleMesh(
        o3d.utility.Vector3dVector(np.asarray(V, float)),
        o3d.utility.Vector3iVector(np.asarray(Fa, np.int32)))
    m.remove_duplicated_vertices()
    m.remove_duplicated_triangles()
    m.remove_degenerate_triangles()
    si = np.asarray(m.get_self_intersecting_triangles())
    info = {'self_int_pairs': int(len(si))}
    if len(si):
        bad = np.unique(si.reshape(-1))
        m.remove_triangles_by_index([int(x) for x in bad])
        m.remove_unreferenced_vertices()
        info['removed_tris'] = int(len(bad))
    V1 = np.asarray(m.vertices, float)
    F1 = np.asarray(m.triangles, np.int64)
    try:
        V1, F1 = F._tm_clean(V1, F1)
    except Exception:
        pass
    added = 0
    try:
        V1, F1, added = F._earcut_fill(V1, F1)
    except Exception:
        pass
    info['filled_tris'] = int(added)
    try:
        V1, F1 = F._tm_clean(V1, F1)
    except Exception:
        pass
    return V1, F1, info


def strat_E2(V, Fa, wd):
    """gmsh 로 표면을 받아 사면체 체적메시를 직접 만들고, 그 경계면을 결과로 쓴다."""
    import gmsh
    # ★ 중복·퇴화 삼각형을 먼저 치운다. 안 치우면 gmsh 가 매개화 단계에서
    #   'Singular matrix 3x3' 으로 먼저 죽어 버려 '왜 안 되는지' 가 안 보인다
    #   (실측 17번: 정리 후에는 'A segment and a facet intersect' = 자기교차로 바뀐다).
    try:
        V, Fa = F._tm_clean(V, Fa)
    except Exception:
        pass
    stl = os.path.join(_mk(wd), 'in.stl')
    F.write_stl(stl, V, Fa)
    diag = float(np.linalg.norm(np.ptp(V, axis=0)))
    gmsh.initialize()
    info = {}
    try:
        gmsh.option.setNumber('General.Terminal', 0)
        gmsh.option.setNumber('Mesh.CharacteristicLengthMin', max(diag / 200.0, 1e-3))
        gmsh.option.setNumber('Mesh.CharacteristicLengthMax', max(diag / 20.0, 1e-2))
        gmsh.merge(stl)
        gmsh.model.mesh.classifySurfaces(40.0 * np.pi / 180.0, True, True, 180.0 * np.pi / 180.0)
        gmsh.model.mesh.createGeometry()
        surf = [e[1] for e in gmsh.model.getEntities(2)]
        info['gmsh_surfaces'] = len(surf)
        sl = gmsh.model.geo.addSurfaceLoop(surf)
        gmsh.model.geo.addVolume([sl])
        gmsh.model.geo.synchronize()
        gmsh.model.mesh.generate(3)
        nt, nc, _ = gmsh.model.mesh.getNodes()
        pts = np.asarray(nc, float).reshape(-1, 3)
        remap = {int(t): i for i, t in enumerate(nt)}
        et, _, enod = gmsh.model.mesh.getElements(3)
        tets = None
        for ty, nd in zip(et, enod):
            if int(ty) == 4:    # 4절점 사면체
                tets = np.asarray([remap[int(x)] for x in nd], np.int64).reshape(-1, 4)
        if tets is None or not len(tets):
            raise RuntimeError('gmsh 사면체 0개')
        info['tets'] = int(len(tets))
    finally:
        gmsh.finalize()
    # 사면체 → 경계 삼각형(한 번만 쓰인 면)
    fs = np.vstack([tets[:, [0, 2, 1]], tets[:, [0, 1, 3]],
                    tets[:, [1, 2, 3]], tets[:, [0, 3, 2]]])
    key = np.sort(fs, axis=1)
    _, first, cnt = np.unique(key, axis=0, return_index=True, return_counts=True)
    bf = fs[first[cnt == 1]]
    Vb, Fb = F.weld_mesh(pts[bf].reshape(-1, 3))
    return Vb, Fb, info


def strat_E3(V, Fa, wd):
    """벽두께/3 해상도로 복셀화 후 표면 재추출."""
    import trimesh
    met = F.mesh_metrics(V, Fa)
    diag = float(np.linalg.norm(np.ptp(V, axis=0)))
    # ★ 열린·자기교차 형상에서 wall_mm(=2V/S) 은 쓰레기값이 될 수 있다.
    #   그대로 /3 하면 피치가 0 근처로 떨어져 복셀화가 메모리를 다 쓰고 멈춘다
    #   → 'timeout' 으로 잘못 기록된다. 하한을 bbox대각/400 으로 묶고 실제 피치를 남긴다.
    pitch = max(float(met['wall_mm']) / 3.0, diag / 400.0, 1e-3)
    info = {'wall_mm_src': round(float(met['wall_mm']), 3),
            'pitch_mm': round(pitch, 4), 'bbox_diag_mm': round(diag, 2)}
    m = trimesh.Trimesh(vertices=np.asarray(V, float),
                        faces=np.asarray(Fa, np.int64), process=False)
    vg = m.voxelized(pitch=pitch)
    try:
        vg = vg.fill()
    except Exception:
        pass
    info['voxels'] = int(vg.filled_count)
    sm = vg.marching_cubes
    # ★ trimesh 의 marching_cubes 는 '복셀 격자 인덱스 좌표' 로 돌아온다.
    #   격자 변환을 안 씌우면 CAD 와 수천 mm 떨어진 자리에 생겨 이탈이 6332mm 로 나온다.
    sm.apply_transform(vg.transform)
    V1 = np.asarray(sm.vertices, float)
    F1 = np.asarray(sm.faces, np.int64)
    try:
        V1, F1 = F._tm_clean(V1, F1)
    except Exception:
        pass
    return V1, F1, info


STRATS = {'E1': strat_E1, 'E2': strat_E2, 'E3': strat_E3}


# ══════════════════════════════════════════════════════════════════
#  3. 기준값 — 지금 정말로 껍질로 대체되는지 확인부터 한다
# ══════════════════════════════════════════════════════════════════
def run_baseline():
    _mk(GEO_DIR)
    rows = []
    for it in extract_parts():
        key = cell_key(it['no'], it['comp'])
        V, Fa = it['V'], it['F']
        np.savez_compressed(geo_path(key), V=V, F=Fa)
        met = F.mesh_metrics(V, Fa)
        emin = float(np.ptp(V, axis=0).min()) if len(V) else 0.0
        wd = _mk(os.path.join(EXP_ROOT, 'base', key))
        t0 = time.time()
        try:
            Vr, Fr, meth = F.repair_for_tet(V, Fa, wd)
        except Exception as e:
            Vr, Fr, meth = np.zeros((0, 3)), np.zeros((0, 3), np.int64), 'fail(%s)' % str(e)[:60]
        hull_dev = None
        if meth.startswith('H ') and len(Fr):
            try:
                hull_dev = round(float(max_dev_to_cad(Vr, Fr, V, Fa)), 3)
            except Exception:
                pass
        rows.append({'key': key, 'no': it['no'], 'comp': it['comp'], 'name': it['name'],
                     'nv': int(len(V)), 'nf': int(len(Fa)),
                     'closed': bool(met['closed']), 'bad_edges': int(met['bad_edges']),
                     'wall_mm': round(float(met['wall_mm']), 3),
                     'volume_mm3': round(float(met['volume_mm3']), 1),
                     'emin_mm': round(emin, 3),
                     'ladder_method': meth, 'hull_max_dev_mm': hull_dev,
                     'sec': round(time.time() - t0, 1)})
        print('[기준] %-7s %-18s 닫힘=%-5s 사다리=%s 껍질이탈=%s'
              % (key, rows[-1]['name'][:18], rows[-1]['closed'], meth, hull_dev), flush=True)
        json.dump(rows, open(BASE_JSON, 'w', encoding='utf-8'),
                  ensure_ascii=False, indent=1)
    nh = sum(1 for r in rows if r['ladder_method'].startswith('H '))
    print('덩어리 %d개 중 껍질대체 %d개' % (len(rows), nh))
    return rows


# ══════════════════════════════════════════════════════════════════
#  4. 셀 1개 실행 (자식 프로세스) / 전수 실행 (부모)
# ══════════════════════════════════════════════════════════════════
def run_cell(key, strat):
    V, Fa = load_geo(key)
    emin = float(np.ptp(V, axis=0).min()) if len(V) else 0.0
    wd = _mk(os.path.join(EXP_ROOT, 'c', key + '_' + strat))
    t0 = time.time()
    try:
        Vr, Fr, info = STRATS[strat](V, Fa, wd)
    except Exception as e:
        print('@@RESULT@@' + json.dumps(
            {'tetgen': False, 'wall_gate': False, 'max_dev_mm': None,
             'note': 'fail(%s)' % str(e)[:140], 'sec': round(time.time() - t0, 1)},
            ensure_ascii=False))
        return 0
    r = measure(Vr, Fr, V, Fa, emin, wd, strat)
    r['info'] = info
    r['sec'] = round(time.time() - t0, 1)
    print('@@RESULT@@' + json.dumps(r, ensure_ascii=False))
    return 0


def run_sweep(only_keys=None, only_strats=None):
    base = json.load(open(BASE_JSON, encoding='utf-8'))
    keys = [b['key'] for b in base if b['ladder_method'].startswith('H ')]
    if only_keys:
        keys = [k for k in keys if k in only_keys]
    strats = only_strats or ['E1', 'E2', 'E3']
    res = {}
    if os.path.isfile(RES_JSON):
        try:
            res = json.load(open(RES_JSON, encoding='utf-8'))
        except Exception:
            res = {}
    # ★ 전략 순서로 돈다 — 중간에 끊겨도 표의 한 열은 온전하다.
    for st in strats:
        for key in keys:
            if res.get(key, {}).get(st):
                continue
            cmd = [sys.executable, os.path.abspath(__file__), '--cell',
                   '--key', key, '--strat', st]
            t0 = time.time()
            try:
                pr = subprocess.run(cmd, capture_output=True, text=True,
                                    encoding='utf-8', errors='replace',
                                    timeout=CELL_TIMEOUT_S, cwd=HERE)
                out = [l for l in (pr.stdout or '').splitlines()
                       if l.startswith('@@RESULT@@')]
                if out:
                    r = json.loads(out[-1][len('@@RESULT@@'):])
                else:
                    r = {'tetgen': False, 'wall_gate': False, 'max_dev_mm': None,
                         'note': 'fail(결과없음 rc=%s %s)'
                                 % (pr.returncode,
                                    ((pr.stderr or '').strip().splitlines() or [''])[-1][:100])}
            except subprocess.TimeoutExpired:
                r = {'tetgen': False, 'wall_gate': False, 'max_dev_mm': None,
                     'note': 'timeout'}
            r.setdefault('sec', round(time.time() - t0, 1))
            res.setdefault(key, {})[st] = r
            json.dump(res, open(RES_JSON, 'w', encoding='utf-8'),
                      ensure_ascii=False, indent=1)
            print('[%s/%s] tetgen=%s 벽게이트=%s 이탈=%s %s (%ss)'
                  % (key, st, r.get('tetgen'), r.get('wall_gate'),
                     r.get('max_dev_mm'), r.get('note', ''), r.get('sec')), flush=True)
    return res


# ══════════════════════════════════════════════════════════════════
#  5. 마크다운 점수표
# ══════════════════════════════════════════════════════════════════
#  ★ 실패 사유를 표 칸에 그대로 넣으면 표가 못 읽을 만큼 넓어진다.
#    짧은 코드로 줄이고 원문은 표 아래 각주로 모은다 — gmsh 가 '어떻게' 실패하는지가
#    그 자체로 발견이다(겹친 면 / 매개화 위상 / 세그먼트-면 교차 = 모두 자기교차를 가리킨다).
FAILCODE = [('A segment and a facet intersect', 'PLC교차'),
            ('overlapping facets', '면겹침'),
            ('parametrization', '매개화'),
            ('사면체 0개', 'tet0'),
            ('Unable to allocate', '메모리'),
            ('MemoryError', '메모리')]


def _short_fail(n):
    for pat, code in FAILCODE:
        if pat in n:
            return 'fail(%s)' % code
    return 'fail'


def _cell_txt(r):
    if not r:
        return '— | — | — | —'
    if r.get('note') == 'timeout':
        return 'timeout | — | — | —'
    n = r.get('note') or ''
    if n.startswith('fail'):
        return '%s | — | — | —' % _short_fail(n)
    dev = r.get('max_dev_mm')
    return '%s | %s | %s | %s' % (
        'O' if r.get('tetgen') else 'X',
        'O' if r.get('wall_gate') else 'X',
        ('%.2f' % dev) if dev is not None else '—',
        ('%.2f' % r['vol_ratio']) if r.get('vol_ratio') is not None else '—(원본열림)')


def run_table():
    base = json.load(open(BASE_JSON, encoding='utf-8'))
    res = json.load(open(RES_JSON, encoding='utf-8'))
    bym = {b['key']: b for b in base}
    # 부피비 = 수리결과 부피 / 원본 CAD 부피. 1.0 에서 멀면 '형상이 변했다'.
    #  ★ 이 칸이 판정의 핵심이다 — tetgen 과 벽게이트를 둘 다 통과해도 부피가 2.6배로
    #    부풀거나 16% 로 쪼그라들면 해석값은 껍질만큼이나 못 믿는다.
    #  ★ 단, 원본이 '열린 면' 이면 기준 부피 자체가 쓰레기값이다(발산정리를 열린 표면에
    #    적용한 값 — fea_server 의 H 단계 주석과 같은 이유). 그때는 부피비를 아예 비운다.
    for k, v in res.items():
        b0 = bym.get(k, {})
        v0 = b0.get('volume_mm3') or 0.0
        for s, r in v.items():
            if r.get('volume_mm3') and v0 and b0.get('closed'):
                r['vol_ratio'] = round(r['volume_mm3'] / v0, 3)
    hdr = ('| 부품 | 이름 | 껍질이탈mm | E1 tet | E1 벽 | E1 이탈mm | E1 부피비 | '
           'E2 tet | E2 벽 | E2 이탈mm | E2 부피비 | '
           'E3 tet | E3 벽 | E3 이탈mm | E3 부피비 |')
    print(hdr)
    print('|' + '---|' * 15)
    cnt = {s: {'tet': 0, 'wall': 0, 'better': 0, 'clean': 0} for s in ('E1', 'E2', 'E3')}
    notes = []
    for b in base:
        if not b['ladder_method'].startswith('H '):
            continue
        hd = b.get('hull_max_dev_mm')
        cells = []
        for s in ('E1', 'E2', 'E3'):
            r = res.get(b['key'], {}).get(s)
            cells.append(_cell_txt(r))
            if r and (r.get('note') or '').startswith('fail'):
                notes.append('%s/%s: %s' % (b['key'], s, r['note']))
            if not r or not r.get('tetgen'):
                continue
            cnt[s]['tet'] += 1
            if r.get('wall_gate'):
                cnt[s]['wall'] += 1
            dev = r.get('max_dev_mm')
            if r.get('wall_gate') and dev is not None and hd is not None and dev < hd:
                cnt[s]['better'] += 1
                vr = r.get('vol_ratio')   # 닫힌 원본에서만 값이 있다
                if vr is not None and 0.9 <= vr <= 1.1:
                    cnt[s]['clean'] += 1
        print('| %s | %s | %s | %s |' % (b['key'], b['name'], hd, ' | '.join(cells)))
    print()
    for s in ('E1', 'E2', 'E3'):
        c = cnt[s]
        print('- %s: tetgen 통과 %d / 벽게이트까지 %d / 껍질보다 CAD 에 가까움 %d '
              '/ 그중 부피도 보존(닫힌 원본 한정, 0.9~1.1배) %d'
              % (s, c['tet'], c['wall'], c['better'], c['clean']))
    if notes:
        print('\n실패 사유 원문:')
        for n in notes:
            print('  - ' + n)


if __name__ == '__main__':
    ap = argparse.ArgumentParser()
    ap.add_argument('--baseline', action='store_true')
    ap.add_argument('--sweep', action='store_true')
    ap.add_argument('--cell', action='store_true')
    ap.add_argument('--table', action='store_true')
    ap.add_argument('--key')
    ap.add_argument('--strat')
    a = ap.parse_args()
    _mk(EXP_ROOT)
    if a.baseline:
        run_baseline()
    elif a.cell:
        sys.exit(run_cell(a.key, a.strat))
    elif a.sweep:
        run_sweep(only_keys=[a.key] if a.key else None,
                  only_strats=[a.strat] if a.strat else None)
    elif a.table:
        run_table()
    else:
        ap.print_help()
