Skip to content

Waveform motion control (G123)

MaD runs cyclic / fatigue waveforms — a position that oscillates as a continuous function of time, repeated for many cycles. This page explains how that works end to end, the control structure behind it, and why it is built the way it is.

In one sentence

The host sends one compact command (G123 → a WaveformMove); the servo driver generates the trajectory itself, evaluating the commanded position from a phase accumulator each control tick, and closes a position loop on the encoder to follow it — measured to within 1 µm.

Why not just send G-code segments?

The alternative is to expand a waveform on the host into ~32 short G1 segments per cycle. Compared with generating it in the firmware:

Concern Host segment expansion Firmware-native waveform
Smoothness each segment is a discrete move → stop → move one continuous trajectory
Cycle count a 1 M-cycle fatigue test = 32 M G-code lines (impossible) one command, any cycle count
Timing per-segment feedrate + queue latency drift the frequency phase advanced from the control clock, exactly

So for fatigue-scale, smooth, frequency-accurate motion the trajectory has to be generated in the firmware, close to the hardware.

The cycle is a template, not a shape

One cycle is four segments, in phase order:

      [ hold at +A ]   [ traverse down ]   [ hold at -A ]   [ traverse up ]
         dwellHigh                            dwellLow
      |<---------------------- one period, 1/f ---------------------->|

shape names only the traverse — how the carriage gets from one peak to the other. Dwell and skew are separate parameters that apply to every shape.

That factorisation is the point. If shape named a whole waveform, every combination anyone wanted ("triangle with a hold", "skewed sine") would need its own variant, and the list would grow forever while the grammar stayed rigid.

A traverse is a normalised curve s(u), with u and s both in [0,1] and zero slope at both ends:

shape traverse s(u)
0 SINE half-cosine (1 − cos(πu)) / 2
1 TRIANGLE constant rate with ramps (trapezoidal) see below

The zero-slope end condition is the whole trick: it means a traverse can butt against a hold, against rest, or against another traverse without a velocity step the machine cannot deliver. Dwell and skew therefore compose freely instead of each needing its own feasibility argument.

A plain sinusoid falls out of this template as the zero-dwell, symmetric, sine-traverse case — centre + A·cos(2πφ) exactly (phase 0 being the top of the template; a rising wave starts half a cycle in, at the bottom).

Starting and stopping at rest

A wave starts at rest where the carriage is, which is one of its peaks, and ends there after whole cycles. A positive amplitude rises first, so the carriage is at the lower peak and the wave is centred A above it: start + A·(1 − cos 2πft). A negative amplitude falls first, from the upper peak. In the cycle template that is the start of the rising (or falling) traverse.

A cosine has zero speed at its peaks, so starting and stopping there asks the carriage for no jump in speed: a feasible wave is followed exactly from its first tick. (A sinusoid started at its centre is already at full speed ωA there, which a carriage at rest cannot match without infinite acceleration.)

The wave still goes through the servo's follower, which every motion that computes its own setpoint shares:

flowchart LR
    A["desired setpoint<br/>(the wave, evaluated from phase)"] --> B["follower<br/>pass through when within maxVel / maxAccel,<br/>else catch up at maxAccel"] --> C["commanded setpoint"] --> D["feedback<br/>feed-forward + Kp·error"]

Where the desired trajectory is within the machine's speed and acceleration limits, which feasibility already guarantees for a wave, it passes straight through. The follower only acts if a trajectory asks for more than that.

History

The driver once started at the centre and only matched the wave's speed, so the travel lost getting up to speed stayed lost and the oscillation sat Vpeak²/(2·Amax) off its centre (0.82 mm on the 5 mm / 1 Hz case). It then started at the upper peak with extra full-speed moves out to it and back. Starting at rest where the carriage is removes both.

The triangle traverse

A literal triangle reverses velocity instantly at its corners, demanding infinite acceleration. The deliverable realisation is a trapezoidal rate profile: ramp at maxAccel, cruise, ramp down.

Writing r for the ramp's share of the traverse, the whole profile collapses to that one parameter:

r = (1 − √(1 − 8A / (a·τ²))) / 2

and the traverse is feasible exactly when that root is real, i.e. a·τ² ≥ 8A. r → 0.5 is a pure triangular rate with no cruise; smaller r is a longer cruise. It is solved once when the waveform is accepted, so the 1 kHz tick does no division and no square root.

Inside the control loop

dev_servo_run() ticks on the MOTOR cog at 1 kHz. In OSCILLATE mode the setpoint is evaluated from the phase, never integrated from a rate — so there is no accumulator that can fall behind and stay behind.

flowchart TB
    PH["phase += step<br/>(exact integer DDS)"]
    EV["excursion = A · s(phase)<br/>evaluated, not integrated"]
    VEL["setpointVel = (excursion(next) − excursion(now)) / dt<br/>the interval AVERAGE"]
    FB["cmdVel = setpointVel + Kp·(setpoint − encoder)"]
    PH --> EV --> VEL --> FB

Two details carry most of the accuracy:

Command the interval average, not the instantaneous velocity. They differ by a·dt/2, and that difference is a bias of one sign for a whole half cycle — so the position loop has to stand off by a·dt/(2·Kp) just to generate it. That is 2.4 µm of standing error against a perfect plant. Differencing the evaluated waveform removes it exactly, for any shape. This is only possible because the setpoint is evaluated rather than integrated.

Difference the excursion, not the absolute position. The centre can be 24,576,000 counts out at the far end of the machine, where a float's ulp is 2 counts; differencing two absolute positions there folds a rounding error into a per-tick displacement of only ~63 counts. Keeping the subtraction on the excursion makes tracking independent of where along the machine the test sits.

The phase accumulator is exact

The phase is a wrapping 32-bit accumulator — the wrap is the cycle boundary, so there is no modulo and no elapsed-time counter. The increment is

Δφ = freq_µHz · elapsed_µs · 2³² / 10¹²

and 10¹² = 2¹² · 5¹², so the 2¹² cancels and the ratio is exactly 2²⁰ / 5¹² — two integers. Dividing by 5¹² and carrying the remainder into the next tick makes the accumulated phase exact for all time, whatever the tick jitter.

Truncating the step instead (the obvious (uint32_t)(f·dt·2³²)) loses 0.296 phase units per tick at 1 Hz: 1.9 µm of position error after an hour and 45.7 µm after a day. A fatigue run is precisely the case that suffers, and precisely the case a two-second test cannot see.

FlexC cannot hold a named 64-bit local

const uint64_t n = (uint64_t)a * b; fails the whole build with Cannot handle expression yet, reported against a libc file rather than the offending line. The same arithmetic as a single expression compiles fine — it is the 64-bit destination FlexC mishandles, the same root as its dest64 = cond ? A : B bug. The accumulator therefore goes through lib_utility_muldivmod64_unsigned, which reaches the P2's CORDIC (QMUL, then SETQ+QDIV with the remainder in QY) and never materialises a 64-bit C variable.

Refused, not approximated

A waveform the machine cannot deliver is rejected, not run at whatever the limiter allows. Running a smaller one instead produces a specimen that never saw the loading the report claims it did — on a fatigue test that is a wrong result, not a slow one.

Feasibility is decided per traverse, because skew and dwell make the two halves different lengths: a cycle can be achievable going down and impossible coming back up, and approving it on an average would run a profile nobody asked for. Holds that together exceed the period are refused for the same reason — the alternative is silently trimming the dwell, and dwell is the variable the test exists to study.

The acceleration budget also bounds the discretisation error for free. Between samples the carriage travels a chord where the ideal is an arc, and that error is at most a·dt²/8 = 0.6 counts (0.075 µm) — under 8% of the 1 µm budget, for every waveform the driver accepts. No separate sampling check is needed.

End-to-end data flow

A waveform is one record in the test program, self-contained on the SD card so a test runs unattended.

flowchart TB
    UI["Control app<br/>generateTestGcode → 'G123 A.. F.. C.. W.. H.. L.. S..'"]
    ENC["gcode.ts<br/>parse + validate → WaveformMove (18-byte wire)"]
    SLAVE["app_messageSlave<br/>ProtoEmb_onWrite_test_waveform"]
    SD["SD program record<br/>app_motion_move_t.wave (its own fields)"]
    MOT["app_motion<br/>unit conversion + completion check"]
    SRV["dev_servo (OSCILLATE)<br/>generates and tracks the trajectory"]
    UI --> ENC --> SLAVE --> SD --> MOT --> SRV

The waveform carries its own fields rather than borrowing a move's position/feedrate/pause slots. Smuggling it through those is how the shape bit came to live in the top byte of the feedrate — where app_motion then masked it off and evaluated a sine regardless, so every triangle ever run was a sine.

app_motion's whole remaining job is unit conversion and asking the driver whether it is done. Completion is the driver's arrival verdict: it alone knows how many cycles have elapsed and whether the closing move has landed.

The G-code

G123 A<mm> F<Hz> C<cycles> [W<shape>] [H<s>] [L<s>] [S<skew>]
Letter Meaning Unit Wire resolution
A peak excursion from the mean (not peak-to-peak); its sign is the direction it sets off in mm 0.1 µm
F frequency of the whole cycle, holds included Hz 1 µHz
C whole cycles count 1
W traverse profile — 0 sine, 1 triangle — 1 byte
H hold at the upper peak s 1 ms
L hold at the lower peak s 1 ms
S share of the traversing time spent descending; 0.5 symmetric ratio 1 ‰
E chirp: the frequency it sweeps to, linearly across the C cycles (phase-continuous); 0 = no sweep; a plain sine only (no W1, H, L, S) Hz 1 µHz

Omitting H, L and S gives a hold-free symmetric cycle — exactly what G123 has always meant, so an existing program keeps its meaning.

Dwell is per peak because creep-fatigue is a hold at peak tension with no hold in compression; a single symmetric dwell cannot express the test that most needs one. Holds take their time from the traverses, not from the period, so adding a hold never silently changes a fatigue test's frequency.

G1 X12.5 F31.4                    ; ramp to the mean
G123 A5 F1 C1000 W0               ; sine, 5 mm, 1 Hz, 1000 cycles
G123 A5 F0.5 C200 W1 H2.0 L0      ; triangle, 2 s hold at peak tension only
G123 A2 F0.2 C50 W0 S0.8          ; 80% of the cycle loading, 20% unloading
G122                              ; signal test complete

Resolutions are set by a physical limit rather than a round number: an encoder count is 0.122 µm, so 0.1 µm means the wire is no longer the coarsest step in the chain; and the phase accumulator is exact in µHz, so nothing is lost after the frequency is quantised.

Changing the wire format is a hard break, by design

The generated runtime gates the waveform write on an exact payload-size match, so a new host against old firmware NACKs at upload rather than running a misread test. The rule that follows: WaveformMove's layout must never change without changing its byte count.

Limits & future work

  • Dwell and skew are not authorable in the Create UI yet — the grammar, the wire and the firmware all carry them; hand-written G-code can use them.
  • No force or strain loop. The waveform is a position trajectory. True load-controlled fatigue (closing a loop on the force gauge) is a separate project; this generator is a prerequisite for it.
  • Tracking margin at the envelope's edge. At the fastest waveform the driver will accept, the worst deviation is 0.844 µm — inside the 1 µm contract, but the dominant term is the transient after each velocity reversal, which decays with a 125-tick time constant at Kp = 8 against a 142-tick half cycle at 3.5 Hz. Raising Kp would reclaim most of that margin and wants validating on hardware.
  • More traverse profiles. shape is a whole byte with 2 of 256 used.

How it is tested

Verified at three levels, so the recorded data is proven to match the commanded trajectory rather than merely to oscillate:

Level What it proves Where
Unit (web) G123 parse / validate / encode, refusal of unknown shapes, overfull holds and impossible skew; golden wire bytes derived by hand from the field table domain/gcode.test.ts, protocol/codec.parity.test.ts
Unit (firmware) the measured encoder position stays within 1 µm of an independently recomputed trajectory, across an amplitude/frequency sweep, at five centres spanning the machine's travel, at the exact feasibility boundary, and with dwell and skew in play; the phase returns to exactly zero after whole cycles test/test_dev_servo
End-to-end (SIL) the recorded position from a full run (host → firmware → gantry → CSV) fits the commanded waveform: least-squares sinusoid fit R² > 0.8, amplitude, cycle count, and the centre to 0.15 mm; for triangles, the fitted fundamental is 8/π² = 0.811 of the peak rather than a sine's 1.000 e2e/run-all.mjs (WAVE-*)

That last row is the one that catches a shape being ignored. Amplitude, frequency, centre and cycle count are identical between a sine and a triangle, so every other assertion passes either way — which is exactly how the shape bit stayed masked off for as long as it did, with every test green.