mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
Pin forgectrl f9b4893: the air-assist ground shift off the coolant readings
The pin carries the compensation (cool_aa_offset_counts, the aa-offset-calibrate diagnostic, the panel's Apply); verified with bitbake -c fetch. The catalog gains cooling.aa-offset-calibrate, which runs the tool and checks its recommendation and spread. scripts/bench/offset_probe.py is the differential probe that found the source: one actuator switched at a time with both sensors at 25 Hz (survey), the air-assist duty ladder, the gantry jogging under the fan, and an armed dark dwell. CAMPAIGN-LOG records the four probes and the result; BRINGUP item 21 names the setting and the calibrate as what remains on this machine, and item 23 opens the initial commissioning procedure. No catalog consequence beyond the new case: a bench tool and records.
This commit is contained in:
+34
-7
@@ -1468,13 +1468,24 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
|
||||
second machine is on the bench (one tube, one supply so far); and if a
|
||||
lit check still trips, the void-on-emission design with the tube as
|
||||
its own flow tracer.
|
||||
Open question, the offset's source: the timing points at the airflow
|
||||
drive (the step lands one sample after the fans go to run duty, before
|
||||
any HV, and lifts when they go idle, long after the tube is dark), but
|
||||
high-voltage energy coupling into the lines between the sensors and
|
||||
the ADC is the other candidate and a shared path could show both; a
|
||||
scope on the two sensor lines through a session, fans and tube
|
||||
switched separately, decides.
|
||||
The offset's source is the air-assist fan's return current on a ground
|
||||
path the thermistor reference shares (about 1.2 C at the run duty,
|
||||
proportional to the fan's current, both sensors alike; not crosstalk
|
||||
on the sensor cable and not HV). The check cancels it now that its
|
||||
baseline is taken under the run profile, but the over-temperature
|
||||
gates read the coolant about 1.2 C cooler than it is while the air
|
||||
assist runs unless `cool_aa_offset_counts` carries the machine's value
|
||||
(the `aa-offset-calibrate` diagnostic measures it, the panel's Apply
|
||||
writes it; zero is the factory's uncorrected reading). Owed: the
|
||||
calibrate run on this machine and its value applied, a second
|
||||
machine's value when one is on the bench; and the mid-run
|
||||
toggling between two levels (0.6 to 1.1 C, both sensors together),
|
||||
which comes only with the tube lit: not with the fans alone, not under
|
||||
motion, not in an armed dark window. The HV supply's input current on
|
||||
a return the thermistor reference shares, or its switching, is what
|
||||
remains; a scope on the two sensor lines during a cut is the next
|
||||
instrument. It sits inside the ceiling's 2 C hysteresis and the flow
|
||||
check reads means, so it is a measurement item, not a gate item.
|
||||
22. **Laser power-good: what the line means.** `cnc/laser_pgood` and its
|
||||
sampled count are defined in the UAPI (active low, one sample every
|
||||
~3.9 ms), the facts bank records that the sampled count reads 0 through
|
||||
@@ -1487,6 +1498,22 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
|
||||
scope against `hv_current` through an armed cut, its meaning written
|
||||
into the facts bank and the UAPI, and then either a warning that means
|
||||
something or no warning.
|
||||
23. **Initial commissioning: measure and set the machine's own numbers
|
||||
methodically.** Every tunable that was measured on the bench machine
|
||||
and shipped as a default varies from machine to machine: the flow
|
||||
check's bands and `cool_flow_rise`, the tube's heat coefficients
|
||||
(`cool_laser_heat_cw`, `cool_laser_heat_density`), the air-assist
|
||||
ground offset on the coolant readings, the laser's striking and lasing
|
||||
thresholds and the duty floor, the fan floors, the thermistor curve
|
||||
itself. Owed: one commissioning procedure, run once on a new machine
|
||||
from the panel or the bench page, that measures each of these in
|
||||
order with the tube dark wherever it can be, fires only where it
|
||||
must, and writes the results as that machine's settings with a
|
||||
record; and a reading of what the cloud sets for the same machine,
|
||||
taken from cloud cuts (the pulse header carries the factory's
|
||||
per-machine values), so the commissioning can start from the
|
||||
factory's own numbers where they exist and note where they differ
|
||||
from the measured ones.
|
||||
|
||||
**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
|
||||
|
||||
@@ -4420,6 +4420,78 @@ head jogged back by the baseline; the case now brings the head back
|
||||
itself. The case rides the next image; its campaign standing comes with
|
||||
that image's campaign.
|
||||
|
||||
## 2026-08-29: the coolant ADC offset is the air-assist fan's return current
|
||||
|
||||
The common-mode offset on the two coolant sensors (about 1 C low while the
|
||||
run airflow profile is on, BRINGUP item 21) was run down differentially,
|
||||
dark, with `scripts/bench/offset_probe.py`: forgectrl idle, one actuator
|
||||
switched at a time, both sensors at 25 Hz, the step at every edge scored
|
||||
as the 1.5 s means after minus before. Records
|
||||
`bench-data/offset_probe_20260829-205928.json` (the survey) and
|
||||
`offset_probe_20260829-210233.json` (the ladder).
|
||||
|
||||
The wiring first, from the OpenGlow board's netlist (pin-compatible with
|
||||
the factory board): the thermistors enter on J2 pins 3 and 4 with the pump
|
||||
enable (2), the TEC enable (1), the TEC thermistor (6), the heater PWM (7),
|
||||
the exhaust tach (8) and the exhaust PWM (9) beside them, then 12 V, the
|
||||
beam-detect lines, Z step and direction, the head I2C and the head camera
|
||||
lanes; the intake fans, the HV lines, `LASER_ON` and `HV_EN` are on J1;
|
||||
the air assist is driven on the head. The run profile drives the exhaust
|
||||
at 65535 (100 %, no PWM edges) and the intakes at 43278.
|
||||
|
||||
The survey: exhaust at 100 %, 50 % and 25 %, the intakes at their run
|
||||
duty, purge, the TEC enable and the lid lamp each move both sensors by
|
||||
0.1 C or less; the heater and the pump edges move the downstream sensor
|
||||
only (thermal). The air assist from its idle 204 to its run 1023 steps
|
||||
both sensors together, -1.37 and -1.25 C in one sample, and back +1.18 and
|
||||
+1.13; "all run fans" gives the same -1.28 and -1.23. The ladder: 256
|
||||
-0.03, 512 -0.27, 768 -0.6, 1023 -1.2 C cumulative, both sensors alike,
|
||||
each step reversed on the way down, and -1.2 to -1.3 C on two full on and
|
||||
off repeats. The offset is proportional to the air-assist fan's current: a
|
||||
ground-return drop on a path the thermistor reference shares, not
|
||||
crosstalk on J2 and not HV (the `flowload` traces already show the step
|
||||
before any HV and no further step at emission). Both sensors read low by
|
||||
the same amount whenever the air assist runs, so the flow check's rise is
|
||||
untouched now that its baseline is taken under the run profile, and the
|
||||
over-temperature gates read the coolant about 1.2 C cooler than it is
|
||||
during a job. The mid-run toggling between two levels is not reproduced by
|
||||
a steady fan (0 toggles in every dwell); the fan's own current variation
|
||||
under motion is the remaining candidate.
|
||||
|
||||
## 2026-08-29: the toggling is not motion
|
||||
|
||||
`offset_probe.py jog` (record `bench-data/offset_jog_20260829-211710.json`),
|
||||
dark, no press: the air assist steady at its run duty while the gantry
|
||||
jogged 30 mm in X and 8 mm in Y for 40 s, then the same jog with the fan
|
||||
idle. Zero level toggles in every phase; with the fan on the readings sat
|
||||
1.1 C low and as quiet while jogging (sd 0.26 C) as while still (0.32),
|
||||
and the fan's tach held 677 to 678 under motion. Motion and the head's
|
||||
pogo contacts under vibration are out. The toggling seen in the day's
|
||||
`flowload t2` traces sits inside the armed windows only (from the moment
|
||||
`armed` went true, past the burst's end, gone when the fans went idle), and
|
||||
the dark runs with the fans at run duty show none, which leaves the HV
|
||||
supply's enable, asserted from the press to the disarm, as the candidate;
|
||||
an armed dark dwell (`M3 S0`, the window open, no emission) is the test.
|
||||
|
||||
## 2026-08-29: the toggling needs the tube lit
|
||||
|
||||
`offset_probe.py armed` (record `bench-data/offset_armed_20260829-212245.json`):
|
||||
`M3 S0`, the press 0.3 s after the M3, the window open 73 s with the head
|
||||
still, the flow check's heater running inside it, no emission (0 lit
|
||||
samples on the LASER_ON witness and the tube current), then M2. Zero level
|
||||
toggles on both sensors through the armed window; the one step after M2
|
||||
is the fans returning to idle. The `hv_enable` switch read false
|
||||
throughout, so it does not follow the arm. Every toggle in the day's
|
||||
`flowload t2` traces sits inside a lit period (seven between +7 and +34 s
|
||||
around a burst lit from +7 to +27; thirteen under a 41 s burst;
|
||||
twenty-two under a 48 s burst), and none appear dark, with the fans
|
||||
alone, under motion, or in an armed dark window. The jitter comes with
|
||||
tube current: the HV supply's input current on a return the thermistor
|
||||
reference shares, or its switching, is what remains, and a scope on the
|
||||
two sensor lines during a cut is the next instrument. Its size is 0.6 to
|
||||
1.1 C either way, inside the over-temperature ceiling's 2 C hysteresis,
|
||||
and the flow check reads means.
|
||||
|
||||
## Superseded status notes
|
||||
|
||||
### Shared machine services — remaining polish, as listed 2026-08-13
|
||||
|
||||
@@ -71,6 +71,58 @@ def flow_verify(ctx):
|
||||
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
|
||||
|
||||
|
||||
@test("cooling.aa-offset-calibrate", title="The air-assist ground shift on the coolant readings measures cleanly",
|
||||
subsystem="cooling", kind="auto", est_min=2,
|
||||
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
|
||||
steps=["Coolant loop normal (pump on); the machine idle. The controller is suspended by "
|
||||
"forgectrl for the duration (about a minute); the air assist cycles three times."],
|
||||
description="forgectrl's aa-offset-calibrate diagnostic: the air-assist fan stepped idle to "
|
||||
"run and back three times with the tube dark and the heater off, both coolant "
|
||||
"sensors read at every edge. The fan's return current shifts both readings by "
|
||||
"a number of ADC counts the engine adds back (cool_aa_offset_counts). PASS = the "
|
||||
"tool reports a recommendation, the six edges agree within its spread limit, and "
|
||||
"the value sits inside the setting's legal range. The setting is not written.")
|
||||
def aa_offset_calibrate(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/diag/status")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /diag/status -> %s", st)
|
||||
ctx.check(not body.get("running"), "a diagnostic is already running (%s)", body.get("tool"))
|
||||
st, body = fc.post("/diag/aa-offset-calibrate")
|
||||
ctx.log("POST /diag/aa-offset-calibrate -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
|
||||
"could not start aa-offset-calibrate (%s %s)", st, body)
|
||||
result = None
|
||||
last_phase = None
|
||||
t0 = time.time()
|
||||
try:
|
||||
while time.time() - t0 < 300:
|
||||
ctx.checkpoint()
|
||||
st, d = fc.get("/diag/status")
|
||||
if st == 200 and isinstance(d, dict):
|
||||
if d.get("phase") != last_phase:
|
||||
last_phase = d.get("phase")
|
||||
ctx.log("phase: %s", last_phase)
|
||||
if not d.get("running") and d.get("result") is not None:
|
||||
result = d.get("result")
|
||||
for line in d.get("log", [])[-10:]:
|
||||
ctx.log(" diag: %s", line)
|
||||
break
|
||||
time.sleep(2)
|
||||
except BaseException:
|
||||
fc.post("/diag/abort")
|
||||
raise
|
||||
ctx.check(result is not None, "aa-offset-calibrate did not finish within 5 minutes")
|
||||
ev["result"] = result
|
||||
ctx.check("error" not in result, "aa-offset-calibrate error: %s", result.get("error"))
|
||||
ctx.log("offset %s counts, spread %s, recommend %s, edges %s", result.get("offset_counts"),
|
||||
result.get("spread_counts"), result.get("recommend"), result.get("steps"))
|
||||
ctx.check("recommend" in result, "no recommendation in %s", result)
|
||||
ctx.check(0 <= float(result["recommend"]) <= 60,
|
||||
"recommendation %s outside the setting's range 0..60", result["recommend"])
|
||||
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
|
||||
|
||||
|
||||
IDLE_DUTY = {"thermal/exhaust_pwm": 0, "thermal/intake_pwm": 0} # forgectrl's idle posture
|
||||
TACH_KEYS = ("exhaust", "intake_1", "intake_2")
|
||||
SAMPLE_S = 5 # tach sampling period (the big exhaust fan coasts for tens of seconds)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "2f18b16fe583a278220d765606c18a2a78ad320f"
|
||||
SRCREV = "f9b48934b32278140510401bd066dac1e1437b7e"
|
||||
PV = "0.1.0"
|
||||
|
||||
@@ -42,6 +42,7 @@ page's takeover does that; from a host, stop them first.
|
||||
| `flow_sustained.py` | Long-run test of the real re-check cadence via M8 (board or host; controller running): counts verdicts/false faults against the configured `cool_flow_rise` and tracks whether the loop accumulates heat. `flow_sustained.py [minutes]`. |
|
||||
| `temp_calibrate.py` (`supply-*` modes) | The power supply's sensor (`pic/pwr_temp`, raw) against a thermometer on its heatsink: `supply-watch`, `supply-point <C>`, `supply-fit`; the fit is printed beside `UAPI.md`'s unverified guess. Three points during a long cut settle it. |
|
||||
| `critical_tier_drill.py` | The coolant critical tier on a rising temperature (board or host): sets the ceiling, the resume gate and the critical line a few tenths above the live upstream reading and lets the engine's own flow-check heater warm the loop through them inside one `M8` session, expecting `OVERTEMP` at the ceiling and then `CRITICAL` (fire blocked, hold, no resume) with the fault ending at `M9`; restores the settings and cycles a session so the engine re-reads them. Results as JSON in the bench data directory. |
|
||||
| `offset_probe.py` | Coolant-sensor offset probe (board; forgectrl idle; dark, no press): switches one actuator at a time (exhaust at 100/50/25 %, intakes, air assist, purge, heater, pump, TEC, lid lamp, then all run fans) with both thermistors sampled at 25 Hz and scores the common-mode step at every edge and the level toggling inside every dwell; `offset_probe.py ladder` runs the air-assist duty ladder alone. Every value is restored on exit. JSON record in the bench data directory. |
|
||||
| `flow_warm_validate.py` | Runs the real check from a heater-warmed baseline (board or host; forgectrl and controller stopped; `flow_warm_validate.py [cycles_per_case]`; results/log in the bench data directory; exit 1 if any run is misclassified). Note the ceiling: 100 % duty pushes the downstream sensor past 50 °C in 30 s while the bulk barely moves, so warm-loop validation above ~23 °C needs the laser, not the heater. |
|
||||
| `flow_recheck_char.py` | Characterizes short in-run re-checks and the differential metric (board or host; forgectrl and controller stopped; `flow_recheck_char.py [heater_pct] [window_s]`); shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. |
|
||||
| `flow_confirm_drill.py` | Coolant flow suspicion/confirmation drill (runs on the board): one continuous M8 session walks the verdict state machine through real pump-off transients — verified → SUSPECT (+ immediate re-check) → cleared → SUSPECT → FAULT (consecutive) → recovered — printing PASS/FAIL per transition. Leaves the machine idle (M9, pump on, heater off). |
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Coolant-sensor offset probe: which actuator moves both thermistors?
|
||||
|
||||
The coolant ADC reads about 1 C low on both sensors while the run airflow
|
||||
profile is on, stepping in one sample after the fans start, out when they
|
||||
stop, and toggling between two levels in between. The two thermistor
|
||||
lines ride J2 pins 3 and 4 beside the pump enable (2), the TEC enable (1),
|
||||
the heater PWM (7), the exhaust tach (8) and the exhaust PWM (9); the
|
||||
intake fans and the HV lines are on J1, the air assist on the head. This
|
||||
probe switches one actuator at a time, dark (no laser, no press), with
|
||||
both sensors sampled at 25 Hz, and scores the common-mode step at every
|
||||
edge and the toggling inside every dwell. Runs on the board with
|
||||
forgectrl idle (the engine writes the fans only at session transitions,
|
||||
so a write here stands until the next session); every value is restored
|
||||
on the way out, whatever happens. The pump is stopped for one short dwell
|
||||
with the tube dark and the heater off, the one pump-off state allowed.
|
||||
|
||||
Usage: offset_probe.py [repeat] (repeats of the exhaust-run edge, default 2)
|
||||
offset_probe.py ladder (the air-assist duty ladder alone: the step
|
||||
against duty tells a current-proportional
|
||||
ground drop from a threshold)
|
||||
offset_probe.py jog (the air assist steady at its run duty while
|
||||
the gantry jogs, dark: do the readings toggle
|
||||
under motion? controller idle in GRBL mode,
|
||||
35 mm +X and 10 mm +Y free; no laser, no press)
|
||||
Record: FORGETEST_BENCH_DATA or /tmp, offset_probe_<stamp>.json (jog: offset_jog_<stamp>.json)
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
SYSFS = '/sys/glowforge/'
|
||||
LEDS = '/sys/class/leds/'
|
||||
HZ = 25
|
||||
PRE, POST, GAP = 1.5, 1.5, 0.3 # seconds around an edge for the step means
|
||||
|
||||
# B-equation from status.c: 10k B3380 NTC in a 10k divider behind a 1.3x
|
||||
# reference.
|
||||
ADC_F = 1024.0 * 1.3
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
|
||||
|
||||
def degc(raw):
|
||||
if raw is None or raw <= 0 or raw >= ADC_F:
|
||||
return float('nan')
|
||||
r = 10000.0 / (ADC_F / raw - 1.0)
|
||||
return 3380.0 / math.log(r / RINF) - 273.15
|
||||
|
||||
|
||||
def rd(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def wr(path, val):
|
||||
with open(path, 'w') as f:
|
||||
f.write(str(val))
|
||||
|
||||
|
||||
class Sampler(threading.Thread):
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True)
|
||||
self.samples = []
|
||||
self.stop = threading.Event()
|
||||
|
||||
def run(self):
|
||||
period = 1.0 / HZ
|
||||
nxt = time.time()
|
||||
while not self.stop.is_set():
|
||||
t = time.time()
|
||||
a, b = rd(SYSFS + 'pic/water_temp_1'), rd(SYSFS + 'pic/water_temp_2')
|
||||
self.samples.append((t, int(a) if a and a.isdigit() else None,
|
||||
int(b) if b and b.isdigit() else None))
|
||||
nxt += period
|
||||
d = nxt - time.time()
|
||||
if d > 0:
|
||||
time.sleep(d)
|
||||
else:
|
||||
nxt = time.time()
|
||||
|
||||
|
||||
def mean_c(samples, t0, t1, idx):
|
||||
v = [degc(s[idx]) for s in samples if t0 <= s[0] < t1 and s[idx] is not None]
|
||||
v = [x for x in v if x == x]
|
||||
return sum(v) / len(v) if v else None
|
||||
|
||||
|
||||
def toggles(samples, t0, t1):
|
||||
"""Level steps inside a dwell: both sensors' half-second means moving
|
||||
0.45 C or more the same way, agreeing within 0.4 C (the flowload
|
||||
detector)."""
|
||||
pts = [(s[0], degc(s[1]), degc(s[2])) for s in samples
|
||||
if t0 <= s[0] < t1 and s[1] is not None and s[2] is not None]
|
||||
pts = [p for p in pts if p[1] == p[1] and p[2] == p[2]]
|
||||
w, gap, n = 12, 2, 0
|
||||
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) >= 0.45 and abs(du) >= 0.45 and (dd > 0) == (du > 0) and abs(dd - du) <= 0.4:
|
||||
n += 1
|
||||
i += w + 2 * gap
|
||||
else:
|
||||
i += 1
|
||||
return n
|
||||
|
||||
|
||||
def run_jog():
|
||||
"""The air assist steady at its run duty while the gantry jogs, dark:
|
||||
does motion make the readings toggle between two levels? Phases: still
|
||||
/ fan on still / fan on jogging / fan on still / fan off jogging / fan
|
||||
off still. Needs the controller idle in GRBL mode and 35 mm of free +X
|
||||
and 10 mm of +Y travel; no laser, no press. The Grbl client comes from
|
||||
live_fire_drills (the bench directory on PYTHONPATH)."""
|
||||
from live_fire_drills import Grbl, HOST, PORT
|
||||
aa = SYSFS + 'head/air_assist_pwm'
|
||||
orig = rd(aa)
|
||||
g = Grbl(HOST, PORT)
|
||||
st = g.status()
|
||||
if 'Idle' not in st:
|
||||
print('REFUSED: controller is %s, expected Idle' % st)
|
||||
return 2
|
||||
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
|
||||
sampler = Sampler()
|
||||
sampler.start()
|
||||
tach = []
|
||||
phases = []
|
||||
stop_motion = threading.Event()
|
||||
|
||||
def mover():
|
||||
while not stop_motion.is_set():
|
||||
for ln in ('G0 X30 F3000', 'G0 Y8 F3000', 'G0 X-30 F3000', 'G0 Y-8 F3000'):
|
||||
g.s.sendall(ln.encode() + b'\n')
|
||||
g.wait_state('Idle', 20)
|
||||
if stop_motion.is_set():
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
def phase(label, fan, motion, dwell):
|
||||
t0 = time.time()
|
||||
if fan is not None:
|
||||
wr(aa, fan)
|
||||
th = None
|
||||
if motion:
|
||||
stop_motion.clear()
|
||||
th = threading.Thread(target=mover, daemon=True)
|
||||
th.start()
|
||||
end = t0 + dwell
|
||||
while time.time() < end:
|
||||
v = rd(SYSFS + 'head/air_assist_tach')
|
||||
tach.append((time.time(), int(v) if v and v.isdigit() else None))
|
||||
time.sleep(1.0)
|
||||
if th:
|
||||
stop_motion.set()
|
||||
th.join(25)
|
||||
g.wait_state('Idle', 20)
|
||||
phases.append((label, t0, time.time()))
|
||||
print(' %+7.1f s %s done' % (time.time() - phases[0][1], label), flush=True)
|
||||
|
||||
try:
|
||||
phase('still, fan idle', None, False, 10)
|
||||
phase('fan run, still', 1023, False, 15)
|
||||
phase('fan run, jogging', None, True, 40)
|
||||
phase('fan run, still again', None, False, 10)
|
||||
phase('fan idle, jogging', 204, True, 30)
|
||||
phase('fan idle, still', None, False, 10)
|
||||
finally:
|
||||
stop_motion.set()
|
||||
try:
|
||||
g.wait_state('Idle', 20)
|
||||
g.cmd('G90')
|
||||
except Exception:
|
||||
pass
|
||||
if orig is not None:
|
||||
wr(aa, orig)
|
||||
time.sleep(2)
|
||||
sampler.stop.set()
|
||||
sampler.join(2)
|
||||
tr = sampler.samples
|
||||
print('\n%d samples, %.1f Hz' % (len(tr), (len(tr) - 1) / (tr[-1][0] - tr[0][0])))
|
||||
print('--- per phase: level toggles (both sensors, >= 0.45 C), the readings and the air-assist tach ---')
|
||||
print(' phase toggles down mean/sd up mean/sd tach mean/sd')
|
||||
rows = []
|
||||
for label, t0, t1 in phases:
|
||||
d = [degc(s[1]) for s in tr if t0 + 1 <= s[0] < t1 and s[1] is not None]
|
||||
u = [degc(s[2]) for s in tr if t0 + 1 <= s[0] < t1 and s[2] is not None]
|
||||
tk = [v for t, v in tach if t0 <= t < t1 and v is not None]
|
||||
sd = lambda v: (sum((x - sum(v) / len(v)) ** 2 for x in v) / len(v)) ** 0.5 if len(v) > 1 else 0.0
|
||||
tg = toggles(tr, t0 + 1.0, t1)
|
||||
rows.append({'phase': label, 'toggles': tg, 'down_mean': sum(d) / len(d) if d else None,
|
||||
'down_sd': sd(d), 'up_mean': sum(u) / len(u) if u else None, 'up_sd': sd(u),
|
||||
'tach_mean': sum(tk) / len(tk) if tk else None, 'tach_sd': sd(tk), 'dwell_s': t1 - t0})
|
||||
print(' %-26s %5d %6.2f/%.2f %6.2f/%.2f %s'
|
||||
% (label, tg, rows[-1]['down_mean'] or 0, sd(d), rows[-1]['up_mean'] or 0, sd(u),
|
||||
'-' if not tk else '%.0f/%.0f' % (rows[-1]['tach_mean'], sd(tk))))
|
||||
rec = {'drill': 'offset_probe_jog', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'phases': rows, 'tach': tach, 'trace': tr}
|
||||
ddir = os.environ.get('FORGETEST_BENCH_DATA') or '/tmp'
|
||||
path = os.path.join(ddir, 'offset_jog_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
|
||||
with open(path, 'w') as f:
|
||||
json.dump(rec, f)
|
||||
print('record: %s' % path)
|
||||
return 0
|
||||
|
||||
|
||||
def run_armed():
|
||||
"""An armed window with the tube dark: M3 S0 opens the window on the
|
||||
press (the fire level is zero, nothing is ever requested), a 60 s
|
||||
dwell, then M2. Phases: before the session / session open, waiting
|
||||
for the press / armed (the HV enable asserted) / after M2. Any lit
|
||||
sample (LASER_ON witness or tube current) soft-resets at once."""
|
||||
import json as _json
|
||||
import urllib.request
|
||||
from live_fire_drills import Grbl, HOST, PORT
|
||||
g = Grbl(HOST, PORT)
|
||||
st = g.status()
|
||||
if 'Idle' not in st:
|
||||
print('REFUSED: controller is %s, expected Idle' % st)
|
||||
return 2
|
||||
print('spindle off: %s' % g.cmd('M5'))
|
||||
|
||||
def status():
|
||||
try:
|
||||
with urllib.request.urlopen('http://127.0.0.1:8080/status', timeout=2) as r:
|
||||
s = _json.load(r)
|
||||
with urllib.request.urlopen('http://127.0.0.1:8080/cool/status', timeout=2) as r:
|
||||
c = _json.load(r)
|
||||
return {'t': time.time(), 'armed': c.get('armed'), 'phase': c.get('phase'),
|
||||
'hv_enable': (s.get('switches') or {}).get('hv_enable'),
|
||||
'emission': (s.get('laser') or {}).get('emission_samples'),
|
||||
'hv': s.get('hv_current_raw')}
|
||||
except Exception:
|
||||
return None
|
||||
pre = status()
|
||||
if not pre or pre['armed']:
|
||||
print('REFUSED: forgectrl unreachable or the window is already armed (%s)' % pre)
|
||||
return 2
|
||||
print('>>> ARMED DARK DWELL: the button lights on the M3; press it. The fire level is')
|
||||
print('>>> zero and nothing is commanded; any lit sample soft-resets the job.')
|
||||
sampler = Sampler()
|
||||
sampler.start()
|
||||
poll = []
|
||||
lit = []
|
||||
stop = threading.Event()
|
||||
|
||||
def poller():
|
||||
while not stop.is_set():
|
||||
s = status()
|
||||
if s:
|
||||
poll.append(s)
|
||||
if (s['emission'] or 0) > 0 or (s['hv'] or 0) > 20:
|
||||
lit.append(s)
|
||||
v = rd(SYSFS + 'cnc/laser_on_sampled')
|
||||
if v and v.isdigit() and int(v) > 0:
|
||||
lit.append({'t': time.time(), 'lon': int(v)})
|
||||
time.sleep(0.5)
|
||||
th = threading.Thread(target=poller, daemon=True)
|
||||
th.start()
|
||||
phases = []
|
||||
t_m3 = None
|
||||
try:
|
||||
time.sleep(8)
|
||||
phases.append(('before the session', sampler.samples[0][0], time.time()))
|
||||
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
|
||||
t_m3 = time.time()
|
||||
for ln in ('M3 S0', 'G4 P60', 'M5', 'G90', 'M2'):
|
||||
g.s.sendall(ln.encode() + b'\n')
|
||||
# wait for the press: the engine's armed goes true
|
||||
t_armed = None
|
||||
deadline = time.time() + 300
|
||||
while time.time() < deadline and not lit:
|
||||
g.drain()
|
||||
if poll and poll[-1]['armed']:
|
||||
t_armed = poll[-1]['t']
|
||||
break
|
||||
time.sleep(0.2)
|
||||
if lit:
|
||||
raise RuntimeError('lit sample before the press: %s' % lit[:2])
|
||||
if t_armed is None:
|
||||
raise RuntimeError('no press within 300 s')
|
||||
phases.append(('session open, waiting for the press', t_m3, t_armed))
|
||||
print(' armed %.1f s after the M3' % (t_armed - t_m3), flush=True)
|
||||
# the dwell: armed, dark
|
||||
t_end = None
|
||||
while time.time() < t_armed + 75 and not lit:
|
||||
g.drain()
|
||||
if poll and not poll[-1]['armed'] and time.time() > t_armed + 5:
|
||||
t_end = poll[-1]['t']
|
||||
break
|
||||
time.sleep(0.2)
|
||||
if lit:
|
||||
raise RuntimeError('LIT SAMPLE DURING THE ARMED DWELL: %s' % lit[:2])
|
||||
t_end = t_end or time.time()
|
||||
phases.append(('armed, dark dwell', t_armed, t_end))
|
||||
print(' window closed %.1f s after the arm' % (t_end - t_armed), flush=True)
|
||||
time.sleep(20)
|
||||
phases.append(('after M2, fans still at run', t_end, time.time()))
|
||||
except Exception as e:
|
||||
print('ABORT: %s' % e)
|
||||
g.rt(b'\x18')
|
||||
time.sleep(2)
|
||||
finally:
|
||||
try:
|
||||
g.cmd('M5', timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
stop.set()
|
||||
sampler.stop.set()
|
||||
sampler.join(2)
|
||||
th.join(2)
|
||||
tr = sampler.samples
|
||||
print('\n%d samples, %.1f Hz; lit samples: %d' % (len(tr), (len(tr) - 1) / (tr[-1][0] - tr[0][0]), len(lit)))
|
||||
print('--- replies ---')
|
||||
for t, ln in g.log:
|
||||
if ln != 'ok':
|
||||
print(' %+7.1f s %s' % (t - (t_m3 or t), ln))
|
||||
print('--- hv_enable / armed transitions ---')
|
||||
last = None
|
||||
for p in poll:
|
||||
k = (p['hv_enable'], p['armed'])
|
||||
if k != last:
|
||||
print(' %+7.1f s hv_enable=%s armed=%s phase=%s' % (p['t'] - (t_m3 or p['t']), p['hv_enable'], p['armed'], p['phase']))
|
||||
last = k
|
||||
print('--- per phase: toggles, readings ---')
|
||||
print(' phase toggles down mean/sd up mean/sd')
|
||||
rows = []
|
||||
for label, t0, t1 in phases:
|
||||
d = [degc(s[1]) for s in tr if t0 + 1 <= s[0] < t1 and s[1] is not None]
|
||||
u = [degc(s[2]) for s in tr if t0 + 1 <= s[0] < t1 and s[2] is not None]
|
||||
sd = lambda v: (sum((x - sum(v) / len(v)) ** 2 for x in v) / len(v)) ** 0.5 if len(v) > 1 else 0.0
|
||||
tg = toggles(tr, t0 + 1.0, t1)
|
||||
rows.append({'phase': label, 'toggles': tg, 'down_mean': sum(d) / len(d) if d else None,
|
||||
'down_sd': sd(d), 'up_mean': sum(u) / len(u) if u else None, 'up_sd': sd(u), 'dwell_s': t1 - t0})
|
||||
print(' %-38s %5d %6.2f/%.2f %6.2f/%.2f'
|
||||
% (label, tg, rows[-1]['down_mean'] or 0, sd(d), rows[-1]['up_mean'] or 0, sd(u)))
|
||||
rec = {'drill': 'offset_probe_armed', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'), 't_m3': t_m3,
|
||||
'phases': rows, 'poll': poll, 'lit': lit, 'replies': g.log, 'trace': tr}
|
||||
ddir = os.environ.get('FORGETEST_BENCH_DATA') or '/tmp'
|
||||
path = os.path.join(ddir, 'offset_armed_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
|
||||
with open(path, 'w') as f:
|
||||
json.dump(rec, f)
|
||||
print('record: %s' % path)
|
||||
return 0 if not lit else 1
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'jog':
|
||||
return run_jog()
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'armed':
|
||||
return run_armed()
|
||||
ladder = len(sys.argv) > 1 and sys.argv[1] == 'ladder'
|
||||
repeat = int(sys.argv[1]) if len(sys.argv) > 1 and not ladder else 2
|
||||
attrs = {
|
||||
'exhaust': SYSFS + 'thermal/exhaust_pwm',
|
||||
'intake': SYSFS + 'thermal/intake_pwm',
|
||||
'air_assist': SYSFS + 'head/air_assist_pwm',
|
||||
'purge': SYSFS + 'head/purge_air',
|
||||
'heater': SYSFS + 'thermal/heater_pwm',
|
||||
'pump': SYSFS + 'thermal/water_pump_on',
|
||||
'tec': SYSFS + 'thermal/tec_on',
|
||||
'lid_led': LEDS + 'lid_led/brightness',
|
||||
}
|
||||
orig = {k: rd(p) for k, p in attrs.items()}
|
||||
print('start values: %s' % orig)
|
||||
if orig['pump'] != '1' or orig['heater'] not in ('0', None):
|
||||
print('REFUSED: the pump is not on or the heater is not off (%s, %s)' % (orig['pump'], orig['heater']))
|
||||
return 2
|
||||
# (label, key, value, dwell)
|
||||
sched = [('baseline', None, None, 10)]
|
||||
if ladder:
|
||||
for duty in (256, 512, 768, 1023, 768, 512, 256):
|
||||
sched.append(('air assist %d/1023' % duty, 'air_assist', duty, 10))
|
||||
sched.append(('air assist idle', 'air_assist', 204, 10))
|
||||
for duty in (1023, 204, 1023, 204):
|
||||
sched.append(('air assist %d' % duty, 'air_assist', duty, 8))
|
||||
for _ in range(0 if ladder else repeat):
|
||||
sched += [('exhaust 100% (run, DC)', 'exhaust', 65535, 12), ('exhaust off', 'exhaust', 0, 12)]
|
||||
if not ladder:
|
||||
sched += [('exhaust 50% (PWM edges)', 'exhaust', 32768, 12), ('exhaust off', 'exhaust', 0, 12),
|
||||
('exhaust 25%', 'exhaust', 16384, 12), ('exhaust off', 'exhaust', 0, 12),
|
||||
('intake 66% (run)', 'intake', 43278, 12), ('intake off', 'intake', 0, 12),
|
||||
('air assist run', 'air_assist', 1023, 12), ('air assist idle', 'air_assist', 204, 12),
|
||||
('purge off', 'purge', 0, 8), ('purge on', 'purge', 1, 8),
|
||||
('heater 40%', 'heater', 26214, 8), ('heater off', 'heater', 0, 14),
|
||||
('pump off (dark, heater off)', 'pump', 0, 8), ('pump on', 'pump', 1, 12),
|
||||
('tec on', 'tec', 1, 6), ('tec off', 'tec', 0, 6),
|
||||
('lid lamp full', 'lid_led', 1023, 6), ('lid lamp back', 'lid_led', orig['lid_led'] or 0, 6),
|
||||
('all run fans', None, None, 0)]
|
||||
sampler = Sampler()
|
||||
sampler.start()
|
||||
edges = []
|
||||
try:
|
||||
for label, key, val, dwell in sched:
|
||||
if label == 'all run fans':
|
||||
t = time.time()
|
||||
wr(attrs['exhaust'], 65535)
|
||||
wr(attrs['intake'], 43278)
|
||||
wr(attrs['air_assist'], 1023)
|
||||
edges.append((t, label))
|
||||
time.sleep(15)
|
||||
t = time.time()
|
||||
wr(attrs['exhaust'], 0)
|
||||
wr(attrs['intake'], 0)
|
||||
wr(attrs['air_assist'], 204)
|
||||
edges.append((t, 'all fans off'))
|
||||
time.sleep(12)
|
||||
continue
|
||||
if key:
|
||||
t = time.time()
|
||||
wr(attrs[key], val)
|
||||
edges.append((t, label))
|
||||
print(' %+7.1f s %s' % (t - edges[0][0], label), flush=True)
|
||||
else:
|
||||
edges.append((time.time(), label))
|
||||
time.sleep(dwell)
|
||||
finally:
|
||||
for k, p in attrs.items():
|
||||
if orig[k] is not None:
|
||||
try:
|
||||
wr(p, orig[k])
|
||||
except OSError as e:
|
||||
print('RESTORE FAILED %s: %s' % (k, e))
|
||||
time.sleep(3)
|
||||
sampler.stop.set()
|
||||
sampler.join(2)
|
||||
tr = sampler.samples
|
||||
t0 = edges[0][0]
|
||||
print('\n%d samples, %.1f Hz' % (len(tr), (len(tr) - 1) / (tr[-1][0] - tr[0][0])))
|
||||
print('--- edges: common-mode step (1.5 s means after minus before), then toggles inside the dwell ---')
|
||||
print(' t(s) edge down step up step | common | toggles')
|
||||
rows = []
|
||||
for i, (t, label) in enumerate(edges):
|
||||
t_end = edges[i + 1][0] if i + 1 < len(edges) else tr[-1][0]
|
||||
b1, a1 = mean_c(tr, t - PRE - GAP, t - GAP, 1), mean_c(tr, t + GAP, t + GAP + POST, 1)
|
||||
b2, a2 = mean_c(tr, t - PRE - GAP, t - GAP, 2), mean_c(tr, t + GAP, t + GAP + POST, 2)
|
||||
d1 = None if None in (a1, b1) else a1 - b1
|
||||
d2 = None if None in (a2, b2) else a2 - b2
|
||||
common = (d1 is not None and d2 is not None and abs(d1) >= 0.4 and abs(d2) >= 0.4
|
||||
and (d1 > 0) == (d2 > 0))
|
||||
tg = toggles(tr, t + 2.0, t_end)
|
||||
rows.append({'t': t - t0, 'edge': label, 'down_step_c': d1, 'up_step_c': d2,
|
||||
'common': common, 'toggles': tg, 'dwell_s': t_end - t})
|
||||
print(' %6.1f %-28s %8s %8s | %-6s | %d'
|
||||
% (t - t0, label, '-' if d1 is None else '%+.2f' % d1,
|
||||
'-' if d2 is None else '%+.2f' % d2, 'YES' if common else '', tg))
|
||||
rec = {'drill': 'offset_probe', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'), 'orig': orig,
|
||||
'edges': rows, 'trace': tr}
|
||||
ddir = os.environ.get('FORGETEST_BENCH_DATA') or '/tmp'
|
||||
path = os.path.join(ddir, 'offset_probe_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
|
||||
with open(path, 'w') as f:
|
||||
json.dump(rec, f)
|
||||
print('record: %s' % path)
|
||||
print('end values: %s' % {k: rd(p) for k, p in attrs.items()})
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user