jaxcont 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.
jaxcont/__init__.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ JaxCont: High-Performance Continuation and Bifurcation Analysis in JAX
3
+
4
+ A modern Python package for numerical continuation and bifurcation analysis
5
+ of dynamical systems, leveraging JAX's automatic differentiation and JIT
6
+ compilation for exceptional performance.
7
+ """
8
+
9
+ from jaxcont._version import __version__
10
+
11
+ __author__ = "Abolfazl Ziaeemehr"
12
+ __license__ = "MIT"
13
+
14
+ # Functional API -- the blessed public surface: bif_problem() + continuation()
15
+ from jaxcont.api import (
16
+ BifProblem,
17
+ bif_problem,
18
+ continuation,
19
+ ContinuationPar,
20
+ ContinuationAlgorithm,
21
+ PseudoArclength,
22
+ Natural,
23
+ Event,
24
+ Fold,
25
+ Hopf,
26
+ EventHit,
27
+ Branch,
28
+ ContinuationResult,
29
+ )
30
+
31
+ # Core imports
32
+ from jaxcont.core.continuation import (
33
+ ContinuationProblem,
34
+ ContinuationSolution,
35
+ equilibrium_continuation,
36
+ )
37
+ from jaxcont.core.predictor_corrector import PredictorCorrector
38
+ from jaxcont.core.natural_continuation import NaturalContinuation
39
+ from jaxcont.core.pseudo_arclength import PseudoArclengthContinuation
40
+
41
+ # Problem definitions
42
+ from jaxcont.problems.equilibrium import EquilibriumProblem
43
+
44
+ # Differentiable fold solver (reverse-mode grad of a fold location via the
45
+ # implicit function theorem -- see examples/example_07_differentiable.py)
46
+ from jaxcont.bifurcations.fold_solve import fold_point, fold_parameter
47
+
48
+ # Bifurcation detection
49
+ from jaxcont.bifurcations.detector import BifurcationDetector
50
+ from jaxcont.bifurcations.fold import FoldBifurcation
51
+ from jaxcont.bifurcations.hopf import HopfBifurcation
52
+
53
+ # Solvers
54
+ from jaxcont.solvers.newton import NewtonSolver
55
+ from jaxcont.solvers.corrector import Corrector
56
+
57
+ # Stability analysis
58
+ from jaxcont.stability.eigenvalue import compute_eigenvalues, analyze_stability
59
+
60
+ # NOTE: v0.1.0 ships equilibria only. Periodic-orbit / Floquet / BVP / period-
61
+ # doubling APIs are experimental stubs and are intentionally NOT exported at
62
+ # the top level (see the project roadmap). They remain importable from their
63
+ # submodules for development, e.g.:
64
+ # from jaxcont.problems.periodic import PeriodicOrbitProblem
65
+ # from jaxcont.stability.floquet import compute_floquet_multipliers
66
+
67
+ # Utilities
68
+ from jaxcont.utils.config import Config
69
+ from jaxcont.utils.plotting import plot_bifurcation_diagram, plot_continuation
70
+
71
+ __all__ = [
72
+ # Functional API (blessed surface)
73
+ "BifProblem",
74
+ "bif_problem",
75
+ "continuation",
76
+ "ContinuationPar",
77
+ "ContinuationAlgorithm",
78
+ "PseudoArclength",
79
+ "Natural",
80
+ "Event",
81
+ "Fold",
82
+ "Hopf",
83
+ "EventHit",
84
+ "Branch",
85
+ "ContinuationResult",
86
+ "fold_point",
87
+ "fold_parameter",
88
+ # Core
89
+ "ContinuationProblem",
90
+ "ContinuationSolution",
91
+ "equilibrium_continuation",
92
+ "PredictorCorrector",
93
+ "NaturalContinuation",
94
+ "PseudoArclengthContinuation",
95
+ # Problems
96
+ "EquilibriumProblem",
97
+ # Bifurcations
98
+ "BifurcationDetector",
99
+ "FoldBifurcation",
100
+ "HopfBifurcation",
101
+ # Solvers
102
+ "NewtonSolver",
103
+ "Corrector",
104
+ # Stability
105
+ "compute_eigenvalues",
106
+ "analyze_stability",
107
+ # Utilities
108
+ "Config",
109
+ "plot_bifurcation_diagram",
110
+ "plot_continuation",
111
+ ]
jaxcont/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Version information for JaxCont."""
2
+
3
+ __version__ = "0.1.0"
jaxcont/api.py ADDED
@@ -0,0 +1,478 @@
1
+ """
2
+ Functional, diffrax-style public API for JaxCont (the "Sketch A" spine).
3
+
4
+ This is the blessed surface going forward:
5
+
6
+ prob = jaxcont.bif_problem(f, u0, p0)
7
+ sol = jaxcont.continuation(prob, p_span=(0.0, 1.0), events=[jaxcont.Fold()])
8
+
9
+ The default algorithm (`PseudoArclength(engine="scan")`) runs on the fully
10
+ JIT-compiled whole-loop engine in `core/scan_continuation.py`, which is what
11
+ makes `vmap`-batched continuation and `jax.grad`/`jax.jacfwd` through the
12
+ analysis possible; `engine="legacy"` and `NaturalContinuation` fall back to
13
+ the class-based Python outer loop for compatibility.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field, replace
19
+ from typing import Any, Callable, Literal, Optional, Sequence, Tuple
20
+
21
+ import jax
22
+ import jax.numpy as jnp
23
+ from jax import Array
24
+
25
+ from jaxcont.core.continuation import ContinuationProblem, ContinuationSolution
26
+ from jaxcont.core.pseudo_arclength import PseudoArclengthContinuation
27
+ from jaxcont.core.natural_continuation import NaturalContinuation
28
+
29
+ __all__ = [
30
+ "BifProblem",
31
+ "bif_problem",
32
+ "continuation",
33
+ "ContinuationPar",
34
+ "ContinuationAlgorithm",
35
+ "PseudoArclength",
36
+ "Natural",
37
+ "Event",
38
+ "Fold",
39
+ "Hopf",
40
+ "EventHit",
41
+ "Branch",
42
+ "ContinuationResult",
43
+ ]
44
+
45
+ PyTree = Any
46
+
47
+ # Internal name for the continuation parameter inside the legacy params dict.
48
+ _P_KEY = "__jaxcont_p__"
49
+
50
+ # Sentinel for "leave this field unchanged" (distinct from args=None, which is
51
+ # a legitimate value meaning "no extra args").
52
+ _KEEP = object()
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Problem
57
+ # ---------------------------------------------------------------------------
58
+
59
+ @dataclass(frozen=True)
60
+ class BifProblem:
61
+ """
62
+ A continuation/bifurcation problem: find ``u`` with ``f(u, p, args) = 0``
63
+ and continue it in the scalar parameter ``p``.
64
+
65
+ ``f`` is a pure function ``f(u, p, args) -> residual`` (same shape as ``u``).
66
+ Extra parameters live in ``args`` (any PyTree) — this is the axis you
67
+ ``vmap``/``grad`` over (a second parameter, a design vector, NN weights).
68
+
69
+ Registered as a JAX PyTree: ``u0``, ``p0``, ``args`` are dynamic leaves;
70
+ ``f`` and ``kind`` are static. This makes ``jax.vmap(..., in_axes=...)`` over
71
+ a batched problem structurally valid (full end-to-end vmap awaits the
72
+ lax.scan loop rewrite; see ARCHITECTURE.md §3.1).
73
+ """
74
+
75
+ f: Callable[[Array, Array, PyTree], Array]
76
+ u0: Array
77
+ p0: Array
78
+ args: PyTree = None
79
+ kind: Literal["equilibrium", "periodic", "bvp"] = "equilibrium"
80
+
81
+ def at(
82
+ self,
83
+ *,
84
+ u0: Optional[Array] = None,
85
+ p0: Optional[Array] = None,
86
+ args: PyTree = _KEEP,
87
+ ) -> "BifProblem":
88
+ """Return a copy with selected fields overridden (cheap, functional)."""
89
+ return replace(
90
+ self,
91
+ u0=self.u0 if u0 is None else u0,
92
+ p0=self.p0 if p0 is None else p0,
93
+ args=self.args if args is _KEEP else args,
94
+ )
95
+
96
+ def as_rhs(self, p) -> Callable[[Array], Array]:
97
+ """
98
+ Return an autonomous ``rhs(u)`` frozen at parameter value ``p``.
99
+
100
+ This is the lyapax bridge (ARCHITECTURE.md §9): hand a branch point to
101
+ ``lyapax.ode_problem(prob.as_rhs(p_star), state0=u_star, ...)``.
102
+ """
103
+ args = self.args
104
+ f = self.f
105
+ return lambda u: f(u, p, args)
106
+
107
+
108
+ def _bifproblem_flatten(prob: BifProblem):
109
+ children = (prob.u0, prob.p0, prob.args)
110
+ aux = (prob.f, prob.kind)
111
+ return children, aux
112
+
113
+
114
+ def _bifproblem_unflatten(aux, children):
115
+ f, kind = aux
116
+ u0, p0, args = children
117
+ return BifProblem(f=f, u0=u0, p0=p0, args=args, kind=kind)
118
+
119
+
120
+ jax.tree_util.register_pytree_node(
121
+ BifProblem, _bifproblem_flatten, _bifproblem_unflatten
122
+ )
123
+
124
+
125
+ def bif_problem(
126
+ f: Callable[[Array, Array, PyTree], Array],
127
+ u0: Array,
128
+ p0: float | Array,
129
+ *,
130
+ args: PyTree = None,
131
+ kind: str = "equilibrium",
132
+ ) -> BifProblem:
133
+ """
134
+ Front-door factory for a :class:`BifProblem` (mirrors lyapax's
135
+ ``ode_problem``). Coerces ``u0``/``p0`` to JAX arrays.
136
+ """
137
+ return BifProblem(
138
+ f=f,
139
+ u0=jnp.asarray(u0),
140
+ p0=jnp.asarray(p0, dtype=jnp.asarray(u0).dtype),
141
+ args=args,
142
+ kind=kind,
143
+ )
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Algorithms & settings
148
+ # ---------------------------------------------------------------------------
149
+
150
+ class ContinuationAlgorithm:
151
+ """Marker base for continuation algorithms."""
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class PseudoArclength(ContinuationAlgorithm):
156
+ """
157
+ Pseudo-arclength continuation (default; passes fold points).
158
+
159
+ ``engine`` selects the implementation.
160
+
161
+ * ``"scan"`` (default) is the fully JIT-compiled whole-loop engine
162
+ (``core/scan_continuation.py``): it is ``vmap``-able and structurally
163
+ bounded. Detection/stability are computed as a vectorized post-pass and
164
+ refined with the same detector.
165
+ * ``"legacy"`` is the class-based Python outer loop, retained for
166
+ compatibility.
167
+ """
168
+
169
+ engine: Literal["legacy", "scan"] = "scan"
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class Natural(ContinuationAlgorithm):
174
+ """Natural-parameter continuation (simple; stalls at folds)."""
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class ContinuationPar:
179
+ """Numerical settings for a continuation run."""
180
+
181
+ ds: float = 0.01
182
+ ds_min: float = 1e-5
183
+ ds_max: float = 0.1
184
+ max_steps: int = 1000
185
+ adaptive: bool = True
186
+ newton_tol: float = 1e-6
187
+ newton_max_iter: int = 20
188
+ compute_stability: bool = True
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Events
193
+ # ---------------------------------------------------------------------------
194
+
195
+ class Event:
196
+ """Marker base for a bifurcation/event detector.
197
+
198
+ Naming follows the standard abbreviations used throughout the
199
+ bifurcation-theory literature (see
200
+ ``jaxcont.bifurcations.taxonomy.BIFURCATION_TYPES``) rather than
201
+ inventing new names -- e.g. a fold is ``jc.Fold`` (abbreviation **LP**)
202
+ and a Hopf point is ``jc.Hopf`` (abbreviation **H**).
203
+ """
204
+
205
+ #: legacy detector key this event maps onto ("fold" | "hopf")
206
+ _kind: str = ""
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class Fold(Event):
211
+ """A limit point / fold bifurcation of equilibria.
212
+
213
+ Abbreviation: **LP** -- ``jaxcont.bifurcations.taxonomy.describe("LP")``.
214
+ """
215
+
216
+ _kind: str = "fold"
217
+
218
+
219
+ @dataclass(frozen=True)
220
+ class Hopf(Event):
221
+ """A Hopf bifurcation of equilibria.
222
+
223
+ Abbreviation: **H** -- ``jaxcont.bifurcations.taxonomy.describe("H")``.
224
+ """
225
+
226
+ _kind: str = "hopf"
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class EventHit:
231
+ """A detected event along the branch."""
232
+
233
+ kind: str
234
+ p: float
235
+ u: Array
236
+ index: Optional[Tuple[int, int]] = None
237
+ info: dict = field(default_factory=dict)
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Results
242
+ # ---------------------------------------------------------------------------
243
+
244
+ @dataclass
245
+ class Branch:
246
+ """The computed solution branch (one connected curve)."""
247
+
248
+ params: Array # (n_valid,)
249
+ states: Array # (n_valid, state_dim)
250
+ tangents: Optional[Array] = None
251
+ eigenvalues: Optional[Array] = None
252
+ stable: Optional[Array] = None
253
+
254
+ @property
255
+ def n_valid(self) -> int:
256
+ return int(self.params.shape[0])
257
+
258
+ def at_param(self, p: float) -> Tuple[float, Array]:
259
+ """Return the ``(param, state)`` on the branch closest to ``p``."""
260
+ idx = int(jnp.argmin(jnp.abs(self.params - p)))
261
+ return float(self.params[idx]), self.states[idx]
262
+
263
+
264
+ @dataclass
265
+ class ContinuationResult:
266
+ """Return value of :func:`continuation`."""
267
+
268
+ branch: Branch
269
+ events: list[EventHit] = field(default_factory=list)
270
+ stats: dict = field(default_factory=dict)
271
+ _solution: Optional[ContinuationSolution] = None
272
+
273
+ def plot(self, **kwargs):
274
+ """Plot the bifurcation diagram (delegates to the legacy solution)."""
275
+ if self._solution is None:
276
+ raise RuntimeError("No underlying solution to plot.")
277
+ return self._solution.plot(**kwargs)
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Adapters (BifProblem <-> legacy ContinuationProblem/Solution)
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def _to_legacy_problem(problem: BifProblem) -> ContinuationProblem:
285
+ """Wrap a BifProblem's ``f(u, p, args)`` as a legacy ``rhs(u, params)``."""
286
+ f = problem.f
287
+ args = problem.args
288
+
289
+ def rhs(u, params):
290
+ return f(u, params[_P_KEY], args)
291
+
292
+ return ContinuationProblem(
293
+ rhs=rhs,
294
+ u0=problem.u0,
295
+ params={_P_KEY: float(problem.p0)},
296
+ continuation_param=_P_KEY,
297
+ problem_type=problem.kind,
298
+ )
299
+
300
+
301
+ def _run_scan(
302
+ problem: BifProblem,
303
+ p_span: Tuple[float, float],
304
+ settings: ContinuationPar,
305
+ events: Sequence[Event],
306
+ verbose: bool,
307
+ ) -> ContinuationResult:
308
+ """
309
+ Run the fully-JIT scan engine and reassemble a legacy-shaped
310
+ :class:`ContinuationSolution` so detection/plotting reuse existing code.
311
+ """
312
+ from jaxcont.core.scan_continuation import (
313
+ pseudo_arclength_scan,
314
+ branch_eigenvalues,
315
+ )
316
+
317
+ args = problem.args
318
+ rhs2 = lambda u, p: problem.f(u, p, args)
319
+
320
+ p_start, p_end = p_span
321
+ u0 = jnp.asarray(problem.u0)
322
+ dtype = u0.dtype
323
+
324
+ res = pseudo_arclength_scan(
325
+ rhs2,
326
+ u0,
327
+ jnp.asarray(p_start, dtype),
328
+ jnp.asarray(p_end, dtype),
329
+ jnp.asarray(settings.ds, dtype),
330
+ jnp.asarray(settings.ds_min, dtype),
331
+ jnp.asarray(settings.ds_max, dtype),
332
+ jnp.asarray(settings.newton_tol, dtype),
333
+ int(settings.max_steps),
334
+ jnp.asarray(settings.newton_max_iter),
335
+ )
336
+
337
+ n = int(res.n_valid)
338
+ states = res.states[:n]
339
+ params = res.params[:n]
340
+ tangents = res.tangents[:n]
341
+
342
+ eigenvalues = None
343
+ stability = None
344
+ want_eigs = settings.compute_stability or len(events) > 0
345
+ if want_eigs and states.shape[0] > 0:
346
+ eigenvalues = branch_eigenvalues(rhs2, states, params)
347
+ stability = jnp.all(jnp.real(eigenvalues) < 0.0, axis=1)
348
+
349
+ convergence_info = [
350
+ {"step": i, "converged": bool(res.converged[i]), "newton_iters": 0}
351
+ for i in range(n)
352
+ ]
353
+
354
+ sol = ContinuationSolution(
355
+ states=states,
356
+ parameters=params,
357
+ tangent_vectors=tangents,
358
+ eigenvalues=eigenvalues,
359
+ stability=stability,
360
+ convergence_info=convergence_info,
361
+ )
362
+
363
+ # Reuse the existing detector on the reassembled solution.
364
+ if len(events) > 0 and eigenvalues is not None:
365
+ from jaxcont.bifurcations.detector import BifurcationDetector
366
+
367
+ requested = {e._kind for e in events if getattr(e, "_kind", "")}
368
+ detector = BifurcationDetector(
369
+ detect_fold="fold" in requested,
370
+ detect_hopf="hopf" in requested,
371
+ tolerance=1e-6,
372
+ )
373
+ sol.bifurcations = detector.detect_along_branch(
374
+ sol,
375
+ eigenvalues,
376
+ refine_location=True,
377
+ problem=_to_legacy_problem(problem),
378
+ fold_extended_system=True,
379
+ )
380
+
381
+ result = _to_result(sol)
382
+ requested = {e._kind for e in events if getattr(e, "_kind", "")}
383
+ if requested:
384
+ result.events = [h for h in result.events if h.kind in requested]
385
+ return result
386
+
387
+
388
+ def _to_result(sol: ContinuationSolution) -> ContinuationResult:
389
+ branch = Branch(
390
+ params=sol.parameters,
391
+ states=sol.states,
392
+ tangents=sol.tangent_vectors,
393
+ eigenvalues=sol.eigenvalues,
394
+ stable=sol.stability,
395
+ )
396
+ events = [
397
+ EventHit(
398
+ kind=b.get("type", "unknown"),
399
+ p=float(b.get("parameter", 0.0)),
400
+ u=b.get("state"),
401
+ index=b.get("index"),
402
+ info={k: v for k, v in b.items()
403
+ if k not in ("type", "parameter", "state", "index")},
404
+ )
405
+ for b in (sol.bifurcations or [])
406
+ ]
407
+ stats = {
408
+ "n_points": sol.n_points,
409
+ "converged_steps": sum(
410
+ 1 for c in (sol.convergence_info or []) if c.get("converged")
411
+ ),
412
+ }
413
+ return ContinuationResult(branch=branch, events=events, stats=stats, _solution=sol)
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Entry point
418
+ # ---------------------------------------------------------------------------
419
+
420
+ def continuation(
421
+ problem: BifProblem,
422
+ alg: ContinuationAlgorithm = PseudoArclength(),
423
+ *,
424
+ p_span: Tuple[float, float],
425
+ settings: ContinuationPar = ContinuationPar(),
426
+ events: Sequence[Event] = (),
427
+ verbose: bool = False,
428
+ ) -> ContinuationResult:
429
+ """
430
+ Continue a solution branch of ``problem`` across ``p_span``.
431
+
432
+ Args:
433
+ problem: the :class:`BifProblem` to continue.
434
+ alg: :class:`PseudoArclength` (default) or :class:`Natural`.
435
+ p_span: ``(p_start, p_end)`` range for the continuation parameter.
436
+ settings: numerical settings (:class:`ContinuationPar`).
437
+ events: detectors to run along the branch (e.g. ``[Fold(), Hopf()]``).
438
+ An empty list disables detection.
439
+ verbose: print a bifurcation summary.
440
+
441
+ Returns:
442
+ :class:`ContinuationResult` with ``.branch`` and ``.events``.
443
+ """
444
+ # Fast path: the fully-JIT whole-loop engine.
445
+ if isinstance(alg, PseudoArclength) and alg.engine == "scan":
446
+ return _run_scan(problem, p_span, settings, events, verbose)
447
+
448
+ if isinstance(alg, Natural):
449
+ runner_cls = NaturalContinuation
450
+ elif isinstance(alg, PseudoArclength):
451
+ runner_cls = PseudoArclengthContinuation
452
+ else:
453
+ raise TypeError(f"Unknown continuation algorithm: {alg!r}")
454
+
455
+ detect = len(events) > 0
456
+ runner = runner_cls(
457
+ ds=settings.ds,
458
+ ds_min=settings.ds_min,
459
+ ds_max=settings.ds_max,
460
+ max_steps=settings.max_steps,
461
+ adaptive_stepsize=settings.adaptive,
462
+ newton_tol=settings.newton_tol,
463
+ newton_max_iter=settings.newton_max_iter,
464
+ detect_bifurcations=detect,
465
+ compute_stability=settings.compute_stability,
466
+ verbose=verbose,
467
+ )
468
+
469
+ legacy_problem = _to_legacy_problem(problem)
470
+ sol = runner.run(legacy_problem, param_range=p_span)
471
+ result = _to_result(sol)
472
+
473
+ # Filter detected events to those requested, if the user narrowed the set.
474
+ requested = {e._kind for e in events if getattr(e, "_kind", "")}
475
+ if requested:
476
+ result.events = [h for h in result.events if h.kind in requested]
477
+
478
+ return result
@@ -0,0 +1,18 @@
1
+ """Bifurcation detection and analysis."""
2
+
3
+ from jaxcont.bifurcations.detector import BifurcationDetector
4
+ from jaxcont.bifurcations.fold import FoldBifurcation
5
+ from jaxcont.bifurcations.hopf import HopfBifurcation
6
+ from jaxcont.bifurcations.period_doubling import PeriodDoublingBifurcation
7
+ from jaxcont.bifurcations.taxonomy import LABELS, BIFURCATION_TYPES, BifurcationLabel, describe
8
+
9
+ __all__ = [
10
+ "BifurcationDetector",
11
+ "FoldBifurcation",
12
+ "HopfBifurcation",
13
+ "PeriodDoublingBifurcation",
14
+ "LABELS",
15
+ "BIFURCATION_TYPES",
16
+ "BifurcationLabel",
17
+ "describe",
18
+ ]