Files
resonance-engine/market_tension_injector.py
T
2026-06-07 13:34:30 +07:00

576 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""market_tension_injector.py — feed REAL market tensions into the lattice.
Replaces the synthetic z-score/STR_CAP scheme of inject_continuous_stream.py.
The previous injectors took market variables, z-scored them away to a
dimensionless ±0.30 blob at (512,512), and asked the lattice to find
structure. That destroyed all native magnitude and unit information. A
5σ flow and a 3σ flow produced identical injections. Predictably, six
"different variables" collapsed to one transfer function.
This injector encodes four NATIVE market tensions, in physical units,
as DISTINCT spatial forcing patterns the lattice can mechanically
respond to differently:
┌──────────────────────┬────────────────────────┬─────────────────────────┐
│ tension │ physical interpretation│ lattice forcing pattern │
├──────────────────────┼────────────────────────┼─────────────────────────┤
│ funding_bps │ directional cost-to- │ x-axis DIPOLE │
│ (signed bps annual)│ carry, longs pulled vs │ (asymmetry forcing) │
│ │ shorts │ │
│ │ │ │
│ bs_ratio_signed │ flow directional │ x-axis OFFSET blob │
│ (-0.5..+0.5) │ imbalance THIS minute │ (velocity bias) │
│ │ │ │
│ activity_excess │ excitation relative to │ ISOTROPIC center blob │
│ (>= -1, unbounded) │ own 500-min baseline │ (density / energy) │
│ │ │ │
│ cvd_divergence │ coiled spring — flow │ VORTICITY RING │
│ ($ / bps signed) │ without price response │ (rotational forcing) │
└──────────────────────┴────────────────────────┴─────────────────────────┘
SCALING DISCIPLINE
No z-scoring. No rolling normalisation. Each tension is scaled to lattice
units by a FIXED PHYSICAL CONSTANT documented in MAP_SCALE below. Cross-
regime amplitude information is preserved: a 100bps funding day produces
exactly 2× the dipole strength of a 50bps day. A panic flow imbalance
produces 5× the velocity bias of a calm one. The lattice sees true
market amplitude.
ARMS
A — control. No injection. Fresh baseline of the lattice's natural
variability over the experiment window. (Re-captured even though
we have an old arm A — field state may have drifted.)
T — tension. All four patterns injected per minute, each from its native
market value via fixed physical scale.
PROTOCOL
- Run arm A first (n minutes), save parquet
- Cooldown 20 min (let field settle to a new baseline)
- Run arm T (same n minutes), save parquet
- Analyzer compares: does field state under arm T predict realized
vol / direction at h=5,15,60 BETTER than (a) arm A field, and (b) the
raw tensions themselves?
REQUIREMENTS
- Lattice daemon alive on 5556 (telemetry PUB) / 5557 (cmd SUB).
- Must be launched via WSL setsid nohup (proven pattern from contstream).
- Tag: --agent-owner=PAPERTRADER per AGENTS.md ledger (this is research,
PaperTrader scope; lattice daemon itself is RESONANCE, untouched).
Output: /mnt/d/Resonance_Engine/traj/tension_<RUN_ID>/
meta.json
funding_history.parquet (raw HL fundingHistory cached)
arm_A_no_inject.parquet
arm_T_tension.parquet
progress.log
"""
from __future__ import annotations
import argparse, glob, json, math, threading, time, urllib.request
from pathlib import Path
import numpy as np
import pandas as pd
import zmq
# ───────────────────────── config ─────────────────────────
DATA_ROOT = "/mnt/d/PaperTrader/research/hl_data/minutes"
COIN = "BTC"
# Default to a single day for the first run. Override with --days-glob.
DEFAULT_DAYS_GLOB = "20260415*"
# fixed physical scales — never refit, never z-score
# (the whole point of this injector)
MAP_SCALE = {
# funding_rate from HL is per-hour. Annualised bps = rate * 8760 * 10000.
# Calibrated 2026-06-07 on 720 hourly rows from April BTC HL fundingHistory.
# Real distribution: median |val|=620bps, q90=1265bps, q99=2320bps, max=3835bps.
# 3000bps annualised → 1.0 dipole-leg strength.
# Second rebalance 2026-06-07: previous scale (1000bps/strength, cap 2.5)
# left funding firing as a near-constant DC offset at strength ~1.3 every
# minute, dominating the stress tensor. Tripling the scale shrinks each
# dipole leg to ~0.4 and lets minute-to-minute variance show through.
"funding_bps_per_strength": 3000.0,
"funding_strength_cap": 2.5,
# bs_ratio_signed ranges natively ±0.5 (since ratio is 0..1, minus 0.5).
# Scale ×2 so full ±0.5 → ±1.0 strength.
# Cap ±3.0 (rebalance 2026-06-07): single-blob encoding was mute (ρ<0.15)
# because a single density blob doesn't couple to global telemetry channels.
# Second rebalance 2026-06-07: bs_ratio is now a Y-AXIS DIPOLE (mechanically
# equivalent to funding's x-axis dipole, rotated 90°). Couples cleanly to
# stress_yy and vel_mean on equal footing with funding.
"bs_ratio_per_strength": 0.5,
"bs_ratio_strength_cap": 3.0,
# activity_excess = (trade_count / rolling_500min_mean) - 1.
# Centred at 0. -1 = zero activity. +2 = 3× baseline. +5 = 6× baseline.
# Scale: 3.0 of excess → 1.0 strength. Cap at 5.
"activity_per_strength": 3.0,
"activity_strength_cap": 5.0,
# cvd_divergence = sum(signed_flow_usd, 60min) / (|price_change_60min_bps| + eps)
# Units: USD per bps. April BTC: median |val|=$566k, 90th=$2.5M, 99th=$13M, max=$100M.
# Tanh scale 2e6 keeps the bulk (q50 strength 0.28, q90 0.85) in the responsive
# part of the curve, while ~8% of extreme minutes saturate near ±1.0.
# Calibrated 2026-06-07 on 43k April minutes.
"cvd_div_tanh_scale": 2e6,
}
# ROLLING WINDOWS (computed per source minute, past-only by shift)
ROLL_ACTIVITY_WIN = 500 # 500-min rolling baseline for activity_excess
ROLL_CVD_WIN = 60 # 60-min summed CVD and price change
EPS_BPS = 0.5 # epsilon to prevent /0 in cvd_div denominator
# Lattice spatial geometry (lattice is 1024×1024, center 512,512)
CENTER = (512.0, 512.0)
DIPOLE_OFF = 64.0 # dipole leg offset from center (funding x, bs_ratio y)
RING_R = 192.0 # vorticity ring radius (was 96; doubled 2026-06-07 to
# register in the global vorticity_mean metric)
SIGMA = 32.0 # gaussian sigma of each injection blob
# Vorticity ring: N legs around the ring, alternating signs to create curl.
# 4 legs is the minimum for a clean dipole-quadrupole rotation. 6 is smoother.
VORTICITY_N_LEGS = 6
# Timing
PER_MINUTE_MS = 100 # 10× realtime (proven in throughput probe)
WAIT_AFTER_INJECT_MS = 80 # extra room for the 4 force-patterns to settle
COOLDOWN_BETWEEN_ARMS_S = 1200 # 20 min, same as contstream
# ZMQ
TEL_ADDR = "tcp://127.0.0.1:5556"
CMD_ADDR = "tcp://127.0.0.1:5557"
CHANNELS = ["asymmetry", "coherence", "stress_xx", "stress_yy", "stress_xy",
"vorticity_mean", "vel_mean", "vel_max", "vel_var"]
# Funding fetch
HL_INFO_URL = "https://api.hyperliquid.xyz/info"
# ───────────────────────── logging ─────────────────────────
RUN_ID = time.strftime("%Y%m%dT%H%M%S")
OUT_DIR = Path(f"/mnt/d/Resonance_Engine/traj/tension_{RUN_ID}")
PROGRESS = OUT_DIR / "progress.log"
def log(msg: str) -> None:
line = f"[{time.strftime('%Y-%m-%dT%H:%M:%S')}] {msg}"
print(line, flush=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)
with PROGRESS.open("a") as f:
f.write(line + "\n")
# ───────────────────────── ZMQ telemetry subscriber ─────────────────────────
class LatestTel:
"""Background thread holding the most-recent telemetry frame only."""
def __init__(self, addr: str):
self.ctx = zmq.Context.instance()
self.sock = self.ctx.socket(zmq.SUB)
self.sock.connect(addr)
self.sock.setsockopt(zmq.SUBSCRIBE, b"")
self.sock.setsockopt(zmq.RCVHWM, 2000)
self.latest: dict | None = None
self.latest_wall: float = 0.0
self.n_seen = 0
self._stop = threading.Event()
self._t = threading.Thread(target=self._run, daemon=True)
self._t.start()
def _run(self):
while not self._stop.is_set():
if self.sock.poll(100):
try:
raw = self.sock.recv_string(zmq.NOBLOCK)
self.latest = json.loads(raw)
self.latest_wall = time.time()
self.n_seen += 1
except Exception:
pass
def snapshot(self) -> tuple[dict | None, float]:
return self.latest, self.latest_wall
def stop(self):
self._stop.set()
self._t.join(timeout=2)
try:
self.sock.close(0)
except Exception:
pass
# ───────────────────────── data loading ─────────────────────────
def load_btc(days_glob: str) -> pd.DataFrame:
day_dirs = sorted(glob.glob(f"{DATA_ROOT}/{days_glob}"))
if not day_dirs:
raise RuntimeError(f"no day dirs matching {days_glob} in {DATA_ROOT}")
log(f"loading {len(day_dirs)} day dirs ({day_dirs[0].split('/')[-1]} .. {day_dirs[-1].split('/')[-1]})")
dfs = []
for d in day_dirs:
for f in sorted(glob.glob(f"{d}/*.parquet")):
dfs.append(pd.read_parquet(f))
df = pd.concat(dfs, ignore_index=True)
df = df[df.coin == COIN].sort_values("minute").drop_duplicates("minute").reset_index(drop=True)
log(f"loaded {len(df)} unique minutes of {COIN}")
return df
def fetch_funding_history(coin: str, start_min: int, end_min: int,
cache: Path) -> pd.DataFrame:
"""Pull HL fundingHistory for coin between [start_min, end_min] (minutes-since-epoch).
Returns DataFrame[minute_floor_hour, funding_bps_annual] joined-ready.
Caches raw JSON in `cache`."""
if cache.exists():
log(f"using cached funding: {cache}")
return pd.read_parquet(cache)
start_ms = int(start_min) * 60 * 1000
end_ms = int(end_min) * 60 * 1000
log(f"fetching HL fundingHistory for {coin} {start_ms} -> {end_ms}")
rows_all = []
cursor = start_ms
while cursor < end_ms:
body = json.dumps({"type": "fundingHistory", "coin": coin,
"startTime": cursor, "endTime": end_ms}).encode()
req = urllib.request.Request(HL_INFO_URL, data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
chunk = json.loads(r.read().decode())
if not chunk:
break
rows_all.extend(chunk)
last_ts = int(chunk[-1].get("time", 0))
if last_ts <= cursor:
break
cursor = last_ts + 1
time.sleep(0.15)
if not rows_all:
log("WARNING: empty fundingHistory response")
return pd.DataFrame(columns=["minute_hour", "funding_bps_annual"])
df = pd.DataFrame(rows_all)
df["minute_hour"] = (df["time"].astype("int64") // (1000 * 60)) # to minute-since-epoch
df["funding_rate_per_hour"] = df["fundingRate"].astype(float)
# annualized bps: per-hour rate × 8760 hours × 10000 bps
df["funding_bps_annual"] = df["funding_rate_per_hour"] * 8760 * 10000.0
df = df[["minute_hour", "funding_bps_annual"]].drop_duplicates("minute_hour").sort_values("minute_hour")
cache.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(cache)
log(f"funding rows: {len(df)} "
f"mean={df.funding_bps_annual.mean():+.2f}bps "
f"std={df.funding_bps_annual.std():+.2f}bps "
f"range={df.funding_bps_annual.min():+.2f}..{df.funding_bps_annual.max():+.2f}")
return df
def compute_tensions(df: pd.DataFrame, funding: pd.DataFrame) -> pd.DataFrame:
"""Per minute, compute the four native tensions in physical units."""
out = df.copy()
# --- funding: forward-fill the hourly value onto each minute ---
# join via the floor-of-hour for each minute
out["minute_hour_floor"] = (out["minute"] // 60) * 60
out = out.merge(funding.rename(columns={"minute_hour": "minute_hour_floor"}),
on="minute_hour_floor", how="left")
out["funding_bps"] = out["funding_bps_annual"].ffill()
# --- buy_sell ratio signed ---
denom = (out["taker_buy_usd"] + out["taker_sell_usd"]).replace(0, np.nan)
out["bs_ratio_signed"] = (out["taker_buy_usd"] / denom) - 0.5 # -0.5..+0.5
# --- activity_excess: trade_count / rolling-500min-mean - 1 ---
# past-only via shift(1)
rolling_mean = out["trade_count"].astype(float).shift(1).rolling(
window=ROLL_ACTIVITY_WIN, min_periods=50
).mean()
out["activity_excess"] = (out["trade_count"].astype(float) / rolling_mean) - 1.0
# --- cvd_divergence: 60min summed signed_flow / |60min bps price change| ---
# past-only: window ends at minute t-1, then we ascribe to minute t.
sflow = out["signed_flow_usd"].astype(float)
cvd_60 = sflow.shift(1).rolling(window=ROLL_CVD_WIN, min_periods=10).sum()
px = out["mid_price"].astype(float)
# |price_change_60min| in bps, looking back from t-1 -> t-1-60
px_now = px.shift(1)
px_then = px.shift(1 + ROLL_CVD_WIN)
pct_chg_bps = ((px_now - px_then) / px_then * 10000.0).abs()
out["cvd_divergence"] = cvd_60 / (pct_chg_bps + EPS_BPS)
return out
# ───────────────────────── injection encoders ─────────────────────────
def _send_inject(pub: zmq.Socket, x: float, y: float,
strength: float, sigma: float = SIGMA) -> None:
payload = {"cmd": "inject_density",
"x": float(x), "y": float(y),
"sigma": float(sigma),
"strength": float(strength)}
pub.send_string(json.dumps(payload))
def encode_funding_dipole(pub: zmq.Socket, funding_bps: float) -> None:
"""Funding → x-axis dipole. Positive funding (longs paying) pushes +x,
negative funding (shorts paying) pushes -x."""
if not np.isfinite(funding_bps):
return
strength = funding_bps / MAP_SCALE["funding_bps_per_strength"]
strength = float(np.clip(strength, -MAP_SCALE["funding_strength_cap"],
+MAP_SCALE["funding_strength_cap"]))
if abs(strength) < 1e-6:
return
cx, cy = CENTER
_send_inject(pub, cx - DIPOLE_OFF, cy, -strength)
_send_inject(pub, cx + DIPOLE_OFF, cy, +strength)
def encode_bsratio_velocity(pub: zmq.Socket, bs_ratio_signed: float) -> None:
"""bs_ratio_signed → Y-AXIS dipole (mechanically equivalent to funding's
x-axis dipole, rotated 90°). Positive = buy pressure pushes +y leg positive,
-y leg negative. Couples to global stress_yy / vel_mean.
Second rebalance 2026-06-07: was a single offset blob that didn't couple
to any global telemetry channel."""
if not np.isfinite(bs_ratio_signed):
return
strength = bs_ratio_signed / MAP_SCALE["bs_ratio_per_strength"]
strength = float(np.clip(strength, -MAP_SCALE["bs_ratio_strength_cap"],
+MAP_SCALE["bs_ratio_strength_cap"]))
if abs(strength) < 1e-6:
return
cx, cy = CENTER
_send_inject(pub, cx, cy - DIPOLE_OFF, -strength)
_send_inject(pub, cx, cy + DIPOLE_OFF, +strength)
def encode_activity_isotropic(pub: zmq.Socket, activity_excess: float) -> None:
"""activity_excess → isotropic center blob. Excess is always relative to
own baseline so -1 (zero activity) is a meaningful 'cold' state. We allow
NEGATIVE strength too — quiet minutes pull energy out of the field."""
if not np.isfinite(activity_excess):
return
strength = activity_excess / MAP_SCALE["activity_per_strength"]
strength = float(np.clip(strength, -MAP_SCALE["activity_strength_cap"] / 3.0,
+MAP_SCALE["activity_strength_cap"]))
if abs(strength) < 1e-6:
return
cx, cy = CENTER
_send_inject(pub, cx, cy, strength)
def encode_cvd_vorticity(pub: zmq.Socket, cvd_divergence: float) -> None:
"""cvd_divergence → vorticity ring around center. N legs alternating signs.
Direction of rotation = sign of divergence. Tanh keeps magnitude bounded."""
if not np.isfinite(cvd_divergence):
return
strength = math.tanh(cvd_divergence / MAP_SCALE["cvd_div_tanh_scale"])
if abs(strength) < 1e-6:
return
cx, cy = CENTER
sigma = SIGMA * 0.75 # tighter sigma so legs don't overlap excessively
for k in range(VORTICITY_N_LEGS):
theta = 2 * math.pi * k / VORTICITY_N_LEGS
x = cx + RING_R * math.cos(theta)
y = cy + RING_R * math.sin(theta)
s = strength if (k % 2 == 0) else -strength
_send_inject(pub, x, y, s, sigma)
# ───────────────────────── per-arm runner ─────────────────────────
def snapshot_row(tel: LatestTel, minute: int, arm: str,
tensions: dict) -> dict:
snap, snap_wall = tel.snapshot()
rec = {
"minute": int(minute),
"arm": arm,
"snap_wall": snap_wall,
"snap_age_ms": (time.time() - snap_wall) * 1000 if snap_wall else None,
"funding_bps": tensions.get("funding_bps"),
"bs_ratio_signed": tensions.get("bs_ratio_signed"),
"activity_excess": tensions.get("activity_excess"),
"cvd_divergence": tensions.get("cvd_divergence"),
}
if snap is None:
for ch in CHANNELS:
rec[ch] = None
else:
for ch in CHANNELS:
rec[ch] = snap.get(ch)
return rec
def run_arm(tel: LatestTel, pub: zmq.Socket | None, arm: str,
df: pd.DataFrame) -> pd.DataFrame:
log(f"\n=== arm {arm}: starting ({len(df)} minutes) ===")
if arm == "A":
log(" arm A: NO INJECTIONS — sampling state at cadence only")
elif arm == "T":
log(" arm T: 4 NATIVE TENSION INJECTIONS per minute")
records: list[dict] = []
t_arm_start = time.time()
last_status = t_arm_start
n_minutes = len(df)
step_s = PER_MINUTE_MS / 1000.0
wait_inject_s = WAIT_AFTER_INJECT_MS / 1000.0
for i in range(n_minutes):
t_tick = time.time()
row = df.iloc[i]
tensions = {
"funding_bps": row.get("funding_bps"),
"bs_ratio_signed": row.get("bs_ratio_signed"),
"activity_excess": row.get("activity_excess"),
"cvd_divergence": row.get("cvd_divergence"),
}
if arm == "T" and pub is not None:
encode_funding_dipole (pub, tensions["funding_bps"])
encode_bsratio_velocity (pub, tensions["bs_ratio_signed"])
encode_activity_isotropic(pub, tensions["activity_excess"])
encode_cvd_vorticity (pub, tensions["cvd_divergence"])
time.sleep(wait_inject_s)
else:
time.sleep(wait_inject_s)
records.append(snapshot_row(tel, int(row.minute), arm, tensions))
spent = time.time() - t_tick
remaining = step_s - spent
if remaining > 0:
time.sleep(remaining)
if time.time() - last_status > 60:
elapsed = time.time() - t_arm_start
pct = (i + 1) / n_minutes * 100
rate_per_s = (i + 1) / elapsed if elapsed > 0 else 0
eta_s = (n_minutes - i - 1) / rate_per_s if rate_per_s > 0 else 0
cur_field = tel.latest or {}
log(f" arm {arm} {i+1}/{n_minutes} ({pct:.1f}%) "
f"rate={rate_per_s:.1f}min/s ETA={eta_s/60:.1f}min "
f"asym={cur_field.get('asymmetry','?'):.3f} "
f"latest_tel_age={(time.time()-tel.latest_wall)*1000 if tel.latest_wall else -1:.0f}ms "
f"tel_seen={tel.n_seen}")
last_status = time.time()
log(f" arm {arm} COMPLETE records={len(records)} total={(time.time()-t_arm_start)/60:.1f}min")
return pd.DataFrame(records)
# ───────────────────────── main ─────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--days-glob", default=DEFAULT_DAYS_GLOB,
help="day-dir glob pattern under hl_data/minutes/")
ap.add_argument("--max-minutes", type=int, default=None,
help="cap the number of minutes used (handy for shakedown)")
ap.add_argument("--arms", default="A,T",
help="comma-separated arm names to run (default A,T)")
ap.add_argument("--skip-funding", action="store_true",
help="don't fetch funding history (uses NaN; useful for dry run)")
ap.add_argument("--cooldown-s", type=int, default=COOLDOWN_BETWEEN_ARMS_S,
help="seconds to wait between arms (only applies after arm T)")
args = ap.parse_args()
log(f"=== market_tension_injector run_id={RUN_ID} ===")
log(f"out_dir={OUT_DIR}")
log(f"args: days_glob={args.days_glob} max_minutes={args.max_minutes} "
f"arms={args.arms}")
# load data
df = load_btc(args.days_glob)
if args.max_minutes:
df = df.head(args.max_minutes).reset_index(drop=True)
log(f"capped to first {args.max_minutes} minutes")
# funding
funding = pd.DataFrame(columns=["minute_hour", "funding_bps_annual"])
if not args.skip_funding:
try:
funding = fetch_funding_history(
COIN,
start_min=int(df.minute.min()) - 120, # extra room for ffill
end_min=int(df.minute.max()) + 120,
cache=OUT_DIR / "funding_history.parquet"
)
except Exception as e:
log(f"funding fetch failed ({e}); proceeding with NaN funding")
# tensions
df = compute_tensions(df, funding)
log("tensions computed:")
for col in ["funding_bps", "bs_ratio_signed", "activity_excess", "cvd_divergence"]:
s = df[col]
finite = int(np.isfinite(s.astype(float)).sum())
log(f" {col:<22} n_finite={finite:>5}/{len(s)} "
f"mean={s.mean():+.4f} std={s.std():+.4f} "
f"min={s.min():+.4f} max={s.max():+.4f}")
# write meta
meta = {
"run_id": RUN_ID,
"coin": COIN,
"data_root": DATA_ROOT,
"days_glob": args.days_glob,
"n_minutes": int(len(df)),
"minute_range": [int(df.minute.min()), int(df.minute.max())],
"map_scale": MAP_SCALE,
"spatial": {
"center": list(CENTER), "dipole_off": DIPOLE_OFF,
"ring_r": RING_R,
"sigma": SIGMA, "vorticity_n_legs": VORTICITY_N_LEGS,
},
"timing": {
"per_minute_ms": PER_MINUTE_MS,
"wait_after_inject_ms": WAIT_AFTER_INJECT_MS,
"cooldown_between_arms_s": COOLDOWN_BETWEEN_ARMS_S,
},
"rolling_windows": {
"activity_baseline_min": ROLL_ACTIVITY_WIN,
"cvd_window_min": ROLL_CVD_WIN,
"eps_bps": EPS_BPS,
},
"arms": args.arms.split(","),
}
(OUT_DIR / "meta.json").write_text(json.dumps(meta, indent=2))
# ZMQ setup
tel = LatestTel(TEL_ADDR)
ctx = zmq.Context.instance()
pub = ctx.socket(zmq.PUB)
pub.connect(CMD_ADDR)
log(f"ZMQ connected: SUB {TEL_ADDR}, PUB {CMD_ADDR}")
log("warming up SUB socket (3s) ...")
time.sleep(3)
log(f" initial tel_seen={tel.n_seen} latest_age_ms="
f"{(time.time()-tel.latest_wall)*1000 if tel.latest_wall else -1:.0f}")
try:
arms_to_run = [a.strip().upper() for a in args.arms.split(",")]
for ai, arm in enumerate(arms_to_run):
if arm not in ("A", "T"):
log(f"skipping unknown arm: {arm}")
continue
# Only cooldown AFTER arm T (the perturbing arm).
# Arm A is passive, no field settling needed before/after it.
if ai > 0 and arms_to_run[ai - 1] == "T":
log(f"--- cooldown {args.cooldown_s}s (let field settle after arm T) ---")
time.sleep(args.cooldown_s)
arm_df = run_arm(tel, pub if arm == "T" else None, arm, df)
outf = OUT_DIR / f"arm_{arm}_{'tension' if arm=='T' else 'no_inject'}.parquet"
arm_df.to_parquet(outf)
log(f" saved {outf.name} ({len(arm_df)} rows)")
log(f"\n=== ALL ARMS COMPLETE ===")
log(f"output: {OUT_DIR}")
finally:
tel.stop()
pub.close(0)
ctx.term()
if __name__ == "__main__":
main()