dynamicalnodes 0.1__tar.gz → 0.1.3__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.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: dynamicalnodes
3
+ Version: 0.1.3
4
+ Summary: dynamicalnodes is a Python library for modeling general control systems.
5
+ Author-email: Nehal Singh Mangat <nehalsinghmangat.software@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/nehalsinghmangat/dynamicalnodes
8
+ Project-URL: Documentation, https://nehalsinghmangat.github.io/dynamicalnodes
9
+ Project-URL: Issues, https://github.com/nehalsinghmangat/dynamicalnodes/issues
10
+ Keywords: control,estimation,ros2
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy>=1.24
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.0; extra == "dev"
23
+ Requires-Dist: pytest-doctestplus>=1.0.0; extra == "dev"
24
+ Requires-Dist: flake8>=6.0; extra == "dev"
25
+ Requires-Dist: black>=24.0; extra == "dev"
26
+ Requires-Dist: isort>=6.0; extra == "dev"
27
+ Requires-Dist: mypy>=1.0; extra == "dev"
28
+ Requires-Dist: build>=0.10; extra == "dev"
29
+ Requires-Dist: twine>=4.0; extra == "dev"
30
+ Provides-Extra: docs
31
+ Requires-Dist: sphinx>=8.0; extra == "docs"
32
+ Requires-Dist: sphinx-rtd-theme>=1.3; extra == "docs"
33
+ Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
34
+ Requires-Dist: myst_nb; extra == "docs"
35
+ Requires-Dist: sphinx-autodoc-typehints>=1.24.0; extra == "docs"
36
+ Requires-Dist: matplotlib>=3.9; extra == "docs"
37
+ Requires-Dist: scipy>=1.11; extra == "docs"
38
+ Requires-Dist: jupyter; extra == "docs"
39
+ Requires-Dist: jaxtyping>=0.2; extra == "docs"
40
+ Requires-Dist: beartype>=0.15; extra == "docs"
41
+ Dynamic: license-file
42
+
43
+ # dynamicalnodes: Control Systems on ROS 2
44
+
45
+ > _"Give me a [control system] long enough and a [computer] on which to place it, and I shall move the world."_ - Archimedes
46
+
47
+ **dynamicalnodes** is a Python development framework that bridges the gap between novel control systems and their implementation onto ROS 2. Designed for hobbyists, students, and academics alike, this framework won't cure cancer, but it can do the next best thing: make robotics easier.
48
+
49
+ To get started, or to explore what this framework has to offer, click here: [dynamicalnodes Documentation](https://nehalsinghmangat.github.io/dynamicalnodes).
50
+
51
+ For a 22 second video describing this framework's intended workflow for, click here: [SAIL 2025 -- Presenting dynamicalnodes](https://youtu.be/5GbVHo6QZrw).
52
+
53
+ ---
54
+ ## Turtlebot: Whiteboard ⟶ Python ⟶ ROS 2 ⟶ World
55
+ <table>
56
+ <tr>
57
+ <td align="center">
58
+ <img src="docs/source/_static/figures/turtlebot_schematic.svg" alt="TurtleBot Schematic" width="400"/><br/>
59
+ </td>
60
+ <td align="center">
61
+ <img src="docs/source/_static/figures/turtlebot_python_simulation.gif" alt="Turtle Software" width="200"/><br/>
62
+ </td>
63
+ <td align="center">
64
+ <img src="docs/source/_static/figures/turtlebot_ros_simulation.gif" alt="Turtle Simulation" width="200"/><br/>
65
+ </td>
66
+ <td align="center">
67
+ <img src="docs/source/_static/figures/turtlebot_mocab.gif" alt="Turtle Hardware" width="200"/><br/>
68
+ </td>
69
+ </tr>
70
+ </table>
71
+
72
+ <p align="center">
73
+ <img src="docs/source/_static/figures/pipeline_theory_python_ros_hardware.png" alt="Step Labels" width="800"/>
74
+ </p>
75
+
76
+
77
+
78
+
79
+
80
+
@@ -0,0 +1,38 @@
1
+ # dynamicalnodes: Control Systems on ROS 2
2
+
3
+ > _"Give me a [control system] long enough and a [computer] on which to place it, and I shall move the world."_ - Archimedes
4
+
5
+ **dynamicalnodes** is a Python development framework that bridges the gap between novel control systems and their implementation onto ROS 2. Designed for hobbyists, students, and academics alike, this framework won't cure cancer, but it can do the next best thing: make robotics easier.
6
+
7
+ To get started, or to explore what this framework has to offer, click here: [dynamicalnodes Documentation](https://nehalsinghmangat.github.io/dynamicalnodes).
8
+
9
+ For a 22 second video describing this framework's intended workflow for, click here: [SAIL 2025 -- Presenting dynamicalnodes](https://youtu.be/5GbVHo6QZrw).
10
+
11
+ ---
12
+ ## Turtlebot: Whiteboard ⟶ Python ⟶ ROS 2 ⟶ World
13
+ <table>
14
+ <tr>
15
+ <td align="center">
16
+ <img src="docs/source/_static/figures/turtlebot_schematic.svg" alt="TurtleBot Schematic" width="400"/><br/>
17
+ </td>
18
+ <td align="center">
19
+ <img src="docs/source/_static/figures/turtlebot_python_simulation.gif" alt="Turtle Software" width="200"/><br/>
20
+ </td>
21
+ <td align="center">
22
+ <img src="docs/source/_static/figures/turtlebot_ros_simulation.gif" alt="Turtle Simulation" width="200"/><br/>
23
+ </td>
24
+ <td align="center">
25
+ <img src="docs/source/_static/figures/turtlebot_mocab.gif" alt="Turtle Hardware" width="200"/><br/>
26
+ </td>
27
+ </tr>
28
+ </table>
29
+
30
+ <p align="center">
31
+ <img src="docs/source/_static/figures/pipeline_theory_python_ros_hardware.png" alt="Step Labels" width="800"/>
32
+ </p>
33
+
34
+
35
+
36
+
37
+
38
+
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "dynamicalnodes"
10
- version = "0.1"
10
+ version = "0.1.3"
11
11
  description = "dynamicalnodes is a Python library for modeling general control systems."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.12"
@@ -16,6 +16,14 @@ authors = [
16
16
  { name = "Nehal Singh Mangat", email = "nehalsinghmangat.software@gmail.com" }
17
17
  ]
18
18
  keywords = ["control", "estimation", "ros2"]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Science/Research",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Scientific/Engineering",
25
+ "Topic :: Software Development :: Libraries",
26
+ ]
19
27
  dependencies = [
20
28
  "numpy>=1.24",
21
29
  ]
@@ -35,21 +43,32 @@ dev = [
35
43
  docs = [
36
44
  "sphinx>=8.0",
37
45
  "sphinx-rtd-theme>=1.3",
46
+ "sphinx-copybutton>=0.5",
38
47
  "myst_nb",
39
48
  "sphinx-autodoc-typehints>=1.24.0",
40
- "matplotlib>=3.7",
49
+ "matplotlib>=3.9",
41
50
  "scipy>=1.11",
42
51
  "jupyter",
52
+ "jaxtyping>=0.2",
53
+ "beartype>=0.15",
43
54
  ]
44
55
 
45
56
  [project.urls]
46
57
  Homepage = "https://github.com/nehalsinghmangat/dynamicalnodes"
47
58
  Documentation = "https://nehalsinghmangat.github.io/dynamicalnodes"
59
+ Issues = "https://github.com/nehalsinghmangat/dynamicalnodes/issues"
48
60
 
49
61
  [tool.pytest.ini_options]
50
62
  addopts = "--doctest-modules"
51
63
  doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS"]
52
64
 
65
+ [tool.isort]
66
+ profile = "black"
67
+
68
+ [tool.mypy]
69
+ python_version = "3.12"
70
+ strict = true
71
+
53
72
  [tool.setuptools]
54
73
  package-dir = {"" = "src"}
55
74
 
@@ -0,0 +1,29 @@
1
+ """
2
+ dynamicalnodes: A modular Python framework for dynamical systems, estimation, and ROS2 integration.
3
+ """
4
+
5
+ from .dynamical_system import DynamicalSystem
6
+ from .rosnode import ROSNode
7
+
8
+ # Lazy import — requires a live ROS2 installation
9
+ _ROS2_MODULES = {
10
+ "ros2py_py2ros": ("ros2py_py2ros", None),
11
+ }
12
+
13
+
14
+ def __getattr__(name):
15
+ if name in _ROS2_MODULES:
16
+ module_name, attr = _ROS2_MODULES[name]
17
+ try:
18
+ import importlib
19
+ mod = importlib.import_module(f".{module_name}", package=__name__)
20
+ return mod if attr is None else getattr(mod, attr)
21
+ except ImportError as e:
22
+ raise ImportError(
23
+ f"{name} requires ROS2 dependencies. Install with: pip install dynamicalnodes[ros2]\n"
24
+ f"Original error: {e}"
25
+ ) from e
26
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
27
+
28
+
29
+ __all__ = ["DynamicalSystem", "ROSNode"]
@@ -0,0 +1,204 @@
1
+ """
2
+ A discrete-time dynamical system (DTDS) is represented as a pair of functions,
3
+ ``(f, h)``, following the definition in Mangat et al., *dynamicalnodes: From
4
+ Theory to Python to ROS*::
5
+
6
+ x_{k+1} = f(x_k, u_k, theta)
7
+ y_k = h(x_k, u_k, theta)
8
+
9
+ ``f`` is the evolution function that propagates the state forward one step;
10
+ ``h`` is the measurement (or output) function that computes an observation
11
+ from the current state. Either function may be omitted -- denoted ``*`` in
12
+ the paper, ``None`` here -- giving three cases:
13
+
14
+ - ``(f, *)`` -- no output function.
15
+ - ``(*, h)`` -- stateless, e.g. a signal generator.
16
+ - ``(f, h)`` -- both state evolution and output are defined.
17
+
18
+ Function signatures are unconstrained in name, type, and number of
19
+ parameters. ``DynamicalSystem.eval`` binds keyword arguments to each
20
+ function's signature by name, so the same keyword argument (e.g. a shared
21
+ state or parameter) can be passed to both ``f`` and ``h``.
22
+ """
23
+
24
+ import inspect
25
+ from typing import Any, Callable, Dict, Optional, Tuple
26
+
27
+
28
+ class DynamicalSystem:
29
+ """
30
+ A discrete-time dynamical system with (optional) evolution and
31
+ (optional) measurement functions.
32
+
33
+ Parameters
34
+ ----------
35
+ f : Callable, optional
36
+ Evolution function ``f(x_k, u_k, theta) -> x_{k+1}``. ``None`` if
37
+ the system has no state transition, i.e. ``(*, h)``.
38
+ h : Callable, optional
39
+ Measurement function ``h(x_k, u_k, theta) -> y_k``. ``None`` if
40
+ the system has no output, i.e. ``(f, *)``.
41
+
42
+ Examples
43
+ --------
44
+ A stateful system with both an evolution and a measurement function --
45
+ a car whose state is ``(displacement, velocity)``, measured via its
46
+ speed:
47
+
48
+ >>> def fcar(xk, uk, theta_car):
49
+ ... d, v = xk
50
+ ... m, b, dt = theta_car
51
+ ... return (d + dt * v, v + dt * (uk - b * v) / m)
52
+ >>> def hcar(xk):
53
+ ... _, v = xk
54
+ ... return abs(v)
55
+ >>> car = DynamicalSystem(f=fcar, h=hcar)
56
+ >>> x_next, y = car.eval(xk=(20.0, 0.0), uk=4.0, theta_car=(2.0, 0.0, 1.0))
57
+ >>> x_next
58
+ (20.0, 2.0)
59
+ >>> y
60
+ 0.0
61
+
62
+ A stateless system, e.g. a signal generator -- ``(*, h)``:
63
+
64
+ >>> def h_ref(tk, A=1.0):
65
+ ... return A if tk >= 1.0 else 0.0
66
+ >>> ref = DynamicalSystem(h=h_ref)
67
+ >>> ref.eval(tk=0.0)
68
+ (None, 0.0)
69
+ >>> ref.eval(tk=2.0)
70
+ (None, 1.0)
71
+
72
+ A system with no output function -- ``(f, *)``:
73
+
74
+ >>> def f_counter(xk):
75
+ ... return xk + 1
76
+ >>> counter = DynamicalSystem(f=f_counter)
77
+ >>> counter.eval(xk=5)
78
+ (6, None)
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ f: Optional[Callable] = None,
85
+ h: Optional[Callable] = None,
86
+ ) -> None:
87
+ """
88
+ Parameters
89
+ ----------
90
+ f : Callable, optional
91
+ Evolution function ``f(x_k, u_k, theta) -> x_{k+1}``. Signature
92
+ typing is not enforced. ``None`` if the system is stateless.
93
+ h : Callable, optional
94
+ Measurement function ``h(x_k, u_k, theta) -> y_k``. Signature
95
+ typing is not enforced. ``None`` if the system has no output.
96
+ """
97
+ self._f = f
98
+ self._h = h
99
+
100
+ @property
101
+ def f(self) -> Optional[Callable]:
102
+ """Evolution function f(x_k, u_k, theta) -> x_{k+1}, or None if the system is stateless."""
103
+ return self._f
104
+
105
+ @f.setter
106
+ def f(self, f: Optional[Callable]) -> None:
107
+ self._f = f
108
+
109
+ @property
110
+ def h(self) -> Optional[Callable]:
111
+ """Measurement function h(x_k, u_k, theta) -> y_k, or None if the system has no output."""
112
+ return self._h
113
+
114
+ @h.setter
115
+ def h(self, h: Optional[Callable]) -> None:
116
+ self._h = h
117
+
118
+ def eval(self, **kwargs) -> Tuple[Optional[Any], Optional[Any]]:
119
+ """
120
+ Evaluate the system for one discrete time step.
121
+
122
+ Keyword arguments are lexically bound by name to whichever of ``f``
123
+ and/or ``h`` declare them in their signature (see
124
+ ``_bind_and_call``), so the same keyword argument can be shared by
125
+ both functions.
126
+
127
+ Returns
128
+ -------
129
+ Tuple[Optional[Any], Optional[Any]]
130
+ - ``(None, y_k)`` for a stateless system, ``(*, h)``.
131
+ - ``(x_{k+1}, None)`` for a system with no output, ``(f, *)``.
132
+ - ``(x_{k+1}, y_k)`` for a fully specified system, ``(f, h)``.
133
+
134
+ Notes
135
+ -----
136
+ ``h`` is evaluated on the same (pre-update) state passed in via
137
+ keyword arguments, not on the value returned by ``f``, so the
138
+ returned observation is one step behind the returned state. In a
139
+ simulation loop, use the returned ``x_{k+1}`` directly if you need
140
+ the post-update state.
141
+
142
+ Raises
143
+ ------
144
+ ValueError
145
+ If neither ``f`` nor ``h`` is defined.
146
+
147
+ Examples
148
+ --------
149
+ See the class docstring for worked examples covering all three
150
+ cases.
151
+
152
+ >>> empty = DynamicalSystem()
153
+ >>> empty.eval()
154
+ Traceback (most recent call last):
155
+ ...
156
+ ValueError: Nothing to evaluate; no f or h functions!
157
+ """
158
+
159
+ def _bind_and_call(
160
+ func: Callable[..., Any],
161
+ **kwargs: Any,
162
+ ) -> Any:
163
+ """
164
+ Call `func` with only the keyword arguments it declares.
165
+
166
+ Inspects `func`'s signature and passes only the entries of
167
+ `kwargs` matching its declared parameter names; if `func`
168
+ accepts `**kwargs`, the remaining pool is passed through too.
169
+ """
170
+ sig = inspect.signature(func)
171
+ pool: Dict[str, Any] = dict(kwargs)
172
+
173
+ # Filter: only pass arguments the function declares
174
+ call_kwargs: Dict[str, Any] = {}
175
+ for name, p in sig.parameters.items():
176
+ if p.kind in (
177
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
178
+ inspect.Parameter.KEYWORD_ONLY,
179
+ ):
180
+ if name in pool:
181
+ call_kwargs[name] = pool.pop(name)
182
+
183
+ # Pass remaining kwargs if function has **kwargs
184
+ if any(
185
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
186
+ ):
187
+ call_kwargs.update(pool)
188
+
189
+ return func(**call_kwargs)
190
+
191
+ if self.f is None and self.h:
192
+ return (None, _bind_and_call(self.h, **kwargs))
193
+
194
+ elif self.f and self.h is None:
195
+ return (_bind_and_call(self.f, **kwargs), None)
196
+
197
+ elif self.f and self.h:
198
+ return (
199
+ _bind_and_call(self.f, **kwargs),
200
+ _bind_and_call(self.h, **kwargs),
201
+ )
202
+
203
+ else:
204
+ raise ValueError("Nothing to evaluate; no f or h functions!")
@@ -24,6 +24,7 @@ from turtlesim.msg import Pose
24
24
  from nav_msgs.msg import Odometry, Path
25
25
  from sensor_msgs.msg import (
26
26
  Imu,
27
+ NavSatFix,
27
28
  LaserScan,
28
29
  Image,
29
30
  CompressedImage,
@@ -558,6 +559,42 @@ def py2ros_imu(arr: NDArray) -> Imu:
558
559
  return msg
559
560
 
560
561
 
562
+ # ---------------------------------------------------------------------------
563
+ # sensor_msgs/NavSatFix <-> np.ndarray(3,) [latitude, longitude, altitude]
564
+ # ---------------------------------------------------------------------------
565
+
566
+ def ros2py_nav_sat_fix(msg: NavSatFix) -> NDArray:
567
+ """
568
+ NavSatFix → [latitude, longitude, altitude]
569
+
570
+ >>> from sensor_msgs.msg import NavSatFix
571
+ >>> msg = NavSatFix()
572
+ >>> msg.latitude, msg.longitude, msg.altitude = 37.5, -122.0, 10.0
573
+ >>> ros2py_nav_sat_fix(msg).tolist()
574
+ [37.5, -122.0, 10.0]
575
+ """
576
+ return np.array([msg.latitude, msg.longitude, msg.altitude], dtype=float)
577
+
578
+
579
+ def py2ros_nav_sat_fix(arr: NDArray) -> NavSatFix:
580
+ """
581
+ [latitude, longitude, altitude] → NavSatFix
582
+
583
+ >>> import numpy as np
584
+ >>> msg = py2ros_nav_sat_fix(np.array([37.5, -122.0, 10.0]))
585
+ >>> msg.latitude, msg.longitude, msg.altitude
586
+ (37.5, -122.0, 10.0)
587
+ """
588
+ flat = np.asarray(arr, dtype=float).ravel()
589
+ if flat.size != 3:
590
+ raise ValueError(f"py2ros_nav_sat_fix expected length 3, got {flat.size}")
591
+ msg = NavSatFix()
592
+ msg.latitude = float(flat[0])
593
+ msg.longitude = float(flat[1])
594
+ msg.altitude = float(flat[2])
595
+ return msg
596
+
597
+
561
598
  # ---------------------------------------------------------------------------
562
599
  # nav_msgs/Odometry <-> np.ndarray(13,)
563
600
  # (pose + twist; covariances ignored)
@@ -907,9 +944,9 @@ def py2ros_uint8_multiarray(arr: NDArray) -> UInt8MultiArray:
907
944
  # std_msgs/Float64 <-> scalar float (as 1D array)
908
945
  # ---------------------------------------------------------------------------
909
946
 
910
- def ros2py_float64(msg: Float64) -> NDArray:
911
- """Convert Float64 message to 1D numpy array with single element."""
912
- return np.array([msg.data], dtype=float)
947
+ def ros2py_float64(msg: Float64) -> float:
948
+ """Convert Float64 message to Python float."""
949
+ return float(msg.data)
913
950
 
914
951
 
915
952
  def py2ros_float64(arr: NDArray) -> Float64:
@@ -994,6 +1031,7 @@ ROS2PY_DEFAULT = {
994
1031
  AccelStamped: ros2py_accel_stamped,
995
1032
  Vector3Stamped: ros2py_vector3_stamped,
996
1033
  Imu: ros2py_imu,
1034
+ NavSatFix: ros2py_nav_sat_fix,
997
1035
  Odometry: ros2py_odometry,
998
1036
  Path: ros2py_path,
999
1037
  JointState: ros2py_joint_state,
@@ -1019,6 +1057,7 @@ PY2ROS_DEFAULT = {
1019
1057
  AccelStamped: py2ros_accel_stamped,
1020
1058
  Vector3Stamped: py2ros_vector3_stamped,
1021
1059
  Imu: py2ros_imu,
1060
+ NavSatFix: py2ros_nav_sat_fix,
1022
1061
  Odometry: py2ros_odometry,
1023
1062
  Path: py2ros_path,
1024
1063
  JointState: py2ros_joint_state,