From 8831d9b20e4e78bc985d7c5000e95571667c9f2c Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sat, 29 Aug 2026 17:36:02 -0400 Subject: [PATCH] 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. --- docs/BRINGUP.md | 41 +- docs/CAMPAIGN-LOG.md | 72 +++ forgetest/forgetest/suite/cooling.py | 52 ++ .../forgectrl/forgectrl-pin.inc | 2 +- scripts/bench/README.md | 1 + scripts/bench/offset_probe.py | 466 ++++++++++++++++++ 6 files changed, 626 insertions(+), 8 deletions(-) create mode 100644 scripts/bench/offset_probe.py diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 0cccfa2..94b166d 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -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 diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index c565ace..4e0621d 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -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 diff --git a/forgetest/forgetest/suite/cooling.py b/forgetest/forgetest/suite/cooling.py index 91cf452..60c3057 100644 --- a/forgetest/forgetest/suite/cooling.py +++ b/forgetest/forgetest/suite/cooling.py @@ -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) diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc index c170a34..23a3aa8 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc @@ -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" diff --git a/scripts/bench/README.md b/scripts/bench/README.md index ae4d6c0..56ccb3d 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -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 `, `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). | diff --git a/scripts/bench/offset_probe.py b/scripts/bench/offset_probe.py new file mode 100644 index 0000000..72873b9 --- /dev/null +++ b/scripts/bench/offset_probe.py @@ -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_.json (jog: offset_jog_.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())