"""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}