#!/usr/bin/env python """Index the SUVI archive and record per-frame quality verdicts. Replaces the older version of this script, which encoded its verdict by renaming files to ``_f``/``_e`` and wrote a diagnostic JPG next to every reject. Verdicts now go to the SQLite index, so the archive keeps NOAA's filenames, several detectors can disagree about a frame, the continuous scores survive for later re-thresholding, and nothing has to be re-read to change a threshold. Subcommands:: filter_FITS.py index # sync the index with the archive filter_FITS.py scan # cache FITS header metadata (~18 KB read per frame) filter_FITS.py detect # run detectors and record verdicts filter_FITS.py plots # regenerate diagnostic images for flagged frames ``index`` and ``scan`` are incremental: they skip what they already have, so they can be re-run as the puller brings in new data. ``index`` reads only the day-directories whose mtime changed, which is what keeps re-indexing affordable -- see suvi/index.py for why traversing this archive in full is something to avoid, not merely optimise. A caution before using ``detect --detectors geometry_v1`` to gate anything: measured over 20 sampled days of 2024, that method rejects 36.9% of frames, and does so all-or-nothing -- 0% on most days, 73-100% on days with a large active region, almost entirely for "failed centre". Its centre test uses the intensity-weighted centroid, which a bright active region displaces even when the header reports the disc exactly centred. Run bench.py before trusting any detector to discard data. """ import argparse import json import os import sys import time from collections import defaultdict from multiprocessing import Pool import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from suvi import db, detectors, fitsio, index, paths, vfs def parse_int_list(text): return tuple(int(part) for part in text.split(",") if part.strip()) # ---------------------------------------------------------------------------- index def cmd_index(args): """Bring the index in step with the archive. Reads only the day-directories whose mtime changed since the last run, so a routine re-index costs ~7,400 directory stats rather than 2.65M file lookups. That ratio is not just a speed matter: a full traversal exhausts the file handles of the virtiofs mount this archive lives on. Use --full only for a cold start or when the index is suspect. """ root = args.root or paths.data_root() conn = db.connect(args.db) satellites = parse_int_list(args.satellites) wavelengths = parse_int_list(args.wavelengths) print(f"Indexing {root}" + (" (full rescan)" if args.full else " (changed directories only)")) summary = index.reconcile( conn, root, satellites, wavelengths, years=args.year, force=args.full, progress=print if args.progress else None, ) total = conn.execute("SELECT count(*) c FROM frame").fetchone()["c"] print( f" {summary['directories_changed']} of {summary['directories_checked']} " f"directories changed in {summary['seconds']:.0f}s" ) print(f" frames added {summary['frames_added']}, removed {summary['frames_removed']}") if summary["directories_vanished"]: print(f" directories that disappeared: {summary['directories_vanished']}") dupes = summary.get("duplicate_slots") or [] if dupes: print(f" {len(dupes)} files share an observation slot with another and were " f"left out of the index (see --duplicates to list them)") if args.duplicates: for path in dupes: print(f" {path}") print(f" index now holds {total} frames") suffixed = conn.execute( "SELECT count(*) c FROM frame WHERE path LIKE '%\\_f.fits' ESCAPE '\\' " " OR path LIKE '%\\_e.fits' ESCAPE '\\'" ).fetchone()["c"] if suffixed: print(f"\n{suffixed} indexed frames still carry _f/_e suffixes.") print("Run migrate_unrename.py to fold those verdicts into the index.") conn.close() return 0 # ----------------------------------------------------------------------------- scan def _scan_one(job): frame_id, path = job values, error = fitsio.scan_header(path) return frame_id, values, error def cmd_scan(args): fitsio.quiet_astropy() conn = db.connect(args.db) root = args.root or paths.data_root() pending = db.unscanned_frames(conn, args.limit) if not pending: print("Every indexed frame already has cached header metadata.") return 0 print(f"Scanning headers for {len(pending)} frames with {args.workers} workers") jobs = [(row["id"], paths.abspath(row["path"], root)) for row in pending] started = time.time() done = failed = 0 batch = [] reliever = vfs.Reliever(label="scan") with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool: for frame_id, values, error in pool.imap_unordered(_scan_one, jobs, 32): reliever.tick() batch.append((frame_id, values, error)) done += 1 failed += bool(error) if len(batch) >= 2000: db.record_headers(conn, batch) conn.commit() batch.clear() rate = done / max(time.time() - started, 1e-6) print(f" {done}/{len(jobs)} ({rate:.0f}/s, {failed} unreadable)") if batch: db.record_headers(conn, batch) conn.commit() print(f"Scanned {done} frames in {time.time() - started:.0f}s; {failed} unreadable") return 0 # --------------------------------------------------------------------------- detect def _detect_one(job): """Run the frame-level detectors on one file.""" frame_id, slot, path, wanted = job verdicts = {} values, header_error = fitsio.scan_header(path) features = detectors.FrameFeatures(slot=slot, header=values, error=header_error) if "header_v1" in wanted: started = time.perf_counter_ns() verdict = detectors.header_v1(features) verdicts["header_v1"] = (verdict, (time.perf_counter_ns() - started) // 1000) pixel_detectors = [n for n in ("geometry_v1", "disc_v1") if n in wanted] if pixel_detectors: image, image_error = fitsio.read_image(path) for name in pixel_detectors: started = time.perf_counter_ns() if image is None: verdict = detectors.Verdict("bad", f"unreadable: {image_error}", {}) elif name == "geometry_v1": verdict = detectors.geometry_v1(image, slot[1]) else: verdict = detectors.disc_v1(image, slot[1], values) verdicts[name] = (verdict, (time.perf_counter_ns() - started) // 1000) return frame_id, verdicts def cmd_detect(args): fitsio.quiet_astropy() conn = db.connect(args.db) root = args.root or paths.data_root() wanted = tuple(args.detectors.split(",")) unknown = [name for name in wanted if name not in detectors.ALL_DETECTORS] if unknown: print(f"Unknown detectors: {unknown}. Available: {list(detectors.ALL_DETECTORS)}") return 1 frame_detectors = [n for n in wanted if n in detectors.FRAME_DETECTORS] series_detectors = [n for n in wanted if n in detectors.SERIES_DETECTORS] if series_detectors: print(f"NOTE: {series_detectors} need a whole series in memory; use bench.py " "for those, or run them here one band at a time via --wavelengths.") where, params = ["1=1"], [] if args.start is not None: where.append("t_start >= ?") params.append(args.start) if args.end is not None: where.append("t_start < ?") params.append(args.end) if args.wavelengths: bands = parse_int_list(args.wavelengths) where.append(f"wavelength IN ({','.join('?' * len(bands))})") params.extend(bands) rows = conn.execute( f"SELECT id, path, satellite, wavelength, t_start FROM frame " f"WHERE {' AND '.join(where)} ORDER BY t_start", params, ).fetchall() if not rows: print("No indexed frames match; run `filter_FITS.py index` first.") return 1 runs = { name: db.create_detector_run(conn, name, {"driver": "filter_FITS"}, notes=args.notes) for name in frame_detectors } print(f"Running {frame_detectors} over {len(rows)} frames with {args.workers} workers") jobs = [ ( row["id"], (row["satellite"], row["wavelength"], row["t_start"]), paths.abspath(row["path"], root), frozenset(frame_detectors), ) for row in rows ] started = time.time() pending = defaultdict(list) tallies = {name: defaultdict(int) for name in frame_detectors} done = 0 reliever = vfs.Reliever(label="detect") with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool: for frame_id, verdicts in pool.imap_unordered(_detect_one, jobs, 16): reliever.tick() for name, (verdict, elapsed) in verdicts.items(): pending[name].append( (frame_id, verdict.verdict, verdict.reason, verdict.scores, elapsed) ) tallies[name][verdict.verdict] += 1 done += 1 if done % 5000 == 0: for name, batch in pending.items(): db.record_detections(conn, runs[name], batch) batch.clear() conn.commit() print(f" {done}/{len(jobs)} ({done / max(time.time() - started, 1e-6):.0f}/s)") for name, batch in pending.items(): if batch: db.record_detections(conn, runs[name], batch) conn.commit() print(f"\nDone in {time.time() - started:.0f}s") for name in frame_detectors: counts = tallies[name] total = sum(counts.values()) or 1 print(f" {name}: run {runs[name]} -- " + ", ".join(f"{v} {c} ({c / total:.1%})" for v, c in sorted(counts.items()))) return 0 # ------------------------------------------------------------------------ calibrate #: Absolute floor on each measurement's half-width, so a band whose sample happens to #: be unusually tight does not get bounds narrower than the physics justifies. Set #: from the observed cross-band spread of each quantity. CALIBRATION_FLOORS = {"limb_contrast": 0.15, "radius_ratio": 0.06, "limb_width": 0.06} #: Which side of each measurement actually diagnoses a fault. Bounding the other #: side buys nothing and, on a wide-spread quantity, produces a negative limit that #: can never trip -- which looks like a rule while being none. #: #: * limb_contrast falls towards zero as the disc disappears; it cannot exceed 1. #: * radius_ratio is diagnostic in both directions -- too small or too large is wrong. #: * limb_width grows as the limb smears; a sharper-than-usual limb is not a fault. CALIBRATION_SIDES = { "limb_contrast": ("lower", 0.02, 1.0), "radius_ratio": ("both", None, None), "limb_width": ("upper", 0.0, None), } def _sample_frames(conn, root, wavelength, count, satellites=(16, 18)): """Pick frames spread evenly across the whole time range of one band. Queries the index rather than walking the archive -- which is the point of having an index, and avoids the traversal that exhausts this mount (see suvi/index.py). Deliberately does *not* filter on the legacy ``_f``/``_e`` verdicts. Those were written in May 2024 by a filter retuned that July, so selecting on them would calibrate the new detector against the old one's mistakes -- including the very active-region rejections this detector exists to stop. """ placeholders = ",".join("?" * len(satellites)) rows = conn.execute( f"SELECT path FROM frame WHERE wavelength = ? AND satellite IN ({placeholders}) " f"ORDER BY t_start", (wavelength, *satellites), ).fetchall() if not rows: return [] stride = max(1, len(rows) // count) return [paths.abspath(row["path"], root) for row in rows[::stride]][:count] def _calibration_measure(path): """Measure one frame, skipping any the header already condemns.""" values, error = fitsio.scan_header(path) if error or not values: return None if values.get("eclipse") or values.get("empty"): return None # genuinely bad; must not widen the good-frame bounds diameter = values.get("diam_sun") mean = values.get("img_mean") if not diameter or mean is None: return None low, high = detectors.HEADER_MEAN_BOUNDS.get( int(values.get("wavelnth") or 0), (0.0, float("inf")) ) if not (low <= mean <= high): return None # dropout or blowout image, image_error = fitsio.read_image(path) if image is None: return None return detectors.disc_profile(image, float(diameter) / 4.0) def cmd_calibrate(args): fitsio.quiet_astropy() root = args.root or paths.data_root() conn = db.connect(args.db, readonly=True) wavelengths = parse_int_list(args.wavelengths) margin = args.margin table = {} print(f"Calibrating disc_v1 from {root}") print(f" {args.samples_per_band} frames per band, margin {margin}x the observed span\n") for wavelength in wavelengths: candidates = _sample_frames(conn, root, wavelength, args.samples_per_band * 2) if not candidates: print(f"{wavelength:>5}: no frames found") continue results = [] reliever = vfs.Reliever(label=f"calibrate {wavelength}A") with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool: for measured in pool.imap_unordered(_calibration_measure, candidates, 8): reliever.tick() if measured is None: continue if any(v is None for v in measured.values()): results.append(measured) # keep, so unmeasurable rate is visible continue results.append(measured) if len(results) >= args.samples_per_band: break usable = [r for r in results if all(v is not None for v in r.values())] unmeasurable = len(results) - len(usable) if len(usable) < 20: print(f"{wavelength:>5}: only {len(usable)} usable frames; skipping") continue bounds = {} print(f"{wavelength:>5}A n={len(usable)} unmeasurable={unmeasurable}") for name in ("limb_contrast", "radius_ratio", "limb_width"): values = np.array([r[name] for r in usable], dtype=float) low, high = float(values.min()), float(values.max()) span = high - low half = max(margin * span, CALIBRATION_FLOORS[name]) side, floor, ceiling = CALIBRATION_SIDES[name] lower = low - half if side in ("lower", "both") else floor upper = high + half if side in ("upper", "both") else ceiling # Keep each limit on the physical side of zero. A negative floor on a # non-negative quantity is not a loose rule, it is no rule at all. if floor is not None: lower = max(lower, floor) if ceiling is not None: upper = min(upper, ceiling) bounds[name] = (round(lower, 4), round(upper, 4)) outside = int(((values < lower) | (values > upper)).sum()) print(f" {name:<14} observed {low:.4f}-{high:.4f} " f"p50 {np.median(values):.4f} -> bounds {lower:.4f}-{upper:.4f} " f"sample failures {outside}/{len(values)}") table[wavelength] = bounds if not table: print("\nNothing calibrated.") return 1 print("\n" + "=" * 78) print("Paste into DISC_BOUNDS in suvi/detectors.py:\n") print("DISC_BOUNDS = {") for wavelength, bounds in sorted(table.items()): print(f" {wavelength}: {{") for name, (lower, upper) in bounds.items(): print(f' "{name}": ({lower}, {upper}),') print(" },") print("}") if args.json: with open(args.json, "w") as handle: json.dump(table, handle, indent=2) print(f"\nAlso written to {args.json}") return 0 # --------------------------------------------------------------------------- sample #: The production combination: a frame is bad if either detector says so. SAMPLE_DETECTORS = ("header_v1", "disc_v1") def _judge_timestamp(job): """Read all six bands for one (satellite, timestamp) and judge each. Returns (satellite, timestamp, {band: (verdict, reason, scores)}), or None if the set is incomplete -- a composite needs all six. """ satellite, timestamp, band_paths = job verdicts = {} for band, path in sorted(band_paths.items()): values, header_error = fitsio.scan_header(path) features = detectors.FrameFeatures( slot=(satellite, band, timestamp), header=values, error=header_error ) header = detectors.header_v1(features) image, image_error = fitsio.read_image(path) if image is None: disc = detectors.Verdict("bad", f"unreadable: {image_error}", {}) else: disc = detectors.disc_v1(image, band, values) bad = [v for v in (header, disc) if v.verdict == "bad"] verdict = "bad" if bad else "good" reason = "; ".join(v.reason for v in bad if v.reason) or None verdicts[band] = (verdict, reason, {**header.scores, **disc.scores}) return satellite, timestamp, verdicts def _render_sample(job): """Render one annotated composite for review.""" import merger_FITS from PIL import ImageDraw, ImageFont satellite, timestamp, band_paths, caption, out_path = job try: arrays = [] for band in sorted(band_paths): image, error = fitsio.read_image(band_paths[band]) if image is None: return out_path, f"band {band}: {error}" arrays.append(image) picture = merger_FITS.composite_from_arrays(arrays, timestamp) font_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "OpenSans-Regular.ttf") font = ImageFont.truetype(font_path, size=20) draw = ImageDraw.Draw(picture) # Caption along the bottom, where it covers no solar structure. y = picture.size[1] - 26 * len(caption) for line in caption: draw.text((12, y), line, (255, 235, 120), font=font) y += 26 picture.save(out_path, quality=95) return out_path, None except Exception as exc: return out_path, f"{type(exc).__name__}: {exc}" def cmd_sample(args): """Write folders of suspected-good and suspected-bad frames for manual review. The bench measures detectors against damage it injected itself, which proves they find what was planted but says nothing about what they do to real frames nobody labelled. This is that check: a person looks at what the detector kept and what it threw away, and says whether they agree. Each image is the full composite for the frame's timestamp and satellite -- which already tiles all six bands down its sides -- captioned with the verdict, the band responsible, and the scores behind it. """ fitsio.quiet_astropy() root = args.root or paths.data_root() conn = db.connect(args.db, readonly=True) satellites = parse_int_list(args.satellites) # Candidate timestamps spread evenly across whatever the index holds, so the # sample is not a picture of one week. rows = conn.execute( f"SELECT satellite, t_start, wavelength, path FROM frame " f"WHERE satellite IN ({','.join('?' * len(satellites))}) " f"ORDER BY t_start", satellites, ).fetchall() conn.close() by_key = defaultdict(dict) for row in rows: by_key[(row["satellite"], row["t_start"])][row["wavelength"]] = paths.abspath( row["path"], root ) complete = [k for k, v in by_key.items() if len(v) == 6] if not complete: print("No timestamp has all six bands; cannot build composites.") return 1 stride = max(1, len(complete) // max(args.scan, 1)) candidates = sorted(complete)[::stride][: args.scan] print(f"{len(complete)} complete timestamps in the index; judging {len(candidates)}") good, bad = [], [] reliever = vfs.Reliever(label="sample") jobs = [(sat, when, by_key[(sat, when)]) for sat, when in candidates] with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool: for result in pool.imap_unordered(_judge_timestamp, jobs, 4): reliever.tick(6) if result is None: continue satellite, timestamp, verdicts = result flagged = {b: v for b, v in verdicts.items() if v[0] == "bad"} if flagged and len(bad) < args.n: bad.append((satellite, timestamp, flagged)) elif not flagged and len(good) < args.n: good.append((satellite, timestamp, verdicts)) if len(good) >= args.n and len(bad) >= args.n: break reliever.finish() print(f" collected {len(bad)} flagged and {len(good)} clean timestamps") if len(bad) < args.n: print(f" NOTE: only {len(bad)} flagged found in {len(candidates)} scanned; " f"raise --scan for more.") render_jobs = [] for folder, items in (("bad", bad), ("good", good)): out_dir = os.path.join(args.out, folder) os.makedirs(out_dir, exist_ok=True) for satellite, timestamp, verdicts in items: stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime(timestamp)) if folder == "bad": bands = ",".join(f"{b}A" for b in sorted(verdicts)) first = verdicts[sorted(verdicts)[0]] # Lead the filename with the reason so the folder sorts by failure mode. slug = "".join( ch if ch.isalnum() else "_" for ch in (first[1] or "flagged") )[:40] caption = [ f"FLAGGED g{satellite} {stamp} bands: {bands}", (first[1] or "")[:110], " ".join(f"{k}={v:.3g}" for k, v in list(first[2].items())[:5]), ] name = f"{slug}__g{satellite}_{stamp}.jpg" else: sample_scores = verdicts[sorted(verdicts)[0]][2] caption = [ f"CLEAN g{satellite} {stamp} all six bands passed", " ".join(f"{k}={v:.3g}" for k, v in list(sample_scores.items())[:5]), ] name = f"g{satellite}_{stamp}.jpg" render_jobs.append( (satellite, timestamp, by_key[(satellite, timestamp)], caption, os.path.join(out_dir, name)) ) print(f"Rendering {len(render_jobs)} composites...") failures = 0 with Pool(args.workers, initializer=fitsio.quiet_astropy) as pool: for path, error in pool.imap_unordered(_render_sample, render_jobs, 2): if error: failures += 1 if failures <= 3: print(f" failed {os.path.basename(path)}: {error}") print(f" wrote {len(render_jobs) - failures} images to {args.out}") print(f" review {args.out}/bad -- nothing here should be a frame you would keep") print(f" review {args.out}/good -- nothing here should be a frame you would discard") return 0 # ---------------------------------------------------------------------------- plots def cmd_plots(args): """Regenerate the diagnostic image the old filter wrote beside every reject. Kept as an on-demand tool rather than a side effect of filtering: the plots are derivable from the index plus the FITS, and writing millions of them into the archive was what made the old approach hard to undo. """ import matplotlib matplotlib.use("Agg") import numpy as np from matplotlib import pyplot as plt fitsio.quiet_astropy() conn = db.connect(args.db, readonly=True) root = args.root or paths.data_root() run_id = db.latest_run_id(conn, args.detector) if run_id is None: print(f"No run named {args.detector!r}") return 1 rows = conn.execute( """ SELECT f.path, f.wavelength, d.reason FROM detection d JOIN frame f ON f.id = d.frame_id WHERE d.run_id = ? AND d.verdict = 'bad' ORDER BY f.t_start LIMIT ? """, (run_id, args.limit), ).fetchall() os.makedirs(args.out, exist_ok=True) print(f"Writing {len(rows)} diagnostic plots to {args.out}") reference = detectors.ideal_disc_axis() axis = list(range(detectors.HALF_DIMS)) for row in rows: path = paths.abspath(row["path"], root) image, error = fitsio.read_image(path) if image is None: continue import cv2 as cv threshold = detectors.GEOMETRY_THRESHOLDS[row["wavelength"]] data = cv.resize( np.nan_to_num(image), (detectors.HALF_DIMS, detectors.HALF_DIMS), interpolation=cv.INTER_LINEAR, ) shown = np.clip(data, None, threshold) / threshold xavg, yavg = np.average(shown, 0), np.average(shown, 1) figure, axes = plt.subplots( 2, 2, width_ratios=(0.2, 1), height_ratios=(0.2, 1), gridspec_kw={"hspace": 0.0, "wspace": 0.0}, figsize=(8, 8), ) axes[0][0].axis("off") axes[0][1].plot(axis, xavg) axes[0][1].plot(axis, reference) axes[1][0].plot(yavg, axis) axes[1][0].plot(reference, axis) axes[1][0].invert_xaxis() axes[1][0].invert_yaxis() axes[1][1].imshow(shown, aspect="auto", vmin=0, vmax=1) axes[0][1].sharex(axes[1][1]) axes[1][0].sharey(axes[1][1]) figure.suptitle(f"{os.path.basename(row['path'])}\n{row['reason']}", fontsize=8) figure.savefig( os.path.join(args.out, os.path.basename(row["path"]).replace(".fits", ".jpg")), dpi=110, ) plt.close(figure) return 0 # ------------------------------------------------------------------------------ cli def build_parser(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--db", default=None, help="SQLite index (default: $SUVI_DB)") parser.add_argument("--root", default=None, help="archive root (default: $SUVI_DATA_ROOT)") subparsers = parser.add_subparsers(dest="command", required=True) index_cmd = subparsers.add_parser( "index", help="sync the index with the archive (only changed directories)" ) index_cmd.add_argument("--satellites", default="16,18,19") index_cmd.add_argument("--wavelengths", default="94,131,171,195,284,304") index_cmd.add_argument("--year", type=int, action="append", default=None) index_cmd.add_argument("--full", action="store_true", help="re-read every directory, ignoring recorded mtimes") index_cmd.add_argument("--progress", action="store_true") index_cmd.add_argument("--duplicates", action="store_true", help="list files that share an observation slot") index_cmd.set_defaults(func=cmd_index) scan = subparsers.add_parser("scan", help="cache FITS header metadata") scan.add_argument("--workers", type=int, default=16) scan.add_argument("--limit", type=int, default=None) scan.set_defaults(func=cmd_scan) detect = subparsers.add_parser("detect", help="run detectors and record verdicts") detect.add_argument("--detectors", default="header_v1") detect.add_argument("--wavelengths", default=None) detect.add_argument("--start", type=int, default=None, help="Unix time, inclusive") detect.add_argument("--end", type=int, default=None, help="Unix time, exclusive") detect.add_argument("--workers", type=int, default=16) detect.add_argument("--notes", default=None) detect.set_defaults(func=cmd_detect) calibrate = subparsers.add_parser( "calibrate", help="derive per-band disc_v1 bounds from a sample of good frames" ) calibrate.add_argument("--wavelengths", default="94,131,171,195,284,304") calibrate.add_argument("--samples-per-band", type=int, default=300) calibrate.add_argument("--margin", type=float, default=1.0, help="half-width added either side, in multiples of the observed span") calibrate.add_argument("--workers", type=int, default=16) calibrate.add_argument("--json", default=None) calibrate.set_defaults(func=cmd_calibrate) sample = subparsers.add_parser( "sample", help="write good/bad frame folders for manual review" ) sample.add_argument("--n", type=int, default=100, help="frames per folder") sample.add_argument("--out", required=True) sample.add_argument("--scan", type=int, default=4000, help="timestamps to judge while looking for N of each") sample.add_argument("--satellites", default="16,18") sample.add_argument("--workers", type=int, default=8) sample.set_defaults(func=cmd_sample) plots = subparsers.add_parser("plots", help="regenerate diagnostics for flagged frames") plots.add_argument("--detector", default="geometry_v1") plots.add_argument("--out", default="diagnostics") plots.add_argument("--limit", type=int, default=200) plots.set_defaults(func=cmd_plots) return parser def main(argv=None): args = build_parser().parse_args(argv) return args.func(args) or 0 if __name__ == "__main__": sys.exit(main())