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:
@@ -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