mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
Prove the density dose model host-side; close the idle-gap level loss
Four new sessions in the stream harness cover the model (grblHAL-glowforge 2bca017, pinned here). Density renders the commanded level exactly - levels 2, 3, 7, 15, 25 and 38 came back as 0.0158, 0.0237, 0.0551, 0.1182, 0.1969 and 0.2993 against level/127 of 0.01575, 0.02362, 0.05512, 0.11811, 0.19685 and 0.29921 - S1000 renders 1.0000 and still ends dark, every power byte carries full duty, and a level change inside a run costs no stream byte where analog ships one per level. Rule 13 is the one worth having: the same job run under both models produces an identical motion grid tick for tick, and all 20051 density FIRE ticks fall inside the 169776 the analog run fired. The model masks the core's fire state and never sources one, measured rather than argued. Rule 14 covers the idle-gap fix: a standalone S between moves, from a sender slow enough to drain the planner, now fires each move at its own level (28338 ticks each at duties 30, 52 and 84). Before the fix duty 30 held all 85014 and the other two levels never appeared. That closes "Next work" item 18, which this work opened earlier today. The harness now derives its expectations from the board's floor and chains two launches over one settings file, because the core precomputes the S to duty mapping once when the spindle is enabled: $35 written at runtime persists and reports immediately but only enters force at the next controller start. That is recorded in BRINGUP beside the existing defaults note, and laser.power-floor's failure message now says so. Acceptance: the density path shipping off by default is inert until laser_power_model is set, and the laser tests' covers already name grblhal-glowforge src/**; the model's own acceptance test waits on the bench drill that picks the base period.
This commit is contained in:
+51
-2
@@ -169,6 +169,14 @@ core mutex stands in for interrupt masking. `GFSINK` unset = null-sink mode
|
|||||||
`^X` mid-motion aborts via kernel `cnc/stop` (controlled decel) and raises an
|
`^X` mid-motion aborts via kernel `cnc/stop` (controlled decel) and raises an
|
||||||
alarm; TCP disconnects never kill the process (the dead-man fd stays held).
|
alarm; TCP disconnects never kill the process (the dead-man fd stays held).
|
||||||
|
|
||||||
|
**Spindle `$`-settings take effect at controller start.** The core
|
||||||
|
precomputes the S -> duty mapping once, when the spindle is enabled, and a
|
||||||
|
settings write does not re-run it: `$35=16` persists to the eeprom
|
||||||
|
immediately and `$$` reports it immediately, but the mapping in force is
|
||||||
|
still the one loaded at start until the controller restarts (a mode switch,
|
||||||
|
a `POST /controller/stop` + `start`, or a boot). Verified host-side: after a
|
||||||
|
runtime `$35=0` the shipped duties stay floored.
|
||||||
|
|
||||||
**Stored `$`-settings beat freshly baked defaults** — after changing
|
**Stored `$`-settings beat freshly baked defaults** — after changing
|
||||||
`GLOWFORGE_DEFAULTS` values, run `$RST=$` once on the board (settings persist
|
`GLOWFORGE_DEFAULTS` values, run `$RST=$` once on the board (settings persist
|
||||||
in the eeprom file in `/data`).
|
in the eeprom file in `/data`).
|
||||||
@@ -194,6 +202,31 @@ of the first tick byte it covers, FIRE as bit 4 OR'd into tick bytes. The
|
|||||||
spindle PWM is precomputed to a period of exactly 127, so computed values ARE
|
spindle PWM is precomputed to a period of exactly 127, so computed values ARE
|
||||||
power bytes (`$30` default 1000 → S1000 = 127).
|
power bytes (`$30` default 1000 → S1000 = 127).
|
||||||
|
|
||||||
|
**Dose model.** `laser_power_model` in the shared machine config selects how
|
||||||
|
the shipper renders the per-segment value the core computes: `analog` (the
|
||||||
|
default) ships it as a power byte, `density` pins the duty at full and
|
||||||
|
modulates the FIRE bit instead - a base period of `laser_pulse_ticks`
|
||||||
|
(default 20 = 710 us at 28160 Hz, the factory's ~1.43 kHz) whose on-count is
|
||||||
|
dithered between adjacent integers with the remainder carried, so densities
|
||||||
|
finer than one tick per period average out. The model is selected per arm
|
||||||
|
and reported (`laser armed (density)`). Density is what the tube's dead band
|
||||||
|
below its lasing threshold requires: every pulse it emits is full-power, so
|
||||||
|
no commanded level lands in the band, and a level change inside a run costs
|
||||||
|
no stream byte at all. It wants `$35` = 0 - the floor exists only to keep an
|
||||||
|
analog duty out of the band, and under density it just clamps the light end
|
||||||
|
of the range; the arm warns when a floor is set. Structurally the model is a
|
||||||
|
mask on the core's fire state and never a source of one, so emission stays
|
||||||
|
exactly where the core commanded it.
|
||||||
|
|
||||||
|
An S word takes effect whether or not motion is in progress. Per-segment
|
||||||
|
updates carry the level inside a laser block, but an S executed between
|
||||||
|
blocks - with the planner drained, so nothing is streaming - arrives only
|
||||||
|
through the synchronous spindle path, which publishes the duty without
|
||||||
|
touching the fire state; and the next run re-asserts the laser state the
|
||||||
|
core last asked for at its first byte, fire only inside an armed window.
|
||||||
|
Without those two a standalone S from a sender slow enough to drain the
|
||||||
|
planner left the following moves cutting at a stale duty, or dark.
|
||||||
|
|
||||||
Contract rules enforced structurally: a power byte leads every kernel run
|
Contract rules enforced structurally: a power byte leads every kernel run
|
||||||
before any fire bit (a run start resets duty to ~100 %), transitions are
|
before any fire bit (a run start resets duty to ~100 %), transitions are
|
||||||
coalesced per tick so power bytes are never consecutive, and power bytes cost
|
coalesced per tick so power bytes are never consecutive, and power bytes cost
|
||||||
@@ -1042,8 +1075,24 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
|
|||||||
the factory's, which still lets dose per unit length rise ~1.8× at a
|
the factory's, which still lets dose per unit length rise ~1.8× at a
|
||||||
corner.
|
corner.
|
||||||
|
|
||||||
Owed: the density model itself. `$35` and the analog path stay as the
|
The model itself is implemented and host-proven, off by default
|
||||||
fallback until it lands.
|
(`laser_power_model`, above). What the harness holds: density renders
|
||||||
|
the commanded level exactly (level/127 to four decimals at every rung),
|
||||||
|
no level ever reaches PWMSAR, a level change inside a run costs no
|
||||||
|
stream byte where analog pays one each, and - run against the same job
|
||||||
|
under both models - the motion grid is identical and every density FIRE
|
||||||
|
tick is one the analog run also fired, so the model only ever masks.
|
||||||
|
|
||||||
|
Owed: one bench drill to choose the base period, which is the parameter
|
||||||
|
the host cannot answer. The factory never emits a pulse shorter than
|
||||||
|
100 us; a tick here is 35.5 us, and every pulse restarts the discharge,
|
||||||
|
so each carries the strike transient the threshold ladder made visible
|
||||||
|
- dose per pulse is therefore probably not proportional to pulse length
|
||||||
|
and density -> dose may be superlinear at the low end. A density ladder
|
||||||
|
on scrap at two or three `laser_pulse_ticks` values answers both that
|
||||||
|
and the shortest pulse that marks reliably. grblHAL is userspace, so it
|
||||||
|
deploys by replacing the binary; no image flash. Then the raster path
|
||||||
|
below.
|
||||||
|
|
||||||
What that model means for image engraving, since it decides the design as
|
What that model means for image engraving, since it decides the design as
|
||||||
much as cutting does. LightBurn has two image paths. Its 1-bit modes
|
much as cutting does. LightBurn has two image paths. Its 1-bit modes
|
||||||
|
|||||||
@@ -2775,6 +2775,91 @@ still gets the copy, capture off writes nothing). No acceptance-catalog
|
|||||||
consequence: the path is an off-by-default debug capture with no bearing on
|
consequence: the path is an off-by-default debug capture with no bearing on
|
||||||
emission, motion or the release surface.
|
emission, motion or the release surface.
|
||||||
|
|
||||||
|
## 2026-08-17 — the density dose model, phases 1 and 2
|
||||||
|
|
||||||
|
Implemented and host-proven; off by default, so nothing about a shipped
|
||||||
|
machine changes until `laser_power_model = density` is set.
|
||||||
|
|
||||||
|
### The change
|
||||||
|
|
||||||
|
The whole hot path is one predicate in the shipper:
|
||||||
|
|
||||||
|
if(gf.cur_fire) -> if(gf.cur_fire && (!gf.dith_period || dither_tick()))
|
||||||
|
b |= 0x10; b |= 0x10;
|
||||||
|
|
||||||
|
That `&&` is the safety property, structurally: the model masks the core's
|
||||||
|
fire state and can never be a source of one, so it stays out of the safety
|
||||||
|
argument entirely — the armed window, the latch, the coolant gates and the
|
||||||
|
hardware chain are all upstream and untouched.
|
||||||
|
|
||||||
|
Around it: a fixed base period of `laser_pulse_ticks` (default 20 = 710 us
|
||||||
|
at 28160 Hz, the factory's ~1.43 kHz), on-count `level x period / 127` with
|
||||||
|
the remainder carried across periods so finer densities average out, the
|
||||||
|
on-ticks leading each period so a level renders as one burst rather than
|
||||||
|
isolated ticks. The accumulator resets only where the dose itself restarts —
|
||||||
|
run boundary, fire off, disarm, abort — never per segment. In density mode
|
||||||
|
the duty is pinned: a power byte still leads every kernel run, because the
|
||||||
|
run start resets the hardware duty, but it always carries full duty and a
|
||||||
|
level never reaches PWMSAR. Selected per arm from the shared machine config
|
||||||
|
and reported as `laser armed (density)`; the arm warns when `$35` is set,
|
||||||
|
since the floor only clamps the light end of a range that cannot fall into
|
||||||
|
the dead band anyway.
|
||||||
|
|
||||||
|
### What the harness holds (rules 11-13)
|
||||||
|
|
||||||
|
- Density renders the commanded level exactly: levels 2, 3, 7, 15, 25, 38
|
||||||
|
came back as 0.0158, 0.0237, 0.0551, 0.1182, 0.1969, 0.2993 against
|
||||||
|
level/127 of 0.01575, 0.02362, 0.05512, 0.11811, 0.19685, 0.29921.
|
||||||
|
- S1000 renders density 1.0000 and still ends dark.
|
||||||
|
- Every power byte carries full duty; a level change inside a run costs no
|
||||||
|
stream byte, where analog ships one per level (4 bytes, duties 0/30/52/84,
|
||||||
|
against density's 1).
|
||||||
|
- The mask invariant, measured rather than argued: the same job run under
|
||||||
|
both models produced an identical motion grid tick for tick, and all
|
||||||
|
20051 density FIRE ticks fell inside the 169776 the analog run fired.
|
||||||
|
- Churn (planner-starve run boundaries) still terminates dark under the
|
||||||
|
model, with no FIRE across a stepless gap.
|
||||||
|
|
||||||
|
The analog path is byte-identical to before the change — same byte counts,
|
||||||
|
duties and fire ticks on every pre-existing session — so the fallback is
|
||||||
|
intact.
|
||||||
|
|
||||||
|
### Two things the work turned up
|
||||||
|
|
||||||
|
**Spindle `$`-settings take effect at controller start, not at the write.**
|
||||||
|
The core precomputes the S -> duty mapping once, when the spindle is
|
||||||
|
enabled; a settings write does not re-run it. After a runtime `$35=0` the
|
||||||
|
shipped duties stayed floored at 57/64/73/127. So `$35=16` set on the bench
|
||||||
|
earlier today persisted immediately and was reported by `$$` immediately,
|
||||||
|
but only entered force at the next controller restart — which the capture
|
||||||
|
work then supplied. The harness now models this the way an operator would:
|
||||||
|
one launch writes the setting, the next runs the job.
|
||||||
|
|
||||||
|
**A laser state change made while the stream is idle was lost — found,
|
||||||
|
root-caused and fixed.** Reproduced in both dose models, so it was not the
|
||||||
|
density model's doing: with a line-at-a-time sender and moves long enough to
|
||||||
|
drain the planner, `S100 / G1 X5 / S300 / G1 X5 / S600 / G1 X5` fired only
|
||||||
|
the first move and shipped duty 30 three times.
|
||||||
|
|
||||||
|
It was two faults wearing one symptom, and fixing the first exposed the
|
||||||
|
second. `gf_stream_laser()` dropped transitions while nothing was streaming,
|
||||||
|
so nothing re-asserted the state for the next run, which a run end leaves
|
||||||
|
dark — the stream engine now records the state the core last asked for
|
||||||
|
whether or not it is streaming, and re-asserts it at the first byte of the
|
||||||
|
next run, fire only inside an armed window (an abort clears it, so a closed
|
||||||
|
window can never be resurrected). With that in, all three moves fired, and
|
||||||
|
all three fired at duty 30: the level had never reached the driver at all,
|
||||||
|
because `spindleSetState` discarded its `rpm` argument. Per-segment updates
|
||||||
|
carry the level inside a laser block, but an S executed between blocks
|
||||||
|
arrives only through that synchronous path. It now publishes the duty, and
|
||||||
|
only the duty — fire stays where `spindleUpdatePWM` and its gates put it,
|
||||||
|
so the new path carries no consent to fire.
|
||||||
|
|
||||||
|
Rule 14 in the harness is the regression: the same standalone-S job must
|
||||||
|
show each level firing its own move. It does — 28338 fire ticks each at
|
||||||
|
duties 30, 52 and 84, where before the fix duty 30 held all 85014 and the
|
||||||
|
two other levels never appeared.
|
||||||
|
|
||||||
## Superseded status notes
|
## Superseded status notes
|
||||||
|
|
||||||
### Shared machine services — remaining polish, as listed 2026-08-13
|
### Shared machine services — remaining polish, as listed 2026-08-13
|
||||||
|
|||||||
@@ -232,7 +232,8 @@ def wait_disarm(ctx, timeout):
|
|||||||
"unfloored, M4's velocity-scaled power falls into the dead band at every corner "
|
"unfloored, M4's velocity-scaled power falls into the dead band at every corner "
|
||||||
"and reversal and marks nothing. Reads $$ and checks the floor is the "
|
"and reversal and marks nothing. Reads $$ and checks the floor is the "
|
||||||
"commissioned percent, that $31 is 0 (the floor, not $31, sets the bottom of "
|
"commissioned percent, that $31 is 0 (the floor, not $31, sets the bottom of "
|
||||||
"the range), and that the floor lands at or above PWMSAR 20.")
|
"the range), and that the floor lands at or above PWMSAR 20. Reports the "
|
||||||
|
"stored setting, which is the one in force after any controller start.")
|
||||||
def power_floor(ctx):
|
def power_floor(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
with ctx.grbl() as g:
|
with ctx.grbl() as g:
|
||||||
@@ -258,7 +259,9 @@ def power_floor(ctx):
|
|||||||
|
|
||||||
ctx.check(abs(floor_pct - POWER_FLOOR_PCT) < 0.05,
|
ctx.check(abs(floor_pct - POWER_FLOOR_PCT) < 0.05,
|
||||||
"$35 is %.1f %%, expected the commissioned %.1f %% - a machine carrying stored "
|
"$35 is %.1f %%, expected the commissioned %.1f %% - a machine carrying stored "
|
||||||
"settings from before the floor needs `$RST=$` once", floor_pct, POWER_FLOOR_PCT)
|
"settings from before the floor needs `$35=16` (or `$RST=$`) and then a "
|
||||||
|
"controller restart, since the spindle mapping is precomputed at start",
|
||||||
|
floor_pct, POWER_FLOOR_PCT)
|
||||||
ctx.check(counts >= PWMSAR_FLOOR_MIN,
|
ctx.check(counts >= PWMSAR_FLOOR_MIN,
|
||||||
"the floor lands at PWMSAR %d, below the %d the tube needs to lase",
|
"the floor lands at PWMSAR %d, below the %d the tube needs to lase",
|
||||||
counts, PWMSAR_FLOOR_MIN)
|
counts, PWMSAR_FLOOR_MIN)
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# changes; keep only SRCREV and PV here - the image manifest leaves
|
# changes; keep only SRCREV and PV here - the image manifest leaves
|
||||||
# *-pin.inc out of the layer content hash because the component entry
|
# *-pin.inc out of the layer content hash because the component entry
|
||||||
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "9466f7650a4722cb42a18fe96a359b2a8b3a639f"
|
SRCREV = "2bca017854291f4084ca2bdf6f1ffaed88ff6d4f"
|
||||||
PV = "0.1.0"
|
PV = "0.1.0"
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
|||||||
ahead of the rung's power byte would burn at full power), and the
|
ahead of the rung's power byte would burn at full power), and the
|
||||||
fire ticks divide evenly across the rungs, which is what fails if a
|
fire ticks divide evenly across the rungs, which is what fails if a
|
||||||
rung's opening ticks carry the previous rung's duty
|
rung's opening ticks carry the previous rung's duty
|
||||||
|
11. under the density dose model no level ever reaches PWMSAR: every
|
||||||
|
power byte carries full duty (one still leads each kernel run), and
|
||||||
|
a level change inside a run costs no stream byte at all
|
||||||
|
12. density matches the level the core commanded, rung by rung, and no
|
||||||
|
burst is longer than the base period
|
||||||
|
13. the model is a mask and never a source: run the same job under both
|
||||||
|
models and every FIRE tick of the density run is a FIRE tick of the
|
||||||
|
analog run, on an identical motion grid
|
||||||
|
14. a laser state change made while the stream is idle survives to the
|
||||||
|
next run: a standalone S word between moves, from a sender slow
|
||||||
|
enough to drain the planner, must still cut at the level it asked
|
||||||
|
for rather than dark at a stale duty
|
||||||
|
|
||||||
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
|
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
|
||||||
"""
|
"""
|
||||||
@@ -119,6 +131,42 @@ for _i, _s in enumerate(LADDER_S):
|
|||||||
JOB_LADDER.append("M5")
|
JOB_LADDER.append("M5")
|
||||||
|
|
||||||
|
|
||||||
|
# Sessions E-G: the density dose model. $35 = 0 for the ladder because
|
||||||
|
# the floor exists only to keep an analog duty out of the tube's dead
|
||||||
|
# band - under density every pulse is full-power, and a floor would just
|
||||||
|
# clamp the light end of the range.
|
||||||
|
DENSITY_PERIOD = 20
|
||||||
|
DENSITY_CONF = ("laser_power_model = density\n"
|
||||||
|
"laser_pulse_ticks = %d\n" % DENSITY_PERIOD)
|
||||||
|
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
|
||||||
|
JOB_DENSITY = ["$35=0"] + JOB_LADDER
|
||||||
|
|
||||||
|
# Session H: three levels inside one kernel run. The moves are short and
|
||||||
|
# fast so the planner never drains, and each carries its own S word, so
|
||||||
|
# the level changes land mid-run. Analog pays a power byte per level;
|
||||||
|
# density pays none, because the level rides the FIRE bits.
|
||||||
|
JOB_LEVELS = ["G91", "G21", "M3"]
|
||||||
|
for _s in (100, 300, 600):
|
||||||
|
for _ in range(20):
|
||||||
|
JOB_LEVELS.append("G1 X0.5 F3000 S%d" % _s)
|
||||||
|
JOB_LEVELS.append("M5")
|
||||||
|
|
||||||
|
|
||||||
|
# Session I: the levels arrive on their own lines, and the moves are long
|
||||||
|
# enough that the planner drains between them, so each S is executed with
|
||||||
|
# nothing streaming. The state has no event to ride and must be
|
||||||
|
# re-asserted at the next run's first byte.
|
||||||
|
IDLE_S_LEVELS = (100, 300, 600)
|
||||||
|
IDLE_S_MM = 5.0
|
||||||
|
IDLE_S_FEED = 300
|
||||||
|
JOB_IDLE_S = ["G91", "G21", "M3"]
|
||||||
|
for _i, _s in enumerate(IDLE_S_LEVELS):
|
||||||
|
JOB_IDLE_S.append("S%d" % _s)
|
||||||
|
JOB_IDLE_S.append("G1 X%g F%d" % (IDLE_S_MM if _i % 2 == 0 else -IDLE_S_MM,
|
||||||
|
IDLE_S_FEED))
|
||||||
|
JOB_IDLE_S.append("M5")
|
||||||
|
|
||||||
|
|
||||||
def fail(msg):
|
def fail(msg):
|
||||||
print("FAIL: %s" % msg)
|
print("FAIL: %s" % msg)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -181,14 +229,26 @@ def publish_verdicts(path, stop):
|
|||||||
stop.wait(0.5)
|
stop.wait(0.5)
|
||||||
|
|
||||||
|
|
||||||
def run_session(name, steps):
|
def run_session(name, steps, conf=None, workdir=None, keep=False,
|
||||||
"""Launch the controller, run the job steps, return the dump bytes."""
|
arm_required=True):
|
||||||
workdir = tempfile.mkdtemp(prefix="laser-test-")
|
"""Launch the controller, run the job steps, return the dump bytes.
|
||||||
|
|
||||||
|
Pass workdir + keep to chain launches over one settings file: the
|
||||||
|
core precomputes the spindle PWM mapping once, when the spindle is
|
||||||
|
enabled, so a $35 written at runtime only takes effect on the next
|
||||||
|
controller start."""
|
||||||
|
if workdir is None:
|
||||||
|
workdir = tempfile.mkdtemp(prefix="laser-test-")
|
||||||
dump = os.path.join(workdir, "stream.bin")
|
dump = os.path.join(workdir, "stream.bin")
|
||||||
verdict = os.path.join(workdir, "cooling.state")
|
verdict = os.path.join(workdir, "cooling.state")
|
||||||
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict,
|
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict,
|
||||||
FFLOG_STDERR="1")
|
FFLOG_STDERR="1")
|
||||||
env.pop("GFSINK", None)
|
env.pop("GFSINK", None)
|
||||||
|
if conf is not None:
|
||||||
|
conf_path = os.path.join(workdir, "forgefirm.conf")
|
||||||
|
with open(conf_path, "w") as f:
|
||||||
|
f.write(conf)
|
||||||
|
env["GFHOME_CONF"] = conf_path
|
||||||
|
|
||||||
stop = threading.Event()
|
stop = threading.Event()
|
||||||
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
|
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
|
||||||
@@ -226,9 +286,10 @@ def run_session(name, steps):
|
|||||||
# is wall-paced), then for the Idle report.
|
# is wall-paced), then for the Idle report.
|
||||||
wait_idle(sock, log)
|
wait_idle(sock, log)
|
||||||
time.sleep(1.0) # let the shipper drain the tail
|
time.sleep(1.0) # let the shipper drain the tail
|
||||||
|
|
||||||
text = "".join(log)
|
text = "".join(log)
|
||||||
if "laser armed" not in text:
|
|
||||||
|
run_session.text = text
|
||||||
|
if arm_required and "laser armed" not in text:
|
||||||
fail("[%s] no 'laser armed' message (arming flow did not run)" % name)
|
fail("[%s] no 'laser armed' message (arming flow did not run)" % name)
|
||||||
|
|
||||||
sock.close()
|
sock.close()
|
||||||
@@ -242,9 +303,10 @@ def run_session(name, steps):
|
|||||||
pub.join(2)
|
pub.join(2)
|
||||||
|
|
||||||
data = open(dump, "rb").read()
|
data = open(dump, "rb").read()
|
||||||
if not data:
|
if not data and arm_required:
|
||||||
fail("[%s] empty stream dump" % name)
|
fail("[%s] empty stream dump" % name)
|
||||||
shutil.rmtree(workdir, ignore_errors=True)
|
if not keep:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -386,6 +448,80 @@ def check_power_ladder(name, data, expect):
|
|||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def fire_spans(ticks, gap=500):
|
||||||
|
"""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."""
|
||||||
|
spans = []
|
||||||
|
start = last = None
|
||||||
|
for i, b in enumerate(ticks):
|
||||||
|
if b & 0x10:
|
||||||
|
if start is None:
|
||||||
|
start = i
|
||||||
|
elif i - last > gap:
|
||||||
|
spans.append((start, last + 1))
|
||||||
|
start = i
|
||||||
|
last = i
|
||||||
|
if start is not None:
|
||||||
|
spans.append((start, last + 1))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def check_density(name, data, levels, period):
|
||||||
|
"""Rules 11-12: pinned duty, and density per rung matching the level."""
|
||||||
|
# A power byte still leads every kernel run - the run start resets the
|
||||||
|
# hardware duty - but under this model it only ever carries full duty:
|
||||||
|
# the level rides the FIRE bits, never PWMSAR.
|
||||||
|
powers = [b & 0x7F for b in data if b & 0x80]
|
||||||
|
if not powers or set(powers) != {PWM_PERIOD}:
|
||||||
|
fail("[%s] density mode shipped power bytes %s; every one must be "
|
||||||
|
"full duty, or a level reached PWMSAR" % (name, sorted(set(powers))))
|
||||||
|
|
||||||
|
ticks = tick_bytes(data)
|
||||||
|
spans = fire_spans(ticks)
|
||||||
|
if len(spans) != len(levels):
|
||||||
|
fail("[%s] %d fire spans, expected one per rung (%d): %s"
|
||||||
|
% (name, len(spans), len(levels), spans[:8]))
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for (a, b), level in zip(spans, levels):
|
||||||
|
seg = ticks[a:b]
|
||||||
|
got = sum(1 for t in seg if t & 0x10) / float(len(seg))
|
||||||
|
want = level / float(PWM_PERIOD)
|
||||||
|
out.append((level, round(got, 4)))
|
||||||
|
# A span is clipped to whole ticks, not whole periods, so allow a
|
||||||
|
# little slack at the edges; the accumulator carries the rest.
|
||||||
|
if abs(got - want) > max(0.01, want * 0.06):
|
||||||
|
fail("[%s] level %d rendered density %.4f, expected %.4f"
|
||||||
|
% (name, level, got, want))
|
||||||
|
run = worst = 0
|
||||||
|
for t in seg:
|
||||||
|
run = run + 1 if t & 0x10 else 0
|
||||||
|
worst = max(worst, run)
|
||||||
|
if worst > period:
|
||||||
|
fail("[%s] level %d burst of %d ticks exceeds the %d-tick base "
|
||||||
|
"period" % (name, level, worst, period))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def check_mask(analog, density):
|
||||||
|
"""Rule 13: same motion, and density fire is a subset of analog fire."""
|
||||||
|
ta, td = tick_bytes(analog), tick_bytes(density)
|
||||||
|
if len(ta) != len(td):
|
||||||
|
fail("[mask] tick counts differ (analog %d, density %d): the two runs "
|
||||||
|
"are not the same motion" % (len(ta), len(td)))
|
||||||
|
for i, (a, b) in enumerate(zip(ta, td)):
|
||||||
|
if (a & ~0x10) != (b & ~0x10):
|
||||||
|
fail("[mask] motion differs at tick %d (analog 0x%02x, density "
|
||||||
|
"0x%02x)" % (i, a, b))
|
||||||
|
stray = [i for i, (a, b) in enumerate(zip(ta, td)) if (b & 0x10) and not (a & 0x10)]
|
||||||
|
if stray:
|
||||||
|
fail("[mask] density fired %d tick(s) the core never commanded, first "
|
||||||
|
"at %d - the model is acting as a source of emission, not a mask"
|
||||||
|
% (len(stray), stray[0]))
|
||||||
|
return sum(1 for b in td if b & 0x10), sum(1 for a in ta if a & 0x10)
|
||||||
|
|
||||||
|
|
||||||
def count_fire(data):
|
def count_fire(data):
|
||||||
return sum(1 for b in tick_bytes(data) if b & 0x10)
|
return sum(1 for b in tick_bytes(data) if b & 0x10)
|
||||||
|
|
||||||
@@ -428,6 +564,89 @@ def main():
|
|||||||
% (len(data), list(LADDER_DUTY),
|
% (len(data), list(LADDER_DUTY),
|
||||||
[counts[d] for d in LADDER_DUTY], gap_d))
|
[counts[d] for d in LADDER_DUTY], gap_d))
|
||||||
|
|
||||||
|
# --- session E: the same ladder under the density model -------------
|
||||||
|
# $35 is written by a first launch and takes effect on the second:
|
||||||
|
# the floor exists only to keep an analog duty out of the tube's
|
||||||
|
# dead band, and under density it would just clamp the light end.
|
||||||
|
wd = tempfile.mkdtemp(prefix="laser-test-")
|
||||||
|
run_session("density-setup", ["$35=0"], conf=DENSITY_CONF, workdir=wd,
|
||||||
|
keep=True, arm_required=False)
|
||||||
|
dens = run_session("density", JOB_DENSITY, conf=DENSITY_CONF, workdir=wd)
|
||||||
|
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD)
|
||||||
|
check_termination("density", dens)
|
||||||
|
if "laser armed (density)" not in run_session.text:
|
||||||
|
fail("[density] the arm did not select the density model")
|
||||||
|
print("PASS [density]: %d bytes, %d power bytes all at full duty, "
|
||||||
|
"level->density %s"
|
||||||
|
% (len(dens), sum(1 for b in dens if b & 0x80), rendered))
|
||||||
|
|
||||||
|
# --- rule 13: the model masks, it never sources ---------------------
|
||||||
|
d_fire, a_fire = check_mask(data, dens)
|
||||||
|
print("PASS [mask]: identical motion grid, %d density fire ticks all "
|
||||||
|
"inside the %d the core commanded" % (d_fire, a_fire))
|
||||||
|
|
||||||
|
# --- session F: full level under the model is continuous fire -------
|
||||||
|
full = run_session("density-full", ["$35=0"] + JOB_M3_TERM, conf=DENSITY_CONF)
|
||||||
|
ticks = tick_bytes(full)
|
||||||
|
spans = fire_spans(ticks)
|
||||||
|
if not spans:
|
||||||
|
fail("[density-full] no FIRE bits in the stream")
|
||||||
|
a, b = spans[0]
|
||||||
|
got = sum(1 for t in ticks[a:b] if t & 0x10) / float(b - a)
|
||||||
|
if got != 1.0:
|
||||||
|
fail("[density-full] S1000 rendered density %.4f, expected 1.0" % got)
|
||||||
|
check_termination("density-full", full)
|
||||||
|
print("PASS [density-full]: S1000 -> density 1.0000 over %d ticks, ends dark"
|
||||||
|
% (b - a))
|
||||||
|
|
||||||
|
# --- session G: churn under the model (rules 7-9 still hold) --------
|
||||||
|
ch = run_session("density-churn", ["$35=0"] + JOB_CHURN, conf=DENSITY_CONF)
|
||||||
|
if not count_fire(ch):
|
||||||
|
fail("[density-churn] no FIRE bits in the stream")
|
||||||
|
check_termination("density-churn", ch)
|
||||||
|
gap_e = check_fire_gaps("density-churn", ch)
|
||||||
|
print("PASS [density-churn]: %d bytes, %d fire ticks, max fire gap %d"
|
||||||
|
% (len(ch), count_fire(ch), gap_e))
|
||||||
|
|
||||||
|
# --- session H: a level change inside a run costs no byte -----------
|
||||||
|
lv_a = run_session("levels-analog", JOB_LEVELS)
|
||||||
|
lv_d = run_session("levels-density", JOB_LEVELS, conf=DENSITY_CONF)
|
||||||
|
pa = [b & 0x7F for b in lv_a if b & 0x80]
|
||||||
|
pd = [b & 0x7F for b in lv_d if b & 0x80]
|
||||||
|
if len([d for d in set(pa) if d]) < 3:
|
||||||
|
fail("[levels] the analog run shipped duties %s: fewer than the three "
|
||||||
|
"commanded levels, so the job is not exercising in-run changes"
|
||||||
|
% sorted(set(pa)))
|
||||||
|
if set(pd) != {PWM_PERIOD}:
|
||||||
|
fail("[levels] density shipped a level as duty: %s" % sorted(set(pd)))
|
||||||
|
if len(pd) >= len(pa):
|
||||||
|
fail("[levels] density shipped %d power bytes against analog's %d - "
|
||||||
|
"the level changes are still costing stream bytes" % (len(pd), len(pa)))
|
||||||
|
print("PASS [levels]: analog %d power bytes %s, density %d at full duty"
|
||||||
|
% (len(pa), sorted(set(pa)), len(pd)))
|
||||||
|
|
||||||
|
# --- session I: a level set while idle still cuts (rule 14) ---------
|
||||||
|
idle_s = run_session("idle-s", JOB_IDLE_S)
|
||||||
|
fire_by_duty = {}
|
||||||
|
cur = None
|
||||||
|
for b in idle_s:
|
||||||
|
if b & 0x80:
|
||||||
|
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
|
||||||
|
for level in IDLE_S_LEVELS:
|
||||||
|
duty = duty_for(level)
|
||||||
|
got = fire_by_duty.get(duty, 0)
|
||||||
|
if got < want_ticks * 0.9:
|
||||||
|
fail("[idle-s] S%d (duty %d) fired %d ticks, expected ~%d: a level "
|
||||||
|
"set while the stream was idle was dropped and the move ran "
|
||||||
|
"dark or at a stale duty (all: %s)"
|
||||||
|
% (level, duty, got, want_ticks, fire_by_duty))
|
||||||
|
check_termination("idle-s", idle_s)
|
||||||
|
print("PASS [idle-s]: standalone S across idle gaps -> fire ticks per duty %s"
|
||||||
|
% {duty_for(l): fire_by_duty[duty_for(l)] for l in IDLE_S_LEVELS})
|
||||||
|
|
||||||
print("PASS: all stream emission rules hold")
|
print("PASS: all stream emission rules hold")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user