Source code for tidal.cli

"""Command-line interface for the Lagrangian-to-PDE pipeline.

Entry point: ``tidal`` command with subcommands:

- ``tidal derive``   — Derive equations from Lagrangian via Wolfram/xAct
- ``tidal inspect``  — Display equation system information from JSON
- ``tidal simulate`` — Run PDE simulation from JSON specification
- ``tidal measure``  — Extract physics measurements from simulation output
- ``tidal plot``     — Generate individual plots from simulation output
- ``tidal sweep``    — Run parameter sweeps and convergence studies
- ``tidal list``     — List available JSON specifications
- ``tidal validate`` — Validate a JSON equation specification
"""

from __future__ import annotations

import argparse
import os
import sys

from tidal.solver._defaults import DEFAULT_ATOL, DEFAULT_RTOL

__all__ = ["main"]


def _build_parser() -> argparse.ArgumentParser:  # noqa: PLR0915
    """Build the top-level argument parser with subcommands."""
    # Use rich-argparse for colored help if available, fallback to default
    try:
        from rich_argparse import RichHelpFormatter

        formatter = RichHelpFormatter
    except ImportError:
        formatter = argparse.HelpFormatter

    parser = argparse.ArgumentParser(
        prog="tidal",
        description="Lagrangian-to-PDE pipeline: derive, inspect, simulate, measure, plot, sweep, list.",
        formatter_class=formatter,
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {_get_version()}",
    )
    parser.add_argument(
        "--no-banner",
        action="store_true",
        default=False,
        help="Suppress the startup banner",
    )
    parser.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        default=False,
        help="Show detailed diagnostic output (solver selection, CFL, Jacobian tier)",
    )
    parser.add_argument(
        "--cite",
        action="store_true",
        default=False,
        help="Print citation information for TIDAL and its dependencies",
    )
    parser.add_argument(
        "--time",
        action="store_true",
        default=False,
        help="Show wall-clock elapsed time after command completes",
    )
    sub = parser.add_subparsers(dest="command", help="Available commands")

    # --- derive ---
    derive_parser = sub.add_parser(
        "derive",
        aliases=["der"],
        help="Derive equations from Lagrangian via Wolfram/xAct",
        description=(
            "Generate equations of motion from a Lagrangian. "
            "Accepts a TOML config file (.toml) or a Wolfram script (.wls)."
        ),
        epilog=(
            "Examples:\n"
            "  tidal derive theory.toml                     # run derivation\n"
            "  tidal derive theory.toml --dry-run            # preview .wls without running\n"
            "  tidal derive theory.toml --save-script eq.wls # save generated script\n"
            "  tidal derive script.wls                       # run existing .wls directly"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    derive_parser.add_argument(
        "config",
        help="Path to .toml config or .wls script (auto-detected by extension)",
    )
    derive_parser.add_argument(
        "--save-script",
        metavar="PATH",
        default=None,
        help="Save generated .wls script to this path (TOML mode only)",
    )
    derive_parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Print generated .wls without running wolframscript",
    )
    derive_parser.add_argument(
        "--force-derive",
        action="store_true",
        help="Force re-derivation even if the generated script is unchanged",
    )
    derive_parser.add_argument(
        "--output",
        metavar="PATH",
        default=None,
        help="Override output JSON path from config",
    )
    derive_parser.add_argument(
        "--timeout",
        type=int,
        default=600,
        metavar="SECONDS",
        help=(
            "Maximum time (seconds) for wolframscript execution. "
            "Default: 600 (10 min). If exceeded, investigate which pipeline "
            "stage is the bottleneck and optimize. Use 0 for no timeout."
        ),
    )

    # --- inspect ---
    inspect_parser = sub.add_parser(
        "inspect",
        aliases=["insp"],
        help="Display equation system information from JSON",
        description="Load a JSON specification and display its contents.",
        epilog=(
            "Examples:\n"
            "  tidal inspect examples/data/klein_gordon_1d.json\n"
            "  tidal inspect spec.json --params    # show default parameter values"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    inspect_parser.add_argument(
        "json_path",
        help="Path to the JSON equation specification",
    )
    inspect_parser.add_argument(
        "--params",
        action="store_true",
        help="Show default parameter values from metadata",
    )
    inspect_parser.add_argument(
        "--json",
        action="store_true",
        dest="json_output",
        help="Output machine-readable JSON instead of human-readable text",
    )
    inspect_parser.add_argument(
        "--latex",
        action="store_true",
        help="Output equations as LaTeX math",
    )
    inspect_parser.add_argument(
        "--latex-format",
        choices=["align", "document", "raw"],
        default="align",
        dest="latex_format",
        help="LaTeX output format (default: align). 'document' wraps in standalone .tex",
    )

    # --- simulate ---
    sim_parser = sub.add_parser(
        "simulate",
        aliases=["sim"],
        help="Run PDE simulation from JSON specification",
        description="Build and run a PDE simulation from a JSON equation specification.",
        epilog=(
            "Examples:\n"
            "  tidal simulate spec.json --param m2=1.0 --t-end 10\n"
            "  tidal simulate spec.json --ic gaussian --ic-width 2.0 --output result.png\n"
            "  tidal simulate spec.json --mode constraint --bc neumann\n"
            "  tidal simulate spec.json --ic formula --ic-formula 'exp(-(x-5)**2)'"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    sim_parser.add_argument(
        "json_path",
        nargs="?",
        default=None,
        help="Path to the JSON equation specification",
    )
    # Parameters
    sim_parser.add_argument(
        "--param",
        action="append",
        default=[],
        metavar="KEY=VAL",
        help="Set symbolic parameter (repeatable, e.g. --param m2=1.0)",
    )
    # Grid
    sim_parser.add_argument(
        "--grid-shape",
        default=None,
        metavar="N[,N,N]",
        help="Grid points per axis (e.g. 32 or 32,32,32). Default: auto from dimension",
    )
    sim_parser.add_argument(
        "--bounds",
        default=None,
        metavar="LO:HI[,...]",
        help="Domain bounds per axis (e.g. 0:20 or 0:20,0:10). Default: 0:10",
    )
    sim_parser.add_argument(
        "--periodic",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Use periodic boundary conditions (default: True)",
    )
    sim_parser.add_argument(
        "--bc",
        default=None,
        metavar="BC[,BC,BC]",
        help="Per-axis boundary conditions: periodic, neumann (comma-separated). Overrides --periodic.",
    )
    # Initial conditions
    sim_parser.add_argument(
        "--ic",
        choices=["gaussian", "plane-wave", "zero", "formula", "file", "noise"],
        default="gaussian",
        help="Initial condition type (default: gaussian)",
    )
    sim_parser.add_argument(
        "--ic-formula",
        default=None,
        metavar="EXPR",
        help="Math expression for --ic=formula. Variables: coordinate names (e.g. x,y,z), np (numpy), pi.",
    )
    sim_parser.add_argument(
        "--ic-center",
        default=None,
        metavar="X[,X,X]",
        help="Gaussian center position (default: domain midpoint)",
    )
    sim_parser.add_argument(
        "--ic-width",
        type=float,
        default=None,
        metavar="W",
        help="Gaussian width (default: domain_size/10)",
    )
    sim_parser.add_argument(
        "--ic-amplitude",
        type=float,
        default=1.0,
        metavar="A",
        help="IC peak amplitude (default: 1.0)",
    )
    sim_parser.add_argument(
        "--ic-component",
        default=None,
        metavar="NAME",
        help="Field component for IC (default: first field)",
    )
    sim_parser.add_argument(
        "--ic-wavevector",
        default=None,
        metavar="K[,K,K]",
        help="Wavevector for plane-wave or gaussian IC (e.g. 3 or 0.1,0.0,0.0). "
        "With gaussian: creates a travelling wave packet (positive k = right-mover)",
    )
    sim_parser.add_argument(
        "--ic-formula-velocity",
        default=None,
        metavar="EXPR",
        help="Velocity (time derivative) expression for --ic=formula. "
        "Same namespace as --ic-formula (x, y, z, sin, cos, exp, ...).",
    )
    sim_parser.add_argument(
        "--ic-field",
        action="append",
        default=[],
        metavar="FIELD:EXPR",
        help="Per-field IC formula override (repeatable). "
        "Format: FIELD:EXPR or FIELD:velocity:EXPR. "
        "Applied after --ic. Example: --ic-field 'chi_0:0.1*sin(x)'",
    )
    sim_parser.add_argument(
        "--ic-file",
        default=None,
        metavar="PATH",
        help="Path to .npy file or simulation output directory for --ic=file.",
    )
    sim_parser.add_argument(
        "--ic-noise-seed",
        type=int,
        default=None,
        metavar="N",
        help="Random seed for --ic=noise (reproducible noise).",
    )
    # Mode
    sim_parser.add_argument(
        "--mode",
        choices=["evolve", "constraint"],
        default="evolve",
        help="'evolve' = time evolution (default), 'constraint' = single constraint solve",
    )
    # Solver
    sim_parser.add_argument(
        "--t-end",
        type=float,
        default=10.0,
        help="Simulation duration (default: 10.0)",
    )
    sim_parser.add_argument(
        "--dt",
        type=float,
        default=None,
        help="Time step (default: auto from CFL)",
    )
    sim_parser.add_argument(
        "--scheme",
        choices=["ida", "leapfrog", "cvode", "scipy", "modal", "auto"],
        default="auto",
        help=(
            "Solver scheme (default: auto). "
            "'auto' selects the best adaptive solver: cvode for wave systems, "
            "ida for DAE/dissipative systems. "
            "'cvode' uses SUNDIALS/CVODE — adaptive BDF/Adams for wave systems. "
            "'ida' uses SUNDIALS/IDA — handles all equation types including constraints. "
            "'scipy' uses scipy.integrate.solve_ivp — DOP853/RK45/Radau/BDF. "
            "'leapfrog' uses symplectic Störmer-Verlet (fixed dt, zero energy drift)."
        ),
    )
    sim_parser.add_argument(
        "--rtol",
        type=float,
        default=DEFAULT_RTOL,
        help=f"Relative tolerance for adaptive solvers (default: {DEFAULT_RTOL})",
    )
    sim_parser.add_argument(
        "--atol",
        type=float,
        default=DEFAULT_ATOL,
        help=f"Absolute tolerance for adaptive solvers (default: {DEFAULT_ATOL})",
    )
    sim_parser.add_argument(
        "--method",
        type=str,
        default=None,
        help=(
            "Integration method (default: auto). "
            "cvode: 'BDF' (default) or 'Adams'. "
            "scipy: 'DOP853' (default), 'RK45', 'Radau', 'BDF', 'RK23', 'LSODA'."
        ),
    )
    sim_parser.add_argument(
        "--max-step",
        type=float,
        default=None,
        help=(
            "Maximum step size for adaptive solvers. "
            "Default: unbounded for cvode/ida, CFL dt for scipy."
        ),
    )
    sim_parser.add_argument(
        "--snapshots",
        type=float,
        default=None,
        metavar="DT",
        help="Snapshot interval (default: t_end/100)",
    )
    sim_parser.add_argument(
        "--fd-order",
        type=int,
        choices=[2, 4, 6],
        default=4,
        help=(
            "Finite-difference accuracy order for spatial operators (default: 4). "
            "Higher orders use wider stencils: 2->3pt, 4->5pt, 6->7pt. "
            "Order 4 is recommended: O(dx^4) convergence at ~2x per-point cost "
            "vs order 2 — typically enables halving N for the same accuracy. "
            "Ref: Fornberg (1988), Mathematics of Computation 51(184)."
        ),
    )
    sim_parser.add_argument(
        "--leapfrog-order",
        type=int,
        choices=[2, 4],
        default=None,
        help=(
            "Leapfrog integrator order. Default: auto-detect (4 for time-independent "
            "non-dissipative systems, 2 otherwise). "
            "2 = Störmer-Verlet (1 force eval/step). "
            "4 = Yoshida triple-composition (3 force evals/step, O(dt⁴) accuracy). "
            "Yoshida allows ~2x larger dt for the same accuracy. "
            "Only applies when --scheme leapfrog is used. "
            "Ref: Yoshida (1990), Physics Letters A 150(5-7), pp. 262-268."
        ),
    )
    sim_parser.add_argument(
        "--spectral",
        action=argparse.BooleanOptionalAction,
        default=None,
        help=(
            "Use FFT-based spectral operators instead of finite-difference stencils. "
            "Default: auto-detect (enabled when all BCs are periodic and solver is "
            "not IDA). Use --no-spectral to force finite-difference stencils. "
            "Achieves exponential convergence for smooth problems -- typically "
            "N=64-128 instead of N=512-1024. "
            "Incompatible with --scheme ida (use leapfrog, cvode, or scipy). "
            "Ref: Burns et al. (2020), Phys. Rev. Research 2:023068."
        ),
    )
    # Output
    sim_parser.add_argument(
        "--output",
        default=None,
        metavar="PATH",
        help="Output file path (default: {spec_dir}/{stem}_output.png)",
    )
    sim_parser.add_argument(
        "--format",
        choices=["png", "summary"],
        default=None,
        dest="output_format",
        help="Output format (default: inferred from --output extension)",
    )
    sim_parser.add_argument(
        "--no-plot",
        action="store_true",
        help="Print summary only, skip visualization",
    )
    sim_parser.add_argument(
        "--dry-run",
        action="store_true",
        default=False,
        help="Preview simulation setup without running (grid, solver, IC, memory estimate)",
    )
    sim_parser.add_argument(
        "--list-schemes",
        action="store_true",
        default=False,
        help="List available solver schemes and exit",
    )
    sim_parser.add_argument(
        "--force",
        action="store_true",
        default=False,
        help="Overwrite existing --output directory without prompting",
    )
    sim_parser.add_argument(
        "--report",
        type=str,
        default=None,
        metavar="PATH",
        help="Generate self-contained HTML summary report at PATH",
    )
    sim_parser.add_argument(
        "--quiet",
        "-q",
        action="store_true",
        help="Suppress progress messages (results and errors still shown)",
    )
    sim_parser.add_argument(
        "--require-stable",
        action="store_true",
        default=False,
        help=(
            "Abort if the pre-simulation stability check detects an unstable "
            "mass matrix (negative eigenvalue). Default: warn only."
        ),
    )
    sim_parser.add_argument(
        "--allow-inconsistent-ic",
        action="store_true",
        default=False,
        help=(
            "Allow inconsistent initial conditions for constraint equations. "
            "When set, constraint violations produce warnings instead of errors. "
            "Default: error if constraint ICs cannot be made consistent."
        ),
    )
    # Resume from checkpoint
    sim_parser.add_argument(
        "--resume",
        default=None,
        metavar="DIR",
        help=(
            "Resume simulation from a snapshot directory. "
            "Inherits grid, parameters, and BC from saved metadata. "
            "Loads final snapshot by default (use --snapshot N to pick)."
        ),
    )
    sim_parser.add_argument(
        "--snapshot",
        type=int,
        default=None,
        metavar="N",
        help="Snapshot index to resume from (default: last). Requires --resume.",
    )
    sim_parser.add_argument(
        "--t-additional",
        type=float,
        default=None,
        metavar="T",
        help=(
            "Additional simulation time beyond the checkpoint "
            "(alternative to --t-end when using --resume)."
        ),
    )

    # --- list ---
    list_parser = sub.add_parser(
        "list",
        aliases=["ls"],
        help="List available JSON specifications",
        description="Scan a directory for JSON equation specifications and display summaries.",
        epilog=(
            "Examples:\n"
            "  tidal list                          # scan default examples/data/\n"
            "  tidal list --dir /path/to/specs      # scan custom directory"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    list_parser.add_argument(
        "--dir",
        default=None,
        metavar="PATH",
        help="Directory to scan (default: examples/data/)",
    )

    # --- validate ---
    validate_parser = sub.add_parser(
        "validate",
        aliases=["val"],
        help="Validate a JSON equation specification",
        description="Check a JSON specification for errors (unknown operators, bad references, etc.).",
        epilog=(
            "Examples:\n"
            "  tidal validate examples/data/klein_gordon_1d.json\n"
            "  tidal validate spec.json --stability\n"
            "  tidal validate spec.json --stability --param m2=1.0"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    validate_parser.add_argument(
        "json_path",
        help="Path to the JSON equation specification to validate",
    )
    validate_parser.add_argument(
        "--stability",
        action="store_true",
        default=False,
        help=(
            "Run stability checks: tachyon detection (negative mass-matrix eigenvalues) "
            "and ghost detection (wrong-sign kinetic terms from Hamiltonian). "
            "Uses a 1-point grid; exact for constant-coefficient systems."
        ),
    )
    validate_parser.add_argument(
        "--param",
        action="append",
        default=[],
        metavar="KEY=VAL",
        help="Set symbolic parameter for stability check (repeatable, e.g. --param m2=1.0)",
    )

    # --- measure ---
    measure_parser = sub.add_parser(
        "measure",
        aliases=["meas"],
        help="Extract physics measurements from simulation output",
        description=(
            "Load simulation output from 'tidal simulate --output' and run "
            "measurement analyses (energy, conversion, mixing length, etc.)."
        ),
        epilog=(
            "Examples:\n"
            "  tidal measure result_dir/ --spec spec.json\n"
            "  tidal measure result_dir/ --json\n"
            "  tidal measure result_dir/ --what conversion --source phi_0 --target chi_0\n"
            "  tidal measure result_dir/ --what energy,conservation\n"
            "  tidal measure result_dir/ --output measurement.png"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    measure_parser.add_argument(
        "data_path",
        nargs="?",
        default=None,
        help="Path to the simulation output directory",
    )
    measure_parser.add_argument(
        "--list-types",
        action="store_true",
        default=False,
        help="List available measurement types and exit",
    )
    measure_parser.add_argument(
        "--spec",
        default=None,
        metavar="PATH",
        help="Path to JSON equation spec (auto-discovered from metadata.json if omitted)",
    )
    measure_parser.add_argument(
        "--what",
        default=None,
        metavar="TYPE[,TYPE,...]",
        help=(
            "Measurements to run (comma-separated). "
            "Options: summary, energy, conversion, mixing, spectrum, dispersion, conservation. "
            "Default: summary"
        ),
    )
    measure_parser.add_argument(
        "--source",
        default=None,
        metavar="FIELD[,FIELD,...]",
        help="Source field(s) for conversion measurement (comma-separated)",
    )
    measure_parser.add_argument(
        "--target",
        default=None,
        metavar="FIELD[,FIELD,...]",
        help="Target field(s) for conversion measurement (comma-separated)",
    )
    measure_parser.add_argument(
        "--param",
        action="append",
        default=[],
        metavar="KEY=VAL",
        help="Override parameter value (repeatable, e.g. --param m2=1.0)",
    )
    measure_parser.add_argument(
        "--energy-threshold",
        type=float,
        default=1e-3,
        metavar="T",
        help="Energy conservation threshold (default: 1e-3)",
    )
    measure_parser.add_argument(
        "--output",
        default=None,
        metavar="PATH",
        help="Save measurement plot (.png or .pdf)",
    )
    measure_parser.add_argument(
        "--json",
        action="store_true",
        dest="json_output",
        help="Output measurements as JSON instead of text",
    )
    measure_parser.add_argument(
        "--quiet",
        "-q",
        action="store_true",
        help="Suppress progress messages",
    )

    # --- plot ---
    plot_parser = sub.add_parser(
        "plot",
        help="Generate individual plots from simulation output",
        description=(
            "Read disk-backed simulation output and produce a single focused plot. "
            "Users compose visualizations via multiple calls in shell scripts."
        ),
        epilog=(
            "Examples:\n"
            "  tidal plot output_dir/ --type heatmap --field phi_0\n"
            "  tidal plot output_dir/ --type snapshot --time-index -1\n"
            "  tidal plot output_dir/ --type amplitude --overlay 'exp(-0.1*t)'\n"
            "  tidal plot output_dir/ --type energy --fields phi_0,chi_0\n"
            "  tidal plot output_dir/ --type profile --cross-section y=25.0\n"
            "  tidal plot output_dir/ --type compare --output compare.png\n"
            "  tidal plot sweep_dir/ --type sweep --metric P_final "
            "--overlay 'sin(kappa * B0 * t_end / 2)**2'\n"
            "  tidal plot sweep_dir/ --type sweep --metric P_final "
            "--overlay 'sin(kappa * Bpeak * R * sqrt(pi/2))**2'"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    plot_parser.add_argument(
        "data_dir",
        help="Path to simulation output directory (from 'tidal simulate --output')",
    )
    plot_parser.add_argument(
        "--type",
        required=True,
        choices=[
            "heatmap",
            "snapshot",
            "amplitude",
            "energy",
            "profile",
            "compare",
            "hamiltonian",
            "conservation",
            "sweep",
            "sweep-compare",
            "sweep-parallel",
            "sweep-tornado",
            "sweep-scatter",
            "convergence",
            "replicate-convergence",
        ],
        help="Plot type to generate",
    )
    # Field selection
    plot_parser.add_argument(
        "--field",
        default=None,
        metavar="NAME",
        help="Single field for heatmap/snapshot/profile (default: first field)",
    )
    plot_parser.add_argument(
        "--fields",
        default=None,
        metavar="NAME[,NAME,...]",
        help="Multiple fields for amplitude/energy/compare (default: all)",
    )
    # Time selection
    plot_parser.add_argument(
        "--time-index",
        type=int,
        default=None,
        metavar="N",
        help="Snapshot index for snapshot type (supports negative: -1 = last)",
    )
    plot_parser.add_argument(
        "--time-indices",
        default=None,
        metavar="N,N,...",
        help="Comma-separated time indices for profile (default: 5 evenly spaced)",
    )
    # Spatial slicing
    plot_parser.add_argument(
        "--cross-section",
        default=None,
        metavar="AXIS=VAL",
        help="Slice 2D data along axis (e.g. y=25.0) for profile/compare",
    )
    # Reference overlay
    plot_parser.add_argument(
        "--overlay",
        default=None,
        metavar="EXPR",
        help=(
            "Analytic reference formula for overlay. "
            "For --type=amplitude: variable 't' (time), e.g. 'exp(-0.1*t)'. "
            "For --type=sweep: swept parameter names, fixed parameters, and numeric "
            "sim settings (t_end, grid_shape) are available as variables. "
            "1D example: 'sin(kappa * B0 * t_end / 2)**2'. "
            "2D example: 'sin(kappa * Bpeak * R * sqrt(pi/2))**2' (3-panel figure)."
        ),
    )
    # Conservation threshold
    plot_parser.add_argument(
        "--threshold",
        type=float,
        default=None,
        metavar="T",
        help="Conservation threshold for --type=conservation (default: 1e-3)",
    )
    # Output options
    plot_parser.add_argument(
        "--output",
        default=None,
        metavar="PATH",
        help="Output file path (default: DATA_DIR/{type}.png)",
    )
    plot_parser.add_argument(
        "--dpi",
        type=int,
        default=None,
        metavar="N",
        help="Output resolution in DPI (default: 150)",
    )
    plot_parser.add_argument(
        "--figsize",
        default=None,
        metavar="W,H",
        help="Figure size in inches (e.g. 8,6)",
    )
    plot_parser.add_argument(
        "--cmap",
        default=None,
        metavar="NAME",
        help="Colormap for heatmap/snapshot (default: RdBu_r)",
    )
    plot_parser.add_argument(
        "--log-scale",
        dest="log_scale",
        action="store_true",
        help="Use logarithmic colorbar for heatmaps (useful for inv_B_min)",
    )
    # Sweep-specific options
    plot_parser.add_argument(
        "--metric",
        default=None,
        metavar="NAME[,NAME,...]",
        help="Metric column(s) for sweep plots (e.g. P_max, L_mix, max_energy_error)",
    )
    plot_parser.add_argument(
        "--title",
        default=None,
        help="Custom figure title",
    )
    # Metadata
    plot_parser.add_argument(
        "--spec",
        default=None,
        metavar="PATH",
        help="Override JSON spec path (auto-discovered from metadata.json)",
    )
    plot_parser.add_argument(
        "--param",
        action="append",
        default=[],
        metavar="KEY=VAL",
        help="Override parameter value (repeatable, e.g. --param m2=1.0)",
    )
    plot_parser.add_argument(
        "--quiet",
        "-q",
        action="store_true",
        help="Suppress progress messages",
    )

    # --- sweep ---
    sweep_parser = sub.add_parser(
        "sweep",
        aliases=["sw"],
        help="Run parameter sweeps and convergence studies",
        description=(
            "Run simulate + measure across a parameter grid and aggregate "
            "scalar metrics into portable CSV/JSON output."
        ),
        epilog=(
            "Examples:\n"
            "  tidal sweep spec.json --sweep 'g0=0.1:1.0:10' --measure conversion\n"
            "  tidal sweep spec.json --sweep 'g0=0.1:1.0:5' --sweep 'mChi2=0.5:4.0:8'\n"
            "  tidal sweep spec.json --converge '32,64,128,256' --measure conservation\n"
            "  tidal sweep spec.json --sweep 'g0=0.1,0.5,1.0' --resume --output sweep_out/"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    sweep_parser.add_argument(
        "json_path",
        nargs="?",
        default=None,
        help="Path to JSON equation specification (optional if --config provides spec)",
    )
    # Sweep specification
    sweep_parser.add_argument(
        "--sweep",
        action="append",
        default=[],
        metavar="PARAM=SPEC",
        help=(
            "Parameter sweep spec (repeatable). "
            "Formats: NAME=START:STOP:N (linear), NAME=START:STOP:N:log, "
            "NAME=V1,V2,V3,..."
        ),
    )
    sweep_parser.add_argument(
        "--converge",
        default=None,
        metavar="N1,N2,...",
        help="Grid convergence study (comma-separated grid sizes). Mutually exclusive with --sweep.",
    )
    # Measurement config
    sweep_parser.add_argument(
        "--measure",
        default=None,
        metavar="TYPE[,TYPE,...]",
        help=(
            "Measurements to run per simulation (comma-separated). "
            "Options: summary, energy, conversion, mixing, dispersion, "
            "conservation, effective_mass, asymptotic, peak_conversion. "
            "Default: summary"
        ),
    )
    sweep_parser.add_argument(
        "--source",
        default=None,
        metavar="FIELD[,FIELD,...]",
        help="Source field(s) for conversion measurement (comma-separated)",
    )
    sweep_parser.add_argument(
        "--target",
        default=None,
        metavar="FIELD[,FIELD,...]",
        help="Target field(s) for conversion measurement (comma-separated)",
    )
    sweep_parser.add_argument(
        "--energy-threshold",
        type=float,
        default=1e-3,
        metavar="T",
        help="Energy conservation threshold (default: 1e-3)",
    )
    # Simulation passthrough flags (same as simulate)
    sweep_parser.add_argument(
        "--param",
        action="append",
        default=[],
        metavar="KEY=VAL",
        help="Set fixed parameter (repeatable, e.g. --param m2=1.0)",
    )
    sweep_parser.add_argument(
        "--grid-shape",
        default=None,
        metavar="N[,N,N]",
        help="Grid points per axis (default: auto)",
    )
    sweep_parser.add_argument(
        "--bounds",
        default=None,
        metavar="LO:HI[,...]",
        help="Domain bounds per axis (e.g. 0:20 or -50:50)",
    )
    sweep_parser.add_argument(
        "--periodic",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Use periodic boundary conditions (default: True)",
    )
    sweep_parser.add_argument(
        "--bc",
        default=None,
        metavar="BC[,BC,BC]",
        help="Per-axis boundary conditions (periodic, neumann). Overrides --periodic.",
    )
    sweep_parser.add_argument(
        "--ic",
        choices=["gaussian", "plane-wave", "zero", "formula", "file", "noise"],
        default="gaussian",
        help="Initial condition type (default: gaussian)",
    )
    sweep_parser.add_argument(
        "--ic-formula",
        default=None,
        metavar="EXPR",
        help="IC formula expression (for --ic=formula)",
    )
    sweep_parser.add_argument(
        "--ic-center",
        default=None,
        metavar="X[,X,X]",
        help="Gaussian center position (default: domain midpoint)",
    )
    sweep_parser.add_argument(
        "--ic-width",
        type=float,
        default=None,
        metavar="W",
        help="Gaussian width (default: domain_size/10)",
    )
    sweep_parser.add_argument(
        "--ic-amplitude",
        type=float,
        default=1.0,
        metavar="A",
        help="IC amplitude (default: 1.0)",
    )
    sweep_parser.add_argument(
        "--ic-component",
        default=None,
        metavar="NAME",
        help="Field component for IC (default: first field)",
    )
    sweep_parser.add_argument(
        "--ic-wavevector",
        default=None,
        metavar="K[,K,K]",
        help="Wavevector for plane-wave or gaussian IC",
    )
    sweep_parser.add_argument(
        "--ic-formula-velocity",
        default=None,
        metavar="EXPR",
        help="Velocity formula for --ic=formula",
    )
    sweep_parser.add_argument(
        "--ic-field",
        action="append",
        default=[],
        metavar="FIELD:EXPR",
        help="Per-field IC formula override (repeatable)",
    )
    sweep_parser.add_argument(
        "--ic-file",
        default=None,
        metavar="PATH",
        help="IC file path (for --ic=file)",
    )
    sweep_parser.add_argument(
        "--ic-noise-seed",
        type=int,
        default=None,
        metavar="N",
        help="Random seed for --ic=noise",
    )
    sweep_parser.add_argument(
        "--t-end",
        type=float,
        default=10.0,
        help="Simulation duration (default: 10.0)",
    )
    sweep_parser.add_argument(
        "--dt",
        type=float,
        default=None,
        help="Time step (default: auto from CFL)",
    )
    sweep_parser.add_argument(
        "--scheme",
        choices=["ida", "leapfrog", "cvode", "scipy", "modal", "auto"],
        default="auto",
        help="Solver scheme (default: auto)",
    )
    sweep_parser.add_argument(
        "--rtol",
        type=float,
        default=DEFAULT_RTOL,
        help=f"Relative tolerance (default: {DEFAULT_RTOL})",
    )
    sweep_parser.add_argument(
        "--atol",
        type=float,
        default=DEFAULT_ATOL,
        help=f"Absolute tolerance (default: {DEFAULT_ATOL})",
    )
    sweep_parser.add_argument(
        "--method",
        type=str,
        default=None,
        help="Integration method (default: auto)",
    )
    sweep_parser.add_argument(
        "--max-step",
        type=float,
        default=None,
        help="Maximum step size for adaptive solvers",
    )
    sweep_parser.add_argument(
        "--snapshots",
        type=float,
        default=None,
        metavar="DT",
        help="Snapshot interval (default: t_end/100)",
    )
    sweep_parser.add_argument(
        "--fd-order",
        type=int,
        choices=[2, 4, 6],
        default=4,
        help=(
            "Finite-difference accuracy order for spatial operators (default: 4). "
            "Higher orders use wider stencils: 2->3pt, 4->5pt, 6->7pt. "
            "Order 4 is recommended: O(dx^4) convergence at ~2x per-point cost "
            "vs order 2 — typically enables halving N for the same accuracy. "
            "Ref: Fornberg (1988), Mathematics of Computation 51(184)."
        ),
    )
    sweep_parser.add_argument(
        "--leapfrog-order",
        type=int,
        choices=[2, 4],
        default=None,
        help=(
            "Leapfrog integrator order (default: auto-detect). "
            "2 = Störmer-Verlet, 4 = Yoshida. "
            "Only applies when --scheme leapfrog. "
            "Ref: Yoshida (1990), Physics Letters A 150(5-7)."
        ),
    )
    sweep_parser.add_argument(
        "--spectral",
        action=argparse.BooleanOptionalAction,
        default=None,
        help=(
            "Use FFT spectral operators (default: auto-detect). "
            "Auto-enabled when all BCs are periodic and solver is not IDA. "
            "Use --no-spectral to force finite-difference. "
            "Ref: Burns et al. (2020), Phys. Rev. Research 2:023068."
        ),
    )
    sweep_parser.add_argument(
        "--mode",
        choices=["evolve", "constraint"],
        default="evolve",
        help="Simulation mode (default: evolve)",
    )
    sweep_parser.add_argument(
        "--require-stable",
        action=argparse.BooleanOptionalAction,
        default=True,
        help=(
            "Abort unstable runs whose mass matrix has a negative eigenvalue "
            "(default: True for sweeps; use --no-require-stable to override)"
        ),
    )
    sweep_parser.add_argument(
        "--allow-inconsistent-ic",
        action="store_true",
        default=False,
        help="Allow inconsistent ICs for constraint equations",
    )
    # Sweep execution
    sweep_parser.add_argument(
        "--output",
        default=None,
        metavar="DIR",
        help="Output directory for sweep results (required)",
    )
    sweep_parser.add_argument(
        "--resume",
        action="store_true",
        help="Skip runs with existing metadata.json (resume interrupted sweep)",
    )
    sweep_parser.add_argument(
        "--parallel",
        type=int,
        default=None,
        metavar="N",
        help="Number of parallel workers (default: sequential)",
    )
    sweep_parser.add_argument(
        "--force-large-sweep",
        action="store_true",
        help="Allow sweeps with more than 10,000 runs (overrides safety limit)",
    )
    sweep_parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Print sweep plan (grid size, parameter combos) without running",
    )
    sweep_parser.add_argument(
        "--force",
        action="store_true",
        default=False,
        help="Overwrite existing --output directory without prompting",
    )
    sweep_parser.add_argument(
        "--config",
        default=None,
        metavar="TOML",
        help="Load sweep configuration from TOML file (CLI flags override TOML values)",
    )
    # Adaptive sampling (F2a)
    sweep_parser.add_argument(
        "--adaptive-metric",
        default=None,
        metavar="METRIC",
        help="Metric to drive adaptive refinement (e.g. P_max)",
    )
    sweep_parser.add_argument(
        "--adaptive-budget",
        type=int,
        default=None,
        metavar="N",
        help="Maximum total points for adaptive refinement (default: 20)",
    )
    sweep_parser.add_argument(
        "--adaptive-threshold",
        type=float,
        default=None,
        metavar="T",
        help="Stop adaptive refinement when max interval score < T (default: 0.01)",
    )

    # Ensemble / replicate runs
    sweep_parser.add_argument(
        "--n-replicates",
        type=int,
        default=None,
        metavar="N",
        help="Number of replicate runs per parameter point (default: 1, no replicates)",
    )
    sweep_parser.add_argument(
        "--base-seed",
        type=int,
        default=None,
        metavar="S",
        help="Base RNG seed for ensemble; replicate i gets seed S+i (default: 42)",
    )
    sweep_parser.add_argument(
        "--ic-perturbation",
        type=float,
        default=None,
        metavar="SCALE",
        help=(
            "Additive Gaussian noise (relative to ic-amplitude) on top of "
            "deterministic ICs. Each replicate uses a different seed."
        ),
    )
    sweep_parser.add_argument(
        "--param-noise",
        type=str,
        action="append",
        default=None,
        metavar="PARAM=SCALE",
        help=(
            "Per-parameter Gaussian noise for uncertainty quantification. "
            "Each replicate draws param += N(0, SCALE). Repeatable."
        ),
    )

    # Multi-D sampling
    sweep_parser.add_argument(
        "--sweep-strategy",
        type=str,
        default=None,
        choices=["grid", "latin_hypercube", "sobol"],
        help="Sampling strategy for multi-parameter sweeps (default: grid = Cartesian product)",
    )
    sweep_parser.add_argument(
        "--n-samples",
        type=int,
        default=None,
        metavar="N",
        help="Number of samples for latin_hypercube/sobol strategies",
    )

    # Critical field extraction (post-sweep)
    sweep_parser.add_argument(
        "--critical-field",
        dest="critical_field",
        metavar="PARAM",
        help=(
            "After sweep, extract minimum field strength for full conversion. "
            "Collapses the named swept parameter by finding the first value "
            "where --metric >= --cf-threshold (default: P_final >= 0.99). "
            "Results saved to OUTPUT/critical_field/"
        ),
    )
    sweep_parser.add_argument(
        "--cf-threshold",
        dest="cf_threshold",
        type=float,
        default=0.99,
        metavar="T",
        help="Conversion threshold for --critical-field (default: 0.99)",
    )
    sweep_parser.add_argument(
        "--no-interpolate",
        dest="no_interpolate",
        action="store_true",
        help="Disable interpolation between sweep grid points for --critical-field",
    )

    # --- analyze ---
    analyze_parser = sub.add_parser(
        "analyze",
        help="Post-hoc analysis of sweep results (sensitivity, critical field)",
        epilog="""Examples:
  tidal analyze sweep_results/ --sensitivity sobol --metric P_max
  tidal analyze sweep_results/ --critical-field B0 --metric P_final --threshold 0.99
""",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    analyze_parser.add_argument(
        "data_path",
        help="Path to sweep output directory",
    )
    analyze_parser.add_argument(
        "--sensitivity",
        choices=["sobol", "morris"],
        default="sobol",
        help="Sensitivity analysis method (default: sobol)",
    )
    analyze_parser.add_argument(
        "--metric",
        help="Metric to analyze (e.g. P_final, P_max, max_energy_error)",
    )
    analyze_parser.add_argument(
        "--bootstrap",
        type=int,
        default=100,
        help="Bootstrap resamples for Sobol confidence intervals (default: 100)",
    )
    # Critical field analysis
    analyze_parser.add_argument(
        "--critical-field",
        dest="critical_field",
        metavar="PARAM",
        help=(
            "Extract minimum field strength for full conversion. "
            "Collapses the named swept parameter by finding the first "
            "value where --metric >= --threshold (default: P_final >= 0.99)"
        ),
    )
    analyze_parser.add_argument(
        "--threshold",
        type=float,
        default=0.99,
        help="Metric threshold for full conversion (default: 0.99)",
    )
    analyze_parser.add_argument(
        "--output",
        help="Output directory for critical field results (required with --critical-field)",
    )
    analyze_parser.add_argument(
        "--no-interpolate",
        dest="no_interpolate",
        action="store_true",
        help="Disable interpolation between sweep grid points",
    )
    analyze_parser.add_argument(
        "--reference-formula",
        dest="reference_formula",
        choices=["boccaletti", "uniform"],
        help=(
            "Compute threshold from analytical E-M formula. "
            "'boccaletti': localized Gaussian B-field, "
            "'uniform': uniform B periodic domain. "
            "Overrides --threshold."
        ),
    )
    analyze_parser.add_argument(
        "--reference-B",
        dest="reference_B",
        type=float,
        help="Reference B value for analytical formula (required with --reference-formula)",
    )

    # --- doctor ---
    sub.add_parser(
        "doctor",
        aliases=["doc"],
        help="Check environment health (Python, dependencies, Wolfram, xAct)",
        epilog="""Examples:
  tidal doctor              # Full environment check
""",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    # Shell completion (optional dependency: shtab)
    try:
        import shtab

        shtab.add_argument_to(parser)
    except ImportError:
        pass

    return parser


# ---------------------------------------------------------------------------
# Dispatch and entry point
# ---------------------------------------------------------------------------


def _get_version() -> str:
    """Return the package version string from installed metadata."""
    from importlib.metadata import PackageNotFoundError, version

    try:
        return version("tidal")
    except PackageNotFoundError:
        return "unknown"


_COMMAND_ALIASES: dict[str, str] = {
    "der": "derive",
    "insp": "inspect",
    "sim": "simulate",
    "ls": "list",
    "val": "validate",
    "meas": "measure",
    "sw": "sweep",
    "doc": "doctor",
}


def _dispatch(args: argparse.Namespace) -> int:  # noqa: PLR0911, C901
    """Lazily import and run the appropriate command handler.

    Parameters
    ----------
    args : argparse.Namespace
        Parsed CLI arguments with ``command`` attribute.

    Returns
    -------
    int
        Exit code from the command handler.

    Raises
    ------
    ValueError
        If ``args.command`` is not a recognized subcommand.
    """
    cmd = _COMMAND_ALIASES.get(args.command, args.command)
    if cmd == "derive":
        from tidal.cli._derive import derive_command

        return derive_command(args)
    if cmd == "inspect":
        from tidal.cli._inspect import inspect_command

        return inspect_command(args)
    if cmd == "simulate":
        from tidal.cli._simulate import simulate_command

        return simulate_command(args)
    if cmd == "list":
        from tidal.cli._list import list_command

        return list_command(args)
    if cmd == "validate":
        from tidal.cli._validate import validate_command

        return validate_command(args)
    if cmd == "measure":
        from tidal.cli._measure import measure_command

        return measure_command(args)
    if cmd == "plot":
        from tidal.cli._plot_command import plot_command

        return plot_command(args)
    if cmd == "sweep":
        from tidal.cli._sweep import sweep_command

        return sweep_command(args)
    if cmd == "analyze":
        from tidal.cli._analyze import analyze_command

        return analyze_command(args)
    if cmd == "doctor":
        from tidal.cli._doctor import doctor_command

        return doctor_command(args)
    msg = f"Unknown command: {cmd}"
    raise ValueError(msg)


[docs] def main(argv: list[str] | None = None) -> int: """CLI entry point. Parameters ---------- argv : list[str] | None Command-line arguments. If None, uses sys.argv. Returns ------- int Exit code (0 for success, non-zero for errors). """ parser = _build_parser() args = parser.parse_args(argv) # Show banner for long-running commands and bare `tidal` (no subcommand). # Quick commands (list, validate, inspect, measure, plot, analyze) skip it. # Skip for --cite (info-only flag). if ( not args.no_banner and not os.environ.get("TIDAL_NO_BANNER") and not args.cite and _COMMAND_ALIASES.get(args.command, args.command) in {None, "simulate", "derive", "sweep"} ): from tidal.banner import print_banner banner_theme = os.environ.get("TIDAL_BANNER_THEME", "ocean") banner_layout = os.environ.get("TIDAL_BANNER_LAYOUT", "auto") print_banner( theme=banner_theme, layout=banner_layout, version=_get_version(), ) if args.cite: from tidal.cli._cite import print_citation print_citation() return 0 if args.command is None: parser.print_help() return 0 from tidal.cli._console import configure as _configure_console from tidal.cli._console import error as _console_error _configure_console( verbose=getattr(args, "verbose", False), quiet=getattr(args, "quiet", False), ) import time t0 = time.perf_counter() try: rc = _dispatch(args) except KeyboardInterrupt: print("\nInterrupted.", file=sys.stderr) rc = 130 except FileNotFoundError as exc: from tidal.cli._console import error_with_hint error_with_hint( str(exc), [ "Run 'tidal list' to see available specifications.", "Run 'tidal derive <theory.toml>' to generate from a Lagrangian.", ], ) rc = 1 except (ValueError, TypeError) as exc: _console_error(str(exc)) rc = 1 except RuntimeError: import traceback traceback.print_exc() rc = 1 if args.time: elapsed = time.perf_counter() - t0 if elapsed >= 60: # noqa: PLR2004 mins = int(elapsed // 60) secs = elapsed % 60 print( f"[TIME] {args.command} completed in {mins}m {secs:.1f}s", file=sys.stderr, ) else: print(f"[TIME] {args.command} completed in {elapsed:.2f}s", file=sys.stderr) return rc