136 lines
8.5 KiB
Python
136 lines
8.5 KiB
Python
"""One local, bounded speed test for the complete owner-assigned drive profile."""
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from .drive_profile import LAYOUTS
|
|
from .protocol import ppm, values, TEST_LIMITS
|
|
from .speed_hold import SpeedHold
|
|
|
|
|
|
def targets(service, params, devices, selected):
|
|
profile = service.drive.value
|
|
bindings = profile["bindings"]
|
|
slots = LAYOUTS.get(profile["layout"], ())
|
|
if (not slots or params["profile_revision"] != profile["revision"]
|
|
or set(bindings) != set(slots)):
|
|
raise ValueError("Профиль изменился или не все моторы назначены.")
|
|
ids = [bindings[slot]["device_id"] for slot in slots]
|
|
if len(set(ids)) != len(ids) or set(params["device_ids"]) != set(ids) or selected.id not in ids:
|
|
raise ValueError("Состав проверяемого привода не совпадает с профилем.")
|
|
available = {device.id: device for device in devices}
|
|
if any(identifier not in available for identifier in ids):
|
|
raise ValueError("Один из назначенных моторов отключён.")
|
|
if any(available[b["device_id"]].identity["uuid"] != b["uuid"] for b in bindings.values()):
|
|
raise ValueError("Идентичность назначенного VESC изменилась.")
|
|
return [available[identifier] for identifier in ids]
|
|
|
|
|
|
def batch(pool, devices, function):
|
|
# Join every submitted call before propagating an error: no late worker may
|
|
# issue torque after the caller has already released the other controllers.
|
|
futures = [(device.id, pool.submit(function, device)) for device in devices]
|
|
result, errors = {}, []
|
|
for identifier, future in futures:
|
|
try: result[identifier] = future.result()
|
|
except (OSError, ValueError, TimeoutError) as error:
|
|
error.device_id = identifier
|
|
errors.append(error)
|
|
if errors: raise errors[0]
|
|
return result
|
|
|
|
|
|
def run(owner, command, devices, moving, originals, backups, unchanged):
|
|
from .motor_test import Rejected, check_values
|
|
params = command["parameters"]
|
|
duration, erpm, current_a = (params[key] for key in ("duration_s", "erpm", "current_a"))
|
|
ids = {device.id for device in moving}
|
|
motors, samples, after, cleanup = {}, [], {}, {}
|
|
restored, failure, rotation, previous_good = True, None, 0.0, False
|
|
outcome, claimed, started, previous = "duration", False, owner.monotonic(), owner.monotonic()
|
|
stalls = {}
|
|
owner.state("testing")
|
|
owner.active, owner.mode = True, "group_speed"
|
|
with ThreadPoolExecutor(max_workers=min(16, len(devices)), thread_name_prefix="vesc-drive") as pool:
|
|
try:
|
|
for device in moving:
|
|
motors[device.id] = owner.limits.apply(device, originals[device.id]["motor"], current_a)
|
|
started = previous = owner.monotonic()
|
|
holds = {device.id: SpeedHold(erpm, duration, started) for device in moving}
|
|
while owner.monotonic() - started < duration + 20:
|
|
cycle = owner.monotonic()
|
|
if owner.stop.is_set(): outcome = "stopped"; break
|
|
unchanged()
|
|
def read(device):
|
|
level = ppm(device.link.query(31, timeout=.06))["level"]
|
|
value = values(device.link.query(4, timeout=.06)) if device.id in ids else None
|
|
return level, value
|
|
observed = batch(pool, devices, read)
|
|
if any(abs(level) > .02 for level, _ in observed.values()):
|
|
owner.state("rc")
|
|
raise Rejected("Приёмник передаёт команду. Общая проверка остановлена; управление за пультом.")
|
|
now = owner.monotonic()
|
|
readings = {identifier: observed[identifier][1] for identifier in ids}
|
|
setpoint = None
|
|
for identifier, value in readings.items():
|
|
check_values(value, moving=True, current_a=current_a, motor=motors[identifier])
|
|
setpoint, _, error = holds[identifier].update(now, value)
|
|
if error: raise Rejected(error)
|
|
if abs(value["motor_current_a"]) > TEST_LIMITS["stall_current_a"]:
|
|
at, tacho = stalls.setdefault(identifier, (now, value["tachometer"]))
|
|
if abs(value["erpm"]) >= 60 and abs(value["tachometer"] - tacho) >= 3:
|
|
stalls[identifier] = (now, value["tachometer"])
|
|
elif now - at >= TEST_LIMITS["stall_timeout_s"]:
|
|
raise Rejected("Один из моторов не движется при токе выше 5 А. Общая проверка остановлена.")
|
|
else: stalls.pop(identifier, None)
|
|
good = all(h.hold_started is not None and h.previous_good for h in holds.values())
|
|
delta = now - previous
|
|
if good and previous_good and delta <= .25: rotation += delta
|
|
previous, previous_good = now, good
|
|
sample = {"at": now-started, "devices": readings, "rotation_s": rotation,
|
|
"phase": "holding" if good else "accelerating", "commanded_erpm": None}
|
|
samples.append(sample)
|
|
if rotation >= duration: break
|
|
if owner.monotonic()-cycle > .12: raise Rejected("Связь слишком медленная для общей проверки.")
|
|
claimed = True
|
|
batch(pool, devices, lambda d: d.link.test_command("claim"))
|
|
if owner.stop.is_set(): outcome = "stopped"; break
|
|
if owner.monotonic()-cycle > .16: raise Rejected("Связь слишком медленная для общей проверки.")
|
|
def send(device):
|
|
if owner.stop.is_set(): return
|
|
if device.id in ids: device.link.test_speed(setpoint)
|
|
else: device.link.test_command("release")
|
|
sent_at = owner.monotonic()
|
|
batch(pool, devices, send)
|
|
sample.update(commanded_erpm=setpoint, command_batch_s=owner.monotonic()-sent_at)
|
|
owner.sleep(max(0, .1-(owner.monotonic()-cycle)))
|
|
if outcome == "duration" and rotation < duration:
|
|
outcome = "Общий срок проверки истёк; заданное время совместного вращения не набрано."
|
|
except (OSError, ValueError, TimeoutError) as error:
|
|
failure = {"type": type(error).__name__, "message": str(error), "elapsed_s": owner.monotonic()-started}
|
|
failure["device_id"] = getattr(error, "device_id", None)
|
|
failure["native_rpc"] = getattr(error, "native_rpc", None)
|
|
failure["native_history"] = getattr(error, "native_history", [])
|
|
outcome = str(error) if isinstance(error, Rejected) else "Ответ одного из VESC не получен вовремя. Общая проверка остановлена."
|
|
finally:
|
|
def release(device):
|
|
try:
|
|
device.link.test_command("release")
|
|
return "zero_current_sent"
|
|
except (OSError, ValueError, TimeoutError): return "unconfirmed"
|
|
if claimed: cleanup = batch(pool, devices, release)
|
|
owner.sleep(.3)
|
|
for device in devices:
|
|
try: after[device.id] = values(device.link.query(4, timeout=.2))
|
|
except (OSError, ValueError, TimeoutError): after[device.id] = None
|
|
confirmed = all(v is not None and abs(v["motor_current_a"]) <= 1 for v in after.values())
|
|
for device in moving:
|
|
try: owner.limits.restore(device)
|
|
except (OSError, ValueError, TimeoutError): restored = False
|
|
if not confirmed or not restored: owner.state("rc")
|
|
elif not owner.latched: owner.state("ready")
|
|
owner.active, owner.mode = False, None
|
|
return {"observed_at": owner.utc(), "device_ids": [d.id for d in moving], "mode": "group_speed",
|
|
"profile_revision": params["profile_revision"], "current_a": current_a, "erpm_target": erpm,
|
|
"duration_limit_s": duration, "rotation_s": rotation, "outcome": outcome, "failure": failure,
|
|
"samples": samples, "release": cleanup, "release_confirmed": confirmed,
|
|
"limits_restored": restored, "after": after, "backups": backups}
|