2.0协议完善加简单交管配置

This commit is contained in:
ykkokluo
2026-08-14 09:49:23 +08:00
parent a28bc68e23
commit 1e780c2b65
51 changed files with 4013 additions and 393 deletions
@@ -335,7 +335,7 @@ namespace StandardScene.Chained
/// </summary>
/// <param name="siteId">站点ID</param>
/// <returns>找到的车辆,未找到返回 null</returns>
protected Car FindCarArrivedAtSite(int siteId)
protected virtual Car FindCarArrivedAtSite(int siteId)
{
try
{
@@ -1027,7 +1027,6 @@ namespace StandardScene.Chained
/// <param name="reverse">是否倒车,默认为 false</param>
public async Task GoSite(AbstractCar car, Site targetSite, string action = "/", bool reverse = false)
{
try
{
// 创建路径规划
@@ -1039,7 +1038,7 @@ namespace StandardScene.Chained
// 查找从当前位置到目标站点的路径
plan.FindRoute(SimpleLib.GetSite(car.GetLastSite()), targetSite);
// 编译并执行移动脚本
// 编译并执行移动脚本(默认 Forecast
var program = plan.Compile("move");
// 标记车辆为占用状态
+4 -2
View File
@@ -297,11 +297,13 @@ namespace StandardScene
&& car.status.pendingLocks.Length == 0 && !car.tags.Contains("deliver") &&//pendingLocks临时增加解决车接多任务问题
(!car.tags.Contains("shouldCharge") || needCharge)
&& !car.tags.Contains("currentWorkStep") && !car.fields.ContainsKey("StopAccept") && car.tags.Contains("Online"); //巡航任务未完成的车
if (car.name.Contains("模拟"))
// Mag2Car 联调:UDP Online 即可调度,不强制 lstatus=上线(模拟器常报 state=0 未准备)
var isMag2Car = string.Equals(car.GetType().Name, "Mag2Car", StringComparison.Ordinal);
if (car.name.Contains("模拟") || isMag2Car)
{
conditions = car.GetLastSite() != -1 &&
(!car.tags.Contains("holdCar") || !checkHoldCar) && !car.tags.Contains("occupied")
&& (!isMag2Car || car.tags.Contains("Online"))
&& car.status.pendingLocks.Length == 0 && !car.tags.Contains("deliver") &&//pendingLocks临时增加解决车接多任务问题
(!car.tags.Contains("shouldCharge") || needCharge)
&& !car.tags.Contains("currentWorkStep") && !car.fields.ContainsKey("StopAccept"); //巡航任务未完成的车
+2
View File
@@ -7,6 +7,8 @@ namespace StandardScene.Fass2Simulator
{
protected override void OnStartup(StartupEventArgs e)
{
Fass2SimBootstrap.ApplyCommandLine(e.Args);
if (HasTestArg(e.Args))
{
var code = Fass2SimSelfTests.Run(e.Args);
@@ -58,6 +58,15 @@ namespace StandardScene.Fass2Simulator
}
}
public void ResetForNewTask()
{
lock (_syncRoot)
{
_pendingActions.Clear();
_expectedStation = null;
}
}
public void OnActionCommand(ulong actionId, Fass2SimNodeMessage patch)
{
if (patch == null)
@@ -67,13 +76,16 @@ namespace StandardScene.Fass2Simulator
lock (_syncRoot)
{
var targetNode = patch.Node != 0 ? patch.Node : _vehicle.Node;
if (patch.Node != 0 && patch.Node != _vehicle.Node)
{
Fass2SimLog.WriteLine(
$"[{Now()}] -> 0xA1 节点不匹配: patch={patch.Node}, current={_vehicle.Node},仍尝试应用");
$"[{Now()}] -> 0xA1 忽略: patch node={patch.Node}, current={_vehicle.Node}");
return;
}
var delay = ResolveActionDelayMs(patch.Node != 0 ? patch.Node : _vehicle.Node);
var delay = ResolveActionDelayMs(targetNode);
MergeExpectedLocked(patch);
SchedulePatch(patch, actionId, 0, delay);
Fass2SimLog.WriteLine(
$"[{Now()}] -> 0xA1 actionId={actionId} 已排队,delay={delay}ms, pending={DescribePatch(patch)}");
@@ -161,6 +173,37 @@ namespace StandardScene.Fass2Simulator
}
}
public void MergeExpected(Fass2SimNodeMessage patch)
{
if (patch == null)
{
return;
}
lock (_syncRoot)
{
MergeExpectedLocked(patch);
}
}
private void MergeExpectedLocked(Fass2SimNodeMessage patch)
{
if (_expectedStation == null)
{
return;
}
if (patch.Node != 0 && patch.Node != _expectedStation.Node)
{
return;
}
if (patch.StartStop > 0)
{
_expectedStation.StartStop = patch.StartStop;
}
}
private bool IsStationCompleteLocked()
{
if (_pendingActions.Count > 0)
@@ -229,9 +272,9 @@ namespace StandardScene.Fass2Simulator
private void ApplyPatch(Fass2SimNodeMessage patch)
{
if (patch.Node != 0)
if (patch.Node != 0 && patch.Node != _vehicle.Node)
{
_vehicle.Node = patch.Node;
return;
}
if (patch.StartStop > 0)
@@ -10,13 +10,14 @@ namespace StandardScene.Fass2Simulator
{
public const byte StartStopPass = 1;
public const byte StartStopStop = 2;
public const byte StartStopControlStart = 11;
public const byte StartStopControlStop = 12;
public const byte StartStopPrecision = 22;
private static readonly (string Name, Func<Fass2SimNodeMessage, byte> Get)[] ActionFields =
public static bool VerifyTrajectoryFields { get; set; }
private static readonly (string Name, Func<Fass2SimNodeMessage, byte> Get)[] MechanismFields =
{
("Direction", n => n.Direction),
("Orientation", n => n.Orientation),
("Byroad", n => n.Byroad),
("Obstacle", n => n.Obstacle),
("Audio", n => n.Audio),
("Light", n => n.Light),
@@ -29,6 +30,18 @@ namespace StandardScene.Fass2Simulator
("Shutdown", n => n.Shutdown)
};
private static readonly (string Name, Func<Fass2SimNodeMessage, byte> Get)[] TrajectoryFields =
{
("Direction", n => n.Direction),
("Orientation", n => n.Orientation),
("Byroad", n => n.Byroad)
};
public static bool AllowsAutoContinue(byte startStop)
{
return startStop == StartStopPass || startStop == StartStopControlStart;
}
public static bool RequiresActionWait(Fass2SimNodeMessage expected)
{
if (expected == null)
@@ -36,12 +49,25 @@ namespace StandardScene.Fass2Simulator
return false;
}
if (expected.StartStop is StartStopStop or StartStopPrecision)
if (expected.StartStop is StartStopStop or StartStopPrecision or StartStopControlStop or StartStopControlStart)
{
return true;
}
foreach (var field in ActionFields)
foreach (var field in MechanismFields)
{
if (field.Get(expected) != 0)
{
return true;
}
}
if (!VerifyTrajectoryFields)
{
return false;
}
foreach (var field in TrajectoryFields)
{
if (field.Get(expected) != 0)
{
@@ -64,7 +90,7 @@ namespace StandardScene.Fass2Simulator
return false;
}
if (vehicleState == 1)
if (vehicleState is 1 or 5)
{
return false;
}
@@ -79,6 +105,11 @@ namespace StandardScene.Fass2Simulator
return true;
}
if (expected.StartStop == StartStopControlStop)
{
return false;
}
return ListPendingFields(expected, actual).Count == 0;
}
@@ -95,12 +126,24 @@ namespace StandardScene.Fass2Simulator
pending.Add("StartStop");
}
if (expected.Speed != 0 && actual.Speed != expected.Speed)
if (VerifyTrajectoryFields)
{
pending.Add("Speed");
if (expected.Speed != 0 && actual.Speed != expected.Speed)
{
pending.Add("Speed");
}
foreach (var field in TrajectoryFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && GetActualField(actual, field.Name) != expectedValue)
{
pending.Add(field.Name);
}
}
}
foreach (var field in ActionFields)
foreach (var field in MechanismFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && GetActualField(actual, field.Name) != expectedValue)
@@ -8,12 +8,30 @@ namespace StandardScene.Fass2Simulator
{
public static class Fass2SimBootstrap
{
public static Fass2SimConfig LoadConfig()
public static string SettingsFile { get; private set; } = "appsettings.json";
public static void ApplyCommandLine(string[] args)
{
if (args == null)
{
return;
}
for (var i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--config", StringComparison.OrdinalIgnoreCase))
{
SettingsFile = args[i + 1];
}
}
}
public static Fass2SimConfig LoadConfig(string settingsFile = null)
{
var basePath = AppContext.BaseDirectory;
var builder = new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false);
.AddJsonFile(settingsFile ?? SettingsFile, optional: true, reloadOnChange: false);
var configuration = builder.Build();
var config = new Fass2SimConfig();
@@ -41,6 +41,12 @@ namespace StandardScene.Fass2Simulator
_windowNodes = nodes;
_vehicle.Task = taskId;
if (taskChanged)
{
_actions.ResetForNewTask();
ResetSegment();
}
ApplyCurrentNodeFields(nodes);
LogNodes(nodes);
@@ -63,10 +69,35 @@ namespace StandardScene.Fass2Simulator
}
}
if (!ContainsNode(nodes, _vehicle.Node))
{
Fass2SimLog.WriteLine(
$"[{Now()}] -> 0xB1 窗口不含当前 node={_vehicle.Node},忽略启动路段(避免回退到窗口首站)");
return;
}
TryStartNextSegment();
}
}
private static bool ContainsNode(Fass2SimNodeMessage[] nodes, ushort node)
{
if (nodes == null)
{
return false;
}
for (var i = 0; i < nodes.Length; i++)
{
if (nodes[i].Node == node)
{
return true;
}
}
return false;
}
public void OnStartCommand()
{
lock (_syncRoot)
@@ -147,7 +178,7 @@ namespace StandardScene.Fass2Simulator
}
_segmentTarget = target;
_segmentLength = ResolveSegmentLength(target);
_segmentLength = ResolveSegmentLength(_vehicle.Node, target);
_segmentProgress = 0;
_segmentActive = true;
_vehicle.State = 1;
@@ -184,7 +215,7 @@ namespace StandardScene.Fass2Simulator
}
var expected = _actions.ExpectedStation;
if (expected == null || expected.StartStop != Fass2SimActionResolver.StartStopPass)
if (expected == null || !Fass2SimActionResolver.AllowsAutoContinue(expected.StartStop))
{
return;
}
@@ -218,15 +249,24 @@ namespace StandardScene.Fass2Simulator
return null;
}
// 必须先在窗口内定位当前站,再取其后一站。
// 旧逻辑在窗口不含当前站时会落到 nodes[0],把更早的站当成下一目标 → 车体 Node 瞬间回退/瞬移。
var foundCurrent = false;
var startIndex = 0;
for (var i = 0; i < nodes.Length; i++)
{
if (nodes[i].Node == currentNode)
{
foundCurrent = true;
startIndex = i + 1;
}
}
if (!foundCurrent)
{
return null;
}
for (var i = startIndex; i < nodes.Length; i++)
{
var node = nodes[i];
@@ -248,12 +288,21 @@ namespace StandardScene.Fass2Simulator
if (node.Node == _vehicle.Node && node.StartStop > 0)
{
_vehicle.StartStop = node.StartStop;
_actions.MergeExpected(node);
}
}
}
private double ResolveSegmentLength(Fass2SimNodeMessage target)
private double ResolveSegmentLength(ushort fromNode, Fass2SimNodeMessage target)
{
foreach (var node in _windowNodes)
{
if (node.Node == fromNode && node.Distance > 0)
{
return node.Distance;
}
}
if (target.Distance > 0)
{
return target.Distance;
@@ -272,12 +321,12 @@ namespace StandardScene.Fass2Simulator
{
if (target.Speed > 0)
{
return target.Speed;
return Fass2SimProtocol.ProtocolSpeedToMmPerSec(target.Speed);
}
if (_vehicle.Speed > 0)
{
return _vehicle.Speed;
return Fass2SimProtocol.ProtocolSpeedToMmPerSec(_vehicle.Speed);
}
return _config.DefaultSpeed;
@@ -152,6 +152,17 @@ namespace StandardScene.Fass2Simulator
}
}
/// <summary>协议速度(0.1 m/min) → mm/s。</summary>
public static double ProtocolSpeedToMmPerSec(ushort protocolSpeed)
{
if (protocolSpeed == 0)
{
return 0;
}
return protocolSpeed * 0.1 * 1000.0 / 60.0;
}
public static string ToHex(byte[] bytes)
{
return bytes == null ? string.Empty : string.Join(" ", bytes.Select(b => b.ToString("X2")));
@@ -21,6 +21,7 @@ namespace StandardScene.Fass2Simulator
private static int RunMotionSelfTest()
{
Fass2SimFileLogger.Configure(new Fass2SimConfig { EnableFileLog = false });
var config = new Fass2SimConfig
{
InitialNode = 1,
@@ -31,9 +32,9 @@ namespace StandardScene.Fass2Simulator
var (vehicle, motion, _) = Fass2SimBootstrap.CreateTestStack(config);
var nodes = new[]
{
new Fass2SimNodeMessage { Node = 1, StartStop = 1 },
new Fass2SimNodeMessage { Node = 1, StartStop = 1, Distance = 500 },
new Fass2SimNodeMessage { Node = 2, StartStop = 1, Distance = 500 },
new Fass2SimNodeMessage { Node = 3, StartStop = 2, Distance = 500 }
new Fass2SimNodeMessage { Node = 3, StartStop = 2 }
};
vehicle.State = 1;
@@ -57,6 +58,7 @@ namespace StandardScene.Fass2Simulator
private static int RunActionSelfTest()
{
Fass2SimFileLogger.Configure(new Fass2SimConfig { EnableFileLog = false });
var config = new Fass2SimConfig
{
InitialNode = 3,
@@ -69,7 +69,7 @@ namespace StandardScene.Fass2Simulator
Fass2SimLog.WriteLine($" 路段模拟 : dist={_config.DefaultSegmentDistance}mm 或 node.Distance, speed={_config.DefaultSpeed}mm/s");
Fass2SimLog.WriteLine($" 动作延时 : {_config.ActionDelayMs}ms0xA1 到位),AutoComplete={_config.AutoCompleteStationActions}");
Fass2SimLog.WriteLine($" 初始节点 : {_config.InitialNode}, 状态={Fass2SimProtocol.StateText(_config.InitialState)}");
Fass2SimLog.WriteLine("等待 MagFass2Car 联调(address=127.0.0.1, Port=5000, ListenPort=20103, VehicleCode=1");
Fass2SimLog.WriteLine("等待 Mag2Car 联调(address=127.0.0.1, Port=5000, ListenPort=20103, VehicleCode=1");
}
public void Stop()
@@ -21,6 +21,7 @@
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.car2.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="sim-nodes.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
@@ -0,0 +1,25 @@
{
"Fass2Simulator": {
"VehicleCode": 2,
"VehicleListenAddress": "0.0.0.0",
"VehicleListenPort": 5002,
"SchedulerHost": "127.0.0.1",
"SchedulerListenPort": 20103,
"ReportIntervalMs": 200,
"InitialNode": 5,
"InitialState": 1,
"BatteryCharge": 100,
"CarLength": 1200,
"CarWidth": 800,
"LogRawFrames": true,
"DefaultSpeed": 500,
"DefaultSegmentDistance": 1000,
"SecondsPerSegment": 2,
"AutoContinueOnPass": true,
"ActionDelayMs": 1000,
"AutoCompleteStationActions": false,
"NodeProfilesPath": "sim-nodes.json",
"EnableFileLog": true,
"LogDirectory": "logs\\car2"
}
}
@@ -2,12 +2,12 @@
"Fass2Simulator": {
"VehicleCode": 1,
"VehicleListenAddress": "0.0.0.0",
"VehicleListenPort": 5000,
"VehicleListenPort": 5001,
"SchedulerHost": "127.0.0.1",
"SchedulerListenPort": 20103,
"ReportIntervalMs": 200,
"InitialNode": 1,
"InitialState": 0,
"InitialState": 1,
"BatteryCharge": 100,
"CarLength": 1200,
"CarWidth": 800,
@@ -0,0 +1,171 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using StandardScene.Magnetic.Tests.TestHelpers;
using System;
using Xunit;
namespace StandardScene.Magnetic.Tests.Protocol
{
public sealed class Fass2ProtocolGoldenTests
{
[Fact]
public void BuildControl_Start_Car1_Param0_MatchesGoldenLayout()
{
var frame = Fass2Protocol.BuildControl(Fass2Protocol.CmdStart, 1, 0);
Assert.Equal(Fass2Protocol.ControlFrameLength, frame.Length);
Assert.Equal(Fass2Protocol.Begin, frame[0]);
Assert.Equal(Fass2Protocol.CmdStart, frame[1]);
Assert.Equal(0x01, frame[2]);
Assert.Equal(0x00, frame[3]);
Assert.Equal(0x00, frame[4]);
Assert.Equal(0x00, frame[5]);
Assert.Equal(Fass2Protocol.Xor(frame, 1, 47), frame[48]);
Assert.Equal(Fass2Protocol.End, frame[49]);
const string goldenPrefix = "BB 01 01 00 00 00";
Assert.StartsWith(goldenPrefix, Fass2TestFrameBuilder.ToHex(frame));
}
[Fact]
public void BuildNodes_TwoNodes_LittleEndianAndXor()
{
var nodes = new[]
{
new Fass2NodeMessage
{
Node = 1,
Distance = 500,
StartStop = 1,
Speed = 120
},
new Fass2NodeMessage
{
Node = 2,
StartStop = 2,
Lift = 1
}
};
var frame = Fass2Protocol.BuildNodes(1, 0x0102030405060708UL, nodes);
Assert.Equal(Fass2Protocol.NodesFrameLength, frame.Length);
Assert.Equal(Fass2Protocol.CmdNodes, frame[1]);
Assert.Equal(0x08, frame[4]);
Assert.Equal(0x01, frame[11]);
Assert.Equal(0x02, frame[12]);
Assert.Equal(0x00, frame[13]);
Assert.Equal(Fass2Protocol.Xor(frame, 1, 297), frame[298]);
Assert.Equal(Fass2Protocol.End, frame[299]);
var parsedNode0 = Fass2NodeMessage.FromBytes(CopyRange(frame, 14, 25));
Assert.Equal((ushort)1, parsedNode0.Node);
Assert.Equal((ushort)500, parsedNode0.Distance);
Assert.Equal((byte)1, parsedNode0.StartStop);
Assert.Equal((ushort)120, parsedNode0.Speed);
var parsedNode1 = Fass2NodeMessage.FromBytes(CopyRange(frame, 39, 25));
Assert.Equal((ushort)2, parsedNode1.Node);
Assert.Equal((byte)2, parsedNode1.StartStop);
Assert.Equal((byte)1, parsedNode1.Lift);
}
[Fact]
public void BuildAction_RoundTripsNodeBlock()
{
var node = new Fass2NodeMessage
{
Node = 3,
StartStop = 22,
Lift = 2,
Speed = 120
};
var frame = Fass2Protocol.BuildAction(2, 999, node);
Assert.Equal(Fass2Protocol.ActionFrameLength, frame.Length);
Assert.Equal(Fass2Protocol.CmdAction, frame[1]);
Assert.Equal(0x02, frame[2]);
Assert.Equal(0x00, frame[3]);
Assert.Equal(Fass2Protocol.Xor(frame, 1, 97), frame[98]);
var parsed = Fass2NodeMessage.FromBytes(CopyRange(frame, 14, 25));
Assert.Equal(node.Node, parsed.Node);
Assert.Equal(node.StartStop, parsed.StartStop);
Assert.Equal(node.Lift, parsed.Lift);
Assert.Equal(node.Speed, parsed.Speed);
}
[Fact]
public void BuildStateResponse_FixedTimestamp_EncodesBeijingCalendar()
{
var frame = Fass2Protocol.BuildStateResponse(1, 1_704_067_200_000UL);
Assert.Equal(Fass2Protocol.CmdStateResponse, frame[1]);
Assert.Equal(0x01, frame[2]);
Assert.Equal(0x00, frame[3]);
Assert.Equal(Fass2Protocol.Xor(frame, 1, 47), frame[48]);
Assert.Equal(Fass2Protocol.End, frame[49]);
}
[Fact]
public void ParseState_RoundTripsMinimalFrame()
{
var frame = Fass2TestFrameBuilder.BuildStateFrame(5, 2, 7, 42);
Assert.True(Fass2Protocol.TryExtractStateFrame(frame, frame.Length, out var extracted));
Assert.Equal(frame, extracted);
var report = Fass2Protocol.ParseState(frame);
Assert.Equal((ushort)5, report.Car);
Assert.Equal((byte)2, report.State);
Assert.Equal((ulong)42, report.Task);
Assert.Equal((ushort)7, report.Node.Node);
Assert.Equal((ushort)500, report.Node.Distance);
}
[Fact]
public void NodeMessage_ToBytes_FromBytes_RoundTrip()
{
var source = new Fass2NodeMessage
{
Node = 11,
Distance = 1234,
StartStop = 2,
Direction = 3,
Orientation = 64,
Byroad = 2,
Speed = 120,
Lift = 1,
Roll = 2,
Shutdown = 1
};
var roundTrip = Fass2NodeMessage.FromBytes(source.ToBytes());
Assert.Equal(source.Node, roundTrip.Node);
Assert.Equal(source.Distance, roundTrip.Distance);
Assert.Equal(source.StartStop, roundTrip.StartStop);
Assert.Equal(source.Direction, roundTrip.Direction);
Assert.Equal(source.Orientation, roundTrip.Orientation);
Assert.Equal(source.Byroad, roundTrip.Byroad);
Assert.Equal(source.Speed, roundTrip.Speed);
Assert.Equal(source.Lift, roundTrip.Lift);
Assert.Equal(source.Roll, roundTrip.Roll);
Assert.Equal(source.Shutdown, roundTrip.Shutdown);
}
[Fact]
public void SpeedToProtocol_UsesMPerMinTimesTen()
{
Assert.Equal((ushort)120, Fass2TaskBuilder.SpeedToProtocol(12));
Assert.Equal((ushort)120, Fass2TaskBuilder.SpeedToProtocol(0.2));
}
private static byte[] CopyRange(byte[] source, int offset, int length)
{
var copy = new byte[length];
Buffer.BlockCopy(source, offset, copy, 0, length);
return copy;
}
}
}
@@ -0,0 +1,63 @@
using StandardScene.Fass2Simulator;
using StandardScene.Magnetic.Protocol;
using Xunit;
namespace StandardScene.Magnetic.Tests.Protocol
{
/// <summary>
/// 验证 Magnetic 编解码与模拟器 100B 状态帧字段布局一致。
/// </summary>
public sealed class Fass2SimMagneticProtocolCrossTests
{
[Fact]
public void StateFrame_NodeBlockOffset_MatchesMagneticParser()
{
var vehicle = new Fass2SimVehicle(new Fass2SimConfig
{
VehicleCode = 1,
InitialNode = 9,
InitialState = 2
});
vehicle.StartStop = 2;
vehicle.Distance = 777;
vehicle.Speed = 120;
vehicle.Lift = 1;
vehicle.Task = 1001;
var simFrame = Fass2SimProtocol.BuildState(vehicle.Snapshot());
var report = Fass2Protocol.ParseState(simFrame);
Assert.Equal((ushort)1, report.Car);
Assert.Equal((byte)2, report.State);
Assert.Equal((ulong)1001, report.Task);
Assert.Equal((ushort)9, report.Node.Node);
Assert.Equal((ushort)777, report.Node.Distance);
Assert.Equal((byte)2, report.Node.StartStop);
Assert.Equal((ushort)120, report.Node.Speed);
Assert.Equal((byte)1, report.Node.Lift);
}
[Fact]
public void NodeBytes_Are25BytesAligned()
{
var node = new Fass2NodeMessage
{
Node = 4,
Distance = 100,
StartStop = 1,
Byroad = 2,
Speed = 60
};
var bytes = node.ToBytes();
Assert.Equal(25, bytes.Length);
var simNode = Fass2SimNodeMessage.FromBytes(bytes, 0);
Assert.Equal((ushort)4, simNode.Node);
Assert.Equal((ushort)100, simNode.Distance);
Assert.Equal((byte)1, simNode.StartStop);
Assert.Equal((byte)2, simNode.Byroad);
Assert.Equal((ushort)60, simNode.Speed);
}
}
}
@@ -0,0 +1,95 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tests.TestHelpers;
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using Xunit;
namespace StandardScene.Magnetic.Tests.Protocol
{
public sealed class Fass2UdpHubMultiCarTests : IDisposable
{
private readonly int _hubPort;
private readonly TestUdpCar _car1;
private readonly TestUdpCar _car2;
public Fass2UdpHubMultiCarTests()
{
_hubPort = AllocateUdpPort();
_car1 = new TestUdpCar(1, AllocateUdpPort());
_car2 = new TestUdpCar(2, AllocateUdpPort());
Fass2UdpHub.Register(_car1);
Fass2UdpHub.Register(_car2);
Fass2UdpHub.EnsureStarted(_hubPort);
Thread.Sleep(100);
}
[Fact]
public void Hub_RoutesStateToRegisteredCar_AndRepliesWith0x10()
{
SendState(_car1.VehicleCode, 2, 10, 100);
var ack1 = _car1.WaitForAck();
Assert.NotNull(ack1);
Assert.Equal(Fass2Protocol.ControlFrameLength, ack1.Length);
Assert.Equal(Fass2Protocol.CmdStateResponse, ack1[1]);
Assert.Equal(0x01, ack1[2]);
Assert.Single(_car1.Reports);
Assert.Equal((ushort)10, _car1.Reports[0].Node.Node);
Assert.Empty(_car2.Reports);
}
[Fact]
public void Hub_DispatchesMultipleCarsIndependently()
{
SendState(1, 1, 11, 201);
SendState(2, 2, 22, 202);
Assert.NotNull(_car1.WaitForAck());
Assert.NotNull(_car2.WaitForAck());
Assert.Single(_car1.Reports);
Assert.Single(_car2.Reports);
Assert.Equal((ushort)11, _car1.Reports[0].Node.Node);
Assert.Equal((ushort)22, _car2.Reports[0].Node.Node);
Assert.Equal((ulong)201, _car1.Reports[0].Task);
Assert.Equal((ulong)202, _car2.Reports[0].Task);
}
[Fact]
public void Hub_IgnoresUnregisteredCarCode()
{
SendState(99, 2, 30, 0);
Thread.Sleep(300);
Assert.Empty(_car1.Reports);
Assert.Empty(_car2.Reports);
}
public void Dispose()
{
Fass2UdpHub.Unregister(_car1);
Fass2UdpHub.Unregister(_car2);
Fass2UdpHub.Stop();
_car1.Dispose();
_car2.Dispose();
}
private void SendState(ushort carCode, byte state, ushort nodeId, ulong taskId)
{
var frame = Fass2TestFrameBuilder.BuildStateFrame(carCode, state, nodeId, taskId);
using var sender = new UdpClient();
sender.Send(frame, frame.Length, new IPEndPoint(IPAddress.Loopback, _hubPort));
Thread.Sleep(80);
}
private static int AllocateUdpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>StandardScene.Magnetic.Tests</RootNamespace>
<AssemblyName>StandardScene.Magnetic.Tests</AssemblyName>
<IsPackable>false</IsPackable>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StandardScene.Magnetic\StandardScene.Magnetic.csproj" />
<ProjectReference Include="..\StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,164 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using System.Collections.Generic;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2ControlAreaGateTests
{
[Fact]
public void Resolve_WithoutArea_UsesCurrentSite()
{
var info = Fass2ControlAreaGate.Resolve(new Fass2SiteActionData(), 17);
Assert.Equal(new[] { 17 }, info.AreaSiteIds);
Assert.Equal("17", info.AreaId);
Assert.Equal(1, info.Capacity);
Assert.Equal(Fass2TaskBuilder.StartStopControlStart, info.ReleaseStartStop);
}
[Fact]
public void Resolve_ParsesSiteArray_AndReleasePass()
{
var info = Fass2ControlAreaGate.Resolve(new Fass2SiteActionData
{
ControlArea = "[10, 11, 12]",
ControlCapacity = 2,
ControlReleaseStartStop = Fass2TaskBuilder.StartStopPass
}, 8);
Assert.Equal(new[] { 10, 11, 12 }, info.AreaSiteIds);
Assert.Equal("10,11,12", info.AreaId);
Assert.Equal(2, info.Capacity);
Assert.Equal(Fass2TaskBuilder.StartStopPass, info.ReleaseStartStop);
}
[Fact]
public void ParseSiteIds_CommaAndSemicolon()
{
var ids = Fass2ControlAreaGate.ParseSiteIds("10;11|12", 8);
Assert.Equal(new[] { 10, 11, 12 }, ids);
}
[Fact]
public void LimitWindow_StopsAtFirstUnreleasedControlStop()
{
var nodes = new[]
{
Node(1, Fass2TaskBuilder.StartStopPass),
Node(2, Fass2TaskBuilder.StartStopControlStop),
Node(3, Fass2TaskBuilder.StartStopPass),
Node(4, Fass2TaskBuilder.StartStopPass)
};
var count = Fass2ControlAreaGate.LimitWindowCount(nodes, 0, 4, releasedIndex: -1);
Assert.Equal(2, count);
}
[Fact]
public void LimitWindow_AfterRelease_IncludesFollowingNodes()
{
var nodes = new[]
{
Node(1, Fass2TaskBuilder.StartStopPass),
Node(2, Fass2TaskBuilder.StartStopControlStart),
Node(3, Fass2TaskBuilder.StartStopPass)
};
var count = Fass2ControlAreaGate.LimitWindowCount(nodes, 1, 3, releasedIndex: 1);
Assert.Equal(2, count);
}
[Fact]
public void Evaluate_ExcludesSelf_AllowsFirstCar()
{
var area = Area(1, 10, 11);
var occupancies = new List<Fass2ControlOccupancy>
{
new Fass2ControlOccupancy(1, new[] { 10, 11 })
};
var check = Fass2ControlAreaGate.Evaluate(1, area, occupancies);
Assert.True(check.CanRelease);
Assert.Equal(0, check.OthersInArea);
}
[Fact]
public void Evaluate_OtherCarOnListedSite_EqualsCapacity_Blocks()
{
var area = Area(1, 10, 11);
var occupancies = new List<Fass2ControlOccupancy>
{
new Fass2ControlOccupancy(1, new[] { 8 }),
new Fass2ControlOccupancy(2, new[] { 11 })
};
var check = Fass2ControlAreaGate.Evaluate(1, area, occupancies);
Assert.False(check.CanRelease);
Assert.Equal(1, check.OthersInArea);
Assert.Contains("capacity=1", check.Reason);
}
[Fact]
public void Evaluate_OtherCarOutsideList_DoesNotBlock()
{
var area = Area(1, 10, 11);
var occupancies = new List<Fass2ControlOccupancy>
{
new Fass2ControlOccupancy(2, new[] { 99 })
};
var check = Fass2ControlAreaGate.Evaluate(1, area, occupancies);
Assert.True(check.CanRelease);
Assert.Equal(0, check.OthersInArea);
}
[Fact]
public void Evaluate_CapacityTwo_AllowsSecondCar()
{
var area = Area(2, 10, 11);
var occupancies = new List<Fass2ControlOccupancy>
{
new Fass2ControlOccupancy(1, new[] { 10 }),
new Fass2ControlOccupancy(2, new[] { 11 })
};
var check = Fass2ControlAreaGate.Evaluate(2, area, occupancies);
Assert.True(check.CanRelease);
Assert.Equal(1, check.OthersInArea);
}
[Fact]
public void ActionResolver_ControlStop_IsNeverComplete()
{
var expected = Node(5, Fass2TaskBuilder.StartStopControlStop);
var actual = Node(5, Fass2TaskBuilder.StartStopControlStop);
Assert.True(Fass2ActionResolver.RequiresActionWait(expected));
Assert.False(Fass2ActionResolver.IsStationActionComplete(expected, actual, vehicleState: 2));
}
private static Fass2ControlAreaInfo Area(int capacity, params int[] siteIds)
{
return new Fass2ControlAreaInfo
{
AreaSiteIds = siteIds,
AreaId = string.Join(",", siteIds),
Capacity = capacity,
ReleaseStartStop = 11
};
}
private static Fass2NodeMessage Node(ushort id, byte startStop)
{
return new Fass2NodeMessage { Node = id, StartStop = startStop };
}
}
}
@@ -0,0 +1,102 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using System.Collections.Generic;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2ControlStopStateMachineTests
{
[Fact]
public void ControlStop_TruncatesWindow_WaitsOccupancy_ThenReleases()
{
Fass2NodeMessage[] lastWindow = null;
Fass2NodeMessage lastPatch = null;
var canRelease = false;
var nodes = new List<Fass2NodeMessage>
{
new Fass2NodeMessage { Node = 1, StartStop = Fass2TaskBuilder.StartStopPass, Distance = 100 },
new Fass2NodeMessage { Node = 2, StartStop = Fass2TaskBuilder.StartStopControlStop },
new Fass2NodeMessage { Node = 3, StartStop = Fass2TaskBuilder.StartStopPass }
};
var plan = new Fass2TaskPlan
{
StartSiteId = 10,
GoalSiteId = 12,
SiteIds = new List<int>(),
Nodes = nodes,
FieldsSignature = "keep"
};
var machine = new Fass2TaskStateMachine(new Fass2TaskCallbacks
{
BuildPlan = (_, __, ___) => plan,
SendNodes = (window, _) => lastWindow = window,
SendAction = (patch, _) => lastPatch = patch,
SendControl = (_, __) => { },
ResolveNodeId = id => (ushort)id,
AllocateTaskId = () => 1UL,
AllocateActionId = () => 9UL,
CheckControlRelease = _ => new Fass2ControlReleaseCheck
{
CanRelease = canRelease,
AreaId = "DockA",
Capacity = 1,
OthersInArea = canRelease ? 0 : 1,
ReleaseStartStop = Fass2TaskBuilder.StartStopControlStart,
Reason = canRelease ? "area clear" : "area occupied"
}
})
{
StartBeforeMove = false,
LockCount = 4,
ResendIntervalMs = 60_000,
ActionRetryIntervalMs = 0
};
machine.Begin(10, 12);
machine.Tick(Report(1, moving: true, startStop: 1));
Assert.Equal(Fass2TaskPhase.Moving, machine.Context.Phase);
Assert.NotNull(lastWindow);
Assert.Equal(2, lastWindow.Length);
Assert.Equal(Fass2TaskBuilder.StartStopControlStop, lastWindow[1].StartStop);
machine.Tick(Report(2, moving: false, startStop: 12));
Assert.Equal(Fass2TaskPhase.AtStation, machine.Context.Phase);
Assert.Equal(1, machine.Context.CurrentIndex);
lastPatch = null;
machine.Tick(Report(2, moving: false, startStop: 12));
Assert.True(machine.Context.WaitingForRelease);
Assert.Null(lastPatch);
Assert.Equal(-1, machine.Context.ControlReleasedIndex);
canRelease = true;
machine.Tick(Report(2, moving: false, startStop: 12));
Assert.NotNull(lastPatch);
Assert.Equal(Fass2TaskBuilder.StartStopControlStart, lastPatch.StartStop);
Assert.Equal(1, machine.Context.ControlReleasedIndex);
Assert.Equal(Fass2TaskBuilder.StartStopControlStart, nodes[1].StartStop);
Assert.True(lastWindow.Length >= 2);
Assert.Equal((ushort)3, lastWindow[lastWindow.Length - 1].Node);
machine.Tick(Report(2, moving: false, startStop: 11));
Assert.Equal(Fass2TaskPhase.AtStation, machine.Context.Phase);
Assert.Equal(1, machine.Context.CurrentIndex);
}
private static Fass2StateReport Report(ushort node, bool moving, byte startStop)
{
return new Fass2StateReport
{
State = (byte)(moving ? 1 : 2),
Node = new Fass2NodeMessage
{
Node = node,
StartStop = startStop
}
};
}
}
}
@@ -0,0 +1,83 @@
using StandardScene.Magnetic.Tasking;
using System.Collections.Generic;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2ReconnectChainSelectorTests
{
[Fact]
public void PreferGoal_OnRemainingPath_Wins()
{
var matches = new List<Fass2ChainMatch>
{
Match(1, start: 6, target: 10, site: 8, index: 1, remain: new[] { 8, 9, 10 }, prio: 9),
Match(2, start: 1, target: 5, site: 8, index: 3, remain: new[] { 8, 4, 5 }, prio: 1)
};
var ok = Fass2ReconnectChainSelector.TrySelect(matches, preferGoalSiteId: 5, out var selected, out var reason);
Assert.True(ok);
Assert.Equal(5, selected.TargetSiteId);
Assert.Contains("preferGoal=5", reason);
}
[Fact]
public void StartPoint_Beats_Priority_WhenGoalNotOnPath()
{
var matches = new List<Fass2ChainMatch>
{
Match(1, start: 8, target: 12, site: 8, index: 0, remain: new[] { 8, 12 }, prio: 1, atStart: true),
Match(2, start: 6, target: 10, site: 8, index: 1, remain: new[] { 8, 9, 10 }, prio: 9)
};
var ok = Fass2ReconnectChainSelector.TrySelect(matches, preferGoalSiteId: 99, out var selected, out _);
Assert.True(ok);
Assert.Equal(12, selected.TargetSiteId);
Assert.True(selected.IsAtStartPoint);
}
[Fact]
public void Ambiguous_DifferentTargets_SameScore_Fails()
{
var matches = new List<Fass2ChainMatch>
{
Match(1, start: 1, target: 5, site: 3, index: 1, remain: new[] { 3, 4, 5 }, prio: 1),
Match(2, start: 9, target: 7, site: 3, index: 1, remain: new[] { 3, 6, 7 }, prio: 1)
};
var ok = Fass2ReconnectChainSelector.TrySelect(matches, preferGoalSiteId: null, out var selected, out var reason);
Assert.False(ok);
Assert.Null(selected);
Assert.Contains("ambiguous", reason);
}
[Fact]
public void Empty_Fails()
{
var ok = Fass2ReconnectChainSelector.TrySelect(new List<Fass2ChainMatch>(), null, out _, out var reason);
Assert.False(ok);
Assert.Equal("no chain", reason);
}
private static Fass2ChainMatch Match(
int taskId, int start, int target, int site, int index, int[] remain, int prio, bool atStart = false)
{
return new Fass2ChainMatch
{
TaskId = taskId,
TaskPriority = prio,
CurrentSiteId = site,
TargetSiteId = target,
IndexInPath = index,
DistanceToTarget = remain.Length > 0 ? remain.Length - 1 : 0,
IsAtStartPoint = atStart || index == 0,
IsAtEndPoint = remain.Length <= 1,
RemainingPath = remain,
FullPath = remain
};
}
}
}
@@ -0,0 +1,62 @@
using StandardScene.Fass2Simulator;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2SimControlStopTests
{
[Fact]
public void ControlStop_StaysUntilReleasePatch()
{
var config = new Fass2SimConfig
{
InitialNode = 1,
DefaultSegmentDistance = 200,
DefaultSpeed = 1000,
AutoContinueOnPass = true,
AutoCompleteStationActions = false,
ActionDelayMs = 0
};
var (vehicle, motion, actions) = Fass2SimBootstrap.CreateTestStack(config);
vehicle.State = 1;
motion.OnNodesCommand(3001, new[]
{
new Fass2SimNodeMessage { Node = 1, StartStop = 1, Distance = 200 },
new Fass2SimNodeMessage { Node = 2, StartStop = 12 }
});
for (var i = 0; i < 20 && vehicle.Node != 2; i++)
{
motion.Tick(50);
actions.Tick(50);
}
Assert.Equal((ushort)2, vehicle.Node);
Assert.Equal((byte)2, vehicle.State);
Assert.Equal((byte)12, vehicle.StartStop);
Assert.False(actions.CanLeaveStation());
motion.OnNodesCommand(3001, new[]
{
new Fass2SimNodeMessage { Node = 2, StartStop = 11, Distance = 200 },
new Fass2SimNodeMessage { Node = 3, StartStop = 1 }
});
actions.OnActionCommand(9, new Fass2SimNodeMessage
{
Node = 2,
StartStop = 11
});
actions.Tick(10);
motion.OnStationActionsCompleted();
for (var i = 0; i < 30 && vehicle.Node != 3; i++)
{
motion.Tick(50);
actions.Tick(50);
}
Assert.Equal((ushort)3, vehicle.Node);
}
}
}
@@ -0,0 +1,69 @@
using StandardScene.Magnetic.Protocol;
using System;
namespace StandardScene.Magnetic.Tests.TestHelpers
{
internal static class Fass2TestFrameBuilder
{
public static byte[] BuildStateFrame(
ushort car,
byte state,
ushort nodeId,
ulong taskId = 0,
byte command = 0)
{
var frame = new byte[Fass2Protocol.StateFrameLength];
frame[0] = Fass2Protocol.Begin;
frame[1] = command;
WriteUInt16(frame, 2, car);
WriteUInt16(frame, 4, 1200);
WriteUInt16(frame, 6, 800);
frame[28] = 100;
frame[29] = 100;
frame[36] = state;
WriteUInt64(frame, 45, taskId);
var node = new Fass2NodeMessage
{
Node = nodeId,
StartStop = 1,
Distance = 500,
Speed = 120
};
Buffer.BlockCopy(node.ToBytes(), 0, frame, 53, 25);
frame[98] = Fass2Protocol.Xor(frame, 1, 97);
frame[99] = Fass2Protocol.End;
return frame;
}
public static string ToHex(byte[] bytes)
{
if (bytes == null)
{
return string.Empty;
}
var parts = new string[bytes.Length];
for (var i = 0; i < bytes.Length; i++)
{
parts[i] = bytes[i].ToString("X2");
}
return string.Join(" ", parts);
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
{
var bytes = BitConverter.GetBytes(value);
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
}
}
}
@@ -0,0 +1,64 @@
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
namespace StandardScene.Magnetic.Tests.TestHelpers
{
internal sealed class TestUdpCar : IFass2UdpCar, IDisposable
{
private readonly UdpClient _ackListener;
private readonly object _syncRoot = new object();
public TestUdpCar(ushort vehicleCode, int remotePort)
{
VehicleCode = vehicleCode;
RemotePort = remotePort;
_ackListener = new UdpClient(new IPEndPoint(IPAddress.Loopback, remotePort));
_ackListener.Client.ReceiveTimeout = 500;
}
public ushort VehicleCode { get; }
public string RemoteAddress => "127.0.0.1";
public int RemotePort { get; }
public List<Fass2StateReport> Reports { get; } = new List<Fass2StateReport>();
public void OnUdpStateReceived(Fass2StateReport report)
{
lock (_syncRoot)
{
Reports.Add(report);
}
}
public byte[] WaitForAck(int timeoutMs = 2000)
{
var deadline = Environment.TickCount64 + timeoutMs;
while (Environment.TickCount64 < deadline)
{
try
{
var remote = new IPEndPoint(IPAddress.Any, 0);
return _ackListener.Receive(ref remote);
}
catch (SocketException)
{
Thread.Sleep(20);
}
}
return null;
}
public void Dispose()
{
_ackListener?.Close();
_ackListener?.Dispose();
}
}
}
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using SimpleCore;
using SimpleCore.Library;
using SimpleLite;
using SimpleLite.Props;
using SimpleLite.RCS;
using SimpleLite.RCS.CarTypes;
using StandardScene.CarTypes;
using StandardScene.Chained;
using StandardScene.Magnetic.Tasking;
using StandardScene.Model;
namespace StandardScene
{
/// <summary>
/// 磁导航 FASS 2.0 环线任务进程。
/// <para>任务分配/触发/流量逻辑继承 Core <see cref="LoopMission"/>。</para>
/// <para>导航执行由 <see cref="Mag2Car"/> 拦截 Loop 编译脚本(含 <c>Mag2Go</c> + <c>goalSite</c>),
/// 改为单次全程 <c>0xB1</c> 状态机任务。</para>
/// </summary>
[MissionType(Name = "磁导航FASS2环线", editor = typeof(Mag2LoopMission))]
public class Mag2LoopMission : LoopMission
{
[JsonIgnore] public override MissionStatus status { get; set; } = new LoopMissionStatus();
/// <summary>
/// Mag2 空闲时可能尚未 TrafficResetholdingLocks 为空),仍按上报站点/siteID 匹配在站车辆,
/// 否则 AutoLoop 永远找不到车、加不上 goalSite。
/// </summary>
protected override Car FindCarArrivedAtSite(int siteId)
{
var byLock = base.FindCarArrivedAtSite(siteId);
if (byLock != null)
return byLock;
try
{
return SimpleLib.GetAllCars()
.OfType<Mag2Car>()
.FirstOrDefault(car =>
{
if (car?.tags == null || car.tags.Contains("occupied") || !car.tags.Contains("Online"))
return false;
if (car.status?.pendingLocks != null && car.status.pendingLocks.Length > 0)
return false;
if (car.status?.holdingLocks != null && car.status.holdingLocks.Length > 1)
return false;
var physical = car.siteID > 0 ? car.siteID : car.GetLastSite();
return physical == siteId;
});
}
catch
{
return null;
}
}
/// <summary>
/// 分配前尽量补上本站交管锁,保证 SelectCar/GoSite 的 GetLastSite 可用。
/// </summary>
protected override void AssignCarToTarget(Car car, int targetSiteId)
{
if (car is Mag2Car mag2)
{
EnsureMag2HoldingCurrentSite(mag2);
}
base.AssignCarToTarget(car, targetSiteId);
}
/// <summary>
/// 用当前占点匹配全部 LoopTask 展开路径(不过滤 IsViaPoint)。
/// 路径用 BFS,避免 FindRoute 污染交管锁。
/// </summary>
public static List<Fass2ChainMatch> FindChainsContaining(int siteId)
{
var matches = new List<Fass2ChainMatch>();
if (siteId <= 0)
{
return matches;
}
IEnumerable<AbstractLoopMission> missions = null;
try
{
missions = SimpleProject.proj?.Missions?.OfType<AbstractLoopMission>();
}
catch
{
missions = null;
}
if (missions == null)
{
return matches;
}
foreach (var mission in missions)
{
IReadOnlyList<LoopTask> tasks;
try
{
tasks = mission.GetTasks();
}
catch
{
continue;
}
if (tasks == null)
{
continue;
}
foreach (var task in tasks)
{
var match = TryMatchTask(task, siteId);
if (match != null)
{
matches.Add(match);
}
}
}
return matches;
}
public static bool TryResolveBelongingChain(
int siteId,
int? preferGoalSiteId,
out Fass2ChainMatch selected,
out string reason)
{
var matches = FindChainsContaining(siteId);
return Fass2ReconnectChainSelector.TrySelect(matches, preferGoalSiteId, out selected, out reason);
}
private static Fass2ChainMatch TryMatchTask(LoopTask task, int siteId)
{
if (task == null || task.CurrentStationId <= 0 || task.TargetStationId <= 0)
{
return null;
}
List<int> fullPath;
try
{
fullPath = Fass2RouteHelper.GetSitesBetween(task.CurrentStationId, task.TargetStationId, null);
}
catch
{
return null;
}
if (fullPath == null || fullPath.Count == 0)
{
return null;
}
var index = fullPath.IndexOf(siteId);
if (index < 0)
{
return null;
}
var remaining = fullPath.Skip(index).ToList();
return new Fass2ChainMatch
{
TaskId = task.Id,
TaskPriority = task.Priority,
CurrentSiteId = siteId,
TargetSiteId = task.TargetStationId,
IndexInPath = index,
DistanceToTarget = Math.Max(0, fullPath.Count - index - 1),
IsAtStartPoint = index == 0,
IsAtEndPoint = index == fullPath.Count - 1,
FullPath = fullPath,
RemainingPath = remaining
};
}
private static void EnsureMag2HoldingCurrentSite(Mag2Car car)
{
try
{
if (car.status?.holdingLocks != null && car.status.holdingLocks.Length == 1)
return;
var siteId = car.siteID > 0 ? car.siteID : car.GetLastSite();
if (siteId <= 0)
return;
var site = SimpleLib.GetSite(siteId);
if (site == null)
return;
car.TrafficReset(site, true, strict: false);
}
catch (Exception)
{
// 交管失败时仍允许打上 goalSite,后续 GoSite/状态机会再处理
}
}
}
}
@@ -1,20 +0,0 @@
using Newtonsoft.Json;
using SimpleLite.Props;
using SimpleLite.RCS;
using StandardScene.Chained;
namespace StandardScene
{
/// <summary>
/// 磁导航 FASS 2.0 环线任务进程。
/// <para>任务分配/触发/流量逻辑完全继承 Core <see cref="LoopMission"/>,不修改 Core。</para>
/// <para>导航执行由 <see cref="MagFass2Car"/> 拦截 Loop 编译脚本(含 <c>MagFass2Go</c> + <c>goalSite</c>),
/// 改为单次全程 <c>0xB1</c> 状态机任务。</para>
/// <para>场景内可继续使用原 <see cref="LoopMission"/>;本类型仅作磁导航 FASS2 场景标识。</para>
/// </summary>
[MissionType(Name = "磁导航FASS2环线", editor = typeof(MagFass2LoopMission))]
public class MagFass2LoopMission : LoopMission
{
[JsonIgnore] public override MissionStatus status { get; set; } = new LoopMissionStatus();
}
}
@@ -7,9 +7,9 @@ namespace StandardScene.Magnetic
{
/// <summary>
/// scene.mag 平台画像:磁导航场景插件。
/// <para>车型:<see cref="MagCar"/>FASS 1.0 TCP)、<see cref="MagFass2Car"/>FASS 2.0 UDP/任务状态机)。
/// Loop 导航:继续使用 Core <c>LoopMission</c> 或本插件 <c>MagFass2LoopMission</c> 分配 <c>goalSite</c>
/// <see cref="MagFass2Car"/> 拦截编译脚本后一次下发全程 <c>0xB1</c>。</para>
/// <para>车型:<see cref="MagCar"/>FASS 1.0 TCP)、<see cref="Mag2Car"/>FASS 2.0 UDP/任务状态机)。
/// Loop 导航:继续使用 Core <c>LoopMission</c> 或本插件 <c>Mag2LoopMission</c> 分配 <c>goalSite</c>
/// <see cref="Mag2Car"/> 拦截编译脚本后一次下发全程 <c>0xB1</c>。</para>
/// 宿主(SimpleLite)加载本 dll 后反射实例化并 OnActivate / 注册。
/// </summary>
public sealed class MagneticSceneProfile : NavigationProfileBase
@@ -23,12 +23,12 @@ namespace StandardScene.Magnetic
public override IReadOnlyList<Type> CarTypes => new[]
{
typeof(MagCar),
typeof(MagFass2Car),
typeof(Mag2Car),
};
public override void OnActivate(ISceneContext context)
{
context.Log($"{DisplayName} 已激活(车型:MagCar / MagFass2Car");
context.Log($"{DisplayName} 已激活(车型:MagCar / Mag2Car");
}
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StandardScene.Magnetic.Tests")]
@@ -9,7 +9,7 @@ namespace StandardScene.Magnetic.Protocol
/// </summary>
public static class Fass2StateCodec
{
public static byte[] BuildState(Fass2StateReport report, string carType = "MagFass2")
public static byte[] BuildState(Fass2StateReport report, string carType = "Mag2")
{
if (report == null)
{
@@ -227,7 +227,7 @@ namespace StandardScene.Magnetic.Protocol
}
Diagnosis.Post(
$"Fass2UdpHub 收到 Car={carCode} 的状态,但未注册该编号(已注册: {registered})。请检查 MagFass2Car.VehicleCode 与模拟器车号是否一致,并重新启动场景。",
$"Fass2UdpHub 收到 Car={carCode} 的状态,但未注册该编号(已注册: {registered})。请检查 Mag2Car.VehicleCode 与模拟器车号是否一致,并重新启动场景。",
"Fass2UdpHub",
true);
}
@@ -6,7 +6,7 @@
"coreVersion": ">=1.0.0",
"requiresCore": "StandardScene.dll",
"provides": {
"carTypes": [ "MagCar", "MagFass2Car" ],
"missionTypes": [ "EventCarMission", "MagFass2LoopMission" ]
"carTypes": [ "MagCar", "Mag2Car" ],
"missionTypes": [ "EventCarMission", "Mag2LoopMission" ]
}
}
@@ -7,15 +7,15 @@ namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 比对期望节点与上报节点,判定到站与动作是否完成(对齐 backend <c>CarResponseService</c>)。
/// 默认只闭环机构类动作;轨迹字段(Speed/Orientation 等)需显式开启。
/// </summary>
public static class Fass2ActionResolver
{
private static readonly (string Name, Func<Fass2NodeMessage, byte> Get, Action<Fass2NodeMessage, byte> Set)[] ActionFields =
/// <summary>是否在停车站比对 Speed/Orientation/Direction/Byroad 等轨迹字段。</summary>
public static bool VerifyTrajectoryFields { get; set; }
private static readonly (string Name, Func<Fass2NodeMessage, byte> Get, Action<Fass2NodeMessage, byte> Set)[] MechanismFields =
{
("StartStop", n => n.StartStop, (n, v) => n.StartStop = v),
("Direction", n => n.Direction, (n, v) => n.Direction = v),
("Orientation", n => n.Orientation, (n, v) => n.Orientation = v),
("Byroad", n => n.Byroad, (n, v) => n.Byroad = v),
("Obstacle", n => n.Obstacle, (n, v) => n.Obstacle = v),
("Audio", n => n.Audio, (n, v) => n.Audio = v),
("Light", n => n.Light, (n, v) => n.Light = v),
@@ -28,6 +28,13 @@ namespace StandardScene.Magnetic.Tasking
("Shutdown", n => n.Shutdown, (n, v) => n.Shutdown = v)
};
private static readonly (string Name, Func<Fass2NodeMessage, byte> Get, Action<Fass2NodeMessage, byte> Set)[] TrajectoryFields =
{
("Direction", n => n.Direction, (n, v) => n.Direction = v),
("Orientation", n => n.Orientation, (n, v) => n.Orientation = v),
("Byroad", n => n.Byroad, (n, v) => n.Byroad = v)
};
public static bool IsAtNode(Fass2StateReport report, ushort targetNode)
{
return report?.Node != null && report.Node.Node == targetNode;
@@ -35,7 +42,7 @@ namespace StandardScene.Magnetic.Tasking
public static bool IsVehicleMoving(byte vehicleState)
{
return vehicleState == 1;
return vehicleState is 1 or 5;
}
public static bool RequiresActionWait(Fass2NodeMessage expected)
@@ -45,12 +52,28 @@ namespace StandardScene.Magnetic.Tasking
return false;
}
if (expected.StartStop is Fass2TaskBuilder.StartStopStop or Fass2TaskBuilder.StartStopPrecision)
if (expected.StartStop is Fass2TaskBuilder.StartStopStop
or Fass2TaskBuilder.StartStopPrecision
or Fass2TaskBuilder.StartStopControlStop
or Fass2TaskBuilder.StartStopControlStart)
{
return true;
}
foreach (var field in ActionFields)
foreach (var field in MechanismFields)
{
if (field.Get(expected) != 0)
{
return true;
}
}
if (!VerifyTrajectoryFields)
{
return false;
}
foreach (var field in TrajectoryFields)
{
if (field.Get(expected) != 0)
{
@@ -83,12 +106,16 @@ namespace StandardScene.Magnetic.Tasking
return true;
}
// 过站(StartStop=1) 只要求到点且非运行态,不要求 Orientation 等轨迹字段到位。
if (expected.StartStop == Fass2TaskBuilder.StartStopPass)
{
return true;
}
if (expected.StartStop == Fass2TaskBuilder.StartStopControlStop)
{
return false;
}
return ListPendingFields(expected, actual).Count == 0;
}
@@ -105,18 +132,25 @@ namespace StandardScene.Magnetic.Tasking
pending.Add("StartStop");
}
if (expected.Speed != 0 && actual.Speed != expected.Speed)
if (VerifyTrajectoryFields)
{
pending.Add("Speed");
}
foreach (var field in ActionFields)
{
if (field.Name == "StartStop")
if (expected.Speed != 0 && actual.Speed != expected.Speed)
{
continue;
pending.Add("Speed");
}
foreach (var field in TrajectoryFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && field.Get(actual) != expectedValue)
{
pending.Add(field.Name);
}
}
}
foreach (var field in MechanismFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && field.Get(actual) != expectedValue)
{
@@ -143,19 +177,27 @@ namespace StandardScene.Magnetic.Tasking
hasPatch = true;
}
if (expected.Speed != 0 && (actual == null || actual.Speed != expected.Speed))
if (VerifyTrajectoryFields)
{
patch.Speed = expected.Speed;
hasPatch = true;
}
foreach (var field in ActionFields)
{
if (field.Name == "StartStop")
if (expected.Speed != 0 && (actual == null || actual.Speed != expected.Speed))
{
continue;
patch.Speed = expected.Speed;
hasPatch = true;
}
foreach (var field in TrajectoryFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && (actual == null || field.Get(actual) != expectedValue))
{
field.Set(patch, expectedValue);
hasPatch = true;
}
}
}
foreach (var field in MechanismFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && (actual == null || field.Get(actual) != expectedValue))
{
@@ -0,0 +1,214 @@
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Globalization;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 管控停止(StartStop=12)按车放行:地图站点保持 12,仅改本车任务报文。
/// </summary>
public sealed class Fass2ControlAreaInfo
{
public int SiteId { get; set; }
public string AreaId { get; set; } = string.Empty;
public int[] AreaSiteIds { get; set; } = Array.Empty<int>();
public int Capacity { get; set; } = 1;
public byte ReleaseStartStop { get; set; } = Fass2TaskBuilder.StartStopControlStart;
}
public sealed class Fass2ControlOccupancy
{
public Fass2ControlOccupancy(int carId, IReadOnlyList<int> occupiedSiteIds)
{
CarId = carId;
OccupiedSiteIds = occupiedSiteIds ?? Array.Empty<int>();
}
public int CarId { get; }
public IReadOnlyList<int> OccupiedSiteIds { get; }
}
public sealed class Fass2ControlReleaseCheck
{
public bool CanRelease { get; set; }
public string AreaId { get; set; } = string.Empty;
public int Capacity { get; set; } = 1;
public int OthersInArea { get; set; }
public byte ReleaseStartStop { get; set; } = Fass2TaskBuilder.StartStopControlStart;
public string Reason { get; set; } = string.Empty;
}
public static class Fass2ControlAreaGate
{
private static readonly char[] SiteIdSeparators = { ',', ';', '|', ' ', '\t', '[', ']', '(', ')', '{', '}' };
public static bool IsControlStop(byte startStop)
{
return startStop == Fass2TaskBuilder.StartStopControlStop;
}
public static bool AllowsLeave(byte startStop)
{
return startStop == Fass2TaskBuilder.StartStopPass
|| startStop == Fass2TaskBuilder.StartStopControlStart;
}
public static Fass2ControlAreaInfo Resolve(Fass2SiteActionData data, int siteId)
{
data ??= new Fass2SiteActionData();
var areaSites = ParseSiteIds(data.ControlArea, siteId);
var capacity = data.ControlCapacity.GetValueOrDefault(1);
if (capacity < 1)
{
capacity = 1;
}
var release = data.ControlReleaseStartStop ?? Fass2TaskBuilder.StartStopControlStart;
if (release != Fass2TaskBuilder.StartStopPass &&
release != Fass2TaskBuilder.StartStopControlStart)
{
release = Fass2TaskBuilder.StartStopControlStart;
}
return new Fass2ControlAreaInfo
{
SiteId = siteId,
AreaSiteIds = areaSites,
AreaId = string.Join(",", areaSites),
Capacity = capacity,
ReleaseStartStop = release
};
}
public static int[] ParseSiteIds(string text, int fallbackSiteId)
{
var ids = new List<int>();
if (!string.IsNullOrWhiteSpace(text))
{
var parts = text.Split(SiteIdSeparators, StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
if (int.TryParse(part.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) &&
id > 0 && !ids.Contains(id))
{
ids.Add(id);
}
}
}
if (ids.Count == 0 && fallbackSiteId > 0)
{
ids.Add(fallbackSiteId);
}
return ids.ToArray();
}
public static int LimitWindowCount(
IReadOnlyList<Fass2NodeMessage> nodes,
int startIndex,
int count,
int releasedIndex)
{
if (nodes == null || count <= 0 || startIndex < 0 || startIndex >= nodes.Count)
{
return 0;
}
var limited = Math.Min(count, nodes.Count - startIndex);
for (var i = 0; i < limited; i++)
{
var node = nodes[startIndex + i];
if (node != null && IsControlStop(node.StartStop) && releasedIndex != startIndex + i)
{
return i + 1;
}
}
return limited;
}
public static Fass2ControlReleaseCheck Evaluate(
int selfCarId,
Fass2ControlAreaInfo area,
IReadOnlyList<Fass2ControlOccupancy> occupancies)
{
area ??= new Fass2ControlAreaInfo();
if (area.AreaSiteIds == null || area.AreaSiteIds.Length == 0)
{
area.AreaSiteIds = area.SiteId > 0 ? new[] { area.SiteId } : Array.Empty<int>();
}
if (string.IsNullOrEmpty(area.AreaId))
{
area.AreaId = string.Join(",", area.AreaSiteIds);
}
if (area.Capacity < 1)
{
area.Capacity = 1;
}
var others = 0;
if (occupancies != null)
{
foreach (var occupancy in occupancies)
{
if (occupancy == null || occupancy.CarId == selfCarId)
{
continue;
}
if (OccupiesListedSites(occupancy.OccupiedSiteIds, area.AreaSiteIds))
{
others++;
}
}
}
var canRelease = others < area.Capacity;
return new Fass2ControlReleaseCheck
{
CanRelease = canRelease,
AreaId = area.AreaId,
Capacity = area.Capacity,
OthersInArea = others,
ReleaseStartStop = area.ReleaseStartStop == 0
? Fass2TaskBuilder.StartStopControlStart
: area.ReleaseStartStop,
Reason = canRelease
? "area clear"
: $"area occupied cars={others}, capacity={area.Capacity}"
};
}
public static bool OccupiesListedSites(IReadOnlyList<int> occupiedSiteIds, IReadOnlyList<int> areaSiteIds)
{
if (occupiedSiteIds == null || occupiedSiteIds.Count == 0 ||
areaSiteIds == null || areaSiteIds.Count == 0)
{
return false;
}
for (var i = 0; i < occupiedSiteIds.Count; i++)
{
var occupied = occupiedSiteIds[i];
if (occupied <= 0)
{
continue;
}
for (var j = 0; j < areaSiteIds.Count; j++)
{
if (areaSiteIds[j] == occupied)
{
return true;
}
}
}
return false;
}
}
}
@@ -0,0 +1,79 @@
using SimpleCore;
using SimpleCore.PropType;
using SimpleLite.RCS.CarTypes;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 按 holdingLocks + 物理 siteID 统计管控站点列表上的他车占用。
/// </summary>
internal static class Fass2ControlAreaOccupancy
{
public static Fass2ControlReleaseCheck Check(Car self, int controlSiteId)
{
var site = SimpleLib.GetSite(controlSiteId);
var area = Fass2ControlAreaGate.Resolve(Fass2SiteFieldReader.Read(site), controlSiteId);
var occupancies = new List<Fass2ControlOccupancy>();
IEnumerable<AbstractCar> cars;
try
{
cars = SimpleLib.GetAllCars();
}
catch (Exception ex)
{
return new Fass2ControlReleaseCheck
{
CanRelease = false,
AreaId = area.AreaId,
Capacity = area.Capacity,
ReleaseStartStop = area.ReleaseStartStop,
Reason = "car list unavailable: " + ex.Message
};
}
if (cars != null)
{
foreach (var other in cars.OfType<Car>())
{
if (other == null)
{
continue;
}
occupancies.Add(new Fass2ControlOccupancy(other.id, CollectOccupiedSiteIds(other)));
}
}
return Fass2ControlAreaGate.Evaluate(self?.id ?? 0, area, occupancies);
}
private static IReadOnlyList<int> CollectOccupiedSiteIds(Car car)
{
var ids = new List<int>();
if (car.siteID > 0)
{
ids.Add(car.siteID);
}
var holding = car.status?.holdingLocks;
if (holding == null)
{
return ids;
}
foreach (var lockId in holding)
{
if (lockId > 0 && !ids.Contains(lockId))
{
ids.Add(lockId);
}
}
return ids;
}
}
}
@@ -5,7 +5,7 @@ namespace StandardScene.Magnetic.Tasking
{
internal static class Fass2PathFinder
{
public static List<int> GetSitesBetween(int startSiteId, int endSiteId)
public static List<int> GetSitesBetweenBfs(int startSiteId, int endSiteId)
{
if (startSiteId == endSiteId)
{
@@ -0,0 +1,127 @@
using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 断线重连后,用当前占点匹配 tasklist 链路的结果。
/// </summary>
public sealed class Fass2ChainMatch
{
public int TaskId { get; set; }
public int TaskPriority { get; set; }
public int CurrentSiteId { get; set; }
public int TargetSiteId { get; set; }
public int IndexInPath { get; set; }
public int DistanceToTarget { get; set; }
public bool IsAtStartPoint { get; set; }
public bool IsAtEndPoint { get; set; }
public IReadOnlyList<int> FullPath { get; set; } = new List<int>();
public IReadOnlyList<int> RemainingPath { get; set; } = new List<int>();
public override string ToString()
{
var position = IsAtStartPoint ? "start" : (IsAtEndPoint ? "end" : "mid");
return $"task={TaskId} {CurrentSiteId}->{TargetSiteId} pos={position} remain={DistanceToTarget} prio={TaskPriority}";
}
}
/// <summary>
/// 一站多链路时的固定裁决:原 goal 仍在剩余路径 → 起点 → 优先级 → 距目标更近。
/// </summary>
public static class Fass2ReconnectChainSelector
{
public static bool TrySelect(
IReadOnlyList<Fass2ChainMatch> matches,
int? preferGoalSiteId,
out Fass2ChainMatch selected,
out string reason)
{
selected = null;
reason = "no chain";
if (matches == null || matches.Count == 0)
{
return false;
}
var candidates = matches.Where(m => m != null && m.TargetSiteId > 0).ToList();
if (candidates.Count == 0)
{
return false;
}
if (preferGoalSiteId != null && preferGoalSiteId.Value > 0)
{
var keepGoal = candidates
.Where(m => RemainingContains(m, preferGoalSiteId.Value))
.ToList();
if (keepGoal.Count == 1)
{
selected = keepGoal[0];
reason = $"preferGoal={preferGoalSiteId.Value}";
return true;
}
if (keepGoal.Count > 1)
{
candidates = keepGoal;
reason = $"preferGoal={preferGoalSiteId.Value} narrowed={keepGoal.Count}";
}
}
var ranked = candidates
.OrderByDescending(m => m.IsAtStartPoint ? 1 : 0)
.ThenByDescending(m => m.TaskPriority)
.ThenBy(m => m.DistanceToTarget)
.ThenBy(m => m.TaskId)
.ToList();
if (ranked.Count == 0)
{
return false;
}
var best = ranked[0];
if (ranked.Count > 1)
{
var second = ranked[1];
var tied = best.IsAtStartPoint == second.IsAtStartPoint &&
best.TaskPriority == second.TaskPriority &&
best.DistanceToTarget == second.DistanceToTarget;
if (tied && best.TargetSiteId != second.TargetSiteId)
{
reason = $"ambiguous {best} vs {second}";
return false;
}
}
selected = best;
if (string.IsNullOrEmpty(reason) || reason == "no chain")
{
reason = $"selected {best}";
}
else
{
reason = $"{reason}; selected {best}";
}
return true;
}
private static bool RemainingContains(Fass2ChainMatch match, int siteId)
{
if (match.RemainingPath != null)
{
for (var i = 0; i < match.RemainingPath.Count; i++)
{
if (match.RemainingPath[i] == siteId)
{
return true;
}
}
}
return match.TargetSiteId == siteId;
}
}
}
@@ -0,0 +1,210 @@
using SimpleCore;
using SimpleCore.Compiler;
using SimpleCore.Library;
using SimpleCore.PropType;
using System;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 从站点序列构建 <see cref="SegmentPlan"/>,供交管 Forecast 与任务下发共用同一路径。
/// </summary>
internal static class Fass2RouteHelper
{
public static List<int> GetSitesBetween(int startSiteId, int endSiteId, AbstractCar car)
{
if (startSiteId == endSiteId)
{
return new List<int> { startSiteId };
}
if (car != null)
{
var startSite = SimpleLib.GetSite(startSiteId);
var endSite = SimpleLib.GetSite(endSiteId);
if (startSite != null && endSite != null)
{
var plan = new SegmentPlan { usingCar = car };
plan.fields["allow_destination_on_route"] = "true";
plan.FindRoute(startSite, endSite);
var sites = ExtractSiteIds(plan);
if (sites.Count > 0)
{
return sites;
}
}
}
return Fass2PathFinder.GetSitesBetweenBfs(startSiteId, endSiteId);
}
public static void ForecastTrafficSequence(AbstractCar car, IReadOnlyList<int> siteIds, int fromIndex)
{
if (car == null || siteIds == null || siteIds.Count == 0)
{
throw new ArgumentException("traffic forecast requires car and siteIds");
}
fromIndex = Math.Max(0, Math.Min(fromIndex, siteIds.Count - 1));
if (fromIndex >= siteIds.Count - 1)
{
return;
}
var routeSiteIds = new List<int>();
for (var i = fromIndex; i < siteIds.Count; i++)
{
routeSiteIds.Add(siteIds[i]);
}
try
{
var plan = BuildSegmentPlan(car, routeSiteIds);
var program = plan.Compile("fass2-traffic", false);
program.Forecast();
}
catch (Exception ex) when (IsRecoverableForecastFailure(ex))
{
// 双车 Multi-car search Exhausted 时降级:只建 pending/seqScope,交由 TryLock 现场占点
ApplyManualTrafficSequence(car, routeSiteIds);
Diagnosis.Post(
$"Mag2Car forecast fallback manual sequence car={car.id}: {ex.Message}",
"Mag2Car",
true);
}
}
/// <summary>
/// 不走 SimpleCore 多车 Forecast,直接写入交管序列(持锁起点已由 TrafficReset 保证)。
/// </summary>
public static void ApplyManualTrafficSequence(AbstractCar car, IReadOnlyList<int> routeSiteIds)
{
if (car == null || routeSiteIds == null || routeSiteIds.Count == 0)
{
return;
}
var scope = new int[routeSiteIds.Count];
for (var i = 0; i < routeSiteIds.Count; i++)
{
scope[i] = routeSiteIds[i];
}
var pending = new int[Math.Max(0, scope.Length - 1)];
for (var i = 0; i < pending.Length; i++)
{
pending[i] = scope[i + 1];
}
car.status.seqScope = scope;
car.status.pendingLocks = pending;
car.status.seqPtr = scope.Length > 1 ? 1 : 0;
car.status.escape = Array.Empty<int>();
}
private static bool IsRecoverableForecastFailure(Exception ex)
{
for (var cur = ex; cur != null; cur = cur.InnerException)
{
var msg = cur.Message ?? string.Empty;
if (msg.IndexOf("Multi-car traffic search", StringComparison.OrdinalIgnoreCase) >= 0 ||
msg.IndexOf("Exhausted", StringComparison.OrdinalIgnoreCase) >= 0 ||
msg.IndexOf("BadForecast", StringComparison.OrdinalIgnoreCase) >= 0 ||
cur.GetType().Name.IndexOf("Forecast", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
public static SegmentPlan BuildSegmentPlan(AbstractCar car, IReadOnlyList<int> siteIds)
{
if (car == null || siteIds == null || siteIds.Count == 0)
{
throw new ArgumentException("segment plan requires car and siteIds");
}
var plan = new SegmentPlan { usingCar = car };
plan.fields["allow_destination_on_route"] = "true";
var firstSite = SimpleLib.GetSite(siteIds[0]);
if (firstSite == null)
{
throw new InvalidOperationException($"site {siteIds[0]} not found");
}
plan.segments.Add(firstSite);
for (var i = 0; i < siteIds.Count - 1; i++)
{
var dstSite = SimpleLib.GetSite(siteIds[i + 1]);
if (dstSite == null)
{
throw new InvalidOperationException($"site {siteIds[i + 1]} not found");
}
var track = FindTrack(siteIds[i], siteIds[i + 1]);
if (track == null)
{
throw new InvalidOperationException($"no track from site {siteIds[i]} to {siteIds[i + 1]}");
}
plan.segments.Add(track);
plan.segments.Add(dstSite);
}
return plan;
}
public static List<int> ExtractSiteIds(SegmentPlan plan)
{
var sites = new List<int>();
if (plan?.segments == null)
{
return sites;
}
foreach (var segment in plan.segments)
{
if (segment is Site site)
{
sites.Add(site.id);
}
}
return sites;
}
public static Track FindTrack(int fromSiteId, int toSiteId)
{
foreach (var track in SimpleLib.GetAllTracks())
{
if (track.direction == 0)
{
if ((track.siteA == fromSiteId && track.siteB == toSiteId) ||
(track.siteB == fromSiteId && track.siteA == toSiteId))
{
return track;
}
continue;
}
if (track.direction == 1 && track.siteA == fromSiteId && track.siteB == toSiteId)
{
return track;
}
if (track.direction == 2 && track.siteB == fromSiteId && track.siteA == toSiteId)
{
return track;
}
}
return null;
}
}
}
@@ -31,6 +31,15 @@ namespace StandardScene.Magnetic.Tasking
data.Shutdown = TryReadByte(site.fields, Fass2SiteFields.Shutdown);
data.PrecisionStop = TryReadBool(site.fields, Fass2SiteFields.PrecisionStop);
data.WaitMode = TryReadString(site.fields, Fass2SiteFields.WaitMode);
data.ControlArea = TryReadString(site.fields, Fass2SiteFields.ControlArea);
data.ControlCapacity = TryReadInt(site.fields, Fass2SiteFields.ControlCapacity);
data.ControlReleaseStartStop = TryReadByte(site.fields, Fass2SiteFields.ControlReleaseStartStop);
if (!data.ControlCapacity.HasValue)
{
TryFillRegionCapacityFallback(site.fields, data);
}
return data;
}
@@ -99,5 +108,40 @@ namespace StandardScene.Magnetic.Tasking
{
return fields.TryGetValue(key, out var text) ? text : null;
}
private static int? TryReadInt(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
{
return null;
}
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
return value;
}
return null;
}
private static void TryFillRegionCapacityFallback(Dictionary<string, string> fields, Fass2SiteActionData data)
{
foreach (var pair in fields)
{
if (string.IsNullOrEmpty(pair.Key) ||
!pair.Key.StartsWith("Region", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (int.TryParse(pair.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var maxCount) &&
maxCount > 0)
{
data.ControlCapacity = maxCount;
}
return;
}
}
}
}
@@ -23,6 +23,9 @@ namespace StandardScene.Magnetic.Tasking
public const string Shutdown = "Fass2_Shutdown";
public const string PrecisionStop = "Fass2_PrecisionStop";
public const string WaitMode = "Fass2_WaitMode";
public const string ControlArea = "Fass2_ControlArea";
public const string ControlCapacity = "Fass2_ControlCapacity";
public const string ControlReleaseStartStop = "Fass2_ControlReleaseStartStop";
}
/// <summary>
@@ -46,5 +49,8 @@ namespace StandardScene.Magnetic.Tasking
public byte? Shutdown { get; set; }
public bool PrecisionStop { get; set; }
public string WaitMode { get; set; }
public string ControlArea { get; set; }
public int? ControlCapacity { get; set; }
public byte? ControlReleaseStartStop { get; set; }
}
}
@@ -3,22 +3,23 @@ using SimpleCore.PropType;
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 将站点路径与站点/边 fields 合并为 FASS 2.0 <c>0xB1</c> 节点序列。
/// 对齐 backend <c>CarRequestService.GetSendNodes</c> 的组包规则
/// 对齐 backend <c>CarRequestService.GetSendNodes</c>:边属性挂在节点<b>出边</b>上
/// </summary>
public static class Fass2TaskBuilder
{
public const byte StartStopPass = 1;
public const byte StartStopStop = 2;
public const byte StartStopControlStart = 11;
public const byte StartStopControlStop = 12;
public const byte StartStopPrecision = 22;
public static Fass2TaskPlan BuildPath(int startSiteId, int goalSiteId, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId)
Func<int, ushort> resolveNodeId, AbstractCar routingCar = null)
{
if (resolveNodeId == null)
{
@@ -26,7 +27,7 @@ namespace StandardScene.Magnetic.Tasking
}
options ??= new Fass2TaskBuildOptions();
var siteIds = Fass2PathFinder.GetSitesBetween(startSiteId, goalSiteId);
var siteIds = Fass2RouteHelper.GetSitesBetween(startSiteId, goalSiteId, routingCar);
if (siteIds.Count == 0)
{
throw new InvalidOperationException($"no path from site {startSiteId} to {goalSiteId}");
@@ -45,9 +46,17 @@ namespace StandardScene.Magnetic.Tasking
}
public static Fass2NodeMessage[] BuildSegment(int srcSiteId, int dstSiteId, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId)
Func<int, ushort> resolveNodeId, AbstractCar routingCar = null)
{
return BuildPath(srcSiteId, dstSiteId, options, resolveNodeId).Nodes.ToArray();
var plan = BuildPath(srcSiteId, dstSiteId, options, resolveNodeId, routingCar);
var nodes = plan.Nodes;
var array = new Fass2NodeMessage[nodes.Count];
for (var i = 0; i < nodes.Count; i++)
{
array[i] = nodes[i];
}
return array;
}
public static IReadOnlyList<Fass2NodeMessage[]> SplitBatches(IReadOnlyList<Fass2NodeMessage> nodes, int maxPerFrame = 10)
@@ -88,14 +97,15 @@ namespace StandardScene.Magnetic.Tasking
var site = SimpleLib.GetSite(siteId);
var siteAction = Fass2SiteFieldReader.Read(site);
var isLast = i == siteIds.Count - 1;
var prevSiteId = i > 0 ? siteIds[i - 1] : siteId;
var nextSiteId = isLast ? -1 : siteIds[i + 1];
Fass2TrackMotionData trackMotion = null;
Site prevSite = null;
if (i > 0)
Site currentSite = site;
Site nextSite = null;
if (!isLast)
{
prevSite = SimpleLib.GetSite(prevSiteId);
var track = FindTrack(prevSiteId, siteId);
nextSite = SimpleLib.GetSite(nextSiteId);
var track = Fass2RouteHelper.FindTrack(siteId, nextSiteId);
trackMotion = Fass2TrackFieldReader.Read(track);
}
@@ -103,9 +113,11 @@ namespace StandardScene.Magnetic.Tasking
{
Node = resolveNodeId(siteId),
StartStop = ResolveStartStop(siteAction, isLast),
Distance = i > 0 ? ComputeEdgeDistance(prevSite, site) : (ushort)0,
Speed = SpeedToProtocol(trackMotion?.Speed ?? options.DefaultSpeed, options.CarSpeed),
Orientation = ResolveOrientation(prevSite, site, trackMotion),
Distance = !isLast ? ComputeEdgeDistance(currentSite, nextSite) : (ushort)0,
Speed = !isLast
? SpeedToProtocol(trackMotion?.Speed ?? options.DefaultSpeed)
: (ushort)0,
Orientation = !isLast ? ResolveOrientation(currentSite, nextSite, trackMotion) : (byte)0,
Byroad = siteAction.Byroad ?? trackMotion?.Byroad ?? 0,
Direction = siteAction.Direction ?? trackMotion?.Direction ?? 0,
Lift = siteAction.Lift ?? 0,
@@ -185,42 +197,17 @@ namespace StandardScene.Magnetic.Tasking
return (ushort)Math.Min(ushort.MaxValue, Math.Max(1, Math.Round(length)));
}
private static ushort SpeedToProtocol(double trackSpeed, double carSpeed)
/// <summary>
/// 协议速度单位 0.1 m/min;地图 Speed 默认 m/min,≤1 视为旧版 m/s 兼容。
/// </summary>
public static ushort SpeedToProtocol(double speed)
{
var value = trackSpeed <= 0 ? carSpeed : trackSpeed;
var protocolSpeed = value <= 1
? (int)Math.Round(value * 600)
: (int)Math.Round(value * 10);
return (ushort)Math.Max(1, Math.Min(10000, protocolSpeed));
}
private static Track FindTrack(int fromSiteId, int toSiteId)
{
foreach (var track in SimpleLib.GetAllTracks())
if (speed > 0 && speed <= 1)
{
if (track.direction == 0)
{
if ((track.siteA == fromSiteId && track.siteB == toSiteId) ||
(track.siteB == fromSiteId && track.siteA == toSiteId))
{
return track;
}
continue;
}
if (track.direction == 1 && track.siteA == fromSiteId && track.siteB == toSiteId)
{
return track;
}
if (track.direction == 2 && track.siteB == fromSiteId && track.siteA == toSiteId)
{
return track;
}
speed *= 60;
}
return null;
return (ushort)Math.Max(1, Math.Min(10000, Math.Round(speed * 10)));
}
}
}
@@ -36,6 +36,18 @@ namespace StandardScene.Magnetic.Tasking
public DateTime LastDispatchAt { get; set; } = DateTime.MinValue;
public ulong LastActionId { get; set; }
public DateTime LastActionSentAt { get; set; } = DateTime.MinValue;
/// <summary>交管窗口未齐、正在等锁;此期间不消耗移动超时预算。</summary>
public bool WaitingForTraffic { get; set; }
/// <summary>停在管控停止点等待按车放行;此期间不消耗移动超时预算。</summary>
public bool WaitingForRelease { get; set; }
/// <summary>本任务已对哪个路径下标发过放行报文;-1 表示尚未放行。</summary>
public int ControlReleasedIndex { get; set; } = -1;
/// <summary>最近一次规划的站点链路,供断线重连判断是否仍在原路径上。</summary>
public int[] RouteSiteIds { get; set; } = Array.Empty<int>();
}
public static class Fass2TaskPersistence
@@ -47,6 +59,7 @@ namespace StandardScene.Magnetic.Tasking
public const string TagTaskId = "Fass2Task_TaskId";
public const string TagFieldsSignature = "Fass2Task_FieldsSig";
public const string TagDefaultSpeed = "Fass2Task_DefaultSpeed";
public const string TagRouteSites = "Fass2Task_RouteSites";
public static void Save(TagSet tags, Fass2TaskContext context)
{
@@ -62,6 +75,7 @@ namespace StandardScene.Magnetic.Tasking
SetTag(tags, TagTaskId, context.TaskId.ToString());
SetTag(tags, TagFieldsSignature, context.FieldsSignature ?? string.Empty);
SetTag(tags, TagDefaultSpeed, context.DefaultSpeed.ToString(System.Globalization.CultureInfo.InvariantCulture));
SetTag(tags, TagRouteSites, FormatIntList(context.RouteSiteIds));
}
public static bool TryLoad(TagSet tags, out Fass2TaskContext context)
@@ -93,6 +107,7 @@ namespace StandardScene.Magnetic.Tasking
System.Globalization.CultureInfo.InvariantCulture,
out var defaultSpeed);
tags.TryGetValue(TagRouteSites, out var routeSitesText);
context = new Fass2TaskContext
{
Phase = phase,
@@ -101,7 +116,8 @@ namespace StandardScene.Magnetic.Tasking
CurrentIndex = currentIndex,
TaskId = taskId,
FieldsSignature = fieldsSignature ?? string.Empty,
DefaultSpeed = defaultSpeed
DefaultSpeed = defaultSpeed,
RouteSiteIds = ParseIntList(routeSitesText)
};
return true;
}
@@ -120,6 +136,7 @@ namespace StandardScene.Magnetic.Tasking
RemoveTag(tags, TagTaskId);
RemoveTag(tags, TagFieldsSignature);
RemoveTag(tags, TagDefaultSpeed);
RemoveTag(tags, TagRouteSites);
}
private static void SetTag(TagSet tags, string key, string value)
@@ -145,5 +162,30 @@ namespace StandardScene.Magnetic.Tasking
value = 0;
return tags.TryGetValue(key, out var text) && int.TryParse(text, out value);
}
private static string FormatIntList(int[] values)
{
return values == null || values.Length == 0 ? string.Empty : string.Join(",", values);
}
private static int[] ParseIntList(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return Array.Empty<int>();
}
var parts = text.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
var ids = new List<int>(parts.Length);
foreach (var part in parts)
{
if (int.TryParse(part.Trim(), out var id) && id > 0)
{
ids.Add(id);
}
}
return ids.ToArray();
}
}
}
@@ -6,7 +6,7 @@ namespace StandardScene.Magnetic.Tasking
public sealed class Fass2TaskBuildOptions
{
public bool UseTagValueAsNode { get; set; }
public double DefaultSpeed { get; set; } = 0.2;
public double DefaultSpeed { get; set; } = 12;
public double CarSpeed { get; set; } = 1;
public int MaxNodesPerFrame { get; set; } = 10;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
using SimpleCore;
using SimpleCore.Compiler;
using SimpleCore.PropType;
using SimpleCore.Traffic;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 方案 B′:用任务下发的 siteIds 经 SegmentPlan.Forecast 建立 seqScope/pendingLocks,再段级 TryLock/Leave。
/// </summary>
internal sealed class Fass2TrafficLocker
{
private readonly AbstractCar _car;
private readonly Action<string> _log;
public Fass2TrafficLocker(AbstractCar car, Action<string> log)
{
_car = car ?? throw new ArgumentNullException(nameof(car));
_log = log ?? (_ => { });
}
public void PrepareSequence(IReadOnlyList<int> siteIds, int fromIndex)
{
if (siteIds == null || siteIds.Count == 0)
{
throw new InvalidOperationException("traffic plan siteIds is empty");
}
fromIndex = Math.Max(0, Math.Min(fromIndex, siteIds.Count - 1));
var startSiteId = siteIds[fromIndex];
var startSite = SimpleLib.GetSite(startSiteId);
if (startSite == null)
{
throw new InvalidOperationException($"traffic start site invalid: {startSiteId}");
}
// TrafficReset 会 programs.Clear() 把 now 置空;TryLock 在真正占点前要求 programs.now != null
// 否则抛 Program obsoleted。环线拦截路径仍处在 move 脚本的 actualSendScript 中,需保留/重建锚点。
var keepProgram = _car.status.programs.now;
_log($"prepare reset start={startSiteId}, goal={siteIds[siteIds.Count - 1]}, fromIndex={fromIndex}, route=[{string.Join(",", siteIds)}]");
try
{
_car.TrafficReset(startSite, makeAvailable: true, strict: false);
}
catch (Exception ex) when (IsStartOccupiedByOtherCar(ex))
{
// 起点被他车占用时不能 Reset;若本车已持有该站则跳过 Reset 继续建序,否则交给上层等待。
if (Array.IndexOf(_car.status.holdingLocks, startSiteId) < 0)
{
throw new InvalidOperationException(
$"traffic start site {startSiteId} occupied by another car", ex);
}
_log($"prepare reset skipped, already holding start={startSiteId}, ex={ex.Message}");
}
EnsureProgramAnchor(keepProgram);
if (fromIndex >= siteIds.Count - 1)
{
LogTrafficState("prepare single-site");
return;
}
Fass2RouteHelper.ForecastTrafficSequence(_car, siteIds, fromIndex);
LogTrafficState("prepare forecast ok");
}
public void RebaseFrom(IReadOnlyList<int> siteIds, int fromIndex, int windowSize)
{
PrepareSequence(siteIds, fromIndex);
EnsureWindowLocked(siteIds, fromIndex, Math.Max(1, windowSize));
}
private static bool IsStartOccupiedByOtherCar(Exception ex)
{
for (var cur = ex; cur != null; cur = cur.InnerException)
{
var msg = cur.Message ?? string.Empty;
if (msg.IndexOf("already locks it", StringComparison.OrdinalIgnoreCase) >= 0 ||
msg.IndexOf("unavailable site", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
public bool TryLockNext(int siteId)
{
if (!_car.status.usage.Get().scheduling)
{
throw new InvalidOperationException("abandoned");
}
if (Array.IndexOf(_car.status.holdingLocks, siteId) >= 0)
{
return true;
}
if (_car.status.pendingLocks.Length > 0 && _car.status.pendingLocks[0] != siteId)
{
// 窗口重入时可能再次请求已锁过的更早站点;交给 EnsureWindowLocked 跳过,避免 UDP 线程抛异常。
_log($"lock skip site={siteId}, expect pending0={_car.status.pendingLocks[0]}, holding=[{FormatInts(_car.status.holdingLocks)}]");
return false;
}
EnsureProgramAnchor(keepProgram: null);
if (TrafficControl.TryLock(_car, siteId))
{
_log($"lock ok site={siteId}, holding=[{FormatInts(_car.status.holdingLocks)}]");
return true;
}
_log($"lock wait site={siteId}, blockedBy={FormatBlocked()}, TCStat={FormatTcStat()}");
return false;
}
public void LeavePassed(int siteId)
{
if (_car.status.holdingLocks.Length <= 1)
{
_log($"leave skip site={siteId}, holding count={_car.status.holdingLocks.Length}");
return;
}
if (Array.IndexOf(_car.status.holdingLocks, siteId) < 0)
{
_log($"leave skip site={siteId}, not in holding");
return;
}
EnsureProgramAnchor(keepProgram: null);
TrafficControl.Leave(_car, siteId);
_log($"leave site={siteId}, holding=[{FormatInts(_car.status.holdingLocks)}]");
}
/// <summary>
/// 释放路径上严格位于 keepFromIndex 之前的持锁站点(当前站及前方窗口保留)。
/// </summary>
public void ReleaseLocksBehind(IReadOnlyList<int> siteIds, int keepFromIndex)
{
if (siteIds == null || siteIds.Count == 0 || _car.status.holdingLocks.Length <= 1)
{
return;
}
keepFromIndex = Math.Max(0, Math.Min(keepFromIndex, siteIds.Count - 1));
var holdingSnapshot = _car.status.holdingLocks.ToArray();
foreach (var siteId in holdingSnapshot)
{
if (_car.status.holdingLocks.Length <= 1)
{
break;
}
var index = IndexOfSite(siteIds, siteId);
if (index < 0 || index >= keepFromIndex)
{
continue;
}
LeavePassed(siteId);
}
}
private static int IndexOfSite(IReadOnlyList<int> siteIds, int siteId)
{
for (var i = 0; i < siteIds.Count; i++)
{
if (siteIds[i] == siteId)
{
return i;
}
}
return -1;
}
public int EnsureWindowLocked(IReadOnlyList<int> siteIds, int fromIndex, int windowSize)
{
if (siteIds == null || siteIds.Count == 0)
{
return 0;
}
fromIndex = Math.Max(0, Math.Min(fromIndex, siteIds.Count - 1));
windowSize = Math.Max(1, Math.Min(windowSize, 10));
var maxIndex = Math.Min(siteIds.Count - 1, fromIndex + windowSize - 1);
// 返回值必须是从 fromIndex 起的连续已占/新锁站数,供 0xB1 窗口切片使用。
// UDP Tick 会反复进入:已在 holding 的站直接计数,禁止再次对已消费的 pending 站 TryLock。
var lockedCount = 0;
for (var i = fromIndex; i <= maxIndex; i++)
{
var siteId = siteIds[i];
if (Array.IndexOf(_car.status.holdingLocks, siteId) >= 0)
{
lockedCount++;
continue;
}
if (_car.status.pendingLocks.Length == 0 || _car.status.pendingLocks[0] != siteId)
{
_log(
$"window gap site={siteId}, pending0={(_car.status.pendingLocks.Length > 0 ? _car.status.pendingLocks[0].ToString() : "-")}, holding=[{FormatInts(_car.status.holdingLocks)}]");
break;
}
if (!TryLockNext(siteId))
{
break;
}
lockedCount++;
}
if (lockedCount == 0)
{
lockedCount = 1;
_log($"window warn: no holding overlap index={fromIndex}, site={siteIds[fromIndex]}, holding=[{FormatInts(_car.status.holdingLocks)}]");
}
_log($"window locked={lockedCount}/{windowSize}, fromIndex={fromIndex}, holding=[{FormatInts(_car.status.holdingLocks)}], pending=[{FormatInts(_car.status.pendingLocks)}]");
return lockedCount;
}
public void FinalizeAtSite(int siteId)
{
_log($"finalize site={siteId}, holding=[{FormatInts(_car.status.holdingLocks)}], pending=[{FormatInts(_car.status.pendingLocks)}]");
}
/// <summary>
/// SimpleCore.TryLock 在写锁前检查 programs.now;为空则抛 Program obsoleted。
/// </summary>
private void EnsureProgramAnchor(CarProgram keepProgram)
{
if (_car.status.programs.now != null)
{
return;
}
if (keepProgram != null)
{
_car.status.programs.now = keepProgram;
_log($"restore programs.now after TrafficReset, name={keepProgram.name}, state={keepProgram.status.state}");
return;
}
var dummy = new CarProgram
{
name = $"fass2-traffic-anchor:{_car.id}",
plans = new[]
{
new SegmentPlan { usingCar = _car }
}
};
dummy.status.state = CarProgram.StatusEnum.Dummy;
_car.status.programs.now = dummy;
_log($"install dummy programs.now for TryLock, name={dummy.name}");
}
private void LogTrafficState(string prefix)
{
_log(
$"{prefix}, holding=[{FormatInts(_car.status.holdingLocks)}], pending=[{FormatInts(_car.status.pendingLocks)}], seqScope=[{FormatInts(_car.status.seqScope)}], seqPtr={_car.status.seqPtr}");
}
private string FormatBlocked()
{
if (_car.status.blockedBy == null || _car.status.blockedBy.Length == 0)
{
return "-";
}
return string.Join(",", _car.status.blockedBy.Select(p => $"{p.Item1}:{p.Item2}"));
}
private string FormatTcStat()
{
try
{
var tc = _car.status.TCStat;
return string.IsNullOrWhiteSpace(tc) ? "-" : tc;
}
catch
{
return "-";
}
}
private static string FormatInts(int[] values)
{
return values == null || values.Length == 0 ? string.Empty : string.Join(",", values);
}
}
}
@@ -6,9 +6,9 @@ using System.Text;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// MagFass2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/magfass2_yyyyMMdd.log
/// Mag2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/mag2_yyyyMMdd.log
/// </summary>
public static class MagFass2CarFileLogger
public static class Mag2CarFileLogger
{
private static readonly ConcurrentDictionary<ushort, object> CarLocks = new ConcurrentDictionary<ushort, object>();
private static string _baseDirectory = "logs";
@@ -27,7 +27,7 @@ namespace StandardScene.Magnetic.Tasking
public static string GetCurrentLogFilePath(ushort vehicleCode)
{
var fileName = $"magfass2_{DateTime.Now:yyyyMMdd}.log";
var fileName = $"mag2_{DateTime.Now:yyyyMMdd}.log";
return Path.Combine(GetCarLogDirectory(vehicleCode), fileName);
}
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>MagFass2Car Guide</title>
<title>Mag2Car Guide</title>
<style>
@page { size: A4; margin: 18mm 16mm; }
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; color: #222; line-height: 1.55; font-size: 11pt; }
@@ -25,10 +25,10 @@
</style>
</head>
<body>
<h1>MagFass2CarFASS 2.0)使用指南</h1>
<h1>Mag2CarFASS 2.0)使用指南</h1>
<blockquote><p>适用对象:第一次接触磁导航 + FASS 2.0 的调试/实施人员 </p></blockquote>
<blockquote><p>插件:`StandardScene.Magnetic.dll` </p></blockquote>
<blockquote><p>车型:`MagFass2Car` </p></blockquote>
<blockquote><p>车型:`Mag2Car` </p></blockquote>
<blockquote><p>最后更新:2026-07</p></blockquote>
<hr/>
<h2>目录</h2>
@@ -57,7 +57,7 @@
</ol>
<hr/>
<h2>1. 这套系统能做什么</h2>
<p><code>MagFass2Car</code> 是 StandardScene **磁导航平台插件**里的 FASS 2.0 车型,主要能力:</p>
<p><code>Mag2Car</code> 是 StandardScene **磁导航平台插件**里的 FASS 2.0 车型,主要能力:</p>
<table>
<thead><tr>
<th>能力</th>
@@ -96,12 +96,12 @@
<h3>步骤 1:加载插件</h3>
<p>确认场景已加载 <code>scene.mag</code><code>StandardScene.Magnetic.scene.json</code>),提供:</p>
<ul>
<li>车型:<code>MagCar</code><code>MagFass2Car</code></li>
<li>进程:<code>EventCarMission</code><code>MagFass2LoopMission</code></li>
<li>车型:<code>MagCar</code><code>Mag2Car</code></li>
<li>进程:<code>EventCarMission</code><code>Mag2LoopMission</code></li>
</ul>
<h3>步骤 2:添加一辆车</h3>
<ol>
<li>在场景中添加车型 **<code>MagFass2Car</code>**</li>
<li>在场景中添加车型 **<code>Mag2Car</code>**</li>
<li>填写基本参数(UDP 模式示例):</li>
</ol>
<table>
@@ -150,8 +150,8 @@
<p>**方式 A — 手动 Go(Web/调度下发)** </p>
<p>用系统自带的「去某站」功能,内部会编译脚本,例如:</p>
<pre><code>
agv.MagFass2Go(1, 2, 0.2);
agv.MagFass2Go(2, 5, 0.2);
agv.Mag2Go(1, 2, 0.2);
agv.Mag2Go(2, 5, 0.2);
</code></pre>
<p>**方式 B — LoopMission 环线** </p>
<ol>
@@ -165,11 +165,11 @@ agv.MagFass2Go(2, 5, 0.2);
<pre><code>
┌─────────────────────────────────────────────────────────┐
│ 业务层:LoopMission / 手动 Go / 脚本 │
│ (分配 goalSite 或编译多行 MagFass2Go
│ (分配 goalSite 或编译多行 Mag2Go
└────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ MagFass2Car 执行层 │
│ Mag2Car 执行层 │
&#183; 脚本拦截(Loop + goalSite)→ 一次全程任务 │
&#183; Fass2TaskBuilder:路径 + fields → 节点序列 │
&#183; Fass2TaskStateMachine:滑动窗口下发 + 动作闭环 │
@@ -186,7 +186,7 @@ agv.MagFass2Go(2, 5, 0.2);
<p>**关键理解:**</p>
<ul>
<li>**LoopMissionCore** 只负责「给谁分配哪个目标站」(写 <code>goalSite</code> 标签)</li>
<li>**MagFass2Car** 负责「怎么走过去」(拼包、下发、等到站)</li>
<li>**Mag2Car** 负责「怎么走过去」(拼包、下发、等到站)</li>
<li>**阶段 4** 后,Loop 不再逐行执行多段脚本,而是**拦截为一次全程任务**(可关)</li>
</ul>
<hr/>
@@ -195,8 +195,8 @@ agv.MagFass2Go(2, 5, 0.2);
<p>文件:<code>StandardScene.Magnetic.scene.json</code></p>
<pre><code>
{
&quot;carTypes&quot;: [ &quot;MagCar&quot;, &quot;MagFass2Car&quot; ],
&quot;missionTypes&quot;: [ &quot;EventCarMission&quot;, &quot;MagFass2LoopMission&quot; ]
&quot;carTypes&quot;: [ &quot;MagCar&quot;, &quot;Mag2Car&quot; ],
&quot;missionTypes&quot;: [ &quot;EventCarMission&quot;, &quot;Mag2LoopMission&quot; ]
}
</code></pre>
<h3>4.2 选哪个 Mission</h3>
@@ -210,21 +210,21 @@ agv.MagFass2Go(2, 5, 0.2);
<tbody><tr>
<td><code>LoopMission</code></td>
<td>Core</td>
<td>通用环线,MagFass2Car 同样可用</td>
<td>通用环线,Mag2Car 同样可用</td>
</tr>
<tr>
<td><code>磁导航FASS2环线</code><code>MagFass2LoopMission</code></td>
<td><code>磁导航FASS2环线</code><code>Mag2LoopMission</code></td>
<td>Magnetic 插件</td>
<td>逻辑与 LoopMission 相同,名称标识磁导航 FASS2 场景</td>
</tr>
<tr>
<td><code>FASS车辆事件进程</code><code>EventCarMission</code></td>
<td>Magnetic 插件</td>
<td>**仅 MagCarFASS 1.0**,不驱动 MagFass2Car</td>
<td>**仅 MagCarFASS 1.0**,不驱动 Mag2Car</td>
</tr>
</tbody>
</table>
<blockquote><p>新手建议:MagFass2Car 环线用 **`LoopMission`** 或 **`磁导航FASS2环线`** 均可。</p></blockquote>
<blockquote><p>新手建议:Mag2Car 环线用 **`LoopMission`** 或 **`磁导航FASS2环线`** 均可。</p></blockquote>
<h3>4.3 tasklist.json 最小示例</h3>
<pre><code>
{
@@ -253,7 +253,7 @@ agv.MagFass2Go(2, 5, 0.2);
<p>含义:车到站 1 → 自动分配目标站 2;到站 2 → 自动分配目标站 1,形成环线。</p>
<hr/>
<h2>5. 车辆参数说明</h2>
<p>在场景编辑器中选中 <code>MagFass2Car</code>,常用字段如下。</p>
<p>在场景编辑器中选中 <code>Mag2Car</code>,常用字段如下。</p>
<h3>5.1 通讯</h3>
<table>
<thead><tr>
@@ -426,8 +426,8 @@ agv.MagFass2Go(2, 5, 0.2);
<p>**流程**</p>
<pre><code>
FindRoute → Compile → 多行脚本:
agv.MagFass2Go(1,2,speed);
agv.MagFass2Go(2,3,speed);
agv.Mag2Go(1,2,speed);
agv.Mag2Go(2,3,speed);
...
→ SelfEvaluating 逐行执行
→ 每行:锁终点 → 发 0xB1 → 等到站 → 释放起点
@@ -435,7 +435,7 @@ FindRoute → Compile → 多行脚本:
<p>**特点**:每**条边**一段任务,多次 <code>taskId</code>,与交管「逐段锁点」一致。</p>
<hr/>
<h3>方式 2:Loop 全程任务(阶段 4,默认)</h3>
<p>**触发**<code>goalSite</code> 标签 + 编译脚本含 <code>MagFass2Go</code> + <code>EnableLoopTaskDrive=true</code></p>
<p>**触发**<code>goalSite</code> 标签 + 编译脚本含 <code>Mag2Go</code> + <code>EnableLoopTaskDrive=true</code></p>
<p>**流程**</p>
<pre><code>
LoopMission 写 goalSite
@@ -446,7 +446,7 @@ LoopMission 写 goalSite
</code></pre>
<p>**特点**:业务上一次任务,协议上仍遵守每帧 ≤10 站。</p>
<hr/>
<h3>方式 3:单段 MagFass2Go(脚本一行)</h3>
<h3>方式 3:单段 Mag2Go(脚本一行)</h3>
<p>**触发**:脚本里只有一行,或无 <code>goalSite</code> 的测试。</p>
<p>**流程**:与方式 1 的单行相同,状态机只跑 <code>src → dst</code> 一段。</p>
<hr/>
@@ -631,7 +631,7 @@ LoopMission 写 goalSite
</tr>
<tr>
<td>车辆开关</td>
<td><code>MagFass2Car.UseTagValueAsNode = true</code></td>
<td><code>Mag2Car.UseTagValueAsNode = true</code></td>
</tr>
<tr>
<td>示例</td>
@@ -1295,7 +1295,7 @@ LoopMission 写 goalSite
<td><code>Magnet</code></td>
<td>bool</td>
<td></td>
<td><code>true</code> 启用 <code>MagneticTrackCoder</code><code>agv.MagGo</code> 等),与 <code>MagFass2Go</code> 模板独立</td>
<td><code>true</code> 启用 <code>MagneticTrackCoder</code><code>agv.MagGo</code> 等),与 <code>Mag2Go</code> 模板独立</td>
</tr>
<tr>
<td><code>ReverseDst</code></td>
@@ -1433,7 +1433,7 @@ Fass2_Obstacle = 2
2. 匹配 tasklistcurrentStationId = N → 分配 targetStationId
3. 写 tagsgoalSite = 目标站 ID
4. LoopStartAction 调用 GoSite
5. MagFass2Car 拦截 → 全程任务到 goalSite
5. Mag2Car 拦截 → 全程任务到 goalSite
6. 到达后 GoSite 清理 goalSite / occupied / loopAssigned
7. 下一轮任务匹配
</code></pre>
@@ -1462,13 +1462,13 @@ Fass2_Obstacle = 2
<ul>
<li>**不需要改 Core**<code>LoopMission</code> 逻辑完全沿用</li>
<li><code>磁导航FASS2环线</code> 仅为场景标识,行为与 <code>LoopMission</code> 相同</li>
<li>真正区别在 **车型** 是否 <code>MagFass2Car</code><code>EnableLoopTaskDrive=true</code></li>
<li>真正区别在 **车型** 是否 <code>Mag2Car</code><code>EnableLoopTaskDrive=true</code></li>
</ul>
<h3>9.4 关闭 Loop 拦截(恢复多段脚本)</h3>
<pre><code>
EnableLoopTaskDrive = false
</code></pre>
<p>Loop 仍走 Compile 多行 <code>MagFass2Go</code>,每行一段边。</p>
<p>Loop 仍走 Compile 多行 <code>Mag2Go</code>,每行一段边。</p>
<hr/>
<h2>10. 任务状态机与协议下发</h2>
<h3>10.1 状态流转</h3>
@@ -1595,7 +1595,7 @@ Idle → Planning → Moving → AtStation → SegmentDone → … → Complete
</tr>
</tbody>
</table>
<p>日志前缀:<code>[MagFass2Car:车名(id)]</code>,任务状态机:<code>task-sm</code></p>
<p>日志前缀:<code>[Mag2Car:车名(id)]</code>,任务状态机:<code>task-sm</code></p>
<hr/>
<h2>12. 常见问题排查</h2>
<h3>Q1:车显示离线(UDP</h3>
@@ -1698,13 +1698,13 @@ Idle → Planning → Moving → AtStation → SegmentDone → … → Complete
<p>看日志:</p>
<ul>
<li><code>loop goal drive intercept</code> → 全程任务(阶段 4</li>
<li><code>script begin</code> + 多行 <code>MagFass2Go</code> → 多段脚本</li>
<li><code>script begin</code> + 多行 <code>Mag2Go</code> → 多段脚本</li>
</ul>
<h3>Q6:与 MagCar 混用注意事项</h3>
<ul>
<li><code>EventCarMission</code> **只控制 MagCar**</li>
<li>同场景两种车型时,tasklist 和车辆类型要对应</li>
<li>FASS 1.0 用 <code>MagCar</code>FASS 2.0 用 <code>MagFass2Car</code></li>
<li>FASS 1.0 用 <code>MagCar</code>FASS 2.0 用 <code>Mag2Car</code></li>
</ul>
<hr/>
<h2>13. 参数推荐与检查清单</h2>
@@ -1722,7 +1722,7 @@ MoveTimeoutSeconds = 120
<h3>13.2 上线前检查清单</h3>
<ul>
<li>[ ] 插件 <code>StandardScene.Magnetic.dll</code> 已加载</li>
<li>[ ] 车型为 <code>MagFass2Car</code>(不是 <code>MagCar</code></li>
<li>[ ] 车型为 <code>Mag2Car</code>(不是 <code>MagCar</code></li>
<li>[ ] <code>VehicleCode</code>、IP、端口与车体一致</li>
<li>[ ] UDP 能收到 100B,日志有 <code>0x10</code> 应答</li>
<li>[ ] 关键站点 <code>TagValue</code> / <code>Fass2_*</code> 已配置</li>
@@ -1785,7 +1785,7 @@ MoveTimeoutSeconds = 120
</thead>
<tbody><tr>
<td>车型</td>
<td><code>CarTypes/MagFass2Car.cs</code></td>
<td><code>CarTypes/Mag2Car.cs</code></td>
</tr>
<tr>
<td>协议</td>
@@ -1805,7 +1805,7 @@ MoveTimeoutSeconds = 120
</tr>
<tr>
<td>环线 Mission</td>
<td><code>Chained/MagFass2LoopMission.cs</code></td>
<td><code>Chained/Mag2LoopMission.cs</code></td>
</tr>
<tr>
<td>站点字段常量</td>
@@ -1,8 +1,8 @@
# MagFass2CarFASS 2.0)使用指南
# Mag2CarFASS 2.0)使用指南
> 适用对象:第一次接触磁导航 + FASS 2.0 的调试/实施人员
> 插件:`StandardScene.Magnetic.dll`
> 车型:`MagFass2Car`
> 车型:`Mag2Car`
> 最后更新:2026-07
---
@@ -33,7 +33,7 @@
## 1. 这套系统能做什么
`MagFass2Car` 是 StandardScene **磁导航平台插件**里的 FASS 2.0 车型,主要能力:
`Mag2Car` 是 StandardScene **磁导航平台插件**里的 FASS 2.0 车型,主要能力:
| 能力 | 说明 |
|------|------|
@@ -54,12 +54,12 @@
确认场景已加载 `scene.mag``StandardScene.Magnetic.scene.json`),提供:
- 车型:`MagCar``MagFass2Car`
- 进程:`EventCarMission``MagFass2LoopMission`
- 车型:`MagCar``Mag2Car`
- 进程:`EventCarMission``Mag2LoopMission`
### 步骤 2:添加一辆车
1. 在场景中添加车型 **`MagFass2Car`**
1. 在场景中添加车型 **`Mag2Car`**
2. 填写基本参数(UDP 模式示例):
| 参数 | 示例值 | 含义 |
@@ -86,8 +86,8 @@
用系统自带的「去某站」功能,内部会编译脚本,例如:
```text
agv.MagFass2Go(1, 2, 0.2);
agv.MagFass2Go(2, 5, 0.2);
agv.Mag2Go(1, 2, 0.2);
agv.Mag2Go(2, 5, 0.2);
```
**方式 B — LoopMission 环线**
@@ -103,11 +103,11 @@ agv.MagFass2Go(2, 5, 0.2);
```text
┌─────────────────────────────────────────────────────────┐
│ 业务层:LoopMission / 手动 Go / 脚本 │
│ (分配 goalSite 或编译多行 MagFass2Go
│ (分配 goalSite 或编译多行 Mag2Go
└────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ MagFass2Car 执行层 │
│ Mag2Car 执行层 │
│ · 脚本拦截(Loop + goalSite)→ 一次全程任务 │
│ · Fass2TaskBuilder:路径 + fields → 节点序列 │
│ · Fass2TaskStateMachine:滑动窗口下发 + 动作闭环 │
@@ -125,7 +125,7 @@ agv.MagFass2Go(2, 5, 0.2);
**关键理解:**
- **LoopMissionCore** 只负责「给谁分配哪个目标站」(写 `goalSite` 标签)
- **MagFass2Car** 负责「怎么走过去」(拼包、下发、等到站)
- **Mag2Car** 负责「怎么走过去」(拼包、下发、等到站)
- **阶段 4** 后,Loop 不再逐行执行多段脚本,而是**拦截为一次全程任务**(可关)
---
@@ -138,8 +138,8 @@ agv.MagFass2Go(2, 5, 0.2);
```json
{
"carTypes": [ "MagCar", "MagFass2Car" ],
"missionTypes": [ "EventCarMission", "MagFass2LoopMission" ]
"carTypes": [ "MagCar", "Mag2Car" ],
"missionTypes": [ "EventCarMission", "Mag2LoopMission" ]
}
```
@@ -147,11 +147,11 @@ agv.MagFass2Go(2, 5, 0.2);
| Mission | 来源 | 适用 |
|---------|------|------|
| `LoopMission` | Core | 通用环线,MagFass2Car 同样可用 |
| `磁导航FASS2环线``MagFass2LoopMission` | Magnetic 插件 | 逻辑与 LoopMission 相同,名称标识磁导航 FASS2 场景 |
| `FASS车辆事件进程``EventCarMission` | Magnetic 插件 | **仅 MagCarFASS 1.0**,不驱动 MagFass2Car |
| `LoopMission` | Core | 通用环线,Mag2Car 同样可用 |
| `磁导航FASS2环线``Mag2LoopMission` | Magnetic 插件 | 逻辑与 LoopMission 相同,名称标识磁导航 FASS2 场景 |
| `FASS车辆事件进程``EventCarMission` | Magnetic 插件 | **仅 MagCarFASS 1.0**,不驱动 Mag2Car |
> 新手建议:MagFass2Car 环线用 **`LoopMission`** 或 **`磁导航FASS2环线`** 均可。
> 新手建议:Mag2Car 环线用 **`LoopMission`** 或 **`磁导航FASS2环线`** 均可。
### 4.3 tasklist.json 最小示例
@@ -186,7 +186,7 @@ agv.MagFass2Go(2, 5, 0.2);
## 5. 车辆参数说明
在场景编辑器中选中 `MagFass2Car`,常用字段如下。
在场景编辑器中选中 `Mag2Car`,常用字段如下。
### 5.1 通讯
@@ -269,8 +269,8 @@ agv.MagFass2Go(2, 5, 0.2);
```text
FindRoute → Compile → 多行脚本:
agv.MagFass2Go(1,2,speed);
agv.MagFass2Go(2,3,speed);
agv.Mag2Go(1,2,speed);
agv.Mag2Go(2,3,speed);
...
→ SelfEvaluating 逐行执行
→ 每行:锁终点 → 发 0xB1 → 等到站 → 释放起点
@@ -282,7 +282,7 @@ FindRoute → Compile → 多行脚本:
### 方式 2:Loop 全程任务(阶段 4,默认)
**触发**`goalSite` 标签 + 编译脚本含 `MagFass2Go` + `EnableLoopTaskDrive=true`
**触发**`goalSite` 标签 + 编译脚本含 `Mag2Go` + `EnableLoopTaskDrive=true`
**流程**
@@ -298,7 +298,7 @@ LoopMission 写 goalSite
---
### 方式 3:单段 MagFass2Go(脚本一行)
### 方式 3:单段 Mag2Go(脚本一行)
**触发**:脚本里只有一行,或无 `goalSite` 的测试。
@@ -370,7 +370,7 @@ LoopMission 写 goalSite
|------|------|
| 映射协议字段 | `Node`(节点号) |
| 何时配置 | 磁导航地标号与地图 `site.id` **不一致**时必须配置 |
| 车辆开关 | `MagFass2Car.UseTagValueAsNode = true` |
| 车辆开关 | `Mag2Car.UseTagValueAsNode = true` |
| 示例 | `TagValue = 10086` |
| 注意 | 未配置且 `UseTagValueAsNode=false` 时,直接用 `site.id` 作为节点号 |
@@ -387,9 +387,9 @@ LoopMission 写 goalSite
| 项目 | 说明 |
|------|------|
| 映射协议字段 | 无(当前版本不直接写入节点块) |
| 映射协议字段 | 无(写入节点块) |
| 规划值 | `None` / `Button` / `Plc` / `Api` |
| 当前状态 | 字段已定义,**等待放行联动逻辑待扩展**;可通过修改 `Fass2_StartStop` 或外部改 fields 触发重发 |
| 当前状态 | 人工/外部放行源预留。**管控区按车放行**请用 `Fass2_StartStop=12` + `Fass2_ControlArea`(站点列表),不要靠改地图 `Fass2_StartStop` 放行 |
---
@@ -401,8 +401,8 @@ LoopMission 写 goalSite
|----|---------------------------|----------|
| `1` | 过站(不停) | 路径中间站、通道站 |
| `2` | 普通停车 | 工位、等待点、装卸点 |
| `11` | 管控启动 | 管控区入口,需授权后启动 |
| `12` | 管控停止 | 管控区停车等待 |
| `11` | 管控启动 | 本车放行后写入 **0xA1 / 后续 0xB1**(地图站点仍保持 12 |
| `12` | 管控停止 | 管控区停车,等调度按车放行 |
| `22` | 精准停止 | 装配、对接,精度要求高 |
**默认规则**(未显式配置 `Fass2_StartStop` 时,`Fass2TaskBuilder` 自动决定):
@@ -413,7 +413,17 @@ LoopMission 写 goalSite
| 末站(goalSite | `2`(停车) |
| 末站 + `Fass2_PrecisionStop=true` | `22`(精准停) |
**动作完成判定**若配置了非过站值,状态机要求上报 `Node.StartStop` 与期望值一致且车体非运行态`State≠1`),才推进下一段
**动作完成判定**`2`/`22` 要求上报 `StartStop` 一致且车体非运行态`12` **不会**因上报 12 就推进:必须等同区占用条件满足后,调度把本车期望值改为 `11`(或 `1`),车体回显后再离站
**管控停止按车放行(`12`**
- 地图 `Fass2_StartStop` **始终保持 12**,不要改成 1 来放行(否则下一辆车的 0xB1 也会变成过站)。
- `0xB1` 窗口截断在第一个尚未放行的 12,其后站点不会出现在同一帧里。
- 本车到站并停稳后,统计 `Fass2_ControlArea` **站点列表**上的他车占用(`holdingLocks` + Mag2 `siteID`)。他车数 **≥** `Fass2_ControlCapacity`(默认 1,即数量与容量相等)则不放行;本车不计入,避免停在列表内时永远走不了。
- 放行只改**本车**报文:`0xA1` 把当前节点 `12→11`(可用 `Fass2_ControlReleaseStartStop=1` 改为过站),并补发含后续站的 `0xB1`。下一辆车仍会收到 12。
- 等待放行期间不消耗移动超时。断线重连若仍停在该管控点,会重新判定占用;条件满足则再次发放行报文。
相关站点字段:`Fass2_ControlArea`(站点 ID 数组,如 `10,11,12`)、`Fass2_ControlCapacity``Fass2_ControlReleaseStartStop`。未配或解析不出站点时,只统计当前管控点本身。
**与 `Fass2_PrecisionStop` 关系**
@@ -614,6 +624,9 @@ LoopMission 写 goalSite
| `Fass2_Tray` | byte | `Tray` | 是(非 0 时) |
| `Fass2_Shutdown` | byte | `Shutdown` | 是(非 0 时) |
| `Fass2_WaitMode` | string | — | 预留 |
| `Fass2_ControlArea` | string | — | 管控站点列表,如 `10,11,12` |
| `Fass2_ControlCapacity` | int | — | 列表内他车数达到该值则不放行,默认 1 |
| `Fass2_ControlReleaseStartStop` | byte | 放行后的 `StartStop` | `11`(默认)或 `1` |
---
@@ -632,7 +645,7 @@ LoopMission 写 goalSite
| `magSelect` | byte | `Byroad` | 磁导航 coder 分叉序号;`Fass2_Byroad` 为空时作为 Byroad 备选 |
| `Fass2_Direction` | byte | `Direction` | 边级车头旋转;站点未覆盖时生效 |
| `Fass2_Orientation` | byte | `Orientation` | 边级朝向;站点未覆盖时生效 |
| `Magnet` | bool | — | `true` 启用 `MagneticTrackCoder``agv.MagGo` 等),与 `MagFass2Go` 模板独立 |
| `Magnet` | bool | — | `true` 启用 `MagneticTrackCoder``agv.MagGo` 等),与 `Mag2Go` 模板独立 |
| `ReverseDst` | int | — | 倒车目的地站 ID;磁 coder 跳过该边 |
**距离 `Distance`**:由地图坐标自动计算(上一站与本站欧氏距离,mm 取整),一般无需手配。
@@ -717,6 +730,21 @@ Fass2_Charge = 2 # 开始充电
---
#### 场景 E2:管控停止(按车放行)
在 12 站点填写要监控的站点列表;**地图 `Fass2_StartStop` 保持 12**。
```text
Fass2_StartStop = 12
Fass2_ControlArea = 10,11,12 # 这些站上有他车且数量达到容量则不放行
Fass2_ControlCapacity = 1
# Fass2_ControlReleaseStartStop = 11 # 默认;改 1 则放行后按过站离站
```
列表 `10,11,12` 上已有 1 辆他车时,本车停在 12 等待;他车离开列表后,调度只给本车发 `0xA1`(12→11)。下一辆车仍会先收到 12。
---
#### 场景 FY 型磁分叉口
**站点**(推荐在岔口站配):
@@ -792,7 +820,7 @@ Fass2_Obstacle = 2
2. 匹配 tasklistcurrentStationId = N → 分配 targetStationId
3. 写 tagsgoalSite = 目标站 ID
4. LoopStartAction 调用 GoSite
5. MagFass2Car 拦截 → 全程任务到 goalSite
5. Mag2Car 拦截 → 全程任务到 goalSite
6. 到达后 GoSite 清理 goalSite / occupied / loopAssigned
7. 下一轮任务匹配
```
@@ -809,7 +837,7 @@ Fass2_Obstacle = 2
- **不需要改 Core**`LoopMission` 逻辑完全沿用
- `磁导航FASS2环线` 仅为场景标识,行为与 `LoopMission` 相同
- 真正区别在 **车型** 是否 `MagFass2Car``EnableLoopTaskDrive=true`
- 真正区别在 **车型** 是否 `Mag2Car``EnableLoopTaskDrive=true`
### 9.4 关闭 Loop 拦截(恢复多段脚本)
@@ -817,7 +845,7 @@ Fass2_Obstacle = 2
EnableLoopTaskDrive = false
```
Loop 仍走 Compile 多行 `MagFass2Go`,每行一段边。
Loop 仍走 Compile 多行 `Mag2Go`,每行一段边。
---
@@ -890,7 +918,7 @@ Idle → Planning → Moving → AtStation → SegmentDone → … → Complete
| 重置UDP监听 | — | 重新注册 UDP 会话 |
| 重置TCP连接 | — | 关闭长连接(TCP 模式) |
日志前缀:`[MagFass2Car:车名(id)]`,任务状态机:`task-sm`
日志前缀:`[Mag2Car:车名(id)]`,任务状态机:`task-sm`
---
@@ -936,13 +964,13 @@ Idle → Planning → Moving → AtStation → SegmentDone → … → Complete
看日志:
- `loop goal drive intercept` → 全程任务(阶段 4
- `script begin` + 多行 `MagFass2Go` → 多段脚本
- `script begin` + 多行 `Mag2Go` → 多段脚本
### Q6:与 MagCar 混用注意事项
- `EventCarMission` **只控制 MagCar**
- 同场景两种车型时,tasklist 和车辆类型要对应
- FASS 1.0 用 `MagCar`FASS 2.0 用 `MagFass2Car`
- FASS 1.0 用 `MagCar`FASS 2.0 用 `Mag2Car`
---
@@ -964,7 +992,7 @@ MoveTimeoutSeconds = 120
### 13.2 上线前检查清单
- [ ] 插件 `StandardScene.Magnetic.dll` 已加载
- [ ] 车型为 `MagFass2Car`(不是 `MagCar`
- [ ] 车型为 `Mag2Car`(不是 `MagCar`
- [ ] `VehicleCode`、IP、端口与车体一致
- [ ] UDP 能收到 100B,日志有 `0x10` 应答
- [ ] 关键站点 `TagValue` / `Fass2_*` 已配置
@@ -993,12 +1021,12 @@ MoveTimeoutSeconds = 120
| 模块 | 路径 |
|------|------|
| 车型 | `CarTypes/MagFass2Car.cs` |
| 车型 | `CarTypes/Mag2Car.cs` |
| 协议 | `Protocol/Fass2Protocol.cs``Fass2UdpHub.cs` |
| 组包 | `Tasking/Fass2TaskBuilder.cs` |
| 状态机 | `Tasking/Fass2TaskStateMachine.cs` |
| 动作判定 | `Tasking/Fass2ActionResolver.cs` |
| 环线 Mission | `Chained/MagFass2LoopMission.cs` |
| 环线 Mission | `Chained/Mag2LoopMission.cs` |
| 站点字段常量 | `Tasking/Fass2SiteFields.cs` |
---
+4 -4
View File
@@ -1,7 +1,7 @@
# Export MagFass2Car guide Markdown to PDF (requires Chrome or Edge)
# Export Mag2Car guide Markdown to PDF (requires Chrome or Edge)
param(
[string]$InputMd = "$PSScriptRoot\MagFass2Car使用指南.md",
[string]$OutputPdf = "$PSScriptRoot\MagFass2Car使用指南.pdf"
[string]$InputMd = "$PSScriptRoot\Mag2Car使用指南.md",
[string]$OutputPdf = "$PSScriptRoot\Mag2Car使用指南.pdf"
)
$ErrorActionPreference = 'Stop'
@@ -147,7 +147,7 @@ $html = @"
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>MagFass2Car Guide</title>
<title>Mag2Car Guide</title>
<style>
@page { size: A4; margin: 18mm 16mm; }
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; color: #222; line-height: 1.55; font-size: 11pt; }
+66
View File
@@ -15,36 +15,102 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.QrLidar", "St
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulator", "StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj", "{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7A745509-1593-4044-BA49-9B6B0A35B505}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Debug|x64.ActiveCfg = Debug|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Debug|x64.Build.0 = Debug|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Debug|x86.ActiveCfg = Debug|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Debug|x86.Build.0 = Debug|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Release|Any CPU.Build.0 = Release|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Release|x64.ActiveCfg = Release|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Release|x64.Build.0 = Release|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Release|x86.ActiveCfg = Release|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Release|x86.Build.0 = Release|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Debug|x64.ActiveCfg = Debug|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Debug|x64.Build.0 = Debug|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Debug|x86.ActiveCfg = Debug|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Debug|x86.Build.0 = Debug|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Release|Any CPU.Build.0 = Release|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Release|x64.ActiveCfg = Release|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Release|x64.Build.0 = Release|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Release|x86.ActiveCfg = Release|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Release|x86.Build.0 = Release|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Debug|x64.ActiveCfg = Debug|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Debug|x64.Build.0 = Debug|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Debug|x86.ActiveCfg = Debug|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Debug|x86.Build.0 = Debug|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Release|Any CPU.Build.0 = Release|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Release|x64.ActiveCfg = Release|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Release|x64.Build.0 = Release|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Release|x86.ActiveCfg = Release|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Release|x86.Build.0 = Release|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Debug|x64.ActiveCfg = Debug|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Debug|x64.Build.0 = Debug|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Debug|x86.ActiveCfg = Debug|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Debug|x86.Build.0 = Debug|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Release|Any CPU.Build.0 = Release|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Release|x64.ActiveCfg = Release|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Release|x64.Build.0 = Release|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Release|x86.ActiveCfg = Release|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Release|x86.Build.0 = Release|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|x64.ActiveCfg = Debug|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|x64.Build.0 = Debug|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|x86.ActiveCfg = Debug|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|x86.Build.0 = Debug|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|Any CPU.Build.0 = Release|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|x64.ActiveCfg = Release|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|x64.Build.0 = Release|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|x86.ActiveCfg = Release|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|x86.Build.0 = Release|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|x64.ActiveCfg = Debug|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|x64.Build.0 = Debug|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|x86.ActiveCfg = Debug|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|x86.Build.0 = Debug|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|Any CPU.Build.0 = Release|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|x64.ActiveCfg = Release|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|x64.Build.0 = Release|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|x86.ActiveCfg = Release|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|x86.Build.0 = Release|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Debug|x64.ActiveCfg = Debug|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Debug|x64.Build.0 = Debug|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Debug|x86.ActiveCfg = Debug|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Debug|x86.Build.0 = Debug|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|Any CPU.Build.0 = Release|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x64.ActiveCfg = Release|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x64.Build.0 = Release|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.ActiveCfg = Release|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+61
View File
@@ -0,0 +1,61 @@
# FASS 2.0 multi-car UDP integration script
param(
[switch]$StartSimulators,
[switch]$SkipTests
)
$ErrorActionPreference = "Stop"
$root = Join-Path $PSScriptRoot ".."
$sln = Join-Path $root "StandardScene.sln"
$testProj = Join-Path $root "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj"
$simProj = Join-Path $root "StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj"
Write-Host "== FASS2 multi-car UDP ==" -ForegroundColor Cyan
Write-Host "Solution: $sln"
dotnet build $sln -c Release | Out-Host
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not $SkipTests) {
Write-Host ""
Write-Host "== Protocol golden + UDP hub tests ==" -ForegroundColor Cyan
dotnet test $testProj -c Release --no-build --filter "FullyQualifiedName~StandardScene.Magnetic.Tests.Protocol" -v n
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host ""
Write-Host "== Simulator self-tests ==" -ForegroundColor Cyan
dotnet run --project $simProj -c Release --no-build -- --motion-test
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet run --project $simProj -c Release --no-build -- --action-test
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
if ($StartSimulators) {
$simDir = Join-Path $root "StandardScene.Fass2Simulator\bin\Release\net8.0-windows"
$exe = Join-Path $simDir "StandardScene.Fass2Simulator.exe"
if (-not (Test-Path $exe)) {
throw "Simulator not found: $exe"
}
Copy-Item (Join-Path $root "StandardScene.Fass2Simulator\appsettings.car2.json") (Join-Path $simDir "appsettings.car2.json") -Force
Write-Host ""
Write-Host "== Starting dual simulators ==" -ForegroundColor Cyan
Write-Host "Car1: VehicleCode=1, Listen=5000 -> 127.0.0.1:20103"
Write-Host "Car2: VehicleCode=2, Listen=5001 -> 127.0.0.1:20103"
Write-Host "SimpleLite: two Mag2Car, VehicleCode 1/2, Port 5000/5001, ListenPort 20103"
Start-Process $exe -WorkingDirectory $simDir
Start-Process $exe -WorkingDirectory $simDir -ArgumentList @("--config", "appsettings.car2.json")
Write-Host "Started two simulator processes." -ForegroundColor Green
}
else {
Write-Host ""
Write-Host "Manual dual-car steps:" -ForegroundColor Yellow
Write-Host " dotnet test StandardScene.Magnetic.Tests -c Release"
Write-Host " Start two simulators; second with --config appsettings.car2.json"
Write-Host " Or: .\run-fass2-multi-car-udp.ps1 -StartSimulators"
}
Write-Host ""
Write-Host "Done." -ForegroundColor Green