op-engine 0.2.0__tar.gz → 0.3.0__tar.gz

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 (103) hide show
  1. {op_engine-0.2.0 → op_engine-0.3.0}/.gitignore +2 -1
  2. op_engine-0.3.0/CHANGELOG.md +140 -0
  3. {op_engine-0.2.0 → op_engine-0.3.0}/PKG-INFO +7 -3
  4. {op_engine-0.2.0 → op_engine-0.3.0}/README.md +3 -2
  5. op_engine-0.3.0/docs/assets/chemostat-split.svg +41 -0
  6. op_engine-0.3.0/docs/assets/chemostat-summary.png +0 -0
  7. op_engine-0.3.0/docs/development/fem-assembly-boundary.md +102 -0
  8. {op_engine-0.2.0 → op_engine-0.3.0}/docs/guides/backends.md +18 -9
  9. op_engine-0.3.0/docs/guides/chemostat-pde.md +97 -0
  10. {op_engine-0.2.0 → op_engine-0.3.0}/docs/guides/getting-started.md +12 -6
  11. {op_engine-0.2.0 → op_engine-0.3.0}/docs/guides/solver-methods.md +65 -7
  12. {op_engine-0.2.0 → op_engine-0.3.0}/docs/index.md +3 -0
  13. op_engine-0.3.0/examples/chemostat_pde.py +614 -0
  14. op_engine-0.3.0/examples/fem_diffusion.py +189 -0
  15. op_engine-0.3.0/examples/test_chemostat_pde.py +107 -0
  16. op_engine-0.3.0/examples/test_fem_diffusion.py +79 -0
  17. op_engine-0.3.0/flepimop2-op_engine/README.md +381 -0
  18. op_engine-0.3.0/flepimop2-op_engine/benchmarks/README.md +93 -0
  19. op_engine-0.3.0/flepimop2-op_engine/benchmarks/__init__.py +17 -0
  20. op_engine-0.3.0/flepimop2-op_engine/benchmarks/adaptive_replay.py +319 -0
  21. op_engine-0.3.0/flepimop2-op_engine/benchmarks/prepared_execution.py +311 -0
  22. op_engine-0.3.0/flepimop2-op_engine/benchmarks/solver_matrix.py +944 -0
  23. op_engine-0.3.0/flepimop2-op_engine/benchmarks/structured_blocks.py +266 -0
  24. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/pyproject.toml +15 -11
  25. op_engine-0.3.0/flepimop2-op_engine/src/flepimop2/engine/op_engine/__init__.py +3605 -0
  26. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/config.py +168 -4
  27. op_engine-0.3.0/flepimop2-op_engine/src/flepimop2/engine/op_engine/explicit_operators.py +300 -0
  28. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/operators.py +314 -13
  29. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/reactions.py +167 -20
  30. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/tests/test_config.py +238 -3
  31. op_engine-0.3.0/flepimop2-op_engine/tests/test_engine.py +1310 -0
  32. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/tests/test_op_system_integration.py +755 -11
  33. op_engine-0.3.0/flepimop2-op_engine/tests/test_operators.py +696 -0
  34. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/tests/test_reactions.py +50 -0
  35. op_engine-0.3.0/flepimop2-op_engine/tests/test_solver_matrix_benchmark.py +99 -0
  36. {op_engine-0.2.0 → op_engine-0.3.0}/justfile +1 -0
  37. {op_engine-0.2.0 → op_engine-0.3.0}/mkdocs.yml +3 -0
  38. {op_engine-0.2.0 → op_engine-0.3.0}/pyproject.toml +5 -1
  39. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/__init__.py +8 -1
  40. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/_additive_runge_kutta.py +3 -2
  41. op_engine-0.3.0/src/op_engine/_array.py +33 -0
  42. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/_multistep.py +2 -14
  43. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/_runge_kutta.py +66 -0
  44. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/_sdirk.py +1 -1
  45. op_engine-0.3.0/src/op_engine/_typing.py +36 -0
  46. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/adaptive_tau.py +1 -1
  47. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/core_solver.py +492 -136
  48. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/matrix_ops.py +179 -99
  49. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/model_core.py +3 -17
  50. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/nonlinear_solver.py +2 -14
  51. op_engine-0.3.0/src/op_engine/spatial_grid.py +252 -0
  52. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/stochastic_solver.py +2 -15
  53. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_array_namespace.py +244 -2
  54. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_configuration.py +22 -0
  55. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_core_solver_accuracy.py +80 -0
  56. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_core_solver_plumbing.py +88 -2
  57. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_matrix_ops.py +153 -0
  58. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_runge_kutta.py +41 -0
  59. op_engine-0.3.0/tests/op_engine/test_spatial_grid.py +140 -0
  60. op_engine-0.2.0/CHANGELOG.md +0 -51
  61. op_engine-0.2.0/flepimop2-op_engine/README.md +0 -120
  62. op_engine-0.2.0/flepimop2-op_engine/src/flepimop2/engine/op_engine/__init__.py +0 -868
  63. op_engine-0.2.0/flepimop2-op_engine/tests/test_engine.py +0 -363
  64. op_engine-0.2.0/flepimop2-op_engine/tests/test_operators.py +0 -259
  65. op_engine-0.2.0/src/op_engine/_typing.py +0 -38
  66. {op_engine-0.2.0 → op_engine-0.3.0}/.github/workflows/ci.yaml +0 -0
  67. {op_engine-0.2.0 → op_engine-0.3.0}/.github/workflows/gh-pages.yaml +0 -0
  68. {op_engine-0.2.0 → op_engine-0.3.0}/.github/workflows/release.yaml +0 -0
  69. {op_engine-0.2.0 → op_engine-0.3.0}/LICENSE +0 -0
  70. {op_engine-0.2.0 → op_engine-0.3.0}/docs/api-reference/.gitignore +0 -0
  71. {op_engine-0.2.0 → op_engine-0.3.0}/docs/development/creating-a-release.md +0 -0
  72. {op_engine-0.2.0 → op_engine-0.3.0}/docs/guides/biogeochemical-network.md +0 -0
  73. {op_engine-0.2.0 → op_engine-0.3.0}/docs/guides/linear-multistep-methods.md +0 -0
  74. {op_engine-0.2.0 → op_engine-0.3.0}/docs/guides/nonlinear-solvers.md +0 -0
  75. {op_engine-0.2.0 → op_engine-0.3.0}/examples/__init__.py +0 -0
  76. {op_engine-0.2.0 → op_engine-0.3.0}/examples/biogeochemical_network.py +0 -0
  77. {op_engine-0.2.0 → op_engine-0.3.0}/examples/simple_sir.py +0 -0
  78. {op_engine-0.2.0 → op_engine-0.3.0}/examples/test_biogeochemical_network.py +0 -0
  79. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/LICENSE +0 -0
  80. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/src/flepimop2/engine/op_engine/py.typed +0 -0
  81. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/tests/conftest.py +0 -0
  82. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/tests/data/config.yaml +0 -0
  83. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/tests/data/sir.py +0 -0
  84. {op_engine-0.2.0 → op_engine-0.3.0}/flepimop2-op_engine/tests/test_integration_cli.py +0 -0
  85. {op_engine-0.2.0 → op_engine-0.3.0}/overrides/main.html +0 -0
  86. {op_engine-0.2.0 → op_engine-0.3.0}/scripts/__init__.py +0 -0
  87. {op_engine-0.2.0 → op_engine-0.3.0}/scripts/api-reference.py +0 -0
  88. {op_engine-0.2.0 → op_engine-0.3.0}/scripts/release_validate.py +0 -0
  89. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/_rosenbrock.py +0 -0
  90. {op_engine-0.2.0 → op_engine-0.3.0}/src/op_engine/py.typed +0 -0
  91. {op_engine-0.2.0 → op_engine-0.3.0}/tests/conftest.py +0 -0
  92. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test__version__.py +0 -0
  93. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_adaptive_tau.py +0 -0
  94. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_additive_runge_kutta.py +0 -0
  95. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_core_solver_ark.py +0 -0
  96. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_core_solver_sdirk.py +0 -0
  97. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_direct_ssa.py +0 -0
  98. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_model_core.py +0 -0
  99. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_multistep.py +0 -0
  100. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_nonlinear_solver.py +0 -0
  101. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_rosenbrock.py +0 -0
  102. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_sdirk.py +0 -0
  103. {op_engine-0.2.0 → op_engine-0.3.0}/tests/op_engine/test_stochastic_solver.py +0 -0
@@ -174,6 +174,8 @@ __marimo__/
174
174
  *.png
175
175
  *.pdf
176
176
  *.svg
177
+ !docs/assets/*.png
178
+ !docs/assets/*.svg
177
179
 
178
180
  # Numpy saved arrays
179
181
  *.npy
@@ -184,4 +186,3 @@ __marimo__/
184
186
 
185
187
  # Optional example output directory convention
186
188
  examples/output/
187
-
@@ -0,0 +1,140 @@
1
+ # Changelog
2
+
3
+ Notable user-facing changes are recorded here. Versions follow semantic
4
+ versioning while the project remains pre-1.0.
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.3.0] - 2026-09-27
9
+
10
+ ### Added
11
+
12
+ - A trait-structured chemostat PDE example combines a curvature-adapted
13
+ non-uniform finite-volume grid, no-flux implicit diffusion, nonlinear
14
+ resource coupling, structured trait moments, an RK45 reference, and a
15
+ documented three-panel diagnostic (#47).
16
+ - The Flepimop provider compiles typed `jump_integral` descriptors into
17
+ conservative Array-API operators for implicit/IMEX and structured explicit
18
+ execution. It preserves dynamic rates and kernel matrices under JAX,
19
+ applies declared direction and continuous target quadrature, and reuses
20
+ selector/multi-axis lifting with real op_system integration coverage (#94).
21
+ - A finite-element boundary design note and tested 1D P1 diffusion proof of
22
+ concept show how externally assembled mass/stiffness systems use the existing
23
+ stage-operator contract, without adding mesh or element abstractions to core
24
+ (#107).
25
+ - Curvature-weighted 1D spatial-grid generators accept vectorized profile
26
+ callables or sampled data, with optional curvature smoothing and a feasible
27
+ minimum-spacing constraint. They return explicit NumPy geometry for use by
28
+ any runtime array backend (#46).
29
+ - Dense Array-API and sparse SciPy diffusion builders now accept monotone
30
+ non-uniform cell-center grids with conservative no-flux boundaries. The
31
+ flepimop2 provider uses the same geometry for flat, PyTree, and block
32
+ explicit or IMEX execution while retaining JAX differentiation (#45).
33
+ - Explicit adaptive JAX discovery now keeps attempted-step acceptance and
34
+ controller state in a bounded device loop, including conservative shared-mesh
35
+ discovery for block layouts. Compact frozen-mesh replay reports array-valued
36
+ local-error freshness diagnostics and can reject materially stale schedules
37
+ at an outer orchestration boundary without compromising JIT or gradients;
38
+ the solver matrix now benchmarks discovery in each row's actual backend
39
+ (#148).
40
+ - Fixed flat-state JAX DOPRI5 now computes only its high-order solution, keeps
41
+ FSAL reuse, and carries a requested-output buffer instead of materializing
42
+ every hidden internal state. Optional `fixed_checkpoint: step` and `chunk`
43
+ policies control reverse-mode rematerialization; RK4 retains its smaller
44
+ compile-sensitive scan. The solver matrix records the resulting forward and
45
+ gradient IR, compile, memory, runtime, and accuracy changes (#147).
46
+ - The flepimop2 provider can prepare and cache stable flat explicit execution
47
+ plans for fixed stepping and frozen adaptive replay. Prepared callables keep
48
+ array values dynamic for NumPy or caller-owned JAX transforms while reusing
49
+ system binding, packing contracts, step grids, and execution structure;
50
+ explicit cache invalidation and matched staging benchmarks are included (#146).
51
+ - A machine-readable solver benchmark now separates provider construction,
52
+ adaptive discovery, JAX trace/lower/compile, first and warm execution, and
53
+ value-and-gradient phases while reporting analytic accuracy, work counts,
54
+ memory, revision, and environment metadata. SciPy and optional Diffrax
55
+ references use explicit solver, controller, and adjoint settings (#145).
56
+ - Numerical namespace discovery now uses `array-api-compat`, including native
57
+ PyTorch tensors that do not implement `__array_namespace__`. A public
58
+ `array_namespace` helper and optional Torch contract test preserve JAX
59
+ JIT/grad behavior while extending eager fixed-step autograd support (#144).
60
+ - Explicit fixed-step runs can bound their internal integration step with
61
+ `fixed_max_step` independently of requested output times, including compact
62
+ differentiable JAX provider trajectories (#130).
63
+ - The flepimop2 provider exposes SDIRK2 with explicit full-RHS Jacobian and
64
+ nonlinear-solver options, plus adaptive initial-step and safety limits. A
65
+ method-surface parity test now requires explicit provider decisions for new
66
+ core methods, and frozen schedules tolerate representational rounding when
67
+ replayed on a lower-precision backend (#131).
68
+ - Compact JAX fixed-step provider trajectories use a single `jax.lax.scan`
69
+ while the core retains backend-neutral functional step kernels (#123).
70
+ - The flepimop2 provider exposes eager adaptive-schedule discovery and
71
+ validated frozen-mesh replay for conditional JAX JIT and differentiation
72
+ workflows (#127).
73
+
74
+ ### Fixed
75
+
76
+ - Sparse implicit-solver cache entries retain and verify their exact operator
77
+ objects, preventing stale factorizations when Python recycles an object ID
78
+ for a different same-shape matrix (#164).
79
+ - IMEX Heun--trapezoidal now assembles its endpoint predictor and corrector as
80
+ additive solves, so nonlinear and non-commuting explicit/implicit splits
81
+ retain second-order convergence across NumPy and Array-API execution (#162).
82
+ - The public `Array` protocol now matches `array-api-compat` namespace
83
+ discovery and accepts native tensors such as `torch.Tensor` without requiring
84
+ a direct `__array_namespace__` method (#155).
85
+ - `flepimop2-op-engine` now types public trajectories with Flepimop2's
86
+ backend-neutral `Array` protocol and returns the native NumPy or JAX result
87
+ without a NumPy-only cast (#154).
88
+ - Explicit flepimop2 provider methods now add typed op_system axis-kernel,
89
+ advection, and diffusion drift at every Runge--Kutta stage. Flat, PyTree,
90
+ and block layouts share the same namespace-preserving operator path instead
91
+ of silently omitting operators outside IMEX execution (#140).
92
+ - Typed advection and transport descriptors now honor op_system's explicit
93
+ `increasing`/`decreasing` direction contract in both IMEX and explicit
94
+ provider paths while preserving JAX differentiation through dynamic
95
+ coefficients (#142).
96
+
97
+ ## [0.2.0] - 2026-09-26
98
+
99
+ ### Added
100
+
101
+ - Array-API-native state, explicit Runge--Kutta, dense implicit, and IMEX
102
+ paths preserve the namespace selected by the initial state. Portable
103
+ fixed-step methods support JAX tracing and differentiation without adding a
104
+ backend-specific solver to the core method surface (#75, #76, #85, #87,
105
+ #89, #90).
106
+ - Reusable explicit Runge--Kutta tableaus through Dormand--Prince 5(4),
107
+ adaptive step control with differentiable accepted-schedule replay, and
108
+ validated public run configuration (#95, #100, #108).
109
+ - A portable nonlinear-solver contract plus corrected ROS2, reusable
110
+ multistep BDF2, SDIRK2 diagnostics, and higher-order paired IMEX
111
+ Runge--Kutta integration (#113, #114, #115, #117, #118, #119).
112
+ - Backend-neutral fixed tau-leaping, exact direct SSA, and bounded adaptive
113
+ tau-leaping with injected random samplers (#102, #120, #121).
114
+ - Portable advection and typed diffusion/advection operator compilation for
115
+ flepimop2, including dynamic Array-API parameters (#81, #91, #92, #93).
116
+ - The flepimop2 provider consumes typed op_system reaction artifacts and can
117
+ execute deterministic, stochastic, or hybrid reaction networks without
118
+ parsing raw model configuration (#122).
119
+
120
+ ### Changed
121
+
122
+ - Provider development and integration coverage now targets the op_system
123
+ 0.4 typed operator/reaction contract.
124
+ - `flepimop2-op-engine` declares its actual `flepimop2>=0.3.0` compatibility
125
+ floor. Clean-wheel release validation installs the lowest published direct
126
+ dependencies instead of installing flepimop2 from its development branch.
127
+ - The documented flepimop2 installation uses the separately distributed
128
+ `flepimop2-op-engine` package. Its `op-system` extra installs the matching
129
+ system provider integration.
130
+
131
+ ### Fixed
132
+
133
+ - Explicit Euler once again performs one Euler update per requested output
134
+ interval (#79).
135
+ - Solver-method documentation now matches the implemented biogeochemical
136
+ splitting order and backend boundaries (#98).
137
+
138
+ ## [0.1.1] - 2026-04-27
139
+
140
+ - Initial PyPI release of `op-engine` and `flepimop2-op-engine`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: op_engine
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Multiphysics integration engine for ODEs and PDEs using implicit-explicit operator splitting
5
5
  Project-URL: Repository, https://github.com/ACCIDDA/op_engine
6
6
  Project-URL: Documentation, https://accidda.github.io/op_engine/
@@ -39,12 +39,15 @@ Classifier: Programming Language :: Python :: 3.14
39
39
  Classifier: Topic :: Scientific/Engineering
40
40
  Classifier: Topic :: Scientific/Engineering :: Mathematics
41
41
  Requires-Python: <3.15,>=3.11
42
+ Requires-Dist: array-api-compat>=1.12
42
43
  Requires-Dist: numpy>=2.0
43
44
  Requires-Dist: scipy>=1.11
44
45
  Provides-Extra: cupy
45
46
  Requires-Dist: cupy>=13; extra == 'cupy'
46
47
  Provides-Extra: jax
47
48
  Requires-Dist: jax>=0.4.38; extra == 'jax'
49
+ Provides-Extra: torch
50
+ Requires-Dist: torch>=2.6; extra == 'torch'
48
51
  Description-Content-Type: text/markdown
49
52
 
50
53
  # op_engine
@@ -124,9 +127,10 @@ solution = core.state_array # shape (n_timesteps, state, subgroup)
124
127
  ### Array namespaces
125
128
 
126
129
  `ModelCore` infers its numerical namespace from the initial state through
127
- `initial_state.__array_namespace__()`. The state, stored history, solver stages,
130
+ `array_api_compat.array_namespace()`. The state, stored history, solver stages,
128
131
  and dense solver operations stay in that namespace. There is no `xp=` or
129
- backend option. Fixed-step methods can be differentiated by JAX; the built-in
132
+ backend option. This supports standard Array-API objects and native arrays such
133
+ as `torch.Tensor`. Fixed-step methods can be differentiated by JAX; the built-in
130
134
  adaptive controller is eager Python control flow. See the
131
135
  [backend guide](docs/guides/backends.md) for the precise contract and the
132
136
  [solver guide](docs/guides/solver-methods.md) for method-specific examples.
@@ -75,9 +75,10 @@ solution = core.state_array # shape (n_timesteps, state, subgroup)
75
75
  ### Array namespaces
76
76
 
77
77
  `ModelCore` infers its numerical namespace from the initial state through
78
- `initial_state.__array_namespace__()`. The state, stored history, solver stages,
78
+ `array_api_compat.array_namespace()`. The state, stored history, solver stages,
79
79
  and dense solver operations stay in that namespace. There is no `xp=` or
80
- backend option. Fixed-step methods can be differentiated by JAX; the built-in
80
+ backend option. This supports standard Array-API objects and native arrays such
81
+ as `torch.Tensor`. Fixed-step methods can be differentiated by JAX; the built-in
81
82
  adaptive controller is eager Python control flow. See the
82
83
  [backend guide](docs/guides/backends.md) for the precise contract and the
83
84
  [solver guide](docs/guides/solver-methods.md) for method-specific examples.
@@ -0,0 +1,41 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="900" height="280" viewBox="0 0 900 280" role="img" aria-labelledby="title desc">
2
+ <title id="title">Chemostat PDE operator split</title>
3
+ <desc id="desc">Population density and resource enter explicit reaction and implicit diffusion partitions, which are combined by the IMEX solver into observables.</desc>
4
+ <defs>
5
+ <marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
6
+ <path d="M0,0 L0,6 L9,3 z" fill="#4051b5"/>
7
+ </marker>
8
+ <style>
9
+ .box { fill: #f7f8fc; stroke: #4051b5; stroke-width: 2; rx: 10; }
10
+ .accent { fill: #eef7f3; stroke: #16866b; stroke-width: 2; rx: 10; }
11
+ .text { font: 16px sans-serif; fill: #202124; text-anchor: middle; }
12
+ .small { font: 13px sans-serif; fill: #4b4d52; text-anchor: middle; }
13
+ .arrow { stroke: #4051b5; stroke-width: 2.5; fill: none; marker-end: url(#arrow); }
14
+ </style>
15
+ </defs>
16
+ <rect class="box" x="25" y="75" width="190" height="120"/>
17
+ <text class="text" x="120" y="110">State</text>
18
+ <text class="small" x="120" y="140">trait density n(theta)</text>
19
+ <text class="small" x="120" y="165">resource S</text>
20
+
21
+ <path class="arrow" d="M215 115 H295"/>
22
+ <path class="arrow" d="M215 165 H295"/>
23
+
24
+ <rect class="accent" x="300" y="25" width="245" height="95"/>
25
+ <text class="text" x="422" y="58">Explicit partition</text>
26
+ <text class="small" x="422" y="83">growth + dilution</text>
27
+ <text class="small" x="422" y="103">resource coupling</text>
28
+
29
+ <rect class="accent" x="300" y="155" width="245" height="95"/>
30
+ <text class="text" x="422" y="188">Implicit partition</text>
31
+ <text class="small" x="422" y="213">non-uniform no-flux</text>
32
+ <text class="small" x="422" y="233">trait diffusion</text>
33
+
34
+ <path class="arrow" d="M545 73 C600 73 600 112 640 125"/>
35
+ <path class="arrow" d="M545 203 C600 203 600 164 640 151"/>
36
+
37
+ <rect class="box" x="645" y="75" width="225" height="120"/>
38
+ <text class="text" x="757" y="110">IMEX trajectory</text>
39
+ <text class="small" x="757" y="140">biomass + resource</text>
40
+ <text class="small" x="757" y="165">mean + central moments</text>
41
+ </svg>
@@ -0,0 +1,102 @@
1
+ # Finite-element assembly boundary
2
+
3
+ Finite elements are a spatial discretization and assembly system, not a new
4
+ `CoreSolver` time-method name. The current decision is **not to add a mesh,
5
+ element, or quadrature framework to `op_engine` core and not to create an
6
+ `op_engine-fem` provider yet**. Externally assembled linear finite-element
7
+ systems can already use the stage-operator interface.
8
+
9
+ ## Supported semi-discrete form
10
+
11
+ Consider the linear system
12
+
13
+ \[
14
+ M \dot y = K y + g(t, y).
15
+ \]
16
+
17
+ An operator factory can map its implicit linear part into the existing solve
18
+
19
+ \[
20
+ L y_{n+1} = R x.
21
+ \]
22
+
23
+ For example, the factory for a trapezoidal stage of size \(h\) returns
24
+
25
+ \[
26
+ L = M - \tfrac{h}{2}K,
27
+ \qquad
28
+ R = M + \tfrac{h}{2}K.
29
+ \]
30
+
31
+ An implicit-Euler stage instead returns \(L=M-hK\) and \(R=M\). This supports
32
+ fixed or variable stage sizes and permits the existing IMEX Euler,
33
+ Heun--trapezoidal, and TR-BDF2 control flow to advance an externally assembled
34
+ mass/stiffness pair.
35
+
36
+ The explicit callable still has derivative units. A finite-element load vector
37
+ `g` must therefore be supplied as `solve(M, g)`; the current API does not inject
38
+ an unscaled weak-form load directly. Likewise, `imex-ark3` currently consumes
39
+ only the left implicit solve at each additive stage, so a non-identity mass
40
+ matrix must be resolved to an operator such as `solve(M, K)` before using that
41
+ method. Changing either contract should be motivated and designed separately,
42
+ not hidden in an FEM adapter.
43
+
44
+ ## Ownership
45
+
46
+ The assembly package or application owns:
47
+
48
+ - mesh topology and geometry;
49
+ - basis functions, quadrature, and element integration;
50
+ - degree-of-freedom maps and constraint elimination;
51
+ - essential and natural boundary-condition assembly;
52
+ - mass, stiffness, load, and nonlinear residual assembly;
53
+ - any matrix reassembly or preconditioner lifecycle.
54
+
55
+ `op_engine` owns time integration, stage context, operator-axis application,
56
+ accepted-step control, and the `L @ y_next = R @ x` solve boundary. This keeps
57
+ optional SciPy, PETSc, FEniCSx, and JAX FEM ecosystems from becoming core
58
+ dependencies.
59
+
60
+ ## Sparse operators and differentiation
61
+
62
+ SciPy CSR pairs use the registered sparse adapter. A provider should reuse
63
+ operator objects when their values do not change so the factorization cache is
64
+ effective. Other array backends can supply dense native arrays and use the
65
+ Array-API linear solve. A JAX-native dense assembly remains differentiable
66
+ through matrix construction and solve; eager NumPy/SciPy assembly and
67
+ factorization do not. An external PETSc or FEniCSx provider would own any
68
+ custom differentiation rule for its solver.
69
+
70
+ Nonlinear forms such as \(M(y,t)\dot y=r(t,y)\), differential-algebraic
71
+ constraints, and external Krylov/preconditioner callbacks are outside the
72
+ current stage-operator contract. They require a concrete use case before a new
73
+ portable solver interface is added.
74
+
75
+ ## Proof of concept
76
+
77
+ [`examples/fem_diffusion.py`](https://github.com/ACCIDDA/op_engine/blob/main/examples/fem_diffusion.py)
78
+ assembles a consistent P1 mass matrix and diffusion stiffness matrix on a 1D
79
+ mesh, eliminates homogeneous Dirichlet boundary degrees of freedom, and runs
80
+ the result with fixed-step `imex-heun-tr`. Assembly uses SciPy only because the
81
+ example is demonstrating the external sparse boundary; it is not a required
82
+ FEM representation.
83
+
84
+ The example intentionally uses fixed steps. For pure implicit diffusion,
85
+ Heun--trapezoidal's embedded low/high pair differs only in its explicit term
86
+ and therefore cannot estimate the implicit truncation error. Adaptive FEM runs
87
+ should currently use a method with a suitable estimator, such as TR-BDF2 step
88
+ doubling, or supply and validate a fixed/replayed mesh at a higher layer.
89
+
90
+ ## When to reconsider a provider
91
+
92
+ Reconsider a separate provider, rather than extending core, when at least one
93
+ real unstructured-domain model identifies all of the following:
94
+
95
+ 1. a specific external assembly and linear-solver ecosystem;
96
+ 2. required boundary-condition and constraint semantics;
97
+ 3. sparse/device and differentiation expectations;
98
+ 4. nonlinear and reassembly requirements;
99
+ 5. a benchmark showing that the existing `(L, R)` boundary is insufficient.
100
+
101
+ Until then, the proof of concept is the supported design pattern and a general
102
+ FEM abstraction would add dependencies and policy without a validated user.
@@ -1,6 +1,9 @@
1
1
  # Backend and solve-strategy boundaries
2
2
 
3
- `op_engine` selects the numerical namespace from the state array. Explicit,
3
+ `op_engine` selects the numerical namespace from the state array with
4
+ `array_api_compat.array_namespace()`. This supports arrays with the standard
5
+ `__array_namespace__` protocol and native arrays such as `torch.Tensor`
6
+ through compatibility namespaces. Explicit,
4
7
  dense linearly implicit, and IMEX methods use that namespace's Array-API
5
8
  operations, including `linalg.solve`. The numerical method does not change when
6
9
  the array namespace changes.
@@ -9,6 +12,10 @@ Namespace portability and automatic differentiation are related but distinct.
9
12
  The Array API does not define `grad`, `jit`, or traced control flow. A namespace
10
13
  such as JAX can nevertheless differentiate the ordinary `op_engine` methods
11
14
  because their fixed-step numerical operations remain in that namespace.
15
+ The optional `torch` dependency group qualifies representative eager
16
+ fixed-step kernels with PyTorch autograd. Compiler integration and complete
17
+ method qualification remain provider/backend capabilities rather than
18
+ consequences of namespace selection.
12
19
 
13
20
  ## Portable fixed-step methods
14
21
 
@@ -47,15 +54,17 @@ parameters move, and always refresh it after a material model or tolerance
47
54
  change. A schedule is validated against the output grid, while matching the
48
55
  method and other configuration is the caller's responsibility.
49
56
 
50
- Schedule replay statically unrolls the accepted steps when JAX traces it. For
51
- very long meshes, a provider may instead implement bounded, masked compiled
52
- control flow around the same portable kernels.
57
+ Direct `CoreSolver` schedule replay statically unrolls accepted steps when JAX
58
+ traces it. The optional flepimop2 provider consumes the same functional kernels
59
+ but defaults explicit JAX replay to one compact `lax.scan`. Its forced compact
60
+ mode can also checkpoint/rematerialize each step for long reverse-mode solves;
61
+ non-JAX and nonlinear-diagnostic paths retain portable core replay.
53
62
 
54
- Projects that require a compiled adaptive controller, checkpointed adjoints, or
55
- other solver-specific capabilities can provide those at an external plugin or
56
- provider boundary. A specialized integration may return a complete trajectory
57
- and adopt it through `ModelCore.apply_trajectory`; it does not need to add a
58
- backend-specific method to `CoreSolver`.
63
+ Projects that require a compiled adaptive controller or other solver-specific
64
+ capabilities can provide those at an external plugin or provider boundary. A
65
+ specialized integration may return a complete trajectory and adopt it through
66
+ `ModelCore.apply_trajectory`; it does not need to add a backend-specific method
67
+ to `CoreSolver`.
59
68
 
60
69
  This keeps optional packages such as Diffrax out of the core dependency and
61
70
  method surfaces. The portable fixed-step methods and their differentiation
@@ -0,0 +1,97 @@
1
+ # Trait-structured chemostat PDE
2
+
3
+ The
4
+ [`chemostat_pde.py`](https://github.com/ACCIDDA/op_engine/blob/main/examples/chemostat_pde.py)
5
+ example couples a population density over a continuous trait coordinate to one
6
+ well-mixed resource. It demonstrates non-uniform finite-volume geometry,
7
+ implicit diffusion, nonlinear explicit coupling, and structured observables in
8
+ one reproducible comparison.
9
+
10
+ ![Chemostat operator split](../assets/chemostat-split.svg)
11
+
12
+ ## Model and split
13
+
14
+ For population density `n(theta, t)` and resource `S(t)`, the example solves
15
+
16
+ ```text
17
+ dn/dt = n g(theta, S) - D n + d d2n/dtheta2
18
+ dS/dt = D (S_in - S) - integral[n g(theta, S) w(theta) dtheta].
19
+ ```
20
+
21
+ Growth combines Monod resource limitation with a Gaussian trait optimum. The
22
+ positive uptake weight varies exponentially with trait and is scaled by the
23
+ yield coefficient.
24
+
25
+ The split supplied to `CoreSolver` is:
26
+
27
+ | Partition | Terms |
28
+ | --- | --- |
29
+ | implicit linear | no-flux trait diffusion |
30
+ | explicit nonlinear | growth, dilution, resource inflow, and consumption |
31
+
32
+ `imex-heun-tr` treats the two partitions to second order. The implicit stage is
33
+ assembled additively as `L y = R y_n + explicit_increment`; the right
34
+ trapezoidal operator does not multiply the explicit increment.
35
+
36
+ The example does not clip stage states or project the result onto the positive
37
+ orthant. Positivity in the canonical run is an observed numerical diagnostic,
38
+ not an extra model operation.
39
+
40
+ ## Non-uniform finite volumes
41
+
42
+ `build_trait_grid()` calls `generate_adaptive_grid()` on the curvature of the
43
+ initial Gaussian trait profile. More centers are placed where the initial
44
+ profile bends rapidly, while the domain endpoints remain fixed.
45
+
46
+ For adjacent center spacings `h_i`, the integration weights are
47
+
48
+ ```text
49
+ q_0 = h_0
50
+ q_i = (h_(i-1) + h_i) / 2
51
+ q_(N-1) = h_(N-2).
52
+ ```
53
+
54
+ These are the same control-volume widths used by the non-uniform diffusion
55
+ operator. With reflecting boundaries, `q^T A = 0`, so diffusion alone
56
+ conserves total biomass.
57
+
58
+ ## Structured observables
59
+
60
+ At every output time the example records resource and five summaries of the
61
+ trait distribution:
62
+
63
+ | Observable | Discrete definition |
64
+ | --- | --- |
65
+ | biomass | `I = sum_i q_i n_i` |
66
+ | mean trait | `rho = sum_i q_i n_i theta_i / I` |
67
+ | variance | `Sigma = sum_i q_i n_i (theta_i-rho)^2 / I` |
68
+ | skewness | third central moment divided by `Sigma^(3/2)` |
69
+ | kurtosis | fourth central moment divided by `Sigma^2` |
70
+
71
+ Kurtosis is the raw standardized fourth moment, not excess kurtosis.
72
+
73
+ ## Run and interpret the comparison
74
+
75
+ From a development checkout:
76
+
77
+ ```bash
78
+ uv sync --dev
79
+ uv run python examples/chemostat_pde.py
80
+ ```
81
+
82
+ The command writes
83
+ `examples/output/chemostat_pde/chemostat_summary.png` and reports maximum state
84
+ and biomass differences. The committed reference figure below uses the default
85
+ 41-cell grid and 0.02-day time step.
86
+
87
+ ![Default chemostat comparison](../assets/chemostat-summary.png)
88
+
89
+ The panels compare initial and final trait densities, resource and total
90
+ biomass, and the evolving mean trait with one standard deviation. Dashed lines
91
+ come from SciPy RK45 applied to the same semi-discrete system with tight
92
+ tolerances. This comparison isolates time integration: RK45 uses the identical
93
+ grid, quadrature, reaction functions, diffusion matrix, and initial state.
94
+
95
+ The RK45 trajectory is a high-accuracy reference for this finite-dimensional
96
+ discretization, not an exact solution of the continuous PDE. Grid convergence
97
+ and time-step convergence remain separate checks.
@@ -21,22 +21,28 @@ CoreSolver(core).run(decay)
21
21
  solution = core.state_array
22
22
  ```
23
23
 
24
- ## NumPy and JAX solves
24
+ ## Array-backend solves
25
25
 
26
- NumPy 2 and JAX arrays implement `__array_namespace__()`. Passing either one to
26
+ `array-api-compat` discovers the numerical namespace from NumPy, JAX, CuPy,
27
+ PyTorch, and other supported native arrays. Passing an array to
27
28
  `set_initial_state` selects the namespace for state storage, history, solver
28
- stages, and adaptive error control. No backend argument is needed. An RHS should
29
- derive operations from its state when it needs namespace functions:
29
+ stages, and adaptive error control. No backend argument is needed. An RHS
30
+ should derive operations from its state when it needs namespace functions:
30
31
 
31
32
  ```python
33
+ from op_engine import array_namespace
34
+
35
+
32
36
  def portable_decay(_time, state):
33
- xp = state.__array_namespace__()
37
+ xp = array_namespace(state)
34
38
  return xp.multiply(state, -0.25)
35
39
  ```
36
40
 
37
41
  Dense implicit and IMEX operators are converted into the state's namespace and
38
42
  solved with its Array-API `linalg.solve` implementation. This provides the
39
- portable correctness path for NumPy, JAX, and other conforming backends.
43
+ portable correctness path for NumPy, JAX, PyTorch, and other supported
44
+ backends. Array portability alone does not promise backend compilation,
45
+ automatic differentiation, sparse support, or random-number semantics.
40
46
 
41
47
  Sparse acceleration is an optional second tier. SciPy sparse operators retain
42
48
  cached factorization for NumPy state. Installing `op-engine[cupy]` registers the
@@ -236,8 +236,22 @@ CoreSolver(core).run(rhs, config=config)
236
236
  ```
237
237
 
238
238
  With `adaptive=False`, every adjacent pair of output times defines one solver
239
- step. With `adaptive=True`, the controller may take multiple internal steps but
240
- still stores only the requested output times.
239
+ step by default. Set `fixed_max_step` to bound explicit Euler, Heun, RK4, or
240
+ Dormand--Prince integration steps independently of the output grid:
241
+
242
+ ```python
243
+ config = RunConfig(method="rk4", fixed_max_step=0.25)
244
+ ```
245
+
246
+ Each output interval then uses as many full `fixed_max_step` steps as fit plus
247
+ a final remainder step that lands exactly on the next output time. Only the
248
+ requested output states are stored. Output grids aligned with the fixed-step
249
+ mesh can therefore be made more or less frequent without changing states at
250
+ shared times. `fixed_max_step` is incompatible with `adaptive=True`; adaptive
251
+ methods use their controller configuration instead.
252
+
253
+ With `adaptive=True`, the controller may take multiple internal steps but still
254
+ stores only the requested output times.
241
255
 
242
256
  Heun, RK4, and Dormand--Prince share one validated explicit Runge--Kutta
243
257
  tableau kernel. `rk4` takes four RHS stages per fixed step. Under adaptivity it
@@ -310,6 +324,46 @@ config = RunConfig(
310
324
  CoreSolver(core, operator_axis="state").run(reaction, config=config)
311
325
  ```
312
326
 
327
+ For a non-uniform axis, pass strictly increasing cell-center coordinates and
328
+ set `dx=None`:
329
+
330
+ ```python
331
+ centers = np.asarray([0.0, 0.08, 0.21, 0.5, 1.0])
332
+ operator = build_diffusion_matrix(
333
+ centers.size,
334
+ None,
335
+ diffusivity,
336
+ grid=centers,
337
+ bc="neumann",
338
+ )
339
+ ```
340
+
341
+ The no-flux operator is conservative and self-adjoint under the inferred
342
+ cell-volume weights. It is generally not symmetric in the unweighted Euclidean
343
+ inner product. Periodic non-uniform grids need an explicit wrap spacing, which
344
+ cannot be inferred from center coordinates alone, and are rejected.
345
+
346
+ Use `generate_adaptive_grid` to create a curvature-weighted point grid from a
347
+ vectorized profile callable:
348
+
349
+ ```python
350
+ from op_engine import generate_adaptive_grid
351
+
352
+ centers = generate_adaptive_grid(
353
+ lambda x: np.exp(-10.0 * x * x),
354
+ (-1.0, 1.0),
355
+ 65,
356
+ smoothing_window=5,
357
+ minimum_spacing=1e-3,
358
+ )
359
+ ```
360
+
361
+ The generator returns exactly the requested number of NumPy coordinates,
362
+ including both interval endpoints. It is eager preprocessing rather than part
363
+ of the differentiable solve; the resulting static geometry can drive NumPy,
364
+ JAX, Torch, or another supported Array-API backend. The sampled-data companion
365
+ is `generate_adaptive_grid_from_data`.
366
+
313
367
  For `imex-trbdf2`, provide both stage schemes:
314
368
 
315
369
  ```python
@@ -348,10 +402,11 @@ across Array-API namespaces.
348
402
 
349
403
  `imex-ars443` and `ars443` are aliases for `imex-ark3`.
350
404
 
351
- The flepimop2 provider can compile typed `op_system` `axis_kernel`, `advection`,
352
- and `diffusion` descriptors into these same factories. Descriptor parsing and
353
- axis-label resolution belong to the provider; the numerical builders and
354
- stepping methods remain in `op_engine`.
405
+ The flepimop2 provider can compile typed `op_system` `axis_kernel`,
406
+ `jump_integral`, `advection`, and `diffusion` descriptors into these same
407
+ factories. Descriptor parsing, axis-type/coordinate resolution, and layout
408
+ lifting belong to the provider; portable jump assembly follows op_system's
409
+ public reference semantics, while stepping methods remain in `op_engine`.
355
410
 
356
411
  ## Linearly implicit methods
357
412
 
@@ -360,8 +415,11 @@ well as the Rosenbrock method, take a Jacobian callable. The callable returns an
360
415
  operator acting along `operator_axis`:
361
416
 
362
417
  ```python
418
+ from op_engine import array_namespace
419
+
420
+
363
421
  def jacobian(_time, state):
364
- xp = state.__array_namespace__()
422
+ xp = array_namespace(state)
365
423
  return xp.multiply(xp.eye(state.shape[0], dtype=state.dtype), -0.5)
366
424
 
367
425
 
@@ -7,6 +7,9 @@ Start with [Getting Started](guides/getting-started.md), then use
7
7
  explicit, IMEX, or linearly implicit method. The
8
8
  [biogeochemical network tutorial](guides/biogeochemical-network.md) shows
9
9
  stage-dependent operator splitting in a complete example.
10
+ The [trait-structured chemostat tutorial](guides/chemostat-pde.md) combines a
11
+ non-uniform finite-volume grid, implicit diffusion, nonlinear resource
12
+ coupling, and structured trait observables.
10
13
 
11
14
  `op_engine` numerical methods use the array namespace supplied by model state.
12
15
  The [backend guide](guides/backends.md) explains portability, JAX