Add the flowload drill and record the flow check under laser load

The flowload drill in scripts/bench/live_fire_drills.py runs the two tests
of the flow-check plan: t1 fires two CW fills on the press with the check
at its defaults, t2 fires one fill of a chosen length with the check off,
and fit reads rise against dose over the t2 records with the ADC offset
steps masked. The sampler adds the heater output, /cool/status is polled
at 1 Hz with the fan gates, and every controller reply is kept. The job
is fed against the RX buffer's free count, M5 is acknowledged before a
run, a run is refused while the window is armed, and M2 is acknowledged
and the window's close is waited for.

BRINGUP gets item 20: the arm at the first laser-on is skipped while the
driver's spindle-state record reads on, the record is not cleared on
disarm, and the serial layer drops bytes on a full RX ring, so a job with
a lost M5 lets the next job run unarmed. CAMPAIGN-LOG records the Test 1
and Test 2 runs and their numbers.

No catalog consequence: a bench drill on the dev image and documentation;
no runtime behavior of the release image changes.
This commit is contained in:
ScottW514
2026-08-29 13:54:41 -04:00
parent f84b9df040
commit b5de7cda9d
4 changed files with 940 additions and 9 deletions
+20
View File
@@ -1434,6 +1434,26 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
(`KPROBES`, `PERF_EVENTS`, `BPF_SYSCALL`, `DEBUG_FS`, `DEVMEM`,
`MAGIC_SYSRQ`: no runtime cost unused, root-only exposure, and root can
load modules anyway).
20. **Arm skipped on a stale spindle state (safety, fix before the next
image).** `glowforge_laser.c` arms on the first laser-on of a job only
while its own record of the spindle state reads off
(`state.on && !cur.on && !laser_ok` in `spindleSetState`), and
`gflaser_disarm` does not clear that record. A job whose M5 never
executes leaves the record on, and the next job's M3 runs with no arm:
no button wait, no run report to forgectrl, no run airflow. Fire stays
suppressed at the stream, so no energy leaves the tube, but the head
runs the whole job without the operator's consent. Seen on the bench:
a sender wrote a 93-line job at once, the RX ring (1023 bytes)
overflowed, and the serial layer drops bytes on a full ring (`serial.c`,
the overflow flag is set and never read), so the job's M5 and M2 were
lost, the window stayed open until the sender disconnected, and the
following job ran unarmed. Fix, in this order: arm on
`state.on && !laser_ok` (the consent question does not depend on the
previous spindle state); clear the spindle state in `gflaser_disarm`;
consume the RX overflow flag (report the error and drop the client, so
a sender that ignores flow control cannot leave a half job behind).
Each part gets a regression test in the null-sink harness with the fix,
and the catalog's `covers` widens to `glowforge_laser.c` and `serial.c`.
**Deliberately not gated:** an armed GRBL job after an underrun cuts at the
stale origin unless homing is required (GRBL mode permits unhomed cutting; the
+88
View File
@@ -4138,6 +4138,94 @@ are not owed. The item is removed from BRINGUP, and the items after it are
renumbered: 17 to 20 are now 16 to 19. `GFSINK_LEAD_MS` (default 10) and
the per-run margin report stay as shipped.
## 2026-08-29: the flow check under laser load, Tests 1 and 2
The 2026-08-25 flow-check trip (heater rise 15.1 C against the 14.4 C limit,
dT 9.4, while the first `dpatch` patch fired CW through the check window) was
run down with a new drill, `flowload` in `scripts/bench/live_fire_drills.py`.
`flowload t1` puts the three check keys at their defaults for the run and
fires two 30 x 4 mm CW fills (F1500, 0.3 mm pitch, about 35 s lit) on the
press with no dark dwell; `flowload t2 <secs> [pct]` writes
`cool_flow_check_s = 0` for the run and fires one fill sized to the lit
seconds at CW or at a density level; `flowload fit` fits rise against dose
over the t2 records. The sampler is the `dpatch` one plus
`thermal/heater_pwm`, `/cool/status` is polled at 1 Hz with the fan gates,
and every controller reply is kept. Every key the drill writes goes back to
what stood before when the run ends. The pump was never commanded off.
Records: `bench-data/flowload_t1_20260829-*.json` and
`bench-data/flowload_t2_*_20260829-*.json` in the tree.
**Test 1, three runs.** The check starts at the session open (the heater
comes on about one second after the M3, not at the press), so the press must
come at once for the fire to overlap the window. Runs 1 and 2 ended their
windows early (the drill closed the session on M2 before the 50 s were up;
fixed: the drill now holds the window open until the heater trace ends).
Run 3 ran the full window with the tube lit for 70 % of it, and the engine
read `coolant flow verified (heater rise 14.1 C, dT 9.5 C)`: 0.3 C from a
SUSPECT, where the same loop reads 11.7 to 12.1 C dark. The trip is
reproduced in kind, and it is not flow. Two things stack:
- **A common-mode ADC offset while the run airflow profile is on.** One
sample after the session opens (fans to run duty) both coolant sensors
drop 1.0 to 1.9 C together; they step back up when the fans return to
idle; in between the readings toggle between two levels 0.6 to 1.1 C apart,
both sensors in lockstep, up to 22 times in a run, with every fan steady
at speed. The one session in which the air-assist fan never left idle
showed no step and no toggling, the only pointer to a source so far. The
engine captures `flow_base_down` from one sample at its first tick after
the heater starts, inside that offset, so every rise carries about +1.1
to +1.4 C from the offset and about +-0.5 C of single-sample scatter, dark
or lit.
- **The tube's heat at the sensors.** Test 2 below: about 1.5 C inside a
fully lit 50 s CW window.
Dark 11.7 plus the tube's 1.5 plus one low base sample reaches 14.1; the
15.1 trip is the tail of the same distribution.
**Test 2, the tube's signature, check off.** CW bursts of 20.8, 40.8, 47.9
and 59.0 s and one 59.4 s burst at 45 % density (S450). With the ADC offset
steps masked (a step is both sensors' half-second mean levels changing by
0.45 C or more the same way, agreeing within 0.4 C, subtracted from all
later samples), the downstream rise at burst end against the `hv_current`
integral is linear through the origin: **k = 3.06e-5 C per raw-second**
(r2 0.981, intercept 0.04 C), 0.030 C per lit second at hv 971, so **a fully
lit 50 s CW check window adds 1.49 C** against the 1.6 C margin; the rise
50 s after fire start read 1.43 to 1.49 C on every burst long enough. The
lag from first emission to the first sensor response is 10 to 20 s, a
smooth ramp on both sensors together, never a step at fire start. At 45 %
density the same window adds 0.46 C, and the heat per raw-second is 0.77 of
CW: the current integral overstates density heat, so a tracer needs one k
per power model.
**Events on the way.** One `t2 40` run held at +7 s on the airflow gate
(`air_assist 1895 under the 6000 floor for 3 s`): the air-assist fan never
left its idle reading inside the 15 s grace, the first air-assist trip on
record; it spun up normally in every other session. The first `t2 60` run
was written to the controller as one 93-line block, about 1270 bytes
against the 1023-byte RX ring, and the serial layer drops bytes on a full
ring, so the fill ran 46 s instead of 60 and the job's M5 and M2 were lost:
the window stayed open (engine phase `run`, `armed` true) until the drill
exited and dropped the connection. The next `t2 60` then ran its whole fill
with no arm, no button wait, no run report and no airflow: the driver's
spindle-state record was still on from the lost M5, and the arm at the
first laser-on is skipped when that record reads on. Fire stayed suppressed
at the stream (no HV, no emission, thermopile flat), so no energy left the
tube, but the head ran a full job without the operator's press. This is
BRINGUP "Next work" item 20 (arm on `state.on && !laser_ok`, clear the
spindle state in `gflaser_disarm`, consume the RX overflow flag), a fix
owed before the next image. The drill now feeds the job against the `Bf:`
free-character count, sends and acknowledges M5 before every run, refuses
to start while `/cool/status` shows the window armed, acknowledges M2 and
waits for the window to close, and prints the controller's replies: the
last three runs show `press the button to start the laser job`,
`laser armed (density)` on the press, and `Pgm End` with
`laser disarmed - latch locked` on the M2.
**Also seen.** `laser power-good degraded during the armed window` is
warned by the engine at every session open, a separate item. The check
window's own baseline and the tube term are the two candidates the fix
chooses between; nothing is built yet.
## Superseded status notes
### Shared machine services — remaining polish, as listed 2026-08-13
+1 -1
View File
@@ -32,7 +32,7 @@ page's takeover does that; from a host, stop them first.
| `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, 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. |
| `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), `dpatch` (depth witness for the density curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25, row B density 100/80/60/45/30 % at F600; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `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. |
| `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), `dpatch` (depth witness for the density curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25, row B density 100/80/60/45/30 % at F600; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `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), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `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. |
| `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. |
+831 -8
View File
@@ -105,6 +105,25 @@ Drills (pass a name):
to what the thermopile says it should be. Samples sysfs at
25 Hz like pcurve; JSON record in the bench data directory.
dpatch [F] [pitch] [length] e.g. dpatch 1500 0.3 30
flowload Cooling under laser load, one armed run per invocation, the
conf keys it writes put back when the run ends; the pump is
never commanded off. `t1` reproduces the flow-check trip:
the check ON at its defaults (50 s window, 14.4 C limit,
150 s re-check) and two 30 x 4 mm serpentine fills at F1500
CW starting on the press with no dark dwell, so the tube is
lit for about 35 s of the window; reports the engine's own
rise/dT verdict beside the 25 Hz trace of both coolant
sensors, the current, the digital witness and the heater
output, in 5 s bins across the window, and the shape at
fire start (a common-mode step is electrical, a lagged ramp
is thermal). `t2 <secs> [pct]` turns the check OFF for the
run (cool_flow_check_s = 0) and fires one fill of about
<secs> lit seconds at CW, or at <pct> density; reports the
lag to each sensor, the rise per raw-second of hv_current
(k) and what a full 50 s window would add against the 1.6 C
margin. `fit` reads every t2 record in the bench data dir
and fits rise against dose. JSON records like dpatch.
flowload t1 | flowload t2 <secs> [pct] | flowload fit
expstop Armed kill on the EXPECTED-stop path: start a mark job,
then mid-burn POST /controller/stop (the supervisor stops
the controller: SIGTERM, reap, exit safing). PASS: emission
@@ -120,6 +139,7 @@ The G-4 arm-refuses-when-a-fire-gate-is-active drill is operator-manual
"""
import json
import os
import re
import socket
import sys
import time
@@ -154,6 +174,8 @@ class Grbl:
self.s = socket.create_connection((host, port), timeout=5)
self.s.settimeout(0.2)
self.buf = b''
self.partial = ''
self.log = [] # (t, line) for every non-status reply
time.sleep(0.5)
self.drain()
@@ -167,7 +189,18 @@ class Grbl:
except socket.timeout:
pass
out, self.buf = self.buf, b''
return out.decode('ascii', 'replace')
text = out.decode('ascii', 'replace')
# Keep every complete reply that is not a status frame: ok, error,
# [MSG:...], ALARM. The drills otherwise discard them, and the
# controller reports arming and refusals only to the sender.
pieces = (self.partial + text).split('\n')
self.partial = pieces.pop()
now = time.time()
for ln in pieces:
ln = ln.strip()
if ln and not ln.startswith('<'):
self.log.append((now, ln))
return text
def cmd(self, line, timeout=5.0):
self.s.sendall(line.encode() + b'\n')
@@ -822,8 +855,9 @@ class Sampler:
forgectrl's /status at ~8 Hz, which carries the current and nothing
the thermopile needs."""
def __init__(self, hz):
def __init__(self, hz, channels=PCURVE_CHANNELS):
import threading
self.channels = channels
self.local = os.path.isdir(SYSFS)
self.period = 1.0 / (hz if self.local else 8)
self.samples = []
@@ -840,7 +874,7 @@ class Sampler:
def _read_sysfs(self):
smp = {'t': time.time()}
for key, attr in PCURVE_CHANNELS:
for key, attr in self.channels:
try:
with open(os.path.join(SYSFS, attr)) as f:
smp[key] = int(f.read().strip())
@@ -851,7 +885,7 @@ class Sampler:
def _read_status(self):
st = sample_forgectrl()
smp = dict((key, None) for key, _attr in PCURVE_CHANNELS)
smp = dict((key, None) for key, _attr in self.channels)
smp['t'] = time.time()
if st is None:
self.errors += 1
@@ -1514,6 +1548,796 @@ def drill_dpatch(g):
return 0
# --- cooling under laser load: the flow check with the tube lit --------------
# Test 1 reproduces the 2026-08-25 trip: the arm-time heater check (50 s at
# cool_flow_heater_pct, judged on the downstream rise against cool_flow_rise)
# ran while the first dpatch patch fired CW through its window and read 15.1 C
# against 14.4, a SUSPECT that held the job. The defaults go back into the
# conf for the run and the burst starts on the press with no dark dwell, so
# the tube is lit for most of the window. Test 2 turns the check off for the
# run and fires one burst of a chosen length, so the trace holds the tube's
# heat alone: the lag to the sensors, the rise per raw-second of hv_current,
# and its shape (a step at fire start is electrical, a lagged ramp is
# thermal). Each invocation is ONE armed run; the conf keys it writes are put
# back when the run ends, and the engine re-reads them at the next run start.
# The pump is never commanded off here.
# The burst is a serpentine fill at the dpatch reference dose (25 mm/s, 0.3 mm
# lines: an engrave, not a burn), sized by the seconds the tube is to be lit.
FLOWLOAD_FEED = 1500 # mm/min, the dpatch reference dose
FLOWLOAD_PITCH = 0.3 # mm between lines
FLOWLOAD_W = 30.0 # mm, the line length (X)
FLOWLOAD_Y_STEP_S = 0.1 # about, per pitch step at the edge
FLOWLOAD_T1_H = 8.0 # mm: two 30 x 4 mm fills, back to back
FLOWLOAD_T1_KEYS = (('cool_flow_check_s', '50'), ('cool_flow_rise', '14.4'),
('cool_recheck_s', '150'))
FLOWLOAD_T2_KEYS = (('cool_flow_check_s', '0'),)
FLOWLOAD_T2_SECS = (20, 40, 60) # the plan's CW bursts
FLOWLOAD_BASE_S = 15.0 # baseline before the first emission
FLOWLOAD_TAIL_S = {'t1': 40.0, 't2': 90.0} # sampled after the window closes
FLOWLOAD_BIN_S = 5.0 # the report's bins
FLOWLOAD_RESP_BIN_S = 2.0 # the lag search's bins
FLOWLOAD_RESP_C = 0.3 # a sensor has responded past this rise
FLOWLOAD_STEP_S = 4.0 # a rise inside this of fire start is a step
FLOWLOAD_MARGIN_C = 1.6 # healthy max 12.75 C to the 14.4 limit
FLOWLOAD_NOISE_C = 0.11 # settled-loop split-half noise
FLOWLOAD_PRESS_WAIT_S = 300.0 # the operator's button timeout
FLOWLOAD_CHANNELS = PCURVE_CHANNELS + (('htr', 'thermal/heater_pwm'),)
# The engine's own verdict lines, as /cool/status "reason" carries them.
FLOWLOAD_RISE_RE = re.compile(
r'heater rise ([0-9.]+) C(?: \(limit ([0-9.]+), dT ([0-9.]+)\)|, dT ([0-9.]+) C)')
class CoolPoller:
"""GET /cool/status once a second in a thread: the engine's phase,
verdict, hold, reason (its last warning, where a check's rise and dT
land) and its own view of both sensors."""
def __init__(self):
import threading
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 _run(self):
keys = ('phase', 'verdict', 'hold', 'reason', 'down_c', 'up_c',
'armed', 'fire_ok', 'fan_gates')
next_t = time.time()
while not self._stop.is_set():
smp = {'t': time.time()}
try:
cs = get_json('/cool/status')
for k in keys:
smp[k] = cs.get(k)
except Exception:
self.errors += 1
self.samples.append(smp)
next_t += 1.0
delay = next_t - time.time()
if delay > 0:
time.sleep(delay)
else:
next_t = time.time()
def conf_del(key):
"""Remove one key, preserving every other line."""
try:
with open(CONF) as f:
lines = f.read().splitlines()
except OSError:
return False
out = [ln for ln in lines
if not ('=' in ln.split('#', 1)[0]
and ln.split('#', 1)[0].split('=', 1)[0].strip() == key)]
try:
mode = os.stat(CONF).st_mode & 0o777
with open(CONF + '.tmp', 'w') as f:
f.write('\n'.join(out) + '\n')
os.chmod(CONF + '.tmp', mode)
os.replace(CONF + '.tmp', CONF)
except OSError:
return False
return True
def conf_push(keys):
"""Write the (key, value) pairs, returning what stood before so
conf_pop can put it back; None when the conf is not ours to write."""
prior = []
for key, val in keys:
prior.append((key, conf_get(key)))
if not conf_set(key, val):
conf_pop(prior)
return None
return prior
def conf_pop(prior):
ok = True
for key, val in prior:
ok = (conf_del(key) if val is None else conf_set(key, val)) and ok
return ok
def flowload_burst(secs, feed=FLOWLOAD_FEED, width=FLOWLOAD_W,
pitch=FLOWLOAD_PITCH):
"""A serpentine fill that keeps the tube lit for about `secs`, and
the seconds it should take."""
line_s = width / (feed / 60.0) + FLOWLOAD_Y_STEP_S
n = max(1, int(round(secs / line_s)))
lines, dx, dy = dpatch_gcode(feed, width, pitch, n)
return lines, dx, dy, n * line_s
def flowload_verdicts(poll):
"""Every change of the engine's verdict/hold/reason seen in the poll,
with the time it first appeared, and the rise, limit and dT parsed
where the line carries them."""
out, last = [], None
for s in poll:
if 'verdict' not in s:
continue
key = (s.get('verdict'), s.get('hold'), s.get('reason'))
if key == last:
continue
last = key
ev = {'t': s['t'], 'verdict': s.get('verdict'), 'hold': s.get('hold'),
'reason': s.get('reason')}
m = FLOWLOAD_RISE_RE.search(s.get('reason') or '')
if m:
ev['rise'] = float(m.group(1))
ev['limit'] = float(m.group(2)) if m.group(2) else None
ev['dt'] = float(m.group(3) or m.group(4))
out.append(ev)
return out
FLOWLOAD_STEP_C = 0.45 # a common-mode level change this big
FLOWLOAD_STEP_AGREE_C = 0.4 # ... on both sensors, agreeing this well
FLOWLOAD_STEP_WIN = 12 # samples (0.5 s at 25 Hz) each side
FLOWLOAD_STEP_GAP = 2 # samples skipped around the edge
def flowload_steps(tr):
"""The common-mode steps in the trace: the mean level of BOTH sensors
changing by FLOWLOAD_STEP_C or more, the same way and by the same
amount, between the half second before a sample and the half second
after it. The coolant ADC carries an offset of about 1 C while the
run airflow profile is on, stepping in at the session open, out when
the fans go idle and toggling in between, on both sensors together.
The tube's heat is a ramp a hundred times slower, and the heater's
rise reaches one sensor only, so neither passes. Returns
[(t, d_down_c, d_up_c)] with the step sizes in degrees."""
pts = []
for s in tr:
if s.get('wt1') is None or s.get('wt2') is None:
continue
d, u = _degc(s['wt1']), _degc(s['wt2'])
if d == d and u == u:
pts.append((s['t'], d, u))
out = []
w, gap = FLOWLOAD_STEP_WIN, FLOWLOAD_STEP_GAP
i = w + gap
while i < len(pts) - w - gap:
pre = pts[i - gap - w:i - gap]
post = pts[i + gap:i + gap + w]
dd = sum(p[1] for p in post) / w - sum(p[1] for p in pre) / w
du = sum(p[2] for p in post) / w - sum(p[2] for p in pre) / w
if abs(dd) >= FLOWLOAD_STEP_C and abs(du) >= FLOWLOAD_STEP_C \
and (dd > 0) == (du > 0) and abs(dd - du) <= FLOWLOAD_STEP_AGREE_C:
out.append((pts[i][0], dd, du))
i += w + 2 * gap # one step per edge
else:
i += 1
return out
def flowload_temps(tr, poll, local, steps=None):
"""(t, down_c, up_c): the sampler's counts through the factory
conversion on the board, else the engine's own 1 Hz view. With
`steps` (flowload_steps), every step is subtracted from all later
samples, so the series carries the thermal signal alone."""
if local and _degc(0) is not None:
out = []
steps = list(steps or [])
off_d = off_u = 0.0
for s in tr:
if s.get('wt1') is None or s.get('wt2') is None:
continue
d, u = _degc(s['wt1']), _degc(s['wt2'])
if d != d or u != u: # NaN
continue
while steps and steps[0][0] <= s['t']:
_t, dd, du = steps.pop(0)
off_d += dd
off_u += du
out.append((s['t'], d - off_d, u - off_u))
if out:
return out
return [(p['t'], p['down_c'], p['up_c']) for p in poll
if p.get('down_c') is not None and p.get('up_c') is not None]
def _tmean(series, t0, t1, idx):
vals = [s[idx] for s in series if t0 <= s[0] < t1]
return sum(vals) / len(vals) if vals else None
def _tsd(series, t0, t1, idx):
vals = [s[idx] for s in series if t0 <= s[0] < t1]
return _stats(vals)['sd'] if len(vals) > 1 else None
def flowload_lit(tr):
"""(first, last) sample time with the tube lit, from the digital
witness or a current above the dark ceiling; (None, None) if never."""
ts = [s['t'] for s in tr
if (s.get('lon') or 0) > 0 or (s.get('hv') or 0) > HV_DARK_MAX]
return (ts[0], ts[-1]) if ts else (None, None)
def flowload_dose(tr, t0, t1):
"""The integral of hv_current from t0 to t1 in raw-seconds, and the
mean current over the lit samples."""
pts = [(s['t'], s['hv']) for s in tr
if t0 <= s['t'] <= t1 and s.get('hv') is not None]
dose = 0.0
for (ta, ha), (tb, hb) in zip(pts, pts[1:]):
dose += 0.5 * (ha + hb) * (tb - ta)
lit = [h for _t, h in pts if h > HV_DARK_MAX]
return dose, (sum(lit) / len(lit) if lit else 0.0)
def flowload_response(temps, idx, t_fire0, t_end, base, sd):
"""How one sensor answered the burst: the lag to its first bin over
the response line, the rise inside the step window, the rise at
t_end and the peak (value, time) up to the end of the record."""
thr = base + max(FLOWLOAD_RESP_C, 3.0 * (sd or 0.0))
lag = None
t = t_fire0
while t < temps[-1][0]:
m = _tmean(temps, t, t + FLOWLOAD_RESP_BIN_S, idx)
if m is not None and m > thr:
lag = t - t_fire0
break
t += FLOWLOAD_RESP_BIN_S
step = _tmean(temps, t_fire0 + 1.0, t_fire0 + FLOWLOAD_STEP_S, idx)
at_end = _tmean(temps, t_end - FLOWLOAD_RESP_BIN_S, t_end, idx)
at_50 = _tmean(temps, t_fire0 + 48.0, t_fire0 + 50.0, idx)
peak, t_peak = None, None
t = t_fire0
while t < temps[-1][0]:
m = _tmean(temps, t, t + FLOWLOAD_RESP_BIN_S, idx)
if m is not None and (peak is None or m > peak):
peak, t_peak = m, t
t += FLOWLOAD_RESP_BIN_S
return {
'lag_s': lag,
'step_c': None if step is None else step - base,
'end_c': None if at_end is None else at_end - base,
'r50_c': None if at_50 is None else at_50 - base,
'peak_c': None if peak is None else peak - base,
't_peak_s': None if t_peak is None else t_peak - t_fire0,
}
def flowload_bins(tr, temps, t0, t1, t_ref):
"""The report's table: per FLOWLOAD_BIN_S bin, seconds from t_ref,
both sensors, dT, the current's mean, the lit fraction, the heater."""
print(' t(s) down C up C dT C hv mean lit heater')
t = t0
while t < t1:
d = _tmean(temps, t, t + FLOWLOAD_BIN_S, 1)
u = _tmean(temps, t, t + FLOWLOAD_BIN_S, 2)
hv = _stats(_window(tr, t, t + FLOWLOAD_BIN_S, 'hv'))['mean']
lon = _window(tr, t, t + FLOWLOAD_BIN_S, 'lon')
lit = (sum(1 for v in lon if v > 0) / float(len(lon))) if lon else None
htr = _stats(_window(tr, t, t + FLOWLOAD_BIN_S, 'htr'))['mean']
print(' %6.0f %7s %7s %6s %7s %4s %6s'
% (t - t_ref, _fmt(d, 2), _fmt(u, 2),
_fmt(None if d is None or u is None else d - u, 2),
_fmt(hv, 0), '' if lit is None else '%.2f' % lit,
_fmt(None if htr is None else 100.0 * htr / 65535.0, 0)))
t += FLOWLOAD_BIN_S
FLOWLOAD_RX_MARGIN = 96 # bytes kept free in the RX buffer
def _bf_free(st):
"""(free planner blocks, free RX characters) from a status report, or
(None, None) when it carries no Bf field."""
for fld in st.strip('<>').split('|'):
if fld.startswith('Bf:'):
try:
a, b = fld[3:].split(',')
return int(a), int(b)
except ValueError:
return None, None
return None, None
def flowload_feed(g, pending, free_chars):
"""Send lines from `pending` while the controller's RX buffer has
room for them, and return the room left. A whole fill written at
once overruns the 1023-byte RX buffer and loses lines (the 60 s
fill ran 46 s), so the job is fed against the Bf field instead."""
while pending and free_chars is not None \
and free_chars - (len(pending[0]) + 1) >= FLOWLOAD_RX_MARGIN:
ln = pending.pop(0)
g.s.sendall(ln.encode() + b'\n')
free_chars -= len(ln) + 1
return free_chars
def flowload_watch(g, est_s, pending):
"""Follow the controller from the press to the end of the burst,
feeding `pending` lines as the RX buffer frees. Returns (outcome,
timeline): 'done' on Idle after Run with every line sent, 'held'
when a Hold or Door interrupted it (the SUSPECT hold looks like
this), 'alarm', 'no-run' when the press never came, 'timeout'."""
timeline = []
last = None
t0 = time.time()
ran = False
t_run0 = None
while True:
st = g.status()
name = st[1:].split('|')[0].split(':')[0] if st else ''
flowload_feed(g, pending, _bf_free(st)[1])
now = time.time()
if name != last:
timeline.append({'t': now, 'state': st})
last = name
if name == 'Run':
if not ran:
t_run0 = now
ran = True
elif name in ('Hold', 'Door'):
time.sleep(1.0)
if g.state().split(':')[0] in ('Hold', 'Door'):
return 'held', timeline
elif name == 'Alarm':
return 'alarm', timeline
elif name == 'Idle' and ran and not pending and now - t_run0 > 1.0:
time.sleep(0.5)
if g.state().startswith('Idle'):
return 'done', timeline
if not ran and now - t0 > FLOWLOAD_PRESS_WAIT_S + 10:
return 'no-run', timeline
if ran and now - t_run0 > est_s + 60.0:
return 'timeout', timeline
time.sleep(0.1)
def flowload_run(g, test, secs, pct, keys):
"""One armed run: conf keys in, baseline, the burst on the press, the
window closed on M2, the tail, conf keys back. Returns the record."""
model = conf_get('laser_power_model') or 'density'
levels, (rpm_max, rpm_min, floor, ceil) = pcurve_levels(g, (pct,))
if levels is None:
print('PRECONDITION FAILED: cannot read $30/$31/$35/$36 (%s/%s/%s/%s)'
% (rpm_max, rpm_min, floor, ceil))
return None
_pct, s_val, level = levels[0]
if pct < 100 and model != 'density':
print('a %d %% burst is a density burst; laser_power_model is %s' % (pct, model))
return None
if test == 't1':
n = int(round(FLOWLOAD_T1_H / FLOWLOAD_PITCH))
lines, dx, dy = dpatch_gcode(FLOWLOAD_FEED, FLOWLOAD_W, FLOWLOAD_PITCH, n)
est_s = n * (FLOWLOAD_W / (FLOWLOAD_FEED / 60.0) + FLOWLOAD_Y_STEP_S)
shape = ('two %g x %g mm fills back to back (%d lines at %g mm), about %.0f s lit'
% (FLOWLOAD_W, FLOWLOAD_T1_H / 2, n, FLOWLOAD_PITCH, est_s))
else:
lines, dx, dy, est_s = flowload_burst(secs)
n = len([ln for ln in lines if 'X' in ln])
shape = ('a %g x %.1f mm fill (%d lines at %g mm), about %.0f s lit'
% (FLOWLOAD_W, n * FLOWLOAD_PITCH, n, FLOWLOAD_PITCH, est_s))
print('=== flowload %s: %s ===' % (test, shape))
print('burst: S%d = %s (level %.2f of full, %s model; $30=%g $31=%g $35=%g $36=%g)'
% (s_val, 'CW' if pct >= 100 else '%d %% density' % pct, level, model,
rpm_max, rpm_min, floor, ceil))
prior = conf_push(keys)
if prior is None:
print('REFUSED: cannot write %s (the check state for this run must be known; '
'run on the board)' % CONF)
return None
print('conf for this run: %s (was: %s)'
% (', '.join('%s=%s' % kv for kv in keys),
', '.join('%s=%s' % (k, v if v is not None else '<unset>') for k, v in prior)))
sampler = Sampler(PCURVE_SAMPLE_HZ, channels=FLOWLOAD_CHANNELS)
poller = CoolPoller()
outcome, timeline, aborted = None, [], None
t_m3 = t_close = None
try:
print('connect: %s' % prepare(g))
# The driver arms on the first laser-on of a job only while its own
# spindle state reads off; a job whose M5 was lost leaves it on and
# the next M3 runs unarmed. Spindle off first, and no run against a
# window that is still open.
print('spindle off: %s' % g.cmd('M5'))
pre = sample_forgectrl()
print('pre-fire: %s' % pre)
if pre is None or pre.get('armed'):
print('REFUSED: the armed window is still open (or forgectrl is unreachable); '
'close it before another run')
return None
sampler.start()
poller.start()
print('baseline: %g s dark and idle' % FLOWLOAD_BASE_S)
time.sleep(FLOWLOAD_BASE_S + 2.0)
arm_cue()
print('>>> A fresh area of scrap: %g mm along +X by %.1f mm along +Y from'
% (FLOWLOAD_W, dy + FLOWLOAD_PITCH))
print('>>> the head. The burst starts ON YOUR PRESS with no dark dwell'
+ (' and the flow check\n>>> runs under it.' if test == 't1'
else ';\n>>> the flow check is OFF for this run.'))
print('>>> Up to %.0f s of %s.\n'
% (est_s, 'CW at full power' if pct >= 100 else '%d %% density' % pct))
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
t_m3 = time.time()
pending = ['M3 S%d' % s_val] + lines + ['M5']
n_job = len(pending)
flowload_feed(g, pending, _bf_free(g.status())[1])
print(' waiting for the press (up to %.0f s), then the burst (%d of %d lines '
'queued, the rest fed as the buffer frees)'
% (FLOWLOAD_PRESS_WAIT_S, n_job - len(pending), n_job))
outcome, timeline = flowload_watch(g, est_s, pending)
if pending:
print(' %d lines were never sent' % len(pending))
if outcome == 'done':
g.s.sendall(('G0 X%g Y%g\n' % (-dx, -dy)).encode()) # back to the origin
g.wait_state('Idle', 30)
if test == 't1':
# M2 closes the window and the window's end stops the
# check, so a check still running (the heater on) gets
# its full window before the M2: the engine's verdict is
# what this test is for.
limit = time.time() + float(dict(keys)['cool_flow_check_s']) + 20.0
print(' burst done; holding the window open until the heater check ends')
while time.time() < limit:
smp = sampler.samples[-250:]
if smp and smp[-1].get('htr') == 0 \
and any((x.get('htr') or 0) > 0 for x in smp):
break # the check ran and ended
if smp and smp[-1].get('htr') == 0 and time.time() - t_m3 > 20.0 \
and not any((x.get('htr') or 0) > 0 for x in sampler.samples):
break # the check never started
time.sleep(0.5)
time.sleep(2.0) # the verdict's poll after the heater
print(' M2: %s' % g.cmd('G90\nM2', timeout=10)) # closes the window
t_close = time.time()
closed = None
for _i in range(15):
cs = sample_forgectrl()
if cs and not cs.get('armed'):
closed = time.time() - t_close
break
time.sleep(1.0)
if closed is None:
print(' !!! the armed window did NOT close after M2 (armed still true '
'after 15 s): the job did not end as sent')
else:
print(' window closed %.0f s after M2, sampling the %.0f s tail'
% (closed, FLOWLOAD_TAIL_S[test]))
else:
cs = sample_forgectrl() or {}
aborted = ('%s (state %s; cooling verdict %s, %r); soft reset, no further moves'
% (outcome, timeline[-1]['state'] if timeline else '?',
cs.get('verdict'), cs.get('reason')))
g.rt(b'\x18')
t_close = time.time()
print(' %s' % aborted)
print(' sampling the %.0f s tail anyway' % FLOWLOAD_TAIL_S[test])
time.sleep(FLOWLOAD_TAIL_S[test])
finally:
try:
g.cmd('M5', timeout=1)
except Exception:
pass
sampler.stop()
poller.stop()
if conf_pop(prior):
print('conf restored')
else:
print('CONF NOT RESTORED: put back by hand: %s'
% ', '.join('%s=%s' % (k, v if v is not None else '<unset>')
for k, v in prior))
tr, poll = sampler.samples, poller.samples
g.drain()
print('\nsampler: %d samples, %.1f Hz achieved, %d read errors; /cool/status %d polls, %d errors'
% (len(tr), sampler.rate(), sampler.errors, len(poll), poller.errors))
replies = [(t, ln) for t, ln in g.log if t >= (t_m3 or 0) - 30.0]
n_ok = sum(1 for _t, ln in replies if ln == 'ok')
print('controller replies since 30 s before M3: %d ok; everything else:' % n_ok)
for t, ln in replies:
if ln != 'ok' and not re.match(r'\$\d+=', ln): # not the settings dump
print(' %+7.1f s %s' % (t - (t_m3 or t), ln))
return {
'replies': replies,
'drill': 'flowload', 'test': test, 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
'model': model, 'burst_s': secs if test == 't2' else None, 'pct': pct,
's': s_val, 'level': level, 'feed': FLOWLOAD_FEED, 'pitch_mm': FLOWLOAD_PITCH,
'width_mm': FLOWLOAD_W, 'lines': n, 'est_lit_s': est_s,
'settings': {'$30': rpm_max, '$31': rpm_min, '$35': floor, '$36': ceil},
'conf': dict(keys), 'conf_prior': dict(prior),
'sampler': {'local': sampler.local, 'hz': sampler.rate(),
'samples': len(tr), 'errors': sampler.errors},
't_m3': t_m3, 't_close': t_close, 'outcome': outcome, 'aborted': aborted,
'timeline': timeline, 'events': flowload_verdicts(poll),
'trace': tr, 'poll': poll,
}
def flowload_analyze(rec):
"""Print the run's reading and put the numbers into the record."""
tr, poll, test = rec['trace'], rec['poll'], rec['test']
steps = flowload_steps(tr) if rec['sampler']['local'] and _degc(0) is not None else []
temps = flowload_temps(tr, poll, rec['sampler']['local'], steps)
t_fire0, t_fire1 = flowload_lit(tr)
rec['offset_steps'] = steps
if steps:
net_d = sum(dd for _t, dd, _du in steps)
print('--- ADC offset steps masked: %d common-mode steps (first %+.2f/%+.2f C at %+.1f s '
'from M3, net %+.2f C over the record) ---'
% (len(steps), steps[0][1], steps[0][2], steps[0][0] - rec['t_m3'], net_d))
print('\n--- engine events (verdict / hold / reason, as /cool/status showed them) ---')
for ev in rec['events']:
print(' %+7.1f s %-8s hold=%-5s %s'
% (ev['t'] - (t_fire0 or rec['t_m3']), ev['verdict'], ev['hold'],
ev['reason'] or ''))
print('--- controller ---')
for s in rec['timeline']:
print(' %+7.1f s %s' % (s['t'] - (t_fire0 or rec['t_m3']), s['state']))
fans = [p for p in poll if p.get('fan_gates')]
if fans:
names = list(fans[0]['fan_gates'].keys())
print('--- fan gates, reading per 5 s from the session open (floor: %s) ---'
% ', '.join('%s %.0f' % (n, fans[0]['fan_gates'][n]['floor']) for n in names))
t_open = rec['t_m3']
for p in fans[::5]:
if p['t'] < t_open - 5:
continue
print(' %+7.1f s %s' % (p['t'] - (t_fire0 or t_open), ' '.join(
'%s %5.0f %s' % (n[:8], p['fan_gates'][n]['reading'], p['fan_gates'][n]['state'][:4])
for n in names)))
if not temps:
print('no temperature series (no sysfs and no engine readings): nothing to read')
return
if t_fire0 is None:
print('the tube never lit: nothing to read')
rec['lit'] = None
return
lit_s = t_fire1 - t_fire0
dose, hv_mean = flowload_dose(tr, t_fire0, t_fire1 + 1.0)
# The baseline is the settled loop before anything heated it: before
# the heater when the check ran ahead of the fire (t1), else before
# the fire.
htr_on = [s['t'] for s in tr if (s.get('htr') or 0) > 0]
t_base = min(t_fire0, htr_on[0]) if htr_on else t_fire0
base = {i: _tmean(temps, t_base - FLOWLOAD_BASE_S, t_base - 0.5, i) for i in (1, 2)}
sd = {i: _tsd(temps, t_base - FLOWLOAD_BASE_S, t_base - 0.5, i) for i in (1, 2)}
rec['lit'] = {'t_fire0': t_fire0, 't_fire1': t_fire1, 'lit_s': lit_s,
'dose_raw_s': dose, 'hv_mean': hv_mean}
rec['baseline'] = {'down_c': base[1], 'up_c': base[2],
'down_sd': sd[1], 'up_sd': sd[2]}
print('--- the burst ---')
print(' lit %.1f s (first to last lit sample), hv mean %.0f raw, dose %.0f raw-s'
% (lit_s, hv_mean, dose))
print(' baseline over the %g s before %s: down %s C (sd %s), up %s C (sd %s), dT %s'
% (FLOWLOAD_BASE_S, 'the heater' if t_base < t_fire0 else 'fire',
_fmt(base[1], 2), _fmt(sd[1], 2), _fmt(base[2], 2),
_fmt(sd[2], 2), _fmt(None if None in base.values() else base[1] - base[2], 2)))
if None in base.values():
print(' no baseline: the series starts after the fire')
return
resp = {}
for i, name in ((1, 'down'), (2, 'up')):
resp[name] = flowload_response(temps, i, t_fire0, t_fire1, base[i], sd[i])
rec['response'] = resp
print(' response: lag to +%.1f C rise in %g s rise at burst end peak (at)'
% (FLOWLOAD_RESP_C, FLOWLOAD_STEP_S))
for name in ('down', 'up'):
r = resp[name]
print(' %-5s %9s s %7s C %7s C %7s C (%s s)'
% (name, _fmt(r['lag_s'], 1), _fmt(r['step_c'], 2), _fmt(r['end_c'], 2),
_fmt(r['peak_c'], 2), _fmt(r['t_peak_s'], 0)))
cm_step = [resp[n]['step_c'] for n in ('down', 'up') if resp[n]['step_c'] is not None]
if cm_step and min(cm_step) > 1.0:
shape = 'STEP on both sensors inside %g s of fire start: electrical, not thermal' % FLOWLOAD_STEP_S
elif any(resp[n]['lag_s'] is not None for n in ('down', 'up')):
shape = 'lagged ramp: thermal'
else:
shape = 'no response above the line inside the record'
rec['shape'] = shape
print(' shape: %s' % shape)
if test == 't1':
if htr_on:
t_w0 = htr_on[0]
after = [s['t'] for s in tr if s['t'] > t_w0 and s.get('htr') == 0]
t_w1 = after[0] if after else tr[-1]['t']
src = 'heater output'
else:
armed = [p['t'] for p in poll if p.get('armed')]
t_w0 = armed[0] if armed else t_fire0
t_w1 = t_w0 + float(rec['conf'].get('cool_flow_check_s', 50))
src = 'the arm time (no heater trace)'
lon = _window(tr, t_w0, t_w1, 'lon')
lit_frac = (sum(1 for v in lon if v > 0) / float(len(lon))) if lon else None
rise = None
d0 = _tmean(temps, t_w0, t_w0 + 2.0, 1)
d1 = _tmean(temps, t_w1 - 2.0, t_w1, 1)
if d0 is not None and d1 is not None:
rise = d1 - d0
dts = [d - u for t, d, u in temps if t_w0 + 30.0 <= t < t_w1]
dt = sum(dts) / len(dts) if dts else None
eng = [ev for ev in rec['events'] if 'rise' in ev]
rec['window'] = {'t0': t_w0, 't1': t_w1, 'source': src, 'lit_frac': lit_frac,
'trace_rise_c': rise, 'trace_dt_c': dt,
'engine_rise_c': eng[-1]['rise'] if eng else None,
'engine_dt_c': eng[-1]['dt'] if eng else None}
print('--- the check window (%s): %.0f s, opened %+.1f s from fire start ---'
% (src, t_w1 - t_w0, t_w0 - t_fire0))
print(' lit for %s of the window; trace rise (down, last 2 s over first 2 s) %s C, '
'dT after 30 s %s C' % ('' if lit_frac is None else '%.0f %%' % (100 * lit_frac),
_fmt(rise, 1), _fmt(dt, 1)))
if eng:
print(' the engine read: rise %.1f C (limit %s), dT %.1f C -> %s'
% (eng[-1]['rise'], _fmt(eng[-1].get('limit'), 1), eng[-1]['dt'],
eng[-1]['verdict']))
else:
print(' the engine published no rise line inside the record (check deferred, '
'or the run ended first)')
print('--- 5 s bins from 10 s before the window to 30 s after (t from fire start) ---')
flowload_bins(tr, temps, t_w0 - 10.0, t_w1 + 30.0, t_fire0)
print('--- reading the branch ---')
print(' rise 11 to 12 C on all three runs: the check works under load; the '
'defect is elsewhere.')
print(' about 15 C, common-mode STEP at fire start: electrical (ADC/bias '
'under the HV load).')
print(' about 15 C, lagged common-mode RAMP: thermal; Test 2 sets k and the '
'tracer design applies.')
print(' no trip: the 22:11 run\'s own conditions; one trace-only t2 run closes it.')
else:
k = {}
for name in ('down', 'up'):
p = resp[name]['peak_c']
k[name] = (p / dose) if (p is not None and dose > 0) else None
rec['k_c_per_raw_s'] = k
print('--- the tube\'s signature ---')
for name in ('down', 'up'):
if k[name] is None:
print(' %s: no k (no peak or no dose)' % name)
continue
per_cw_s = k[name] * hv_mean
print(' %-5s k = %.3g C per raw-s = %.3f C per lit second at hv %.0f; '
'a full 50 s window at this level adds %.2f C against the %.1f C margin'
% (name, k[name], per_cw_s, hv_mean, per_cw_s * 50.0, FLOWLOAD_MARGIN_C))
pk = resp['down']['peak_c']
if pk is not None:
print(' down peak %.2f C at this dose is %.0fx the %.2f C settled noise'
% (pk, pk / FLOWLOAD_NOISE_C, FLOWLOAD_NOISE_C))
print('--- 5 s bins from 15 s before fire to the end of the tail (t from fire start) ---')
flowload_bins(tr, temps, t_fire0 - 15.0, temps[-1][0], t_fire0)
print(' linearity across doses: run 20, 40 and 60 s, then `flowload fit`')
def flowload_fit():
"""Rise against dose across every t2 record in the bench data dir:
k through the origin per sensor, and the fit's r2 as the linearity."""
import glob
ddir = os.environ.get('FORGETEST_BENCH_DATA') or os.getcwd()
paths = sorted(glob.glob(os.path.join(ddir, 'flowload_t2_*.json')))
if not paths:
print('no flowload_t2_*.json under %s' % ddir)
return 1
import io
import contextlib
rows = []
for p in paths:
try:
with open(p) as f:
r = json.load(f)
except (OSError, ValueError) as e:
print('skip %s: %s' % (p, e))
continue
# Re-read every record from its trace, so the offset masking and
# the current reading apply to records written before them.
with contextlib.redirect_stdout(io.StringIO()):
flowload_analyze(r)
if not r.get('lit') or not r.get('response') or r['lit']['lit_s'] < 15.0:
print(' %s: no usable burst (%s)' % (os.path.basename(p), r.get('outcome')))
continue
rows.append((os.path.basename(p), r))
if not rows:
return 1
print(' offset steps masked per record; rises are the downstream sensor over its pre-burst baseline')
print(' record pct lit s hv dose raw-s +50 s end peak (at) steps')
for name, r in rows:
d = r['response']['down']
print(' %-38s %4d %6.1f %4.0f %10.0f %6s %6s %6s (%3s s) %3d'
% (name, r['pct'], r['lit']['lit_s'], r['lit']['hv_mean'],
r['lit']['dose_raw_s'], _fmt(d['r50_c'], 2), _fmt(d['end_c'], 2),
_fmt(d['peak_c'], 2), _fmt(d['t_peak_s'], 0), len(r.get('offset_steps') or [])))
for label, sel in (('CW', lambda r: r['pct'] >= 100), ('density', lambda r: r['pct'] < 100)):
pts = [(r['lit']['dose_raw_s'], r['response']['down']['end_c'], r['lit']['lit_s'])
for _n, r in rows if sel(r) and r['response']['down']['end_c'] is not None]
if not pts:
continue
sxy = sum(d * c for d, c, _s in pts)
sxx = sum(d * d for d, _c, _s in pts)
k0 = sxy / sxx if sxx else 0.0
hv = sum(r['lit']['hv_mean'] for _n, r in rows if sel(r)) / len([1 for _n, r in rows if sel(r)])
line = (' %s (%d run%s): rise at burst end = %.3g C per raw-s through the origin '
'= %.4f C per lit second at hv %.0f; a fully lit 50 s window adds %.2f C '
'(margin %.1f)' % (label, len(pts), '' if len(pts) == 1 else 's', k0, k0 * hv,
hv, k0 * hv * 50.0, FLOWLOAD_MARGIN_C))
fit = _linfit([d for d, _c, _s in pts], [c for _d, c, _s in pts]) if len(pts) >= 3 else None
if fit:
line += '; free fit intercept %.2f C, r2 %.3f' % (fit[0], fit[2])
print(line)
cw = [r for _n, r in rows if r['pct'] >= 100 and r['response']['down']['end_c']]
dn = [r for _n, r in rows if r['pct'] < 100 and r['response']['down']['end_c']]
if cw and dn:
kc = sum(r['response']['down']['end_c'] / r['lit']['dose_raw_s'] for r in cw) / len(cw)
kd = sum(r['response']['down']['end_c'] / r['lit']['dose_raw_s'] for r in dn) / len(dn)
print(' density heat per raw-s is %.2f of CW: the current integral overstates '
'density heat, one k per model' % (kd / kc))
return 0
def drill_flowload(g):
args = sys.argv[2:]
test = args[0] if args else ''
if test == 'fit':
return flowload_fit()
if test == 't1':
if len(args) > 1:
print('usage: flowload t1')
return 2
secs, pct, keys = None, 100, FLOWLOAD_T1_KEYS
elif test == 't2':
try:
secs = float(args[1])
pct = int(args[2]) if len(args) > 2 else 100
except (IndexError, ValueError):
secs, pct = 0, 0
if not 5.0 <= secs <= 120.0 or not 1 <= pct <= 100:
print('usage: flowload t2 <secs 5..120> [density pct 1..100] e.g. flowload t2 60 45')
return 2
keys = FLOWLOAD_T2_KEYS
else:
print('usage: flowload t1 | flowload t2 <secs> [pct] | flowload fit')
return 2
rec = flowload_run(g, test, secs, pct, keys)
if rec is None:
return 2
flowload_analyze(rec)
ddir = os.environ.get('FORGETEST_BENCH_DATA') or ('/tmp' if rec['sampler']['local'] else os.getcwd())
ts = time.strftime('%Y%m%d-%H%M%S')
stem = ('flowload_t1_%s' % ts if test == 't1'
else 'flowload_t2_%ds_%d_%s' % (int(secs), pct, ts))
path = os.path.join(ddir, stem + '.json')
try:
with open(path, 'w') as f:
json.dump(rec, f, indent=1)
print('record: %s' % path)
except OSError as e:
print('record not written: %s' % e)
return 0 if rec['outcome'] == 'done' else 1
# --- the rapids after an M5 ship dark ----------------------------------------
# One constant-power line, M5, then two rapids over it with dwells between,
@@ -1703,14 +2527,13 @@ def main():
'faultpos': drill_faultpos, 'ircut': drill_ircut,
'pthresh': drill_pthresh, 'dladder': drill_dladder,
'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
'dpatch': drill_dpatch,
'dpatch': drill_dpatch, 'flowload': drill_flowload,
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
if drill not in drills:
print(__doc__)
return 2
if drill == 'ctrlstart':
drills[drill](None)
return 0
if drill == 'ctrlstart' or (drill == 'flowload' and sys.argv[2:3] == ['fit']):
return drills[drill](None) or 0 # no controller connection needed
g = Grbl(HOST, PORT)
try:
drills[drill](g)