80 lines
4.4 KiB
Python
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}}
|