mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user