Piloting a helios laser from redis feed
  • Rust 76.7%
  • Python 18.6%
  • HTML 2.2%
  • Shell 1.7%
  • Dockerfile 0.8%
Find a file
alban ed9c63b433
Some checks failed
release / test (push) Failing after 3s
release / e2e (push) Failing after 2s
release / release (push) Has been skipped
release / publish (push) Has been skipped
docs: 📝 roadmap: point rate (kpps) guide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:38:46 +01:00
.forgejo/workflows fix(etherdream): 🚑 blank startup square instead of a lit sine wave on connect 2026-10-24 18:30:25 +02:00
.tasks docs: 📝 tip lead task: dark moves on stars; 400k in production 2026-10-24 22:08:12 +02:00
docker fix(etherdream): 🐛 point rate changes and refused chunks no longer break the stream 2026-10-25 13:50:51 +01:00
docs docs: 📝 optimizer guide: an analogy and a values table per setting, effective acceleration, tuning procedure 2026-10-24 23:28:03 +02:00
examples fix(redis): 🐛 SET calls state their unit return type 2026-10-25 11:32:58 +01:00
lab fix(lab): 🐛 the camera films at 25 fps: a 30 fps camera locks to 15 and 30 fps laser frames 2026-10-24 18:30:26 +02:00
src fix(etherdream): 🐛 point rate changes and refused chunks no longer break the stream 2026-10-25 13:50:51 +01:00
tests fix(etherdream): 🐛 point rate changes and refused chunks no longer break the stream 2026-10-25 13:50:51 +01:00
vendor fix(etherdream): 🐛 point rate changes and refused chunks no longer break the stream 2026-10-25 13:50:51 +01:00
.dockerignore feat(lab): ✨ lj_lab crate: test frames through Redis as LJ, tracer prediction, camera stack, run record 2026-10-24 18:30:26 +02:00
.gitignore fix(etherdream): 🐛 point rate changes and refused chunks no longer break the stream 2026-10-25 13:50:51 +01:00
AGENTS.md docs: 📝 review cleanup: stale and context-dependent docs, missing usage 2026-10-24 18:30:25 +02:00
Cargo.lock fix(etherdream): 🐛 point rate changes and refused chunks no longer break the stream 2026-10-25 13:50:51 +01:00
Cargo.toml fix(etherdream): 🐛 point rate changes and refused chunks no longer break the stream 2026-10-25 13:50:51 +01:00
CHANGELOG.md chore(release): v0.2.0 2026-10-16 21:06:57 +00:00
CLAUDE.md docs(agents): 📝 add agent principles and Rust spec 2026-10-13 22:43:37 +02:00
cliff.toml ci(release): 👷 single test/release/publish workflow with git-cliff semver 2026-10-13 22:47:26 +02:00
compose.helios.yml build(docker): 🐳 add Docker image, compose stack and run.py launcher 2026-10-15 10:37:44 +02:00
compose.lab.yml feat(lab): ✨ ./run.py lab calibrate: the EDH fitted to the test target in one command 2026-10-24 18:30:26 +02:00
compose.yml ci(e2e): 👷 run run.py tests and the Docker end-to-end test in CI 2026-10-15 11:02:39 +02:00
copyme.settings.toml feat(transformer): ✨ etherdream_to_helios maps LJ's geometry onto the Helios range 2026-10-24 18:30:26 +02:00
Dockerfile feat(lab): ✨ compose.lab.yml and ./run.py lab: LJ, throwaway Valkey, tracer on Helios, lab with camera 2026-10-24 18:30:26 +02:00
README.md feat(lab): ✨ ./run.py lab calibrate: the EDH fitted to the test target in one command 2026-10-24 18:30:26 +02:00
ROADMAP.md docs: 📝 roadmap: point rate (kpps) guide 2026-10-25 15:38:46 +01:00
run.py feat(run.py): ✨ work folders named /tmp/lj-yymmdd-hhmmss-<kind>, so they sort and say what they hold 2026-10-24 18:30:26 +02:00
VERSION.txt chore(release): v0.2.0 2026-10-16 21:06:57 +00:00

LJ rust

Laser tracer for LJ: it reads frames from Redis, optimizes them for the mirrors (galvos) and sends them to a Helios or Etherdream DAC.

Docker

Every example in this README runs in Docker: you only need docker (with compose) and python3 on the host. ./run.py builds the images and runs the tracer, Valkey (Redis-compatible), the tests and the renders in containers.

$ cp copyme.settings.toml settings.toml      # edit the DAC and redis_url
$ ./run.py docker --dac dummy --config ./settings.toml
$ ./run.py --help

What the optimizer changes

Frames as a client sends them, through the old angle_correction, and through optimize, drawn by a simulated galvo (2.5 kHz mirrors, 30 kpps):

as sent angle_correction optimize
star
ILDA test pattern
100 small stars

How to read them: thin grey lines are the shape asked for, the coloured line is where the beam goes with the laser on, dashed grey lines are blank jumps, faint dots are the points sent to the DAC (orange lit, grey blank).

  • As sent, the mirrors cannot keep up: the star is a squiggle in the middle, and the jumps between small stars are lit.
  • angle_correction holds the corners, but the beam overshoots every tip, and the ILDA pattern needs 14 000 points (2 frames per second).
  • optimize brakes before corners, waits for the mirrors after jumps, orders the strokes to shorten jumps, and fits the frame to the point budget.

Settings and tuning per laser: docs/optimizer.md.

Crashcourse

Without Docker, on a host with Rust and libudev-dev (./run.py docker does the same in containers):


# Copy and edit the settings file 
$ cp copyme.settings.toml settings.toml

# Populate the redis database 
$ cargo run --example populate_redis

# Run 
$ cargo run --release 

Examples

All of these run in Docker through ./run.py (./run.py --help, ./run.py <command> --help).

# Run tracer + Valkey (Redis-compatible, data in the lj_rust_valkey-data volume) with the DAC of your settings file
$ ./run.py docker --dac helios --config ./settings.toml
$ ./run.py docker --dac etherdream --config ./settings.toml -d   # in the background
$ ./run.py down                                                  # stop; --wipe also deletes the Valkey data

# Run the cargo binary against an already running Redis
$ ./run.py raw --config ./settings.toml

# See what a command would run, without running it
$ ./run.py --dry-run docker --dac dummy --config copyme.settings.toml

Checking drawings without a laser:

# Render every test frame (as sent, angle_correction, optimize) to PNG and compare with tests/visual.
# Prints a directory with the renders, the references and the differences, for review.
$ ./run.py visual
$ ./run.py visual --update     # after an intended change: review, then accept the new references

# One HTML page with every frame at several mirror speeds and damping, with quality numbers
$ ./run.py report --out report/optimizer.html

Tests:

# Gate: fmt, clippy, tests with coverage (Redis tests against a throwaway Valkey), run.py tests
# (CI also runs visual and test)
$ ./run.py check --lcov lcov.info
$ ./run.py fmt                 # format the sources

# End to end: Dummy DAC, throwaway Valkey on port 6380
$ ./run.py test

# Optimizer time per frame, and stress frames (huge, noisy, many strokes, broken input);
# the lj_rust:check image is built by ./run.py check
$ docker run --rm --entrypoint cargo lj_rust:check run --release --example optimizer_bench
$ docker run --rm --entrypoint cargo lj_rust:check run --release --example optimizer_stress
# What each optimizer setting does when pushed too far (ranges in docs/optimizer.md)
$ docker run --rm --entrypoint cargo lj_rust:check run --release --example optimizer_sweep

# Run your own frame (Redis line format) as sent, through angle_correction and optimize,
# with an optional profile override; SVGs go to the mounted directory
$ docker run --rm -v "$PWD/out:/out" --entrypoint cargo lj_rust:check run --release --example optimizer_frame -- tests/fixtures/client_frame_1440.ron /out "(accel: 60000.0)"

Hardware lab

Draws test frames on a real laser through LJ's Redis keys, films them with a webcam and writes a YAML record per frame. It needs a Helios on USB, a V4L2 camera and someone at the laser: it asks once to confirm the area is clear before drawing, then for an accept or reject per frame (a reason only on reject, optional).

# LJ (web UI on http://127.0.0.1:8080), a throwaway Valkey, the tracer on the Helios
$ ./run.py lab up --camera /dev/video4
# Fit the geometry to the test target (lab/calibration-A4.md, on the wall in the projection):
# small crosses, a homography, the EDH written to /EDH/0 as LJ does, the outline checked in mm.
# Lights the laser. Rerun after moving the projector or the sheet, and after any LJ restart.
$ ./run.py lab calibrate --camera /dev/video4
# Built-in frames: simulator shapes, stress frames, the ILDA test pattern
$ ./run.py lab list
# A series of frames (every built-in but `dot`), one keystroke per frame: Enter accepts, r rejects
$ ./run.py lab suite
$ ./run.py lab suite -- --patterns square,star,ilda --scale 0.5
# One frame; its folder (images, frame, record.yaml) is printed at the end
$ ./run.py lab run -- run --pattern ilda --scale 0.6
$ ./run.py lab run -- --help
# Stop; LJ's settings (calibration) stay in a volume, --wipe deletes them
$ ./run.py lab down

Measuring tools (OpenCV, in the analysis image) are in lab/analysis/: laser_on_target.py (a figure in mm on the sheet), figure_vs_frame.py (a drawn frame against the frame sent: deviation, coverage, leak), lens.py (camera lens calibration).

The tracer settings come from docker/lab.settings.toml (--config to use another file). The lab sets black before the tracer starts, after each run, on error and on Ctrl-C. The degenerate frame (non-finite points) is refused unless the settings include optimize.

Docker notes

  • The tracer runs on the host network: Etherdream discovery is a UDP broadcast.
  • Valkey listens on 127.0.0.1 only, on the port of redis_url.
  • Helios: install the udev rules on the host.

Documentation