mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
Laser: the rapids after an M5 ship dark and the next job fires; the performance-curve drill
Stream harness rules 16 and 17 with their sessions: an M5 executed with the planner drained and the kernel run over must darken the rapids that follow it (m5-idle), and a job whose M3 runs at the level the previous job ended at must still fire its first cut (next-job). The second rule is the core's contract: set_state records the rpm and the per-segment update is skipped while it is unchanged, so the driver's set_state is the only thing that can light that move. Both sessions run under both dose models; the bench build that went dark on its second job fails next-job with one fire span. Bench drills: pcurve (a per-level ladder of 100 mm lines read from the HV current and the head thermopile at 25 Hz, with the instrument checks and the JSON record) and m5dark (one line, M5, two rapids, judged on the current trace and laser_on_sampled until the armed window closes). Catalog: laser.m5-rapid-dark, a live test of the M5 case (46 tests; the counts in BRINGUP follow). CAMPAIGN-LOG carries the day's record: the two curve ladders, the defect pair, the root cause, the host and bench proof.
This commit is contained in:
+6
-6
@@ -48,7 +48,7 @@ hardware-validated.**
|
|||||||
modes (cancel-and-return on a lid or interlock open, button pause/resume),
|
modes (cancel-and-return on a lid or interlock open, button pause/resume),
|
||||||
bench-validated 2026-08-17.
|
bench-validated 2026-08-17.
|
||||||
- **Releases are gated by the acceptance tool** (`forgetest`, dev image only):
|
- **Releases are gated by the acceptance tool** (`forgetest`, dev image only):
|
||||||
a 45-test catalog, domain-scoped inheritance, an always-required safety core,
|
a 46-test catalog, domain-scoped inheritance, an always-required safety core,
|
||||||
a bench actuator that works the lid, the interlock and the button so most of
|
a bench actuator that works the lid, the interlock and the button so most of
|
||||||
the operator's part runs unattended, and a release gate that reads the
|
the operator's part runs unattended, and a release gate that reads the
|
||||||
exported artifact. The latest full campaign, on dev image `20260824230512`,
|
exported artifact. The latest full campaign, on dev image `20260824230512`,
|
||||||
@@ -588,7 +588,7 @@ the export reads "Release authorized: YES" for that image's manifest. That
|
|||||||
authorizes a release; it is not one until `releases/v<version>/acceptance.json`
|
authorizes a release; it is not one until `releases/v<version>/acceptance.json`
|
||||||
is committed.
|
is committed.
|
||||||
|
|
||||||
- **Catalog: 45 tests** in `forgetest/forgetest/suite/`, every one a port of a
|
- **Catalog: 46 tests** in `forgetest/forgetest/suite/`, every one a port of a
|
||||||
proven bench drill or a bench-verified check: the always-required core
|
proven bench drill or a bench-verified check: the always-required core
|
||||||
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
|
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
|
||||||
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
|
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
|
||||||
@@ -597,14 +597,14 @@ is committed.
|
|||||||
quiet after motion, a gate setting tripping and off by value, a fan under
|
quiet after motion, a gate setting tripping and off by value, a fan under
|
||||||
its floor), `camera.*`,
|
its floor), `camera.*`,
|
||||||
`laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
|
`laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
|
||||||
pause/resume/lid-cancel) and `cloud.*` (the service protocol answered by
|
pause/resume/lid-cancel, the rapids after an M5 shipping dark) and `cloud.*` (the service protocol answered by
|
||||||
the emulator in this machine's identity, with only the app to drive; the
|
the emulator in this machine's identity, with only the app to drive; the
|
||||||
mode round trip with the lid-open hunt and the web-service homing on it;
|
mode round trip with the lid-open hunt and the web-service homing on it;
|
||||||
one real print; and the job-behavior tests under the offline service:
|
one real print; and the job-behavior tests under the offline service:
|
||||||
the cloud client driven from a local socket with a synthesized
|
the cloud client driven from a local socket with a synthesized
|
||||||
laser-free job, no account, no network, nothing on the bed). Tests that
|
laser-free job, no account, no network, nothing on the bed). Tests that
|
||||||
share a setup are merged; the `auto` tests stay separate for failure
|
share a setup are merged; the `auto` tests stay separate for failure
|
||||||
isolation. 28 are `auto`, 9 `operator`, 8 `live`; with the bench actuator up,
|
isolation. 28 are `auto`, 9 `operator`, 9 `live`; with the bench actuator up,
|
||||||
eight of the operator tests run in the unattended queue.
|
eight of the operator tests run in the unattended queue.
|
||||||
- **The operator's part is asked for by name, not by popup**
|
- **The operator's part is asked for by name, not by popup**
|
||||||
(`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a
|
(`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a
|
||||||
@@ -614,8 +614,8 @@ is committed.
|
|||||||
emission witness's mark). The head accelerometer, the beam detector, the
|
emission witness's mark). The head accelerometer, the beam detector, the
|
||||||
button LEDs, and a lid-lamp toggle between two snapshots replaced the
|
button LEDs, and a lid-lamp toggle between two snapshots replaced the
|
||||||
other eyeball confirmations; `kernel.fire-line` and `camera.snapshot` are
|
other eyeball confirmations; `kernel.fire-line` and `camera.snapshot` are
|
||||||
`auto`. With the bench actuator up the attended block is the nine tests
|
`auto`. With the bench actuator up the attended block is the ten tests
|
||||||
that need a person (four laser live, five cloud): 12 minutes on dev image
|
that need a person (five laser live, five cloud): 12 minutes on dev image
|
||||||
`20260824230512`. A test's implementation hash is its own function plus its
|
`20260824230512`. A test's implementation hash is its own function plus its
|
||||||
module's shared code, so a fix inside one test re-requires that test
|
module's shared code, so a fix inside one test re-requires that test
|
||||||
alone.
|
alone.
|
||||||
|
|||||||
@@ -4045,6 +4045,86 @@ open question, whether ForgeFIRM's load wants the heatsink the factory's
|
|||||||
never did, closes with this entry: it does not. The per-job SoC range and
|
never did, closes with this entry: it does not. The per-job SoC range and
|
||||||
the throttle log line stay as the running record.
|
the throttle log line stay as the running record.
|
||||||
|
|
||||||
|
## 2026-08-25: the performance-curve ladders, and the rapids that fired after M5
|
||||||
|
|
||||||
|
The day opened with a new instrument. The head carries a thermopile that
|
||||||
|
reads scatter off the beam inside the head, upstream of the mirror that turns
|
||||||
|
it down to the work, so it sees the beam and not the material. A ladder of
|
||||||
|
100 mm lines at 10 mm/s, one per level, sampled from sysfs at 25 Hz along
|
||||||
|
with the HV current, is a performance curve for this tube and supply, and the
|
||||||
|
`pcurve` drill in `scripts/bench/live_fire_drills.py` runs it.
|
||||||
|
|
||||||
|
- **Analog ladder (E1), `$35` = 0, 13 rungs from 16 to 100 percent plus a
|
||||||
|
repeat of rung 7:** the current is proportional to duty above 30 percent
|
||||||
|
(slope 959 counts per 100 percent, r-squared 0.9999) and reaches 990 at
|
||||||
|
full, so this PSU's ADC does not clip; below 30 percent the discharge is
|
||||||
|
unstable. The thermopile is monotonic to 85 percent and puts the lasing
|
||||||
|
knee between 19.7 and 22.8 percent duty, not at 16, with the strike spot
|
||||||
|
showing as a first-second spike on the low rungs; its baseline holds within
|
||||||
|
50 counts over the ladder and the repeat rung reads 3.5 percent high. It
|
||||||
|
does not settle inside a line above about 50 percent (swings of 20 to 30
|
||||||
|
percent at constant current), so the top of the analog curve is not yet a
|
||||||
|
measurement. Record `pcurve_analog_20260825-195947.json`.
|
||||||
|
- **Density ladder (E3), `$35` = 0, period 20, minimum 3, 13 rungs from 1 to
|
||||||
|
100 percent:** the dose is strongly convex in density at a 710 us period
|
||||||
|
(80 percent of density reads 0.53 of full, 60 reads 0.37, 45 reads 0.21,
|
||||||
|
30 reads 0.07), while `laser_on_sampled` tracked the commanded on-fraction
|
||||||
|
exactly, so the drive delivered what was asked and the light did not
|
||||||
|
follow. Whether that is the per-pulse strike deficit or the sensor is the
|
||||||
|
next ladder's question. Lines were flat inside to within a few percent.
|
||||||
|
Record `pcurve_density_20260825-202317.json`.
|
||||||
|
|
||||||
|
**The rapids fired after `M5`.** Seen by the operator on the density block
|
||||||
|
and confirmed in both traces: the pulsed current ran on through the `G0`
|
||||||
|
back and the `G0` up after every line, at the rung's level, and through a
|
||||||
|
bare `G0` sent with no `M3` at all. Under density that is full-power light
|
||||||
|
where nothing was commanded. `M5` executed with the stream idle only stored
|
||||||
|
the off state; the stream re-asserts its wanted fire state at the first byte
|
||||||
|
of every run, and the wanted state was still the last cut's true. Live fire
|
||||||
|
stopped.
|
||||||
|
|
||||||
|
**The first fix made the second job dark.** Pushing `fire=false` on `M5`
|
||||||
|
darkened the rapids (bench run 1 passed: the current fell from 393 to 0
|
||||||
|
inside one 40 ms sample) and then every following job in the same controller
|
||||||
|
process shipped no fire at all (runs 2 and 3, HV 0..0, motion ran). That was
|
||||||
|
first read as hardware, with the `laser power-good degraded` warning as the
|
||||||
|
suspect. It was software, and it reproduces on the null sink with two jobs
|
||||||
|
in one process: the second G1 ships zero FIRE ticks under both models.
|
||||||
|
|
||||||
|
**The root cause is a core contract.** grblHAL's per-segment laser update is
|
||||||
|
edge-triggered on rpm: `set_state(on, rpm)` records the rpm, and a block at
|
||||||
|
that same rpm gets no `update_pwm`, because the core takes the driver's
|
||||||
|
`set_state` as having lit the laser. Our `spindleSetState` pushed the duty
|
||||||
|
only. A process's first job always fired because the parser starts in G0,
|
||||||
|
where the `M3` and `S` words run at rpm 0 and the first G1 differs; after
|
||||||
|
`M2` the motion mode is G1 and S is modal, so the next job's `M3` runs at the
|
||||||
|
old level, the core records it, and nothing lights the G1 except the stale
|
||||||
|
wanted state. The old build fired job 2 by that accident, the same stale flag
|
||||||
|
that lit the rapids; removing the accident exposed the hole.
|
||||||
|
|
||||||
|
**The fix, and its proof.** `spindleSetState` now computes the pwm for the
|
||||||
|
state it is given (the off value when off, refused, or rpm 0) and pushes it
|
||||||
|
through `spindleUpdatePWM`, the whole state through the same armed and
|
||||||
|
coolant gates; the duty-only stream call is gone. Harness rule 17 and the
|
||||||
|
`next-job` sessions (two jobs in one process, `M2` between, same S) join
|
||||||
|
rule 16 and the `m5-idle` sessions; the build that went dark fails the new
|
||||||
|
session with one fire span, and the fix passes all 14 stream sessions, the
|
||||||
|
13 lifecycle cases and the arm test. On the bench, with the corrected
|
||||||
|
controller hot-installed: `m5dark` run 4 (the process's first job) and run 5
|
||||||
|
(its second, the case that went dark) both passed, 2.00 s of discharge, the
|
||||||
|
`M5` taking the current to 0 inside one sample, both rapids and every dwell
|
||||||
|
dark over 11.4 s of sampling, the operator confirming by eye. The catalog
|
||||||
|
gains `laser.m5-rapid-dark` (46 tests).
|
||||||
|
|
||||||
|
**Power-good is not a witness of anything here.** The factory 2.6.0 binary
|
||||||
|
carries no power-good string at all; ForgeFIRM warns on it once per armed
|
||||||
|
window and reports it in `/status`, and nothing gates fire on it. On this PSU
|
||||||
|
it reads not-good at full tube current.
|
||||||
|
|
||||||
|
A bench note for the next hot install: a file copied to the board with `scp`
|
||||||
|
lands without its execute bit, and busybox `cp` keeps that, so the supervisor
|
||||||
|
loops on exit 127 until a `chmod 755`.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -396,6 +396,83 @@ def emission_witness(ctx):
|
|||||||
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
|
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
|
||||||
|
|
||||||
|
|
||||||
|
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
|
||||||
|
|
||||||
|
|
||||||
|
@test("laser.m5-rapid-dark", title="The rapids after an M5 ship dark",
|
||||||
|
subsystem="laser", kind="live", mode="grbl", est_min=3,
|
||||||
|
covers=_LASER_COVERS,
|
||||||
|
requires=["laser.emission-witness"], actions=["button"],
|
||||||
|
steps=["Scrap under the head with 20 mm of free +X travel; lid closed; exhaust on.",
|
||||||
|
"Press the physical button when it lights white (the arm)."],
|
||||||
|
description="A 20 mm line at constant power (M3 S400/F600), then M5, a dwell, a rapid back "
|
||||||
|
"over the line, a dwell, a rapid forward, a dwell, and the program end. M5 "
|
||||||
|
"executes with the planner drained and the kernel run over, and the core "
|
||||||
|
"issues no per-segment laser update for moves made with the spindle off, so "
|
||||||
|
"only the stream's own wanted state decides whether those rapids fire; a stale "
|
||||||
|
"true there lights them at the last level, full duty under the density model. "
|
||||||
|
"The kernel's LASER_ON sample count must go to 0 after the M5 and stay 0 "
|
||||||
|
"through both rapids, and the HV current must stay at its idle reading.")
|
||||||
|
def m5_rapid_dark(ctx):
|
||||||
|
ev = ctx.evidence
|
||||||
|
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||||
|
prepare(ctx, g)
|
||||||
|
base = sample(ctx)
|
||||||
|
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||||
|
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||||
|
ctx.ready(ARM_CUE % "20 mm +X")
|
||||||
|
job = ["G91", "G21", "M3", "S400",
|
||||||
|
"G1 X20 F600",
|
||||||
|
"M5", "G4 P2.5",
|
||||||
|
"G0 X-20", "G4 P2.5",
|
||||||
|
"G0 X20", "G4 P2.5",
|
||||||
|
"G90", "M2"]
|
||||||
|
ctx.arm_press()
|
||||||
|
try:
|
||||||
|
samples = run_and_sample(ctx, g, job)
|
||||||
|
# The controller reports Idle inside a G4 dwell, so the sampler
|
||||||
|
# above can return before the rapids; the window closing at M2
|
||||||
|
# is the end of the job. Keep sampling until then.
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 30:
|
||||||
|
ctx.checkpoint()
|
||||||
|
smp = sample(ctx)
|
||||||
|
if smp:
|
||||||
|
samples.append(smp)
|
||||||
|
if not smp["armed"]:
|
||||||
|
break
|
||||||
|
time.sleep(0.125)
|
||||||
|
finally:
|
||||||
|
ctx.clear_notice()
|
||||||
|
emis = [(s["t"], s["emission"], s["hv"]) for s in samples if s["emission"] is not None]
|
||||||
|
peak = max((e for _t, e, _hv in emis), default=0)
|
||||||
|
ctx.check(peak > 0, "no emission witnessed on the G1 (emission_samples stayed 0)")
|
||||||
|
# The first zero after the peak is the dark window the M5 and its dwell
|
||||||
|
# produce; everything after it is the two rapids and their dwells.
|
||||||
|
i_peak = max(range(len(emis)), key=lambda i: emis[i][1])
|
||||||
|
after = [x for x in emis[i_peak:] if x[1] == 0]
|
||||||
|
ctx.check(after, "emission_samples never returned to 0 after the M5")
|
||||||
|
t_dark = after[0][0]
|
||||||
|
tail = [x for x in emis if x[0] >= t_dark]
|
||||||
|
relit = [x for x in tail if x[1]]
|
||||||
|
hv_tail = max((hv for _t, _e, hv in tail if hv is not None), default=0)
|
||||||
|
ev.update({"samples": len(samples), "emission_peak": peak, "tail_samples": len(tail),
|
||||||
|
"relit_samples": len(relit), "hv_tail_max": hv_tail,
|
||||||
|
"relit_first": relit[0] if relit else None})
|
||||||
|
ctx.log("emission peak %s; dark from +%.1f s; %d samples after it spanning %.1f s, %d with "
|
||||||
|
"emission, HV max after dark %s", peak, t_dark - emis[0][0], len(tail),
|
||||||
|
tail[-1][0] - t_dark, len(relit), hv_tail)
|
||||||
|
ctx.check(tail[-1][0] - t_dark >= 5.0, "sampling ended %.1f s after the dark point, before "
|
||||||
|
"both rapids and their dwells (~5.2 s) had run", tail[-1][0] - t_dark)
|
||||||
|
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%.1f s "
|
||||||
|
"(emission_samples %s) - a rapid after M5 fired at the last level",
|
||||||
|
len(relit), (relit[0][0] - t_dark) if relit else 0, relit[0][1] if relit else None)
|
||||||
|
ctx.check(hv_tail <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge "
|
||||||
|
"ran through a rapid", hv_tail)
|
||||||
|
ctx.log("PASS: emission peak %s on the G1, 0 through both rapids, HV %s after the M5",
|
||||||
|
peak, hv_tail)
|
||||||
|
|
||||||
|
|
||||||
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
|
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
|
||||||
kind="live", mode="grbl", est_min=4,
|
kind="live", mode="grbl", est_min=4,
|
||||||
covers=_LASER_COVERS,
|
covers=_LASER_COVERS,
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ page's takeover does that; from a host, stop them first.
|
|||||||
| `fire_test.py` | FIRE drop-timing scope test (runs on the board): A = latch locked (expects nothing on FIRE/LASER_ON), B = latch unlocked / normal end-of-data, U = true underrun. Duty 0 throughout; refuses to unlock if HV reports good. |
|
| `fire_test.py` | FIRE drop-timing scope test (runs on the board): A = latch locked (expects nothing on FIRE/LASER_ON), B = latch unlocked / normal end-of-data, U = true underrun. Duty 0 throughout; refuses to unlock if HV reports good. |
|
||||||
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. |
|
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. |
|
||||||
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
|
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
|
||||||
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn). Runs in the grblHAL repo's CI. |
|
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn, the rapids after an M5 executed at idle ship dark, and the next job in the same process fires at the level the previous one ended at). Runs in the grblHAL repo's CI. |
|
||||||
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
|
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
|
||||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||||
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
|
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
|
||||||
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
|
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
|
||||||
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
|
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
|||||||
next run: a standalone S word between moves, from a sender slow
|
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
|
enough to drain the planner, must still cut at the level it asked
|
||||||
for rather than dark at a stale duty
|
for rather than dark at a stale duty
|
||||||
|
16. and the off transition survives the same way: an M5 executed with
|
||||||
|
the planner drained and the kernel run over must darken the rapids
|
||||||
|
that follow it, and a bare G0 sent with the spindle off must ship
|
||||||
|
dark, under both dose models - the stream's wanted fire state is
|
||||||
|
the only thing those moves consult, and a stale true there lights
|
||||||
|
the next run at the last level (full duty under density)
|
||||||
|
17. and a job's first cut at the level the previous job ended at
|
||||||
|
fires: S is modal across M2, the core records the level a set_state
|
||||||
|
carries and skips the per-segment update while it is unchanged, so
|
||||||
|
the M3 that opens the next job is the only thing that can light its
|
||||||
|
first move - set_state must push the whole state, fire included,
|
||||||
|
never the duty alone
|
||||||
|
|
||||||
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)
|
||||||
"""
|
"""
|
||||||
@@ -177,6 +189,47 @@ for _i, _s in enumerate(IDLE_S_LEVELS):
|
|||||||
JOB_IDLE_S.append("M5")
|
JOB_IDLE_S.append("M5")
|
||||||
|
|
||||||
|
|
||||||
|
# Session J: the bench ladder's shape. M5 executes with the planner
|
||||||
|
# drained and the kernel run over, and the rapids that follow start a
|
||||||
|
# new run; the core issues no per-segment laser update for moves made
|
||||||
|
# with the spindle off, so the stream's wanted state is all that decides
|
||||||
|
# whether those rapids fire. A bare G0 with no M3 since the M5 is the
|
||||||
|
# same case one step further.
|
||||||
|
M5_IDLE_MM = 5.0
|
||||||
|
M5_IDLE_FEED = 600
|
||||||
|
M5_IDLE_TICKS = M5_IDLE_MM / (M5_IDLE_FEED / 60.0) * 28160
|
||||||
|
JOB_M5_IDLE = [
|
||||||
|
"G91", "G21",
|
||||||
|
"M3 S500",
|
||||||
|
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
|
||||||
|
WAIT_IDLE, ("sleep", 0.5),
|
||||||
|
"M5", ("sleep", 0.5),
|
||||||
|
"G0 X%g" % -M5_IDLE_MM, "G0 Y1",
|
||||||
|
WAIT_IDLE,
|
||||||
|
"G0 X%g" % M5_IDLE_MM,
|
||||||
|
WAIT_IDLE,
|
||||||
|
"M3 S500",
|
||||||
|
"G1 X%g" % -M5_IDLE_MM,
|
||||||
|
WAIT_IDLE, ("sleep", 0.5),
|
||||||
|
"M5",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Session K: two jobs in one controller process, the second at the level
|
||||||
|
# the first ended at. M2 leaves S modal and resets the motion mode to G1,
|
||||||
|
# so the next job's M3 executes at that S; the core records it and issues
|
||||||
|
# no per-segment update for a G1 at the same level, so the set_state is
|
||||||
|
# the only thing that can light it. The parser starts in G0, which is why
|
||||||
|
# a process's FIRST job never shows this: its M3 runs at rpm 0.
|
||||||
|
JOB_NEXT = [
|
||||||
|
"G91", "G21", "M3", "S500",
|
||||||
|
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
|
||||||
|
WAIT_IDLE, ("sleep", 0.5),
|
||||||
|
"M5", "G0 X%g" % -M5_IDLE_MM, "G0 Y1",
|
||||||
|
WAIT_IDLE, "G90", "M2", ("sleep", 1.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def fail(msg):
|
def fail(msg):
|
||||||
print("FAIL: %s" % msg)
|
print("FAIL: %s" % msg)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -551,6 +604,30 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
def check_cut_spans(name, ticks, n, cut_ticks, what):
|
||||||
|
"""Exactly n fire spans, each one cutting move long, none stepping
|
||||||
|
at a rapid's rate: FIRE rode nothing but the G1s."""
|
||||||
|
spans = fire_spans(ticks)
|
||||||
|
if len(spans) != n:
|
||||||
|
fail("[%s] %d fire spans, expected exactly %d (%s) (spans %s)"
|
||||||
|
% (name, len(spans), n, what, spans))
|
||||||
|
for s0, s1 in spans:
|
||||||
|
if not 0.8 * cut_ticks <= s1 - s0 <= 1.25 * cut_ticks:
|
||||||
|
fail("[%s] fire span of %d ticks, expected ~%d (one G1): FIRE "
|
||||||
|
"carried into the move after it" % (name, s1 - s0, cut_ticks))
|
||||||
|
# A G1 at F600 steps once per ~53 ticks; a rapid at 200 mm/s
|
||||||
|
# steps every ~2.6. Any 100-tick window under FIRE with more
|
||||||
|
# than a handful of steps is a rapid being cut.
|
||||||
|
worst = 0
|
||||||
|
for i in range(s0, max(s0 + 1, s1 - 100), 50):
|
||||||
|
worst = max(worst, sum(1 for b in ticks[i:i + 100]
|
||||||
|
if (b & 0x10) and (b & 0x25)))
|
||||||
|
if worst > 8:
|
||||||
|
fail("[%s] %d steps in a 100-tick window under FIRE: a rapid "
|
||||||
|
"was cut" % (name, worst))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# --- session A: M4 dynamic power, rules 1-6 + 7-8 -------------------
|
# --- session A: M4 dynamic power, rules 1-6 + 7-8 -------------------
|
||||||
data = run_session("m4", JOB_M4, conf=ANALOG_CONF)
|
data = run_session("m4", JOB_M4, conf=ANALOG_CONF)
|
||||||
@@ -673,6 +750,35 @@ def main():
|
|||||||
print("PASS [idle-s]: standalone S across idle gaps -> fire ticks per duty %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})
|
% {duty_for(l): fire_by_duty[duty_for(l)] for l in IDLE_S_LEVELS})
|
||||||
|
|
||||||
|
# --- session J: M5 executed while idle darkens the next run (rule 16) ---
|
||||||
|
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)):
|
||||||
|
name = "m5-idle-" + model
|
||||||
|
data = run_session(name, JOB_M5_IDLE, conf=conf)
|
||||||
|
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||||
|
"the two G1 moves: FIRE rode a rapid after M5, "
|
||||||
|
"or the bare G0 sent with the spindle off")
|
||||||
|
check_termination(name, data)
|
||||||
|
print("PASS [%s]: M5 at idle -> the rapids after it and a bare G0 ship "
|
||||||
|
"dark; 2 fire spans of %s ticks"
|
||||||
|
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||||
|
|
||||||
|
# --- session K: the next job, at the same level, fires (rule 17) ---
|
||||||
|
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)):
|
||||||
|
name = "next-job-" + model
|
||||||
|
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=conf)
|
||||||
|
text = run_session.text
|
||||||
|
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
|
||||||
|
fail("[%s] expected two armed windows closed by M2 (armed %d, "
|
||||||
|
"disarmed %d)" % (name, text.count("laser armed"),
|
||||||
|
text.count("laser disarmed")))
|
||||||
|
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||||
|
"one G1 per job: the second job's M3 at the "
|
||||||
|
"first job's S lit nothing, or a rapid fired")
|
||||||
|
check_termination(name, data)
|
||||||
|
print("PASS [%s]: the next job's M3 at the previous job's S fires its "
|
||||||
|
"G1; 2 fire spans of %s ticks"
|
||||||
|
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||||
|
|
||||||
print("PASS: all stream emission rules hold")
|
print("PASS: all stream emission rules hold")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ the operator to press the physical arm button before the machine fires;
|
|||||||
nothing here defeats that gate.
|
nothing here defeats that gate.
|
||||||
|
|
||||||
Usage: live_fire_drills.py <drill> [arg] [F] (ircut/pthresh take S,
|
Usage: live_fire_drills.py <drill> [arg] [F] (ircut/pthresh take S,
|
||||||
dladder takes the base period in machine ticks)
|
dladder takes the base period in machine ticks, pcurve takes
|
||||||
|
F, the line length and an optional rung list)
|
||||||
|
|
||||||
Drills (pass a name):
|
Drills (pass a name):
|
||||||
witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples
|
witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples
|
||||||
@@ -64,6 +65,37 @@ Drills (pass a name):
|
|||||||
what a shipped machine would actually emit. Sets
|
what a shipped machine would actually emit. Sets
|
||||||
laser_pulse_ticks itself when run on the board.
|
laser_pulse_ticks itself when run on the board.
|
||||||
dladder [period] [F] e.g. dladder 20 300
|
dladder [period] [F] e.g. dladder 20 300
|
||||||
|
pcurve Laser performance-curve ladder, both instruments: one line
|
||||||
|
per level at constant feed (default F600, 10 mm/s, 100 mm),
|
||||||
|
M3 constant power, the laser off between rungs and a
|
||||||
|
mid-ladder rung repeated at the end as the drift witness. Reads
|
||||||
|
the HV current and the head thermopile (a scatter detector
|
||||||
|
in the beam path upstream of the final mirror, so it sees
|
||||||
|
the beam, not the material) straight from sysfs at ~25 Hz
|
||||||
|
while the machine runs, brackets each rung on the
|
||||||
|
controller's Run/Idle states, and reports per rung the
|
||||||
|
current (mean, spread, max, CLIPPED at 1023), the thermopile
|
||||||
|
delta over its laser-off baseline with its in-line drift,
|
||||||
|
the digital flag's duty and the coolant temperature; then
|
||||||
|
the normalized curve, a monotonicity check, a straight-line
|
||||||
|
fit with its x-intercept as the measured threshold, and the
|
||||||
|
repeat-rung comparison. Rungs follow laser_power_model:
|
||||||
|
analog 16..100 % of duty (dense at the knee), density
|
||||||
|
1..100 %; a comma list overrides. Records the actual level
|
||||||
|
each S lands on from $30/$31/$35/$36; measuring the curve
|
||||||
|
itself wants $35 = 0. JSON record (with the raw trace) in
|
||||||
|
FORGETEST_BENCH_DATA, else /tmp. Runs on the board for the
|
||||||
|
thermopile; from a host it falls back to /status (current
|
||||||
|
only, no curve). Reaches FULL power for 10 s per line.
|
||||||
|
pcurve [F] [len] [pcts] e.g. pcurve 600 100 16,20,30,50,100
|
||||||
|
m5dark The rapids after an M5 ship dark: one 20 mm line at M3 S400,
|
||||||
|
M5, a dwell, a rapid back over the line, a dwell, a rapid
|
||||||
|
forward, a dwell, M2. Samples sysfs at 25 Hz (board only):
|
||||||
|
PASS when the current shows exactly one discharge segment,
|
||||||
|
reads dark after the M5, and laser_on_sampled never goes
|
||||||
|
nonzero again after its first zero past the line. Prints the
|
||||||
|
9 s after the line at 40 ms steps. The catalog's
|
||||||
|
laser.m5-rapid-dark is its port.
|
||||||
expstop Armed kill on the EXPECTED-stop path: start a mark job,
|
expstop Armed kill on the EXPECTED-stop path: start a mark job,
|
||||||
then mid-burn POST /controller/stop (the supervisor stops
|
then mid-burn POST /controller/stop (the supervisor stops
|
||||||
the controller: SIGTERM, reap, exit safing). PASS: emission
|
the controller: SIGTERM, reap, exit safing). PASS: emission
|
||||||
@@ -739,6 +771,617 @@ def drill_dladder(g):
|
|||||||
return samples
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
# --- performance curve ladder ---------------------------------------------
|
||||||
|
|
||||||
|
# One line per level at constant feed, two instruments read through each:
|
||||||
|
# the HV current (the supply's curve under analog; presence only under
|
||||||
|
# density, where every pulse is full current) and the head thermopile, a
|
||||||
|
# scatter detector in the beam path upstream of the final mirror, so it
|
||||||
|
# reads the beam and not the material. Rungs are percents of full. The
|
||||||
|
# analog list is dense at the knee where the tube starts to lase; the
|
||||||
|
# density list is weighted to the bottom, where the per-pulse strike
|
||||||
|
# deficit lives.
|
||||||
|
PCURVE_ANALOG_PCT = (16, 18, 20, 23, 26, 30, 35, 42, 50, 60, 72, 85, 100)
|
||||||
|
PCURVE_DENSITY_PCT = (1, 2, 3, 5, 7, 10, 15, 20, 30, 45, 60, 80, 100)
|
||||||
|
PCURVE_FEED = 600 # mm/min: 10 mm/s
|
||||||
|
PCURVE_LEN = 100.0 # mm of burn per rung
|
||||||
|
PCURVE_PITCH = 3.0 # mm between rungs (+X)
|
||||||
|
PCURVE_GAP_S = 4.0 # laser-off settle before each rung
|
||||||
|
PCURVE_GAP_SKIP_S = 1.5 # of which the first part still decays
|
||||||
|
PCURVE_SAMPLE_HZ = 25 # sysfs sampler target rate
|
||||||
|
PCURVE_TRIM_HEAD_S = 1.0 # dropped from the start of each line
|
||||||
|
PCURVE_TRIM_TAIL_S = 0.5 # dropped from its end
|
||||||
|
SYSFS = '/sys/glowforge'
|
||||||
|
HV_FULL_SCALE = 1023 # the PIC ADC's top count
|
||||||
|
# (key, sysfs attribute) per sampled channel.
|
||||||
|
PCURVE_CHANNELS = (
|
||||||
|
('hv', 'pic/hv_current'),
|
||||||
|
('tp', 'head/beam_detect_analog'),
|
||||||
|
('tpd', 'head/beam_detect_digital'),
|
||||||
|
('lon', 'cnc/laser_on_sampled'),
|
||||||
|
('wt1', 'pic/water_temp_1'),
|
||||||
|
('wt2', 'pic/water_temp_2'),
|
||||||
|
('pt', 'pic/pwr_temp'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Sampler:
|
||||||
|
"""Reads the pcurve channels in a thread. On the board they come
|
||||||
|
straight from sysfs, each read a live bus transaction, so the achieved
|
||||||
|
rate is whatever the PIC (SPI) and head (I2C) buses allow and it is
|
||||||
|
reported rather than assumed. From a host the only source is
|
||||||
|
forgectrl's /status at ~8 Hz, which carries the current and nothing
|
||||||
|
the thermopile needs."""
|
||||||
|
|
||||||
|
def __init__(self, hz):
|
||||||
|
import threading
|
||||||
|
self.local = os.path.isdir(SYSFS)
|
||||||
|
self.period = 1.0 / (hz if self.local else 8)
|
||||||
|
self.samples = []
|
||||||
|
self.errors = 0
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thr = threading.Thread(target=self._run, daemon=True)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._thr.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
self._thr.join(timeout=3)
|
||||||
|
|
||||||
|
def _read_sysfs(self):
|
||||||
|
smp = {'t': time.time()}
|
||||||
|
for key, attr in PCURVE_CHANNELS:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(SYSFS, attr)) as f:
|
||||||
|
smp[key] = int(f.read().strip())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
smp[key] = None
|
||||||
|
self.errors += 1
|
||||||
|
return smp
|
||||||
|
|
||||||
|
def _read_status(self):
|
||||||
|
st = sample_forgectrl()
|
||||||
|
smp = dict((key, None) for key, _attr in PCURVE_CHANNELS)
|
||||||
|
smp['t'] = time.time()
|
||||||
|
if st is None:
|
||||||
|
self.errors += 1
|
||||||
|
return smp
|
||||||
|
smp['hv'] = st['hv']
|
||||||
|
smp['lon'] = st['emission']
|
||||||
|
return smp
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
read = self._read_sysfs if self.local else self._read_status
|
||||||
|
next_t = time.time()
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self.samples.append(read())
|
||||||
|
next_t += self.period
|
||||||
|
delay = next_t - time.time()
|
||||||
|
if delay > 0:
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
next_t = time.time()
|
||||||
|
|
||||||
|
def rate(self):
|
||||||
|
if len(self.samples) < 2:
|
||||||
|
return 0.0
|
||||||
|
span = self.samples[-1]['t'] - self.samples[0]['t']
|
||||||
|
return (len(self.samples) - 1) / span if span > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _stats(vals):
|
||||||
|
n = len(vals)
|
||||||
|
if not n:
|
||||||
|
return {'n': 0, 'mean': None, 'sd': None, 'min': None, 'max': None}
|
||||||
|
mean = sum(vals) / float(n)
|
||||||
|
var = sum((v - mean) ** 2 for v in vals) / float(n)
|
||||||
|
return {'n': n, 'mean': mean, 'sd': var ** 0.5, 'min': min(vals),
|
||||||
|
'max': max(vals)}
|
||||||
|
|
||||||
|
|
||||||
|
def _window(samples, t0, t1, key):
|
||||||
|
return [s[key] for s in samples
|
||||||
|
if t0 <= s['t'] < t1 and s.get(key) is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _linfit(xs, ys):
|
||||||
|
"""Least squares y = a + b x; (a, b, r2), or None below two points."""
|
||||||
|
n = len(xs)
|
||||||
|
if n < 2:
|
||||||
|
return None
|
||||||
|
mx = sum(xs) / float(n)
|
||||||
|
my = sum(ys) / float(n)
|
||||||
|
sxx = sum((x - mx) ** 2 for x in xs)
|
||||||
|
if sxx == 0:
|
||||||
|
return None
|
||||||
|
b = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sxx
|
||||||
|
a = my - b * mx
|
||||||
|
ss_res = sum((y - (a + b * x)) ** 2 for x, y in zip(xs, ys))
|
||||||
|
ss_tot = sum((y - my) ** 2 for y in ys)
|
||||||
|
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0
|
||||||
|
return a, b, r2
|
||||||
|
|
||||||
|
|
||||||
|
def _degc(raw):
|
||||||
|
"""The factory coolant conversion when gfbench is importable (on the
|
||||||
|
board, or with GF_HOST set), else None and the raw count is quoted.
|
||||||
|
The helper lives beside this file in the repo and under the bench
|
||||||
|
directory on the dev image; a copy staged elsewhere still finds it."""
|
||||||
|
for d in (os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
'/usr/share/forgetest/bench'):
|
||||||
|
if d not in sys.path:
|
||||||
|
sys.path.append(d)
|
||||||
|
try:
|
||||||
|
from gfbench import degc
|
||||||
|
except (ImportError, SystemExit):
|
||||||
|
return None
|
||||||
|
return degc(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def pcurve_levels(g, pcts):
|
||||||
|
"""(pct, S, level) per rung: the level is what the core maps S onto
|
||||||
|
with the settings in force, as a fraction of full (duty/127 under
|
||||||
|
analog, density under density). None when a setting cannot be read."""
|
||||||
|
floor = grbl_setting(g, '$35')
|
||||||
|
ceil = grbl_setting(g, '$36')
|
||||||
|
rpm_max = grbl_setting(g, '$30')
|
||||||
|
rpm_min = grbl_setting(g, '$31')
|
||||||
|
if None in (floor, ceil, rpm_max, rpm_min) or rpm_max <= rpm_min:
|
||||||
|
return None, (rpm_max, rpm_min, floor, ceil)
|
||||||
|
min_value = int(PWM_PERIOD * floor / 100.0)
|
||||||
|
max_value = int(PWM_PERIOD * ceil / 100.0)
|
||||||
|
gradient = (max_value - min_value) / (rpm_max - rpm_min)
|
||||||
|
levels = []
|
||||||
|
for pct in pcts:
|
||||||
|
sval = int(round(rpm_max * pct / 100.0))
|
||||||
|
if sval <= rpm_min:
|
||||||
|
level = 0
|
||||||
|
else:
|
||||||
|
level = min(int((sval - rpm_min) * gradient) + min_value, max_value)
|
||||||
|
levels.append((pct, sval, level / float(PWM_PERIOD)))
|
||||||
|
return levels, (rpm_max, rpm_min, floor, ceil)
|
||||||
|
|
||||||
|
|
||||||
|
def pcurve_analyze(samples, rungs, head_trim=PCURVE_TRIM_HEAD_S,
|
||||||
|
tail_trim=PCURVE_TRIM_TAIL_S):
|
||||||
|
"""Per-rung statistics over the trimmed steady window of each line,
|
||||||
|
then the curve: normalized thermopile delta against level, a
|
||||||
|
monotonicity count, straight-line fits with their x-intercepts, and
|
||||||
|
the repeat-rung comparison. Pure: takes the raw trace and the rung
|
||||||
|
brackets, returns a dict, so it can be checked without a machine."""
|
||||||
|
rows = []
|
||||||
|
for r in rungs:
|
||||||
|
t0, t1 = r['t_run0'] + head_trim, r['t_run1'] - tail_trim
|
||||||
|
if t1 - t0 < 1.0:
|
||||||
|
t0, t1 = r['t_run0'], r['t_run1']
|
||||||
|
hv = _stats(_window(samples, t0, t1, 'hv'))
|
||||||
|
tp = _stats(_window(samples, t0, t1, 'tp'))
|
||||||
|
# The thermopile falls back to baseline within about a second of a
|
||||||
|
# line ending (measured 2026-08-25), so the first part of the gap
|
||||||
|
# still carries the previous rung's tail; the baseline is the rest.
|
||||||
|
base = _stats(_window(samples, r['t_gap0'] + PCURVE_GAP_SKIP_S,
|
||||||
|
r['t_m3'], 'tp'))
|
||||||
|
first = _stats(_window(samples, t0, min(t0 + 2.0, t1), 'tp'))
|
||||||
|
last = _stats(_window(samples, max(t1 - 2.0, t0), t1, 'tp'))
|
||||||
|
tpd = _window(samples, t0, t1, 'tpd')
|
||||||
|
# laser_on_sampled is a once-per-second window count, so the
|
||||||
|
# last window of a line lands after Idle: look one second past.
|
||||||
|
lon = _window(samples, r['t_run0'], r['t_run1'] + 1.0, 'lon')
|
||||||
|
# Coolant from the UPSTREAM sensor: water_temp_1 sits downstream of
|
||||||
|
# the flow-check heater and swings with it during a run.
|
||||||
|
wt1 = _stats(_window(samples, r['t_gap0'], r['t_m3'], 'wt2'))
|
||||||
|
pt = _stats(_window(samples, r['t_gap0'], r['t_m3'], 'pt'))
|
||||||
|
delta = (tp['mean'] - base['mean']
|
||||||
|
if tp['mean'] is not None and base['mean'] is not None
|
||||||
|
else None)
|
||||||
|
drift = (last['mean'] - first['mean']
|
||||||
|
if last['mean'] is not None and first['mean'] is not None
|
||||||
|
else None)
|
||||||
|
rows.append({
|
||||||
|
'rung': r['rung'], 'repeat': r.get('repeat', False),
|
||||||
|
'pct': r['pct'], 's': r['s'], 'level': r['level'],
|
||||||
|
'seconds': round(r['t_run1'] - r['t_run0'], 2),
|
||||||
|
'hv_n': hv['n'], 'hv_mean': hv['mean'], 'hv_sd': hv['sd'],
|
||||||
|
'hv_max': hv['max'],
|
||||||
|
'hv_clipped': hv['max'] is not None and hv['max'] >= HV_FULL_SCALE,
|
||||||
|
'tp_n': tp['n'], 'tp_mean': tp['mean'], 'tp_sd': tp['sd'],
|
||||||
|
'tp_base': base['mean'], 'tp_base_sd': base['sd'],
|
||||||
|
'tp_delta': delta, 'tp_drift': drift,
|
||||||
|
'tpd_duty': (sum(1 for v in tpd if v) / float(len(tpd))
|
||||||
|
if tpd else None),
|
||||||
|
'lon_max': max(lon) if lon else None,
|
||||||
|
'fired': bool(lon) and max(lon) > 0,
|
||||||
|
'coolant_raw': wt1['mean'],
|
||||||
|
'coolant_c': _degc(wt1['mean']) if wt1['mean'] is not None else None,
|
||||||
|
'supply_raw': pt['mean'],
|
||||||
|
})
|
||||||
|
primary = [row for row in rows if not row['repeat']]
|
||||||
|
primary.sort(key=lambda row: row['level'])
|
||||||
|
fired = [row for row in primary if row['fired']]
|
||||||
|
curve = {'rows': len(rows), 'fired': len(fired)}
|
||||||
|
# Normalize the thermopile delta to the top of the ladder.
|
||||||
|
deltas = [row['tp_delta'] for row in fired if row['tp_delta'] is not None]
|
||||||
|
top = max(deltas) if deltas else None
|
||||||
|
for row in rows:
|
||||||
|
row['tp_norm'] = (row['tp_delta'] / top
|
||||||
|
if top and row['tp_delta'] is not None else None)
|
||||||
|
# Monotonicity: decreases of the delta with rising level, beyond noise.
|
||||||
|
dec = 0
|
||||||
|
prev = None
|
||||||
|
for row in fired:
|
||||||
|
if row['tp_delta'] is None:
|
||||||
|
continue
|
||||||
|
if prev is not None and row['tp_delta'] < prev['tp_delta'] - 2.0 * (row['tp_sd'] or 0):
|
||||||
|
dec += 1
|
||||||
|
prev = row
|
||||||
|
curve['tp_decreases'] = dec
|
||||||
|
dec = 0
|
||||||
|
prev = None
|
||||||
|
for row in fired:
|
||||||
|
if row['hv_mean'] is None or row['hv_clipped']:
|
||||||
|
continue
|
||||||
|
if prev is not None and row['hv_mean'] < prev['hv_mean'] - 2.0 * (row['hv_sd'] or 0):
|
||||||
|
dec += 1
|
||||||
|
prev = row
|
||||||
|
curve['hv_decreases'] = dec
|
||||||
|
# Signal rungs: delta clear of the baseline noise, for the fits.
|
||||||
|
sig = [row for row in fired if row['tp_delta'] is not None
|
||||||
|
and row['tp_delta'] > 3.0 * (row['tp_base_sd'] or 0)]
|
||||||
|
fit = _linfit([row['level'] for row in sig], [row['tp_delta'] for row in sig])
|
||||||
|
if fit:
|
||||||
|
a, b, r2 = fit
|
||||||
|
curve['tp_fit'] = {'points': len(sig), 'intercept': a, 'slope': b,
|
||||||
|
'r2': r2,
|
||||||
|
'x_intercept': (-a / b) if b else None}
|
||||||
|
unclipped = [row for row in fired if row['hv_mean'] is not None
|
||||||
|
and not row['hv_clipped']]
|
||||||
|
fit = _linfit([row['level'] for row in unclipped],
|
||||||
|
[row['hv_mean'] for row in unclipped])
|
||||||
|
if fit:
|
||||||
|
a, b, r2 = fit
|
||||||
|
curve['hv_fit'] = {'points': len(unclipped), 'intercept': a,
|
||||||
|
'slope': b, 'r2': r2,
|
||||||
|
'x_intercept': (-a / b) if b else None}
|
||||||
|
curve['hv_clipped_rungs'] = [row['rung'] for row in rows if row['hv_clipped']]
|
||||||
|
reps = [row for row in rows if row['repeat']]
|
||||||
|
firsts = [row for row in rows if not row['repeat'] and reps
|
||||||
|
and row['rung'] == reps[-1]['rung']]
|
||||||
|
if reps and firsts:
|
||||||
|
first, again = firsts[0], reps[-1]
|
||||||
|
rep = {'rung': first['rung']}
|
||||||
|
if first['tp_delta'] is not None and again['tp_delta'] is not None:
|
||||||
|
rep['tp_delta_first'] = first['tp_delta']
|
||||||
|
rep['tp_delta_again'] = again['tp_delta']
|
||||||
|
rep['tp_delta_change'] = again['tp_delta'] - first['tp_delta']
|
||||||
|
rep['tp_delta_change_pct'] = (100.0 * rep['tp_delta_change'] / first['tp_delta']
|
||||||
|
if first['tp_delta'] else None)
|
||||||
|
if first['hv_mean'] is not None and again['hv_mean'] is not None:
|
||||||
|
rep['hv_change'] = again['hv_mean'] - first['hv_mean']
|
||||||
|
bases = [row['tp_base'] for row in rows if row['tp_base'] is not None]
|
||||||
|
if len(bases) >= 2:
|
||||||
|
rep['baseline_walk'] = bases[-1] - bases[0]
|
||||||
|
curve['repeat'] = rep
|
||||||
|
return {'rungs': rows, 'curve': curve}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(v, prec=1):
|
||||||
|
if v is None:
|
||||||
|
return '-'
|
||||||
|
if isinstance(v, float):
|
||||||
|
return '%.*f' % (prec, v)
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def pcurve_report(res, model):
|
||||||
|
rows, curve = res['rungs'], res['curve']
|
||||||
|
print('\n--- per rung (steady window, first %gs and last %gs of each line dropped) ---'
|
||||||
|
% (PCURVE_TRIM_HEAD_S, PCURVE_TRIM_TAIL_S))
|
||||||
|
unit = 'density' if model == 'density' else 'duty'
|
||||||
|
print(' rung %% S %-8s hv mean sd max | tp delta sd base drift norm | dig lon cool'
|
||||||
|
% unit)
|
||||||
|
for row in rows:
|
||||||
|
tag = '%2d%s' % (row['rung'], 'r' if row['repeat'] else ' ')
|
||||||
|
print(' %s %3d %4d %6.2f%% %7s %5s %5s%s | %8s %5s %7s %6s %5s | %4s %4s %s'
|
||||||
|
% (tag, row['pct'], row['s'], 100.0 * row['level'],
|
||||||
|
_fmt(row['hv_mean']), _fmt(row['hv_sd']), _fmt(row['hv_max'], 0),
|
||||||
|
'!' if row['hv_clipped'] else ' ',
|
||||||
|
_fmt(row['tp_delta']), _fmt(row['tp_sd']), _fmt(row['tp_base']),
|
||||||
|
_fmt(row['tp_drift']), _fmt(row['tp_norm'], 3),
|
||||||
|
_fmt(row['tpd_duty'], 2), _fmt(row['lon_max'], 0),
|
||||||
|
_fmt(row['coolant_c']) if row['coolant_c'] is not None
|
||||||
|
else _fmt(row['coolant_raw'], 0) + 'raw'))
|
||||||
|
print(' (! = hv_current touched %d: the ADC is clipped there and the'
|
||||||
|
% HV_FULL_SCALE)
|
||||||
|
print(' current column is no longer a measurement on that rung)')
|
||||||
|
print('\n--- curve ---')
|
||||||
|
print('rungs fired (laser_on_sampled > 0): %d of %d' % (curve['fired'], curve['rows']))
|
||||||
|
print('thermopile delta decreases with rising level (beyond 2 sd): %s'
|
||||||
|
% curve['tp_decreases'])
|
||||||
|
print('hv_current decreases with rising level (beyond 2 sd, unclipped): %s'
|
||||||
|
% curve['hv_decreases'])
|
||||||
|
if curve.get('hv_clipped_rungs'):
|
||||||
|
print('hv_current CLIPPED on rungs %s' % curve['hv_clipped_rungs'])
|
||||||
|
for name, key in (('thermopile', 'tp_fit'), ('hv_current', 'hv_fit')):
|
||||||
|
f = curve.get(key)
|
||||||
|
if not f:
|
||||||
|
print('%s fit: not enough signal rungs' % name)
|
||||||
|
continue
|
||||||
|
print('%s vs level: %d points, slope %.1f per 100%%, r2 %.3f, '
|
||||||
|
'x-intercept %s%% (the measured threshold if the fit holds)'
|
||||||
|
% (name, f['points'], f['slope'], f['r2'],
|
||||||
|
_fmt(100.0 * f['x_intercept']) if f['x_intercept'] is not None else '-'))
|
||||||
|
rep = curve.get('repeat')
|
||||||
|
if rep:
|
||||||
|
print('repeat of rung %d: thermopile delta %s -> %s (%s, %s%%), '
|
||||||
|
'hv %s; baseline walked %s over the ladder'
|
||||||
|
% (rep['rung'], _fmt(rep.get('tp_delta_first')),
|
||||||
|
_fmt(rep.get('tp_delta_again')), _fmt(rep.get('tp_delta_change')),
|
||||||
|
_fmt(rep.get('tp_delta_change_pct')), _fmt(rep.get('hv_change')),
|
||||||
|
_fmt(rep.get('baseline_walk'))))
|
||||||
|
|
||||||
|
|
||||||
|
def drill_pcurve(g):
|
||||||
|
feed = int(sys.argv[2]) if len(sys.argv) > 2 else PCURVE_FEED
|
||||||
|
length = float(sys.argv[3]) if len(sys.argv) > 3 else PCURVE_LEN
|
||||||
|
model = conf_get('laser_power_model') or 'density'
|
||||||
|
if len(sys.argv) > 4:
|
||||||
|
pcts = tuple(int(x) for x in sys.argv[4].split(',') if x.strip())
|
||||||
|
else:
|
||||||
|
pcts = PCURVE_DENSITY_PCT if model == 'density' else PCURVE_ANALOG_PCT
|
||||||
|
if not pcts or min(pcts) < 1 or max(pcts) > 100:
|
||||||
|
print('rungs must be percents in 1..100')
|
||||||
|
return 2
|
||||||
|
print('=== laser performance curve: %s model, %d rungs + repeat, F%d, %g mm each ==='
|
||||||
|
% (model, len(pcts), feed, length))
|
||||||
|
print('constant power (M3): the commanded level is the tested level.')
|
||||||
|
levels, (rpm_max, rpm_min, floor, ceil) = pcurve_levels(g, pcts)
|
||||||
|
if levels is None:
|
||||||
|
print('PRECONDITION FAILED: cannot read $30/$31/$35/$36 (%s/%s/%s/%s)'
|
||||||
|
% (rpm_max, rpm_min, floor, ceil))
|
||||||
|
return 2
|
||||||
|
print('mapping: $30=%g $31=%g $35=%g $36=%g' % (rpm_max, rpm_min, floor, ceil))
|
||||||
|
if floor > 0.0:
|
||||||
|
print('a floor is set, so the low rungs land on it: this run records')
|
||||||
|
print('the shipping mapping. To measure the curve itself set $35=0')
|
||||||
|
print('and restart the controller first.')
|
||||||
|
unit = 'density' if model == 'density' else 'duty'
|
||||||
|
# The drift witness is a mid-ladder rung drawn again at the end: it
|
||||||
|
# has real signal (the bottom rung sits at the threshold and reads
|
||||||
|
# nothing twice) and it is not full power, so it adds little heat.
|
||||||
|
witness = len(levels) // 2
|
||||||
|
print('rungs (each a +X line from the block\'s X0, stepping +Y %g mm; rung'
|
||||||
|
% PCURVE_PITCH)
|
||||||
|
print('%d is drawn again at the end, running -X, as the drift witness;'
|
||||||
|
% (witness + 1))
|
||||||
|
print('the next run\'s block starts %g mm further along X):' % length)
|
||||||
|
for i, (pct, sval, level) in enumerate(levels):
|
||||||
|
print(' %2d: %3d%% -> S%-4d %s %.2f%%' % (i + 1, pct, sval, unit, 100.0 * level))
|
||||||
|
sampler = Sampler(PCURVE_SAMPLE_HZ)
|
||||||
|
if sampler.local:
|
||||||
|
print('sampling sysfs on the board at a target %d Hz: %s'
|
||||||
|
% (PCURVE_SAMPLE_HZ, ' '.join(attr for _k, attr in PCURVE_CHANNELS)))
|
||||||
|
else:
|
||||||
|
print('NOT on the board: sampling forgectrl /status at ~8 Hz instead.')
|
||||||
|
print('That carries the current and the emission witness only; the')
|
||||||
|
print('thermopile is not in /status, so this run yields no curve.')
|
||||||
|
print('connect: %s' % prepare(g))
|
||||||
|
print('pre-fire: %s' % sample_forgectrl())
|
||||||
|
arm_cue()
|
||||||
|
print('>>> This ladder reaches FULL power for %.0f s per line. Use'
|
||||||
|
% (length / feed * 60.0))
|
||||||
|
print('>>> something you are willing to cut through and that will not')
|
||||||
|
print('>>> flame: scrap tile, firebrick, thick draftboard on a')
|
||||||
|
print('>>> sacrificial layer. The thermopile is in the head, so the')
|
||||||
|
print('>>> material is not part of the measurement.\n')
|
||||||
|
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
|
||||||
|
order = list(range(len(levels))) + [witness]
|
||||||
|
line_s = length / feed * 60.0
|
||||||
|
sampler.start()
|
||||||
|
rungs = []
|
||||||
|
aborted = None
|
||||||
|
try:
|
||||||
|
for n, idx in enumerate(order):
|
||||||
|
pct, sval, level = levels[idx]
|
||||||
|
repeat = n == len(order) - 1
|
||||||
|
# Every line runs +X from the block's X0 at one Y and the rungs
|
||||||
|
# step +Y, so a run occupies a block `length` wide by
|
||||||
|
# PCURVE_PITCH x rungs tall and the next run's block starts
|
||||||
|
# `length` further along X. The drift witness runs the other
|
||||||
|
# way, from the far end back to X0: a swing that reverses with
|
||||||
|
# direction is head position along the gantry; one that repeats
|
||||||
|
# is time.
|
||||||
|
if repeat:
|
||||||
|
g.s.sendall(('G0 X%g\n' % length).encode())
|
||||||
|
g.wait_state('Idle', 30)
|
||||||
|
t_gap0 = time.time()
|
||||||
|
time.sleep(PCURVE_GAP_S) # laser off: the baseline
|
||||||
|
t_m3 = time.time()
|
||||||
|
job = ['M3 S%d' % sval,
|
||||||
|
'G1 X%g F%d' % (-length if repeat else length, feed),
|
||||||
|
'M5']
|
||||||
|
for ln in job:
|
||||||
|
g.s.sendall(ln.encode() + b'\n')
|
||||||
|
st = g.wait_state('Run', 240 if n == 0 else 60)
|
||||||
|
if not st.startswith('Run'):
|
||||||
|
aborted = ('rung %d never ran (state=%s): arm refused, no '
|
||||||
|
'press, or the controller alarmed' % (idx + 1, st))
|
||||||
|
break
|
||||||
|
t_run0 = time.time()
|
||||||
|
st = g.wait_state('Idle', line_s + 30.0, poll=0.05)
|
||||||
|
t_run1 = time.time()
|
||||||
|
if not st.startswith('Idle'):
|
||||||
|
aborted = 'rung %d did not finish (state=%s)' % (idx + 1, st)
|
||||||
|
break
|
||||||
|
if t_run1 - t_run0 < line_s - 1.5:
|
||||||
|
# A line that ended early was cancelled by the operator or
|
||||||
|
# the controller, and a cancel may have moved the head
|
||||||
|
# (the controller returns to machine zero). From here every
|
||||||
|
# relative move is aimed from a position this drill no
|
||||||
|
# longer knows, so send nothing more.
|
||||||
|
aborted = ('rung %d ran %.1f s of %.1f: cancelled; no further '
|
||||||
|
'moves sent' % (idx + 1, t_run1 - t_run0, line_s))
|
||||||
|
break
|
||||||
|
rungs.append({'rung': idx + 1, 'repeat': repeat, 'pct': pct,
|
||||||
|
's': sval, 'level': level, 't_gap0': t_gap0,
|
||||||
|
't_m3': t_m3, 't_run0': t_run0, 't_run1': t_run1})
|
||||||
|
print(' rung %2d%s: S%-4d ran %.1f s' % (idx + 1, 'r' if repeat else ' ',
|
||||||
|
sval, t_run1 - t_run0))
|
||||||
|
back = '' if repeat else 'G0 X%g\n' % -length
|
||||||
|
g.s.sendall((back + 'G0 Y%g\n' % PCURVE_PITCH).encode())
|
||||||
|
g.wait_state('Idle', 30)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
g.cmd('M5', timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if aborted:
|
||||||
|
g.rt(b'\x18') # abort out of whatever it is in
|
||||||
|
else:
|
||||||
|
g.s.sendall(b'G90\nM2\n') # program end closes the window
|
||||||
|
time.sleep(1.5)
|
||||||
|
sampler.stop()
|
||||||
|
if aborted:
|
||||||
|
print('ABORTED: %s' % aborted)
|
||||||
|
print('\nsampler: %d samples, %.1f Hz achieved, %d read errors'
|
||||||
|
% (len(sampler.samples), sampler.rate(), sampler.errors))
|
||||||
|
if not rungs:
|
||||||
|
return 1
|
||||||
|
res = pcurve_analyze(sampler.samples, rungs)
|
||||||
|
pcurve_report(res, model)
|
||||||
|
record = {
|
||||||
|
'drill': 'pcurve', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||||
|
'model': model, 'feed': feed, 'length_mm': length,
|
||||||
|
'gap_s': PCURVE_GAP_S, 'pitch_mm': PCURVE_PITCH,
|
||||||
|
'settings': {'$30': rpm_max, '$31': rpm_min, '$35': floor, '$36': ceil},
|
||||||
|
'sampler': {'local': sampler.local, 'hz': sampler.rate(),
|
||||||
|
'samples': len(sampler.samples), 'errors': sampler.errors},
|
||||||
|
'aborted': aborted, 'rungs': res['rungs'], 'curve': res['curve'],
|
||||||
|
'trace': sampler.samples,
|
||||||
|
}
|
||||||
|
ddir = os.environ.get('FORGETEST_BENCH_DATA') or ('/tmp' if sampler.local else os.getcwd())
|
||||||
|
path = os.path.join(ddir, 'pcurve_%s_%s.json' % (model, time.strftime('%Y%m%d-%H%M%S')))
|
||||||
|
try:
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
json.dump(record, f, indent=1)
|
||||||
|
print('record: %s' % path)
|
||||||
|
except OSError as e:
|
||||||
|
print('record not written: %s' % e)
|
||||||
|
print('\nRead it in this order. First the instrument: the thermopile')
|
||||||
|
print('delta must rise with the level on every rung that fired, settle')
|
||||||
|
print('inside the line (small drift), return to its baseline between')
|
||||||
|
print('rungs, and the repeat rung must agree with its first run. Any')
|
||||||
|
print('miss there is a fact about the sensor, not the tube. Then the')
|
||||||
|
print('curve: under analog the current column is the supply and the')
|
||||||
|
print('thermopile is the tube; under density the current is only a')
|
||||||
|
print('presence witness and the thermopile is the whole story. A knee')
|
||||||
|
print('where the delta stops rising before 100%% is the ceiling S1000')
|
||||||
|
print('should map to. The material remains the witness that it lased.')
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# --- the rapids after an M5 ship dark ----------------------------------------
|
||||||
|
|
||||||
|
# One constant-power line, M5, then two rapids over it with dwells between,
|
||||||
|
# the shape every ladder above uses between rungs. M5 executes with the
|
||||||
|
# planner drained and the kernel run over, and the core issues no
|
||||||
|
# per-segment laser update for moves made with the spindle off, so only the
|
||||||
|
# stream's wanted fire state decides whether those rapids fire.
|
||||||
|
# S1000 is a certain strike and, under the density model, the worst case
|
||||||
|
# for the bug: full duty pinned, so a rapid that inherited fire would run
|
||||||
|
# at full power.
|
||||||
|
M5DARK_JOB = ['G91', 'G21', 'M3', 'S400',
|
||||||
|
'G1 X20 F600',
|
||||||
|
'M5', 'G4 P2.5',
|
||||||
|
'G0 X-20', 'G4 P2.5',
|
||||||
|
'G0 X20', 'G4 P2.5',
|
||||||
|
'G90', 'M2']
|
||||||
|
HV_DARK_MAX = 20 # hv_current reads 0 with the tube off
|
||||||
|
|
||||||
|
|
||||||
|
def drill_m5dark(g):
|
||||||
|
print('=== the rapids after an M5 ship dark: M3 S400, 20 mm line, M5, two rapids ===')
|
||||||
|
sampler = Sampler(PCURVE_SAMPLE_HZ)
|
||||||
|
if not sampler.local:
|
||||||
|
print('run this on the board: the witnesses are sysfs at 25 Hz')
|
||||||
|
return 2
|
||||||
|
print('connect: %s' % prepare(g))
|
||||||
|
print('pre-fire: %s' % sample_forgectrl())
|
||||||
|
arm_cue()
|
||||||
|
print('>>> 20 mm of free +X travel at the head. One 20 mm line at S400,')
|
||||||
|
print('>>> then the head rapids back over it and forward again, dark.\n')
|
||||||
|
sampler.start()
|
||||||
|
for ln in M5DARK_JOB:
|
||||||
|
g.s.sendall(ln.encode() + b'\n')
|
||||||
|
st = g.wait_state('Run', 240)
|
||||||
|
if not st.startswith('Run'):
|
||||||
|
print('FAIL: the job never ran (state=%s)' % st)
|
||||||
|
g.rt(b'\x18')
|
||||||
|
sampler.stop()
|
||||||
|
return 1
|
||||||
|
# The controller reports Idle inside a G4 dwell, so Idle is no sign the
|
||||||
|
# job is over; the armed window closing at M2 is.
|
||||||
|
t0 = time.time()
|
||||||
|
seen_armed = False
|
||||||
|
while time.time() - t0 < 90:
|
||||||
|
smp = sample_forgectrl()
|
||||||
|
if smp and smp['armed']:
|
||||||
|
seen_armed = True
|
||||||
|
elif smp and seen_armed and not smp['armed']:
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
time.sleep(1.5)
|
||||||
|
sampler.stop()
|
||||||
|
tr = sampler.samples
|
||||||
|
# Discharge segments from the current, 1 s hysteresis: the line is one;
|
||||||
|
# a rapid that fired is another.
|
||||||
|
segs, cur = [], None
|
||||||
|
for s in tr:
|
||||||
|
on = s['hv'] is not None and s['hv'] > 30
|
||||||
|
if on and cur is None:
|
||||||
|
cur = [s['t'], s['t']]
|
||||||
|
elif on:
|
||||||
|
cur[1] = s['t']
|
||||||
|
elif cur is not None and s['t'] - cur[1] > 1.0:
|
||||||
|
segs.append(cur)
|
||||||
|
cur = None
|
||||||
|
if cur:
|
||||||
|
segs.append(cur)
|
||||||
|
print('\n--- results (%d samples, %.1f Hz) ---' % (len(tr), sampler.rate()))
|
||||||
|
if not segs:
|
||||||
|
print('FAIL: no discharge seen at all (arm refused, no press, or no fire)')
|
||||||
|
return 1
|
||||||
|
t_end = segs[0][1]
|
||||||
|
base = _stats(_window(tr, segs[0][0] - 2.0, segs[0][0] - 0.2, 'tp'))['mean']
|
||||||
|
print('line: %.2f s of discharge; %d discharge segment(s) in the run%s'
|
||||||
|
% (t_end - segs[0][0], len(segs),
|
||||||
|
'' if len(segs) == 1 else ': the extra ones are rapids that FIRED'))
|
||||||
|
hv_after = max((s['hv'] for s in tr if s['t'] > t_end + 0.3 and s['hv'] is not None),
|
||||||
|
default=0)
|
||||||
|
lon_after = [s for s in tr if s['t'] > t_end + 0.3 and s.get('lon')]
|
||||||
|
# laser_on_sampled lags a window: the first zero past the line is the
|
||||||
|
# dark point, and nothing after it may be nonzero.
|
||||||
|
zeros = [s['t'] for s in tr if s['t'] > t_end and s.get('lon') == 0]
|
||||||
|
relit = [s for s in tr if zeros and s['t'] > zeros[0] and s.get('lon')]
|
||||||
|
print('after the M5: hv max %d (dark <= %d); laser_on_sampled nonzero samples %d, '
|
||||||
|
'after its first zero %d' % (hv_after, HV_DARK_MAX, len(lon_after), len(relit)))
|
||||||
|
print('trace from 0.2 s before the line ended, 40 ms steps (hv / thermopile delta):')
|
||||||
|
row = [s for s in tr if t_end - 0.2 <= s['t'] <= t_end + 9.0]
|
||||||
|
for i in range(0, len(row), 25):
|
||||||
|
chunk = row[i:i + 25]
|
||||||
|
print(' +%4.1fs hv: %s' % (chunk[0]['t'] - t_end, ' '.join('%d' % (s['hv'] or 0) for s in chunk)))
|
||||||
|
print(' tp: %s' % ' '.join('%d' % ((s['tp'] or 0) - (base or 0)) for s in chunk))
|
||||||
|
covered = tr[-1]['t'] - t_end
|
||||||
|
if covered < 7.0:
|
||||||
|
print('M5DARK INCONCLUSIVE: the trace ends %.1f s after the line, before '
|
||||||
|
'the rapids (the job runs ~8 s past the M5)' % covered)
|
||||||
|
return 1
|
||||||
|
ok = len(segs) == 1 and hv_after <= HV_DARK_MAX and not relit
|
||||||
|
print('M5DARK %s' % ('PASS: the rapids after the M5 shipped dark (%.1f s sampled past the line)'
|
||||||
|
% covered if ok else 'FAIL: the laser fired after the M5'))
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
def post_ctrl(action):
|
def post_ctrl(action):
|
||||||
# http.client preserves the header-name case exactly as given.
|
# http.client preserves the header-name case exactly as given.
|
||||||
import http.client
|
import http.client
|
||||||
@@ -825,6 +1468,7 @@ def main():
|
|||||||
drills = {'witness': drill_witness, 'hold': drill_hold,
|
drills = {'witness': drill_witness, 'hold': drill_hold,
|
||||||
'faultpos': drill_faultpos, 'ircut': drill_ircut,
|
'faultpos': drill_faultpos, 'ircut': drill_ircut,
|
||||||
'pthresh': drill_pthresh, 'dladder': drill_dladder,
|
'pthresh': drill_pthresh, 'dladder': drill_dladder,
|
||||||
|
'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
|
||||||
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
|
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
|
||||||
if drill not in drills:
|
if drill not in drills:
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
|
|||||||
Reference in New Issue
Block a user