/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using Opc.Ua; namespace Robotics { /// /// Drives the two robots through a coordinated transfer cycle without letting them /// collide. /// /// /// /// The cell is divided into two end zones and a shared corridor with one lane per /// direction of travel. A robot reserves the zone it is about to enter before it moves /// into it, and releases the zone behind it once it has left. An end zone admits one /// robot, so a deployed arm always has its half of the cell to itself; a corridor lane /// also admits one robot, so two robots travelling opposite ways pass on opposite sides /// of the aisle while two travelling the same way queue instead of overtaking. /// /// /// The arm must be folded into its transport envelope before a corridor lane can be /// reserved, which is what stops the arms sweeping through each other - the failure the /// previous free-running figure-eight paths had, where the platforms cleared by 1.2 m /// but the arms reach 1.61 m. /// /// /// Everything here is deterministic and driven by an explicit time step, so the tests /// can run a full cycle and assert the separation and reservation invariants directly. /// /// public sealed class CellChoreographer { private const double CruiseSpeed = 0.85; private const double Acceleration = 0.7; private const double ArrivalTolerance = 0.005; private const double GripSeconds = 0.7; private const double DwellSeconds = 0.4; private const double ProximitySlowdownDistance = 2.0; private const double SlowSpeed = 0.3; private const double BatteryDrainPerMetre = 0.35; private const double BatteryDrainPerSecond = 0.01; private const double ChargeRatePerSecond = 6.0; private const double ChargeThreshold = 25.0; private const double PartHeightOffset = 0.0175; private readonly List m_robots; private readonly List m_parts; private readonly Dictionary m_reservations = []; private readonly Random m_random; private readonly double m_faultProbability; private double m_now; private long m_restingSequence; /// /// Creates the choreographer with the cell staged warm: parts waiting on both /// stations, so each robot has work from the first tick instead of one of them idling /// until the other completes a full traverse. /// /// Seed for the fault injection, so runs are reproducible. /// /// The chance that a grip slips and drops the part, from 0 to 1. /// public CellChoreographer(int seed = 20260801, double faultProbability = 0.06) { m_random = new Random(seed); m_faultProbability = faultProbability; m_robots = [ new RobotAgent("R1", CellStation.TableA, CellStation.TableB, CellLayout.DockD1X, CellLayout.TableAX), new RobotAgent("R2", CellStation.TableB, CellStation.TableA, CellLayout.DockD2X, CellLayout.TableBX) ]; m_parts = [ new CellPart("Part01", CellStation.TableA, 0), new CellPart("Part02", CellStation.TableA, 1), new CellPart("Part03", CellStation.TableB, 0) ]; foreach (CellPart part in m_parts) { part.RestingSequence = ++m_restingSequence; } foreach (RobotAgent robot in m_robots) { robot.HeldZone = ZoneAt(robot.X, robot.Y); m_reservations[robot.HeldZone] = robot.Id; } } /// /// The robots in the cell. /// public IReadOnlyList Robots => m_robots; /// /// The parts circulating in the cell. /// public IReadOnlyList Parts => m_parts; /// /// The cell performance counters. /// public CellKpis Kpis { get; } = new(); /// /// Whether the emergency stop is asserted, which halts all motion. /// public bool EmergencyStop { get; set; } /// /// The robot holding the eastbound corridor lane, or null. /// public string? EastboundLaneOwner => m_reservations.TryGetValue(CellZone.CorridorEastbound, out string? owner) ? owner : null; /// /// The robot holding the westbound corridor lane, or null. /// public string? WestboundLaneOwner => m_reservations.TryGetValue(CellZone.CorridorWestbound, out string? owner) ? owner : null; /// /// Advances the whole cell by one time step. /// /// The step, in seconds. public void Advance(double deltaSeconds) { if (deltaSeconds <= 0.0) { return; } m_now += deltaSeconds; Kpis.ElapsedSeconds += deltaSeconds; foreach (RobotAgent robot in m_robots) { if (EmergencyStop) { robot.Speed = 0.0; continue; } AdvanceRobot(robot, deltaSeconds); if (robot.State is not (RobotCycleState.Idle or RobotCycleState.Blocked)) { Kpis.BusySeconds += deltaSeconds / m_robots.Count; } } Kpis.Utilisation = Kpis.ElapsedSeconds > 0.0 ? Kpis.BusySeconds / Kpis.ElapsedSeconds : 0.0; UpdateCarriedParts(); } /// /// Returns the pose of a robot's tool centre point. /// /// The robot. /// The tool centre point pose in cell coordinates. public static RigidTransform ToolCentrePointOf(RobotAgent robot) { if (robot == null) { throw new ArgumentNullException(nameof(robot)); } RigidTransform mount = RobotKinematics.CreateMountPose( robot.X, robot.Y, 0.0, robot.HeadingDegrees); return RobotKinematics.ComputeToolCentrePoint(mount, robot.Axes); } /// /// Returns the zone a position belongs to. /// /// The X position, in metres. /// The Y position, in metres. /// The zone. public static CellZone ZoneAt(double x, double y) { // A dock is its own zone. A robot with no work parks there and releases the end // zone, so the station stays free for the other robot to deliver into - without // that, an idle robot standing next to its table deadlocks the whole cell. if (IsNear(x, y, CellLayout.DockD1X, CellLayout.DockY)) { return CellZone.DockA; } if (IsNear(x, y, CellLayout.DockD2X, CellLayout.DockY)) { return CellZone.DockB; } if (x < -CellLayout.CorridorHalfWidthX) { return CellZone.EndZoneA; } if (x > CellLayout.CorridorHalfWidthX) { return CellZone.EndZoneB; } return y > (CellLayout.EastboundLaneY + CellLayout.WestboundLaneY) / 2.0 ? CellZone.CorridorEastbound : CellZone.CorridorWestbound; } private static bool IsNear(double x, double y, double atX, double atY) { const double dockRadius = 0.45; double dx = x - atX; double dy = y - atY; return ((dx * dx) + (dy * dy)) <= (dockRadius * dockRadius); } /// /// Whether two robots' footprints intersect, by the separating axis test over their /// oriented boxes. /// /// The first robot. /// The second robot. /// true when the footprints overlap. public static bool FootprintsOverlap(RobotAgent a, RobotAgent b) { if (a == null) { throw new ArgumentNullException(nameof(a)); } if (b == null) { throw new ArgumentNullException(nameof(b)); } (double ax, double ay, double ahl, double ahw, double ah) = a.Footprint; (double bx, double by, double bhl, double bhw, double bh) = b.Footprint; double[] axes = [ ah, ah + 90.0, bh, bh + 90.0 ]; foreach (double axisDegrees in axes) { double r = axisDegrees * (Math.PI / 180.0); double nx = Math.Cos(r); double ny = Math.Sin(r); double centreGap = Math.Abs(((bx - ax) * nx) + ((by - ay) * ny)); double spread = Project(ahl, ahw, ah, nx, ny) + Project(bhl, bhw, bh, nx, ny); if (centreGap > spread) { return false; } } return true; } private static double Project( double halfLength, double halfWidth, double headingDegrees, double nx, double ny) { double r = headingDegrees * (Math.PI / 180.0); double ux = Math.Cos(r); double uy = Math.Sin(r); return (halfLength * Math.Abs((ux * nx) + (uy * ny))) + (halfWidth * Math.Abs((-uy * nx) + (ux * ny))); } private void AdvanceRobot(RobotAgent robot, double dt) { DrainBattery(robot, dt); if (robot.DwellRemaining > 0.0) { robot.DwellRemaining -= dt; robot.Speed = 0.0; return; } switch (robot.State) { case RobotCycleState.Idle: BeginNextCycle(robot); break; case RobotCycleState.Blocked: case RobotCycleState.TravelToSource: case RobotCycleState.TravelToTarget: case RobotCycleState.TravelToDock: Travel(robot, dt); break; case RobotCycleState.ReachToSource: case RobotCycleState.ReachToTarget: Reach(robot, dt); break; case RobotCycleState.Grip: ActuateGripper(robot, dt, closing: true); break; case RobotCycleState.Release: ActuateGripper(robot, dt, closing: false); break; case RobotCycleState.Lift: case RobotCycleState.Retreat: Stow(robot, dt); break; case RobotCycleState.Charging: Charge(robot, dt); break; default: robot.State = RobotCycleState.Idle; break; } } private void BeginNextCycle(RobotAgent robot) { robot.Speed = 0.0; CellPart? part = FindCollectablePart(robot); if (part == null || robot.NeedsCharge) { // Nothing to collect, or the battery is low: park on the dock. This also // releases the end zone, so the other robot can deliver into the station // this robot serves. if (ZoneAt(robot.X, robot.Y) != DockZoneOf(robot)) { RouteTo(robot, robot.DockX, CellLayout.DockY); robot.State = RobotCycleState.TravelToDock; } return; } robot.TargetSlot = part.Slot; robot.CycleStartedAt = m_now; double approachX = StationX(robot.Source); RouteTo(robot, approachX, CellLayout.TableApproachY); robot.State = RobotCycleState.TravelToSource; } private static CellZone DockZoneOf(RobotAgent robot) { return robot.DockX < 0.0 ? CellZone.DockA : CellZone.DockB; } private CellPart? FindCollectablePart(RobotAgent robot) { CellPart? best = null; foreach (CellPart part in m_parts) { if (!part.IsResting || part.OnFloor || part.Station != robot.Source) { continue; } if (best == null || part.RestingSequence < best.RestingSequence) { best = part; } } return best; } private void Travel(RobotAgent robot, double dt) { if (robot.Waypoints.Count == 0) { ArriveFromTravel(robot); return; } (double targetX, double targetY) = robot.Waypoints.Peek(); double dx = targetX - robot.X; double dy = targetY - robot.Y; double distance = Math.Sqrt((dx * dx) + (dy * dy)); if (distance <= ArrivalTolerance) { robot.X = targetX; robot.Y = targetY; _ = robot.Waypoints.Dequeue(); if (robot.Waypoints.Count == 0) { robot.Speed = 0.0; ArriveFromTravel(robot); } return; } // Reserve the zone immediately ahead, not the zone of the final waypoint: a robot // travelling from one end of the cell to the other passes through the corridor, // and demanding the far end zone up front would have each robot waiting on the // zone the other is standing in. const double lookahead = 0.35; double nextX = robot.X + (dx / distance * lookahead); double nextY = robot.Y + (dy / distance * lookahead); CellZone next = ZoneAt(nextX, nextY); if (next != robot.HeldZone && !EnsureReservation(robot, next)) { robot.Speed = 0.0; if (robot.State != RobotCycleState.Blocked) { robot.ResumeState = robot.State; robot.State = RobotCycleState.Blocked; } return; } if (robot.State == RobotCycleState.Blocked) { robot.State = robot.ResumeState; } robot.BlockedBy = null; double limit = SpeedLimitFor(robot); double braking = (robot.Speed * robot.Speed) / (2.0 * Acceleration); robot.Speed = distance <= braking ? Math.Max(0.05, robot.Speed - (Acceleration * dt)) : Math.Min(limit, robot.Speed + (Acceleration * dt)); double step = Math.Min(robot.Speed * dt, distance); robot.X += dx / distance * step; robot.Y += dy / distance * step; robot.DistanceTravelled += step; robot.HeadingDegrees = Math.Atan2(dy, dx) * (180.0 / Math.PI); CellZone occupied = ZoneAt(robot.X, robot.Y); if (occupied == robot.ReservedZone && occupied != robot.HeldZone) { ReleaseZone(robot.HeldZone, robot.Id); robot.HeldZone = occupied; robot.ReservedZone = CellZone.None; } } private void ArriveFromTravel(RobotAgent robot) { robot.Speed = 0.0; robot.HeadingDegrees = 90.0; switch (robot.State) { case RobotCycleState.TravelToSource: robot.State = RobotCycleState.ReachToSource; break; case RobotCycleState.TravelToTarget: robot.State = RobotCycleState.ReachToTarget; break; case RobotCycleState.TravelToDock: robot.State = robot.NeedsCharge ? RobotCycleState.Charging : RobotCycleState.Idle; break; default: robot.State = RobotCycleState.Idle; break; } } private void Reach(RobotAgent robot, double dt) { bool toSource = robot.State == RobotCycleState.ReachToSource; CellStation station = toSource ? robot.Source : robot.Target; int slot = toSource ? robot.TargetSlot : FirstFreeSlot(station); robot.TargetSlot = slot; (double slotX, double slotY, double slotZ) = CellLayout.SlotPosition(station, slot); CellPart? floorPart = toSource ? FindFloorPart(robot) : null; if (floorPart != null) { slotX = floorPart.X; slotY = floorPart.Y; slotZ = floorPart.Z; } double forward = slotY - robot.Y; double lateral = -(slotX - robot.X); var solved = new double[RobotKinematics.AxisCount]; if (!RobotArmSolver.TrySolve(forward, lateral, slotZ, solved)) { robot.State = RobotCycleState.Idle; return; } robot.ArmStowed = false; if (!StepAxesTowards(robot, solved, dt)) { return; } robot.State = toSource ? RobotCycleState.Grip : RobotCycleState.Release; robot.DwellRemaining = DwellSeconds; } private void ActuateGripper(RobotAgent robot, double dt, bool closing) { double rate = dt / GripSeconds; robot.GripperOpening = closing ? Math.Max(0.0, robot.GripperOpening - rate) : Math.Min(1.0, robot.GripperOpening + rate); if (closing && robot.GripperOpening <= 0.0) { CellPart? part = FindFloorPart(robot) ?? FindCollectablePart(robot); if (part != null) { part.CarriedBy = robot.Id; part.OnFloor = false; robot.HoldingPart = part.Id; } robot.State = RobotCycleState.Lift; robot.DwellRemaining = DwellSeconds; return; } if (!closing && robot.GripperOpening >= 1.0) { CellPart? part = FindCarriedPart(robot); if (part != null) { part.CarriedBy = null; part.Station = robot.Target; part.Slot = robot.TargetSlot; part.RestingSequence = ++m_restingSequence; (part.X, part.Y, part.Z) = CellLayout.SlotPosition(part.Station, part.Slot); part.HeadingDegrees = 0.0; CompleteCycle(robot); } robot.HoldingPart = null; robot.State = RobotCycleState.Retreat; robot.DwellRemaining = DwellSeconds; } } private void Stow(RobotAgent robot, double dt) { if (!StepAxesTowards(robot, RobotAgent.TransportPose, dt)) { return; } robot.ArmStowed = true; if (robot.State == RobotCycleState.Lift) { // A seeded, non-cryptographic generator is exactly what is wanted here: the // injected grip faults have to be reproducible so a test can assert the // recovery path deterministically. #pragma warning disable CA5394 // Do not use insecure randomness bool slipped = m_random.NextDouble() < m_faultProbability; #pragma warning restore CA5394 if (robot.HoldingPart != null && slipped) { DropPart(robot); robot.State = RobotCycleState.ReachToSource; return; } RouteTo(robot, StationX(robot.Target), CellLayout.TableApproachY); robot.State = RobotCycleState.TravelToTarget; return; } robot.State = RobotCycleState.Idle; } private void DropPart(RobotAgent robot) { CellPart? part = FindCarriedPart(robot); if (part == null) { return; } part.CarriedBy = null; part.OnFloor = true; part.X = robot.X; part.Y = robot.Y + 0.85; part.Z = PartHeightOffset; robot.HoldingPart = null; robot.GripperOpening = 1.0; Kpis.FaultCount++; } private void Charge(RobotAgent robot, double dt) { robot.Speed = 0.0; robot.BatteryLevel = Math.Min(100.0, robot.BatteryLevel + (ChargeRatePerSecond * dt)); if (robot.BatteryLevel >= 99.9) { robot.NeedsCharge = false; robot.State = RobotCycleState.Idle; } } private void CompleteCycle(RobotAgent robot) { double duration = m_now - robot.CycleStartedAt; Kpis.PartsMoved++; Kpis.CycleCount++; Kpis.LastCycleSeconds = duration; Kpis.TotalCycleSeconds += duration; Kpis.AverageCycleSeconds = Kpis.TotalCycleSeconds / Kpis.CycleCount; if (robot.BatteryLevel < ChargeThreshold) { robot.NeedsCharge = true; } } private void DrainBattery(RobotAgent robot, double dt) { if (robot.State == RobotCycleState.Charging) { return; } double drain = (BatteryDrainPerSecond * dt) + (BatteryDrainPerMetre * robot.Speed * dt); robot.BatteryLevel = Math.Max(0.0, robot.BatteryLevel - drain); } private double SpeedLimitFor(RobotAgent robot) { foreach (RobotAgent other in m_robots) { if (ReferenceEquals(other, robot)) { continue; } double dx = other.X - robot.X; double dy = other.Y - robot.Y; if (Math.Sqrt((dx * dx) + (dy * dy)) < ProximitySlowdownDistance) { return SlowSpeed; } } return CruiseSpeed; } private static bool StepAxesTowards(RobotAgent robot, ArrayOf goal, double dt) { const double axisSpeed = 90.0; bool settled = true; for (int i = 0; i < robot.Axes.Length; i++) { double delta = goal[i] - robot.Axes[i]; double step = axisSpeed * dt; if (Math.Abs(delta) <= step) { robot.Axes[i] = goal[i]; continue; } robot.Axes[i] += Math.Sign(delta) * step; settled = false; } return settled; } private void RouteTo(RobotAgent robot, double destinationX, double destinationY) { robot.Waypoints.Clear(); bool eastbound = destinationX > robot.X; double laneY = eastbound ? CellLayout.EastboundLaneY : CellLayout.WestboundLaneY; if (Math.Abs(robot.Y - laneY) > ArrivalTolerance) { robot.Waypoints.Enqueue((robot.X, laneY)); } robot.Waypoints.Enqueue((destinationX, laneY)); if (Math.Abs(destinationY - laneY) > ArrivalTolerance) { robot.Waypoints.Enqueue((destinationX, destinationY)); } } private bool EnsureReservation(RobotAgent robot, CellZone zone) { if (zone == CellZone.None || zone == robot.HeldZone) { return true; } if (robot.ReservedZone == zone) { return true; } bool corridor = zone is CellZone.CorridorEastbound or CellZone.CorridorWestbound; if (corridor && !robot.ArmStowed) { robot.BlockedBy = robot.Id; return false; } if (m_reservations.TryGetValue(zone, out string? owner) && owner != robot.Id) { robot.BlockedBy = owner; return false; } m_reservations[zone] = robot.Id; robot.ReservedZone = zone; return true; } private void ReleaseZone(CellZone zone, string robotId) { if (zone != CellZone.None && m_reservations.TryGetValue(zone, out string? owner) && owner == robotId) { _ = m_reservations.Remove(zone); } } private CellPart? FindCarriedPart(RobotAgent robot) { foreach (CellPart part in m_parts) { if (part.CarriedBy == robot.Id) { return part; } } return null; } private CellPart? FindFloorPart(RobotAgent robot) { foreach (CellPart part in m_parts) { if (part.OnFloor && Math.Abs(part.X - robot.X) < 1.0) { return part; } } return null; } private int FirstFreeSlot(CellStation station) { int count = CellLayout.SlotOffsetsX.Count; var taken = new bool[count]; foreach (CellPart part in m_parts) { if (part.IsResting && !part.OnFloor && part.Station == station && part.Slot >= 0 && part.Slot < count) { taken[part.Slot] = true; } } for (int i = 0; i < count; i++) { if (!taken[i]) { return i; } } return 0; } private void UpdateCarriedParts() { foreach (RobotAgent robot in m_robots) { CellPart? part = FindCarriedPart(robot); if (part == null) { continue; } RigidTransform tcp = ToolCentrePointOf(robot); (part.X, part.Y, part.Z) = tcp.Origin; part.HeadingDegrees = robot.HeadingDegrees; } } private static double StationX(CellStation station) { return station == CellStation.TableA ? CellLayout.TableAX : CellLayout.TableBX; } } }