# Strido — complete documentation > Strido is an open 12-DoF quadruped robot: printable CAD, a verified assembly, parts catalogue and shop, electronics and wiring, ESP32-S3 firmware, ROS 2, simulation and reinforcement learning, and an engineering wiki. # AI assistant and agents > **Status:** the assistant answers from this documentation (English and German) through Cortex Keyring; the same knowledge is open to coding agents through an MCP server, an Agent Skill and `llms.txt`. Answers are generated: check figures against the linked page before you cut, crimp or power anything. ## Ask the assistant The bar at the bottom of every page is the assistant. | Do | How | |---|---| | Open it | Click the bar or press `/` anywhere outside a text field (`Ctrl K` / `⌘ K` stays the page search) | | Ask | Type and press `Enter` (`Shift Enter` for a new line); it knows which page you are on | | Show it something | Paste a screenshot or photo (`Ctrl V` / `⌘ V`), drop images or text files onto the page, or use the paperclip; a long pasted text becomes an attachment | | Keep it open | Pin it: it becomes a sidebar on the right while you read; unpin to float it again | | Shrink it | `Esc` or the collapse button reduces it to the small bar | | Conversations | Click the title for your history: start a new one (`Alt N`), switch, rename or delete | | Stop | The stop button ends an answer; the part already written is kept | It searches the documentation, reads the pages it needs and links them in its answer; on the German site it answers in German. Photos of your build are welcome: it compares them with the assembly steps and the wiring list. **Your data:** conversations and attachments are stored by the assistant service for your account only, until you delete them. **Availability:** the assistant runs on the owner's Cortex Keyring subscription; when the bar shows a lock, it is not available for your account. ## Use the knowledge base in your own agent The documentation is published for language models and coding agents in three forms. ### MCP server The MCP server is `https://strido.cortex.a2olabs.com/mcp`. Agents sign in with a personal Cortex app token (90 days by default): ```sh cortex --json api post /api/v1/apps/strido/tokens --data '{"label":"my agent"}' # prints {"id", "token": "cxu_...", "expiresAt"} export STRIDO_TOKEN=cxu_... # keep it out of files you share claude mcp add --transport http strido https://strido.cortex.a2olabs.com/mcp --header "Authorization: Bearer $STRIDO_TOKEN" ``` Codex: add `[mcp_servers.strido]` with `url = "https://strido.cortex.a2olabs.com/mcp"` and `bearer_token_env_var = "STRIDO_TOKEN"` to `~/.codex/config.toml`. Tools: | Tool | Returns | |---|---| | `search_docs` | the best-matching sections (title, link, snippet), English or German | | `read_page` | a whole page or one section as Markdown | | `list_pages` | the site map with links | | `search_parts` | bill-of-materials lines with quantity, price, shop and product data | `cortex api delete /api/v1/apps/strido/tokens/` revokes the token. ### Agent Skill with the full documentation [Download the skill (ZIP)](/downloads/strido-skill.zip) (or, with the token: `curl -H "Authorization: Bearer $STRIDO_TOKEN" -o strido-skill.zip https://strido.cortex.a2olabs.com/api/agent/strido-skill.zip`) and unpack it into your agent's skill directory (Claude Code: `~/.claude/skills/`, Codex: `~/.agents/skills/`). It holds `strido/SKILL.md` and every page in English and German under `references/`, so an agent can answer offline; fetch it again after a release. ### llms.txt | File | Contents | |---|---| | [/llms.txt](/llms.txt) | index of every page with its link ([German](/de/llms.txt)) | | [/llms-full.txt](/llms-full.txt) | every page in one Markdown file ([German](/de/llms-full.txt)) | The site needs a Cortex sign-in: people download the files in the browser; agents use the MCP server or fetch the same files with the app token under `/api/agent/` (`llms.txt`, `llms-full.txt`, `de/llms.txt`, `de/llms-full.txt`). --- # Architecture > **Status:** describes the repository as built (0.2). Layer rules are enforced by import-linter (`make layers`). ## System ```mermaid flowchart TB subgraph Robot["Robot"] PI["Raspberry Pi 5
ROS 2 Jazzy: gait / policy, mcu_bridge, LiDAR, camera"] <-->|"USB CDC · COBS + CRC16"| MCU["XIAO ESP32-S3 firmware
200 Hz servo loop · watchdog · e-stop · cut-off"] MCU -->|I²C| PCA["PCA9685 → 12 servos"] MCU -->|I²C| SENS["LSM6DSOX IMU · INA260"] MCU -->|SPI| LCD["2-inch LCD face"] end subgraph Dev["Development workstation"] CAD["strido-cad
STEP → parts → assembly"] --> VER["strido-verify
interference · ROM · fits · clearance · sensors · electronics"] CAD --> MOD["models: URDF/xacro · MJCF · meshes"] VER --> PUB["strido-publish
print kit · BOM · manual · book PDF · renders · GLB"] MOD --> SIM["strido-sim
MuJoCo walk · Gym env · PPO"] MOD --> NTX["strido-ntx
NT-X catalog assets · serial RobotPort"] PUB --> SITE["docs-site (VitePress, English + German)
catalogue · 3D viewer · guides · AI bar"] SITE --> AST["assistant
chat API · MCP · Cortex Keyring"] WS["strido-workspace
CLI · hardware MCP server"] --> VER end SIM -->|"policy.npz"| PI NTX -->|"same MCU protocol"| MCU CORE["strido-core
geometry · leg kinematics · gait · protocol · policy runtime"] CORE -.-> PI CORE -.-> SIM CORE -.-> CAD CORE -.-> NTX ``` ## Repository layout | Path | What | Owner of truth | |---|---|---| | `hardware/` | Tracked hardware data: kinematics, joint limits, fasteners, BOM, electronics, tolerances, contact rules, assembly corrections; the print kit (`print/`) | YAML sources; `print/` and `joint_limits.yaml` are generated | | `packages/strido-core` | Robot runtime library (numpy + pyserial only): runs on the Pi, in ROS 2, simulation and tooling | code | | `packages/strido-cad` | CAD pipeline: STEP registration, kinematics extraction, Strido parts (CadQuery), vendor models, fasteners, posing, code and model generation | code + `hardware/` | | `packages/strido-verify` | Physical verification: interference, range of motion, fits and tolerances, moving clearance, sensor field of view, electronics | code + `hardware/verification/` | | `packages/strido-publish` | BOM optimiser, print kit, assembly manual, catalogue & book PDF, renders, web 3D viewer | code | | `packages/strido-sim` | MuJoCo walk demo, Gymnasium environment, PPO training/evaluation | code | | `packages/strido-ntx` | NT-X extension: catalog assets, stand skill, serial `RobotPort` | code; assets generated | | `src/strido_workspace` | `strido` CLI and the `strido-hardware` MCP server | code | | `firmware/strido_esp32s3` | PlatformIO firmware; `lib/strido_core` is portable and natively tested | code; `geometry_generated.h` generated | | `ros2/` | ROS 2 packages: description (generated), bringup, control | code | | `models/` | MuJoCo models (generated) and released policies | generated | | `docs/`, `requirements/` | Canonical documentation; `docs/{bom,assembly,catalog,viewer,verification}` are generated | Markdown | | `docs-site/` | VitePress site; `content/{docs,requirements,de,public/*}` are synced copies (German pages rendered from `i18n/`) | site code | | `i18n/` | German translation catalogues, one per published page and data file (`i18n/README.md`) | translations | | `assistant/` | Assistant service behind the AI bar: conversations, documentation search, MCP server for agents | code | | `skills/strido/` | Agent Skill for the documentation (also as a ZIP with every page) | Markdown | | `upstream/yertle/` | Unmodified upstream Yertle design and software (reference) | upstream | | `.harness/` | Engineering harness submodule (agents, hooks, checks) | upstream harness | ## Package layering ```mermaid flowchart TD WS["strido_workspace"] --> PUB["strido_publish"] --> VER["strido_verify"] --> CAD["strido_cad"] --> CORE["strido (core)"] SIM["strido_sim"] --> CORE NTX["strido_ntx"] --> CORE ``` Contracts (`pyproject.toml`, `[tool.importlinter]`): the core never imports tooling; publish → verify → cad → core is a strict layer order; simulation and the NT-X adapter never pull in the CAD stack (they run where CadQuery is not installed). ## Data flow of a hardware change ```mermaid sequenceDiagram participant Dev as Developer / agent participant YAML as hardware/*.yaml · parts.py participant CAD as strido_cad participant VER as strido_verify participant PUB as strido_publish Dev->>YAML: change a part, placement, fastener or limit Dev->>CAD: make cad (kinematics, limits, bindings, models) CAD->>VER: make verify (interference, ROM, fits, clearance, sensors) VER-->>Dev: fail → fix geometry, never widen a rule Dev->>PUB: make publish (print kit, BOM, manual, renders, viewer, book) PUB-->>Dev: make bom fails if any CAD part is not purchasable ``` ## Interfaces | Interface | Definition | Consumers | |---|---|---| | MCU wire protocol | `packages/strido-core/src/strido/protocol.py` ⇄ `firmware/.../protocol.h` (COBS + CRC16, 12 × float32 joint targets, 103-byte state payload) | ROS 2 `mcu_bridge`, `strido-cli`, NT-X `StridoSerialPort` | | Joint convention | Go2 names and signs, order FL, FR, RL, RR × hip/thigh/calf | firmware, ROS 2, MuJoCo, NT-X | | ROS 2 topics | [software](software.md) | teleop, Nav2, NT-X rosbridge | | NT-X | `RobotSpec` + MJCF + skill (catalog assets), `RobotPort` (registry adapter `strido-serial`) | NT-X harness and Studio | | Hardware knowledge | `strido-hardware` MCP tools / `strido` CLI | coding agents, humans | --- # Assembly manual Generated from the verified CAD assembly by `strido_publish.manual`: every printed part, purchased part and fastener modelled in the CAD appears in exactly one parts box (cables are drawn in the steps that lay them; their lengths are in the [cut list](../verification/wiring.md#cut-list), consumables in the [shop](../shop.md)). Leg steps show the front-left leg; build all four (quantities are per leg). Highlighted parts are new in the step; arrows show where they go. ## Step 0 – Before you start * Print every part in the [print list](../../hardware/print/print_list.md) (easiest: the [print kit](../print-kit.md) package with the ready plates). Remove supports and brims; test-fit bearings (625ZZ, 633ZZ, 6805-2RS) and sleeves before assembly. * Wiring: every cable is drawn in the steps that install it; lengths are in the [cut list](../verification/wiring.md#cut-list) and the build order in [electronics › wiring harness](../electronics.md#wiring-harness). Set both UBECs to 6.0 V and measure them before any servo is connected. * **Centre all 12 servos** before fitting any horn. Bench set-up: XIAO (flashed with `firmware/strido_esp32s3`) on USB to your computer, PCA9685 on I²C with its OE pull-up, servo power from one UBEC at 6.0 V (or a 6 V bench supply) to the servo's red/black wires, signal to the PCA9685 channel it will use. Run `strido-cli --port center` (1500 µs on every channel; works without the INA260 and stops with an error if the controller is not driving). A centred servo is the assembly reference: hip leg vertical, femur 45° backwards, knee crank 35°. * Tools: hex keys 1.5 / 2.0 / 2.5 mm, nut drivers 4.0 / 5.0 / 5.5 mm, flush cutters, soldering iron and solder, multimeter, crimp tool for Dupont, medium thread locker for screws into metal horns. * Screws into printed holes marked *self-tapping*: turn until snug, never to full torque. Metal-to-metal screws (horns): one drop of medium thread locker. ## Step 1 – Prepare the electronics deck ![Step 1](img/step_01.png)
1×Electronics deck (printed)
1×LSM6DSOX IMU
2×M2.5 hex nut (ISO 4032)
10×M2.5 × 5 socket head screw (ISO 4762)
2×M2.5 × 6 socket head screw (ISO 4762)
3×M2 × 5 socket head screw (ISO 4762)
10×M2.5 × 6 stand-off F-F (brass)
3×M2 × 8 stand-off F-F (brass)
1. Press the LSM6DSOX IMU into its pocket (chip side up, STEMMA connectors towards the rear) and fix it with two M2.5 × 6 screws from the top and M2.5 nuts underneath. 2. Fix the stand-offs from below with M2.5 × 5 screws: 8 stand-offs M2.5, 6 mm long (Raspberry Pi 5 and PCA9685) and 2 more for the INA260. 3. Fix 3 stand-offs M2, 8 mm long, for the 5 V regulator with M2 × 5 screws from below. 4. The screw heads sit in the counterbores under the deck. Do not over-tighten: the deck is 3 mm thick in the centre. ## Step 2 – Chassis frame ![Step 2](img/step_02.png)
2×Inner shoulder frame (printed)
2×Side body beam (printed)
8×M3 hex nut (ISO 4032)
8×M3 × 10 socket head screw (ISO 4762)
1. Slide the prepared deck into the grooves of both side body beams. 2. Close the frame with the two inner shoulder frames: 8 × M3 × 10 screws with hex nuts (nut side inside the bay). 3. Check that the frame sits flat on the table and the deck cannot move. ## Step 3 – Hip servos ![Step 3](img/step_03.png)
4×Servo SPT5435LV-180W
16×M3 hex nut (ISO 4032)
16×M3 × 10 socket head screw (ISO 4762)
1. Centre all twelve servos before mounting any horn or servo (see Step 0). 2. Insert the four hip servos through the inner shoulder frames from the bay side; output shafts point outwards (front pair to the front, rear pair to the rear). 3. Fix each servo with 4 × M3 × 10 screws and hex nuts through the servo ears (16 in total). 4. Route the servo leads into the bay, they will run to the front and rear cavities later. ## Step 4 – Hip bearings and shoulder frames ![Step 4](img/step_04.png)
1×Lower shoulder frame front (printed)
1×Lower shoulder frame rear (printed)
2×Outer shoulder frame (printed)
2×Upper shoulder frame (printed)
4×Ball bearing 625ZZ
32×M3 hex nut (ISO 4032)
16×M3 × 10 socket head screw (ISO 4762)
1. Press two 625ZZ bearings into each outer shoulder frame (4 in total). 2. Assemble outer, upper and lower shoulder frames front and rear with 16 × M3 × 10 screws and hex nuts. 3. Use the Strido lower shoulder frames: `lower_shoulder_frame_front` and `_rear` have the extra pilot hole for the servo-bus holder. 4. Push M3 nuts into the nut pockets now, while they are reachable: 6 on top of the upper shoulder frames and 8 under the lower shoulder frames for the shell screws (Steps 21 and 22), and 2 more on top of the front upper shoulder frame for the camera base (Step 18). ## Step 5 – Servo power bus and motion controller ![Step 5](img/step_05.png)
1×Servo bus holder front (printed)
1×Servo bus holder rear (printed)
1×Seeed XIAO ESP32-S3
8×WAGO 221-415
2×M3 × 12 socket head screw (ISO 4762)
1. Clip four WAGO 221-415 connectors into each servo-bus holder. 2. Screw each holder to its lower shoulder frame with one M3 × 12 screw (self-tapping into the printed pilot hole). 3. Place the XIAO ESP32-S3 in the front cavity, USB-C towards the bay (foam tape). ## Step 6 – Servo UBECs ![Step 6](img/step_06.png)
2×Hobbywing UBEC-10A
1. Leave both UBEC output jumpers open (6.0 V). Measure 6.0 V before any servo is connected. 2. Foam-tape one UBEC into each side pocket next to the battery bay, standing on its long edge, leads towards the deck. ## Step 7 – Boards on the deck ![Step 7](img/step_07.png)
1×INA260 power monitor
1×PCA9685 servo driver
1×Pololu D36V50F5 5 V regulator
1×Pololu #2813 pushbutton switch
1×Raspberry Pi 5 + Active Cooler
10×M2.5 × 5 socket head screw (ISO 4762)
3×M2 × 5 socket head screw (ISO 4762)
1. Raspberry Pi 5 with Active Cooler: ports to the front, 4 × M2.5 × 5 screws. 2. PCA9685: first solder the 2.2 kΩ OE pull-up (OE to VCC on the header, wiring W20), then mount it with the servo headers towards the right side, 4 × M2.5 × 5 screws. Leave its V+ terminal unconnected. 3. INA260 (bridge A0 first, address 0x41): 2 × M2.5 × 5 screws. Pololu D36V50F5: 3 × M2 × 5 screws. 4. Pololu pushbutton switch on foam tape in the right side strip, its two 5 mm terminal blocks soldered on (VIN/GND and VOUT/GND). ## Step 8 – Leg servo module (×4 legs) ![Step 8](img/step_08.png)
1×Hip bearing pin (printed)
1×Left servo mount (printed) – legs RR only
1×Left servo mount fl (printed) – legs FL only
1×Right servo mount (printed) – legs RL only
1×Right servo mount fr (printed) – legs FR only
1×Servo mount top bracket (printed)
2×Servo SPT5435LV-180W
8×M3 × 10 socket head screw (ISO 4762)
2×M3 × 20 socket head screw (ISO 4762)
1. Build one leg at a time; the drawing shows the front-left leg. Front-left and rear-right legs use the *left* servo mount, front-right and rear-left the *right* one. The front legs use the Strido variants `left_servo_mount_fl` / `right_servo_mount_fr` (extra hip-pivot pilot). 2. Mount the thigh servo and the knee servo in the servo mount with 8 × M3 × 10 screws through the servo ears, then close it with the top bracket: 2 × M3 × 20 screws. 3. Press the hip bearing pin into the mount; the hip disc horn is fitted in Step 13. ## Step 9 – Thigh (×4 legs) ![Step 9](img/step_09.png)
1×Femur (printed)
1×Femur servo connector (printed)
1×25T aluminium disc horn
4×M3 × 20 socket head screw (ISO 4762)
1×M3 × 8 socket head screw (ISO 4762)
1. Fix the femur and the femur servo connector to a 25T aluminium disc horn with 4 × M3 × 20 screws. 2. With the thigh servo centred, push the horn on the spline with the femur pointing 45° backwards (assembly reference) and fix it with the M3 × 8 centre screw. ## Step 10 – Knee drive crank (×4 legs) ![Step 10](img/step_10.png)
1×Left shoulder (printed) – legs FL, RL only
1×Right shoulder (printed) – legs FR, RR only
1×Ball bearing 6805-2RS
1×25T aluminium arm horn
1×M3 × 8 socket head screw (ISO 4762)
1. Press the 6805-2RS bearing into the shoulder crank (left or right version per leg) and fit it over the femur servo connector: it turns freely about the thigh axis. 2. With the knee servo centred, push the 25T aluminium arm horn on its spline at 35° (assembly reference) and fix it with the M3 × 8 centre screw. ## Step 11 – Lower leg (×4 legs) ![Step 11](img/step_11.png)
1×Foot spacer (printed)
1×Inner tibia (printed)
1×Outer tibia (printed)
2×Ball bearing 633ZZ
3×M3 lock nut (nylon insert) (DIN 985)
2×M3 × 10 socket head screw (ISO 4762)
2×M3 × 12 socket head screw (ISO 4762)
1×M3 × 20 socket head screw (ISO 4762)
2×M3 washer (ISO 7089)
1. Press two 633ZZ bearings into the tibia halves. 2. Join inner and outer tibia with 2 × M3 × 12 screws and lock nuts; clamp the foot spacer with the M3 × 20 screw and lock nut. 3. Fit the knee axle: 2 × M3 × 10 screws with washers from both sides through the femur. ## Step 12 – Knee linkage (×4 legs) ![Step 12](img/step_12.png)
1×Short link (printed)
1×Stiff long link (printed)
4×Sleeve bearings 3×5×5 (pair)
1×M3 lock nut (nylon insert) (DIN 985)
1×M3 × 16 socket head screw (ISO 4762)
3×M3 × 25 socket head screw (ISO 4762)
1×M3 washer (ISO 7089)
1. Press two 3 × 5 × 5 sleeve bearings into every link eye (8 per leg). 2. Connect the links as drawn: 2 × M3 × 25 crank pins through the shoulder crank, the M3 × 16 screw with washer at the arm horn, and 1 × M3 × 25 with a lock nut at the tibia. 3. Tighten the lock nuts only until the joint is free of play: the links must swing under their own weight. ## Step 13 – Mount the legs ![Step 13](img/step_13.png)
4×25T aluminium disc horn
4×M3 × 10 socket head screw (ISO 4762)
12×M3 × 8 socket head screw (ISO 4762)
4×M3 washer (ISO 7089)
1. Fix a disc horn to each leg module with 2 × M3 × 8 screws. 2. With the hip servo centred, push the leg on the hip servo spline (leg vertical) and fix it with the M3 × 8 centre screw. 3. Insert the hip pivot screw (M3 × 10 with washer) through the 625 bearing into the hip bearing pin. ## Step 14 – Power entry ![Step 14](img/step_14.png)
1×ATO fuse holder
1×XT60H female
1. Cut every wire to its length in the cut list ([wiring check](../verification/wiring.md#cut-list)); strip, tin and heat-shrink every solder joint. 2. Battery input: solder W01 (14 AWG red) and W04 (14 AWG black) to an XT60H with sockets (check the contact rule in [electronics](../electronics.md#xt60-contact-rule-the-loop-key-must-never-fit-the-battery)) and lay it in the pocket beside the battery under the deck, hood towards the rear. Slide spiral wrap over W01 (it is unfused). 3. Ground star beside it: splice W04 to the black inputs of both UBECs (14 AWG) and to the logic ground (18 AWG black, on to the switch GND). 4. Main fuse: trim both leads of a 25 A in-line holder to 20 mm, splice W01 to one and W02 to the other, lay the holder on the rear WAGO bus with its leads out through the inner and outer frame openings and tie it to the upper shoulder frame. No fuse yet. 5. W02 and W03 (to both UBEC red inputs, spliced under the deck) end behind the rear outer frame: they are soldered to the arming jack in Step 19. W09 (18 AWG red, logic feed) runs from there through the rear cavity and over the deck to the switch VIN; its other end waits for the logic fuse holder (Step 19). ## Step 15 – Servo leads and servo bus ![Step 15](img/step_15.png)
1. Thigh and knee servo leads: tie each to its servo mount plate at the marked point (close to the hip axis), leave the free loop (front: 30 mm thigh, 32 mm knee; rear: 22 mm; it takes up the hip motion) and tie it again at the shoulder frame (thigh) or the bus holder (knee). 2. Unclip each WAGO pair from its holder before clamping (the XIAO platform blocks the levers in bay 0). At the tie peel the red and brown wires off the lead, cut them to the cut-list length, strip 11 mm and clamp them into the leg's WAGO pair: red into the upper (V+) connector, brown into the lower (GND), ports hip 1, thigh 2, knee 3; refit the pair. Hip servo leads split at the inner frame opening. 3. Cut the orange signal wire to its cut-list length (measured from the tie), crimp a female Dupont contact, push it into a 1-pin housing and plug it onto the PWM pin of its PCA9685 channel (channel map in [electronics](../electronics.md#pin-maps)). 4. UBEC outputs (W05 front, W06 rear): extend with 18 AWG into port 0 of bay 0. Jumpers W07/W08: 18 AWG from port 4 of bay 0 to port 0 of bay 1, V+ and GND separately along the WAGO faces. ## Step 16 – Logic and signal wiring ![Step 16](img/step_16.png)
1. 18 AWG: switch VOUT to INA260 VIN+ (W10, red), INA260 VIN- to the regulator VIN (W11, red), switch GND to the regulator GND (W24, black; the INA260 terminal block takes only VIN+ and VIN-). 2. W12: four 22 AWG wires from the regulator VOUT/GND, Dupont crimps at the Pi end. Leave them unplugged: they go onto the Pi header only after the supply check in Step 20. 3. I²C: W17 (3V3, GND, SDA, SCL) and W20 (D3 to OE) are 22 AWG wires soldered to the XIAO pins and crimped (Dupont) for the PCA9685 left header. STEMMA QT cables: PCA9685 right header to the IMU (W18), IMU to the INA260 header (W19). 4. USB: Pi lower port to the XIAO (W14, fold the spare length and tie it in the front cavity). Optional W22: Pi GPIO 26 to the switch OFF pad (22 AWG, Dupont crimp at the Pi, soldered at the switch). ## Step 17 – Front display ![Step 17](img/step_17.png)
1×Display bracket (printed)
1×Front shell display (printed)
1×Waveshare 2" LCD (ST7789)
4×M2 × 5 socket head screw (ISO 4762)
2×M3 × 10 socket head screw (ISO 4762)
1. Put the 2-inch LCD into the front shell window from the inside, screen outwards, header pins down. 2. Fix the bracket to the LCD hex studs with 4 × M2 × 5 screws, then to the shell bosses with 2 × M3 × 10 (self-tapping). 3. Plug the display cable (W21, supplied with the LCD) into the display, run it through the outer shoulder frame opening and solder its 8 wires to the XIAO pins (no room for Dupont housings under the upper shoulder frame); fold the spare length (cut list) and tie it at the opening. ## Step 18 – Front camera ![Step 18](img/step_18.png)
1×Camera mount base (printed)
1×Camera tilt plate (printed)
1×Camera Module 3 Wide
1×M3 lock nut (nylon insert) (DIN 985)
4×M2 hex nut (ISO 4032)
4×M2 × 10 socket head screw (ISO 4762)
2×M3 × 12 socket head screw (ISO 4762)
1×M3 × 40 socket head screw (ISO 4762)
1. Fix the Camera Module 3 Wide to the tilt plate: 4 × M2 × 10 screws, M2 nuts behind the plate. 2. Hang the tilt plate between the base cheeks with the M3 × 40 pivot screw and a lock nut; tighten until the camera holds its angle (15° down is the reference). 3. Plug the 300 mm camera ribbon (W16) into CAM/DISP 0 of the Pi, run it through both shoulder frame openings and up through the front-shell slot to the camera; fold the spare length flat (no creases). Then put the front shell on and screw the camera base and shell to the upper shoulder frame with 2 × M3 × 12. ## Step 19 – Rear panel ![Step 19](img/step_19.png)
1×Arming port bezel (printed)
1×Arming port ring (printed)
1×Back shell button (printed)
1×ATO fuse holder
1×12 mm push button
1×XT60H female
2×M2.5 × 6 socket head screw (ISO 4762)
1. Mount the 12 mm power button in the small hole of the back shell and solder W13 (22 AWG pair, routed to the switch A/B pads). 2. Insert the XT60 arming port into the printed bezel from outside, clamp it with the ring from inside: 2 × M2.5 × 6. 3. Solder W02 and W03 to the two contacts of the arming jack. Logic fuse: trim the input lead of a second in-line holder to its cut length and splice it to W02 (heat-shrink); trim its output lead to 20 mm and splice W09 to it. Lay the holder in the shell between the jack and the button. No fuse yet. 4. Put the back shell on. The loop key stays out until first power-up (final step). ## Step 20 – Dress the harness and check the logic supply ![Step 20](img/step_20.png)
2×Adhesive cable-tie mount 13 × 13 mm
1. Supply check before the Pi is connected: plug a charged pack into the battery input (W00) for a moment, insert both fuses, loop key out, press the button and measure 4.8–5.2 V at the W12 Dupont ends (D36V50F5: 5 V ± 4 %). Press again, pull the fuses, unplug the pack. Then plug W12 onto Pi pins 2/4 (5 V) and 6/14 (GND). 2. Spiral-wrap every bundle of three or more cables and W01 where the drawing shows a sleeve; it protects the leads where they pass frames and moving parts. 3. Tie at every marked point (2.5 × 100 mm ties, cut flush); two adhesive tie mounts on the deck carry the bundles in the side strips. 4. Move every leg through its full range by hand: no cable may be pulled tight, pinched or touch a horn or link ([wiring check](../verification/wiring.md)). ## Step 21 – Top shell and LiDAR ![Step 21](img/step_21.png)
1×Lidar pedestal (printed)
1×Top shell sensor (printed)
1×RPLIDAR C1
1×RPLIDAR USB adapter
4×M2.5 × 6 socket head screw (ISO 4762)
4×M3 × 12 socket head screw (ISO 4762)
6×M3 × 8 socket head screw (ISO 4762)
1. Screw the LiDAR pedestal to the top shell from below: 4 × M3 × 12 (self-tapping). 2. Put the LiDAR USB adapter into the pedestal, behind the cable slot, USB end towards the slot (foam tape). Lead the LiDAR's own cable (W23) through the notch at the pedestal front into the adapter, then fix the RPLIDAR C1 with 4 × M2.5 × 6 (max. 4 mm into the LiDAR). 3. Plug the second USB cable (W15, from the Pi's upper USB port, routed over the Pi beside the cooler fan) into the adapter through the top-shell slot, then close the top shell over the front and back shells: 6 × M3 × 8 into the nuts inserted in Step 4. ## Step 22 – Battery and bottom shell ![Step 22](img/step_22.png)
1×Bottom shell (printed)
1×LiPo 2S 5000 mAh
8×M3 × 8 socket head screw (ISO 4762)
1. Strap the 2S LiPo under the deck (hook-and-loop strap), lead end to the rear. 2. Plug the pack's own lead (W00) into the battery input XT60H in the pocket beside the battery (the lead turns forward along the left side under the rear hip servo). Keep the loop key out. 3. Close the bottom shell: 8 × M3 × 8 screws into the nuts inserted in Step 4. ## Step 23 – Wiring, calibration and first power-up 1. Check the harness against [electronics › wiring harness](../electronics.md#wiring-harness) (W00–W23, servo leads S-*): every joint soldered and heat-shrunk, no strands outside a WAGO or terminal. Keep the fuses and the arming loop key out. 2. Fuses in, press the power button: the Pi boots, the face appears on the LCD, `ros2 topic echo /battery_state` shows the pack voltage. The servos stay limp: the controller keeps the PCA9685 outputs off until it receives position commands. 3. Fine-calibrate each joint with `strido-cli calibrate` (robot on a stand, legs free; offsets stored in the ESP32 flash). 4. Lay the robot on its belly with the legs folded (the rest pose), insert the loop key (servos stay limp), then start `ros2 launch strido_bringup robot.launch.py`: the servos move to the rest pose and the robot stands up within 3 s. Never insert the key while position commands are running: all 12 servos would jump at once (up to 6 × 3.5 A stall per cavity against a 15 A UBEC peak). 5. Teleoperate: `ros2 run teleop_twist_keyboard teleop_twist_keyboard`. Publish `true` on `/strido/estop` to stop. After releasing an e-stop or a relax, restart the launch with the robot lying in the rest pose: the gait node only ramps up on start. --- # Bill of materials Generated by `strido_publish.bom` from `hardware/bom.yaml` and the CAD assembly. Prices: EUR incl. VAT, observed 2026-10-30 (cheapest offer shown; all offers and bundles in [shopping_list.md](shopping_list.md); research notes in [sources.md](sources.md)). Printed parts: [hardware/print/print_list.md](../../hardware/print/print_list.md). ## Compute and sensors | Qty | Item | Cheapest offer | Price | Note | |---|---|---|---|---| | 1 | Raspberry Pi 5, 4 GB | DigiKey.at SC1431 (2648-SC1431-ND) | €117.18 | | | 1 | Raspberry Pi Active Cooler | Welectron (DE) SC1148 | €4.90 | | | 1 | microSD 64 GB A2 (SanDisk Extreme) | Berrybase.at SanDisk Extreme A2 64GB | €25.90 | | | 1 | Raspberry Pi Camera Module 3 Wide | Welectron (DE) Camera Module 3 Wide | €35.90 | | | 1 | Pi 5 camera cable 22→15 pin, 300 mm | DigiKey.at SC1129 | €2.14 | | | 1 | Seeed XIAO ESP32-S3, pre-soldered headers | DigiKey.at 102010634 | €9.05 | | | 1 | PCA9685 16-channel PWM board (Adafruit 815 layout) | Amazon.de B06XSFFXQY | €6.95 | clones must have the Adafruit 815 footprint (62.5 x 25.4 mm, 4 corner holes); clone/third-party | | 1 | Adafruit LSM6DSOX 6-DoF IMU (4438) | DigiKey.at Adafruit 4438 | €12.73 | | | 1 | Adafruit INA260 power monitor (4226) | DigiKey.at Adafruit 4226 | €10.60 | | | 1 | Waveshare 2" LCD module, ST7789, 240x320 (17344) | Welectron (DE) 17344 | €10.90 | | | 1 | Slamtec RPLIDAR C1 (with USB adapter) | Amazon.de B0CP8YRY44 (WayPonDEV) | €70.58 | | | 2 | STEMMA QT to female sockets cable, 150 mm (Adafruit 4397; IMU to PCA9685 and IMU to INA260) | DigiKey.at Adafruit 4397 | €2.02 | | | 1 | Resistor 2.2 kΩ 0.25 W (PCA9685 OE pull-up) | Reichelt.at METALL 2,20K | €0.10 | price unverified | | | **Subtotal** | | **€308.95** | | ## Actuation and mechanics | Qty | Item | Cheapest offer | Price | Note | |---|---|---|---|---| | 13 | SPT5435LV-180W servo, 35 kg·cm, 180° (12 + 1 spare) | AliExpress (DigitalServo Store) 4000135787995, variant 35kg-180 Waterproof | €174.98 | must be the 180° variant (500-2500 µs = 180°); confirm angle with any non-SPT seller | | 8 | 25T aluminium disc horn | Amazon.de B07F71J1Q2 (DollaTek, 5-pack) (packs of 5) | €10.06 | clone/third-party | | 4 | 25T aluminium clamping arm horn | Amazon.de B0F5QG8HQS (5-pack) (packs of 5) | €9.06 | clone/third-party | | 4 | Ball bearing 625ZZ (5x16x5) | kugellager-express.de 625-ZZ | €3.52 | | | 4 | Ball bearing 6805-2RS (25x37x7) | kugellager-express.de 6805/61805-2RS | €6.80 | | | 8 | Ball bearing 633ZZ (3x13x5) | Amazon.de B0GGT3GW69 (8-pack) (packs of 8) | €10.27 | | | 32 | Sintered bronze sleeve bearing 3x5x5 | Amazon.de B0G15L264D (50-pack) (packs of 50) | €10.27 | | | | **Subtotal** | | **€224.96** | | ## Power | Qty | Item | Cheapest offer | Price | Note | |---|---|---|---|---| | 2 | Hobbywing UBEC-10A (V2-Car), 6.0 V | Lindinger.at HW30603003 | €53.98 | | | 1 | LiPo 2S 5000 mAh hardcase, XT60, max 138 x 47 x 25 mm (Gens ace G-Tech) | Amazon.de B0GKP683YF | €22.18 | | | 1 | LiPo balance charger, AC input (B6AC class) | Amazon.de B0F3CYZ6L8 (HTRC B6AC) | €46.38 | | | 1 | LiPo safety bag | Lindinger.at Robbe 23x30 cm (9787514) | €6.49 | | | 1 | LiPo cell checker / low-voltage alarm | Lindinger.at Absima (9771616) | €5.95 | | | 1 | Pololu D36V50F5 5 V 5.5 A step-down regulator | Eckstein (DE) D36V50F5 | €41.59 | | | 1 | Pololu #2813 Big Pushbutton Power Switch HP | DigiKey.at Pololu 2813 (Marketplace) | €10.80 | | | 1 | 12 mm momentary pushbutton, stainless (ONPOW GQ12B-10S) | Reichelt.at 318555 | €5.66 | | | 1 | XT60H connectors, 3 pairs (battery lead, arming port, loop key) | Lindinger.at Robbe XT60 3 pairs (9750234) | €4.99 | | | 2 | Inline ATO fuse holder, 14 AWG leads (main + logic branch) | Amazon.de B07426WCLM (Nilight, 10 holders) (packs of 10) | €8.56 | clone/third-party | | 3 | ATO blade fuse 25 A (main, 1 + 2 spare) | Reichelt.at 7786 | €1.08 | | | 2 | ATO blade fuse 7.5 A (logic branch, 1 + 1 spare) | Reichelt.at FKS 7.5 A (same series as 7786) | €0.72 | price approximate | | 8 | WAGO 221-415 lever connector | Reichelt.at 149800 | €6.48 | | | | **Subtotal** | | **€214.86** | | ## Wiring | Qty | Item | Cheapest offer | Price | Note | |---|---|---|---|---| | 1 | Silicone wire 14 AWG / 2.5 mm², red + black, 1 m each | Lindinger.at 20479 | €4.99 | | | 2 | Silicone wire 18 AWG / 0.75 mm², red + black (1 m each per unit) | Lindinger.at 44422 | €5.98 | | | 1 | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | Amazon.de B0C7T9KYND (6 colours x 4 m) | €12.10 | | | 2 | USB-A to USB-C cable, 20 cm, 90° (Pi → XIAO, Pi → LiDAR adapter) | Amazon.de B09ZDV7S44 (aceyoon 20 cm 90°, 3-pack) (packs of 3) | €7.64 | | | 1 | Dupont jumper set 20 cm (F-F, M-M, F-M) | Berrybase.at DUPK-SET-20 | €4.90 | | | 1 | Heat-shrink assortment | Reichelt.at 152924 | €5.39 | | | 1 | Cable ties 100 x 2.5 mm (100) | Berrybase.at E-E510 | €0.90 | | | 1 | Spiral wrap, black PE, for bundles 4-10 mm (5-10 m) | Reichelt.at CIMCO 186220 (10 m, bundles 4-20 mm) | €4.84 | | | 2 | Adhesive cable-tie mount 13 x 13 mm (HellermannTyton MB2APT-A) | Reichelt.at HT 151-02243 (per piece) | €2.12 | | | 1 | Dupont 2.54 mm crimp contacts and housings (1-6 pin), for the cut-to-length signal leads | Amazon.de B0FP14VDGS (620 pieces) | €9.06 | | | 1 | Solder wire, lead-free, 0.8 mm, flux core (50-100 g) | Reichelt.at Goobay 420259 (50 g, SAC) | €13.61 | | | 1 | Hook-and-loop straps (battery strap) | Reichelt.at 280182 | €5.14 | | | 1 | Double-sided foam mounting tape, 19 mm | Reichelt.at tesa PRO Universal 338987 | €9.98 | | | | **Subtotal** | | **€86.65** | | ## Fasteners | Qty | Item | Cheapest offer | Price | Note | |---|---|---|---|---| | 1 | M3 socket head screw kit, stainless (6-50 mm, nuts, washers; WZHUIDA 1080 pcs) | Amazon.de B0D2VST57H | €17.14 | | | 100 | M3 x 10 DIN 912 A2 | Schraubenking.at M3x10 DIN912 A2, 100 (packs of 100) | €2.35 | price approximate | | 10 | M3 x 20 DIN 912 A2 | Schraubenking.at M3x20 DIN912 A2, 10 (packs of 10) | €0.97 | price approximate | | 25 | M3 DIN 985 nylon lock nut | Schraubenking.at P000461 (zinc-plated) (packs of 100) | €1.39 | | | 22 | M2.5 x 5 DIN 912 A2 | Schraubenking.at P004986 (packs of 10) | €2.49 | | | 10 | M2.5 x 6 DIN 912 A2 | Schraubenking.at P005010 (packs of 10) | €0.83 | | | 12 | M2 x 5 DIN 912 A2 | Schraubenking.at P003957 (packs of 10) | €1.72 | | | 6 | M2 x 10 DIN 912 A2 | Schraubenking.at P002803 (packs of 10) | €0.78 | | | 6 | M2 hex nut DIN 934 A2 | Schraubenking.at P002807 (packs of 100) | €1.87 | | | 4 | M2.5 hex nut DIN 934 A2 | Schraubenking.at P004196 (packs of 100) | €2.00 | price unverified | | 12 | Stand-off M2.5 x 6 mm female-female (Würth 970060151) | DigiKey.at 970060151 | €6.62 | | | 5 | Stand-off M2 x 8 mm female-female, brass | Amazon.de B0DR67YGQL (packs of 40) | €7.73 | | | 1 | Thread locker, medium strength (blue) | Schraubenking.at Förch 10 ml (P002471) | €5.71 | | | | **Subtotal** | | **€51.60** | | ## Filament | Qty | Item | Cheapest offer | Price | Note | |---|---|---|---|---| | 1 | Filament PETG, graphite/black, 1 kg | 3DJake.at ELG-50.203.0216 (Elegoo Rapid PETG black) | €11.19 | PETG to match the verified slicer profile and the strength check (PLA softens near the warm servos) | | 1 | Filament PETG, lime/yellow (shells), 1 kg | 3DJake.at ELG-50.203.0221 (Elegoo Rapid PETG yellow) | €11.19 | PETG to match the verified slicer profile and the strength check | | | **Subtotal** | | **€22.38** | | ## Bench (recommended) | Qty | Item | Cheapest offer | Price | Note | |---|---|---|---|---| | 1 | Raspberry Pi 27 W USB-C power supply, EU plug (bench power without battery) | Welectron (DE) SC1407 | €11.90 | | | | **Subtotal** | | **€11.90** | | **Parts total (cheapest offers, before shipping): €921.30.** Shipping-optimised bundles: [shopping_list.md](shopping_list.md). ## Fasteners by joint (from the CAD) | Fastener | In CAD | Buy (incl. spares) | Used at | |---|---|---|---| | `din985_m3` | 17 | 19 | tibia_joints ×8, tibia_foot ×4, tibia_link_pin ×4, pivot nyloc (friction) ×1 | | `iso4032_m2` | 4 | 6 | M2 nut behind tilt plate ×4 | | `iso4032_m2_5` | 2 | 4 | IMU nut under the deck ×2 | | `iso4032_m3` | 56 | 62 | shoulder_frames ×16, hip_servo_ears ×16, shell_to_shoulder_bottom ×8, chassis_side ×8, shell_to_shoulder_top ×6, captive nut in upper shoulder frame (upstream pocket) ×2 | | `iso4762_m2_5x5` | 20 | 22 | stand-off -> deck (from below, head in pocket) ×8, board -> stand-off ×8, INA260 -> stand-off ×2, stand-off -> deck (from below) ×2 | | `iso4762_m2_5x6` | 8 | 10 | LiDAR -> pedestal ×4, clamp ring -> bezel through the D20 hole ×2, IMU -> deck ×2 | | `iso4762_m2x10` | 4 | 6 | camera PCB -> tilt plate bosses ×4 | | `iso4762_m2x5` | 10 | 12 | bracket -> LCD hex studs ×4, regulator -> stand-off ×3, stand-off -> deck (from below) ×3 | | `iso4762_m3x10` | 86 | 95 | leg_servo_ears_and_bracket ×32, shoulder_frames ×16, hip_servo_ears ×16, chassis_side ×8, knee_axle ×8, hip_pivot ×4, bracket -> front shell boss (self-tapping) ×2 | | `iso4762_m3x12` | 16 | 18 | tibia_joints ×8, pedestal -> top shell (from below, self-tapping) ×4, camera base + front shell -> upper shoulder frame nut (replaces upstream M3x8) ×2, bus holder -> lower shoulder frame (self-tapping) ×2 | | `iso4762_m3x16` | 4 | 6 | arm_pin ×4 | | `iso4762_m3x20` | 28 | 31 | femur_to_horn ×16, bracket_screws ×8, tibia_foot ×4 | | `iso4762_m3x25` | 12 | 14 | crank_pins ×8, tibia_link_pin ×4 | | `iso4762_m3x40` | 1 | 3 | camera tilt pivot ×1 | | `iso4762_m3x8` | 34 | 38 | shell_to_shoulder_bottom ×8, horn_centre_screw ×8, hip_horn ×8, shell_to_shoulder_top ×6, knee_drive_centre_screw ×4 | | `iso7089_m3` | 16 | 18 | knee_axle ×8, hip_pivot ×4, arm_pin ×4 | | `standoff_m2_5_ff_6` | 10 | 12 | board stand-off ×8, INA260 stand-off ×2 | | `standoff_m2_ff_8` | 3 | 5 | regulator stand-off ×3 | ## CAD coverage check (cheapest offers) Every purchased part and fastener instance in the assembled CAD model must be supplied by the offers a strategy buys (packs rounded up; fasteners with max(2, 10 %) spares). Checked for every strategy in the shopping list. | CAD id | In CAD | Needed | Supplied | OK | |---|---|---|---|---| | `adafruit_ina260` | 1 | 1 | 1 | yes | | `adafruit_lsm6dsox` | 1 | 1 | 1 | yes | | `adafruit_pca9685` | 1 | 1 | 1 | yes | | `bearing_625zz` | 4 | 4 | 4 | yes | | `bearing_633zz` | 8 | 8 | 8 | yes | | `bearing_6805_2rs` | 4 | 4 | 4 | yes | | `cable_tie_mount` | 2 | 2 | 2 | yes | | `din985_m3` | 17 | 19 | 100 | yes | | `fuse_holder_ato` | 2 | 2 | 10 | yes | | `hobbywing_ubec_10a` | 2 | 2 | 2 | yes | | `iso4032_m2` | 4 | 6 | 100 | yes | | `iso4032_m2_5` | 2 | 4 | 100 | yes | | `iso4032_m3` | 56 | 62 | 360 | yes | | `iso4762_m2_5x5` | 20 | 22 | 30 | yes | | `iso4762_m2_5x6` | 8 | 10 | 10 | yes | | `iso4762_m2x10` | 4 | 6 | 10 | yes | | `iso4762_m2x5` | 10 | 12 | 20 | yes | | `iso4762_m3x10` | 86 | 95 | 130 | yes | | `iso4762_m3x12` | 16 | 18 | 30 | yes | | `iso4762_m3x16` | 4 | 6 | 30 | yes | | `iso4762_m3x20` | 28 | 31 | 40 | yes | | `iso4762_m3x25` | 12 | 14 | 20 | yes | | `iso4762_m3x40` | 1 | 3 | 20 | yes | | `iso4762_m3x8` | 34 | 38 | 40 | yes | | `iso7089_m3` | 16 | 18 | 360 | yes | | `lipo_2s_5000_gensace` | 1 | 1 | 1 | yes | | `pololu_d36v50f5` | 1 | 1 | 1 | yes | | `pololu_power_switch_hp` | 1 | 1 | 1 | yes | | `pushbutton_12mm` | 1 | 1 | 1 | yes | | `raspberry_pi_5` | 1 | 1 | 1 | yes | | `rpi_camera_module_3_wide` | 1 | 1 | 1 | yes | | `rplidar_c1` | 1 | 1 | 1 | yes | | `rplidar_usb_adapter` | 1 | 1 | 1 | yes | | `seeed_xiao_esp32s3` | 1 | 1 | 1 | yes | | `servo_arm_horn_25t` | 4 | 4 | 5 | yes | | `servo_disc_horn_25t` | 8 | 8 | 10 | yes | | `servo_spt5435lv` | 12 | 12 | 13 | yes | | `sleeve_bearing_3x5x5_pair` | 16 | 16 | 25 | yes | | `standoff_m2_5_ff_6` | 10 | 12 | 12 | yes | | `standoff_m2_ff_8` | 3 | 5 | 40 | yes | | `wago_221_415` | 8 | 8 | 8 | yes | | `waveshare_lcd_2inch` | 1 | 1 | 1 | yes | | `xt60h_female` | 2 | 2 | 3 | yes | Result: all covered Not modelled in the CAD and therefore bought from the lines above without a coverage check: cables, wire, fuses, consumables, microSD, charger. ## Tools (not in the totals) | Tool | Vendor | Price | |---|---|---| | Hex keys 1.5-2.5 mm (Wera 950/9 Hex-Plus) | Amazon.de B009ODV0OE | €28.22 | | Soldering iron (Miniware TS101) | Amazon.de B0CPDRQZYB | €74.62 | | Wire stripper (Jokari Super 4 plus) | Amazon.de | €14.06 | | Dupont crimp set with housings (DxCRIMP) | Amazon.de | €30.22 | | Multimeter (Kaiweets) | Amazon.de | €46.28 | | Calipers | Amazon.de | €18.94 | | Flush cutters (Wiha 26812) | Amazon.de | €16.09 | | Nut drivers 4.0 / 5.0 / 5.5 mm (M2, M2.5, M3 nuts) | not priced | — | | Thin CA glue (liquid superglue) for hardening weak printed pilots (ADR-009) | not priced | — | | 3D printer with >= 220 x 220 mm bed (shells are 114 x 208 mm), e.g. Bambu Lab A1 Combo | eu.store.bambulab.com | €339.00 | --- # Shopping list and bundles (Austria) **How to read this page.** There are four alternative ways to buy the parts. Each plan is a *complete* shopping list for the whole robot: every purchased part, every screw and nut with spares, and the filament. Pick **one** plan and order everything listed under it; do not mix plans. The plans differ only in which shop supplies an item, so the price, the number of parcels and the delivery time differ. Tools and the 3D printer are not included (see the [tools list](README.md#tools-not-in-the-totals)). To change single items, order for several robots or fill the shop carts, use the [parts shop](../shop.md). | Plan | Choose it when | Shops (parcels) | Parts | Shipping | **Total** | vs. cheapest | |---|---|---|---|---|---|---| | [Plan A: Lowest total](#plan-a-lowest-total) | the price matters most; servos come from AliExpress (2-3 weeks; above €150 split the order or expect import VAT) | 8 | €939.23 | €39.53 | **€978.76** | cheapest | | [Plan B: Fewest shops](#plan-b-fewest-shops) | you want as few parcels and accounts as possible; costs more because Amazon replaces cheaper shops | 4 | €1,111.72 | €15.25 | **€1,126.97** | +€148.21 | | [Plan C: EU warehouses (DigiKey only for parts without an EU offer, no AliExpress)](#plan-c-eu-warehouses-digikey-only-for-parts-without-an-eu-offer-no-aliexpress) | everything from EU warehouses (no AliExpress), fastest delivery; DigiKey only where no EU shop lists the part | 9 | €1,070.76 | €74.48 | **€1,145.24** | +€166.48 | | [Plan D: Genuine boards where available](#plan-d-genuine-boards-where-available) | like A, but original Adafruit/Seeed/Pololu boards wherever a clone would be cheaper | 8 | €948.20 | €39.53 | **€987.73** | +€8.97 | Prices EUR incl. VAT, observed 2026-10-30; shipping is to Austria (free-shipping thresholds and per-order fees included). Re-check prices and stock before ordering. ## Where the plans differ 26 of 62 items come from different shops depending on the plan; every other item is bought from the same shop in all plans. Shop and cost per plan: | Item | Plan A | Plan B | Plan C | Plan D | |---|---|---|---|---| | Raspberry Pi 5, 4 GB | DigiKey.at €117.18 | DigiKey.at €117.18 | Berrybase.at €123.90 | DigiKey.at €117.18 | | Raspberry Pi Active Cooler | DigiKey.at €5.33 | DigiKey.at €5.33 | Welectron (DE) €4.90 | DigiKey.at €5.33 | | microSD 64 GB A2 (SanDisk Extreme) | Amazon.de €26.21 | Amazon.de €26.21 | Berrybase.at €25.90 | Amazon.de €26.21 | | Raspberry Pi Camera Module 3 Wide | DigiKey.at €37.28 | DigiKey.at €37.28 | Welectron (DE) €35.90 | DigiKey.at €37.28 | | Pi 5 camera cable 22→15 pin, 300 mm | DigiKey.at €2.14 | DigiKey.at €2.14 | Berrybase.at €2.30 | DigiKey.at €2.14 | | Seeed XIAO ESP32-S3, pre-soldered headers | DigiKey.at €9.05 | DigiKey.at €9.05 | Berrybase.at €9.90 | DigiKey.at €9.05 | | PCA9685 16-channel PWM board (Adafruit 815 layout) | Amazon.de €6.95 | Amazon.de €6.95 | Amazon.de €6.95 | DigiKey.at €15.92 | | Adafruit LSM6DSOX 6-DoF IMU (4438) | DigiKey.at €12.73 | DigiKey.at €12.73 | Amazon.de €20.16 | DigiKey.at €12.73 | | Adafruit INA260 power monitor (4226) | DigiKey.at €10.60 | DigiKey.at €10.60 | Eckstein (DE) €11.84 | DigiKey.at €10.60 | | Waveshare 2" LCD module, ST7789, 240x320 (17344) | Eckstein (DE) €14.27 | Amazon.de €20.16 | Welectron (DE) €10.90 | Eckstein (DE) €14.27 | | STEMMA QT to female sockets cable, 150 mm (Adafruit 4397; IMU to PCA9685 and IMU to INA260) | DigiKey.at €2.02 | DigiKey.at €2.02 | Berrybase.at €5.00 | DigiKey.at €2.02 | | SPT5435LV-180W servo, 35 kg·cm, 180° (12 + 1 spare) | AliExpress (DigitalServo Store) €174.98 | Amazon.de €292.24 | Amazon.de €292.24 | AliExpress (DigitalServo Store) €174.98 | | Ball bearing 625ZZ (5x16x5) | 3DJake.at €4.99 | Amazon.de €7.36 | 3DJake.at €4.99 | 3DJake.at €4.99 | | Hobbywing UBEC-10A (V2-Car), 6.0 V | Lindinger.at €53.98 | Amazon.de €59.80 | Lindinger.at €53.98 | Lindinger.at €53.98 | | LiPo safety bag | Lindinger.at €6.49 | Amazon.de €14.11 | Lindinger.at €6.49 | Lindinger.at €6.49 | | LiPo cell checker / low-voltage alarm | Lindinger.at €5.95 | Amazon.de €6.04 | Lindinger.at €5.95 | Lindinger.at €5.95 | | Pololu D36V50F5 5 V 5.5 A step-down regulator | Eckstein (DE) €41.59 | DigiKey.at €48.24 | Eckstein (DE) €41.59 | Eckstein (DE) €41.59 | | XT60H connectors, 3 pairs (battery lead, arming port, loop key) | Lindinger.at €4.99 | Amazon.de €6.44 | Lindinger.at €4.99 | Lindinger.at €4.99 | | Silicone wire 14 AWG / 2.5 mm², red + black, 1 m each | Lindinger.at €4.99 | Amazon.de €12.10 | Lindinger.at €4.99 | Lindinger.at €4.99 | | Silicone wire 18 AWG / 0.75 mm², red + black (1 m each per unit) | Lindinger.at €5.98 | Amazon.de €18.12 | Lindinger.at €5.98 | Lindinger.at €5.98 | | Dupont jumper set 20 cm (F-F, M-M, F-M) | Amazon.de €8.05 | Amazon.de €8.05 | Berrybase.at €4.90 | Amazon.de €8.05 | | Cable ties 100 x 2.5 mm (100) | Reichelt.at €1.16 | Reichelt.at €1.16 | Berrybase.at €0.90 | Reichelt.at €1.16 | | Stand-off M2.5 x 6 mm female-female (Würth 970060151) | DigiKey.at €6.62 | DigiKey.at €6.62 | Amazon.de €11.29 | DigiKey.at €6.62 | | Filament PETG, graphite/black, 1 kg | 3DJake.at €11.19 | Amazon.de €14.11 | 3DJake.at €11.19 | 3DJake.at €11.19 | | Filament PETG, lime/yellow (shells), 1 kg | 3DJake.at €11.19 | Amazon.de €14.36 | 3DJake.at €11.19 | 3DJake.at €11.19 | | Raspberry Pi 27 W USB-C power supply, EU plug (bench power without battery) | DigiKey.at €12.78 | DigiKey.at €12.78 | Welectron (DE) €11.90 | DigiKey.at €12.78 | How the plans are computed (`strido_publish.bom`): for each plan the optimiser tries every subset of the allowed shops, takes the cheapest allowed offer per item inside the subset and adds each shop's shipping. It does not buy dearer offers just to reach a free-shipping threshold. ## Plan A: Lowest total **€978.76** in 8 orders. Order everything below; the per-shop subtotals add up to this total. ### Amazon.de – €295.72 + shipping €2.40 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | microSD 64 GB A2 (SanDisk Extreme) | B09X7CXWQQ | €26.21 | | 1 | PCA9685 16-channel PWM board (Adafruit 815 layout) | B06XSFFXQY | €6.95 | | 1 | Slamtec RPLIDAR C1 (with USB adapter) | B0CP8YRY44 (WayPonDEV) | €70.58 | | 2 × pack of 5 | 25T aluminium disc horn | B07F71J1Q2 (DollaTek, 5-pack) | €10.06 | | 1 × pack of 5 | 25T aluminium clamping arm horn | B0F5QG8HQS (5-pack) | €9.06 | | 2 × pack of 2 | Ball bearing 6805-2RS (25x37x7) | B0CL4Z679J (2-pack) | €13.48 | | 1 × pack of 8 | Ball bearing 633ZZ (3x13x5) | B0GGT3GW69 (8-pack) | €10.27 | | 1 × pack of 50 | Sintered bronze sleeve bearing 3x5x5 | B0G15L264D (50-pack) | €10.27 | | 1 | LiPo 2S 5000 mAh hardcase, XT60, max 138 x 47 x 25 mm (Gens ace G-Tech) | B0GKP683YF | €22.18 | | 1 | LiPo balance charger, AC input (B6AC class) | B0F3CYZ6L8 (HTRC B6AC) | €46.38 | | 1 × pack of 10 | Inline ATO fuse holder, 14 AWG leads (main + logic branch) | B07426WCLM (Nilight, 10 holders) | €8.56 | | 1 | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | B0C7T9KYND (6 colours x 4 m) | €12.10 | | 1 × pack of 3 | USB-A to USB-C cable, 20 cm, 90° (Pi → XIAO, Pi → LiDAR adapter) | B09ZDV7S44 (aceyoon 20 cm 90°, 3-pack) | €7.64 | | 1 | Dupont jumper set 20 cm (F-F, M-M, F-M) | B0F98KBV61 (160 x F-F) | €8.05 | | 1 | Dupont 2.54 mm crimp contacts and housings (1-6 pin), for the cut-to-length signal leads | B0FP14VDGS (620 pieces) | €9.06 | | 1 | M3 socket head screw kit, stainless (6-50 mm, nuts, washers; WZHUIDA 1080 pcs) | B0D2VST57H | €17.14 | | 1 × pack of 40 | Stand-off M2 x 8 mm female-female, brass | B0DR67YGQL | €7.73 | _+2.40 Austrian parcel tax per order_ ### DigiKey.at – €226.53 + shipping €0.00 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Raspberry Pi 5, 4 GB | SC1431 (2648-SC1431-ND) | €117.18 | | 1 | Raspberry Pi Active Cooler | SC1148 | €5.33 | | 1 | Raspberry Pi Camera Module 3 Wide | SC1224 | €37.28 | | 1 | Pi 5 camera cable 22→15 pin, 300 mm | SC1129 | €2.14 | | 1 | Seeed XIAO ESP32-S3, pre-soldered headers | 102010634 | €9.05 | | 1 | Adafruit LSM6DSOX 6-DoF IMU (4438) | Adafruit 4438 | €12.73 | | 1 | Adafruit INA260 power monitor (4226) | Adafruit 4226 | €10.60 | | 2 | STEMMA QT to female sockets cable, 150 mm (Adafruit 4397; IMU to PCA9685 and IMU to INA260) | Adafruit 4397 | €2.02 | | 1 | Pololu #2813 Big Pushbutton Power Switch HP | Pololu 2813 (Marketplace) | €10.80 | | 12 | Stand-off M2.5 x 6 mm female-female (Würth 970060151) | 970060151 | €6.62 | | 1 | Raspberry Pi 27 W USB-C power supply, EU plug (bench power without battery) | SC1407 (not SC1153 = US plug) | €12.78 | ### AliExpress (DigitalServo Store) – €174.98 + shipping €5.90 | Order | Item | SKU | Cost | |---|---|---|---| | 13 | SPT5435LV-180W servo, 35 kg·cm, 180° (12 + 1 spare) | 4000135787995, variant 35kg-180 Waterproof | €174.98 | _servo order > 150 EUR leaves IOSS: split into two orders or expect import VAT/clearance_ ### Lindinger.at – €82.38 + shipping €5.99 | Order | Item | SKU | Cost | |---|---|---|---| | 2 | Hobbywing UBEC-10A (V2-Car), 6.0 V | HW30603003 | €53.98 | | 1 | LiPo safety bag | Robbe 23x30 cm (9787514) | €6.49 | | 1 | LiPo cell checker / low-voltage alarm | Absima (9771616) | €5.95 | | 1 | XT60H connectors, 3 pairs (battery lead, arming port, loop key) | Robbe XT60 3 pairs (9750234) | €4.99 | | 1 | Silicone wire 14 AWG / 2.5 mm², red + black, 1 m each | 20479 | €4.99 | | 2 | Silicone wire 18 AWG / 0.75 mm², red + black (1 m each per unit) | 44422 | €5.98 | ### Reichelt.at – €56.28 + shipping €6.95 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Resistor 2.2 kΩ 0.25 W (PCA9685 OE pull-up) | METALL 2,20K | €0.10 | | 1 | 12 mm momentary pushbutton, stainless (ONPOW GQ12B-10S) | 318555 | €5.66 | | 3 | ATO blade fuse 25 A (main, 1 + 2 spare) | 7786 | €1.08 | | 2 | ATO blade fuse 7.5 A (logic branch, 1 + 1 spare) | FKS 7.5 A (same series as 7786) | €0.72 | | 8 | WAGO 221-415 lever connector | 149800 | €6.48 | | 1 | Heat-shrink assortment | 152924 | €5.39 | | 1 | Cable ties 100 x 2.5 mm (100) | Delock 19186 | €1.16 | | 1 | Spiral wrap, black PE, for bundles 4-10 mm (5-10 m) | CIMCO 186220 (10 m, bundles 4-20 mm) | €4.84 | | 2 | Adhesive cable-tie mount 13 x 13 mm (HellermannTyton MB2APT-A) | HT 151-02243 (per piece) | €2.12 | | 1 | Solder wire, lead-free, 0.8 mm, flux core (50-100 g) | Goobay 420259 (50 g, SAC) | €13.61 | | 1 | Hook-and-loop straps (battery strap) | 280182 | €5.14 | | 1 | Double-sided foam mounting tape, 19 mm | tesa PRO Universal 338987 | €9.98 | ### Eckstein (DE) – €55.86 + shipping €6.90 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Waveshare 2" LCD module, ST7789, 240x320 (17344) | Waveshare 2inch LCD | €14.27 | | 1 | Pololu D36V50F5 5 V 5.5 A step-down regulator | D36V50F5 | €41.59 | _shipping to AT not shown before checkout: 6.90 assumed (UNVERIFIED)_ ### 3DJake.at – €27.37 + shipping €5.49 | Order | Item | SKU | Cost | |---|---|---|---| | 1 × pack of 10 | Ball bearing 625ZZ (5x16x5) | 625ZZ 10-pack | €4.99 | | 1 | Filament PETG, graphite/black, 1 kg | ELG-50.203.0216 (Elegoo Rapid PETG black) | €11.19 | | 1 | Filament PETG, lime/yellow (shells), 1 kg | ELG-50.203.0221 (Elegoo Rapid PETG yellow) | €11.19 | ### Schraubenking.at – €20.11 + shipping €5.90 | Order | Item | SKU | Cost | |---|---|---|---| | 1 × pack of 100 | M3 x 10 DIN 912 A2 | M3x10 DIN912 A2, 100 | €2.35 | | 1 × pack of 10 | M3 x 20 DIN 912 A2 | M3x20 DIN912 A2, 10 | €0.97 | | 1 × pack of 100 | M3 DIN 985 nylon lock nut | P000461 (zinc-plated) | €1.39 | | 3 × pack of 10 | M2.5 x 5 DIN 912 A2 | P004986 | €2.49 | | 1 × pack of 10 | M2.5 x 6 DIN 912 A2 | P005010 | €0.83 | | 2 × pack of 10 | M2 x 5 DIN 912 A2 | P003957 | €1.72 | | 1 × pack of 10 | M2 x 10 DIN 912 A2 | P002803 | €0.78 | | 1 × pack of 100 | M2 hex nut DIN 934 A2 | P002807 | €1.87 | | 1 × pack of 100 | M2.5 hex nut DIN 934 A2 | P004196 | €2.00 | | 1 | Thread locker, medium strength (blue) | Förch 10 ml (P002471) | €5.71 | ## Plan B: Fewest shops **€1,126.97** in 4 orders. Order everything below; the per-shop subtotals add up to this total. ### Amazon.de – €760.56 + shipping €2.40 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | microSD 64 GB A2 (SanDisk Extreme) | B09X7CXWQQ | €26.21 | | 1 | PCA9685 16-channel PWM board (Adafruit 815 layout) | B06XSFFXQY | €6.95 | | 1 | Waveshare 2" LCD module, ST7789, 240x320 (17344) | B081NBBRWS | €20.16 | | 1 | Slamtec RPLIDAR C1 (with USB adapter) | B0CP8YRY44 (WayPonDEV) | €70.58 | | 13 | SPT5435LV-180W servo, 35 kg·cm, 180° (12 + 1 spare) | B0G39ZHL4B (angle not stated) | €292.24 | | 2 × pack of 5 | 25T aluminium disc horn | B07F71J1Q2 (DollaTek, 5-pack) | €10.06 | | 1 × pack of 5 | 25T aluminium clamping arm horn | B0F5QG8HQS (5-pack) | €9.06 | | 1 × pack of 15 | Ball bearing 625ZZ (5x16x5) | B0CM8TYDR6 (15-pack) | €7.36 | | 2 × pack of 2 | Ball bearing 6805-2RS (25x37x7) | B0CL4Z679J (2-pack) | €13.48 | | 1 × pack of 8 | Ball bearing 633ZZ (3x13x5) | B0GGT3GW69 (8-pack) | €10.27 | | 1 × pack of 50 | Sintered bronze sleeve bearing 3x5x5 | B0G15L264D (50-pack) | €10.27 | | 2 | Hobbywing UBEC-10A (V2-Car), 6.0 V | B0BCWYQYKW (genuine Hobbywing, marketplace sellers) | €59.80 | | 1 | LiPo 2S 5000 mAh hardcase, XT60, max 138 x 47 x 25 mm (Gens ace G-Tech) | B0GKP683YF | €22.18 | | 1 | LiPo balance charger, AC input (B6AC class) | B0F3CYZ6L8 (HTRC B6AC) | €46.38 | | 1 | LiPo safety bag | B09C31KVK1 | €14.11 | | 1 | LiPo cell checker / low-voltage alarm | B077Z1N9CS | €6.04 | | 1 | XT60H connectors, 3 pairs (battery lead, arming port, loop key) | B07C3KH1VT (Amass, 3 pairs) | €6.44 | | 1 × pack of 10 | Inline ATO fuse holder, 14 AWG leads (main + logic branch) | B07426WCLM (Nilight, 10 holders) | €8.56 | | 1 | Silicone wire 14 AWG / 2.5 mm², red + black, 1 m each | B0C53D343Y (5 m) | €12.10 | | 2 | Silicone wire 18 AWG / 0.75 mm², red + black (1 m each per unit) | B0D12R349S (5+5 m) | €18.12 | | 1 | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | B0C7T9KYND (6 colours x 4 m) | €12.10 | | 1 × pack of 3 | USB-A to USB-C cable, 20 cm, 90° (Pi → XIAO, Pi → LiDAR adapter) | B09ZDV7S44 (aceyoon 20 cm 90°, 3-pack) | €7.64 | | 1 | Dupont jumper set 20 cm (F-F, M-M, F-M) | B0F98KBV61 (160 x F-F) | €8.05 | | 1 | Dupont 2.54 mm crimp contacts and housings (1-6 pin), for the cut-to-length signal leads | B0FP14VDGS (620 pieces) | €9.06 | | 1 | M3 socket head screw kit, stainless (6-50 mm, nuts, washers; WZHUIDA 1080 pcs) | B0D2VST57H | €17.14 | | 1 × pack of 40 | Stand-off M2 x 8 mm female-female, brass | B0DR67YGQL | €7.73 | | 1 | Filament PETG, graphite/black, 1 kg | B07TJDTJZD (Sunlu PETG black) | €14.11 | | 1 | Filament PETG, lime/yellow (shells), 1 kg | B0D9NDB63H (Sunlu Rapid PETG yellow) | €14.36 | _+2.40 Austrian parcel tax per order_ ### DigiKey.at – €274.77 + shipping €0.00 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Raspberry Pi 5, 4 GB | SC1431 (2648-SC1431-ND) | €117.18 | | 1 | Raspberry Pi Active Cooler | SC1148 | €5.33 | | 1 | Raspberry Pi Camera Module 3 Wide | SC1224 | €37.28 | | 1 | Pi 5 camera cable 22→15 pin, 300 mm | SC1129 | €2.14 | | 1 | Seeed XIAO ESP32-S3, pre-soldered headers | 102010634 | €9.05 | | 1 | Adafruit LSM6DSOX 6-DoF IMU (4438) | Adafruit 4438 | €12.73 | | 1 | Adafruit INA260 power monitor (4226) | Adafruit 4226 | €10.60 | | 2 | STEMMA QT to female sockets cable, 150 mm (Adafruit 4397; IMU to PCA9685 and IMU to INA260) | Adafruit 4397 | €2.02 | | 1 | Pololu D36V50F5 5 V 5.5 A step-down regulator | Pololu 4091 (Marketplace) | €48.24 | | 1 | Pololu #2813 Big Pushbutton Power Switch HP | Pololu 2813 (Marketplace) | €10.80 | | 12 | Stand-off M2.5 x 6 mm female-female (Würth 970060151) | 970060151 | €6.62 | | 1 | Raspberry Pi 27 W USB-C power supply, EU plug (bench power without battery) | SC1407 (not SC1153 = US plug) | €12.78 | ### Reichelt.at – €56.28 + shipping €6.95 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Resistor 2.2 kΩ 0.25 W (PCA9685 OE pull-up) | METALL 2,20K | €0.10 | | 1 | 12 mm momentary pushbutton, stainless (ONPOW GQ12B-10S) | 318555 | €5.66 | | 3 | ATO blade fuse 25 A (main, 1 + 2 spare) | 7786 | €1.08 | | 2 | ATO blade fuse 7.5 A (logic branch, 1 + 1 spare) | FKS 7.5 A (same series as 7786) | €0.72 | | 8 | WAGO 221-415 lever connector | 149800 | €6.48 | | 1 | Heat-shrink assortment | 152924 | €5.39 | | 1 | Cable ties 100 x 2.5 mm (100) | Delock 19186 | €1.16 | | 1 | Spiral wrap, black PE, for bundles 4-10 mm (5-10 m) | CIMCO 186220 (10 m, bundles 4-20 mm) | €4.84 | | 2 | Adhesive cable-tie mount 13 x 13 mm (HellermannTyton MB2APT-A) | HT 151-02243 (per piece) | €2.12 | | 1 | Solder wire, lead-free, 0.8 mm, flux core (50-100 g) | Goobay 420259 (50 g, SAC) | €13.61 | | 1 | Hook-and-loop straps (battery strap) | 280182 | €5.14 | | 1 | Double-sided foam mounting tape, 19 mm | tesa PRO Universal 338987 | €9.98 | ### Schraubenking.at – €20.11 + shipping €5.90 | Order | Item | SKU | Cost | |---|---|---|---| | 1 × pack of 100 | M3 x 10 DIN 912 A2 | M3x10 DIN912 A2, 100 | €2.35 | | 1 × pack of 10 | M3 x 20 DIN 912 A2 | M3x20 DIN912 A2, 10 | €0.97 | | 1 × pack of 100 | M3 DIN 985 nylon lock nut | P000461 (zinc-plated) | €1.39 | | 3 × pack of 10 | M2.5 x 5 DIN 912 A2 | P004986 | €2.49 | | 1 × pack of 10 | M2.5 x 6 DIN 912 A2 | P005010 | €0.83 | | 2 × pack of 10 | M2 x 5 DIN 912 A2 | P003957 | €1.72 | | 1 × pack of 10 | M2 x 10 DIN 912 A2 | P002803 | €0.78 | | 1 × pack of 100 | M2 hex nut DIN 934 A2 | P002807 | €1.87 | | 1 × pack of 100 | M2.5 hex nut DIN 934 A2 | P004196 | €2.00 | | 1 | Thread locker, medium strength (blue) | Förch 10 ml (P002471) | €5.71 | ## Plan C: EU warehouses (DigiKey only for parts without an EU offer, no AliExpress) **€1,145.24** in 9 orders. Order everything below; the per-shop subtotals add up to this total. ### Amazon.de – €585.15 + shipping €2.40 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | PCA9685 16-channel PWM board (Adafruit 815 layout) | B06XSFFXQY | €6.95 | | 1 | Adafruit LSM6DSOX 6-DoF IMU (4438) | B085F3F19D | €20.16 | | 1 | Slamtec RPLIDAR C1 (with USB adapter) | B0CP8YRY44 (WayPonDEV) | €70.58 | | 13 | SPT5435LV-180W servo, 35 kg·cm, 180° (12 + 1 spare) | B0G39ZHL4B (angle not stated) | €292.24 | | 2 × pack of 5 | 25T aluminium disc horn | B07F71J1Q2 (DollaTek, 5-pack) | €10.06 | | 1 × pack of 5 | 25T aluminium clamping arm horn | B0F5QG8HQS (5-pack) | €9.06 | | 2 × pack of 2 | Ball bearing 6805-2RS (25x37x7) | B0CL4Z679J (2-pack) | €13.48 | | 1 × pack of 8 | Ball bearing 633ZZ (3x13x5) | B0GGT3GW69 (8-pack) | €10.27 | | 1 × pack of 50 | Sintered bronze sleeve bearing 3x5x5 | B0G15L264D (50-pack) | €10.27 | | 1 | LiPo 2S 5000 mAh hardcase, XT60, max 138 x 47 x 25 mm (Gens ace G-Tech) | B0GKP683YF | €22.18 | | 1 | LiPo balance charger, AC input (B6AC class) | B0F3CYZ6L8 (HTRC B6AC) | €46.38 | | 1 × pack of 10 | Inline ATO fuse holder, 14 AWG leads (main + logic branch) | B07426WCLM (Nilight, 10 holders) | €8.56 | | 1 | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | B0C7T9KYND (6 colours x 4 m) | €12.10 | | 1 × pack of 3 | USB-A to USB-C cable, 20 cm, 90° (Pi → XIAO, Pi → LiDAR adapter) | B09ZDV7S44 (aceyoon 20 cm 90°, 3-pack) | €7.64 | | 1 | Dupont 2.54 mm crimp contacts and housings (1-6 pin), for the cut-to-length signal leads | B0FP14VDGS (620 pieces) | €9.06 | | 1 | M3 socket head screw kit, stainless (6-50 mm, nuts, washers; WZHUIDA 1080 pcs) | B0D2VST57H | €17.14 | | 1 × pack of 12 | Stand-off M2.5 x 6 mm female-female (Würth 970060151) | B0GK7QTZ7B (brass F-F assortment; confirm the 6 mm variant) | €11.29 | | 1 × pack of 40 | Stand-off M2 x 8 mm female-female, brass | B0DR67YGQL | €7.73 | _+2.40 Austrian parcel tax per order_ ### Berrybase.at – €172.80 + shipping €5.95 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Raspberry Pi 5, 4 GB | RPI5-4GB | €123.90 | | 1 | microSD 64 GB A2 (SanDisk Extreme) | SanDisk Extreme A2 64GB | €25.90 | | 1 | Pi 5 camera cable 22→15 pin, 300 mm | RPI-CC-SM-30 | €2.30 | | 1 | Seeed XIAO ESP32-S3, pre-soldered headers | SE-102010634 | €9.90 | | 2 | STEMMA QT to female sockets cable, 150 mm (Adafruit 4397; IMU to PCA9685 and IMU to INA260) | SE-114992154 (Seeed, alternative) | €5.00 | | 1 | Dupont jumper set 20 cm (F-F, M-M, F-M) | DUPK-SET-20 | €4.90 | | 1 | Cable ties 100 x 2.5 mm (100) | E-E510 | €0.90 | ### Lindinger.at – €82.38 + shipping €5.99 | Order | Item | SKU | Cost | |---|---|---|---| | 2 | Hobbywing UBEC-10A (V2-Car), 6.0 V | HW30603003 | €53.98 | | 1 | LiPo safety bag | Robbe 23x30 cm (9787514) | €6.49 | | 1 | LiPo cell checker / low-voltage alarm | Absima (9771616) | €5.95 | | 1 | XT60H connectors, 3 pairs (battery lead, arming port, loop key) | Robbe XT60 3 pairs (9750234) | €4.99 | | 1 | Silicone wire 14 AWG / 2.5 mm², red + black, 1 m each | 20479 | €4.99 | | 2 | Silicone wire 18 AWG / 0.75 mm², red + black (1 m each per unit) | 44422 | €5.98 | ### Welectron (DE) – €63.60 + shipping €9.90 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Raspberry Pi Active Cooler | SC1148 | €4.90 | | 1 | Raspberry Pi Camera Module 3 Wide | Camera Module 3 Wide | €35.90 | | 1 | Waveshare 2" LCD module, ST7789, 240x320 (17344) | 17344 | €10.90 | | 1 | Raspberry Pi 27 W USB-C power supply, EU plug (bench power without battery) | SC1407 | €11.90 | ### Reichelt.at – €55.12 + shipping €6.95 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Resistor 2.2 kΩ 0.25 W (PCA9685 OE pull-up) | METALL 2,20K | €0.10 | | 1 | 12 mm momentary pushbutton, stainless (ONPOW GQ12B-10S) | 318555 | €5.66 | | 3 | ATO blade fuse 25 A (main, 1 + 2 spare) | 7786 | €1.08 | | 2 | ATO blade fuse 7.5 A (logic branch, 1 + 1 spare) | FKS 7.5 A (same series as 7786) | €0.72 | | 8 | WAGO 221-415 lever connector | 149800 | €6.48 | | 1 | Heat-shrink assortment | 152924 | €5.39 | | 1 | Spiral wrap, black PE, for bundles 4-10 mm (5-10 m) | CIMCO 186220 (10 m, bundles 4-20 mm) | €4.84 | | 2 | Adhesive cable-tie mount 13 x 13 mm (HellermannTyton MB2APT-A) | HT 151-02243 (per piece) | €2.12 | | 1 | Solder wire, lead-free, 0.8 mm, flux core (50-100 g) | Goobay 420259 (50 g, SAC) | €13.61 | | 1 | Hook-and-loop straps (battery strap) | 280182 | €5.14 | | 1 | Double-sided foam mounting tape, 19 mm | tesa PRO Universal 338987 | €9.98 | ### Eckstein (DE) – €53.43 + shipping €6.90 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Adafruit INA260 power monitor (4226) | Adafruit 4226 | €11.84 | | 1 | Pololu D36V50F5 5 V 5.5 A step-down regulator | D36V50F5 | €41.59 | _shipping to AT not shown before checkout: 6.90 assumed (UNVERIFIED)_ ### 3DJake.at – €27.37 + shipping €5.49 | Order | Item | SKU | Cost | |---|---|---|---| | 1 × pack of 10 | Ball bearing 625ZZ (5x16x5) | 625ZZ 10-pack | €4.99 | | 1 | Filament PETG, graphite/black, 1 kg | ELG-50.203.0216 (Elegoo Rapid PETG black) | €11.19 | | 1 | Filament PETG, lime/yellow (shells), 1 kg | ELG-50.203.0221 (Elegoo Rapid PETG yellow) | €11.19 | ### Schraubenking.at – €20.11 + shipping €5.90 | Order | Item | SKU | Cost | |---|---|---|---| | 1 × pack of 100 | M3 x 10 DIN 912 A2 | M3x10 DIN912 A2, 100 | €2.35 | | 1 × pack of 10 | M3 x 20 DIN 912 A2 | M3x20 DIN912 A2, 10 | €0.97 | | 1 × pack of 100 | M3 DIN 985 nylon lock nut | P000461 (zinc-plated) | €1.39 | | 3 × pack of 10 | M2.5 x 5 DIN 912 A2 | P004986 | €2.49 | | 1 × pack of 10 | M2.5 x 6 DIN 912 A2 | P005010 | €0.83 | | 2 × pack of 10 | M2 x 5 DIN 912 A2 | P003957 | €1.72 | | 1 × pack of 10 | M2 x 10 DIN 912 A2 | P002803 | €0.78 | | 1 × pack of 100 | M2 hex nut DIN 934 A2 | P002807 | €1.87 | | 1 × pack of 100 | M2.5 hex nut DIN 934 A2 | P004196 | €2.00 | | 1 | Thread locker, medium strength (blue) | Förch 10 ml (P002471) | €5.71 | ### DigiKey.at – €10.80 + shipping €25.00 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Pololu #2813 Big Pushbutton Power Switch HP | Pololu 2813 (Marketplace) | €10.80 | ## Plan D: Genuine boards where available **€987.73** in 8 orders. Order everything below; the per-shop subtotals add up to this total. ### Amazon.de – €288.77 + shipping €2.40 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | microSD 64 GB A2 (SanDisk Extreme) | B09X7CXWQQ | €26.21 | | 1 | Slamtec RPLIDAR C1 (with USB adapter) | B0CP8YRY44 (WayPonDEV) | €70.58 | | 2 × pack of 5 | 25T aluminium disc horn | B07F71J1Q2 (DollaTek, 5-pack) | €10.06 | | 1 × pack of 5 | 25T aluminium clamping arm horn | B0F5QG8HQS (5-pack) | €9.06 | | 2 × pack of 2 | Ball bearing 6805-2RS (25x37x7) | B0CL4Z679J (2-pack) | €13.48 | | 1 × pack of 8 | Ball bearing 633ZZ (3x13x5) | B0GGT3GW69 (8-pack) | €10.27 | | 1 × pack of 50 | Sintered bronze sleeve bearing 3x5x5 | B0G15L264D (50-pack) | €10.27 | | 1 | LiPo 2S 5000 mAh hardcase, XT60, max 138 x 47 x 25 mm (Gens ace G-Tech) | B0GKP683YF | €22.18 | | 1 | LiPo balance charger, AC input (B6AC class) | B0F3CYZ6L8 (HTRC B6AC) | €46.38 | | 1 × pack of 10 | Inline ATO fuse holder, 14 AWG leads (main + logic branch) | B07426WCLM (Nilight, 10 holders) | €8.56 | | 1 | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | B0C7T9KYND (6 colours x 4 m) | €12.10 | | 1 × pack of 3 | USB-A to USB-C cable, 20 cm, 90° (Pi → XIAO, Pi → LiDAR adapter) | B09ZDV7S44 (aceyoon 20 cm 90°, 3-pack) | €7.64 | | 1 | Dupont jumper set 20 cm (F-F, M-M, F-M) | B0F98KBV61 (160 x F-F) | €8.05 | | 1 | Dupont 2.54 mm crimp contacts and housings (1-6 pin), for the cut-to-length signal leads | B0FP14VDGS (620 pieces) | €9.06 | | 1 | M3 socket head screw kit, stainless (6-50 mm, nuts, washers; WZHUIDA 1080 pcs) | B0D2VST57H | €17.14 | | 1 × pack of 40 | Stand-off M2 x 8 mm female-female, brass | B0DR67YGQL | €7.73 | _+2.40 Austrian parcel tax per order_ ### DigiKey.at – €242.45 + shipping €0.00 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Raspberry Pi 5, 4 GB | SC1431 (2648-SC1431-ND) | €117.18 | | 1 | Raspberry Pi Active Cooler | SC1148 | €5.33 | | 1 | Raspberry Pi Camera Module 3 Wide | SC1224 | €37.28 | | 1 | Pi 5 camera cable 22→15 pin, 300 mm | SC1129 | €2.14 | | 1 | Seeed XIAO ESP32-S3, pre-soldered headers | 102010634 | €9.05 | | 1 | PCA9685 16-channel PWM board (Adafruit 815 layout) | Adafruit 815 | €15.92 | | 1 | Adafruit LSM6DSOX 6-DoF IMU (4438) | Adafruit 4438 | €12.73 | | 1 | Adafruit INA260 power monitor (4226) | Adafruit 4226 | €10.60 | | 2 | STEMMA QT to female sockets cable, 150 mm (Adafruit 4397; IMU to PCA9685 and IMU to INA260) | Adafruit 4397 | €2.02 | | 1 | Pololu #2813 Big Pushbutton Power Switch HP | Pololu 2813 (Marketplace) | €10.80 | | 12 | Stand-off M2.5 x 6 mm female-female (Würth 970060151) | 970060151 | €6.62 | | 1 | Raspberry Pi 27 W USB-C power supply, EU plug (bench power without battery) | SC1407 (not SC1153 = US plug) | €12.78 | ### AliExpress (DigitalServo Store) – €174.98 + shipping €5.90 | Order | Item | SKU | Cost | |---|---|---|---| | 13 | SPT5435LV-180W servo, 35 kg·cm, 180° (12 + 1 spare) | 4000135787995, variant 35kg-180 Waterproof | €174.98 | _servo order > 150 EUR leaves IOSS: split into two orders or expect import VAT/clearance_ ### Lindinger.at – €82.38 + shipping €5.99 | Order | Item | SKU | Cost | |---|---|---|---| | 2 | Hobbywing UBEC-10A (V2-Car), 6.0 V | HW30603003 | €53.98 | | 1 | LiPo safety bag | Robbe 23x30 cm (9787514) | €6.49 | | 1 | LiPo cell checker / low-voltage alarm | Absima (9771616) | €5.95 | | 1 | XT60H connectors, 3 pairs (battery lead, arming port, loop key) | Robbe XT60 3 pairs (9750234) | €4.99 | | 1 | Silicone wire 14 AWG / 2.5 mm², red + black, 1 m each | 20479 | €4.99 | | 2 | Silicone wire 18 AWG / 0.75 mm², red + black (1 m each per unit) | 44422 | €5.98 | ### Reichelt.at – €56.28 + shipping €6.95 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Resistor 2.2 kΩ 0.25 W (PCA9685 OE pull-up) | METALL 2,20K | €0.10 | | 1 | 12 mm momentary pushbutton, stainless (ONPOW GQ12B-10S) | 318555 | €5.66 | | 3 | ATO blade fuse 25 A (main, 1 + 2 spare) | 7786 | €1.08 | | 2 | ATO blade fuse 7.5 A (logic branch, 1 + 1 spare) | FKS 7.5 A (same series as 7786) | €0.72 | | 8 | WAGO 221-415 lever connector | 149800 | €6.48 | | 1 | Heat-shrink assortment | 152924 | €5.39 | | 1 | Cable ties 100 x 2.5 mm (100) | Delock 19186 | €1.16 | | 1 | Spiral wrap, black PE, for bundles 4-10 mm (5-10 m) | CIMCO 186220 (10 m, bundles 4-20 mm) | €4.84 | | 2 | Adhesive cable-tie mount 13 x 13 mm (HellermannTyton MB2APT-A) | HT 151-02243 (per piece) | €2.12 | | 1 | Solder wire, lead-free, 0.8 mm, flux core (50-100 g) | Goobay 420259 (50 g, SAC) | €13.61 | | 1 | Hook-and-loop straps (battery strap) | 280182 | €5.14 | | 1 | Double-sided foam mounting tape, 19 mm | tesa PRO Universal 338987 | €9.98 | ### Eckstein (DE) – €55.86 + shipping €6.90 | Order | Item | SKU | Cost | |---|---|---|---| | 1 | Waveshare 2" LCD module, ST7789, 240x320 (17344) | Waveshare 2inch LCD | €14.27 | | 1 | Pololu D36V50F5 5 V 5.5 A step-down regulator | D36V50F5 | €41.59 | _shipping to AT not shown before checkout: 6.90 assumed (UNVERIFIED)_ ### 3DJake.at – €27.37 + shipping €5.49 | Order | Item | SKU | Cost | |---|---|---|---| | 1 × pack of 10 | Ball bearing 625ZZ (5x16x5) | 625ZZ 10-pack | €4.99 | | 1 | Filament PETG, graphite/black, 1 kg | ELG-50.203.0216 (Elegoo Rapid PETG black) | €11.19 | | 1 | Filament PETG, lime/yellow (shells), 1 kg | ELG-50.203.0221 (Elegoo Rapid PETG yellow) | €11.19 | ### Schraubenking.at – €20.11 + shipping €5.90 | Order | Item | SKU | Cost | |---|---|---|---| | 1 × pack of 100 | M3 x 10 DIN 912 A2 | M3x10 DIN912 A2, 100 | €2.35 | | 1 × pack of 10 | M3 x 20 DIN 912 A2 | M3x20 DIN912 A2, 10 | €0.97 | | 1 × pack of 100 | M3 DIN 985 nylon lock nut | P000461 (zinc-plated) | €1.39 | | 3 × pack of 10 | M2.5 x 5 DIN 912 A2 | P004986 | €2.49 | | 1 × pack of 10 | M2.5 x 6 DIN 912 A2 | P005010 | €0.83 | | 2 × pack of 10 | M2 x 5 DIN 912 A2 | P003957 | €1.72 | | 1 × pack of 10 | M2 x 10 DIN 912 A2 | P002803 | €0.78 | | 1 × pack of 100 | M2 hex nut DIN 934 A2 | P002807 | €1.87 | | 1 × pack of 100 | M2.5 hex nut DIN 934 A2 | P004196 | €2.00 | | 1 | Thread locker, medium strength (blue) | Förch 10 ml (P002471) | €5.71 | ## Caveats * Low stock at research time: 633ZZ 8-pack, bronze sleeves, Lindinger Hobbywing UBEC, Amazon M2 stand-offs. Order these first or use the alternatives in `hardware/bom.yaml`. * Servos: buy the SPT5435LV **-180W** (180°) variant. The Amazon listing does not state the angle; the AliExpress order exceeds the €150 IOSS limit (split it or expect import VAT). * DigiKey ships from the US (customs-cleared, VAT included). Pololu items on DigiKey are Marketplace items shipped by Pololu; their extra shipping fee is not modelled. * DE shops show German VAT; Austrian checkout may add ~1 %. * Clone boards are fine for the PCA9685 (Adafruit 815 footprint), horns and fuse holders; buy genuine LiPo, LiDAR, Pi, regulator and UBECs (only genuine UBEC offers are listed). --- # Price research sources > **What this page is:** research notes — every offer that was found per item, including alternatives that are dearer, out of stock or superseded. It is **not** a list to order from. To buy the robot, use the [shopping list](shopping_list.md): it turns these offers into four complete alternative plans with their totals. Two research passes (2026-10-30) priced the BOM for delivery to Austria. They are reproduced here with their URLs, stock notes and caveats; `hardware/bom.yaml` holds the selected offers. Prices change: re-check before ordering. Pass 1 priced an earlier draft of the design (ESP32-S3 DevKitC, one 20 A BEC, heat-set inserts, 4 servo extensions); its strategy totals are superseded by the [shopping list](shopping_list.md). Corrections that apply to both passes: the Bambu A1 mini (180 mm bed) is too small for the 114 × 208 mm shells (use a ≥ 220 mm bed); the Pololu #2813 is rated 6 A continuous at 55 °C (16 A only at 150 °C) and #2808 3 A at 55 °C (Pololu product pages). Pass 2 priced the items that the final design added or changed (XIAO ESP32-S3, two Hobbywing UBEC-10A, Pololu switch, pushbutton, 30 cm extensions, M2/M2.5 hardware). --- ## Strido EU shopping list for an Austrian builder: prices, vendors and bundle strategies (2026-10-30) **Answer:** Three shops (DigiKey.at, Amazon.de and AliExpress) cover the whole robot BOM for **€969.59** delivered. Spreading the order over six shops gets it down to **€908.31**. An EU-stock-only build costs about **€1,149** plus Eckstein's shipping, which I couldn't see. All three totals use the Pi 5 4 GB and exclude tools and the 3D printer. **Sources:** - **Sub-agents:** most prices come from pages four research sub-agents fetched today. - **Amazon.de:** I re-fetched every ASIN used in the strategy totals today with `curl` (buy-box price and stock). - **DigiKey.at:** prices were read in a browser and include 20% Austrian VAT. - **Conrad.at:** prices are shown without VAT. The gross figures here are net × 1.2, so they are **calculated, not displayed**. - **DE shops (Welectron, Eckstein, kugellager-*, ruthex, MYBOTSHOP):** they show 19% German VAT. Whether checkout switches to Austria's 20% is UNVERIFIED. ## 1. Offers per item ### 1a. Compute, camera and boards | Item (qty) | Vendor | Product / SKU | € incl. VAT | Stock | URL | |---|---|---|---|---|---| | Pi 5 4 GB (1) | **DigiKey.at** | SC1431 (newer revision of SC1111), 2648-SC1431-ND | **117.18** | 203 | digikey.at (search 2648-SC1431-ND) | | | Berrybase.at | RPI5-4GB | 123.90 | Limited | https://www.berrybase.at/raspberry-pi-5-4gb-ram | | | Welectron | RP1-151400-P | 118.00 (DE VAT) | Out of stock | https://www.welectron.com/Raspberry-Pi-5-4-GB-RAM | | | Reichelt.at | RASP PI 5 B 4GB | 137.55 | Pre-order | https://www.reichelt.at/at/de/shop/produkt/raspberry_pi_5_b_4x_2_4_ghz_4_gb_ram_wlan_bt-359842 | | Pi 5 8 GB (option) | DigiKey.at | SC1432 | **186.42** | 19,766 | digikey.at | | | Welectron | RP1-151800-P | 189.00 | In stock | https://www.welectron.com/Raspberry-Pi-5-8-GB-RAM_1 | | | Reichelt.at / Berrybase.at | – | 191.59 / 189.00 | In stock / Limited | reichelt …-359846 / https://www.berrybase.at/raspberry-pi-5-8gb-ram | | Active Cooler (1) | DigiKey / Welectron / Reichelt / Berrybase | SC1148 | 5.33 / 4.90 / 5.60 / 5.90 | All in stock (Berrybase limited) | https://www.welectron.com/Offizieller-Raspberry-Pi-Active-Cooler | | 64 GB A2 microSD (1) | **Amazon.de** | SanDisk Extreme 64GB, B09X7CXWQQ | **26.21** | In stock | https://www.amazon.de/dp/B09X7CXWQQ | | | Berrybase.at | SanDisk Extreme A2 64GB | 25.90 | Limited | https://www.berrybase.at/sandisk-extreme-microsdxc-a2-uhs-i-u3-v30-170mb-s-speicherkarte-adapter-64gb | | | Reichelt.at | Official Raspberry Pi A2 64GB | 44.62 | In stock | https://www.reichelt.at/at/de/shop/produkt/raspberry_pi_-_microsd-karte_a2-class_64_gb-383381 | | Cam Module 3 **Wide** (1) | DigiKey.at | SC1224, 2648-SC1224-ND | 37.28 | 6,881 | digikey.at | | | Welectron | Official CM3 Wide | **35.90** | In stock | https://www.welectron.com/Official-Raspberry-Pi-Camera-Module-3-Wide | | | Berrybase / Reichelt | – | 39.90 / 38.32 | Sold out / Out of stock | https://www.berrybase.at/raspberry-pi-camera-module-3-wide-12mp | | Cam Module 3 standard (option) | Welectron / Berrybase / Reichelt | – | 25.90 / 28.90 / 30.15 | In stock / Limited / In stock | https://www.welectron.com/Official-Raspberry-Pi-Camera-Module-3 | | Pi 5 camera cable, 200 mm (1) | DigiKey / Berrybase / Welectron | SC1892 / RPI-CC-SM-20 / – | 1.07 / 1.20 / 1.90 | In stock | https://www.berrybase.at/raspberry-pi-camera-cable-standard-mini-200mm | | 27 W USB-C PSU, EU plug | DigiKey (SC1407) / Welectron / Berrybase | – | 12.78 / 11.90 / 12.40 | In stock | DigiKey also lists SC1153, which is the **US-plug** version; don't order it | | ESP32-S3-DevKitC-1-**N16R8**, genuine | Reichelt.at | 444792 | 17.75 | **Out of stock** | https://www.reichelt.at/at/de/shop/produkt/entwicklungsboard_esp32-s3-wroom-1-n16r8-444792 | | | soyter.pl | genuine Espressif | 20.87 (VAT basis unclear) | **Out of stock** | https://soyter.pl/en/products/espressif-esp32-s3-devkitc-1-n16r8-development-board-4875.html | | | DigiKey.at / Mouser.at | – | not listed / UNVERIFIED (bot block) | – | – | | ↳ genuine alternative | DigiKey.at | ESP32-S3-DevKitC-1-**N32R16V** | 17.42 | 53 | digikey.at. Uses octal 1.8 V PSRAM, so GPIO35–37 are not usable | | ↳ clone N16R8 | Amazon.de | B0FYFF8CB2 | **10.07** | In stock | https://www.amazon.de/dp/B0FYFF8CB2 | | | makeroo.de | ESP32-S3 N16R8 DevKit (made in China; small business, no VAT charged) | 7.99 | – | https://makeroo.de/products/esp32-s3-n16r8-dev-kit-dual-usb-c-16mb-flash-8mb-psram-ai-optimiert-wifi-bluetooth-microcontroller (shipping to AT UNVERIFIED) | | PCA9685 (1) | DigiKey.at | Adafruit 815, 1528-815-ND | 15.92 | 366 | digikey.at | | | Amazon.de | JZK clone, B06XSFFXQY | **6.95** | – | https://www.amazon.de/dp/B06XSFFXQY | | | Amazon.de | DollaTek clone, B0BKZC1XWR | 7.04 | 10 left | https://www.amazon.de/dp/B0BKZC1XWR | | | Eckstein | QITA clone | 10.50 | In stock | https://eckstein-shop.de/16CH-Servomotor-Treiber-Modul-PCA9685-PWM-fuer-Arduino-Raspberry-Pi | | LSM6DSOX IMU (1) | DigiKey.at | Adafruit 4438 | **12.73** | 270 | digikey.at | | | Amazon.de | B085F3F19D | 20.16 | 3 left | https://www.amazon.de/dp/B085F3F19D | | | Berrybase.at | ADA4438 | 11.90 | Sold out | https://www.berrybase.at/adafruit-lsm6dsox-6-dof-beschleugnigunssensor-und-gyroskop | | INA260 (1) | DigiKey.at | Adafruit 4226 | **10.60** | 1,375 | digikey.at | | | Eckstein | 4226 | 11.84 | In stock | https://eckstein-shop.de/AdafruitINA260HighorLowSideVoltage2FCurrent2FPowerSensor | | | Berrybase | ADA4226 | 10.95 | Sold out | https://www.berrybase.at/adafruit-ina260-high-oder-low-side-spannungs-strom-und-leistungs-sensor | | Waveshare 2" LCD, ST7789 (1) | Welectron | 17344 | **10.90** | In stock | https://www.welectron.com/Waveshare-17344-2inch-LCD-Module_1 | | | Amazon.de | B081NBBRWS | 20.16 | In stock | https://www.amazon.de/dp/B081NBBRWS | | | Eckstein | – | 14.27 | In stock | https://eckstein-shop.de/WaveShare-240320-General-2inch-IPS-LCD-Display-Module | | Pololu D36V50F5 (1) | DigiKey.at | Pololu 4091, 2183-4091-ND (DigiKey Marketplace) | 48.24 | 215 | digikey.at | | | Eckstein | – | **41.59** | In stock | https://eckstein-shop.de/Pololu-5V-55A-Step-Down-Voltage-Regulator-D36V50F5-EN | | | Berrybase | – | 34.95 | Sold out | https://www.berrybase.at/pololu-d36v50f5-5v-5-5a-step-down-spannungsregler | | STEMMA QT cable, 100 mm (3) | DigiKey.at | Adafruit 4210 | **1.01 each** | 17,267 | digikey.at | | | Amazon.de | 12-pc JST-SH 4-pin set, B0HB5F48YD | 9.06 | In stock | https://www.amazon.de/dp/B0HB5F48YD | | | Eckstein / Berrybase | – | 2.25 / 1.90–2.30 | Out of stock | – | ### 1b. LiDAR, servos, horns | Item (qty) | Vendor | Product / SKU | € incl. VAT | Stock | URL | |---|---|---|---|---|---| | RPLIDAR C1 (1) | Amazon.de (WayPonDEV) | B0CP8YRY44 | **70.58** | Ships in 4–5 days | https://www.amazon.de/dp/B0CP8YRY44 | | | Amazon.de (Waveshare) | B0CT31PH8S | 103.86 | In stock | https://www.amazon.de/dp/B0CT31PH8S | | | Génération Robots | A-000000-06970 | 81.12 (net 67.60 × 1.2, calculated) | Not shown | https://www.generationrobots.com/de/404200-telemetre-laser-360-rplidar-c1-slamtec.html | | | MYBOTSHOP.DE | – | 99.95 (DE VAT) | In stock | https://www.mybotshop.de/SLAMTEC-RPLIDAR-C1-360-Laser-Scanner-12-m | | SPT5435LV-180W (13) | AliExpress "DigitalServo Store" | 4000135787995, variant "35kg-180 Waterproof" | **13.46** (with alu Arm/Disc horn: 14.21); shipping €5.90 | 8,515 | https://de.aliexpress.com/item/4000135787995.html | | | Amazon.de | "SPT5435LV", B0H17Y4KS2 — **rotation angle not stated** | 23.99 | In stock | https://www.amazon.de/dp/B0H17Y4KS2 | | | Amazon.de | B0G39ZHL4B | 22.48 | 3 left | https://www.amazon.de/dp/B0G39ZHL4B | | | Banggood | -180 (not waterproof), p-1577509 | US\$27.99 (≈25.05) | UNVERIFIED | banggood.com | | 25T alu disc horn (4) | Amazon.de (DollaTek) | 5-pack, B07F71J1Q2 | 5.03 | In stock | https://www.amazon.de/dp/B07F71J1Q2 | | 25T alu clamping arm (4) | Amazon.de | 5-pack, B0F5QG8HQS | 9.06 | 17 left | https://www.amazon.de/dp/B0F5QG8HQS | | | Lindinger | SAVÖX 25T single | 7.90 each | Pre-order | https://www.lindinger.at/de/RC-Elektronik/RC-Produkte/Servos-Zubehoer/SAVOEX-Servo-Horn-Single-Alu-High-Torque-25T-Servohebel-Alu/9798687 | | Horns, cheapest route | AliExpress | Servo bought as "WP Arm" or "WP Disc" variant | +0.75 per servo | – | Same item page as the servos | ### 1c. Power | Item (qty) | Vendor | Product / SKU | € incl. VAT | Stock | URL | |---|---|---|---|---|---| | Servo BEC | Lindinger | **Hobbywing UBEC-10A V2-Car (P/N 30603003)**: 2–6S in, 6.0/7.4/8.4 V out, 10 A continuous / 15 A peak ([manual](https://oss.hobbywing.com/pdf/pdfen/UBEC10ACar.pdf)); the aircraft UBEC-10A (P/N 30603000, 20 A peak) is a different unit | 26.99 each (sale) | Few left | https://www.lindinger.at/de/RC-Elektronik/Stromversorgung-Bus-Systeme/Akkuweichen-Spannungsregler-BEC/HOBBYWING-BEC-10A-V2-Car-UBEC-Regler-fuer-2-6s/9787110 | | | Amazon.de | SPARKHOBBY 10A UBEC clone, B0DK31PYFZ | 26.63 each | 3 left | https://www.amazon.de/dp/B0DK31PYFZ | | | Amazon.de | Suoumwa "20A SBEC", B0D4YCJWR3 (continuous rating and minimum input UNVERIFIED) | 37.75 | In stock | https://www.amazon.de/dp/B0D4YCJWR3 | | | Motion RC EU | Castle CC BEC Pro 20A, 010-0004-01 (15 A continuous at 2S, per sub-agent) | 56.99 | Not shown | https://motionrc.com/eu/products/castle-creations-20a-bec-pro-010-0004-01 | | LiPo 2S 5000 mAh, XT60 (1) | Amazon.de | Gens ace G-Tech 60C, 138×47×25 mm, B0GKP683YF | **22.18** | In stock | https://www.amazon.de/dp/B0GKP683YF | | | Amazon.de | Zeee 5200 80C, 2-pack, B08X4GF9DK | 41.34 per 2 | In stock | https://www.amazon.de/dp/B08X4GF9DK | | | Lindinger | Robbe RO-POWER 5000 2S 35C, 139×46.8×25.9 mm (0.9 mm over the height limit) | 32.99 | Few left | https://www.lindinger.at/de/RC-Elektronik/Antriebe/Akkus/Robbe-Modellsport-RO-POWER-ULTRA-MAXAMP-CAR-5000MAH-7-4-VOLT-2S-35-70-C-LIPO-AKKU-T-XT-60-Stecker/9767786 | | | Stefansliposhop | SLS XTRON 5000 2S 50C hardcase (4 mm bullets) | 42.50 | 1–3 days | https://www.stefansliposhop.de/en/batteries/sls-xtron/sls-xtron-car/sls-xtron-5000mah-2s1p-7-4v-50c-100c-hardcase-steckkontakte::1105.html | | Charger, AC input | Amazon.de | HTRC B6AC 80 W, B0F3CYZ6L8 | **46.38** | In stock | https://www.amazon.de/dp/B0F3CYZ6L8 | | | Robitronic (AT) | SkyRC B6AC V2, genuine | 61.90 | In stock | https://shop.robitronic.com/de/skyrc-b6ac-v2-ladegeraet-sk100008 | | | Lindinger | SkyRC B6AC Neo / ISDT 608AC | 64.99 / 63.99 | Few left | lindinger.at (9798342 / 9771152) | | LiPo bag | Lindinger / Amazon | Robbe 23×30 cm / FLASLD, B09C31KVK1 | 6.49 / 14.11 | – | lindinger.at 9787514 / https://www.amazon.de/dp/B09C31KVK1 | | LiPo checker | Lindinger / Amazon | Absima / ARCELI, B077Z1N9CS | 5.95 / 6.04 | – | lindinger.at 9771616 / https://www.amazon.de/dp/B077Z1N9CS | | XT60 pairs | Amazon.de | Genuine Amass XT60H, 3 pairs, B07C3KH1VT | 6.44 | In stock | https://www.amazon.de/dp/B07C3KH1VT | | | Lindinger / Reichelt | Robbe, 3 pairs / Jamara, 1 pair | 4.99 / 2.01 | – | lindinger.at 9750234 / reichelt.at 207648 | | ATO fuse holder + 25 A fuse | Reichelt.at | Holder 325426 + fuse 7786 | 3.02 + 0.36 each | In stock | reichelt.at (325426, 7786) | | | Amazon.de | Holder 5-pack (with 20 A fuses), B0B4MWDFD8 + 25 A fuses ×10, B09ZXRZLV8 | 8.05 + 4.02 | In stock | https://www.amazon.de/dp/B0B4MWDFD8 · https://www.amazon.de/dp/B09ZXRZLV8 | | Main switch ≥30 A | Amazon.de | XT60 inline switch 30 A, B0FG2NTYXF | 11.09 | 7 left | https://www.amazon.de/dp/B0FG2NTYXF | | Silicone wire 14 / 18 / 22 AWG | Amazon.de | B0C53D343Y (5 m) / B0D12R349S (5+5 m) / B0C7T9KYND (6×4 m) | 12.10 / 9.06 / 12.10 | In stock | amazon.de/dp/… | | | Lindinger | 2.5 mm² / 0.75 mm², 1 m red + 1 m black each | 4.99 / 2.99 | – | lindinger.at 20479 / 44422 | | Wago 221-415 (4) | Reichelt.at | 149800 | 0.81 each | In stock | reichelt.at 149800 | | | Amazon.de | 25-pack, B0107SYYGU | 14.11 | In stock | https://www.amazon.de/dp/B0107SYYGU | | Servo extension 15 cm (6) | Amazon.de | 5-pack, B081NVPRNG (buy 2) | 6.54 per pack | 20 left | https://www.amazon.de/dp/B081NVPRNG | | | Lindinger | Multiplex | 3.90 each | – | lindinger.at 67983 | | Dupont F-F / heat-shrink / cable ties / spiral wrap | Reichelt.at | 282699 / 152924 / 280182 (velcro) / 332584 | 1.11 / 5.39 / 5.14 / 2.50 | In stock | reichelt.at | | | Amazon.de | B01EV70C78 / B071D7LJ31 / B0BFVRQVFP / B093SQP3PZ | 7.04 / 8.56 / 7.04 / 6.16 | In stock | amazon.de/dp/… | ### 1d. Mechanical | Item (qty) | Vendor | Product / SKU | € incl. VAT | Stock | URL | |---|---|---|---|---|---| | 625ZZ (4) | 3DJake.at | 10-pack | **4.99** | In stock | https://www.3djake.at/3djake/kugellager?sai=33676 | | | Amazon.de | QUARKZMAN 15-pack, B0CM8TYDR6 | 7.36 | In stock | https://www.amazon.de/dp/B0CM8TYDR6 | | | kugellager-express.de | 625-ZZ | 0.88 each from 4 (DE VAT) | Available | https://www.kugellager-express.de/miniatur-kugellager-625-ZZ-5x16x5-mm | | | 123kugellager.at | 625-ZZ | 1.60 each | 50+ | 123kugellager.at | | 6805-2RS (4) | Amazon.de | QUARKZMAN 2-pack, B0CL4Z679J (buy 2) | 6.74 per pack | In stock | https://www.amazon.de/dp/B0CL4Z679J | | | kugellager-express.de | 6805/61805-2RS | **1.70 each** from 4 | Available | https://www.kugellager-express.de/rillenkugellager-6805-61805-2rs-25x37x7-mm | | | kugellager-shop.net / 123kugellager.at | – | 1.90 / 4.36 each | In stock | – | | 633ZZ (8) | Amazon.de | QUARKZMAN 8-pack ABEC5, B0GGT3GW69 | **10.27** | **3 left** | https://www.amazon.de/dp/B0GGT3GW69 | | | 123kugellager.at | 633-ZZ | 2.04 each (16.32 for 8) | 50+ | 123kugellager.at | | Plain bearing 3×5×5 (32) | Amazon.de | QUARKZMAN sintered bronze, 50-pack, B0G15L264D | **10.27** | **4 left** | https://www.amazon.de/dp/B0G15L264D | | | Amazon.de | Wrapped bushing 10-pack, B0CWRXCWF9 | 5.44 per pack | In stock | – | | | igus.at | iglidur G **GSM-0304-05 (3×4.5×5)**; prices shown excl. VAT | 50 pcs = 18.50 net (22.20 gross, calculated) | 24–48 h | https://www.igus.at/iglidur-ibh/zylindrische-gleitlager/product-details/iglidur-g-m?artnr=GSM-0304-05 | | M3 SHCS assortment | Amazon.de | WZHUIDA 1080 pcs, stainless (40 each of 6/8 mm; 30 each of 10/12/14/16/18/20; 20 each of 25/30/35/40/50; plus 360 nuts, 360 washers), B0D2VST57H | **17.14** | 17 left | https://www.amazon.de/dp/B0D2VST57H | | | Schraubenking.at | DIN 912 A2, loose packs: 6–16 mm in 100s at €2.04–2.35; 18–35 mm in 10s at €0.83–0.97 (cheaper per pack from 3 packs) | see left | In stock | https://www.schraubenking.at/Zylinderschrauben__M3__Edelstahl-A2-/-V2A | | M3 nyloc DIN 985 (100) | Schraubenking / Amazon | P000454 / B0GXYB1TWK | 2.30 / 7.04 | In stock | schraubenking.at / https://www.amazon.de/dp/B0GXYB1TWK | | M3 heat-set inserts (30) | Amazon.de | ORTAVA M3×5.7, 100-pack, B0CNGHVWHC | **7.04** | In stock | https://www.amazon.de/dp/B0CNGHVWHC | | | 3DJake.at / ruthex.de | ruthex RX-M3x5.7, 100-pack (no 50-pack exists) | 8.99 | In stock | https://www.3djake.at/ruthex/gewindeeinsatz-m3-100-stueck | | M2.5 standoff kit | Amazon.de | GeeekPi 220 pcs brass, B07PHBTTGV | 12.10 | In stock | https://www.amazon.de/dp/B07PHBTTGV | | M2 screws (20) | Schraubenking.at | M2×6 DIN 912 A2, 10-pack | 0.88 per pack | In stock | https://www.schraubenking.at/M2-x-6mm-Zylinderschrauben-DIN912-Edelstahl-A2-P003960 | | | Amazon.de | 660-pc M2 kit (button head), B0DJ948G7K | 8.05 | In stock | https://www.amazon.de/dp/B0DJ948G7K | | Filament, 2 × 1 kg | 3DJake.at | Elegoo PLA black / yellow | **10.39 / kg** | In stock | https://www.3djake.at/elegoo/pla-schwarz-5 | | | Extrudr (Austria) | PLA NX2 Matt (−40% sale) | 15.59 / kg | In stock | https://extrudr.com/de/at/products/pla-nx2-matt | | | Amazon.de | Sunlu PLA black, B0BTHSX6S3 / light yellow, B0FLWKNF2G | 13.10 / 15.12 | In stock | amazon.de/dp/… | | | Bambu EU / Polymaker (3DJake) / Prusament | PLA Basic / PolyTerra / Prusament PLA | 16.99 refill · 19.99 with spool / 14.99 / 26.39 | In stock | eu.store.bambulab.com/products/pla-basic-filament | ### 1e. Tools (optional; prices from the tools sub-agent) | Tool | Pick (vendor, price €) | Alternatives | |---|---|---| | Printer | Bambu A1 mini, 180³ bed: **179** + €10 shipping (eu.store.bambulab.com/products/a1-mini) | A1 Combo 339 (A1 without AMS lite: UNVERIFIED); Prusa MK4S kit 719 / assembled 999; CORE One+ 1,049 kit / 1,349 assembled (prusa3d.com). Black Friday prices | | Hex keys | Wera 950/9 Hex-Plus, Amazon 28.22 (B009ODV0OE) | Conrad Wera 950 SPKL ≈31.99 (calculated) | | Soldering + insert tips | Miniware TS101 74.62 (B0CPDRQZYB) + ruthex tip set 17.04 (B0DP51DDF7) + adapter 13.10 (B0CTQV11YC) | Pinecil 82.14; budget Beizkna 22.16 + Preciva tips 25.61 (tip fit UNVERIFIED) | | Stripper / crimper | Jokari Super 4 plus 14.06 / DxCRIMP set with plugs 30.22 | iCrimp SN-2549 26.21; Knipex 12 62 180 at Conrad ≈34.50 | | Multimeter / calipers / flush cutters | Kaiweets 46.28 / Kynup 18.94 / Wiha 26812 16.09 | Voltcraft VC131 at Conrad ≈29.99; Knipex 78 61 125 ≈20.99 | | Drills, tap, files | Bosch 3 mm ×10: 6.04; Bosch 5 mm: 1.91 (B078W5CF3D); tap set with wrench: 9.95; KS files: 6.85; deburrer: 9.06 | RS M3 tap at Conrad ≈3.34 | | Thread locker / CA glue | Loctite 243, 5 ml: 8.11 / Pattex Ultra Gel: 5.80 | – | | **Tools subtotal, all Amazon** | **€306.29** (budget iron variant €249.30); free shipping if added to the robot's Amazon order | Printer extra: €189 (A1 mini + shipping) | ## 2. Shipping to Austria | Vendor | Shipping to AT | Free from | Source | |---|---|---|---| | DigiKey.at | €25 | **€75** | Banner on the digikey.at product page. Whether Marketplace items (the Pololu) count toward it is UNVERIFIED | | Amazon.de | €3.99 home / €1.99 pickup point (Amazon-shipped items) | **€39** | https://www.amazon.de/gp/help/customer/display.html?nodeId=GESTJTJG49FEM5SV | | Amazon.de parcel tax | **+€2.40 per order** (Austrian Paketsteuer, charged at checkout) | – | https://www.amazon.de/gp/help/customer/display.html?nodeId=TjkNaI0ZNMEFZXbP1q · BMF: https://www.bmf.gv.at/en/topics/taxation/parcel_delivery_tax_overview_and_faq.html | | Berrybase.at | DHL €5.95 | €200 on the shipping page; €150 on the banner (conflicting) | https://www.berrybase.at/footer-at/informationen/versand-und-zahlungsbedingungen | | Reichelt.at | DPD €6.95 | UNVERIFIED | https://www.reichelt.at/at/de/shop/service/-12_28 | | Welectron | UPS €9.90 (≤2 kg) / DHL €14.90 | €200 (DHL) | https://www.welectron.com/Zahlung-und-Versand | | Eckstein | UNVERIFIED (only shown in the cart) | UNVERIFIED | https://eckstein-shop.de/Informationen-zum-Versand | | Lindinger.at | €5.99 | €89 (not for dangerous goods or bulky items) | https://www.lindinger.at/de/Modellbau-Lindinger/Versandkosten/ | | 3DJake.at | €5.49 | €49.90 | https://www.3djake.at/info/versand-und-lieferung | | Schraubenking.at | €5.90 | €99 | https://www.schraubenking.at/Versandinformationen | | kugellager-express.de / kugellager-shop.net | €8.90 / €8.50 | None for AT | https://www.kugellager-express.de/Zahlungsarten-Versand | | ruthex.de | €9.90 | €80 | https://www.ruthex.de/policies/shipping-policy | | Extrudr | from €6.90 | 5 items | https://extrudr.com/de/at/page/shipping/ | | Bambu EU | Printer €10; filament €10 | Filament from €55; printers never free | https://eu.store.bambulab.com/policies/shipping-policy | | Robitronic / Motion RC EU | €5.90 / €6.99 | – / €99 | shop.robitronic.com/de/versandkosten · https://www.motionrc.eu/pages/shipping-policy | | Stefansliposhop | UPS €16.61 | None seen | https://www.stefansliposhop.de/en/shipping-costs:_:1.html | | Conrad.at | €6.99 | €69 | https://www.conrad.at/de/service/versandkosten.html | | AliExpress DigitalServo / Fidurobot | €5.90 / free from €10 | – | Item pages | | igus, 123kugellager, Génération Robots, MYBOTSHOP, Banggood, DFRobot, HobbyKing (blocked) | UNVERIFIED | UNVERIFIED | – | ## 3. Bundle strategies These cover the full robot BOM with the Pi 5 4 GB, one Camera Module 3 Wide, 13 servos (12 + 1 spare), 4 disc + 4 arm horns and 2 kg of PLA. Tools and printer are excluded. Prices are per section 1. | Shop | (a) Fewest shops: subtotal + shipping = total | (b) Lowest total | (c) Fastest, EU stock only | |---|---|---|---| | DigiKey.at | Pi, cooler, CM3W, cable, PSU, genuine ESP32 N32R16V, Ada 815, 4438, 4226, 4210 ×3, Pololu: 281.57 + 0 = **281.57** | Same without ESP32 and 815: 248.23 + 0 = **248.23** | – (ships from the US) | | Amazon.de | Everything else in the EU: LiDAR (WayPonDEV), LCD, SD, LiPo, HTRC charger, 2× SPARKHOBBY BEC, all wiring, bearings, bushings, screws, inserts, standoffs, Sunlu PLA ×2: 498.74 + 0 + 2.40 = **501.14** | LiDAR, LCD, SD, LiPo, charger, switch, 6805, 633, bushings, M3 kit, nyloc, inserts, standoffs, M2, extensions, ties, 22 AWG, clone ESP32 + PCA9685: 331.23 + 2.40 = **333.63** | Items from (a) plus Waveshare C1 (103.86, in stock), Ada 4438, JST-SH set, clone ESP32, 13× Amazon "SPT5435LV" (311.87), horn packs: 850.90 + 2.40 = **853.30** | | AliExpress (DigitalServo) | 4× WP Arm + 4× WP Disc + 5× WP: 180.98 + 5.90 = **186.88** | Same: **186.88** | – | | Reichelt.at | – | Wago ×4, ATO holder, 2× 25 A fuse, Dupont, heat-shrink, spiral wrap: 15.98 + 6.95 = **22.93** | – | | Lindinger.at | – | 2× Hobbywing UBEC 10A V2, bag, checker, 2.5 mm² + 0.75 mm² wire, XT60 ×3 pairs: 79.39 + 5.99 = **85.38** | – | | 3DJake.at | – | Elegoo PLA black + yellow, 625ZZ 10-pack: 25.77 + 5.49 = **31.26** | – | | Berrybase.at | – | – | Pi 4 GB, cooler, SD, cable, PSU: 169.30 + 5.95 = **175.25** | | Welectron | – | – | CM3 Wide, LCD: 46.80 + 9.90 = **56.70** | | Eckstein | – | – | Pololu, INA260, PCA9685 clone: 63.93 + UNVERIFIED shipping | | **Grand total** | **€969.59** (3 shops) | **€908.31** (6 shops) | **€1,149.18 + Eckstein shipping** (4 shops) | **Trade-offs in the totals:** - **Dropping shops from (b):** removing 3DJake costs +€4.32, Lindinger +€15.63 and Reichelt +€25.01. - **Genuine boards in (b):** using the genuine ESP32 (N32R16V) and the Adafruit 815 instead of clones adds €16.32, for €924.63. - **Pi 5 8 GB:** adds €69.24 at DigiKey. - **Strategy (c) caveats:** - The Amazon SPT listing doesn't state the rotation angle; a 90° or 270° unit would break the joint mapping. Confirm with the seller before buying 13. - "In stock" at Berrybase means LimitedAvailability. - No EU shop had the genuine N16R8 ESP32 or the Adafruit LSM6DSOX in quantity; Amazon has 3 of the IMU left. - **Stock that could change a total:** Amazon's 633ZZ (3 left), bronze bushings (4 left), SPARKHOBBY BEC (3 left) and Lindinger's Hobbywing (few left). Order these first. ## 4. AliExpress and other non-EU options | Item | Offer | € | Note | |---|---|---|---| | SPT5435LV-180W ×13 | DigitalServo Store, item 4000135787995 | 13.46 each (14.21 with horn) + 5.90 | Most-sold listing with the full SPT range. A search summary says it is SPT's exclusive distributor (UNVERIFIED). The 10-pack (155.16) costs more than 10 singles (134.60) | | RPLIDAR C1 | Hefei Fidurobot, 1005006190309082 / FD Robot, 1005008790608977 | 66.29 / 65.01, free shipping | Neither is an official Slamtec store; only €4–5 cheaper than Amazon.de | | RPLIDAR C1 | DFRobot DFR1138 | US\$69 (≈61.76 net) | Shipping and import VAT UNVERIFIED | | ESP32-S3 N16R8 clone | item 1005006240070551 | US\$4.49 (search snippet only, UNVERIFIED) | – | **Rules for non-EU orders:** - **€3 duty per item:** since 1 Jul 2026 the EU charges a flat €3 per item (counted per tariff line, not per unit) on consignments up to €150. The seller pays it ([EU Commission](https://taxation-customs.ec.europa.eu/news/guidance-and-legal-text-temporary-flat-fee-low-value-imports-which-will-apply-until-1-july-2028-2026-06-08_en)). - **€2 handling fee:** from 1 Nov 2026 there is an extra €2 per item ([vienna.at](https://www.vienna.at/temu-und-shein-neue-2-euro-gebuehr-ab-november/10583882)). It can be passed on in prices. - **The servo order is over €150.** IOSS (VAT collected at checkout) only applies to consignments up to €150 ([Revenue.ie](https://www.revenue.ie/en/vat/vat-ecommerce/import-oss/index.aspx)). Above that, the normal import procedure applies, so expect possible carrier VAT and clearance fees. Splitting into two orders of 7 + 6 servos (about €90 each) stays under the limit for +€5.90 shipping. How AliExpress handles this is UNVERIFIED. - **Delivery and quality:** delivery is slow; quality risk is acceptable for horns, bearings, the PCA9685 and the ESP32. **Don't buy LiPo, charger or LiDAR there.** ## 5. Clone fine vs genuine matters | Clone or generic is fine | Genuine or brand matters | |---|---| | PCA9685; bearings (625, 633); bronze bushings; M2/M3 screws, nuts and inserts (stainless A2); standoffs; 25T horns (check the spline); camera cable; LCD (generic ST7789 works); JST-SH cables; Dupont, heat-shrink, ties, spiral wrap, Wago-style connectors; LiPo bag and checker; filament brand | **Servos** (genuine SPT, the -180W variant); **LiPo** (Gens ace, SLS or Robbe class, hardcase, real C rating); **LiDAR** (real Slamtec C1, for the ROS driver); **Pi 5, cameras, PSU** (official only); **microSD** (fakes are common, so buy from Amazon-shipped or retailer stock); **Pololu regulator** (cheap buck converters overstate current); **XT60** (genuine Amass); **BEC** (Hobbywing or Castle over clones, since it feeds all 13 servos); **charger** (SkyRC or ISDT preferred; HTRC is clone-tier); **6805-2RS main-joint bearings** (play shows up directly at the joint); Loctite 243 | ## 6. Caveats that affect the BOM 1. **No real "20 A, 6/7.4 V, 2S-input" BEC turned up.** A buck regulator can't put out 7.4 V from a 2S pack (6.4–8.4 V), so run the servos at **6.0 V**. The sensible options are 2× Hobbywing UBEC-10A V2-Car (10 A continuous / 15 A peak each), each feeding two legs, or a Castle BEC Pro 20A (15 A continuous at 2S per sub-agent). 2. **Genuine ESP32-S3-DevKitC-1-N16R8 is out of stock everywhere checked in the EU** (Reichelt, soyter.pl; DigiKey doesn't list it; Mouser was blocked). Pick one: - the genuine N32R16V (DigiKey €17.42), giving up GPIO35–37; - an N16R8 clone (Amazon €10.07). 3. **igus has no 3×5×5 part.** Its 3 mm bore part is 3×4.5×5 (GSM-/JSM-0304-05). Either change the printed bore to 4.5 mm or use bronze 3×5×5. 4. **Shops that changed status:** - Botland now sells to businesses only (VAT ID required). - AZ-Delivery's shop is closed. - Mouser, Antratek, RobotShop EU and HobbyKing blocked automated access, so they are not priced. 5. **Austrian parcel tax:** €2.40 per order, charged at Amazon checkout. It only applies to distance sellers with more than €100 M in sales, so it doesn't apply to most small EU shops. 6. **LiPo shipping:** a 2S 5000 mAh pack is about 37 Wh, under 100 Wh, so it ships at normal rates. Lindinger and SLS add surcharges only above 100 Wh. 7. **DE-VAT shops:** Welectron, Eckstein and kugellager-* prices may rise about 0.8% if checkout applies Austria's 20% VAT. 8. **Conrad tool prices** are net × 1.2 (calculated). Bambu and Prusa prices are time-limited Black Friday prices. --- ## EU pricing for the 15 extra quadruped BOM items (Austria, 2026-10-30) Every item has at least one offer that I read on the shop's page. Prices are € incl. VAT as shown on 2026-10-30, cheapest first. **Three corrections to the brief:** - **Item 1:** Seeed SKU 102010635 is the XIAO ESP32S3 **Sense** pre-soldered (it has a camera). The plain pre-soldered board is **102010634**, and 113991114 is the plain board without headers. - **Item 2:** Pololu #2813 is the **Big** Pushbutton Power Switch HP (4.5–40 V, about 16 A), not a Mini. No "Mini HP 6 A" exists. #2812 is the Big MP (4.5–40 V, 8 A). The 6 A part is the Mini LV **#2808** (2.2–20 V). - **Item 11:** The GeeekPi kit B07PHBTTGV has **no** M2.5×6 F-F standoffs. You need to buy those separately. **How to read the price columns:** - Eckstein and Botland show German VAT (19%). The Austrian price may be about 1% higher. - Amazon.de prices were fetched without a delivery address, so they likely include German VAT. The Austrian price may differ slightly. - "(listing)" means I saw the price only on the Amazon search results page, not the product page. Treat it as UNVERIFIED. - DigiKey prices are the "incl. VAT" figure on DigiKey.at. ## 1. Seeed XIAO ESP32S3 (qty 1) | Vendor | Product / SKU | € | Pack | Stock | URL | |---|---|---|---|---|---| | DigiKey.at | 113991114 plain, no headers (genuine) | 7.98 | 1 | 9,565 | https://www.digikey.at/de/products/result?keywords=113991114 | | DigiKey.at | 102010634 pre-soldered (genuine) | 9.05 | 1 | 159 | https://www.digikey.at/de/products/detail/seeed-technology-co-ltd/102010634/26553885 | | Berrybase.at | SE-102010634 "Mit Header" (genuine) | 9.90 | 1 | 100+ | https://www.berrybase.at/en/seeed-studio-xiao-esp32-s3-wifi-bluetooth-5.0-mesh-8mb-psram-dual-core-240-mhz-6-mb-flash-5v-019de2c9775872f985e5ef385c70decc | | Amazon.de | B0BYSB66S5, Seeed XIAO ESP32-S3 | 16.08 | 1 | Auf Lager | https://www.amazon.de/dp/B0BYSB66S5 | - The Berrybase "Ohne Header" variant is in stock, but I did not see its own price. The page says "From €9.90". - Berrybase's €9.50 "XIAO ESP32-S3 **Plus**" is a different board. ## 2. Pololu pushbutton power switch (qty 1) | Vendor | Product | € | Stock | URL | |---|---|---|---|---| | Botland.de | #2808 Mini LV, 2.2–20 V / 6 A | 4.50 | InStock | https://botland.de/elektronische-und-digitale-schalter/29436-mini-push-mosfet-schalter-lv-2-2-20v-6a-mit-rueckstromschutz-pololu-2808.html | | Botland.de | #2812 Big MP, 4.5–40 V / 8 A | 4.90 | InStock | https://botland.de/elektronische-und-digitale-schalter/5118-grosser-push-mosfet-mp-schalter-45-40-v-8-a-mit-ruckstromschutz-pololu-2812-5903351244879.html | | Botland.de | #2813 Big HP, 4.5–40 V / 16 A | 7.90 | InStock | https://botland.de/elektronische-und-digitale-schalter/5117-grosser-push-mosfet-schalter-hp-45-40v-16a-mit-ruckstromschutz-pololu-2813-5903351244862.html | | Eckstein (DE) | #2808 (PO2808) | 5.89 | Available, 1–3 days | https://eckstein-shop.de/PololuMiniPushbuttonPowerSwitchwithReverseVoltageProtection2CLVEN | | DigiKey.at Marketplace | #2808 / #2812 / #2813 | 5.98 / 7.18 / 10.80 | 382 / 241 / 156 | https://www.digikey.at/de/products/result?keywords=pololu%202813 | - **Fit for 2S (6.0–8.4 V):** all three variants cover that range. #2808 is limited to 20 V, so it would not work at 3S or higher. - **Which one to pick depends on what the switch carries:** - If it switches only the logic/Pi branch, #2808 (6 A) is enough. - If it switches the whole pack feeding two 10 A UBECs, the battery side can draw about 18 A at full servo load (my estimate). That is beyond #2813's rated ~16 A continuous. In that case keep it out of the servo path and use the XT60 arming loop. - **DigiKey Marketplace** items ship from Pololu in the USA, and the page says a separate shipping fee may apply. - **Not recommended:** Amazon B079NSQ1ML (#2813) at €56.34 (listing). Berrybase POL2812 at €4.90 is not available. ## 3. 12 mm momentary metal pushbutton, normally open (qty 1) | Vendor | Product | € | Stock | URL | |---|---|---|---|---| | Reichelt.at | ONPOW VS GQ12B-10S, stainless, screw terminals, momentary NO | 5.66 (was 7.05) | ab Lager | https://www.reichelt.at/at/de/shop/produkt/drucktaster_17_5_12_mm_2a-36_vdc_edelstahl-318555 | | Reichelt.at | ONPOW VS GQ12B-10JS, stainless, solder lugs | 7.46 | ab Lager | https://www.reichelt.at/at/de/shop/produkt/drucktaster_17_5_12_mm_2a-36_vdc_edelstahl-318547 | | Amazon.de | Metzler 12 mm stainless, B0FH2TX1LN | 5.53 | Auf Lager | https://www.amazon.de/dp/B0FH2TX1LN | - **Do not buy the Metzler:** its page states a 40 mm installation depth, which is over your 30 mm limit. - **GQ12B depth is UNVERIFIED.** The datasheet I fetched had no readable body length. ONPOW describes the series as "short body". - GQ12B-10/J/N is the nickel-plated brass version. Reichelt stocks the stainless (S) version. ## 4. Hobbywing UBEC 10A V2-Car, 2–6S (qty 2) | Vendor | Product | € each | Stock | URL | |---|---|---|---|---| | Lindinger.at | HW30603003 BEC 10A V2-Car (genuine) | **26.99** (was 31.98), confirmed | "Only a few left in stock", 1–3 days | https://www.lindinger.at/en/RC-Electronics/Power-Supply-Bus-System/Battery-Switches-BEC/HOBBYWING-BEC-10A-V2-Car-UBEC-controller-for-2-6S/9787110 | | Amazon.de | SPARKHOBBY UBEC 10A 2–6S, B0DK31PYFZ (**clone**) | 26.63 | 3 left | https://www.amazon.de/dp/B0DK31PYFZ | | Amazon.de | Hobbywing B0BCWYQYKW (genuine) | 29.90 from 3 sellers (listing) | no Buy Box | https://www.amazon.de/dp/B0BCWYQYKW | - Two units at Lindinger cost €53.98, plus €5.99 shipping (free from €89). - Lindinger's Austrian free-shipping threshold of €89 is confirmed on its site. ## 5. ATO/ATC inline fuse holder with ≥14 AWG leads, plus 20 A fuses | Vendor | Product | € | Pack | Stock | URL | |---|---|---|---|---|---| | Amazon.de | Nilight NI-FH01, 14 AWG, 12-inch loop, B07426WCLM | 8.56 | 10 holders | Auf Lager | https://www.amazon.de/dp/B07426WCLM | | Amazon.de | Bst4U, 14 AWG, waterproof cap, plus 100 assorted fuses incl. 20 A, B087BT1PH3 | 10.07 | 6 holders + 100 fuses | Auf Lager | https://www.amazon.de/dp/B087BT1PH3 | | Reichelt.at | FKS 20A ATO fuse | 0.36 each (0.236 from 10) | 1 | ab Lager | https://www.reichelt.at/at/de/shop/produkt/kfz-flachsicherung_20a_gelb-7785 | | Amazon.de | JatilEr 20 A standard blade fuses, B09ZXQ8F3J | 4.02 | 10 | Auf Lager | https://www.amazon.de/dp/B09ZXQ8F3J | - Reichelt has no wired **ATO** inline holder. Its nearest option is the IMAXX HLP7335 for **mini** blade fuses (2.5 mm² leads, 30 A, IP55) at €2.62. It would need FK1 20A mini fuses at €0.41 each. Link: https://www.reichelt.at/at/de/shop/produkt/kfz-sicherungshalter_minioto_30_a_58_v_low_profile_ip55-229125 ## 6. Servo extension cables, 30 cm JR (qty 12) | Vendor | Product | € | Pack | Stock | URL | |---|---|---|---|---|---| | Amazon.de | 30 cm male–female JR, B07FYF76Y1 (gauge not stated) | 10.07 | 10 | 12 left | https://www.amazon.de/dp/B07FYF76Y1 | | Amazon.de | VUNIVERSUM 30 cm **22 AWG** (0.32 mm²), B078VSNC9D | 11.08 | 10 | 11 left | https://www.amazon.de/dp/B078VSNC9D | | Amazon.de | GTIWUNG 30 pcs JR, B082WYFWSY (length mix not checked) | 12.10 (listing) | 30 | ? | https://www.amazon.de/dp/B082WYFWSY | - You need 12, so buy two packs of 10: 2 × €11.08 = €22.16 for the 22 AWG version. - I found nothing suitable at Berrybase or Reichelt. Lindinger was not checked. ## 7. Pi 5 camera cable, 22-pin to 15-pin, 300 mm (qty 1) | Vendor | Product | € | Stock | URL | |---|---|---|---|---| | DigiKey.at | Raspberry Pi SC1129 (official) | 2.14 | 1 | https://www.digikey.at/de/products/detail/raspberry-pi/SC1129/21658260 | | Berrybase.at | RPI-CC-SM-30, official Standard–Mini 300 mm | 2.30 | 100+ | https://www.berrybase.at/raspberry-pi-camera-cable-standard-mini-300mm | | Amazon.de | B0CMZPKZFT, sold as "offizielles" (unconfirmed) | 5.20 | Auf Lager | https://www.amazon.de/dp/B0CMZPKZFT | | Amazon.de | Waveshare 22-to-15 pin 300 mm, B0CX1SFD44 | 9.67 | Auf Lager | https://www.amazon.de/dp/B0CX1SFD44 | - Reichelt's "RASP CAM FPC 30" at €2.27 is the older orange camera cable, probably 15-to-15 pin. It is not the Pi 5 22-pin type. ## 8. Short USB-A to USB-C cable, 90° (qty 2) | Vendor | Product | € | Pack | Stock | URL | |---|---|---|---|---|---| | Amazon.de | deleyCON 0.15 m USB-A to C 90°, B0FVXP8LXJ | 4.99 | 1 | Auf Lager | https://www.amazon.de/dp/B0FVXP8LXJ | | Amazon.de | deleyCON 0.15 m USB-A to C 90°, 480 Mbps, B0DGTR2X97 | 5.84 | 1 | Auf Lager | https://www.amazon.de/dp/B0DGTR2X97 | | Amazon.de | aceyoon 20 cm USB-A to C 90°, B09ZDV7S44 | 7.64 | 3 | Auf Lager | https://www.amazon.de/dp/B09ZDV7S44 | - I found no short USB-A to C cable at Berrybase. ## 9. Adafruit STEMMA QT cable (qty 1) | Vendor | Product | € | Stock | URL | |---|---|---|---|---| | DigiKey.at | Adafruit 4397, JST SH to female sockets, 150 mm | 1.01 | 8 | https://www.digikey.at/de/products/result?keywords=adafruit%204397 | | DigiKey.at | Adafruit 4209, JST SH to male header | 1.01 | 5 | https://www.digikey.at/de/products/result?keywords=adafruit%204209 | | Berrybase.at | Seeed SE-114992154, Grove & Qwiic/STEMMA QT to male/female jumpers (alternative) | 2.50 | 3 | search "stemma qt kabel" | - Berrybase's Adafruit ADA-JSTSH4 and the SparkFun CAB-14988 / CAB-17261 cables are all "nicht lieferbar". - DigiKey's shipping is €25 below €75, so only order 4397 there as part of a bigger DigiKey order. ## 10. Dupont jumper wires, female–female (1 pack) | Vendor | Product | € | Stock | URL | |---|---|---|---|---| | Berrybase.at | DUPK-SET-10: 40-pin F-F + M-M + F-M, 10 cm | 4.40 | 100+ | https://www.berrybase.at/40pin-jumper-dupont-kabel-set-je-1x-f-f-m-m-f-m-10cm | | Berrybase.at | DUPK-SET-20: same set, 20 cm | 4.90 | 100+ | https://www.berrybase.at/40pin-jumper-dupont-kabel-set-je-1x-f-f-m-m-f-m-20cm | | Amazon.de | 120 × F-F 10 cm, B072JT6BCN | 7.04 | Auf Lager | https://www.amazon.de/dp/B072JT6BCN | | Amazon.de | 160 × F-F, 10/20/30/40 cm, B0F98KBV61 | 8.05 | stock field empty | https://www.amazon.de/dp/B0F98KBV61 | ## 11. M2 and M2.5 hardware **GeeekPi B07PHBTTGV**, €12.10, in stock. Contents as listed on the page: | Part | Qty | |---|---| | M2.5 × 20+6 male–female | 16 | | M2.5 × 5+6 male–female | 16 | | M2.5 × 11+6 male–female | 16 | | M2.5 × 11 (probably F-F; my inference) | 16 | | M2.5 × 12+6 | 4 | | M2.5 screws | 50 | | M2.5 nuts | 50 | | Acrylic washers | 50 | There are **no 6 mm F-F standoffs** in the kit. | Need | Vendor | Product | € | Pack | Stock | URL | |---|---|---|---|---|---|---| | M2.5 × 6 F-F (≥10) | DigiKey.at | Würth 970060151, steel hex, female–female | 0.552 each, so ≈5.52 for 10 | 1 | 3,699 | https://www.digikey.at/de/products/result?keywords=970060151 | | M2.5 × 6 F-F | Amazon.de | B0GK7QTZ7B, multi-size F-F brass listing (variant not confirmed) | 11.29 | 2–50 | Auf Lager | https://www.amazon.de/dp/B0GK7QTZ7B | | M2 × 8 F-F (≥3) | Amazon.de | B0DR67YGQL, M2×8 brass, female both ends | 7.73 | 40 | 2 left | https://www.amazon.de/dp/B0DR67YGQL | | M2 × 5 DIN 912 | Schraubenking.at | A2 stainless | 0.86 | 10 | 2–3 days | https://www.schraubenking.at/M2-x-5mm-Zylinderschrauben-DIN912-Edelstahl-A2-P003957 | | M2 × 10 DIN 912 | Schraubenking.at | A2 stainless | 0.78 | 10 | 2–3 days | https://www.schraubenking.at/M2-x-10mm-Zylinderschrauben-DIN912-Edelstahl-A2-P002803 | | M2 nuts DIN 934 | Schraubenking.at | A2 stainless | 1.87 | 100 | 1 day | https://www.schraubenking.at/M2-Sechskantmuttern-DIN934-Edelstahl-A2-P002807 | | M2.5 × 5 / × 6 DIN 912 | Schraubenking.at | A2 stainless | 0.83 each size | 10 | 2–3 days | …-P004986 / https://www.schraubenking.at/M25-x-6mm-Zylinderschrauben-DIN912-Edelstahl-A2-P005010 | | M2.5 nuts DIN 934 | Schraubenking.at | A2 stainless | price not seen | 100 | ? | https://www.schraubenking.at/M25-Sechskantmuttern-DIN934-Edelstahl-A2-P004196 | - No brass M2 × 8 F-F standoff turned up in a quick DigiKey search. ## 12. M3 × 40 DIN 912 and M3 DIN 985 nylock nuts **WZHUIDA kit B0D2VST57H**, €17.14, "Nur noch 17 auf Lager". The listing confirms it **does contain M3 × 40**: | Length | Qty | |---|---| | M3 × 6, M3 × 8 | 40 each | | M3 × 10, 12, 14, 16, 18, 20 | 30 each | | M3 × 25, 30, 35, **40**, 50 | 20 each | | M3 nuts (plain, **not** nylock) | 360 | | M3 washers | 360 | The screws are 304 stainless with a socket cap ("Zylinderkopf" variant). | Need | Vendor | Product | € | Pack | Stock | URL | |---|---|---|---|---|---|---| | M3 × 40 DIN 912 | Schraubenking.at | A2 stainless | 1.01 | 10 | Sofort verfügbar | https://www.schraubenking.at/M3-x-40mm-Zylinderschrauben-DIN912-Edelstahl-A2-P004987 | | M3 × 40 DIN 912 | Schraubenking.at | 8.8 zinc-plated | 1.16 | 10 | 1 day | https://www.schraubenking.at/M3-x-40mm-Zylinderschrauben-DIN912-Stahl-verzinkt-FKL-88-P005519 | | M3 DIN 985 nylock | Schraubenking.at | zinc-plated steel | 1.39 | 100 | 1 day | https://www.schraubenking.at/M3-Sicherungsmuttern-DIN985-Stahl-verzinkt-FKL6/8-P000461 | | M3 DIN 985 nylock | Schraubenking.at | A2 stainless | 2.30 | 100 | 1 day | https://www.schraubenking.at/M3-Sicherungsmuttern-DIN985-Edelstahl-A2-P000454 | | M3 DIN 985 nylock | Reichelt.at | SKSM M3-100 | 3.48 | 100 | ab Lager | https://www.reichelt.at/at/de/shop/produkt/sicherungsmuttern_m3_100_stueck-65808 | | M3 DIN 985 nylock | Amazon.de | A2 stainless, B0BJYRLG6G | 5.53 | 50 | Auf Lager | https://www.amazon.de/dp/B0BJYRLG6G | ## 13. Foam mounting tape (19 mm) and 2.5 mm cable ties (100 pcs) | Vendor | Product | € | Pack | Stock | URL | |---|---|---|---|---|---| | Reichelt.at | tesa PRO Universal 19 mm × 5 m (not VHB) | 9.98 | 1 roll | ab Lager | https://www.reichelt.at/at/de/shop/produkt/montageband_tesa_pro_universal_5_m_x_19_mm_weiss-338987 | | Amazon.de | 3M VHB 5952F, 19 mm × 3 m × 1.1 mm, B00EDLPG7Y | 14.11 | 1 roll | Auf Lager | https://www.amazon.de/dp/B00EDLPG7Y | | Amazon.de | 3M VHB 4991, 19 mm × 5.5 m × 2.3 mm, B0779B5LBZ | 20.59 | 1 roll | Auf Lager | https://www.amazon.de/dp/B0779B5LBZ | | Berrybase.at | Cable ties 100 × 2.5 mm, white (E-E510) | 0.90 | 100 | 9 | https://www.berrybase.at/kabelbinder-100-mm-x-2-5-mm-weiss-100-stueck | | Reichelt.at | Delock 19186, 100 × 2.5 mm, black, cold-resistant | 1.16 | 100 | ab Lager | https://www.reichelt.at/at/de/shop/produkt/kabelbinder_kaelteresistent_l_100_x_b_2_5_mm_schwarz_100_stueck-316436 | | Berrybase.at | Cable ties 100 × 2.5 mm, transparent (D18605) | 1.90 | 100 | 3 | https://www.berrybase.at/kabelbinder-100-x-2-5mm-transparent-100-stueck | ## 14. XT60 arming-loop plug **Confirmed.** B07C3KH1VT is "3 Paar (6 Stück) Original Amass Premium XT60H … Stecker Buchse Male Female inkl. Schutzkappen". That is 3 male and 3 female connectors, €6.44, in stock: https://www.amazon.de/dp/B07C3KH1VT ## 15. Medium-strength thread locker | Vendor | Product | € | Stock | URL | |---|---|---|---|---| | Schraubenking.at | Förch thread locker, medium, blue, 10 ml (not Loctite) | 5.71 | 1 day | https://www.schraubenking.at/Schraubensicherung-mittelfest-blau-10ml-P002471 | | Amazon.de | Loctite 243, 5 ml, B00745GNPK | 8.11 | 4 left | https://www.amazon.de/dp/B00745GNPK | | Amazon.de | Loctite 243, 10 ml, B00Y8DOMCC | 13.07 | Auf Lager | https://www.amazon.de/dp/B00Y8DOMCC | | Reichelt.at | Loctite 243, 10 ml | 15.12 | ab Lager | https://www.reichelt.at/at/de/shop/produkt/schraubensicherung_lack_mitttelfest_blau_10_ml-94948 | ## Shipping for shops outside your list - **Botland (ships from Poland):** I only saw German terms: from €4.99, free above €100. Rates to Austria are UNVERIFIED. - **Eckstein (DE):** this shop is on your preferred list but you gave no shipping cost for it. The rate to Austria is UNVERIFIED; the shipping-info pages I fetched did not show it. - **DigiKey Marketplace (Pololu items):** shipped by Pololu from the USA, and a separate shipping fee may apply on top of DigiKey's €25 / free from €75. ## Not reached - **TME** and **metzler-electronics.com** both block automated access, so I have no prices from them. - I did not check **Conrad.at** or **AliExpress**. --- # Parts catalogue > **Status:** generated from the verified CAD assembly and `hardware/bom.yaml` (`make catalog`). Prices are observed offers (EUR incl. VAT, 2026-10-30); re-check before ordering. **[Download the catalogue & assembly book (A4 PDF)](catalog/strido-catalog.pdf)** — printed parts, purchased parts, hardware bags drawn at 1:1, the shopping plan, tools, every assembly step, wiring and the software appendix. Machine-readable: `docs/catalog/parts.json` (also served by the `strido-hardware` MCP server). Items that are not modelled in the CAD (wire, servo extensions, fuses, filament, tape) are listed in the [BOM](bom/README.md). --- # Configuration > **Status:** every environment key the code reads. Defaults live in the committed `.env.example`; secrets: none. | Key | Default | Read by | Meaning | |---|---|---|---| | `ROS_DOMAIN_ID` | `42` | `docker/compose.yaml` | ROS 2 domain of the robot and simulation containers | | `STRIDO_BLENDER` | *(empty)* | `strido_publish.scene` | Blender executable for renders; empty = `blender` on PATH, else the macOS app bundle | | `STRIDO_PRUSASLICER` | *(empty)* | `strido_verify.printability` | PrusaSlicer executable for the printability gate; empty = `prusa-slicer` on PATH, else the macOS app bundle | | `STRIDO_REPO_ROOT` | *(empty)* | `strido.workspace` | Repository root for the tooling when running outside the checkout | | `STRIDO_SERIAL_PORT` | `/dev/strido_mcu` | `strido-cli`, `strido_ntx.plugin` | Serial device of the XIAO ESP32-S3 (udev symlink from `ros2/strido_bringup/config/99-strido.rules`) | The ROS 2 launch files take the same values as launch arguments (`mcu_port`, `use_policy`, `policy`, `camera`). Makefile variables: `NTX_HARNESS` (path of the NT-X checkout for `make ntx-smoke`, default `../../ntx-harness`), `POLICY` (policy file for `make sim-gazebo`). --- # Decisions > **Status:** architecture decision records for Strido. Decision date 2026-10-31 unless noted. ## ADR-001 — Keep the Yertle mechanics, add a modular sensor head **Context.** Mini Pupper 4 is a pre-production product without published CAD, BOM or PCB design. **Options.** (A) Re-model a Mini Pupper 4 clone from photos. (B) Design a new robot. (C) Keep the published, physically built Yertle legs and frame and add the sensor head, electronics and software. **Decision.** C. **Consequences.** Mechanics inherit a real build history; every change is a CAD boolean on the upstream STEP/STL, verified by the harness. **Reconsider only if:** MangDang publishes Mini Pupper 4 CAD under an open licence. ## ADR-002 — CAD as code, verification as gates **Context.** People print and assemble from this repository alone; a wrong hole costs a reprint. **Decision.** All new and modified parts are CadQuery/manifold booleans in `strido_cad`; every check (interference, ROM, fits, clearance, sensors, electronics, BOM coverage) is an executable gate with a tracked report. Tolerance bands and contact rules live in `hardware/verification/*.yaml` with their rationale. **Consequences.** Physical claims are reproducible; a gate may only be relaxed by a recorded decision. **Reconsider only if:** a parametric CAD system with equivalent scripting replaces CadQuery. ## ADR-003 — uv workspace with layered packages **Context.** The robot runtime must stay light (Pi, ROS 2 image) while tooling needs CadQuery, MuJoCo, PyTorch and WeasyPrint. **Options.** (A) One package with extras. (B) A uv workspace: core, cad, verify, publish, sim, ntx, workspace. **Decision.** B, with import-linter contracts (core imports nothing; publish → verify → cad → core; sim and ntx never import CAD). **Consequences.** `pip install packages/strido-core --no-deps` is all the robot needs. **Reconsider only if:** the package count becomes a maintenance burden without dependency benefit. ## ADR-004 — Fix upstream defects as derived parts, not by editing upstream files **Context.** The verification found upstream issues: the front servo mounts lack the hip-pivot pilot on the face the front legs use; the right-leg femur horn pattern is 30° off the STEP; the connector registration is 22° off. **Decision.** Keep `upstream/yertle/` byte-identical; express fixes as data (`hardware/assembly/corrections.yaml`) and derived parts (`left_servo_mount_fl`, `right_servo_mount_fr` in the print kit). **Consequences.** The print kit contains two extra part types; upstream updates can be diffed cleanly. **Reconsider only if:** upstream publishes corrected files. ## ADR-005 — Joint limits include the mirrored partner leg and running clearance **Context.** Per-leg limits allowed both front feet to meet under the body when both hips swing inward. **Decision.** The hip sweep places the axle partner at the mirrored angle and requires ≥ 0.3 mm clearance as well as no overlap. **Consequences.** Narrower inward hip limits (≈ −17.5°); firmware, gait, simulation and NT-X share them through code generation. **Reconsider only if:** a controller coordinates legs to avoid inter-leg contact. ## ADR-006 — Feedback-free learned controller **Context.** SPT5435LV hobby servos report no position or torque. **Decision.** The policy observes only IMU, command, previous action and a gait clock; the firmware's slew limit and clamps are modelled in training; the numpy runtime is shared by simulation and the robot. **Consequences.** Lateral and reverse tracking are weak; transfer depends on measured servo response. **Reconsider only if:** servos with feedback (e.g. serial bus servos) replace the SPT5435LV. ## ADR-007 — NT-X integration through an extension, not core edits **Context.** NT-X dispatches hardware only to Unitree SDK2. **Decision.** Ship catalog assets and a registry `robot` adapter in `strido-ntx`; document the one core change NT-X needs; never edit the NT-X repository from here. **Consequences.** Simulation, Studio and training work today; live hardware sessions wait for the NT-X change. **Reconsider only if:** NT-X adopts registry-based hardware dispatch (then add the target file). ## ADR-008 — VitePress site and WeasyPrint book **Context.** Builders need a browsable catalogue, a 3D model and a printable A4 book. **Decision.** VitePress (as in the other workspace projects) with a three.js viewer of a GLB exported from the CAD; the book is HTML + CSS rendered by WeasyPrint so fasteners can be drawn in true millimetres. **Consequences.** Node 26 for the site; Pango for the PDF. **Reconsider only if:** the site must be served without a build step. ## ADR-009 — Accept the upstream servo-mount M3 pilots, with a slicer action **Context.** The fit check grades a printed M3 pilot above 0.93 d (printed size, CAD − 0.15 mm) as "no grip". 29 screws of the upstream servo-mount family (`left_servo_mount`, `right_servo_mount`, their derived `_fl`/`_fr` variants) and `servo_mount_top_bracket` thread into 2.94–2.97 mm CAD pilots (0.93–0.94 d printed) with 4–12 mm of engagement. Upstream Yertle robots are printed and assembled with exactly these holes; most FDM printers print small holes 0.2–0.3 mm undersize, which lands them in the weak-grip band. Redesigning the four mount parts would change upstream geometry for a printer-dependent margin. **Decision.** Keep the band at 0.93 d and list these parts in `accepted_deviations` of `hardware/verification/tolerances.yaml` with a builder action: print them with X-Y hole compensation −0.15 mm (pilot ≈ 2.65 mm) or, on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check that it bites before loading the joint (anaerobic thread locker does not cure on plastic and can stress-crack PETG; Loctite 243 data sheet). The fit report shows them as warnings carrying that action; any other part over the band still fails. **Consequences.** The gate stays red for new parts and for any further widening; the accepted list is explicit and reviewable. **Reconsider only if:** a printed mount strips a thread (then add Ø2.5 mm re-pilot variants as in ADR-004). ## ADR-010 — Long link buckling: a known weak point, accepted only until the Strido long link replaces it **Context.** The strength check (`strido_verify.strength`) sweeps the knee linkage over every pose inside the joint limits with the knee servo at stall (3.43 N·m). The upstream `long_link` is a bar 5 mm wide in the leg plane and 8 mm across it, 130 mm between its pins. In the leg plane its ends are pinned (K = 1) and its walls give I ≈ 75 mm⁴, so its Euler buckling load is π² · 1500 · 75 / 130² ≈ 65 N. The worst pose drives it with 192 N, so S ≈ 0.34. At the reference pose it is 127 N (S ≈ 0.51); standing (0.70 N·m at the knee, MuJoCo) it is about 29 N (S ≈ 2.2). Across the leg plane it is adequate (S ≈ 1.8 with K = 0.7). Every other check passes S ≥ 1.5. Upstream Yertle robots run this link with servos of the same torque class, so in normal walking it holds. A knee servo pushing at stall against a blocked foot or in an extreme pose bends it sideways in the leg plane. **Decision.** Treat it as a known weak point, not as an acceptable margin. It is listed under `accepted` in `hardware/strength.yaml` with `min_safety: 0.3`, so the gate reports a warning with the builder action (never command the knee against a blocked foot or a hard stop; after hard landings check the long links for a bow and replace a bent one), and fails again if the margin gets worse. **Consequences.** A Strido long link with a shaft about 9 mm wide in the leg plane (I ≈ 330 mm⁴ with walls only, S ≥ 1.5 at the worst pose) is the planned fix (`TODO.md`). It needs the clearance, ROM, printability and strength gates re-run. Once it exists, the `accepted` entry is removed. **Resolved (2026-11-01).** The Strido stiff long link (`strido_cad.long_link`, printed part `stiff_long_link`) replaces the upstream link on all four legs: same pins (131 mm apart), bushing eyes and eye thickness, a 10 × 10 mm shaft instead of 5 × 8 mm. Centred on the pin line it came within 0.25 mm of the femur at the stretched knee and the range-of-motion sweep cut the knee range by 5°; its centre line therefore sits 2 mm off the pin line, away from the femur, and every neighbour keeps at least 1 mm (the joint limits are unchanged). The strength check now gives S = 2.53 for buckling and no check needs an acceptance (`accepted: {}` in `hardware/strength.yaml`). It prints flat without supports. The builder actions above are no longer required; a bent upstream link still means replacing it with the stiff one. ## ADR-011 — Wiring harness routed and checked in the CAD **Context.** Until v0.2.11 the wiring existed only as a list with estimated lengths; nothing modelled a cable. Routing it through the assembly exposed defects the list could not show: the 25 A fuse holder on the deck strip left 4 mm for its 14 AWG lead to leave towards the front hip servo; Dupont housings fit neither on the IMU header (2.5 mm under the Pi) nor on the XIAO pins (9 mm under the upper shoulder frame); the LiDAR USB adapter covered the top-shell cable slot; the XIAO-to-PCA9685 wires need ~0.3 m, not 0.1 m; one STEMMA QT cable was missing; the 18 AWG stock was short; and a 60 mm 18 AWG tap sat behind the 25 A fuse alone. **Options.** (A) Keep the list and fix the defects by hand. (B) Model every cable: anchors on the connectors, tie nodes, a router through the free space (parts plus everything the legs sweep), and a gate. **Decision.** B: `hardware/wiring.yaml` is the single source; `strido_cad.wiring` routes, `strido_cad.wiring_geometry` adds cables, sleeves, ties and tie mounts to the assembly (renders, viewer, manual, model masses); `strido_verify.wiring` checks reach, stock, hip-loop slack, ports and clearance in every operating pose. Servo leads run without extensions, cut to length (signal re-crimped). The main fuse moves to the rear cavity (its leads exit through the frame openings), the logic fuse to the back shell with the holder's own 14 AWG lead as the tap, the battery input to the pocket beside the battery. **Consequences.** W01 (battery input to main fuse, ≈ 0.15 m) is unfused, like the pack's own lead; it runs in spiral wrap clear of moving parts. Builders cut every wire from the generated cut list. Bend radii and the shape of a hanging loop are not modelled (route corners are sharp; loops are checked by length). Any CAD change re-routes and re-checks the harness. **Reconsider only if:** a compact fused battery input (fuse within a few centimetres of the XT60) fits under the deck, or the cable mass/stiffness measured on a real build differs enough to matter for the loops. ## ADR-012 — German site from translation catalogues; assistant service on Cortex Keyring **Context.** The site needed German, an AI assistant that answers from the documentation (with images, history and a pinnable panel) and a way for language models and coding agents to use the same knowledge. About a third of the 110 000 words are generated (assembly manual, BOM, verification reports) and change with every `make` run; the site is static nginx behind Cortex SSO. **Options.** German: (A) a translated copy of every Markdown file; (B) German strings inside every Python generator; (C) a catalogue per page that maps each English segment (paragraph, list item, table row, heading, diagram) to its translation, rendered at build time. Assistant: (D) a model call from the browser; (E) a service next to the site that holds conversations, searches the pages and calls the model through Cortex Keyring. **Decision.** (C) and (E). A changed English segment falls back to English (the page says it is partly translated) and `make drift` fails until it is translated; numbers inside a translated segment carry over, so regenerated reports keep their German; German headings keep the English ids, so every anchor works in both languages. The assistant (`assistant/`, Node, no dependencies) stores conversations and attachments per signed-in user in SQLite, answers with tools over the same pages (`search_docs`, `read_page`, `list_pages`, `search_parts`) and serves them as an MCP server to agents holding a Cortex app token; `/llms.txt` and the Agent Skill package are generated by the docs sync. The app ships its own gateway route: `/api/*` and `/mcp` go to the assistant without compression (server-sent events), everything else to nginx. **Consequences.** English edits need a translation pass (`make i18n`); the catalogues are tracked source. The Keyring `subscriber` is the owner, so until the pool can serve a shared key only the owner's account gets answers (others see why). The gateway secret reaches the assistant through the gateway's generated env file (the Sign/Folio contract), which also exposes the stack's other generated values to that container; copying only that key into the host `.env` (the Canvas contract) is the safer follow-up (TODO). The support widget is off: its proxy would sit in the answer stream. Answers are generated: the bar and `docs/ai.md` say to check figures on the linked page. --- # Design and CAD pipeline ## Source of truth The upstream Yertle STEP assembly (`upstream/yertle/design/CAD/Yertle_Single_v2.step`) and the published print STLs (`upstream/yertle/design/STL/`) define the mechanics. Nothing is re-modelled by hand: the pipeline reads the STEP, registers every STL to its STEP solid, measures the kinematics, and builds the Strido parts as CadQuery booleans on top of the upstream geometry. ```mermaid flowchart TD STEP["upstream/yertle/design/CAD/Yertle_Single_v2.step"] --> CACHE["strido_cad.step_cache
tessellated solids (build/)"] STL["upstream/yertle/design/STL/*.stl"] --> REG["strido_cad.register_upstream
STL ↔ STEP registration"] CACHE --> REG --> PLACE["hardware/assembly/upstream_placements.yaml"] CACHE --> KIN["strido_cad.extract_kinematics"] --> KY["hardware/kinematics.yaml
hip/thigh/knee axes, linkage lengths"] KY --> ROM["strido_verify.rom
collision-free joint limits"] --> JL["hardware/joint_limits.yaml"] KY --> GEN["strido_cad.codegen"] JL --> GEN GEN --> PYG["packages/strido-core/src/strido/geometry.py"] GEN --> CG["firmware/.../geometry_generated.h"] VEND["strido_cad.fetch_vendor_cad
official vendor STEP"] --> PARTS PLACE --> PARTS["strido_cad.parts + catalog.py
new / modified parts, vendor boards, fasteners"] PARTS --> ASM["strido_cad.assembly
full posed assembly"] ASM --> VER["strido_verify
interference, ROM, fits, clearance, sensor FoV"] ASM --> SIM["strido_cad.models
URDF/xacro + MJCF + meshes"] ASM --> OUT["print files, BOM, manual, renders"] ``` Frames: the STEP frame has +x = robot left, +y = robot rear, +z = up (mm). `assembly.step_to_base()` maps it to the REP-103 `base_link` frame (x forward, y left, z up; origin between the hip axes). ## Changes compared with upstream Yertle | Module | Part | Change and reason | |---|---|---| | Front display | `front_shell_display` | Upstream front shell + 43 × 33 mm window and two M3 bosses for the Waveshare 2" LCD; camera ribbon slot | | | `display_bracket` | Clamps the LCD on its M2 hex studs to the shell bosses | | Front camera | `camera_mount_base`, `camera_tilt_plate` | Tilting mount on the flat front-shell top; shares the two upstream shell screws (M3 × 12 instead of × 8); pivot M3 × 40 with lock nut gives a friction tilt. Moved here from the top shell because the first position saw the robot's nose in 42 % of the image (ray-cast check); now 0 % | | Top sensors | `top_shell_sensor`, `lidar_pedestal` | 14 mm pedestal with a pocket for the C1 USB adapter lifts the scan plane above every leg pose and the camera; cable slot to the bay | | Rear panel | `back_shell_button`, `arming_port_bezel`, `arming_port_ring` | Ø 12.2 mm hole for the power button; XT60 arming port in the existing Ø 20 mm hole | | Electronics | `electronics_deck` | Upstream plate + IMU pocket and stand-off holes for the regulator and INA260; printable top-face-down without supports | | Cavities | `lower_shoulder_frame_front/_rear`, `servo_bus_holder_front/_rear` | WAGO servo power bus per cavity, one extra pilot hole in the upstream frame | | Purchased | — | Pi 5 (was Pi 4), XIAO ESP32-S3 (was ESP32 DevKit), 2 × UBEC-10A (was one SBEC), LiDAR, camera, LCD, INA260, LSM6DSOX, Pololu switch and regulator | The upstream "servo holder3" solid in the STEP is a stale duplicate that overlaps the real holder by 2860 mm³; it is ignored. The foot spacer and hip bearing pin exist only in the STEP and are exported as printable STLs. ## Kinematics Each leg: hip abduction (servo in the body), thigh (servo in the leg module, direct drive) and knee. The knee servo drives a crank; a parallelogram and a four-bar linkage transmit it to the tibia, so the calf angle is a non-linear function of the crank angle *and* the thigh angle. `packages/strido-core/src/strido/leg.py` and the firmware's `servo_map` solve that linkage exactly from the CAD-measured link lengths (`hardware/kinematics.yaml`). The upstream STEP has a 1.02 mm link-length mismatch (drawn knee −98.8°, closed linkage −101.2°); the measured lengths are used. Joint limits come from a collision sweep of the full assembly (all parts and the Strido fasteners; upstream joint fasteners, whose head side and nut orientation are approximated, are only in the static interference check): thigh × calf on a 5° grid at zero abduction with a minimum linkage transmission angle of 25°, then the hip range at the standing and mid poses with the mirrored partner leg and 0.3 mm clearance (`hardware/joint_limits.yaml`, MECH-005). Code generation additionally keeps every joint within ±85° servo travel of its assembly neutral: | Joint | Limit | |---|---| | Hip (abduction) | FL −17.5…50°, FR −50…17.5°, RL −17.5…45°, RR −45…17.5° | | Thigh | −40…125° (front; sweep −45°, travel-limited around the 45° neutral), −15…125° (rear) | | Calf | −150…−45° | | Knee crank | −2.5…75° | Assembly reference (all servos at 1500 µs): legs vertical in abduction, femur 45° backwards, knee crank 35°. ## Mass properties Masses come from part volumes (printed parts: PETG/PLA density with the print settings' effective infill) and datasheet masses for purchased parts (`hardware/mass_properties.yaml`); inertias from the meshes, the routed cables by length. Total 3.07 kg; per-link values in `ros2/strido_description/config/mass_report.yaml`. --- # Downloads > **Status:** generated from the verified CAD assembly; the files below are rebuilt with every documentation deploy. They are served by the documentation site, not by the repository view. | Download | Contents | |---|---| | **[Build kit (ZIP)](/downloads/strido-build-kit.zip)** | Every printed part as an oriented STL, the ready 3MF build plates, the verified PrusaSlicer profile, the print list, the catalogue & assembly book PDF and the bill of materials | | **[Catalogue & assembly book (A4 PDF)](/catalog/strido-catalog.pdf)** | Printed and purchased parts, hardware drawn at 1:1, shopping plan, tools, every assembly step, wiring, software appendix | | 3D model (GLB) | The complete assembled robot for any glTF viewer or Blender; interactive in the [3D viewer](viewer.md) | ## Print everything 1. Unpack the build kit and load `prusaslicer.ini` (PrusaSlicer: File › Import › Import Config). Other slicers: enter the settings from `print_list.md` (0.4 mm nozzle, 0.2 mm layers, 3 perimeters, 5 top/bottom layers, 30 % gyroid, PETG). 2. Print the plates in `plates/`: graphite plates for the frame and legs, lime plates for the shells and shoulders. Single parts: `stl/` (quantities in `print_list.md`). 3. Supports and brims as listed per part; the slicer gate in [printability](verification/printability.md) slices every part and plate with exactly this profile. About 1 kg of PETG (one spool per colour) and roughly 100 hours of printing for the whole robot (slicer estimate). ## Source The repository is public: [github.com/Xpitfire/strido](https://github.com/Xpitfire/strido), software under Apache-2.0 and hardware files under CERN-OHL-P-2.0. It holds the CAD pipeline, firmware, ROS 2 packages, simulation and every verification gate; `make publish` regenerates all downloads. --- # Electronics, wiring and power Strido splits the robot into two power domains that share one ground: * **Servo domain (6.0 V):** battery → 25 A main fuse → arming loop key → two Hobbywing UBEC-10A → WAGO servo bus in the front and rear cavities → 12 servos. The PCA9685 supplies only the servo *signals*; servo current never flows through a logic board. * **Logic domain (5 V):** battery → 25 A main fuse → 7.5 A logic fuse → Pololu pushbutton switch → INA260 (measures pack voltage and logic current) → Pololu D36V50F5 5 V/5.5 A → Raspberry Pi 5 GPIO 5 V pins. The Pi powers the XIAO ESP32-S3 (motion controller) and the LiDAR adapter over USB. Board positions are in the CAD; the [interference check](verification/interference.md) covers every board, stand-off and connector body. Every cable is routed through the CAD as well (`hardware/wiring.yaml`, see [wiring harness](#wiring-harness)): the renders, the 3D viewer and the assembly manual draw it, and the [wiring check](verification/wiring.md) proves that every lead reaches, every loop across a hip joint has slack over the whole hip range and no cable touches a moving part in the operating poses. **Power** (servo domain left, logic domain right): ```mermaid flowchart TD BAT["2S LiPo 5000 mAh
XT60 lead (W00)"] --> XT["robot battery input XT60H
(under the deck)"] -->|"W01 14 AWG"| FUSE["ATO holder, 25 A main fuse
(rear cavity)"] FUSE -->|"W02 14 AWG"| KEY["XT60 arming port
(back shell, loop key)"] KEY -->|"W03 14 AWG"| UF["UBEC-10A front
6.0 V"] -->|"W05"| WF["WAGO 221-415 ×4
front cavity bus"] --> SF["FL, FR servos ×6"] KEY -->|"W03 14 AWG"| UR["UBEC-10A rear
6.0 V"] -->|"W06"| WR["WAGO 221-415 ×4
rear cavity bus"] --> SR["RL, RR servos ×6"] FUSE -->|"holder lead 14 AWG"| LF["ATO holder, 7.5 A logic fuse
(back shell)"] -->|"W09 18 AWG"| SW["Pololu #2813
pushbutton switch"] -->|"W10"| INA["INA260
0x41"] -->|"W11"| REG["Pololu D36V50F5
5 V 5.5 A"] XT -->|"W04 14/18 AWG, ground star"| UF & UR & SW BTN["12 mm button
(back shell)"] -.->|"A–B"| SW REG -->|"W12: GPIO pins 2/4 (5 V), 6/14 (GND)"| PI["Raspberry Pi 5"] ``` **Signals:** ```mermaid flowchart TD PI["Raspberry Pi 5"] -->|"USB-C"| XIAO["XIAO ESP32-S3"] PI -->|"USB"| LIDAR["RPLIDAR C1
+ USB adapter"] PI -->|"CSI 300 mm"| CAM["Camera Module 3 Wide"] XIAO -->|"I²C 400 kHz"| PCA["PCA9685 0x40"] XIAO -->|"I²C"| IMU["LSM6DSOX 0x6A"] XIAO -->|"I²C"| INA["INA260 0x41"] XIAO -->|"SPI"| LCD["Waveshare 2-inch ST7789"] XIAO -->|"D3 → OE (2.2 kΩ pull-up)"| PCA PCA -->|"signal only"| SERVOS["12 servos"] ``` ## Ratings and why these parts | Element | Rating | Load in this robot | Margin / note | |---|---|---|---| | SPT5435LV servo | 4.8–6.0 V only; PWM signal 3.3–5.0 V; 35 kg·cm @ 6 V; stall 3.5 A | standing ≈ 0.7 N·m at the knee (MuJoCo) | 6.0 V is the upper limit: do **not** use the 7.4 V UBEC setting. The PCA9685 drives 3.3 V pulses (VCC = XIAO 3V3), exactly the servo's lowest rated signal level: in spec, without margin | | Hobbywing UBEC-10A ×2 | 2–6S in; 6.0 V with no jumper; 10 A continuous, 15 A peak each | 6 servos each | 20 A continuous / 30 A peak total. Six stalled servos draw 21 A, more than one UBEC's 15 A peak: never command all legs into hard stops or apply power while servos are commanded (see first power-up) | | WAGO 221-415 | 32 A, 0.2–4 mm² | ≤ 3 servos + feed per connector | 2 connectors (+/–) per leg, 8 in total | | Main fuse (ATO) | 25 A | servo branch (≤ 2 × 10 A UBEC output ≈ 21 A at an empty pack) + logic | protects the 14 AWG trunk behind it; W01 (≈ 0.15 m from the battery input) is unfused like the pack's own lead | | Logic fuse (ATO) | 7.5 A | logic branch ≤ 3 A | protects the 18 AWG logic wiring after its holder. The tap from W02 is the holder's own 14 AWG lead, so no wire thinner than 14 AWG sits behind the 25 A fuse alone | | Pololu #2813 (Big Pushbutton Power Switch HP) | 4.5–40 V; 6 A continuous at 55 °C | logic branch ≤ 3 A | never in the servo branch; not an emergency stop (Pololu) | | INA260 | 36 V, 15 A, 1.25 mA LSB | logic branch | measures pack voltage for the low-battery cut-off. **Required:** without it the firmware refuses to drive the servos | | Pololu D36V50F5 | 5 V ± 4 %, 5.5 A typical continuous | Pi 5 + USB + camera ≈ 2–3 A | Pi needs `PSU_MAX_CURRENT=5000` (see below) | | XT60 arming port | 30 A continuous | servo branch | the loop key is an XT60 with a 14 AWG bridge, see the gender rule below | Every supply input and every logic signal is checked against its rated range by `python -m strido_verify.electronics` (rails, supplies and signals in `hardware/electronics.yaml`; report in [verification/electronics.md](verification/electronics.md)). Three pass without margin: the servo PWM and the LCD run at 3.3 V, the lowest level both accept, so a weak XIAO 3V3 rail (below 3.3 V) is the first thing to measure if a servo jitters or the LCD stays dark. ### XT60 contact rule (the loop key must never fit the battery) Describe XT60 halves by their contacts, not by "male/female" (vendors use both words for both halves): one half has **pins**, the other **sockets** (Amass: XT60-M = pins, XT60-F = sockets). The loop key is a shorted connector, so it must carry the **same contact type as the battery's own connector**: then it cannot mate with the pack. The robot's battery input and the arming port carry the other type. Example: pack with sockets → robot battery input pins, arming port pins, loop key sockets. The CAD models an XT60H-F (sockets) arming port, i.e. a pack with pins; swap all three if your pack has sockets. Check before bridging the key: take the *unbridged* key housing and try it on the pack connector; it must not fit. Never test with a bridged key. The battery itself can mate with the arming port; label the port "ARM" and always connect the pack to the battery input under the deck (W00 into the XT60H of W01/W04). ## Wiring list Every cable of `hardware/wiring.yaml`; routes, cut lengths and the checks are in the [wiring check](verification/wiring.md) (regenerated from the CAD). Colour: red = +, black/brown = –, signals as noted. | ID | From | To | Cable | Termination | |---|---|---|---|---| | W00 | battery (its own 12 AWG lead) | battery input XT60H, under the deck | supplied with the pack | plug in at Step 22 | | W01 | battery input XT60H (+) | 25 A main fuse holder (rear cavity) | 14 AWG red in spiral wrap | solder, heat-shrink; spliced to the holder lead trimmed to 20 mm; unfused | | W02 | main fuse out (holder lead trimmed to 20 mm) | back-shell splice → arming jack contact 1, and the logic fuse holder's own input lead (trimmed to its cut length) | 14 AWG red | solder + heat-shrink at the splices | | W03 | arming jack contact 2 | splice under the deck → UBEC front IN+ and UBEC rear IN+ | 14 AWG red | solder; the loop key bridges the jack's two contacts | | W04 | battery input XT60H (–) | ground star beside it → UBEC IN– ×2 (14 AWG), switch GND (18 AWG) | 14 / 18 AWG black | one star point at the battery input | | W05 / W06 | UBEC front / rear OUT | port 0 of WAGO bay 0, front / rear cavity | UBEC lead extended with 18 AWG pair | strip 11 mm into the WAGO pair (red V+ upper, black GND lower) | | W07 / W08 | WAGO port 4 of bay 0 | port 0 of bay 1 (second leg of the cavity) | 18 AWG, one wire per rail | along the WAGO faces | | S-*leg*-*joint* (12) | servo lead | split at the base tie: red/brown → the leg's WAGO pair (port hip 1, thigh 2, knee 3); orange → PCA9685 channel | the servo's own 260 mm lead, cut to length | red/brown stripped into the WAGO; orange re-crimped (female Dupont, 1-pin housing) | | W09 | 7.5 A logic fuse out (holder lead trimmed to 20 mm) | switch VIN | 18 AWG red | splice at the holder, screw terminal at the switch; its ground is W04 | | W10 | switch VOUT | INA260 VIN+ | 18 AWG red | screw terminals; the INA260 is a high-side shunt | | W11 | INA260 VIN– | D36V50F5 VIN | 18 AWG red | screw terminal, solder | | W24 | switch GND | D36V50F5 GND | 18 AWG black | the INA260 terminal block takes only VIN+ and VIN–; its logic ground comes over I²C (W19) | | W12 | D36V50F5 VOUT / GND | Pi 5 header pins 2 + 4 (5 V), 6 + 14 (GND) | 22 AWG ×4 | Dupont female crimp, one wire per pin (2 × 1.5 A per rail) | | W13 | 12 mm pushbutton | switch A and B | 22 AWG pair | solder, heat-shrink | | W14 | Pi 5 USB-A (lower port of the left stack) | XIAO USB-C | 20 cm USB-A (90°) → C | fold the spare length in the front cavity | | W15 | Pi 5 USB-A (upper port) | LiDAR USB adapter (in the pedestal) | 20 cm USB-A (90°) → C | over the Pi beside the cooler fan, up through the top-shell slot | | W16 | Pi 5 CAM/DISP 0 | Camera Module 3 Wide | 22→15 pin ribbon, 300 mm | through both shoulder-frame openings and the front-shell slot; fold flat | | W17 | XIAO D4/D5 (SDA/SCL), 3V3, GND | PCA9685 left header (SDA, SCL, VCC, GND) | 22 AWG ×4 | soldered to the XIAO pins (no room for housings under the upper shoulder frame), Dupont crimps at the PCA | | W18 | PCA9685 right header (VCC, SDA, SCL, GND) | LSM6DSOX STEMMA QT | STEMMA QT → female sockets, 150 mm | the IMU sits 2.5 mm under the Pi: only the low STEMMA QT plug fits | | W19 | LSM6DSOX second STEMMA QT | INA260 header (VCC, SDA, SCL, GND) | STEMMA QT → female sockets, 150 mm | INA260 A0 jumper bridged → 0x41 | | W20 | XIAO D3 | PCA9685 OE | 22 AWG green | high = all servo outputs off. Solder a 2.2 kΩ resistor from OE to VCC on the PCA9685 header: the board pulls OE low, so without it the outputs stay on while the XIAO resets or sits in its bootloader | | W21 | XIAO D8, D10, D9, D0, D1, D2, 3V3, GND | LCD CLK, DIN, DC, CS, RST, BL, VCC, GND | display cable (PH2.0 8-pin, 200 mm, supplied) | its Dupont ends soldered to the XIAO pins; 3V3 and GND share the pins of W17 | | W22 (optional) | Pi 5 GPIO 26 (pin 37) | switch OFF | 22 AWG white | Dupont crimp at the Pi, soldered at the switch; lets the Pi cut its own power after shutdown, see below | | W23 | RPLIDAR C1 | its USB adapter | the sensor's own cable | through the notch at the pedestal front | ## Pin maps **XIAO ESP32-S3** (`firmware/strido_esp32s3/src/board_config.h` is the source of truth) | XIAO pin | GPIO | Function | |---|---|---| | D0 | 1 | LCD CS | | D1 | 2 | LCD RST | | D2 | 3 | LCD backlight (PWM; strapping pin, driven only after boot) | | D3 | 4 | PCA9685 OE (active low enable; firmware holds it high until armed) | | D4 | 5 | I²C SDA | | D5 | 6 | I²C SCL | | D8 | 7 | LCD SCK | | D9 | 8 | LCD DC | | D10 | 9 | LCD MOSI | | — | 21 | on-board user LED (status) | **PCA9685 channels** (200 Hz, 500–2500 µs) | Leg | hip | thigh | knee crank | |---|---|---|---| | FL | 15 | 11 | 7 | | FR | 14 | 10 | 6 | | RL | 13 | 9 | 5 | | RR | 12 | 8 | 4 | **I²C bus:** PCA9685 0x40 (and its all-call 0x70), LSM6DSOX 0x6A, INA260 0x41 (A0 bridged; 0x40 would collide with the PCA9685). ## Power budget and runtime | Consumer | Typical | Peak | Source | |---|---|---|---| | 12 servos standing | ≈ 30 W (≈ 5 A at 6 V) | — | [ESTIMATE] from the MuJoCo standing torques and the 3.5 A stall current at 3.43 N·m | | 12 servos trotting | ≈ 50 W | > 150 W for ms | [ESTIMATE] | | Pi 5 + cooler | 5–7 W | 12 W | Raspberry Pi | | RPLIDAR C1 | ≈ 1.2 W | — | Slamtec datasheet | | Camera, XIAO, LCD, IMU, PCA | ≈ 1.5 W | — | datasheets | | **Total trotting** | ≈ 60 W from the pack (90 % converters) | | | 2S 5000 mAh ≈ 37 Wh, 80 % usable → **≈ 30 min trotting, ≈ 45 min standing** [ESTIMATE; measure with `/battery_state` and the arming-key current with a clamp meter]. The firmware warns at 6.8 V (3.4 V/cell) and relaxes the servos at 6.4 V (3.2 V/cell). ## Wiring harness ![The routed harness with the shells, top sensors and upper shoulder frames removed](images/wiring.png) The harness is designed in the CAD, not improvised in the build: `hardware/wiring.yaml` names every cable, the connector each end sits on and the tie points it passes; `strido_cad.wiring` routes the legs between those points through the free space of the robot (every base part and everything the legs sweep over their joint ranges is kept out), and the result is drawn in the renders, the [3D viewer](viewer.md) and the [assembly manual](assembly/README.md) (Steps 14–16, the shell steps and Step 20). The [wiring check](verification/wiring.md) then proves, independently of the router: * **reach:** every ready-made lead (servo leads 260 mm, USB 200 mm, ribbon 300 mm, STEMMA QT 150 mm, the pack lead) covers its route with at least 10 mm to spare; * **stock:** the cut list (route + strip/solder allowance + 8 % slack) fits the wire, spiral wrap and ties in the BOM; * **hip loops:** the leads of the thigh and knee servos ride on the hip and cross the hip joint near its axis; each has a free loop at least 1.2 × the largest distance its two ties reach over the whole hip range (36 mm thigh, 40 mm knee); * **clearance:** no cable passes through a part, and against every part of another body (legs, horns, links) it keeps the running clearance of the moving-parts check in the 13 operating poses (the router keeps cables out of everything the legs sweep over a grid of joint angles); * **ports:** one cable per WAGO entry, one plug per PCA9685 channel. Build order (the manual draws each step): 1. **Servo bus.** Clip the four WAGO 221-415 into each bus holder: one V+ (upper) and one GND (lower) connector per leg; all entries face the robot's left side. Bay 0 (front of each cavity): UBEC feed port 0, hip/thigh/knee ports 1–3, jumper port 4. Bay 1: jumper port 0, servos 1–3, port 4 spare. Unclip a WAGO pair to open its levers: the XIAO platform blocks them in bay 0. 2. **UBECs.** Leave the output jumper open (6.0 V). Measure the unloaded output before connecting any servo: accept 5.8–6.0 V (the servo is rated up to 6.0 V; return a unit that reads higher). Foam-tape both UBECs into the side pockets under the deck. 3. **Power entry (Step 14).** Battery input XT60H with W01 (red, in spiral wrap) and W04 (black) in the pocket beside the battery; ground star; main fuse holder (leads trimmed to 20 mm) on the rear WAGO bus, tied to the upper shoulder frame; W02, W03 and W09 run to the back-shell space; no fuses yet. 4. **Servo leads (Step 15).** Tie each thigh and knee lead at the servo mount plate, leave the free loop, tie it at the shoulder frame or bus holder; split it there: red/brown cut to length into the leg's WAGO pair, orange cut to length and re-crimped for its PCA9685 channel. Hip servo leads split at the inner frame opening. 5. **Logic and signals (Step 16).** W10/W11 (18 AWG red) from the switch through the INA260 (A0 bridged) to the D36V50F5, W24 (18 AWG black) switch GND to regulator GND, W12 crimped but **not yet plugged** into the Pi, W14, W17–W20, W22. 6. **Shell steps.** Display cable W21 (Step 17), camera ribbon W16 (Step 18); rear panel (Step 19): button W13, W02/W03 soldered to the arming jack, logic fuse holder spliced into W02 and W09. 7. **Supply check and dressing (Step 20).** Pack plugged in for a moment, both fuses in, loop key out, button pressed: 4.8–5.2 V at the W12 Dupont ends (D36V50F5: 5 V ± 4 %); fuses out again, then plug W12 onto Pi pins 2/4/6/14. Spiral wrap on every bundle of three or more cables and on W01, ties at the marked points, two adhesive tie mounts on the deck; move every leg through its range by hand and check nothing pulls or rubs. The LiDAR cables go in with the top shell (Step 21), the pack lead with the battery (Step 22). 8. **Smoke test without servos:** fuses in, loop key **out**, power on → Pi boots, `ros2 topic echo /battery_state` shows the pack voltage, `/diagnostics` shows `pwm_ok`, `imu_ok`, `power_ok`. Hold the XIAO's BOOT button and press RESET: OE must read ≥ 2.4 V (outputs off) while it sits in the bootloader. Why the harness is laid out this way: [cable management](wiki/making/cable-management.md) (decision: [ADR-011](decisions.md#adr-011-wiring-harness-routed-and-checked-in-the-cad)). ## Raspberry Pi configuration Power through the GPIO 5 V pins bypasses USB-PD, so tell the Pi the supply can deliver 5 A: ```bash sudo rpi-eeprom-config --edit # add: PSU_MAX_CURRENT=5000 echo "usb_max_current_enable=1" | sudo tee -a /boot/firmware/config.txt ``` Stable device names (`ros2/strido_bringup/config/99-strido.rules`): ```bash sudo cp ros2/strido_bringup/config/99-strido.rules /etc/udev/rules.d/ && sudo udevadm control --reload && sudo udevadm trigger ``` Optional self power-off (W22): `dtoverlay=gpio-poweroff,gpiopin=26` in `config.txt` drives GPIO 26 high after shutdown, which pulses the switch's OFF input. [UNVERIFIED on Pi 5: test with the servos disarmed before relying on it.] ## Safety rules * Charge only in a LiPo bag with a balance charger; never leave charging unattended. * The loop key is the servo kill: pull it to disarm. The pushbutton switch is not an emergency stop (Pololu says so explicitly). * `/strido/estop` latches: the firmware pulls PCA9685 OE high and stops all pulses. * Startup order: logic on → robot lying in the rest pose (legs folded) → insert the loop key (servos stay limp; the controller sends no pulses before the first position command) → start `ros2 launch strido_bringup robot.launch.py` (servos go to the rest pose, then a 3 s stand-up). Never insert the key while position commands are running: all servos would jump at once. * The firmware refuses to drive without a working INA260 (no low-battery cut-off otherwise). The cut-off needs 5 consecutive readings (0.5 s) below 6.4 V, so load sags do not drop the robot mid-step. * Without commands for 200 ms the servos hold their pose; 2 s later they relax. There is no separate keep-alive: if the gait or policy node dies, the robot holds, then relaxes. --- # Getting started Seven stages from an empty desk to a walking robot. Each stage links to the page with the details; the [Learn wiki](wiki/README.md) explains the engineering behind them. ## 1 · Print Easiest: the [print kit](print-kit.md) page builds a ready-to-print package (pick complete robots or single parts; STLs named with their quantity, the slicer profile, a parts sheet and the ready plates), or take the whole build kit from the [downloads](downloads.md) page. Import the included `prusaslicer.ini` (PrusaSlicer: File › Import › Import Config; other slicers: enter the values from the [print list](../hardware/print/print_list.md)), then print the plates (220 × 220 mm bed or larger) or the single STLs. Every part and plate was sliced with exactly this profile ([printability](verification/printability.md)); about 1.0 kg PETG and 100 h of printing. Lime parts are the shells and shoulders; everything else graphite. After printing, follow the warnings in the [fit report](verification/fits.md). ## 2 · Buy The [shop](shop.md) sells the complete robot as one kit (or single parts) with real shop offers: pick one of the four [buying plans](bom/shopping_list.md) or change the shop per part, see the total with every shop's shipping in the cart, and check out per shop with a guided checklist that opens each item's shop page in turn (plus a copyable order list for shops with a quick-order form). Fasteners include 10 % spares. Tools: section E of the [catalogue PDF](catalog/strido-catalog.pdf). ## 3 · Assemble Follow the [assembly manual](assembly/README.md) or the PDF. The [3D viewer](viewer.md) replays every step on the full model. Before step 1: centre all twelve servos (step 5 below can be done on the bench first). ## 4 · Wire [Electronics](electronics.md): power tree, wiring list, the routed wiring harness, pin map and the first smoke test without servos. The pin map is machine-checked against the firmware ([electronics check](verification/electronics.md)). ## 5 · Flash and calibrate ```bash uv sync --locked # Python tools (strido-cli) cd firmware/strido_esp32s3 && pio run -e xiao_esp32s3 -t upload uv run strido-cli --port /dev/ttyACM0 center # assembly reference: hip vertical, femur 45° back, crank 35° uv run strido-cli --port /dev/ttyACM0 calibrate # per-joint trim, stored in flash ``` Details: [software › firmware](software.md#firmware) and [electronics › wiring harness](electronics.md#wiring-harness). ## 6 · Run On the Raspberry Pi 5 (Docker): `docker compose -f docker/compose.yaml run --rm robot`, then `ros2 run teleop_twist_keyboard teleop_twist_keyboard`. Topics and launch files: [software](software.md). ## 7 · Simulate, train, integrate `make sim-walk`, `make sim-gazebo`, `make rl-train`, and the NT-X harness via `make ntx-smoke`: [simulation](simulation.md), [NT-X integration](ntx-integration.md). ## Developing on this repository Changing the design, running the verification gates or rebuilding this site: see the [harness runbook](harness.md). --- # Engineering harness runbook > **Status:** how to work on this repository (humans and coding agents). Rules: `AGENTS.md`. ## Setup ```bash make setup # uv sync, docs-site npm ci, submodules, harness install (hooks, skills, MCP config) ``` Requirements: uv ≥ 0.9, Python 3.12 (managed by uv), Node 26 (`.nvmrc`), PlatformIO (`pipx install platformio`), Blender 4 for renders, Docker for Gazebo, Pango for the PDF (`brew install pango`). ## Gates | Tier | Command | Proves | Time | |---|---|---|---| | Q0 | `make check-fast` | ruff, mypy (strict for core/ntx/workspace), import-linter layers, generated-file drift (geometry bindings, electronics report, NT-X assets), Python tests with coverage, firmware native tests | ~1 min | | Q1 | `make verify` | CAD: hole stacks → fasteners, interference (4 poses), sensor FoV, ROM sweep, fits/tolerances, strength (link bending/buckling, screw shear, hole pressure, bearings, pilot torques), moving clearance | ~10 min | | Q1 | `make printability` | every printed part and 3MF plate sliced by PrusaSlicer with `hardware/print/prusaslicer.ini` (errors, conflicts, stability issues, missing parts fail), wall-thickness scan, filament and time | ~1 min, needs PrusaSlicer | | Q1 | `make check` | Q0 + verify + printability + BOM coverage + CAD tests + docs build + harness checks | ~13 min | | Q2 | `make smoke` | MuJoCo walk video, MCP server over stdio, NT-X extension inside the NT-X harness | ~2 min | | Q2 | `make sim-gazebo`, `make rl-smoke` | ROS 2 + Gazebo walk test in Docker; PPO pipeline | 5–10 min | | Q3 | hardware | a built robot: stand, walk, measured current/runtime | — | The pre-commit hook runs the harness self-check; CI (`.github/workflows/quality.yml`) runs Q0 and the docs build. ## Changing hardware 1. Edit the source: a CadQuery part in `packages/strido-cad/src/strido_cad/parts.py`, a placement, `hardware/*.yaml`. 2. `make cad` if kinematics or limits can change, else `make models`. 3. `make verify` — fix geometry until it passes; never widen `hardware/verification/*.yaml` to pass. Record any deliberate rule change in `docs/decisions.md`. 4. `make publish` — print kit, BOM, manual, renders, viewer, book. 5. `make check-fast`, record the run in `docs/verification.md`. ## Agent tooling * **Skills** (`.agents/skills/`): `strido-hardware`, `strido-verification`, `strido-bringup`, `strido-docs`, `strido-ntx`, plus the harness skills (`software-engineering`, …). * **MCP** (`.mcp.json`, `.codex/config.toml`): `strido-hardware` (part sizes, fasteners, fits, clearances, joint limits, pin map, BOM, verification status), `codegraph` (code index). * **CLI**: `uv run strido part electronics_deck`, `strido pinout lcd`, `strido fits --status warn`, `strido clearances --max-gap 1`. ## Release `make publish && make package` writes `dist/strido-.zip` (repository + PDF + walk video). Tag `strido-v`. ## Documentation site on Cortex The site runs at **https://strido.cortex.a2olabs.com/**, public, in English and German (`/de/`). The image (`docs-site/Dockerfile`, `docker-compose.docs.yml` + `docker-compose.cortex.yml`) builds the site from the repository and bundles the downloads: the build kit ZIP (every STL, the 3MF plates, slicer profile, print list, book PDF, BOM), the book PDF, the 3D model, the Agent Skill ZIP and `llms.txt`. A second service, `assistant`, answers the AI bar and the MCP server `/mcp` from the same pages; it reaches the model through Cortex Keyring (`models` in `.cortex/app.yml`) and keeps conversations in a Docker volume. The app's own gateway route (`.cortex/access/caddy/sso/`) serves the site to everyone and sends `/api/*` and `/mcp` to the assistant behind Cortex SSO (or a Cortex app token), so the assistant's model use stays with signed-in users. ```bash make docs & make assistant # local: site with live AI bar on http://127.0.0.1:4173/ docker compose -f docker-compose.docs.yml up --build # local images: site :4392, assistant :4393 make i18n # after English changes: new German segments to translate git push # Cortex builds from GitHub, not from the checkout cortex deploy --ref "$(git rev-parse HEAD)" cortex jobs watch cortex apps show strido ``` The repository is public at [github.com/Xpitfire/strido](https://github.com/Xpitfire/strido). Publish only after `make publish` and `make printability`, so the downloads match the verified CAD. --- # NT-X integration > **Status:** catalog, simulation and the serial transport are verified inside the NT-X harness environment (`make ntx-smoke`). Opening the physical robot from an NT-X session needs one NT-X core change (below). `packages/strido-ntx` is an NT-X extension. Installed into the NT-X environment it contributes: | Entry point | Contributes | |---|---| | `ntx.assets` → `strido_ntx` | `robots/strido.yaml` (RobotSpec), `robots/models/strido.xml` + meshes (MJCF), `skills/strido-stand.yaml` | | `ntx.adapters` → `strido_ntx.plugin:register` | robot adapter `strido-serial` → `StridoSerialPort` | | `ntx-extension.yaml` | manifest for `ntx ext info/doctor strido` | ## Install into NT-X ```bash cd ../../ntx-harness uv pip install -e ../workspace/robot/packages/strido-core -e ../workspace/robot/packages/strido-ntx uv run ntx ext doctor strido uv run ntx robots show strido ``` `make ntx-smoke` runs the same checks without touching the NT-X virtualenv (`uv run --project ntx-harness --with-editable …`): the robot loads from the catalog, compiles into an NT-X MuJoCo world and stands for 2 s, the adapter is registered, and the serial port drives a protocol-level fake MCU (command → state → protective stop → close). ## Robot spec Generated by `python -m strido_ntx.build_assets` from the CAD-derived MJCF (`make models` runs it; `make drift` checks it): * joints in MCU order FL, FR, RL, RR × (hip, thigh, calf), limits from the collision sweep, defaults = 170 mm stand pose; * SPT5435LV limits: 3.43 N·m effort, 7.48 rad/s velocity; PD gains from the MuJoCo servo model; * feet `FL_foot` … bodies at the foot centres; `hardware_status: unverified` until a robot is qualified. ## RobotPort semantics (`StridoSerialPort`) | NT-X expects | Strido provides | |---|---| | `read_state().q`, `dq`, `tau` | `q` = the angle the MCU applies after its clamps (hobby servos report nothing); `dq` zeros; `tau` empty | | base pose / velocity | orientation + body rates from the IMU; position and linear velocity not measured → `has_odometry = False`. A state with `imu_ok` cleared latches the protective stop and raises `ImuUnavailableError` (fail closed) | | `apply(JointCommand)` | position target only. Positive `kp`/`kd` run as the servo's fixed stiffness and `tau_ff` (the gravity feed-forward of `pd-pose`) is dropped; a non-zero `dq` or `kp <= 0` is rejected; non-finite targets latch a protective stop | | `hold()` | re-sends the last target (MCU watchdog holds 200 ms, relaxes after 2 s) | | `protective_stop()` | latched MCU e-stop: PCA9685 OE high, every servo pulse off. Servos go limp (no damping) | | `release()`, `close()` | clear the e-stop; stop and free the serial device | | freshness | the input buffer is flushed on open and drained on every poll (newest frame wins); `TimeoutError` when no state frame arrives within 0.2 s | **Odometry gate.** NT-X sessions refuse non-gantry skills on a port without odometry (`ntx.runtime.session._check_port_support`). `strido-stand` is a free-standing skill, so on hardware it runs only after a base estimator exists, or as a gantry variant with the body on a stand and the legs free. Simulation sessions are unaffected (the MuJoCo port measures the base). ## What NT-X needs to change for hardware sessions NT-X opens hardware through `ntx.deploy.open_robot_port`, which always builds the Unitree SDK2 port, and `TargetSpec.transport.kind` only accepts `unitree-sdk2`. To run Strido live from a session, NT-X should dispatch hardware through its adapter registry: 1. allow `transport.kind` values that name a registered `robot` adapter (e.g. `strido-serial`); 2. in `open_robot_port`, `registry.create("robot", target.transport.kind, spec=robot, device=…)`; 3. add a target `config/targets/strido-desk.yaml` with the qualification gates (identity, e-stop, shadow) for a built robot; 4. provide a base pose estimate (IMU + leg kinematics) or a gantry variant of `strido-stand` for the first hardware sessions. This change belongs to the NT-X repository and is tracked in [TODO](https://github.com/Xpitfire/strido/blob/main/TODO.md); nothing in this repository edits NT-X. ## Training in NT-X The NT-X `locomotion-velocity` template trains any catalog quadruped: `uv run ntx train run --template locomotion-velocity --set spec.robot_id=strido`. Policies trained there use NT-X's observation layout; the Strido ROS 2 `policy_node` runs policies from `strido_sim.rl.train` (feedback-free observation). Bridging the two runtimes is open work. --- # Strido at a glance > **Status:** design verified in CAD and simulation; no robot has been built from this revision yet (see [verification](verification.md)). Strido is a 3D-printed, 12-DoF quadruped in the class of the Mini Pupper 4: small enough for a desk, complete enough to develop real ROS 2 and learning software that transfers to a Unitree Go2. It keeps the proven legs and frame of [Yertle](https://github.com/Jerome-Graves/yertle) (MIT, Jerome Graves) and adds the sensor head, electronics and software stack of a modern research robot. ![Strido standing](images/hero_front.png) ## Specifications | Property | Strido | |---|---| | Size (standing, incl. LiDAR) | 430 × 188 × 264 mm | | Mass (CAD, incl. battery) | ≈ 3.0 kg | | Actuation | 12 × SPT5435LV, 35 kg·cm at 6 V; knee driven through a crank and four-bar linkage | | Compute | Raspberry Pi 5 (4 GB) + Seeed XIAO ESP32-S3 motion controller (200 Hz) | | Sensors | RPLIDAR C1 (unobstructed 360°), Camera Module 3 Wide, LSM6DSOX IMU, INA260 power monitor | | Face | Waveshare 2" ST7789 LCD behind the front shell | | Power | 2S 5000 mAh LiPo; 2 × 6.0 V UBEC for the servos; 5 V/5.5 A logic regulator; 25 A main + 7.5 A logic fuse; XT60 arming key | | Software | ROS 2 Jazzy (Docker, amd64/arm64), Gazebo Harmonic, MuJoCo 3, PPO, NT-X extension | | Cost | from €979 incl. shipping to Austria (plan A, lowest total; see the [shop](shop.md) and the [shopping list](bom/shopping_list.md)) | ## How it compares | | Strido | Mini Pupper 2 | Mini Pupper 4 (pre-production) | Unitree Go2 | |---|---|---|---|---| | Leg DoF | 12 | 12 | 12 | 12 | | Size class | 430 mm | 210 mm | ≈ 250 mm | 700 mm | | Motion controller | ESP32-S3 | ESP32-S3 | n/a | proprietary | | LiDAR / camera / face | yes / yes / yes | optional / yes / yes | yes / yes / yes | yes / yes / no | | ROS 2 contract | Go2 joint names, `/cmd_vel`, `/scan` | MangDang stack | MangDang stack | Unitree SDK2 / ROS 2 | | Openness | full CAD, print files, firmware, sim models | partial | not published | closed | The point is transfer: develop navigation, perception and learning at home against the same ROS 2 topics and joint conventions as the Go2, then move the application layer to the Go2 at work. Low-level gaits and learned policies do not transfer unchanged between a 3 kg hobby-servo robot and a 15 kg Go2; see [simulation](simulation.md). ## What changed compared with Yertle * **Sensor head:** front shell with LCD window and bracket; tilting camera mount; LiDAR pedestal lifting the scan plane above every leg pose. * **Electronics:** Pi 5 + XIAO ESP32-S3, PCA9685 with hardware output-enable kill, IMU, power monitor; servo current on two UBECs through WAGO buses, never through a logic board. * **Mechanics fixed by the verification harness:** front servo mounts get the missing hip-pivot pilot; right-leg femur horns and connectors aligned to the printed hole pattern; LiDAR pedestal pilots deepened so the screws clamp; hip limits that keep the feet apart when both legs swing in. * **Software:** linkage-exact knee mapping, watchdog, latched e-stop, battery cut-off, Go2-style ROS 2 contract, MuJoCo/Gazebo models generated from CAD, PPO training, NT-X integration. Continue with [getting started](getting-started.md). --- # Print kit Choose the printed parts and quantities (or complete robots) and download a ready-to-print package: every STL oriented for printing and named with its quantity, the PrusaSlicer profile every part was verified with, a parts sheet with filament, time, supports and brims, and for complete robots the ready 220 × 220 mm build plates. Open any part for what it does and where it goes. The whole kit is also in the [build kit ZIP](downloads.md). --- # Shop Everything to build Strido, priced at real shops: Raspberry Pi 5 with cooler and microSD, XIAO ESP32-S3, PCA9685 servo driver, twelve SPT5435LV servos with aluminium horns, RPLIDAR C1, Camera Module 3 Wide, 2" LCD, LSM6DSOX IMU, INA260 power monitor, 2S LiPo with charger, UBECs, regulator, fuses, connectors and wire, bearings, every screw with spares, and the filament. Add the complete robot (or single parts) to the cart, choose one of the four buying plans or change the shop per part, and open any part for its description, specifications and where it goes in the robot. The cart adds every shop's shipping; the checkout splits the order per shop and walks you through it: a checklist per shop opens each item's page in turn with the quantity and order code to enter, and a copyable order list serves shops with a quick-order form. Nothing is ordered here, and prices are the offers observed on 30 October 2026: check each shop's cart before paying. The printed parts are on the [print kit](print-kit.md) page. --- # Simulation and learning All models are generated from the CAD by `strido_cad.models` (`make models`): | File | Use | |---|---| | `ros2/strido_description/urdf/strido.urdf.xacro` (+ `strido.urdf`) | ROS 2 / Gazebo Harmonic: `sim:=true` adds `gz_ros2_control`, IMU, GPU LiDAR, camera and a ground-truth odometry publisher | | `ros2/strido_description/meshes/*.stl` | 13 link meshes (decimated, 25 k faces each) | | `models/mujoco/strido.xml`, `scene.xml` | MuJoCo 3: position servos (kp 25, kv 0.4, ±3.43 N·m = SPT5435LV stall torque), IMU and foot sites | Link masses and inertias come from the CAD (`ros2/strido_description/config/mass_report.yaml`, total 3.07 kg incl. the routed wiring). The knee is modelled as a direct revolute joint on the calf; the linkage is solved in software (gait, firmware) and its limits are applied before commands reach the joint. ## MuJoCo ```bash make sim-walk # 10 s trot at 0.15 m/s -> build/sim/walk.mp4 uv run --locked python -m strido_sim.walk --seconds 6 --wz 0.6 # turn uv run --locked mjpython -m strido_sim.walk --viewer # interactive (macOS needs mjpython) ``` The walking demo runs `packages/strido-core/src/strido/gait.py`, the gait the robot runs through ROS 2, plus a heading hold that uses the simulator's yaw (the robot has no such loop). ## Gazebo Harmonic (ROS 2 Jazzy, Docker) ```bash make sim-gazebo # build image, headless walk test with pass/fail docker compose -f docker/compose.yaml run --rm ros ros2 launch strido_bringup gazebo.launch.py # GUI needs X11/Wayland forwarding ``` `POLICY=/path/policy.npz` runs the same test with `policy_node` (mount the file into the container, see `docker/ros2/sim_smoke.sh`). `walk_smoke` stands the robot up, commands 0.15 m/s for 8 s and checks ground-truth progress (≥ 0.4 m), uprightness and that `/joint_states`, `/imu/data`, `/scan`, `/camera/image_raw` and `/ground_truth/odom` are publishing. ## Reinforcement learning (MuJoCo + PPO) `strido_sim.rl.env` (`packages/strido-sim`) is a Gymnasium velocity-tracking task designed for transfer to hobby servos: the policy only observes what the robot can measure (IMU gyro and gravity, command, its previous action, a gait clock) — no joint feedback, because the SPT5435LV has none. Actions are joint offsets around the stand pose, clamped by the same joint and knee-crank limits as the firmware. The firmware's 6 rad/s joint slew limit is applied in the environment (per 20 ms policy step; the firmware ramps in 5 ms steps at the same speed), followed by the joint and knee-crank clamp. Domain randomisation: contact friction (floor and feet together, since MuJoCo uses the larger of the two), servo stiffness, base mass, IMU noise. `policy_node` clips `/cmd_vel` to the trained command ranges and publishes no `/odom` (use LiDAR odometry/SLAM). ```bash make rl-smoke # 20 k steps + evaluation (pipeline check) make rl-train # 20 M steps, 8 envs (CPU, ≈ 30 min) uv run --locked python -m strido_sim.rl.evaluate build/rl//policy.npz --vx 0.2 --video build/rl/eval.mp4 ``` Training writes `policy.npz` (plain arrays). `strido.policy.MlpPolicy` runs it with numpy, both in `evaluate.py` and in the ROS 2 `policy_node`, so no PyTorch is needed on the Pi: ```bash ros2 launch strido_bringup gazebo.launch.py use_policy:=true policy:=/path/policy.npz ros2 launch strido_bringup robot.launch.py use_policy:=true policy:=/path/policy.npz ``` Before running a learned policy on hardware: evaluate it in MuJoCo and Gazebo, start with the robot on a stand, keep `/strido/estop` at hand, and expect to retrain with measured servo response (kp/kv, latency) once the robot exists. The upstream PyBullet (`upstream/yertle/learning/`) and Isaac Lab (`upstream/yertle/isaac_lab/`) pipelines target the original Yertle URDF and are kept for reference. --- # Software ```mermaid flowchart TD subgraph PI["Raspberry Pi 5 – ROS 2 Jazzy (Docker or native)"] TELEOP["teleop / Nav2 / your node"] -->|"/cmd_vel"| GAIT["gait_node
(or policy_node)"] GAIT -->|"/leg_controller/commands
12 × rad, Go2 order"| BRIDGE["mcu_bridge"] GAIT -->|"/odom + TF"| TF["robot_state_publisher"] BRIDGE -->|"/joint_states /imu/data
/battery_state /diagnostics"| TF LIDAR["sllidar_ros2"] -->|"/scan"| TELEOP CAM["camera_ros"] -->|"/camera/image_raw"| TELEOP end BRIDGE <-->|"USB CDC, COBS + CRC16"| MCU["XIAO ESP32-S3 firmware
200 Hz control, watchdog, e-stop"] MCU -->|"I²C"| PCA["PCA9685 → 12 servos"] MCU -->|"I²C"| IMU["LSM6DSOX, INA260"] MCU -->|"SPI"| LCD["LCD face"] ``` The same `/leg_controller/commands` topic is consumed by `ros2_control` in Gazebo and by `mcu_bridge` on the robot, so every node above the bridge runs unchanged in simulation and on hardware. ## ROS 2 interface (Go2-style) | Topic | Type | Direction | Notes | |---|---|---|---| | `/cmd_vel` | geometry_msgs/Twist | in | vx, vy [m/s], wz [rad/s]; limits 0.30 / 0.15 / 1.0; 0.5 s timeout → stand | | `/leg_controller/commands` | std_msgs/Float64MultiArray | internal | 12 joint angles [rad] in Go2 order FL, FR, RL, RR × hip, thigh, calf | | `/joint_states` | sensor_msgs/JointState | out | Go2 joint names (`FL_hip_joint` …). Robot: angles the MCU applies after clamping (hobby servos have no feedback) | | `/imu/data` | sensor_msgs/Imu | out | orientation (Mahony on the MCU), angular velocity, acceleration; frame `imu_link`; not published while the IMU reports a fault | | `/scan` | sensor_msgs/LaserScan | out | RPLIDAR C1, frame `lidar_link`, 10 Hz | | `/camera/image_raw` | sensor_msgs/Image | out | Camera Module 3 Wide, frame `camera_optical_frame` | | `/battery_state` | sensor_msgs/BatteryState | out | pack voltage + logic-branch current (INA260), negative = discharging | | `/odom` + TF `odom→base_link` | nav_msgs/Odometry | out | dead reckoning of the *commanded* velocity (gait_node only) | | `/strido/estop` | std_msgs/Bool | in | true = latched stop (servo outputs off), false = release | | `/strido/relax` | std_msgs/Bool | in | true = servos limp | | `/strido/face` | std_msgs/UInt8 | in | LCD expression | | `/diagnostics` | diagnostic_msgs/DiagnosticArray | out | MCU flags: estop, relaxed, watchdog, imu_ok, power_ok, pwm_ok, clamped | Frames follow REP-103 (`base_link`: x forward, y left, z up); joint zero = leg straight down, thigh positive = foot backwards, calf negative = knee bent (Go2 convention). ### Porting code from a Unitree Go2 Joint names, signs, frames and `/cmd_vel` follow the Go2 ROS 2 description, so velocity-level stacks (teleop, Nav2, SLAM with `/scan`) run unchanged. Differences: the command array order is FL, FR, RL, RR (the Unitree SDK motor index order is FR, FL, RR, RL: map by joint name, not by index), there are no foot-contact or motor-torque topics, `/joint_states` are commanded values, the speed envelope is ≈ 0.3 m/s, and the low-level `unitree_go` messages are not provided. ## Packages | Path | Contents | |---|---| | `ros2/strido_description` | URDF/xacro and meshes **generated from CAD** (`strido_cad.models`), ros2_control controller config, `display.launch.py` | | `ros2/strido_bringup` | `gazebo.launch.py`, `robot.launch.py`, world, udev rules | | `ros2/strido_control` | `gait_node`, `policy_node`, `mcu_bridge`, `walk_smoke` | | `packages/strido-core` | pure-Python core shared by ROS, MuJoCo and tests: generated geometry, leg/linkage kinematics, trot gait, MCU protocol, numpy policy runtime, `strido-cli` | | `firmware/strido_esp32s3` | PlatformIO firmware; `lib/strido_core` holds the portable kinematics/protocol/safety code with native unit tests | Launch: ```bash ros2 launch strido_bringup gazebo.launch.py headless:=false # simulation with GUI ros2 launch strido_bringup robot.launch.py # on the robot (add camera:=true once camera_ros is installed) ros2 launch strido_bringup robot.launch.py use_policy:=true policy:=/path/policy.npz # learned controller ros2 launch strido_description display.launch.py # model + joint sliders in RViz ``` ## Firmware PlatformIO, Arduino-ESP32 3.x on the Seeed XIAO ESP32-S3: ```bash cd firmware/strido_esp32s3 pio test -e native # kinematics, protocol, safety unit tests on the host pio run -e xiao_esp32s3 -t upload ``` * 200 Hz loop: command → linkage-exact joint→servo mapping (`servo_map`, generated geometry) → slew limit 6 rad/s → PCA9685 pulses (200 Hz PWM). * Safety: only joint commands feed the watchdog (no keep-alive message): no command for 200 ms → hold, 2 s more → relax; latched e-stop; PCA9685 OE pin pulled high on relax/e-stop (plus a hardware pull-up for resets); battery warn 6.8 V, cut-off after 5 consecutive readings below 6.4 V; no driving without the INA260; non-finite commands rejected; joint and knee-crank limits from the CAD range-of-motion sweep. * Calibration (neutral pulse, direction, limits per joint) is stored in flash (`Preferences`). ### Protocol (USB CDC) Frames: `COBS(type | payload | CRC16-CCITT-FALSE little endian) 0x00`. Payloads are little-endian packed structs, mirrored in `packages/strido-core/src/strido/protocol.py` and `firmware/.../protocol.h`. | Type | Name | Payload | |---|---|---| | 0x01 | JOINT_COMMAND | seq u16, mode u8 (0 relax, 1 position), q[12] f32 | | 0x02 | SERVO_PULSE_COMMAND | seq u16, pulse_us[12] u16 (0 = off) – calibration only | | 0x03 | SET_CALIBRATION | joint u8, direction i8, neutral_us u16, zero_rad f32, min_us u16, max_us u16 | | 0x04 | SAVE_CALIBRATION | – | | 0x05 | REQUEST_CALIBRATION | – → 12 × CALIBRATION | | 0x06 | ESTOP | engaged u8 | | 0x07 | DISPLAY | expression u8, length u8, text | | 0x81 | STATE (100 Hz) | last seq u16, time_ms u32, flags u8, q[12], quat wxyz[4], gyro[3], accel[3], bus V, current A (f32) | | 0x82 | CALIBRATION | as SET_CALIBRATION | | 0x83 | LOG | ASCII | ## Bench tool `strido-cli` (installed with `packages/strido-core`) talks to the controller without ROS: ```bash strido-cli center # 1500 µs on every channel: the assembly reference (fit horns now; raw pulses bypass joint limits) strido-cli pulse FL_thigh 1600 # one joint strido-cli calibrate # trim each joint's neutral (±5/±25 µs), saved to flash strido-cli monitor # battery, flags, roll/pitch ``` ## Raspberry Pi setup 1. Ubuntu Server 24.04 (arm64) or Raspberry Pi OS Bookworm 64-bit; Docker Engine. 2. `docker compose -f docker/compose.yaml build ros` (ROS 2 Jazzy, Gazebo libraries, sllidar_ros2 pinned, this repo's packages). 3. Power and udev configuration: see [electronics.md](electronics.md#raspberry-pi-configuration). 4. Camera: the Pi 5 camera stack needs the Raspberry Pi libcamera fork; build `camera_ros` against it on the host (Raspberry Pi OS ships it) or use `rpicam-vid` + a GStreamer bridge, then launch with `camera:=true` (default false; the Docker image has no camera node). --- # Moving-part clearance Generated by `strido_verify.clearance` (exact minimum distance between closed meshes, manifold3d). Poses: stand_170mm, crouch_120mm, tall_210mm, trot_0of8, trot_1of8, trot_2of8, trot_3of8, trot_4of8, trot_5of8, trot_6of8, trot_7of8, hips_out, hips_in. Pairs on different rigid bodies closer than 1.5 mm in any pose: 92 — **fail (< 0.3 mm) 0**, tight (< 1.0 mm) 4, ok 8, rotational interfaces (constant gap) 80. Joint interfaces (in contact at the CAD pose, not gated): 72. | Parts | Bodies | Minimum gap (mm) | Maximum gap (mm) | Worst pose | Result | |---|---|---|---|---|---| | bearing_625zz / iso7089_m3 | RR.hip / base | 0.11 | 0.11 | stand_170mm | rotational interface | | bearing_625zz / iso7089_m3 | RL.hip / base | 0.11 | 0.11 | stand_170mm | rotational interface | | bearing_633zz / femur | FL.calf / FL.thigh | 0.12 | 0.12 | tall_210mm | rotational interface | | bearing_633zz / femur | RR.calf / RR.thigh | 0.12 | 0.12 | tall_210mm | rotational interface | | bearing_633zz / femur | FR.calf / FR.thigh | 0.12 | 0.12 | tall_210mm | rotational interface | | bearing_633zz / femur | RL.calf / RL.thigh | 0.14 | 0.14 | trot_2of8 | rotational interface | | iso4762_m3x8 / servo_spt5435lv | FL.hip / base | 0.25 | 0.25 | stand_170mm | rotational interface | | iso4762_m3x8 / servo_spt5435lv | FR.hip / base | 0.25 | 0.25 | stand_170mm | rotational interface | | bearing_625zz / left_servo_mount_fl | FL.hip / base | 0.32 | 0.32 | hips_in | rotational interface | | bearing_625zz / right_servo_mount_fr | FR.hip / base | 0.34 | 0.34 | hips_in | rotational interface | | left_shoulder / stiff_long_link | FL.crank / FL.long_link | 0.49 | 0.49 | tall_210mm | rotational interface | | left_shoulder / stiff_long_link | RL.crank / RL.long_link | 0.50 | 0.50 | tall_210mm | rotational interface | | right_shoulder / short_link | RR.crank / RR.short_link | 0.50 | 0.50 | trot_7of8 | rotational interface | | right_shoulder / short_link | FR.crank / FR.short_link | 0.50 | 0.50 | tall_210mm | rotational interface | | right_shoulder / sleeve_bearing_3x5x5_pair | RR.crank / RR.short_link | 0.50 | 0.50 | trot_1of8 | rotational interface | | right_shoulder / sleeve_bearing_3x5x5_pair | FR.crank / FR.short_link | 0.50 | 0.50 | trot_4of8 | rotational interface | | right_shoulder / stiff_long_link | FR.crank / FR.long_link | 0.51 | 0.51 | crouch_120mm | rotational interface | | left_shoulder / sleeve_bearing_3x5x5_pair | FL.crank / FL.long_link | 0.51 | 0.51 | hips_out | rotational interface | | right_shoulder / stiff_long_link | RR.crank / RR.long_link | 0.51 | 0.51 | tall_210mm | rotational interface | | left_shoulder / sleeve_bearing_3x5x5_pair | RL.crank / RL.long_link | 0.52 | 0.52 | crouch_120mm | rotational interface | | right_shoulder / sleeve_bearing_3x5x5_pair | RR.crank / RR.long_link | 0.52 | 0.52 | tall_210mm | rotational interface | | right_shoulder / sleeve_bearing_3x5x5_pair | FR.crank / FR.long_link | 0.52 | 0.52 | crouch_120mm | rotational interface | | left_shoulder / short_link | RL.crank / RL.short_link | 0.53 | 0.53 | tall_210mm | rotational interface | | left_shoulder / sleeve_bearing_3x5x5_pair | RL.crank / RL.short_link | 0.53 | 0.53 | trot_4of8 | rotational interface | | left_shoulder / sleeve_bearing_3x5x5_pair | FL.crank / FL.short_link | 0.53 | 0.53 | trot_6of8 | rotational interface | | left_shoulder / short_link | FL.crank / FL.short_link | 0.53 | 0.54 | trot_7of8 | rotational interface | | right_shoulder / servo_spt5435lv | FR.crank / FR.hip | 0.59 | 0.59 | trot_3of8 | rotational interface | | left_shoulder / servo_spt5435lv | FL.crank / FL.hip | 0.59 | 0.59 | tall_210mm | rotational interface | | left_shoulder / servo_spt5435lv | RL.crank / RL.hip | 0.59 | 0.60 | tall_210mm | rotational interface | | femur / right_shoulder | RR.crank / RR.thigh | 0.60 | 0.61 | tall_210mm | rotational interface | | left_shoulder / servo_spt5435lv | FL.crank / FL.hip | 0.60 | 0.61 | tall_210mm | rotational interface | | left_shoulder / servo_spt5435lv | RL.crank / RL.hip | 0.60 | 0.60 | tall_210mm | rotational interface | | right_shoulder / servo_spt5435lv | FR.crank / FR.hip | 0.60 | 0.61 | trot_3of8 | rotational interface | | bearing_625zz / iso4762_m3x10 | RR.hip / base | 0.61 | 0.61 | stand_170mm | rotational interface | | bearing_625zz / iso4762_m3x10 | RL.hip / base | 0.61 | 0.61 | stand_170mm | rotational interface | | femur / left_shoulder | FL.crank / FL.thigh | 0.62 | 0.62 | crouch_120mm | rotational interface | | right_shoulder / servo_spt5435lv | RR.crank / RR.hip | 0.62 | 0.62 | tall_210mm | rotational interface | | right_shoulder / servo_spt5435lv | RR.crank / RR.hip | 0.62 | 0.62 | trot_7of8 | rotational interface | | femur / right_shoulder | FR.crank / FR.thigh | 0.63 | 0.64 | tall_210mm | rotational interface | | femur / left_shoulder | RL.crank / RL.thigh | 0.64 | 0.64 | tall_210mm | rotational interface | | back_shell_button / right_shoulder | RR.crank / base | 0.73 | 0.73 | hips_out | tight | | front_shell_display / servo_arm_horn_25t | FR.arm / base | 0.77 | 0.77 | hips_out | tight | | back_shell_button / left_shoulder | RL.crank / base | 0.78 | 0.78 | hips_out | tight | | front_shell_display / servo_arm_horn_25t | FL.arm / base | 0.79 | 0.79 | hips_out | tight | | femur_servo_connector / servo_spt5435lv | FL.hip / FL.thigh | 0.91 | 0.91 | trot_6of8 | rotational interface | | femur_servo_connector / servo_spt5435lv | RR.hip / RR.thigh | 0.91 | 0.91 | crouch_120mm | rotational interface | | femur_servo_connector / servo_spt5435lv | FR.hip / FR.thigh | 0.91 | 0.91 | crouch_120mm | rotational interface | | femur_servo_connector / servo_spt5435lv | RL.hip / RL.thigh | 0.91 | 0.91 | trot_4of8 | rotational interface | | iso4762_m3x25 / stiff_long_link | RR.calf / RR.long_link | 0.97 | 0.97 | trot_6of8 | rotational interface | | iso4762_m3x25 / stiff_long_link | FR.calf / FR.long_link | 0.97 | 0.97 | tall_210mm | rotational interface | | outer_tibia / stiff_long_link | RR.calf / RR.long_link | 0.98 | 0.99 | crouch_120mm | rotational interface | | outer_tibia / stiff_long_link | FR.calf / FR.long_link | 0.99 | 0.99 | crouch_120mm | rotational interface | | iso4762_m3x25 / stiff_long_link | RR.crank / RR.long_link | 1.00 | 1.01 | trot_5of8 | rotational interface | | outer_tibia / sleeve_bearing_3x5x5_pair | RR.calf / RR.long_link | 1.00 | 1.00 | trot_0of8 | rotational interface | | outer_tibia / sleeve_bearing_3x5x5_pair | FR.calf / FR.long_link | 1.00 | 1.00 | trot_0of8 | rotational interface | | iso4762_m3x25 / stiff_long_link | FR.crank / FR.long_link | 1.01 | 1.01 | trot_3of8 | rotational interface | | iso7089_m3 / outer_shoulder_frame | FL.hip / base | 1.01 | 1.17 | hips_in | ok | | inner_tibia / stiff_long_link | FL.calf / FL.long_link | 1.02 | 1.02 | tall_210mm | rotational interface | | iso4762_m3x25 / stiff_long_link | FL.crank / FL.long_link | 1.02 | 1.02 | trot_6of8 | rotational interface | | inner_tibia / stiff_long_link | RL.calf / RL.long_link | 1.02 | 1.02 | tall_210mm | rotational interface | | iso4762_m3x25 / stiff_long_link | RL.crank / RL.long_link | 1.02 | 1.02 | trot_0of8 | rotational interface | | inner_tibia / sleeve_bearing_3x5x5_pair | RL.calf / RL.long_link | 1.02 | 1.02 | crouch_120mm | rotational interface | | inner_tibia / sleeve_bearing_3x5x5_pair | FL.calf / FL.long_link | 1.02 | 1.02 | trot_1of8 | rotational interface | | iso4762_m3x25 / short_link | RR.crank / RR.short_link | 1.03 | 1.03 | trot_0of8 | rotational interface | | iso4762_m3x25 / short_link | FR.crank / FR.short_link | 1.03 | 1.03 | trot_4of8 | rotational interface | | iso4762_m3x25 / short_link | RL.crank / RL.short_link | 1.03 | 1.03 | trot_4of8 | rotational interface | | iso4762_m3x25 / short_link | FL.crank / FL.short_link | 1.03 | 1.03 | trot_0of8 | rotational interface | | iso7089_m3 / outer_shoulder_frame | FR.hip / base | 1.04 | 1.20 | hips_in | ok | | femur_servo_connector / servo_spt5435lv | RR.hip / RR.thigh | 1.08 | 1.08 | trot_3of8 | rotational interface | | femur_servo_connector / servo_spt5435lv | FR.hip / FR.thigh | 1.08 | 1.08 | trot_0of8 | rotational interface | | left_shoulder / servo_arm_horn_25t | FL.arm / FL.crank | 1.09 | 1.10 | trot_3of8 | rotational interface | | femur / outer_tibia | RR.calf / RR.thigh | 1.09 | 1.09 | tall_210mm | rotational interface | | femur / outer_tibia | FR.calf / FR.thigh | 1.09 | 1.09 | tall_210mm | rotational interface | | left_shoulder / servo_arm_horn_25t | RL.arm / RL.crank | 1.09 | 1.11 | trot_7of8 | rotational interface | | femur_servo_connector / servo_spt5435lv | RL.hip / RL.thigh | 1.10 | 1.10 | stand_170mm | rotational interface | | femur_servo_connector / servo_spt5435lv | FL.hip / FL.thigh | 1.10 | 1.10 | tall_210mm | rotational interface | | right_shoulder / servo_arm_horn_25t | FR.arm / FR.crank | 1.11 | 1.13 | crouch_120mm | rotational interface | | right_shoulder / servo_arm_horn_25t | RR.arm / RR.crank | 1.11 | 1.13 | crouch_120mm | rotational interface | | femur / inner_tibia | RR.calf / RR.thigh | 1.12 | 1.12 | tall_210mm | rotational interface | | femur / outer_tibia | FL.calf / FL.thigh | 1.12 | 1.12 | tall_210mm | rotational interface | | femur / inner_tibia | FR.calf / FR.thigh | 1.12 | 1.13 | tall_210mm | rotational interface | | femur / inner_tibia | RL.calf / RL.thigh | 1.13 | 1.13 | crouch_120mm | rotational interface | | femur / inner_tibia | FL.calf / FL.thigh | 1.13 | 1.14 | crouch_120mm | rotational interface | | femur / outer_tibia | RL.calf / RL.thigh | 1.14 | 1.14 | crouch_120mm | rotational interface | | iso4762_m3x25 / servo_spt5435lv | RL.crank / RL.hip | 1.22 | 1.46 | tall_210mm | ok | | iso4762_m3x25 / servo_spt5435lv | FL.crank / FL.hip | 1.22 | 1.46 | tall_210mm | ok | | iso4762_m3x25 / servo_spt5435lv | RR.crank / RR.hip | 1.22 | 1.46 | tall_210mm | ok | | iso4762_m3x25 / servo_spt5435lv | FR.crank / FR.hip | 1.22 | 1.46 | tall_210mm | ok | | iso4762_m3x10 / outer_shoulder_frame | FL.hip / base | 1.30 | 1.37 | hips_in | ok | | iso4762_m3x10 / outer_shoulder_frame | FR.hip / base | 1.33 | 1.40 | hips_in | ok | | iso4762_m3x8 / servo_spt5435lv | RL.hip / base | 1.39 | 1.43 | hips_out | rotational interface | | iso4762_m3x8 / servo_spt5435lv | RR.hip / base | 1.39 | 1.43 | hips_out | rotational interface | --- # Electronics check Generated by `strido_verify.electronics` from `hardware/electronics.yaml` and the firmware pin header `firmware/strido_esp32s3/src/board_config.h`. Do not edit by hand. ## XIAO ESP32-S3 pins | Pad | GPIO | Function | Connects to | Firmware constant | Note | |---|---|---|---|---|---| | D0 | 1 | lcd_cs | LCD CS | `kLcdCs` | | | D1 | 2 | lcd_rst | LCD RST | `kLcdRst` | | | D2 | 3 | lcd_backlight | LCD BL | `kLcdBacklight` | strapping pin: driven only after boot | | D3 | 4 | servo_oe_n | PCA9685 OE | `kServoOutputEnableN` | active low; 2.2 kΩ pull-up to VCC keeps outputs off in reset | | D4 | 5 | i2c_sda | I²C SDA | `kSda` | | | D5 | 6 | i2c_scl | I²C SCL | `kScl` | | | D8 | 7 | lcd_sck | LCD CLK | `kLcdSck` | | | D9 | 8 | lcd_dc | LCD DC | `kLcdDc` | | | D10 | 9 | lcd_mosi | LCD DIN | `kLcdMosi` | | ## I²C bus 400 kHz. | Address | Device | Note | |---|---|---| | 0x40 | PCA9685 servo driver | 0x70 = all-call | | 0x6A | LSM6DSOX IMU | | | 0x41 | INA260 power monitor | bridge the A0 jumper (default 0x40 collides with the PCA9685) | ## PCA9685 servo channels | Leg | hip | thigh | calf (knee crank) | |---|---|---|---| | FL | 15 | 11 | 7 | | FR | 14 | 10 | 6 | | RL | 13 | 9 | 5 | | RR | 12 | 8 | 4 | ## Worst-case currents | Branch | Current (A) | |---|---| | ubec_front | 10.00 | | ubec_rear | 10.00 | | servo_branch | 20.83 | | logic_branch | 2.74 | | logic_5v | 3.16 | | logic_5v_per_pin | 3.16 | | pack | 23.58 | ## Checks: 81 passed (3 without margin), 0 failed | Check | Result | Detail | |---|---|---| | firmware pin | pass | kLcdCs = 1 (YAML D0 = GPIO 1) | | firmware pin | pass | kLcdRst = 2 (YAML D1 = GPIO 2) | | firmware pin | pass | kLcdBacklight = 3 (YAML D2 = GPIO 3) | | firmware pin | pass | kServoOutputEnableN = 4 (YAML D3 = GPIO 4) | | firmware pin | pass | kSda = 5 (YAML D4 = GPIO 5) | | firmware pin | pass | kScl = 6 (YAML D5 = GPIO 6) | | firmware pin | pass | kLcdSck = 7 (YAML D8 = GPIO 7) | | firmware pin | pass | kLcdDc = 8 (YAML D9 = GPIO 8) | | firmware pin | pass | kLcdMosi = 9 (YAML D10 = GPIO 9) | | I²C frequency | pass | kI2cHz = 400000 (YAML 400000) | | firmware I²C address | pass | kPcaAddress = 64 (YAML 0x40) | | firmware I²C address | pass | kImuAddress = 106 (YAML 0x6A) | | firmware I²C address | pass | kPowerMonitorAddress = 65 (YAML 0x41) | | I²C address unique | pass | 0x40 PCA9685 servo driver, 0x41 INA260 power monitor, 0x6A LSM6DSOX IMU, 0x70 PCA9685 servo driver | | servo channels unique | pass | [15, 11, 7, 14, 10, 6, 13, 9, 5, 12, 8, 4] | | servo channels in range | pass | 0..15 | | firmware servo channels | pass | kServoChannel = [15, 11, 7, 14, 10, 6, 13, 9, 5, 12, 8, 4] | | servo PWM frequency | pass | kServoFrequencyHz = 200 Hz, servo max 330 Hz | | on-board LED | pass | kStatusLed = 21 (YAML GPIO 21) | | header constants described | pass | 19 constants | | ubec_front load | pass | 6 servos trotting ≈ 4.2 A ≤ 10 A continuous | | ubec_front stall warning | pass | 6 stalled servos 21 A > 15 A peak: never drive all joints into hard stops | | ubec_rear load | pass | 6 servos trotting ≈ 4.2 A ≤ 10 A continuous | | ubec_rear stall warning | pass | 6 stalled servos 21 A > 15 A peak: never drive all joints into hard stops | | 5 V regulator load | pass | 3.16 A peak ≤ 5.5 A | | power switch load | pass | 2.74 A ≤ 6.0 A | | INA260 range | pass | 2.74 A, 8.4 V | | main fuse ≥ load | pass | servo + logic 23.6 A ≤ 25.0 A | | logic fuse ≥ load | pass | 2.74 A ≤ 7.5 A | | wire W01 load | pass | 14 AWG: 23.6 A ≤ 32 A | | wire W02 load | pass | 14 AWG: 23.6 A ≤ 32 A | | wire W02 fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W02/arming load | pass | 14 AWG: 23.6 A ≤ 32 A | | wire W02/arming fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W02/logic load | pass | 14 AWG: 23.6 A ≤ 32 A | | wire W02/logic fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W03 load | pass | 14 AWG: 20.8 A ≤ 32 A | | wire W03 fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W03/front load | pass | 14 AWG: 20.8 A ≤ 32 A | | wire W03/front fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W03/rear load | pass | 14 AWG: 20.8 A ≤ 32 A | | wire W03/rear fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W04 load | pass | 14 AWG: 23.6 A ≤ 32 A | | wire W04 fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W04/front load | pass | 14 AWG: 23.6 A ≤ 32 A | | wire W04/front fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W04/rear load | pass | 14 AWG: 23.6 A ≤ 32 A | | wire W04/rear fuse | pass | main fuse 25.0 A ≤ 14 AWG 32 A | | wire W04/logic load | pass | 18 AWG: 2.7 A ≤ 16 A | | wire W04/logic fuse | pass | logic fuse 7.5 A ≤ 18 AWG 16 A | | wire W05 load | pass | 18 AWG: 10.0 A ≤ 16 A | | wire W06 load | pass | 18 AWG: 10.0 A ≤ 16 A | | wire W07/vplus load | pass | 18 AWG: 10.0 A ≤ 16 A | | wire W07/gnd load | pass | 18 AWG: 10.0 A ≤ 16 A | | wire W08/vplus load | pass | 18 AWG: 10.0 A ≤ 16 A | | wire W08/gnd load | pass | 18 AWG: 10.0 A ≤ 16 A | | wire W09 load | pass | 18 AWG: 2.7 A ≤ 16 A | | wire W09 fuse | pass | logic fuse 7.5 A ≤ 18 AWG 16 A | | wire W10 load | pass | 18 AWG: 2.7 A ≤ 16 A | | wire W10 fuse | pass | logic fuse 7.5 A ≤ 18 AWG 16 A | | wire W11 load | pass | 18 AWG: 2.7 A ≤ 16 A | | wire W11 fuse | pass | logic fuse 7.5 A ≤ 18 AWG 16 A | | wire W24 load | pass | 18 AWG: 2.7 A ≤ 16 A | | wire W24 fuse | pass | logic fuse 7.5 A ≤ 18 AWG 16 A | | wire W12 load | pass | 22 AWG: 1.6 A ≤ 7 A | | supply: Hobbywing UBEC-10A input | pass | 6.0-8.4 V (nominal 7.4) within 6.0-25.2 V (2-6S LiPo at 3.0-4.2 V per cell (Hobbywing UBEC-10A V2-Car manual: input 2-6S)) | | supply: Pololu 2813 power switch | pass | 6.0-8.4 V (nominal 7.4) within 4.5-40.0 V (Pololu product page) | | supply: INA260 bus input (VIN+/-) | pass | 6.0-8.4 V (nominal 7.4) within 0.0-36.0 V (TI INA260 datasheet) | | supply: Pololu D36V50F5 input | pass | 6.0-8.4 V (nominal 7.4) within 5.5-50.0 V (Pololu product page) | | supply: SPT5435LV servo V+ | pass | 5.4-6.0 V (nominal 6.0) within 4.8-6.0 V (SPT spec page (voltage range 4.8/6.0 V)) | | supply: Raspberry Pi 5 (GPIO 5 V) | pass | 4.8-5.2 V (nominal 5.0) within 4.75-5.25 V ([ESTIMATE] 5 V +/- 5 %; undervoltage flag below 4.63 V) | | supply: XIAO ESP32-S3 (USB from Pi) | pass | 4.8-5.2 V (nominal 5.0) within 4.75-5.25 V (USB 2.0 VBUS range) | | supply: RPLIDAR C1 (USB from Pi) | pass | 4.8-5.2 V (nominal 5.0) within 4.75-5.25 V (USB 2.0 VBUS range [ESTIMATE: C1 datasheet states 5 V]) | | supply: PCA9685 VCC | pass | 3.2-3.4 V (nominal 3.3) within 2.3-5.5 V (NXP PCA9685 datasheet) | | supply: LSM6DSOX breakout VIN | pass | 3.2-3.4 V (nominal 3.3) within 3.0-5.0 V (Adafruit 4438 (3-5 V)) | | supply: INA260 VS | pass | 3.2-3.4 V (nominal 3.3) within 2.7-5.5 V (TI INA260 datasheet) | | supply: Waveshare 2inch LCD VCC | pass, no margin | rail minimum 3.2 V below the rated 3.3 V (Waveshare wiki (3.3 V / 5 V)): no margin, measure it | | signal: servo PWM -> SPT5435LV signal input | pass, no margin | rail minimum 3.2 V below the rated 3.3 V (SPT spec page (PWM voltage 3.3-5.0 V)): no margin, measure it | | signal: I²C -> PCA9685, LSM6DSOX, INA260 | pass | 3.2-3.4 V (nominal 3.3) within 3.0-5.0 V (overlap of the three VCC ranges (PCA9685 2.3-5.5, LSM6DSOX breakout 3-5, INA260 2.7-5.5 V)) | | signal: LCD SPI -> Waveshare 2inch LCD | pass, no margin | rail minimum 3.2 V below the rated 3.3 V (Waveshare wiki): no margin, measure it | | signal: servo OE -> PCA9685 OE | pass | 3.2-3.4 V (nominal 3.3) within 2.3-5.5 V (NXP PCA9685 datasheet (VCC range)) | --- # Fits and tolerances Generated by `strido_verify.fits` from the assembled CAD (reference pose) and `hardware/verification/tolerances.yaml`. Every screw axis is sampled every 0.5 mm; each printed part around it is cut normal to the axis and its narrowest hole diameter classified. **Screws:** 223 — ok 86, warn 137, **fail 0**. **Bearing seats:** 32 — fail 0. | Fit class | ok | warn | fail | |---|---|---|---| | clearance | 69 | 80 | 0 | | thread-forming | 19 | 67 | 0 | | tight passage | 0 | 58 | 0 | | passage | 37 | 0 | 0 | | bearing bore | 31 | 0 | 0 | | line-to-line | 0 | 0 | 0 | | solid | 0 | 0 | 0 | | oversize | 0 | 0 | 0 | | Screw termination | Count | |---|---| | nut | 105 | | thread-forming | 78 | | vendor thread | 40 | ## Warnings: what to do after printing Printed holes come out ~0.15 mm undersize. *Clearance / tight passage*: run the ISO 273 drill through once. *Thread-forming, weak grip*: tighten by feel; if a screw spins, harden the pilot with a drop of thin CA glue and re-drive (anaerobic thread locker does not cure on plastic and can crack PETG). *Short engagement*: do not over-tighten. | Part | Fit | Thread | CAD hole (mm) | Holes | Action | |---|---|---|---|---|---| | arming_port_bezel | thread-forming | M2.5 | 2.10 | 2 | short engagement 2.5 mm (< 1.5 d) | | back_shell_button | tight passage | M3 | 2.94 | 1 | drill 3.2 mm (or 3.4 mm) | | back_shell_button | tight passage | M3 | 2.95 | 1 | drill 3.2 mm (or 3.4 mm) | | back_shell_button | tight passage | M3 | 2.98 | 1 | drill 3.2 mm (or 3.4 mm) | | back_shell_button | tight passage | M3 | 3.02 | 1 | drill 3.2 mm (or 3.4 mm) | | bottom_shell | clearance | M3 | 3.05 | 4 | drill 3.2 mm (or 3.4 mm) | | camera_tilt_plate | clearance | M2 | 2.20 | 4 | drill 2.2 mm (or 2.4 mm) | | electronics_deck | clearance | M2.5 | 2.69 | 1 | drill 2.7 mm (or 2.9 mm) | | electronics_deck | clearance | M2.5 | 2.69 | 1 | drill 2.7 mm (or 2.9 mm) | | femur | thread-forming | M3 | 2.80 | 2 | short engagement 4.0 mm (< 1.5 d) | | femur | thread-forming | M3 | 2.85 | 1 | short engagement 4.0 mm (< 1.5 d) | | femur | thread-forming | M3 | 2.85 | 1 | short engagement 4.0 mm (< 1.5 d) | | femur | thread-forming | M3 | 2.85 | 1 | short engagement 4.0 mm (< 1.5 d) | | front_shell_display | clearance | M3 | 3.08 | 1 | drill 3.2 mm (or 3.4 mm) | | front_shell_display | clearance | M3 | 3.08 | 1 | drill 3.2 mm (or 3.4 mm) | | front_shell_display | tight passage | M3 | 2.96 | 1 | drill 3.2 mm (or 3.4 mm) | | front_shell_display | tight passage | M3 | 2.97 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | clearance | M3 | 3.07 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | clearance | M3 | 3.08 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | clearance | M3 | 3.10 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | clearance | M3 | 3.11 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | clearance | M3 | 3.12 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | clearance | M3 | 3.13 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | clearance | M3 | 3.15 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.58 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.58 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.69 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.69 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.87 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.88 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.89 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.90 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.92 | 4 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.96 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_shoulder_frame | tight passage | M3 | 2.97 | 2 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | clearance | M3 | 3.09 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | clearance | M3 | 3.10 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | clearance | M3 | 3.12 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | clearance | M3 | 3.13 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.90 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.90 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.90 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.91 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.92 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.93 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.93 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.94 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.94 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.94 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.94 | 1 | drill 3.2 mm (or 3.4 mm) | | inner_tibia | tight passage | M3 | 2.95 | 1 | drill 3.2 mm (or 3.4 mm) | | left_servo_mount | thread-forming | M3 | 2.92 | 1 | weak grip (printed ratio 0.92): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.92 | 1 | weak grip (printed ratio 0.92): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.92 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.93 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.94 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.94 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.94 | 2 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.94 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.95 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.95 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.96 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount | thread-forming | M3 | 2.96 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.50 | 1 | short engagement 4.0 mm (< 1.5 d) | | left_servo_mount_fl | thread-forming | M3 | 2.88 | 1 | weak grip (printed ratio 0.91): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.89 | 1 | weak grip (printed ratio 0.91): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.90 | 3 | weak grip (printed ratio 0.92): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.90 | 1 | weak grip (printed ratio 0.92): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.91 | 1 | weak grip (printed ratio 0.92): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.91 | 1 | weak grip (printed ratio 0.92): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.92 | 1 | weak grip (printed ratio 0.92): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.93 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.94 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | left_servo_mount_fl | thread-forming | M3 | 2.95 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | lower_shoulder_frame_front | clearance | M3 | 3.13 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_front | clearance | M3 | 3.13 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_front | clearance | M3 | 3.15 | 2 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_front | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_front | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_front | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_front | clearance | M3 | 3.17 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_rear | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_rear | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_rear | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_rear | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_rear | clearance | M3 | 3.16 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_rear | clearance | M3 | 3.17 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_rear | clearance | M3 | 3.17 | 1 | drill 3.2 mm (or 3.4 mm) | | lower_shoulder_frame_rear | clearance | M3 | 3.17 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_shoulder_frame | clearance | M3 | 3.10 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_shoulder_frame | clearance | M3 | 3.10 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_shoulder_frame | clearance | M3 | 3.10 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_shoulder_frame | clearance | M3 | 3.10 | 2 | drill 3.2 mm (or 3.4 mm) | | outer_shoulder_frame | clearance | M3 | 3.11 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_shoulder_frame | clearance | M3 | 3.11 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_shoulder_frame | clearance | M3 | 3.12 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | clearance | M3 | 3.09 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | clearance | M3 | 3.11 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | clearance | M3 | 3.12 | 2 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.92 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.92 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.93 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.94 | 2 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.94 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.94 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.95 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.95 | 1 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.96 | 2 | drill 3.2 mm (or 3.4 mm) | | outer_tibia | tight passage | M3 | 2.96 | 1 | drill 3.2 mm (or 3.4 mm) | | right_servo_mount | thread-forming | M3 | 2.94 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.94 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.94 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.94 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.94 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.95 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.95 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.95 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.96 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.96 | 2 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.96 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount | thread-forming | M3 | 2.96 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount_fr | thread-forming | M3 | 2.50 | 1 | short engagement 4.0 mm (< 1.5 d) | | right_servo_mount_fr | thread-forming | M3 | 2.92 | 2 | weak grip (printed ratio 0.92): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | right_servo_mount_fr | thread-forming | M3 | 2.93 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | right_servo_mount_fr | thread-forming | M3 | 2.93 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | right_servo_mount_fr | thread-forming | M3 | 2.94 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | right_servo_mount_fr | thread-forming | M3 | 2.94 | 2 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | right_servo_mount_fr | thread-forming | M3 | 2.94 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount_fr | thread-forming | M3 | 2.94 | 2 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | right_servo_mount_fr | thread-forming | M3 | 2.94 | 2 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | servo_mount_top_bracket | thread-forming | M3 | 2.93 | 2 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | servo_mount_top_bracket | thread-forming | M3 | 2.94 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | servo_mount_top_bracket | thread-forming | M3 | 2.94 | 1 | weak grip (printed ratio 0.93): stop when snug; if it spins, harden the pilot with thin CA glue and re-drive (no anaerobic thread locker on plastic) | | servo_mount_top_bracket | thread-forming | M3 | 2.96 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | servo_mount_top_bracket | thread-forming | M3 | 2.96 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | servo_mount_top_bracket | thread-forming | M3 | 2.97 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | servo_mount_top_bracket | thread-forming | M3 | 2.97 | 1 | accepted deviation (ADR-009): print with X-Y hole compensation -0.15 mm (pilot ~2.65 mm); on an uncompensated print, harden the pilot with a drop of thin CA glue before driving the screw and check it bites before loading the joint (no anaerobic thread locker on plastic) | | side_body_beam | clearance | M3 | 3.13 | 2 | drill 3.2 mm (or 3.4 mm) | | side_body_beam | clearance | M3 | 3.13 | 2 | drill 3.2 mm (or 3.4 mm) | | side_body_beam | clearance | M3 | 3.16 | 2 | drill 3.2 mm (or 3.4 mm) | | side_body_beam | clearance | M3 | 3.16 | 2 | drill 3.2 mm (or 3.4 mm) | | stiff_long_link | tight passage | M3 | 2.91 | 1 | drill 3.2 mm (or 3.4 mm) | | stiff_long_link | tight passage | M3 | 2.91 | 1 | drill 3.2 mm (or 3.4 mm) | | stiff_long_link | tight passage | M3 | 3.02 | 1 | drill 3.2 mm (or 3.4 mm) | | stiff_long_link | tight passage | M3 | 3.03 | 1 | drill 3.2 mm (or 3.4 mm) | | top_shell_sensor | tight passage | M3 | 2.36 | 1 | drill 3.2 mm (or 3.4 mm) | | top_shell_sensor | tight passage | M3 | 2.37 | 1 | drill 3.2 mm (or 3.4 mm) | | top_shell_sensor | tight passage | M3 | 2.38 | 1 | drill 3.2 mm (or 3.4 mm) | | top_shell_sensor | tight passage | M3 | 2.38 | 1 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.08 | 2 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.12 | 2 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.13 | 1 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.14 | 1 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.14 | 1 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.14 | 1 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.14 | 2 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.15 | 1 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.16 | 2 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.16 | 1 | drill 3.2 mm (or 3.4 mm) | | upper_shoulder_frame | clearance | M3 | 3.16 | 1 | drill 3.2 mm (or 3.4 mm) | ## Failures None. ## Bearing seats | Bearing | Part | OD (mm) | Seat (mm) | Result | |---|---|---|---|---| | body/bearing_625zz@0147 | outer_shoulder_frame | 16.0 | 16.213 | ok | | body/bearing_625zz@0148 | outer_shoulder_frame | 16.0 | 16.166 | ok | | body/bearing_625zz@0150 | outer_shoulder_frame | 16.0 | 16.191 | ok | | body/bearing_625zz@0151 | outer_shoulder_frame | 16.0 | 16.195 | ok | | RL/sleeve_bearing_3x5x5_pair@0159 | short_link | 5.0 | 4.986 | ok | | RL/sleeve_bearing_3x5x5_pair@0160 | short_link | 5.0 | 4.977 | ok | | RL/sleeve_bearing_3x5x5_pair@0177 | stiff_long_link | 5.0 | 4.948 | ok | | RL/sleeve_bearing_3x5x5_pair@0178 | stiff_long_link | 5.0 | 4.861 | ok | | RL/bearing_6805_2rs@0179 | left_shoulder | 37.0 | 36.933 | ok | | RL/bearing_633zz@0203 | outer_tibia | 13.0 | 13.226 | ok | | RL/bearing_633zz@0204 | inner_tibia | 13.0 | 13.208 | ok | | FL/sleeve_bearing_3x5x5_pair@0218 | short_link | 5.0 | 4.983 | ok | | FL/sleeve_bearing_3x5x5_pair@0219 | short_link | 5.0 | 4.979 | ok | | FL/sleeve_bearing_3x5x5_pair@0236 | stiff_long_link | 5.0 | 4.948 | ok | | FL/sleeve_bearing_3x5x5_pair@0237 | stiff_long_link | 5.0 | 4.863 | ok | | FL/bearing_6805_2rs@0238 | left_shoulder | 37.0 | 36.921 | ok | | FL/bearing_633zz@0262 | inner_tibia | 13.0 | 13.226 | ok | | FL/bearing_633zz@0263 | outer_tibia | 13.0 | 13.227 | ok | | RR/sleeve_bearing_3x5x5_pair@0276 | short_link | 5.0 | 4.983 | ok | | RR/sleeve_bearing_3x5x5_pair@0277 | short_link | 5.0 | 4.983 | ok | | RR/sleeve_bearing_3x5x5_pair@0294 | stiff_long_link | 5.0 | 4.927 | ok | | RR/sleeve_bearing_3x5x5_pair@0295 | stiff_long_link | 5.0 | 4.853 | ok | | RR/bearing_6805_2rs@0296 | right_shoulder | 37.0 | 36.945 | ok | | RR/bearing_633zz@0320 | outer_tibia | 13.0 | 13.186 | ok | | RR/bearing_633zz@0321 | inner_tibia | 13.0 | 13.233 | ok | | FR/sleeve_bearing_3x5x5_pair@0335 | short_link | 5.0 | 4.984 | ok | | FR/sleeve_bearing_3x5x5_pair@0336 | short_link | 5.0 | 4.983 | ok | | FR/sleeve_bearing_3x5x5_pair@0353 | stiff_long_link | 5.0 | 4.925 | ok | | FR/sleeve_bearing_3x5x5_pair@0354 | stiff_long_link | 5.0 | 4.856 | ok | | FR/bearing_6805_2rs@0355 | right_shoulder | 37.0 | 36.94 | ok | | FR/bearing_633zz@0379 | outer_tibia | 13.0 | 13.207 | ok | | FR/bearing_633zz@0380 | inner_tibia | 13.0 | 13.176 | ok | --- # Interference check Generated by `strido_verify.contacts` (exact mesh booleans, manifold3d). Solids per pose: 483 (printed parts, purchased parts, fasteners). | Pose | Overlapping pairs | Thread engagement | Seating contact | Joint | Upstream fit | Upstream joint fastener | **FAIL** | |---|---|---|---|---|---|---|---| | stand_170mm | 292 | 31 | 8 | 67 | 28 | 158 | **0** | | crouch_120mm | 292 | 31 | 8 | 67 | 28 | 158 | **0** | | tall_210mm | 290 | 29 | 8 | 67 | 28 | 158 | **0** | | cad_reference | 290 | 29 | 8 | 67 | 28 | 158 | **0** | ## Classified contacts at the standing pose | Verdict | Pair | Volume mm³ | Reason | |---|---|---|---| | joint | RR/femur_servo_connector#36 / RR/servo_disc_horn_25t@0298 | 121.34 | horn clamped inside the femur connector (upstream fit) | | joint | FR/femur_servo_connector#45 / FR/servo_disc_horn_25t@0357 | 121.34 | horn clamped inside the femur connector (upstream fit) | | joint | FL/femur_servo_connector#27 / FL/servo_disc_horn_25t@0240 | 120.97 | horn clamped inside the femur connector (upstream fit) | | joint | RL/femur_servo_connector#18 / RL/servo_disc_horn_25t@0181 | 119.97 | horn clamped inside the femur connector (upstream fit) | | joint | body/bearing_625zz@0150 / FL/hip_bearing_pin@0213 | 29.32 | hip bearing on its pin | | joint | body/bearing_625zz@0151 / FR/hip_bearing_pin@0330 | 29.32 | hip bearing on its pin | | joint | RL/right_servo_mount#16 / body/bearing_625zz@0148 | 14.61 | hip bearing seat | | joint | RR/left_servo_mount#34 / body/bearing_625zz@0147 | 14.28 | hip bearing seat | | joint | RR/femur#37 / RR/bearing_633zz@0320 | 14.22 | knee bearing on its pin/bore | | joint | FR/femur#46 / FR/bearing_633zz@0379 | 14.22 | knee bearing on its pin/bore | | joint | RL/femur#19 / RL/bearing_633zz@0204 | 13.93 | knee bearing on its pin/bore | | joint | FL/femur#28 / FL/bearing_633zz@0262 | 13.08 | knee bearing on its pin/bore | | joint | RL/inner_tibia#22 / RL/outer_tibia#23 | 8.09 | tibia halves bonded together (upstream fit) | | joint | RR/right_shoulder#39 / RR/bearing_6805_2rs@0296 | 7.24 | bearing pressed into the crank | | joint | FL/inner_tibia#31 / FL/outer_tibia#32 | 7.15 | tibia halves bonded together (upstream fit) | | joint | RL/right_servo_mount#16 / RL/servo_spt5435lv@0188 | 7.12 | servo flange in its mount | | joint | FR/right_shoulder#48 / FR/bearing_6805_2rs@0355 | 5.97 | bearing pressed into the crank | | joint | RL/inner_tibia#22 / RL/foot_spacer | 5.09 | foot spacer between the tibia halves | | joint | FL/left_shoulder#30 / FL/bearing_6805_2rs@0238 | 4.88 | bearing pressed into the crank | | joint | body/bearing_625zz@0147 / RR/hip_bearing_pin@0271 | 4.79 | hip bearing on its pin | | joint | body/bearing_625zz@0148 / RL/hip_bearing_pin@0154 | 4.79 | hip bearing on its pin | | joint | RR/outer_tibia#40 / RR/foot_spacer | 4.34 | foot spacer between the tibia halves | | joint | FL/inner_tibia#31 / FL/foot_spacer | 4.15 | foot spacer between the tibia halves | | joint | RL/left_shoulder#21 / RL/bearing_6805_2rs@0179 | 3.63 | bearing pressed into the crank | | joint | RR/long_link#38 / RR/sleeve_bearing_3x5x5_pair@0295 | 3.43 | sleeve bearings pressed into the link | | joint | FR/long_link#47 / FR/sleeve_bearing_3x5x5_pair@0354 | 3.38 | sleeve bearings pressed into the link | | joint | FL/long_link#29 / FL/sleeve_bearing_3x5x5_pair@0237 | 3.28 | sleeve bearings pressed into the link | | joint | RL/long_link#20 / RL/sleeve_bearing_3x5x5_pair@0178 | 3.25 | sleeve bearings pressed into the link | | joint | RL/servo_arm_horn_25t@0157 / RL/servo_spt5435lv@0188 | 2.91 | horn on the servo output spline | | joint | FL/servo_arm_horn_25t@0216 / FL/servo_spt5435lv@0247 | 2.91 | horn on the servo output spline | | joint | RR/servo_arm_horn_25t@0274 / RR/servo_spt5435lv@0305 | 2.91 | horn on the servo output spline | | joint | FR/servo_arm_horn_25t@0333 / FR/servo_spt5435lv@0364 | 2.91 | horn on the servo output spline | | joint | FL/left_servo_mount#24 / FL/servo_spt5435lv@0241 | 2.69 | servo flange in its mount | | joint | FL/left_servo_mount#24 / FL/servo_spt5435lv@0247 | 2.6 | servo flange in its mount | | joint | FR/outer_tibia#49 / FR/foot_spacer | 2.34 | foot spacer between the tibia halves | | joint | RL/right_servo_mount#16 / RL/servo_spt5435lv@0182 | 2.04 | servo flange in its mount | | joint | RR/servo_disc_horn_25t@0298 / RR/servo_spt5435lv@0299 | 1.84 | horn on the servo output spline | | joint | FR/servo_disc_horn_25t@0357 / FR/servo_spt5435lv@0358 | 1.84 | horn on the servo output spline | | joint | RL/servo_disc_horn_25t@0181 / RL/servo_spt5435lv@0182 | 1.83 | horn on the servo output spline | | joint | FL/servo_disc_horn_25t@0240 / FL/servo_spt5435lv@0241 | 1.83 | horn on the servo output spline | | joint | RR/long_link#38 / RR/sleeve_bearing_3x5x5_pair@0294 | 1.76 | sleeve bearings pressed into the link | | joint | RR/femur_servo_connector#36 / RR/femur#37 | 1.75 | connector bonded/bolted into the femur (upstream fit) | | joint | FR/femur_servo_connector#45 / FR/femur#46 | 1.75 | connector bonded/bolted into the femur (upstream fit) | | joint | FR/long_link#47 / FR/sleeve_bearing_3x5x5_pair@0353 | 1.73 | sleeve bearings pressed into the link | | joint | FL/outer_tibia#32 / FL/foot_spacer | 1.67 | foot spacer between the tibia halves | | joint | FL/femur_servo_connector#27 / FL/femur#28 | 1.46 | connector bonded/bolted into the femur (upstream fit) | | joint | RR/left_servo_mount#34 / RR/servo_spt5435lv@0299 | 1.35 | servo flange in its mount | | joint | FL/servo_disc_horn_25t@0264 / FL/servo_spt5435lv@0265 | 1.32 | horn on the servo output spline | | joint | FR/servo_disc_horn_25t@0381 / FR/servo_spt5435lv@0382 | 1.32 | horn on the servo output spline | | joint | RL/outer_tibia#23 / RL/foot_spacer | 1.31 | foot spacer between the tibia halves | | joint | FL/long_link#29 / FL/sleeve_bearing_3x5x5_pair@0236 | 1.17 | sleeve bearings pressed into the link | | joint | RL/servo_disc_horn_25t@0206 / RL/servo_spt5435lv@0207 | 1.16 | horn on the servo output spline | | joint | RR/servo_disc_horn_25t@0323 / RR/servo_spt5435lv@0324 | 1.16 | horn on the servo output spline | | joint | RR/left_servo_mount#34 / RR/servo_spt5435lv@0305 | 1.1 | servo flange in its mount | | joint | RL/long_link#20 / RL/sleeve_bearing_3x5x5_pair@0177 | 1.08 | sleeve bearings pressed into the link | | joint | FR/inner_tibia#50 / FR/foot_spacer | 0.93 | foot spacer between the tibia halves | | joint | RR/short_link#35 / RR/sleeve_bearing_3x5x5_pair@0277 | 0.47 | sleeve bearings pressed into the link | | joint | FR/short_link#44 / FR/sleeve_bearing_3x5x5_pair@0336 | 0.47 | sleeve bearings pressed into the link | | joint | RR/short_link#35 / RR/sleeve_bearing_3x5x5_pair@0276 | 0.45 | sleeve bearings pressed into the link | | joint | FR/short_link#44 / FR/sleeve_bearing_3x5x5_pair@0335 | 0.45 | sleeve bearings pressed into the link | | joint | RL/femur_servo_connector#18 / RL/bearing_6805_2rs@0179 | 0.43 | thigh bearing seat | | joint | RL/short_link#17 / RL/sleeve_bearing_3x5x5_pair@0160 | 0.42 | sleeve bearings pressed into the link | | joint | RR/femur#37 / RR/bearing_6805_2rs@0296 | 0.42 | thigh bearing seat | | joint | FR/femur#46 / FR/bearing_6805_2rs@0355 | 0.42 | thigh bearing seat | | joint | FL/short_link#26 / FL/sleeve_bearing_3x5x5_pair@0219 | 0.38 | sleeve bearings pressed into the link | | joint | RL/short_link#17 / RL/sleeve_bearing_3x5x5_pair@0159 | 0.37 | sleeve bearings pressed into the link | | joint | FL/short_link#26 / FL/sleeve_bearing_3x5x5_pair@0218 | 0.34 | sleeve bearings pressed into the link | | seating contact | front_shell_display / camera_mount_base | 5.64 | camera base stands on the front shell top surface (penetration 0.01 mm) | | seating contact | top_shell_sensor / lidar_pedestal | 4.5 | pedestal stands on the shell top surface (penetration 0.01 mm) | | seating contact | body/upper_shoulder_frame#13 / iso4032_m3#15 | 2.91 | iso4032_m3 bearing face on upper_shoulder_frame (0.08 mm) | | seating contact | body/upper_shoulder_frame#13 / iso4032_m3#17 | 2.64 | captive iso4032_m3 in its hex pocket (corner contact 0.14 mm) | | seating contact | back_shell_button / arming_port_ring | 1.97 | clamp ring against the inside of the back shell (penetration 0.03 mm) | | seating contact | front_shell_display / waveshare_lcd_2inch#0 | 0.95 | LCD glass pressed against the inside of the front shell by the bracket (penetration 0.01 mm) | | seating contact | top_shell_sensor / rplidar_usb_adapter#2 | 0.62 | adapter taped to the shell top inside the pedestal (penetration 0.00 mm) | | seating contact | back_shell_button / pushbutton_12mm#4 | 0.31 | button bezel against the shell, body through the D12.2 hole (penetration 0.01 mm) | | upstream fit | body/upper_shoulder_frame#13 / front_shell_display | 44.88 | upper_shoulder_frame / front_shell (penetration 0.06 mm, upstream geometry) | | upstream fit | body/side_body_beam#8 / top_shell_sensor | 39.35 | side_body_beam / top_shell (penetration 0.02 mm, upstream geometry) | | upstream fit | body/outer_shoulder_frame#11 / back_shell_button | 35.11 | outer_shoulder_frame / back_shell (penetration 0.05 mm, upstream geometry) | | upstream fit | body/outer_shoulder_frame#12 / front_shell_display | 31.11 | outer_shoulder_frame / front_shell (penetration 0.04 mm, upstream geometry) | | upstream fit | body/side_body_beam#7 / electronics_deck | 26.04 | side_body_beam / electronics_mounting_plate (penetration 0.06 mm, upstream geometry) | | upstream fit | body/upper_shoulder_frame#14 / back_shell_button | 19.33 | upper_shoulder_frame / back_shell (penetration 0.04 mm, upstream geometry) | | upstream fit | body/bottom_shell#1 / body/inner_shoulder_frame#10 | 16.7 | bottom_shell / inner_shoulder_frame (penetration 0.03 mm, upstream geometry) | | upstream fit | back_shell_button / lower_shoulder_frame_rear | 15.2 | back_shell / lower_shoulder_frame (penetration 0.03 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#10 / electronics_deck | 14.89 | inner_shoulder_frame / electronics_mounting_plate (penetration 0.21 mm, upstream geometry) | | upstream fit | body/side_body_beam#8 / body/inner_shoulder_frame#10 | 11.92 | side_body_beam / inner_shoulder_frame (penetration 0.09 mm, upstream geometry) | | upstream fit | body/side_body_beam#7 / body/inner_shoulder_frame#9 | 11.81 | side_body_beam / inner_shoulder_frame (penetration 0.09 mm, upstream geometry) | | upstream fit | front_shell_display / lower_shoulder_frame_front | 6.51 | front_shell / lower_shoulder_frame (penetration 0.02 mm, upstream geometry) | | upstream fit | body/bottom_shell#1 / body/side_body_beam#7 | 6.4 | bottom_shell / side_body_beam (penetration 0.01 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#10 / top_shell_sensor | 6.13 | inner_shoulder_frame / top_shell (penetration 0.04 mm, upstream geometry) | | upstream fit | body/bottom_shell#1 / lower_shoulder_frame_front | 5.86 | bottom_shell / lower_shoulder_frame (penetration 0.02 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#9 / top_shell_sensor | 5.59 | inner_shoulder_frame / top_shell (penetration 0.02 mm, upstream geometry) | | upstream fit | body/upper_shoulder_frame#14 / top_shell_sensor | 5.07 | upper_shoulder_frame / top_shell (penetration 0.01 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#10 / lower_shoulder_frame_front | 4.78 | inner_shoulder_frame / lower_shoulder_frame (penetration 0.02 mm, upstream geometry) | | upstream fit | body/bottom_shell#1 / body/inner_shoulder_frame#9 | 3.86 | bottom_shell / inner_shoulder_frame (penetration 0.02 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#9 / lower_shoulder_frame_rear | 1.45 | inner_shoulder_frame / lower_shoulder_frame (penetration 0.01 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#9 / RR/servo_spt5435lv@0324 | 0.79 | inner_shoulder_frame / servo_spt5435lv (penetration 0.01 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#10 / FL/servo_spt5435lv@0265 | 0.79 | inner_shoulder_frame / servo_spt5435lv (penetration 0.01 mm, upstream geometry) | | upstream fit | body/upper_shoulder_frame#13 / top_shell_sensor | 0.72 | upper_shoulder_frame / top_shell (penetration 0.01 mm, upstream geometry) | | upstream fit | FR/right_servo_mount#42 / FR/servo_disc_horn_25t@0381 | 0.67 | right_servo_mount / servo_disc_horn_25t (penetration 0.00 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#10 / body/upper_shoulder_frame#13 | 0.64 | inner_shoulder_frame / upper_shoulder_frame (penetration 0.00 mm, upstream geometry) | | upstream fit | body/bottom_shell#1 / body/side_body_beam#8 | 0.62 | bottom_shell / side_body_beam (penetration 0.00 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#9 / RL/servo_spt5435lv@0207 | 0.46 | inner_shoulder_frame / servo_spt5435lv (penetration 0.04 mm, upstream geometry) | | upstream fit | body/inner_shoulder_frame#10 / FR/servo_spt5435lv@0382 | 0.46 | inner_shoulder_frame / servo_spt5435lv (penetration 0.04 mm, upstream geometry) | 31 thread-engagement overlaps (screw shank inside a pilot hole, nut, stand-off or stud) and 158 upstream-joint fastener overlaps (fastener on a measured hole axis; captive-nut and slot orientation is not modelled) are omitted from the table. --- # Printability Generated by `strido_verify.printability` with PrusaSlicer-2.9.6 based on Slic3r (with GUI support) and `hardware/print/prusaslicer.ini`. 34 part types sliced as oriented in the kit, 13 plates sliced as arranged: **0 fail**, 4 warn. Whole kit: 958 g PETG, 100 h printing (slicer estimate, all pieces). Thin areas: lateral walls under 0.8 mm (two lines) or horizontal skins under 0.6 mm (three layers); 1 % percentile thickness per orientation. | Part | Qty | Supports | Brim | Time | Filament (g) | Thin lateral (mm²) | Thin skin (mm²) | Min wall / skin (mm) | Result | Slicer / notes | |---|---|---|---|---|---|---|---|---|---|---| | left_servo_mount | 1 | yes | no | 2h 01m | 18.0 | 12 | 22 | 1.54 / 0.42 | warn | thin walls or skins: printable, but only one or two lines / layers thick | | left_servo_mount_fl | 1 | yes | no | 2h 01m | 18.0 | 7 | 17 | 1.52 / 0.51 | warn | thin walls or skins: printable, but only one or two lines / layers thick | | right_servo_mount | 1 | yes | no | 2h 01m | 18.0 | 12 | 11 | 1.53 / 0.53 | warn | thin walls or skins: printable, but only one or two lines / layers thick | | right_servo_mount_fr | 1 | yes | no | 2h 01m | 18.0 | 12 | 26 | 1.52 / 0.38 | warn | thin walls or skins: printable, but only one or two lines / layers thick | | arming_port_bezel | 1 | no | no | 0h 11m | 1.4 | 0 | 0 | 0.50 / 1.00 | ok | | | arming_port_ring | 1 | no | no | 0h 07m | 0.8 | 0 | 0 | 0.96 / 3.00 | ok | | | back_shell_button | 1 | no | yes | 4h 33m | 53.0 | 0 | 0 | 1.64 / 2.00 | ok | | | bottom_shell | 1 | yes | yes | 8h 12m | 76.2 | 0 | 0 | 1.00 / 1.97 | ok | | | camera_mount_base | 1 | no | no | 0h 45m | 5.2 | 0 | 0 | 2.83 / 3.00 | ok | | | camera_tilt_plate | 1 | yes | no | 0h 40m | 6.8 | 0 | 0 | 1.10 / 3.00 | ok | | | display_bracket | 1 | no | no | 1h 06m | 9.5 | 0 | 0 | 1.82 / 3.00 | ok | | | electronics_deck | 1 | yes | no | 5h 11m | 45.3 | 6 | 0 | 1.17 / 1.50 | ok | | | femur | 4 | yes | no | 2h 29m | 24.6 | 0 | 0 | 2.36 / 0.73 | ok | | | femur_servo_connector | 4 | yes | no | 0h 17m | 2.9 | 0 | 0 | 2.17 / 1.00 | ok | | | foot_spacer | 4 | yes | no | 0h 12m | 1.9 | 0 | 0 | 0.83 / 1.16 | ok | | | front_shell_display | 1 | no | yes | 3h 40m | 36.7 | 0 | 0 | 1.07 / 1.50 | ok | | | hip_bearing_pin | 4 | no | no | 0h 01m | 0.1 | 3 | 0 | 0.58 / 0.65 | ok | | | inner_shoulder_frame | 2 | no | no | 2h 01m | 17.7 | 0 | 0 | 0.97 / 2.00 | ok | | | inner_tibia | 4 | yes | no | 1h 40m | 16.8 | 0 | 0 | 1.94 / 1.41 | ok | | | left_shoulder | 2 | yes | no | 1h 11m | 12.6 | 9 | 0 | 1.04 / 1.50 | ok | | | lidar_pedestal | 1 | no | no | 2h 13m | 20.8 | 0 | 0 | 2.79 / 3.00 | ok | | | lower_shoulder_frame_front | 1 | no | no | 1h 16m | 11.5 | 0 | 0 | 0.96 / 0.77 | ok | | | lower_shoulder_frame_rear | 1 | no | no | 1h 16m | 11.5 | 0 | 0 | 0.96 / 0.77 | ok | | | outer_shoulder_frame | 2 | no | no | 2h 19m | 21.8 | 0 | 0 | 3.05 / 0.60 | ok | | | outer_tibia | 4 | yes | no | 1h 40m | 16.7 | 0 | 0 | 1.37 / 1.30 | ok | | | right_shoulder | 2 | yes | no | 1h 12m | 12.5 | 0 | 0 | 1.06 / 1.50 | ok | | | servo_bus_holder_front | 1 | yes | no | 1h 13m | 11.4 | 0 | 0 | 1.60 / 0.60 | ok | | | servo_bus_holder_rear | 1 | yes | no | 0h 45m | 6.8 | 0 | 0 | 1.54 / 0.60 | ok | supports listed, but the slicer finds nothing to support | | servo_mount_top_bracket | 4 | no | no | 0h 26m | 4.2 | 0 | 0 | 2.53 / 1.33 | ok | | | short_link | 4 | yes | no | 0h 13m | 1.9 | 0 | 0 | 0.57 / 0.56 | ok | | | side_body_beam | 2 | no | no | 1h 33m | 14.2 | 0 | 0 | 2.10 / 1.03 | ok | | | stiff_long_link | 4 | no | no | 0h 58m | 10.8 | 0 | 0 | 1.00 / 10.00 | ok | | | top_shell_sensor | 1 | yes | yes | 8h 37m | 77.2 | 0 | 0 | 1.00 / 1.97 | ok | | | upper_shoulder_frame | 2 | no | no | 1h 50m | 17.9 | 0 | 0 | 1.40 / 0.95 | ok | | | Plate | Result | Slicer | |---|---|---| | plate_graphite_01.3mf | ok | | | plate_graphite_02.3mf | ok | | | plate_graphite_03.3mf | ok | | | plate_graphite_04.3mf | ok | | | plate_graphite_05.3mf | ok | | | plate_graphite_06.3mf | ok | | | plate_graphite_07.3mf | ok | | | plate_graphite_08.3mf | ok | | | plate_graphite_09.3mf | ok | | | plate_graphite_10.3mf | ok | | | plate_lime_01.3mf | ok | | | plate_lime_02.3mf | ok | | | plate_lime_03.3mf | ok | | --- # Sensor field of view Generated by `strido_verify.sensor_fov` (ray casting on the posed CAD model). LiDAR scan plane: 150.3 mm in the STEP frame = 71.6 mm above the hip axes (254 mm above ground when standing at 170 mm). | Pose | LiDAR rays blocked (of 720) | Camera image showing the robot | |---|---|---| | stand | 0 | 0.0 % | | crouch | 0 | 0.0 % | | trot_swing_apex | 0 | 0.0 % | | trot_max_stride | 0 | 0.0 % | | hips_abducted | 0 | 0.0 % | --- # Strength check Generated by `python -m strido_verify.strength` from `hardware/strength.yaml`, the printed STLs, the leg geometry and the fit report. **19 checks: 19 ok, 0 warn, 0 fail; lowest safety factor 2.22 (tibia halves bending (knee torque, shared by the two halves)).** Safety factor S = allowable / actual; fail below 1.5, warn below 2.0. Formulas and background: [strength, torque and failure](../wiki/making/strength.md). ## Loads | Load | Value | How | |---|---|---| | Foot force, hard landing | 45.2 N | F = 3.0 · m · g / 2, m = 3.07 kg | | Thigh and hip joint torque | 3.43 N·m | servo stall torque (the largest torque the servo applies) | | Knee joint torque | 3.07 N·m | knee servo at stall through the linkage, largest over the joint range | | Knee torque a landing would need | 5.60 N·m | F_foot · L_tibia (124 mm); beyond the servo's torque the knee is back-driven | | Short link force | 211 N | T_stall / (r_arm · sin α), largest over the joint range (r_arm 24.0 mm) | | Long link force | 192 N | T_crank / (r_crank_b · sin β), largest over the joint range | | Knee joint radial force | 237 N | F_foot + F_long (upper bound) | Material: PETG (Prusament PETG technical data sheet v1.1, printed specimens, 0.2 mm layers): 47 MPa along the layers, 18 MPa across them. Sections count only the 1.2 mm walls (infill ignored). Screws: DIN 912 A2-70 (ISO 3506, Bossard tables). ## Checks | Check | Parts | Formula | Actual | Allowable | S | Status | |---|---|---|---|---|---|---| | femur bending at its weakest section (thigh servo stall torque) | femur | $\sigma = T_\mathrm{thigh} / W$ | 14.54 MPa | 47 MPa | **3.23** | ok | | tibia halves bending (knee torque, shared by the two halves) | inner_tibia, outer_tibia | $\sigma = \dfrac{T_\mathrm{knee}}{W_\mathrm{inner} + W_\mathrm{outer}}$ | 21.16 MPa | 47 MPa | **2.22** | ok | | short link tension/compression (pin-line offset e = 0.0 mm) | short_link | $\sigma = \dfrac{F}{A} + \dfrac{F\,e}{W}\sec\!\left(\dfrac{L}{2}\sqrt{\dfrac{F}{E I}}\right)$ | 8.65 MPa | 47 MPa | **5.44** | ok | | short link buckling (pinned in the leg plane, K = 0.7 across it) | short_link | $F_\mathrm{cr} = \dfrac{\pi^2 E I}{(K L)^2}$ | 211.49 N | 2002.8 N | **9.47** | ok | | short link pin hole bearing pressure | short_link | $p = \dfrac{F}{d\,t}$ | 4.28 MPa | 47 MPa | **10.97** | ok | | long link tension/compression (pin-line offset e = 2.0 mm) | stiff_long_link | $\sigma = \dfrac{F}{A} + \dfrac{F\,e}{W}\sec\!\left(\dfrac{L}{2}\sqrt{\dfrac{F}{E I}}\right)$ | 10.82 MPa | 47 MPa | **4.34** | ok | | long link buckling (pinned in the leg plane, K = 0.7 across it) | stiff_long_link | $F_\mathrm{cr} = \dfrac{\pi^2 E I}{(K L)^2}$ | 192.1 N | 486.6 N | **2.53** | ok | | long link pin hole bearing pressure | stiff_long_link | $p = \dfrac{F}{d\,t}$ | 6.61 MPa | 47 MPa | **7.11** | ok | | 4 × M3 shear, femur on the thigh horn (r = 7.0 mm) | femur, femur_servo_connector | $\tau = \dfrac{T}{n\,r\,A_s}$ | 24.35 MPa | 259.6 MPa | **10.66** | ok | | printed hole bearing pressure, femur on the thigh horn | femur, femur_servo_connector | $p = \dfrac{T}{n\,r\,d\,t}$ | 8.48 MPa | 47 MPa | **5.55** | ok | | 2 × M3 shear, hip horn on the servo mount (r = 7.3 mm) | left_servo_mount, left_servo_mount_fl, right_servo_mount, right_servo_mount_fr | $\tau = \dfrac{T}{n\,r\,A_s}$ | 46.51 MPa | 259.6 MPa | **5.58** | ok | | printed hole bearing pressure, hip horn on the servo mount | left_servo_mount, left_servo_mount_fl, right_servo_mount, right_servo_mount_fr | $p = \dfrac{T}{n\,r\,d\,t}$ | 17.93 MPa | 47 MPa | **2.62** | ok | | M3 shear, arm pin (short link) | short_link | $\tau = F / A_s$ | 42.04 MPa | 259.6 MPa | **6.18** | ok | | M3 shear, tibia link pin (long link) | inner_tibia, outer_tibia, stiff_long_link | $\tau = \dfrac{F}{2\,A_s}$ | 19.1 MPa | 259.6 MPa | **13.6** | ok | | M3 shear, knee axle screw (one per side) | femur, inner_tibia, outer_tibia | $\tau = F / A_s$ | 23.59 MPa | 259.6 MPa | **11.01** | ok | | knee axle bearing pressure in the femur pilot | femur | $p = \dfrac{F}{d\,L_e}$ | 10.59 MPa | 47 MPa | **4.44** | ok | | 633ZZ static radial load (knee, two per leg) | bearing_633zz | $S_0 = C_0 / F_r$ | 118.64 N | 490 N | **4.13** | ok * | | 6805-2RS static radial load (shoulder crank) | bearing_6805 | $S_0 = C_0 / F_r$ | 403.58 N | 2600 N | **6.44** | ok * | | 625ZZ static radial load (hip pivot) | bearing_625zz | $S_0 = C_0 / F_r$ | 45.18 N | 380 N | **8.41** | ok * | \* **633ZZ static radial load (knee, two per leg):** Koyo 633 ZZ \* **6805-2RS static radial load (shoulder crank):** SKF 61805-2RS1 \* **625ZZ static radial load (hip pivot):** SKF 625-2Z ## Tightening torque into printed pilots Pull-out of a thread-forming screw shears the plastic on a cylinder of the thread diameter: $F = \tau\,\pi\,d\,L_e$ with $\tau = 0.577 \times$ layer adhesion (the weakest direction). The torque that produces it is $T = k\,F\,d$ ($k = 0.2$); tighten to at most half of that. | Joint | Part | Thread | Engaged | Pull-out force | Stripping torque | **Tighten to max.** | |---|---|---|---|---|---|---| | bracket -> front shell boss (self-tapping) | front_shell_display | M3 | 6.5 mm | 636 N | 0.38 N·m | **0.19 N·m** | | bracket_screws | left_servo_mount | M3 | 12.0 mm | 1175 N | 0.7 N·m | **0.35 N·m** | | bracket_screws | left_servo_mount_fl | M3 | 12.0 mm | 1175 N | 0.7 N·m | **0.35 N·m** | | bracket_screws | right_servo_mount | M3 | 12.0 mm | 1175 N | 0.7 N·m | **0.35 N·m** | | bracket_screws | right_servo_mount_fr | M3 | 12.0 mm | 1175 N | 0.7 N·m | **0.35 N·m** | | bracket_screws | servo_mount_top_bracket | M3 | 7.0 mm | 685 N | 0.41 N·m | **0.21 N·m** | | bus holder -> lower shoulder frame (self-tapping) | lower_shoulder_frame_front | M3 | 5.0 mm | 489 N | 0.29 N·m | **0.15 N·m** | | bus holder -> lower shoulder frame (self-tapping) | lower_shoulder_frame_rear | M3 | 5.0 mm | 489 N | 0.29 N·m | **0.15 N·m** | | clamp ring -> bezel through the D20 hole | arming_port_bezel | M2.5 | 2.5 mm | 204 N | 0.1 N·m | **0.05 N·m** | | crank_pins | left_shoulder | M3 | 11.5 mm | 1126 N | 0.68 N·m | **0.34 N·m** | | crank_pins | right_shoulder | M3 | 11.5 mm | 1126 N | 0.68 N·m | **0.34 N·m** | | hip_horn | left_servo_mount | M3 | 4.5 mm | 440 N | 0.26 N·m | **0.13 N·m** | | hip_horn | left_servo_mount_fl | M3 | 4.5 mm | 440 N | 0.26 N·m | **0.13 N·m** | | hip_horn | right_servo_mount | M3 | 4.5 mm | 440 N | 0.26 N·m | **0.13 N·m** | | hip_horn | right_servo_mount_fr | M3 | 4.5 mm | 440 N | 0.26 N·m | **0.13 N·m** | | hip_pivot | left_servo_mount | M3 | 4.0 mm | 392 N | 0.23 N·m | **0.12 N·m** | | hip_pivot | left_servo_mount_fl | M3 | 4.0 mm | 392 N | 0.23 N·m | **0.12 N·m** | | hip_pivot | right_servo_mount | M3 | 4.0 mm | 392 N | 0.23 N·m | **0.12 N·m** | | hip_pivot | right_servo_mount_fr | M3 | 4.0 mm | 392 N | 0.23 N·m | **0.12 N·m** | | knee_axle | femur | M3 | 4.0 mm | 392 N | 0.23 N·m | **0.12 N·m** | | leg_servo_ears_and_bracket | left_servo_mount | M3 | 9.5 mm | 930 N | 0.56 N·m | **0.28 N·m** | | leg_servo_ears_and_bracket | left_servo_mount_fl | M3 | 9.5 mm | 930 N | 0.56 N·m | **0.28 N·m** | | leg_servo_ears_and_bracket | right_servo_mount | M3 | 9.5 mm | 930 N | 0.56 N·m | **0.28 N·m** | | leg_servo_ears_and_bracket | right_servo_mount_fr | M3 | 9.5 mm | 930 N | 0.56 N·m | **0.28 N·m** | | pedestal -> top shell (from below, self-tapping) | lidar_pedestal | M3 | 9.5 mm | 930 N | 0.56 N·m | **0.28 N·m** | ## Limits * Static checks with peak loads; fatigue of PETG under millions of steps, creep under a standing load and notch effects at sharp corners are not modelled (hence S ≥ 2). * Joint torques are the servos' rated stall torque. Back-driving a powered spur-gear servo from outside takes more (about 1 / (η_forward · η_reverse), roughly 1.5–3 × for a multi-stage gearbox [estimate]): a hard impact can exceed these loads. * The impact factor is an assumption, not a measurement; the servo stall torque is the datasheet value at 6.0 V. * Bending is taken in the leg plane at the weakest of 41 sections; buckling covers both directions with the stiffness of the sections in series. Bending of the tibias across the leg plane (side loads) is not checked: the two halves are joined, and their combined section is not modelled. * Hole bearing pressure is compared with the strength along the layers. * The aluminium horns are not checked (their alloy is not stated by the sellers); they are far stronger than the printed parts they hold. --- # Wiring check Generated by `python -m strido_verify.wiring` from `hardware/wiring.yaml`, routed through the CAD by `strido_cad.wiring`. **37 cables in 71 runs; 174 checks: 173 ok, 1 warn, 0 fail.** How the harness is built: [electronics, wiring and power](../electronics.md#wiring-harness); background: [cable management](../wiki/making/cable-management.md). Clearance uses mesh surface samples in every operating pose (stand, crouch, tall, eight trot phases, hips out/in); runs through the hip joint are flexible loops checked by length over the full hip range. Not modelled: bend radii (route corners are drawn sharp) and where a hip loop hangs. ## Needs attention | Check | Item | Status | Value | Limit | Note | |---|---|---|---|---|---| | reach | `W23` | warn | 57 mm | length not published | RPLIDAR C1 sensor cable to the USB adapter (supplied; length not published [MEASURE]) | ## Cut list | Run | Type | From | To | Routed | Cut length | |---|---|---|---|---|---| | `W00/red` | battery_core_red | `battery.lead_red` | `xt60_battery.pack_red` | 42 mm | ready-made | | `W00/black` | battery_core_black | `battery.lead_black` | `xt60_battery.pack_black` | 53 mm | ready-made | | `W01` | wire_14awg_red | `xt60_battery.robot_red` | `fuse_main.in` | 129 mm | 151 mm | | `W02` | wire_14awg_red | `fuse_main.out` | `back.splice` | 46 mm | 62 mm | | `W02/arming` | wire_14awg_red | `back.splice` | `xt60_arming.cups` | 10 mm | 26 mm | | `W02/logic` | fuse_holder_lead | `back.splice` | `fuse_logic.in` | 36 mm | ready-made | | `W03` | wire_14awg_red | `xt60_arming.cups` | `mid.ubec_split` | 163 mm | 182 mm | | `W03/front` | wire_14awg_red | `mid.ubec_split` | `ubec_front.in` | 68 mm | 84 mm | | `W03/rear` | wire_14awg_red | `mid.ubec_split` | `ubec_rear.in` | 191 mm | 212 mm | | `W04` | wire_14awg_black | `xt60_battery.robot_black` | `mid.ground_star` | 50 mm | 66 mm | | `W04/front` | wire_14awg_black | `mid.ground_star` | `ubec_front.in` | 59 mm | 75 mm | | `W04/rear` | wire_14awg_black | `mid.ground_star` | `ubec_rear.in` | 197 mm | 219 mm | | `W04/logic` | wire_18awg_black | `mid.ground_star` | `switch.vin` | 202 mm | 226 mm | | `W05` | wire_18awg_pair | `ubec_front.out` | `wago:front.0.p0` | 201 mm | 234 mm | | `W06` | wire_18awg_pair | `ubec_rear.out` | `wago:rear.0.p0` | 151 mm | 180 mm | | `W07/vplus` | wire_18awg_red | `wago:front.0.p4.V+` | `wago:front.1.p0.V+` | 15 mm | 47 mm | | `W07/gnd` | wire_18awg_black | `wago:front.0.p4.GND` | `wago:front.1.p0.GND` | 17 mm | 49 mm | | `W08/vplus` | wire_18awg_red | `wago:rear.0.p4.V+` | `wago:rear.1.p0.V+` | 15 mm | 47 mm | | `W08/gnd` | wire_18awg_black | `wago:rear.0.p4.GND` | `wago:rear.1.p0.GND` | 17 mm | 49 mm | | `W09` | wire_18awg_red | `fuse_logic.out` | `switch.vin` | 256 mm | 290 mm | | `W10` | wire_18awg_red | `switch.vout` | `ina.vin` | 133 mm | 157 mm | | `W11` | wire_18awg_red | `ina.vin` | `reg.vin` | 44 mm | 67 mm | | `W24` | wire_18awg_black | `switch.vout` | `reg.vin` | 125 mm | 148 mm | | `W12` | wire_22awg_quad | `reg.vout` | `pi.gpio_power` | 104 mm | 123 mm | | `W13` | wire_22awg_pair | `button.terminals` | `switch.ab` | 248 mm | 280 mm | | `W14` | usb_a_c | `pi.usb_low` | `xiao.usb` | 22 mm | ready-made | | `W15` | usb_a_c | `pi.usb_high` | `lidar_adapter.usb` | 145 mm | ready-made | | `W16` | camera_ffc | `pi.cam0` | `camera.ffc` | 167 mm | ready-made | | `W17` | wire_22awg_i2c | `xiao.pins` | `pca.header_left` | 280 mm | 312 mm | | `W18` | stemma_qt | `pca.header_right` | `imu.qt_right` | 96 mm | ready-made | | `W19` | stemma_qt | `imu.qt_left` | `ina.header` | 125 mm | ready-made | | `W20` | wire_22awg_green | `xiao.pins` | `pca.header_left` | 280 mm | 312 mm | | `W21` | lcd_cable | `lcd.connector` | `xiao.pins` | 53 mm | ready-made | | `W22` | wire_22awg_white | `pi.gpio_poweroff` | `switch.ab` | 194 mm | 219 mm | | `W23` | lidar_cable | `lidar.cable` | `lidar_adapter.sensor` | 57 mm | ready-made | | `S-FL-hip` | servo_lead | `servo:FL.hip` | `front.opening` | 62 mm | ready-made | | `S-FL-hip/power` | servo_power | `front.opening` | `wago:front.0.p1` | 93 mm | 114 mm | | `S-FL-hip/signal` | servo_signal | `front.opening` | `pca:15` | 134 mm | 147 mm | | `S-FL-thigh` | servo_lead | `servo:FL.thigh` | `front.thigh_base.L` | 35 mm | ready-made | | `S-FL-thigh/power` | servo_power | `front.thigh_base.L` | `wago:front.0.p2` | 59 mm | 80 mm | | `S-FL-thigh/signal` | servo_signal | `front.thigh_base.L` | `pca:11` | 148 mm | 162 mm | | `S-FL-calf` | servo_lead | `servo:FL.calf` | `front.calf_base.L` | 55 mm | ready-made | | `S-FL-calf/power` | servo_power | `front.calf_base.L` | `wago:front.0.p3` | 29 mm | 50 mm | | `S-FL-calf/signal` | servo_signal | `front.calf_base.L` | `pca:7` | 171 mm | 188 mm | | `S-FR-hip` | servo_lead | `servo:FR.hip` | `front.opening` | 53 mm | ready-made | | `S-FR-hip/power` | servo_power | `front.opening` | `wago:front.1.p1` | 58 mm | 79 mm | | `S-FR-hip/signal` | servo_signal | `front.opening` | `pca:14` | 131 mm | 145 mm | | `S-FR-thigh` | servo_lead | `servo:FR.thigh` | `front.thigh_base.R` | 35 mm | ready-made | | `S-FR-thigh/power` | servo_power | `front.thigh_base.R` | `wago:front.1.p2` | 60 mm | 81 mm | | `S-FR-thigh/signal` | servo_signal | `front.thigh_base.R` | `pca:10` | 140 mm | 154 mm | | `S-FR-calf` | servo_lead | `servo:FR.calf` | `front.calf_base.R` | 55 mm | ready-made | | `S-FR-calf/power` | servo_power | `front.calf_base.R` | `wago:front.1.p3` | 65 mm | 86 mm | | `S-FR-calf/signal` | servo_signal | `front.calf_base.R` | `pca:6` | 169 mm | 186 mm | | `S-RL-hip` | servo_lead | `servo:RL.hip` | `rear.opening` | 59 mm | ready-made | | `S-RL-hip/power` | servo_power | `rear.opening` | `wago:rear.0.p1` | 50 mm | 71 mm | | `S-RL-hip/signal` | servo_signal | `rear.opening` | `pca:13` | 78 mm | 90 mm | | `S-RL-thigh` | servo_lead | `servo:RL.thigh` | `rear.thigh_base.L` | 27 mm | ready-made | | `S-RL-thigh/power` | servo_power | `rear.thigh_base.L` | `wago:rear.0.p2` | 57 mm | 78 mm | | `S-RL-thigh/signal` | servo_signal | `rear.thigh_base.L` | `pca:9` | 138 mm | 152 mm | | `S-RL-calf` | servo_lead | `servo:RL.calf` | `rear.calf_base.L` | 45 mm | ready-made | | `S-RL-calf/power` | servo_power | `rear.calf_base.L` | `wago:rear.0.p3` | 22 mm | 43 mm | | `S-RL-calf/signal` | servo_signal | `rear.calf_base.L` | `pca:5` | 104 mm | 117 mm | | `S-RR-hip` | servo_lead | `servo:RR.hip` | `rear.opening` | 59 mm | ready-made | | `S-RR-hip/power` | servo_power | `rear.opening` | `wago:rear.1.p1` | 82 mm | 103 mm | | `S-RR-hip/signal` | servo_signal | `rear.opening` | `pca:12` | 76 mm | 88 mm | | `S-RR-thigh` | servo_lead | `servo:RR.thigh` | `rear.thigh_base.R` | 27 mm | ready-made | | `S-RR-thigh/power` | servo_power | `rear.thigh_base.R` | `wago:rear.1.p2` | 46 mm | 67 mm | | `S-RR-thigh/signal` | servo_signal | `rear.thigh_base.R` | `pca:8` | 138 mm | 153 mm | | `S-RR-calf` | servo_lead | `servo:RR.calf` | `rear.calf_base.R` | 45 mm | ready-made | | `S-RR-calf/power` | servo_power | `rear.calf_base.R` | `wago:rear.1.p3` | 60 mm | 81 mm | | `S-RR-calf/signal` | servo_signal | `rear.calf_base.R` | `pca:4` | 104 mm | 117 mm | Cut length = routed length + terminations (wago 11 mm, solder 6 mm, crimp 3 mm, plug 0 mm, screw 7 mm) + slack (8%, at least 10 mm). Servo-lead branches (`S-…/power`, `S-…/signal`) are measured from the tie where the lead splits. ## Reach | Check | Item | Status | Value | Limit | Note | |---|---|---|---|---|---| | reach | `W00` | ok | 53 mm | ≤ 110 mm | 120 mm supplied, 67 mm to fold and tie | | reach | `W14` | ok | 22 mm | ≤ 190 mm | 200 mm supplied, 178 mm to fold and tie | | reach | `W15` | ok | 145 mm | ≤ 190 mm | 200 mm supplied, 55 mm to fold and tie | | reach | `W16` | ok | 167 mm | ≤ 290 mm | 300 mm supplied, 133 mm to fold and tie | | reach | `W18` | ok | 96 mm | ≤ 140 mm | 150 mm supplied, 54 mm to fold and tie | | reach | `W19` | ok | 125 mm | ≤ 140 mm | 150 mm supplied, 25 mm to fold and tie | | reach | `W21` | ok | 59 mm | ≤ 190 mm | 200 mm supplied, 141 mm to fold and tie | | reach | `W23` | warn | 57 mm | length not published | RPLIDAR C1 sensor cable to the USB adapter (supplied; length not published [MEASURE]) | | reach | `S-FL-hip` | ok | 210 mm | ≤ 250 mm | 260 mm supplied, 50 mm to fold and tie | | reach | `S-FL-thigh` | ok | 197 mm | ≤ 250 mm | 260 mm supplied, 63 mm to fold and tie | | reach | `S-FL-calf` | ok | 243 mm | ≤ 250 mm | 260 mm supplied, 17 mm to fold and tie | | reach | `S-FR-hip` | ok | 198 mm | ≤ 250 mm | 260 mm supplied, 62 mm to fold and tie | | reach | `S-FR-thigh` | ok | 189 mm | ≤ 250 mm | 260 mm supplied, 71 mm to fold and tie | | reach | `S-FR-calf` | ok | 241 mm | ≤ 250 mm | 260 mm supplied, 19 mm to fold and tie | | reach | `S-RL-hip` | ok | 149 mm | ≤ 250 mm | 260 mm supplied, 111 mm to fold and tie | | reach | `S-RL-thigh` | ok | 179 mm | ≤ 250 mm | 260 mm supplied, 81 mm to fold and tie | | reach | `S-RL-calf` | ok | 162 mm | ≤ 250 mm | 260 mm supplied, 98 mm to fold and tie | | reach | `S-RR-hip` | ok | 162 mm | ≤ 250 mm | 260 mm supplied, 98 mm to fold and tie | | reach | `S-RR-thigh` | ok | 180 mm | ≤ 250 mm | 260 mm supplied, 80 mm to fold and tie | | reach | `S-RR-calf` | ok | 162 mm | ≤ 250 mm | 260 mm supplied, 98 mm to fold and tie | ## Wire stock | Check | Item | Status | Value | Limit | Note | |---|---|---|---|---|---| | wire stock | `wire_14awg black` | ok | 360 mm | ≤ 1000 mm | Silicone wire 14 AWG / 2.5 mm², red + black, 1 m each | | wire stock | `wire_14awg red` | ok | 717 mm | ≤ 1000 mm | Silicone wire 14 AWG / 2.5 mm², red + black, 1 m each | | wire stock | `wire_18awg black` | ok | 886 mm | ≤ 2000 mm | Silicone wire 18 AWG / 0.75 mm², red + black (1 m each per unit) | | wire stock | `wire_18awg red` | ok | 1022 mm | ≤ 2000 mm | Silicone wire 18 AWG / 0.75 mm², red + black (1 m each per unit) | | wire stock | `wire_22awg black` | ok | 838 mm | ≤ 4000 mm | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | | wire stock | `wire_22awg blue` | ok | 312 mm | ≤ 4000 mm | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | | wire stock | `wire_22awg green` | ok | 312 mm | ≤ 4000 mm | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | | wire stock | `wire_22awg red` | ok | 838 mm | ≤ 4000 mm | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | | wire stock | `wire_22awg white` | ok | 219 mm | ≤ 4000 mm | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | | wire stock | `wire_22awg yellow` | ok | 312 mm | ≤ 4000 mm | Silicone wire 22 AWG (Pi 5 V feed with Dupont crimps, button, jumpers) | | wire stock | `spiral_wrap` | ok | 312 mm | ≤ 5000 mm | Spiral wrap, black PE, for bundles 4-10 mm (5-10 m) | ## Ready-made cables | Check | Item | Status | Value | Limit | Note | |---|---|---|---|---|---| | ready-made cables | `cable_ties` | ok | 30 | ≤ 100 | Cable ties 100 x 2.5 mm (100) | | ready-made cables | `camera_cable_300` | ok | 1 | ≤ 1 | Pi 5 camera cable 22→15 pin, 300 mm | | ready-made cables | `stemma_qt_female` | ok | 2 | ≤ 2 | STEMMA QT to female sockets cable, 150 mm (Adafruit 4397; IMU to PCA9685 and IMU to INA260) | | ready-made cables | `usb_a_c_short` | ok | 2 | ≤ 2 | USB-A to USB-C cable, 20 cm, 90° (Pi → XIAO, Pi → LiDAR adapter) | ## Hip loop | Check | Item | Status | Value | Limit | Note | |---|---|---|---|---|---| | hip loop | `front.thigh_tie.L → front.thigh_base.L` | ok | 30 mm | ≥ 28.9 mm | tie-to-tie 13.0–24.1 mm over the hip range | | hip loop | `front.calf_tie.L → front.calf_base.L` | ok | 32 mm | ≥ 30.7 mm | tie-to-tie 20.0–25.6 mm over the hip range | | hip loop | `front.thigh_tie.R → front.thigh_base.R` | ok | 30 mm | ≥ 28.9 mm | tie-to-tie 13.0–24.1 mm over the hip range | | hip loop | `front.calf_tie.R → front.calf_base.R` | ok | 32 mm | ≥ 30.7 mm | tie-to-tie 20.0–25.6 mm over the hip range | | hip loop | `rear.thigh_tie.L → rear.thigh_base.L` | ok | 22 mm | ≥ 19.4 mm | tie-to-tie 11.9–16.2 mm over the hip range | | hip loop | `rear.calf_tie.L → rear.calf_base.L` | ok | 22 mm | ≥ 19.4 mm | tie-to-tie 11.9–16.1 mm over the hip range | | hip loop | `rear.thigh_tie.R → rear.thigh_base.R` | ok | 22 mm | ≥ 19.4 mm | tie-to-tie 11.9–16.2 mm over the hip range | | hip loop | `rear.calf_tie.R → rear.calf_base.R` | ok | 22 mm | ≥ 19.4 mm | tie-to-tie 11.9–16.1 mm over the hip range | ## Port | Check | Item | Status | Value | Limit | Note | |---|---|---|---|---|---| | port | `pca:10` | ok | 1 | 1 | S-FR-thigh/signal | | port | `pca:11` | ok | 1 | 1 | S-FL-thigh/signal | | port | `pca:12` | ok | 1 | 1 | S-RR-hip/signal | | port | `pca:13` | ok | 1 | 1 | S-RL-hip/signal | | port | `pca:14` | ok | 1 | 1 | S-FR-hip/signal | | port | `pca:15` | ok | 1 | 1 | S-FL-hip/signal | | port | `pca:4` | ok | 1 | 1 | S-RR-calf/signal | | port | `pca:5` | ok | 1 | 1 | S-RL-calf/signal | | port | `pca:6` | ok | 1 | 1 | S-FR-calf/signal | | port | `pca:7` | ok | 1 | 1 | S-FL-calf/signal | | port | `pca:8` | ok | 1 | 1 | S-RR-thigh/signal | | port | `pca:9` | ok | 1 | 1 | S-RL-thigh/signal | | port | `wago:front.0.p0.GND` | ok | 1 | 1 | W05 | | port | `wago:front.0.p0.V+` | ok | 1 | 1 | W05 | | port | `wago:front.0.p1.GND` | ok | 1 | 1 | S-FL-hip/power | | port | `wago:front.0.p1.V+` | ok | 1 | 1 | S-FL-hip/power | | port | `wago:front.0.p2.GND` | ok | 1 | 1 | S-FL-thigh/power | | port | `wago:front.0.p2.V+` | ok | 1 | 1 | S-FL-thigh/power | | port | `wago:front.0.p3.GND` | ok | 1 | 1 | S-FL-calf/power | | port | `wago:front.0.p3.V+` | ok | 1 | 1 | S-FL-calf/power | | port | `wago:front.0.p4.GND` | ok | 1 | 1 | W07/gnd | | port | `wago:front.0.p4.V+` | ok | 1 | 1 | W07/vplus | | port | `wago:front.1.p0.GND` | ok | 1 | 1 | W07/gnd | | port | `wago:front.1.p0.V+` | ok | 1 | 1 | W07/vplus | | port | `wago:front.1.p1.GND` | ok | 1 | 1 | S-FR-hip/power | | port | `wago:front.1.p1.V+` | ok | 1 | 1 | S-FR-hip/power | | port | `wago:front.1.p2.GND` | ok | 1 | 1 | S-FR-thigh/power | | port | `wago:front.1.p2.V+` | ok | 1 | 1 | S-FR-thigh/power | | port | `wago:front.1.p3.GND` | ok | 1 | 1 | S-FR-calf/power | | port | `wago:front.1.p3.V+` | ok | 1 | 1 | S-FR-calf/power | | port | `wago:rear.0.p0.GND` | ok | 1 | 1 | W06 | | port | `wago:rear.0.p0.V+` | ok | 1 | 1 | W06 | | port | `wago:rear.0.p1.GND` | ok | 1 | 1 | S-RL-hip/power | | port | `wago:rear.0.p1.V+` | ok | 1 | 1 | S-RL-hip/power | | port | `wago:rear.0.p2.GND` | ok | 1 | 1 | S-RL-thigh/power | | port | `wago:rear.0.p2.V+` | ok | 1 | 1 | S-RL-thigh/power | | port | `wago:rear.0.p3.GND` | ok | 1 | 1 | S-RL-calf/power | | port | `wago:rear.0.p3.V+` | ok | 1 | 1 | S-RL-calf/power | | port | `wago:rear.0.p4.GND` | ok | 1 | 1 | W08/gnd | | port | `wago:rear.0.p4.V+` | ok | 1 | 1 | W08/vplus | | port | `wago:rear.1.p0.GND` | ok | 1 | 1 | W08/gnd | | port | `wago:rear.1.p0.V+` | ok | 1 | 1 | W08/vplus | | port | `wago:rear.1.p1.GND` | ok | 1 | 1 | S-RR-hip/power | | port | `wago:rear.1.p1.V+` | ok | 1 | 1 | S-RR-hip/power | | port | `wago:rear.1.p2.GND` | ok | 1 | 1 | S-RR-thigh/power | | port | `wago:rear.1.p2.V+` | ok | 1 | 1 | S-RR-thigh/power | | port | `wago:rear.1.p3.GND` | ok | 1 | 1 | S-RR-calf/power | | port | `wago:rear.1.p3.V+` | ok | 1 | 1 | S-RR-calf/power | ## Through parts | Check | Item | Status | Value | Limit | Note | |---|---|---|---|---|---| | through parts | `W00/red` | ok | +1.07 mm | ≥ 0 | nearest: inner_shoulder_frame | | through parts | `W00/black` | ok | +1.11 mm | ≥ 0 | nearest: inner_shoulder_frame | | through parts | `W01` | ok | +0.89 mm | ≥ 0 | nearest: lipo_2s_5000_gensace | | through parts | `W02` | ok | +1.67 mm | ≥ 0 | nearest: fuse_holder_ato | | through parts | `W02/arming` | ok | +3.87 mm | ≥ 0 | nearest: xt60h_female | | through parts | `W02/logic` | ok | +1.18 mm | ≥ 0 | nearest: fuse_holder_ato | | through parts | `W03` | ok | +1.04 mm | ≥ 0 | nearest: lipo_2s_5000_gensace | | through parts | `W03/front` | ok | +0.90 mm | ≥ 0 | nearest: xt60h_female | | through parts | `W03/rear` | ok | +1.18 mm | ≥ 0 | nearest: lipo_2s_5000_gensace | | through parts | `W04` | ok | +1.06 mm | ≥ 0 | nearest: bottom_shell | | through parts | `W04/front` | ok | +1.06 mm | ≥ 0 | nearest: bottom_shell | | through parts | `W04/rear` | ok | +0.78 mm | ≥ 0 | nearest: lipo_2s_5000_gensace | | through parts | `W04/logic` | ok | +1.03 mm | ≥ 0 | nearest: electronics_deck | | through parts | `W05` | ok | +0.65 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `W06` | ok | +0.73 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `W07/vplus` | ok | +0.60 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `W07/gnd` | ok | +1.08 mm | ≥ 0 | nearest: servo_bus_holder_front | | through parts | `W08/vplus` | ok | +0.60 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `W08/gnd` | ok | +1.08 mm | ≥ 0 | nearest: servo_bus_holder_rear | | through parts | `W09` | ok | +0.83 mm | ≥ 0 | nearest: outer_shoulder_frame | | through parts | `W10` | ok | +2.20 mm | ≥ 0 | nearest: cable_tie_mount | | through parts | `W11` | ok | +2.00 mm | ≥ 0 | nearest: pololu_d36v50f5 | | through parts | `W24` | ok | +2.20 mm | ≥ 0 | nearest: cable_tie_mount | | through parts | `W12` | ok | +2.92 mm | ≥ 0 | nearest: pololu_d36v50f5 | | through parts | `W13` | ok | +0.91 mm | ≥ 0 | nearest: raspberry_pi_5 | | through parts | `W14` | ok | +4.89 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `W15` | ok | +0.89 mm | ≥ 0 | nearest: raspberry_pi_5 | | through parts | `W16` | ok | +0.77 mm | ≥ 0 | nearest: front_shell_display | | through parts | `W17` | ok | +0.82 mm | ≥ 0 | nearest: raspberry_pi_5 | | through parts | `W18` | ok | +0.70 mm | ≥ 0 | nearest: electronics_deck | | through parts | `W19` | ok | +0.63 mm | ≥ 0 | nearest: adafruit_pca9685 | | through parts | `W20` | ok | +1.88 mm | ≥ 0 | nearest: raspberry_pi_5 | | through parts | `W21` | ok | +2.81 mm | ≥ 0 | nearest: outer_shoulder_frame | | through parts | `W22` | ok | +1.08 mm | ≥ 0 | nearest: raspberry_pi_5 | | through parts | `W23` | ok | +0.89 mm | ≥ 0 | nearest: rplidar_c1 | | through parts | `S-FL-hip` | ok | +0.80 mm | ≥ 0 | nearest: inner_shoulder_frame | | through parts | `S-FL-hip/power` | ok | +0.72 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-FL-hip/signal` | ok | +1.06 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-FL-thigh` | ok | +3.34 mm | ≥ 0 | nearest: left_servo_mount_fl | | through parts | `S-FL-thigh/power` | ok | +0.72 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-FL-thigh/signal` | ok | +1.14 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-FL-calf` | ok | +1.17 mm | ≥ 0 | nearest: servo_spt5435lv | | through parts | `S-FL-calf/power` | ok | +0.63 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-FL-calf/signal` | ok | +1.31 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-FR-hip` | ok | +1.29 mm | ≥ 0 | nearest: inner_shoulder_frame | | through parts | `S-FR-hip/power` | ok | +0.54 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-FR-hip/signal` | ok | +1.07 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-FR-thigh` | ok | +3.58 mm | ≥ 0 | nearest: right_servo_mount_fr | | through parts | `S-FR-thigh/power` | ok | +0.89 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-FR-thigh/signal` | ok | +1.17 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-FR-calf` | ok | +1.04 mm | ≥ 0 | nearest: servo_spt5435lv | | through parts | `S-FR-calf/power` | ok | +0.46 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-FR-calf/signal` | ok | +1.33 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-RL-hip` | ok | +3.34 mm | ≥ 0 | nearest: inner_shoulder_frame | | through parts | `S-RL-hip/power` | ok | +0.79 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-RL-hip/signal` | ok | +1.22 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-RL-thigh` | ok | +1.41 mm | ≥ 0 | nearest: right_servo_mount | | through parts | `S-RL-thigh/power` | ok | +0.41 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-RL-thigh/signal` | ok | +0.63 mm | ≥ 0 | nearest: fuse_holder_ato | | through parts | `S-RL-calf` | ok | +1.65 mm | ≥ 0 | nearest: right_servo_mount | | through parts | `S-RL-calf/power` | ok | +0.66 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-RL-calf/signal` | ok | +1.99 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-RR-hip` | ok | +3.33 mm | ≥ 0 | nearest: inner_shoulder_frame | | through parts | `S-RR-hip/power` | ok | +0.42 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-RR-hip/signal` | ok | +1.25 mm | ≥ 0 | nearest: top_shell_sensor | | through parts | `S-RR-thigh` | ok | +1.68 mm | ≥ 0 | nearest: left_servo_mount | | through parts | `S-RR-thigh/power` | ok | +0.85 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-RR-thigh/signal` | ok | +0.71 mm | ≥ 0 | nearest: fuse_holder_ato | | through parts | `S-RR-calf` | ok | +1.46 mm | ≥ 0 | nearest: left_servo_mount | | through parts | `S-RR-calf/power` | ok | +0.50 mm | ≥ 0 | nearest: wago_221_415 | | through parts | `S-RR-calf/signal` | ok | +1.03 mm | ≥ 0 | nearest: fuse_holder_ato | ## Moving parts | Check | Item | Status | Value | Limit | Note | |---|---|---|---|---|---| | moving parts | `S-FL-hip/power` | ok | 1.20 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-FL-thigh/power` | ok | 1.19 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-FL-calf/power` | ok | 1.20 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-FR-hip/power` | ok | 1.60 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-RL-thigh/power` | ok | 1.18 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-RL-thigh/signal` | ok | 1.79 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-RL-calf/power` | ok | 1.38 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-RL-calf/signal` | ok | 1.79 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-RR-thigh/power` | ok | 1.67 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-RR-thigh/signal` | ok | 1.76 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-RR-calf/power` | ok | 1.34 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | | moving parts | `S-RR-calf/signal` | ok | 1.79 mm | ≥ 1.0 mm | servo_spt5435lv (hips_in) | --- # Verification > **Status:** evidence ledger. Each section records what ran, at which tier, the observed result and its boundary. Tiers: **Q0** static/unit (seconds), **Q1** CAD verification (minutes), **Q2** simulation and integration smokes, **Q3** hardware. Nothing here has been exercised on a physical robot (no Q3 yet). ## 2026-11-01 — print kit after the builder's second review | Check | Command | Tier | Observed result | Boundary | |---|---|---|---|---| | Hole-step detection | `strido_publish.print_bridges.hole_steps` on every kit part as it lies | Q0 | 4 steps found and cut: one bearing seat each in `inner_tibia` and `outer_tibia`, two 625ZZ seats (16.3 → 12.3 mm) in `outer_shoulder_frame` (the parts the builder named); steps under one extrusion line (0.4 mm) and part rims are skipped; after the cut the detector finds none. Section images at the step show wide hole, slot, square, narrow hole. Review sliced the cut parts: `outer_shoulder_frame` prints slot, square and hole at 4.2/4.4/4.6 mm with no support; in both tibias the seat sits under a boss 7.4 mm above the bed and PrusaSlicer still fills the bore with support (the notes now say to block it); cut heights are snapped to the slicing layers | geometric detection; a seat that is not round is not handled; slicer support placement checked by hand on these three parts | | Printability with the new orientation and cuts | `make printability` (PrusaSlicer 2.9.6) | Q1 | 34 parts and 13 plates: 0 fail, 4 warn (thin skins in the upstream servo mounts, 0.38–0.53 mm); kit 958 g, 100 h; servo mounts 19 mm tall like a U, 2 % overhang; `inner_tibia` on its flat side (1638 mm² on the bed, was 71 mm²) | slicer estimate; no part printed yet | | Strength and print-kit tests | `python -m strido_verify.strength`, `pytest tests/verify/test_print_kit.py tests/verify/test_strength.py` | Q1 | 19 checks ok (sections read from the cut STLs); 6 tests passed | — | ## 2026-11-01 — stiff long link and print kit orientation | Check | Command | Tier | Observed result | Boundary | |---|---|---|---|---| | Strength with the stiff long link | `python -m strido_verify.strength` | Q1 | 19 checks, 19 ok, 0 warn, 0 fail; long link buckling S = 2.53 (upstream link 0.34, ADR-010), tension/compression S = 4.34 including the bending from the 2 mm shaft offset (secant formula; review finding, the first run checked F/A only and gave 10.3), eye bearing pressure S = 7.1; lowest factor now the tibia halves (2.22); no accepted deviation left (`accepted: {}`); mass 3.07 kg (+24 g for the four links), landing foot force 45.2 N | walls-only sections, PETG data sheet values | | Gate seen red while designing the link | `python -m strido_verify.rom`, neighbour-distance sweep (throwaway script, 4 thigh × 43 calf poses per leg) | Q1 | a link centred on the pin line came within 0.25 mm of the femur at the stretched knee and touched the short link's washer at full flex; the ROM sweep moved the calf limit from −45° to −50° (seen as a `hardware/joint_limits.yaml` diff). Shaft 2 mm off the pin line with the upstream eyes: every neighbour ≥ 1.0 mm (femur 1.1, short link 1.4), joint limits unchanged | point-sampled distances; the gates below are the evidence | | Full CAD verification | `make verify` | Q1 | interference 0 unexplained overlaps (4 poses, 483 solids); clearance 0 fail, 4 tight (unchanged); ROM limits unchanged (only the collision evidence beyond the limits lists the new link); fits 0 fail (sleeve seats 4.85–4.95 mm in the 5.0 mm bores); wiring 174 checks, 0 fail, 1 warn; geometry bindings in sync | nominal geometry | | Print kit orientation | `make print`, `make printability` (PrusaSlicer 2.9.6) | Q1 | femur, top bracket, femur servo connector, side beam and right shoulder re-oriented; camera base on its base; stiff long link flat, no supports; the servo mounts stay upright (review: on their side the hip-horn pilots would print horizontal, out of reach of the ADR-009 hole compensation). 34 parts and 13 plates: 0 fail, 4 warn (thin skins in the upstream servo mounts); kit 1002 g, 103 h (was 1021 g, 104 h). Triggered by a builder's review: the plates kept the published STL poses (top bracket 56 mm tall on a 19 × 6 mm footprint, femur on 2 mm² of bed contact) | slicer estimate; no part printed yet | | Strength and translation tests | `pytest tests/verify/test_strength.py`, `make drift` | Q0 | strength tests 4 passed (acceptance mechanism tested with an injected ADR entry; the stiff link's walls-only I matches the hollow-square formula within 2 %) | — | ## 2026-10-31 — German site, AI assistant, MCP server and Agent Skill | Check | Command | Tier | Observed result | Boundary | |---|---|---|---|---| | **New:** translation catalogues complete and structurally sound | `node docs-site/scripts/i18n-catalogues.mjs check` (in `make drift`) | Q0 | 5746 of 5746 German segments; 0 broken (code spans, math, link targets, HTML tags, bold markers, table cells, line count) | structure only: wording reviewed by spot checks and a terminology pass, not by a native proofreader | | Gate seen red | same | Q0 | failed with 5431/5433 while two new paragraphs were untranslated, and during translation on bold-marker mismatches; a number re-grouped by the translator (`1126` → `1.126`) now makes the segment untranslated instead of keeping a stale figure (test fails on the earlier rule) | — | | Translation engine tests | `node --test 'docs-site/scripts/*.test.mjs'` (in `make test-web`) | Q0 | 7 passed: every untranslated page renders back to its English source, German headings keep the English ids (repeats numbered like VitePress), numbers carry over in German notation, merge keeps/opens/drops segments, structure check | synthetic segments plus every published page | | German site build | `make docs-build`, built HTML compared | Q0 | builds with the dead-link check; 60 pages × heading ids identical in `/docs/**` and `/de/docs/**` (0 mismatches); every German page 100 % translated | — | | German site in the browser | managed Chrome, `/de/`, `/de/docs/shop` | Q2 | German navigation, home, shop (plan A € 978,76), sidebar, language switch | visual spot check | | **New:** assistant service tests | `cd assistant && npm test` (in `make test-web`) | Q0 | 34 passed: owner scoping, header-secret and `cxu_` token checks (refusals cached), SSE event order, stop stores `stopped`, regenerate without a duplicate question, last tool round with `tool_choice: none`, attachment magic bytes and limits, per-user quota, body limits, MCP initialize/list/call | fake model transport, fixture knowledge | | Assistant against the real model | `make assistant` + `make docs`, managed Chrome | Q2 | Keyring `claude-sonnet-5`: "front knee loop" answered 32 mm = 1.2 × 25.6 mm with KaTeX and four source links; pinned sidebar persists across pages; a pasted 2400 × 1600 image of Step 1 identified in German on `/de/docs/assembly/` with a link to Step 1; Stop then Retry stored one question and one answer; conversations listed, renamed, deleted over the API | local development identity; Keyring credential state on the stack not exercised | | MCP with a real client | `@modelcontextprotocol/sdk` Streamable HTTP client against `/mcp` | Q2 | tools `search_docs`, `read_page`, `list_pages`, `search_parts`; German search for "loop key arming" returns the glossary entry with its `/de/` link; `read_page` returns the wiring-list section | local, no app token (dev identity) | | Production images and identity | `docker compose -f docker-compose.docs.yml -f docker-compose.cortex.yml` (both targets) | Q2 | read-only containers start; assistant without the header secret 401, with it serves `/me` (disabled without Keyring) and writes to its volume; a fake `cxu_` token 401; docs 200 on `/de/docs/ai` | Cortex gateway route, Keyring and app key exercised only after deploy | | Deployed 0.3.1 (two services, own gateway route) | `cortex deploy` (job `mvwtwfpf-530e539a`), `cortex apps show strido`, `curl` | Q2 | healthy on `c80f807` ("SSO route HTTP 302, upstream HTTP 200; 2 live containers"); `/api/health` open (61 en / 61 de pages, model configured); `/`, `/de/`, `/llms.txt`, `/api/assistant/me`, `/mcp` without a session redirect to the SSO login; a fake `cxu_` bearer reaches the assistant and gets 401 from Cortex's token check | signed-in browser pages not re-checked (the relay tab's session had expired) | | MCP on the deployed site with a real app token | token minted with `cortex api post /api/v1/apps/strido/tokens` (1 day), SDK client, then revoked | Q2 | `tools/list` 4 tools; German `search_docs` "Hauptsicherung Bemessung" answers from the German glossary; `search_parts` "servo" returns the BOM line; `/api/agent/llms.txt` 200, skill ZIP 615 655 bytes; the token gets 403 on `/api/assistant/*`. `cortex apps connect strido` answers 404: Control records `.cortex/package.json` only for Cortex-created apps (TODO) | one token, one client | ## 2026-10-31 — wiring harness routed and checked in the CAD | Check | Command | Tier | Observed result | Boundary | |---|---|---|---|---| | **New:** wiring gate | `python -m strido_verify.wiring` (in `make verify`) | Q1 | 37 cables in 71 runs, 174 checks: 0 fail, 1 warn (W23, the LiDAR's own cable: length not published). Reach (cut branches counted with their slack and crimp): tightest the front knee leads (243/241 of 250 mm usable from 260 mm), W19 STEMMA QT (125 of 140 mm). Stock: 18 AWG fits only with the BOM raised to 2 m per colour. Hip loops: front 30/32 mm vs. 28.9/30.7 mm needed (1.2 × tie-to-tie 24.1/25.6 mm), rear 22 mm vs. 19.4 mm. Clearance in 13 poses: no conductor through a part; nearest moving part ≥ 1 mm | nominal geometry; bend radii and loop shape not modelled; lead exits and supplied lead lengths partly assumed (TODO) | | Gate seen red during design | same | Q1 | first runs failed: W00/W15/W17/W19/W20 and three front servo leads too short, 18 AWG and STEMMA QT stock short, the front bus jumper 0.3 mm into the FL calf servo at the inward hip limit, the pack lead's mated plug inside the front UBEC, a conductor drawn outside its routed envelope (the gate now checks every drawn conductor). After the review's reach fix the front knee leads (258/259 mm) and W22 (196 mm) failed: knee crossing moved to the bus holder and the front opening tie onto the cables' natural path, W22 now cut 22 AWG wire. Each fixed in the data or the CAD, not by a looser limit | — | | Review findings fixed | reviewer subagent on the change, then `make verify` | Q1 | reach ignored branch slack and counted the PCA end as a plug (both fixed; regression test fails on the old rule); tie mounts were not in the obstacle grid (now catalogue parts); W10/W11 were drawn as pairs onto a two-terminal INA260 (now single wires + W24 ground); W01 unwrapped although unfused (spiral wrap per cable); W04 trunk missing from the main fuse's list; manual dressed the harness before the rear-panel wiring existed and plugged the Pi before a supply check (Step 20 now does both, in that order) | — | | Wiring gate unit tests | `pytest tests/verify/test_wiring.py tests/verify/test_electronics.py` | Q0 | 17 passed: reach margin, a cut branch needing its slack, longest servo-lead branch, a port used twice and a pair taking both rails, a loop 1 mm shorter than needed, a 22 AWG servo branch behind the 25 A fuse, a branch held to its own fuse | synthetic rows plus the tracked YAML | | Full CAD verification with the cables and the re-placed power parts | `make verify` | Q1 | pass: interference 0 unexplained overlaps (first run caught the rotated LiDAR adapter in a pedestal screw column, fixed), clearance unchanged (0 fail, 4 tight), sensors: cables block no LiDAR ray and stay out of the camera view, wiring 173 ok / 1 warn | cables excluded from the part-to-part gates (they have their own); tie mounts are checked | | Electronics gate on the routed cables | `python -m strido_verify.electronics` | Q0 | 81 checks pass (3 without margin); the old 18 AWG tap behind the 25 A fuse would have failed and is gone (logic holder's own 14 AWG lead) | ampacity table, not thermal model | | Published artefacts | `make bom models manual renders viewer catalog` | Q1 | plans A €978.76 / B €1,126.97 / C €1,145.24 / D €987.73 (servo extensions out, spiral wrap, tie mounts, crimp contacts, solder, 2nd STEMMA QT and 2nd 18 AWG set in); CAD mass 3.008 → 3.050 kg (cables weighed per instance, lump removed); manual 22 steps (wiring Steps 14–16 and 20), new `wiring.png` render, GLB with the harness | prices observed 2026-10-31 | ## 2026-10-31 — documentation site: shop, print kit, product pages, Learn wiki | Check | Command | Tier | Observed result | Boundary | |---|---|---|---|---| | Shop pricing reproduces the BOM optimiser | managed Chrome on the built site: complete robot in the cart, plans A–D | Q2 | cart totals €963.67 / €1,107.16 / €1,128.66 / €972.64 with 8 / 4 / 9 / 8 shops, equal to `make bom`; kit card shows €963.67 incl. shipping (€924.14 parts + €39.53); reviewer recomputed the same totals from `shop.json` | offers observed 2026-10-30; nothing ordered | | Print package download | print kit page, complete robot, ZIP captured from the page | Q2 | `strido-print-package-1-robot.zip`, 49 files (README, profile, 34 STLs named with quantity, 13 plates), `unzip -t` clean; parts sheet 67 pieces, 719 g graphite, 301 g lime, 104 h 16 min | ZIP built in the browser, stored (uncompressed) | | Product information complete | `make bom`, `pytest tests/workspace/test_products.py` | Q0 | 59 BOM lines and 34 printed parts covered, every Learn link resolves; the test also proves a removed part and a dead wiki link are reported | wording reviewed, datasheet values partly from memory (flagged in the review) | | Site build and links | `make docs-build` | Q0 | builds; dead-link check still fails on a missing public file (`/viewer/missing.glb`) and on a directory link (`/viewer/`), and accepts existing STL/GLB/INI/log files | only internal links are checked | | Site audit | managed Chrome, all sidebar pages at 1440×900 and 390×844, light and dark | Q2 | 0 broken images, 0 failing internal links, 0 console errors, no horizontal overflow on phones; 35 findings (3 broken, 18 confusing) fixed, re-checked on the rebuilt site | visual review, no automated accessibility suite | | Thread-locker fallback corrected | `python -m strido_verify.fits` | Q1 | 223 screws ok 86 / warn 137 / fail 0, seats 32 / fail 0 (unchanged); weak-pilot and ADR-009 actions now name a CA-hardened pilot | wording change only | | **New:** strength of legs and joints | `python -m strido_verify.strength` | Q1 | 19 checks: 18 ok, 1 warn, 0 fail. Long link buckling in the leg plane S = 0.34 at the worst pose with the knee servo at stall (known weak point, ADR-010, bounded by `min_safety` 0.3); femur bending S = 3.2, tibia S = 2.2, hip horn hole pressure S = 2.6; pilot torque limits 0.05–0.35 N·m; reviewer recomputed femur bending, buckling and a pilot torque by hand and found the link axes swapped in the first version (fixed: pin axis from the bores); section math unit-tested against b·h³/12 (mutation that keeps hole orientation fails: area 104 instead of 56 mm²) | static, walls-only sections, servo stall torque as design load (back-driving takes more), assumed 3 × weight landing, K = 0.7 across the plane; no fatigue/creep; not a physical test | | Datasheet check of disagreements and product specs | research agent against manufacturer sources (Hobbywing, Gens ace, Raspberry Pi, Seeed, NXP, ST, TI, Slamtec, SKF, Koyo, Prusament, Bossard) | static | UBEC 15 A peak confirmed (20 A was the aircraft model); LiPo 60C; all listed product specs ok; filament offers were PLA (now PETG); cooler "JST" unverified (removed) | bearing ratings of branded parts used as upper bounds for unbranded ones | | Amazon one-click cart | signed-in Chrome, `gp/aws/cart/add.html` with one ASIN (no click on the page) | Q2 | redirects via `associates/addtocart` to an empty cart: the form needs an Associates tag; replaced by the guided checkout (window.open into one tab per shop, checklist persisted) | Amazon policy can change | | Plans after the PETG offers | `make bom` | Q0 | A €965.27 (8 shops), B €1,107.41 (4), C €1,130.26 (9), D €974.24 (8) | prices observed 2026-10-30/31 | | LaTeX rendering of the wiki and strength report | `make docs-build`, built HTML scanned, managed Chrome at 1440 px and 390 px | Q0/Q2 | 0 MathJax error nodes, 0 unrendered `$…$` pairs in the built pages (15 one-line `
` answers had to be split to render); theory pages 73–121 equations each; no horizontal page overflow at 390 px (display equations scroll inside the column) | visual spot checks of 4 pages, not every equation | ## 2026-10-31 — v0.2.0 rework (harness, packages, new gates, fixes) | Check | Command | Tier | Observed result | Boundary | |---|---|---|---|---| | Lint, strict types (core, NT-X, workspace), import layers | `make lint typecheck layers` | Q0 | ruff clean; mypy: no issues in 21 files; 3 import-linter contracts kept | CAD/verify/publish packages are linted, not strictly typed | | Generated sources in sync | `make drift` | Q0 | geometry bindings, electronics report and NT-X assets up to date; seen red when `geometry.py` was reformatted and when limits changed | text assets compared byte for byte; NT-X meshes only for presence | | Python tests with coverage gate (80 %) | `make test` | Q0 | 73 passed, coverage 83.7 % | modules that run in NT-X / over stdio are excluded from coverage and exercised by the smokes | | Electronics: firmware header vs `hardware/electronics.yaml` in both directions, unique GPIO/I²C, servo channels, loads ≤ fuses ≤ wire ampacity | `python -m strido_verify.electronics` | Q0 | 44/44 checks pass; tests prove a swapped pin, a header pin missing from the YAML, duplicate channel, address clash, undersized wire and UBEC overload all fail | currents are datasheet/[ESTIMATE] values, not measured | | Firmware core (kinematics with new limits, protocol, safety) | `make test-firmware` | Q0 | 15/15 passed | native host build, not the ESP32 | | Interference, 479 solids, 4 poses | `make verify` | Q1 | 0 unexplained overlaps (292 classified overlaps at stand) | mesh geometry, nominal sizes | | **New:** fits and tolerances of every screw (223) and bearing/bushing seat (32) | `python -m strido_verify.fits` | Q1 | 0 fail, 137 with a builder action (drill a tight hole on a nut joint, CA-hardened pilot / hole compensation on a weak pilot), 86 ok; 29 of the warnings are the accepted upstream servo-mount pilots (ADR-009: fail without the acceptance). Seen red with 168 → 0 failures while fixing the model and three real defects (below); engagement counts only material the screw cuts; a bearing without a seat fails | CAD sizes with a 0.15 mm FDM shrink model; real prints vary by printer | | **New:** moving-part clearance, 13 operating poses | `python -m strido_verify.clearance` | Q1 | 0 fail (< 0.3 mm), 4 tight (0.73–0.79 mm at the hip-out limit), 80 rotational interfaces, 72 joint interfaces (only across real joints/linkage pins); seen red: both front feet collided in `hips_in` (50 fails) before the limit fix; restricting the exemption exposed the RR shoulder / back shell pair (0.73 mm, previously exempt because it touched at the CAD pose) | nominal geometry; servo backlash not modelled | | Range of motion with mirrored partner leg and 0.3 mm clearance | `python -m strido_verify.rom` | Q1 | hip limits now FL/RL −17.5…50/45°, FR/RR −50/−45…17.5° (inward travel reduced from 25–30° so the feet cannot meet) | grid 5°/2.5° | | Sensor field of view | `python -m strido_verify.sensor_fov` | Q1 | 0 of 720 LiDAR rays blocked, camera sees 0 % robot, 5 poses | — | | **New:** print kit complete and printable | `pytest tests/verify/test_print_kit.py` | Q1 | 34 part types / 67 pieces, every STL a closed manifold within 220 × 220 × 250 mm, every piece on exactly one 3MF plate; seen red: the camera base and tilt plate were missing from the plates (trimesh drops 3MF nodes named `camera*`) | slicer settings not simulated | | BOM covers every CAD part and fastener | `make bom` | Q1 | all strategies covered; lowest total €963.67 (8 shops) | prices observed 2026-10-30 | | MuJoCo gait walk | `make sim-walk` | Q2 | 0.15 m/s for 10 s → 1.104 m, height 0.174 m, yaw −1.7° | simulator heading hold | | Gazebo Harmonic headless walk, gait node (ROS 2 Jazzy, Docker, arm64) | `make sim-gazebo` | Q2 | 8 s at 0.15 m/s → 0.98 m forward, lateral −0.003 m, height 0.178 m, roll/pitch < 0.01 rad; joint_states, IMU, scan (min 0.49 m), camera and odom all publishing | simulator only; same container as the robot image minus the Pi camera stack | | Released PPO policy with the new limits | `make rl-eval` | Q2 | 0.2 m/s command → 0.215 m/s mean, 2.15 m in 10 s, upright | MuJoCo only | | PPO pipeline | `make rl-smoke` | Q2 | 20 k steps in 6 s, policy exported and evaluated (not trained to walk) | pipeline check only | | **New:** hardware MCP server over stdio | `make mcp-smoke` | Q2 | 10 tools listed, 9/9 calls ok | `measure_gap` covered by the CAD tests, not the smoke | | **New:** NT-X integration inside the NT-X harness environment | `make ntx-smoke` | Q2 | catalog robot (12 joints, 3.008 kg) and skill load; stands 2 s in NT-X MuJoCo (base 0.178 m, tilt 0.0°); adapter `strido-serial` registered; RobotPort ↔ protocol-level fake MCU: the real `pd-pose` runtime output (gains + gravity feed-forward up to 0.06 N·m) → position target → state with the echoed command sequence, IMU loss raises and latches the protective stop, protective stop latches, close frees the device; seen red when the model path was wrong | fake MCU speaks the real protocol but is not the firmware; hardware sessions also need a base estimate (NT-X odometry gate) | | **New:** CAD regression tests for the fixed fit defects | `make test-cad` | Q1 | 3 passed (no screw fit fails; all 4 hip-pivot screws thread into a servo mount incl. the `_fl`/`_fr` variants; LiDAR pedestal screws engage the pedestal); seen red with the old 6 mm pedestal pilot depth (2 failed: `solid`/`fail`) | same CAD-size model as the fits gate | | **New:** docs site and 3D viewer | `make docs-build`, preview in Chrome | Q2 | site builds; viewer loads the GLB, explode / step / shells / fasteners / picking work (picked part shows name, id, count, step); parts catalogue shows 76 part types; seen broken: every unique mesh was also exported at the GLB origin in millimetres (a 100 m "wedge" around the robot) until the geometry was stored without its own node | desktop Chrome only; no mobile/Safari check | | Harness gates (agent config drift, env docs, arch scan, secrets, npm audit) | `make harness-check` | Q0 | pass; gitleaks no leaks; npm audit 0 vulnerabilities; three v0.1 CAD definition modules baselined in the shrink-only arch-scan ledger | Semgrep/Trivy skipped (heavy security off) | | **New:** printability: every part sliced by a real slicer | `make printability` (PrusaSlicer 2.9.6, `hardware/print/prusaslicer.ini`) | Q1 | 34 part types and 13 plates: 0 fail. Each part sliced as prescribed (supports/brim) with no error message and complete G-code; supported parts also sliced without supports and show only issues supports fix; every plate prints all its parts without tool-path conflicts. Kit 1021 g PETG (719 g graphite, 301 g lime: one 1 kg spool each), 104 h. Wall-thickness scan 0 fail, 5 warn (upstream femur and servo mounts: walls 0.65–0.8 mm, printable but thin). Seen red: electronics deck without supports (floating bridge anchors under the IMU pocket); tibias/short links/hip pins in published orientation (low bed adhesion, found once supported parts got the extra unsupported slice); plates with 4 mm part gaps (tool-path conflicts); deck with supports removed from the print list (unit test of the parser and a gate run) | slicer simulation, not a test print; thin regions shorter than 5 mm and narrower than 1 mm count as cusps | | **New:** electronics voltage domains | `python -m strido_verify.electronics` | Q0 | 16 supply/signal checks pass: no rail can exceed a rated maximum, every nominal level is in range; 3 pass without margin because the rail minimum reaches below the rating (servo PWM and LCD at 3.3 V, the lowest level they accept); seen red: servo rail at the UBEC's 7.4 V setting and a 6.3 V UBEC (tests) | ratings from vendor pages/datasheets; rail tolerances partly [ESTIMATE]; no custom PCB exists, so no ERC/DRC/SPICE applies | | **New:** documentation site on Cortex | `docker compose -f docker-compose.docs.yml up --build docs`; `cortex deploy --ref b0fe000` (job `mvvsdmmc-108830e0`) | Q2 | local container: /healthz ok, /docs/downloads 200, build kit 17.5 MB `application/zip` (57 files, `unzip -t` clean), PDF 13.9 MB; deployed 0.2.1: `cortex apps show strido` healthy ("SSO route HTTP 302, upstream HTTP 200"); unauthenticated requests to /, /docs/downloads, the ZIP, the PDF and /healthz all redirect (302) to the Cortex SSO login; signed in (Chrome): home page renders, ZIP/PDF/GLB downloads answer 200 with the right type | one browser session; no load test | ### Defects found and fixed (gates and review) | Defect | Found by | Fix | |---|---|---| | LiDAR pedestal pilots 9 mm deep for screws that protrude 10 mm: the screws bottom out before clamping the shell | fits (`solid` at the tip) | pilot depth 11 mm (`PEDESTAL_PILOT_DEPTH`) | | Front servo mounts (same STL as the rear) have no hip-pivot pilot on the face the front legs use: 5.5 mm of solid plastic | fits (`solid`) | Strido variants `left_servo_mount_fl`, `right_servo_mount_fr` with a Ø2.5 mm pilot (ADR-004) | | Right-leg femur horn screws 3.7 mm off the printed femur's holes (STEP mirrors the right femurs; one STL prints all four) | fits (`solid`) | `hardware/assembly/corrections.yaml`: disc horn −30°, femur connector +22° on FR/RR | | Left-leg thigh horn centre screws pointed away from the servo (head placed in the spline cavity) | fits (`nothing holds the screw`) | head pocket = longest end-opening bore in `strido_cad.fasteners` | | Both front (and both rear) feet could meet under the body at the inward hip limits | clearance (`hips_in`) | ROM sweep with mirrored partner leg and 0.3 mm clearance (ADR-005) | | Camera base and tilt plate missing from the 3MF plates of v0.1.0 | print-kit test | plate node names prefixed `part_` | | Right-leg hip disc horns moved by the femur correction (wrong body) | ROM (RR outward limit collapsed to 20°) | corrections apply per rigid body | | The NT-X serial port rejected every command of the shipped `pd-pose` skill (gravity feed-forward is never zero) and reported a level, still robot when the IMU failed | review; `make ntx-smoke` now runs the real runtime output | feed-forward dropped, `kp <= 0` rejected, IMU loss fails closed, stale frames drained | | CAD cache served meshes of an older source after a revert (manifest per digest, meshes at fixed paths) | `make check` failed after a deliberate gate break was reverted | one manifest, valid only for the digest of the last build; seen red/green by breaking and restoring `PEDESTAL_PILOT_DEPTH` | | Clearance exemption covered any pair touching at the CAD pose, not only joints | review | exemption limited to bodies joined by a joint or linkage pin; exposed one tight pair (0.73 mm) | | Electronics deck printed without supports: the IMU pocket floor is an 18 mm bridge with floating anchors | printability (slicer) | deck declared `supports=True` | | Tibias (135 mm upright), short links and hip bearing pins have too little bed contact in the published orientation | printability (unsupported analysis slice) | re-oriented flat in the print kit; tibias 1.5 h instead of 2.5 h | | 3MF plates packed with 4 mm gaps: neighbouring supported parts produced conflicting tool paths | printability (slicer) | 10 mm gaps, snug supports, first-fit packing (13 plates) | | XT60 window corners left 0.26 mm slivers in the bezel boss; FDM-rounded inner corners would bind the square XT60 hood | thickness scan, review | boss reduced to an upper and lower arc; 0.5 mm dog-bone corner reliefs in bezel and clamp ring | | A 6.2 mm screwdriver bore in the 6 mm bus-holder divider left 0.1 mm slivers | thickness scan | access slot instead of the bore | | UBEC acceptance window (6.0 ± 0.1 V) allowed 6.1 V on a servo rated to 6.0 V | voltage-domain check | accept 5.8–6.0 V unloaded | ## 2026-10-30 — v0.1.0 Excerpts of the command output are linked per row (`docs/verification/logs/`); deliberate gate breaks are in [gates.txt](verification/logs/gates.txt). Commands refer to the v0.1.0 layout (`tools/…`). | Check | Command | Tier | Result | Log | |---|---|---|---|---| | Fasteners derived from the measured hole stacks; placed fasteners must equal the rule totals | `make verify` (`hole_stacks.py`, `fasteners.py`) | static | all stacks matched; placements = rule counts (gate proven red by un-merging the split tibia-pin stacks) | [verify.txt](verification/logs/verify.txt) | | Interference of 479 solids (printed, purchased, fasteners) at stand 170 mm, crouch 120 mm, tall 210 mm and the CAD pose | `interference_check.py` | static | **0 unexplained overlaps** in all 4 poses; every remaining overlap is classified (thread engagement, seating contact, joint, upstream fit, upstream fastener on its measured hole axis) – [interference.md](verification/interference.md) | verify.txt | | LiDAR occlusion (720 rays) and camera self-view, 5 poses incl. trot and full hip abduction | `sensor_fov.py` | static | 0 rays blocked; robot in 0.0 % of the camera image – [sensors.md](verification/sensors.md) | verify.txt | | Range of motion: thigh × calf grid (5°) at zero abduction, transmission ≥ 25°; hip range at stand and mid pose | `rom_sweep.py` | static | limits as committed in `hardware/joint_limits.yaml` (no diff after the run) | verify.txt | | Generated geometry in firmware/Python matches the YAML | `codegen.py --check` | static | no drift | verify.txt, [test.txt](verification/logs/test.txt) | | Printability: closed manifold, fits 220 × 220 × 250 mm, on the bed | `make print` | static | 32 part types / 67 pieces pass | [docs.txt](verification/logs/docs.txt) | | BOM supplies every purchased part and fastener of the CAD for the offers each strategy buys (packs rounded up, fasteners + max(2, 10 %) spares) | `make bom` | static | all covered in all 4 strategies; gate proven red by removing the M3 × 10 supply | docs.txt | | Python core: protocol (CRC vector shared with the firmware, COBS, corruption, resync), IK/FK, knee linkage inverse, gait limits incl. the knee-crank clamp, CLI calibration and the CLI's refusal when the controller keeps the servos relaxed (fake controller on a pseudo-terminal) | `pytest` | unit | 27 passed; the crank-limit test fails when the clamp is removed | test.txt, gates.txt | | Firmware core: kinematics, protocol, watchdog, e-stop, debounced battery cut-off, no driving without the power monitor (bench calibration allowed), NaN rejection | `pio test -e native` | unit | 15/15 passed; the NaN test fails with the old clamp | test.txt, gates.txt | | Firmware build for the XIAO ESP32-S3 | `pio run -e xiao_esp32s3` | build | success, RAM 8.0 %, flash 16.0 % | [firmware.txt](verification/logs/firmware.txt) | | `mcu_bridge` (ROS 2 Jazzy container) against a fake controller on a raw pseudo-terminal | throwaway script | integration | STATE → `/joint_states` (Go2 names) and `/battery_state` (7.7 V, −1.2 A); command → JOINT_COMMAND; NaN command dropped; relax → JOINT_COMMAND(relax); e-stop → ESTOP; no frame dropped | [mcu_bridge_fake.txt](verification/logs/mcu_bridge_fake.txt) | | MuJoCo, production gait (heading hold uses the simulator's yaw) | `make sim-walk` | simulation | 0.15 m/s: 1.10 m in 10 s, height 0.174 m, yaw −1.7°; turn 0.6 rad/s: 197.9° in 6 s (−162.1° wrapped) | [mujoco_walk.txt](verification/logs/mujoco_walk.txt) | | Gazebo Harmonic headless walk, gait node (ROS 2 Jazzy, Docker, arm64) | `make sim-gazebo` | simulation | 1.00 m in 8 s at 0.15 m/s, lateral 0.12 m, roll/pitch < 0.1 rad, all sensor topics publishing | [gazebo.txt](verification/logs/gazebo.txt) | | PPO policy, 20 M steps (1734 s, CPU), firmware slew limit modelled | `make rl-train`, `evaluate.py` | simulation | MuJoCo, 10 s per command: vx 0.2 → 0.210 m/s (yaw 2°), 0.15 → 0.152 m/s, 0 → stands, −0.1 → −0.068 m/s, vy 0.1 → 0.023 m/s (weak), wz 0.5 → 232° in 10 s (−128.4° wrapped) | [rl_eval.txt](verification/logs/rl_eval.txt) | | Same policy in Gazebo through `policy_node` (sim-to-sim) | `make sim-gazebo POLICY=build/rl/ppo-v1/policy.npz` | simulation | 0.82 m in 8 s at 0.15 m/s, lateral 0.10 m, upright | gazebo.txt | | Vendor CAD fetch | `make vendor-cad` | — | 8 of 9 downloads verified by SHA-256; the Littelfuse file is form-gated (manual download, envelope fallback) | gates.txt | ## Not verified * Nothing has been printed, assembled, wired or powered. Fits are checked against CAD and datasheets, not against printed parts (expect to tune hole and bearing fits to your printer). * Servo current, runtime and thermal behaviour are estimates ([electronics](electronics.md)). The OE pull-up value assumes the PCA9685 board's pull-down is ≥ 10 kΩ (measure OE during reset as described). * Datasheet envelopes (no vendor CAD): servos (upstream STEP model), WAGO 221-415, LiPo, UBEC, pushbutton, LiDAR USB adapter (size unverified). * Upstream joint fasteners are placed from hole stacks with an approximate head side and nut orientation; they are part of the static interference check but not of the range-of-motion sweep. * The knee is a revolute joint in Gazebo/MuJoCo; linkage compliance and backlash are not simulated. The learned policy has not seen real servo dynamics. * The camera node on the Pi 5 needs the Raspberry Pi libcamera fork (not in the Docker image); `robot.launch.py` was not run on a Pi. --- # 3D viewer The verified CAD assembly, standing at 170 mm (meshes simplified for the web). Drag to orbit, scroll to zoom, click a part to identify it. * **Explode** pulls every part away from the body centre. * **Step** replays the [assembly manual](assembly/README.md): parts appear in the order you fit them and the current step glows orange. * **Shells / fasteners** hide the shells to look inside, or the screws to see the parts. * Click any part to identify it: name, part id, how many the robot has, and the step that fits it. [Download the model (GLB)](viewer/strido.glb): it opens in any glTF viewer, Blender or the NT-X Studio's asset view. --- # Glossary Terms used on the Strido site and in this wiki, A–Z. Each entry says what the term means and, where it matters, how it applies to this robot; follow the link for the wiki page that explains it in depth. ## A ### Abduction Sideways swing of a leg away from or towards the body's centre line. On Strido it is the hip joint, driven by the servo in the body; see [kinematics and gaits](software/kinematics-and-gaits.md). ### Accepted deviation A hole that misses a verification band but is deliberately kept, recorded in `accepted_deviations` of `hardware/verification/tolerances.yaml` with an ADR and a builder action. The only one is ADR-009, the servo-mount M3 pilots; see [fasteners](making/fasteners.md). ### ADR (architecture decision record) A short record of a design decision: context, options, decision, consequences and when to reconsider. Strido keeps them in [decisions.md](../decisions.md). ### Arachne PrusaSlicer's variable-width perimeter generator, enabled in the kit profile, which fills thin walls instead of leaving gaps; see [3D printing](making/3d-printing.md). ### Arming port The XT60H jack in the back shell (`arming_port_bezel`): W02 from the main fuse on one contact, W03 to the UBECs on the other. It connects the servo power branch only when the loop key is inserted; see [safety](making/safety.md). ### AWG (American Wire Gauge) Wire size scale where a smaller number means a thicker wire. Strido uses 14 AWG for the battery trunk, 18 AWG for UBEC outputs, bus jumpers and the logic branch, 22 AWG for the servo leads, signals and the Pi 5 V feed; see [power](hardware/power.md). ## B ### Balance charger A LiPo charger that monitors and equalises each cell through the pack's balance lead. The BOM includes a B6AC-class charger; see [batteries](hardware/batteries.md). ### Bed adhesion How firmly the first layer sticks to the print bed. Too little and the part detaches; with PETG on smooth PEI, too much can damage the sheet; see [3D printing](making/3d-printing.md). ### Bearing seat A printed bore into which a ball bearing or sleeve is pressed. The fit gate accepts a seat from 0.15 mm under to 0.30 mm over the bearing's outer diameter; see [mechanics](hardware/mechanics.md). ### BEC / UBEC (Universal) battery eliminator circuit: a switching regulator from RC models that turns pack voltage into servo voltage. Strido uses two Hobbywing UBEC-10A at 6.0 V, each feeding two legs; see [power](hardware/power.md). ### BOM (bill of materials) The list of everything to buy, with quantities and offers. Source: `hardware/bom.yaml`; published as the [BOM page](../bom/README.md). ### Brim Extra lines around a part's first layer that increase bed contact. In the kit: 5 mm brim on parts taller than 40 mm; see [3D printing](making/3d-printing.md). ### Buck regulator (step-down) A switching converter that lowers a voltage efficiently. The Pololu D36V50F5 makes the 5 V logic rail from the 2S pack; see [power](hardware/power.md). ### Build kit The downloadable ZIP with everything needed to print and build a robot; see [downloads](../downloads.md). ## C ### C rating A LiPo's maximum discharge current as a multiple of its capacity, $I = C \cdot Q / 1\,\mathrm{h}$ (e.g. $60 \times 5\,\mathrm{A} = 300\,\mathrm{A}$ continuous for the 5 Ah Strido pack, 100C burst). See [batteries](hardware/batteries.md). ### CadQuery A Python library for parametric CAD. All new and modified Strido parts are CadQuery booleans on the upstream geometry; see [CAD as code](software/cad-as-code.md). ### Catalogue The browsable list of all 76 part types (printed, purchased and fasteners) with images, sizes and assembly steps; see the [catalogue](../catalog.md). ### Clearance hole A hole a screw passes through freely. ISO 273 sizes for M3 are 3.2 mm (fine) and 3.4 mm (medium); printed holes are chased with these drills; see [fasteners](making/fasteners.md). ### COBS Consistent Overhead Byte Stuffing: an encoding that removes zero bytes from a message so `0x00` can mark the end of each frame. Used on the USB link between the Pi and the XIAO; see [firmware](software/firmware.md). ### CRC16 A 16-bit checksum appended to every protocol frame (CRC16-CCITT-FALSE) so corrupted frames are rejected; see [firmware](software/firmware.md). ### CSI Camera Serial Interface: the ribbon-cable camera connector on the Raspberry Pi 5, used by the Camera Module 3 Wide via a 22→15-pin cable; see [sensors](hardware/sensors.md). ### Cut list The table in the [wiring check](../verification/wiring.md) that gives every wire's routed length and the length to cut: routed length + a termination allowance per end (WAGO strip 11 mm, solder 6 mm, crimp 3 mm, screw terminal 7 mm) + slack of 8 %, at least 10 mm. Builders cut every wire from it; see [cable management](making/cable-management.md). ## D ### Docker A tool that runs software in containers with all its dependencies. The ROS 2 Jazzy image for the Pi and for Gazebo is built from `docker/compose.yaml`; see [Docker](software/docker.md). ### Domain randomisation Varying simulation parameters (friction, servo stiffness, mass, IMU noise) during training so a learned policy copes with the real robot; see [simulation and RL](software/simulation-and-rl.md). ### Dupont connector A 2.54 mm pitch crimp contact and housing that plugs onto header pins and holds by friction. Used for the Pi 5 V feed (W12), W17/W20 at the PCA9685 and the twelve servo signal wires (re-crimped into 1-pin housings); see [tools and workshop](making/tools-and-workshop.md). ## E ### E-stop (emergency stop) A latched stop. On Strido, `/strido/estop` makes the firmware pull PCA9685 OE high and stop all pulses; the physical servo kill is the loop key. See [safety](making/safety.md). ### Elephant foot The flared bottom edge of a print caused by the squashed first layer; it tightens bottom-facing holes; see [3D printing](making/3d-printing.md). ### Engagement length The length of a screw that actually forms or engages thread. The fit gate wants at least 1.5 d in printed pilots (4.5 mm for M3); see [fasteners](making/fasteners.md). ### ESD (electrostatic discharge) A static spark that can damage chips. Handle the Pi, XIAO and sensor boards by their edges after touching grounded metal; see [safety](making/safety.md). ### ESP32-S3 Espressif's dual-core microcontroller with USB, used on the Seeed XIAO ESP32-S3 motion controller; see [microcontrollers](hardware/microcontrollers.md). ## F ### FDM (fused deposition modelling) 3D printing by extruding molten plastic line by line and layer by layer; see [3D printing](making/3d-printing.md). ### Femur The thigh link of a leg, between the leg servo module and the knee; four are printed (`femur`). ### FFC (flat flexible cable) The flat ribbon cable that connects the camera to the Pi's CSI port (300 mm in Strido). ### Firmware The program running on the microcontroller. Strido's firmware (`firmware/strido_esp32s3`, PlatformIO) runs the 200 Hz control loop and the safety logic; see [firmware](software/firmware.md). ### Fit gate The verification step `python -m strido_verify.fits` that measures every screw hole and bearing seat in the CAD against the bands in `tolerances.yaml`; see [fasteners](making/fasteners.md). ### Four-bar linkage Four links joined in a loop by four pivots. Strido's knee uses a parallelogram and a four-bar to drive the tibia from the knee crank; see [mechanics](hardware/mechanics.md). ### Fuse A wire link that melts above its rated current and so protects the wire after it. Strido uses a 25 A main and a 7.5 A logic ATO blade fuse; see [safety](making/safety.md). ## G ### Gait The pattern in which legs move. Strido's default is a trot (diagonal leg pairs together) in `packages/strido-core`; see [kinematics and gaits](software/kinematics-and-gaits.md). ### Gate An executable check that must pass before a change is accepted (interference, ROM, fits, clearance, printability, electronics, wiring, BOM). See the [verification](../verification.md) page. ### Gazebo Harmonic A robot simulator that runs the ROS 2 stack unchanged against a simulated Strido; see [simulation and RL](software/simulation-and-rl.md). ### Go2 convention Joint names, signs, frames and topics copied from the Unitree Go2's ROS 2 interface, so code written for a Go2 can drive Strido; see [ROS 2](software/ros2.md). ### Ground star The single point where all grounds join. On Strido it is a heat-shrunk splice beside the battery input, from which W04 runs to both UBEC inputs (14 AWG) and the switch ground (18 AWG); one point keeps the servo return currents out of the logic grounds; see [power](hardware/power.md). ### Gyroid A wavy, self-supporting infill pattern that is strong in all directions; the kit uses 30 % gyroid; see [3D printing](making/3d-printing.md). ## H ### Heat shrink Tubing that shrinks when heated to insulate and strain-relieve a soldered joint; see [tools and workshop](making/tools-and-workshop.md). ### High-side shunt A current-measuring resistor placed in the positive supply line. The INA260 measures the logic branch this way (VIN+ in, VIN− out); see [sensors](hardware/sensors.md). ### Hole compensation A slicer setting that shrinks or grows printed holes. The print list asks for X-Y hole compensation −0.15 mm on the servo mounts (ADR-009); see [3D printing](making/3d-printing.md). ### Horn The arm or disc that fits on a servo's splined output shaft and transmits its torque. Strido uses 25-tooth (25T) aluminium disc and clamping arm horns; see [servos](hardware/servos.md). ## I ### I²C A two-wire bus (SDA data, SCL clock) where each device has an address. On Strido: PCA9685 0x40, LSM6DSOX 0x6A, INA260 0x41 at 400 kHz; see [buses](hardware/buses.md). ### IMU (inertial measurement unit) A sensor measuring acceleration and rotation rate. The LSM6DSOX gives the robot its orientation (Mahony filter on the XIAO); see [sensors](hardware/sensors.md). ### INA260 Texas Instruments' current/voltage/power monitor with an integrated shunt (Adafruit 4226). It reports pack voltage for the low-battery cut-off; the firmware will not drive without it; see [sensors](hardware/sensors.md). ### Infill The internal fill of a printed part, given as a density and a pattern (kit: 30 % gyroid); see [3D printing](making/3d-printing.md). ### Interference check The gate that tests the assembled CAD for parts that overlap in space; report in [verification/interference.md](../verification/interference.md). ### ISO 4762 / DIN 912 The standard for socket head cap screws, the only screw head type in Strido; see [fasteners](making/fasteners.md). ## J ### Joint limits The safe range of each joint, from a collision sweep of the full assembly (e.g. calf −150…−45°), shared by firmware, gait and simulation; see [kinematics and gaits](software/kinematics-and-gaits.md). ## K ### Kinematics The geometry that relates joint angles to foot positions (forward) and foot positions to joint angles (inverse); see [kinematics and gaits](software/kinematics-and-gaits.md). ### Knee crank The arm on the knee servo that drives the knee linkage; its angle maps non-linearly to the calf angle; see [mechanics](hardware/mechanics.md). ## L ### Layer height The thickness of each printed layer; 0.2 mm in the kit, half the 0.4 mm nozzle; see [3D printing](making/3d-printing.md). ### LiDAR A sensor that measures distances with a rotating laser. The Slamtec RPLIDAR C1 publishes `/scan` at 10 Hz from the top pedestal; see [sensors](hardware/sensors.md). ### LiPo (lithium polymer) The rechargeable battery chemistry of the robot's 2S 5000 mAh pack: high power, but dangerous if shorted, overcharged or over-discharged; see [batteries](hardware/batteries.md). ### Lock nut (DIN 985) A nut with a nylon insert that grips the thread, used at moving joints so they can turn freely without the nut backing off; see [fasteners](making/fasteners.md). ### Loop key A shorted XT60 plug that arms the servo branch when inserted in the arming port; pulling it is the servo kill; see [safety](making/safety.md). ## M ### mAh (milliampere-hour) Battery capacity: 5000 mAh can supply 5 A for one hour. With 7.4 V nominal it stores about 37 Wh; see [batteries](hardware/batteries.md). ### Mahony filter A lightweight algorithm that fuses gyroscope and accelerometer data into an orientation estimate; runs on the XIAO for `/imu/data`; see [sensors](hardware/sensors.md). ### Manifold (closed) A mesh with no holes or loose faces, so it has a well-defined inside; every kit STL is checked to be closed; see [CAD as code](software/cad-as-code.md). ### Microcontroller (MCU) A small single-chip computer for real-time tasks. The XIAO ESP32-S3 is Strido's MCU; see [microcontrollers](hardware/microcontrollers.md). ### MJCF MuJoCo's XML model format; `models/mujoco/strido.xml` is generated from the CAD; see [simulation and RL](software/simulation-and-rl.md). ### MuJoCo A fast physics simulator used for the walking demo and reinforcement learning; see [simulation and RL](software/simulation-and-rl.md). ## N ### Nav2 The ROS 2 navigation stack. It talks to the robot through `/cmd_vel` and `/scan`, so it runs unchanged on Strido; see [ROS 2](software/ros2.md). ### NT-X The robotics platform Strido integrates with through the `strido-ntx` extension; see [NT-X integration](../ntx-integration.md). ### N·m and kg·cm Units of torque. Servo makers quote kg·cm; $1\,\mathrm{kg\,cm} = 9.81\,\mathrm{N} \times 0.01\,\mathrm{m} \approx 0.098\,\mathrm{N\,m}$, so $35\,\mathrm{kg\,cm} \approx 3.43\,\mathrm{N\,m}$; see [servos](hardware/servos.md). ## O ### OE (output enable) The PCA9685 pin that switches all 16 outputs off when high. The XIAO holds it high until armed, and a 2.2 kΩ pull-up keeps it high during resets; see [safety](making/safety.md). ### Overhang A surface printed over empty space. Beyond about 45° from vertical it usually needs supports; the print list gives each part's overhang share; see [3D printing](making/3d-printing.md). ## P ### PCA9685 A 16-channel, 12-bit PWM driver on I²C. It generates the 12 servo signals at 200 Hz but carries none of the servo current; see [buses](hardware/buses.md). ### Perimeter An outline line of a printed layer; the kit prints 3 perimeters (≈ 1.2 mm wall); see [3D printing](making/3d-printing.md). ### PETG Polyethylene terephthalate glycol: a tough, heat-resistant filament and the kit profile's material; PLA+ is the alternative; see [3D printing](making/3d-printing.md). ### Pilot hole A hole smaller than the screw, into which a thread-forming screw cuts its thread (about 0.8 d after printing); see [fasteners](making/fasteners.md). ### PlatformIO The build system for the firmware: `pio run` to compile and upload, `pio test -e native` for host unit tests; see [firmware](software/firmware.md). ### PLA+ A tougher variant of PLA filament allowed by the print list as an alternative to PETG; see [3D printing](making/3d-printing.md). ### Plate A ready-arranged 220 × 220 mm build plate (3MF file); the kit has 10 graphite and 3 lime plates; see [3D printing](making/3d-printing.md). ### Policy A learned controller that maps observations (IMU, command, previous action, gait clock) to joint actions; stored as `policy.npz` and run with numpy; see [simulation and RL](software/simulation-and-rl.md). ### PPO (proximal policy optimisation) The reinforcement-learning algorithm used to train Strido's walking policy in MuJoCo; see [simulation and RL](software/simulation-and-rl.md). ### Print kit The package of oriented STLs, slicer profile, parts sheet and plates; build one on the [print kit](../print-kit.md) page. ### Printability gate The check that slices every part and plate with PrusaSlicer and scans wall thickness; report in [verification/printability.md](../verification/printability.md); see [3D printing](making/3d-printing.md). ### Pull-up resistor A resistor that holds a signal at a defined high level when nothing drives it, like the 2.2 kΩ on PCA9685 OE; see [buses](hardware/buses.md). ### PWM (pulse-width modulation) A signal whose pulse length carries information. Hobby servos read the position from the pulse width: 500–2500 µs for 180° on the SPT5435LV, 1500 µs being the centre; see [servos](hardware/servos.md). ## R ### Range of motion (ROM) How far each joint can move without collisions; the ROM sweep produces the joint limits; see [kinematics and gaits](software/kinematics-and-gaits.md). ### Raspberry Pi 5 The robot's main computer (4 GB) running Linux and ROS 2; see [Linux on the Pi](software/linux-on-the-pi.md). ### REP-103 The ROS convention for units and frames: x forward, y left, z up, SI units; see [ROS 2](software/ros2.md). ### Reinforcement learning (RL) Training a controller by trial and error in simulation, rewarding good behaviour; see [simulation and RL](software/simulation-and-rl.md). ### ROS 2 Robot Operating System 2: a middleware where nodes exchange messages on topics. Strido runs ROS 2 Jazzy; see [ROS 2](software/ros2.md). ### ros2_control The ROS 2 framework for controllers and hardware interfaces; in Gazebo it consumes `/leg_controller/commands`; see [ROS 2](software/ros2.md). ## S ### Service loop A deliberate length of free cable between two ties that lets a cable cross a moving joint without pulling on its terminations. Strido's thigh and knee servo leads cross the hip joint with 36 mm and 40 mm loops, at least 1.2 × the largest tie-to-tie distance over the hip range; see [cable management](making/cable-management.md). ### Servo (hobby servo) A motor, gearbox, position sensor and controller in one case that moves to the angle given by a PWM pulse. Strido uses twelve SPT5435LV-180W, without position feedback to the controller; see [servos](hardware/servos.md). ### Slew limit The maximum speed at which the firmware lets a commanded joint angle change (6 rad/s), to avoid violent jumps; see [firmware](software/firmware.md). ### Slicer Software that turns a 3D model into printer instructions (G-code) layer by layer, e.g. PrusaSlicer; see [3D printing](making/3d-printing.md). ### SLAM Simultaneous localisation and mapping: building a map while tracking the robot's position in it, here from `/scan`; see [ROS 2](software/ros2.md). ### Smoke test The first power-up with the loop key out: check boot, `/battery_state`, `/diagnostics` and the OE voltage before any servo can move; see [safety](making/safety.md). ### Snug Tightening a screw only until the head seats and the parts stop moving, the rule for screws into printed parts; see [fasteners](making/fasteners.md). ### SPI A fast synchronous bus with clock, data and chip-select lines. The XIAO drives the ST7789 LCD over SPI, with an extra DC (data/command) line; see [buses](hardware/buses.md). ### Spiral wrap A plastic spiral wound around a bundle of cables that holds and protects it while letting single cables branch out anywhere. Strido puts it on every bundle of three or more cables (`spiral_wrap`); see [cable management](making/cable-management.md). ### Stall torque / stall current The torque a motor produces and the current it draws when blocked. SPT5435LV: about 3.43 N·m and 3.5 A at 6 V; see [servos](hardware/servos.md). ### Stand-off A threaded spacer that lifts a circuit board off the deck (M2.5 × 6 mm and M2 × 8 mm brass, female-female); see [fasteners](making/fasteners.md). ### STEMMA QT Adafruit's 4-pin JST-SH I²C connector system. Two STEMMA QT → female-socket cables connect the IMU to the PCA9685 (W18; the IMU sits too close under the Pi for Dupont housings) and to the INA260 (W19); see [buses](hardware/buses.md). ### STEP, STL and 3MF STEP: exact CAD solids (the upstream Yertle assembly). STL: triangle meshes for printing. 3MF: a package of meshes plus arrangement, used for the plates; see [CAD as code](software/cad-as-code.md). ### Strain relief Anything that keeps pull and bending away from a wire's termination: the insulation grip of a crimp, heat-shrink over a solder joint, a cable tie close to a connector. It matters most where solder has wicked into the strands and made them stiff; see [cable management](making/cable-management.md). ### Supports Temporary printed structures under overhangs, removed after printing; the kit uses snug supports on listed parts; see [3D printing](making/3d-printing.md). ## T ### TF The ROS 2 transform system that tracks coordinate frames (`odom`, `base_link`, `imu_link`, `lidar_link`); see [ROS 2](software/ros2.md). ### Thread-forming screw A screw that cuts or forms its own thread in a plain pilot hole. 78 of Strido's screws do this in printed parts; see [fasteners](making/fasteners.md). ### Threadlocker An anaerobic adhesive that stops screws from loosening. Medium (blue) strength is used on screws into metal horns; see [fasteners](making/fasteners.md). ### Tibia The lower leg link (calf). Each leg has an `inner_tibia` and `outer_tibia` printed half with a 633ZZ bearing each; see [mechanics](hardware/mechanics.md). ### Topic A named ROS 2 message channel, such as `/cmd_vel`, `/joint_states` or `/battery_state`; see [ROS 2](software/ros2.md). ### Trot A gait in which diagonal leg pairs (front-left with rear-right, front-right with rear-left) move together; see [kinematics and gaits](software/kinematics-and-gaits.md). ## U ### udev rule A Linux rule that gives a device a stable name when it is plugged in; `99-strido.rules` names the XIAO and LiDAR; see [Linux on the Pi](software/linux-on-the-pi.md). ### Upstream (Yertle) The original open-source Yertle robot whose STEP and STL files Strido builds on, kept unchanged in `upstream/yertle/`; see [design](../design.md). ### URDF / xacro The ROS robot description format (URDF) and its macro language (xacro); `strido.urdf.xacro` is generated from the CAD; see [simulation and RL](software/simulation-and-rl.md). ### USB CDC USB's communication device class: the XIAO appears as a serial port to the Pi, carrying the COBS-framed protocol; see [firmware](software/firmware.md). ### uv A Python package and workspace manager; Strido's Python packages are a uv workspace; see [CAD as code](software/cad-as-code.md). ## V ### VitePress The static site generator that builds this documentation site. ## W ### WAGO 221-415 A five-way lever connector (32 A, 0.2–4 mm², strip 11 mm) used to build the servo power bus in the front and rear cavities; see [cable management](making/cable-management.md). ### Watchdog A timer that triggers a safe action if no command arrives: no joint command for 200 ms → hold, 2 s more → relax; see [firmware](software/firmware.md). ## X ### XIAO ESP32-S3 Seeed's thumb-sized ESP32-S3 board: the robot's motion controller, talking USB to the Pi and I²C/SPI to the PCA9685, sensors and LCD; see [microcontrollers](hardware/microcontrollers.md). ### XT60 A two-pole power connector (Amass XT series) rated 30 A continuous, used for the battery input (under the deck), the arming port and the loop key; describe halves by pins or sockets, not male/female; see [power](hardware/power.md). --- # Batteries: the 2S LiPo Strido runs from one 2S lithium-polymer (LiPo) pack: 7.4 V nominal, 5000 mAh, about 37 Wh. LiPo stores a lot of energy for its weight and can deliver large currents, but it is also the one part of the robot that can start a fire. This page explains the numbers on the label and the habits that keep the pack healthy and safe. ::: tip In Strido - **Part:** LiPo 2S 5000 mAh hardcase with XT60, max 138 × 47 × 25 mm (Gens ace G-Tech class), BOM line `lipo_2s_5000`. - **Accessories:** balance charger with AC input (B6AC class, `charger`), LiPo safety bag (`lipo_bag`), LiPo cell checker / low-voltage alarm (`lipo_checker`). - **Data:** `hardware/electronics.yaml` → `power.pack` (7.4 V nominal, 8.4 V full, 6.4 V empty, 5000 mAh) and `rails.pack` (6.0–8.4 V). - **Firmware:** low-battery warning at 6.8 V, servo cut-off at 6.4 V (`firmware/strido_esp32s3/lib/strido_core/include/strido/safety.h`), measured by the INA260. - **ROS 2:** `/battery_state` (pack voltage and logic-branch current). ::: ## Cells and the "2S" label A LiPo **cell** is one lithium-ion pouch. Its voltage depends on its state of charge: | Cell state | Voltage per cell | 2S pack | |---|---|---| | Full (end of charge) | 4.20 V | 8.4 V | | Nominal (label value) | 3.70 V | 7.4 V | | Strido warning | 3.40 V | 6.8 V | | Strido servo cut-off | 3.20 V | 6.4 V | | Lowest rail value the logic branch is checked for | 3.00 V | 6.0 V | **2S** means two cells in **s**eries: the voltages add, and the capacity stays that of one cell. (A "2S2P" pack would have two parallel pairs, doubling capacity.) Two cells were chosen because: - 2S (6.4–8.4 V in use) sits just above the 6.0 V servo rail, so the UBECs only need to step down a little. - 2S is the common RC-car pack, so packs, chargers and XT60 leads are cheap and easy to find. ## Capacity and energy **Capacity** (mAh) is charge $Q$: 5000 mAh means the pack can deliver 5 A for one hour, or 1 A for five hours, before it is empty. Charge is current integrated over time, $Q = \int I\,\mathrm{d}t$, so at constant current $Q = I\,t$. **Energy** (Wh) also accounts for voltage. Electrical power is $P = V I$; integrating it over the discharge gives the energy, and if the voltage stays close to its nominal value this reduces to $$ E = \int V I\,\mathrm{d}t \approx V_\mathrm{nom}\, Q = 7.4\,\mathrm{V} \times 5.0\,\mathrm{Ah} = 37\,\mathrm{Wh} $$ (1 Wh = 3600 J, so this is about 133 kJ.) The nominal voltage is used because a LiPo's voltage falls gently from 4.2 V to about 3.2 V per cell over most of the discharge; 3.7 V per cell is a good average. Energy is the number that predicts runtime. `docs/electronics.md` estimates about 60 W drawn from the pack while trotting (12 servos ≈ 50 W, plus Pi, LiDAR and the rest, through 90 %-efficient converters). With a usable fraction $\eta_\mathrm{use} = 0.8$ of the stored energy: $$ E_\mathrm{usable} \approx 37\,\mathrm{Wh} \times 0.8 = 29.6\,\mathrm{Wh} $$ $$ t_\mathrm{run} = \frac{E_\mathrm{usable}}{P} \approx \frac{29.6\,\mathrm{Wh}}{60\,\mathrm{W}} \approx 0.5\,\mathrm{h} \approx 30\,\mathrm{min}\ \text{trotting} $$ Standing draws less, so the doc estimates about 45 min. The 80 % factor is there because the firmware stops the servos well before the cells are truly empty. Both runtimes are marked [ESTIMATE]: measure your own with `/battery_state` and a clamp meter. 37 Wh is also below the 100 Wh limit above which shipping a LiPo needs a surcharge (`docs/bom/sources.md`). ## C-rating: how fast the pack can deliver The **C-rating** expresses current as a multiple of capacity. With $Q$ in ampere-hours, a rate of $C$ means the current $$ I = C \cdot \frac{Q}{1\,\mathrm{h}} $$ so 1C on a 5000 mAh pack is $5\,\mathrm{A}$, and 60C is $60 \times 5\,\mathrm{A} = 300\,\mathrm{A}$ (the Gens ace pack's continuous rating; 100C gives $500\,\mathrm{A}$ burst). Equivalently, a pack discharged at $n$C empties in $1/n$ hours. `hardware/electronics.yaml` records a `max_discharge_a` of 300 A for this pack (60C continuous, Gens ace GEA502S60X6HGT). The robot's worst-case pack current is about 23.6 A (servo and logic branches, `docs/verification/electronics.md`), so the pack works at under a tenth of its rating. Why buy far more C than you need? Every cell behaves roughly like an ideal voltage source $V_\mathrm{oc}$ (the open-circuit voltage) in series with an **internal resistance** $R_\mathrm{int}$. By Ohm's law the terminal voltage under load is $$ V_\mathrm{term} = V_\mathrm{oc} - I\,R_\mathrm{int} $$ and the pack also turns $P_\mathrm{loss} = I^2 R_\mathrm{int}$ into heat. A pack with a higher genuine C-rating usually has a lower internal resistance, so it **sags** less when twelve servos pull a current peak together. As an illustration (not a measured value), a pack with $R_\mathrm{int} = 10\,\mathrm{m\Omega}$ at the robot's worst-case $23.6\,\mathrm{A}$ would sag by $0.24\,\mathrm{V}$ and dissipate $5.6\,\mathrm{W}$. Less sag means fewer false low-battery readings and more voltage headroom for the UBECs. Cheap packs often overstate their C-rating, which is why `docs/bom/sources.md` lists the LiPo among the parts where the brand matters (hardcase, real C rating). ### Load sag and the cut-off filter Sag is also why the firmware does **not** cut off on a single low reading. The INA260 is read every 100 ms. The servos only relax after **5 consecutive readings below 6.4 V** (0.5 s), so a short current spike in mid-step does not drop the robot. A pack that really is empty stays low and trips the cut-off. ## Charging: always a balance charger LiPo cells charge with **constant current, then constant voltage** (CC/CV): the charger pushes a set current until the cell reaches 4.20 V, then holds 4.20 V while the current tapers. Going above 4.20 V per cell damages the cell and can make it unstable. In a series pack the charger only sees the total voltage, and the two cells are never exactly equal. One cell could reach 4.30 V while the other is still at 4.10 V, and the total would still read 8.4 V. The **balance lead**, the small extra plug with one wire per cell junction, lets a **balance charger** measure each cell and bleed off the higher one. Strido's B6AC-class charger does this. Rules of thumb (see Battery University in Further reading): - Charge at **1C or less**: 5 A for this pack. - Always plug in the balance lead and select the LiPo program with the right cell count (2S). - Charge **inside the LiPo bag**, on a non-flammable surface, and **never leave it unattended** (`docs/electronics.md`, safety rules). - Let a warm pack cool down before charging it. ## Storage voltage A LiPo ages fastest when it sits **full** (4.20 V per cell), and it is damaged if it sits **empty**. For storage longer than a few days, bring each cell to about **3.80–3.85 V** (about 7.6–7.7 V for the pack). Most balance chargers, including the B6AC class, have a *storage* program that charges or discharges to this level automatically. ## The LiPo checker The cell checker plugs into the balance lead and shows each cell's voltage and the total. Use it: - **Before a run:** both cells near 4.2 V and within a few hundredths of a volt of each other. - **After a run:** cells still at or above about 3.2 V and still balanced. - **Before storage:** cells at storage voltage. A cell that drifts away from its partner run after run is the first sign of a failing pack. In alarm mode the checker beeps when any cell drops below a threshold. That adds a second, independent warning on top of the firmware's. ## Safe handling ::: danger LiPo fire risk A damaged, over-charged or over-discharged LiPo can vent and burn violently. A LiPo fire cannot be put out by smothering it. ::: - **Inspect the pack.** A hardcase helps, but check for swelling ("puffing"), dents, cut wires or a hot pack after use. Retire any puffed pack. - **Never short the leads.** The pack can deliver hundreds of amps. That is also why the arming loop key must never fit the battery's own XT60. See [Power](power.md) for the contact rule. - **Fuse close to the pack.** The 25 A main fuse protects the 14 AWG trunk right after the battery input. - **Unplug after use.** Even with the logic switched off, the robot stays connected to the pack through its wiring. Disconnect the battery when you finish. - **Transport and store** the pack in the LiPo bag, at storage voltage, away from heat. - **Dispose properly:** discharge fully with a charger's discharge program, then take it to a battery collection point. ## Check yourself 1. What is the pack voltage of a 2S LiPo at 3.4 V per cell, and what does the firmware do there? 2. How much current is 2C for this pack? 3. Why does a balance charger need the balance lead if it already measures 8.4 V at the main leads? 4. Why does the firmware wait for five consecutive low readings before it cuts off? 5. You will not use the robot for a month. What should you do with the pack?
Answers 1. 6.8 V; the firmware raises the low-battery warning. 2. $2 \times 5\,\mathrm{A} = 10\,\mathrm{A}$. 3. The total can read 8.4 V while one cell is over-charged and the other under-charged; only per-cell measurement shows and corrects that. 4. Current peaks make the voltage sag briefly. Requiring 0.5 s below 6.4 V ignores spikes and reacts only to a truly empty pack. 5. Bring it to storage voltage (about 3.80–3.85 V per cell) with the charger's storage program and keep it in the LiPo bag.
## Further reading - [BU-409: Charging Lithium-ion (Battery University)](https://batteryuniversity.com/article/bu-409-charging-lithium-ion) - [BU-808: How to prolong lithium-based batteries (Battery University)](https://batteryuniversity.com/article/bu-808-how-to-prolong-lithium-based-batteries) - [TI INA260 datasheet](https://www.ti.com/lit/ds/symlink/ina260.pdf): the sensor that measures the pack voltage - Related pages: [Power](power.md), [Safety](../making/safety.md), [Electronics, wiring and power](../../electronics.md) --- # Buses and signals A bus is an agreed way of moving bits between chips: which wires carry what, how fast, and who may talk when. Strido uses five: I²C for the sensors and the servo driver, USB serial between the Pi and the ESP32 (and to the LiDAR), PWM to the servos, SPI to the face display and CSI to the camera. Each was picked for a reason you can work out from the numbers. ::: tip In Strido - **I²C** (400 kHz) on XIAO D4 = SDA, D5 = SCL: PCA9685 0x40 (all-call 0x70), LSM6DSOX 0x6A, INA260 0x41. Wires W17 (22 AWG, soldered at the XIAO, crimped at the PCA9685) and W18, W19 (STEMMA QT cables, `stemma_qt_female` × 2). - **USB serial:** Pi → XIAO USB CDC (W14, `/dev/strido_mcu`) and Pi → RPLIDAR C1 USB adapter (W15, `/dev/rplidar`). - **PWM:** PCA9685 → 12 servos, 200 Hz, 500–2500 µs; LCD backlight on D2. - **SPI:** XIAO → Waveshare 2" ST7789 LCD (D8 SCK, D10 MOSI, D9 DC, D0 CS, D1 RST), W21. - **CSI:** Pi 5 CAM/DISP 0 → Camera Module 3 Wide, 22→15-pin FFC, 300 mm (W16). - **Source of truth:** `hardware/electronics.yaml`, `firmware/strido_esp32s3/src/board_config.h`, `hardware/wiring.yaml` (cables and routes; cut list in the [wiring check](../../verification/wiring.md)), `docs/electronics.md` (pin maps, wiring list). ::: ## Overview | Bus | Wires | Speed in Strido | Topology | Used for | |---|---|---|---|---| | I²C | SDA, SCL (+ 3.3 V, GND) | 400 kHz | shared, addressed | servo driver, IMU, power monitor | | USB (CDC serial) | D+, D−, 5 V, GND | USB full speed | point to point | Pi ⇄ XIAO commands and state | | UART (behind USB adapter) | TX, RX, GND | 460 800 baud | point to point | RPLIDAR C1 scan data | | Servo PWM | one signal per servo | 200 Hz frame, µs pulse | one wire per servo | joint position commands | | SPI | SCK, MOSI, CS, DC | 27 MHz write clock | one device here | LCD pixels | | MIPI CSI-2 | differential lanes in a ribbon | Gbit/s class | point to point | camera images | ## I²C: one pair of wires, many devices **I²C** (Inter-Integrated Circuit) uses two wires: **SCL** (clock, driven by the controller) and **SDA** (data, both directions). Every device has a 7-bit **address**. The XIAO starts each transaction by sending an address, and only the device with that address answers. ### Addresses in this robot | Address | Device | Note | |---|---|---| | 0x40 | PCA9685 servo driver | firmware `kPcaAddress` | | 0x70 | PCA9685 all-call | the PCA9685 also answers here; nothing else may use it | | 0x6A | LSM6DSOX IMU | `kImuAddress` | | 0x41 | INA260 power monitor | `kPowerMonitorAddress`; **bridge the A0 jumper** | The INA260 ships at **0x40**, the same as the PCA9685. Two devices on one address both answer at once and corrupt each other's replies. Bridging the solder jumper A0 moves the INA260 to 0x41. `strido_verify.electronics` checks that all addresses are unique and that the firmware constants match the YAML. ### Open drain and pull-ups I²C devices never drive a line high. They can only pull it **low** (open-drain), and **pull-up resistors** return it to the supply voltage when nobody pulls. That is how several devices share one wire without short-circuiting each other. Two consequences: - **The pull-up voltage sets the logic level.** On Strido the bus is pulled up to the XIAO's **3.3 V**, and every device on it is powered from that 3.3 V rail. The check confirms 3.3 V sits inside every device's range: PCA9685 2.3–5.5 V, LSM6DSOX breakout 3–5 V, INA260 2.7–5.5 V. - **The resistors and the wiring set the speed.** Each pull-up charges the bus capacitance (wires plus pins) back to high. Too much capacitance, or pull-ups that are too weak, makes the rising edges slow, and fast-mode I²C at 400 kHz has a strict rise-time limit. Breakout boards usually carry their own pull-ups, and on a shared bus they act in parallel. Short wires keep the capacitance low. W17 is the longest hop, routed 272 mm and cut 300 mm; W18 and W19 are 150 mm STEMMA QT cables (routed 86 mm and 125 mm). The pull-up resistor $R_\mathrm{p}$ and the bus capacitance $C_\mathrm{b}$ form an RC circuit. When the last device releases the line, the voltage rises exponentially towards the supply $V_\mathrm{DD}$: $$ V(t) = V_\mathrm{DD}\left(1 - e^{-t/(R_\mathrm{p} C_\mathrm{b})}\right) $$ I²C measures the **rise time** $t_\mathrm{r}$ between 30 % and 70 % of $V_\mathrm{DD}$. Solving $V(t)$ for those two levels gives $t_\mathrm{r} = R_\mathrm{p} C_\mathrm{b} \ln(0.7/0.3) \approx 0.847\,R_\mathrm{p} C_\mathrm{b}$. The I²C specification (NXP UM10204) allows at most $300\,\mathrm{ns}$ in fast mode and $400\,\mathrm{pF}$ of bus capacitance, so the largest allowed pull-up is $$ R_\mathrm{p,max} = \frac{t_\mathrm{r}}{0.847\, C_\mathrm{b}} $$ For an assumed $C_\mathrm{b} = 100\,\mathrm{pF}$ (an illustration, not a measurement of this harness) that is $R_\mathrm{p,max} \approx 3.5\,\mathrm{k\Omega}$. The lower bound comes from the $3\,\mathrm{mA}$ a device must be able to sink while holding the line below $0.4\,\mathrm{V}$: $R_\mathrm{p,min} = (V_\mathrm{DD} - 0.4\,\mathrm{V}) / 3\,\mathrm{mA} \approx 0.97\,\mathrm{k\Omega}$ at $3.3\,\mathrm{V}$. Pull-ups on several breakouts combine like parallel resistors, $1/R_\mathrm{p} = \sum_i 1/R_i$, which is why too many boards can push $R_\mathrm{p}$ below that minimum. ### Why 400 kHz The clock period, and so the time for one bit, is $T_\mathrm{bit} = 1/f_\mathrm{SCL}$; at $400\,\mathrm{kHz}$ that is $2.5\,\mu\mathrm{s}$. Each I²C byte takes 9 clock cycles (8 data bits plus an acknowledge). Setting one PCA9685 channel means sending the address, a register number and four data bytes: $$ t_\mathrm{ch} = 6 \times 9 \times T_\mathrm{bit} = 54 \times 2.5\,\mu\mathrm{s} \approx 135\,\mu\mathrm{s} $$ $$ t_{12} = 12\, t_\mathrm{ch} \approx 1.6\,\mathrm{ms}\quad\text{(plus small overheads)} $$ The IMU read and, every 100 ms, the INA260 read share the same 5 ms control period. At the 100 kHz standard mode the servo writes alone would take about 6.5 ms and miss the deadline. That is why the bus runs in **fast mode, 400 kHz** (`kI2cHz`). These figures are rough estimates; the exact count depends on the library. ### STEMMA QT Both hops to and from the IMU use a **STEMMA QT** cable: Adafruit's 4-pin JST SH (1 mm pitch) connector carrying 3.3 V, GND, SDA and SCL. It is pin-compatible with SparkFun's Qwiic. The IMU breakout has two STEMMA QT sockets so the bus can continue; each BOM cable ends in female sockets for a 2.54 mm header. The IMU sits 2.5 mm under the Pi's circuit board, so Dupont housings on its header cannot fit; the low STEMMA QT plug does. The first hop has the opposite problem. The XIAO's pins point up 9 mm below the upper shoulder frame, too little for a 14 mm Dupont housing, so W17 (3V3, GND, SDA, SCL, 22 AWG) is **soldered to the XIAO pins** and **crimped** with Dupont contacts at the PCA9685 (see [cable management](../making/cable-management.md#connectors-and-terminations)). The full chain is: ``` XIAO ──W17 22 AWG (soldered / Dupont)──▶ PCA9685 (left header) ──W18 STEMMA QT──▶ LSM6DSOX ──W19 STEMMA QT──▶ INA260 ``` ## USB serial and UART ### UART basics A **UART** sends bytes one bit at a time on a TX wire and receives on an RX wire. There is no clock wire: both sides agree on a **baud rate** in advance. Each byte is framed by a start bit and a stop bit, so 8N1 framing costs 10 bit-times per byte. The RPLIDAR C1 speaks 3.3 V TTL UART at **460 800 baud, 8N1**: $$ \frac{460\,800\,\mathrm{bit/s}}{10\,\mathrm{bit/byte}} \approx 46\,\mathrm{kB/s} $$ That is plenty for 5000 samples per second. A small CP210x USB adapter (in the LiDAR pedestal) turns that UART into a USB serial port on the Pi. The udev rule names it `/dev/rplidar`, so the port name never changes between boots. ### USB CDC to the XIAO The ESP32-S3 has native USB. The firmware presents itself as a **CDC** (Communications Device Class) serial port, which Linux shows as a tty and udev names `/dev/strido_mcu`. Over USB the "baud rate" is meaningless: data moves in USB packets. The firmware's `kUsbBaud = 2000000` is ignored and only kept for a possible UART fallback. ### Framing: COBS and CRC16 A serial link is just a stream of bytes, so the receiver needs to know where a message starts and whether it arrived intact: - **COBS** (Consistent Overhead Byte Stuffing) rewrites each frame so it contains no `0x00` byte, then appends one `0x00` as the end marker. After a glitch, the receiver resynchronises at the next zero. - **CRC16-CCITT-FALSE** is a 16-bit checksum over the frame. A frame with a wrong CRC is dropped instead of moving a leg to a garbage angle. The traffic is small: a `STATE` payload is 103 bytes at 100 Hz, $103\,\mathrm{B} \times 100\,\mathrm{Hz} \approx 10\,\mathrm{kB/s}$, and a `JOINT_COMMAND` carries 12 × float32 per gait step. ## PWM to the servos The servo signal is a pulse-width-modulated wire: a 3.3 V pulse every 5 ms (200 Hz) whose **width**, 500–2500 µs, encodes the angle. Each servo has its own signal wire from a PCA9685 channel; ground is shared through the servo power bus. Details and the numbers are in [Servos](servos.md). The LCD backlight is a second use of PWM, on XIAO D2. Here the **duty cycle** $D = t_\mathrm{on}/T$, the fraction of time the line is high, sets brightness: the average voltage the LED sees is $\bar{V} = D\,V_\mathrm{high}$. It runs at a 20 kHz frequency ($T = 50\,\mu\mathrm{s}$), far above what the eye can see flicker in. D2 is GPIO 3, an ESP32-S3 **strapping pin** that is read at boot to select the boot mode. The firmware therefore drives it only after boot. ## SPI to the face display **SPI** (Serial Peripheral Interface) has a clock (SCK), a data line from controller to device (MOSI) and a chip-select (CS) per device. There are no addresses: pulling CS low selects the device. SPI is push-pull rather than open-drain, so it runs far faster than I²C. The Waveshare 2" LCD uses an ST7789 controller, 240 × 320 pixels. One full frame at 16 bits per pixel is: $$ N = 240 \times 320 \times 16\,\mathrm{bit} = 1\,228\,800\,\mathrm{bit} $$ $$ t_\mathrm{frame} = \frac{N}{f_\mathrm{SCK}} = \frac{1\,228\,800\,\mathrm{bit}}{27 \times 10^{6}\,\mathrm{bit/s}} \approx 45\,\mathrm{ms} $$ On I²C at 400 kHz, with 9 clocks per 8-bit byte, the same frame would take $N \cdot \tfrac{9}{8} / 400\,\mathrm{kHz} \approx 3.5\,\mathrm{s}$. The firmware writes at **27 MHz**, noted as "conservative for ~15 cm jumper wires" in `face.cpp`: longer, unshielded wires blur fast edges. The display needs only one direction, so MISO is unused. The extra **DC** (data/command) line tells the controller whether a byte is a command or pixel data. **RST** resets the panel. ## CSI to the camera The camera produces far more data than any of the buses above: 12 megapixels, 1080p50 video. It uses **MIPI CSI-2**, a high-speed serial interface with differential data lanes, carried on a flat flexible cable (FFC) straight into the Pi's image signal processor. - The **Pi 5** has 22-pin, 0.5 mm-pitch camera connectors; **Camera Module 3** has a 15-pin, 1 mm-pitch connector. Hence the **22 → 15 pin** adapter cable, 300 mm long, routed through the front-shell ribbon slot. - FFCs are fragile: the ribbon goes in with the camera, just before the front shell (assembly Step 18). Avoid sharp folds, fold its 124 mm of spare length flat, and insert it with the metal contacts facing the connector's contacts. The camera also has its own small control bus for focus and settings. That runs inside the CSI cable to the Pi, not on the robot's I²C bus. ## Check yourself 1. What would go wrong if you forgot to bridge the INA260's A0 jumper? 2. Why do I²C lines need pull-up resistors, while SPI lines do not? 3. Roughly how long does it take to write all 12 servo channels at 100 kHz, and why does that matter? 4. What does the `0x00` byte at the end of each USB frame do? 5. Why does the camera use CSI instead of USB or SPI?
Answers 1. The INA260 would sit at 0x40 together with the PCA9685, and both would answer the same transactions, corrupting the bus. The firmware would then refuse to drive without a working power monitor. 2. I²C outputs are open-drain and only pull low, so something must pull the line high. SPI outputs actively drive both levels. 3. About 6.5 ms (four times the 1.6 ms at 400 kHz), longer than the 5 ms control period. 4. It marks the end of a COBS-encoded frame. COBS guarantees no other zero appears inside, so the receiver can always resynchronise. 5. Its data rate (12 MP stills, 1080p50 video) needs a high-speed differential link straight into the Pi's image processor; SPI is far too slow, and CSI is the Pi's native camera interface.
## Further reading - [I²C bus pull-up resistor calculation (TI SLVA689)](https://www.ti.com/lit/an/slva689/slva689.pdf) - [Introducing Adafruit STEMMA QT](https://learn.adafruit.com/introducing-adafruit-stemma-qt) - [Consistent Overhead Byte Stuffing (overview)](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) - [RPLIDAR C1 datasheet (Slamtec)](https://wiki.slamtec.com/download/attachments/83066883/SLAMTEC_rplidar_datasheet_C1_v1.0_en.pdf?api=v2&modificationDate=1700531479533&version=1) - Related pages: [Microcontrollers](microcontrollers.md), [Sensors](sensors.md), [Firmware](../software/firmware.md), [Cable management](../making/cable-management.md), [Electronics, wiring and power](../../electronics.md) --- # Mechanics: legs, linkages, bearings and fits A walking robot is a set of rigid links joined by joints, and how well it walks depends on how those joints are built: what turns on what, where the motors sit, and how much play each joint allows. This page follows one Strido leg from the body to the foot, then explains the bearings, the joint limits and why tenths of a millimetre matter on printed parts. ::: tip In Strido - **Kinematic data:** `hardware/kinematics.yaml` (link lengths measured from the upstream STEP), `hardware/joint_limits.yaml` (collision-free limits), generated into `firmware/.../geometry_generated.h` and `packages/strido-core/src/strido/geometry.py`. - **Printed parts per leg:** `femur`, `femur_servo_connector`, `left_shoulder` / `right_shoulder` (shoulder crank), `short_link`, `stiff_long_link` (Strido, replaces the upstream `long_link`), `inner_tibia`, `outer_tibia`, `foot_spacer`, servo mount and `servo_mount_top_bracket`, `hip_bearing_pin`. - **Bearings:** 4 × 625ZZ, 4 × 6805-2RS, 8 × 633ZZ, 32 × sintered bronze sleeves 3 × 5 × 5 (BOM lines `bearing_625zz`, `bearing_6805`, `bearing_633zz`, `sleeve_3x5x5`). - **Verification:** `docs/verification/fits.md` (screws and bearing seats), `clearance.md` (moving parts), `interference.md` (static overlaps), tolerance rules in `hardware/verification/tolerances.yaml`. ::: ## Degrees of freedom Each leg has **three degrees of freedom (DoF)**, one servo each: | Joint | Moves the leg | Servo location | Drive | |---|---|---|---| | Hip (abduction) | sideways, about an axis along the body | in the body, through the inner shoulder frame | direct, via a 25T disc horn | | Thigh | forwards and backwards | in the leg module's servo mount | direct, via a 25T disc horn into the femur servo connector | | Knee | bends the lower leg | in the same servo mount, beside the thigh servo | indirect, through a crank and two linkages | Three DoF per leg is the minimum to put a foot anywhere in 3D space within reach. Four legs give 12 servos in total, the same layout as a Unitree Go2. That is why Strido can use the Go2 joint names and conventions. ## The kinematic chain of one leg ```mermaid flowchart LR BODY["Body
(shoulder frames)"] -->|"hip servo + 625ZZ pivot"| MOD["Leg module
(servo mount)"] MOD -->|"thigh servo, disc horn"| FEM["Femur
130 mm"] MOD -->|"knee servo, arm horn r 24 mm"| SL["Short link
28.3 mm"] SL --> SC["Shoulder crank
(6805-2RS on thigh axis)"] SC --> LL["Long link
131 mm"] FEM -->|"knee axle, 2 × 633ZZ"| TIB["Tibia
124 mm to foot"] LL --> TIB ``` ### Hip: supported on both sides The hip servo's spline carries one side of the leg module. The other side turns on a **625ZZ** bearing in the outer shoulder frame, on the printed `hip_bearing_pin` and an M3 × 10 pivot screw. Supporting the axis at both ends means the leg's weight and the ground forces are not carried by the servo's own small output bearings alone, as they would be in a cantilever. ### Thigh: direct drive The thigh servo turns the femur directly. The aluminium disc horn is clamped inside the `femur_servo_connector` and bolted to the femur with 4 × M3 × 20. The femur is 130 mm from thigh axis to knee. ### Knee: crank, parallelogram and four-bar Putting the knee servo *at* the knee would hang 72 g of servo at the end of the thigh, where it adds the most inertia to every swing. Strido keeps the knee servo up in the leg module and transmits its motion down the leg: 1. **Servo arm → shoulder crank (parallelogram).** The knee servo's arm horn (radius 24.0 mm) drives the **short link** (28.28 mm). The short link drives the shoulder crank's first arm (radius 24.0 mm), which turns about the **thigh axis** on a **6805-2RS** bearing. The servo shaft and the thigh axis are 28.26 mm apart. Two equal cranks joined by a link as long as their spacing form a **parallelogram**: the shoulder crank copies the servo's angle exactly onto the thigh axis. 2. **Shoulder crank → tibia (four-bar).** The crank's second arm (radius 26.92 mm) pushes the **long link** (131 mm) to a 24 mm crank on the tibia. The tibia turns on two **633ZZ** bearings at the knee axle. Together with the femur (130 mm) this forms a **four-bar linkage**. Because the four-bar's ground link is the femur itself, and the arm lengths are not equal, the calf angle depends **non-linearly** on both the crank angle *and* the thigh angle. Moving the thigh alone changes the knee angle. The firmware (`servo_map.cpp`, `kinematics.cpp`) and `strido-core` (`leg.py`) therefore solve the linkage exactly, converting every requested calf angle into a knee-crank angle. The upstream Yertle CAD has a small inconsistency here: the drawn knee (−98.8°) and the angle at which the linkage actually closes (−101.2°) differ, a 1.02 mm link-length mismatch. Strido uses the measured lengths, not the drawn pose (`docs/design.md`). ### Transmission angle In a four-bar, the **transmission angle** is the angle between the coupler (long link) and the output crank. Near 0° or 180° the link pushes along the crank instead of turning it. Force transmission collapses there, and the linkage can jam or snap through. The range-of-motion sweep rejects every pose with a transmission angle below **25°** (`transmission_min_deg`). ## Joint limits The limits come from a collision sweep of the complete assembly: every printed part, bearing, horn and Strido fastener. The sweep runs over thigh × calf on a 5° grid at zero abduction, followed by a hip sweep at the standing and mid poses. A further rule keeps every servo within **±85°** of its assembly neutral, inside its ±90° travel. These are the generated values (the sweep result in `hardware/joint_limits.yaml`, clamped to the servo travel in `codegen.py` / `geometry_generated.h`; the front thigh minimum of −40° comes from that clamp): | Joint | FL | FR | RL | RR | |---|---|---|---|---| | Hip | −17.5…50° | −50…17.5° | −17.5…45° | −45…17.5° | | Thigh | −40…125° | −40…125° | −15…125° | −15…125° | | Calf | −150…−45° | −150…−45° | −150…−45° | −150…−45° | | Knee crank | −2.5…75° | −2.5…75° | −2.5…75° | −2.5…75° | Front thighs sweep collision-free to −45°, but the ±85° travel rule around the 45° neutral cuts them to −40°. On the FL leg, 566 of the thigh × calf grid cells collide and 422 are feasible. The firmware clamps every command to these limits and reports `clamped` in `/diagnostics` when it had to. Sign conventions follow the Go2: thigh positive = foot backwards, calf negative = knee bent. ## Bearings | Bearing | Size (bore × OD × width) | Qty | Where | |---|---|---|---| | 625ZZ | 5 × 16 × 5 mm | 4 | hip pivot, outer shoulder frames | | 6805-2RS | 25 × 37 × 7 mm | 4 | shoulder crank, turning about the thigh axis around the femur servo connector | | 633ZZ | 3 × 13 × 5 mm | 8 | knee, two per tibia (inner and outer half) | | Sintered bronze sleeve | 3 × 5 × 5 mm | 32 | link eyes, two per eye, 8 per leg | **Suffixes:** **ZZ** means a metal shield on both sides, which keeps out dust but is not sealed. **2RS** means rubber seals on both sides, which also keep the grease in. **Ball bearings vs sleeves.** A ball bearing rolls, so friction is low and stays low under load. That suits the joints that carry the robot's weight and turn through large angles. A **sintered bronze sleeve** is a porous, oil-impregnated plain bearing: tiny, cheap, and good for link pins that only oscillate a few tens of degrees on an M3 screw. A printed hole alone would wear oval quickly; the bronze sleeve gives the screw a hard, lubricated surface to turn in. **How long does a ball bearing last?** Rolling contact eventually fatigues the raceways. The standard rating life (ISO 281) of a ball bearing is $$ L_{10} = \left(\frac{C}{P}\right)^{3}\quad\text{million revolutions} $$ where $C$ is the bearing's basic dynamic load rating from the catalogue, $P$ the equivalent load it carries, and $L_{10}$ the life that 90 % of a large batch of bearings reach. The exponent 3 applies to ball bearings (roller bearings use $10/3$). The cube makes life very sensitive to load: halving $P$ gives eight times the life. As a worked example, take the robot's 3.07 kg (`docs/design.md`) in a trot, where two legs carry the weight, so each leg sees about $P \approx 3.07\,\mathrm{kg} \times 9.81\,\mathrm{m/s^2} / 2 \approx 15.1\,\mathrm{N}$ when standing still in mid-stance. With $C \approx 1.14\,\mathrm{kN}$, a typical catalogue rating for a 625 deep-groove bearing (check your supplier's data sheet), $$ L_{10} \approx \left(\frac{1140\,\mathrm{N}}{15.1\,\mathrm{N}}\right)^{3} \approx 4.3 \times 10^{5}\ \text{million revolutions} $$ Dynamic foot impacts are several times the static load, and the joints only oscillate, but even a tenfold load leaves hundreds of millions of revolutions. Fatigue is not what limits these bearings; dirt, a creeping outer ring or a cracked printed seat is. See [statics and dynamics](../theory/statics-and-dynamics.md) for how joint loads arise. Test-fit every bearing and sleeve in its printed seat **before** assembly (`docs/assembly/README.md`). ## Fits: press, transition and clearance A **fit** describes how a shaft and hole of nominally the same size go together: | Fit | Hole vs part | Use | |---|---|---| | Clearance | hole always larger | parts that must slide or turn: screws through clearance holes, link eyes on pins | | Transition | about the same size | located parts that can still be pushed in by hand | | Interference (press) | hole always smaller | parts that must never move in their seat: bearing outer rings, sleeves | A bearing must turn on its **balls**, not in its seat. If the outer ring creeps in a loose seat, the plastic wears and the joint develops play. If the seat is far too tight, the printed part cracks, or the outer ring is squeezed so the bearing binds. ### Strido bearing seats `tolerances.yaml` allows a CAD seat bore between **0.15 mm under** and **0.30 mm over** the bearing's outer diameter. FDM printers print holes about **0.15 mm undersize**, so these CAD bands already include the expected shrink. All 32 seats pass (`docs/verification/fits.md`): | Bearing | OD | CAD seats measured | Difference to OD | |---|---|---|---| | 625ZZ (outer shoulder frame) | 16.0 mm | 16.17–16.21 mm | +0.17 to +0.21 mm | | 633ZZ (tibia halves) | 13.0 mm | 13.18–13.23 mm | +0.18 to +0.23 mm | | 6805-2RS (shoulder crank) | 37.0 mm | 36.92–36.95 mm | −0.05 to −0.08 mm | | Sleeve, short link | 5.0 mm | 4.98–4.99 mm | about −0.02 mm | | Sleeve, long link | 5.0 mm | 4.85–4.95 mm | −0.05 to −0.15 mm | If a printed seat comes out loose and the bearing spins, a drop of CA glue fixes it (`tolerances.yaml`). ### Screws in printed holes The same shrink affects every screw hole. The fits check samples 223 screws: 0 fail, 137 warnings, each with an action. - **Clearance holes** that print tight: run a 3.2 mm drill (ISO 273 fine series for M3) through once. - **Thread-forming holes**, where the screw cuts its own thread in plastic: the printed pilot should be 0.70–0.90 × the screw diameter. Above that the screw grips weakly: tighten by feel, and if it spins, harden the pilot with a drop of thin CA glue and re-drive (anaerobic thread locker does not cure on plastic). The upstream servo mounts sit just above the band (ADR-009). Print them with −0.15 mm X-Y hole compensation, or CA-harden their pilots. - **Lock-nut joints** in the linkage: tighten only until the joint is free of play. The links must still swing under their own weight (`docs/assembly/README.md`, Step 12). ## Why tolerances matter so much on printed parts `tolerances.yaml` assumes each printed surface can deviate by about 0.15 mm (0.4 mm nozzle, 0.2 mm layers). That sounds small, but leg joints act as levers. Suppose the thigh joint has a hypothetical 0.2 mm of play, measured 24 mm from its axis (the radius of the knee-drive cranks): $$ \Delta\theta \approx \frac{s}{r} = \frac{0.2\,\mathrm{mm}}{24\,\mathrm{mm}} \approx 0.0083\,\mathrm{rad} \approx 0.48^\circ $$ This is the small-angle form of arc length, $s = r\,\Delta\theta$. The foot can be up to $l_1 + l_2 = 130\,\mathrm{mm} + 124\,\mathrm{mm} = 254\,\mathrm{mm}$ from the thigh axis, so the same angle moves it by $$ \Delta x \approx \Delta\theta \,(l_1 + l_2) \approx 0.0083 \times 254\,\mathrm{mm} \approx 2.1\,\mathrm{mm} $$ Small errors in several joints add up, and the foot then lands somewhere other than where the kinematics expected. The same deviations can also make moving parts rub. The moving-clearance check therefore treats any gap below **0.3 mm** between parts on different links as a failure (it could close after printing) and below **1.0 mm** as tight (it may rub on a rough print). Across 13 poses Strido has **0 failures** and **4 tight pairs**: the front and back shells against the arm horns and shoulder cranks at full hip abduction, 0.73–0.79 mm (`docs/verification/clearance.md`). ## Check yourself 1. Why is the knee servo placed in the leg module instead of at the knee? 2. Why does moving only the thigh servo change the calf angle? 3. What goes wrong if a 633ZZ seat prints far too loose? Far too tight? 4. A screw hole for an M3 clearance fit prints at 2.95 mm. What do you do? 5. Estimate the foot wobble from 0.1 mm radial play at the 24 mm tibia crank, with the foot 124 mm from the knee.
Answers 1. To keep mass and inertia out of the swinging lower leg, so the leg can accelerate faster with the same servo torque. 2. The four-bar that drives the tibia is anchored on the femur, so rotating the femur changes the linkage geometry and therefore the knee angle. 3. Too loose: the outer ring turns in the plastic, wears the seat and adds play (fix with a drop of CA glue). Too tight: the seat cracks or the squeezed outer ring makes the bearing bind. 4. Run a 3.2 mm drill through it once (ISO 273 fine clearance for M3). 5. $\Delta\theta \approx 0.1 / 24 \approx 0.0042\,\mathrm{rad}$; $\Delta x \approx 0.0042 \times 124\,\mathrm{mm} \approx 0.5\,\mathrm{mm}$ at the foot.
## Further reading - [Four-bar linkage (overview and transmission angle)](https://en.wikipedia.org/wiki/Four-bar_linkage) - [Deep groove ball bearings (SKF)](https://www.skf.com/group/products/rolling-bearings/ball-bearings/deep-groove-ball-bearings) - Related pages: [Servos](servos.md), [Kinematics and gaits](../software/kinematics-and-gaits.md), [3D printing](../making/3d-printing.md), [Fasteners](../making/fasteners.md), [CAD as code](../software/cad-as-code.md), [Assembly guide](../../assembly/README.md), [Strength, torque and failure](../making/strength.md) - Theory: [Kinematics](../theory/kinematics.md), [Statics and dynamics](../theory/statics-and-dynamics.md) --- # Microcontrollers and the Raspberry Pi Strido has two computers. A Raspberry Pi 5 runs Linux and ROS 2 and decides *where the legs should go*. A Seeed XIAO ESP32-S3 microcontroller runs one small program that turns those targets into servo pulses every 5 ms and keeps the robot safe. This page explains why the work is split this way and how data flows between them. ::: tip In Strido - **Raspberry Pi 5, 4 GB** with Active Cooler (`pi5_4gb`, `pi5_cooler`): ROS 2 Jazzy in Docker or native, running `gait_node` / `policy_node`, `mcu_bridge`, `sllidar_ros2` and optionally `camera_ros` (`ros2/strido_control`, `ros2/strido_bringup`). - **Seeed XIAO ESP32-S3** (`xiao_esp32s3`): firmware in `firmware/strido_esp32s3` (PlatformIO, Arduino-ESP32 3.x). Pins are in `src/board_config.h`, mirrored and checked against `hardware/electronics.yaml`. - **PCA9685 16-channel PWM driver** (`pca9685`, Adafruit 815 layout) at I²C address 0x40: generates the 12 servo pulses. - **Link:** USB CDC serial, Pi USB-A → XIAO USB-C (W14), device name `/dev/strido_mcu` from `ros2/strido_bringup/config/99-strido.rules`. ::: ## Two kinds of computer | | Raspberry Pi 5 | XIAO ESP32-S3 | |---|---|---| | Processor | Broadcom BCM2712, 4 × Arm Cortex-A76 at 2.4 GHz | Espressif ESP32-S3, 2 × Xtensa LX7 at up to 240 MHz | | Memory | 4 GB RAM, OS on a 64 GB microSD | 8 MB PSRAM, 8 MB flash | | Operating system | Linux (Ubuntu Server 24.04 or Raspberry Pi OS) | FreeRTOS underneath the Arduino core | | Strength | big software: ROS 2, Docker, Python, neural-network policy, LiDAR and camera drivers | predictable timing, instant start-up, direct control of pins and buses | | Weakness | timing is not guaranteed; boot takes tens of seconds; SD card can be corrupted by power loss | little memory, no OS services, no ROS | | Power | 5–7 W typical, 12 W peak with cooler | about 1 W including LCD, IMU and PCA9685 logic | A **microcontroller** (MCU) is a whole computer on one chip, built to run one program forever and react to hardware events within microseconds. A **single-board computer** like the Pi is a small PC: a general-purpose operating system shares the CPU between many processes. ## Real-time vs "fast on average" A control loop is **real-time** when it meets its deadline *every* cycle, not just on average. The servo loop in Strido has a 5 ms deadline (`kControlPeriodUs = 5000`): the period and the loop rate are reciprocals, $T = 1/f = 1/200\,\mathrm{Hz} = 5\,\mathrm{ms}$. Why the period matters to a controller is covered in [control](../theory/control.md). Linux is fast but not deterministic. The scheduler, a USB interrupt, an SD-card write, Python's garbage collector or a busy ROS node can delay a process by several milliseconds. A late servo update shows up as a jerk in the leg. A frozen process on the Pi must also never leave twelve 3.43 N·m servos holding their last command forever. The XIAO runs only its own firmware, split into two FreeRTOS tasks pinned to separate cores (`src/main.cpp`): | Task | Core | Priority | Period | Work | |---|---|---|---|---| | `controlTask` | 1 | 3 (higher) | 5 ms | read IMU, update orientation, read INA260 every 100 ms, run the safety state machine, compute and write 12 servo pulses | | `commsTask` | 0 | 2 | ~1 ms poll | parse incoming frames, send a STATE frame every 10 ms (100 Hz), update the LCD face every 50 ms | Splitting the cores means a burst of serial traffic can never delay the servo update. ## Why both, and not one or the other - **Pi only?** The Pi would have to generate pulses and enforce safety from Linux, with no timing guarantee. A Pi crash or a reboot would leave the servos in an unknown state. - **ESP32 only?** It cannot run ROS 2, Nav2, the LiDAR driver, the camera stack or the learned policy with its numpy runtime. - **Both:** each does what it is good at. The Pi plans at 100 Hz (`gait_node`, `rate_hz: 100`) or 50 Hz (`policy_node`). The XIAO executes at 200 Hz and owns everything safety-critical. ### What the XIAO guarantees on its own These hold no matter what the Pi is doing (`docs/software.md`, `safety.h`): - **Watchdog:** no joint command for 200 ms → hold the pose; 2 s more → relax (outputs off). Only joint commands feed the watchdog, so if the gait node dies, the robot holds, then goes limp. - **Latched e-stop:** `/strido/estop` pulls the PCA9685 output-enable pin high. All pulses stop at once, and the stop stays latched until released. - **Battery cut-off:** warning at 6.8 V; relax and refuse to drive after 5 readings below 6.4 V. - **Limits:** every target is rate-limited to 6 rad/s and clamped to the joint and knee-crank limits from the CAD range-of-motion sweep. Non-finite values (NaN) are rejected. - **No INA260, no driving:** without a power monitor there is no cut-off. ## The PCA9685 PWM driver Why not let the ESP32 generate the twelve pulses directly? 1. **Pins.** The XIAO exposes 11 pads (D0–D10). Nine are already used, for the LCD (6), I²C (2) and the output-enable line (1). Twelve more pulse outputs are not available. 2. **Offloading.** The PCA9685 holds a 12-bit on/off count per channel and repeats the pulses by itself at 200 Hz. The firmware only writes a new value when a target changes. 3. **One kill line.** Its active-low **OE** (output enable) pin switches all 16 outputs off at once, in hardware. 4. **Continuity with upstream.** Strido keeps the upstream Yertle channel map (channels 4–15), so wiring knowledge carries over. ::: warning The OE pull-up The PCA9685 board pulls OE **low**, which means "outputs on". While the XIAO resets or sits in its bootloader, its D3 pin floats, and the servos would follow whatever the PCA9685 last held. A **2.2 kΩ resistor from OE to VCC** keeps the outputs off until the firmware actively enables them. The smoke test checks it: in the bootloader, OE must read ≥ 2.4 V (`docs/electronics.md`, harness step 8). ::: ## Data flow ```mermaid flowchart LR CMD["/cmd_vel
teleop, Nav2"] --> GAIT["gait_node 100 Hz
or policy_node 50 Hz"] GAIT -->|"/leg_controller/commands
12 joint angles"| BR["mcu_bridge"] BR -->|"USB CDC: JOINT_COMMAND
COBS + CRC16"| CT["XIAO controlTask 200 Hz
rate limit, linkage, clamp"] CT -->|"I²C 400 kHz"| PCA["PCA9685"] -->|"PWM 200 Hz"| SV["12 servos"] IMU["LSM6DSOX"] -->|"I²C"| CT INA["INA260"] -->|"I²C"| CT CT -->|"STATE 100 Hz"| BR BR --> TOP["/joint_states /imu/data
/battery_state /diagnostics"] ``` One cycle, end to end: 1. **Plan.** `gait_node` turns the velocity command into 12 joint angles in radians, Go2 order FL, FR, RL, RR × hip, thigh, calf. 2. **Send.** `mcu_bridge` packs them into a `JOINT_COMMAND` frame: sequence number, mode, 12 × float32. It adds a CRC16 checksum, COBS-encodes the frame and writes it to `/dev/strido_mcu`. 3. **Execute.** On its next 5 ms tick the XIAO: - rate-limits the targets; - converts calf angle to knee-crank angle through the linkage model (`jointsToActuators`); - clamps to the joint limits; - converts each angle to a pulse with the per-joint calibration; - writes the pulses to the PCA9685. 4. **Report.** Every 10 ms the XIAO sends a 103-byte `STATE` payload: the applied joint angles, the orientation quaternion, gyro and accelerometer readings, bus voltage, current and status flags. `mcu_bridge` publishes them as ROS topics. Because both sides share one protocol definition (`packages/strido-core/src/strido/protocol.py` ⇄ `firmware/.../protocol.h`), the same frames work from ROS, from `strido-cli` on a laptop and from the NT-X adapter. ## Check yourself 1. What happens to the servos 200 ms and 2.2 s after `gait_node` crashes? 2. Name two reasons the servo pulses come from a PCA9685 rather than directly from the XIAO. 3. Why is the control task on its own core with a higher priority than the comms task? 4. What does the 2.2 kΩ resistor on the PCA9685 OE pin prevent? 5. Which component converts the calf angle into a knee-crank angle, and why is it not done on the Pi?
Answers 1. After 200 ms without commands the servos hold the last pose. After 2 s more (2.2 s in total) they relax, with outputs off. 2. Any two of: the XIAO has too few free pins; the PCA9685 repeats the pulses in hardware; its OE pin kills all outputs at once; it keeps the upstream channel map. 3. So serial traffic and LCD updates can never delay the 5 ms servo deadline. 4. Servos being driven while the XIAO resets or sits in its bootloader, when its D3 pin is not actively holding OE high. 5. The XIAO firmware (`jointsToActuators` in `servo_map.cpp`). Doing it next to the clamping and pulse generation lets the MCU enforce knee-crank limits itself, whatever the Pi sends. The same maths also exists in `strido-core` for simulation.
## Further reading - [ESP32-S3 datasheet (Espressif)](https://www.espressif.com/sites/default/files/documentation/esp32-s3_datasheet_en.pdf) - [Seeed XIAO ESP32-S3 getting started](https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/) - [Raspberry Pi 5 product page](https://www.raspberrypi.com/products/raspberry-pi-5/) - [PCA9685 datasheet (NXP, Adafruit mirror)](https://cdn-shop.adafruit.com/datasheets/PCA9685.pdf) - Related pages: [Buses](buses.md), [Servos](servos.md), [Firmware](../software/firmware.md), [ROS 2](../software/ros2.md), [Software](../../software.md), [Control theory](../theory/control.md) --- # Power distribution Power distribution takes one battery and turns it into the right voltage for every part, with protection against the faults that would otherwise melt a wire or kill a board. Strido splits its supply into a 6.0 V **servo domain** and a 5 V / 3.3 V **logic domain** that share one ground; this page follows the current from the XT60 to the last servo. ::: tip In Strido - **Source of truth:** `hardware/wiring.yaml` (every cable W00–W23 and the servo leads, routed through the CAD; cut list in the [wiring check](../../verification/wiring.md)), `hardware/electronics.yaml` (`power`, `rails`, `supplies`, `fuses`, `wire_ampacity_a`) and `docs/electronics.md` (wiring list, ratings table). `python -m strido_verify.electronics` reads the power cables from `wiring.yaml` and checks every wire, fuse and converter against its load (`docs/verification/electronics.md`). How the harness is laid out: [cable management](../making/cable-management.md). - **Parts:** XT60H connectors (battery input, arming port, loop key), 2 × inline ATO fuse holder with 25 A and 7.5 A fuses, 2 × Hobbywing UBEC-10A (6.0 V), 8 × WAGO 221-415, Pololu #2813 pushbutton power switch, Adafruit INA260, Pololu D36V50F5 5 V regulator, 14 / 18 / 22 AWG silicone wire. - **Rails:** `pack` 6.0–8.4 V, `servo_6v` 5.4–6.0 V, `logic_5v` 4.8–5.2 V, `logic_3v3` 3.2–3.4 V. ::: ## The power tree ```mermaid flowchart LR BAT["2S LiPo
6.4–8.4 V"] --> XT["XT60 battery input"] -->|"W01 14 AWG, unfused"| F25["25 A main fuse"] F25 -->|"14 AWG"| ARM["XT60 arming port
+ loop key"] ARM --> UF["UBEC front 6.0 V"] --> SF["FL + FR servos"] ARM --> UR["UBEC rear 6.0 V"] --> SR["RL + RR servos"] F25 -->|"holder lead 14 AWG"| F7["7.5 A logic fuse"] --> SW["Pololu #2813
switch"] --> INA["INA260
shunt"] --> REG["D36V50F5
5 V"] REG --> PI["Raspberry Pi 5"] PI -->|"USB 5 V"| XIAO["XIAO ESP32-S3"] PI -->|"USB 5 V"| LID["RPLIDAR C1"] XIAO -->|"3.3 V"| L3["PCA9685, IMU,
INA260 logic, LCD"] ``` Two branches leave the main fuse: - **Servo branch:** arming port → two UBECs → WAGO buses → 12 servos. It carries up to about 21 A and is switched only by the loop key. - **Logic branch:** logic fuse → pushbutton switch → INA260 → 5 V regulator → Pi 5, which feeds the XIAO and LiDAR over USB. The XIAO's own 3.3 V regulator then powers the I²C boards and the LCD. This branch draws at most about 2.7 A. The split exists because the two loads are very different. Servos draw large, spiky currents that make the pack voltage sag. A Raspberry Pi browns out and corrupts its SD card if its supply dips. Separate regulators keep servo transients away from the computer, and you can disarm the motors while the computer keeps running. ## Connectors and the arming loop ### XT60 The XT60 is the standard high-current RC connector, rated 30 A continuous. Strido uses three XT60H halves: | Connector | Role | |---|---| | Battery input | under the deck, in the pocket beside the battery; the pack's own lead (W00) plugs into it | | Arming port | in the back shell, bezel `arming_port_bezel`; its two contacts are the two ends of the servo feed | | Loop key | a loose XT60 with a 14 AWG bridge: plugging it in closes the servo branch | The **loop key** is a physical, visible kill switch for every servo. Pull it out and no servo can get power, whatever the software does. The pushbutton switch is *not* an emergency stop: Pololu says so explicitly, and it only switches the logic branch. ::: danger The loop key must never fit the battery The loop key is a shorted connector. If it mated with the pack, it would short the battery. Give it the **same contact type as the pack's own connector** (pins or sockets), so it physically cannot plug in. Test with an *unbridged* housing before soldering the bridge (`docs/electronics.md`, "XT60 contact rule"). ::: ### Arming order The safe start-up order (`docs/electronics.md`, safety rules): 1. Logic on (pushbutton). Pi and XIAO boot, and the servos stay limp. 2. Robot lying in the rest pose, legs folded. 3. Insert the loop key. The servos still get no pulses: the PCA9685 output-enable line is held off. 4. Start `ros2 launch strido_bringup robot.launch.py`: rest pose, then a 3 s stand-up. Never insert the key while position commands are running: all twelve servos would jump at once. ## Fuses: protecting the wire, not the device A fuse exists to stop a **wire** from overheating in a short circuit. Its rating must satisfy both: $$ I_\mathrm{load} \le I_\mathrm{fuse} \le I_\mathrm{wire} $$ (load current, fuse rating, and the current the wire can carry). | Fuse | Rating | Protects | Worst-case load | Wire limit | |---|---|---|---|---| | Main (ATO) | 25 A | W02, W03, W04/front, W04/rear (14 AWG) | 23.6 A (servo + logic) | 32 A | | Logic (ATO) | 7.5 A | W09, W10, W11 and the logic ground returns W24, W04/logic (18 AWG) | 2.74 A | 16 A | The "wire limit" is that of the *thinnest* wire behind the fuse. The logic fuse holder is fed through its **own 14 AWG lead**, spliced to W02 in the back shell, so no wire thinner than 14 AWG sits behind the 25 A fuse alone, and everything after the 7.5 A fuse is at least 18 AWG. (An earlier design used a 60 mm 18 AWG tap there: covered only by the 25 A fuse, above that wire's 16 A, which is why it was replaced.) W01, from the battery input to the main fuse, is **unfused**, like the pack's own lead. The fuse holder sits on the rear WAGO bus where its 14 AWG leads have room to bend, about 0.15 m from the battery input, so W01 is kept that short, in spiral wrap and clear of moving parts. Details: [cable management](../making/cable-management.md#fusing-a-fuse-protects-the-wire). ## Regulators: getting the right voltage | Converter | Type | Output | Rating | Feeds | |---|---|---|---|---| | Hobbywing UBEC-10A ×2 | switching buck, 2–6S input | 6.0 V (jumper open) | 10 A continuous, 15 A peak each | 6 servos each | | Pololu D36V50F5 | switching buck, 5.5–50 V input | 5 V ± 4 % | 5.5 A | Pi 5 (and via USB: XIAO, LiDAR) | | XIAO ESP32-S3 on-board | linear regulator | 3.3 V | small | PCA9685 VCC, LSM6DSOX, INA260 VS, LCD | ### Why switching regulators Two laws underlie every number on this page. **Ohm's law** relates the voltage across a resistance to the current through it, and electrical **power** is voltage times current: $$ V = I R, \qquad P = V I = I^2 R = \frac{V^2}{R} $$ A **linear** regulator is a variable resistor in series with the load: the full load current flows through it while it drops the voltage difference, so it burns $P_\mathrm{loss} = (V_\mathrm{in} - V_\mathrm{out})\, I$ as heat. Dropping 7.4 V to 5 V at 3 A would waste: $$ P_\mathrm{loss} = (7.4\,\mathrm{V} - 5\,\mathrm{V}) \times 3\,\mathrm{A} = 7.2\,\mathrm{W} $$ That is more than the Pi itself uses on average. A **buck** (step-down switching) converter chops the input with a transistor and smooths it with an inductor and capacitor. An ideal buck switched with duty cycle $D$ outputs $V_\mathrm{out} = D\,V_\mathrm{in}$, and because energy is conserved, $V_\mathrm{in} I_\mathrm{in} = V_\mathrm{out} I_\mathrm{out}/\eta$ with efficiency $\eta$: it converts power instead of burning it. The verification assumes 90 % efficiency (`converter_efficiency: 0.9`). Converted back to pack current at the lowest pack voltage, the logic branch's 15.8 W peak at 5 V becomes: $$ I_\mathrm{pack} = \frac{P_\mathrm{out}}{\eta\, V_\mathrm{pack}} = \frac{15.8\,\mathrm{W}}{0.9 \times 6.4\,\mathrm{V}} \approx 2.74\,\mathrm{A} $$ That is the figure the switch, the INA260 and the logic fuse are checked against. ### Dropout A buck can only step **down**. Its output stays a little below its input when the input approaches the target. With an empty pack (6.4 V) and 10 A, the 6.0 V servo rail is expected to fall to about 5.4 V ([ESTIMATE] in `rails.servo_6v`). This is one reason why a 7.4 V servo rail is impossible from 2S, as `docs/bom/sources.md` points out. ### The Pi's 5 V feed The Pi 5 is fed through its GPIO 5 V pins (2 and 4, ground on 6 and 14), not through USB-C. That bypasses USB Power Delivery, so the Pi does not know its supply can deliver 5 A. Tell it: ```bash sudo rpi-eeprom-config --edit # add: PSU_MAX_CURRENT=5000 ``` Without this setting the Pi limits the current its USB ports may draw, and the LiDAR and XIAO hang off those ports. ## Wire gauge and voltage drop **AWG** (American Wire Gauge) numbers go *down* as wires get thicker. A conductor's resistance grows with its length $L$ and shrinks with its cross-section $A$: $$ R = \frac{\rho L}{A} $$ with copper's resistivity $\rho \approx 1.72 \times 10^{-8}\,\Omega\,\mathrm{m}$. For 18 AWG, $\rho / A = 1.72 \times 10^{-8}\,\Omega\,\mathrm{m} / 0.82 \times 10^{-6}\,\mathrm{m^2} \approx 21\,\mathrm{m\Omega/m}$, the value in the table. Thick wire has lower resistance, so it heats less ($P = I^2 R$) and loses less voltage. | Gauge | Copper area (approx.) | Resistance (approx.) | Ampacity used by the check | Used for | |---|---|---|---|---| | 14 AWG | 2.1 mm² | 8.3 mΩ/m | 32 A | battery trunk W01–W04, logic fuse holder lead | | 18 AWG | 0.82 mm² | 21 mΩ/m | 16 A | UBEC output extensions W05/W06, bus jumpers W07/W08, logic branch W09–W11, logic grounds W24 and W04/logic | | 20 AWG | 0.52 mm² | 33 mΩ/m | 11 A | UBEC output leads | | 22 AWG | 0.33 mm² | 53 mΩ/m | 7 A | Pi 5 V feed W12, servo leads (the servos' own 260 mm leads, cut to length; no extensions), signals | The ampacity values come from `wire_ampacity_a` in `hardware/electronics.yaml`. They are deliberately conservative for short chassis runs in free air. **Voltage drop** follows Ohm's law, $\Delta V = I R$, and current flows *out and back*, so count both conductors. The same current heats the wire with $P = I^2 R$: power loss grows with the *square* of current, which is why the high-current servo branch gets the thickest, shortest wires. Lengths below come from the cut list in the [wiring check](../../verification/wiring.md). Taking the cut length as the copper in circuit gives an upper bound. - **W05**, UBEC front to WAGO bus: 18 AWG pair, routed 190 mm, cut 222 mm, at 10 A. $$ R = 0.222\,\mathrm{m} \times 21\,\mathrm{m\Omega/m} \approx 4.7\,\mathrm{m\Omega}\ \text{per conductor} $$ $$ \Delta V \approx 10\,\mathrm{A} \times 4.7\,\mathrm{m\Omega} \times 2 \approx 93\,\mathrm{mV}, \qquad P \approx (10\,\mathrm{A})^2 \times 9.3\,\mathrm{m\Omega} \approx 0.93\,\mathrm{W} $$ - **A servo lead's power branch**: the servo's own 22 AWG red/brown pair from the servo to its WAGO pair, no extension. The longest one is the front-left hip, 60 mm to its tie plus 88 mm to the WAGO = 148 mm routed, at the 3.5 A stall current. $$ R = 0.148\,\mathrm{m} \times 53\,\mathrm{m\Omega/m} \approx 7.8\,\mathrm{m\Omega}\ \text{per conductor} $$ $$ \Delta V \approx 3.5\,\mathrm{A} \times 7.8\,\mathrm{m\Omega} \times 2 \approx 55\,\mathrm{mV} $$ A 300 mm extension of the same wire, as the robot once used, would have added about 0.11 V on top, and a plug and socket per servo. - **W12**, the Pi feed: 2 × 22 AWG in parallel per rail, routed 104 mm, cut 123 mm, 1.6 A per wire ($0.123\,\mathrm{m} \times 53\,\mathrm{m\Omega/m} \approx 6.5\,\mathrm{m\Omega}$ per wire). $$ \Delta V \approx 1.6\,\mathrm{A} \times 6.5\,\mathrm{m\Omega} \times 2 \approx 21\,\mathrm{mV} $$ Short, adequately thick wires keep every drop well below 0.2 V. The Pi feed is doubled up anyway, because the Pi flags under-voltage below 4.63 V and the regulator's lower tolerance is already 4.8 V. ## Measuring power: the INA260 The INA260 is a current, voltage and power monitor with a built-in 2 mΩ shunt resistor. It sits **high-side** in the logic branch: current enters at VIN+, leaves at VIN−, and the chip measures the tiny voltage across its internal shunt, $V_\mathrm{shunt} = I R_\mathrm{shunt}$, and divides by the known $R_\mathrm{shunt} = 2\,\mathrm{m\Omega}$ to get the current. At the logic branch's 2.74 A that is only $5.5\,\mathrm{mV}$, and the shunt dissipates $I^2 R \approx 15\,\mathrm{mW}$. | What it measures | Why | |---|---| | Bus voltage at VIN− (= pack voltage minus small losses) | the low-battery warning (6.8 V) and cut-off (6.4 V) | | Logic-branch current (1.25 mA resolution, up to 15 A) | logic power use in `/battery_state` | It does **not** see the servo current, which bypasses it through the arming port. Its job here is mainly the voltmeter that protects the pack. The firmware therefore refuses to drive the servos if the INA260 does not answer on I²C at address 0x41: without it there is no low-battery cut-off. ## Grounding All domains share **one ground**, joined in a star at the battery input (W04: battery input − → ground star beside it → both UBEC inputs in 14 AWG and the switch ground in 18 AWG). The regulator and INA260 grounds go to the switch ground, and the Pi's ground pins to the regulator. A common ground matters because a signal is only a voltage *relative to ground*. The PCA9685's 3.3 V pulse must be measured by the servo against the same reference. A star point keeps the large servo return currents from flowing through the thin logic grounds. If they did, the 21 A of six stalled servos would shift the logic reference by `I × R` and could make signals misread. ## Check yourself 1. Which components does pulling the loop key switch off, and which keep running? 2. Why may the logic fuse be 7.5 A but not 20 A? 3. Estimate the round-trip voltage drop of W06 (18 AWG pair, routed 151 mm) at 10 A. 4. Why does the firmware refuse to drive the servos without the INA260, even though the INA260 does not measure servo current? 5. Why is the Pi fed through two 5 V pins instead of one?
Answers 1. The loop key opens the servo branch, so the UBECs and all 12 servos lose power. The logic branch (Pi, XIAO, sensors, LCD) keeps running. 2. The fuse must not exceed what the protected 18 AWG wire can carry (16 A). 7.5 A is above the 2.74 A load and below 16 A; 20 A would let the wire overheat before the fuse blew. 3. $R \approx 0.151\,\mathrm{m} \times 21\,\mathrm{m\Omega/m} \approx 3.2\,\mathrm{m\Omega}$ per conductor; $\Delta V \approx 10\,\mathrm{A} \times 3.2\,\mathrm{m\Omega} \times 2 \approx 63\,\mathrm{mV}$ (with the 180 mm cut length as an upper bound, about 76 mV). 4. It measures the pack voltage. Without it the low-battery cut-off cannot work, and the pack could be over-discharged. 5. To halve the current per 22 AWG wire and Dupont contact (about 1.6 A each) and the voltage drop, keeping the Pi above its under-voltage threshold.
## Further reading - [TI INA260 datasheet](https://www.ti.com/lit/ds/symlink/ina260.pdf) - [Pololu D36V50F5 5 V 5.5 A step-down regulator](https://www.pololu.com/product/4091) - [Pololu Big Pushbutton Power Switch HP (#2813)](https://www.pololu.com/product/2813) - [Hobbywing UBEC-10A](https://www.hobbywing.com/en/products/ubec-10a-2-6s152) - Related pages: [Batteries](batteries.md), [Servos](servos.md), [Cable management](../making/cable-management.md), [Electronics, wiring and power](../../electronics.md#wiring-harness), [wiring check](../../verification/wiring.md), [Safety](../making/safety.md) - Horowitz and Hill, *The Art of Electronics*, 3rd ed., Cambridge University Press (Ohm's law, regulators, switching converters) --- # Sensors Sensors are how the robot knows anything about itself and its surroundings. Hobby servos report nothing back, so Strido relies on four sensors: an IMU for body orientation, a 2D LiDAR for distances, a wide-angle camera for images and a power monitor for the battery. This page explains how each one works, where it sits and which software uses its data. ::: tip In Strido | Sensor | BOM line | Connected to | ROS 2 topic | Frame | |---|---|---|---|---| | Adafruit LSM6DSOX 6-DoF IMU (4438) | `lsm6dsox` | XIAO, I²C 0x6A | `/imu/data` | `imu_link` | | Slamtec RPLIDAR C1 + USB adapter | `rplidar_c1` | Pi USB, `/dev/rplidar` | `/scan` | `lidar_link` | | Raspberry Pi Camera Module 3 Wide | `camera_module_3_wide` | Pi CSI, 22→15 pin FFC | `/camera/image_raw` | `camera_optical_frame` | | Adafruit INA260 power monitor (4226) | `ina260` | XIAO, I²C 0x41 | `/battery_state` | — | Field-of-view checks: `docs/verification/sensors.md`. Firmware set-up: `firmware/strido_esp32s3/src/main.cpp`. ::: ## IMU: LSM6DSOX accelerometer and gyroscope An **IMU** (inertial measurement unit) measures motion from the inside, with no external reference. The LSM6DSOX is a 6-DoF IMU: two 3-axis MEMS sensors in one chip. | Sensor | Measures | Unit | Strido setting | |---|---|---|---| | Accelerometer | specific force: gravity plus any acceleration | m/s² (g) | ±8 g range, 416 Hz output data rate | | Gyroscope | angular velocity about each axis | rad/s (°/s) | ±1000 °/s range, 416 Hz output data rate | The firmware reads both every 5 ms (200 Hz). Running the chip at 416 Hz means every read gets a fresh sample. ### Mounting and axes The IMU sits in a pocket in the `electronics_deck`, chip side up with its STEMMA QT sockets towards the rear, held by two M2.5 × 6 screws. Mounted that way, its x axis points to the robot's left and its y axis to the rear. The firmware rotates every reading into the robot's `base_link` convention (x forward, y left, z up, REP-103): $$ \begin{pmatrix} x \\ y \\ z \end{pmatrix}_\mathrm{base} = \underbrace{\begin{pmatrix} 0 & -1 & 0 \\ 1 & 0 & 0 \\ 0 & 0 & 1 \end{pmatrix}}_{\mathbf{R}_\mathrm{base,imu}} \begin{pmatrix} x \\ y \\ z \end{pmatrix}_\mathrm{imu} $$ that is, $x_\mathrm{base} = -y_\mathrm{imu}$, $y_\mathrm{base} = x_\mathrm{imu}$, $z_\mathrm{base} = z_\mathrm{imu}$. The matrix $\mathbf{R}_\mathrm{base,imu}$ is a rotation of $+90^\circ$ about $z$; the same matrix is applied to both the accelerometer and the gyro vectors. Rotation matrices are explained in [frames and rotations](../theory/frames-and-rotations.md). A wrong sign here would make the robot believe it is tilting the other way, the classic first bug in any balance controller. ### From two sensors to one orientation Neither sensor alone gives a good orientation: - **The accelerometer** at rest measures only gravity, so roll and pitch follow directly from which way "down" points. While walking, every footstep adds acceleration, so this estimate is noisy. - **The gyroscope** is smooth and fast. Integrating angular velocity over time gives the angle change, but any small bias integrates into a steadily growing **drift**. **Sensor fusion** combines the two: trust the gyro over short times, and let the accelerometer slowly pull the estimate back towards true "down". The simplest version is the **complementary filter** for one angle $\theta$ (say pitch), updated every time step $\Delta t$: $$ \hat\theta_k = \alpha\left(\hat\theta_{k-1} + \omega_k\,\Delta t\right) + (1 - \alpha)\,\theta_{\mathrm{acc},k}, \qquad \theta_\mathrm{acc} = \operatorname{atan2}\!\left(-a_x,\ \sqrt{a_y^2 + a_z^2}\right) $$ The first term integrates the gyro rate $\omega$; the second is the tilt computed from the gravity direction in the accelerometer reading $\mathbf{a}$. With $\alpha$ close to 1 the filter is a high-pass on the gyro and a low-pass on the accelerometer, which is where the name comes from: the two filters add up to one. The crossover time constant is $\tau = \alpha\,\Delta t / (1 - \alpha)$; for example $\alpha = 0.98$ at $\Delta t = 5\,\mathrm{ms}$ gives $\tau \approx 0.25\,\mathrm{s}$, so gyro drift is corrected within about a quarter of a second while footstep jolts shorter than that are smoothed out. Strido runs a **Mahony filter** (`Adafruit_Mahony`) on the XIAO at 200 Hz, the 3D, quaternion form of the same idea. It integrates the gyro and feeds the error $\mathbf{e} = \mathbf{g}_\mathrm{meas} \times \hat{\mathbf{g}}$ between the predicted and measured gravity direction back as a correction to the rate: $$ \boldsymbol{\omega}_\mathrm{corr} = \boldsymbol{\omega}_\mathrm{gyro} + K_\mathrm{p}\,\mathbf{e} + K_\mathrm{i} \int \mathbf{e}\,\mathrm{d}t $$ The proportional gain $K_\mathrm{p}$ sets how hard the accelerometer pulls, and the optional integral gain $K_\mathrm{i}$ can learn a constant gyro bias. The firmware uses the library defaults: $K_\mathrm{p} = 0.5$ and $K_\mathrm{i} = 0$, so no bias learning. [State estimation](../theory/state-estimation.md) derives both filters and compares them with the Kalman filter. The result is a **quaternion**, a four-number representation of 3D orientation that has no gimbal-lock problems. It travels in the `STATE` frame at 100 Hz and is published as `/imu/data` together with the raw gyro and acceleration. ::: warning Yaw is not observable Gravity tells you roll and pitch but says nothing about heading (yaw). Without a magnetometer, the IMU's yaw estimate drifts. For heading over time, use the LiDAR (scan matching / SLAM). ::: ### Who uses it - **`policy_node`** (learned controller) turns the quaternion into the gravity direction in the body frame and feeds it, with the gyro rates, into the policy's observation every 50 Hz step. It does not start until it has seen IMU data. - **`strido-cli monitor`** shows roll and pitch on the bench. - **`mcu_bridge`** never publishes a fake upright orientation: if the IMU reports a fault (`imu_ok` false), `/imu/data` is simply not published. ## LiDAR: RPLIDAR C1 A **2D LiDAR** spins a laser rangefinder and measures the distance to the nearest object at each angle in one horizontal plane. The C1 measures **time of flight** (DTOF): it times how long a 905 nm infrared laser pulse takes to come back. | Specification (Slamtec datasheet) | Value | |---|---| | Range | 0.05–12 m (white target), 0.05–6 m (black target) | | Sample rate | 5000 samples/s | | Scan frequency | 10 Hz typical (8–12 Hz) | | Angular resolution | 0.72° at 10 Hz | | Accuracy | ±30 mm | | Interface | 3.3 V TTL UART, 460 800 baud (here via a USB adapter) | | Supply | 5 V, about 230 mA running, about 800 mA at start-up | | Laser safety | IEC 60825 Class 1 | The numbers fit together: $\dfrac{5000\,\mathrm{samples/s}}{10\,\mathrm{scans/s}} = 500$ points per scan, and $360^\circ / 500 = 0.72^\circ$ between points. Time of flight turns into distance as $d = c\,\Delta t / 2$ (the light travels out and back); at the 12 m maximum range the round trip lasts only $\Delta t = 2d/c \approx 80\,\mathrm{ns}$. ### Placement A LiDAR sees nothing below or above its scan plane, and anything of the robot's own that crosses the plane blinds part of the scan. Strido lifts the C1 on a 14 mm `lidar_pedestal` on `top_shell_sensor`. Its scan plane sits 71.6 mm above the hip axes, about 254 mm above ground when standing at 170 mm. The ray-cast check in `docs/verification/sensors.md` blocks **0 of 720** test rays in every pose: stand, crouch, trot swing apex, maximum stride and hips abducted. ### Who uses it `sllidar_ros2` publishes `/scan` (`sensor_msgs/LaserScan`) at 10 Hz in `lidar_link`. The `/scan` topic follows the Go2 conventions, so standard ROS 2 tools run unchanged: SLAM to build a map, Nav2 for obstacle avoidance and path planning. This matters because `gait_node`'s `/odom` integrates only the *commanded* velocity (dead reckoning, no slip model). For real navigation, a LiDAR-based odometry or SLAM source should replace it (`gait_node.py` docstring). ## Camera: Camera Module 3 Wide | Specification (Raspberry Pi product brief) | Value | |---|---| | Sensor | Sony IMX708, 11.9 MP (4608 × 2592), back-illuminated, stacked | | Lens | 2.75 mm, F2.2 | | Field of view | 120° diagonal, 102° horizontal, 67° vertical | | Focus | phase-detection autofocus, 5 cm to infinity | | Video modes | 1080p50, 720p100, 480p120 | | Interface | MIPI CSI-2, 15-pin 1 mm FPC | The **Wide** variant was chosen because a small robot close to the ground sees more of its surroundings with a 102° horizontal view than with the standard module's 66°. ### Placement The camera sits on a tilting mount (`camera_mount_base` and `camera_tilt_plate`) on the flat top of the front shell. An M3 × 40 pivot with a lock nut gives a friction tilt. The first design put the camera on the top shell, where the ray-cast check found the robot's own nose filling **42 %** of the image. The new position shows **0 %** in every checked pose (`docs/design.md`, `docs/verification/sensors.md`). ### Who uses it `camera_ros` publishes `/camera/image_raw` in `camera_optical_frame`. On the Pi 5 the camera needs the Raspberry Pi fork of libcamera, so the node is built on the host and enabled with `camera:=true` (default false; the Docker image has no camera node). The camera is there for your own perception nodes: teleoperation video, object detection, visual navigation. ## Power monitor: INA260 The INA260 measures **voltage and current** with a built-in 2 mΩ shunt resistor, 1.25 mA current resolution, up to 15 A and 36 V. In Strido it sits high-side in the logic branch (see [Power](power.md)): - **Bus voltage = pack voltage** (minus tiny losses). The firmware reads it every 100 ms. It warns at 6.8 V and relaxes the servos after 5 consecutive readings below 6.4 V. - **Current = logic-branch current only.** The servo current bypasses it. `mcu_bridge` publishes both as `/battery_state` (`sensor_msgs/BatteryState`, technology LiPo, negative current = discharging). The firmware refuses to drive the servos without it, because a robot with no voltage sensor can drain its LiPo into damage. ## Summary: sensor to software | Sensor | Read by | Rate | Software that uses it | |---|---|---|---| | LSM6DSOX | XIAO, I²C | 200 Hz, published at 100 Hz | Mahony filter → `/imu/data` → `policy_node`, monitoring | | INA260 | XIAO, I²C | 10 Hz | low-battery warning and cut-off, `/battery_state` | | RPLIDAR C1 | Pi, USB serial | 10 Hz scans | `/scan` → SLAM, Nav2, obstacle avoidance | | Camera Module 3 Wide | Pi, CSI | video rate | `/camera/image_raw` → your perception nodes | ## Check yourself 1. Why can the accelerometer alone not give a reliable orientation while the robot trots? 2. In the Mahony filter, what does the proportional gain decide, and what would a non-zero integral gain add? 3. How many points does one C1 scan contain at 10 Hz, and what is their angular spacing? 4. Why was the camera moved from the top shell to the front shell? 5. Which robot heading information can the IMU *not* provide, and which sensor fills the gap?
Answers 1. While moving, it measures footstep accelerations on top of gravity, so the "down" direction it reports is noisy. 2. The proportional gain decides how strongly the accelerometer's gravity direction pulls the gyro-integrated estimate back. A non-zero integral gain would also learn a constant gyro bias; Strido leaves it at 0. 3. $5000 / 10 = 500$ points, $0.72^\circ$ apart. 4. On the top shell the robot's nose filled 42 % of the image; on the front shell it fills 0 %. 5. Yaw (heading) is not observable from gravity and drifts; the LiDAR (scan matching or SLAM) provides it.
## Further reading - [LSM6DSOX product page (STMicroelectronics)](https://www.st.com/en/mems-and-sensors/lsm6dsox.html) - [Adafruit LSM6DSOX guide](https://learn.adafruit.com/lsm6dsox-and-ism330dhc-6-dof-imu) - [RPLIDAR C1 datasheet (Slamtec)](https://wiki.slamtec.com/download/attachments/83066883/SLAMTEC_rplidar_datasheet_C1_v1.0_en.pdf?api=v2&modificationDate=1700531479533&version=1) - [Camera Module 3 product brief (Raspberry Pi)](https://pip.raspberrypi.com/documents/RP-008151-DS-camera-module-3-product-brief.pdf) - [Open-source IMU and AHRS algorithms (x-io, includes Mahony)](https://x-io.co.uk/open-source-imu-and-ahrs-algorithms/) - Related pages: [Buses](buses.md), [Power](power.md), [ROS 2](../software/ros2.md), [Kinematics and gaits](../software/kinematics-and-gaits.md) - Theory: [Frames and rotations](../theory/frames-and-rotations.md), [State estimation](../theory/state-estimation.md) --- # Servos A hobby servo is a small gearmotor with its own position controller: you send it a pulse width, it turns its output shaft to the matching angle and holds it there. Strido uses twelve of them, one per joint, and everything about how the robot moves starts with how these servos are driven. ::: tip In Strido - **Part:** SPT5435LV-180W digital servo, 13 bought (12 + 1 spare), BOM line `servo_spt5435lv` in `hardware/bom.yaml`. - **Joints:** hip (abduction), thigh and knee crank on each leg, FL, FR, RL, RR. PCA9685 channels 4–15 (`hardware/electronics.yaml`, `servos.channels`). - **Signal:** PCA9685 PWM driver at 200 Hz, 500–2500 µs, 3.3 V logic level, enabled through the XIAO D3 → PCA9685 OE line. - **Power:** the 6.0 V servo rail from two Hobbywing UBEC-10A, six servos each, through the WAGO buses in the front and rear cavities. - **Horns:** 25T aluminium disc horns (8) for hip and thigh, 25T aluminium clamping arm horns (4) for the knee crank. - **Code:** `firmware/strido_esp32s3/lib/strido_core/src/servo_map.cpp` (angle → pulse), `strido-cli center` / `calibrate`. ::: ## What is inside a hobby servo A servo packs four things into a 40.5 × 20 × 40.5 mm case (72 g for the SPT5435LV): 1. **DC motor.** The SPT5435LV uses a cored (iron-armature) motor. 2. **Gearbox.** All-metal gears trade speed for torque. The output shaft carries a 25-tooth spline (Futaba 25T) and runs in two ball bearings (2BB). 3. **Potentiometer.** It sits on the output shaft and turns shaft angle into a voltage, so the servo always knows where its output is. 4. **Control board.** It compares the commanded angle (from the pulse width) with the measured angle (from the potentiometer) and drives the motor to close the gap. This is a closed position loop *inside* the servo. The control loop never leaves the servo. The robot only *commands* angles and cannot read them back, so `/joint_states` reports the angles the firmware commanded after clamping, not measured ones (`docs/software.md`). ### Analogue vs digital An analogue servo updates its motor drive at the rate the pulses arrive. A digital servo has a microcontroller that samples the pulse and re-drives the motor at a much higher internal rate. The result is a stiffer hold, a smaller dead band and faster response. The cost is a higher idle current: the SPT page lists 100 mA quiescent. For a walking robot, the stiff hold is what keeps a leg from sagging under load. ## The pulse-width signal (PWM) The servo reads the **width** of a repeating logic pulse, not its frequency or duty cycle: | Pulse width | SPT5435LV-180W shaft | |---|---| | 500 µs | one end (−90° from neutral) | | 1500 µs | neutral point (centre) | | 2500 µs | other end (+90°) | Inside that window the mapping is linear: the angle $\theta$ away from neutral is proportional to how far the pulse width $t_\mathrm{p}$ is from $1500\,\mu\mathrm{s}$, $$ \theta = \frac{t_\mathrm{p} - 1500\,\mu\mathrm{s}}{k}, \qquad k = \frac{2500\,\mu\mathrm{s} - 500\,\mu\mathrm{s}}{180^\circ} \approx 11.1\,\mu\mathrm{s}/^\circ = \frac{2000\,\mu\mathrm{s}}{\pi\,\mathrm{rad}} \approx 636.6\,\mu\mathrm{s/rad} $$ The radian form of $k$ is the constant `kUsPerRad` in `servo_map.h`. The pulse repeats at a fixed rate. Classic RC gear sends one pulse every 20 ms (50 Hz). The SPT5435LV accepts up to 330 Hz, and Strido runs at **200 Hz** (`kServoFrequencyHz`), one new pulse every $T = 1/f = 5\,\mathrm{ms}$. That matches the firmware's 200 Hz control loop, so every new joint target reaches the servo in the next frame. In general PWM terms, a pulse of width $t_\mathrm{p}$ repeated every $T$ has a **duty cycle** $D = t_\mathrm{p}/T$. The neutral pulse at 200 Hz is $D = 1500\,\mu\mathrm{s} / 5000\,\mu\mathrm{s} = 30\,\%$, the extremes 10 % and 50 %. The servo ignores $D$ and times only $t_\mathrm{p}$, which is why changing the frame rate from 50 Hz to 200 Hz does not move the shaft, as long as the gap between pulses stays long enough for the servo to see each one separately. ::: warning Buy the right angle variant The same pulse range means different angles on different variants. On a 270° servo, 500–2500 µs would cover 270°, and every joint mapping would be wrong. The BOM requires the **-180W** variant; confirm the angle with any non-SPT seller (`hardware/bom.yaml`, note on `servo_spt5435lv`). ::: ### Resolution and dead band The **dead band** is the smallest change in pulse width the servo reacts to. The SPT page gives 4 µs, about 0.36°. The PCA9685 that generates the pulses divides each period into 4096 steps (12-bit): $$ \Delta t_\mathrm{step} = \frac{T}{2^{12}} = \frac{T}{4096} $$ $$ 200\,\mathrm{Hz}:\ \frac{5000\,\mu\mathrm{s}}{4096} \approx 1.22\,\mu\mathrm{s}, \qquad 50\,\mathrm{Hz}:\ \frac{20\,000\,\mu\mathrm{s}}{4096} \approx 4.88\,\mu\mathrm{s} $$ At 200 Hz one step is $1.22\,\mu\mathrm{s} / 636.6\,\mu\mathrm{s/rad} \approx 1.9\,\mathrm{mrad} \approx 0.11^\circ$ of shaft travel. At 50 Hz one step would already be wider than the dead band; at 200 Hz the driver resolves about three steps inside it. The higher frame rate buys both faster updates and finer positioning. ### Signal level The SPT page specifies a PWM signal level of 3.3–5.0 V. The PCA9685 runs from the XIAO's 3.3 V regulator, so its pulses are exactly 3.3 V high: in spec, but with no margin. `docs/verification/electronics.md` flags this check as "pass, no margin". If a servo jitters, measure the XIAO 3V3 rail first. ## Torque, speed and current | Specification (SPT spec page) | 4.8 V | 6.0 V | |---|---|---| | Stall torque | 29 kg·cm | 35 kg·cm | | Speed (no load) | 0.16 s / 60° | 0.14 s / 60° | | Rated current | | 1.4 A | | Stall (blocking) current | | 3.5 A | ### Stall torque in SI units Hobby servos quote torque in kg·cm: the weight of a mass in kilograms hanging from an arm in centimetres. Torque is force times lever arm, $\tau = F r$, and the force of 1 kg is its weight $m g$, so $$ 1\,\mathrm{kg\,cm} = 1\,\mathrm{kg} \times 9.81\,\mathrm{m/s^2} \times 0.01\,\mathrm{m} = 0.0981\,\mathrm{N\,m} $$ $$ 35\,\mathrm{kg\,cm} \times 0.0981\,\frac{\mathrm{N\,m}}{\mathrm{kg\,cm}} = 3.43\,\mathrm{N\,m} $$ **Stall torque** is the most the servo can push when it is not moving at all. It is a limit, not a working point. In MuJoCo the standing knee needs about **0.7 N·m**, roughly a fifth of stall (`docs/electronics.md`). That margin is what lets the robot trot, recover from a stumble and carry its 3.07 kg (`docs/design.md`). ### Current follows torque A DC motor's torque is proportional to its current, $\tau_\mathrm{m} = k_\mathrm{t}\, I$, with the motor's torque constant $k_\mathrm{t}$; the gearbox multiplies the motor torque by its ratio. So the current rises roughly in proportion to the torque the servo delivers, and at full stall torque the SPT5435LV draws its **stall current**, 3.5 A. This is why the power design counts stalls: $$ 6 \times 3.5\,\mathrm{A} = 21\,\mathrm{A} > 15\,\mathrm{A}\ \text{(UBEC peak rating)}, \qquad 12 \times 3.5\,\mathrm{A} = 42\,\mathrm{A}\ \text{total} $$ A servo pushing into a hard stop, or fighting another leg, sits at stall current and heats up fast. Two rules in the docs follow from this: - **Never** insert the arming key while position commands are running: all twelve servos would jump at once. - **Never** command all legs into hard stops. The firmware also limits joint speed to **6 rad/s** (`max_joint_speed_rad_s` in `safety.h`), below the servo's no-load speed at 6 V, $\omega_0 = 60^\circ / 0.14\,\mathrm{s} = (\pi/3)\,\mathrm{rad} / 0.14\,\mathrm{s} \approx 7.5\,\mathrm{rad/s}$. Sudden steps would otherwise pull bursts of near-stall current. A DC motor's torque falls roughly linearly from stall at zero speed to zero at $\omega_0$, $\tau(\omega) \approx \tau_\mathrm{stall}(1 - \omega/\omega_0)$; see [statics and dynamics](../theory/statics-and-dynamics.md). ## The 6 V servo rail The SPT5435LV is rated **4.8–6.0 V only**. The 2S LiPo swings from 8.4 V (full) to 6.4 V (cut-off), which is always above that range. A connection straight to the pack would over-volt every servo. Two Hobbywing UBEC-10A regulators therefore step the pack down to 6.0 V: - The UBEC's **output jumper stays open**: that selects 6.0 V. The 7.4 V setting would destroy the servos. - Measure every UBEC unloaded before connecting servos and accept only **5.8–6.0 V** ([electronics › wiring harness](../../electronics.md#wiring-harness), step 2). - Under load near an empty pack, the rail is expected to sag to about **5.4 V** (estimate in `hardware/electronics.yaml`). The servo is still in range but weaker: torque falls roughly with voltage (29 kg·cm at 4.8 V vs 35 kg·cm at 6 V). The PCA9685 carries **only the signal**. Servo current flows UBEC → WAGO bus → the servo's red/brown wires, and never through a logic board. The V+ pin on the PCA9685 header stays unused. More detail is in [Power](power.md). ## Horns and the 25T spline The **horn** couples the servo's output spline to the robot's structure. Strido uses aluminium horns because printed parts bolted to a plastic horn would strip the spline or flex. | Horn | Qty | Joint | Fixed with | |---|---|---|---| | 25T aluminium disc horn | 4 | thigh: femur and femur servo connector, 4 × M3 × 20 | M3 × 8 centre screw | | 25T aluminium disc horn | 4 | hip: leg module, 2 × M3 × 8 | M3 × 8 centre screw | | 25T aluminium clamping arm horn | 4 | knee drive crank | M3 × 8 centre screw | **25T** means 25 teeth on the spline. Each tooth is $360^\circ / 25 = 14.4^\circ$, so the horn can only sit in 14.4° steps. A horn pushed on "one tooth off" is 14.4° wrong, about $14.4^\circ \times 11.1\,\mu\mathrm{s}/^\circ \approx 160\,\mu\mathrm{s}$ of pulse. Horns from another spline standard (for example 24T or 23T) do not fit properly and will strip. Screws that go from steel into the aluminium horn get one drop of **medium thread locker**: metal-to-metal joints vibrate loose otherwise (`docs/assembly/README.md`). ## Centring and calibration ### Step 1: centre before you fit a horn Before any horn goes on, every servo is driven to **1500 µs**, its neutral: ```bash strido-cli --port center # 1500 µs on every channel ``` With the shaft at neutral, each horn goes on at the **assembly reference pose**: | Joint | Reference at 1500 µs | |---|---| | Hip | leg vertical | | Thigh | femur 45° backwards | | Knee crank | 35° | These are the `neutral_hip`, `neutral_thigh` and `neutral_crank` values in the generated geometry (0, 0.785 and 0.611 rad). Because the spline only allows 14.4° steps, pick the tooth that comes closest; the remainder is fixed in software. ### Step 2: trim in firmware Each joint has a `ServoCalibration` record stored in the ESP32 flash (`Preferences`): | Field | Meaning | |---|---| | `direction` | +1 or −1: mirrored servos on the left and right turn the opposite way for the same joint motion | | `neutral_us` | pulse that puts the joint at its reference angle (default 1500) | | `zero_rad` | the reference angle itself | | `min_us`, `max_us` | hard pulse window | The firmware converts an actuator angle to a pulse with: $$ t_\mathrm{p} = t_\mathrm{neutral} + s\, k\, (\theta - \theta_0) $$ with $t_\mathrm{neutral}$ = `neutral_us`, the sign $s$ = `direction`, $k = 636.6\,\mu\mathrm{s/rad}$ and the reference angle $\theta_0$ = `zero_rad`; the result is clamped to `[min_us, max_us]`. Worked example: to move a thigh with `direction = +1` from its 45° reference to 60°: $$ \theta - \theta_0 = 60^\circ - 45^\circ = 15^\circ = 0.262\,\mathrm{rad} $$ $$ t_\mathrm{p} \approx 1500\,\mu\mathrm{s} + 0.262 \times 636.6\,\mu\mathrm{s} \approx 1500\,\mu\mathrm{s} + 167\,\mu\mathrm{s} = 1667\,\mu\mathrm{s} $$ `strido-cli calibrate` lets you nudge each `neutral_us` in ±5 µs or ±25 µs steps until the joint sits exactly at its reference, then saves to flash. The robot should be on a stand with its legs free. A half-tooth error (7.2°) is about 80 µs, well within the trim range. Calibration also absorbs small per-chip differences in the PCA9685 clock, which scale every pulse. ::: danger Raw pulses bypass joint limits `strido-cli center` and `pulse` send raw pulse widths. They skip the joint and knee-crank limits from the CAD range-of-motion sweep. Use them only while horns are off or the legs are free to move. ::: ## Check yourself 1. Why can the robot not report the true angle of a joint? 2. What pulse width does the firmware send for a joint 10° past its reference, with `direction = −1` and `neutral_us = 1500`? 3. Why is the servo rail regulated to 6.0 V instead of taken straight from the 7.4 V pack? 4. A horn ends up visibly crooked even after centring. How big can the error be before you should move it one tooth? 5. Why does each UBEC carry a stall warning in `docs/verification/electronics.md`?
Answers 1. The position feedback (potentiometer) is used only inside the servo. Nothing reads it back, so the firmware reports the angles it commanded. 2. $10^\circ = 0.1745\,\mathrm{rad}$; $0.1745 \times 636.6\,\mu\mathrm{s} \approx 111\,\mu\mathrm{s}$; with direction $-1$ the pulse is $1500 - 111 \approx 1389\,\mu\mathrm{s}$. 3. The SPT5435LV is rated 4.8–6.0 V only; the pack is 6.4–8.4 V in use and would over-volt it. 4. Up to half a tooth, 7.2° (≈ 80 µs), is a job for `strido-cli calibrate`. Beyond that, move the horn one tooth (14.4°). 5. Six stalled servos draw $6 \times 3.5\,\mathrm{A} = 21\,\mathrm{A}$, above the UBEC's 15 A peak rating.
## Further reading - [SPT5435LV-180W specification (SPT Servo)](http://www.spt-servo.com/Product/015234339.html) - [PCA9685 datasheet (NXP, Adafruit mirror)](https://cdn-shop.adafruit.com/datasheets/PCA9685.pdf) - [Adafruit 16-channel PWM/servo driver guide](https://learn.adafruit.com/16-channel-pwm-servo-driver) - [Servo spline reference (ServoCity)](https://www.servocity.com/servo-spline-info/) - Related pages: [Power](power.md), [Microcontrollers](microcontrollers.md), [Mechanics](mechanics.md), [Electronics, wiring and power](../../electronics.md) - Theory: [Control](../theory/control.md) (the PD loop inside the servo), [Statics and dynamics](../theory/statics-and-dynamics.md) (joint torques) --- # 3D printing Strido's frame, legs and shells are 67 printed pieces of 34 part types, all made by FDM (fused deposition modelling) on an ordinary 220 × 220 mm printer. This page explains the settings in the kit profile, why each one matters for a walking robot, and how to read the print list and the printability report. ::: tip In Strido Every printed part is listed in the [print list](../../../hardware/print/print_list.md) with its quantity, colour, file, size on the bed, supports and notes. The verified slicer settings are in [prusaslicer.ini](../../../hardware/print/prusaslicer.ini); 13 ready 220 × 220 mm build plates (10 graphite, 3 lime) come with the [print kit](../../print-kit.md) and the build kit. The printability gate (`make printability`, report in [printability](../../verification/printability.md)) slices every part and plate with PrusaSlicer. The [print kit](../../print-kit.md) page packages all of this for download. ::: ## How FDM works An FDM printer pushes a 1.75 mm plastic filament through a hot nozzle (0.4 mm in this kit) and lays it down as thin lines. Lines form a layer; layers stack up to form the part. Three consequences follow from this, and every setting below is about one of them: 1. **A part is a stack of welded lines, not a solid block.** It is strongest along the lines and weakest between layers. 2. **Each layer needs something underneath.** Steep overhangs and floating features need supports. 3. **Molten plastic flows and shrinks.** Holes come out slightly smaller than drawn and the first layer spreads out. ## The material: PETG (or PLA+) The print list allows PETG or PLA+; the profile is set up for PETG (`filament_type = PETG`, density 1.27 g/cm³). | Property | Why it matters for Strido | |---|---| | Tough rather than brittle | Legs take impacts at every footfall; PETG tends to bend before it cracks | | Good layer adhesion | Fewer splits between layers in thin links such as `short_link` | | Heat deflection at 68 °C (Prusament PETG data sheet) | Servos and UBECs warm up inside the body; PLA softens at a lower temperature | | Low warping | Large flat parts such as `bottom_shell` and `top_shell_sensor` (114 × 208 mm) stay flat | | Strings and sticks to smooth PEI | Use a textured or satin sheet, or glue stick on smooth PEI | | Supports are hard to remove | Snug supports and careful use of the flush cutters (see [tools](tools-and-workshop.md)) | Kit profile temperatures: nozzle 235 °C for the first layer, then 240 °C; bed 80 °C throughout. Prusa's PETG guide recommends 230/240 °C for the nozzle and 85/90 °C for the bed, so the kit values are in the normal range; adjust the bed to your sheet if the first layer does not stick. ::: warning Dry filament PETG absorbs moisture from the air. Wet filament pops and strings and gives weaker layers. If you hear crackling at the nozzle, dry the spool before printing structural parts. ::: ## The kit profile, setting by setting `hardware/print/prusaslicer.ini` mirrors the print list. Load it in PrusaSlicer with *File > Import > Import Config*. If you use another slicer, copy these values by hand. | Setting | Value | What it does and why this value | |---|---|---| | Bed size | 220 × 220 mm, 250 mm tall | The largest parts (shells, 114 × 208 mm) must fit; this is also the plate size | | Nozzle | 0.4 mm | The standard nozzle; one line is about 0.45 mm wide | | Layer height | 0.2 mm (first layer 0.2 mm) | Half the nozzle diameter: good strength and speed; the fit and wall checks assume it | | Perimeters | 3 (≈ 1.2 mm wall) | Walls carry most of the load around screw holes; Prusa recommends more perimeters, not more infill, for strong parts | | Perimeter generator | Arachne | Varies the line width so thin walls are filled instead of leaving gaps | | Top / bottom solid layers | 5 / 5 (1.0 mm skin) | Closes the infill with a stiff skin; fewer layers can show infill through the top | | Infill | 30 % gyroid | Gyroid is strong in all directions and prints without the nozzle crossing its own lines at the same height | | Supports | off by default, auto-detect, snug style | The gate turns supports on per part as listed; snug supports follow the part outline and are easier to remove | | Brim | 0 by default | The gate adds a 5 mm brim to parts taller than 40 mm | | Skirt | 1 loop | Primes the nozzle before the part starts | | `gcode_label_objects` | firmware | Lets you cancel one failed part on a full plate without stopping the rest | ## Orientation and layer-line strength Because layers bond less well than lines, a part loaded so that it would peel its layers apart is weakest. The kit STLs are already oriented, and the print list explains each choice in its *Note* column: - **published orientation**: upstream Yertle's own orientation, kept where it already prints well. The note says which face would lower the overhang (e.g. `bottom_shell`: 9 % overhang as published, 1 % on its −Y face, but then 208 mm tall on a narrow edge). Lower overhang means fewer supports, but it also changes the layer direction, so keep the published orientation unless you have a reason. - **re-oriented**: the published STL was often the part's CAD pose, tall on a small footprint; the kit lays it on the face with the most bed contact and least overhang, never with a link's long axis upright (e.g. `inner_tibia` and `outer_tibia` on their flat sides, `short_link`, `femur`, `femur_servo_connector`, `servo_mount_top_bracket`, `side_body_beam`). The femur's and the femur servo connector's bores come out vertical this way. The servo mounts lie like a U (open side up): 19 mm instead of 56 mm tall, 2 % instead of 13 % overhang, and 10 of their 13 M3 pilots (servo ears, bracket) vertical; the two hip-horn pilots and the hip-pivot pilot lie horizontal. - **print on its base / upside down**: `camera_mount_base` stands on its base (its M3 pivot is a friction pivot, so a slightly flattened horizontal bore is fine; drill 3.4 mm if it is tight); `lidar_pedestal` is printed top plate down; `electronics_deck` top face down; `stiff_long_link` lies flat with both bushing bores vertical. Why a vertical bore? A hole whose axis is vertical is printed as a stack of circles and comes out round. A horizontal hole is built from layers, so its top has to bridge or be supported and comes out slightly flattened. Bores that carry a pivot or bearing should be vertical whenever possible. ## Supports and brims **Supports** are temporary structures under overhangs (surfaces steeper than the printer can bridge). The print list column *Supports* gives `yes (N % overhang)`, where N is the share of the part's surface that needs support. The profile uses `support_material_threshold = 0`, i.e. automatic overhang detection, and the snug style. **Brims** are a few extra lines around the first layer that increase bed contact. A tall, narrow part acts as a lever on its small footprint: the nozzle knocks it loose before it is finished. Rule in this kit: brim on parts taller than 40 mm, 5 mm wide. In the printability report this applies, for example, to the shells; the servo mounts (19 mm, lying like a U) and `servo_mount_top_bracket` (6 mm, flat) need none. **Hole steps** are the one overhang the kit removes in the model instead: where a wide hole on the bed side (a bearing seat) narrows to a smaller hole above, the ring between them hangs in the air, and a support inside a bearing seat is hard to dig out. The kit STLs cut the two layers above such a step for bridging (the *hole overhang trick*): layer 1 leaves a slot as wide as the narrow hole across the whole wide hole, so the material either side is a straight bridge between its walls; layer 2 leaves a square as wide as the narrow hole, bridging across the slot; layer 3 is the narrow hole as designed. The seat diameter and the narrow hole do not change; the bearing then rests on the two crescents either side of the slot. `strido_publish.print_bridges` finds these steps in every part as it lies on the bed (today the bearing seats of `inner_tibia`, `outer_tibia` and `outer_shoulder_frame`); the print list notes each one. In `outer_shoulder_frame` the seat opens onto the bed and slices without any support. In the tibias it sits under a boss that floats above the bed, which needs support anyway, and the slicer also fills the bore: block supports in the bearing bore (a support blocker or paint-on) and the step bridges. Remove supports and brims before assembly (assembly manual, Step 0), and deburr any edge that touches a moving part. ## Bed adhesion and the first layer The first layer decides whether a print succeeds: - Clean the sheet (fingerprints are oil; PETG and PLA both release from greasy spots). - Level/calibrate the first layer so lines are slightly squashed but do not ride up. - For PETG on smooth PEI, use glue stick as a release layer: PETG can bond so strongly that it tears the sheet surface (Prusa). - Let the bed cool before removing large parts like `bottom_shell`. ## Tolerances: why holes come out small Molten lines are laid on a polygon approximation of a circle, and the first layer is squashed outwards. Both effects make holes smaller than drawn. The Strido fit check assumes holes print **0.15 mm undersize** (`expected_hole_shrink_mm: 0.15` in `hardware/verification/tolerances.yaml`); ADR-009 notes that most printers print small holes 0.2–0.3 mm undersize. What you do about it after printing: | Hole type | Fix | Example from the fit report | |---|---|---| | Clearance (screw passes through) | Run a drill through once: 3.2 mm for M3, 2.7 mm for M2.5, 2.2 mm for M2 (ISO 273 fine; medium 3.4 / 2.9 / 2.4 mm) | `bottom_shell`, 4 × M3 at 3.05 mm CAD: drill 3.2 mm | | Thread-forming pilot (screw cuts its own thread) | Leave it; see [fasteners](fasteners.md) | `femur` M3 pilots, 2.80–2.85 mm CAD | | Bearing seat (press fit) | Test-fit before assembly; a loose bearing gets a drop of CA glue | `outer_shoulder_frame`, 625ZZ (16 mm OD) in a 16.17–16.21 mm seat | **Elephant foot** is the flare at the bottom edge of a part caused by the squashed first layer and the weight of the layers above. It makes bottom-facing holes and slots tighter and can stop a part from sitting flat. Most slicers have an *elephant foot compensation* setting that shrinks the first layer slightly; if a part's bottom edge flares, trim it with a deburring tool. ::: tip Hole compensation for the servo mounts The print list asks for **X-Y hole compensation −0.15 mm** on `left_servo_mount`, `right_servo_mount`, `left_servo_mount_fl`, `right_servo_mount_fr` and `servo_mount_top_bracket`, which brings their M3 pilots down to about 2.65 mm so the screws grip (ADR-009). It acts on vertical holes; the servo mounts' hip-horn and hip-pivot pilots lie horizontal, so harden those with thin CA if a screw spins. The option has this name in Bambu Studio and OrcaSlicer; not every slicer has a hole-only compensation. If you print without it, harden those pilots with a drop of thin CA glue before driving the screws, as explained in [fasteners](fasteners.md); anaerobic thread locker is not for plastic. ::: ## Plates and colours The kit uses two colours: **primary (graphite)** for the frame and legs and **secondary (lime)** for the shells and shoulders. The 13 ready plates in `hardware/print/plates/` (`plate_graphite_01…10.3mf`, `plate_lime_01…03.3mf`) are pre-arranged 220 × 220 mm beds, so a whole robot needs no manual arranging. Each plate was sliced as arranged by the printability gate and accepted. Filament budget: the print list totals 943 cm³ of solid parts (≈ 659 g of PETG at the kit settings). The slicer, including infill, supports and brims for every piece, estimates **958 g and 100 h**. The BOM buys two 1 kg spools (one per colour); the print list budgets 1.2 kg including test prints. ## The printability gate The printability gate (`python -m strido_verify.printability`, `make printability`) checks that the kit really prints with exactly these settings: ```mermaid flowchart LR STL["oriented STLs
+ 3MF plates"] --> SL["PrusaSlicer
prusaslicer.ini + per-part supports/brim"] SL --> R1["slicer warnings?
(overhangs, floating bridges,
low bed adhesion)"] STL --> W["wall-thickness scan"] W --> R2["walls < 1 line / skins < 1 layer?"] R1 --> REP["printability.md
ok / warn / fail"] R2 --> REP ``` - **Slicer stage:** every part is sliced as it lies, with the supports and brim from the print list. It fails if the slicer errors or reports stability problems that those supports and brim do not prevent. Filament mass and time come from the generated G-code. - **Wall scan:** a lateral wall is built from lines (≈ 0.45 mm each), a horizontal skin from layers (0.2 mm). Below one line (0.4 mm) or one layer (0.2 mm) the slicer drops the feature: **fail**. Below two lines (0.8 mm) or three layers (0.6 mm) it prints but is fragile: **warn**. - **Plates:** each 3MF plate is sliced as arranged and must fit on the bed. Current result: 34 part types and 13 plates, **0 fail, 4 warn**. The warnings are the four servo mounts, with small skin areas only 0.38–0.53 mm thick (two layers): printable, but print them carefully. ## How to read the print list Each row of `hardware/print/print_list.md` has eight columns: | Column | Meaning | |---|---| | Part | Part id, the same as in the [catalogue](../../catalog.md) and the [assembly manual](../../assembly/README.md) | | Qty | Pieces per robot (e.g. `femur` 4, one per leg) | | Colour | primary (graphite) or secondary (lime) | | File | STL under `hardware/print/`, already oriented | | Size on bed (mm) | X × Y × Z as it lies; Z is the height that decides the brim (> 40 mm) | | Supports | `no`, or `yes (N % overhang)` | | Source | upstream STL, upstream STEP, new Strido part, or a modified upstream part | | Note | orientation reasons and what was changed against upstream | Read the two paragraphs above the table too: they contain the global settings and the special instruction for the servo mounts. ## Check yourself 1. Why does the kit use three perimeters and only 30 % infill rather than more infill? 2. `bottom_shell` is 42 mm tall. Does it get a brim, and why? 3. A printed M3 clearance hole is too tight for the screw. What do you do? 4. What does the kit do where a bearing seat on the bed side narrows to a smaller hole above it? 5. What are the two failure criteria of the wall-thickness scan?
Answers 1. Loads around screw holes and edges are carried by the walls; extra perimeters add strength where it is needed, whereas infill mostly adds mass and time (Prusa gives the same advice). 2. Yes. Every part taller than 40 mm gets a 5 mm brim because a tall part on a small footprint can be knocked off the bed. 3. Run a 3.2 mm drill through once (3.4 mm for a looser fit); printed holes come out about 0.15 mm undersize. 4. It cuts the two layers above the step for bridging (the hole overhang trick): a slot as wide as the small hole across the seat, then a square, then the small hole. Every layer then bridges between walls it rests on, so the seat needs no support of its own (where the slicer still adds some, as in the tibias, block it in the bore). 5. A lateral wall thinner than one extrusion line (0.4 mm) or a horizontal skin thinner than one layer (0.2 mm).
## Further reading - [Prusa Knowledge Base: PETG](https://help.prusa3d.com/article/petg_2059) - [Prusa Knowledge Base: Elephant foot compensation](https://help.prusa3d.com/article/elephant-foot-compensation_114487) - [Bambu Lab Wiki: XY hole / contour compensation](https://wiki.bambulab.com/en/software/bambu-studio/xy-hole-contour-compensation) - [PrusaSlicer on GitHub](https://github.com/prusa3d/PrusaSlicer) - Related in this wiki: [fasteners](fasteners.md), [CAD as code](../software/cad-as-code.md), [mechanics](../hardware/mechanics.md) --- # Cable management and the wiring harness A walking robot shakes, flexes and bends its own wiring thousands of times an hour. Most failures in hobby robots are not broken parts but a lead that chafed through, a contact that worked loose or a wire that snapped inside its insulation next to a joint. This page explains how a **wiring harness** is designed against those failures: how thick each wire must be, what a fuse really protects, how much slack a cable needs to cross a moving joint, how to bend, bundle and terminate wires, and what the CAD check can and cannot prove. Every rule is worked through with Strido's own cut list. ::: tip In Strido **Source of truth:** `hardware/wiring.yaml` names every cable W00–W23 and the twelve servo leads (S-*leg*-*joint*), the connector each end sits on, the tie points it passes and the wire stock. `strido_cad.wiring` routes them through the CAD; `python -m strido_verify.wiring` checks them and writes the [wiring check](../../verification/wiring.md) with the **cut list**. Ratings and fuses are in `hardware/electronics.yaml`. The build order is in [electronics, wiring and power](../../electronics.md#wiring-harness) and assembly Steps 14–17 (and the shell Steps 18–22) of the [assembly manual](../../assembly/README.md). Decision: [ADR-011](../../decisions.md#adr-011-wiring-harness-routed-and-checked-in-the-cad). ::: ## Why design a harness at all? The robot's earlier wiring was a list with estimated lengths. As soon as every cable was modelled in the CAD, the list turned out to hide real defects (ADR-011): the main fuse holder left 4 mm for a 14 AWG lead that cannot bend that tightly; Dupont housings fitted neither on the IMU (2.5 mm under the Pi) nor on the XIAO pins (9 mm under the upper shoulder frame); the XIAO-to-PCA9685 wires needed about 0.3 m instead of 0.1 m; and a 60 mm 18 AWG tap sat behind the 25 A fuse alone. None of these shows up on paper. All of them show up in a real build, usually as a late surprise. A harness is designed against the ways wiring fails in a walking machine: | Failure | What happens | Design answer | |---|---|---| | **Chafing** | a cable rubs on a moving horn, link or shell edge until the insulation wears through and the conductor shorts | route along structure, clear of everything that moves; spiral wrap on bundles; check clearance over the joint ranges | | **Connector fretting** | vibration makes two mated contacts slide by micrometres; the plating wears, oxide builds up and the contact becomes intermittent | fewer connectors; latching or clamping contacts; tie the cable so its weight does not hang on the contact | | **Flex fatigue** | a wire bent back and forth at one point breaks strand by strand inside intact insulation | cross joints near their axis with a free loop; avoid stiff points (solder wicking) next to a flexing section | | **Pinch** | a cable is caught between two parts that close, such as a leg folding against the body | keep every cable out of the volume the legs sweep | | **Heat** | an undersized wire heats with $P = I^2 R$; insulation softens; in a short circuit it can burn | size every wire for its current; fuse it below what it can carry | The rest of the page goes through these one by one. ## Conductor sizing ### Ampacity A wire's **ampacity** is the current it can carry continuously without its insulation getting too hot. It depends on the copper cross-section, the insulation's temperature rating, the surrounding temperature and how well the wire can shed heat: a wire alone in free air carries more than the same wire in a tight bundle (general; published tables derate bundles). Strido uses deliberately conservative values for short chassis runs (`wire_ampacity_a` in `hardware/electronics.yaml`): | Gauge | Copper area (approx.) | Resistance (approx.) | Ampacity used | Used for in Strido | |---|---|---|---|---| | 14 AWG | $2.1\,\mathrm{mm^2}$ | $8.3\,\mathrm{m\Omega/m}$ | 32 A | battery trunk W01–W04 | | 18 AWG | $0.82\,\mathrm{mm^2}$ | $21\,\mathrm{m\Omega/m}$ | 16 A | UBEC outputs W05/W06, bus jumpers W07/W08, logic branch W09–W11, logic grounds W24, W04/logic | | 22 AWG | $0.33\,\mathrm{mm^2}$ | $53\,\mathrm{m\Omega/m}$ | 7 A | Pi 5 V feed W12, servo leads, I²C and OE wires W17/W20, button W13 | The `python -m strido_verify.electronics` gate reads the power cables from `hardware/wiring.yaml` and checks each one's worst-case current against this table: for example W05 carries the UBEC's 10 A continuous on 18 AWG ($10 \le 16$), and W12 carries 1.6 A per 22 AWG wire ($1.6 \le 7$). ### Resistance and voltage drop A conductor's resistance grows with its length $L$ and shrinks with its cross-section $A$: $$ R = \frac{\rho\,L}{A}, \qquad \rho_\mathrm{Cu} \approx 1.72 \times 10^{-8}\,\Omega\,\mathrm{m} $$ The current flows out on one conductor and back on the other, so a supply pair loses $$ \Delta V = I \cdot 2R, \qquad P = I^2 \cdot 2R $$ **W05**, front UBEC to the front WAGO bus: 18 AWG pair, routed 190 mm, cut 222 mm. Taking the full cut length as the copper in circuit (an upper bound) at the UBEC's 10 A continuous rating: $$ R = 0.222\,\mathrm{m} \times 21\,\mathrm{m\Omega/m} \approx 4.7\,\mathrm{m\Omega}\ \text{per conductor} $$ $$ \Delta V \approx 10\,\mathrm{A} \times 2 \times 4.7\,\mathrm{m\Omega} \approx 93\,\mathrm{mV}, \qquad P \approx (10\,\mathrm{A})^2 \times 9.3\,\mathrm{m\Omega} \approx 0.93\,\mathrm{W} $$ That is about 1.6 % of the 6.0 V rail: acceptable. **W12**, regulator to the Pi: 22 AWG, routed 104 mm, cut 123 mm, two wires per rail with 1.6 A each: $$ R = 0.123\,\mathrm{m} \times 53\,\mathrm{m\Omega/m} \approx 6.5\,\mathrm{m\Omega}, \qquad \Delta V \approx 1.6\,\mathrm{A} \times 2 \times 6.5\,\mathrm{m\Omega} \approx 21\,\mathrm{mV} $$ Small, but the Pi's margin is small too: the regulator may deliver as little as 4.8 V and the Pi flags under-voltage below 4.63 V ([power](../hardware/power.md)). That is why W12 uses two wires per rail, and why it is kept short. ## Fusing: a fuse protects the wire A fuse does not protect the robot's boards. It protects **the wire behind it** from overheating in a fault. It must therefore blow before the weakest wire it covers overheats, and must not blow at normal load: $$ I_\mathrm{load} \le I_\mathrm{fuse} \le I_\mathrm{wire,min} $$ where $I_\mathrm{wire,min}$ is the ampacity of the *thinnest* wire anywhere behind that fuse. Strido's two fuses (`fuses` in `hardware/electronics.yaml`): | Fuse | Rating | Protects | Thinnest wire behind it | Worst-case load | |---|---|---|---|---| | Main (ATO) | 25 A | W02, W03, W04/front, W04/rear | 14 AWG, 32 A | 23.6 A (servo branch ≈ 21 A at an empty pack + logic) | | Logic (ATO) | 7.5 A | W09, W10, W11, W24, W04/logic | 18 AWG, 16 A | 2.74 A | W04/logic and W24 are the logic branch's *ground* returns. In normal operation they carry the same current as the logic feed, so the logic fuse limits it and they may be 18 AWG. The limit is one of normal current, not of every fault: a short from the servo branch (behind only the 25 A fuse) onto a logic ground would return through them. That is why the servo-branch wires run apart from the logic grounds except at the inner frame opening, inside their insulation and wrap. **Why the old tap was wrong.** The old design fed the logic fuse holder through a 60 mm piece of 18 AWG taken off the trunk *behind the 25 A fuse*. Nothing but the 25 A fuse covered that piece, and 25 A is above 18 AWG's 16 A. A fault drawing, say, 20 A through the tap would never blow the fuse but would heat the tap with $$ P' = I^2 \frac{\rho}{A} = (20\,\mathrm{A})^2 \times 21\,\mathrm{m\Omega/m} \approx 8.4\,\mathrm{W/m} $$ indefinitely (an illustration of the principle, not a measured fault). "Keep it short" lowers the chance of a fault but does not change the rule. The fix: the tap is now the logic fuse holder's **own 14 AWG lead**, spliced to W02 in the back shell. No wire thinner than 14 AWG sits behind the 25 A fuse alone, and everything after the 7.5 A fuse is at least 18 AWG. **Why W01 is unfused and kept short.** W01 runs from the battery input to the main fuse. Nothing can protect it except the pack's own limits: the pack is rated for 300 A (`hardware/electronics.yaml`), so a short on W01 is a battery short. The fuse sits where there was room for its holder and its 14 AWG leads to bend: on the rear WAGO bus, about 0.15 m from the battery input. W01 is therefore treated like the pack's own lead: 14 AWG, cut 151 mm, in spiral wrap, routed clear of everything that moves. ADR-011 says to move the fuse closer only if a compact fused battery input fits under the deck. ## Crossing a joint: service loops A cable that crosses a joint must change length as the joint turns. If it cannot, the joint pulls on the cable, and the pull ends up at the weakest point: a solder joint, a crimp or a connector. A **service loop** is a deliberate piece of free cable between two fixed ties that absorbs this change. On Strido the thigh and knee servos ride on the hip body, so their leads cross only the **hip** joint. Each lead is tied at the servo mount plate near the hip axis (on the moving side) and again at the shoulder frame or bus holder (on the body). Between the ties hangs the loop. The hip servos sit on the body and need no loop. The other joints need none either, because no lead crosses them: placing the motors decides which joints the wiring has to cross. ### How far apart do the ties move? Put the origin on the hip axis. Let $\mathbf{p}$ be the tie on the moving hip body (in the hip's reference pose) and $\mathbf{q}$ the tie on the body. When the hip turns by $\theta$, the moving tie goes to $R(\theta)\,\mathbf{p}$, with $R(\theta)$ the rotation about the hip axis (see [frames and rotations](../theory/frames-and-rotations.md)). The tie-to-tie distance is $$ d(\theta) = \lVert R(\theta)\,\mathbf{p} - \mathbf{q} \rVert $$ To see what drives it, split each point into a part along the unit axis $\hat{\mathbf{a}}$ and a part perpendicular to it: $\mathbf{p} = p_a\,\hat{\mathbf{a}} + \mathbf{p}_\perp$, $\mathbf{q} = q_a\,\hat{\mathbf{a}} + \mathbf{q}_\perp$, with radii $r_p = \lVert \mathbf{p}_\perp \rVert$ and $r_q = \lVert \mathbf{q}_\perp \rVert$. The rotation leaves the axial parts alone and turns $\mathbf{p}_\perp$ by $\theta$, so the law of cosines gives $$ d(\theta)^2 = (p_a - q_a)^2 + r_p^2 + r_q^2 - 2\,r_p\,r_q \cos(\theta - \theta_0) $$ where $\theta_0$ is the hip angle at which the two perpendicular parts point the same way. Over a full turn $d$ would swing between $$ d_\mathrm{min} = \sqrt{(p_a - q_a)^2 + (r_p - r_q)^2}, \qquad d_\mathrm{max} = \sqrt{(p_a - q_a)^2 + (r_p + r_q)^2} $$ and within the hip's limited range it covers part of that interval. The lesson is in the radii: the swing grows with $r_p\,r_q$. **Ties close to the axis move little**, so the loop can be short. That is why the leads are tied at the servo mount plate *near the hip axis* rather than further out. The wiring gate does not trust a hand formula. It places both ties in the CAD and evaluates $d(\theta)$ over the full hip range. For the front legs: | Lead | Tie-to-tie distance over the hip range | Required loop $1.2\,d_\mathrm{max}$ | Loop in `wiring.yaml` | |---|---|---|---| | front thigh | 13.0–24.1 mm | 28.9 mm | 30 mm | | front knee | 20.0–25.6 mm | 30.7 mm | 32 mm | | rear thigh | 11.9–16.2 mm | 19.4 mm | 22 mm | | rear knee | 11.9–16.1 mm | 19.4 mm | 22 mm | The front knee lead's tie on the shoulder side sits at the bus holder, 17 mm towards the inner frame: its loop is a little longer, but the signal wire saves more on its way to the PCA9685, and with that the 260 mm lead reaches without an extension. ### The slack rule The robot's rule is $$ L_\mathrm{loop} \ge 1.2\, d_\mathrm{max} $$ Worked for the front knee: $1.2 \times 25.6\,\mathrm{mm} = 30.7\,\mathrm{mm}$, rounded up to **32 mm**. Why not exactly $d_\mathrm{max}$? At that length the cable would be a straight, taut chord at the extreme hip angle and would pull on both ties. A cable also cannot leave a tie at any angle: it leaves in the direction it is tied and has to curve towards the other tie, and the curve costs length. The 20 % covers both. Why not much longer? Extra loop has to hang somewhere, and a long loop can droop into a moving part. The gate checks the loop's *length* only; where it hangs is not modelled, so you check that by hand (see the checklist below). ## Bend radius and flex life Every cable has a minimum bend radius. A common rule of thumb (general, not a datasheet value; check the cable maker's data) is a few times the outer diameter (OD) for a cable that is bent once and then fixed, and about ten times the OD or more for a cable that flexes in service. `hardware/wiring.yaml` records a minimum static bend for each cable type, about three times the OD: 11 mm for 14 AWG (3.6 mm OD), 7 mm for 18 AWG (2.2 mm), 5 mm for 22 AWG (1.6 mm), 15 mm for the USB cables. The old main-fuse position left 4 mm for a 14 AWG lead, which needs 11 mm: that is why the fuse moved. Why bending fatigues a wire: a strand of diameter $d_s$ bent to radius $R$ stretches on its outside by the bending strain (general beam result) $$ \varepsilon \approx \frac{d_s}{2R} $$ Copper survives many cycles at small strain and few at large strain. Two consequences: - **Fine strands flex better.** In a stranded wire the strands can slide, so each one bends about its own centre. For an illustrative strand of $d_s = 0.08\,\mathrm{mm}$ at $R = 5\,\mathrm{mm}$, $\varepsilon \approx 0.08/10 = 0.8\,\%$. A solid 22 AWG conductor ($A = 0.33\,\mathrm{mm^2}$, so $d = \sqrt{4A/\pi} \approx 0.65\,\mathrm{mm}$) bent the same way sees $\varepsilon \approx 0.65/10 = 6.5\,\%$, eight times more. Silicone wire and servo leads are fine-stranded for this reason. - **A longer loop bends gentler.** Spreading the same joint motion over more cable means a larger radius and a smaller strain. A loop that is just long enough bends sharply at its ends every step. The CAD draws route corners sharp and does not check bend radii, so the radius is your job at the bench. ## Bundling, spiral wrap and ties - **Spiral wrap** (a PE spiral that wraps around a bundle) on every bundle of **three or more cables**. It holds the bundle together, protects it from edges, and still lets single cables branch out anywhere. Strido uses about 228 mm of it (`spiral_wrap`, wire-stock check). - **Cable ties** at the marked tie nodes of `wiring.yaml`, about 30 from a bag of 100: servo mount plate edges, shoulder frame slots, bus holder slots, frame openings. Ties fix the cable to structure so it cannot wander into a moving part, and they take pull off the terminations. - **Adhesive tie mounts:** two 13 × 13 mm mounts on the deck side strips (`cable_tie_mounts`, modelled in the CAD) where there is no slot to tie to. - **Tie spacing** (general practice): tie at every branch, before and after every bend, and close to every connector, so a pull on the cable ends at a tie rather than at a contact. On the robot, the tie nodes in the CAD already do this. - **Tension** (general): pull a tie snug so the cable cannot slide, not so tight that it dents the insulation. A crushed spot is a stiff point and a future chafe point. Flush-cut the tail; a sharp tail scratches skin and insulation. ## Signal and power A changing current $I$ in one loop induces a voltage in a nearby loop, $V = M\,\mathrm{d}I/\mathrm{d}t$, where $M$ (mutual inductance) grows with the loop areas and shrinks with distance (general). Servo currents change fast: up to 3.5 A per servo at stall. Two general habits keep that out of the signals: - **Separate them.** Run signal wires away from high-current wires where you can, and cross them at right angles rather than in parallel. On Strido each servo lead is split at a tie: red/brown drop to the WAGO bus in the cavity, the orange signal runs on to the PCA9685 beside the Pi. - **Keep loops small and twist pairs.** A signal and its return run side by side enclose little area. Twisting a pair (general technique; the robot's docs do not prescribe it) alternates the direction of each small loop, so induced voltages cancel along the length. A common ground is part of the same story: a servo's signal is read against its ground, so all domains share one ground star at the battery input ([power](../hardware/power.md)). ## Connectors and terminations ### WAGO lever clamps The servo bus is built from WAGO 221-415 lever connectors: five entries, 0.2–4 mm², rated 32 A. A spring clamp grips the bare conductor when the lever closes, with no tool. WAGO's datasheet requires a **strip length of 11 mm**: shorter and the clamp grips too little copper; longer and bare copper sticks out of the housing. Push the conductor in until the insulation meets the housing, close the lever, and tug. The housing is transparent, so you can see the conductor reach the end. One wire per entry: the gate checks this for every WAGO port. ### Crimp or solder? - **Crimping** squeezes a metal contact cold around the bare strands (conductor grip) and around the insulation (insulation grip). The insulation grip is the **strain relief**: it takes bending and pull off the conductor joint. The strands stay flexible right up to the contact. - **Soldering** joins the strands with molten alloy. Solder also runs up inside the insulation by capillary action (**wicking**) and turns that part of the wire into a solid rod. Bending then concentrates at the end of the wicked section, exactly as in the strain formula above (solid conductor, large $\varepsilon$), and strands break there under vibration. Wicking is a defect in harness workmanship standards such as IPC/WHMA-A-620. So: solder where the wire is fixed and supported (and add heat-shrink as strain relief); crimp or clamp where it moves. On Strido the soldered joints are the power entry (XT60H cups, fuse holder leads, the heat-shrunk splices), the switch, regulator and button, and the XIAO pins. The XIAO pins sit 9 mm under the upper shoulder frame, and a Dupont housing is 14 mm tall, so W17 (3V3, GND, SDA, SCL) and W20 (D3 → OE) are soldered there and crimped at the PCA9685, and the display cable's Dupont ends (W21) are soldered there too. Tie these wires close to the board so no pull reaches the solder. ### Dupont contacts Dupont contacts plug onto 2.54 mm header pins and hold by spring friction only. A heavy or swinging cable can work them loose (general), so tie the bundle near the board and let nothing hang on the contacts. On the robot they are used where they fit: W12 on the Pi (one wire per pin), W17/W20 at the PCA9685, and each servo's orange signal wire, re-crimped into a 1-pin housing on its PCA9685 channel. Where even a housing does not fit, there is another connector: the IMU sits 2.5 mm under the Pi, so W18 is a STEMMA QT cable with its low JST-SH plug. ### XT60 and the contact rule The XT60 is the 30 A connector of the battery input, the arming port and the loop key. Describe its halves by **pins** and **sockets**, not "male/female". The loop key is a shorted connector, so it must have the same contact type as the pack's own connector: then it cannot mate with the pack ([XT60 contact rule](../../electronics.md#xt60-contact-rule-the-loop-key-must-never-fit-the-battery)). ## Cut leads to length, don't extend them Earlier the servos were connected with 300 mm extension leads. Every extension adds a mated three-pin plug and socket per servo: twelve extra connections, 36 extra contacts, each one a candidate for fretting. Each servo now uses only its own 260 mm lead: it runs to a tie, where the red and brown wires are peeled off, cut to length and clamped in the leg's WAGO pair (red into the upper V+ connector, brown into the lower GND; ports hip 1, thigh 2, knee 3), while the orange signal wire is cut to length and re-crimped for its PCA9685 channel. Fewer connectors in a walking robot means fewer intermittent contacts, and a shorter power path ([power](../hardware/power.md)). The **cut list** in the [wiring check](../../verification/wiring.md) gives every wire's routed length and the length to cut: $$ L_\mathrm{cut} = L_\mathrm{routed} + t_1 + t_2 + \max(10\,\mathrm{mm},\ 0.08\,L_\mathrm{routed}) $$ with a termination allowance $t$ per end (WAGO strip 11 mm, solder 6 mm, crimp 3 mm, screw terminal 7 mm). The slack covers dressing and one re-termination. Two examples: $$ \text{W05:}\quad 190 + 6 + 11 + 0.08 \times 190 = 222.2 \;\Rightarrow\; 222\,\mathrm{mm} $$ $$ \text{W12:}\quad 104 + 6 + 3 + \max(10,\ 8.3) = 123\,\mathrm{mm} $$ Ready-made cables cannot be cut, so the gate checks **reach** instead: supplied length ≥ route + terminations + 10 mm. Their excess is folded and tied: 178 mm on the Pi–XIAO USB cable (W14), 124 mm on the camera ribbon (W16), 141 mm on the display cable (W21), 55 mm on the LiDAR USB cable (W15). Servo leads have 12–124 mm to spare before cutting. The front knee leads are the tightest at 12 mm: route them exactly as the manual draws them. ## How the CAD routes the harness `strido_cad.wiring` treats routing as a path search: 1. **Obstacles.** A voxel grid with 1 mm cells marks every base part, plus every volume a leg part sweeps over its joint limits (the hip in 1° steps, the thigh and calf over a grid of angles). A cable routed through free voxels stays clear of a leg at the sampled joint angles; the gate then re-checks the operating poses against the real meshes. 2. **Search.** Between consecutive tie nodes, Dijkstra's shortest-path algorithm finds a path through free voxels. Its cost prefers voxels within 3 mm of a surface, because real cables are tied along structure, not strung through open space. 3. **Shortcut.** The path is straightened only where the straight line stays as close to structure as the searched path did. The result is drawn in the renders, the [3D viewer](../../viewer.md) and the assembly manual, so what you see in the build steps is the routed harness. ### What the gate proves, and what it does not `python -m strido_verify.wiring` re-checks the routed harness independently of the router: - **Reach:** every ready-made cable covers its route with terminations and 10 mm to spare. - **Stock:** wire per gauge and colour, spiral wrap and ties fit the BOM. - **Hip loops:** every loop is at least $1.2\,d_\mathrm{max}$. - **Ports:** one cable per WAGO entry and per PCA9685 channel. - **Clearance:** no conductor passes through a part, and every cable keeps at least 1.0 mm (warn) / 0.3 mm (fail) from every part of another body in every operating pose: stand, crouch, tall, eight trot phases, hips in and out. It does **not** model bend radii (route corners are drawn sharp) or where a hip loop hangs. It also cannot see workmanship: a crimp that pulls out, a cold solder joint, a tie pulled too tight. Those are checked at the bench. ## Build checklist 1. Cut every wire from the cut list; label both ends with its ID. 2. Strip WAGO ends to 11 mm; tug every WAGO entry, crimp and solder joint. 3. Slide heat-shrink on before soldering; shrink it over every soldered power joint. 4. Fuses and loop key stay **out** until the wiring is checked ([electronics](../../electronics.md#wiring-harness), build order steps 3–7). 5. Tie each thigh and knee lead at the servo mount plate and at the shoulder frame / bus holder, leaving the loop between the ties (front: 30 mm thigh, 32 mm knee; rear: 22 mm). 6. Spiral wrap every bundle of three or more cables; ties at the marked points; snug, not crushing. 7. Respect bend radii by eye: no 14 AWG lead bent tighter than about 11 mm. 8. **Move every joint through its full range by hand**, loop key out: hips in and out, thighs and knees from folded to stretched. Watch every loop and bundle: nothing may pull taut, rub or come near a moving part. 9. Continuity-check each cable against the wiring list; measure the resistance between + and − at the battery input (not near 0 Ω) before connecting the pack. ## Check yourself 1. Name three ways wiring fails in a walking robot and one design measure against each. 2. The main fuse is 25 A. Why may no 18 AWG wire sit behind it alone, and how did the redesign fix the old logic tap? 3. Estimate the round-trip voltage drop of W06 (18 AWG pair, routed 151 mm) at 10 A. 4. A tie on the hip sits at radius $r_p = 10\,\mathrm{mm}$ from the hip axis and a body tie at $r_q = 15\,\mathrm{mm}$, with no axial offset. Over a full turn, between which distances would $d(\theta)$ swing? What does that say about where to put ties? 5. The gate finds a loop's largest tie-to-tie distance is 25 mm. How long must the loop be by the robot's rule, and why is a loop of exactly 25 mm not enough? 6. Why does a soldered stranded wire break sooner under vibration than a crimped one? 7. Why were the servo extensions removed? 8. Compute the cut length of W09 (routed 256 mm, solder at the fuse holder, screw terminal at the switch). 9. Name two things the wiring gate does not check, and how you check them instead.
Answers 1. Any three of: chafing (route along structure, spiral wrap, clearance check), connector fretting (fewer connectors, tie the cable so nothing hangs on contacts), flex fatigue (loops near the joint axis, no stiff points next to flexing wire), pinch (keep cables out of the volume the legs sweep), heat (size wires for their current, fuse below ampacity). 2. A fuse must be no larger than the ampacity of the thinnest wire behind it; 18 AWG is rated 16 A, so a fault between 16 and 25 A would heat it without blowing the fuse. The tap is now the logic fuse holder's own 14 AWG lead, spliced to W02. 3. $R = 0.151 \times 21 \approx 3.2\,\mathrm{m\Omega}$ per conductor; $\Delta V \approx 10 \times 2 \times 3.2 \approx 63\,\mathrm{mV}$ (with the 180 mm cut length as an upper bound, about 76 mV). 4. $d_\mathrm{min} = \lvert 10 - 15 \rvert = 5\,\mathrm{mm}$, $d_\mathrm{max} = 10 + 15 = 25\,\mathrm{mm}$. The swing grows with both radii, so ties close to the axis need a shorter loop. 5. $1.2 \times 25 = 30\,\mathrm{mm}$. At exactly 25 mm the cable is taut at the extreme angle and pulls on the ties and terminations; it also needs length to curve away from each tie. 6. Solder wicks up the strands and makes a solid, stiff section; bending concentrates at its end, where a solid conductor sees much larger strain than individual strands. A crimp leaves the strands flexible and its insulation grip relieves strain. 7. Each extension added a three-pin plug and socket per servo (36 contacts in total) that could fret loose, plus extra wire and voltage drop. Cutting each servo's own lead to length removes them. 8. $256 + 6 + 7 + 0.08 \times 256 = 289.5 \Rightarrow 290\,\mathrm{mm}$, as in the cut list. 9. Bend radii and where a loop hangs (move every joint through its range by hand and look); workmanship such as crimps, solder joints and tie tension (pull test, inspection, continuity check).
## Further reading - [IPC/WHMA-A-620, Requirements and Acceptance for Cable and Wire Harness Assemblies (table of contents)](https://www.electronics.org/TOC/IPC-WHMA-A-620E_TOC.pdf): the industry workmanship standard for crimps, solder joints, wicking, bundling and ties. - [NASA-STD-8739.4, Workmanship Standard for Crimping, Interconnecting Cables, Harnesses, and Wiring](https://standards.nasa.gov/sites/default/files/standards/NASA/A/4/nasa-std-87394a_w_change_4_0.pdf): a free, detailed harness workmanship standard. - [Molex Quality Crimping Handbook](https://www.molex.com/content/dam/molex/molex-dot-com/products/automated/en-us/commercialcrimpbookpdf/638/63800/TM-638000029-001.pdf): conductor and insulation grip, pull tests, crimp defects. - [WAGO 221-415 data sheet](https://wago.priintcloud.com/datasheets/221-415/en): 0.2–4 mm², 32 A, strip length 11 mm. - [HellermannTyton spiral binding (SBPE)](https://www.hellermanntyton.com/products/protective-tubing-and-spiral-binding/sbpe1.5/161-41001): spiral wrap sizes and temperature range. - [NBS Handbook 100, Copper Wire Tables](https://nvlpubs.nist.gov/nistpubs/Legacy/hb/nbshandbook100.pdf): AWG diameters, areas and resistances. - [PowerStream AWG table](https://www.powerstream.com/Wire_Size.htm): a widely copied chassis-wiring ampacity table (secondary source; the robot's values are deliberately lower). - [Raspberry Pi hardware documentation](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html): GPIO header power pins. - Robot docs: [electronics, wiring and power](../../electronics.md#wiring-harness), [wiring check and cut list](../../verification/wiring.md), [ADR-011](../../decisions.md#adr-011-wiring-harness-routed-and-checked-in-the-cad). - Related in this wiki: [power](../hardware/power.md), [tools and workshop](tools-and-workshop.md), [safety](safety.md), [frames and rotations](../theory/frames-and-rotations.md). --- # Fasteners Strido is held together by 223 screws in three sizes (M2, M2.5, M3), all with socket heads. This page explains how to read a screw designation, how a screw holds in a printed hole, why the pilot holes are sized as they are, and how tight is tight enough in plastic. ::: tip In Strido The fastener list is in the BOM ([bom/README.md](../../bom/README.md), sections *Fasteners* and *Fasteners by joint*); each assembly step lists its screws in the parts box ([assembly manual](../../assembly/README.md)). The fit gate (`python -m strido_verify.fits`, report [verification/fits.md](../../verification/fits.md)) checks every screw against the bands in `hardware/verification/tolerances.yaml`. ADR-009 in [decisions.md](../../decisions.md) covers the servo-mount pilots. ::: ## Reading a screw designation `M3 × 10 DIN 912 A2` means: | Part | Meaning | |---|---| | `M3` | Metric ISO thread, 3 mm nominal (outer) diameter. Coarse pitch unless stated: M2 = 0.4 mm, M2.5 = 0.45 mm, M3 = 0.5 mm per turn | | `× 10` | Length in mm, measured from under the head to the tip | | `DIN 912` / `ISO 4762` | Socket head cap screw: cylindrical head with a hex socket. DIN 912 and ISO 4762 are the same shape; the BOM orders DIN 912, the CAD calls them `iso4762_…` | | `A2` | Stainless steel grade (A2 = 304-type austenitic) | ## The fasteners in this robot All screws are socket head cap screws. One hex key size per thread: | Thread | Hex key | Nut (across flats) | Lengths used | Typical use | |---|---|---|---|---| | M2 | 1.5 mm | 4.0 mm | 5, 10 mm | LCD bracket, regulator stand-offs, camera PCB | | M2.5 | 2.0 mm | 5.0 mm | 5, 6 mm | Pi 5 / PCA9685 / INA260 stand-offs, IMU, LiDAR | | M3 | 2.5 mm | 5.5 mm | 8, 10, 12, 16, 20, 25, 40 mm | everything structural: frames, servos, legs, shells | The M3 × 10 is by far the most common (86 in the CAD: servo ears, shoulder frames, chassis, knee axles). The longest is the single M3 × 40 camera tilt pivot. Other fastener types in the BOM: - **Hex nuts** ISO 4032 / DIN 934 (M2, M2.5, M3): plain nuts, 56 of them M3. Some sit in moulded pockets in the printed part (for example the captive nut in `upper_shoulder_frame`), so you tighten from the screw side only. - **Nylon-insert lock nuts** DIN 985 M3 (17 in the CAD): a nylon ring grips the thread so the nut does not back off even when it is *not* tight. That is why they are used at moving joints (tibia joints, tibia link pins, camera pivot). - **Washers** ISO 7089 M3 (16): spread the load under a head or nut at the knee axles, hip pivots and arm pins. - **Stand-offs**, female-female: brass M2.5 × 6 mm (boards) and M2 × 8 mm (regulator). They lift circuit boards off the deck. No heat-set inserts are used: every screw ends in a nut, a printed pilot, or a metal thread. ## Three ways a screw ends The fit report counts how each of the 223 screws terminates: | Termination | Count | What holds the screw | |---|---|---| | Nut | 105 | A hex nut, lock nut or brass stand-off; the printed parts are clamped between head and nut | | Thread-forming | 78 | The screw cuts its own thread into a printed pilot hole (servo ears, servo brackets, knee axles, crank pins, hip horns, shell bosses) | | Vendor thread | 40 | A metal thread in a bought part: aluminium horns (femur-to-horn), servo output shafts (centre screws), LCD hex studs, LiDAR body | A nut joint is the strongest and can be retightened indefinitely. A thread-forming joint is convenient (no nut to hold) but plastic threads wear out if you take the screw out and in many times. ## Thread-forming screws in printed pilots The BOM's ordinary machine screws are used as thread-forming screws: driven into a hole smaller than the screw, they push plastic aside and form a matching thread. Two numbers decide whether that works. ### Pilot diameter Guides for thread-forming screws in thermoplastics recommend a pilot of about $0.8\,d$ ($d$ = nominal diameter). Strido grades the **printed** pilot as a ratio of $d$, taking the expected print shrink into account: $$ \frac{D_\mathrm{printed}}{d} = \frac{D_\mathrm{CAD} - 0.15\,\mathrm{mm}}{d} $$ | Printed pilot / d | Grade | Why | |---|---|---| | < 0.70 | fail | too much material to displace: the boss splits or the screw head strips | | 0.70 – 0.90 | ok | full grip | | 0.90 – 0.93 | warn | weak grip: tighten by feel; if a screw spins, harden the pilot with thin CA glue and re-drive | | > 0.93 | fail | no grip | For M3 that means a CAD pilot of about 2.25–2.85 mm is ok and 2.85–2.94 mm is a warning. Example: the `femur` M3 pilots are 2.80–2.85 mm in CAD, so they print at about 2.65–2.70 mm, and $2.70\,\mathrm{mm} / 3\,\mathrm{mm} = 0.90$ (range $0.88$–$0.90\,d$): ok. ### Engagement length Engagement is the length over which the hole is narrower than the screw, i.e. how much thread is formed. Rule: at least $1.5\,d$ is ok ($1.5 \times 3\,\mathrm{mm} = 4.5\,\mathrm{mm}$ for M3), $1.0$–$1.5\,d$ is a warning (do not over-tighten), below $1.0\,d$ fails. The `femur` pilots have 4.0 mm engagement ($4.0 / 3 \approx 1.33\,d$) and the `arming_port_bezel` M2.5 pilots 2.5 mm ($1.0\,d$): both warn, so tighten gently. Why engagement matters (thread shear area grows with engaged length) is worked through in [strength, torque and failure](strength.md). ### ADR-009: the servo-mount pilots 29 screws of the upstream servo-mount family (`left_servo_mount`, `right_servo_mount`, the derived `_fl`/`_fr` variants and `servo_mount_top_bracket`) thread into 2.94–2.97 mm CAD pilots: 0.93–0.94 d after printing, just above the weak-grip band, with 4–12 mm engagement. Upstream Yertle robots are built with exactly these holes, so instead of redesigning them the project records an accepted deviation with a builder action: 1. **Preferred:** print these five parts with **X-Y hole compensation −0.15 mm**, which brings the pilot to about 2.65 mm (see [3D printing](3d-printing.md)). 2. **Otherwise:** before driving each screw, wick a drop of thin CA (cyanoacrylate) glue into the pilot and let it cure; it hardens the hole wall so the screw forms a firmer thread. Check that each screw bites before the joint carries load. If a mount ever strips a thread, the ADR's fallback is a re-pilot variant with a Ø 2.5 mm hole. ## Threadlocker Medium-strength (blue) threadlocker is an anaerobic adhesive: it cures in the absence of air between **metal** threads (the metal ions catalyse the cure) and stops vibration from loosening a screw, while still allowing removal with a hand tool. Strido uses it only on **metal-to-metal screws**: the screws into the servo output shafts and the tapped aluminium 25T horns: the horn centre screws, the femur-to-horn M3 × 20, the hip-horn M3 × 8 through the servo mount into the disc horn and the arm-pin M3 × 16 into the knee-drive arm horn. Every step reverses the load on a horn, and metal on metal has no plastic to grip the thread, so these are the screws most likely to work loose. One drop on the screw thread is enough. ::: warning No threadlocker in printed holes Henkel's data sheet for Loctite 243 states that it is not normally recommended on plastics, particularly thermoplastics, because stress cracking can result; between a steel screw and plastic it also lacks the metal surfaces it needs to cure. For weak printed pilots use hole compensation or a CA-hardened pilot instead. ::: ## Tightening in plastic A steel M3 screw can easily produce more clamping force than a printed boss can take. The [strength report](../../verification/strength.md#tightening-torque-into-printed-pilots) gives a torque limit for every printed pilot (0.05–0.35 N·m; the formulas are in [strength, torque and failure](strength.md)). The rules from the assembly manual: - **Screws into printed pilots (marked self-tapping):** turn until snug, never to full torque. "Snug" means the head has just seated and the parts no longer move; stop there. - **Through thin parts:** the electronics deck is 3 mm thick in the centre; its screw heads sit in counterbores, so do not over-tighten. - **Lock nuts at moving joints:** tighten only until the joint has no play; the knee links must swing under their own weight (Step 12). A lock nut holds its position without being tight. - **Friction joints:** the camera pivot (M3 × 40 with a lock nut) is tightened until the camera holds its angle, 15° down being the reference. - **Short hex keys:** hold a hex key by its long arm only for loosening; use the short arm (or finger pressure on a screwdriver handle) for tightening into plastic, so you cannot apply much torque by accident. Signs you went too far: the screw keeps turning without getting tighter (thread stripped), a white stress mark appears around the hole, or a layer line opens up next to the boss. ## Check yourself 1. What do `M2.5 × 6` and `ISO 4762` tell you? 2. An M3 pilot is drawn at 2.80 mm. What will it print at, and what grade does it get? 3. Why do the knee joints use DIN 985 lock nuts rather than plain nuts? 4. Which screws in Strido get thread locker as standard? 5. What is the preferred builder action for the servo-mount pilots under ADR-009?
Answers 1. Metric thread 2.5 mm diameter, 6 mm long under the head; a socket head cap screw. 2. About 2.65 mm (2.80 − 0.15), i.e. 0.88 d: ok, full grip. 3. The joint must turn freely, so the nut cannot be tightened hard; the nylon insert keeps it from backing off anyway. 4. Only the metal-to-metal screws into the servo horns; printed pilots never get anaerobic thread locker. 5. Print those parts with X-Y hole compensation −0.15 mm, giving a pilot of about 2.65 mm.
## Further reading - [Henkel: LOCTITE 243 technical data sheet](https://datasheets.tdx.henkel.com/LOCTITE-243-en_GL.pdf) - [Fit report: verification/fits.md](../../verification/fits.md) and [decisions (ADR-009)](../../decisions.md) - [Prusa Knowledge Base: PETG](https://help.prusa3d.com/article/petg_2059) (material the pilots are printed in) - Related in this wiki: [3D printing](3d-printing.md), [mechanics](../hardware/mechanics.md), [tools and workshop](tools-and-workshop.md) --- # Safety Strido carries a lithium-polymer battery that can deliver hundreds of amperes into a short circuit, and twelve servos strong enough to trap a finger. This page explains the hazards, the protective parts the robot uses against them, and the order in which to power it up the first time. ::: tip In Strido Safety parts: the 25 A main fuse and 7.5 A logic fuse (ATO blade fuses), the XT60 arming port with its loop key in the back shell, the Pololu #2813 pushbutton switch, the INA260 power monitor (low-battery cut-off), and the PCA9685 OE pull-up. Rules and the smoke test are in [electronics.md](../../electronics.md#safety-rules); first power-up is assembly Step 23 ([assembly manual](../../assembly/README.md)). Firmware safety (watchdog, e-stop, cut-off) is described in [software.md](../../software.md#firmware). ::: ## LiPo batteries The pack is a **2S LiPo, 5000 mAh**, hardcase, XT60 lead. "2S" means two cells in series: 7.4 V nominal, 8.4 V full, about 37 Wh. In `hardware/electronics.yaml` its maximum discharge is listed as 300 A (60C; 500 A in bursts). A short across its terminals therefore does not blow gently: the wire glows, insulation burns and the cells can be damaged into a fire. | Rule | Why | |---|---| | Charge only with a **balance charger**, inside the **LiPo bag**, never unattended | Balancing keeps both cells at the same voltage; an unbalanced cell can be overcharged and fail; the bag contains a fire long enough to react | | Never charge above 4.2 V per cell (8.4 V for the pack) | Overcharging lithium cells is the classic cause of thermal runaway | | Never run the pack flat | Deep discharge damages lithium cells. The firmware warns at **6.8 V** (3.4 V/cell) and relaxes the servos at **6.4 V** (3.2 V/cell); the cell checker / low-voltage alarm in the BOM gives a second opinion | | Store at storage charge (about 3.8 V/cell; B6AC-class chargers have a storage mode) | Full packs age faster and hold more energy if something goes wrong | | Retire a pack that is **puffed**, dented or hot after normal use | Swelling is gas from decomposing electrolyte, a sign of internal damage | | Insulate bare leads; transport the pack in the bag | Prevents shorts from keys, tools or the other connector | ::: danger If a LiPo smokes Do not try to save it. Move away, keep others away and ventilate. Do not cover a burning pack with your hands or a cloth. Contact your local fire service if the fire spreads. A pack that has vented is never used again. ::: ## Fuses: protecting the wire A fuse protects the **wire** downstream of it from overheating. A wire's heating power is $P = I^2 R$, so it grows with the square of the current, and a short circuit can heat a thin wire until its insulation melts long before the battery runs out. The fuse is a deliberately weak link that melts first, so it is sized below the current the wire can carry and above the current the load needs: $$ I_\mathrm{load} \le I_\mathrm{fuse} \le I_\mathrm{wire} $$ For the main fuse the servo load converts back to the pack through the UBECs (90 % efficient, see [power](../hardware/power.md)). The worst case is the nearly empty pack at the $6.4\,\mathrm{V}$ cut-off: $2 \times 10\,\mathrm{A} \times 6.0\,\mathrm{V} / (0.9 \times 6.4\,\mathrm{V}) \approx 20.8\,\mathrm{A}$, plus $2.7\,\mathrm{A}$ logic $= 23.6\,\mathrm{A}$ ([electronics check](../../verification/electronics.md)). That stays below the 25 A fuse, which in turn is below the 14 AWG trunk's 32 A. | Fuse | Rating | Protects | Wire rating (`electronics.yaml`) | Load | |---|---|---|---|---| | Main (ATO) | **25 A** | W02, W03, W04/front, W04/rear: the 14 AWG trunk | 32 A | servo branch ≤ 2 × 10 A UBEC output (≈ 21 A at an empty pack, see [electronics](../../electronics.md#ratings-and-why-these-parts)) + logic | | Logic (ATO) | **7.5 A** | W09, W10, W11 and the logic ground returns W24, W04/logic: the 18 AWG logic wiring | 16 A | logic branch ≤ 3 A | The fuses sit as close to the battery input as their holders fit, because the short-circuit hazard is largest there. The main fuse holder lies on the rear WAGO bus, about 0.15 m from the battery input; W01, the 14 AWG lead between them, is unfused like the pack's own lead, so it is kept short, in spiral wrap and away from moving parts. The logic fuse holder in the back shell is fed through **the holder's own 14 AWG lead**, spliced to W02: no wire thinner than 14 AWG sits behind the 25 A fuse alone. Why that matters: [cable management](cable-management.md#fusing-a-fuse-protects-the-wire). Spares are in the BOM (two extra 25 A, one extra 7.5 A). Replace a blown fuse only with the **same rating**, and only after you have found why it blew. During harness building the fuses stay **out** until the wiring is checked: the power entry is wired without them (harness step 3), and they go in only for the Pi power check (harness step 6). ## First power-up without servos: the smoke test The first time the robot gets battery power, the servos must not be able to move and the expensive boards must see the right voltages. The harness steps in [electronics.md](../../electronics.md#wiring-harness) do it in this order: ```mermaid flowchart TD A["UBECs: measure 5.8–6.0 V unloaded
(no servo connected)"] --> B["Fuses in, button pressed:
4.8–5.2 V at the W12 Dupont ends"] B --> C["Plug W12 onto Pi pins 2/4 (5 V), 6/14 (GND)"] C --> D["Smoke test: fuses in, loop key OUT, power on"] D --> E["Pi boots; /battery_state shows pack voltage;
/diagnostics: pwm_ok, imu_ok, power_ok"] E --> F["XIAO in bootloader (BOOT + RESET):
PCA9685 OE ≥ 2.4 V (outputs off)"] ``` Why each check: - **UBEC 5.8–6.0 V before servos:** the SPT5435LV is rated 4.8–6.0 V only. A unit set to (or failing at) 7.4 V would over-stress twelve servos at once. - **5 V at the Dupont ends before the Pi:** the Pi is fed through its GPIO 5 V pins, which bypasses the USB-C power input and its power negotiation; a wrong voltage there reaches the board directly. - **Loop key out:** the servo branch is dead, so nothing can move while you check the logic side. - **OE ≥ 2.4 V in the bootloader:** the PCA9685 board pulls OE low (outputs on) by default. The 2.2 kΩ pull-up keeps the outputs off whenever the XIAO is resetting or not running firmware, so a reset can never send random pulses. Keep a hand near the power button for the first boot, and switch off at once if anything gets hot or smells. ## Arming: the loop key The **loop key** is a shorted XT60 plug that you insert into the **arming port** in the back shell to connect the servo branch. Pulling it is the servo kill: no current reaches the UBECs, whatever the software does. - The **pushbutton switch is not an emergency stop.** It switches only the logic branch (Pololu states it is not intended as an emergency stop). - The loop key must never fit the battery itself, otherwise it would short the pack. It therefore has the same contact type as the battery connector. Check this with the *unbridged* key housing before you solder the bridge (see the [XT60 contact rule](../../electronics.md#xt60-contact-rule-the-loop-key-must-never-fit-the-battery)). - Label the port "ARM": the battery itself can mate with it, so always connect the pack to the battery input under the deck (its lead W00 into the XT60H). **Startup order** (from the safety rules): logic on → robot lying in the rest pose, legs folded → insert the loop key (servos stay limp; the controller sends no pulses before the first position command) → start `ros2 launch strido_bringup robot.launch.py` (servos go to the rest pose, then a 3 s stand-up). ::: danger Never arm while commands are running Inserting the loop key while position commands are being sent makes all twelve servos jump to their targets at once. Six stalled servos draw about 21 A, more than one UBEC's 15 A peak. ::: Software stops, in addition to the key: - `/strido/estop` latches: the firmware pulls PCA9685 OE high and stops all pulses. - Watchdog: no joint command for 200 ms → servos hold; 2 s later → they relax. - The firmware refuses to drive without a working INA260, because without it there is no low-battery cut-off. ## Pinch points and moving legs Each SPT5435LV is rated 35 kg·cm (about 3.43 N·m at stall). Torque is force times lever arm, $\tau = F r$, so at $r = 30\,\mathrm{mm}$ from the axis the servo can press with $$ F = \frac{\tau}{r} = \frac{3.43\,\mathrm{N\,m}}{0.03\,\mathrm{m}} \approx 114\,\mathrm{N} $$ roughly the weight of $114\,\mathrm{N} / 9.81\,\mathrm{m/s^2} \approx 11.7\,\mathrm{kg}$, on a fingertip. Closer to the axis the force is even larger. The knee is driven through a crank and linkages, so there are scissor points between the links, the femur and the tibia that close as the knee bends. - Keep fingers out of the legs whenever the loop key is in. - Hold the robot by the body shell, never by a leg. - Test new gaits or policies with the robot lifted on a stand first, legs free. - Before reaching into the legs, pull the loop key; relaxing the servos in software is not enough. - Long hair, sleeves and cables away from the knee linkages. ## ESD: static electricity A static spark you do not even feel can damage the inputs of a microcontroller or sensor. The Raspberry Pi 5, XIAO ESP32-S3, LSM6DSOX IMU, INA260 and PCA9685 are bare boards with exposed pins. - Touch a grounded metal object before handling a board; an ESD wrist strap is better. - Hold boards by their edges; do not touch the pins or components. - Keep boards in their anti-static bags until mounting. - Work on a bare table or an ESD mat rather than carpet or plastic sheeting. ## Other workshop hazards - **Burns:** the soldering iron runs at around 370–400 °C, the printer nozzle at 240 °C, the bed at 80 °C. - **Fumes:** ventilate when soldering (flux) and printing. - **Flying cut-offs:** wear eye protection when cutting supports, wire and resistor leads with flush cutters. ## Check yourself 1. What is the difference between pulling the loop key and pressing the pushbutton switch? 2. Why is the logic fuse 7.5 A when the logic branch only draws up to 3 A? 3. What two voltages must you measure before the first servo or the Pi is connected? 4. At what pack voltage does the firmware relax the servos, and why? 5. Why must the loop key have the same contact type as the battery connector?
Answers 1. The loop key cuts the servo power branch (servo kill); the pushbutton switches only the logic branch and is not an emergency stop. 2. The fuse protects the 18 AWG wire (rated 16 A here), not the load; it must not blow at normal load but must blow well before the wire overheats. 3. 5.8–6.0 V at each unloaded UBEC output and 4.8–5.2 V at the W12 Dupont ends. 4. 6.4 V (3.2 V per cell), after 5 consecutive readings below it, to avoid deep-discharging the LiPo. 5. So that the shorted key cannot mate with the battery and short the pack.
## Further reading - [Battery University: Safety concerns with Li-ion (BU-304a)](https://batteryuniversity.com/article/bu-304a-safety-concerns-with-li-ion) - [OSHA: Lithium-ion battery safety (PDF)](https://www.osha.gov/sites/default/files/publications/OSHA4480.pdf) - [Pololu Big Pushbutton Power Switch HP (#2813)](https://www.pololu.com/product/2813) - [Electronics, wiring and power: safety rules](../../electronics.md#safety-rules) - Related in this wiki: [batteries](../hardware/batteries.md), [power](../hardware/power.md), [cable management](cable-management.md), [firmware](../software/firmware.md) --- # Strength, torque and failure Will a printed leg break, bend or tear out its screws? This page covers the mechanics behind that question: torque, stress and strain, bending, buckling, joints and safety factors. Each formula is worked through with Strido's own numbers. The same formulas run as an automatic check on every design change. ::: tip In Strido **Gate:** `python -m strido_verify.strength` (part of `make verify`) computes 19 checks from the printed STLs, the leg geometry and the fit report; results in the [strength report](../../verification/strength.md). **Inputs:** `hardware/strength.yaml` (loads, PETG and screw data with sources). **Decision:** [ADR-010](../../decisions.md): the long link's buckling, once the one known weak point, fixed by the stiff long link (no check needs an exception now). **Each part's results** appear on its page in the [print kit](../../print-kit.md) and the [shop](../../shop.md). The forces in the legs come from [statics and dynamics](../theory/statics-and-dynamics.md). ::: ## Torque: force times lever A torque $T$ (German *Drehmoment*) is a force $F$ acting at a distance $r$ from an axis. Only the force component at right angles to the lever turns the axis. With $\alpha$ the angle between lever and force: $$ T = r\,F\,\sin\alpha \qquad [\mathrm{N\,m}] = [\mathrm{m}]\cdot[\mathrm{N}] $$ In vector form this is the cross product $\mathbf{T} = \mathbf{r}\times\mathbf{F}$. Servo datasheets give torque in kg·cm: the weight of 1 kg hanging 1 cm from the axis. With $g = 9.81\,\mathrm{m/s^2}$: $$ 35\,\mathrm{kg\,cm} = 35 \cdot 9.81\,\mathrm{N} \cdot 0.01\,\mathrm{m} = 3.43\,\mathrm{N\,m} \qquad \text{(SPT5435LV at 6.0 V, stall)} $$ Read backwards, the same formula gives the force a servo can push through an arm. The knee-drive arm horn is $r = 24\,\mathrm{mm}$: $$ F = \frac{T}{r\,\sin\alpha} = \frac{3.43\,\mathrm{N\,m}}{0.024\,\mathrm{m}\cdot 1.0} = 143\,\mathrm{N}\ \ (\alpha = 90^\circ), \qquad \frac{3.43}{0.024 \cdot 0.68} = 211\,\mathrm{N}\ \ \text{(worst angle within the joint limits)} $$ Small levers and flat angles turn modest torques into large forces. As $\alpha \to 0$ the force grows without bound: that is the *dead point* (toggle) of a linkage. The check sweeps every pose inside the joint limits and keeps the largest force. ## Where the loads come from 1. **Weight and landings.** The robot weighs $m g = 3.07 \cdot 9.81 = 30.1\,\mathrm{N}$. While it trots, one diagonal pair of feet carries it. A hard landing multiplies the force by an impact factor $k$. The check assumes $k = 3$ on two feet: $$ F_\text{foot} = \frac{k\,m\,g}{2} = \frac{3 \cdot 30.1}{2} = 45.2\,\mathrm{N} $$ (an assumption; measured trotting dogs peak at about 1–1.2 × their weight per front leg). 2. **The servos themselves.** Each servo pushes with at most its stall torque $T_\text{stall}$, and that is the design load for the joints. A landing would need $F_\text{foot}\,L_\text{tibia} = 45.2 \cdot 0.124 = 5.6\,\mathrm{N\,m}$ at the knee. The knee servo applies only $3.07\,\mathrm{N\,m}$ there, so the knee gives way and the servo is back-driven. Back-driving a geared servo takes more than its stall torque, though. With forward and reverse gear efficiencies $\eta_f$ and $\eta_r$ it takes roughly $T_\text{stall}/(\eta_f\,\eta_r)$, about 1.5–3 × (an estimate). Hard impacts can therefore exceed the design loads, and the safety factors have to cover that. Pins and bearings carry the full landing force along the leg. ## Stress and strain Load on a part is spread over its cross-section. **Stress** $\sigma$ is force per area, in $\mathrm{N/mm^2} = \mathrm{MPa}$. **Strain** $\varepsilon$ is the relative stretch $\Delta L / L$. For small strains most materials are linear-elastic (Hooke's law), with Young's modulus $E$: $$ \sigma = \frac{F}{A}, \qquad \varepsilon = \frac{\Delta L}{L}, \qquad \sigma = E\,\varepsilon $$ PETG has $E \approx 1.5\,\mathrm{GPa}$ and yields at about $47\,\mathrm{MPa}$; Hooke's law would put that at $\varepsilon = 47/1500 \approx 3\,\%$, the data sheet measures $5\,\%$ because PETG softens before it yields. Steel has $E \approx 200\,\mathrm{GPa}$, so for the same stress it stretches 130 times less. Beyond the **yield strength** the part deforms permanently. Beyond the **tensile strength** it breaks. Real parts carry several stresses at once (tension, bending, shear). The **von Mises** equivalent stress turns them into one number to compare with the tensile yield strength. For normal stress $\sigma$ plus shear $\tau$: $$ \sigma_\text{v} = \sqrt{\sigma^2 + 3\,\tau^2} \qquad\Rightarrow\qquad \tau_\text{allow} = \frac{\sigma_\text{allow}}{\sqrt 3} \approx 0.577\,\sigma_\text{allow} $$ ## Bending A beam bent by a moment $M$ stretches on one side and compresses on the other. The stress grows linearly from the neutral axis (zero stress) to the outermost fibre at distance $c$. Euler–Bernoulli beam theory gives: $$ \sigma(y) = \frac{M\,y}{I}, \qquad \sigma_\text{max} = \frac{M\,c}{I} = \frac{M}{W}, \qquad I = \int_A y^2\,\mathrm{d}A, \qquad W = \frac{I}{c} $$ $I$ is the **second moment of area**: it measures how far the material sits from the neutral axis. $W$ is the **section modulus**. For a solid rectangle of width $b$ and height $h$, bent across $h$: $$ I = \frac{b\,h^3}{12}, \qquad W = \frac{b\,h^2}{6} $$ Height counts with its cube: doubling the height makes a link 8 times stiffer and 4 times stronger in bending. That is why I-beams put their material far from the middle. **Printed parts are mostly shells.** The slicer profile prints 3 perimeters (1.2 mm) and only 30 % gyroid infill. The check computes $I$ and $W$ for the 1.2 mm walls only and ignores the infill, which is conservative. It takes 41 sections along each link and uses the weakest one. Example: the femur's weakest section has $W = 236\,\mathrm{mm^3}$ in the leg plane, and the thigh servo applies $M = 3430\,\mathrm{N\,mm}$: $$ \sigma = \frac{3430\,\mathrm{N\,mm}}{236\,\mathrm{mm^3}} = 14.5\,\mathrm{MPa}, \qquad S = \frac{47}{14.5} = 3.2 $$ ## Printed plastic is anisotropic An FDM part is a stack of welded layers. Prusament's PETG data sheet gives: | Direction | Strength | |---|---| | Along the layers (printed flat) | $47\,\mathrm{MPa}$ tensile yield | | Across the layers (layer adhesion) | $18 \pm 4\,\mathrm{MPa}$ | Printed parts usually fail by pulling layers apart. They tear (German *reißen*) along a layer line, often at a sharp corner where the stress concentrates. That is why the print list lays the links, femurs and tibias flat. Their bending stress then runs along the layers. The check uses $47\,\mathrm{MPa}$ when a link lies flat and $18\,\mathrm{MPa}$ when its long axis points up in its print orientation. ## Safety factor $$ S = \frac{\text{allowable}}{\text{actual}} $$ The check fails below $S = 1.5$ and warns below $2.0$. FDM strength scatters from printer to printer and PETG creeps under a standing load. Repeated loading also causes **fatigue**: millions of steps at a stress well below the static strength can grow a crack. None of that is in a static calculation, so the margin covers it. On the robot the lowest factors are the tibia halves ($S = 2.2$) and the long link's buckling ($S = 2.5$, next section), which was far below everything else until the link was redesigned. ## Buckling A slender bar in compression does not crush. It suddenly bows sideways once the force reaches Euler's critical load. The deflected bar is in equilibrium when $E I\,w'' + F\,w = 0$. The smallest non-zero solution of this equation gives: $$ F_\text{cr} = \frac{\pi^2 E I}{(K L)^2} $$ $L$ is the pin spacing and $K$ the effective-length factor of the end conditions: $1.0$ for pinned ends, $0.5$ for clamped ends, $0.7$ for one of each. Below $F_\text{cr}$ nothing visible happens. At $F_\text{cr}$ the bar loses its stiffness against sideways deflection. The upstream long knee link is a bar $5\,\mathrm{mm}$ wide in the leg plane and $8\,\mathrm{mm}$ across it, $130\,\mathrm{mm}$ between its pins. In the leg plane its ends turn freely on pins ($K = 1$), and its walls give $I \approx 75\,\mathrm{mm^4}$: $$ F_\text{cr} = \frac{\pi^2 \cdot 1500\,\mathrm{MPa} \cdot 75\,\mathrm{mm^4}}{(130\,\mathrm{mm})^2} \approx 65\,\mathrm{N} \qquad F_\text{knee servo, worst pose} = 192\,\mathrm{N} \qquad S \approx 0.34 $$ That made it the robot's weakest point by far. Standing loads it with about $0.70\,\mathrm{N\,m} / (0.024\,\mathrm{m}\cdot\sin 86^\circ) \approx 29\,\mathrm{N}$ ($S \approx 2.2$; see [statics and dynamics](../theory/statics-and-dynamics.md)), so it held in normal walking, but a knee pushing at stall against a blocked foot or a hard stop bends it sideways ([ADR-010](../../decisions.md)). Because $I \propto h^3$, width in the leg plane is what buys stiffness. Strido therefore prints a **stiff long link** (`stiff_long_link`): the same pins and bushing eyes, but a $10 \times 10\,\mathrm{mm}$ shaft. With $1.2\,\mathrm{mm}$ walls only, a hollow square gives $I = (10^4 - 7.6^4)/12 \approx 555\,\mathrm{mm^4}$, more than seven times the upstream bar: $$ F_\text{cr} = \frac{\pi^2 \cdot 1500\,\mathrm{MPa} \cdot 555\,\mathrm{mm^4}}{(131\,\mathrm{mm})^2} \approx 480\,\mathrm{N} \qquad S \approx \frac{480}{192} \approx 2.5 $$ The gate measures the printed STL section by section and reports $S = 2.53$. A wider part also needs room: centred on the pin line, the link came within $0.25\,\mathrm{mm}$ of the femur at the stretched knee, so its shaft sits $2\,\mathrm{mm}$ off the pin line, away from the femur. Every neighbour keeps at least $1\,\mathrm{mm}$, and the joint limits did not change. ## Screws in shear and in printed holes A screw that clamps two parts which try to slide past each other is loaded in **shear**. The stress is spread over the tensile stress area $A_s$ ($5.03\,\mathrm{mm^2}$ for M3). When a torque $T$ is carried by $n$ screws on a bolt circle of radius $r$: $$ F = \frac{T}{n\,r}, \qquad \tau = \frac{F}{A_s} $$ The kit's DIN 912 A2-70 stainless screws have a yield strength of $R_{p0.2} = 450\,\mathrm{MPa}$, so $\tau_\text{allow} = 0.577 \cdot 450 = 260\,\mathrm{MPa}$. The femur is bolted to its horn with $4 \times$ M3 on $r = 7\,\mathrm{mm}$: $$ F = \frac{3.43}{4 \cdot 0.007} = 122\,\mathrm{N}, \qquad \tau = \frac{122}{5.03} = 24\,\mathrm{MPa}, \qquad S = 10.7 $$ The screw is rarely the problem. The plastic around it is. The **hole bearing pressure** is the force over the projected area of the hole (diameter $d$, material thickness $t$): $$ p = \frac{F}{d\,t} $$ For the hip horn screws in the servo mount this is $17.9\,\mathrm{MPa}$ against $47\,\mathrm{MPa}$ ($S = 2.6$), the lowest of the screw joints. ## Pull-out and tightening torque A thread-forming screw in a printed pilot hole holds by the plastic thread it cuts. Pulled hard enough, it shears out a cylinder of plastic (*ausreißen*) of diameter $d$ and engaged length $L_e$. The shear strength across the layers is $\tau = 0.577 \cdot 18 = 10.4\,\mathrm{MPa}$: $$ F_\text{pull} = \tau\,\pi\,d\,L_e $$ Tightening turns torque into axial preload. Most of the torque goes into friction in the thread and under the head. The *nut factor* $k$ (about 0.2 for dry stainless threads) lumps all of that into one number: $$ T = k\,F\,d $$ So every printed pilot has a torque that strips it. Example: the knee axle screws engage $L_e = 4.0\,\mathrm{mm}$ of the femur: $$ F_\text{pull} = 10.4 \cdot \pi \cdot 3 \cdot 4 = 392\,\mathrm{N}, \qquad T_\text{strip} = 0.2 \cdot 392\,\mathrm{N} \cdot 0.003\,\mathrm{m} = 0.24\,\mathrm{N\,m} $$ **Tighten to at most half of that, about $0.12\,\mathrm{N\,m}$.** That is a gentle two-finger turn on the short arm of a 2.5 mm hex key: snug, not tight. The [strength report](../../verification/strength.md#tightening-torque-into-printed-pilots) lists the limit for every printed pilot (0.05–0.35 N·m). For comparison, an M3 A2-70 screw into a steel nut may take up to about $1.26\,\mathrm{N\,m}$ (Bossard, $\mu = 0.2$), ten times more. ## Bearings Ball bearings have a **static load rating** $C_0$: the load that dents the races permanently. The static safety factor is $S_0 = C_0 / F_r$. The two knee bearings share the knee force (237 N: landing plus the largest link force). Each 633ZZ therefore sees $118\,\mathrm{N}$ against $C_0 = 490\,\mathrm{N}$ (Koyo), $S_0 = 4.2$. Rotating bearings also wear out by fatigue. Their **rating life** in millions of revolutions follows from the dynamic rating $C$ and the equivalent load $P$: $$ L_{10} = \left(\frac{C}{P}\right)^3 \quad [10^6\ \text{revolutions}] $$ 90 % of bearings reach this life. The knee joints only oscillate through a small angle, so the static rating matters more here. The kit's unbranded bearings are assumed no better than the branded ratings. ## Check yourself 1. A servo is rated 25 kg·cm. What force can it push through a 20 mm arm at right angles? 2. Why does a printed link lie flat on the bed rather than stand up? 3. You make a link 1.5 × thicker. How do its bending strength and its buckling load change? 4. What limits the bending moment in the legs, the landing or the servo? 5. A pilot hole engages 6 mm of an M3 screw. What is the maximum tightening torque by this page's rule?
Answers 1. $T = 25 \cdot 9.81 \cdot 0.01 = 2.45\,\mathrm{N\,m}$; $F = 2.45 / 0.020 = 123\,\mathrm{N}$. 2. So that bending stress runs along the layers ($47\,\mathrm{MPa}$) instead of across them ($18\,\mathrm{MPa}$ layer adhesion). 3. Bending across that thickness: $W \propto h^2$, so $2.25\times$. Buckling across it: $I \propto h^3$, so $3.4\times$. 4. The servo's stall torque is the design load: the knee gives way above it. Back-driving a geared servo takes more than its stall torque, so hard impacts can exceed the design load; pins and bearings always see the full landing force. 5. $F = 10.4 \cdot \pi \cdot 3 \cdot 6 = 588\,\mathrm{N}$; $T_\text{strip} = 0.2 \cdot 588 \cdot 0.003 = 0.35\,\mathrm{N\,m}$; tighten to at most about $0.18\,\mathrm{N\,m}$.
## Further reading - R. C. Hibbeler, *Mechanics of Materials*: stress, bending, buckling, the textbook treatment of every formula above. - [Prusament PETG technical data sheet](https://www.prusa3d.com/file/3464616/prusament-petg-technical-data-sheet.pdf): printed tensile and layer-adhesion values used here. - [Bossard: preload and tightening torques (VDI 2230)](https://assets.eu.ctfassets.net/0vp0u5uh75zd/3S40LEUM235Qk3rJk2phR1/c715f9f45232756c12b59aa681c7fede/060_074_Preload_tightening_torques_Fastening_EN_01_2025.pdf). - [SKF deep groove ball bearings](https://www.skf.com/group/products/rolling-bearings/ball-bearings/deep-groove-ball-bearings): load ratings, static safety and rating life. - Related in this wiki: [statics and dynamics](../theory/statics-and-dynamics.md), [fasteners](fasteners.md), [3D printing](3d-printing.md), [mechanics and bearings](../hardware/mechanics.md), [servos](../hardware/servos.md). --- # Tools and workshop Building Strido needs a small set of hand tools, a soldering iron, a Dupont crimp tool and a multimeter, plus a 3D printer. This page lists them, explains the electrical techniques the harness uses (soldering, crimping, lever clamps, heat-shrinking) and the multimeter checks that catch wiring mistakes before they cost a board. ::: tip In Strido The tools are listed in the BOM under [Tools (not in the totals)](../../bom/README.md#tools-not-in-the-totals) and in section E (*Tools and consumables*) of the catalogue and assembly book ([catalogue](../../catalog.md)). The harness steps and voltage checks are in [electronics.md](../../electronics.md#wiring-harness); assembly Step 0 lists the tools per task ([assembly manual](../../assembly/README.md)). ::: ## The tool list From the catalogue's section E (what each tool is for) and the BOM's tool table (example products): | Tool | Used for in Strido | Example in the BOM | |---|---|---| | Hex keys 1.5 / 2.0 / 2.5 mm | M2 / M2.5 / M3 socket heads | Wera 950/9 Hex-Plus set | | Nut drivers 4.0 / 5.0 / 5.5 mm | holding M2 / M2.5 / M3 nuts | not priced | | Drills 2.2 / 2.7 / 3.2 mm (hand or pin vice) | chasing the tight holes listed in the [fit report](../../verification/fits.md) | — | | Flush cutters, deburring tool | removing supports and brims | Wiha 26812 | | Soldering iron, solder, heat-shrink | power entry, XT60 connectors, splices, XIAO pins, PCA9685 OE pull-up | Miniware TS101; solder (`solder`) and the heat-shrink assortment are in the BOM | | Wire stripper | stripping 14–22 AWG silicone wire | Jokari Super 4 plus | | Dupont crimp tool, contacts + housings | Pi 5 V feed (W12), W17/W20 at the PCA9685, the 12 re-crimped servo signal wires | DxCRIMP set; contacts and housings: `dupont_crimp_contacts` (610-piece kit) in the BOM | | Spiral wrap, cable ties, adhesive tie mounts | dressing the harness: wrap on every bundle of three or more cables, ties at the marked points, two 13 × 13 mm mounts on the deck side strips | in the BOM (`spiral_wrap`, `cable_ties` bag of 100, `cable_tie_mounts`) | | Multimeter | 6.0 V UBEC check, 5 V rail, OE voltage | Kaiweets | | Calipers | measuring printed holes, bearings, screw lengths | — | | Medium thread locker | screws into metal horns only (never into printed holes) | in the BOM (fasteners) | | Thin CA glue (liquid) | hardening weak printed pilots (ADR-009 fallback) | any liquid superglue | | LiPo balance charger + LiPo bag | battery care | in the BOM (power) | | USB-C cable | flashing the XIAO ESP32-S3 | — | | 3D printer, ≥ 220 × 220 mm bed | all printed parts (shells are 114 × 208 mm) | e.g. Bambu Lab A1 Combo | Why a pin vice and not a power drill for chasing holes? You only want to remove the 0.1–0.2 mm of plastic that printing added; a power drill heats PETG, grabs and can crack a thin boss. ## Workspace set-up - **A flat, non-flammable surface** for soldering and charging, with nothing flammable within reach of the iron or the LiPo bag. - **Good light and a magnifier** for the 2.54 mm Dupont pins and the small Adafruit boards. - **Parts trays** per assembly step: the manual's parts boxes list exactly which screws each step uses, so sort them first. - **Ventilation** while soldering (flux fumes) and while printing. - **An anti-static habit**: touch a grounded metal object before handling the Pi, XIAO, IMU or PCA9685, and hold boards by their edges (see [safety](safety.md)). - **A bench supply option**: the BOM's recommended Raspberry Pi 27 W USB-C supply powers the Pi alone for software work, so the LiPo is not needed at the desk. ## Soldering Soldering joins two metals with a lower-melting alloy. A good joint is made by heating **both parts** with the iron and feeding solder into the joint, not onto the iron tip. Where you solder on this robot: | Joint | Wire | Notes | |---|---|---| | Battery input XT60H: W01 (+) and W04 (−) | 14 AWG | Large contacts need a hot, broad tip; pre-tin both cup and wire | | Arming jack XT60H: W02/arming on contact 1, W03 on contact 2; the loop key's bridge | 14 AWG | the loop key bridges the jack's two contacts; check its contact type first (multimeter table below) | | Main fuse holder leads: W01 in, W02 out | 14 AWG | heat-shrink each joint | | Back-shell splice: W02, W02/arming and the logic fuse holder's own lead | 14 AWG | heat-shrunk splice; the holder's 14 AWG lead is the logic tap (no thinner wire behind the 25 A fuse) | | Splice under the deck: W03 → both UBEC IN+ | 14 AWG | heat-shrunk splice | | Ground star W04 beside the battery input | 14 AWG to both UBEC IN−, 18 AWG to the switch GND | one star point; heat-shrink it | | UBEC outputs W05 / W06 | 18 AWG pair | extends each UBEC's output lead | | Logic fuse out (W09), regulator in and out (W11, W24 ground, W12) | 18 / 22 AWG | the switch and INA260 ends of W09–W11 and W24 are screw terminals | | Button pair W13 | 22 AWG | heat-shrink each joint | | XIAO pins: W17 (3V3, GND, SDA, SCL), W20 (D3), W21 (display cable's Dupont ends) | 22 AWG / display cable | no room for Dupont housings 9 mm under the upper shoulder frame. Two wires share the XIAO 3V3 pin and two the GND pin (W17 and W21): solder both to the pin | | 2.2 kΩ OE pull-up | resistor leads | on the PCA9685 header, from OE to VCC; solder it before the board is mounted (Step 7) | Technique, in order: 1. Cut the wire to its length from the cut list in the [wiring check](../../verification/wiring.md), strip just enough to fill the cup or joint (the cut list allows 6 mm per soldered end), twist the strands. 2. **Slide the heat-shrink onto the wire first.** It cannot go on after the connector is soldered. 3. Tin the wire and the contact separately, then join them and reheat until the solder flows into both. 4. Hold still until the solder sets: a moved joint looks dull and grainy and is weak ("cold joint"). 5. Inspect: a good joint is smooth and shiny (lead-free solder is slightly duller) with the strands visible under the solder. Adafruit's guide suggests roughly 370 °C for leaded and 400 °C for lead-free solder on a temperature-controlled iron; heavy XT60 contacts on 14 AWG need the heat but not a long dwell, or the connector's plastic softens. Mating the XT60 to its counterpart while soldering keeps the contacts aligned. ## Crimping Dupont connectors A crimp joins a wire to a contact by deforming metal around it, cold. The Dupont crimp tool shapes two pairs of wings: the front pair bites the bare conductor, the rear pair grips the insulation for strain relief. The finished contact clicks into a plastic housing that plugs onto a 2.54 mm header pin. Crimps in Strido: - **W12:** the 5 V feed from the D36V50F5 regulator to Raspberry Pi header pins 2 and 4 (5 V) and 6 and 14 (GND), 22 AWG, one wire per pin, because each pin carries about 1.6 A. Thick silicone insulation may not fit the insulation wings; trim it or use thin-wall wire. - **W17 and W20 at the PCA9685:** the I²C wires (3V3, GND, SDA, SCL) and the OE wire, soldered at the XIAO end, crimped onto the PCA9685's left header. - **The 12 servo signal wires:** each servo's own lead runs to a tie, where the red and brown wires are peeled off for the WAGO bus. The orange signal wire is **cut to length** and **re-crimped** with a female Dupont contact in a **1-pin housing**, which plugs onto the PWM pin of its PCA9685 channel. There are no servo extensions: every plug-and-socket pair you leave out is one less contact to work loose in a walking robot ([cable management](cable-management.md)). Cut the orange wire so it reaches its channel without strain; the front knee leads have only 12 mm to spare. Crimp rather than solder at these ends: a crimp leaves the strands flexible right up to the contact, and its insulation grip relieves strain. Checks for every crimp: - Pull test: a firm tug must not pull the wire out. - No strands outside the contact. - The contact latches in the housing (it should not push out from the front). ## Lever clamps (WAGO) The servo power bus is made of WAGO 221-415 lever connectors: no tool, but the strip length matters. Strip **11 mm** (WAGO's data sheet), open the lever, push the conductor in until the insulation meets the housing, close the lever and tug. Through the transparent housing you can see the copper reach the end. Each servo's red wire goes into the leg's upper (V+) connector, the brown into the lower (GND), ports hip 1, thigh 2, knee 3; one wire per entry. Wire each WAGO before sliding it into its holder: there is no room to open the levers in place. ## Heat shrink Heat-shrink tubing shrinks to about half its diameter (a common ratio; check your assortment) when heated, insulating and strain-relieving a joint. Choose a size that slides over the joint before shrinking and fits tightly after. Cover every exposed soldered joint in the power path. Use a hot-air tool or the side of the iron shaft rather than a flame near a LiPo. ## Multimeter checks A multimeter is the cheapest insurance in the build. The harness steps prescribe these measurements: | When | Where | Expected | If not | |---|---|---|---| | Before connecting any servo | each UBEC output, no load | **5.8–6.0 V** | the servo is rated up to 6.0 V: return a unit that reads higher; check the jumper is open (no 7.4 V setting) | | Before plugging into the Pi | Dupont ends of W12, fuses in, button pressed | **4.8–5.2 V** (D36V50F5: 5 V ± 4 %) | do not plug in; check polarity and the regulator | | Smoke test | PCA9685 OE while the XIAO is in its bootloader (hold BOOT, press RESET) | **≥ 2.4 V** (outputs off) | the 2.2 kΩ pull-up is missing or not on VCC | | Before first power-up (good practice) | resistance between + and − at the battery input, no pack connected | not near 0 Ω | a short: find it before connecting the battery | | Before bridging the loop key | try the *unbridged* key housing on the pack connector | must **not** fit | wrong contact type: see the XT60 rule in [electronics.md](../../electronics.md#xt60-contact-rule-the-loop-key-must-never-fit-the-battery) | Measure voltage with the black probe on ground (COM) and the red probe on the point; set the meter to DC volts in a range above 8.4 V (the full 2S pack). Continuity mode (beep) is handy for checking that a wire goes where the wiring list says. ## Check yourself 1. Why must heat-shrink go onto the wire before soldering the XT60? 2. Which three hex key sizes do you need, and why exactly these? 3. What does the rear pair of wings on a Dupont crimp do? 4. What voltage must a UBEC show before any servo is connected, and why does a higher reading disqualify it? 5. Why chase tight holes with a pin vice rather than a power drill? 6. Why is each servo's orange signal wire cut and re-crimped instead of plugged in with an extension lead?
Answers 1. Once the connector is soldered on, the tubing cannot be slid over it. 2. 1.5, 2.0 and 2.5 mm: the socket sizes of the M2, M2.5 and M3 socket head screws. 3. It grips the insulation for strain relief; the front pair grips the conductor. 4. 5.8–6.0 V unloaded. The SPT5435LV is rated for 4.8–6.0 V only, so anything above 6.0 V over-stresses the servos. 5. Only 0.1–0.2 mm needs removing; a power drill heats and grabs the plastic and can crack thin bosses. 6. An extension adds a plug-and-socket pair per servo that vibration can work loose, plus extra wire to stow. Cut to length, the lead goes straight to its PCA9685 channel with one contact.
## Further reading - [Adafruit Guide to Excellent Soldering](https://learn.adafruit.com/adafruit-guide-excellent-soldering) - [Pololu D36V50F5 step-down regulator](https://www.pololu.com/product/4091) - [Electronics, wiring and power](../../electronics.md) and the [assembly manual](../../assembly/README.md) - [WAGO 221-415 data sheet](https://wago.priintcloud.com/datasheets/221-415/en) - [Molex Quality Crimping Handbook](https://www.molex.com/content/dam/molex/molex-dot-com/products/automated/en-us/commercialcrimpbookpdf/638/63800/TM-638000029-001.pdf) - Related in this wiki: [cable management](cable-management.md), [safety](safety.md), [power](../hardware/power.md), [fasteners](fasteners.md) --- # Learn The Learn wiki explains the engineering behind Strido. You don't need it to build the robot, but it covers why the robot is built this way. Every article starts from the real robot: the servos, rails and parts you hold, the firmware and ROS 2 code in this repository, and the numbers the verification gates check. Each one ends with a few self-check questions and links to the primary sources. ## Learning paths | If you want to… | Read in this order | |---|---| | Build the robot with confidence | [3D printing](making/3d-printing.md) → [Fasteners](making/fasteners.md) → [Tools and workshop](making/tools-and-workshop.md) → [Cable management](making/cable-management.md) → [Safety](making/safety.md) | | Understand the electronics | [Power electronics](hardware/power.md) → [LiPo batteries](hardware/batteries.md) → [Servos](hardware/servos.md) → [Buses](hardware/buses.md) → [Sensors](hardware/sensors.md) | | Get the computers running | [Microcontrollers and the Pi](hardware/microcontrollers.md) → [Linux on the Pi](software/linux-on-the-pi.md) → [Docker](software/docker.md) → [Firmware](software/firmware.md) → [ROS 2](software/ros2.md) | | Know what can break | [Strength, torque and failure](making/strength.md) → [Fasteners](making/fasteners.md) → [Mechanics and bearings](hardware/mechanics.md) | | Make it walk better | [Mechanics and bearings](hardware/mechanics.md) → [Kinematics and gaits](software/kinematics-and-gaits.md) → [Simulation and RL](software/simulation-and-rl.md) | | Learn robotics from the ground up | [Frames and rotations](theory/frames-and-rotations.md) → [Kinematics](theory/kinematics.md) → [Statics and dynamics](theory/statics-and-dynamics.md) → [Control](theory/control.md) → [Legged locomotion](theory/legged-locomotion.md) → [State estimation](theory/state-estimation.md) → [Reinforcement learning](theory/reinforcement-learning.md) | | Change the design | [CAD as code](software/cad-as-code.md) → [Mechanics and bearings](hardware/mechanics.md) → [3D printing](making/3d-printing.md) | ## Robotics theory The mathematics behind a walking robot, each worked on Strido's own leg, servos and sensors. - [Frames and rotations](theory/frames-and-rotations.md): coordinate frames, rotation matrices, quaternions, homogeneous transforms and the robot's tf tree. - [Kinematics](theory/kinematics.md): forward and inverse kinematics of the leg, the knee four-bar, the Jacobian and singularities. - [Statics and dynamics](theory/statics-and-dynamics.md): virtual work, $\boldsymbol{\tau} = \mathbf{J}^\top \mathbf{F}$, mechanical advantage, inertia and the torque and power a joint needs. - [Control](theory/control.md): feedback, PD and PID control, sampling rates and the servo's own position loop. - [Legged locomotion](theory/legged-locomotion.md): gaits, support polygons, stability and how the trot generator places the feet. - [State estimation](theory/state-estimation.md): IMU models, complementary and Mahony filters, the Kalman filter and odometry. - [Reinforcement learning](theory/reinforcement-learning.md): Markov decision processes, policy gradients, PPO and sim-to-real. ## Hardware - [Servos](hardware/servos.md): pulse widths, torque, stall current, and centring the 12 SPT5435LV joints. - [LiPo batteries](hardware/batteries.md): the 2S pack, its voltage window, charging, storage and handling. - [Power electronics](hardware/power.md): from XT60 to fuses, UBECs and the regulator, wire gauges and power monitoring. - [Microcontrollers and the Pi](hardware/microcontrollers.md): why a real-time ESP32-S3 sits next to a Linux Raspberry Pi 5. - [Buses: I²C, UART, SPI, PWM](hardware/buses.md): how the boards talk to each other. - [Sensors](hardware/sensors.md): IMU, LiDAR, camera and power monitor, and what the software does with them. - [Mechanics and bearings](hardware/mechanics.md): the leg linkage, bearings, fits and joint limits. ## Software - [Linux on the Pi](software/linux-on-the-pi.md): the operating system, headless setup, SSH, permissions and services. - [Docker](software/docker.md): containers and how the robot software runs in one. - [ROS 2](software/ros2.md): nodes, topics, launch files and tf, using the robot's own packages. - [Firmware](software/firmware.md): the ESP32-S3 control loop, the serial protocol, calibration and failsafes. - [Kinematics and gaits](software/kinematics-and-gaits.md): leg geometry, inverse kinematics and the trot. - [Simulation and RL](software/simulation-and-rl.md): MuJoCo, Gazebo, PPO training and the sim-to-real gap. - [CAD as code](software/cad-as-code.md): how the robot is designed, verified and published from code. ## Making - [3D printing](making/3d-printing.md): PETG, orientation, supports and the verified slicer profile. - [Fasteners](making/fasteners.md): screws into printed plastic, pilot holes, engagement and threadlocker. - [Tools and workshop](making/tools-and-workshop.md): what you need and how to solder, crimp and measure. - [Cable management and the wiring harness](making/cable-management.md): wire sizing, fuses, service loops across the hip, bend radius, bundling, connectors and the CAD-routed cut list. - [Strength, torque and failure](making/strength.md): torque, stress, layer tear-out, buckling, screw shear and tightening torques, worked on the robot's own parts. - [Safety](making/safety.md): LiPo safety, first power-up, fuses and moving legs. The [glossary](glossary.md) defines every term used on this site. --- # CAD as code In code-CAD, parts are not drawn by clicking in a GUI; they are Python programs that produce geometry. Strido takes this further: every physical claim about the robot (screws fit, legs do not collide, parts print, the wiring is within its ratings) is an executable check that can fail the build. This page explains the pipeline, the gates and why it is built this way. ::: tip In Strido - **CAD package:** `packages/strido-cad` (CadQuery 2.8 for solids, manifold3d for robust mesh booleans, trimesh for mesh work). - **Verification package:** `packages/strido-verify` (interference, range of motion, fits, clearance, sensor field of view, printability, electronics). - **Publication package:** `packages/strido-publish` (print kit, BOM, manual, renders, 3D viewer, catalogue book). - **Data:** `hardware/*.yaml` (kinematics, joint limits, BOM, electronics, fasteners), tolerance bands in `hardware/verification/tolerances.yaml`. - **Decision record:** ADR-002 "CAD as code, verification as gates" in [decisions](../../decisions.md); evidence in [verification](../../verification.md). ::: ## Why code instead of a GUI CAD file A GUI CAD file records *what* the geometry is. Code also records *why*: the constant `PEDESTAL_PILOT_DEPTH = 11.0` sits next to its comment "screw tip at 10.0 mm into the pedestal". Code-CAD brings software habits to hardware: | Software habit | Effect on the robot | |---|---| | Version control with readable diffs | A changed hole depth is one line in a review, not an opaque binary file | | Single source of truth | Link lengths are measured once and *generated* into Python and C++; nobody types them twice | | Automated tests | A wrong hole fails a check before anyone prints it | | Reproducible builds | `make publish` regenerates every STL, plate, BOM and manual page from the same inputs | The motivation in ADR-002 is concrete: people print and assemble from this repository alone, and a wrong hole costs a reprint. ## Start from a real, built design Strido does not re-model the robot. The mechanics come from the upstream Yertle project: its STEP assembly (`upstream/yertle/design/CAD/Yertle_Single_v2.step`) and its published print STLs, which people have actually printed and assembled. Those files are kept byte-identical (ADR-004); every Strido change is a boolean operation on top of them, and fixes to upstream defects are expressed as data (`hardware/assembly/corrections.yaml`) or derived parts (`left_servo_mount_fl`, `right_servo_mount_fr`). ## The pipeline ```mermaid flowchart TD STEP["upstream STEP + STLs"] --> CACHE["step_cache: tessellate once"] CACHE --> REG["register_upstream: STL ↔ STEP"] CACHE --> KIN["extract_kinematics"] --> KY["hardware/kinematics.yaml"] KY --> ROM["strido_verify.rom"] --> JL["hardware/joint_limits.yaml"] KY --> GEN["codegen"] JL --> GEN GEN --> PY["strido/geometry.py"] GEN --> H["geometry_generated.h"] REG --> PARTS["parts + catalog: new/modified parts,
vendor models, fasteners"] PARTS --> ASM["assembly: posed with the exact leg linkage"] ASM --> GATES["verification gates"] ASM --> MODELS["models: URDF, MJCF, meshes"] ASM --> PUB["publish: print kit, BOM, manual,
renders, viewer, catalogue"] ``` Key steps: 1. **`step_cache`** tessellates every solid of the 32 MB STEP once and caches the meshes, keyed by the file hash so it never goes stale silently. 2. **`extract_kinematics`** measures the robot from cylinder axes in the CAD (servo splines, bearing bores, link pins): hip/thigh/knee axes, link lengths, the two knee linkages. "Nothing is typed in by hand." It found, for example, a 1.02 mm link-length mismatch in the upstream STEP ([design](../../design.md#kinematics)). 3. **`rom`** sweeps every leg through its range on a 5° grid, posing the whole assembly with the exact knee linkage, and writes the collision-free joint limits. 4. **`codegen`** writes those numbers into `packages/strido-core/src/strido/geometry.py` and `firmware/.../geometry_generated.h`. The gait, simulation, NT-X adapter and firmware all read the same generated values ([kinematics](kinematics-and-gaits.md)). 5. **`parts`** builds the Strido parts. A real example, the LiDAR pedestal (abridged from `parts.py`): ```python outer = box(cx - PEDESTAL_HALF, cy - PEDESTAL_HALF, z0, cx + PEDESTAL_HALF, cy + PEDESTAL_HALF, z1).edges("|Z").fillet(4.0) inner = box(cx - 25.0, cy - 25.0, z0 - 1, cx + 25.0, cy + 25.0, z1 - 3.0) shape = outer.cut(inner) # hollow, 3 mm top plate for dx, dy in PEDESTAL_SCREWS: # M3 pilot holes from below cuts.append(cylinder((cx + dx, cy + dy, z0), (0, 0, 1), 1.3, -0.1, PEDESTAL_PILOT_DEPTH)) return boolean(to_mesh(shape), cut=cuts) ``` `box` and `cylinder` return CadQuery workplanes; `boolean` converts to manifold3d solids for the final cuts, which stay watertight even on the tessellated upstream meshes. 6. **`assembly`** places every instance (printed, purchased, fastener) on a rigid body (`base`, `FL.thigh`, `FL.crank` …) and can pose the robot at any joint angles. Every check runs on this real mechanism, not on a simplified URDF. ## The verification gates A **gate** is a program that exits with code 1 when a rule is violated, so `make` stops. Each writes a tracked report under `docs/verification/`. Tolerance bands and contact rules live in YAML with their rationale; ADR-002 says a gate may only be relaxed by a recorded decision. | Gate | Command | What it proves | |---|---|---| | Hole stacks and fasteners | `python -m strido_cad.hole_stacks`, `python -m strido_cad.fasteners` | Every coaxial hole group in the upstream parts matches exactly one fastener rule; unmatched holes fail | | Interference | `python -m strido_verify.contacts` | No solids overlap in 4 poses, except classified contacts (thread engagement, designed seating ≤ 0.2 mm, joints) | | Range of motion | `python -m strido_verify.rom` | Joint limits free of collision, transmission angle ≥ 25°, servo within ±85° | | **Fits** | `python -m strido_verify.fits` | Every screw and bearing seat has a fit that will work after FDM printing | | **Clearance** | `python -m strido_verify.clearance` | Moving parts keep a running gap in every operating pose | | Sensor field of view | `python -m strido_verify.sensor_fov` | 720 LiDAR rays unblocked; camera does not see the robot | | **Electronics** | `python -m strido_verify.electronics` | Pins, buses, channels and power ratings agree between YAML and firmware | | **Printability** | `make printability` | Every part and plate slices in PrusaSlicer without stability issues; walls thick enough | | BOM coverage | `make bom` | Every purchased part and fastener in the CAD is bought, with spares | | Drift | `make drift` | Generated files equal what the generators would write now | `make verify` runs the mechanical gates; `make check` runs everything (about 10 minutes). ### Fits The fit gate samples every screw's shank axis every 0.5 mm and cuts each printed part around it to measure the hole. FDM prints holes roughly 0.1–0.2 mm undersize, so the gate assumes 0.15 mm of shrink and checks the *printed* size: | Fit | Rule ($d$ = screw diameter) | |---|---| | Clearance hole | $\ge d + 0.05\,\mathrm{mm}$ (warn below $d + 0.20\,\mathrm{mm}$: chase with a drill) | | Thread-forming pilot | printed pilot $0.70$–$0.90\,d$ ($0.93\,d$ warn: weak grip), engaged length $\ge 1.5\,d$ | | Bearing seat | bore −0.15 … +0.30 mm relative to the bearing outer diameter | Every screw must also end in something that holds it: a nut, a printed pilot or a threaded part. The recorded run checked 223 screws and 32 bearing seats: 0 fail, 137 with a builder action (drill this hole, CA-harden a weak pilot). It found real defects, such as LiDAR pedestal pilots 9 mm deep for screws that protrude 10 mm, which is why `PEDESTAL_PILOT_DEPTH` is now 11 mm. See [fasteners](../making/fasteners.md). ### Clearance Interference proves nothing overlaps; clearance proves nothing *rubs*. Two printed surfaces can each be ~0.15 mm off, so moving parts on different rigid bodies must keep ≥ 0.3 mm in all 13 operating poses (stand, crouch, tall, eight trot phases, hips out and in), and below 1.0 mm they are reported as tight. This gate caught both front feet colliding in the `hips_in` pose, which led to the narrower inward hip limits (ADR-005). ### Electronics `hardware/electronics.yaml` is the source of truth for wiring. The gate checks that the firmware header `board_config.h` defines exactly its pins, I²C addresses, servo channels and frequencies (in both directions), that no GPIO or I²C address is used twice, and that every converter, switch, fuse and wire carries its load (`load ≤ fuse ≤ wire ampacity`). Its tests prove that a swapped pin, a duplicate channel, an address clash or an undersized wire fail. See [power](../hardware/power.md). ### Printability Static checks (closed manifold, fits a 220 × 220 × 250 mm bed, resting on z = 0) run in `make print`. The printability gate goes further: it slices every part with the real PrusaSlicer and the repo's profile (`hardware/print/prusaslicer.ini`), with supports and brim exactly as the print list prescribes, and fails on slicer errors or stability warnings (floating bridge anchors, collapsing overhangs, low bed adhesion). A ray-based scan fails walls thinner than one extrusion line or skins thinner than one layer. It also slices every 3MF plate as arranged. Result: 34 part types and 13 plates, 0 fail, 958 g of PETG and 100 h of printing. See [3D printing](../making/3d-printing.md). ::: tip Seen red Every gate in the verification ledger records a case where it *failed* on a real problem or a deliberate break. A check that has never failed may not be checking anything. ::: ## The publish pipeline The same verified assembly feeds every artefact you download: ```bash make print # oriented STLs (hardware/print/stl/), 3MF plates (hardware/print/plates/), print_list.md make bom # docs/bom/README.md, shopping_list.md, bom.csv + CAD coverage check make manual # docs/assembly/README.md + step drawings (Blender) make renders # hero, exploded, section, x-ray images → docs/images/ make viewer # docs/viewer/strido.glb + assembly.json for the web 3D viewer make catalog # docs/catalog/parts.json + strido-catalog.pdf (WeasyPrint) make publish # all of the above, in that order ``` Order matters: the catalogue book reads the print list, manual and render output. Because each instance in the assembly is assigned to exactly one manual step (the build fails otherwise), the parts in the manual, the BOM, the viewer and the [catalogue](../../catalog.md) always add up to the same robot. The simulation models come from the same assembly too (`make models`, see [simulation and RL](simulation-and-rl.md)). ## Limits of the approach The gates check the CAD, not reality. The [verification](../../verification.md) ledger states it plainly: nothing has been printed, assembled, wired or powered yet (no Q3 hardware tier). Fits assume a 0.15 mm shrink model; your printer may differ, which is why the fit report includes builder actions. Code-CAD moves errors earlier and makes them visible; it does not replace the first physical build. ## Check yourself 1. Why does Strido build on the upstream STEP instead of re-modelling the robot? 2. How does a measured link length reach the firmware without anyone typing it? 3. What is the difference between the interference gate and the clearance gate? 4. Why does the fit gate subtract 0.15 mm from CAD hole sizes? 5. Why does `make catalog` have to run after `make print`, `make manual` and `make renders`?
Answers 1. The upstream mechanics have actually been printed and built; modifying them by booleans keeps that history and lets upstream changes be diffed (ADR-001, ADR-004). 2. `extract_kinematics` writes it to `hardware/kinematics.yaml`; `codegen` generates `geometry_generated.h`; `make drift` fails if the header is stale. 3. Interference checks that no solids overlap; clearance checks that moving parts keep at least 0.3 mm in every operating pose, so they will not rub after printing. 4. FDM prints holes about 0.1–0.2 mm undersize; the gate judges the hole as it will be printed. 5. The book lays out the print list, manual steps and renders those targets produce.
## Further reading - [CadQuery documentation](https://cadquery.readthedocs.io/en/latest/) - [Manifold geometry library](https://github.com/elalish/manifold) - [trimesh documentation](https://trimesh.org/) - [PrusaSlicer command-line interface](https://github.com/prusa3d/PrusaSlicer/wiki/Command-Line-Interface) - [Prusa knowledge base: dimensional accuracy and hole compensation](https://help.prusa3d.com/article/dimensional-accuracy_2207) --- # Docker and containers Docker packages software with everything it needs (libraries, ROS 2, Python packages) into an *image* that runs the same way on a laptop, a CI runner and the robot. Strido uses one image, `strido-ros:jazzy`, for both the Gazebo simulation and the real robot. ::: tip In Strido - `docker/ros2/Dockerfile` builds the ROS 2 Jazzy + Gazebo Harmonic workspace image for amd64 and arm64 (Raspberry Pi 5, Apple silicon). - `docker/compose.yaml` defines three services: `ros` (builds the image), `sim-smoke` (headless Gazebo walk test) and `robot` (runs on the Pi with access to `/dev`). - `docker/ros2/entrypoint.sh` sources ROS 2 and the workspace; `docker/ros2/sim_smoke.sh` is the automated simulation test behind `make sim-gazebo`. - `ROS_DOMAIN_ID` defaults to `42` ([configuration](../../configuration.md)). ::: ## Containers versus virtual machines A **virtual machine** emulates a whole computer: its own kernel, virtual disk and virtual hardware. A **container** is just a group of normal processes on the host kernel, isolated with Linux namespaces (own file system view, process list, network) and limited with cgroups. | | Virtual machine | Container | |---|---|---| | Kernel | Own guest kernel | Shares the host kernel | | Start-up | Tens of seconds | About a second | | Overhead | Reserved RAM and CPU | Almost none | | Hardware access | Through emulated or passed-through devices | Host device files can be mapped in directly | For a robot this matters: the Pi 5 has 4 GB of RAM in this build, and the `mcu_bridge` polls a USB serial port every 2 ms. A container gives near-native performance and direct access to `/dev/strido_mcu`; a VM would cost memory and add latency. On macOS, Docker Desktop runs a small Linux VM and the containers live inside it. That is why the Gazebo test works on a Mac but USB devices are hard to reach there: you test simulation on the laptop and hardware on the Pi. ## Images and layers An image is a stack of read-only **layers**, one per file-changing instruction in the `Dockerfile`. Docker caches layers: if an instruction and everything before it is unchanged, the cached layer is reused. The Strido Dockerfile is ordered so that slow, rarely changing steps come first: ```mermaid flowchart TD A["FROM ros:jazzy-ros-base
Ubuntu 24.04 + ROS 2 core"] --> B["apt-get install
ros-gz, gz-ros2-control, ros2-controllers,
xacro, robot-state-publisher, tf2-ros,
teleop-twist-keyboard, python3-serial, numpy"] B --> C["git clone sllidar_ros2
pinned commit"] C --> D["COPY + pip install packages/strido-core"] D --> E["COPY ros2/strido_description,
strido_bringup, strido_control"] E --> F["colcon build --symlink-install"] F --> G["entrypoint.sh: source ROS + workspace"] ``` Editing a ROS node only invalidates the last few layers, so a rebuild after a code change takes seconds to minutes instead of re-downloading Gazebo. Details worth noticing: - **Pinned dependencies.** The Slamtec LiDAR driver is not packaged for Jazzy, so it is cloned at a fixed commit (`ARG SLLIDAR_COMMIT=...`). Pinning means a rebuild next year produces the same driver. - **`--no-deps` install of `strido-core`.** The core package (gait, kinematics, protocol, numpy policy runtime) has only NumPy as a runtime need, which apt already installed. No PyTorch, CadQuery or MuJoCo enters the robot image (ADR-003 in [decisions](../../decisions.md)). - **`rm -rf /var/lib/apt/lists/*`** in the same `RUN` as `apt-get install` keeps the package index out of the layer. Deleting it in a later layer would not shrink the image, because earlier layers are immutable. - **Multi-architecture.** `ros:jazzy-ros-base` is published for amd64 and arm64, so the same Dockerfile builds natively on the Pi and on an Apple silicon Mac. ## Containers at run time A container is a running instance of an image plus a thin writable layer. Anything written inside disappears when the container is removed (`--rm`). That suits the robot: state that must persist (servo calibration) lives in the ESP32's flash, not in the container. The entrypoint is three lines: ```bash source /opt/ros/jazzy/setup.bash source /ws/install/setup.bash exec "$@" ``` It makes every command you pass (for example `ros2 launch ...`) see both ROS 2 and the Strido packages, and `exec` replaces the shell so signals such as Ctrl-C reach ROS directly. ## Compose: the repo's `docker/compose.yaml` Docker Compose describes several containers in one YAML file so you do not have to remember long `docker run` command lines. | Service | What it does | Key settings | |---|---|---| | `ros` | Builds `strido-ros:jazzy` from `docker/ros2/Dockerfile` (context = repo root) | `network_mode: host`, `ipc: host`, `ROS_DOMAIN_ID` | | `sim-smoke` | Runs `/sim_smoke.sh`: Gazebo headless, stand up, walk 8 s at 0.15 m/s, check ≥ 0.4 m progress and sensor topics | exit code is the test result | | `robot` | Runs `ros2 launch strido_bringup robot.launch.py` on the Pi | `privileged: true`, `/dev:/dev`, host network | Commands you will use: ```bash docker compose -f docker/compose.yaml build ros # build or update the image docker compose -f docker/compose.yaml run --rm sim-smoke # headless Gazebo test (what make sim-gazebo runs) docker compose -f docker/compose.yaml run --rm robot # on the Pi: bring up the robot docker compose -f docker/compose.yaml run --rm ros bash # interactive shell with ROS 2 sourced docker compose -f docker/compose.yaml run --rm ros \ ros2 run teleop_twist_keyboard teleop_twist_keyboard # drive from a second terminal ``` `make sim-gazebo POLICY=` mounts a learned policy read-only into the container (`-v ...:/policy.npz:ro`) and sets `POLICY=/policy.npz`, which makes `sim_smoke.sh` launch `policy_node` instead of `gait_node`. ### Why host networking and host IPC ROS 2 discovers nodes with DDS multicast and, between processes on the same machine, can use shared memory. Docker's default bridge network hides multicast, and a private IPC namespace hides shared memory. `network_mode: host` and `ipc: host` put the container on the host's network and IPC so that a teleop container, the robot container and a laptop on the same Wi-Fi (same `ROS_DOMAIN_ID`) see each other's topics without extra configuration. Use a different `ROS_DOMAIN_ID` per robot when several share a network: ```bash ROS_DOMAIN_ID=43 docker compose -f docker/compose.yaml run --rm robot ``` ## Device access A container cannot see host hardware unless you grant it. The `robot` service does it bluntly: ```yaml privileged: true volumes: - /dev:/dev ``` Bind-mounting the whole `/dev` matters because the udev symlinks (`/dev/strido_mcu`, `/dev/rplidar`) are created by the host's udev, and a USB device that is unplugged and replugged gets a new device node. A fixed `devices:` list would only map nodes that exist when the container starts. `privileged: true` lifts the device cgroup restrictions so the new nodes are usable. ::: warning A privileged container has nearly full control of the host. That is acceptable on a dedicated robot computer; do not use the `robot` service on your everyday laptop. For the simulation services, which need no hardware, no privileges are set. ::: The udev rules must be installed on the **host**, not in the image ([Linux on the Pi](linux-on-the-pi.md#users-groups-and-device-permissions)). ## What is not in the image - **Camera node.** The Pi 5 camera stack needs the Raspberry Pi libcamera fork; the image has no camera node, and `robot.launch.py` defaults to `camera:=false`. Build `camera_ros` on the host or use `rpicam-vid` with a GStreamer bridge ([software](../../software.md#raspberry-pi-setup)). - **Firmware tools.** PlatformIO is not installed; flash the ESP32-S3 from a development machine or install PlatformIO on the Pi ([firmware](firmware.md)). - **GUI.** Gazebo with a window (`gazebo.launch.py` without `headless:=true`) needs X11/Wayland forwarding into the container ([simulation](../../simulation.md)). ## Check yourself 1. Why does a container start faster and use less memory than a VM? 2. Which Dockerfile change triggers the shortest rebuild, and why? 3. What breaks if you remove `network_mode: host` from the `robot` service? 4. Why does the `robot` service mount all of `/dev` instead of listing two devices? 5. Where must `99-strido.rules` be installed for the container to see `/dev/strido_mcu`?
Answers 1. It shares the host kernel: no guest OS to boot and no reserved RAM; it is just isolated processes. 2. Changing files in `ros2/` only invalidates the `COPY ros2/...` layers and the `colcon build` after them; the apt and Git layers come from the cache. 3. DDS discovery across containers and machines; teleop or RViz in another container or on a laptop would no longer see the robot's topics. 4. udev on the host creates the symlinks and replugged devices get new nodes; a static device list would miss them. 5. On the host, in `/etc/udev/rules.d/`; the container sees the result through the `/dev` bind mount.
## Further reading - [Docker overview (images, containers, layers)](https://docs.docker.com/get-started/docker-overview/) - [Compose file reference](https://docs.docker.com/reference/compose-file/) - [Dockerfile reference](https://docs.docker.com/reference/dockerfile/) - [Official ROS images on Docker Hub](https://hub.docker.com/_/ros) --- # Firmware on the ESP32-S3 The firmware is the C++ program on the Seeed XIAO ESP32-S3 that turns joint-angle commands from the Pi into servo pulses 200 times a second, reads the IMU and battery monitor, and stops the robot safely when anything goes wrong. It is small, but it is the last line of software between a bug and twelve servos. ::: tip In Strido - **Project:** `firmware/strido_esp32s3` (PlatformIO, Arduino-ESP32 3.x). - **Entry point:** `src/main.cpp`; pins and rates in `src/board_config.h`; LCD face in `src/face.cpp`. - **Portable core:** `lib/strido_core` (knee linkage, servo mapping, protocol, safety), unit-tested on your computer with `pio test -e native`. - **Generated:** `lib/strido_core/include/strido/geometry_generated.h`, written from the CAD by `strido_cad.codegen`; never edit by hand. - **Peripherals on I²C (400 kHz):** PCA9685 servo driver `0x40`, LSM6DSOX IMU `0x6A`, INA260 power monitor `0x41`; ST7789V LCD on SPI. - **Host side:** `mcu_bridge` (ROS 2) and `strido-cli` (bench tool) speak the same protocol through `packages/strido-core/src/strido/protocol.py`. ::: ## Why a microcontroller at all The Pi runs Linux, which can pause any process for tens of milliseconds. A servo signal that stutters makes the leg jerk; a safety check that runs "usually" is not a safety check. The ESP32-S3 runs nothing but this firmware, so its loop period is predictable. It also keeps working if the Pi crashes, which is exactly when you need the watchdog. The division of labour: the Pi decides **what** angles the legs should have; the ESP32 decides **how** to reach them safely (linkage maths, slew limit, clamps, calibration) and **whether** to drive at all. ## Build, test and flash with PlatformIO PlatformIO is a build system and package manager for embedded code. `platformio.ini` pins the toolchain (`espressif32 @ ~8.0.0`), the board (`seeed_xiao_esp32s3`) and every library (Adafruit PWM Servo Driver, LSM6DS, INA260, AHRS, LovyanGFX), so everyone builds the same binary. It defines two environments: | Environment | Builds for | Used by | |---|---|---| | `xiao_esp32s3` (default) | The real board | `make firmware`, upload | | `native` | Your computer, with the Unity test framework | `make test-firmware` | ```bash cd firmware/strido_esp32s3 pio test -e native # 15 unit tests: linkage, protocol, watchdog, e-stop, battery, NaN rejection pio run -e xiao_esp32s3 # compile (same as `make firmware` from the repo root) pio run -e xiao_esp32s3 -t upload # compile and flash over USB pio device monitor # serial monitor (frames are binary; use strido-cli instead) ``` `-DARDUINO_USB_CDC_ON_BOOT=1` routes `Serial` to the chip's native USB, so the board appears to Linux as a CDC-ACM device (`/dev/ttyACM*`, symlinked to `/dev/strido_mcu`). The baud rate is ignored on USB CDC. If an upload cannot connect, hold the XIAO's BOOT button while plugging in USB to force the ROM bootloader. ::: tip Why a native test environment `lib/strido_core` contains no Arduino calls. The same kinematics, protocol and safety code compiles for the ESP32 *and* for your laptop, so `pio test -e native` checks it in seconds, without hardware, and fails in CI if someone breaks the watchdog. ::: ## Program structure `setup()` initialises the hardware and starts two FreeRTOS tasks pinned to the two cores of the ESP32-S3. `loop()` then sleeps forever. ```mermaid flowchart LR subgraph C0["Core 0: commsTask (priority 2)"] RX["read USB bytes
→ COBS frames → handlePacket"] --> CMD[("command
(spinlock)")] ST2[("state
(spinlock)")] --> TX["STATE frame
every 10 ms (100 Hz)"] FACE["LCD face, status LED
every 50 ms"] end subgraph C1["Core 1: controlTask (priority 3), every 5 ms"] IMU["IMU read + Mahony filter"] --> SAFE["SafetySupervisor.update"] PWR["INA260 every 100 ms"] --> SAFE SAFE --> SERVO["rate limit → linkage → clamp
→ calibrated pulses → PCA9685"] SERVO --> ST1["write state"] end CMD --> SAFE ST1 --> ST2 ``` Why two tasks: USB traffic arrives in bursts and the LCD takes time to draw; neither must delay the servo update. The control task runs at a higher priority on its own core, and the shared data (`command`, `state`) is copied under short spinlock critical sections (`portENTER_CRITICAL`). I²C reads, which block, are never done inside a critical section. ### Start-up sequence 1. Drive the PCA9685 **OE** pin (D3) high: all servo outputs off, servos limp. 2. Start I²C on D4/D5 at 400 kHz; load calibration from flash. 3. Set the internal joint state to the assembly reference pose. 4. Start the PCA9685 at 200 Hz with every channel off; IMU at ±8 g, ±1000 °/s, 416 Hz; INA260 at `0x41`. 5. Show "ready" on the LCD (or "NO PCA9685"), start the tasks. The servos stay limp until the host sends its first position command. That is why the start-up order in [electronics](../../electronics.md#safety-rules) works: the loop key can go in safely, and the gait node's first command is the rest pose the robot is already lying in. ## One control cycle (5 ms) 1. **IMU.** Read the LSM6DSOX, rotate its axes into the base frame (the board is mounted with its x to robot left, y to robot rear), update the Mahony orientation filter. 2. **Power.** Every 100 ms read bus voltage and current from the INA260 and feed the voltage to the safety supervisor. 3. **Safety.** `safety.update(now)` returns the drive state (below). Copy the latest command. 4. **Servos**, if the state is Active: - **Rate limit:** no joint moves faster than $\dot q_\mathrm{max} = 6\,\mathrm{rad/s}$, so each 5 ms step is clamped to $\lvert \Delta q \rvert \le \dot q_\mathrm{max}\,\Delta t = 0.03\,\mathrm{rad}$. The SPT5435LV's rated speed is 0.14 s per 60° at 6 V, about $7.5\,\mathrm{rad/s}$; staying below it keeps the commanded and real positions close. - **Knee linkage:** convert (hip, thigh, calf) into the three *actuator* angles (hip, thigh, knee crank). The knee servo drives the calf through a parallelogram and a four-bar, so the crank angle depends on both thigh and calf ([kinematics](kinematics-and-gaits.md)). - **Clamp** hip, thigh and crank to the generated limits, then check the calf angle that results is inside its window. An unreachable request keeps the previous pulses. - **Pulse:** convert each actuator angle $\theta$ to a pulse width, then clamp it to `[min_us, max_us]`: $$ t_\mathrm{p} = t_\mathrm{neutral} + s \cdot 636.6\,\mu\mathrm{s/rad} \cdot (\theta - \theta_0) $$ with `neutral_us` $t_\mathrm{neutral}$, `direction` $s = \pm 1$ and `zero_rad` $\theta_0$. The slope is $2000\,\mu\mathrm{s} / \pi\,\mathrm{rad} \approx 636.6\,\mu\mathrm{s/rad}$: the SPT5435LV-180W maps 500–2500 µs onto 180° ([servos](../hardware/servos.md)). - Write the pulses to the PCA9685 channels (`kServoChannel`: FL hip on 15 … RR calf on 4, kept identical to the upstream Yertle wiring). 5. **Report** the angles actually applied (after clamping, and the calf angle the clamped crank really produces) plus flags into the shared state. ### Why the PCA9685 The PCA9685 is a 16-channel, 12-bit PWM generator controlled over I²C. Once configured, it produces all pulses in hardware, so the ESP32 only sends new values. At 200 Hz a period is $T = 5000\,\mu\mathrm{s}$ and 12 bits give $2^{12} = 4096$ steps, $T/4096 \approx 1.2\,\mu\mathrm{s}$ per step, or roughly 0.1° of servo travel. The servo frequency is 200 Hz (the SPT5435LV accepts up to 330 Hz): a faster frame rate than the classic 50 Hz means less delay between a new command and the servo seeing it. The sampling period also bounds what any digital controller can do; see [control](../theory/control.md). Its active-low **OE** (output enable) pin is wired to D3. Driving it high stops every pulse at once, independent of I²C. The board itself pulls OE low (outputs on), so the harness adds a 2.2 kΩ pull-up from OE to VCC (W20 in [electronics](../../electronics.md)): it holds OE high, servos off, while the ESP32 resets or sits in its bootloader. ## The serial protocol to the Pi Messages travel over USB CDC as frames: ```text COBS( type | payload | CRC16 little-endian ) 0x00 ``` - **COBS** (Consistent Overhead Byte Stuffing) removes every zero byte from the data, so `0x00` can mark the end of a frame. A receiver that joins mid-stream, or loses a byte, resynchronises at the next zero. - **CRC-16/CCITT-FALSE** detects corrupted frames, which are dropped. The firmware and Python share a test vector so both sides compute the same CRC. - **Payloads** are little-endian packed structs, defined twice and tested against each other: `lib/strido_core/include/strido/protocol.h` and `packages/strido-core/src/strido/protocol.py`. | Type | Name | Direction | Payload | |---|---|---|---| | `0x01` | JOINT_COMMAND | Pi → MCU | seq u16, mode u8 (0 relax, 1 position), q[12] f32 | | `0x02` | SERVO_PULSE_COMMAND | Pi → MCU | seq u16, pulse_us[12] u16 (0 = off); calibration only | | `0x03` | SET_CALIBRATION | Pi → MCU | joint, direction, neutral_us, zero_rad, min_us, max_us | | `0x04` | SAVE_CALIBRATION | Pi → MCU | none | | `0x05` | REQUEST_CALIBRATION | Pi → MCU | none → 12 × CALIBRATION | | `0x06` | ESTOP | Pi → MCU | engaged u8 | | `0x07` | DISPLAY | Pi → MCU | expression u8, length u8, text | | `0x81` | STATE | MCU → Pi, 100 Hz | last seq, time, flags, q[12], quaternion, gyro, accel, bus V, current | | `0x82` | CALIBRATION | MCU → Pi | as SET_CALIBRATION | | `0x83` | LOG | MCU → Pi | ASCII | A joint command is 56 bytes on the wire (51-byte payload + type + CRC + COBS overhead + delimiter); at the gait's 100 Hz that is about 5.6 kB/s, trivial for USB. The firmware rejects any joint command containing NaN or infinity at deserialisation: a NaN compares false against every limit and would otherwise slip through all the clamps. ## Calibration in flash No two servo horns sit on their splines at exactly the same angle. Each joint therefore has a `ServoCalibration`: | Field | Meaning | |---|---| | `direction` | +1 or −1: sign between shaft angle and pulse width (mirrored legs) | | `neutral_us` | Pulse that holds the shaft at `zero_rad` (default 1500 µs) | | `zero_rad` | Actuator angle at the neutral pulse (default: the assembly reference pose) | | `min_us`, `max_us` | Hard pulse window (default 500–2500 µs) | Defaults come from the generated geometry. Saved values are stored with the ESP32 `Preferences` library (key-value storage in flash, namespace `strido`, keys `cal0` … `cal11`), so they survive power-off and re-flashing the application. The bench tool drives the process: ```bash strido-cli center # 1500 µs on every channel: fit the horns in this reference pose strido-cli calibrate # trim each joint's neutral in ±5 / ±25 µs steps, saved to flash strido-cli monitor # battery, flags, roll/pitch ``` `strido-cli` streams commands at 20 Hz so the watchdog stays fed. Raw pulses bypass the joint limits (only the per-joint pulse window applies), which is why calibration is done with horns unfitted or legs free to move. See the [assembly manual](../../assembly/README.md) for when to do this. ## Safety and failsafes, as implemented `SafetySupervisor` (`lib/strido_core/src/safety.cpp`) is a small state machine with four drive states: ```mermaid flowchart LR R["Relaxed
OE high, limp"] -->|"position command
(battery ok, INA260 present)"| A["Active
following commands"] A -->|"no command for 200 ms"| H["Holding
last pose"] H -->|"new command"| A H -->|"2 s more"| R A -->|"relax command"| R A -->|"ESTOP engaged"| E["Estop
latched, OE high"] H -->|"ESTOP engaged"| E R -->|"ESTOP engaged"| E E -->|"ESTOP released"| R ``` | Failsafe | Behaviour | Why | |---|---|---| | Command watchdog | Only joint commands feed it; no command for 200 ms → hold; 2 s more → relax | If the gait node or the Pi dies, the robot neither freezes forever under load nor collapses instantly | | Latched e-stop | `ESTOP` turns outputs off until released; release goes to Relaxed, so a *fresh* position command is needed | Releasing the stop must never make the robot jump back into motion | | Hard output kill | Relaxed and Estop pull PCA9685 OE high and zero every channel | Works even if I²C is stuck | | Battery cut-off | Warning below 6.8 V (3.4 V/cell); cut-off after 5 consecutive readings (0.5 s) below 6.4 V (3.2 V/cell); recovers only above 6.8 V | Protects the 2S LiPo; debouncing ignores the short voltage sags of a stepping leg | | No power monitor, no driving | Without a working INA260 position commands are refused (raw calibration pulses still allowed) | Without voltage readings there is no low-battery protection | | Rate limit | 6 rad/s per joint | No step change, even if the host sends one | | Joint and knee-crank limits | From the CAD range-of-motion sweep, generated into the firmware | A bad command cannot drive a leg into the body or lock the linkage | | Non-finite rejection | Commands with NaN/Inf are dropped | NaN bypasses comparisons | | Re-enable from command | When outputs come back on, the internal state jumps to the commanded pose instead of slewing from a stale one | Servo positions are unknown after being limp | The state of all of this reaches ROS as flags in `/diagnostics` (`estop`, `relaxed`, `watchdog`, `imu_ok`, `power_ok`, `pwm_ok`, `clamped`, `calibration_mode`). ::: danger These are software failsafes. The servo loop key is the hardware kill and must stay within reach whenever the robot is armed ([safety](../making/safety.md)). ::: ## Check yourself 1. Why does the servo loop run on the ESP32 and not on the Pi? 2. What happens, step by step, if the gait node crashes while the robot stands? 3. Why does COBS make it safe to use `0x00` as a frame delimiter? 4. What is the maximum change of one joint angle in one 5 ms cycle? 5. Where do calibration trims live, and why do they survive a firmware upload?
Answers 1. Linux can pause processes unpredictably; the ESP32 runs only this loop, so the 200 Hz timing and the safety checks are deterministic and keep working if the Pi fails. 2. Joint commands stop; after 200 ms the supervisor goes to Holding and keeps the last pose (watchdog flag set); after 2 s more it relaxes, pulling OE high. 3. COBS encoding removes all zero bytes from the data, so the only zero in the stream is the delimiter. 4. $6\,\mathrm{rad/s} \times 0.005\,\mathrm{s} = 0.03\,\mathrm{rad}$ (about $1.7^\circ$). 5. In the ESP32's flash via `Preferences` (namespace `strido`, keys `cal0`–`cal11`); that storage partition is separate from the application, so an upload does not erase it.
## Further reading - [PlatformIO documentation](https://docs.platformio.org/en/latest/) - [Seeed Studio XIAO ESP32S3 getting started](https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/) - [ESP32-S3 datasheet (Espressif)](https://www.espressif.com/sites/default/files/documentation/esp32-s3_datasheet_en.pdf) - [PCA9685 datasheet (NXP, copy hosted by Adafruit)](https://cdn-shop.adafruit.com/datasheets/PCA9685.pdf) - [Arduino-ESP32 Preferences library](https://docs.espressif.com/projects/arduino-esp32/en/latest/api/preferences.html) - Theory: [Control](../theory/control.md), [State estimation](../theory/state-estimation.md) (the IMU filter) --- # Kinematics and gaits Kinematics is the geometry of motion: given joint angles, where is the foot (forward kinematics), and given a foot position, which joint angles put it there (inverse kinematics). A gait is the rhythm in which the four feet lift, swing and push. This page works through both for the Strido leg, using the code that actually drives the robot. ::: tip In Strido - **Leg kinematics and knee linkage:** `packages/strido-core/src/strido/leg.py` (Python) and its C++ mirror `firmware/strido_esp32s3/lib/strido_core/src/kinematics.cpp` + `servo_map.cpp`. - **Gait:** `packages/strido-core/src/strido/gait.py` (`TrotGait`, `GaitConfig`, `stand_pose`, `clamp_leg`), used unchanged by `gait_node` (ROS 2), `strido_sim.walk` (MuJoCo) and as the stand pose for the learned policy. - **Dimensions and limits:** measured from the CAD into `hardware/kinematics.yaml` and `hardware/joint_limits.yaml`, then generated into `packages/strido-core/src/strido/geometry.py` and `geometry_generated.h` ([CAD as code](cad-as-code.md)). - **Servos:** 12 × SPT5435LV-180W position servos ([servos](../hardware/servos.md)). ::: ## The leg Each leg has three joints, named and signed like the Unitree Go2 so that tools and policies transfer: | Joint | Axis | Zero | Positive direction | Actuated by | |---|---|---|---|---| | hip (q1) | +x (forward) | leg vertical | foot moves to +y (left) | hip servo, direct | | thigh (q2) | +y | femur straight down | knee moves backwards | thigh servo, direct (coaxial with the femur pivot) | | calf (q3) | +y, relative to the femur | calf in line with femur | negative = knee bent | knee servo, **through two linkages** | Measured dimensions (front-left leg, generated values rounded): | Quantity | Value | |---|---| | Hip axis position in `base_link` | x = ±0.120 m, y = ±0.039 m | | Lateral offset hip axis → leg plane (`thigh_offset_y`) | 0.038 m front, 0.040 m rear | | Thigh (femur) length l1 | 0.130 m | | Calf (tibia) length l2, to foot centre | 0.124 m | | Foot sphere radius | 0.012 m | Full extension is $l_1 + l_2 \approx 0.254\,\mathrm{m}$; the robot stands with the hip axes 0.17 m above the foot centres. ### The knee linkage The knee servo does not sit at the knee: it is up in the leg module. It turns an arm; a **parallelogram** (arm 24 mm, short link 28.3 mm, crank) copies the arm angle onto a crank coaxial with the femur pivot; a **four-bar** (crank 26.9 mm, long link 131 mm, tibia crank 24 mm, femur) turns the tibia. Keeping the servo mass near the body makes the leg lighter to swing. The cost: the knee angle $q_3$ depends on **both** the crank angle $\psi$ and the thigh angle $q_2$. Moving only the thigh servo also changes the knee angle. So software must solve the linkage: - `crank_from_calf(leg, q2, q3)` gives the crank angle the knee servo must hold (inverse linkage), by intersecting two circles: one around the femur pivot with the crank radius, one around the tibia pin with the long-link length. - `calf_from_crank(leg, q2, ψ)` is the forward linkage. Two circles intersect in two points; the code picks the branch the CAD assembly is in (`_branches`), so the solver never flips to the mirror configuration. **Transmission angle** is the angle between the long link and the crank (or tibia crank). Near 0° or 180° the linkage is near a singularity: a small servo motion produces a huge knee motion and the servo loses leverage. The range-of-motion sweep keeps it at or above 25°; in the standing pose it is about 84–86°, close to the ideal 90°. ## Forward kinematics `foot_position(name, q)` in `leg.py`: 1. In the leg plane ($u$ forward, $v$ up, origin at the thigh pivot): $$ \mathbf{p}_\mathrm{knee} = \begin{pmatrix} -l_1 \sin q_2 \\ -l_1 \cos q_2 \end{pmatrix}, \qquad \mathbf{p}_\mathrm{foot} = \mathbf{p}_\mathrm{knee} + \begin{pmatrix} -l_2 \sin(q_2 + q_3) \\ -l_2 \cos(q_2 + q_3) \end{pmatrix} $$ The calf's absolute angle is $q_2 + q_3$ because $q_3$ is measured relative to the femur: angles of a serial chain add. 2. Place the plane point at lateral offset `thigh_offset_y`, rotate it about $x$ by the hip angle $q_1$, then add the hip position in `base_link`: $\mathbf{p} = \mathbf{p}_\mathrm{hip} + \mathbf{R}_x(q_1)\,(u,\ d_y,\ v)^\top$. The general method (homogeneous transforms) is in [kinematics](../theory/kinematics.md) and [frames and rotations](../theory/frames-and-rotations.md). Example from the code: the stand pose for the front-left leg is $\mathbf{q} = (0,\ 0.812,\ -1.676)\,\mathrm{rad} = (0^\circ,\ 46.5^\circ,\ -96.0^\circ)$, and forward kinematics returns the foot at $(0.120,\ 0.077,\ -0.170)\,\mathrm{m}$: right under the hip, 0.170 m down. Check the height in the plane: $v = -0.130\cos(0.812) - 0.124\cos(0.812 - 1.676) \approx -0.089 - 0.081 = -0.170\,\mathrm{m}$. The $y$ value 0.077 m is the hip's 0.039 m plus the 0.038 m leg-plane offset. ## Inverse kinematics `inverse_kinematics(name, foot)` solves the reverse, analytically (no iteration): 1. **Hip.** Look at the leg from the front. The foot must lie on a circle of radius $d_y$ (`thigh_offset_y`) around the hip axis plus a "depth" $d$ along the leg, by Pythagoras: $d = \sqrt{y^2 + z^2 - d_y^2}$. The hip angle is the rotation that aligns the leg plane with the foot: $q_1 = \operatorname{atan2}(z, y) - \operatorname{atan2}(-d, d_y)$. 2. **Knee from the law of cosines.** In the leg plane the foot is at $(u, v) = (x, -d)$. The triangle thigh–calf–(pivot to foot) has sides $l_1$, $l_2$ and $r = \sqrt{u^2 + v^2}$, so $r^2 = l_1^2 + l_2^2 + 2 l_1 l_2 \cos q_3$, which gives $$ \cos q_3 = \frac{u^2 + v^2 - l_1^2 - l_2^2}{2\, l_1 l_2}, \qquad q_3 = -\arccos(\cos q_3) $$ (negative: the knee bends backwards). If $\lvert \cos q_3 \rvert > 1$ the target is out of reach and the function returns `None`. 3. **Thigh.** The direction to the foot minus the angle the bent calf adds: $$ q_2 = \operatorname{atan2}(-u, -v) - \operatorname{atan2}\left(l_2 \sin q_3,\ l_1 + l_2 \cos q_3\right) $$ Then `clamp_leg` applies the joint limits and the knee-crank window. If the crank needed is outside its window, it is clamped and the calf angle recomputed from the clamped crank: the firmware does exactly the same in actuator space, so simulation and robot agree. ## Joint limits Generated from the CAD range-of-motion sweep (collisions with all parts, a mirrored partner leg with 0.3 mm clearance, transmission angle ≥ 25°, and ±85° servo travel around the assembly neutral): | Joint | FL | FR | RL | RR | |---|---|---|---|---| | Hip | −17.5…50° | −50…17.5° | −17.5…45° | −45…17.5° | | Thigh | −40…125° | −40…125° | −15…125° | −15…125° | | Calf | −150…−45° | −150…−45° | −150…−45° | −150…−45° | | Knee crank | −2.5…75° | −2.5…75° | −2.5…75° | −2.5…75° | The inward hip limits (±17.5°) are small because both front (and both rear) feet could otherwise meet under the body (ADR-005 in [decisions](../../decisions.md)). The front thigh sweep allowed −45°, but the servo can only reach −40° within ±85° of its 45° neutral. Limits are applied in three places (gait, policy environment, firmware) from the same generated numbers. ## From angles to servo pulses Joint space and servo space differ only for the knee: hip and thigh servos hold $q_1$ and $q_2$ directly, the knee servo holds the crank angle $\psi$. The firmware then maps each actuator angle $\theta$ to a pulse width $t_\mathrm{p}$, with calibration sign $s$ (`direction`), `neutral_us` $t_\mathrm{neutral}$ and reference angle $\theta_0$ (`zero_rad`): $$ t_\mathrm{p} = t_\mathrm{neutral} + s \cdot \frac{2000\,\mu\mathrm{s}}{\pi\,\mathrm{rad}} \cdot (\theta - \theta_0) $$ with per-joint calibration from flash ([firmware](firmware.md)). At the assembly reference (all servos at 1500 µs) the legs are vertical in abduction, the femur is 45° backwards and the knee crank at 35°. ## Gaits: stance, swing and the gait cycle A gait repeats with a **period** $T$. Each leg spends a fraction of it on the ground (**stance**, pushing the body) and the rest in the air (**swing**, moving forward to its next foothold). The stance fraction is the **duty factor** $\beta = T_\mathrm{stance}/T$. **Phase offsets** say when each leg starts its cycle. The theory behind gaits, support polygons and Raibert foot placement is in [legged locomotion](../theory/legged-locomotion.md). | Gait | Duty factor | Phase pattern | Character | |---|---|---|---| | Walk | ≥ 0.75 | one leg at a time (e.g. FL, RR, FR, RL) | at least three feet down: statically stable, slow | | Trot | 0.5 | diagonal pairs together (FL+RR, then FR+RL) | two feet down: needs momentum and a short period; robust and simple | **Static stability:** a robot standing still does not fall if its centre of mass projects inside the **support polygon** formed by the feet on the ground. With three feet down (walk) that triangle can be kept under the body. With two diagonal feet (trot) the polygon is a line, so the body is briefly unsupported in roll and pitch; a 0.4 s cycle switches pairs before it tips noticeably. **Strido implements a trot only** (`TrotGait`, `PHASE_OFFSET = {"FL": 0.0, "RR": 0.0, "FR": 0.5, "RL": 0.5}`). A walk would need a new phase table and a duty factor ≥ 0.75; nothing in the repo provides one. ### The trot in numbers `GaitConfig` defaults: | Parameter | Value | |---|---| | `body_height` | 0.17 m | | `period` | 0.40 s (2.5 steps per second per leg pair) | | `duty` | 0.5 (0.2 s stance, 0.2 s swing) | | `step_height` | 0.035 m swing apex | | `max_linear` | 0.30 m/s forward, 0.15 m/s sideways | | `max_yaw_rate` | 1.0 rad/s | How `foot_targets()` places each foot: 1. **Per-leg velocity.** For body command $(v_x, v_y, \omega_z)$, a foot at $(x, y)$ must move at $\mathbf{v}_\mathrm{foot} = (v_x - \omega_z y,\ v_y + \omega_z x)$, the planar form of $\mathbf{v} + \boldsymbol{\omega} \times \mathbf{r}$: turning on the spot makes the left feet move backward and the right feet forward. 2. **Stride** = velocity × stance time, $L = v\,\beta T$. At 0.15 m/s: $0.15\,\mathrm{m/s} \times 0.2\,\mathrm{s} = 0.03\,\mathrm{m}$. At the 0.30 m/s limit: 0.06 m. 3. **Stance** (phase $< \beta$): the foot slides from $+L/2$ to $-L/2$ relative to its nominal position at body speed, so the body moves forward over it. 4. **Swing**: the foot returns from $-L/2$ to $+L/2$ and lifts on a half-sine, $h(s) = 0.035\,\mathrm{m} \cdot \sin(\pi s)$ with swing progress $s \in [0, 1]$. Landing half a stride ahead of the hip is the Raibert-style foot placement: the touchdown point is symmetric about the hip, so the stance phase neither accelerates nor brakes the body on average. 5. **Inverse kinematics** turns the four foot targets into 12 angles, then `clamp_leg` applies the limits. With a zero command the feet stay at their nominal positions: the robot stands still rather than trotting in place. `gait_node` adds a ramp (velocity changes at most 0.5 m/s² and 0.5 rad/s²) and a smooth 3 s stand-up from the rest pose (0.07 m: thigh 69.4°, calf −148.1°) to the stand pose. This gait is **open-loop**: it does not read the IMU and makes no balance corrections. It works because the trot is short-period and the robot is light (3.01 kg). The learned policy ([simulation and RL](simulation-and-rl.md)) is the closed-loop alternative: it observes the IMU. ## PD control: what holds a joint at its target A position servo is a small feedback controller. The usual model is a **PD** (proportional-derivative) law: $$ \tau = k_\mathrm{p}\,(q_\mathrm{target} - q) - k_\mathrm{d}\,\dot q $$ The P term pulls towards the target like a spring; the D term damps motion like a shock absorber. Large $k_\mathrm{p}$ gives stiff, accurate joints but can oscillate; $k_\mathrm{d}$ suppresses the oscillation. For a joint with inertia $I$ the closed loop is a mass–spring–damper, $I\ddot q + k_\mathrm{d}\dot q + k_\mathrm{p} q = k_\mathrm{p} q_\mathrm{target}$, with natural frequency $\omega_n = \sqrt{k_\mathrm{p}/I}$ and damping ratio $\zeta = k_\mathrm{d} / (2\sqrt{k_\mathrm{p} I})$; see [control](../theory/control.md). In Strido: - **On the robot** the PD loop lives *inside each SPT5435LV*: its electronics compare the commanded pulse with an internal potentiometer and drive the motor. The ESP32 sends only targets; nothing outside the servo knows the real angle or torque. - **In MuJoCo** each servo is a position actuator with $k_\mathrm{p} = 25\,\mathrm{N\,m/rad}$, $k_\mathrm{v} = 0.4\,\mathrm{N\,m\,s/rad}$ and a torque limit of $\pm 3.43\,\mathrm{N\,m}$, the SPT5435LV stall torque at 6 V. So 0.1 rad of error gives $25 \times 0.1 = 2.5\,\mathrm{N\,m}$; beyond $3.43 / 25 \approx 0.14\,\mathrm{rad}$ the servo saturates, as a real one would. - **In Gazebo** `gz_ros2_control` exposes a `position` command interface per joint, driven by `leg_controller` (`position_controllers/JointGroupPositionController`, 200 Hz). These gains are modelling estimates; [simulation](../../simulation.md) notes they should be refitted to measured servo response once a robot exists. ## Check yourself 1. Why does moving only the thigh servo also change the knee angle? 2. Using the law of cosines, what is $\cos q_3$ for a foot at full extension (distance $l_1 + l_2$)? 3. What is the stride length at 0.20 m/s with the default trot? 4. Why is a trot not statically stable, and why does it still work? 5. In MuJoCo, what torque does a joint produce at 0.05 rad error and zero velocity?
Answers 1. The knee servo drives the tibia through a parallelogram and four-bar attached to the femur; the knee angle depends on the crank angle and the thigh angle. 2. $(l_1 + l_2)^2 - l_1^2 - l_2^2 = 2 l_1 l_2$, so $\cos q_3 = 1$ and $q_3 = 0$: the leg is straight (and the calf limit $-45^\circ$ forbids it). 3. $0.20\,\mathrm{m/s} \times 0.2\,\mathrm{s} = 0.04\,\mathrm{m}$. 4. Only two diagonal feet support the body, so the centre of mass is not inside a support polygon; the short 0.4 s period switches pairs before the body tips far. 5. $25\,\mathrm{N\,m/rad} \times 0.05\,\mathrm{rad} = 1.25\,\mathrm{N\,m}$ (below the 3.43 N·m limit).
## Further reading - [Modern Robotics (Lynch and Park), free textbook and videos](https://hades.mech.northwestern.edu/index.php/Modern_Robotics) - [M. H. Raibert, Legged Robots That Balance (MIT Press)](https://mitpress.mit.edu/9780262681193/legged-robots-that-balance/) - [MuJoCo XML reference: position actuators](https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator-position) - [ros2_control: position controllers](https://control.ros.org/jazzy/doc/ros2_controllers/position_controllers/doc/userdoc.html) - Theory: [Frames and rotations](../theory/frames-and-rotations.md), [Kinematics](../theory/kinematics.md), [Legged locomotion](../theory/legged-locomotion.md), [Control](../theory/control.md) --- # Linux on the Raspberry Pi 5 The Raspberry Pi 5 is Strido's "brain": a small Linux computer that runs ROS 2 inside Docker and talks to the servo controller over USB. This page covers choosing an operating system, flashing it, logging in without a screen, and the handful of Linux skills you need to keep the robot running. ::: tip In Strido - **Hardware:** Raspberry Pi 5 (4 GB) with the Raspberry Pi Active Cooler and a 64 GB A2 microSD card (SanDisk Extreme), all in `hardware/bom.yaml`. It is fed with 5 V through the GPIO header, not through USB-C. - **OS:** Ubuntu Server 24.04 (arm64) or Raspberry Pi OS Bookworm 64-bit, plus Docker Engine ([software](../../software.md#raspberry-pi-setup)). - **Robot software:** runs in the `strido-ros:jazzy` container built from `docker/ros2/Dockerfile`, so the host OS only needs Docker, udev rules and a few firmware settings. - **Devices:** the XIAO ESP32-S3 (USB CDC) and the RPLIDAR C1 (CP210x USB adapter) appear as `/dev/strido_mcu` and `/dev/rplidar` through `ros2/strido_bringup/config/99-strido.rules`. ::: ## What the Pi does, and what it does not The Pi runs everything above the servo loop: the gait or policy node, the `mcu_bridge`, the LiDAR driver and (optionally) the camera. It does **not** drive servos or read the IMU itself. Those are on the ESP32-S3, which owns the I²C bus (PCA9685, LSM6DSOX, INA260) and runs a hard 200 Hz loop. The split exists because Linux is not a real-time OS: a busy Pi can pause a process for tens of milliseconds, which is harmless for planning but would make servos twitch. See [firmware](firmware.md). So the Pi needs only USB serial access, not I²C or SPI. That keeps the Linux setup short. ## Choosing the operating system | | Ubuntu Server 24.04 (arm64) | Raspberry Pi OS Bookworm 64-bit | |---|---|---| | Base | Ubuntu "Noble" | Debian 12 | | ROS 2 Jazzy natively | Yes: Ubuntu 24.04 is Jazzy's Tier 1 platform (apt packages) | No official binary packages; use Docker | | Pi 5 camera (libcamera fork) | Must be built yourself | Ships with it (`rpicam-apps`) | | Tools such as `raspi-config`, `vcgencmd` | Partly | Yes | Either works because the repo puts ROS 2 in a container (`FROM ros:jazzy-ros-base`, itself built on Ubuntu 24.04). The container brings its own Ubuntu userland; the host only provides the kernel, USB devices and networking. Pick **Raspberry Pi OS** if you want the camera (the Pi 5 camera stack needs the Raspberry Pi libcamera fork, which the Docker image does not include); pick **Ubuntu Server** if you want the option of installing ROS 2 natively later. Always use the **64-bit** image. The ROS image, NumPy wheels and Docker's arm64 builds all assume `aarch64`. ## Flashing the card with Raspberry Pi Imager 1. Install Raspberry Pi Imager on your laptop and insert the microSD card. 2. Choose device **Raspberry Pi 5**, then the OS (under "Other general-purpose OS → Ubuntu" for Ubuntu Server 24.04, or "Raspberry Pi OS (64-bit)"; the Lite variant has no desktop and is enough for a headless robot). 3. When asked, open **OS customisation** (Edit settings) and set: - hostname, for example `strido`; - a user name and password (do not keep a default user); - Wi-Fi SSID, password and country; - on the **Services** tab: enable SSH, ideally with "public-key authentication only" and your public key pasted in. 4. Write, eject, insert the card into the Pi. ::: tip Why customise in the Imager A walking robot has no keyboard or monitor. Pre-seeding user, Wi-Fi and SSH lets the Pi join your network on first boot so you can reach it from a laptop: this is a *headless* setup. ::: ## First boot and SSH Power the Pi (logic power only, servo loop key out). After a minute it should be on the network: ```bash ssh @strido.local # mDNS name; if it does not resolve, use the IP from your router ``` Use key-based login rather than passwords: ```bash ssh-keygen -t ed25519 # on the laptop, once ssh-copy-id @strido.local # installs the public key on the Pi ``` Then bring the system up to date before installing anything: ```bash sudo apt update && sudo apt full-upgrade -y sudo reboot ``` On Raspberry Pi OS, also keep the bootloader EEPROM current (`sudo rpi-eeprom-update`); it holds power settings you will change below. ## Pi-specific configuration for this robot Two settings from [electronics](../../electronics.md#raspberry-pi-configuration) matter because Strido powers the Pi through the GPIO 5 V pins. That path bypasses USB-C Power Delivery negotiation, so the Pi would otherwise assume a weak supply and limit USB current: ```bash sudo rpi-eeprom-config --edit # add: PSU_MAX_CURRENT=5000 echo "usb_max_current_enable=1" | sudo tee -a /boot/firmware/config.txt ``` `PSU_MAX_CURRENT=5000` tells the bootloader the supply delivers 5 A; `usb_max_current_enable=1` lets the USB ports supply their full current (the LiDAR and ESP32 are USB powered). `config.txt` lives in `/boot/firmware/` on both Bookworm and Ubuntu 24.04. ::: warning The optional self power-off overlay (`dtoverlay=gpio-poweroff,gpiopin=26`) is marked [UNVERIFIED on Pi 5] in the repo. Test it with the servos disarmed before relying on it. ::: ## Users, groups and device permissions Linux controls hardware access through files in `/dev` and their owner group: | Device | Typical name | Default group | Used by Strido? | |---|---|---|---| | USB CDC serial (XIAO ESP32-S3) | `/dev/ttyACM0` | `dialout` | Yes: `mcu_bridge`, `strido-cli` | | USB-UART bridge (CP210x, LiDAR) | `/dev/ttyUSB0` | `dialout` | Yes: `sllidar_ros2` | | I²C on the GPIO header | `/dev/i2c-1` | `i2c` | No: I²C is on the ESP32 | | GPIO | `/dev/gpiochip*` | `gpio` | Only the optional power-off overlay | | Docker daemon socket | `/var/run/docker.sock` | `docker` | Yes | Two things make this simple here: - **udev rules.** `99-strido.rules` matches the devices by USB vendor/product ID (Espressif `303a:1001`, Silicon Labs `10c4:ea60`), creates stable symlinks and sets `MODE="0666"`. Without them, the ESP32 and LiDAR could swap between `ttyACM0`/`ttyUSB0` names across reboots, and only `dialout` members could open them. ```bash sudo cp ros2/strido_bringup/config/99-strido.rules /etc/udev/rules.d/ sudo udevadm control --reload && sudo udevadm trigger ls -l /dev/strido_mcu /dev/rplidar ``` - **docker group.** Adding yourself lets you run `docker` without `sudo`: ```bash sudo usermod -aG docker $USER # log out and back in afterwards ``` ::: warning Membership of `docker` is equivalent to root: anyone in it can start a privileged container. Only add trusted users. ::: If you run `strido-cli` or ROS 2 natively (outside Docker) without the udev rule, add yourself to `dialout` instead: `sudo usermod -aG dialout $USER`. ## Installing Docker and building the robot image Install Docker Engine from Docker's own repository (the Debian or Ubuntu instructions in Further reading), then from a clone of the repository: ```bash docker compose -f docker/compose.yaml build ros # ROS 2 Jazzy, Gazebo libs, sllidar_ros2, this repo's packages docker compose -f docker/compose.yaml run --rm robot ``` The second command starts `ros2 launch strido_bringup robot.launch.py`. Follow the start-up order in [electronics](../../electronics.md#safety-rules): robot lying in the rest pose, loop key in, *then* launch. Details are in [Docker](docker.md) and [ROS 2](ros2.md). ## systemd: services that start at boot systemd is the process that starts and supervises services on both OSs. Docker itself is a systemd service: ```bash systemctl status docker # running? enabled at boot? sudo systemctl enable --now docker journalctl -u docker -f # follow its log journalctl -b -p warning # warnings and errors since this boot ``` The repo ships **no** systemd unit for the robot, deliberately: the gait node commands the rest pose and stands up within 3 s of launch, and the safety rules require you to insert the loop key *before* launching. Auto-starting the robot at boot would move servos without a human present. If you add your own unit later (a `Type=simple` service running `docker compose -f docker/compose.yaml run --rm robot`, `After=docker.service`), keep the loop key as the arming step and never leave it inserted unattended. ## Useful commands | Command | Tells you | |---|---| | `hostname -I`, `ip a` | IP addresses | | `lsusb` | USB devices: look for `303a:1001` (XIAO) and `10c4:ea60` (CP210x) | | `ls -l /dev/strido_mcu /dev/rplidar` | udev symlinks exist and point at a tty | | `udevadm info -a -n /dev/ttyACM0` | attributes to match in a udev rule | | `dmesg -w` | kernel messages live (plug a device in and watch) | | `vcgencmd get_throttled` | `0x0` is good; bit 0 (`0x1`) = under-voltage now, bit 16 (`0x10000`) = under-voltage happened since boot | | `vcgencmd measure_temp` | SoC temperature (the Active Cooler keeps it in check under ROS load) | | `htop`, `free -h`, `df -h` | CPU, memory, disk | | `docker ps`, `docker logs ` | running containers and their output | `vcgencmd` is part of Raspberry Pi OS; on Ubuntu it may need installing (`libraspberrypi-bin`) or may be unavailable. ## Keeping it maintainable - Update the host regularly (`apt full-upgrade`); ROS 2 lives in the image, so a host update cannot break ROS. - Rebuild the image after pulling repo changes: the Dockerfile copies `packages/strido-core` and the three ROS packages at build time. - Shut down cleanly (`sudo poweroff`) before pulling power: an SD card written to during power loss can corrupt the file system. ## Check yourself 1. Why does the Pi not need I²C enabled in Strido? 2. What do `PSU_MAX_CURRENT=5000` and `usb_max_current_enable=1` fix, and why does this robot need them? 3. Which file gives the ESP32-S3 a stable device name, and what is that name? 4. Why is Raspberry Pi OS the better choice if you want the camera? 5. Why does the repo not start the robot automatically at boot?
Answers 1. All I²C devices (PCA9685, LSM6DSOX, INA260) hang off the ESP32-S3; the Pi talks to the ESP32 over USB serial only. 2. Powering through the GPIO 5 V pins skips USB-C PD negotiation, so the Pi would assume a weak supply and limit USB current. The settings declare a 5 A supply and allow full USB current for the LiDAR and ESP32. 3. `ros2/strido_bringup/config/99-strido.rules`, copied to `/etc/udev/rules.d/`; it creates `/dev/strido_mcu`. 4. The Pi 5 camera stack needs the Raspberry Pi libcamera fork, which Raspberry Pi OS ships and the Docker image does not contain. 5. Launch makes the robot stand up within 3 s; the start-up order requires a person to arm the servos with the loop key just before launching.
## Further reading - [Raspberry Pi documentation: getting started and Imager](https://www.raspberrypi.com/documentation/computers/getting-started.html) - [Raspberry Pi documentation: `config.txt` and EEPROM settings](https://www.raspberrypi.com/documentation/computers/config_txt.html) - [Docker Engine on Debian](https://docs.docker.com/engine/install/debian/) and [on Ubuntu](https://docs.docker.com/engine/install/ubuntu/) - [ROS 2 Jazzy installation and supported platforms](https://docs.ros.org/en/jazzy/Installation.html) - [systemd.service manual](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html) --- # ROS 2 Jazzy ROS 2 (Robot Operating System 2) is not an operating system but a framework: a set of libraries and tools that let many small programs, called nodes, exchange typed messages. Strido uses ROS 2 Jazzy so that the same nodes run in Gazebo and on the real robot, and so that standard tools (teleop, RViz, Nav2, SLAM) can drive it. ::: tip In Strido - **Packages** (in `ros2/`): `strido_description` (URDF/xacro generated from CAD, `controllers.yaml`, `display.launch.py`), `strido_bringup` (`robot.launch.py`, `gazebo.launch.py`, `worlds/flat.sdf`, udev rules), `strido_control` (`gait_node`, `policy_node`, `mcu_bridge`, `walk_smoke`). - **Shared core:** `packages/strido-core` (plain Python, no ROS) holds the gait, kinematics, MCU protocol and policy runtime that the nodes call. - **Interface:** Unitree Go2-style topics and joint names, listed in [software](../../software.md#ros-2-interface-go2-style). - **Runs in:** the `strido-ros:jazzy` Docker image ([Docker](docker.md)). ::: ## Why Jazzy Jazzy Jalisco (May 2024) is a long-term-support release, supported until May 2029, and its Tier 1 platform is Ubuntu 24.04, the base of the `ros:jazzy-ros-base` image. It pairs with Gazebo Harmonic, which the repo uses for simulation. An LTS release means the robot's software stack does not have to chase a new ROS version every year. ## The ROS graph of this robot ```mermaid flowchart LR TELEOP["teleop_twist_keyboard
(or Nav2, your node)"] -->|"/cmd_vel"| GAIT["gait_node
or policy_node"] GAIT -->|"/leg_controller/commands"| BRIDGE["mcu_bridge"] GAIT -->|"/odom, TF odom→base_link"| RSP["robot_state_publisher"] BRIDGE -->|"/joint_states"| RSP BRIDGE -->|"/imu/data, /battery_state, /diagnostics"| OUT["your tools"] BRIDGE -->|"/imu/data"| GAIT LIDAR["sllidar_ros2"] -->|"/scan"| OUT BRIDGE <-->|"USB CDC"| MCU["ESP32-S3 firmware"] ``` (`policy_node` subscribes to `/imu/data`; `gait_node` does not.) ## Nodes A **node** is one process (or one object in a process) with a single job. Splitting the robot into nodes means you can replace one piece without touching the others: `gait_node` and `policy_node` are interchangeable because both turn `/cmd_vel` into `/leg_controller/commands`. | Node (package `strido_control`) | Job | |---|---| | `gait_node` | Velocity command → trot gait → 12 joint targets at 100 Hz; publishes `/odom` and the `odom→base_link` transform by integrating the *commanded* velocity | | `policy_node` | Runs a learned policy (`policy.npz`) at 50 Hz from `/cmd_vel` and `/imu/data` | | `mcu_bridge` | Translates between ROS topics and the binary serial protocol of the ESP32-S3 | | `walk_smoke` | Automated Gazebo test: walks and checks ground truth | Plus standard nodes: `robot_state_publisher`, the Slamtec `sllidar_ros2` driver, `camera_ros` (optional), and in simulation the Gazebo bridge and `ros2_control` controllers. Each `strido_control` node is a Python class derived from `rclpy.node.Node`, registered as a console script in `setup.py`, so you start it with: ```bash ros2 run strido_control gait_node ``` ## Topics A **topic** is a named, typed, many-to-many stream. Publishers do not know who listens. That loose coupling is why the gait node needs no change between simulation and hardware: in Gazebo, `/leg_controller/commands` is consumed by `ros2_control`'s `leg_controller`; on the robot, by `mcu_bridge`. The topics this repo actually uses: | Topic | Type | Published by | Notes | |---|---|---|---| | `/cmd_vel` | `geometry_msgs/Twist` | you / teleop | `linear.x`, `linear.y` [m/s], `angular.z` [rad/s]; gait clamps to 0.30 / 0.15 / 1.0 | | `/leg_controller/commands` | `std_msgs/Float64MultiArray` | gait or policy node | 12 angles [rad], order FL, FR, RL, RR × hip, thigh, calf | | `/joint_states` | `sensor_msgs/JointState` | `mcu_bridge` (robot), `joint_state_broadcaster` (Gazebo) | names `FL_hip_joint` …; on the robot these are the commanded angles after clamping, because the servos have no feedback | | `/imu/data` | `sensor_msgs/Imu` | `mcu_bridge` / Gazebo IMU | frame `imu_link`; not published while the IMU reports a fault | | `/battery_state` | `sensor_msgs/BatteryState` | `mcu_bridge` | pack voltage, current negative = discharging | | `/diagnostics` | `diagnostic_msgs/DiagnosticArray` | `mcu_bridge` | flags `estop`, `relaxed`, `watchdog`, `imu_ok`, `power_ok`, `pwm_ok`, `clamped`, `calibration_mode` | | `/odom` | `nav_msgs/Odometry` | `gait_node` | dead reckoning of the commanded velocity | | `/scan` | `sensor_msgs/LaserScan` | `sllidar_ros2` / Gazebo | frame `lidar_link`, 10 Hz | | `/camera/image_raw` | `sensor_msgs/Image` | `camera_ros` / Gazebo | frame `camera_optical_frame` | | `/strido/estop` | `std_msgs/Bool` | you | `true` latches a stop (servo outputs off) | | `/strido/relax` | `std_msgs/Bool` | you | `true` makes the servos limp | | `/strido/face` | `std_msgs/UInt8` | you | LCD expression id | | `/ground_truth/odom` | `nav_msgs/Odometry` | Gazebo only | used by `walk_smoke` to measure real progress | `/odom` from `gait_node` is pure dead reckoning: each step it rotates the commanded body velocity into the odometry frame with the current heading $\psi$ and integrates it, $$ \begin{pmatrix} x \\ y \end{pmatrix}_{k+1} = \begin{pmatrix} x \\ y \end{pmatrix}_{k} + \begin{pmatrix} \cos\psi_k & -\sin\psi_k \\ \sin\psi_k & \cos\psi_k \end{pmatrix} \begin{pmatrix} v_x \\ v_y \end{pmatrix} \Delta t, \qquad \psi_{k+1} = \psi_k + \omega_z\,\Delta t $$ so any difference between commanded and real motion (foot slip, a policy that undershoots) accumulates without bound. That is why `walk_smoke` judges progress with `/ground_truth/odom`, and why SLAM corrects the pose from `/scan`. See [frames and rotations](../theory/frames-and-rotations.md) and [state estimation](../theory/state-estimation.md). Exploring them from a shell inside the container: ```bash ros2 node list ros2 topic list -t ros2 topic hz /leg_controller/commands # ≈ 100 Hz from gait_node ros2 topic echo /battery_state --once ros2 topic echo /diagnostics ros2 topic pub --once /strido/estop std_msgs/msg/Bool "{data: true}" # stop ros2 topic pub --once /strido/estop std_msgs/msg/Bool "{data: false}" # release (a new joint command is needed to move again) ``` ## Services and actions A **service** is a request/response call (like a function call over the network); an **action** is a long-running goal with feedback and cancellation (like "walk to this pose"). The Strido nodes define **neither**: every command, including e-stop and relax, is a topic. That is a deliberate fit for a robot whose firmware treats the command stream itself as the heartbeat (no command for 200 ms → hold, see [firmware](firmware.md)). You still meet services through standard nodes. Every node exposes parameter services (`ros2 param ...` uses them), and the `spawner` in `gazebo.launch.py` calls the `controller_manager`'s services to load `joint_state_broadcaster` and `leg_controller`. Actions appear once you add Nav2, whose `NavigateToPose` action would publish `/cmd_vel` to this robot. ## Parameters **Parameters** are per-node settings, declared in code with defaults and overridable at launch. The real ones: | Node | Parameter | Default | |---|---|---| | `gait_node` | `rate_hz` | 100.0 | | | `body_height` | 0.17 m | | | `stand_up_s` | 3.0 s | | | `cmd_timeout_s` | 0.5 s (no `/cmd_vel` for this long → stand still) | | | `accel_limit` | 0.5 m/s² and rad/s² | | | `publish_tf` | true | | `policy_node` | `policy` | "" (required: path to `policy.npz`) | | | `stand_up_s`, `cmd_timeout_s` | 3.0 s, 0.5 s | | `mcu_bridge` | `port` | `/dev/strido_mcu` | | `walk_smoke` | `settle_s`, `walk_s`, `vx`, `min_forward_m`, `timeout_s` | 6.0, 8.0, 0.15, 0.4, 120.0 | The nodes read their parameters once at start-up, so set them on the command line rather than with `ros2 param set` while running: ```bash ros2 run strido_control gait_node --ros-args -p body_height:=0.15 -p accel_limit:=0.3 ``` `use_sim_time:=true` is a parameter every node has: it makes the node's clock follow Gazebo's `/clock` topic instead of wall time. `gazebo.launch.py` sets it for all its nodes. ## Launch files A **launch file** starts a set of nodes with their parameters and arguments. Strido's are Python (`*.launch.py`). ```bash ros2 launch strido_bringup robot.launch.py # on the robot ros2 launch strido_bringup robot.launch.py lidar:=false # without the LiDAR ros2 launch strido_bringup robot.launch.py use_policy:=true policy:=/path/policy.npz ros2 launch strido_bringup gazebo.launch.py headless:=true # simulation ros2 launch strido_description display.launch.py # model + joint sliders in RViz ``` | Launch file | Arguments (default) | |---|---| | `robot.launch.py` | `mcu_port` (`/dev/strido_mcu`), `lidar_port` (`/dev/rplidar`), `lidar` (true), `camera` (false), `use_policy` (false), `policy` ("") | | `gazebo.launch.py` | `world` (`worlds/flat.sdf`), `headless` (false), `use_policy` (false), `policy` ("") | | `display.launch.py` | `rviz` (true) | `robot.launch.py` shows the main launch building blocks: `DeclareLaunchArgument`, `Node(...)`, `IfCondition`/`UnlessCondition` to choose `gait_node` or `policy_node`, `IncludeLaunchDescription` to reuse the Slamtec launch file, and `Command(["xacro ", file])` to turn the xacro into the `robot_description` string. `gazebo.launch.py` adds event handlers (`OnProcessExit`) so the controllers are spawned only after the robot exists in the simulator. ## tf2 and frames **tf2** keeps track of coordinate frames over time, so any node can ask "where is the LiDAR relative to the odometry origin, 50 ms ago?". Frames follow REP-103: `base_link` has x forward, y left, z up. ```mermaid flowchart TD ODOM["odom"] -->|"gait_node (TransformBroadcaster)"| BASE["base_link"] BASE -->|"robot_state_publisher
from /joint_states"| HIP["FL_hip → FL_thigh → FL_calf → FL_foot
(same for FR, RL, RR)"] BASE -->|"fixed joints in URDF"| SENS["imu_link, lidar_link,
camera_link → camera_optical_frame"] ``` `robot_state_publisher` reads the URDF (generated from the CAD by `strido_cad.models`) and the `/joint_states`, and publishes every link's pose. On the robot the joint states are the commanded angles after clamping: good for visualisation, but they cannot show a servo that stalls. The `odom→base_link` transform comes from integrating the commanded velocity, with no slip model. It is enough for teleoperation and as a seed for SLAM; for navigation use a LiDAR odometry or SLAM source. `policy_node` publishes no `/odom`. ## Quality of Service (QoS) **QoS** settings decide how messages are delivered: reliability (reliable or best effort), history depth, durability (whether late subscribers get old messages) and deadlines. A publisher and subscriber must have compatible QoS or they will silently not connect. All Strido publishers and subscribers pass a plain integer depth (`10`; `50` for `policy_node`'s IMU subscription). In `rclpy` that means the default profile: **keep last N, reliable, volatile**. Reliable delivery is right for commands on the same machine. If you connect a sensor driver that publishes best effort (common for cameras and some LiDAR drivers), a reliable subscriber will not receive it: check with ```bash ros2 topic info /scan --verbose # shows each endpoint's QoS ``` ## Teleoperation Drive the robot (or the simulation) from the keyboard. The image includes `teleop_twist_keyboard`: ```bash # terminal 1 (on the Pi, robot lying in rest pose, loop key in) docker compose -f docker/compose.yaml run --rm robot # terminal 2 docker compose -f docker/compose.yaml run --rm ros ros2 run teleop_twist_keyboard teleop_twist_keyboard ``` `teleop_twist_keyboard` starts with 0.5 m/s and 1.0 rad/s; `gait_node` clamps to 0.30 m/s forward, 0.15 m/s sideways and 1.0 rad/s, and ramps at 0.5 m/s². Press `z` (all speeds −10 %) or `x` (linear only) a few times so the key presses map onto the usable range. The teleop publishes a message per key press (hold a key and keyboard auto-repeat keeps it coming); `gait_node` treats `/cmd_vel` older than 0.5 s (`cmd_timeout_s`) as stale and stands still, so the robot stops when the messages stop. Kill the gait node entirely and the firmware holds the pose for 200 ms, then relaxes 2 s later. ::: danger Teleop is not an emergency stop. Pulling the servo loop key is the hardware kill; `/strido/estop` is the software one ([safety](../making/safety.md)). ::: ## Porting from a Unitree Go2 Joint names, signs, frames and `/cmd_vel` match the Go2 ROS 2 description, so velocity-level stacks run unchanged. Differences: the command array order is FL, FR, RL, RR (the Unitree SDK uses FR, FL, RR, RL: map by name), there are no foot-contact or motor-torque topics, `/joint_states` are commanded values, and the speed envelope is about 0.3 m/s. ## Check yourself 1. Why can `gait_node` run unchanged in Gazebo and on the robot? 2. Which Strido node publishes `odom→base_link`, and what is its weakness? 3. Name one service the robot uses even though `strido_control` defines none. 4. You run `ros2 param set /gait_node body_height 0.15` and nothing changes. Why? 5. A best-effort camera driver publishes `/camera/image_raw` but your default subscriber gets nothing. What is the likely cause?
Answers 1. It only publishes `/leg_controller/commands`; in Gazebo `ros2_control`'s `leg_controller` consumes it, on the robot `mcu_bridge` does. 2. `gait_node`; it integrates the commanded velocity (dead reckoning without slip), so it drifts from reality. 3. The `controller_manager` services called by `spawner` in `gazebo.launch.py`, or the parameter services every node has. 4. The node reads its parameters only at start-up; restart it with `--ros-args -p body_height:=0.15`. 5. A QoS mismatch: a reliable subscriber does not match a best-effort publisher.
## Further reading - [ROS 2 Jazzy concepts (nodes, topics, services, actions, parameters, launch)](https://docs.ros.org/en/jazzy/Concepts.html) - [About Quality of Service settings](https://docs.ros.org/en/jazzy/Concepts/Intermediate/About-Quality-of-Service-Settings.html) - [tf2 introduction](https://docs.ros.org/en/jazzy/Concepts/Intermediate/About-Tf2.html) - [REP-103: units and coordinate conventions](https://www.ros.org/reps/rep-0103.html) - [ros2_control documentation (Jazzy)](https://control.ros.org/jazzy/index.html) --- # Simulation and reinforcement learning A simulator lets you walk the robot thousands of times without breaking a part; reinforcement learning (RL) lets the computer discover a walking controller by trial and error inside that simulator. Strido uses MuJoCo for fast physics and training, Gazebo for testing the complete ROS 2 stack, and PPO to train a walking policy that runs on the Pi with plain NumPy. ::: tip In Strido - **MuJoCo model:** `models/mujoco/strido.xml` + `scene.xml`, generated from the CAD by `make models`. - **Gazebo model:** `ros2/strido_description/urdf/strido.urdf.xacro` (with `sim:=true`), world `ros2/strido_bringup/worlds/flat.sdf`, launched by `gazebo.launch.py`. - **RL code:** `packages/strido-sim/src/strido_sim/rl/` (`env.py`, `train.py`, `evaluate.py`); policy runtime `packages/strido-core/src/strido/policy.py`. - **Released policy:** `models/policies/ppo-v1/policy.npz` (MLP 23 → 128 → 128 → 12). - **Commands:** `make sim-walk`, `make sim-gazebo`, `make rl-smoke`, `make rl-train`, `make rl-eval` ([simulation](../../simulation.md)). ::: ## Why two simulators | | MuJoCo 3 | Gazebo Harmonic | |---|---|---| | Runs | Python process (`mujoco==3.17.0`) | Docker container with ROS 2 Jazzy | | Strength | Fast, deterministic contact physics; easy to step thousands of times per second from Python | Simulates sensors (IMU, GPU LiDAR, camera) and runs the real ROS 2 nodes and controllers | | Used for | Gait demo, PPO training and evaluation | End-to-end test of `gait_node` / `policy_node` with the same topics as the robot | | Command | `make sim-walk`, `make rl-*` | `make sim-gazebo` | Running a policy trained in MuJoCo through Gazebo is a cheap **sim-to-sim** check: if it only works in the simulator it was trained in, it has learned that simulator's quirks, and it will not survive the real robot either. ## The models, generated from CAD `strido_cad.models` builds all simulation models from the verified CAD assembly, so the simulated robot has the real link lengths, masses and joint limits: - 13 link meshes (`ros2/strido_description/meshes/`), decimated to 25 k faces each; - masses from part volumes and densities, inertias from the meshes, cables by routed length: 3.07 kg in total (`ros2/strido_description/config/mass_report.yaml`); - MJCF with 12 position actuators (`kp 25`, `kv 0.4`, torque limit ±3.432 N·m = SPT5435LV stall torque at 6 V), an IMU site with quaternion, gyro and accelerometer sensors, and foot sites; - physics step 0.002 s (500 Hz) with the `implicitfast` integrator. One simplification: the knee is modelled as a direct revolute joint on the calf. The four-bar linkage is solved in software (gait, firmware) and its limits are applied before commands reach the joint ([kinematics](kinematics-and-gaits.md)). In Gazebo, the xacro with `sim:=true` adds `gz_ros2_control` with a `position` command interface per joint, an IMU (200 Hz), a GPU LiDAR (360 samples, 10 Hz, 0.05–12 m, Gaussian noise σ 0.01 m), a camera (640 × 360, 15 Hz) and a ground-truth odometry publisher (`/ground_truth/odom`, 50 Hz). ## MuJoCo: the gait walk ```bash make sim-walk # 10 s trot at 0.15 m/s → build/sim/walk.mp4 uv run --locked python -m strido_sim.walk --seconds 6 --wz 0.6 # turn uv run --locked mjpython -m strido_sim.walk --viewer # interactive (macOS needs mjpython) ``` This runs `strido.gait`, the same trot the robot runs through ROS 2, at 100 Hz like the real command stream. It adds a heading hold using the simulator's yaw, which the robot does not have. Recorded result: 1.10 m in 10 s at 0.15 m/s, body height 0.174 m ([verification](../../verification.md)). ## Gazebo: the full ROS 2 stack ```bash make sim-gazebo # build image, headless walk test, exit code = pass/fail make sim-gazebo POLICY=models/policies/ppo-v1/policy.npz # same test with policy_node ``` `walk_smoke` waits 6 s for the robot to stand, sends 0.15 m/s on `/cmd_vel` for 8 s and passes if the base moved ≥ 0.4 m forward (ground truth), stayed upright (|roll|, |pitch| < 0.5 rad, height > 0.10 m) and `/joint_states`, `/imu/data`, `/scan`, `/camera/image_raw` and `/ground_truth/odom` all published. Recorded: 0.98 m in 8 s with the gait node. ## Reinforcement learning in one paragraph An **agent** (here: a neural network, the *policy* $\pi_\theta(a \mid s)$ with weights $\theta$) observes the state $s_t$, chooses an **action** $a_t$, and receives a **reward** $r_t$. Over millions of trials it adjusts itself to maximise the expected discounted return $$ J(\theta) = \mathbb{E}_{\pi_\theta}\left[\sum_{t=0}^{\infty} \gamma^{t} r_t\right] $$ where the discount $\gamma < 1$ makes near rewards count more than distant ones. With the repo's $\gamma = 0.99$ and 50 policy steps per second, rewards about $1/(1-\gamma) = 100$ steps (2 s) ahead still matter. **PPO** (Proximal Policy Optimisation) is a widely used algorithm for this: it collects a batch of experience with the current policy, then improves the policy in small, *clipped* steps so one bad batch cannot wreck it. It maximises $$ L^\mathrm{CLIP}(\theta) = \mathbb{E}_t\left[\min\left(\rho_t(\theta)\,\hat A_t,\ \operatorname{clip}\left(\rho_t(\theta),\, 1-\epsilon,\, 1+\epsilon\right)\hat A_t\right)\right], \qquad \rho_t(\theta) = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\theta_\mathrm{old}}(a_t \mid s_t)} $$ where $\hat A_t$ is the advantage (how much better the action was than the critic expected) and $\epsilon = 0.2$ here: once the new policy makes an action more than 20 % more or less likely than the old one did, the objective stops rewarding further change. The repo uses the PPO implementation of Stable-Baselines3 on the CPU. The full derivation is in [reinforcement learning](../theory/reinforcement-learning.md). ## The environment, as implemented `StridoEnv` (`strido_sim/rl/env.py`) is a Gymnasium environment: "walk at the commanded velocity". ### Observations (23 numbers) | Part | Size | Source on the robot | |---|---|---| | Angular velocity (gyro) | 3 | IMU (`/imu/data`) | | Gravity direction in the body frame | 3 | IMU orientation quaternion | | Command vx, vy, ωz | 3 | `/cmd_vel` | | Previous action | 12 | the policy's own output | | Gait clock sin, cos (0.4 s period) | 2 | a timer | There are **no joint angles or velocities**. The SPT5435LV servos report nothing, so a policy that relied on joint feedback could not run on the robot (ADR-006 in [decisions](../../decisions.md)). The observation holds only what the robot can measure or knows. ### Actions (12 numbers) Each action is in $[-1, 1]$. The joint target is $\mathbf{q}_\mathrm{target} = \mathbf{q}_\mathrm{stand} + 0.3\,\mathbf{a}$ (rad), then: 1. the firmware's 6 rad/s slew limit ($6\,\mathrm{rad/s} \times 0.02\,\mathrm{s} = 0.12\,\mathrm{rad}$ per policy step), 2. the joint and knee-crank clamp, exactly as the firmware does it. Modelling these inside training means the policy learns with the same limits the robot will impose, instead of discovering them on hardware. ### Timing Policy at 50 Hz (`CONTROL_DT = 0.02` s), physics at 500 Hz: $0.02\,\mathrm{s} / 0.002\,\mathrm{s} = 10$ physics steps per action. An episode lasts at most 1000 steps ($1000 \times 0.02\,\mathrm{s} = 20\,\mathrm{s}$). ### Commands Each episode samples a command: 20 % of episodes stand still (zero command); the rest draw uniformly from vx −0.15…0.30 m/s, vy −0.10…0.10 m/s, ωz −0.6…0.6 rad/s. `policy_node` clips `/cmd_vel` to these ranges on the robot, because the policy has never seen anything outside them. ### Reward Per step, scaled so perfect tracking gives at most $1.0 + 0.8 = 1.8$: $$ \begin{aligned} r = {} & 1.0\, \exp\!\left(-\lVert \mathbf{v}_{xy} - \mathbf{c}_{xy} \rVert^2 / 0.01\right) && \text{track linear velocity} \\ & + 0.8\, \exp\!\left(-(\omega_z - c_{\omega z})^2 / 0.05\right) && \text{track yaw rate} \\ & - 2.0\, v_z^2 && \text{no bouncing} \\ & - 0.05\, (\omega_x^2 + \omega_y^2) && \text{no rolling/pitching} \\ & - 0.01\, \lVert \mathbf{a}_t - \mathbf{a}_{t-1} \rVert^2 && \text{smooth actions} \\ & - 0.002 \sum_j \tau_j^2 && \text{save energy, spare the servos} \\ & - 2.0\, \max(0,\ 0.15\,\mathrm{m} - h) && \text{do not crouch below } 0.15\,\mathrm{m} \end{aligned} $$ Here $\mathbf{v}$ and $\boldsymbol{\omega}$ are the base's linear and angular velocity, $\mathbf{c}$ the command, $\mathbf{a}$ the action, $\tau_j$ the joint torques and $h$ the base height. An episode ends with a −2 penalty if the base drops below 0.09 m or tilts beyond about 53°. The exponential "tracking kernels" give a smooth reward that is 1 for perfect tracking and fades with error: with the width 0.01, a speed error of $0.1\,\mathrm{m/s}$ already drops the term to $e^{-1} \approx 0.37$. Penalties shape *how* the robot walks. The torque and action-rate terms matter more than they look: a hobby servo asked to jitter at 50 Hz heats up and wears its gears. ### Domain randomisation The real robot will not match the model. Rather than hoping it does, training varies the model each episode so the policy learns to cope with a range: | Randomised | Range | |---|---| | Servo stiffness (kp, and the matching bias) | × 0.85 … 1.15 | | Base mass | × 0.90 … 1.15 | | Contact friction, floor and feet together | × 0.6 … 1.2 | | IMU noise | gyro σ 0.05 rad/s, gravity σ 0.02 | | Initial joint angles | stand pose + σ 0.02 rad | Floor and feet are scaled together because MuJoCo uses the larger friction of the two contacting geoms; scaling one alone would often change nothing. ## Training, exporting and evaluating ```bash uv sync --locked --group rl # once: PyTorch, Stable-Baselines3, Gymnasium (opt-in, large) make rl-smoke # 20 k steps + evaluation: checks the pipeline in seconds make rl-train # 20 M steps, 8 parallel envs, CPU → build/rl/ppo-v1/ make rl-eval # evaluate models/policies/ppo-v1/policy.npz with a video uv run --locked --group rl python -m strido_sim.rl.evaluate build/rl//policy.npz --vx 0.2 --video build/rl/eval.mp4 ``` Key PPO settings in `train.py`: 1024 steps per env per update, batch 2048, 5 epochs, learning rate $3 \times 10^{-4}$, $\gamma = 0.99$, GAE $\lambda = 0.95$, clip $\epsilon = 0.2$; actor `[128, 128]`, critic `[256, 256]`; observations and rewards normalised (`VecNormalize`, clip 10). The recorded `ppo-v1` run took 1734 s on a CPU. Training writes `policy.zip` (Stable-Baselines3), `vecnormalize.pkl`, `progress.csv` and **`policy.npz`**: the actor's weights, biases and the observation mean/variance as plain arrays. `strido.policy.MlpPolicy` re-implements the forward pass in a dozen lines of NumPy: normalise $\tilde{\mathbf{o}} = \operatorname{clip}\left((\mathbf{o} - \boldsymbol{\mu})/\sqrt{\boldsymbol{\sigma}^2 + 10^{-8}},\ -10,\ 10\right)$, then $\mathbf{h}_1 = \tanh(\mathbf{W}_1 \tilde{\mathbf{o}} + \mathbf{b}_1)$, $\mathbf{h}_2 = \tanh(\mathbf{W}_2 \mathbf{h}_1 + \mathbf{b}_2)$, $\mathbf{a} = \operatorname{clip}(\mathbf{W}_3 \mathbf{h}_2 + \mathbf{b}_3,\ -1,\ 1)$. The same code runs in `evaluate.py` and in `policy_node`, so what is evaluated is bit-for-bit the function the robot runs, and the Pi needs no PyTorch. ### What ppo-v1 achieves | Test | Result | |---|---| | MuJoCo, vx 0.2 m/s command (current limits) | 0.215 m/s mean, 2.15 m in 10 s, upright | | MuJoCo, v0.1 sweep | vx 0.15 → 0.152 m/s; 0 → stands; −0.1 → −0.068 m/s; vy 0.1 → 0.023 m/s (weak); ωz 0.5 → 232° in 10 s | | Gazebo through `policy_node` (sim-to-sim) | 0.82 m in 8 s at 0.15 m/s, upright | Lateral and reverse tracking are weak: expected from a policy without joint feedback (ADR-006). ## The sim-to-real gap Everything above is simulation. Nothing has yet run on a physical Strido. The known gaps: - **Servo response.** kp 25 / kv 0.4 and zero latency are estimates; real hobby servos have dead band, backlash, gear compliance and a voltage-dependent speed. - **Battery sag.** Servo speed and torque drop with pack voltage; the model assumes 6 V. - **IMU.** Training reads a perfect quaternion plus Gaussian noise; the robot's comes from a Mahony filter with its own lag and bias. - **Contacts.** Printed feet on a real floor, not a uniform plane with randomised friction. - **Unmodelled mechanics.** The knee linkage is a direct joint in simulation; print tolerances and flex are not modelled. The repo's procedure for a first hardware run: evaluate in MuJoCo and Gazebo, start with the robot on a stand, keep `/strido/estop` at hand, and expect to retrain with measured servo response (kp/kv, latency) once the robot exists. ```bash ros2 launch strido_bringup robot.launch.py use_policy:=true policy:=/path/policy.npz ``` ::: warning A learned policy can command fast, large motions the scripted gait never would. The firmware's slew limit, joint clamps and watchdog still apply, but they cannot stop a leg hitting your hand. Test on a stand first ([safety](../making/safety.md)). ::: ## Check yourself 1. Why does the policy observe no joint angles? 2. What two firmware behaviours are reproduced inside `env.step()`, and why? 3. Why are floor and foot friction randomised together? 4. Why is `policy.npz` exported instead of running the Stable-Baselines3 model on the Pi? 5. What does running the MuJoCo-trained policy in Gazebo tell you?
Answers 1. The SPT5435LV servos have no position feedback; a policy must use only what the real robot measures (IMU, command, its own previous action, a clock). 2. The 6 rad/s slew limit and the joint/knee-crank clamp, so the policy trains under the same constraints the robot imposes. 3. MuJoCo uses the larger friction of the two contacting geoms; scaling only one would often have no effect. 4. The robot would need PyTorch; the NumPy runtime is small, and the same code is used in evaluation, so the deployed function is exactly the evaluated one. 5. Whether the policy depends on MuJoCo-specific quirks: a sim-to-sim gap is a warning sign for the sim-to-real gap.
## Further reading - [MuJoCo documentation](https://mujoco.readthedocs.io/en/stable/overview.html) - [Gazebo Harmonic documentation](https://gazebosim.org/docs/harmonic/getstarted/) - [Stable-Baselines3 PPO](https://stable-baselines3.readthedocs.io/en/master/modules/ppo.html) - [Schulman et al., Proximal Policy Optimization Algorithms (2017)](https://arxiv.org/abs/1707.06347) - [Tobin et al., Domain Randomization for Transferring Deep Neural Networks from Simulation to the Real World (2017)](https://arxiv.org/abs/1703.06907) - Theory: [Reinforcement learning](../theory/reinforcement-learning.md), [Legged locomotion](../theory/legged-locomotion.md) --- # Control Control theory is about making a system do what you command, despite loads, noise and modelling errors, by measuring what it actually does and correcting for the difference. This page covers open and closed loop, PID and PD control, transfer functions and step responses, sampling rates, the hobby servo's internal loop, and compliance, using the Strido servos and its MuJoCo model as examples. ::: tip In Strido - **Rates:** the firmware runs a $200\,\mathrm{Hz}$ control and IMU loop (`kControlPeriodUs = 5000`) and refreshes the servos at $200\,\mathrm{Hz}$ (`kServoFrequencyHz`; the SPT5435LV accepts up to $330\,\mathrm{Hz}$). It streams state to the host at $100\,\mathrm{Hz}$ (all in `firmware/strido_esp32s3/src/board_config.h`). The joint command stream is $100\,\mathrm{Hz}$ (`strido_sim/walk.py`) and the learned policy runs at $50\,\mathrm{Hz}$ (`strido/policy.py`). - **Servo model in simulation:** MuJoCo position actuators with `kp="25" kv="0.4" forcerange="-3.432 3.432"`, plus joint `damping="0.25" armature="0.012" frictionloss="0.05"` (`models/mujoco/strido.xml`). - **Hardware:** 12 SPT5435LV-180W position servos driven by a PCA9685 PWM board ([servos](../hardware/servos.md), [firmware](../software/firmware.md)). ::: ## Open loop and closed loop **Open loop** applies a precomputed input and never checks the result. A trot gait that replays foot trajectories through inverse kinematics is open loop with respect to the body: if the robot slips, the gait does not notice. **Closed loop** (feedback) measures the output $y$, compares it with the reference $r$, and acts on the error $e = r - y$. Feedback rejects disturbances and hides modelling errors. The price is stability problems if the gain is too high or the delay too long. Strido stacks several loops, each faster than the one above it: ```mermaid graph LR P[policy / gait
50–100 Hz] -->|joint targets| F[ESP32 loop
200 Hz, slew limit, linkage] F -->|PWM pulse, 200 Hz frames| S[servo's internal
position loop] S --> J[joint] IMU[IMU 200 Hz] -->|gravity, gyro| P ``` The servos give no position feedback to the host (`strido_sim/rl/env.py`). So the only joint-level loop closes *inside* each servo. The policy closes the body-level loop through the IMU. ## PID control The classic controller combines three terms: $$ u(t) = K_p\,e(t) + K_i \int_0^t e(\tau)\,\mathrm{d}\tau + K_d\,\dot e(t) $$ - **P** pushes in proportion to the error, like a spring. - **I** accumulates past error and removes steady-state offsets, at the cost of overshoot and wind-up. - **D** opposes the rate of change, like a damper. In practice it acts on $-\dot y$ instead of $\dot e$, which avoids a kick when the reference jumps. For joints, the usual choice is **PD** with the target velocity set to zero: $$ \tau = k_p\,(q^\ast - q) - k_v\,\dot q $$ This is exactly MuJoCo's `` actuator. It is then clipped to the force range, $\lvert\tau\rvert \le 3.432\,\mathrm{N\,m}$, the servo's stall torque. ## Transfer functions and the step response Model one joint as an inertia $I$ with viscous damping $b$. In the Laplace domain, $I s^2 Q = T - b s Q$. With PD control the closed loop from target to angle is: $$ \frac{Q(s)}{Q^\ast(s)} = \frac{k_p}{I s^2 + (b + k_v)s + k_p} = \frac{\omega_n^2}{s^2 + 2\zeta\omega_n s + \omega_n^2}, \qquad \omega_n = \sqrt{\frac{k_p}{I}},\quad \zeta = \frac{b + k_v}{2\sqrt{k_p I}} $$ This standard second-order system explains most of what you see in a step response: - $\omega_n$ sets the speed. Rise time $\approx 1.8/\omega_n$, and bandwidth $\approx \omega_n$ for $\zeta$ near $0.7$. - $\zeta$ sets the shape. Below 1 the response overshoots and rings. The overshoot is $\exp(-\pi\zeta/\sqrt{1-\zeta^2})$. Above 1 it is sluggish but monotone. - Raising $k_p$ speeds the response up but lowers $\zeta$, so you raise $k_v$ with it. - The static stiffness is $k_p$: a constant load $\tau_L$ leaves an error of $\tau_L/k_p$. Only an integral term removes it. ### Worked example: the MuJoCo knee From `models/mujoco/strido.xml`: $k_p = 25\,\mathrm{N\,m/rad}$, $k_v = 0.4\,\mathrm{N\,m\,s/rad}$, joint damping $b = 0.25$, armature $0.012\,\mathrm{kg\,m^2}$. The calf adds $m r_c^2 \approx 0.0521 \cdot 0.048^2 = 1.2\times10^{-4}\,\mathrm{kg\,m^2}$ plus its own $1.5\times10^{-4}\,\mathrm{kg\,m^2}$ about its centre of mass (`mass_report.yaml`), so $I \approx 0.0123\,\mathrm{kg\,m^2}$ for a swinging calf (the armature dominates). Then: $$ \omega_n = \sqrt{\frac{25}{0.0121}} = 45\,\mathrm{rad/s}\ (7.2\,\mathrm{Hz}), \qquad \zeta = \frac{0.25 + 0.4}{2\sqrt{25 \cdot 0.0121}} = 0.59 $$ That predicts a rise time of about $40\,\mathrm{ms}$ and roughly $10\,\%$ overshoot. These are estimates: they ignore friction loss, the torque clamp and the other links. In the stand the knee carries $0.70\,\mathrm{N\,m}$ (see [statics and dynamics](statics-and-dynamics.md)), so the model sags by: $$ \Delta q = \frac{0.70}{25} = 0.028\,\mathrm{rad} \approx 1.6^\circ $$ The policy can learn to command slightly past the target to cancel this. Because the RL environment randomises the servo stiffness each episode (`env.py`), the policy cannot rely on one exact value. ## Sampling, delay and bandwidth Digital controllers sample. The Nyquist limit says you need more than two samples per period of the fastest signal you care about. For good control, the rule of thumb is a loop **10–20 times faster** than the closed-loop bandwidth. Delay matters just as much: a pure delay $T_d$ adds phase lag $\varphi = \omega T_d$, which eats the phase margin and eventually destabilises a high-gain loop. For Strido: | Loop | Rate | Period / worst-case added delay | |---|---|---| | Policy | $50\,\mathrm{Hz}$ | $20\,\mathrm{ms}$ | | Joint command stream | $100\,\mathrm{Hz}$ | $10\,\mathrm{ms}$ | | ESP32 control + IMU | $200\,\mathrm{Hz}$ | $5\,\mathrm{ms}$ | | Servo PWM frame | $200\,\mathrm{Hz}$ (max $330\,\mathrm{Hz}$) | $5\,\mathrm{ms}$ ($3.0\,\mathrm{ms}$) | | MuJoCo physics | $500\,\mathrm{Hz}$ | $2\,\mathrm{ms}$ | A classic analogue servo takes a new pulse only every $20\,\mathrm{ms}$ ($50\,\mathrm{Hz}$). At the $7.2\,\mathrm{Hz}$ joint bandwidth estimated above, that much delay alone costs $\omega T_d = 45 \cdot 0.02 = 0.9\,\mathrm{rad} \approx 52^\circ$ of phase. At $200\,\mathrm{Hz}$ it is $13^\circ$. That is presumably why the firmware runs the PCA9685 at $200\,\mathrm{Hz}$. The $50\,\mathrm{Hz}$ policy is fine because it only shapes the gait, whose cycle is $0.4\,\mathrm{s}$ ($2.5\,\mathrm{Hz}$, `GAIT_PERIOD_S` in `strido/policy.py`). That gives 20 policy steps per cycle, while the fast loops below it do the stiff work. ## The hobby servo's internal loop A hobby servo is a small closed-loop system in a box: a DC motor, a gearbox, a potentiometer on the output and a controller chip. The pulse width encodes the target angle. The chip compares it with the pot reading and drives the motor with roughly proportional (often PD-like) control, saturating at the stall torque. You can neither read its position nor tune its gains. Externally it behaves like a stiff PD joint with a torque limit, a speed limit (no-load $0.14\,\mathrm{s}/60^\circ$ at $6\,\mathrm{V}$, about $7.5\,\mathrm{rad/s}$) and some deadband. That is the abstraction MuJoCo's position actuator models. The firmware adds a $6\,\mathrm{rad/s}$ slew limit on top (`MAX_JOINT_SPEED_RAD_S`), so that sudden steps do not pull near-stall current. ## Impedance and compliance A position controller with gains $k_p$ and $k_v$ makes the joint behave like a **spring and damper** towards the target: $$ \tau_{\text{ext}} = k_p\,\Delta q + k_v\,\dot q $$ Mapped to the foot through $\boldsymbol{\tau} = \mathbf{J}^\top\mathbf{F}$ and $\delta\mathbf{p} = \mathbf{J}\,\delta\mathbf{q}$, the foot's Cartesian stiffness is: $$ \mathbf{K}_x = \mathbf{J}^{-\top}\,\mathbf{K}_q\,\mathbf{J}^{-1} $$ **Impedance control** chooses this stiffness on purpose. A legged robot wants its feet *compliant* when they touch down, so impacts are absorbed and small terrain errors do not jolt the body, and stiff during stance. Robots with torque-controlled motors (the MIT Cheetah, for example) set $\mathbf{K}_x$ in software. A hobby servo is stiff and fixed, so on Strido compliance comes from lower commanded speed, the slew limit, the foot pads, and a policy trained with randomised stiffness. ## Check yourself 1. Why does a PD-controlled joint carrying a constant load not reach its target exactly? 2. If you double $k_p$ and keep $k_v$, what happens to $\omega_n$ and $\zeta$? 3. Why would a $50\,\mathrm{Hz}$ servo frame hurt more than a $50\,\mathrm{Hz}$ policy? 4. Where is the only joint-position feedback loop on the real robot?
Answers 1. At steady state the P term must supply the load torque, so $e = \tau_L/k_p \neq 0$. Only an integral term removes it. 2. $\omega_n$ grows by $\sqrt{2}$. $\zeta$ shrinks by $\sqrt{2}$, so there is more overshoot. 3. The frame delay sits inside the fast, stiff joint loop, whose bandwidth is several hertz. The policy shapes slower body motion (the $2.5\,\mathrm{Hz}$ gait cycle) and relies on the joint loops beneath it. 4. Inside each servo, between its potentiometer and its controller chip. The host and the ESP32 never see the joint angle.
## Further reading - K. J. Åström and R. M. Murray, *Feedback Systems: An Introduction for Scientists and Engineers*, 2nd ed., Princeton University Press, 2021. [Free online](https://fbswiki.org) - K. M. Lynch and F. C. Park, *Modern Robotics*, Cambridge University Press, 2017, ch. 11 (robot control, including impedance control). - N. Hogan, "Impedance Control: An Approach to Manipulation", *ASME J. Dynamic Systems, Measurement, and Control*, 107(1), 1985. - [MuJoCo documentation: actuators](https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator). - [Servos](../hardware/servos.md) and [firmware](../software/firmware.md) in this wiki. --- # Frames and rotations Every number a robot uses for position or orientation is relative to some coordinate frame. This page covers frames, rotation matrices, roll–pitch–yaw angles and gimbal lock, quaternions, and homogeneous transforms, then shows how they fit together in the Strido transform (tf) tree. ::: tip In Strido - **Base frame:** REP-103 (`x` forward, `y` left, `z` up), with the origin at the centre of the four hip-axis intersections. It is defined in `hardware/kinematics.yaml` (`base_frame`), together with the rotation from the CAD (STEP) frame. - **Frame tree:** `ros2/strido_description/urdf/strido.urdf.xacro` defines `base_link`, `imu_link`, `lidar_link`, `camera_link`, `camera_optical_frame` and, for each leg, `*_hip`, `*_thigh`, `*_calf` and `*_foot`. - **Orientation:** the firmware runs a Mahony filter and outputs a quaternion (`firmware/strido_esp32s3/src/main.cpp`). `mcu_bridge` publishes it as `/imu/data` in `imu_link`. - **Leg transforms in code:** `foot_position` in `packages/strido-core/src/strido/leg.py`. ::: ## Frames A **frame** is an origin plus three orthogonal unit axes. A point $\mathbf{p}$ has no coordinates on its own; it only has coordinates *in* a frame. We write ${}^{A}\mathbf{p}$ for "the coordinates of $\mathbf{p}$ in frame $A$". All frames here are right-handed: $\hat{\mathbf{x}} \times \hat{\mathbf{y}} = \hat{\mathbf{z}}$. ROS fixes the conventions in [REP-103](https://www.ros.org/reps/rep-0103.html). Body frames use $x$ forward, $y$ left and $z$ up. Units are metres and radians. Strido follows this. Its `base_link` origin is the centre of the four hip-axis intersections, so the hips sit at $(\pm 0.120, \pm 0.039, 0)\,\mathrm{m}$ (`legs_base` in `hardware/kinematics.yaml`). The CAD model uses a different frame. `hardware/kinematics.yaml` stores the rotation that maps STEP coordinates into base coordinates: $$ \mathbf{R}_{\text{base}\leftarrow\text{STEP}} = \begin{bmatrix} 0 & -1 & 0 \\ 1 & 0 & 0 \\ 0 & 0 & 1 \end{bmatrix} $$ This is a $+90^\circ$ turn about $z$: the STEP $y$ axis points backwards, so STEP $-y$ becomes base $+x$. ## Rotation matrices The orientation of frame $B$ relative to frame $A$ is a $3\times 3$ matrix ${}^{A}\mathbf{R}_{B}$. Its columns are $B$'s unit axes written in $A$ coordinates. A vector converts as ${}^{A}\mathbf{v} = {}^{A}\mathbf{R}_{B}\,{}^{B}\mathbf{v}$. Because the columns are orthonormal: $$ \mathbf{R}^\top \mathbf{R} = \mathbf{I}, \qquad \det \mathbf{R} = +1, \qquad \mathbf{R}^{-1} = \mathbf{R}^\top $$ Rotation matrices with these properties form the group $SO(3)$. The three elementary rotations are: $$ \mathbf{R}_x(\theta) = \begin{bmatrix} 1 & 0 & 0 \\ 0 & \cos\theta & -\sin\theta \\ 0 & \sin\theta & \cos\theta \end{bmatrix},\quad \mathbf{R}_y(\theta) = \begin{bmatrix} \cos\theta & 0 & \sin\theta \\ 0 & 1 & 0 \\ -\sin\theta & 0 & \cos\theta \end{bmatrix},\quad \mathbf{R}_z(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix} $$ Rotations combine by multiplication, ${}^{A}\mathbf{R}_{C} = {}^{A}\mathbf{R}_{B}\,{}^{B}\mathbf{R}_{C}$. The order matters, because rotations do not commute: $\mathbf{R}_x\mathbf{R}_y \neq \mathbf{R}_y\mathbf{R}_x$ in general. ## Roll, pitch, yaw and gimbal lock A rotation has three degrees of freedom, but its matrix has nine numbers. **Euler angles** describe it with three successive elementary rotations. ROS and URDF use **roll–pitch–yaw** (RPY): roll $\phi$ about $x$, pitch $\theta$ about $y$, yaw $\psi$ about $z$, applied about the fixed axes in that order. That gives: $$ \mathbf{R} = \mathbf{R}_z(\psi)\,\mathbf{R}_y(\theta)\,\mathbf{R}_x(\phi) $$ RPY is easy to read. The Strido camera is mounted with `rpy="0 0.261799 0"`, a $15^\circ$ downward pitch. A body "rolled $5^\circ$" is easy to picture. RPY angles have a singularity. At $\theta = \pm 90^\circ$ the roll and yaw axes line up, and only $\phi \pm \psi$ is defined. One degree of freedom has gone. This is **gimbal lock**. Near it, a small change in orientation can cause a large jump in the angles, and the angle rates $$ \dot{\phi},\ \dot{\theta},\ \dot{\psi} $$ blow up. That is because the matrix that maps body angular velocity to $(\dot\phi,\dot\theta,\dot\psi)$ contains $1/\cos\theta$. No set of three angles avoids this everywhere; it is a topological property of $SO(3)$. A walking robot rarely pitches $90^\circ$, but a fall can. This is one reason estimators store orientation as a quaternion. ## Quaternions A unit quaternion $\mathbf{q} = (w, x, y, z)$ with $w^2+x^2+y^2+z^2 = 1$ encodes a rotation by angle $\alpha$ about the unit axis $\hat{\mathbf{n}}$: $$ \mathbf{q} = \left(\cos\tfrac{\alpha}{2},\ \hat{\mathbf{n}}\sin\tfrac{\alpha}{2}\right) $$ Rotations compose by the quaternion (Hamilton) product. $\mathbf{q}$ and $-\mathbf{q}$ give the same rotation. The representation has no singularity, needs four numbers instead of nine, and can be renormalised cheaply after numerical drift. The equivalent rotation matrix is: $$ \mathbf{R}(\mathbf{q}) = \begin{bmatrix} 1-2(y^2+z^2) & 2(xy-wz) & 2(xz+wy) \\ 2(xy+wz) & 1-2(x^2+z^2) & 2(yz-wx) \\ 2(xz-wy) & 2(yz+wx) & 1-2(x^2+y^2) \end{bmatrix} $$ The Strido firmware gets $\mathbf{q}$ from `orientation.getQuaternion` and sends it to the Pi. The learned policy does not use the raw angles. It uses the direction of gravity seen from the body, ${}^{B}\mathbf{g} = \mathbf{R}(\mathbf{q})^\top (0,0,-1)^\top$ (see `packages/strido-sim/src/strido_sim/rl/env.py`). This vector is smooth for every orientation and has no gimbal lock. See [state estimation](state-estimation.md) for how the filter produces $\mathbf{q}$. ## Homogeneous transforms A **pose** combines a rotation and a translation. A $4\times 4$ homogeneous transform packs both together: $$ {}^{A}\mathbf{T}_{B} = \begin{bmatrix} {}^{A}\mathbf{R}_{B} & {}^{A}\mathbf{t}_{B} \\ \mathbf{0}^\top & 1 \end{bmatrix}, \qquad \begin{bmatrix} {}^{A}\mathbf{p} \\ 1 \end{bmatrix} = {}^{A}\mathbf{T}_{B} \begin{bmatrix} {}^{B}\mathbf{p} \\ 1 \end{bmatrix} $$ Here ${}^{A}\mathbf{t}_{B}$ is the origin of $B$ expressed in $A$. Transforms chain by multiplication, ${}^{A}\mathbf{T}_{C} = {}^{A}\mathbf{T}_{B}\,{}^{B}\mathbf{T}_{C}$. The inverse is: $$ {}^{B}\mathbf{T}_{A} = \left({}^{A}\mathbf{T}_{B}\right)^{-1} = \begin{bmatrix} \mathbf{R}^\top & -\mathbf{R}^\top\mathbf{t} \\ \mathbf{0}^\top & 1 \end{bmatrix} $$ Each URDF joint is exactly one such transform. The fixed `` comes first. For a revolute joint, it is followed by a rotation about `` by the joint angle. ## The Strido tf tree ```mermaid graph TD odom --> base_link base_link --> imu_link base_link --> lidar_link base_link --> camera_link --> camera_optical_frame base_link --> FL_hip --> FL_thigh --> FL_calf --> FL_foot base_link --> FR_hip --> FR_thigh --> FR_calf --> FR_foot base_link --> RL_hip --> RL_thigh --> RL_calf --> RL_foot base_link --> RR_hip --> RR_thigh --> RR_calf --> RR_foot ``` `robot_state_publisher` computes the leg transforms from the joint angles. The sensor frames are fixed. For example, `imu_link` sits at $(0.042, -0.004, -0.011)\,\mathrm{m}$ with its axes aligned to `base_link`. `camera_optical_frame` uses `rpy="-π/2 0 -π/2"` so that its $z$ axis points along the view direction, which is the optical convention in REP-103. `odom → base_link` comes from `gait_node`, which integrates the *commanded* body velocity. It is not measured odometry (see the docstring of `ros2/strido_control/strido_control/gait_node.py`). For the front-left leg, the URDF chain is: $$ {}^{\text{base}}\mathbf{T}_{\text{foot}} = \underbrace{\mathrm{Trans}(0.1197, 0.039, 0)\,\mathrm{Rot}_x(q_1)}_{\text{FL\_hip\_joint}}\; \underbrace{\mathrm{Trans}(0, 0.0383, 0)\,\mathrm{Rot}_y(q_2)}_{\text{FL\_thigh\_joint}}\; \underbrace{\mathrm{Trans}(0, 0, -0.130)\,\mathrm{Rot}_y(q_3)}_{\text{FL\_calf\_joint}}\; \underbrace{\mathrm{Trans}(0, 0, -0.124)}_{\text{FL\_foot\_joint}} $$ ## Worked example: abducting the front-left leg In the stand pose (`stand_pose()` in `strido/gait.py`, body height $0.17\,\mathrm{m}$), the front-left joint angles are $q_1 = 0$, $q_2 = 46.5^\circ$ and $q_3 = -96.0^\circ$. The thigh and calf rotations place the foot at $\mathbf{p}_{\text{hip}} = (0,\ 0.0383,\ -0.170)\,\mathrm{m}$ relative to the hip-joint origin. The $0.0383\,\mathrm{m}$ is the lateral offset to the leg plane, and the foot sits straight below the hip. Now abduct by $q_1 = 10^\circ$ ($\cos = 0.9848$, $\sin = 0.1736$): $$ \mathbf{R}_x(10^\circ)\,\mathbf{p}_{\text{hip}} = \begin{bmatrix} 0 \\ 0.9848 \cdot 0.0383 - 0.1736 \cdot (-0.170) \\ 0.1736 \cdot 0.0383 + 0.9848 \cdot (-0.170) \end{bmatrix} = \begin{bmatrix} 0 \\ 0.0672 \\ -0.1608 \end{bmatrix}\mathrm{m} $$ Adding the hip origin $(0.1197, 0.039, 0)$ gives the foot at $(0.1197,\ 0.1062,\ -0.1608)\,\mathrm{m}$ in `base_link`. `foot_position("FL", (0.1745, 0.8121, -1.6761))` returns the same point. The same rotation as a quaternion is $(\cos 5^\circ, \sin 5^\circ, 0, 0) = (0.9962, 0.0872, 0, 0)$. ## Check yourself 1. Why is ${}^{A}\mathbf{R}_{B}^{-1}$ just the transpose? 2. The IMU frame is aligned with `base_link` but offset by a few centimetres. Does the offset change the measured angular velocity? Does it change the measured acceleration? 3. Why does the policy observe the gravity vector rather than roll, pitch and yaw? 4. Write ${}^{\text{camera\_optical}}\mathbf{T}_{\text{base}}$ in terms of the URDF transforms.
Answers 1. The columns are orthonormal, so $\mathbf{R}^\top\mathbf{R} = \mathbf{I}$. 2. Angular velocity is the same everywhere on a rigid body, so the offset does not change it. Linear acceleration does change: an offset point sees extra $\boldsymbol{\omega}\times(\boldsymbol{\omega}\times\mathbf{r}) + \dot{\boldsymbol{\omega}}\times\mathbf{r}$ terms. 3. It is continuous and has no singularity. Yaw is also unobservable from an IMU without a magnetometer, and walking does not need it. 4. $\left({}^{\text{base}}\mathbf{T}_{\text{camera}}\,{}^{\text{camera}}\mathbf{T}_{\text{optical}}\right)^{-1}$, with each factor built from the joint's `xyz` and `rpy`.
## Further reading - K. M. Lynch and F. C. Park, *Modern Robotics: Mechanics, Planning, and Control*, Cambridge University Press, 2017, ch. 3 (rigid-body motions). [Free PDF](http://hades.mech.northwestern.edu/index.php/Modern_Robotics) - B. Siciliano, L. Sciavicco, L. Villani and G. Oriolo, *Robotics: Modelling, Planning and Control*, Springer, 2009, ch. 2. - [REP-103: Standard Units of Measure and Coordinate Conventions](https://www.ros.org/reps/rep-0103.html) and [REP-105: Coordinate Frames for Mobile Platforms](https://www.ros.org/reps/rep-0105.html). - J. Solà, "Quaternion kinematics for the error-state Kalman filter", arXiv:1711.02508, 2017. - [ROS 2 and the robot description](../software/ros2.md) in this wiki. --- # Kinematics Kinematics is the geometry of motion, without forces. Forward kinematics maps joint angles to the foot position. Inverse kinematics maps a foot position back to joint angles. The Jacobian links joint velocities to foot velocities. This page derives all three for the Strido leg, including its knee linkage. ::: tip In Strido - **Leg model:** `packages/strido-core/src/strido/leg.py` (`foot_position`, `inverse_kinematics`, `calf_from_crank`, `crank_from_calf`, `transmission_angles`), mirrored in C++ in `firmware/strido_esp32s3/lib/strido_core/`. - **Dimensions:** `hardware/kinematics.yaml` (`legs_linkage`, `legs_base`), measured from the CAD. **Limits:** `hardware/joint_limits.yaml`. - **Practical guide:** [Kinematics and gaits](../software/kinematics-and-gaits.md) shows how the gait uses these functions. ::: ## The leg as a kinematic chain Each leg has three revolute joints (`leg.py` docstring; the conventions match the Unitree Go2): | Joint | Axis | Zero | Positive | |---|---|---|---| | hip $q_1$ | $+x$ | leg vertical | foot towards $+y$ | | thigh $q_2$ | $+y$ | femur straight down | knee backwards | | calf $q_3$ | $+y$, relative to femur | calf in line with femur | negative = knee bent | From `hardware/kinematics.yaml` (front-left leg, rounded): the hip is at $\mathbf{h} = (0.1197, 0.039, 0)\,\mathrm{m}$ in `base_link`, the leg plane sits $d = 38.3\,\mathrm{mm}$ outboard of the hip ($40.2\,\mathrm{mm}$ on the rear legs), the thigh is $l_1 = 130.0\,\mathrm{mm}$ and the calf is $l_2 = 124.1\,\mathrm{mm}$ to the foot centre. The thigh and calf move in a vertical **leg plane**. The hip joint tilts that plane about $x$. So the problem splits into two easier ones: a planar two-link arm, and one rotation. ## Forward kinematics In the leg plane, let $u$ point forward and $v$ point up, both measured from the thigh pivot. Write $s_2 = \sin q_2$ and $s_{23} = \sin(q_2 + q_3)$, and similarly for cosines. The knee and the foot are at: $$ \mathbf{k} = \begin{bmatrix} -l_1 s_2 \\ -l_1 c_2 \end{bmatrix}, \qquad \begin{bmatrix} u \\ v \end{bmatrix} = \begin{bmatrix} -l_1 s_2 - l_2 s_{23} \\ -l_1 c_2 - l_2 c_{23} \end{bmatrix} $$ The angles add because $q_3$ is measured relative to the femur, so the calf's absolute angle is $q_2 + q_3$. The signs are negative because zero means "pointing down" and positive $q_2$ swings the foot backwards (towards $-u$). Next, the hip rotation. The foot in the hip frame, before abduction, is $(u, d, v)$. Rotating about $x$ by $q_1$ and adding the hip position gives: $$ \mathbf{p} = \mathbf{h} + \mathbf{R}_x(q_1)\begin{bmatrix} u \\ d \\ v \end{bmatrix} = \mathbf{h} + \begin{bmatrix} u \\ d\cos q_1 - v\sin q_1 \\ d\sin q_1 + v\cos q_1 \end{bmatrix} $$ This is `foot_position` line for line. For the frame conventions, see [frames and rotations](frames-and-rotations.md). ## Inverse kinematics We are given a foot target $\mathbf{p}$ and need $(q_1, q_2, q_3)$. Let $\mathbf{r} = \mathbf{p} - \mathbf{h} = (r_x, r_y, r_z)$. ### Hip abduction A rotation about $x$ leaves the $x$ component alone and preserves length in the $y$–$z$ plane: $$ r_y^2 + r_z^2 = d^2 + v^2 $$ The foot is below the hip, so we take the negative root: $$ v = -\sqrt{r_y^2 + r_z^2 - d^2} $$ If the term under the root is not positive, the target lies inside the cylinder of radius $d$ around the hip axis and cannot be reached. The direction of $(r_y, r_z)$ is the direction of $(d, v)$ rotated by $q_1$, so: $$ q_1 = \operatorname{atan2}(r_z, r_y) - \operatorname{atan2}(v, d) $$ ### The planar two-link problem: law of cosines Now set $u = r_x$ and solve the two-link arm. The thigh, the calf and the line from pivot to foot form a triangle with sides $l_1$, $l_2$ and $\rho = \sqrt{u^2+v^2}$. The law of cosines at the knee gives the knee angle. With our convention (the knee interior angle is $\pi + q_3$): $$ \rho^2 = l_1^2 + l_2^2 + 2 l_1 l_2 \cos q_3 \quad\Longrightarrow\quad \cos q_3 = \frac{u^2 + v^2 - l_1^2 - l_2^2}{2 l_1 l_2} $$ There are two solutions, $\pm\arccos$, for knee-forward and knee-backward. Strido uses $q_3 = -\arccos(\cdot)$, a bent knee pointing backwards. If $\lvert\cos q_3\rvert > 1$, the target is out of reach. The thigh angle is the direction to the foot minus the angle the calf adds inside the triangle: $$ q_2 = \operatorname{atan2}(-u, -v) - \operatorname{atan2}\!\left(l_2 \sin q_3,\ l_1 + l_2\cos q_3\right) $$ The minus signs in the first term come from the "zero = straight down, positive = backwards" convention. Use `atan2`, never `atan`, so that the quadrant is correct. ### The knee four-bar The knee servo does not drive $q_3$ directly. A parallelogram (arm $24.0\,\mathrm{mm}$, short link $28.3\,\mathrm{mm}$, crank $24.0\,\mathrm{mm}$) copies the servo angle onto a crank $\psi$ at the thigh pivot. A four-bar (crank $26.9\,\mathrm{mm}$, long link $131\,\mathrm{mm}$, tibia crank $24.0\,\mathrm{mm}$, and the femur $130.0\,\mathrm{mm}$ as ground link) then turns the tibia. Because the four-bar's ground link is the femur itself: $$ q_3 = f(q_2, \psi) $$ Moving the thigh alone also moves the knee. `crank_from_calf` inverts this geometrically. The far end of the crank lies on a circle of radius $26.9\,\mathrm{mm}$ around the pivot, and also on a circle of radius $131\,\mathrm{mm}$ (the long link) around the tibia pin. Intersecting the two circles gives two candidates, and the assembly branch picks one. `circle_intersection` returns `None` when the circles do not meet. That means the linkage cannot be assembled in that pose. ```mermaid graph LR S[knee servo angle] -->|parallelogram 1:1| C[crank ψ] C -->|four-bar, depends on q2| K[knee q3] T[thigh servo q2] --> K ``` ## The Jacobian The Jacobian maps joint rates to foot velocity, $\dot{\mathbf{p}} = \mathbf{J}(\mathbf{q})\,\dot{\mathbf{q}}$. Differentiating the planar forward kinematics: $$ \mathbf{J}_{\text{plane}} = \frac{\partial (u, v)}{\partial (q_2, q_3)} = \begin{bmatrix} -l_1 c_2 - l_2 c_{23} & -l_2 c_{23} \\ \phantom{-}l_1 s_2 + l_2 s_{23} & \phantom{-}l_2 s_{23} \end{bmatrix}, \qquad \det \mathbf{J}_{\text{plane}} = -\,l_1 l_2 \sin q_3 $$ The full $3\times 3$ Jacobian adds the hip column, $\partial\mathbf{p}/\partial q_1 = \hat{\mathbf{x}} \times (\mathbf{p} - \mathbf{h})$, and rotates the planar columns by $\mathbf{R}_x(q_1)$. The Jacobian also governs forces through $\boldsymbol{\tau} = \mathbf{J}^\top\mathbf{F}$ (see [statics and dynamics](statics-and-dynamics.md)). For the drive, the chain rule adds one more factor. The knee servo's rate is $\dot\psi$, and $$ \dot q_3 = \frac{\partial f}{\partial q_2}\dot q_2 + \frac{\partial f}{\partial \psi}\dot\psi $$ ## Workspace and singularities Ignoring joint limits, the planar foot reaches an annulus: $$ \lvert l_1 - l_2\rvert \le \rho \le l_1 + l_2 \quad\Rightarrow\quad 5.9\,\mathrm{mm} \le \rho \le 254.1\,\mathrm{mm} $$ The joint limits and self-collisions cut this down. `hardware/joint_limits.yaml` limits $q_3$ to $[-150^\circ, -45^\circ]$ and the front $q_2$ to $[-45^\circ, 125^\circ]$, found by sweeping the posed CAD for collisions on a $5^\circ$ grid. A **singularity** is a pose where $\det\mathbf{J} = 0$. There the foot cannot move in some direction, however fast the joints turn, and inverse kinematics becomes ill-conditioned. For the planar leg this happens at $\sin q_3 = 0$: the leg fully straight ($q_3 = 0$, on the outer workspace boundary) or fully folded. The calf limit keeps $\lvert\sin q_3\rvert \ge \sin 30^\circ = 0.5$ (at the $-150^\circ$ end; at least $0.71$ at the $-45^\circ$ end), so the leg never gets close. This is one reason the stand pose keeps the knee well bent. The four-bar has its own singularities. When the long link lines up with the crank or with the tibia crank (a transmission angle near $0$ or $180^\circ$), $\partial f/\partial\psi$ blows up or vanishes. `transmission_angles` measures both angles, and `joint_limits.yaml` requires at least $25^\circ$ (`transmission_min_deg`). ## Worked example: the stand pose The stand pose puts each foot straight below its hip, $0.17\,\mathrm{m}$ down (`stand_pose(height=0.17)` in `strido/gait.py`). For the front-left leg, $\mathbf{r} = (0,\ 0.0383,\ -0.170)\,\mathrm{m}$. **Hip.** $v = -\sqrt{0.0383^2 + 0.170^2 - 0.0383^2} = -0.170$, so $q_1 = \operatorname{atan2}(-0.170, 0.0383) - \operatorname{atan2}(-0.170, 0.0383) = 0$. **Knee.** With $u = 0$: $$ \cos q_3 = \frac{0.170^2 - 0.1300^2 - 0.1241^2}{2 \cdot 0.1300 \cdot 0.1241} = \frac{-0.00339}{0.03226} = -0.105 \quad\Rightarrow\quad q_3 = -96.0^\circ $$ **Thigh.** $l_2\sin q_3 = -0.1234$ and $l_1 + l_2\cos q_3 = 0.1170$, so $$ q_2 = \operatorname{atan2}(0, 0.170) - \operatorname{atan2}(-0.1234, 0.1170) = 0 + 46.5^\circ = 46.5^\circ $$ These match `stand_pose()`, which returns $(0^\circ, 46.53^\circ, -96.03^\circ)$ for every leg. The calf's absolute angle is $q_2 + q_3 = -49.5^\circ$: the knee sits behind and above the foot, and the calf leans forward to reach it. **Jacobian.** At this pose, $$ \mathbf{J}_{\text{plane}} = \begin{bmatrix} -0.170 & -0.0806 \\ 0 & -0.0943 \end{bmatrix}\mathrm{m/rad}, \qquad \det = 0.0160\,\mathrm{m^2} $$ A thigh rate of $1\,\mathrm{rad/s}$ moves the foot $0.17\,\mathrm{m/s}$ backwards and not at all vertically. To lift the foot you mostly need the knee. **Linkage.** `leg.py` gives a crank angle of $\psi = 38.9^\circ$ and transmission angles of $84^\circ$ and $86^\circ$, close to the ideal $90^\circ$. The sensitivity is $\partial q_3/\partial\psi = -1.12$: the knee turns $1.12^\circ$ for each degree of crank. ## Check yourself 1. Why can the hip angle be solved before, and separately from, the thigh and knee? 2. What does $\lvert\cos q_3\rvert > 1$ mean physically? 3. At the stand pose, which joint mainly moves the foot forward, and which mainly moves it vertically? 4. Why do the transmission angles matter even when $\det\mathbf{J}_{\text{plane}}$ is far from zero?
Answers 1. The hip axis is the $x$ axis, and $q_2$, $q_3$ never change the foot's distance from it. So $r_y^2 + r_z^2$ depends only on the planar depth $v$ and the fixed offset $d$. 2. The target is farther than $l_1 + l_2$ or closer than $\lvert l_1 - l_2\rvert$, so no triangle exists. 3. The thigh moves it forward and back ($J_{11} = -0.170$, $J_{21} = 0$). Only the knee column has a vertical component. 4. They describe the *drive* linkage, not the leg. Near $0^\circ$ or $180^\circ$ the knee servo loses leverage and the crank–knee map becomes ill-conditioned, whatever the leg Jacobian says.
## Further reading - K. M. Lynch and F. C. Park, *Modern Robotics*, Cambridge University Press, 2017, ch. 4–6 (forward kinematics, velocity kinematics, inverse kinematics). - B. Siciliano et al., *Robotics: Modelling, Planning and Control*, Springer, 2009, ch. 2–3. - R. L. Norton, *Design of Machinery*, McGraw-Hill, ch. 4 (four-bar position analysis and transmission angle). - [Kinematics and gaits](../software/kinematics-and-gaits.md) in this wiki. --- # Legged locomotion How a four-legged robot stays up while it moves: gaits and their timing, the support polygon, static and dynamic stability, and the foot-placement rule that Strido's trot generator uses. ::: tip In Strido The scripted gait is `TrotGait` in `packages/strido-core/src/strido/gait.py`, shared by MuJoCo, Gazebo and the real robot (via `ros2/strido_control/strido_control/gait_node.py`). The learned policy (`packages/strido-sim/`) receives a clock with the same $0.4\,\mathrm{s}$ period (`GAIT_PERIOD_S` in `packages/strido-core/src/strido/policy.py`). Practical usage is in [Kinematics and gaits](../software/kinematics-and-gaits.md). ::: ## Gaits as timing patterns A **gait** is a periodic pattern of foot contacts. Each leg $i$ runs through one **cycle** of period $T$ and is described by a phase $\phi_i \in [0, 1)$: $$ \phi_i(t) = \left( \frac{t}{T} + \delta_i \right) \bmod 1 $$ where $\delta_i$ is that leg's **phase offset**. The leg is in **stance** (foot on the ground, pushing) while $\phi_i < \beta$ and in **swing** (foot in the air, returning) otherwise. The **duty factor** $\beta$ is the fraction of the cycle spent in stance: $$ \beta = \frac{T_\mathrm{stance}}{T}, \qquad T_\mathrm{stance} = \beta T, \qquad T_\mathrm{swing} = (1-\beta) T $$ Gaits differ only in $\beta$ and the offsets $\delta_i$: | Gait | Duty factor $\beta$ | Offsets (FL, FR, RL, RR) | Feet on ground | |---|---|---|---| | Walk (crawl) | $\ge 0.75$ | $0, \tfrac12, \tfrac34, \tfrac14$ | always $\ge 3$ | | Trot | $\approx 0.5$ | $0, \tfrac12, \tfrac12, 0$ | 2 (a diagonal pair) | | Pace | $\approx 0.5$ | $0, \tfrac12, 0, \tfrac12$ | 2 (a lateral pair) | | Bound | $\approx 0.5$ | $0, 0, \tfrac12, \tfrac12$ | 2 (front or rear pair) | With $\beta \ge 0.75$ only one leg is ever in the air, so the walk can be **statically stable**. With $\beta = 0.5$ two legs are always in the air; the trot relies on **dynamic stability**: the body would tip if frozen, but each pair lands before it falls far. ## Centre of mass and the support polygon The **centre of mass** (CoM) of bodies with masses $m_k$ at positions $\mathbf{p}_k$ is $$ \mathbf{p}_\mathrm{CoM} = \frac{\sum_k m_k \mathbf{p}_k}{\sum_k m_k} $$ The **support polygon** is the convex hull of the contact points projected onto the ground. A robot standing still (or moving slowly enough that inertia is negligible) is statically stable if the ground projection of the CoM lies inside this polygon: gravity then produces no net tipping moment about any polygon edge. The **static stability margin** is the shortest distance from the projected CoM to the polygon's boundary: $$ S = \min_{e \in \text{edges}} d(\mathbf{p}_\mathrm{CoM}^{xy}, e) $$ $S > 0$ means stable; larger $S$ tolerates bigger pushes and modelling errors. In a trot the support "polygon" degenerates to the line segment between the two stance feet, so $S \le 0$ almost always: static stability is impossible and the gait must be dynamic. ## Beyond statics: ZMP and the capture point When the body accelerates, inertial forces add to gravity. The **zero-moment point** (ZMP) is the point on the ground where the horizontal moments of gravity plus inertia vanish. For a point mass at height $h$ with horizontal acceleration $\ddot{\mathbf{x}}$ (the *linear inverted pendulum* model, constant height): $$ \mathbf{p}_\mathrm{ZMP} = \mathbf{x}_\mathrm{CoM} - \frac{h}{g}\,\ddot{\mathbf{x}}_\mathrm{CoM} $$ The contact forces can only push, so the ZMP must stay inside the support polygon; if the plan demands a ZMP outside it, a foot rolls or lifts and the robot tips. ZMP planning is the classic tool for walking humanoids. The same pendulum gives the **capture point** (instantaneous capture point, divergent component of motion). Its dynamics $\ddot{x} = \omega^2 (x - p)$ with $\omega = \sqrt{g/h}$ have an unstable mode, and the point $$ \boldsymbol{\xi} = \mathbf{x}_\mathrm{CoM} + \frac{\dot{\mathbf{x}}_\mathrm{CoM}}{\omega} $$ is where a foot must be placed for the body to come to rest above it. Intuition: the faster you are moving, the further ahead you must step to stop. **Worked example.** Strido's default body height is $h = 0.17\,\mathrm{m}$ (hip axis above the feet, `GaitConfig.body_height`). Then $$ \omega = \sqrt{\frac{9.81}{0.17}} \approx 7.6\,\mathrm{s^{-1}}, \qquad \frac{1}{\omega} \approx 0.13\,\mathrm{s} $$ At the maximum forward command $v = 0.30\,\mathrm{m/s}$ the capture point is about $0.30 \times 0.13 \approx 0.04\,\mathrm{m}$ ahead of the CoM. The pendulum's time constant ($0.13\,\mathrm{s}$) is comparable to the stance time ($0.2\,\mathrm{s}$, below), which is why the gait has to keep stepping: a frozen trot would tip within a fraction of a second. ## Raibert-style foot placement Marc Raibert's hopping and running machines decomposed control into three parts: hopping height, body attitude, and **forward speed via foot placement**. The idea: during stance the body moves a distance $v\,T_\mathrm{stance}$ over the foot. If the foot lands at the centre of that path, the body passes symmetrically over it and speed is preserved (the *neutral point*): $$ x_\mathrm{f} = \frac{v\,T_\mathrm{stance}}{2} + k\,(v - v_\mathrm{des}) $$ The feedback term $k(v - v_\mathrm{des})$ moves the foot further forward to brake and further back to accelerate. It is a capture-point rule in disguise. ## How Strido's trot generator works `TrotGait` is a deliberately simple, deterministic, open-loop version of the above (no feedback term, $k = 0$). Its defaults in `GaitConfig`: | Parameter | Value | Meaning | |---|---|---| | `period` | $T = 0.40\,\mathrm{s}$ | full cycle | | `duty` | $\beta = 0.5$ | trot: stance and swing equal | | `step_height` | $h_\mathrm{s} = 0.035\,\mathrm{m}$ | swing apex | | `body_height` | $0.17\,\mathrm{m}$ | hip axis above the feet | | `max_linear` | $\lvert v_x \rvert \le 0.30$, $\lvert v_y \rvert \le 0.15\,\mathrm{m/s}$ | command clamp | | `max_yaw_rate` | $\lvert \omega_z \rvert \le 1.0\,\mathrm{rad/s}$ | command clamp | `PHASE_OFFSET` gives FL and RR $\delta = 0$ and FR and RL $\delta = \tfrac12$: the diagonals alternate. **Per-foot velocity.** For a command $(v_x, v_y, \omega_z)$ and a foot's nominal position $(p_x, p_y)$ in the body frame, the body moves over that foot with the rigid-body velocity $\mathbf{v} + \boldsymbol{\omega} \times \mathbf{p}$, so the stance foot moves at $-\mathbf{v}_i$ relative to the body: $$ \mathbf{v}_i = \begin{pmatrix} v_x - \omega_z p_y \\ v_y + \omega_z p_x \\ 0 \end{pmatrix}, \qquad \mathbf{s}_i = \mathbf{v}_i\, \beta T $$ where $\mathbf{s}_i$ is the stride over one stance. **Stance.** With normalised stance progress $s = \phi/\beta \in [0, 1)$, the foot slides from half a stride ahead to half a stride behind: $$ \mathbf{p}_i = \mathbf{p}_i^\mathrm{nom} + \mathbf{s}_i \left(\tfrac12 - s\right) $$ **Swing.** With $s = (\phi - \beta)/(1-\beta)$, the foot returns and lifts on a half-sine: $$ \mathbf{p}_i = \mathbf{p}_i^\mathrm{nom} + \mathbf{s}_i \left(s - \tfrac12\right) + \begin{pmatrix} 0 \\ 0 \\ h_\mathrm{s} \sin(\pi s) \end{pmatrix} $$ So each foot lands at $+\mathbf{s}_i/2$: exactly Raibert's neutral point $v T_\mathrm{stance}/2$. Foot targets are then converted to 12 joint angles by inverse kinematics (see [Kinematics](kinematics.md)) and clamped to the joint and knee-crank limits (`clamp_leg`). With a zero command the feet stay at their nominal stance. **Worked example.** Forward command $v_x = 0.30\,\mathrm{m/s}$: $$ T_\mathrm{stance} = 0.5 \times 0.40 = 0.20\,\mathrm{s}, \qquad s_x = 0.30 \times 0.20 = 0.06\,\mathrm{m} $$ Each foot sweeps from $+30\,\mathrm{mm}$ to $-30\,\mathrm{mm}$ about its nominal position, then swings back in $0.20\,\mathrm{s}$ peaking $35\,\mathrm{mm}$ up. Its swing speed relative to the body is $0.06/0.20 = 0.30\,\mathrm{m/s}$ on average and $\tfrac{\pi}{2} \times 0.035 / 0.10 \approx 0.55\,\mathrm{m/s}$ peak vertical speed. For a pure turn, $\omega_z = 1.0\,\mathrm{rad/s}$: the front-left foot sits at $p_x \approx 0.120\,\mathrm{m}$, $p_y \approx 0.039 + 0.038 = 0.077\,\mathrm{m}$ (hip and thigh offsets from `packages/strido-core/src/strido/geometry.py`), so $\mathbf{v} = (-0.077, 0.120)\,\mathrm{m/s}$ and the stride is $(-15, 24)\,\mathrm{mm}$: the feet sweep along a circle about the body centre. ```mermaid gantt dateFormat x axisFormat %L ms title Trot timing (T = 400 ms, duty 0.5) section FL + RR stance :0, 200 swing :200, 400 section FR + RL swing :0, 200 stance :200, 400 ``` **Limitations.** Being open-loop, the generator does not react to pushes, slips or tilt: nothing moves the foot further forward when the body is too fast. That is the gap the learned policy fills — it sees the IMU and can learn feedback (see [Reinforcement learning](reinforcement-learning.md)). ## Check yourself 1. A gait has $\beta = 0.8$. How many feet are on the ground at any instant, and can it be statically stable?
Answer Each leg is airborne for 20 % of the cycle; with staggered offsets only one leg swings at a time, so at least three feet are down. Yes, if the CoM stays inside the support triangle.
2. Why is the static stability margin of a trot almost always non-positive?
Answer Only two diagonal feet support the body, so the support polygon is a line segment; the CoM projection lies on it only by chance, never strictly inside it.
3. In `TrotGait`, what is the stride length at $v_x = 0.15\,\mathrm{m/s}$, and where does the foot land?
Answer $s = 0.15 \times 0.20 = 0.03\,\mathrm{m}$; the foot lands $15\,\mathrm{mm}$ ahead of nominal.
4. What does raising `body_height` do to the capture-point time constant?
Answer $1/\omega = \sqrt{h/g}$ grows, so the body tips more slowly but the capture point lies further ahead for the same speed.
5. Which Raibert term is missing from Strido's trot, and what does that mean?
Answer The speed-feedback term $k(v - v_\mathrm{des})$. The gait cannot correct speed errors or disturbances.
## Further reading - M. H. Raibert, *Legged Robots That Balance*, MIT Press, 1986. - B. Siciliano and O. Khatib (eds.), *Springer Handbook of Robotics*, 2nd ed., 2016 — chapters on legged robots and walking. - J. Pratt, J. Carff, S. Drakunov and A. Goswami, "Capture Point: A Step toward Humanoid Push Recovery", *IEEE-RAS Humanoids*, 2006. - M. Vukobratović and B. Borovac, "Zero-Moment Point — Thirty Five Years of Its Life", *International Journal of Humanoid Robotics*, 2004. - R. B. McGhee and A. A. Frank, "On the stability properties of quadruped creeping gaits", *Mathematical Biosciences*, 1968. --- # Reinforcement learning for locomotion How a walking controller can be learned by trial and error in simulation: Markov decision processes, returns and value functions, the policy-gradient theorem, GAE, PPO, and the exact observation, action and reward used for Strido, plus how domain randomisation narrows the sim-to-real gap. ::: tip In Strido The environment is `StridoEnv` in `packages/strido-sim/src/strido_sim/rl/env.py` (Gymnasium + MuJoCo); training is `rl/train.py` (stable-baselines3 PPO on CPU). Shared constants (`CONTROL_DT`, `ACTION_SCALE`, `COMMAND_RANGES`) live in `packages/strido-core/src/strido/policy.py`, and the robot runs the exported network in `ros2/strido_control/strido_control/policy_node.py`. Workflow: [Simulation and RL](../software/simulation-and-rl.md) and [docs/simulation.md](../../simulation.md). ::: ## The Markov decision process An MDP is a tuple $(\mathcal{S}, \mathcal{A}, P, r, \gamma)$: states, actions, transition probabilities $P(s' \mid s, a)$, reward $r(s, a)$ and discount $\gamma \in [0, 1)$. At each step the agent observes $s_t$, samples $a_t \sim \pi_\theta(a \mid s_t)$ from a **policy** with parameters $\theta$ (a neural network), and the environment returns $r_t$ and $s_{t+1}$. *Markov* means $s_{t+1}$ depends only on $(s_t, a_t)$, not on earlier history. A real robot only sees part of the state (a POMDP); in practice we add enough history (here, the previous action and a gait clock) to make the observation nearly Markov. ## Returns and discounting The **return** from time $t$ is the discounted sum of future rewards: $$ G_t = \sum_{k=0}^{\infty} \gamma^k\, r_{t+k} $$ Discounting keeps the sum finite and expresses a preference for sooner reward. The effective horizon is about $1/(1-\gamma)$ steps. **Worked example:** Strido trains with $\gamma = 0.99$ (`train.py`) at a policy rate of $50\,\mathrm{Hz}$ ($\Delta t = 0.02\,\mathrm{s}$), so the horizon is about $100$ steps $= 2\,\mathrm{s}$, i.e. five gait cycles of $0.4\,\mathrm{s}$: long enough to value not falling over, short enough to learn quickly. The objective is the expected return $J(\theta) = \mathbb{E}_{\tau \sim \pi_\theta}[G_0]$ over trajectories $\tau$. ## Value functions $$ V^\pi(s) = \mathbb{E}_\pi[G_t \mid s_t = s], \qquad Q^\pi(s, a) = \mathbb{E}_\pi[G_t \mid s_t = s, a_t = a] $$ They satisfy the **Bellman equation** $$ V^\pi(s) = \mathbb{E}_{a \sim \pi,\, s' \sim P}\left[ r(s, a) + \gamma V^\pi(s') \right] $$ The **advantage** $A^\pi(s, a) = Q^\pi(s, a) - V^\pi(s)$ measures how much better action $a$ is than the policy's average in state $s$. ## The policy-gradient theorem We want $\nabla_\theta J$ without differentiating through the (unknown, non-differentiable) physics. The likelihood-ratio trick gives $$ \nabla_\theta J(\theta) = \mathbb{E}_{\pi_\theta}\left[ \sum_t \nabla_\theta \log \pi_\theta(a_t \mid s_t)\, A^{\pi}(s_t, a_t) \right] $$ In words: increase the log-probability of actions that turned out better than average, decrease it for worse ones. Only samples from the simulator and the gradient of the network's own log-probability are needed. Using the advantage (rather than $G_t$) subtracts a baseline $V(s)$, which leaves the gradient unbiased but greatly reduces its variance. ## Generalised advantage estimation (GAE) The advantage is estimated from a learned critic $V_\phi$. The one-step **TD error** is $$ \delta_t = r_t + \gamma V_\phi(s_{t+1}) - V_\phi(s_t) $$ GAE takes an exponentially weighted sum of these: $$ \hat{A}_t = \sum_{l=0}^{\infty} (\gamma \lambda)^l\, \delta_{t+l} $$ $\lambda = 0$ gives the low-variance but biased one-step estimate; $\lambda = 1$ gives the unbiased but high-variance Monte Carlo return minus $V$. Strido uses $\lambda = 0.95$ (`gae_lambda` in `train.py`). On termination (a fall) the bootstrap $V_\phi(s_{t+1})$ is zero; on time-limit truncation it is kept, since the robot could have continued. ## PPO: proximal policy optimisation Plain policy gradient takes one step per batch, and a step that is too large can wreck the policy. PPO reuses each batch for several epochs while preventing the new policy from drifting far from the one that collected the data. With the probability ratio $$ \rho_t(\theta) = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\theta_\mathrm{old}}(a_t \mid s_t)} $$ the **clipped surrogate objective** is $$ L^\mathrm{CLIP}(\theta) = \mathbb{E}_t\left[ \min\left( \rho_t \hat{A}_t,\; \operatorname{clip}(\rho_t, 1 - \epsilon, 1 + \epsilon)\, \hat{A}_t \right) \right] $$ For a good action ($\hat{A} > 0$) the gain stops once $\rho > 1 + \epsilon$; for a bad one ($\hat{A} < 0$) once $\rho < 1 - \epsilon$. The $\min$ makes it a pessimistic bound, so there is no incentive to move further. The full loss adds a critic regression term and, optionally, an entropy bonus that rewards keeping the policy random enough to explore: $$ L(\theta, \phi) = -L^\mathrm{CLIP}(\theta) + c_1\, \mathbb{E}_t\left[ \left( V_\phi(s_t) - \hat{G}_t \right)^2 \right] - c_2\, \mathbb{E}_t\left[ \mathcal{H}\left( \pi_\theta(\cdot \mid s_t) \right) \right] $$ For continuous actions the policy is a diagonal Gaussian $\pi_\theta(a \mid s) = \mathcal{N}(\mu_\theta(s), \operatorname{diag}(\boldsymbol{\sigma}^2))$; at deployment the mean $\mu_\theta(s)$ is used. **Strido's settings** (`train.py`): $\epsilon = 0.2$, $\gamma = 0.99$, $\lambda = 0.95$, learning rate $3 \times 10^{-4}$, $1024$ steps per environment per rollout, minibatch $2048$, 5 epochs per rollout, $c_2 = 0$ (`ent_coef=0.0`: exploration comes only from the Gaussian's own spread, `log_std_init=-1.0`), $2 \times 10^7$ total steps by default. Observations and rewards are normalised with running statistics (`VecNormalize`, observations clipped at $\pm 10$); the normaliser's mean and variance are exported with the weights so the robot applies the same scaling. **Worked example.** Suppose an action had $\hat{A}_t = 0.5$ and after an update $\rho_t = 1.35$. Unclipped the term would be $0.675$; clipped it is $1.2 \times 0.5 = 0.6$, and the gradient through this sample is zero, so PPO stops pushing that action's probability up. ## The Strido environment **Timing.** Policy at $50\,\mathrm{Hz}$ (`CONTROL_DT = 0.02`), physics at $500\,\mathrm{Hz}$ (10 substeps). Episodes last at most $1000$ steps ($20\,\mathrm{s}$). Servos are modelled as MJCF position actuators with a $3.43\,\mathrm{N\,m}$ torque limit (SPT5435LV stall torque at $6\,\mathrm{V}$). **Observation** ($23$ values, `OBSERVATION_SIZE`): $$ \mathbf{o}_t = \left[ \boldsymbol{\omega}_b,\; \mathbf{g}_b,\; \mathbf{c},\; \mathbf{a}_{t-1},\; \sin\varphi_t,\; \cos\varphi_t \right] \in \mathbb{R}^{3+3+3+12+2} $$ - $\boldsymbol{\omega}_b$: body angular velocity from the IMU gyro; - $\mathbf{g}_b = \mathbf{R}^\top (0, 0, -1)^\top$: the unit gravity direction in the base frame, from the IMU quaternion (encodes roll and pitch, not yaw); - $\mathbf{c} = (v_x, v_y, \omega_z)$: the velocity command, sampled per episode from $v_x \in [-0.15, 0.30]\,\mathrm{m/s}$, $v_y \in [-0.10, 0.10]\,\mathrm{m/s}$, $\omega_z \in [-0.6, 0.6]\,\mathrm{rad/s}$, and zero with probability $0.2$; - $\mathbf{a}_{t-1}$: the previous action; - a gait clock $\varphi_t = 2\pi t / T$ with $T = 0.4\,\mathrm{s}$ (`GAIT_PERIOD_S`), matching the scripted trot. Every input is available on the real robot from `/imu/data` and `/cmd_vel`; there is no base velocity or joint feedback (the servos have none). **Action.** $\mathbf{a}_t \in [-1, 1]^{12}$ are offsets from the stand pose $\mathbf{q}_0$: $$ \mathbf{q}^\mathrm{target}_t = \operatorname{clamp}\left( \operatorname{slew}\left( \mathbf{q}_0 + 0.3\, \mathbf{a}_t \right) \right) $$ `ACTION_SCALE` $= 0.3\,\mathrm{rad}$; `slew_limit` caps joint speed at $6\,\mathrm{rad/s}$ (the firmware's limit), and `clamp_targets` applies the joint and knee-crank limits in the same order as the firmware, so the policy never learns to rely on motions the robot would refuse. **Reward.** With base-frame linear velocity $\mathbf{v}_b$, angular velocity $\boldsymbol{\omega}_b$, actuator forces $\boldsymbol{\tau}$ and base height $z$: $$ \begin{aligned} r_t ={}& 1.0\, \exp\left( -\frac{\lVert \mathbf{v}_b^{xy} - \mathbf{c}^{xy} \rVert^2}{0.01} \right) + 0.8\, \exp\left( -\frac{(\omega_{b,z} - c_{\omega})^2}{0.05} \right) \\ &- 2.0\, v_{b,z}^2 - 0.05\, \lVert \boldsymbol{\omega}_b^{xy} \rVert^2 - 0.01\, \lVert \mathbf{a}_t - \mathbf{a}_{t-1} \rVert^2 - 0.002\, \lVert \boldsymbol{\tau} \rVert^2 - 2.0 \max(0,\, 0.15 - z) \end{aligned} $$ The two exponential terms reward tracking the command (at most $1.8$ per step); the penalties discourage bouncing, wobbling, jerky actions, high torque (heat, battery) and crouching below $0.15\,\mathrm{m}$. The code multiplies by `CONTROL_DT * 50`, which equals $1$ at $50\,\mathrm{Hz}$. **Termination.** The episode ends with a $-2$ penalty when the base drops below $0.09\,\mathrm{m}$ or the body tilts so that $-g_{b,z} < 0.6$ (about $53^\circ$ roll or pitch). **Worked example.** Commanded $v_x = 0.20\,\mathrm{m/s}$, achieved $0.15\,\mathrm{m/s}$: the tracking term is $\exp(-0.05^2/0.01) = \exp(-0.25) \approx 0.78$. The width $0.01$ means errors around $0.1\,\mathrm{m/s}$ already cost most of the reward ($\exp(-1) \approx 0.37$). ## Domain randomisation and the sim-to-real gap A policy that exploits simulator quirks fails on hardware: the **sim-to-real gap**. Sources include unmodelled servo dynamics and backlash, friction, mass and inertia errors, sensor noise and latency. **Domain randomisation** trains on a distribution of simulators so the real robot is, hopefully, just one more sample: $$ \max_\theta\; \mathbb{E}_{\xi \sim p(\xi)}\left[ J_\xi(\theta) \right] $$ where $\xi$ are physics parameters. `StridoEnv._randomise` samples per episode: | Parameter | Range | |---|---| | servo stiffness $k_p$ | $\times\, U(0.85, 1.15)$ | | base mass | $\times\, U(0.90, 1.15)$ | | floor and foot friction | $\times\, U(0.6, 1.2)$ | | gyro noise | $\mathcal{N}(0, 0.05^2)$ per step | | gravity-vector noise | $\mathcal{N}(0, 0.02^2)$ per step | | initial joint angles | $\mathbf{q}_0 + \mathcal{N}(0, 0.02^2)$ | Because the policy cannot identify which $\xi$ it is in from a single observation, it learns behaviour that is robust across all of them. Other tools against the gap (not all used here): actuator models fitted to measured servo responses, observation latency, push perturbations, and terrain curricula. ## Check yourself 1. What is the effective horizon in seconds for $\gamma = 0.99$ at $50\,\mathrm{Hz}$, and what if the policy ran at $100\,\mathrm{Hz}$ with the same $\gamma$?
Answer About $100$ steps $= 2\,\mathrm{s}$; at $100\,\mathrm{Hz}$ it halves to $1\,\mathrm{s}$, so $\gamma$ should rise to about $0.995$ to keep the same horizon.
2. Why does subtracting $V(s)$ in the policy gradient not bias it?
Answer $\mathbb{E}_{a \sim \pi}[\nabla_\theta \log \pi_\theta(a \mid s)] = \nabla_\theta \sum_a \pi_\theta(a \mid s) = \nabla_\theta 1 = 0$, so any state-only baseline has zero expected contribution.
3. With $\epsilon = 0.2$ and $\hat{A}_t < 0$, for which $\rho_t$ does the PPO term stop giving a gradient?
Answer For $\rho_t < 0.8$: the action's probability has already been reduced enough.
4. Why does the observation contain the gravity direction rather than Euler angles including yaw?
Answer Walking does not depend on heading, and yaw from a magnetometer-less IMU drifts; the gravity vector is drift-free and continuous.
5. Name two sim-to-real effects not covered by Strido's randomisation.
Answer For example sensor and actuation latency, servo backlash or deadband, external pushes, uneven terrain.
## Further reading - R. S. Sutton and A. G. Barto, *Reinforcement Learning: An Introduction*, 2nd ed., MIT Press, 2018. - J. Schulman, F. Wolski, P. Dhariwal, A. Radford and O. Klimov, "Proximal Policy Optimization Algorithms", arXiv:1707.06347, 2017. - J. Schulman, P. Moritz, S. Levine, M. Jordan and P. Abbeel, "High-Dimensional Continuous Control Using Generalized Advantage Estimation", *ICLR*, 2016. - J. Tobin et al., "Domain Randomization for Transferring Deep Neural Networks from Simulation to the Real World", *IROS*, 2017. - J. Hwangbo et al., "Learning agile and dynamic motor skills for legged robots", *Science Robotics*, 2019. --- # State estimation A robot never measures its state directly; it infers orientation, velocity and position from noisy, biased sensors. This page covers IMU models, complementary and Mahony filters, the Kalman filter, leg odometry and LiDAR SLAM, and what Strido actually publishes. ::: tip In Strido The XIAO ESP32-S3 reads an LSM6DSOX IMU and runs a Mahony filter in its $200\,\mathrm{Hz}$ control loop (`firmware/strido_esp32s3/src/main.cpp`, period `kControlPeriodUs = 5000` in `board_config.h`). `ros2/strido_control/strido_control/mcu_bridge.py` republishes it as `/imu/data`; `gait_node.py` publishes `/odom` by dead reckoning; an RPLIDAR C1 publishes `/scan` (see [docs/software.md](../../software.md) and [Sensors](../hardware/sensors.md)). ::: ## IMU measurement models An inertial measurement unit contains a 3-axis **gyroscope** (angular velocity) and a 3-axis **accelerometer** (specific force). Both are expressed in the sensor's body frame $\{b\}$. **Gyroscope.** $$ \tilde{\boldsymbol{\omega}} = \boldsymbol{\omega} + \mathbf{b}_g + \mathbf{n}_g $$ $\mathbf{b}_g$ is a slowly drifting **bias** (temperature dependent), $\mathbf{n}_g$ white noise. Integrating the gyro gives orientation, but integrating the bias gives an angle error growing linearly in time: a bias of only $0.5^\circ/\mathrm{s}$ becomes $30^\circ$ after a minute. **Accelerometer.** It measures acceleration *minus* gravity, rotated into the body: $$ \tilde{\mathbf{a}} = \mathbf{R}^\top \left( \ddot{\mathbf{p}} - \mathbf{g} \right) + \mathbf{b}_a + \mathbf{n}_a, \qquad \mathbf{g} = (0, 0, -9.81)^\top\,\mathrm{m/s^2} $$ where $\mathbf{R}$ rotates body to world. Standing still ($\ddot{\mathbf{p}} = 0$), it reads $\mathbf{R}^\top (0, 0, 9.81)^\top$: the "up" vector in body coordinates. That fixes **roll and pitch** with no drift, but it is noisy and corrupted whenever the body accelerates (every footstep). It says nothing about **yaw**, because rotating about gravity leaves the gravity vector unchanged. The two sensors are complementary: gyro is smooth but drifts; accelerometer is noisy but drift-free. ## The complementary filter For one tilt angle $\theta$, blend the integrated gyro (trusted at high frequency) with the accelerometer angle (trusted at low frequency): $$ \hat{\theta}_k = \alpha \left( \hat{\theta}_{k-1} + \tilde{\omega}_k\, \Delta t \right) + (1 - \alpha)\, \theta_{\mathrm{acc},k}, \qquad \theta_{\mathrm{acc}} = \operatorname{atan2}(\tilde{a}_y, \tilde{a}_z) $$ In the frequency domain this is a high-pass on the gyro plus a low-pass on the accelerometer with the same cut-off; the crossover time constant is $$ \tau = \frac{\alpha\, \Delta t}{1 - \alpha} $$ **Worked example.** At Strido's $\Delta t = 5\,\mathrm{ms}$, choosing $\alpha = 0.98$ gives $\tau = 0.98 \times 0.005 / 0.02 \approx 0.25\,\mathrm{s}$: longer than the $0.2\,\mathrm{s}$ stance of the trot, so footstep jolts are mostly filtered out while gyro drift is corrected within about a second. ## The Mahony filter (what the firmware runs) Mahony's filter is a complementary filter on the rotation group, using a quaternion $\hat{\mathbf{q}}$ instead of separate angles. Each step: 1. Predict the gravity ("up") direction from the current estimate: $\hat{\mathbf{v}} = \hat{\mathbf{R}}^\top \mathbf{e}_z$. 2. Normalise the accelerometer: $\bar{\mathbf{a}} = \tilde{\mathbf{a}} / \lVert \tilde{\mathbf{a}} \rVert$. 3. The error is the rotation needed to align them: $$ \mathbf{e} = \bar{\mathbf{a}} \times \hat{\mathbf{v}} $$ 4. Correct the gyro with proportional and integral feedback (the integral term converges to minus the gyro bias, cancelling it): $$ \mathbf{b}_{k} = \mathbf{b}_{k-1} + K_i\, \mathbf{e}\, \Delta t, \qquad \boldsymbol{\omega}_\mathrm{c} = \tilde{\boldsymbol{\omega}} + K_p\, \mathbf{e} + \mathbf{b}_k $$ 5. Integrate the quaternion kinematics $\dot{\mathbf{q}} = \tfrac12\, \mathbf{q} \otimes (0, \boldsymbol{\omega}_\mathrm{c})$ and renormalise: $$ \hat{\mathbf{q}}_k = \frac{\hat{\mathbf{q}}_{k-1} + \tfrac12\, \hat{\mathbf{q}}_{k-1} \otimes (0, \boldsymbol{\omega}_\mathrm{c})\, \Delta t}{\lVert \cdot \rVert} $$ $K_p$ sets the crossover (roughly $\tau \approx 1/K_p$); $K_i$ removes constant gyro bias. Yaw is not observable without a magnetometer, so it drifts. In the firmware, `Adafruit_Mahony` is started at $1/\Delta t = 200\,\mathrm{Hz}$ and fed through `updateIMU` (gyro and accelerometer only, no magnetometer). Before filtering, board axes are remapped to the base frame because the IMU is mounted with its $x$ to robot left and $y$ to the rear: $$ (\omega_x, \omega_y, \omega_z) = (-\tilde{\omega}_y, \tilde{\omega}_x, \tilde{\omega}_z), \qquad (a_x, a_y, a_z) = (-\tilde{a}_y, \tilde{a}_x, \tilde{a}_z) $$ The sensor is configured for $\pm 8\,g$, $\pm 1000^\circ/\mathrm{s}$ at $416\,\mathrm{Hz}$ output data rate. The repository keeps the library's default gains (Adafruit AHRS: $K_p = 0.5$, $K_i = 0$, i.e. no bias integration). ## The Kalman filter The Kalman filter is the optimal estimator for linear systems with Gaussian noise. State $\mathbf{x}$ with covariance $\mathbf{P}$, model $$ \mathbf{x}_k = \mathbf{F} \mathbf{x}_{k-1} + \mathbf{B} \mathbf{u}_k + \mathbf{w}_k, \quad \mathbf{w} \sim \mathcal{N}(0, \mathbf{Q}); \qquad \mathbf{z}_k = \mathbf{H} \mathbf{x}_k + \mathbf{v}_k, \quad \mathbf{v} \sim \mathcal{N}(0, \mathbf{R}_\mathrm{n}) $$ **Predict** (propagate the model; uncertainty grows): $$ \hat{\mathbf{x}}_{k \mid k-1} = \mathbf{F} \hat{\mathbf{x}}_{k-1} + \mathbf{B} \mathbf{u}_k, \qquad \mathbf{P}_{k \mid k-1} = \mathbf{F} \mathbf{P}_{k-1} \mathbf{F}^\top + \mathbf{Q} $$ **Update** (fuse a measurement; uncertainty shrinks): $$ \mathbf{K}_k = \mathbf{P}_{k \mid k-1} \mathbf{H}^\top \left( \mathbf{H} \mathbf{P}_{k \mid k-1} \mathbf{H}^\top + \mathbf{R}_\mathrm{n} \right)^{-1} $$ $$ \hat{\mathbf{x}}_k = \hat{\mathbf{x}}_{k \mid k-1} + \mathbf{K}_k \left( \mathbf{z}_k - \mathbf{H} \hat{\mathbf{x}}_{k \mid k-1} \right), \qquad \mathbf{P}_k = (\mathbf{I} - \mathbf{K}_k \mathbf{H})\, \mathbf{P}_{k \mid k-1} $$ The gain $\mathbf{K}$ weighs the innovation $\mathbf{z} - \mathbf{H}\hat{\mathbf{x}}$ by how much we trust the measurement ($\mathbf{R}_\mathrm{n}$) versus the prediction ($\mathbf{P}$). A tilt filter with state $(\theta, b_g)$, gyro as input and accelerometer angle as measurement converges to a complementary filter with an automatically tuned $\alpha$ and bias estimate. Nonlinear systems (rotations, legged robots) use the **extended** Kalman filter, which linearises $\mathbf{F}$ and $\mathbf{H}$ about the current estimate. ## Leg odometry If a stance foot does not slip, its world position is fixed, so the body velocity follows from the leg's joint velocities through forward kinematics (see [Kinematics](kinematics.md)): $$ \mathbf{v}_\mathrm{body} = -\left( \mathbf{J}(\mathbf{q})\, \dot{\mathbf{q}} + \boldsymbol{\omega} \times \mathbf{p}_\mathrm{foot} \right) $$ averaged over the stance feet and integrated for position. Errors come from slip, contact detection and calibration, so it drifts; state-of-the-art quadrupeds fuse it with the IMU in an EKF. Strido's hobby servos have **no position feedback**: `/joint_states` reports the commanded angles after clamping, not measured ones. True leg odometry is therefore not available, and `gait_node.py` does something simpler: it integrates the *commanded* body velocity, $$ x_{k} = x_{k-1} + (v_x \cos\psi - v_y \sin\psi)\, \Delta t, \quad y_{k} = y_{k-1} + (v_x \sin\psi + v_y \cos\psi)\, \Delta t, \quad \psi_k = \psi_{k-1} + \omega_z \Delta t $$ with no slip model. If the robot actually achieves 80 % of a $0.30\,\mathrm{m/s}$ command, `/odom` overestimates distance by $0.06\,\mathrm{m}$ every second. ## LiDAR scan matching and SLAM A 2D LiDAR returns ranges $r_j$ at bearings $\theta_j$. **Scan matching** finds the rigid transform $(\mathbf{R}, \mathbf{t})$ that best aligns a new scan to a previous scan or map, e.g. with ICP (iterative closest point): $$ (\mathbf{R}^\star, \mathbf{t}^\star) = \arg\min_{\mathbf{R}, \mathbf{t}} \sum_j \left\lVert \mathbf{R} \mathbf{p}_j + \mathbf{t} - \mathbf{m}_{c(j)} \right\rVert^2 $$ where $\mathbf{m}_{c(j)}$ is the closest map point to $\mathbf{p}_j$. Chaining matches gives LiDAR odometry, which also drifts. **SLAM** (simultaneous localisation and mapping) removes the drift by recognising previously seen places (*loop closure*) and solving a pose-graph optimisation over all poses at once. Wheel/leg odometry is a useful initial guess for the matcher, which is the role the code comment assigns to Strido's `/odom` ("a seed for SLAM"). ## What Strido publishes | Topic | Contents | Source | |---|---|---| | `/imu/data` (`sensor_msgs/Imu`, frame `imu_link`, axes aligned with `base_link`) | orientation quaternion from the Mahony filter, angular velocity in $\mathrm{rad/s}$, linear acceleration in $\mathrm{m/s^2}$ (gravity included); not published while the IMU reports a fault | `mcu_bridge.py`, state stream at $100\,\mathrm{Hz}$ (`kStatePeriodMs = 10`) | | `/odom` + TF `odom` $\to$ `base_link` (`nav_msgs/Odometry`) | dead reckoning of the commanded velocity | `gait_node.py` only | | `/scan` (`sensor_msgs/LaserScan`, `lidar_link`) | RPLIDAR C1 ranges at $10\,\mathrm{Hz}$ | `sllidar_ros2` | The learned policy uses only `/imu/data`: the gyro and the gravity direction derived from the quaternion (see [Reinforcement learning](reinforcement-learning.md)). ## Check yourself 1. Why can an accelerometer give roll and pitch but not yaw?
Answer It measures the gravity direction in the body frame; rotating about the vertical axis does not change that direction.
2. With $\Delta t = 5\,\mathrm{ms}$, what $\alpha$ gives a complementary-filter time constant of $0.5\,\mathrm{s}$?
Answer $\alpha/(1-\alpha) = 100$, so $\alpha = 100/101 \approx 0.990$.
3. What does the integral gain $K_i$ in the Mahony filter estimate, and what happens with $K_i = 0$?
Answer The gyro bias. With $K_i = 0$ a constant bias leaves a small steady roll/pitch offset of about $b_g/K_p$ and uncorrected yaw drift.
4. In the Kalman update, what happens to $\mathbf{K}$ as $\mathbf{R}_\mathrm{n} \to \infty$?
Answer $\mathbf{K} \to 0$: the measurement is ignored and the estimate follows the prediction.
5. Why can't Strido do classic leg odometry?
Answer Its servos give no joint-position feedback, so the actual $\mathbf{q}$ and $\dot{\mathbf{q}}$ are unknown; only commands are available.
## Further reading - R. Mahony, T. Hamel and J.-M. Pflimlin, "Nonlinear Complementary Filters on the Special Orthogonal Group", *IEEE Transactions on Automatic Control*, 2008. - S. Thrun, W. Burgard and D. Fox, *Probabilistic Robotics*, MIT Press, 2005. - J. Solà, "Quaternion kinematics for the error-state Kalman filter", arXiv:1711.02508, 2017. - M. Bloesch et al., "State Estimation for Legged Robots — Consistent Fusion of Leg Kinematics and IMU", *Robotics: Science and Systems*, 2012. - W. Hess, D. Kohler, H. Rapp and D. Andor, "Real-Time Loop Closure in 2D LIDAR SLAM" (Cartographer), *IEEE ICRA*, 2016. --- # Statics and dynamics Statics asks which joint torques hold a load still. Dynamics adds the torques that accelerate masses. Together they tell you whether a servo is strong enough and how hard each part is loaded. This page derives $\boldsymbol{\tau} = \mathbf{J}^\top\mathbf{F}$ from virtual work, explains the leverage of the Strido knee linkage, and compares the torque the robot needs with the torque its servos can give. ::: tip In Strido - **Servo limits:** SPT5435LV-180W, stall torque $3.43\,\mathrm{N\,m}$ at $6\,\mathrm{V}$ (`hardware/strength.yaml`), no-load speed $0.14\,\mathrm{s}/60^\circ$ ([servos](../hardware/servos.md)). - **Mass:** $3.07\,\mathrm{kg}$ including the battery and the wiring harness; per-link masses in `ros2/strido_description/config/mass_report.yaml`. - **Linkage forces:** `packages/strido-verify/src/strido_verify/strength.py` sweeps the knee linkage at stall. Results are in `docs/verification/data/strength.json`, explained in [strength](../making/strength.md). - **Simulation:** MuJoCo clamps every actuator to $\pm 3.432\,\mathrm{N\,m}$ (`models/mujoco/strido.xml`). ::: ## Virtual work and $\boldsymbol{\tau} = \mathbf{J}^\top \mathbf{F}$ Suppose the foot pushes on the ground with force $\mathbf{F}$, and the ground pushes back with $-\mathbf{F}$. Hold the leg still and imagine a tiny joint motion $\delta\mathbf{q}$. The foot moves by $\delta\mathbf{p} = \mathbf{J}\,\delta\mathbf{q}$ (see [kinematics](kinematics.md)). In equilibrium, with no friction, the work done by the joints equals the work delivered at the foot: $$ \boldsymbol{\tau}^\top \delta\mathbf{q} = \mathbf{F}^\top \delta\mathbf{p} = \mathbf{F}^\top \mathbf{J}\,\delta\mathbf{q} $$ This must hold for every $\delta\mathbf{q}$, so: $$ \boldsymbol{\tau} = \mathbf{J}^\top \mathbf{F} $$ Two consequences: - At a **singularity**, $\mathbf{J}$ loses rank. Some foot forces then need no joint torque at all: a straight leg carries a load along its length "on the bones". At the same time, the leg cannot push in the lost direction. - Velocity gain and force gain trade off. Where a joint moves the foot fast (large Jacobian entries), it pushes weakly, and the other way round. The same argument works for any mechanism with one input and one output. If the output angle changes by $\delta\theta_{\text{out}} = G\,\delta\theta_{\text{in}}$, then the output torque is $\tau_{\text{out}} = \tau_{\text{in}}/G$. ## Mechanical advantage of the knee drive The knee servo reaches the tibia through two linkages (see [kinematics](kinematics.md)). For a link that carries force $F$ along its length, only the perpendicular part of $F$ acts on a crank of radius $r$: $$ \tau = F\,r\,\sin\alpha $$ Here $\alpha$ is the angle between the crank and the link. `strength.py` chains this rule through the drive: $$ F_{\text{short}} = \frac{\tau_{\text{servo}}}{r_{\text{arm}}\sin\alpha},\qquad \tau_{\text{crank}} = F_{\text{short}}\, r_{\text{crank,a}}\sin\alpha',\qquad F_{\text{long}} = \frac{\tau_{\text{crank}}}{r_{\text{crank,b}}\sin\beta},\qquad \tau_{\text{knee}} = F_{\text{long}}\, r_{\text{knee}}\sin\gamma $$ - **Parallelogram.** Opposite sides are equal ($r_{\text{arm}} = r_{\text{crank,a}} = 24.0\,\mathrm{mm}$), so $\alpha' = \alpha$ and $\tau_{\text{crank}} = \tau_{\text{servo}}$. The ratio is $1{:}1$. The link force $F_{\text{short}}$, however, grows as $1/\sin\alpha$. - **Four-bar.** The torque ratio is $$ \frac{\tau_{\text{knee}}}{\tau_{\text{crank}}} = \frac{r_{\text{knee}}\sin\gamma}{r_{\text{crank,b}}\sin\beta} $$ and it changes with the pose. When the transmission angle $\beta$ or $\gamma$ approaches $0$ or $180^\circ$, the forces become very large or the drive loses all leverage. That is why the joint limits require at least $25^\circ$ (`hardware/joint_limits.yaml`). Sweeping every pose inside the limits with the knee servo at stall, the worst cases (`docs/verification/data/strength.json`) are $211\,\mathrm{N}$ in the short link, $192\,\mathrm{N}$ in the long link and $3.07\,\mathrm{N\,m}$ at the knee. ## Dynamics: inertia and Newton–Euler Moving bodies need more torque. For a single rigid body, the **Newton–Euler** equations are: $$ \mathbf{F} = m\,\ddot{\mathbf{c}}, \qquad \boldsymbol{\tau}_c = \mathbf{I}_c\,\dot{\boldsymbol{\omega}} + \boldsymbol{\omega}\times\mathbf{I}_c\,\boldsymbol{\omega} $$ Here $\mathbf{c}$ is the centre of mass and $\mathbf{I}_c$ the $3\times 3$ inertia tensor about it. For a link turning about a fixed axis, the parallel-axis theorem gives the inertia about the axis, $I = I_c + m r_c^2$, with $r_c$ the distance from the axis to the centre of mass. Then $\tau = I\ddot\theta$. Chaining Newton–Euler through every link yields the standard manipulator equation: $$ \mathbf{M}(\mathbf{q})\,\ddot{\mathbf{q}} + \mathbf{C}(\mathbf{q},\dot{\mathbf{q}})\,\dot{\mathbf{q}} + \mathbf{g}(\mathbf{q}) = \boldsymbol{\tau} + \mathbf{J}^\top\mathbf{F}_{\text{ext}} $$ The terms are: the mass matrix $\mathbf{M}$, the Coriolis and centrifugal terms $\mathbf{C}$, gravity $\mathbf{g}$, and contact forces through $\mathbf{J}^\top$. MuJoCo solves exactly this every $2\,\mathrm{ms}$. Each Strido joint also has `armature="0.012"` ($\mathrm{kg\,m^2}$) added to its diagonal of $\mathbf{M}$. Armature models the gearbox: a rotor of inertia $I_r$ behind a reduction $N$ appears at the output as $N^2 I_r$. ## Power and the torque–speed line Mechanical power is $P = \tau\,\omega$. A DC servo motor roughly follows a straight torque–speed line. It gives full stall torque $\tau_s$ at zero speed and zero torque at no-load speed $\omega_0$: $$ \tau(\omega) \approx \tau_s\left(1 - \frac{\omega}{\omega_0}\right) \quad\Rightarrow\quad P(\omega) = \tau_s\,\omega\left(1-\frac{\omega}{\omega_0}\right),\qquad P_{\max} = \frac{\tau_s\,\omega_0}{4}\ \text{at}\ \omega = \tfrac{\omega_0}{2} $$ For the SPT5435LV at $6\,\mathrm{V}$: $$ \omega_0 = \frac{\pi/3}{0.14\,\mathrm{s}} = 7.48\,\mathrm{rad/s},\qquad P_{\max} \approx \frac{3.43 \cdot 7.48}{4} = 6.4\,\mathrm{W} $$ The linear line is a model; the datasheet gives only the two end points. A servo can deliver full torque or full speed, never both at once. The firmware caps joint speed at $6\,\mathrm{rad/s}$ (`MAX_JOINT_SPEED_RAD_S` in `strido/policy.py`, mirroring `safety.h`). At that speed the line leaves only about $3.43\,(1 - 6/7.48) \approx 0.68\,\mathrm{N\,m}$. ## Worked example: holding the stand pose At the stand pose ($q_2 = 46.5^\circ$, $q_3 = -96.0^\circ$, foot straight below the hip, $0.17\,\mathrm{m}$ down), the planar Jacobian is (see [kinematics](kinematics.md)): $$ \mathbf{J}_{\text{plane}} = \begin{bmatrix} -0.170 & -0.0806 \\ 0 & -0.0943 \end{bmatrix}\mathrm{m/rad} $$ With four feet down, each foot carries a quarter of the weight: $$ F_v = \frac{3.07 \cdot 9.81}{4} = 7.53\,\mathrm{N} $$ The foot pushes down on the ground, so with the convention above $\mathbf{F} = (0, -7.53)\,\mathrm{N}$ in $(u, v)$ (the ground pushes back with $+7.53\,\mathrm{N}$). The joint torques that hold it are: $$ \boldsymbol{\tau} = \mathbf{J}^\top\mathbf{F} = \begin{bmatrix} -0.170 & 0 \\ -0.0806 & -0.0943 \end{bmatrix} \begin{bmatrix} 0 \\ -7.53 \end{bmatrix} = \begin{bmatrix} 0 \\ 0.71 \end{bmatrix}\mathrm{N\,m} $$ The thigh needs no torque, because the foot is straight under its pivot and the load passes through the joint. The knee needs $+0.71\,\mathrm{N\,m}$, matching MuJoCo, where the front-left knee actuator holds the stand pose with $0.688\,\mathrm{N\,m}$ (see also [servos](../hardware/servos.md)). Through the four-bar, $\partial q_3/\partial\psi = -1.12$ at this pose. Virtual work then gives the knee-servo torque: $$ \tau_\psi = \tau_{q_3}\,\frac{\partial q_3}{\partial \psi} = 0.71 \cdot 1.12 = 0.80\,\mathrm{N\,m} $$ As a cross-check, the lever-arm rule with the transmission angles $84^\circ$ and $86^\circ$ gives a ratio of $\frac{24.0\,\sin 86^\circ}{26.9\,\sin 84^\circ} = 0.89 \approx 1/1.12$. The knee servo's $0.80\,\mathrm{N\,m}$ is about $23\,\%$ of its $3.43\,\mathrm{N\,m}$ stall torque. A trot puts the weight on two feet instead of four, roughly doubling the load to about $1.6\,\mathrm{N\,m}$, which still leaves about a factor of two for accelerations and stumbles. **Dynamics check.** The calf weighs $0.0521\,\mathrm{kg}$ with its centre of mass about $48\,\mathrm{mm}$ from the knee. That gives $m r_c^2 \approx 1.2\times10^{-4}\,\mathrm{kg\,m^2}$; with the calf's own inertia about its centre of mass ($1.5\times10^{-4}$, `mass_report.yaml`) the knee sees about $2.7\times10^{-4}\,\mathrm{kg\,m^2}$, roughly $1/44$ of the modelled armature. Swinging a light leg is therefore limited by speed and by the servo's own rotor, not by the link's inertia. **Landing.** `strength.py` assumes a hard landing of $3\times$ body weight on one diagonal pair: $$ F = \frac{3 \cdot 3.07 \cdot 9.81}{2} = 45.2\,\mathrm{N} $$ per foot. Acting on the $124\,\mathrm{mm}$ tibia, that is $5.60\,\mathrm{N\,m}$ at the knee, more than stall. So a landing back-drives the servo, and the printed parts must take it. See [strength](../making/strength.md). ## Check yourself 1. Derive $\boldsymbol{\tau} = \mathbf{J}^\top\mathbf{F}$ in one line from virtual work. 2. Why does the parallelogram not change the torque, yet can still overload the short link? 3. Why does the thigh need almost no torque in the stand pose? 4. Can the servo deliver $3.43\,\mathrm{N\,m}$ while turning at $6\,\mathrm{rad/s}$?
Answers 1. $\boldsymbol{\tau}^\top\delta\mathbf{q} = \mathbf{F}^\top\mathbf{J}\,\delta\mathbf{q}$ for all $\delta\mathbf{q}$, so $\boldsymbol{\tau} = \mathbf{J}^\top\mathbf{F}$. 2. Its input and output cranks are equal, so the $\sin\alpha$ terms cancel in the torque ratio. The link force is $\tau/(r\sin\alpha)$, though, and it grows without bound as $\alpha \to 0$. 3. The foot is directly below the thigh pivot ($u = 0$), so the vertical force has no lever arm about it. In the Jacobian, $J_{21} = 0$. 4. No. Under the linear model, about $0.7\,\mathrm{N\,m}$ is left at $6\,\mathrm{rad/s}$. Stall torque exists only at zero speed.
## Further reading - K. M. Lynch and F. C. Park, *Modern Robotics*, Cambridge University Press, 2017, ch. 5.2 (statics) and ch. 8 (dynamics of open chains). - R. Featherstone, *Rigid Body Dynamics Algorithms*, Springer, 2008. - B. Siciliano et al., *Robotics: Modelling, Planning and Control*, Springer, 2009, ch. 3.8 and 7. - R. L. Norton, *Design of Machinery*, McGraw-Hill (linkage force analysis, transmission angle). - [Strength of the printed parts](../making/strength.md) and [servos](../hardware/servos.md) in this wiki. --- # Product requirements > **Status:** requirement catalogue for Strido 0.2. The *Status* column states what the repository demonstrates today; evidence tiers are defined in [docs/verification.md](../docs/verification.md). CAD, simulation and native tests are not hardware evidence. Strido is a small, affordable, 3D-printed quadruped that a developer builds at home to develop ROS 2 and learning software that later transfers to a Unitree Go2 at work, and that runs in the NT-X harness. It reuses the proven Yertle legs and frame and adds what a Mini Pupper 4 class robot offers: a face display, a 360° LiDAR, a wide camera and an onboard computer. ## Families | Family | Scope | |---|---| | MECH | Mechanical design, fits, motion | | MAKE | Printing, purchasing, assembly documentation | | ELEC | Power, wiring, pins | | FW | Motion-controller firmware | | SW | Robot software (ROS 2, bench tools) | | SIM | Simulation and learning | | NTX | NT-X harness integration | | DOC | Documentation site, catalogue, renders | | ENG | Engineering harness, checks, release | ## Requirements | ID | Requirement and observable acceptance | Prio | Status | Evidence | |---|---|---|---|---| | MECH-001 | 12 actuated joints (hip abduction, thigh, knee per leg) in Go2 joint naming and order. | MUST | met (Q1) | `models/mujoco/strido.xml`, `hardware/joint_limits.yaml` | | MECH-002 | No two solids of the assembled robot intersect except designed contacts (threads, seats, press fits), in every reference pose. | MUST | met (Q1) | [interference](../docs/verification/interference.md) | | MECH-003 | Every screw passes through clearance holes or forms its thread in a pilot of 0.70–0.97 d (printed size), is held by a nut, pilot or threaded part, and engages ≥ 1.0 d; every bearing seat is within −0.15…+0.30 mm of the bearing OD. | MUST | met (Q1) | [fits](../docs/verification/fits.md) | | MECH-004 | Parts on different rigid bodies keep ≥ 0.3 mm running clearance in every operating pose (stand/crouch/tall, trot cycle, hip limits). | MUST | met (Q1) | [clearance](../docs/verification/clearance.md) | | MECH-005 | Joint limits come from a collision and linkage sweep (incl. the mirrored partner leg and 0.3 mm clearance) and are enforced identically in firmware, Python and simulation. | MUST | met (Q0/Q1) | `make drift`, `hardware/joint_limits.yaml` | | MECH-006 | Sensor head: 2" LCD face, 360° LiDAR with an unobstructed scan plane, wide camera that does not see the robot. | MUST | met (Q1) | [sensors](../docs/verification/sensors.md) | | MAKE-001 | Every printed part ships as an oriented STL and on 220 × 220 mm 3MF plates that fit a 220 × 220 × 250 mm bed; closed manifold. | MUST | met (Q1) | `hardware/print/` | | MAKE-002 | The BOM covers every purchased part and fastener of the CAD (fasteners + 10 %, ≥ 2 spares) in every shopping strategy; offers cite a vendor, SKU, price and date. | MUST | met (Q1) | `make bom`, [BOM](../docs/bom/README.md) | | MAKE-003 | Shopping plan minimises the delivered cost (shipping thresholds, per-order fees) and offers fewest-shops, EU-only and genuine-board alternatives. | SHOULD | met (Q1) | [shopping list](../docs/bom/shopping_list.md) | | MAKE-004 | IKEA-style assembly: every printed part, purchased part and fastener appears in exactly one step's parts box, with a CAD drawing per step. | MUST | met (Q1) | [assembly](../docs/assembly/README.md) | | MAKE-005 | A4 catalogue & assembly book (PDF): printed and purchased part catalogues, fasteners at 1:1, shopping plan, tools, all steps, wiring, software appendix. | MUST | met (Q1) | [catalogue PDF](../docs/catalog/strido-catalog.pdf) | | ELEC-001 | Servo current never flows through a logic board; separate fused servo (6.0 V) and logic (5 V) domains with a physical arming key. | MUST | met (design) | [electronics](../docs/electronics.md) | | ELEC-002 | Pins, I²C addresses, servo channels, fuses and wire gauges are machine-checked against the firmware header and the load. | MUST | met (Q0) | [electronics check](../docs/verification/electronics.md) | | FW-001 | Command watchdog (hold 200 ms, relax 2 s), latched e-stop via PCA9685 OE, low-battery cut-off, refusal to drive without the power monitor. | MUST | met (Q0 native) | `make test-firmware` | | FW-002 | Joint ↔ servo mapping is linkage-exact (knee driven through the crank four-bar) and identical to the Python mirror. | MUST | met (Q0) | firmware tests with Python golden values | | SW-001 | ROS 2 Jazzy bringup with a Go2-style contract: `/cmd_vel`, `/joint_states`, `/imu/data`, `/scan`, `/battery_state`, `/tf`. | MUST | met (Q2 sim) | `make sim-gazebo` | | SW-002 | Bench CLI to centre, pulse, calibrate and monitor the servos without ROS. | MUST | met (Q0) | `tests/core/test_cli.py` | | SIM-001 | MuJoCo and Gazebo models generated from the verified CAD (masses, inertias, limits). | MUST | met (Q2) | `make models`, `make sim-walk` | | SIM-002 | A velocity-tracking policy trained in simulation runs through the same numpy runtime as on the robot. | SHOULD | partial (sim only) | `make rl-smoke`, `models/policies/ppo-v1` | | NTX-001 | The robot appears in the NT-X catalog (robot spec + MJCF + stand skill) without NT-X core changes, and stands in NT-X MuJoCo. | MUST | met (Q2) | `make ntx-smoke` | | NTX-002 | A `RobotPort` drives the real robot over the MCU protocol with explicit semantics (no odometry, commanded q, latched protective stop). | MUST | met (protocol-level fake MCU) | `make ntx-smoke`, `tests/ntx/` | | NTX-003 | NT-X sessions open the Strido port through the registry. | SHOULD | open: NT-X core dispatches hardware only to Unitree SDK2 | [NT-X integration](../docs/ntx-integration.md) | | DOC-001 | Documentation site with the catalogue, an interactive 3D model (explode, step-through, part picking), renders, guides and verification reports. | MUST | met (Q2) | `make docs-build` | | DOC-002 | Renders of the complete robot including the display face, LiDAR and camera. | MUST | met | `docs/images/` | | ENG-001 | Engineering harness installed (checks profile) with project skills, a hardware MCP server and gates for lint, types, architecture, drift, tests, firmware and CAD verification. | MUST | met (Q0) | `make check-fast`, `make harness-check` | | ENG-002 | One command builds a release zip with the full repository, print plates, the PDF, the walk video and the policy. | MUST | met | `make package` | | HW-001 | A built robot stands, walks under teleoperation, and its measured mass, current and runtime are recorded. | MUST | open: no robot built yet | [TODO](../TODO.md) | --- # Print list Generated by `strido_publish.print_files`. Every part (upstream Yertle and Strido) prints from `hardware/print/stl/`, oriented for printing; `hardware/print/plates/` holds ready 3MF plates. Servo mounts and `servo_mount_top_bracket`: print with X-Y hole compensation -0.15 mm so their M3 pilots grip (ADR-009); otherwise harden those pilots with a drop of thin CA glue before driving the screws (anaerobic thread locker is not for plastic: it does not cure there and can crack PETG). The compensation acts on vertical holes: the servo mounts lie like a U, so their hip-horn and hip-pivot pilots are horizontal and get the CA treatment if a screw spins. Hole steps (a bearing seat narrowing to a smaller hole above it) are cut for bridging in the STLs: the two layers above the step print a slot, then a square (see the notes). Where the note says so, block supports in the bearing bore in the slicer: the seat sits above the bed and would otherwise be supported. Settings (all parts unless noted): PETG or PLA+, 0.2 mm layers, 3 walls (1.2 mm), 5 top/bottom layers, 30 % gyroid infill, snug supports where listed, brim on parts taller than 40 mm. PrusaSlicer profile: `hardware/print/prusaslicer.ini` (File > Import > Import Config). Checked: closed manifold, fits a 220 x 220 x 250 mm bed, and every part and plate slices without stability warnings with exactly these settings (`make printability`, docs/verification/printability.md). | Part | Qty | Colour | File (in `hardware/print/`) | Size on bed (mm) | Supports | Source | Note | |---|---|---|---|---|---|---|---| | arming_port_bezel | 1 | primary (graphite) | `stl/arming_port_bezel.stl` | 26 x 26 x 4 | no | Strido (new) | | | arming_port_ring | 1 | primary (graphite) | `stl/arming_port_ring.stl` | 22 x 22 x 3 | no | Strido (new) | | | back_shell_button | 1 | secondary (lime) | `stl/back_shell_button.stl` | 74 x 114 x 69 | no | Strido (modified upstream back_shell) | upstream Back_Shell with the D11.5 hole opened to D12.2 | | bottom_shell | 1 | secondary (lime) | `stl/bottom_shell.stl` | 114 x 208 x 42 | yes (9 % overhang) | upstream STL | published orientation; resting it on its -Y face lowers overhang to 1 % | | camera_mount_base | 1 | primary (graphite) | `stl/camera_mount_base.stl` | 40 x 28 x 20 | no | Strido (new) | print standing on its base; the M3 pivot bore prints horizontal (a 3.4 mm drill clears it if tight) | | camera_tilt_plate | 1 | primary (graphite) | `stl/camera_tilt_plate.stl` | 27 x 30 x 19 | yes (24 % overhang) | Strido (new) | | | display_bracket | 1 | primary (graphite) | `stl/display_bracket.stl` | 40 x 83 x 3 | no | Strido (new) | | | electronics_deck | 1 | primary (graphite) | `stl/electronics_deck.stl` | 106 x 150 x 5 | yes (2 % overhang) | Strido (modified upstream electronics_mounting_plate) | upstream plate + IMU pocket and stand-off holes for the regulator and INA260; print top face down with supports (the IMU pocket floor is an 18 mm bridge the slicer flags without them) | | femur | 4 | primary (graphite) | `stl/femur.stl` | 26 x 151 x 15 | yes (5 % overhang) | upstream STL | re-oriented from the published STL: more bed contact, less overhang | | femur_servo_connector | 4 | primary (graphite) | `stl/femur_servo_connector.stl` | 28 x 28 x 6 | yes (7 % overhang) | upstream STL | re-oriented from the published STL: more bed contact, less overhang | | foot_spacer | 4 | primary (graphite) | `stl/foot_spacer.stl` | 22 x 24 x 12 | yes (21 % overhang) | upstream STEP (no upstream STL published) | | | front_shell_display | 1 | secondary (lime) | `stl/front_shell_display.stl` | 74 x 114 x 50 | no | Strido (modified upstream front_shell) | upstream Front_Shell + 43x33 display window, 2 display bosses, camera ribbon slot | | hip_bearing_pin | 4 | primary (graphite) | `stl/hip_bearing_pin.stl` | 5 x 5 x 5 | no | upstream STEP (no upstream STL published) | re-oriented from the published STL: more bed contact, less overhang | | inner_shoulder_frame | 2 | primary (graphite) | `stl/inner_shoulder_frame.stl` | 110 x 70 x 5 | no | upstream STL | | | inner_tibia | 4 | primary (graphite) | `stl/inner_tibia.stl` | 135 x 104 x 14 | yes (10 % overhang) | upstream STL | re-oriented from the published STL: more bed contact, less overhang; 1 hole step cut for bridging (hole overhang trick); block supports in the bearing bore so the step bridges | | left_servo_mount | 1 | primary (graphite) | `stl/left_servo_mount.stl` | 56 x 52 x 19 | yes (2 % overhang) | upstream STL | re-oriented from the published STL: more bed contact, less overhang | | left_servo_mount_fl | 1 | primary (graphite) | `stl/left_servo_mount_fl.stl` | 56 x 52 x 19 | yes (2 % overhang) | Strido (modified upstream left_servo_mount) | upstream left_servo_mount + Ø2.5 mm hip pivot pilot for the FL leg; re-oriented from the published STL: more bed contact, less overhang | | left_shoulder | 2 | secondary (lime) | `stl/left_shoulder.stl` | 50 x 55 x 26 | yes (5 % overhang) | upstream STL | | | lidar_pedestal | 1 | primary (graphite) | `stl/lidar_pedestal.stl` | 56 x 56 x 14 | no | Strido (new) | print upside down (top plate on the bed) | | lower_shoulder_frame_front | 1 | primary (graphite) | `stl/lower_shoulder_frame_front.stl` | 20 x 70 x 12 | no | Strido (modified upstream lower_shoulder_frame) | upstream Lower_Shoulder_Frame + one M3 pilot hole for the servo-bus holder | | lower_shoulder_frame_rear | 1 | primary (graphite) | `stl/lower_shoulder_frame_rear.stl` | 20 x 70 x 12 | no | Strido (modified upstream lower_shoulder_frame) | upstream Lower_Shoulder_Frame + one M3 pilot hole for the servo-bus holder | | outer_shoulder_frame | 2 | primary (graphite) | `stl/outer_shoulder_frame.stl` | 110 x 70 x 5 | no | upstream STL | 2 hole steps cut for bridging (hole overhang trick) | | outer_tibia | 4 | primary (graphite) | `stl/outer_tibia.stl` | 135 x 104 x 14 | yes (10 % overhang) | upstream STL | re-oriented from the published STL: more bed contact, less overhang; 1 hole step cut for bridging (hole overhang trick); block supports in the bearing bore so the step bridges | | right_servo_mount | 1 | primary (graphite) | `stl/right_servo_mount.stl` | 56 x 52 x 19 | yes (2 % overhang) | upstream STL | re-oriented from the published STL: more bed contact, less overhang | | right_servo_mount_fr | 1 | primary (graphite) | `stl/right_servo_mount_fr.stl` | 56 x 52 x 19 | yes (2 % overhang) | Strido (modified upstream right_servo_mount) | upstream right_servo_mount + Ø2.5 mm hip pivot pilot for the FR leg; re-oriented from the published STL: more bed contact, less overhang | | right_shoulder | 2 | secondary (lime) | `stl/right_shoulder.stl` | 50 x 55 x 26 | yes (5 % overhang) | upstream STL | re-oriented from the published STL: more bed contact, less overhang | | servo_bus_holder_front | 1 | primary (graphite) | `stl/servo_bus_holder_front.stl` | 22 x 67 x 28 | yes (12 % overhang) | Strido (new) | | | servo_bus_holder_rear | 1 | primary (graphite) | `stl/servo_bus_holder_rear.stl` | 22 x 67 x 14 | yes (6 % overhang) | Strido (new) | | | servo_mount_top_bracket | 4 | primary (graphite) | `stl/servo_mount_top_bracket.stl` | 56 x 19 x 6 | no | upstream STL | re-oriented from the published STL: more bed contact, less overhang | | short_link | 4 | primary (graphite) | `stl/short_link.stl` | 35 x 10 x 7 | yes (15 % overhang) | upstream STL | re-oriented from the published STL: more bed contact, less overhang | | side_body_beam | 2 | primary (graphite) | `stl/side_body_beam.stl` | 25 x 150 x 6 | no | upstream STL | re-oriented from the published STL: more bed contact, less overhang | | stiff_long_link | 4 | primary (graphite) | `stl/stiff_long_link.stl` | 10 x 141 x 10 | no | Strido (modified upstream long_link) | upstream long_link, 10 x 10 mm instead of 5 x 8 mm, same pins and eyes: buckling, ADR-010 | | top_shell_sensor | 1 | secondary (lime) | `stl/top_shell_sensor.stl` | 114 x 208 x 42 | yes (8 % overhang) | Strido (modified upstream top_shell) | upstream Top_Shell + LiDAR pedestal holes and LiDAR USB cable slot | | upper_shoulder_frame | 2 | primary (graphite) | `stl/upper_shoulder_frame.stl` | 40 x 70 x 12 | no | upstream STL | | Total solid volume of all pieces: 943 cm³ (filament ≈ 659 g PETG at the settings above; budget 1.2 kg incl. brims, supports and test prints). ---