Finish the x32 xy_microsteps default in the baseline test and the stream harness

The x32 default landed in forgetest/baseline.py and the driver, but two
callers still judged the machine at x8 and both failed on the host.

forgetest/tests/test_baseline.py: setUp seeded the fake machine from the x8
FIXED_SYSFS literals while enforce() compares against fixed_sysfs() of the
resolved mode, so x_mode, y_mode, step_freq and ramp_rate read as deviations
on a clean machine - 23 failures across BaselineTests and
TransientNotLeftoverTests. It seeds from fixed_sysfs() now, and the tick
expectations come from it (DEFAULT_TICK) rather than a typed 28160. The
xy_mode_of and ref_xy_mode unset/invalid cases expect 32, with an explicit
"8" case added that had no coverage. Two reference_preconfig dumps taken on
an x8 machine carry xy_microsteps = 8, because the markers are read at the
reference's own mode. wait_configured wrote the static CONFIGURED_MARKERS
where the function watches configured_markers() of the mode in force, and
the held-controller jog typed 221 steps for "4.144 mm", which is 1.036 mm at
x32; both derive from the mode now. 69 tests, all pass.

scripts/bench/laser_stream_test.py: STEPS_PER_MM was the x8 53.333, so the
X-peak check failed at 2133 steps against an expected 533. The whole harness
now derives from XY_MICROSTEPS_BASE/DEFAULT the way glowforge.h and
baseline.py do, which uncovered five more x8-only expectations behind the
first: the machine tick, the fire-gap limit (it grows as sqrt(k), not k - a
finer mode shortens the accel interval by sqrt(k) while speeding the tick by
k), the rung split in fire_spans, the density period and minimum burst
(laser_pulse_ticks is in x8 ticks and the stream scales it, so the config
keeps the x8 numbers and the measured lengths scale), and the decel/hold
budgets. Run against the null-sink build: all stream emission rules hold.

xy_mode_test.py's docstring still described the no-key case as x8 while its
own assertions had moved to x32.

No behavior change and no acceptance-catalog consequence: these are test
expectations and a bench harness, not image component sources. The coverage
lint is unchanged at 0 uncovered paths.
This commit is contained in:
ScottW514
2026-09-18 17:47:53 -04:00
parent 8850adcc5e
commit cd4c176a87
3 changed files with 94 additions and 46 deletions
+34 -17
View File
@@ -15,6 +15,11 @@ import unittest
from forgetest import baseline
# The fixed machine tick at the mode an unset xy_microsteps reads as.
# Taken from baseline rather than typed, so a change to the default mode
# moves the expectations with it.
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
class BaselineTests(unittest.TestCase):
def setUp(self):
@@ -26,8 +31,11 @@ class BaselineTests(unittest.TestCase):
for name in baseline.BUTTON_LEDS + ("lid_led",):
os.makedirs(self.leds + name)
self._led(name, "0")
# a clean machine
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS:
# A clean machine at the mode an unset xy_microsteps reads as:
# x/y_mode, step_freq and ramp_rate are the mode's, not the x8
# literals in FIXED_SYSFS, and that is what enforce() compares
# against.
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
self._attr(attr, val)
self._attr("cnc/interlock_circuit", "45")
self._attr("pic/lid_led", "0")
@@ -95,7 +103,7 @@ class BaselineTests(unittest.TestCase):
for x in left:
self.assertEqual(x.action, "restored", str(x))
self.assertEqual(self._read("cnc/motor_lock"), "0")
self.assertEqual(self._read("cnc/step_freq"), "28160")
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
self.assertEqual(self._read("cnc/streaming"), "0")
self.assertEqual(items["cnc/motor_lock"].found, "15")
self.assertEqual(items["cnc/motor_lock"].expected, "0")
@@ -158,14 +166,16 @@ class BaselineTests(unittest.TestCase):
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
b = self.bl()
cap = b.capture()
self._pos(221, 0, 0) # 4.144 mm into the held move
# 4.144 mm into the held move, in the steps of the mode in force
held_mm = 4.144
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
self.assertEqual(dev.sent[0], "^X")
jogs = [l for l in dev.sent if l.startswith("$J=")]
self.assertEqual(len(jogs), 1)
self.assertIn("X-4.144", jogs[0])
self.assertIn("X-%.3f" % held_mm, jogs[0])
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
finally:
dev.stop()
@@ -250,7 +260,7 @@ class BaselineTests(unittest.TestCase):
def test_fixed_constants_checked_against_a_dump(self):
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
diffs = baseline.check_fixed_against(ref, self.lines.append)
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
# -- the reference is taken after the controller applied its config -----
@@ -269,7 +279,8 @@ class BaselineTests(unittest.TestCase):
def sleep(_s):
calls["n"] += 1
if calls["n"] == 3: # the controller's init writes land
for attr, val in baseline.CONFIGURED_MARKERS:
# at the mode wait_controller_configured watches by default
for attr, val in baseline.configured_markers():
self._attr(attr, val)
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
@@ -307,11 +318,15 @@ class BaselineTests(unittest.TestCase):
def test_preconfig_reference_is_recognized(self):
self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
# The markers are read at the reference's own mode, so a dump taken
# on an x8 machine carries the setting that says so.
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
# a genuinely different single constant is a machine fact, not pre-config
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
# -- the XY microstep mode drives the fixed values ------------------------
@@ -330,16 +345,18 @@ class BaselineTests(unittest.TestCase):
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
def test_the_mode_is_read_from_the_settings_with_a_default(self):
# An unset or unusable key reads as the driver's default, x32.
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
self.assertEqual(baseline.xy_mode_of({}), 8)
self.assertEqual(baseline.xy_mode_of(None), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
self.assertEqual(baseline.xy_mode_of({}), 32)
self.assertEqual(baseline.xy_mode_of(None), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
self.assertEqual(baseline.ref_xy_mode({}), 8)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
self.assertEqual(baseline.ref_xy_mode({}), 32)
def test_fixed_constants_are_checked_at_the_reference_mode(self):
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
@@ -576,7 +593,7 @@ class BaselineModeTests(BaselineTests):
self._attr("pic/lid_led", "77")
left = self.bl().enforce("pre", captured=None)
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
self.assertEqual(self._read("cnc/step_freq"), "28160")
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
self.assertEqual(self._read("pic/lid_led"), "236")
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
+58 -27
View File
@@ -144,7 +144,17 @@ import time
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
PORT = 2399
STEPS_PER_MM = 53.333
# The XY microstep mode. No session below writes xy_microsteps, so every
# one of them runs at the driver's default (boards/glowforge.h
# XY_MICROSTEPS_DEFAULT). $100/$101 and the machine tick are the x8 base
# scaled by the mode, so both move together and a mode change is one
# edit here.
XY_MICROSTEPS_BASE = 8 # the factory x8 reference the scaling divides by
XY_MICROSTEPS_DEFAULT = 32 # the mode an unset xy_microsteps key reads as
XY_SCALE = XY_MICROSTEPS_DEFAULT // XY_MICROSTEPS_BASE
STEPS_PER_MM_X8 = 53.333 # DEFAULT_X/Y_STEPS_PER_MM, the x8 base
STEPS_PER_MM = STEPS_PER_MM_X8 * XY_SCALE # $100/$101 at the mode in force
# The S -> level mapping the board defaults produce: $30 = 1000, $31 = 0,
# and a $35 floor (boards/glowforge.h DEFAULT_SPINDLE_PWM_MIN_VALUE)
@@ -163,14 +173,20 @@ def duty_for(s):
"""Duty the core computes for an S word, floor included."""
return int(s * (PWM_PERIOD - PWM_MIN) / RPM_MAX) + PWM_MIN
# The machine tick: one stream byte per tick, so byte counts are
# durations. The factory travel tick is the x8 one (GF_TICK_X8_HZ),
# scaled with the mode so the ticks per step stay the same.
MACHINE_TICK_X8_HZ = 28160.0
MACHINE_TICK_HZ = MACHINE_TICK_X8_HZ * XY_SCALE
# Longest stepless run allowed to carry FIRE, in machine ticks. The
# slowest legitimate between-step interval in these jobs is the first
# step of an accel-from-rest: sqrt(2 * (1/53.333 mm) / 700 mm/s^2)
# = 7.3 ms = ~206 ticks at 28160 Hz. 500 gives >2x margin while staying
# far below any idle-gap pad run.
FIRE_GAP_LIMIT_TICKS = 500
# The machine tick: one stream byte per tick, so byte counts are durations.
MACHINE_TICK_HZ = 28160.0
# step of an accel-from-rest: sqrt(2 * (1/STEPS_PER_MM mm) / 700 mm/s^2),
# which at x8 is 7.3 ms = ~206 ticks at 28160 Hz. A finer mode shortens
# that interval by sqrt(k) but speeds the tick by k, so the interval
# measured in ticks grows as sqrt(k): ~412 ticks at x32. The limit scales
# with it to keep the same >2x margin while staying far below any
# idle-gap pad run.
FIRE_GAP_LIMIT_TICKS = int(500 * XY_SCALE ** 0.5)
WAIT_IDLE = ("wait_idle",)
@@ -254,8 +270,15 @@ ANALOG_FLOOR_DEFAULT_PCT = 16.0
ANALOG_CONF = ("laser_power_model = analog\n"
"laser_dose_curve = off\n"
"laser_floor_analog = %g\n" % PWM_MIN_PCT)
# Both keys are in ticks of the x8 reference tick, and the stream scales
# them to the tick in force, so the period is a time at every microstep
# mode (glowforge_laser.c, laser_pulse_ticks). The config carries the x8
# numbers; the burst lengths measured out of the stream are in the mode's
# own machine ticks, so the expectations are scaled.
DENSITY_PERIOD = 20
DENSITY_MIN_TICKS = 3
DENSITY_PERIOD_TICKS = DENSITY_PERIOD * XY_SCALE
DENSITY_MIN_MACHINE_TICKS = DENSITY_MIN_TICKS * XY_SCALE
DENSITY_CONF_BASE = ("laser_pulse_ticks = %d\n"
"laser_pulse_min_ticks = %d\n"
% (DENSITY_PERIOD, DENSITY_MIN_TICKS))
@@ -634,11 +657,12 @@ def holds_in(ticks, min_run=2000):
# The deceleration into a hold from F3000 (50 mm/s) at the board's
# 700 mm/s^2: 71 ms, about 2000 ticks. A gate that closes with the
# hold darkens all of it but the producer's lead (10 ms, ~280 ticks);
# a fire state that outlives the gate lights it to the last step.
# 700 mm/s^2: 71 ms, about 2000 ticks at x8. A gate that closes with the
# hold darkens all of it but the producer's lead (10 ms, ~280 ticks at
# x8); a fire state that outlives the gate lights it to the last step.
# Both are durations, so the tick counts scale with the mode's tick.
DECEL_TICKS = int(50.0 / 700.0 * MACHINE_TICK_HZ)
DECEL_DARK_MIN = DECEL_TICKS - 600
DECEL_DARK_MIN = DECEL_TICKS - 600 * XY_SCALE
def dark_lead(ticks, hold):
@@ -662,10 +686,11 @@ def check_decel_dark(name, ticks, hold, what):
# A lit deceleration ends within the producer's lead (10 ms) plus one
# shipper period (10 ms) of the stop: the gate closes when the core
# reports the hold complete, and the bytes produced ahead of the cursor
# by then ship dark. 1000 ticks is 35 ms, under 0.2 mm at the end of a
# ramp from 50 mm/s; a gate that closed before the head stopped shows
# as the whole deceleration (~2000 ticks) or more.
DECEL_LIT_MAX = 1000
# by then ship dark. 1000 x8 ticks is 35 ms, under 0.2 mm at the end of
# a ramp from 50 mm/s; a gate that closed before the head stopped shows
# as the whole deceleration (DECEL_TICKS) or more. The budget is a
# duration, so it scales with the mode's tick.
DECEL_LIT_MAX = 1000 * XY_SCALE
def check_decel_lit(name, ticks, hold, what):
@@ -829,10 +854,15 @@ def check_power_ladder(name, data, expect):
return counts
def fire_spans(ticks, gap=500):
RUNG_SPLIT_TICKS = 500 * XY_SCALE
def fire_spans(ticks, gap=RUNG_SPLIT_TICKS):
"""Tick spans carrying fire, split on dark gaps (the G0 between
rungs). Within a rung the model's own dark stretches are at most a
couple of base periods, far below the split."""
couple of base periods, far below the split. Both the G0 gap and
those stretches are measured in machine ticks, so the split scales
with the microstep mode's tick."""
spans = []
start = last = None
for i, b in enumerate(ticks):
@@ -1006,8 +1036,8 @@ def main():
# Unfloored through the config key (laser_floor_density = 0), which
# the arm loads into $35.
dens = run_session("density", JOB_LADDER, conf=DENSITY_CONF)
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD,
DENSITY_MIN_TICKS)
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD_TICKS,
DENSITY_MIN_MACHINE_TICKS)
check_termination("density", dens)
if "laser armed (density, floor 0 %, curve off)" not in run_session.text:
fail("[density] the arm did not select the density model at floor 0")
@@ -1069,7 +1099,7 @@ def main():
cur = b & 0x7F
elif b & 0x10:
fire_by_duty[cur] = fire_by_duty.get(cur, 0) + 1
want_ticks = IDLE_S_MM / (IDLE_S_FEED / 60.0) * 28160
want_ticks = IDLE_S_MM / (IDLE_S_FEED / 60.0) * MACHINE_TICK_HZ
for level in IDLE_S_LEVELS:
duty = duty_for(level)
got = fire_by_duty.get(duty, 0)
@@ -1117,8 +1147,8 @@ def main():
# every rung renders through it.
floored = run_session("floor-derived", JOB_DENSITY, conf=DENSITY_CONF_FLOORED)
expect_levels = tuple(duty_for(x) for x in LADDER_S)
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD,
DENSITY_MIN_TICKS)
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD_TICKS,
DENSITY_MIN_MACHINE_TICKS)
if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in run_session.text:
fail("[floor-derived] the arm report does not name the derived floor "
"(text: %r)" % run_session.text[-400:])
@@ -1197,8 +1227,8 @@ def main():
# first-window check is what pins the M3 resume, which once ran dark
# for a segment buffer because the segments prepped while held
# carried no spindle update.
HOLD_WIN = 704 # 25 ms at 28160 Hz
HOLD_EDGE = 120 # one pulse straddles each edge
HOLD_WIN = int(0.025 * MACHINE_TICK_HZ) # 25 ms at the machine tick
HOLD_EDGE = int(120 * XY_SCALE) # one pulse straddles each edge
for mode, job in (("m4", ["G90", "G21", "M4 S0", "G1 X150 F6000 S500"]),
("m3", ["G90", "G21", "M3 S500", "G1 X150 F6000"])):
steps = job + [("sleep", 0.7), ("rt", b"!"), ("wait_state", "Hold:0"),
@@ -1249,7 +1279,7 @@ def main():
# and the stretch it moved in between ships dark. The clean verdict
# then resumes the hold the client took, and the rest of the line
# cuts lit; M2 closes the window and locks.
TICK_HZ = 28160
TICK_HZ = MACHINE_TICK_HZ
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
("verdict", "hold"), ("wait_state", "Hold:0"), ("sleep", 0.5),
("rt", b"~"), ("sleep", 1.2), ("wait_state", "Hold:0"),
@@ -1481,8 +1511,9 @@ def main():
# Runs sit back to back in the dump, so the three moves are told
# apart by their steps: each is 10 mm, and the cut is the last third.
total = sum(1 for t in ticks if t & 0x01)
if abs(total - 3 * 533) > 12:
fail("[jog-dark] %d X steps, expected about %d (three 10 mm moves)" % (total, 3 * 533))
want_jog = 3 * round(10 * STEPS_PER_MM)
if abs(total - want_jog) > 12 * XY_SCALE:
fail("[jog-dark] %d X steps, expected about %d (three 10 mm moves)" % (total, want_jog))
cut_from = 2 * (total // 3) - 2 # a fire tick may lead the cut's first step
jog_fire = cut_fire = steps = 0
for t in ticks:
+2 -2
View File
@@ -11,12 +11,12 @@ never takes them typed: $100/$101, the machine tick, and the kernel stop
ramp. This harness drives the native grblHAL_glowforge binary in
null-sink mode (no hardware, no root) over TCP, one process per config:
1. no key: x8, $100/$101 = 53.333, the 28160 Hz tick
1. no key: x32, $100/$101 = 213.333, the 112640 Hz tick
2. xy_microsteps = 16: 106.667, the 56320 Hz tick; a typed $100 is
overwritten on the spot; $110 is left alone under a tick that
carries it
3. xy_microsteps = 32: 213.333, the 112640 Hz tick
4. a value that is not a mode: x8 with a warning in the log
4. a value that is not a mode: x32 with a warning in the log
5. GFSINK_RATE lowered under the mode's tick: $110/$111 are held at
the feed the tick carries, one step per tick per axis