StochasticForceInference 2.0.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.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,129 @@
1
+ """Framework-owned extras and the single resolver that materialises them.
2
+
3
+ A force or diffusion expression reads two kinds of data through its ``extras``
4
+ mapping: **user** values attached to the trajectory (drive protocols,
5
+ per-particle properties, geometry) and **reserved** values supplied by the
6
+ framework. The reserved keys are defined once here, in a small registry, and
7
+ assembled together with the user values by :func:`resolve_extras` — the single
8
+ entry point used by simulation, inference, and diagnostics.
9
+
10
+ Reserved keys:
11
+
12
+ ``time``
13
+ Absolute time at each resolved frame — lets time-dependent bases (e.g.
14
+ :func:`~SFI.bases.time_fourier`) read the clock.
15
+ ``duration``
16
+ Total trajectory span.
17
+ ``dataset_index``
18
+ Dense index of the dataset within its collection, for pooled
19
+ multi-experiment models (:func:`~SFI.bases.per_dataset_scalar`,
20
+ :func:`~SFI.bases.dataset_indicator`).
21
+ ``particle_index``
22
+ Per-particle integer ids, gathered per edge by interaction dispatchers.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import Any, Callable, Dict, Mapping, Optional
29
+
30
+ import jax.numpy as jnp
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ExtrasContext:
35
+ """Everything a reserved-key resolver needs for a set of frames.
36
+
37
+ Assembled by whoever drives the evaluation (the trajectory producer, the
38
+ diagnostics residual builder, or the simulator) and passed to
39
+ :func:`resolve_extras`.
40
+ """
41
+
42
+ n_particles: int
43
+ dataset_index: int
44
+ frame_times: Any
45
+ duration: Any
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ReservedKey:
50
+ """A framework-owned extras key and how it is materialised."""
51
+
52
+ name: str
53
+ resolve: Callable[[ExtrasContext], Any]
54
+
55
+
56
+ _REGISTRY: Dict[str, ReservedKey] = {}
57
+
58
+
59
+ def register(key: ReservedKey) -> None:
60
+ """Add a reserved key to the registry."""
61
+ _REGISTRY[key.name] = key
62
+
63
+
64
+ register(ReservedKey("time", lambda ctx: ctx.frame_times))
65
+ register(ReservedKey("duration", lambda ctx: ctx.duration))
66
+ register(ReservedKey("dataset_index", lambda ctx: jnp.asarray(ctx.dataset_index, dtype=jnp.int32)))
67
+ register(ReservedKey("particle_index", lambda ctx: jnp.arange(int(ctx.n_particles), dtype=jnp.int32)))
68
+
69
+
70
+ #: The set of reserved key names; user extras may not use these.
71
+ RESERVED_NAMES = frozenset(_REGISTRY)
72
+
73
+
74
+ def is_reserved(name: str) -> bool:
75
+ """True when ``name`` is a framework-owned reserved key."""
76
+ return name in _REGISTRY
77
+
78
+
79
+ def resolve_reserved(ctx: ExtrasContext) -> Dict[str, Any]:
80
+ """Materialise every reserved key for ``ctx``."""
81
+ return {name: key.resolve(ctx) for name, key in _REGISTRY.items()}
82
+
83
+
84
+ def resolve_extras(user_extras: Mapping[str, Any], ctx: ExtrasContext) -> Dict[str, Any]:
85
+ """Full per-frame extras: user values plus the resolved reserved keys.
86
+
87
+ Reserved names are framework-owned; a user entry colliding with one is
88
+ rejected so the meaning of a reserved key is never ambiguous.
89
+ """
90
+ out = dict(user_extras)
91
+ clash = RESERVED_NAMES.intersection(out)
92
+ if clash:
93
+ raise ValueError(f"extras keys {sorted(clash)} are reserved; rename the user entries.")
94
+ out.update(resolve_reserved(ctx))
95
+ return out
96
+
97
+
98
+ def slice_frame_extras(
99
+ extras_global: Optional[Mapping[str, Any]],
100
+ extras_local: Optional[Mapping[str, Any]],
101
+ *,
102
+ frame_idx: Any,
103
+ context: Optional[str] = None,
104
+ ) -> Dict[str, Any]:
105
+ """Materialise user extras at ``frame_idx``.
106
+
107
+ * :class:`~SFI.trajectory.dataset.TimeSeriesExtra` → sliced ``value.data[frame_idx]``;
108
+ * :class:`~SFI.trajectory.dataset.FunctionExtra` → its callable, forwarded;
109
+ * plain callable → invoked as ``value(frame_idx, context=context)``;
110
+ * anything else → forwarded unchanged.
111
+
112
+ ``extras_local`` overrides ``extras_global`` on key conflicts.
113
+ """
114
+ from SFI.trajectory.dataset import FunctionExtra, TimeSeriesExtra
115
+
116
+ def _materialise(value: Any) -> Any:
117
+ if isinstance(value, FunctionExtra):
118
+ return value.func
119
+ if isinstance(value, TimeSeriesExtra):
120
+ return jnp.asarray(value.data)[frame_idx]
121
+ if callable(value):
122
+ return value(frame_idx, context=context)
123
+ return value
124
+
125
+ out: Dict[str, Any] = {}
126
+ for source in (extras_global or {}, extras_local or {}):
127
+ for key, value in source.items():
128
+ out[key] = _materialise(value)
129
+ return out
SFI/utils/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ SFI.utils — Mathematical, formatting, and plotting utilities.
3
+ """
4
+
5
+ from .formatting import model_summary, print_model_comparison
6
+ from .maths import as_default_float, default_float_dtype, fd_velocity, solve_or_pinv, sqrtm_psd, stable_pinv
7
+
8
+ __all__ = [
9
+ "stable_pinv",
10
+ "sqrtm_psd",
11
+ "solve_or_pinv",
12
+ "fd_velocity",
13
+ "model_summary",
14
+ "print_model_comparison",
15
+ "default_float_dtype",
16
+ "as_default_float",
17
+ ]
@@ -0,0 +1,308 @@
1
+ # SFI/utils/formatting.py
2
+ """Pretty-printing utilities for inferred models."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional, Sequence
7
+
8
+ import numpy as np
9
+
10
+ # ANSI escape codes for terminal highlighting
11
+ _BOLD = "\033[1m"
12
+ _DIM = "\033[2m"
13
+ _RESET = "\033[0m"
14
+
15
+
16
+ def model_summary(
17
+ labels: Sequence[str],
18
+ coefficients: np.ndarray,
19
+ *,
20
+ stderr: Optional[np.ndarray] = None,
21
+ support: Optional[np.ndarray] = None,
22
+ coeffs_true: Optional[np.ndarray] = None,
23
+ support_true: Optional[np.ndarray] = None,
24
+ title: str = "Coefficient Table",
25
+ max_rows: int = 60,
26
+ significance_thresholds: tuple = (2.0, 10.0, 100.0),
27
+ auto_labels: bool = False,
28
+ ) -> str:
29
+ """
30
+ Build a human-readable coefficient table with SNR and significance.
31
+
32
+ Only active (support) coefficients are shown in the table body. Zeroed
33
+ basis functions are listed separately below, unless labels are
34
+ auto-generated.
35
+
36
+ Parameters
37
+ ----------
38
+ labels : sequence of str
39
+ One label per basis function.
40
+ coefficients : 1-D array
41
+ Coefficient vector (length must match *labels* or *support*).
42
+ stderr : 1-D array or None
43
+ Standard errors (same length as *coefficients*). These reflect
44
+ sampling error only; discretization (finite-time-step) bias is
45
+ not included.
46
+ support : 1-D int array or None
47
+ Indices into *labels* that *coefficients* correspond to.
48
+ If ``None``, full support is assumed (all basis functions
49
+ have non-zero coefficients).
50
+ title : str
51
+ Section header printed above the table.
52
+ max_rows : int
53
+ If the table exceeds this many rows, truncate the middle.
54
+ significance_thresholds : tuple of float
55
+ Three SNR thresholds (in multiples of stderr) for the ``*``, ``**``,
56
+ and ``***`` significance levels. Defaults ``(2.0, 10.0, 100.0)``.
57
+ auto_labels : bool
58
+ If ``True``, labels were auto-generated (e.g. ``b0``, ``b1``, …)
59
+ and the list of zeroed functions is suppressed.
60
+
61
+ Returns
62
+ -------
63
+ str
64
+ Ready-to-print multi-line table. Terms are marked *, **, or ***
65
+ according to *significance_thresholds*; *** terms are **bold**,
66
+ * and ** terms are normal weight, non-significant active terms are dimmed.
67
+ """
68
+ coefficients = np.asarray(coefficients).ravel()
69
+ n_labels = len(labels)
70
+
71
+ # Expand coefficients to full length when support is given
72
+ if support is not None:
73
+ support = np.asarray(support).ravel().astype(int)
74
+ if len(coefficients) == len(support):
75
+ # coefficients is sparse; expand to full length
76
+ full = np.zeros(n_labels)
77
+ full[support] = coefficients
78
+ coefficients = full
79
+ elif len(coefficients) != n_labels:
80
+ raise ValueError(
81
+ f"len(coefficients)={len(coefficients)} matches neither "
82
+ f"len(support)={len(support)} nor n_labels={n_labels}"
83
+ )
84
+ active = set(int(i) for i in support)
85
+ else:
86
+ active = None # all active
87
+
88
+ has_stderr = stderr is not None
89
+ if has_stderr:
90
+ stderr = np.asarray(stderr).ravel()
91
+ if stderr.shape[0] != n_labels:
92
+ # Might be sparse; try to expand
93
+ if support is not None and stderr.shape[0] == len(support):
94
+ se_full = np.zeros(n_labels)
95
+ se_full[support] = stderr
96
+ stderr = se_full
97
+ else:
98
+ raise ValueError(
99
+ f"len(stderr)={stderr.shape[0]} matches neither "
100
+ f"n_labels={n_labels} nor len(support)={len(support) if support is not None else 'N/A'}"
101
+ )
102
+
103
+ # Expand ground-truth coefficients to full length (paired "True" column)
104
+ has_true = coeffs_true is not None
105
+ true_full = None
106
+ if has_true:
107
+ ct = np.asarray(coeffs_true).ravel()
108
+ true_full = np.zeros(n_labels)
109
+ if support_true is not None:
110
+ st = np.asarray(support_true).ravel().astype(int)
111
+ true_full[st] = ct
112
+ elif ct.shape[0] == n_labels:
113
+ true_full = ct
114
+ elif support is not None and ct.shape[0] == len(support):
115
+ true_full[support] = ct
116
+ else:
117
+ true_full[: ct.shape[0]] = ct
118
+
119
+ # ── Build rows ──
120
+ # (index, label, coefficient, stderr_or_None, is_active, snr_or_None, true_or_None)
121
+ rows: list = []
122
+ for i, lab in enumerate(labels):
123
+ c = float(coefficients[i])
124
+ se = float(stderr[i]) if (stderr is not None and i < len(stderr)) else None
125
+ is_active = (active is None) or (i in active)
126
+ snr = abs(c / se) if (se is not None and se > 0) else None
127
+ tv = float(true_full[i]) if has_true else None
128
+ rows.append((i, lab, c, se, is_active, snr, tv))
129
+
130
+ # ── Column widths ──
131
+ idx_w = max(3, len(str(n_labels - 1)))
132
+ lab_w = max(5, max(len(r[1]) for r in rows))
133
+ coeff_fmt = "{:>12.5e}"
134
+ se_fmt = "{:>12.5e}"
135
+ snr_fmt = "{:>6.1f}"
136
+
137
+ # Determine whether SNR column should be shown
138
+ has_snr = has_stderr and any(r[5] is not None for r in rows)
139
+
140
+ # ── Header ──
141
+ sep_w = idx_w + lab_w + 30
142
+ if has_stderr:
143
+ sep_w += 15
144
+ if has_snr:
145
+ sep_w += 9 # " SNR "
146
+ if has_true:
147
+ sep_w += 15
148
+ sep = "─" * sep_w
149
+
150
+ lines: list[str] = []
151
+ lines.append(f" {title}")
152
+ lines.append(f" {sep}")
153
+
154
+ hdr = f" {'#':<{idx_w}} {'Label':<{lab_w}} {'Coefficient':>12}"
155
+ if has_stderr:
156
+ hdr += f" {'Std.Err':>12}"
157
+ if has_snr:
158
+ hdr += f" {'SNR':>6}"
159
+ if has_true:
160
+ hdr += f" {'True':>12}"
161
+ hdr += " Sig"
162
+ lines.append(hdr)
163
+ lines.append(f" {sep}")
164
+
165
+ # ── Rows (with optional truncation) — only show active (support) entries ──
166
+ thr1, thr2, thr3 = significance_thresholds
167
+
168
+ def _sig_marker(snr):
169
+ """Return (marker_str, is_bold) for a given SNR (or None)."""
170
+ if snr is None:
171
+ return "·", False
172
+ if snr >= thr3:
173
+ return "***", True
174
+ if snr >= thr2:
175
+ return "**", False
176
+ if snr >= thr1:
177
+ return "*", False
178
+ return "·", False
179
+
180
+ def _fmt_row(r):
181
+ i, lab, c, se, act, snr, tv = r
182
+ marker, bold = _sig_marker(snr if act else None)
183
+
184
+ # Start with index and label
185
+ s = f" {i:<{idx_w}} {lab:<{lab_w}} {coeff_fmt.format(c)}"
186
+ if has_stderr:
187
+ s += f" {se_fmt.format(se)}" if se is not None else f" {'---':>12}"
188
+ if has_snr:
189
+ s += f" {snr_fmt.format(snr)}" if snr is not None else f" {'---':>6}"
190
+ if has_true:
191
+ s += f" {coeff_fmt.format(tv)}" if tv is not None else f" {'---':>12}"
192
+
193
+ # Significance marker (padded to 3 chars for alignment)
194
+ s += f" {marker:<3}"
195
+
196
+ # Apply ANSI formatting: bold for ***, dim for non-significant active
197
+ if bold:
198
+ s = _BOLD + s + _RESET
199
+ elif marker == "·" and has_snr:
200
+ s = _DIM + s + _RESET
201
+
202
+ return s
203
+
204
+ active_rows = [r for r in rows if r[4]]
205
+ zero_rows = [r for r in rows if not r[4]]
206
+
207
+ if len(active_rows) <= max_rows:
208
+ for r in active_rows:
209
+ lines.append(_fmt_row(r))
210
+ else:
211
+ half = (max_rows - 1) // 2
212
+ for r in active_rows[:half]:
213
+ lines.append(_fmt_row(r))
214
+ lines.append(f" {'...':^{sep_w}}")
215
+ for r in active_rows[-half:]:
216
+ lines.append(_fmt_row(r))
217
+
218
+ lines.append(f" {sep}")
219
+
220
+ n_active = len(active_rows)
221
+ summary_line = f" {n_active}/{n_labels} basis functions in support"
222
+ if has_snr:
223
+ n1 = sum(1 for r in active_rows if r[5] is not None and r[5] >= thr1)
224
+ n2 = sum(1 for r in active_rows if r[5] is not None and r[5] >= thr2)
225
+ n3 = sum(1 for r in active_rows if r[5] is not None and r[5] >= thr3)
226
+ summary_line += f", sig: {n1}* / {n2}** / {n3}*** (|SNR| ≥ {thr1:.4g} / {thr2:.4g} / {thr3:.4g})"
227
+ lines.append(summary_line)
228
+ if has_stderr:
229
+ lines.append(" (Std.err. reflects sampling error only; discretization bias is not included.)")
230
+
231
+ # ── Zeroed basis functions ──
232
+ if zero_rows and not auto_labels:
233
+ zero_labels = ", ".join(r[1] for r in zero_rows)
234
+ lines.append(f" Zeroed ({len(zero_rows)}): {zero_labels}")
235
+
236
+ return "\n".join(lines)
237
+
238
+
239
+ def print_model_comparison(
240
+ inferences,
241
+ labels,
242
+ *,
243
+ extra_cols=None,
244
+ metrics=None,
245
+ title: str = "Model Comparison",
246
+ ) -> str:
247
+ """Build a multi-model comparison table from several inference objects.
248
+
249
+ Parameters
250
+ ----------
251
+ inferences :
252
+ Sequence of fitted inference objects.
253
+ labels :
254
+ One name per inference (row labels).
255
+ metrics :
256
+ Attribute names to read from each inference (default
257
+ ``["n_params", "NMSE_force", "force_predicted_MSE"]``). The special
258
+ ``"n_params"`` counts ``force_coefficients_full``.
259
+ extra_cols :
260
+ Optional ``{column_name: {label: value}}`` of caller-supplied cells.
261
+ title :
262
+ Header line.
263
+
264
+ Returns
265
+ -------
266
+ str
267
+ Ready-to-print table.
268
+ """
269
+ if metrics is None:
270
+ metrics = ["n_params", "NMSE_force", "force_predicted_MSE"]
271
+ extra_cols = extra_cols or {}
272
+ cols = ["Model"] + list(metrics) + list(extra_cols.keys())
273
+
274
+ def _metric(inf, name):
275
+ if name == "n_params":
276
+ cf = getattr(inf, "force_coefficients_full", None)
277
+ return int(np.asarray(cf).size) if cf is not None else None
278
+ v = getattr(inf, name, None)
279
+ return float(v) if v is not None else None
280
+
281
+ rows = []
282
+ for inf, lab in zip(inferences, labels):
283
+ row = {"Model": lab}
284
+ for name in metrics:
285
+ row[name] = _metric(inf, name)
286
+ for col, vals in extra_cols.items():
287
+ row[col] = vals.get(lab) if isinstance(vals, dict) else None
288
+ rows.append(row)
289
+
290
+ def _fmt(v):
291
+ if v is None:
292
+ return "—"
293
+ if isinstance(v, bool):
294
+ return str(v)
295
+ if isinstance(v, int):
296
+ return str(v)
297
+ if isinstance(v, float):
298
+ return f"{v:.4g}"
299
+ return str(v)
300
+
301
+ widths = {c: max(len(str(c)), *(len(_fmt(r.get(c))) for r in rows)) for c in cols}
302
+ lines = [f" {title}"]
303
+ header = " " + " ".join(f"{str(c):>{widths[c]}}" for c in cols)
304
+ lines.append(header)
305
+ lines.append(" " + "─" * (len(header) - 2))
306
+ for r in rows:
307
+ lines.append(" " + " ".join(f"{_fmt(r.get(c)):>{widths[c]}}" for c in cols))
308
+ return "\n".join(lines)
SFI/utils/maths.py ADDED
@@ -0,0 +1,185 @@
1
+ import jax
2
+ import jax.numpy as jnp
3
+ import jax.scipy.linalg as jsp_la
4
+ from jax import dtypes as _jdtypes
5
+ from jax import jit
6
+
7
+ # ---------------------------------------------------------------------
8
+ # Mathematical utilities
9
+ # ---------------------------------------------------------------------
10
+
11
+
12
+ def default_float_dtype():
13
+ """Return JAX's currently-active default float dtype.
14
+
15
+ Returns ``float64`` when ``JAX_ENABLE_X64`` is set, ``float32`` otherwise.
16
+ Use this everywhere user-supplied arrays enter the simulation pipeline
17
+ so that all internal arithmetic shares a single dtype and ``lax.scan``
18
+ carry-in / carry-out types always agree.
19
+ """
20
+ return _jdtypes.canonicalize_dtype(jnp.float64)
21
+
22
+
23
+ def as_default_float(x):
24
+ """Return ``x`` cast to the JAX default float dtype.
25
+
26
+ Accepts any array-like; passes through ``None`` unchanged so callers
27
+ can use it on optional inputs (e.g. ``v0``).
28
+ """
29
+ if x is None:
30
+ return None
31
+ return jnp.asarray(x, dtype=default_float_dtype())
32
+
33
+
34
+ def fd_velocity(x, dt, *, scheme: str = "central"):
35
+ """Finite-difference velocity along the leading (time) axis.
36
+
37
+ Reconstructs ``v(t) ≈ dx/dt`` from a position array using a
38
+ finite-difference stencil — the same secant-velocity convention the
39
+ underdamped inference engine uses internally. The output keeps the
40
+ same leading time dimension as ``x`` (boundary frames fall back to a
41
+ one-sided stencil).
42
+
43
+ Parameters
44
+ ----------
45
+ x : array_like, shape ``(T, ...)``
46
+ Positions sampled in time along axis 0 (e.g. ``(T, d)`` or
47
+ ``(T, N, d)``).
48
+ dt : float or array_like, shape ``(T - 1,)``
49
+ Time step. Scalar for uniform sampling, or per-interval spacings.
50
+ scheme : {"central", "forward", "backward"}
51
+ Interior stencil. ``"central"`` is second-order accurate and the
52
+ default; ``"forward"`` / ``"backward"`` are first-order.
53
+
54
+ Returns
55
+ -------
56
+ v : jax.Array, shape ``(T, ...)``
57
+ Velocity estimate, same shape as ``x``.
58
+ """
59
+ x = jnp.asarray(x)
60
+ if x.shape[0] < 2:
61
+ raise ValueError("fd_velocity needs at least 2 time points.")
62
+ dt = jnp.asarray(dt)
63
+ n_extra = x.ndim - 1
64
+
65
+ def _bcast(a):
66
+ # reshape a per-step (T-1,) array to broadcast over the trailing axes
67
+ return a.reshape(a.shape + (1,) * n_extra)
68
+
69
+ # forward differences between consecutive frames, shape (T-1, ...)
70
+ fwd = (x[1:] - x[:-1]) / (dt if dt.ndim == 0 else _bcast(dt))
71
+
72
+ if scheme == "forward":
73
+ return jnp.concatenate([fwd, fwd[-1:]], axis=0)
74
+ if scheme == "backward":
75
+ return jnp.concatenate([fwd[:1], fwd], axis=0)
76
+ if scheme == "central":
77
+ denom = (2.0 * dt) if dt.ndim == 0 else _bcast(dt[:-1] + dt[1:])
78
+ central = (x[2:] - x[:-2]) / denom
79
+ return jnp.concatenate([fwd[:1], central, fwd[-1:]], axis=0)
80
+ raise ValueError(f"Unknown scheme {scheme!r}; expected central/forward/backward.")
81
+
82
+
83
+ def stable_pinv(G):
84
+ """Numerically-stable pseudo-inverse of a Gram matrix.
85
+
86
+ Normalizes G by its diagonal before inversion so that all diagonal
87
+ entries of the rescaled matrix are 1. This avoids ill-conditioning
88
+ when basis functions have very different scales.
89
+
90
+ Algorithm: let d_i = sqrt(G_{ii}) (clamped to 1 when zero). Compute
91
+ pinv(G / outer(d, d)) and rescale by outer(1/d, 1/d) to recover the
92
+ pseudo-inverse of the original G.
93
+ """
94
+ # Normalize the matrix before computing the pseudo-inverse, for numerical stability
95
+ G_norm = jnp.sqrt(jnp.diag(G)) # Extract diagonal elements as normalization factors
96
+ # Avoid division by zero: if G_norm[i] is zero, keep it zero in scaling
97
+ safe_G_norm = jnp.where(G_norm > 0, G_norm, 1.0) # Replace 0 with 1 to avoid NaN in division
98
+ return jnp.linalg.pinv(G / jnp.outer(safe_G_norm, safe_G_norm)) * jnp.outer(1 / safe_G_norm, 1 / safe_G_norm)
99
+
100
+
101
+ @jit
102
+ def sqrtm_psd(A: jax.Array, eps: float = 0.0) -> jax.Array:
103
+ """
104
+ Symmetric PSD matrix square root, applied to the last two (matrix) axes.
105
+ - Symmetrizes input for stability.
106
+ - Clips negative eigenvalues to 0 (PSD) then takes sqrt.
107
+ - Re-symmetrizes the result to kill small asymmetries from numerics.
108
+ """
109
+ A = 0.5 * (A + jnp.swapaxes(A, -1, -2))
110
+ w, V = jnp.linalg.eigh(A)
111
+ w = jnp.clip(w, min=0.0) # PSD safeguard
112
+ if eps:
113
+ w = w + eps # optional jitter if you like
114
+ S = (V * jnp.sqrt(w)[..., None, :]) @ jnp.swapaxes(V, -1, -2)
115
+ return 0.5 * (S + jnp.swapaxes(S, -1, -2))
116
+
117
+
118
+ def solve_or_pinv(A: jax.Array, b: jax.Array, tol: float = 1e-15) -> jax.Array:
119
+ """
120
+ Solve A ⋅ x = b for x, with a fallback to the Moore–Penrose pseudo-inverse
121
+ if A is singular or not square. To improve numerical stability, we first
122
+ normalize A by its diagonal: A_norm = D^{-1} A D^{-1}, b_norm = D^{-1} b,
123
+ solve A_norm ⋅ x_norm = b_norm, and then recover x = D^{-1} x_norm.
124
+
125
+ This ensures that the diagonal entries of A_norm are 1 (assuming A has
126
+ positive diagonal), which often makes the linear solve or pseudo-inverse
127
+ more robust when A has widely varying scales on its diagonal.
128
+
129
+ Parameters
130
+ ----------
131
+ A : jax.Array, shape (k, k)
132
+ The matrix to solve against. We assume that A has nonnegative diagonal
133
+ entries; if any diagonal entry is zero, we clip it to a small floor
134
+ to avoid division by zero.
135
+ b : jax.Array, shape (k,)
136
+ The right-hand side vector.
137
+ tol : float, default=1e-15
138
+ The tolerance for the pseudo-inverse. If A_norm is effectively singular,
139
+ we compute x_norm = pinv(A_norm, rcond=tol) @ b_norm.
140
+
141
+ Returns
142
+ -------
143
+ x : jax.Array, shape (k,)
144
+ The solution vector to A ⋅ x = b, computed as follows:
145
+ 1) d_i = sqrt(max(A_{ii}, tol))
146
+ (we floor each diagonal entry to tol > 0 to avoid zero divides)
147
+ 2) A_norm = D_inv @ A @ D_inv where D_inv = diag(1 / d_i)
148
+ b_norm = b / d
149
+ 3) Solve A_norm ⋅ x_norm = b_norm:
150
+ - if A_norm is non-singular, use a direct solver
151
+ - otherwise, fall back to x_norm = pinv(A_norm) @ b_norm
152
+ 4) Recover x = x_norm / d
153
+ """
154
+ # 1) Extract and “floor” the diagonal of A to avoid zeros or negatives.
155
+ # If A_{ii} is very small or negative (due to numerical noise), we floor it.
156
+ diag_A = jnp.diag(A) # shape (k,)
157
+ # Clip to tol to avoid sqrt of zero or negative
158
+ diag_clipped = jnp.clip(diag_A, min=tol) # shape (k,)
159
+ d = jnp.sqrt(diag_clipped) # shape (k,)
160
+
161
+ # 2) Build the inverse scaling matrix D_inv = diag(1 / d_i)
162
+ # We do this by dividing each row of A by d_i and each column by d_j.
163
+ # More precisely: A_norm[i,j] = A[i,j] / (d[i] * d[j]).
164
+ D_inv = 1.0 / d # shape (k,)
165
+ # Use broadcasting to normalize A: first divide each row by d,
166
+ # then each column by d.
167
+ A_norm = (A * D_inv[:, None]) * D_inv[None, :]
168
+
169
+ # 3) Normalize the RHS vector b as well: b_norm = b / d.
170
+ b_norm = b / d # shape (k,)
171
+
172
+ # 4) Attempt a direct solve of A_norm ⋅ x_norm = b_norm.
173
+ # If A_norm is non-singular, this will succeed.
174
+ try:
175
+ # We do not assume positive-definite here, so use 'gen' unless we detect
176
+ # symmetry and positive definiteness. For simplicity, assume 'gen'.
177
+ x_norm = jsp_la.solve(A_norm, b_norm, assume_a="gen")
178
+ except Exception: # broad catch: JAX/XLA doesn't expose a stable LinAlgError type
179
+ # 4a) Fallback: if A_norm is singular or not square, use pseudo-inverse.
180
+ x_norm = jnp.linalg.pinv(A_norm, rtol=tol) @ b_norm
181
+
182
+ # 5) Scale back to obtain the final solution: x_i = x_norm[i] / d[i].
183
+ x = x_norm / d # shape (k,)
184
+
185
+ return x