grafito-canstepper 0.1.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 (31) hide show
  1. grafito_canstepper-0.1.0/PKG-INFO +122 -0
  2. grafito_canstepper-0.1.0/README.md +94 -0
  3. grafito_canstepper-0.1.0/canstepper/__init__.py +95 -0
  4. grafito_canstepper-0.1.0/canstepper/axis.py +198 -0
  5. grafito_canstepper-0.1.0/canstepper/bus.py +367 -0
  6. grafito_canstepper-0.1.0/canstepper/config.py +240 -0
  7. grafito_canstepper-0.1.0/canstepper/exceptions.py +60 -0
  8. grafito_canstepper-0.1.0/canstepper/group.py +113 -0
  9. grafito_canstepper-0.1.0/canstepper/kinematics.py +428 -0
  10. grafito_canstepper-0.1.0/canstepper/node.py +626 -0
  11. grafito_canstepper-0.1.0/canstepper/protocol.py +317 -0
  12. grafito_canstepper-0.1.0/canstepper/py.typed +0 -0
  13. grafito_canstepper-0.1.0/canstepper/sim.py +363 -0
  14. grafito_canstepper-0.1.0/canstepper/telemetry.py +190 -0
  15. grafito_canstepper-0.1.0/canstepper/transport/__init__.py +4 -0
  16. grafito_canstepper-0.1.0/canstepper/transport/base.py +43 -0
  17. grafito_canstepper-0.1.0/canstepper/transport/serial_bridge.py +81 -0
  18. grafito_canstepper-0.1.0/grafito_canstepper.egg-info/PKG-INFO +122 -0
  19. grafito_canstepper-0.1.0/grafito_canstepper.egg-info/SOURCES.txt +29 -0
  20. grafito_canstepper-0.1.0/grafito_canstepper.egg-info/dependency_links.txt +1 -0
  21. grafito_canstepper-0.1.0/grafito_canstepper.egg-info/requires.txt +14 -0
  22. grafito_canstepper-0.1.0/grafito_canstepper.egg-info/top_level.txt +1 -0
  23. grafito_canstepper-0.1.0/pyproject.toml +44 -0
  24. grafito_canstepper-0.1.0/setup.cfg +4 -0
  25. grafito_canstepper-0.1.0/setup.py +19 -0
  26. grafito_canstepper-0.1.0/tests/test_axis.py +51 -0
  27. grafito_canstepper-0.1.0/tests/test_bus_node.py +153 -0
  28. grafito_canstepper-0.1.0/tests/test_config.py +90 -0
  29. grafito_canstepper-0.1.0/tests/test_group.py +37 -0
  30. grafito_canstepper-0.1.0/tests/test_kinematics.py +121 -0
  31. grafito_canstepper-0.1.0/tests/test_protocol.py +72 -0
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: grafito-canstepper
3
+ Version: 0.1.0
4
+ Summary: Host library for Grafito CANStepper boards (GCSP v1): multi-node CAN stepper control with encoders, homing, groups and coordinated motion
5
+ Author-email: Grafito Innovations <grafito.innovations@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Documentation, https://docs.grafito.in
8
+ Project-URL: Homepage, https://docs.grafito.in
9
+ Project-URL: Source, https://github.com/Grafito-Innovations/Grafito-Edge-Services/tree/main/can_stepper
10
+ Project-URL: Issues, https://github.com/Grafito-Innovations/Grafito-Edge-Services/issues
11
+ Keywords: stepper,CAN,motion-control,esp32,tmc2209,encoder
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Manufacturing
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: System :: Hardware :: Hardware Drivers
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: pyserial>=3.4
20
+ Provides-Extra: toml
21
+ Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "toml"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: mypy; extra == "dev"
26
+ Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "dev"
27
+ Dynamic: requires-python
28
+
29
+ # Grafito CANStepper
30
+
31
+ Closed-loop stepper motor control over CAN. Each board carries an ESP32-C3,
32
+ a TMC2209 driver, an MT6701 14-bit magnetic encoder and a CAN transceiver;
33
+ up to 31 boards daisy-chain on one 1 Mbps bus and are driven from Python
34
+ through the USB port of any board on the chain.
35
+
36
+ ```
37
+ can_stepper/
38
+ ├── firmware/GrafitoCANStepper_C3/ # node firmware (GCSP v1)
39
+ ├── canstepper/ # Python package (grafito-canstepper)
40
+ ├── examples/ # runnable examples + machine.toml
41
+ ├── tests/ # pytest suite (runs on a software sim)
42
+ └── docs/
43
+ ├── PRD.md # product requirements
44
+ ├── protocol.md # GCSP v1 wire protocol reference
45
+ └── quickstart.md # unboxing -> moving motor
46
+ ```
47
+
48
+ ## Highlights
49
+
50
+ - **Layered Python API** — raw frames → `StepperNode` → `NodeGroup` →
51
+ `Axis` (mm units via `rotation_distance`) → `DualMotorAxis` / `CoreXY` /
52
+ `MotionGroup` → declarative `machine.toml`.
53
+ - **Safety scopes** — `node.estop()`, `group.estop()`, `bus.estop_all()`
54
+ (single broadcast frame); e-stop is latched until re-enabled.
55
+ - **Homing** — physical endstop on IO8, sensorless (StallGuard), or
56
+ set-zero; configurable current, backoff and timeout.
57
+ - **Closed loop** — firmware ≥1.2 plans a rest-to-rest **trapezoidal**
58
+ trajectory with **velocity feedforward** and a light tracking PID on the
59
+ MT6701 encoder (settle + stall watchdog). All motion limits are
60
+ *configurable defaults*, never hard clamps.
61
+ - **On-bus leader/follower** — dual-motor gantries stay coupled with no
62
+ host in the loop.
63
+ - **Simulator included** — `canstepper.sim.SimNetwork` implements the whole
64
+ protocol in software; the test suite and the examples run without hardware.
65
+
66
+ ## Install & first spin
67
+
68
+ The package is **`grafito-canstepper`** on the index; you `import canstepper`.
69
+
70
+ ```bash
71
+ # Works today (GitHub monorepo subdirectory — not yet on PyPI):
72
+ pip install "git+https://github.com/Grafito-Innovations/Grafito-Edge-Services.git#subdirectory=can_stepper"
73
+
74
+ # Development (from this directory):
75
+ pip install -U pip setuptools && pip install -e ".[dev]"
76
+
77
+ # After publishing to PyPI:
78
+ # pip install grafito-canstepper
79
+ ```
80
+
81
+ Docs: **https://docs.grafito.in**
82
+
83
+ ```python
84
+ from canstepper import CANStepperBus
85
+
86
+ with CANStepperBus.serial("/dev/ttyACM0") as bus:
87
+ print(bus.discover())
88
+ node = bus.node(1)
89
+ node.set_run_current(40).enable()
90
+ node.move_to(360.0, blocking=True)
91
+ ```
92
+
93
+ Continue with:
94
+
95
+ - [docs/quickstart.md](docs/quickstart.md) — first motion
96
+ - [docs/closed_loop_tuning.md](docs/closed_loop_tuning.md) — trapezoid + v_ff,
97
+ hardware matrix + 10 min soak on **PR42HS40-1204AF-02** (NEMA 17 1.2 A)
98
+ - [docs/protocol.md](docs/protocol.md) — GCSP v1 wire protocol
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ cd can_stepper
104
+ python3 -m pytest # simulator suite
105
+ python3 tools/cl_speed_validate.py /dev/ttyACM0 1 70 8 # hardware OL vs CL
106
+ ```
107
+
108
+ Firmware builds with Arduino IDE or `arduino-cli` (ESP32C3 Dev Module, USB
109
+ CDC On Boot = Enabled; libraries: FastAccelStepper, TMC2209 janelia-arduino):
110
+
111
+ ```bash
112
+ arduino-cli compile --fqbn esp32:esp32:esp32c3:CDCOnBoot=cdc \
113
+ firmware/GrafitoCANStepper_C3
114
+ arduino-cli upload -p /dev/ttyACM0 --fqbn esp32:esp32:esp32c3:CDCOnBoot=cdc \
115
+ firmware/GrafitoCANStepper_C3
116
+ ```
117
+
118
+ ## Legacy note
119
+
120
+ `CANStepperNode_C3/` and `canbus.py` are the earlier bring-up firmware and
121
+ host script based on a third-party protocol. They are superseded by the
122
+ clean-room GCSP v1 stack above and kept only for reference during migration.
@@ -0,0 +1,94 @@
1
+ # Grafito CANStepper
2
+
3
+ Closed-loop stepper motor control over CAN. Each board carries an ESP32-C3,
4
+ a TMC2209 driver, an MT6701 14-bit magnetic encoder and a CAN transceiver;
5
+ up to 31 boards daisy-chain on one 1 Mbps bus and are driven from Python
6
+ through the USB port of any board on the chain.
7
+
8
+ ```
9
+ can_stepper/
10
+ ├── firmware/GrafitoCANStepper_C3/ # node firmware (GCSP v1)
11
+ ├── canstepper/ # Python package (grafito-canstepper)
12
+ ├── examples/ # runnable examples + machine.toml
13
+ ├── tests/ # pytest suite (runs on a software sim)
14
+ └── docs/
15
+ ├── PRD.md # product requirements
16
+ ├── protocol.md # GCSP v1 wire protocol reference
17
+ └── quickstart.md # unboxing -> moving motor
18
+ ```
19
+
20
+ ## Highlights
21
+
22
+ - **Layered Python API** — raw frames → `StepperNode` → `NodeGroup` →
23
+ `Axis` (mm units via `rotation_distance`) → `DualMotorAxis` / `CoreXY` /
24
+ `MotionGroup` → declarative `machine.toml`.
25
+ - **Safety scopes** — `node.estop()`, `group.estop()`, `bus.estop_all()`
26
+ (single broadcast frame); e-stop is latched until re-enabled.
27
+ - **Homing** — physical endstop on IO8, sensorless (StallGuard), or
28
+ set-zero; configurable current, backoff and timeout.
29
+ - **Closed loop** — firmware ≥1.2 plans a rest-to-rest **trapezoidal**
30
+ trajectory with **velocity feedforward** and a light tracking PID on the
31
+ MT6701 encoder (settle + stall watchdog). All motion limits are
32
+ *configurable defaults*, never hard clamps.
33
+ - **On-bus leader/follower** — dual-motor gantries stay coupled with no
34
+ host in the loop.
35
+ - **Simulator included** — `canstepper.sim.SimNetwork` implements the whole
36
+ protocol in software; the test suite and the examples run without hardware.
37
+
38
+ ## Install & first spin
39
+
40
+ The package is **`grafito-canstepper`** on the index; you `import canstepper`.
41
+
42
+ ```bash
43
+ # Works today (GitHub monorepo subdirectory — not yet on PyPI):
44
+ pip install "git+https://github.com/Grafito-Innovations/Grafito-Edge-Services.git#subdirectory=can_stepper"
45
+
46
+ # Development (from this directory):
47
+ pip install -U pip setuptools && pip install -e ".[dev]"
48
+
49
+ # After publishing to PyPI:
50
+ # pip install grafito-canstepper
51
+ ```
52
+
53
+ Docs: **https://docs.grafito.in**
54
+
55
+ ```python
56
+ from canstepper import CANStepperBus
57
+
58
+ with CANStepperBus.serial("/dev/ttyACM0") as bus:
59
+ print(bus.discover())
60
+ node = bus.node(1)
61
+ node.set_run_current(40).enable()
62
+ node.move_to(360.0, blocking=True)
63
+ ```
64
+
65
+ Continue with:
66
+
67
+ - [docs/quickstart.md](docs/quickstart.md) — first motion
68
+ - [docs/closed_loop_tuning.md](docs/closed_loop_tuning.md) — trapezoid + v_ff,
69
+ hardware matrix + 10 min soak on **PR42HS40-1204AF-02** (NEMA 17 1.2 A)
70
+ - [docs/protocol.md](docs/protocol.md) — GCSP v1 wire protocol
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ cd can_stepper
76
+ python3 -m pytest # simulator suite
77
+ python3 tools/cl_speed_validate.py /dev/ttyACM0 1 70 8 # hardware OL vs CL
78
+ ```
79
+
80
+ Firmware builds with Arduino IDE or `arduino-cli` (ESP32C3 Dev Module, USB
81
+ CDC On Boot = Enabled; libraries: FastAccelStepper, TMC2209 janelia-arduino):
82
+
83
+ ```bash
84
+ arduino-cli compile --fqbn esp32:esp32:esp32c3:CDCOnBoot=cdc \
85
+ firmware/GrafitoCANStepper_C3
86
+ arduino-cli upload -p /dev/ttyACM0 --fqbn esp32:esp32:esp32c3:CDCOnBoot=cdc \
87
+ firmware/GrafitoCANStepper_C3
88
+ ```
89
+
90
+ ## Legacy note
91
+
92
+ `CANStepperNode_C3/` and `canbus.py` are the earlier bring-up firmware and
93
+ host script based on a third-party protocol. They are superseded by the
94
+ clean-room GCSP v1 stack above and kept only for reference during migration.
@@ -0,0 +1,95 @@
1
+ """grafito-canstepper — host library for Grafito CANStepper boards (GCSP v1).
2
+
3
+ Quick start::
4
+
5
+ from canstepper import CANStepperBus
6
+
7
+ bus = CANStepperBus.serial("/dev/ttyACM0")
8
+ print(bus.discover())
9
+
10
+ node = bus.node(1)
11
+ node.set_run_current(40).set_microsteps(16).enable()
12
+ node.move_to(180.0, blocking=True)
13
+
14
+ bus.estop_all() # single broadcast frame stops every node
15
+ """
16
+
17
+ from .axis import Axis
18
+ from .bus import CANStepperBus
19
+ from .config import Machine
20
+ from .exceptions import (
21
+ CANStepperError,
22
+ ConfigError,
23
+ EStopActive,
24
+ HomingFailed,
25
+ LimitViolation,
26
+ NodeFault,
27
+ NotHomed,
28
+ ParamRejected,
29
+ RequestTimeout,
30
+ TransportError,
31
+ UnknownParam,
32
+ )
33
+ from .group import NodeGroup
34
+ from .kinematics import CoreXY, DualMotorAxis, MotionGroup
35
+ from .node import SpeedLimits, StepperNode
36
+ from .protocol import (
37
+ BROADCAST,
38
+ PROTOCOL_VERSION,
39
+ Cmd,
40
+ EndstopAction,
41
+ Event,
42
+ Fault,
43
+ Frame,
44
+ HomeMethod,
45
+ Mode,
46
+ Param,
47
+ StandstillMode,
48
+ Tel,
49
+ )
50
+ from .telemetry import CanHealth, FollowStatus, NodeState, NodeStatus, PidStatus
51
+ from .transport import SerialBridgeTransport, Transport
52
+
53
+ __version__ = "0.1.0"
54
+
55
+ __all__ = [
56
+ "Axis",
57
+ "BROADCAST",
58
+ "CANStepperBus",
59
+ "CANStepperError",
60
+ "CanHealth",
61
+ "Cmd",
62
+ "ConfigError",
63
+ "CoreXY",
64
+ "DualMotorAxis",
65
+ "EndstopAction",
66
+ "EStopActive",
67
+ "Event",
68
+ "Fault",
69
+ "FollowStatus",
70
+ "Frame",
71
+ "HomeMethod",
72
+ "HomingFailed",
73
+ "LimitViolation",
74
+ "Machine",
75
+ "Mode",
76
+ "MotionGroup",
77
+ "NodeFault",
78
+ "NodeGroup",
79
+ "NodeState",
80
+ "NodeStatus",
81
+ "NotHomed",
82
+ "Param",
83
+ "ParamRejected",
84
+ "PidStatus",
85
+ "PROTOCOL_VERSION",
86
+ "RequestTimeout",
87
+ "SerialBridgeTransport",
88
+ "SpeedLimits",
89
+ "StandstillMode",
90
+ "StepperNode",
91
+ "Tel",
92
+ "Transport",
93
+ "TransportError",
94
+ "UnknownParam",
95
+ ]
@@ -0,0 +1,198 @@
1
+ """Axis — a stepper node dressed in real-world linear units.
2
+
3
+ Klipper-style ``rotation_distance``: the distance the axis travels for one
4
+ full motor revolution (e.g. an 8 mm-lead leadscrew -> 8.0, a GT2-20T belt
5
+ -> 40.0). All Axis positions/speeds are in those units ("mm" below).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional, Union
11
+
12
+ from .exceptions import LimitViolation, NotHomed
13
+ from .node import StepperNode
14
+ from .protocol import HomeMethod, Param
15
+
16
+
17
+ class Axis:
18
+ """Linear (or geared rotary) axis on one node.
19
+
20
+ Args:
21
+ node: the StepperNode driving the axis
22
+ rotation_distance: units of travel per motor revolution
23
+ gear_ratio: motor revs per output rev (e.g. 5.0 for a 5:1 box)
24
+ min_pos / max_pos: optional host-side soft travel limits (units)
25
+ max_speed: optional host-side speed limit (units/s)
26
+ require_homing: refuse moves until the axis was homed
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ node: StepperNode,
32
+ rotation_distance: float,
33
+ gear_ratio: float = 1.0,
34
+ min_pos: Optional[float] = None,
35
+ max_pos: Optional[float] = None,
36
+ max_speed: Optional[float] = None,
37
+ require_homing: bool = False,
38
+ name: str = "",
39
+ ):
40
+ if rotation_distance <= 0:
41
+ raise ValueError("rotation_distance must be positive")
42
+ if gear_ratio <= 0:
43
+ raise ValueError("gear_ratio must be positive")
44
+ self.node = node
45
+ self.rotation_distance = float(rotation_distance)
46
+ self.gear_ratio = float(gear_ratio)
47
+ self.min_pos = min_pos
48
+ self.max_pos = max_pos
49
+ self.max_speed = max_speed
50
+ self.require_homing = require_homing
51
+ self.name = name or f"axis{node.node_id}"
52
+ self._homed = False
53
+
54
+ # -- unit conversion ---------------------------------------------------------
55
+
56
+ def units_to_deg(self, units: float) -> float:
57
+ return units / self.rotation_distance * 360.0 * self.gear_ratio
58
+
59
+ def deg_to_units(self, deg: float) -> float:
60
+ return deg / 360.0 / self.gear_ratio * self.rotation_distance
61
+
62
+ # -- state ---------------------------------------------------------------------
63
+
64
+ @property
65
+ def position(self) -> Optional[float]:
66
+ """Last-known position in axis units (cached telemetry)."""
67
+ deg = self.node.state.position_deg
68
+ return None if deg is None else self.deg_to_units(deg)
69
+
70
+ def get_position(self) -> float:
71
+ """Round-trip read of the position in axis units."""
72
+ return self.deg_to_units(self.node.get_position())
73
+
74
+ @property
75
+ def homed(self) -> bool:
76
+ status = self.node.state.status
77
+ if status is not None:
78
+ return status.homed or self._homed
79
+ return self._homed
80
+
81
+ # -- motion ---------------------------------------------------------------------
82
+
83
+ def move_to(
84
+ self,
85
+ pos: float,
86
+ speed: Optional[float] = None,
87
+ blocking: bool = False,
88
+ timeout: float = 60.0,
89
+ ) -> "Axis":
90
+ """Absolute move in axis units; ``speed`` (units/s) sets the node's
91
+ move speed for this and subsequent moves."""
92
+ self._check_motion_allowed(pos, speed)
93
+ if speed is not None:
94
+ self.node.set_max_speed(self.units_to_deg(speed))
95
+ self.node.move_to(self.units_to_deg(pos), blocking=blocking, timeout=timeout)
96
+ return self
97
+
98
+ def move_by(
99
+ self,
100
+ delta: float,
101
+ speed: Optional[float] = None,
102
+ blocking: bool = False,
103
+ timeout: float = 60.0,
104
+ ) -> "Axis":
105
+ target = None
106
+ if self.min_pos is not None or self.max_pos is not None:
107
+ current = self.position
108
+ if current is not None:
109
+ target = current + delta
110
+ self._check_motion_allowed(target, speed)
111
+ if speed is not None:
112
+ self.node.set_max_speed(self.units_to_deg(speed))
113
+ self.node.move_by(self.units_to_deg(delta), blocking=blocking, timeout=timeout)
114
+ return self
115
+
116
+ def run(self, speed: float) -> "Axis":
117
+ """Continuous motion at signed ``speed`` (units/s)."""
118
+ if self.max_speed is not None and abs(speed) > self.max_speed:
119
+ raise LimitViolation(
120
+ f"{self.name}: {abs(speed):.3f} exceeds max_speed {self.max_speed:.3f}"
121
+ )
122
+ self.node.run(self.units_to_deg(speed))
123
+ return self
124
+
125
+ def stop(self) -> "Axis":
126
+ self.node.stop()
127
+ return self
128
+
129
+ def estop(self) -> "Axis":
130
+ self.node.estop()
131
+ return self
132
+
133
+ # -- homing ---------------------------------------------------------------------
134
+
135
+ def home(
136
+ self,
137
+ method: Union[str, HomeMethod] = HomeMethod.ENDSTOP,
138
+ direction: int = -1,
139
+ speed: float = 10.0,
140
+ current_percent: Optional[int] = None,
141
+ backoff: Optional[float] = None,
142
+ timeout: float = 60.0,
143
+ ) -> "Axis":
144
+ """Home the axis. ``speed`` and ``backoff`` are in axis units.
145
+
146
+ For ``"stallguard"`` homing, tune ``node.set_stall_threshold()`` and
147
+ use a reduced ``current_percent`` so the crash into the hard stop
148
+ stays gentle.
149
+ """
150
+ if current_percent is not None:
151
+ self.node.set_param(Param.HOMING_CURRENT, current_percent)
152
+ if backoff is not None:
153
+ self.node.set_param(Param.HOMING_BACKOFF, self.units_to_deg(backoff))
154
+ self.node.home(
155
+ method=method,
156
+ direction=direction,
157
+ speed_deg_s=self.units_to_deg(speed),
158
+ blocking=True,
159
+ timeout=timeout,
160
+ )
161
+ self._homed = True
162
+ return self
163
+
164
+ def set_zero(self) -> "Axis":
165
+ self.node.set_zero()
166
+ self._homed = True
167
+ return self
168
+
169
+ def wait_settled(self, timeout: float = 30.0) -> "Axis":
170
+ self.node.wait_settled(timeout=timeout)
171
+ return self
172
+
173
+ # -- internals ---------------------------------------------------------------------
174
+
175
+ def _check_motion_allowed(
176
+ self, target: Optional[float], speed: Optional[float]
177
+ ) -> None:
178
+ if self.require_homing and not self.homed:
179
+ raise NotHomed(f"{self.name}: home the axis before moving it")
180
+ if speed is not None and self.max_speed is not None and abs(speed) > self.max_speed:
181
+ raise LimitViolation(
182
+ f"{self.name}: {abs(speed):.3f} exceeds max_speed {self.max_speed:.3f}"
183
+ )
184
+ if target is not None:
185
+ if self.min_pos is not None and target < self.min_pos:
186
+ raise LimitViolation(
187
+ f"{self.name}: target {target:.3f} below min_pos {self.min_pos:.3f}"
188
+ )
189
+ if self.max_pos is not None and target > self.max_pos:
190
+ raise LimitViolation(
191
+ f"{self.name}: target {target:.3f} above max_pos {self.max_pos:.3f}"
192
+ )
193
+
194
+ def __repr__(self) -> str:
195
+ return (
196
+ f"<Axis {self.name!r} node={self.node.node_id} "
197
+ f"rotation_distance={self.rotation_distance}>"
198
+ )