mihenk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mihenk/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """mihenk: a touchstone for discovered equations.
2
+
3
+ Before you interpret the law that PySINDy (or any equation-discovery tool)
4
+ found, mihenk checks whether it could be an artifact of noise, of how
5
+ derivatives were estimated, of which states the data happened to visit, or of
6
+ analysis choices.
7
+
8
+ import mihenk
9
+ report = mihenk.scan(bundle, model) # tier 0: data + equation
10
+ report = mihenk.scan_pysindy(sindy, x, t) # tiers 0-1 with a fitted PySINDy model
11
+ """
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ from .bundle import Bundle # noqa: E402
16
+ from .model import Model # noqa: E402
17
+ from .report import Check, Finding, Report # noqa: E402
18
+ from .scan import Thresholds, scan # noqa: E402
19
+
20
+
21
+ def scan_pysindy(sindy_model, x, t, *, feature_names=None, name="pysindy", val=None,
22
+ thresholds=None, variants=True, seed=0, meta=None):
23
+ """Scan a fitted PySINDy model on the data it was fitted to (tiers 0 and 1)."""
24
+ from .adapters import pysindy as _ps
25
+
26
+ return _ps.scan_pysindy(sindy_model, x, t, feature_names=feature_names, name=name, val=val,
27
+ thresholds=thresholds, variants=variants, seed=seed, meta=meta)
28
+
29
+
30
+ __all__ = ["Bundle", "Model", "Report", "Finding", "Check", "Thresholds", "scan", "scan_pysindy",
31
+ "__version__"]
@@ -0,0 +1 @@
1
+ """Adapters for discovery tools. Each is optional and imports its tool lazily."""
@@ -0,0 +1,146 @@
1
+ """PySINDy adapter (``pip install "mihenk[pysindy]"``), written against PySINDy 2.x.
2
+
3
+ Turns a fitted ``pysindy.SINDy`` into a :class:`~mihenk.model.Model`, and builds
4
+ the tier-1 callables: ``refit`` (the same configuration on resampled data) and
5
+ ``variants`` (the same data under other defensible derivative methods and
6
+ sparsity thresholds).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from ..bundle import Bundle
14
+ from ..model import Model, parse_term
15
+
16
+
17
+ def _require():
18
+ try:
19
+ import pysindy # noqa: F401
20
+ except ImportError as exc: # pragma: no cover
21
+ raise ImportError('this needs PySINDy: pip install "mihenk[pysindy]"') from exc
22
+
23
+
24
+ def from_pysindy(sindy, variables=None, source="pysindy") -> Model:
25
+ """Read variables, candidate library and coefficients from a fitted PySINDy model."""
26
+ _require()
27
+ if getattr(sindy, "n_control_features_", 0):
28
+ raise NotImplementedError("models with control inputs (u) are not supported in mihenk 0.1")
29
+ lib_name = type(sindy.feature_library).__name__
30
+ if "Weak" in lib_name or "PDE" in lib_name:
31
+ raise NotImplementedError(f"{lib_name} is not supported in mihenk 0.1 (ODE libraries only)")
32
+ variables = list(variables or sindy.feature_names)
33
+ names = sindy.get_feature_names()
34
+ try:
35
+ terms = [parse_term(n, variables) for n in names]
36
+ except Exception as exc:
37
+ raise ValueError(f"cannot read PySINDy feature names as expressions ({names[:5]}...): {exc}") from exc
38
+ coefs = np.asarray(sindy.coefficients(), dtype=float)
39
+ m = Model(variables, terms, coefs, library=list(terms), source=source)
40
+ m.meta = {"library": lib_name, "optimizer": type(sindy.optimizer).__name__,
41
+ "differentiation": type(sindy.differentiation_method).__name__}
42
+ return m
43
+
44
+
45
+ def _fit_clone(sindy, pairs, variables, **overrides):
46
+ from sklearn.base import clone
47
+
48
+ m = clone(sindy)
49
+ if overrides:
50
+ m.set_params(**overrides)
51
+ xs = [x for x, _ in pairs]
52
+ ts = [t for _, t in pairs]
53
+ import warnings
54
+
55
+ with warnings.catch_warnings():
56
+ warnings.simplefilter("ignore")
57
+ if len(xs) == 1:
58
+ m.fit(xs[0], t=ts[0], feature_names=list(variables))
59
+ else:
60
+ m.fit(xs, t=ts, feature_names=list(variables))
61
+ model = from_pysindy(m, variables)
62
+ # STLSQ can stop at max_iter with coefficients far below its own threshold still non-zero;
63
+ # in a refit those are optimizer leftovers, not structure, so they are not counted as terms.
64
+ thr = m.optimizer.get_params().get("threshold")
65
+ if thr:
66
+ small = (np.abs(model.coefficients) < thr) & (model.coefficients != 0)
67
+ model.meta["pruned_below_threshold"] = int(small.sum())
68
+ model.coefficients[small] = 0.0
69
+ return model
70
+
71
+
72
+ def make_refit(sindy, variables=None):
73
+ variables = list(variables or sindy.feature_names)
74
+ return lambda pairs: _fit_clone(sindy, pairs, variables)
75
+
76
+
77
+ def make_variants(sindy, variables=None, threshold_factors=(0.5, 1.0, 1.5)) -> dict:
78
+ """Other defensible analyses of the same data: derivative method x sparsity threshold."""
79
+ import pysindy as ps
80
+
81
+ variables = list(variables or sindy.feature_names)
82
+ diffs = {"FiniteDifference": ps.FiniteDifference, "SmoothedFiniteDifference": ps.SmoothedFiniteDifference}
83
+ current_diff = type(sindy.differentiation_method).__name__
84
+ params = sindy.optimizer.get_params()
85
+ th0 = params.get("threshold")
86
+ out = {}
87
+ for dname, dcls in diffs.items():
88
+ for f in threshold_factors if th0 is not None else (1.0,):
89
+ if dname == current_diff and f == 1.0:
90
+ continue # that is the original analysis
91
+ over = {"differentiation_method": dcls()}
92
+ label = dname
93
+ if th0 is not None:
94
+ over["optimizer__threshold"] = th0 * f
95
+ label += f", threshold {th0 * f:.3g}"
96
+ out[label] = (lambda pairs, over=over: _fit_clone(sindy, pairs, variables, **over))
97
+ return out
98
+
99
+
100
+ def scan_pysindy(sindy, x, t, *, feature_names=None, name="pysindy", val=None, thresholds=None,
101
+ variants=True, seed=0, meta=None):
102
+ from ..scan import scan
103
+
104
+ variables = list(feature_names or sindy.feature_names)
105
+ xs = x if isinstance(x, (list, tuple)) else [x]
106
+ if isinstance(t, (int, float)):
107
+ ts = [np.arange(len(xi)) * float(t) for xi in xs]
108
+ else:
109
+ ts = t if isinstance(t, (list, tuple)) else [t]
110
+ b = Bundle(list(xs), list(ts), variables, val=val, meta={"name": name, **(meta or {})})
111
+ model = from_pysindy(sindy, variables)
112
+ return scan(b, model, refit=make_refit(sindy, variables),
113
+ variants=make_variants(sindy, variables) if variants else None,
114
+ thresholds=thresholds, seed=seed)
115
+
116
+
117
+ # --------------------------------------------------------------- CLI helpers
118
+ DIFFS = {"fd": "FiniteDifference", "smoothed": "SmoothedFiniteDifference"}
119
+
120
+
121
+ def build_sindy(degree: int = 2, threshold: float = 0.1, diff: str = "fd"):
122
+ """A PySINDy model from a small, serializable configuration (used by ``mihenk fit``)."""
123
+ _require()
124
+ import pysindy as ps
125
+
126
+ if diff not in DIFFS:
127
+ raise ValueError(f"diff must be one of {', '.join(DIFFS)}")
128
+ return ps.SINDy(optimizer=ps.STLSQ(threshold=threshold),
129
+ feature_library=ps.PolynomialLibrary(degree=degree),
130
+ differentiation_method=getattr(ps, DIFFS[diff])())
131
+
132
+
133
+ def fit_bundle(bundle: Bundle, degree: int = 2, threshold: float = 0.1, diff: str = "fd"):
134
+ """Fit PySINDy to a bundle's trajectories; returns ``(sindy, model)``."""
135
+ import warnings
136
+
137
+ sindy = build_sindy(degree, threshold, diff)
138
+ with warnings.catch_warnings():
139
+ warnings.simplefilter("ignore")
140
+ if bundle.n_traj == 1:
141
+ sindy.fit(bundle.xs[0], t=bundle.ts[0], feature_names=bundle.variables)
142
+ else:
143
+ sindy.fit(bundle.xs, t=bundle.ts, feature_names=bundle.variables)
144
+ model = from_pysindy(sindy, bundle.variables, source="pysindy (mihenk fit)")
145
+ model.meta["pysindy_config"] = {"degree": degree, "threshold": threshold, "diff": diff}
146
+ return sindy, model
mihenk/bundle.py ADDED
@@ -0,0 +1,168 @@
1
+ """The mihenk bundle: the observations an equation was discovered from, as plain arrays.
2
+
3
+ A bundle is one ``.npz`` file holding one or more trajectories. Shapes use T
4
+ time samples and n state variables; trajectories may differ in length.
5
+
6
+ Keys
7
+ x (T, n) one trajectory, or ``x_0, x_1, ...`` for several
8
+ t (T,) sample times (or ``t_0, t_1, ...``); a scalar ``dt`` also works
9
+ x_dot (T, n) optional measured derivatives (``x_dot_0, ...``)
10
+ val (K,) optional bool: trajectories held out from discovery.
11
+ With one trajectory, the last 20% is held out instead.
12
+ meta JSON string: variables, name, units, true_equations (synthetic only), ...
13
+
14
+ ``true_equations`` in meta (``{"x": "-10*x + 10*y", ...}``) unlocks the tier-2
15
+ ground-truth checks; only synthetic data has it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+
26
+
27
+ @dataclass
28
+ class Bundle:
29
+ xs: list # list of (T_k, n) arrays
30
+ ts: list # list of (T_k,) arrays
31
+ variables: list
32
+ x_dots: list | None = None
33
+ val: np.ndarray | None = None # (K,) bool, held-out trajectories
34
+ meta: dict = field(default_factory=dict)
35
+
36
+ def __post_init__(self):
37
+ if isinstance(self.xs, np.ndarray):
38
+ self.xs = [self.xs]
39
+ if isinstance(self.ts, np.ndarray):
40
+ self.ts = [self.ts]
41
+ self.xs = [np.atleast_2d(np.asarray(x, float)) for x in self.xs]
42
+ self.ts = [np.asarray(t, float).ravel() for t in self.ts]
43
+ if len(self.xs) != len(self.ts):
44
+ raise ValueError(f"{len(self.xs)} trajectories but {len(self.ts)} time vectors")
45
+ n = self.xs[0].shape[1]
46
+ for k, (x, t) in enumerate(zip(self.xs, self.ts)):
47
+ if x.shape[1] != n:
48
+ raise ValueError(f"trajectory {k} has {x.shape[1]} variables, expected {n}")
49
+ if x.shape[0] != t.shape[0]:
50
+ raise ValueError(f"trajectory {k}: {x.shape[0]} samples but {t.shape[0]} times")
51
+ if x.shape[0] < 20:
52
+ raise ValueError(f"trajectory {k} has only {x.shape[0]} samples; mihenk needs at least 20")
53
+ if np.any(np.diff(t) <= 0):
54
+ raise ValueError(f"trajectory {k}: times must be strictly increasing")
55
+ if not np.all(np.isfinite(x)):
56
+ raise ValueError(f"trajectory {k} contains NaN or inf")
57
+ if len(self.variables) != n:
58
+ raise ValueError(f"{len(self.variables)} variable names for {n} columns")
59
+ self.variables = [str(v) for v in self.variables]
60
+ if self.x_dots is not None:
61
+ self.x_dots = [np.asarray(d, float) for d in self.x_dots]
62
+ if self.val is not None:
63
+ self.val = np.asarray(self.val, bool).ravel()
64
+ if self.val.shape[0] != len(self.xs):
65
+ raise ValueError("val must have one entry per trajectory")
66
+ if self.val.all():
67
+ raise ValueError("every trajectory is marked held-out; nothing is left for fitting")
68
+
69
+ # ------------------------------------------------------------ split
70
+ @property
71
+ def n_traj(self) -> int:
72
+ return len(self.xs)
73
+
74
+ @property
75
+ def n_vars(self) -> int:
76
+ return self.xs[0].shape[1]
77
+
78
+ @property
79
+ def name(self) -> str:
80
+ return self.meta.get("name", "bundle")
81
+
82
+ @property
83
+ def true_equations(self) -> dict | None:
84
+ return self.meta.get("true_equations")
85
+
86
+ def split(self, holdout_fraction: float = 0.2):
87
+ """(train, test) lists of (x, t) pairs.
88
+
89
+ Held-out trajectories when ``val`` is given (or, with several
90
+ trajectories, the last 20% of them); otherwise the tail of the single
91
+ trajectory.
92
+ """
93
+ pairs = list(zip(self.xs, self.ts))
94
+ if self.val is not None:
95
+ train = [p for p, v in zip(pairs, self.val) if not v]
96
+ test = [p for p, v in zip(pairs, self.val) if v]
97
+ return train, test, "held-out trajectories (val)"
98
+ if self.n_traj >= 3:
99
+ k = max(1, int(round(holdout_fraction * self.n_traj)))
100
+ return pairs[:-k], pairs[-k:], f"last {k} of {self.n_traj} trajectories"
101
+ train, test = [], []
102
+ for x, t in pairs:
103
+ cut = int(round((1 - holdout_fraction) * len(t)))
104
+ train.append((x[:cut], t[:cut]))
105
+ test.append((x[cut:], t[cut:]))
106
+ return train, test, f"last {int(holdout_fraction * 100)}% of each trajectory"
107
+
108
+ # ------------------------------------------------------------ io
109
+ def save(self, path) -> Path:
110
+ path = Path(path)
111
+ arrays = {}
112
+ for k, (x, t) in enumerate(zip(self.xs, self.ts)):
113
+ arrays[f"x_{k}"] = x
114
+ arrays[f"t_{k}"] = t
115
+ if self.x_dots is not None:
116
+ arrays[f"x_dot_{k}"] = self.x_dots[k]
117
+ if self.val is not None:
118
+ arrays["val"] = self.val
119
+ meta = dict(self.meta)
120
+ meta["variables"] = self.variables
121
+ arrays["meta"] = np.array(json.dumps(meta))
122
+ np.savez_compressed(path, **arrays)
123
+ return path
124
+
125
+ @classmethod
126
+ def load(cls, path) -> "Bundle":
127
+ path = Path(path)
128
+ if path.suffix == ".csv":
129
+ return cls.from_csv(path)
130
+ with np.load(path, allow_pickle=False) as z:
131
+ keys = set(z.files)
132
+ meta = json.loads(str(z["meta"])) if "meta" in keys else {}
133
+ if "x" in keys:
134
+ xs = [z["x"]]
135
+ ts = [_times(z, "t", xs[0].shape[0])]
136
+ dots = [z["x_dot"]] if "x_dot" in keys else None
137
+ else:
138
+ idx = sorted(int(k[2:]) for k in keys if k.startswith("x_") and k[2:].isdigit())
139
+ if not idx:
140
+ raise ValueError(f"{path}: no 'x' or 'x_0' array")
141
+ xs = [z[f"x_{k}"] for k in idx]
142
+ ts = [_times(z, f"t_{k}", xs[i].shape[0]) for i, k in enumerate(idx)]
143
+ dots = [z[f"x_dot_{k}"] for k in idx] if all(f"x_dot_{k}" in keys for k in idx) else None
144
+ val = z["val"] if "val" in keys else None
145
+ variables = meta.get("variables") or [f"x{i}" for i in range(xs[0].shape[1])]
146
+ meta.setdefault("name", path.stem)
147
+ return cls(xs, ts, variables, x_dots=dots, val=val, meta=meta)
148
+
149
+ @classmethod
150
+ def from_csv(cls, path, time_column: str = "t") -> "Bundle":
151
+ """One trajectory per file; a header row names the columns, one of them the time."""
152
+ path = Path(path)
153
+ with open(path) as fh:
154
+ header = [h.strip() for h in fh.readline().split(",")]
155
+ data = np.loadtxt(path, delimiter=",", skiprows=1, ndmin=2)
156
+ if time_column not in header:
157
+ raise ValueError(f"{path}: no '{time_column}' column; columns are {header}")
158
+ ti = header.index(time_column)
159
+ cols = [i for i in range(len(header)) if i != ti]
160
+ return cls([data[:, cols]], [data[:, ti]], [header[i] for i in cols], meta={"name": path.stem})
161
+
162
+
163
+ def _times(z, key, n):
164
+ if key in z.files:
165
+ return z[key]
166
+ if "dt" in z.files:
167
+ return np.arange(n) * float(z["dt"])
168
+ raise ValueError(f"no '{key}' times and no 'dt'")
mihenk/cli.py ADDED
@@ -0,0 +1,185 @@
1
+ """mihenk command line.
2
+
3
+ mihenk scan BUNDLE --model MODEL [--out DIR] [--json] [--set name=value ...] [--daftar]
4
+ mihenk fit BUNDLE -o MODEL.json [--degree D] [--threshold T] [--diff fd|smoothed]
5
+ mihenk synth SYSTEM -o BUNDLE.npz [--noise F] [--n-traj K] [--seed S]
6
+ mihenk validate [--quick]
7
+ mihenk info
8
+
9
+ ``scan`` exits with status 1 when it finds errors, so it can gate a pipeline.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from contextlib import nullcontext
18
+ from pathlib import Path
19
+
20
+ from . import __version__, provenance
21
+ from .bundle import Bundle
22
+ from .model import Model
23
+
24
+
25
+ def _parse_sets(items):
26
+ out = {}
27
+ for item in items or []:
28
+ if "=" not in item:
29
+ raise SystemExit(f"--set expects name=value, got {item!r}")
30
+ k, v = item.split("=", 1)
31
+ out[k.strip()] = v.strip()
32
+ return out
33
+
34
+
35
+ def cmd_scan(args) -> int:
36
+ from .scan import Thresholds, scan
37
+
38
+ bundle = Bundle.load(args.bundle)
39
+ model = Model.load(args.model)
40
+ th = Thresholds().update(_parse_sets(args.set))
41
+ refit = variants = None
42
+ cfg = model.meta.get("pysindy_config")
43
+ if cfg and not args.no_refit:
44
+ try:
45
+ from .adapters import pysindy as A
46
+
47
+ sindy, _ = A.fit_bundle(bundle, **cfg)
48
+ refit = A.make_refit(sindy, bundle.variables)
49
+ variants = A.make_variants(sindy, bundle.variables)
50
+ except ImportError:
51
+ print("note: PySINDy is not installed, so tier-1 checks are skipped "
52
+ '(pip install "mihenk[pysindy]")', file=sys.stderr)
53
+ if args.daftar and not provenance.available():
54
+ print('daftar is not installed; run `pip install "mihenk[provenance]"`', file=sys.stderr)
55
+ return 2
56
+ params = {"thresholds": _parse_sets(args.set), "seed": args.seed, "bundle": str(args.bundle),
57
+ "model": str(args.model)}
58
+ ctx = provenance.tracked("mihenk scan", params, inputs=[args.bundle, args.model], seed=args.seed) \
59
+ if args.daftar else nullcontext(None)
60
+ with ctx as run:
61
+ report = scan(bundle, model, refit=refit, variants=variants, thresholds=th, seed=args.seed)
62
+ out = None
63
+ if args.out:
64
+ out = report.save(args.out)
65
+ provenance.log_report(run, report, out)
66
+ if args.json:
67
+ print(json.dumps(report.to_dict(), indent=2, default=str))
68
+ else:
69
+ print(report.render_text(), end="")
70
+ if out:
71
+ print(f"\nreport written to {out}/report.html (also .json, .md, .txt)")
72
+ return 1 if report.has_errors else 0
73
+
74
+
75
+ def cmd_fit(args) -> int:
76
+ from .adapters import pysindy as A
77
+
78
+ bundle = Bundle.load(args.bundle)
79
+ _, model = A.fit_bundle(bundle, degree=args.degree, threshold=args.threshold, diff=args.diff)
80
+ model.save(args.output)
81
+ print(model.pretty())
82
+ print(f"\nmodel written to {args.output}; next: mihenk scan {args.bundle} --model {args.output}")
83
+ return 0
84
+
85
+
86
+ def cmd_synth(args) -> int:
87
+ from . import synth
88
+
89
+ if args.list or not args.system:
90
+ for name, s in synth.SYSTEMS.items():
91
+ print(f"{name:<24} {', '.join(s.variables):<10} {s.note}")
92
+ return 0
93
+ b = synth.simulate(args.system, noise=args.noise, n_traj=args.n_traj, dt=args.dt,
94
+ duration=args.duration, seed=args.seed)
95
+ out = args.output or f"{b.name}.npz"
96
+ b.save(out)
97
+ print(f"wrote {out}: {b.n_traj} trajectory(ies), {len(b.ts[0])} samples each, variables "
98
+ f"{', '.join(b.variables)}")
99
+ print("true law: " + "; ".join(f"{v}' = {e}" for v, e in b.true_equations.items()))
100
+ return 0
101
+
102
+
103
+ def cmd_validate(args) -> int:
104
+ from . import validate
105
+
106
+ if args.seeds > 1:
107
+ text, ok = validate.sweep(range(args.seed, args.seed + args.seeds), quick=args.quick)
108
+ print(text)
109
+ return 0 if ok else 1
110
+ results = validate.run_all(quick=args.quick, seed=args.seed)
111
+ print(validate.render(results))
112
+ return 0 if all(r.passed for r in results) else 1
113
+
114
+
115
+ def cmd_info(_args) -> int:
116
+ print(f"mihenk {__version__}")
117
+ for mod, why in (("pysindy", 'extra [pysindy]: fit, tier-1 refits'),
118
+ ("daftar", "extra [provenance]: scan --daftar")):
119
+ try:
120
+ m = __import__(mod)
121
+ status = getattr(m, "__version__", "installed")
122
+ except ImportError:
123
+ status = "not installed"
124
+ print(f" {mod:<10} {status:<16} {why}")
125
+ return 0
126
+
127
+
128
+ def build_parser() -> argparse.ArgumentParser:
129
+ p = argparse.ArgumentParser(prog="mihenk", description="A touchstone for discovered equations.")
130
+ p.add_argument("--version", action="version", version=f"mihenk {__version__}")
131
+ sub = p.add_subparsers(dest="cmd", required=True)
132
+
133
+ s = sub.add_parser("scan", help="check a discovered equation against its data")
134
+ s.add_argument("bundle", help="data: .npz bundle or .csv with a 't' column")
135
+ s.add_argument("--model", required=True, help="equation: .json from `mihenk fit`/Model.save, or a text "
136
+ "file with lines like x' = -10 x + 10 y")
137
+ s.add_argument("--out", help="directory for report.html/.json/.md/.txt")
138
+ s.add_argument("--json", action="store_true", help="print the report as JSON")
139
+ s.add_argument("--set", action="append", metavar="NAME=VALUE", help="override a threshold")
140
+ s.add_argument("--seed", type=int, default=0)
141
+ s.add_argument("--no-refit", action="store_true", help="skip tier-1 refits even if possible")
142
+ s.add_argument("--daftar", action="store_true", help="record the scan with daftar")
143
+ s.set_defaults(fn=cmd_scan)
144
+
145
+ f = sub.add_parser("fit", help="fit PySINDy (polynomial library, STLSQ) and save the equation")
146
+ f.add_argument("bundle")
147
+ f.add_argument("-o", "--output", default="model.json")
148
+ f.add_argument("--degree", type=int, default=2)
149
+ f.add_argument("--threshold", type=float, default=0.1)
150
+ f.add_argument("--diff", choices=["fd", "smoothed"], default="fd")
151
+ f.set_defaults(fn=cmd_fit)
152
+
153
+ y = sub.add_parser("synth", help="simulate a system with a known law")
154
+ y.add_argument("system", nargs="?")
155
+ y.add_argument("-o", "--output")
156
+ y.add_argument("--noise", type=float, default=0.0, help="noise SD as a fraction of each variable's SD")
157
+ y.add_argument("--n-traj", type=int, default=1)
158
+ y.add_argument("--dt", type=float)
159
+ y.add_argument("--duration", type=float)
160
+ y.add_argument("--seed", type=int, default=0)
161
+ y.add_argument("--list", action="store_true")
162
+ y.set_defaults(fn=cmd_synth)
163
+
164
+ v = sub.add_parser("validate", help="run mihenk's self-validation cases")
165
+ v.add_argument("--quick", action="store_true")
166
+ v.add_argument("--seed", type=int, default=0)
167
+ v.add_argument("--seeds", type=int, default=1, help="run over this many seeds and summarize rates")
168
+ v.set_defaults(fn=cmd_validate)
169
+
170
+ i = sub.add_parser("info", help="version and optional dependencies")
171
+ i.set_defaults(fn=cmd_info)
172
+ return p
173
+
174
+
175
+ def main(argv=None) -> int:
176
+ args = build_parser().parse_args(argv)
177
+ try:
178
+ return args.fn(args)
179
+ except (ValueError, KeyError, FileNotFoundError, NotImplementedError) as exc:
180
+ print(f"mihenk: error: {exc}", file=sys.stderr)
181
+ return 2
182
+
183
+
184
+ if __name__ == "__main__": # pragma: no cover
185
+ sys.exit(main())