scirodev 0.4.0__tar.gz → 0.5.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: scirodev
3
- Version: 0.4.0
3
+ Version: 0.5.0
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
@@ -43,6 +43,8 @@ from sciro.parameters import Port # Port.A .. Port.H (PeakHub has 8 por
43
43
  from sciro.iodevices import PUMPDevice # generic PUMP device access
44
44
  from sciro.pump import FloorPro # PUMP devices (LP-FloorPro, ...)
45
45
  from sciro.hubs import PeakHub # hub class incl. display.device()
46
+ from sciro.tools import RingBuffer # experimental: RAM recorder -> CSV file via the console
47
+ from sciro.robotics import PIDController # experimental: PID with optional logging
46
48
 
47
49
  hub = PeakHub()
48
50
  fp = FloorPro(Port.G)
@@ -50,6 +52,32 @@ cog_dark, cog_bright, brightness, darkness, mask, calibrating = fp.line.read()
50
52
  hub.display.device(fp, brightness=50) # mirror the sensor's LED strip on the 5x5
51
53
  ```
52
54
 
55
+ Experimental (API may change): a RAM recorder that publishes CSV through the
56
+ console, and a PID controller that can log into it:
57
+
58
+ ```python
59
+ from sciro.robotics import PIDController
60
+ from sciro.tools import RingBuffer
61
+
62
+ pid = PIDController(kp=15, kd=0.2, output_limit=300)
63
+ log = pid.make_log(seconds=10, rate=100) # RingBuffer of pid.LOG_FIELDS
64
+ turn_rate = pid.update(error, derivative=-hub.imu.angular_velocity(Axis.Z))
65
+ log.publish("doc/pid_run.csv") # scirodev run writes the file next to the script
66
+ ```
67
+
68
+ The drivebase's own controllers log too (built into Pybricks, undocumented
69
+ upstream): `db.heading_control.log.start(5000, down_sample=2)` records the
70
+ reference trajectory, the estimated state and the P/I/D terms at 100 Hz from the
71
+ firmware's control loop; `db.heading_control.log.publish("heading.csv")` writes
72
+ it with a header line. `sciro.robotics.DriveBase` is the Pybricks class with
73
+ these attributes typed. `log.record(db.state)` is the coroutine form for
74
+ anything else.
75
+
76
+ `publish(path)` wraps the CSV in pybricksdev's `_file_begin_ <path>` /
77
+ `_file_end_` lines; `scirodev run` (and `pybricksdev run`) then write the block
78
+ to that file, relative to the script's folder, instead of echoing it. Without a
79
+ path the CSV goes to the terminal.
80
+
53
81
  Full demo programs for the LP FloorPro (both for LEGO hubs and the PeakHub) live in
54
82
  [sciurus-robotics/FloorPro-CodeDemos](https://github.com/sciurus-robotics/FloorPro-CodeDemos);
55
83
  `examples/` here stays minimal.
@@ -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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scirodev"
7
- version = "0.4.0"
7
+ version = "0.5.0"
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"
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
18
18
  _Euler = Tuple[float, float, float, int]
19
19
  _StateData = Tuple[bytes, bytes]
20
20
  _RGB = Tuple[int, int, int]
21
+ _Calibration = Tuple[Tuple[int, int, int, int], Tuple[int, int, int, int], Tuple[int, int, int], bool, bool, int]
21
22
  _Pixels = Tuple[_RGB, ...]
22
23
 
23
24
  class MaybeAwaitableLine(_Line, Awaitable[_Line]): ...
@@ -33,3 +34,9 @@ if TYPE_CHECKING:
33
34
  class MaybeAwaitableStateData(_StateData, Awaitable[_StateData]): ...
34
35
 
35
36
  class MaybeAwaitablePixels(_Pixels, Awaitable[_Pixels]): ...
37
+
38
+ class MaybeAwaitableRGB8(_RGB, Awaitable[_RGB]): ...
39
+
40
+ class MaybeAwaitableCalibration(_Calibration, Awaitable[_Calibration]): ...
41
+
42
+ class MaybeAwaitableStr(str, Awaitable[str]): ...
@@ -45,6 +45,34 @@ class LightMatrix(_common.LightMatrix):
45
45
  """
46
46
 
47
47
 
48
+ class System(_common.System):
49
+ """The PeakHub's system object: everything ``pybricks`` offers, plus the
50
+ board identity.
51
+ """
52
+
53
+ def device_id(self) -> str:
54
+ """device_id() -> str
55
+
56
+ The full factory unique ID of the hub's MCU as a 24-character uppercase
57
+ hex string. Stable per physical hub.
58
+ """
59
+
60
+ def short_id(self) -> str:
61
+ """short_id() -> str
62
+
63
+ The hub's 8-character short ID (Crockford base32, derived from the
64
+ device ID): the same value the boot console prints and the device
65
+ inventory uses.
66
+ """
67
+
68
+ def info(self) -> dict:
69
+ """info() -> dict
70
+
71
+ ``{"name", "device_id", "short_id", "reset_reason", "program_id",
72
+ "program_start_type"}`` in one call.
73
+ """
74
+
75
+
48
76
  class PeakHub:
49
77
  """LEGO-compatible hub by Sciurus Robotics: 8 ports, 5x5 RGB matrix, IMU."""
50
78
 
@@ -56,7 +84,7 @@ class PeakHub:
56
84
  display = LightMatrix(5, 5)
57
85
  imu = _common.IMU()
58
86
  speaker = _common.Speaker()
59
- system = _common.System()
87
+ system = System()
60
88
  ble = _common.BLE()
61
89
 
62
90
  def __init__(
@@ -19,6 +19,9 @@ if TYPE_CHECKING:
19
19
  MaybeAwaitableIRCalib,
20
20
  MaybeAwaitableLine,
21
21
  MaybeAwaitablePixels,
22
+ MaybeAwaitableRGB8,
23
+ MaybeAwaitableCalibration,
24
+ MaybeAwaitableStr,
22
25
  MaybeAwaitableRGBC,
23
26
  )
24
27
 
@@ -122,12 +125,48 @@ class ColorSensor(_Stream):
122
125
  def read(self) -> MaybeAwaitableRGBC:
123
126
  """read() -> Tuple[int, int, int, int, int] -- raw (red, green, blue, clear, status) at device resolution."""
124
127
 
128
+ def calibrated(self) -> MaybeAwaitableRGB8:
129
+ """calibrated() -> Tuple[int, int, int]
130
+
131
+ Red, green, blue 0 .. 255 as the device itself shows them: stretched
132
+ over the calibrated range when a valid calibration applies, scaled to
133
+ full scale otherwise.
134
+ """
135
+
125
136
  def hsv(self) -> MaybeAwaitableColor:
126
137
  """hsv() -> Color
127
138
 
128
139
  Hue (0 .. 359), saturation (0 .. 100) and value (0 .. 100) of the
129
- surface, as a ``Color``. Standard HSV of the raw reading; value is
130
- relative to the full scale of the current integration time.
140
+ surface, as a ``Color``: standard HSV of the calibrated colour when a
141
+ valid calibration applies, of the raw reading otherwise.
142
+ """
143
+
144
+ def calibration_status(self) -> MaybeAwaitableStr:
145
+ """calibration_status() -> str
146
+
147
+ ``"none"`` (nothing stored), ``"weak"`` (stored but not applicable:
148
+ incomplete, or LED / gain / integration differ from the calibration
149
+ profile), ``"ok"`` (applied), or ``"calibrating"``.
150
+ """
151
+
152
+ def calibrate(self, enable: bool = True) -> MaybeAwaitable:
153
+ """calibrate(enable=True)
154
+
155
+ Start or stop the range calibration on the device. While it runs, move
156
+ the sensor over the darkest and brightest surfaces (or elements) it
157
+ will see; stopping stores the table on the sensor, bound to the LED,
158
+ gain and integration time in force. Changing those meanwhile aborts it.
159
+ """
160
+
161
+ def clear_calibration(self) -> MaybeAwaitable:
162
+ """clear_calibration() -- drop the stored calibration (back to full-scale colour)."""
163
+
164
+ def calibration(self) -> MaybeAwaitableCalibration:
165
+ """calibration() -> Tuple
166
+
167
+ The stored table: ``(min, max, (led, gain, atime), valid, applicable,
168
+ visited_mask)`` with ``min``/``max`` tuples of four counts (red, green,
169
+ blue, clear). Fetched from the device on demand.
131
170
  """
132
171
 
133
172
  def color(self) -> MaybeAwaitableColor:
@@ -0,0 +1,84 @@
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 Optional, Tuple
8
+
9
+ from pybricks import _common
10
+ from pybricks.robotics import DriveBase as _DriveBase
11
+ from pybricks.tools import StopWatch
12
+
13
+ from sciro.tools import Logger, RingBuffer
14
+
15
+
16
+ class Control(_common.Control):
17
+ """A drivebase / motor controller, with its built-in logger exposed."""
18
+
19
+ log: Logger
20
+
21
+
22
+ class DriveBase(_DriveBase):
23
+ """``pybricks.robotics.DriveBase`` (the same class on the hub); the stub
24
+ adds the ``log`` on ``heading_control`` / ``distance_control``."""
25
+
26
+ heading_control: Control # type: ignore[assignment]
27
+ distance_control: Control # type: ignore[assignment]
28
+
29
+
30
+ class PIDController:
31
+ """Discrete PID controller: ``output = kp*e + ki*integral(e) + kd*de/dt``.
32
+
33
+ ``ki = kd = 0`` gives a plain P or PD controller. ``integral_limit`` clamps
34
+ the I term (anti-windup), ``output_limit`` the total output; ``None`` means
35
+ unlimited. A gap between updates longer than ``reset_after`` milliseconds
36
+ restarts the controller (a new movement); ``None`` never. ``stopwatch``
37
+ is the time source (default: an own ``StopWatch``); ``log`` a
38
+ :class:`RingBuffer` with :attr:`LOG_FIELDS` (see :meth:`make_log`).
39
+ """
40
+
41
+ LOG_FIELDS: Tuple[Tuple[str, str], ...]
42
+ """``t_ms, dt, error, p, i, d, output`` -- the row layout of the log."""
43
+
44
+ kp: float
45
+ ki: float
46
+ kd: float
47
+ integral_limit: Optional[float]
48
+ output_limit: Optional[float]
49
+ reset_after: Optional[int]
50
+ stopwatch: StopWatch
51
+ log: Optional[RingBuffer]
52
+ name: str
53
+ output: float
54
+ """The last output."""
55
+ integral: float
56
+
57
+ def __init__(
58
+ self,
59
+ kp: float,
60
+ ki: float = 0.0,
61
+ kd: float = 0.0,
62
+ integral_limit: Optional[float] = None,
63
+ output_limit: Optional[float] = None,
64
+ reset_after: Optional[int] = 100,
65
+ stopwatch: Optional[StopWatch] = None,
66
+ log: Optional[RingBuffer] = None,
67
+ name: str = "PID",
68
+ ) -> None: ...
69
+
70
+ def make_log(self, seconds: float, rate: int = 100) -> RingBuffer:
71
+ """Attach and return a RingBuffer for ``seconds`` of updates at ``rate`` Hz."""
72
+
73
+ def reset(self) -> None:
74
+ """Forget the integral, the last error and the last update time."""
75
+
76
+ def update(self, error: float, derivative: Optional[float] = None, p_limit: Optional[float] = None) -> float:
77
+ """One controller step; returns the output.
78
+
79
+ ``error`` is setpoint minus measurement. ``derivative`` is d(error)/dt
80
+ when measured (e.g. minus the gyro rate for a heading error); ``None``
81
+ differentiates the error numerically. ``p_limit`` clamps the P term for
82
+ this step only, e.g. a braking profile that leaves the D term free to
83
+ damp.
84
+ """
@@ -0,0 +1,91 @@
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 in
69
+ the controller's units: mm or deg; torque in uNm; status bits: actuation
70
+ 0..1, stalled 2, on target 3, integration paused 4).
71
+ Servo (Motor) columns: ``t_ms, time_ms, angle, speed, status, voltage_mv,
72
+ est_angle, est_speed, torque_fb, torque_ff, observer_mv``.
73
+ """
74
+
75
+ def start(self, duration: int, down_sample: int = 1) -> None:
76
+ """Allocate and start: ``duration`` ms, one row every
77
+ ``5 * down_sample`` ms. RAM = rows * 52 bytes (controller), so 30 s at
78
+ ``down_sample=2`` (100 Hz) is 156 KB; prefer 10 (20 Hz) for long runs.
79
+ """
80
+
81
+ def stop(self) -> None: ...
82
+
83
+ def save(self, path: Optional[str] = None) -> None:
84
+ """Upstream: print the rows framed with ``PB_OF:<path>`` / ``PB_EOF``,
85
+ no header. Kept unchanged."""
86
+
87
+ def publish(self, path: Optional[str] = None, names: Optional[str] = None) -> None:
88
+ """PeakHub: print the rows as CSV with a header line, framed with the
89
+ ``_file_begin_ <path>`` / ``_file_end_`` markers like
90
+ :meth:`RingBuffer.publish`. ``names`` overrides the header
91
+ (comma separated). Stops logging first."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scirodev
3
- Version: 0.4.0
3
+ Version: 0.5.0
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
@@ -43,6 +43,8 @@ from sciro.parameters import Port # Port.A .. Port.H (PeakHub has 8 por
43
43
  from sciro.iodevices import PUMPDevice # generic PUMP device access
44
44
  from sciro.pump import FloorPro # PUMP devices (LP-FloorPro, ...)
45
45
  from sciro.hubs import PeakHub # hub class incl. display.device()
46
+ from sciro.tools import RingBuffer # experimental: RAM recorder -> CSV file via the console
47
+ from sciro.robotics import PIDController # experimental: PID with optional logging
46
48
 
47
49
  hub = PeakHub()
48
50
  fp = FloorPro(Port.G)
@@ -50,6 +52,32 @@ cog_dark, cog_bright, brightness, darkness, mask, calibrating = fp.line.read()
50
52
  hub.display.device(fp, brightness=50) # mirror the sensor's LED strip on the 5x5
51
53
  ```
52
54
 
55
+ Experimental (API may change): a RAM recorder that publishes CSV through the
56
+ console, and a PID controller that can log into it:
57
+
58
+ ```python
59
+ from sciro.robotics import PIDController
60
+ from sciro.tools import RingBuffer
61
+
62
+ pid = PIDController(kp=15, kd=0.2, output_limit=300)
63
+ log = pid.make_log(seconds=10, rate=100) # RingBuffer of pid.LOG_FIELDS
64
+ turn_rate = pid.update(error, derivative=-hub.imu.angular_velocity(Axis.Z))
65
+ log.publish("doc/pid_run.csv") # scirodev run writes the file next to the script
66
+ ```
67
+
68
+ The drivebase's own controllers log too (built into Pybricks, undocumented
69
+ upstream): `db.heading_control.log.start(5000, down_sample=2)` records the
70
+ reference trajectory, the estimated state and the P/I/D terms at 100 Hz from the
71
+ firmware's control loop; `db.heading_control.log.publish("heading.csv")` writes
72
+ it with a header line. `sciro.robotics.DriveBase` is the Pybricks class with
73
+ these attributes typed. `log.record(db.state)` is the coroutine form for
74
+ anything else.
75
+
76
+ `publish(path)` wraps the CSV in pybricksdev's `_file_begin_ <path>` /
77
+ `_file_end_` lines; `scirodev run` (and `pybricksdev run`) then write the block
78
+ to that file, relative to the script's folder, instead of echoing it. Without a
79
+ path the CSV goes to the terminal.
80
+
53
81
  Full demo programs for the LP FloorPro (both for LEGO hubs and the PeakHub) live in
54
82
  [sciurus-robotics/FloorPro-CodeDemos](https://github.com/sciurus-robotics/FloorPro-CodeDemos);
55
83
  `examples/` here stays minimal.
@@ -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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes