Edit on GitHub

teach_csv

View teach_csv.py on GitHub →

Batch-teach recording CSVs into Atlas (no robot needed).

Teaches labeled recordings collected earlier, e.g. after the P0 run:

python teach_csv.py recordings/zzzold/recording_20261029_153038_water_half.csv
python teach_csv.py recordings/ --session thu
python teach_csv.py recordings/ --dry-run     # show what would be taught
python teach_csv.py --list                    # signature counts per label

Label resolution: --label override > object_label column > filename. Files labeled mystery/unlabeled are skipped unless --include-mystery. Files already taught (matched by properties.csv) are skipped unless --force.

  1#!/usr/bin/env python3
  2"""Batch-teach recording CSVs into Atlas (no robot needed).
  3
  4Teaches labeled recordings collected earlier, e.g. after the P0 run:
  5
  6    python teach_csv.py recordings/zzzold/recording_20261029_153038_water_half.csv
  7    python teach_csv.py recordings/ --session thu
  8    python teach_csv.py recordings/ --dry-run     # show what would be taught
  9    python teach_csv.py --list                    # signature counts per label
 10
 11Label resolution: --label override > object_label column > filename.
 12Files labeled mystery/unlabeled are skipped unless --include-mystery.
 13Files already taught (matched by properties.csv) are skipped unless --force.
 14"""
 15
 16import argparse
 17import glob
 18import os
 19import sys
 20
 21import pandas as pd
 22
 23import atlas
 24import feature_extractor
 25
 26SKIP_LABELS = {"mystery", "unlabeled", "unknown"}
 27MOTION_TYPE = "swirl_macro"
 28
 29
 30def resolve_label(csv_path, df, override=None):
 31    """Determine the teach label for a CSV.
 32
 33    Precedence: explicit override, then the object_label column, then the
 34    recording_YYYYMMDD_HHMMSS_<label>.csv filename.
 35
 36    Args:
 37        csv_path: path to the recording CSV.
 38        df: the recording DataFrame.
 39        override: forced label, or None.
 40
 41    Returns:
 42        Label string, or None when none can be determined.
 43    """
 44    if override:
 45        return override
 46    if "object_label" in df.columns:
 47        lbl = str(df["object_label"].iloc[0]).strip()
 48        if lbl and lbl.lower() != "nan":
 49            return lbl
 50    base = os.path.basename(csv_path)
 51    # recording_YYYYMMDD_HHMMSS_<label>.csv
 52    parts = base.replace(".csv", "").split("_")
 53    if len(parts) >= 4 and parts[0] == "recording":
 54        return "_".join(parts[3:])
 55    return None
 56
 57
 58def teach_file(db, csv_path, session, label_override=None, dry_run=False,
 59               force=False, include_mystery=False, require_swirl=False):
 60    """Extract features from one CSV and teach it to Atlas.
 61
 62    Args:
 63        db: MongoDB database handle.
 64        csv_path: path to the recording CSV.
 65        session: session tag, e.g. "thu".
 66        label_override: forced label, or None.
 67        dry_run: report what would be taught without writing.
 68        force: re-teach files already in Atlas.
 69        include_mystery: also teach mystery-labeled files.
 70        require_swirl: skip recordings without a true SWIRL phase.
 71
 72    Returns:
 73        Tuple (status, filename, detail) where status is one of
 74        "taught", "dry", "skip", "error".
 75    """
 76    name = os.path.basename(csv_path)
 77    try:
 78        df = pd.read_csv(csv_path)
 79    except Exception as e:
 80        return ("error", name, f"unreadable: {e}")
 81    if not {"phase", "tau2_nm", "tau3_nm"}.issubset(df.columns):
 82        return ("skip", name, "not a grasp recording")
 83    label = resolve_label(csv_path, df, label_override)
 84    if not label:
 85        return ("skip", name, "no label found")
 86    if label.lower() in SKIP_LABELS and not include_mystery:
 87        return ("skip", name, f"label '{label}' (use --include-mystery to teach it)")
 88    try:
 89        feats = feature_extractor.extract_features(df)
 90    except Exception as e:
 91        return ("error", name, f"extraction failed: {e}")
 92    if dry_run:
 93        dq = feats["diagnostics"]
 94        proto = "swirl" if dq["phase_source"] == "SWIRL" else "fallback"
 95        if require_swirl and dq["phase_source"] != "SWIRL":
 96            return ("skip", name, "no SWIRL phase (fallback protocol)")
 97        return ("dry", name,
 98                f"would teach '{label}' [{proto}] "
 99                f"({dq['n_samples']} samples, tare={dq['tare_flags'] or 'OK'})")
100    if require_swirl and feats["diagnostics"]["phase_source"] != "SWIRL":
101        return ("skip", name, "no SWIRL phase (fallback protocol)")
102    if not force and db.signatures.count_documents({"properties.csv": name}, limit=1):
103        return ("skip", name, "already in Atlas (use --force to re-teach)")
104    dq = feats["diagnostics"]
105    saved = atlas.teach(
106        label, feats["e"], motion_type=MOTION_TYPE, session=session,
107        properties={"csv": name, "source": "teach_csv.py",
108                    "tare_flags": dq["tare_flags"],
109                    "phase_source": dq["phase_source"],
110                    "z": [round(float(v), 4) for v in feats["z"]],
111                    "n_samples": dq["n_samples"], "fs_hz": dq["fs_hz"]})
112    return ("taught", name, f"'{label}' sig_id={saved['sig_id']}")
113
114
115def list_library():
116    """Print signature counts per label in the Atlas library."""
117    db = atlas.get_db()
118    rows = list(db.signatures.aggregate([
119        {"$group": {"_id": "$label", "n": {"$sum": 1},
120                    "motion_types": {"$addToSet": "$motion_type"},
121                    "sessions": {"$addToSet": "$session"}}},
122        {"$sort": {"_id": 1}}]))
123    if not rows:
124        print("signatures collection is empty")
125        return
126    print(f"{'label':<24} {'n':>3}  motion_types  sessions")
127    for r in rows:
128        print(f"{r['_id']:<24} {r['n']:>3}  "
129              f"{','.join(sorted(r['motion_types']))}  {','.join(sorted(r['sessions']))}")
130
131
132def main():
133    """CLI entry: batch-teach recordings to Atlas."""
134    ap = argparse.ArgumentParser(
135        description="Batch-teach recording CSVs into Atlas.",
136        epilog="""examples:
137  python teach_csv.py recordings/ --require-swirl --dry-run   # preview
138  python teach_csv.py recordings/ --require-swirl --session fri
139  python teach_csv.py --list                                  # library contents
140  python teach_csv.py foo.csv --label water_075pct --force    # re-teach one file
141
142Label resolution: --label > object_label column > filename.
143Mystery/unlabeled and non-grasp CSVs are skipped; already-taught files are
144skipped unless --force. Needs MONGODB_URI in .env, and the Atlas IP
145allowlist must include this machine.""",
146        formatter_class=argparse.RawDescriptionHelpFormatter)
147    ap.add_argument("paths", nargs="*", help="CSV files, globs, or dirs (default: recordings/)")
148    ap.add_argument("--session", default="thu", help="session tag (default: thu)")
149    ap.add_argument("--label", default=None, help="force one label for all files")
150    ap.add_argument("--dry-run", action="store_true", help="show what would be taught")
151    ap.add_argument("--force", action="store_true", help="re-teach files already in Atlas")
152    ap.add_argument("--include-mystery", action="store_true")
153    ap.add_argument("--require-swirl", action="store_true",
154                    help="only teach recordings with a true SWIRL phase")
155    ap.add_argument("--list", action="store_true", help="show signature counts per label")
156    args = ap.parse_args()
157
158    if args.list:
159        list_library()
160        return
161
162    files = []
163    for p in args.paths or ["recordings/"]:
164        if os.path.isdir(p):
165            files += sorted(glob.glob(os.path.join(p, "**", "*.csv"), recursive=True))
166        elif os.path.isfile(p):
167            files.append(p)
168        else:
169            files += sorted(glob.glob(p, recursive=True))
170    if not files:
171        sys.exit("no CSV files found")
172
173    db = None if args.dry_run else atlas.get_db()
174    counts = {}
175    for f in files:
176        status, name, detail = teach_file(
177            db, f, args.session, label_override=args.label,
178            dry_run=args.dry_run, force=args.force,
179            include_mystery=args.include_mystery,
180            require_swirl=args.require_swirl)
181        counts[status] = counts.get(status, 0) + 1
182        print(f"[{status:>7}] {name}: {detail}")
183    print("\nsummary:", ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
184
185
186if __name__ == "__main__":
187    main()
SKIP_LABELS = {'mystery', 'unlabeled', 'unknown'}
MOTION_TYPE = 'swirl_macro'
def resolve_label(csv_path, df, override=None):
31def resolve_label(csv_path, df, override=None):
32    """Determine the teach label for a CSV.
33
34    Precedence: explicit override, then the object_label column, then the
35    recording_YYYYMMDD_HHMMSS_<label>.csv filename.
36
37    Args:
38        csv_path: path to the recording CSV.
39        df: the recording DataFrame.
40        override: forced label, or None.
41
42    Returns:
43        Label string, or None when none can be determined.
44    """
45    if override:
46        return override
47    if "object_label" in df.columns:
48        lbl = str(df["object_label"].iloc[0]).strip()
49        if lbl and lbl.lower() != "nan":
50            return lbl
51    base = os.path.basename(csv_path)
52    # recording_YYYYMMDD_HHMMSS_<label>.csv
53    parts = base.replace(".csv", "").split("_")
54    if len(parts) >= 4 and parts[0] == "recording":
55        return "_".join(parts[3:])
56    return None

Determine the teach label for a CSV.

Precedence: explicit override, then the object_label column, then the recording_YYYYMMDD_HHMMSS_

Args: csv_path: path to the recording CSV. df: the recording DataFrame. override: forced label, or None.

Returns: Label string, or None when none can be determined.

def teach_file( db, csv_path, session, label_override=None, dry_run=False, force=False, include_mystery=False, require_swirl=False):
 59def teach_file(db, csv_path, session, label_override=None, dry_run=False,
 60               force=False, include_mystery=False, require_swirl=False):
 61    """Extract features from one CSV and teach it to Atlas.
 62
 63    Args:
 64        db: MongoDB database handle.
 65        csv_path: path to the recording CSV.
 66        session: session tag, e.g. "thu".
 67        label_override: forced label, or None.
 68        dry_run: report what would be taught without writing.
 69        force: re-teach files already in Atlas.
 70        include_mystery: also teach mystery-labeled files.
 71        require_swirl: skip recordings without a true SWIRL phase.
 72
 73    Returns:
 74        Tuple (status, filename, detail) where status is one of
 75        "taught", "dry", "skip", "error".
 76    """
 77    name = os.path.basename(csv_path)
 78    try:
 79        df = pd.read_csv(csv_path)
 80    except Exception as e:
 81        return ("error", name, f"unreadable: {e}")
 82    if not {"phase", "tau2_nm", "tau3_nm"}.issubset(df.columns):
 83        return ("skip", name, "not a grasp recording")
 84    label = resolve_label(csv_path, df, label_override)
 85    if not label:
 86        return ("skip", name, "no label found")
 87    if label.lower() in SKIP_LABELS and not include_mystery:
 88        return ("skip", name, f"label '{label}' (use --include-mystery to teach it)")
 89    try:
 90        feats = feature_extractor.extract_features(df)
 91    except Exception as e:
 92        return ("error", name, f"extraction failed: {e}")
 93    if dry_run:
 94        dq = feats["diagnostics"]
 95        proto = "swirl" if dq["phase_source"] == "SWIRL" else "fallback"
 96        if require_swirl and dq["phase_source"] != "SWIRL":
 97            return ("skip", name, "no SWIRL phase (fallback protocol)")
 98        return ("dry", name,
 99                f"would teach '{label}' [{proto}] "
100                f"({dq['n_samples']} samples, tare={dq['tare_flags'] or 'OK'})")
101    if require_swirl and feats["diagnostics"]["phase_source"] != "SWIRL":
102        return ("skip", name, "no SWIRL phase (fallback protocol)")
103    if not force and db.signatures.count_documents({"properties.csv": name}, limit=1):
104        return ("skip", name, "already in Atlas (use --force to re-teach)")
105    dq = feats["diagnostics"]
106    saved = atlas.teach(
107        label, feats["e"], motion_type=MOTION_TYPE, session=session,
108        properties={"csv": name, "source": "teach_csv.py",
109                    "tare_flags": dq["tare_flags"],
110                    "phase_source": dq["phase_source"],
111                    "z": [round(float(v), 4) for v in feats["z"]],
112                    "n_samples": dq["n_samples"], "fs_hz": dq["fs_hz"]})
113    return ("taught", name, f"'{label}' sig_id={saved['sig_id']}")

Extract features from one CSV and teach it to Atlas.

Args: db: MongoDB database handle. csv_path: path to the recording CSV. session: session tag, e.g. "thu". label_override: forced label, or None. dry_run: report what would be taught without writing. force: re-teach files already in Atlas. include_mystery: also teach mystery-labeled files. require_swirl: skip recordings without a true SWIRL phase.

Returns: Tuple (status, filename, detail) where status is one of "taught", "dry", "skip", "error".

def list_library():
116def list_library():
117    """Print signature counts per label in the Atlas library."""
118    db = atlas.get_db()
119    rows = list(db.signatures.aggregate([
120        {"$group": {"_id": "$label", "n": {"$sum": 1},
121                    "motion_types": {"$addToSet": "$motion_type"},
122                    "sessions": {"$addToSet": "$session"}}},
123        {"$sort": {"_id": 1}}]))
124    if not rows:
125        print("signatures collection is empty")
126        return
127    print(f"{'label':<24} {'n':>3}  motion_types  sessions")
128    for r in rows:
129        print(f"{r['_id']:<24} {r['n']:>3}  "
130              f"{','.join(sorted(r['motion_types']))}  {','.join(sorted(r['sessions']))}")

Print signature counts per label in the Atlas library.

def main():
133def main():
134    """CLI entry: batch-teach recordings to Atlas."""
135    ap = argparse.ArgumentParser(
136        description="Batch-teach recording CSVs into Atlas.",
137        epilog="""examples:
138  python teach_csv.py recordings/ --require-swirl --dry-run   # preview
139  python teach_csv.py recordings/ --require-swirl --session fri
140  python teach_csv.py --list                                  # library contents
141  python teach_csv.py foo.csv --label water_075pct --force    # re-teach one file
142
143Label resolution: --label > object_label column > filename.
144Mystery/unlabeled and non-grasp CSVs are skipped; already-taught files are
145skipped unless --force. Needs MONGODB_URI in .env, and the Atlas IP
146allowlist must include this machine.""",
147        formatter_class=argparse.RawDescriptionHelpFormatter)
148    ap.add_argument("paths", nargs="*", help="CSV files, globs, or dirs (default: recordings/)")
149    ap.add_argument("--session", default="thu", help="session tag (default: thu)")
150    ap.add_argument("--label", default=None, help="force one label for all files")
151    ap.add_argument("--dry-run", action="store_true", help="show what would be taught")
152    ap.add_argument("--force", action="store_true", help="re-teach files already in Atlas")
153    ap.add_argument("--include-mystery", action="store_true")
154    ap.add_argument("--require-swirl", action="store_true",
155                    help="only teach recordings with a true SWIRL phase")
156    ap.add_argument("--list", action="store_true", help="show signature counts per label")
157    args = ap.parse_args()
158
159    if args.list:
160        list_library()
161        return
162
163    files = []
164    for p in args.paths or ["recordings/"]:
165        if os.path.isdir(p):
166            files += sorted(glob.glob(os.path.join(p, "**", "*.csv"), recursive=True))
167        elif os.path.isfile(p):
168            files.append(p)
169        else:
170            files += sorted(glob.glob(p, recursive=True))
171    if not files:
172        sys.exit("no CSV files found")
173
174    db = None if args.dry_run else atlas.get_db()
175    counts = {}
176    for f in files:
177        status, name, detail = teach_file(
178            db, f, args.session, label_override=args.label,
179            dry_run=args.dry_run, force=args.force,
180            include_mystery=args.include_mystery,
181            require_swirl=args.require_swirl)
182        counts[status] = counts.get(status, 0) + 1
183        print(f"[{status:>7}] {name}: {detail}")
184    print("\nsummary:", ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))

CLI entry: batch-teach recordings to Atlas.