Files
NODEDC_MISSION_CORE/plugins/vesc/runtime/link_check.py
T

80 lines
4.4 KiB
Python

"""Bounded idle transport measurement through the installed native owners.
Only PPM/telemetry reads, no leases, motor commands or configuration writes.
The 500 ms diagnostic deadline observes replies beyond the 60 ms motor budget;
it never relaxes the motor-control deadline or authorizes powered operation.
"""
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from datetime import datetime, timezone
import math
import time
from .protocol import ppm, values
def summary(samples):
times = sorted(s["elapsed_ms"] for s in samples)
if not times: return {"replies": 0}
def percentile(p): return times[max(0, math.ceil(len(times)*p)-1)]
return {"replies": len(times), "p50_ms": percentile(.5), "p95_ms": percentile(.95),
"p99_ms": percentile(.99), "max_ms": times[-1],
"over_60_ms": sum(t > 60 for t in times)}
def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monotonic):
started = monotonic()
observed = datetime.now(timezone.utc).isoformat()
deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
ids = {d.id: d.session for d in devices}
if not devices or len(ids) != len(devices) or ids != command["parameters"]["sessions"]:
raise ValueError("Controller sessions changed")
samples = {d.id: [] for d in devices}
failure = None
stop_reason = "complete"
def read(device):
for code in (31, 4):
before = monotonic()
try:
raw = device.link.query(code, timeout=.5)
reading = ppm(raw) if code == 31 else values(raw)
sample = {"command": code, "at_s": before-started, "elapsed_ms": (monotonic()-before)*1000,
"native_rpc": getattr(device.link, "last_rpc", None)}
if code == 31:
sample["ppm_level"] = reading["level"]
idle = abs(reading["level"]) <= .02
else:
sample.update(erpm=reading["erpm"], current_a=reading["motor_current_a"],
duty=reading["duty"], fault_code=reading["fault_code"])
idle = abs(reading["erpm"]) <= 30 and abs(reading["motor_current_a"]) <= 1 and abs(reading["duty"]) <= .01
samples[device.id].append(sample)
if not idle: return {"device_id": device.id, "reason": "not_idle"}
except (OSError, ValueError, TimeoutError) as error:
return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error),
"elapsed_ms": (monotonic()-before)*1000,
"native_rpc": getattr(error, "native_rpc", None),
"native_history": getattr(error, "native_history", [])}
return None
with ExitStack() as locks:
for device in sorted(devices, key=lambda d: d.id): locks.enter_context(device.lock)
if any(d.link is None for d in devices): raise ValueError("Controller unavailable")
# Same cadence and per-controller query order as group rotation, with a
# larger read-only deadline to expose latency instead of destroying it.
with ThreadPoolExecutor(max_workers=min(16, len(devices))) as pool:
for _ in range(100):
cycle = monotonic()
cancelled = service.motor.cancelled_at
if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")):
stop_reason = "stopped"; break
if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2:
stop_reason = "deadline"; break
futures = [pool.submit(read, d) for d in devices]
errors = [error for future in futures if (error := future.result()) is not None]
if errors:
failure = errors; stop_reason = errors[0]["reason"]; break
sleep(max(0, .1-(monotonic()-cycle)))
return {"observed_at": observed, "monotonic_started": started, "duration_s": monotonic()-started,
"outcome": stop_reason, "motor_commands_sent": False, "read_timeout_ms": 500,
"failure": failure, "devices": {d.id: {"name": "VESC "+d.identity["uuid"][:6].upper(),
"summary": summary(samples[d.id]), "samples": samples[d.id]} for d in devices}}