scirodev 0.4.1__tar.gz → 0.5.2__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: scirodev
3
- Version: 0.4.1
3
+ Version: 0.5.2
4
4
  Summary: PeakHub / sciro extensions to Pybricks: typed API stubs (sciro.*) and hub tooling on top of pybricksdev
5
5
  Author: Thomas Schank
6
6
  License: MIT
@@ -16,6 +16,9 @@ Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: pybricksdev>=2.3
18
18
  Requires-Dist: pybricks>=3.6
19
+ Provides-Extra: docs
20
+ Requires-Dist: sphinx>=8; extra == "docs"
21
+ Requires-Dist: furo; extra == "docs"
19
22
  Dynamic: license-file
20
23
 
21
24
  # scirodev
@@ -43,6 +46,8 @@ from sciro.parameters import Port # Port.A .. Port.H (PeakHub has 8 por
43
46
  from sciro.iodevices import PUMPDevice # generic PUMP device access
44
47
  from sciro.pump import FloorPro # PUMP devices (LP-FloorPro, ...)
45
48
  from sciro.hubs import PeakHub # hub class incl. display.device()
49
+ from sciro.tools import RingBuffer # experimental: RAM recorder -> CSV file via the console
50
+ from sciro.robotics import PIDController # experimental: PID with optional logging
46
51
 
47
52
  hub = PeakHub()
48
53
  fp = FloorPro(Port.G)
@@ -50,6 +55,32 @@ cog_dark, cog_bright, brightness, darkness, mask, calibrating = fp.line.read()
50
55
  hub.display.device(fp, brightness=50) # mirror the sensor's LED strip on the 5x5
51
56
  ```
52
57
 
58
+ Experimental (API may change): a RAM recorder that publishes CSV through the
59
+ console, and a PID controller that can log into it:
60
+
61
+ ```python
62
+ from sciro.robotics import PIDController
63
+ from sciro.tools import RingBuffer
64
+
65
+ pid = PIDController(kp=15, kd=0.2, output_limit=300)
66
+ log = pid.make_log(seconds=10, rate=100) # RingBuffer of pid.LOG_FIELDS
67
+ turn_rate = pid.update(error, derivative=-hub.imu.angular_velocity(Axis.Z))
68
+ log.publish("doc/pid_run.csv") # scirodev run writes the file next to the script
69
+ ```
70
+
71
+ The drivebase's own controllers log too (built into Pybricks, undocumented
72
+ upstream): `db.heading_control.log.start(5000, down_sample=2)` records the
73
+ reference trajectory, the estimated state and the P/I/D terms at 100 Hz from the
74
+ firmware's control loop; `db.heading_control.log.publish("heading.csv")` writes
75
+ it with a header line. `sciro.robotics.DriveBase` is the Pybricks class with
76
+ these attributes typed. `log.record(db.state)` is the coroutine form for
77
+ anything else.
78
+
79
+ `publish(path)` wraps the CSV in pybricksdev's `_file_begin_ <path>` /
80
+ `_file_end_` lines; `scirodev run` (and `pybricksdev run`) then write the block
81
+ to that file, relative to the script's folder, instead of echoing it. Without a
82
+ path the CSV goes to the terminal.
83
+
53
84
  Full demo programs for the LP FloorPro (both for LEGO hubs and the PeakHub) live in
54
85
  [sciurus-robotics/FloorPro-CodeDemos](https://github.com/sciurus-robotics/FloorPro-CodeDemos);
55
86
  `examples/` here stays minimal.
@@ -61,6 +92,18 @@ pybricks.parameters.Port` — the stubs only add what the IDE is missing. The
61
92
  stub files here mirror the frozen modules; a release of this package matches
62
93
  the PeakHub firmware of the same date.
63
94
 
95
+ ## API reference (HTML)
96
+
97
+ The stubs double as the source of the API reference, built with Sphinx the way
98
+ docs.pybricks.com is (cross-links into it for the upstream types):
99
+
100
+ ```
101
+ pip install -e ".[docs]"
102
+ scripts/build-docs # -> docs/_build/html/index.html
103
+ ```
104
+
105
+ The build output is static HTML; a website can copy that folder as is.
106
+
64
107
  ## Releasing
65
108
 
66
109
  Bump `version` in `pyproject.toml`, commit, tag `vX.Y.Z` and push the tag. The
@@ -23,6 +23,8 @@ from sciro.parameters import Port # Port.A .. Port.H (PeakHub has 8 por
23
23
  from sciro.iodevices import PUMPDevice # generic PUMP device access
24
24
  from sciro.pump import FloorPro # PUMP devices (LP-FloorPro, ...)
25
25
  from sciro.hubs import PeakHub # hub class incl. display.device()
26
+ from sciro.tools import RingBuffer # experimental: RAM recorder -> CSV file via the console
27
+ from sciro.robotics import PIDController # experimental: PID with optional logging
26
28
 
27
29
  hub = PeakHub()
28
30
  fp = FloorPro(Port.G)
@@ -30,6 +32,32 @@ cog_dark, cog_bright, brightness, darkness, mask, calibrating = fp.line.read()
30
32
  hub.display.device(fp, brightness=50) # mirror the sensor's LED strip on the 5x5
31
33
  ```
32
34
 
35
+ Experimental (API may change): a RAM recorder that publishes CSV through the
36
+ console, and a PID controller that can log into it:
37
+
38
+ ```python
39
+ from sciro.robotics import PIDController
40
+ from sciro.tools import RingBuffer
41
+
42
+ pid = PIDController(kp=15, kd=0.2, output_limit=300)
43
+ log = pid.make_log(seconds=10, rate=100) # RingBuffer of pid.LOG_FIELDS
44
+ turn_rate = pid.update(error, derivative=-hub.imu.angular_velocity(Axis.Z))
45
+ log.publish("doc/pid_run.csv") # scirodev run writes the file next to the script
46
+ ```
47
+
48
+ The drivebase's own controllers log too (built into Pybricks, undocumented
49
+ upstream): `db.heading_control.log.start(5000, down_sample=2)` records the
50
+ reference trajectory, the estimated state and the P/I/D terms at 100 Hz from the
51
+ firmware's control loop; `db.heading_control.log.publish("heading.csv")` writes
52
+ it with a header line. `sciro.robotics.DriveBase` is the Pybricks class with
53
+ these attributes typed. `log.record(db.state)` is the coroutine form for
54
+ anything else.
55
+
56
+ `publish(path)` wraps the CSV in pybricksdev's `_file_begin_ <path>` /
57
+ `_file_end_` lines; `scirodev run` (and `pybricksdev run`) then write the block
58
+ to that file, relative to the script's folder, instead of echoing it. Without a
59
+ path the CSV goes to the terminal.
60
+
33
61
  Full demo programs for the LP FloorPro (both for LEGO hubs and the PeakHub) live in
34
62
  [sciurus-robotics/FloorPro-CodeDemos](https://github.com/sciurus-robotics/FloorPro-CodeDemos);
35
63
  `examples/` here stays minimal.
@@ -41,6 +69,18 @@ pybricks.parameters.Port` — the stubs only add what the IDE is missing. The
41
69
  stub files here mirror the frozen modules; a release of this package matches
42
70
  the PeakHub firmware of the same date.
43
71
 
72
+ ## API reference (HTML)
73
+
74
+ The stubs double as the source of the API reference, built with Sphinx the way
75
+ docs.pybricks.com is (cross-links into it for the upstream types):
76
+
77
+ ```
78
+ pip install -e ".[docs]"
79
+ scripts/build-docs # -> docs/_build/html/index.html
80
+ ```
81
+
82
+ The build output is static HTML; a website can copy that folder as is.
83
+
44
84
  ## Releasing
45
85
 
46
86
  Bump `version` in `pyproject.toml`, commit, tag `vX.Y.Z` and push the tag. The
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scirodev"
7
- version = "0.4.1"
7
+ version = "0.5.2"
8
8
  description = "PeakHub / sciro extensions to Pybricks: typed API stubs (sciro.*) and hub tooling on top of pybricksdev"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -24,6 +24,9 @@ dependencies = [
24
24
  "pybricks>=3.6",
25
25
  ]
26
26
 
27
+ [project.optional-dependencies]
28
+ docs = ["sphinx>=8", "furo"]
29
+
27
30
  [project.urls]
28
31
  Homepage = "https://github.com/sciurus-robotics/scirodev"
29
32
  Repository = "https://github.com/sciurus-robotics/scirodev"
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
20
20
  _RGB = Tuple[int, int, int]
21
21
  _Calibration = Tuple[Tuple[int, int, int, int], Tuple[int, int, int, int], Tuple[int, int, int], bool, bool, int]
22
22
  _Pixels = Tuple[_RGB, ...]
23
+ _Bools = Tuple[bool, ...]
23
24
 
24
25
  class MaybeAwaitableLine(_Line, Awaitable[_Line]): ...
25
26
 
@@ -40,3 +41,5 @@ if TYPE_CHECKING:
40
41
  class MaybeAwaitableCalibration(_Calibration, Awaitable[_Calibration]): ...
41
42
 
42
43
  class MaybeAwaitableStr(str, Awaitable[str]): ...
44
+
45
+ class MaybeAwaitableBools(_Bools, Awaitable[_Bools]): ...
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
11
11
 
12
12
  from ._common import MaybeAwaitableStateData
13
13
 
14
- from .parameters import Port as _Port
14
+ from pybricks.parameters import Port as _Port # base type: sciro.parameters.Port is a subclass
15
15
 
16
16
  # (stream id, url, ext_port, state length)
17
17
  StreamInfo = Tuple[int, str, int, int]
@@ -0,0 +1,62 @@
1
+ """sciro.parameters -- the Pybricks parameter types, with the PeakHub port set.
2
+
3
+ On the hub this module re-exports ``pybricks.parameters`` unchanged (the firmware
4
+ has always had ``Port.G`` and ``Port.H``); this stub exists because the upstream
5
+ stubs declare ``Port.A`` .. ``Port.F`` only.
6
+ """
7
+
8
+ from pybricks.parameters import ( # noqa: F401 (re-exports)
9
+ Axis as Axis,
10
+ Button as Button,
11
+ Color as Color,
12
+ Direction as Direction,
13
+ Icon as Icon,
14
+ Side as Side,
15
+ Stop as Stop,
16
+ )
17
+ from typing import TYPE_CHECKING
18
+
19
+ from pybricks.parameters import _PybricksEnum
20
+
21
+ if TYPE_CHECKING:
22
+ from pybricks.parameters import Port as _Port
23
+
24
+ class Port(_Port):
25
+ """Port on the PeakHub. Eight LPF2/PUP ports, A .. H.
26
+
27
+ For the type checker this is a subclass of ``pybricks.parameters.Port``
28
+ (on the hub it is the same object), so a ``sciro`` port is accepted
29
+ wherever the upstream stubs expect a ``pybricks.parameters.Port``
30
+ (``Motor``, ``DriveBase``, ...). A .. F are inherited; G and H are
31
+ the two extra PeakHub ports.
32
+ """
33
+
34
+ G: Port
35
+ H: Port
36
+
37
+ else:
38
+ # At runtime the upstream Port is a real Enum, which cannot be subclassed
39
+ # once it has members; tooling that imports the stubs (docs, tests) gets
40
+ # an equivalent enum with all eight ports instead.
41
+ class Port(_PybricksEnum):
42
+ """Port on the PeakHub. Eight LPF2/PUP ports, A .. H."""
43
+
44
+ A = ord("A")
45
+ B = ord("B")
46
+ C = ord("C")
47
+ D = ord("D")
48
+ E = ord("E")
49
+ F = ord("F")
50
+ G = ord("G")
51
+ H = ord("H")
52
+
53
+
54
+ class ExtPort:
55
+ """Extension port of a PUMP device (1-based, as in its stream table)."""
56
+
57
+ EXT1: int = 1
58
+ EXT2: int = 2
59
+ EXT3: int = 3
60
+ EXT4: int = 4
61
+ EXT5: int = 5
62
+ EXT6: int = 6
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from typing import TYPE_CHECKING, Iterable, Optional, Tuple, Union
12
12
 
13
13
  if TYPE_CHECKING:
14
- from pybricks._common import MaybeAwaitable, MaybeAwaitableColor, MaybeAwaitableFloat
14
+ from pybricks._common import MaybeAwaitable, MaybeAwaitableBool, MaybeAwaitableColor, MaybeAwaitableFloat
15
15
 
16
16
  from ._common import (
17
17
  MaybeAwaitableEuler,
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
22
22
  MaybeAwaitableRGB8,
23
23
  MaybeAwaitableCalibration,
24
24
  MaybeAwaitableStr,
25
+ MaybeAwaitableBools,
25
26
  MaybeAwaitableRGBC,
26
27
  )
27
28
 
@@ -29,7 +30,7 @@ from .iodevices import PUMPDevice as PUMPDevice # noqa: F401 (re-export)
29
30
  from .iodevices import StreamInfo
30
31
  from pybricks.parameters import Color
31
32
 
32
- from .parameters import Port as _Port
33
+ from pybricks.parameters import Port as _Port # base type: sciro.parameters.Port is a subclass
33
34
 
34
35
 
35
36
  class _Stream:
@@ -50,12 +51,32 @@ class Line(_Stream):
50
51
  """read() -> Tuple
51
52
 
52
53
  Returns ``(cog_dark, cog_bright, brightness, darkness, mask, calibrating)``:
53
- centre of gravity of the dark / bright pixels in sensor pitches from the
54
+ center of gravity of the dark / bright pixels in sensor pitches from the
54
55
  middle sensor (-7 .. +7), overall brightness / darkness (0 .. 1), a
55
56
  15-bit bright-pixel mask (bit i = sensor i), and whether calibration is
56
57
  active.
57
58
  """
58
59
 
60
+ def dark_centroid(self) -> MaybeAwaitableFloat:
61
+ """dark_centroid() -> float -- center of gravity of the dark pixels, in
62
+ sensor pitches from the middle sensor (-7 .. +7)."""
63
+
64
+ def bright_centroid(self) -> MaybeAwaitableFloat:
65
+ """bright_centroid() -> float -- center of gravity of the bright pixels (-7 .. +7)."""
66
+
67
+ def brightness(self) -> MaybeAwaitableFloat:
68
+ """brightness() -> float -- overall brightness 0 .. 1."""
69
+
70
+ def darkness(self) -> MaybeAwaitableFloat:
71
+ """darkness() -> float -- overall darkness 0 .. 1."""
72
+
73
+ def line_sensors_binary(self) -> MaybeAwaitableBools:
74
+ """line_sensors_binary() -> Tuple[bool, ...] -- 15 flags, sensor 0 first,
75
+ True where the sensor sees bright."""
76
+
77
+ def calibrating(self) -> MaybeAwaitableBool:
78
+ """calibrating() -> bool -- True while the min-max calibration runs."""
79
+
59
80
  def calibrate(self, enable: bool = True) -> MaybeAwaitable:
60
81
  """calibrate(enable=True) -- start/stop min-max calibration of the array."""
61
82
 
@@ -99,7 +120,7 @@ class Pixels(_Stream):
99
120
 
100
121
 
101
122
  class ColorSensor(_Stream):
102
- """TCS3400 colour sensor on an extension port of a PUMP device.
123
+ """TCS3400 color sensor on an extension port of a PUMP device.
103
124
 
104
125
  ``ColorSensor(port, ext_port)`` opens it directly; :meth:`FloorPro.color_sensor`
105
126
  returns the same class. ``hsv()`` and ``color()`` follow
@@ -119,7 +140,7 @@ class ColorSensor(_Stream):
119
140
  port (Port): Hub port of the PUMP device carrying the sensor (or the
120
141
  opened ``PUMPDevice`` itself).
121
142
  ext_port (ExtPort): Extension port the sensor is plugged into.
122
- Raises ``OSError`` if there is no colour sensor there.
143
+ Raises ``OSError`` if there is no color sensor there.
123
144
  """
124
145
 
125
146
  def read(self) -> MaybeAwaitableRGBC:
@@ -137,7 +158,7 @@ class ColorSensor(_Stream):
137
158
  """hsv() -> Color
138
159
 
139
160
  Hue (0 .. 359), saturation (0 .. 100) and value (0 .. 100) of the
140
- surface, as a ``Color``: standard HSV of the calibrated colour when a
161
+ surface, as a ``Color``: standard HSV of the calibrated color when a
141
162
  valid calibration applies, of the raw reading otherwise.
142
163
  """
143
164
 
@@ -159,7 +180,7 @@ class ColorSensor(_Stream):
159
180
  """
160
181
 
161
182
  def clear_calibration(self) -> MaybeAwaitable:
162
- """clear_calibration() -- drop the stored calibration (back to full-scale colour)."""
183
+ """clear_calibration() -- drop the stored calibration (back to full-scale color)."""
163
184
 
164
185
  def calibration(self) -> MaybeAwaitableCalibration:
165
186
  """calibration() -> Tuple
@@ -172,12 +193,12 @@ class ColorSensor(_Stream):
172
193
  def color(self) -> MaybeAwaitableColor:
173
194
  """color() -> Color
174
195
 
175
- The nearest of the detectable colours (default: red, yellow, green,
196
+ The nearest of the detectable colors (default: red, yellow, green,
176
197
  blue, white, none), matched like ``pybricks.pupdevices.ColorSensor``.
177
198
  """
178
199
 
179
200
  def detectable_colors(self, colors: Optional[Iterable[Color]] = None) -> Optional[Tuple[Color, ...]]:
180
- """detectable_colors(colors) -- set the colours color() chooses from; with no argument, get them."""
201
+ """detectable_colors(colors) -- set the colors color() chooses from; with no argument, get them."""
181
202
 
182
203
  def settings(self) -> Tuple[int, int, int]:
183
204
  """settings() -> Tuple[int, int, int] -- (led_percent, gain_x, atime) in effect."""
@@ -259,6 +280,30 @@ class FloorPro:
259
280
  def imu(self, ext_port: int = 2) -> Gyro:
260
281
  """imu(ext_port=2) -> Gyro -- alias of :meth:`gyro`."""
261
282
 
283
+ # Line conveniences at device level (the names of the LUMP driver class):
284
+
285
+ def all_sensor_data(self) -> MaybeAwaitableLine:
286
+ """all_sensor_data() -> Tuple -- the same as ``line.read()``."""
287
+
288
+ def dark_centroid(self) -> MaybeAwaitableFloat:
289
+ """dark_centroid() -> float -- see :meth:`Line.dark_centroid`."""
290
+
291
+ def bright_centroid(self) -> MaybeAwaitableFloat:
292
+ """bright_centroid() -> float -- see :meth:`Line.bright_centroid`."""
293
+
294
+ def brightness(self) -> MaybeAwaitableFloat:
295
+ """brightness() -> float -- overall brightness 0 .. 1."""
296
+
297
+ def darkness(self) -> MaybeAwaitableFloat:
298
+ """darkness() -> float -- overall darkness 0 .. 1."""
299
+
300
+ def line_sensors_binary(self) -> MaybeAwaitableBools:
301
+ """line_sensors_binary() -> Tuple[bool, ...] -- 15 flags, True = bright."""
302
+
303
+ def line_sensors_analog(self) -> MaybeAwaitableInts:
304
+ """line_sensors_analog() -> Tuple[int, ...] -- 15 raw ADC counts
305
+ (0 .. 4095), sensor 0 first; one ``ir_raw`` frame on demand."""
306
+
262
307
  def streams(self) -> Tuple[StreamInfo, ...]:
263
308
  """streams() -> Tuple -- the enumerated streams ``(id, url, ext_port, state_len)``."""
264
309
 
@@ -0,0 +1,156 @@
1
+ """Stubs for ``sciro.robotics`` (PeakHub firmware) -- control helpers.
2
+ EXPERIMENTAL: the API may still change between releases.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING, Optional, Tuple, Union
8
+
9
+ from pybricks.parameters import Stop
10
+
11
+ if TYPE_CHECKING:
12
+ from pybricks._common import MaybeAwaitable
13
+ from pybricks.parameters import Number
14
+ else:
15
+ # Upstream defines these only for type checkers; runtime importers of the
16
+ # stubs (the docs build) need values. Annotations are strings here
17
+ # (from __future__ import annotations), so placeholders suffice.
18
+ Number = Union[int, float]
19
+ MaybeAwaitable = None
20
+
21
+ from pybricks import _common
22
+ from pybricks.robotics import DriveBase as _DriveBase
23
+ from pybricks.tools import StopWatch
24
+
25
+ from sciro.tools import Logger, RingBuffer
26
+
27
+
28
+ class Control(_common.Control):
29
+ """A drivebase / motor controller, with its built-in logger exposed."""
30
+
31
+ log: Logger
32
+
33
+
34
+ class DriveBase(_DriveBase):
35
+ """``pybricks.robotics.DriveBase`` (the same class on the hub). The stub
36
+ adds the ``log`` on ``heading_control`` / ``distance_control`` and the
37
+ PeakHub extensions of :meth:`straight`."""
38
+
39
+ heading_control: Control # type: ignore[assignment]
40
+ distance_control: Control # type: ignore[assignment]
41
+
42
+ def straight( # type: ignore[override]
43
+ self,
44
+ distance: Number,
45
+ then: Stop = Stop.HOLD,
46
+ wait: bool = True,
47
+ speed: Optional[Number] = None,
48
+ heading: Optional[Number] = None,
49
+ exit_speed: Number = 0,
50
+ ) -> MaybeAwaitable:
51
+ """straight(distance, then=Stop.HOLD, wait=True, speed=None, heading=None, exit_speed=0)
52
+
53
+ Drives straight for ``distance`` mm, as in Pybricks, with three
54
+ PeakHub extensions (keyword use recommended):
55
+
56
+ ``speed``: drive speed in mm/s for this move only; ``None`` uses the
57
+ ``straight_speed`` setting.
58
+
59
+ ``heading``: absolute heading in degrees to hold during the move
60
+ instead of the heading at its start (the gyro heading with
61
+ ``use_gyro(True)``, else the wheel-based one since the last
62
+ ``reset()``). Any value is accepted: the nearest whole-turn
63
+ equivalent of the current heading is targeted, so ``heading=90``
64
+ while the internal heading reads 450 corrects by 0 degrees, never by
65
+ a full turn. ``None`` keeps the Pybricks behavior.
66
+
67
+ ``exit_speed``: speed in mm/s the robot has when it reaches the
68
+ target; it keeps driving at that speed until the next command, so the
69
+ next ``straight()`` (or ``drive()``) blends in without a stop. Implies
70
+ ``then=Stop.NONE``; any other explicit ``then`` raises ``ValueError``.
71
+ Clamped to the move's drive speed. ``0`` (default) stops or holds as
72
+ ``then`` says. ``then=Stop.NONE`` without ``exit_speed`` keeps the
73
+ upstream meaning: continue at the drive speed.
74
+ """
75
+
76
+ def heading_target(self, angle: Optional[Number] = None) -> Optional[float]:
77
+ """heading_target(angle) / heading_target() -> float
78
+
79
+ Steers a running :meth:`straight` (PeakHub): replaces the heading
80
+ setpoint without touching the distance trajectory (position, speed,
81
+ exit speed and end condition stay). Absolute heading in degrees,
82
+ float, same frame and nearest-whole-turn rule as ``straight(heading=)``.
83
+ The heading controller branches off its current reference on a new
84
+ trajectory, so small steps (the usual < 1 degree per 10 ms) are smooth
85
+ and large ones are limited by the ``turn_rate`` / ``turn_acceleration``
86
+ settings. Without an argument returns the current heading target.
87
+
88
+ Typical use, a line follower as a setpoint generator::
89
+
90
+ db.straight(dist, speed=v, exit_speed=e, heading=course, wait=False)
91
+ while not db.done():
92
+ data = await floor.all_sensor_data()
93
+ db.heading_target(course + K * data[0])
94
+ await wait(10)
95
+
96
+ Raises ``OSError`` when no ``straight()`` is running (``drive()``,
97
+ ``turn()``, ``curve()``, ``arc()`` and an idle drivebase are not
98
+ steerable).
99
+ """
100
+
101
+
102
+ class PIDController:
103
+ """Discrete PID controller: ``output = kp*e + ki*integral(e) + kd*de/dt``.
104
+
105
+ ``ki = kd = 0`` gives a plain P or PD controller. ``integral_limit`` clamps
106
+ the I term (anti-windup), ``output_limit`` the total output; ``None`` means
107
+ unlimited. A gap between updates longer than ``reset_after`` milliseconds
108
+ restarts the controller (a new movement); ``None`` never. ``stopwatch``
109
+ is the time source (default: an own ``StopWatch``); ``log`` a
110
+ :class:`RingBuffer` with :attr:`LOG_FIELDS` (see :meth:`make_log`).
111
+ """
112
+
113
+ LOG_FIELDS: Tuple[Tuple[str, str], ...]
114
+ """``t_ms, dt, error, p, i, d, output`` -- the row layout of the log."""
115
+
116
+ kp: float
117
+ ki: float
118
+ kd: float
119
+ integral_limit: Optional[float]
120
+ output_limit: Optional[float]
121
+ reset_after: Optional[int]
122
+ stopwatch: StopWatch
123
+ log: Optional[RingBuffer]
124
+ name: str
125
+ output: float
126
+ """The last output."""
127
+ integral: float
128
+
129
+ def __init__(
130
+ self,
131
+ kp: float,
132
+ ki: float = 0.0,
133
+ kd: float = 0.0,
134
+ integral_limit: Optional[float] = None,
135
+ output_limit: Optional[float] = None,
136
+ reset_after: Optional[int] = 100,
137
+ stopwatch: Optional[StopWatch] = None,
138
+ log: Optional[RingBuffer] = None,
139
+ name: str = "PID",
140
+ ) -> None: ...
141
+
142
+ def make_log(self, seconds: float, rate: int = 100) -> RingBuffer:
143
+ """Attach and return a RingBuffer for ``seconds`` of updates at ``rate`` Hz."""
144
+
145
+ def reset(self) -> None:
146
+ """Forget the integral, the last error and the last update time."""
147
+
148
+ def update(self, error: float, derivative: Optional[float] = None, p_limit: Optional[float] = None) -> float:
149
+ """One controller step; returns the output.
150
+
151
+ ``error`` is setpoint minus measurement. ``derivative`` is d(error)/dt
152
+ when measured (e.g. minus the gyro rate for a heading error); ``None``
153
+ differentiates the error numerically. ``p_limit`` clamps the P term for
154
+ this step only, e.g. a braking profile that leaves the D term free to
155
+ damp.
156
+ """
@@ -0,0 +1,92 @@
1
+ """Stubs for ``sciro.tools`` (PeakHub firmware) -- recording and publishing
2
+ helpers. EXPERIMENTAL: the API may still change between releases.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Awaitable, Callable, Iterable, Iterator, Optional, Sequence, Tuple
8
+
9
+
10
+ class RingBuffer:
11
+ """Fixed-size recorder of rows with a fixed binary layout, kept in RAM.
12
+
13
+ ``fields`` are ``(name, format)`` pairs with ``struct`` formats (``"I"``
14
+ unsigned 32 bit, ``"i"`` signed, ``"f"`` float, ``"h"`` 16 bit, ``"B"``
15
+ byte, ...); ``capacity`` rows are kept, older rows are overwritten.
16
+ RAM used = capacity * row size (100 Hz * 30 s * 7 floats = 84 KB).
17
+ """
18
+
19
+ fields: Tuple[Tuple[str, str], ...]
20
+ names: Tuple[str, ...]
21
+ format: str
22
+ row_size: int
23
+ capacity: int
24
+ name: Optional[str]
25
+ count: int
26
+ """Rows appended since the last clear (not capped at capacity)."""
27
+
28
+ def __init__(self, fields: Iterable[Sequence[str]], capacity: int, name: Optional[str] = None) -> None: ...
29
+
30
+ def __len__(self) -> int:
31
+ """Rows currently kept (at most ``capacity``)."""
32
+
33
+ def __getitem__(self, idx: int) -> Tuple[float | int, ...]:
34
+ """Row ``idx`` as a tuple; 0 is the oldest kept row, -1 the newest."""
35
+
36
+ def __iter__(self) -> Iterator[Tuple[float | int, ...]]:
37
+ """Rows, oldest first."""
38
+
39
+ def clear(self) -> None: ...
40
+
41
+ def append(self, *values: float | int) -> None:
42
+ """Append one row; values in field order."""
43
+
44
+ def record(self, source: Callable[[], Sequence[float | int]], period: int = 10) -> Awaitable[None]:
45
+ """Coroutine: append ``source()`` every ``period`` ms until cancelled.
46
+
47
+ Run it next to the movement, e.g.
48
+ ``await multitask(drive(), log.record(db.state), race=True)``.
49
+ """
50
+
51
+ def publish(self, path: Optional[str] = None, decimals: int = 3) -> None:
52
+ """Print the rows as CSV (``#`` header comments, then the names).
53
+
54
+ With ``path`` the block is wrapped in the pybricksdev file markers
55
+ (``_file_begin_ <path>`` ... ``_file_end_``): ``scirodev run`` writes it
56
+ to that file, relative to the folder of the running script, instead of
57
+ the terminal. Expect on the order of 1000 rows per few seconds over BLE.
58
+ """
59
+
60
+
61
+ class Logger:
62
+ """The built-in pbio logger of a controller (``DriveBase.heading_control.log``,
63
+ ``DriveBase.distance_control.log``, ``Motor.log``): rows are written by
64
+ the firmware's 5 ms control loop while the controller is active, into a
65
+ fixed buffer (not a ring) allocated by :meth:`start`.
66
+
67
+ Controller columns: ``t_ms, traj_ms, position, speed, status, torque,
68
+ ref_position, ref_speed, est_position, est_speed, p, i, d, position_c``
69
+ (``position_c`` = position in 0.01 units, PeakHub; position in
70
+ the controller's units: mm or deg; torque in uNm; status bits: actuation
71
+ 0..1, stalled 2, on target 3, integration paused 4).
72
+ Servo (Motor) columns: ``t_ms, time_ms, angle, speed, status, voltage_mv,
73
+ est_angle, est_speed, torque_fb, torque_ff, observer_mv``.
74
+ """
75
+
76
+ def start(self, duration: int, down_sample: int = 1) -> None:
77
+ """Allocate and start: ``duration`` ms, one row every
78
+ ``5 * down_sample`` ms. RAM = rows * 52 bytes (controller), so 30 s at
79
+ ``down_sample=2`` (100 Hz) is 156 KB; prefer 10 (20 Hz) for long runs.
80
+ """
81
+
82
+ def stop(self) -> None: ...
83
+
84
+ def save(self, path: Optional[str] = None) -> None:
85
+ """Upstream: print the rows framed with ``PB_OF:<path>`` / ``PB_EOF``,
86
+ no header. Kept unchanged."""
87
+
88
+ def publish(self, path: Optional[str] = None, names: Optional[str] = None) -> None:
89
+ """PeakHub: print the rows as CSV with a header line, framed with the
90
+ ``_file_begin_ <path>`` / ``_file_end_`` markers like
91
+ :meth:`RingBuffer.publish`. ``names`` overrides the header
92
+ (comma separated). Stops logging first."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scirodev
3
- Version: 0.4.1
3
+ Version: 0.5.2
4
4
  Summary: PeakHub / sciro extensions to Pybricks: typed API stubs (sciro.*) and hub tooling on top of pybricksdev
5
5
  Author: Thomas Schank
6
6
  License: MIT
@@ -16,6 +16,9 @@ Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: pybricksdev>=2.3
18
18
  Requires-Dist: pybricks>=3.6
19
+ Provides-Extra: docs
20
+ Requires-Dist: sphinx>=8; extra == "docs"
21
+ Requires-Dist: furo; extra == "docs"
19
22
  Dynamic: license-file
20
23
 
21
24
  # scirodev
@@ -43,6 +46,8 @@ from sciro.parameters import Port # Port.A .. Port.H (PeakHub has 8 por
43
46
  from sciro.iodevices import PUMPDevice # generic PUMP device access
44
47
  from sciro.pump import FloorPro # PUMP devices (LP-FloorPro, ...)
45
48
  from sciro.hubs import PeakHub # hub class incl. display.device()
49
+ from sciro.tools import RingBuffer # experimental: RAM recorder -> CSV file via the console
50
+ from sciro.robotics import PIDController # experimental: PID with optional logging
46
51
 
47
52
  hub = PeakHub()
48
53
  fp = FloorPro(Port.G)
@@ -50,6 +55,32 @@ cog_dark, cog_bright, brightness, darkness, mask, calibrating = fp.line.read()
50
55
  hub.display.device(fp, brightness=50) # mirror the sensor's LED strip on the 5x5
51
56
  ```
52
57
 
58
+ Experimental (API may change): a RAM recorder that publishes CSV through the
59
+ console, and a PID controller that can log into it:
60
+
61
+ ```python
62
+ from sciro.robotics import PIDController
63
+ from sciro.tools import RingBuffer
64
+
65
+ pid = PIDController(kp=15, kd=0.2, output_limit=300)
66
+ log = pid.make_log(seconds=10, rate=100) # RingBuffer of pid.LOG_FIELDS
67
+ turn_rate = pid.update(error, derivative=-hub.imu.angular_velocity(Axis.Z))
68
+ log.publish("doc/pid_run.csv") # scirodev run writes the file next to the script
69
+ ```
70
+
71
+ The drivebase's own controllers log too (built into Pybricks, undocumented
72
+ upstream): `db.heading_control.log.start(5000, down_sample=2)` records the
73
+ reference trajectory, the estimated state and the P/I/D terms at 100 Hz from the
74
+ firmware's control loop; `db.heading_control.log.publish("heading.csv")` writes
75
+ it with a header line. `sciro.robotics.DriveBase` is the Pybricks class with
76
+ these attributes typed. `log.record(db.state)` is the coroutine form for
77
+ anything else.
78
+
79
+ `publish(path)` wraps the CSV in pybricksdev's `_file_begin_ <path>` /
80
+ `_file_end_` lines; `scirodev run` (and `pybricksdev run`) then write the block
81
+ to that file, relative to the script's folder, instead of echoing it. Without a
82
+ path the CSV goes to the terminal.
83
+
53
84
  Full demo programs for the LP FloorPro (both for LEGO hubs and the PeakHub) live in
54
85
  [sciurus-robotics/FloorPro-CodeDemos](https://github.com/sciurus-robotics/FloorPro-CodeDemos);
55
86
  `examples/` here stays minimal.
@@ -61,6 +92,18 @@ pybricks.parameters.Port` — the stubs only add what the IDE is missing. The
61
92
  stub files here mirror the frozen modules; a release of this package matches
62
93
  the PeakHub firmware of the same date.
63
94
 
95
+ ## API reference (HTML)
96
+
97
+ The stubs double as the source of the API reference, built with Sphinx the way
98
+ docs.pybricks.com is (cross-links into it for the upstream types):
99
+
100
+ ```
101
+ pip install -e ".[docs]"
102
+ scripts/build-docs # -> docs/_build/html/index.html
103
+ ```
104
+
105
+ The build output is static HTML; a website can copy that folder as is.
106
+
64
107
  ## Releasing
65
108
 
66
109
  Bump `version` in `pyproject.toml`, commit, tag `vX.Y.Z` and push the tag. The
@@ -9,6 +9,8 @@ sciro/iodevices.py
9
9
  sciro/parameters.py
10
10
  sciro/pump.py
11
11
  sciro/py.typed
12
+ sciro/robotics.py
13
+ sciro/tools.py
12
14
  scirodev/__init__.py
13
15
  scirodev/cli.py
14
16
  scirodev.egg-info/PKG-INFO
@@ -1,2 +1,6 @@
1
1
  pybricksdev>=2.3
2
2
  pybricks>=3.6
3
+
4
+ [docs]
5
+ sphinx>=8
6
+ furo
@@ -1,41 +0,0 @@
1
- """sciro.parameters -- the Pybricks parameter types, with the PeakHub port set.
2
-
3
- On the hub this module re-exports ``pybricks.parameters`` unchanged (the firmware
4
- has always had ``Port.G`` and ``Port.H``); this stub exists because the upstream
5
- stubs declare ``Port.A`` .. ``Port.F`` only.
6
- """
7
-
8
- from pybricks.parameters import ( # noqa: F401 (re-exports)
9
- Axis as Axis,
10
- Button as Button,
11
- Color as Color,
12
- Direction as Direction,
13
- Icon as Icon,
14
- Side as Side,
15
- Stop as Stop,
16
- )
17
- from pybricks.parameters import _PybricksEnum
18
-
19
-
20
- class Port(_PybricksEnum):
21
- """Port on the PeakHub. Eight LPF2/PUP ports, A .. H."""
22
-
23
- A: Port = ord("A")
24
- B: Port = ord("B")
25
- C: Port = ord("C")
26
- D: Port = ord("D")
27
- E: Port = ord("E")
28
- F: Port = ord("F")
29
- G: Port = ord("G")
30
- H: Port = ord("H")
31
-
32
-
33
- class ExtPort:
34
- """Extension port of a PUMP device (1-based, as in its stream table)."""
35
-
36
- EXT1: int = 1
37
- EXT2: int = 2
38
- EXT3: int = 3
39
- EXT4: int = 4
40
- EXT5: int = 5
41
- EXT6: int = 6
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes