dynamicalnodes 0.1.4__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dynamicalnodes
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: dynamicalnodes is a Python library for modeling general control systems.
5
5
  Author-email: Nehal Singh Mangat <nehalsinghmangat.software@gmail.com>
6
6
  License-Expression: MIT
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "dynamicalnodes"
10
- version = "0.1.4"
10
+ version = "0.2.0"
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,7 +16,7 @@ the paper, ``None`` here -- giving three cases:
16
16
  - ``(f, h)`` -- both state evolution and output are defined.
17
17
 
18
18
  Function signatures are unconstrained in name, type, and number of
19
- parameters. ``DynamicalSystem.eval`` binds keyword arguments to each
19
+ parameters. ``DynamicalSystem.step`` binds keyword arguments to each
20
20
  function's signature by name, so the same keyword argument (e.g. a shared
21
21
  state or parameter) can be passed to both ``f`` and ``h``.
22
22
  """
@@ -53,7 +53,7 @@ class DynamicalSystem:
53
53
  ... _, v = xk
54
54
  ... return abs(v)
55
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))
56
+ >>> x_next, y = car.step(xk=(20.0, 0.0), uk=4.0, theta_car=(2.0, 0.0, 1.0))
57
57
  >>> x_next
58
58
  (20.0, 2.0)
59
59
  >>> y
@@ -64,9 +64,9 @@ class DynamicalSystem:
64
64
  >>> def h_ref(tk, A=1.0):
65
65
  ... return A if tk >= 1.0 else 0.0
66
66
  >>> ref = DynamicalSystem(h=h_ref)
67
- >>> ref.eval(tk=0.0)
67
+ >>> ref.step(tk=0.0)
68
68
  (None, 0.0)
69
- >>> ref.eval(tk=2.0)
69
+ >>> ref.step(tk=2.0)
70
70
  (None, 1.0)
71
71
 
72
72
  A system with no output function -- ``(f, *)``:
@@ -74,7 +74,7 @@ class DynamicalSystem:
74
74
  >>> def f_counter(xk):
75
75
  ... return xk + 1
76
76
  >>> counter = DynamicalSystem(f=f_counter)
77
- >>> counter.eval(xk=5)
77
+ >>> counter.step(xk=5)
78
78
  (6, None)
79
79
  """
80
80
 
@@ -97,6 +97,26 @@ class DynamicalSystem:
97
97
  self._f = f
98
98
  self._h = h
99
99
 
100
+ def __repr__(self) -> str:
101
+ """
102
+ Show the system as its ``(f, h)`` pair, using ``*`` for whichever
103
+ half is unset -- matching the paper's notation.
104
+
105
+ Examples
106
+ --------
107
+ >>> def fcar(xk): return xk
108
+ >>> def hcar(xk): return xk
109
+ >>> DynamicalSystem(f=fcar, h=hcar)
110
+ (fcar, hcar)
111
+ >>> DynamicalSystem(f=fcar)
112
+ (fcar, *)
113
+ >>> DynamicalSystem(h=hcar)
114
+ (*, hcar)
115
+ """
116
+ f_name = self._f.__name__ if self._f is not None else "*"
117
+ h_name = self._h.__name__ if self._h is not None else "*"
118
+ return f"({f_name}, {h_name})"
119
+
100
120
  @property
101
121
  def f(self) -> Optional[Callable]:
102
122
  """Evolution function f(x_k, u_k, theta) -> x_{k+1}, or None if the system is stateless."""
@@ -115,7 +135,7 @@ class DynamicalSystem:
115
135
  def h(self, h: Optional[Callable]) -> None:
116
136
  self._h = h
117
137
 
118
- def eval(self, **kwargs) -> Tuple[Optional[Any], Optional[Any]]:
138
+ def step(self, **kwargs) -> Tuple[Optional[Any], Optional[Any]]:
119
139
  """
120
140
  Evaluate the system for one discrete time step.
121
141
 
@@ -150,7 +170,7 @@ class DynamicalSystem:
150
170
  cases.
151
171
 
152
172
  >>> empty = DynamicalSystem()
153
- >>> empty.eval()
173
+ >>> empty.step()
154
174
  Traceback (most recent call last):
155
175
  ...
156
176
  ValueError: Nothing to evaluate; no f or h functions!
@@ -3,7 +3,7 @@ ROSNode wrapper for simulation and ROS2 deployment.
3
3
 
4
4
  Two workflows:
5
5
 
6
- **Simulation** — call ``eval()`` directly in Python or Jupyter notebooks to test ros2py and/or py2ros translation functions.
6
+ **Simulation** — call ``step()`` directly in Python or Jupyter notebooks to test ros2py and/or py2ros translation functions.
7
7
  No ROS2 installation required.
8
8
 
9
9
  **Deployment** — call ``write_ROSNode_to_rclpy(path)`` to emit a self-contained
@@ -36,7 +36,7 @@ class ROSNode:
36
36
  """
37
37
  Wraps a DynamicalSystem for simulation and ROS2 deployment.
38
38
 
39
- Use ``eval()`` for notebook simulation; use ``write_ROSNode_to_rclpy()`` to
39
+ Use ``step()`` for notebook simulation; use ``write_ROSNode_to_rclpy()`` to
40
40
  generate a deployable rclpy node.
41
41
 
42
42
  Parameters
@@ -48,10 +48,9 @@ class ROSNode:
48
48
 
49
49
  - ``"topic"`` (str): ROS topic name.
50
50
  - ``"msg_type"`` (type): ROS message class.
51
- - ``"arg"`` (str): kwarg name passed to eval().
51
+ - ``"arg"`` (str): kwarg name passed to step().
52
52
  - ``"ros2py"`` (Callable): ROS msg → NumPy array converter.
53
53
  Import from ``dynamicalnodes.ros2py_py2ros`` or write your own.
54
- - ``"stale_after"`` (float, optional): Seconds before data expires.
55
54
  - ``"buffer_size"`` (int, optional): Queue depth (default 1).
56
55
  - ``"use_msg_timestamp"`` (bool, optional): When ``True``, the
57
56
  subscription buffer stores the message's own ``header.stamp``
@@ -74,14 +73,14 @@ class ROSNode:
74
73
  state_name : str, optional
75
74
  Name of the state parameter in ``f``'s signature (e.g. ``"ck"`` for
76
75
  ``f(ck, ...)``) . Required for stateful systems; ignored if ``f`` is
77
- ``None``. ``eval()`` injects ``self._state`` under this name before
78
- calling ``DynamicalSystem.eval()``.
76
+ ``None``. ``step()`` injects ``self._state`` under this name before
77
+ calling ``DynamicalSystem.step()``.
79
78
 
80
79
  Notes
81
80
  -----
82
- ``sync_mode`` and ``timer_hz`` are not constructor parameters — they only
83
- affect the generated ROS2 node's triggering behavior, so they're passed to
84
- ``write_ROSNode_to_rclpy()`` instead.
81
+ ``sync_mode``, ``timer_hz``, and ``stale_after`` are not constructor
82
+ parameters — they only affect the generated ROS2 node's triggering
83
+ behavior, so they're passed to ``write_ROSNode_to_rclpy()`` instead.
85
84
 
86
85
  Examples
87
86
  --------
@@ -100,7 +99,7 @@ class ROSNode:
100
99
  ... pubs=[{"topic": "/smooth", "msg_type": Float64,
101
100
  ... "py2ros": py2ros_float64}],
102
101
  ... )
103
- >>> out = node.eval(imu=np.array([2.0]))
102
+ >>> out = node.step(imu=np.array([2.0]))
104
103
  >>> out.data
105
104
  1.0
106
105
  """
@@ -118,7 +117,7 @@ class ROSNode:
118
117
  self._state_key: Optional[str] = state_name
119
118
 
120
119
  # -------- Parse subscriptions --------
121
- self._subs: List[Tuple[str, type, Any, str, Optional[float], int, bool]] = []
120
+ self._subs: List[Tuple[str, type, Any, str, int, bool]] = []
122
121
  for sub_dict in subs or []:
123
122
  if not isinstance(sub_dict, dict):
124
123
  raise TypeError("Each subscription must be a dict")
@@ -127,7 +126,6 @@ class ROSNode:
127
126
  msg_type = sub_dict.get("msg_type")
128
127
  arg_name = sub_dict.get("arg")
129
128
  ros2py = sub_dict.get("ros2py")
130
- stale_after = sub_dict.get("stale_after")
131
129
  buffer_size = sub_dict.get("buffer_size", 1)
132
130
  use_msg_timestamp = bool(sub_dict.get("use_msg_timestamp", False))
133
131
 
@@ -159,7 +157,6 @@ class ROSNode:
159
157
  msg_type,
160
158
  ros2py,
161
159
  arg_name,
162
- float(stale_after) if stale_after else None,
163
160
  int(buffer_size),
164
161
  use_msg_timestamp,
165
162
  )
@@ -204,7 +201,7 @@ class ROSNode:
204
201
  # SIMULATION API
205
202
  # --------------------------------------------------------------------------
206
203
 
207
- def eval(self, **kwargs: Any) -> Any:
204
+ def step(self, **kwargs: Any) -> Any:
208
205
  """
209
206
  Simulate one step without ROS2.
210
207
 
@@ -241,10 +238,10 @@ class ROSNode:
241
238
  ... pubs=[{"topic": "/y", "msg_type": Float64,
242
239
  ... "py2ros": py2ros_float64}],
243
240
  ... )
244
- >>> node.eval(x=Float64(data=3.0)).data
241
+ >>> node.step(x=Float64(data=3.0)).data
245
242
  6.0
246
243
  """
247
- sub_arg_names = {a for _, _, _, a, _, _, _ in self._subs}
244
+ sub_arg_names = {a for _, _, _, a, _, _ in self._subs}
248
245
  step_kwargs: Dict[str, Any] = {}
249
246
 
250
247
  for arg_name, value in kwargs.items():
@@ -252,7 +249,7 @@ class ROSNode:
252
249
  continue
253
250
  if arg_name in sub_arg_names:
254
251
  ros2py = next(
255
- (c for _, _, c, a, _, _, _ in self._subs if a == arg_name), None
252
+ (c for _, _, c, a, _, _ in self._subs if a == arg_name), None
256
253
  )
257
254
  assert ros2py is not None
258
255
  if hasattr(value, "__slots__") or hasattr(
@@ -267,7 +264,7 @@ class ROSNode:
267
264
  if self._state is not None and self._state_key is not None:
268
265
  step_kwargs[self._state_key] = self._state
269
266
 
270
- x_next, yk = self._dynamical_system.eval(**step_kwargs)
267
+ x_next, yk = self._dynamical_system.step(**step_kwargs)
271
268
  if self._dynamical_system.f is not None:
272
269
  self._state = x_next
273
270
 
@@ -304,6 +301,7 @@ class ROSNode:
304
301
  node_name: str,
305
302
  sync_mode: Optional[str] = None,
306
303
  timer_hz: Optional[float] = None,
304
+ stale_after: Optional[Dict[str, float]] = None,
307
305
  deps: Optional[List[Any]] = None,
308
306
  initial_state: Optional[Any] = None,
309
307
  initial_inputs: Optional[Dict[str, Any]] = None,
@@ -334,6 +332,15 @@ class ROSNode:
334
332
  timer_hz : float, optional
335
333
  Fires ``_run_step`` at this frequency instead of triggering from
336
334
  subscription callbacks.
335
+ stale_after : dict, optional
336
+ Maximum age, in seconds, a buffered subscription value may have
337
+ before it's treated as stale — dropped to ``None``, the same as
338
+ an empty buffer, instead of being reused indefinitely. Keys must
339
+ match subscription ``arg`` names; a subscription not listed here
340
+ never expires. Use this to bound how long a value may go
341
+ unrefreshed before a step must skip (``sync_mode="all"``) or
342
+ ignore (``sync_mode="any"``) it.
343
+ Example: ``{"rk": 0.2, "xhatk": 0.2}``.
337
344
  deps : list of callable, optional
338
345
  Helper functions that ``f`` or ``h`` depend on. Each function's
339
346
  source is inlined before the dynamics functions in the output file.
@@ -344,7 +351,7 @@ class ROSNode:
344
351
  ``__init__``. Supports NumPy arrays and any ``repr()``-able value.
345
352
  static_params : dict, optional
346
353
  Parameters baked into the node at generation time. Embedded as
347
- ``self._static_params = {...}`` and merged into every ``eval()``
354
+ ``self._static_params = {...}`` and merged into every ``step()``
348
355
  call. Use for values that never change after deployment.
349
356
  dynamic_params : dict, optional
350
357
  Parameters declared on the ROS2 parameter server with their
@@ -358,9 +365,9 @@ class ROSNode:
358
365
  restarting the node (e.g. controller gains).
359
366
  initial_inputs : dict, optional
360
367
  Cold-start fallback values for subscription inputs. Keys must
361
- match subscription ``arg`` names. A value is used only when the
362
- subscription buffer is completely empty — i.e. no message has
363
- ever arrived on that topic. Once the first message arrives the
368
+ match subscription ``arg`` names. A value is used only while no
369
+ message has ever arrived on that topic (consuming a buffered
370
+ value in a step does not count as "never arrived"). Once the first message arrives the
364
371
  subscription value takes over permanently. If the subscription
365
372
  later goes stale the step is skipped (``None`` is not replaced),
366
373
  preventing silent operation on dead data. Use this to break
@@ -394,6 +401,11 @@ class ROSNode:
394
401
  If the generated file contains invalid Python (indicates a bug
395
402
  in the generator).
396
403
 
404
+ Returns
405
+ -------
406
+ None
407
+ The generated node is written to ``path``.
408
+
397
409
  Notes
398
410
  -----
399
411
  **Function resolution**
@@ -420,15 +432,15 @@ class ROSNode:
420
432
  >>> node = ROSNode(
421
433
  ... dynamical_system=sys,
422
434
  ... subs=[{"topic": "/u_k", "msg_type": Float64,
423
- ... "arg": "u_k", "ros2py": ros2py_float64,
424
- ... "stale_after": 0.5}],
435
+ ... "arg": "u_k", "ros2py": ros2py_float64}],
425
436
  ... pubs=[{"topic": "/y_k", "msg_type": Float64,
426
437
  ... "py2ros": py2ros_float64}],
427
438
  ... )
428
439
  >>> with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp:
429
440
  ... path = tmp.name
430
441
  >>> node.write_ROSNode_to_rclpy(path, node_name="plant",
431
- ... initial_state=np.array([0.0]))
442
+ ... initial_state=np.array([0.0]),
443
+ ... stale_after={"u_k": 0.5})
432
444
  >>> content = open(path).read()
433
445
  >>> "def f_plant" in content and "def h_plant" in content
434
446
  True
@@ -438,6 +450,8 @@ class ROSNode:
438
450
  True
439
451
  >>> "SingleThreadedExecutor" in content
440
452
  True
453
+ >>> "_u_k_seen" in content
454
+ True
441
455
  >>> os.unlink(path)
442
456
  """
443
457
  import importlib
@@ -588,21 +602,26 @@ class ROSNode:
588
602
  ds = self._dynamical_system
589
603
  _check_lambda(ds.f, "DynamicalSystem.f")
590
604
  _check_lambda(ds.h, "DynamicalSystem.h")
591
- for _, _, ros2py, arg, _, _, _ in self._subs:
605
+ for _, _, ros2py, arg, _, _ in self._subs:
592
606
  _check_lambda(ros2py, f"ros2py for '{arg}'")
593
607
  for _, _, py2ros, key in self._pubs_cfg:
594
608
  _check_lambda(py2ros, f"py2ros for key={key!r}")
595
609
 
596
610
  _sub_noise: Dict[str, float] = dict(sub_noise or {})
597
611
  _pub_noise: Dict[str, float] = dict(pub_noise or {})
612
+ _stale_after: Dict[str, float] = dict(stale_after or {})
598
613
  _valid_sub_topics = {t for t, *_ in self._subs}
599
614
  _valid_pub_topics = {t for t, *_ in self._pubs_cfg}
615
+ _valid_sub_args = {a for _, _, _, a, _, _ in self._subs}
600
616
  for t in _sub_noise:
601
617
  if t not in _valid_sub_topics:
602
618
  raise ValueError(f"sub_noise topic {t!r} not in subs")
603
619
  for t in _pub_noise:
604
620
  if t not in _valid_pub_topics:
605
621
  raise ValueError(f"pub_noise topic {t!r} not in pubs")
622
+ for a in _stale_after:
623
+ if a not in _valid_sub_args:
624
+ raise ValueError(f"stale_after key {a!r} not a subscription 'arg'")
606
625
 
607
626
  # ── classify all functions ─────────────────────────────────────────────
608
627
 
@@ -611,7 +630,7 @@ class ROSNode:
611
630
 
612
631
  # subs: [(topic, msg_type, ros2py, cls, arg, stale, buf, use_msg_ts), ...]
613
632
  subs = []
614
- for topic, msg_type, ros2py, arg, stale, buf, use_msg_ts in self._subs:
633
+ for topic, msg_type, ros2py, arg, buf, use_msg_ts in self._subs:
615
634
  subs.append(
616
635
  (
617
636
  topic,
@@ -619,7 +638,7 @@ class ROSNode:
619
638
  ros2py,
620
639
  _classify(ros2py),
621
640
  arg,
622
- stale,
641
+ _stale_after.get(arg),
623
642
  buf,
624
643
  use_msg_ts,
625
644
  )
@@ -891,6 +910,7 @@ class ROSNode:
891
910
  ln(" # ── Subscription buffers " + "─" * 47)
892
911
  for _, _, _, _, arg, _, buf, _ in subs:
893
912
  ln(f" self._{arg}_buf: deque = deque(maxlen={buf})")
913
+ ln(f" self._{arg}_seen: bool = False # any message ever received")
894
914
 
895
915
  blank()
896
916
  ln(" # ── Subscriptions " + "─" * 52)
@@ -937,7 +957,11 @@ class ROSNode:
937
957
  ln(
938
958
  " # enables the staleness check in _fresh(). _run_step() proceeds only when"
939
959
  )
940
- ln(" # sync_mode conditions are satisfied.")
960
+ ln(
961
+ " # sync_mode conditions are satisfied, and pops each value it consumes —"
962
+ )
963
+ ln(" # so a stored value can trigger at most one step before it must be")
964
+ ln(" # refreshed by new data.")
941
965
  for topic, msg_type, ros2py, cls, arg, _, _, use_msg_ts in subs:
942
966
  ros2py_name = _ref_name(ros2py, cls)
943
967
  blank()
@@ -957,6 +981,7 @@ class ROSNode:
957
981
  else:
958
982
  ln(f" ts = self.get_clock().now().nanoseconds * 1e-9")
959
983
  ln(f" self._{arg}_buf.append((ts, arr))")
984
+ ln(f" self._{arg}_seen = True")
960
985
  if timer_hz is None:
961
986
  ln(f" self._run_step()")
962
987
 
@@ -980,7 +1005,7 @@ class ROSNode:
980
1005
  ln(" # Control step")
981
1006
  ln(" # " + "─" * 68)
982
1007
  ln(" # Builds kwargs from static params, dynamic params, fresh subscription")
983
- ln(" # values, and current state, then calls DynamicalSystem.eval() and")
1008
+ ln(" # values, and current state, then calls DynamicalSystem.step() and")
984
1009
  ln(" # publishes. Skipped entirely when sync_mode conditions are not met.")
985
1010
  blank()
986
1011
  ln(" def _run_step(self) -> None:")
@@ -1006,7 +1031,7 @@ class ROSNode:
1006
1031
  " # Cold-start fallbacks — used only before first message arrives"
1007
1032
  )
1008
1033
  for arg in cold_start_args:
1009
- ln(f" if {arg} is None and not self._{arg}_buf:")
1034
+ ln(f" if {arg} is None and not self._{arg}_seen:")
1010
1035
  ln(
1011
1036
  f" {arg} = {_repr_value(_initial[arg])} # initial input"
1012
1037
  )
@@ -1022,6 +1047,17 @@ class ROSNode:
1022
1047
  ln(f" if {cond}:")
1023
1048
  ln(" return # all inputs are stale — skip this tick")
1024
1049
 
1050
+ blank()
1051
+ ln(" # Consume: pop each fresh value now that we're committing to a")
1052
+ ln(
1053
+ " # step, so it cannot re-trigger another step until genuinely new"
1054
+ )
1055
+ ln(" # data arrives (prevents event-driven feedback loops from")
1056
+ ln(" # cascading at an unbounded rate).")
1057
+ for _, _, _, _, arg, _, _, _ in subs:
1058
+ ln(f" if {arg} is not None and self._{arg}_buf:")
1059
+ ln(f" self._{arg}_buf.pop()")
1060
+
1025
1061
  blank()
1026
1062
  ln(' kwargs: dict = {"tk": tk, **self._static_params}')
1027
1063
  for name in _dynamic:
@@ -1034,7 +1070,7 @@ class ROSNode:
1034
1070
  ln(f' kwargs["{self._state_key}"] = self._state')
1035
1071
 
1036
1072
  blank()
1037
- ln(" x_next, yk = self._system.eval(**kwargs)")
1073
+ ln(" x_next, yk = self._system.step(**kwargs)")
1038
1074
  ln(" if self._system.f is not None:")
1039
1075
  ln(" self._state = x_next # stateful: advance to next state")
1040
1076
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dynamicalnodes
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: dynamicalnodes is a Python library for modeling general control systems.
5
5
  Author-email: Nehal Singh Mangat <nehalsinghmangat.software@gmail.com>
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes