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> /// </summary>
/// <param name="siteId">站点ID</param> /// <param name="siteId">站点ID</param>
/// <returns>找到的车辆,未找到返回 null</returns> /// <returns>找到的车辆,未找到返回 null</returns>
protected Car FindCarArrivedAtSite(int siteId) protected virtual Car FindCarArrivedAtSite(int siteId)
{ {
try try
{ {
@@ -1027,7 +1027,6 @@ namespace StandardScene.Chained
/// <param name="reverse">是否倒车,默认为 false</param> /// <param name="reverse">是否倒车,默认为 false</param>
public async Task GoSite(AbstractCar car, Site targetSite, string action = "/", bool reverse = false) public async Task GoSite(AbstractCar car, Site targetSite, string action = "/", bool reverse = false)
{ {
try try
{ {
// 创建路径规划 // 创建路径规划
@@ -1039,7 +1038,7 @@ namespace StandardScene.Chained
// 查找从当前位置到目标站点的路径 // 查找从当前位置到目标站点的路径
plan.FindRoute(SimpleLib.GetSite(car.GetLastSite()), targetSite); plan.FindRoute(SimpleLib.GetSite(car.GetLastSite()), targetSite);
// 编译并执行移动脚本 // 编译并执行移动脚本(默认 Forecast
var program = plan.Compile("move"); 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.status.pendingLocks.Length == 0 && !car.tags.Contains("deliver") &&//pendingLocks临时增加解决车接多任务问题
(!car.tags.Contains("shouldCharge") || needCharge) (!car.tags.Contains("shouldCharge") || needCharge)
&& !car.tags.Contains("currentWorkStep") && !car.fields.ContainsKey("StopAccept") && car.tags.Contains("Online"); //巡航任务未完成的车 && !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 && conditions = car.GetLastSite() != -1 &&
(!car.tags.Contains("holdCar") || !checkHoldCar) && !car.tags.Contains("occupied") (!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.status.pendingLocks.Length == 0 && !car.tags.Contains("deliver") &&//pendingLocks临时增加解决车接多任务问题
(!car.tags.Contains("shouldCharge") || needCharge) (!car.tags.Contains("shouldCharge") || needCharge)
&& !car.tags.Contains("currentWorkStep") && !car.fields.ContainsKey("StopAccept"); //巡航任务未完成的车 && !car.tags.Contains("currentWorkStep") && !car.fields.ContainsKey("StopAccept"); //巡航任务未完成的车
+2
View File
@@ -7,6 +7,8 @@ namespace StandardScene.Fass2Simulator
{ {
protected override void OnStartup(StartupEventArgs e) protected override void OnStartup(StartupEventArgs e)
{ {
Fass2SimBootstrap.ApplyCommandLine(e.Args);
if (HasTestArg(e.Args)) if (HasTestArg(e.Args))
{ {
var code = Fass2SimSelfTests.Run(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) public void OnActionCommand(ulong actionId, Fass2SimNodeMessage patch)
{ {
if (patch == null) if (patch == null)
@@ -67,13 +76,16 @@ namespace StandardScene.Fass2Simulator
lock (_syncRoot) lock (_syncRoot)
{ {
var targetNode = patch.Node != 0 ? patch.Node : _vehicle.Node;
if (patch.Node != 0 && patch.Node != _vehicle.Node) if (patch.Node != 0 && patch.Node != _vehicle.Node)
{ {
Fass2SimLog.WriteLine( 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); SchedulePatch(patch, actionId, 0, delay);
Fass2SimLog.WriteLine( Fass2SimLog.WriteLine(
$"[{Now()}] -> 0xA1 actionId={actionId} 已排队,delay={delay}ms, pending={DescribePatch(patch)}"); $"[{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() private bool IsStationCompleteLocked()
{ {
if (_pendingActions.Count > 0) if (_pendingActions.Count > 0)
@@ -229,9 +272,9 @@ namespace StandardScene.Fass2Simulator
private void ApplyPatch(Fass2SimNodeMessage patch) 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) if (patch.StartStop > 0)
@@ -10,13 +10,14 @@ namespace StandardScene.Fass2Simulator
{ {
public const byte StartStopPass = 1; public const byte StartStopPass = 1;
public const byte StartStopStop = 2; public const byte StartStopStop = 2;
public const byte StartStopControlStart = 11;
public const byte StartStopControlStop = 12;
public const byte StartStopPrecision = 22; 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), ("Obstacle", n => n.Obstacle),
("Audio", n => n.Audio), ("Audio", n => n.Audio),
("Light", n => n.Light), ("Light", n => n.Light),
@@ -29,6 +30,18 @@ namespace StandardScene.Fass2Simulator
("Shutdown", n => n.Shutdown) ("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) public static bool RequiresActionWait(Fass2SimNodeMessage expected)
{ {
if (expected == null) if (expected == null)
@@ -36,12 +49,25 @@ namespace StandardScene.Fass2Simulator
return false; return false;
} }
if (expected.StartStop is StartStopStop or StartStopPrecision) if (expected.StartStop is StartStopStop or StartStopPrecision or StartStopControlStop or StartStopControlStart)
{ {
return true; 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) if (field.Get(expected) != 0)
{ {
@@ -64,7 +90,7 @@ namespace StandardScene.Fass2Simulator
return false; return false;
} }
if (vehicleState == 1) if (vehicleState is 1 or 5)
{ {
return false; return false;
} }
@@ -79,6 +105,11 @@ namespace StandardScene.Fass2Simulator
return true; return true;
} }
if (expected.StartStop == StartStopControlStop)
{
return false;
}
return ListPendingFields(expected, actual).Count == 0; return ListPendingFields(expected, actual).Count == 0;
} }
@@ -95,12 +126,24 @@ namespace StandardScene.Fass2Simulator
pending.Add("StartStop"); pending.Add("StartStop");
} }
if (VerifyTrajectoryFields)
{
if (expected.Speed != 0 && actual.Speed != expected.Speed) if (expected.Speed != 0 && actual.Speed != expected.Speed)
{ {
pending.Add("Speed"); pending.Add("Speed");
} }
foreach (var field in ActionFields) 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 MechanismFields)
{ {
var expectedValue = field.Get(expected); var expectedValue = field.Get(expected);
if (expectedValue != 0 && GetActualField(actual, field.Name) != expectedValue) if (expectedValue != 0 && GetActualField(actual, field.Name) != expectedValue)
@@ -8,12 +8,30 @@ namespace StandardScene.Fass2Simulator
{ {
public static class Fass2SimBootstrap 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 basePath = AppContext.BaseDirectory;
var builder = new ConfigurationBuilder() var builder = new ConfigurationBuilder()
.SetBasePath(basePath) .SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); .AddJsonFile(settingsFile ?? SettingsFile, optional: true, reloadOnChange: false);
var configuration = builder.Build(); var configuration = builder.Build();
var config = new Fass2SimConfig(); var config = new Fass2SimConfig();
@@ -41,6 +41,12 @@ namespace StandardScene.Fass2Simulator
_windowNodes = nodes; _windowNodes = nodes;
_vehicle.Task = taskId; _vehicle.Task = taskId;
if (taskChanged)
{
_actions.ResetForNewTask();
ResetSegment();
}
ApplyCurrentNodeFields(nodes); ApplyCurrentNodeFields(nodes);
LogNodes(nodes); LogNodes(nodes);
@@ -63,10 +69,35 @@ namespace StandardScene.Fass2Simulator
} }
} }
if (!ContainsNode(nodes, _vehicle.Node))
{
Fass2SimLog.WriteLine(
$"[{Now()}] -> 0xB1 窗口不含当前 node={_vehicle.Node},忽略启动路段(避免回退到窗口首站)");
return;
}
TryStartNextSegment(); 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() public void OnStartCommand()
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -147,7 +178,7 @@ namespace StandardScene.Fass2Simulator
} }
_segmentTarget = target; _segmentTarget = target;
_segmentLength = ResolveSegmentLength(target); _segmentLength = ResolveSegmentLength(_vehicle.Node, target);
_segmentProgress = 0; _segmentProgress = 0;
_segmentActive = true; _segmentActive = true;
_vehicle.State = 1; _vehicle.State = 1;
@@ -184,7 +215,7 @@ namespace StandardScene.Fass2Simulator
} }
var expected = _actions.ExpectedStation; var expected = _actions.ExpectedStation;
if (expected == null || expected.StartStop != Fass2SimActionResolver.StartStopPass) if (expected == null || !Fass2SimActionResolver.AllowsAutoContinue(expected.StartStop))
{ {
return; return;
} }
@@ -218,15 +249,24 @@ namespace StandardScene.Fass2Simulator
return null; return null;
} }
// 必须先在窗口内定位当前站,再取其后一站。
// 旧逻辑在窗口不含当前站时会落到 nodes[0],把更早的站当成下一目标 → 车体 Node 瞬间回退/瞬移。
var foundCurrent = false;
var startIndex = 0; var startIndex = 0;
for (var i = 0; i < nodes.Length; i++) for (var i = 0; i < nodes.Length; i++)
{ {
if (nodes[i].Node == currentNode) if (nodes[i].Node == currentNode)
{ {
foundCurrent = true;
startIndex = i + 1; startIndex = i + 1;
} }
} }
if (!foundCurrent)
{
return null;
}
for (var i = startIndex; i < nodes.Length; i++) for (var i = startIndex; i < nodes.Length; i++)
{ {
var node = nodes[i]; var node = nodes[i];
@@ -248,12 +288,21 @@ namespace StandardScene.Fass2Simulator
if (node.Node == _vehicle.Node && node.StartStop > 0) if (node.Node == _vehicle.Node && node.StartStop > 0)
{ {
_vehicle.StartStop = node.StartStop; _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) if (target.Distance > 0)
{ {
return target.Distance; return target.Distance;
@@ -272,12 +321,12 @@ namespace StandardScene.Fass2Simulator
{ {
if (target.Speed > 0) if (target.Speed > 0)
{ {
return target.Speed; return Fass2SimProtocol.ProtocolSpeedToMmPerSec(target.Speed);
} }
if (_vehicle.Speed > 0) if (_vehicle.Speed > 0)
{ {
return _vehicle.Speed; return Fass2SimProtocol.ProtocolSpeedToMmPerSec(_vehicle.Speed);
} }
return _config.DefaultSpeed; 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) public static string ToHex(byte[] bytes)
{ {
return bytes == null ? string.Empty : string.Join(" ", bytes.Select(b => b.ToString("X2"))); return bytes == null ? string.Empty : string.Join(" ", bytes.Select(b => b.ToString("X2")));
@@ -21,6 +21,7 @@ namespace StandardScene.Fass2Simulator
private static int RunMotionSelfTest() private static int RunMotionSelfTest()
{ {
Fass2SimFileLogger.Configure(new Fass2SimConfig { EnableFileLog = false });
var config = new Fass2SimConfig var config = new Fass2SimConfig
{ {
InitialNode = 1, InitialNode = 1,
@@ -31,9 +32,9 @@ namespace StandardScene.Fass2Simulator
var (vehicle, motion, _) = Fass2SimBootstrap.CreateTestStack(config); var (vehicle, motion, _) = Fass2SimBootstrap.CreateTestStack(config);
var nodes = new[] 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 = 2, StartStop = 1, Distance = 500 },
new Fass2SimNodeMessage { Node = 3, StartStop = 2, Distance = 500 } new Fass2SimNodeMessage { Node = 3, StartStop = 2 }
}; };
vehicle.State = 1; vehicle.State = 1;
@@ -57,6 +58,7 @@ namespace StandardScene.Fass2Simulator
private static int RunActionSelfTest() private static int RunActionSelfTest()
{ {
Fass2SimFileLogger.Configure(new Fass2SimConfig { EnableFileLog = false });
var config = new Fass2SimConfig var config = new Fass2SimConfig
{ {
InitialNode = 3, InitialNode = 3,
@@ -69,7 +69,7 @@ namespace StandardScene.Fass2Simulator
Fass2SimLog.WriteLine($" 路段模拟 : dist={_config.DefaultSegmentDistance}mm 或 node.Distance, speed={_config.DefaultSpeed}mm/s"); Fass2SimLog.WriteLine($" 路段模拟 : dist={_config.DefaultSegmentDistance}mm 或 node.Distance, speed={_config.DefaultSpeed}mm/s");
Fass2SimLog.WriteLine($" 动作延时 : {_config.ActionDelayMs}ms0xA1 到位),AutoComplete={_config.AutoCompleteStationActions}"); Fass2SimLog.WriteLine($" 动作延时 : {_config.ActionDelayMs}ms0xA1 到位),AutoComplete={_config.AutoCompleteStationActions}");
Fass2SimLog.WriteLine($" 初始节点 : {_config.InitialNode}, 状态={Fass2SimProtocol.StateText(_config.InitialState)}"); 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() public void Stop()
@@ -21,6 +21,7 @@
<ItemGroup> <ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" /> <None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.car2.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="sim-nodes.json" CopyToOutputDirectory="PreserveNewest" /> <None Update="sim-nodes.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </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": { "Fass2Simulator": {
"VehicleCode": 1, "VehicleCode": 1,
"VehicleListenAddress": "0.0.0.0", "VehicleListenAddress": "0.0.0.0",
"VehicleListenPort": 5000, "VehicleListenPort": 5001,
"SchedulerHost": "127.0.0.1", "SchedulerHost": "127.0.0.1",
"SchedulerListenPort": 20103, "SchedulerListenPort": 20103,
"ReportIntervalMs": 200, "ReportIntervalMs": 200,
"InitialNode": 1, "InitialNode": 1,
"InitialState": 0, "InitialState": 1,
"BatteryCharge": 100, "BatteryCharge": 100,
"CarLength": 1200, "CarLength": 1200,
"CarWidth": 800, "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> /// <summary>
/// scene.mag 平台画像:磁导航场景插件。 /// scene.mag 平台画像:磁导航场景插件。
/// <para>车型:<see cref="MagCar"/>FASS 1.0 TCP)、<see cref="MagFass2Car"/>FASS 2.0 UDP/任务状态机)。 /// <para>车型:<see cref="MagCar"/>FASS 1.0 TCP)、<see cref="Mag2Car"/>FASS 2.0 UDP/任务状态机)。
/// Loop 导航:继续使用 Core <c>LoopMission</c> 或本插件 <c>MagFass2LoopMission</c> 分配 <c>goalSite</c> /// Loop 导航:继续使用 Core <c>LoopMission</c> 或本插件 <c>Mag2LoopMission</c> 分配 <c>goalSite</c>
/// <see cref="MagFass2Car"/> 拦截编译脚本后一次下发全程 <c>0xB1</c>。</para> /// <see cref="Mag2Car"/> 拦截编译脚本后一次下发全程 <c>0xB1</c>。</para>
/// 宿主(SimpleLite)加载本 dll 后反射实例化并 OnActivate / 注册。 /// 宿主(SimpleLite)加载本 dll 后反射实例化并 OnActivate / 注册。
/// </summary> /// </summary>
public sealed class MagneticSceneProfile : NavigationProfileBase public sealed class MagneticSceneProfile : NavigationProfileBase
@@ -23,12 +23,12 @@ namespace StandardScene.Magnetic
public override IReadOnlyList<Type> CarTypes => new[] public override IReadOnlyList<Type> CarTypes => new[]
{ {
typeof(MagCar), typeof(MagCar),
typeof(MagFass2Car), typeof(Mag2Car),
}; };
public override void OnActivate(ISceneContext context) 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> /// </summary>
public static class Fass2StateCodec 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) if (report == null)
{ {
@@ -227,7 +227,7 @@ namespace StandardScene.Magnetic.Protocol
} }
Diagnosis.Post( Diagnosis.Post(
$"Fass2UdpHub 收到 Car={carCode} 的状态,但未注册该编号(已注册: {registered})。请检查 MagFass2Car.VehicleCode 与模拟器车号是否一致,并重新启动场景。", $"Fass2UdpHub 收到 Car={carCode} 的状态,但未注册该编号(已注册: {registered})。请检查 Mag2Car.VehicleCode 与模拟器车号是否一致,并重新启动场景。",
"Fass2UdpHub", "Fass2UdpHub",
true); true);
} }
@@ -6,7 +6,7 @@
"coreVersion": ">=1.0.0", "coreVersion": ">=1.0.0",
"requiresCore": "StandardScene.dll", "requiresCore": "StandardScene.dll",
"provides": { "provides": {
"carTypes": [ "MagCar", "MagFass2Car" ], "carTypes": [ "MagCar", "Mag2Car" ],
"missionTypes": [ "EventCarMission", "MagFass2LoopMission" ] "missionTypes": [ "EventCarMission", "Mag2LoopMission" ]
} }
} }
@@ -7,15 +7,15 @@ namespace StandardScene.Magnetic.Tasking
{ {
/// <summary> /// <summary>
/// 比对期望节点与上报节点,判定到站与动作是否完成(对齐 backend <c>CarResponseService</c>)。 /// 比对期望节点与上报节点,判定到站与动作是否完成(对齐 backend <c>CarResponseService</c>)。
/// 默认只闭环机构类动作;轨迹字段(Speed/Orientation 等)需显式开启。
/// </summary> /// </summary>
public static class Fass2ActionResolver 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), ("Obstacle", n => n.Obstacle, (n, v) => n.Obstacle = v),
("Audio", n => n.Audio, (n, v) => n.Audio = v), ("Audio", n => n.Audio, (n, v) => n.Audio = v),
("Light", n => n.Light, (n, v) => n.Light = 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) ("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) public static bool IsAtNode(Fass2StateReport report, ushort targetNode)
{ {
return report?.Node != null && report.Node.Node == targetNode; return report?.Node != null && report.Node.Node == targetNode;
@@ -35,7 +42,7 @@ namespace StandardScene.Magnetic.Tasking
public static bool IsVehicleMoving(byte vehicleState) public static bool IsVehicleMoving(byte vehicleState)
{ {
return vehicleState == 1; return vehicleState is 1 or 5;
} }
public static bool RequiresActionWait(Fass2NodeMessage expected) public static bool RequiresActionWait(Fass2NodeMessage expected)
@@ -45,12 +52,28 @@ namespace StandardScene.Magnetic.Tasking
return false; 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; 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) if (field.Get(expected) != 0)
{ {
@@ -83,12 +106,16 @@ namespace StandardScene.Magnetic.Tasking
return true; return true;
} }
// 过站(StartStop=1) 只要求到点且非运行态,不要求 Orientation 等轨迹字段到位。
if (expected.StartStop == Fass2TaskBuilder.StartStopPass) if (expected.StartStop == Fass2TaskBuilder.StartStopPass)
{ {
return true; return true;
} }
if (expected.StartStop == Fass2TaskBuilder.StartStopControlStop)
{
return false;
}
return ListPendingFields(expected, actual).Count == 0; return ListPendingFields(expected, actual).Count == 0;
} }
@@ -105,18 +132,25 @@ namespace StandardScene.Magnetic.Tasking
pending.Add("StartStop"); pending.Add("StartStop");
} }
if (VerifyTrajectoryFields)
{
if (expected.Speed != 0 && actual.Speed != expected.Speed) if (expected.Speed != 0 && actual.Speed != expected.Speed)
{ {
pending.Add("Speed"); pending.Add("Speed");
} }
foreach (var field in ActionFields) foreach (var field in TrajectoryFields)
{ {
if (field.Name == "StartStop") var expectedValue = field.Get(expected);
if (expectedValue != 0 && field.Get(actual) != expectedValue)
{ {
continue; pending.Add(field.Name);
}
}
} }
foreach (var field in MechanismFields)
{
var expectedValue = field.Get(expected); var expectedValue = field.Get(expected);
if (expectedValue != 0 && field.Get(actual) != expectedValue) if (expectedValue != 0 && field.Get(actual) != expectedValue)
{ {
@@ -143,19 +177,27 @@ namespace StandardScene.Magnetic.Tasking
hasPatch = true; hasPatch = true;
} }
if (VerifyTrajectoryFields)
{
if (expected.Speed != 0 && (actual == null || actual.Speed != expected.Speed)) if (expected.Speed != 0 && (actual == null || actual.Speed != expected.Speed))
{ {
patch.Speed = expected.Speed; patch.Speed = expected.Speed;
hasPatch = true; hasPatch = true;
} }
foreach (var field in ActionFields) foreach (var field in TrajectoryFields)
{ {
if (field.Name == "StartStop") var expectedValue = field.Get(expected);
if (expectedValue != 0 && (actual == null || field.Get(actual) != expectedValue))
{ {
continue; field.Set(patch, expectedValue);
hasPatch = true;
}
}
} }
foreach (var field in MechanismFields)
{
var expectedValue = field.Get(expected); var expectedValue = field.Get(expected);
if (expectedValue != 0 && (actual == null || field.Get(actual) != expectedValue)) 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 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) 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.Shutdown = TryReadByte(site.fields, Fass2SiteFields.Shutdown);
data.PrecisionStop = TryReadBool(site.fields, Fass2SiteFields.PrecisionStop); data.PrecisionStop = TryReadBool(site.fields, Fass2SiteFields.PrecisionStop);
data.WaitMode = TryReadString(site.fields, Fass2SiteFields.WaitMode); 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; return data;
} }
@@ -99,5 +108,40 @@ namespace StandardScene.Magnetic.Tasking
{ {
return fields.TryGetValue(key, out var text) ? text : null; 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 Shutdown = "Fass2_Shutdown";
public const string PrecisionStop = "Fass2_PrecisionStop"; public const string PrecisionStop = "Fass2_PrecisionStop";
public const string WaitMode = "Fass2_WaitMode"; 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> /// <summary>
@@ -46,5 +49,8 @@ namespace StandardScene.Magnetic.Tasking
public byte? Shutdown { get; set; } public byte? Shutdown { get; set; }
public bool PrecisionStop { get; set; } public bool PrecisionStop { get; set; }
public string WaitMode { 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 StandardScene.Magnetic.Protocol;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking namespace StandardScene.Magnetic.Tasking
{ {
/// <summary> /// <summary>
/// 将站点路径与站点/边 fields 合并为 FASS 2.0 <c>0xB1</c> 节点序列。 /// 将站点路径与站点/边 fields 合并为 FASS 2.0 <c>0xB1</c> 节点序列。
/// 对齐 backend <c>CarRequestService.GetSendNodes</c> 的组包规则 /// 对齐 backend <c>CarRequestService.GetSendNodes</c>:边属性挂在节点<b>出边</b>上
/// </summary> /// </summary>
public static class Fass2TaskBuilder public static class Fass2TaskBuilder
{ {
public const byte StartStopPass = 1; public const byte StartStopPass = 1;
public const byte StartStopStop = 2; public const byte StartStopStop = 2;
public const byte StartStopControlStart = 11;
public const byte StartStopControlStop = 12;
public const byte StartStopPrecision = 22; public const byte StartStopPrecision = 22;
public static Fass2TaskPlan BuildPath(int startSiteId, int goalSiteId, Fass2TaskBuildOptions options, public static Fass2TaskPlan BuildPath(int startSiteId, int goalSiteId, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId) Func<int, ushort> resolveNodeId, AbstractCar routingCar = null)
{ {
if (resolveNodeId == null) if (resolveNodeId == null)
{ {
@@ -26,7 +27,7 @@ namespace StandardScene.Magnetic.Tasking
} }
options ??= new Fass2TaskBuildOptions(); options ??= new Fass2TaskBuildOptions();
var siteIds = Fass2PathFinder.GetSitesBetween(startSiteId, goalSiteId); var siteIds = Fass2RouteHelper.GetSitesBetween(startSiteId, goalSiteId, routingCar);
if (siteIds.Count == 0) if (siteIds.Count == 0)
{ {
throw new InvalidOperationException($"no path from site {startSiteId} to {goalSiteId}"); 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, 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) 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 site = SimpleLib.GetSite(siteId);
var siteAction = Fass2SiteFieldReader.Read(site); var siteAction = Fass2SiteFieldReader.Read(site);
var isLast = i == siteIds.Count - 1; var isLast = i == siteIds.Count - 1;
var prevSiteId = i > 0 ? siteIds[i - 1] : siteId; var nextSiteId = isLast ? -1 : siteIds[i + 1];
Fass2TrackMotionData trackMotion = null; Fass2TrackMotionData trackMotion = null;
Site prevSite = null; Site currentSite = site;
if (i > 0) Site nextSite = null;
if (!isLast)
{ {
prevSite = SimpleLib.GetSite(prevSiteId); nextSite = SimpleLib.GetSite(nextSiteId);
var track = FindTrack(prevSiteId, siteId); var track = Fass2RouteHelper.FindTrack(siteId, nextSiteId);
trackMotion = Fass2TrackFieldReader.Read(track); trackMotion = Fass2TrackFieldReader.Read(track);
} }
@@ -103,9 +113,11 @@ namespace StandardScene.Magnetic.Tasking
{ {
Node = resolveNodeId(siteId), Node = resolveNodeId(siteId),
StartStop = ResolveStartStop(siteAction, isLast), StartStop = ResolveStartStop(siteAction, isLast),
Distance = i > 0 ? ComputeEdgeDistance(prevSite, site) : (ushort)0, Distance = !isLast ? ComputeEdgeDistance(currentSite, nextSite) : (ushort)0,
Speed = SpeedToProtocol(trackMotion?.Speed ?? options.DefaultSpeed, options.CarSpeed), Speed = !isLast
Orientation = ResolveOrientation(prevSite, site, trackMotion), ? SpeedToProtocol(trackMotion?.Speed ?? options.DefaultSpeed)
: (ushort)0,
Orientation = !isLast ? ResolveOrientation(currentSite, nextSite, trackMotion) : (byte)0,
Byroad = siteAction.Byroad ?? trackMotion?.Byroad ?? 0, Byroad = siteAction.Byroad ?? trackMotion?.Byroad ?? 0,
Direction = siteAction.Direction ?? trackMotion?.Direction ?? 0, Direction = siteAction.Direction ?? trackMotion?.Direction ?? 0,
Lift = siteAction.Lift ?? 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))); 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; if (speed > 0 && speed <= 1)
var protocolSpeed = value <= 1 {
? (int)Math.Round(value * 600) speed *= 60;
: (int)Math.Round(value * 10);
return (ushort)Math.Max(1, Math.Min(10000, protocolSpeed));
} }
private static Track FindTrack(int fromSiteId, int toSiteId) return (ushort)Math.Max(1, Math.Min(10000, Math.Round(speed * 10)));
{
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;
} }
} }
} }
@@ -36,6 +36,18 @@ namespace StandardScene.Magnetic.Tasking
public DateTime LastDispatchAt { get; set; } = DateTime.MinValue; public DateTime LastDispatchAt { get; set; } = DateTime.MinValue;
public ulong LastActionId { get; set; } public ulong LastActionId { get; set; }
public DateTime LastActionSentAt { get; set; } = DateTime.MinValue; 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 public static class Fass2TaskPersistence
@@ -47,6 +59,7 @@ namespace StandardScene.Magnetic.Tasking
public const string TagTaskId = "Fass2Task_TaskId"; public const string TagTaskId = "Fass2Task_TaskId";
public const string TagFieldsSignature = "Fass2Task_FieldsSig"; public const string TagFieldsSignature = "Fass2Task_FieldsSig";
public const string TagDefaultSpeed = "Fass2Task_DefaultSpeed"; public const string TagDefaultSpeed = "Fass2Task_DefaultSpeed";
public const string TagRouteSites = "Fass2Task_RouteSites";
public static void Save(TagSet tags, Fass2TaskContext context) public static void Save(TagSet tags, Fass2TaskContext context)
{ {
@@ -62,6 +75,7 @@ namespace StandardScene.Magnetic.Tasking
SetTag(tags, TagTaskId, context.TaskId.ToString()); SetTag(tags, TagTaskId, context.TaskId.ToString());
SetTag(tags, TagFieldsSignature, context.FieldsSignature ?? string.Empty); SetTag(tags, TagFieldsSignature, context.FieldsSignature ?? string.Empty);
SetTag(tags, TagDefaultSpeed, context.DefaultSpeed.ToString(System.Globalization.CultureInfo.InvariantCulture)); 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) public static bool TryLoad(TagSet tags, out Fass2TaskContext context)
@@ -93,6 +107,7 @@ namespace StandardScene.Magnetic.Tasking
System.Globalization.CultureInfo.InvariantCulture, System.Globalization.CultureInfo.InvariantCulture,
out var defaultSpeed); out var defaultSpeed);
tags.TryGetValue(TagRouteSites, out var routeSitesText);
context = new Fass2TaskContext context = new Fass2TaskContext
{ {
Phase = phase, Phase = phase,
@@ -101,7 +116,8 @@ namespace StandardScene.Magnetic.Tasking
CurrentIndex = currentIndex, CurrentIndex = currentIndex,
TaskId = taskId, TaskId = taskId,
FieldsSignature = fieldsSignature ?? string.Empty, FieldsSignature = fieldsSignature ?? string.Empty,
DefaultSpeed = defaultSpeed DefaultSpeed = defaultSpeed,
RouteSiteIds = ParseIntList(routeSitesText)
}; };
return true; return true;
} }
@@ -120,6 +136,7 @@ namespace StandardScene.Magnetic.Tasking
RemoveTag(tags, TagTaskId); RemoveTag(tags, TagTaskId);
RemoveTag(tags, TagFieldsSignature); RemoveTag(tags, TagFieldsSignature);
RemoveTag(tags, TagDefaultSpeed); RemoveTag(tags, TagDefaultSpeed);
RemoveTag(tags, TagRouteSites);
} }
private static void SetTag(TagSet tags, string key, string value) private static void SetTag(TagSet tags, string key, string value)
@@ -145,5 +162,30 @@ namespace StandardScene.Magnetic.Tasking
value = 0; value = 0;
return tags.TryGetValue(key, out var text) && int.TryParse(text, out value); 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 sealed class Fass2TaskBuildOptions
{ {
public bool UseTagValueAsNode { get; set; } 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 double CarSpeed { get; set; } = 1;
public int MaxNodesPerFrame { get; set; } = 10; public int MaxNodesPerFrame { get; set; } = 10;
} }
@@ -3,6 +3,7 @@ using SimpleCore.PropType;
using StandardScene.Magnetic.Protocol; using StandardScene.Magnetic.Protocol;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -21,6 +22,27 @@ namespace StandardScene.Magnetic.Tasking
public Action ClearPersisted { get; set; } public Action ClearPersisted { get; set; }
public Func<ulong> AllocateTaskId { get; set; } public Func<ulong> AllocateTaskId { get; set; }
public Func<ulong> AllocateActionId { get; set; } public Func<ulong> AllocateActionId { get; set; }
/// <summary>规划完成后 Forecast 建序(seqScope/pendingLocks)。</summary>
public Action<IReadOnlyList<int>, int> PrepareTraffic { get; set; }
/// <summary>返回当前可下发窗口长度(已锁站点数,含当前站)。</summary>
public Func<IReadOnlyList<int>, int, int, int> EnsureTrafficWindow { get; set; }
/// <summary>段完成后释放刚离开的站点。</summary>
public Action<int> LeaveTraffic { get; set; }
/// <summary>按路径索引释放后方锁点(keepFromIndex 之前)。</summary>
public Action<IReadOnlyList<int>, int> ReleaseTrafficBehind { get; set; }
/// <summary>重连对齐:Reset 当前站并从 fromIndex 重建前方窗口。</summary>
public Action<IReadOnlyList<int>, int> RebaseTraffic { get; set; }
/// <summary>全程完成,保留终点 holding。</summary>
public Action<int> FinalizeTraffic { get; set; }
/// <summary>管控停止到站后,判断同区他车占用是否已允许放行本车。</summary>
public Func<int, Fass2ControlReleaseCheck> CheckControlRelease { get; set; }
} }
public sealed class Fass2TaskTickResult public sealed class Fass2TaskTickResult
@@ -110,6 +132,10 @@ namespace StandardScene.Magnetic.Tasking
Context.LastDispatchAt = DateTime.MinValue; Context.LastDispatchAt = DateTime.MinValue;
Context.LastActionSentAt = DateTime.MinValue; Context.LastActionSentAt = DateTime.MinValue;
Context.LastActionId = 0; Context.LastActionId = 0;
Context.WaitingForTraffic = false;
Context.WaitingForRelease = false;
Context.ControlReleasedIndex = -1;
Context.RouteSiteIds = Array.Empty<int>();
Log($"task begin start={startSiteId}, goal={goalSiteId}, speed={defaultSpeed}"); Log($"task begin start={startSiteId}, goal={goalSiteId}, speed={defaultSpeed}");
Persist(); Persist();
} }
@@ -142,6 +168,10 @@ namespace StandardScene.Magnetic.Tasking
Context.LastActionSentAt = DateTime.MinValue; Context.LastActionSentAt = DateTime.MinValue;
Context.Plan = null; Context.Plan = null;
Context.FaultReason = null; Context.FaultReason = null;
Context.RouteSiteIds = saved.RouteSiteIds ?? Array.Empty<int>();
Context.WaitingForTraffic = false;
Context.WaitingForRelease = false;
Context.ControlReleasedIndex = -1;
if (Context.Phase == Fass2TaskPhase.Planning) if (Context.Phase == Fass2TaskPhase.Planning)
{ {
@@ -157,6 +187,134 @@ namespace StandardScene.Magnetic.Tasking
} }
} }
public bool TryFindIndexOnRoute(int siteId, out int index)
{
lock (_syncRoot)
{
return TryFindIndexOnRouteUnlocked(siteId, out index);
}
}
public bool IsAtRouteEnd(int siteId)
{
lock (_syncRoot)
{
var sites = GetRouteSiteIds();
return sites.Count > 0 && sites[sites.Count - 1] == siteId;
}
}
/// <summary>
/// 上报站仍在原任务链路上:对齐 index,从该站重建后方锁窗口,继续同一 goal。
/// 不取消等待中的任务。
/// </summary>
public bool RebaseToSite(int siteId)
{
lock (_syncRoot)
{
if (!IsRunning)
{
return false;
}
if (!TryEnsurePlanUnlocked())
{
return false;
}
if (!TryFindIndexOnRouteUnlocked(siteId, out var index))
{
return false;
}
Context.CurrentIndex = index;
RememberRoute(Context.Plan);
try
{
if (_callbacks.RebaseTraffic != null)
{
_callbacks.RebaseTraffic(Context.Plan.SiteIds, index);
}
else
{
_callbacks.PrepareTraffic?.Invoke(Context.Plan.SiteIds, index);
}
}
catch (Exception ex)
{
Log($"reconnect rebase traffic wait: {ex.Message}");
Context.WaitingForTraffic = true;
Context.Phase = Fass2TaskPhase.Moving;
Context.LastDispatchAt = DateTime.MinValue;
Context.StartedAt = DateTime.Now;
Persist();
return true;
}
Context.WaitingForTraffic = false;
Context.WaitingForRelease = false;
Context.Phase = Fass2TaskPhase.Moving;
Context.LastDispatchAt = DateTime.MinValue;
Context.StartedAt = DateTime.Now;
Log($"reconnect rebase site={siteId}, index={index}, goal={Context.GoalSiteId}");
Persist();
return true;
}
}
/// <summary>
/// 不在原链路:保留等待句柄,从新站重开同一/新 goal。
/// </summary>
public bool RestartFromSite(int startSiteId, int goalSiteId)
{
lock (_syncRoot)
{
if (!IsRunning)
{
return false;
}
Context.Phase = Fass2TaskPhase.Planning;
Context.StartSiteId = startSiteId;
Context.GoalSiteId = goalSiteId;
Context.CurrentIndex = 0;
Context.Plan = null;
Context.RouteSiteIds = Array.Empty<int>();
Context.TaskId = 0;
Context.FieldsSignature = string.Empty;
Context.LastDispatchAt = DateTime.MinValue;
Context.LastActionSentAt = DateTime.MinValue;
Context.LastActionId = 0;
Context.StartedAt = DateTime.Now;
Context.WaitingForTraffic = false;
Context.WaitingForRelease = false;
Context.ControlReleasedIndex = -1;
Context.FaultReason = null;
Log($"reconnect restart start={startSiteId}, goal={goalSiteId}");
Persist();
return true;
}
}
public bool TryCompleteAtSite(int siteId)
{
lock (_syncRoot)
{
if (IsIdle)
{
return false;
}
Context.Phase = Fass2TaskPhase.Complete;
TryFinalizeTraffic();
ClearPersisted();
Log($"reconnect complete at site={siteId}");
Completed?.Invoke(Context);
_completionSource?.TrySetResult(1);
return true;
}
}
public Fass2TaskTickResult Tick(Fass2StateReport report) public Fass2TaskTickResult Tick(Fass2StateReport report)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -216,6 +374,7 @@ namespace StandardScene.Magnetic.Tasking
Context.Phase = Fass2TaskPhase.Cancelled; Context.Phase = Fass2TaskPhase.Cancelled;
Context.FaultReason = reason; Context.FaultReason = reason;
Log($"task cancelled: {reason}"); Log($"task cancelled: {reason}");
TryFinalizeTraffic();
ClearPersisted(); ClearPersisted();
_completionSource?.TrySetException(new OperationCanceledException(reason)); _completionSource?.TrySetException(new OperationCanceledException(reason));
} }
@@ -235,7 +394,6 @@ namespace StandardScene.Magnetic.Tasking
waiter = _completionSource ?? ResetCompletionSource(); waiter = _completionSource ?? ResetCompletionSource();
} }
var timeoutTask = Task.Delay(timeoutMs, cancellationToken);
var pollTask = Task.Run(async () => var pollTask = Task.Run(async () =>
{ {
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
@@ -264,19 +422,12 @@ namespace StandardScene.Magnetic.Tasking
} }
}, cancellationToken); }, cancellationToken);
var finished = await Task.WhenAny(waiter.Task, timeoutTask); await WaitWithTrafficPauseAsync(waiter, timeoutMs, cancellationToken);
if (finished == timeoutTask)
{
Cancel("wait timeout");
throw new TimeoutException(
$"FASS2 task timeout after {timeoutMs}ms, phase={Context.Phase}, index={Context.CurrentIndex}, goal={Context.GoalSiteId}");
}
await waiter.Task;
} }
/// <summary> /// <summary>
/// UDP 模式由 Hub 回调驱动 Tick,此处只等待完成信号,避免与 OnUdpStateReceived 争用锁导致死锁。 /// UDP 模式由 Hub 回调驱动 Tick,此处只等待完成信号,避免与 OnUdpStateReceived 争用锁导致死锁。
/// 等锁(WaitingForTraffic)期间不消耗 timeoutMs。
/// </summary> /// </summary>
public async Task WaitForCompletionAsync(int timeoutMs, CancellationToken cancellationToken = default) public async Task WaitForCompletionAsync(int timeoutMs, CancellationToken cancellationToken = default)
{ {
@@ -291,14 +442,86 @@ namespace StandardScene.Magnetic.Tasking
waiter = _completionSource ?? ResetCompletionSource(); waiter = _completionSource ?? ResetCompletionSource();
} }
var timeoutTask = Task.Delay(timeoutMs, cancellationToken); await WaitWithTrafficPauseAsync(waiter, timeoutMs, cancellationToken);
var finished = await Task.WhenAny(waiter.Task, timeoutTask); }
if (finished == timeoutTask)
/// <summary>
/// 移动超时倒计时;交管等锁或管控等待放行时暂停扣减。
/// </summary>
private async Task WaitWithTrafficPauseAsync(TaskCompletionSource<int> waiter, int timeoutMs,
CancellationToken cancellationToken)
{
if (waiter == null)
{
throw new ArgumentNullException(nameof(waiter));
}
var remainingMs = timeoutMs <= 0 ? -1 : timeoutMs;
const int sliceMs = 500;
var lastPauseLog = DateTime.MinValue;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
if (waiter.Task.IsCompleted)
{
await waiter.Task;
return;
}
var delayMs = remainingMs < 0 ? sliceMs : Math.Min(sliceMs, Math.Max(1, remainingMs));
var delayTask = Task.Delay(delayMs, cancellationToken);
var finished = await Task.WhenAny(waiter.Task, delayTask);
if (finished == waiter.Task)
{
await waiter.Task;
return;
}
bool waitingPaused;
string pauseKind;
Fass2TaskPhase phase;
int index;
int goal;
lock (_syncRoot)
{
phase = Context.Phase;
index = Context.CurrentIndex;
goal = Context.GoalSiteId;
waitingPaused = Context.WaitingForTraffic || Context.WaitingForRelease;
pauseKind = Context.WaitingForRelease ? "control wait" : "traffic wait";
if (phase is Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled or Fass2TaskPhase.Fault)
{
break;
}
}
if (waitingPaused)
{
if ((DateTime.Now - lastPauseLog).TotalSeconds >= 5)
{
lastPauseLog = DateTime.Now;
Log($"timeout paused ({pauseKind}), remain={(remainingMs < 0 ? "inf" : remainingMs + "ms")}, index={index}, goal={goal}");
}
continue;
}
if (remainingMs < 0)
{
continue;
}
remainingMs -= delayMs;
if (remainingMs <= 0)
{ {
Cancel("wait timeout"); Cancel("wait timeout");
throw new TimeoutException( throw new TimeoutException(
$"FASS2 task timeout after {timeoutMs}ms, phase={Context.Phase}, index={Context.CurrentIndex}, goal={Context.GoalSiteId}"); $"FASS2 task timeout after {timeoutMs}ms, phase={Context.Phase}, index={Context.CurrentIndex}, goal={Context.GoalSiteId}");
} }
}
await waiter.Task; await waiter.Task;
} }
@@ -319,13 +542,33 @@ namespace StandardScene.Magnetic.Tasking
Context.FieldsSignature = Context.Plan.FieldsSignature; Context.FieldsSignature = Context.Plan.FieldsSignature;
Context.TaskId = _callbacks.AllocateTaskId(); Context.TaskId = _callbacks.AllocateTaskId();
Context.ControlReleasedIndex = -1;
Context.WaitingForRelease = false;
RememberRoute(Context.Plan);
try
{
_callbacks.PrepareTraffic?.Invoke(Context.Plan.SiteIds, Context.CurrentIndex);
}
catch (Exception ex)
{
return Fault($"traffic prepare failed: {ex.Message}", result);
}
if (StartBeforeMove && Context.CurrentIndex == 0) if (StartBeforeMove && Context.CurrentIndex == 0)
{ {
_callbacks.SendControl(Fass2Protocol.CmdStart, HeadingAngle); _callbacks.SendControl(Fass2Protocol.CmdStart, 0);
}
if (!TryDispatchCurrentWindow(out var waitReason))
{
result.Message = waitReason;
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
Persist();
return result;
} }
DispatchCurrentWindow();
Context.Phase = Fass2TaskPhase.Moving; Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase; result.Phase = Context.Phase;
result.Dispatched = true; result.Dispatched = true;
@@ -337,16 +580,26 @@ namespace StandardScene.Magnetic.Tasking
private Fass2TaskTickResult HandleMoving(Fass2StateReport report, Fass2TaskTickResult result) private Fass2TaskTickResult HandleMoving(Fass2StateReport report, Fass2TaskTickResult result)
{ {
if (TryRebuildForFieldsChange(result)) if (TryRebuildForFieldsChange(report, result))
{ {
return result; return result;
} }
if (ShouldResend()) if (ShouldResend())
{ {
DispatchCurrentWindow(); if (TryDispatchCurrentWindow(out var waitReason))
{
result.Dispatched = true; result.Dispatched = true;
} }
else if (string.IsNullOrEmpty(result.Message))
{
result.Message = waitReason;
}
}
// 滑动窗口一次下发多站时,过站点可能被车体直接飞过;上报已到更后站则追赶 Leave + 推进 index。
TryCatchUpPassedSites(report, result);
ReleaseLocksBehindVehicle(report);
var expected = GetExpectedNode(); var expected = GetExpectedNode();
if (!Fass2ActionResolver.IsAtNode(report, expected.Node)) if (!Fass2ActionResolver.IsAtNode(report, expected.Node))
@@ -357,7 +610,11 @@ namespace StandardScene.Magnetic.Tasking
if (Fass2ActionResolver.IsVehicleMoving(report.State)) if (Fass2ActionResolver.IsVehicleMoving(report.State))
{ {
result.Message = $"at node={expected.Node}, still running"; // 仍报到本站时不要 Leave/推进:模拟器过站常保持 node=当前站并增加 dist。
// 真正飞过到更后站时由 TryCatchUpPassedSites 推进并放锁。
result.Message = Fass2ActionResolver.RequiresActionWait(expected)
? $"at node={expected.Node}, still running"
: $"pass node={expected.Node}, still running (wait leave)";
return result; return result;
} }
@@ -371,43 +628,55 @@ namespace StandardScene.Magnetic.Tasking
private Fass2TaskTickResult HandleAtStation(Fass2StateReport report, Fass2TaskTickResult result) private Fass2TaskTickResult HandleAtStation(Fass2StateReport report, Fass2TaskTickResult result)
{ {
if (TryRebuildForFieldsChange(result)) if (TryRebuildForFieldsChange(report, result))
{ {
return result; return result;
} }
if (ShouldResend()) if (ShouldResend())
{ {
DispatchCurrentWindow(); if (TryDispatchCurrentWindow(out var waitReason))
{
result.Dispatched = true; result.Dispatched = true;
} }
else if (string.IsNullOrEmpty(result.Message))
{
result.Message = waitReason;
}
}
if (TryCatchUpPassedSites(report, result))
{
Context.WaitingForRelease = false;
ReleaseLocksBehindVehicle(report);
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Message = $"catch-up from AtStation to index={Context.CurrentIndex}, node={report.Node.Node}";
return result;
}
ReleaseLocksBehindVehicle(report);
var expected = GetExpectedNode(); var expected = GetExpectedNode();
if (!Fass2ActionResolver.IsAtNode(report, expected.Node)) if (!Fass2ActionResolver.IsAtNode(report, expected.Node))
{ {
Context.WaitingForRelease = false;
Context.Phase = Fass2TaskPhase.Moving; Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase; result.Phase = Context.Phase;
result.Message = $"left station node={report.Node.Node}, expect={expected.Node}"; result.Message = $"left station node={report.Node.Node}, expect={expected.Node}";
return result; return result;
} }
if (!Fass2ActionResolver.IsStationActionComplete(expected, report.Node, report.State)) if (TryHandleControlStop(report, expected, result))
{ {
if (ShouldSendActionPatch()) return result;
{
var patch = Fass2ActionResolver.BuildActionPatch(expected, report.Node);
if (patch != null)
{
Context.LastActionId = _callbacks.AllocateActionId();
Context.LastActionSentAt = DateTime.Now;
_callbacks.SendAction(patch, Context.LastActionId);
result.ActionSent = true;
result.Message =
$"action pending [{Fass2ActionResolver.DescribePending(expected, report.Node)}], sent 0xA1={Context.LastActionId}";
Log(result.Message);
}
} }
Context.WaitingForRelease = false;
if (!Fass2ActionResolver.IsStationActionComplete(expected, report.Node, report.State))
{
TrySendActionPatch(expected, report, result);
return result; return result;
} }
@@ -419,13 +688,157 @@ namespace StandardScene.Magnetic.Tasking
return result; return result;
} }
private bool TryHandleControlStop(Fass2StateReport report, Fass2NodeMessage expected, Fass2TaskTickResult result)
{
var unreleased = Fass2ControlAreaGate.IsControlStop(expected.StartStop)
&& Context.ControlReleasedIndex != Context.CurrentIndex;
var releasedHere = Context.ControlReleasedIndex == Context.CurrentIndex;
if (!unreleased && !releasedHere)
{
return false;
}
Context.WaitingForRelease = true;
var isLast = Context.Plan != null && Context.CurrentIndex >= Context.Plan.Nodes.Count - 1;
if (Fass2ActionResolver.IsVehicleMoving(report.State))
{
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Message = $"control node={expected.Node}, vehicle running";
return true;
}
if (unreleased)
{
if (report.Node.StartStop != Fass2TaskBuilder.StartStopControlStop)
{
TrySendActionPatch(expected, report, result);
result.Message = $"control stop pending StartStop, node={expected.Node}";
return true;
}
var siteId = ResolveControlSiteId();
var check = _callbacks.CheckControlRelease?.Invoke(siteId)
?? new Fass2ControlReleaseCheck
{
CanRelease = true,
ReleaseStartStop = Fass2TaskBuilder.StartStopControlStart,
Reason = "no occupancy callback"
};
if (!check.CanRelease)
{
result.Message =
$"control wait site={siteId}, area={check.AreaId}, others={check.OthersInArea}/{check.Capacity}, {check.Reason}";
return true;
}
var releaseSs = check.ReleaseStartStop == 0
? Fass2TaskBuilder.StartStopControlStart
: check.ReleaseStartStop;
expected.StartStop = releaseSs;
Context.ControlReleasedIndex = Context.CurrentIndex;
SendControlReleasePatch(expected, releaseSs, result);
if (TryDispatchCurrentWindow(out var waitReason))
{
result.Dispatched = true;
}
else if (string.IsNullOrEmpty(result.Message))
{
result.Message = waitReason;
}
result.Message =
$"control released site={siteId}, 12->{releaseSs}, area={check.AreaId}, others={check.OthersInArea}/{check.Capacity}";
Log(result.Message);
Persist();
return true;
}
if (report.Node.StartStop != expected.StartStop)
{
TrySendActionPatch(expected, report, result);
result.Message = $"control release pending StartStop={expected.StartStop}, node={expected.Node}";
return true;
}
if (isLast)
{
Context.WaitingForRelease = false;
Context.Phase = Fass2TaskPhase.SegmentDone;
result.Phase = Context.Phase;
result.Message = $"control last-station done index={Context.CurrentIndex}, node={expected.Node}";
Log(result.Message);
Persist();
return true;
}
result.Message = $"control released waiting leave, node={expected.Node}";
return true;
}
private void TrySendActionPatch(Fass2NodeMessage expected, Fass2StateReport report, Fass2TaskTickResult result)
{
if (!ShouldSendActionPatch())
{
return;
}
var patch = Fass2ActionResolver.BuildActionPatch(expected, report?.Node);
if (patch == null)
{
return;
}
Context.LastActionId = _callbacks.AllocateActionId();
Context.LastActionSentAt = DateTime.Now;
_callbacks.SendAction(patch, Context.LastActionId);
result.ActionSent = true;
result.Message =
$"action pending [{Fass2ActionResolver.DescribePending(expected, report?.Node)}], sent 0xA1={Context.LastActionId}";
Log(result.Message);
}
private void SendControlReleasePatch(Fass2NodeMessage expected, byte releaseStartStop, Fass2TaskTickResult result)
{
var patch = new Fass2NodeMessage
{
Node = expected.Node,
StartStop = releaseStartStop
};
Context.LastActionId = _callbacks.AllocateActionId();
Context.LastActionSentAt = DateTime.Now;
_callbacks.SendAction(patch, Context.LastActionId);
result.ActionSent = true;
}
private int ResolveControlSiteId()
{
if (Context.Plan?.SiteIds != null &&
Context.CurrentIndex >= 0 &&
Context.CurrentIndex < Context.Plan.SiteIds.Count)
{
return Context.Plan.SiteIds[Context.CurrentIndex];
}
return Context.StartSiteId;
}
private Fass2TaskTickResult HandleSegmentDone(Fass2StateReport report, Fass2TaskTickResult result) private Fass2TaskTickResult HandleSegmentDone(Fass2StateReport report, Fass2TaskTickResult result)
{ {
var finishedIndex = Context.CurrentIndex;
Context.CurrentIndex++; Context.CurrentIndex++;
result.Advanced = true; result.Advanced = true;
Context.WaitingForRelease = false;
if (Context.Plan == null || Context.CurrentIndex >= Context.Plan.Nodes.Count) if (Context.Plan == null || Context.CurrentIndex >= Context.Plan.Nodes.Count)
{ {
if (Context.Plan?.SiteIds != null && finishedIndex < Context.Plan.SiteIds.Count)
{
_callbacks.FinalizeTraffic?.Invoke(Context.Plan.SiteIds[finishedIndex]);
}
Context.Phase = Fass2TaskPhase.Complete; Context.Phase = Fass2TaskPhase.Complete;
result.Phase = Context.Phase; result.Phase = Context.Phase;
result.Completed = true; result.Completed = true;
@@ -437,8 +850,23 @@ namespace StandardScene.Magnetic.Tasking
return result; return result;
} }
if (Context.Plan.SiteIds != null && finishedIndex < Context.Plan.SiteIds.Count)
{
_callbacks.LeaveTraffic?.Invoke(Context.Plan.SiteIds[finishedIndex]);
}
ReleaseLocksBehindVehicle(report);
Context.Phase = Fass2TaskPhase.Dispatching; Context.Phase = Fass2TaskPhase.Dispatching;
DispatchCurrentWindow(); if (!TryDispatchCurrentWindow(out var waitReason))
{
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Message = waitReason;
Persist();
return result;
}
Context.Phase = Fass2TaskPhase.Moving; Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase; result.Phase = Context.Phase;
result.Dispatched = true; result.Dispatched = true;
@@ -448,7 +876,7 @@ namespace StandardScene.Magnetic.Tasking
return result; return result;
} }
private bool TryRebuildForFieldsChange(Fass2TaskTickResult result) private bool TryRebuildForFieldsChange(Fass2StateReport report, Fass2TaskTickResult result)
{ {
if (Context.Plan?.SiteIds == null || Context.Plan.SiteIds.Count == 0) if (Context.Plan?.SiteIds == null || Context.Plan.SiteIds.Count == 0)
{ {
@@ -462,7 +890,8 @@ namespace StandardScene.Magnetic.Tasking
return false; return false;
} }
var currentSiteId = Context.Plan.SiteIds[Context.CurrentIndex]; // 优先用车辆实际报到站重建,避免逻辑 index 超前时 Reset 到他车正占用的前方站。
var currentSiteId = ResolveRebuildStartSiteId(report);
Log($"fields changed at index={Context.CurrentIndex}, rebuild from site={currentSiteId}"); Log($"fields changed at index={Context.CurrentIndex}, rebuild from site={currentSiteId}");
Context.Plan = _callbacks.BuildPlan(currentSiteId, Context.GoalSiteId, Context.DefaultSpeed); Context.Plan = _callbacks.BuildPlan(currentSiteId, Context.GoalSiteId, Context.DefaultSpeed);
if (Context.Plan == null || Context.Plan.Nodes.Count == 0) if (Context.Plan == null || Context.Plan.Nodes.Count == 0)
@@ -474,7 +903,35 @@ namespace StandardScene.Magnetic.Tasking
Context.CurrentIndex = 0; Context.CurrentIndex = 0;
Context.FieldsSignature = Context.Plan.FieldsSignature; Context.FieldsSignature = Context.Plan.FieldsSignature;
Context.TaskId = _callbacks.AllocateTaskId(); Context.TaskId = _callbacks.AllocateTaskId();
DispatchCurrentWindow(); Context.ControlReleasedIndex = -1;
Context.WaitingForRelease = false;
RememberRoute(Context.Plan);
try
{
_callbacks.PrepareTraffic?.Invoke(Context.Plan.SiteIds, Context.CurrentIndex);
}
catch (Exception ex)
{
// 起点被他车占用等:等待,不要把整单打成 Fault
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Rebuilt = true;
result.Message = $"traffic rebuild prepare wait: {ex.Message}";
Log(result.Message);
Persist();
return true;
}
if (!TryDispatchCurrentWindow(out var waitReason))
{
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Rebuilt = true;
result.Message = waitReason;
Persist();
return true;
}
Context.Phase = Fass2TaskPhase.Moving; Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase; result.Phase = Context.Phase;
result.Rebuilt = true; result.Rebuilt = true;
@@ -484,28 +941,73 @@ namespace StandardScene.Magnetic.Tasking
return true; return true;
} }
private void DispatchCurrentWindow() private int ResolveRebuildStartSiteId(Fass2StateReport report)
{ {
var window = BuildDispatchWindow(); if (report?.Node != null && _callbacks.ResolveSite != null)
{
var physical = _callbacks.ResolveSite(report.Node.Node);
if (physical != null && physical.id > 0)
{
return physical.id;
}
}
if (Context.Plan?.SiteIds != null &&
Context.CurrentIndex >= 0 &&
Context.CurrentIndex < Context.Plan.SiteIds.Count)
{
return Context.Plan.SiteIds[Context.CurrentIndex];
}
return Context.StartSiteId;
}
private bool TryDispatchCurrentWindow(out string waitReason)
{
waitReason = null;
var window = BuildDispatchWindow(out var lockedCount, out var wantWindow);
if (window.Length == 0) if (window.Length == 0)
{ {
throw new InvalidOperationException("dispatch window is empty"); waitReason = "dispatch window is empty";
Context.WaitingForTraffic = true;
return false;
}
if (_callbacks.EnsureTrafficWindow != null && lockedCount < wantWindow && lockedCount <= 1)
{
waitReason = $"traffic wait, locked={lockedCount}/{wantWindow}, index={Context.CurrentIndex}";
Context.WaitingForTraffic = true;
return false;
} }
_callbacks.SendNodes(window, Context.TaskId); _callbacks.SendNodes(window, Context.TaskId);
Context.LastDispatchAt = DateTime.Now; Context.LastDispatchAt = DateTime.Now;
Log($"dispatch window count={window.Length}, fromIndex={Context.CurrentIndex}, task={Context.TaskId}"); Context.WaitingForTraffic = false;
Log($"dispatch window count={window.Length}, locked={lockedCount}/{wantWindow}, fromIndex={Context.CurrentIndex}, task={Context.TaskId}");
return true;
} }
private Fass2NodeMessage[] BuildDispatchWindow() private Fass2NodeMessage[] BuildDispatchWindow(out int lockedCount, out int wantWindow)
{ {
var nodes = Context.Plan.Nodes; var nodes = Context.Plan.Nodes;
var start = Context.CurrentIndex; var start = Context.CurrentIndex;
var remaining = nodes.Count - start; var remaining = nodes.Count - start;
var windowSize = Math.Max(1, Math.Min(LockCount, remaining)); wantWindow = Math.Max(1, Math.Min(LockCount, remaining));
windowSize = Math.Min(windowSize, 10); wantWindow = Math.Min(wantWindow, 10);
var window = new Fass2NodeMessage[windowSize]; wantWindow = Fass2ControlAreaGate.LimitWindowCount(
for (var i = 0; i < windowSize; i++) nodes, start, wantWindow, Context.ControlReleasedIndex);
lockedCount = wantWindow;
if (_callbacks.EnsureTrafficWindow != null && Context.Plan?.SiteIds != null)
{
lockedCount = _callbacks.EnsureTrafficWindow(Context.Plan.SiteIds, start, wantWindow);
}
lockedCount = Fass2ControlAreaGate.LimitWindowCount(
nodes, start, lockedCount, Context.ControlReleasedIndex);
lockedCount = Math.Max(1, Math.Min(lockedCount, wantWindow));
var window = new Fass2NodeMessage[lockedCount];
for (var i = 0; i < lockedCount; i++)
{ {
window[i] = nodes[start + i]; window[i] = nodes[start + i];
} }
@@ -518,6 +1020,99 @@ namespace StandardScene.Magnetic.Tasking
return Context.Plan.Nodes[Context.CurrentIndex]; return Context.Plan.Nodes[Context.CurrentIndex];
} }
/// <summary>
/// 当上报节点已是计划中更后方站点时,释放中间站交管锁并推进 CurrentIndex。
/// </summary>
private bool TryCatchUpPassedSites(Fass2StateReport report, Fass2TaskTickResult result)
{
if (Context.Plan?.Nodes == null || report?.Node == null)
{
return false;
}
var reportIndex = FindPlanIndexByNode(report.Node.Node, Context.CurrentIndex);
if (reportIndex <= Context.CurrentIndex)
{
return false;
}
var catchTo = reportIndex;
for (var i = Context.CurrentIndex; i < reportIndex; i++)
{
if (Fass2ControlAreaGate.IsControlStop(Context.Plan.Nodes[i].StartStop) &&
Context.ControlReleasedIndex != i)
{
catchTo = i;
break;
}
}
if (catchTo <= Context.CurrentIndex)
{
return false;
}
while (Context.CurrentIndex < catchTo)
{
var finishedIndex = Context.CurrentIndex;
if (Context.Plan.SiteIds != null && finishedIndex < Context.Plan.SiteIds.Count)
{
_callbacks.LeaveTraffic?.Invoke(Context.Plan.SiteIds[finishedIndex]);
}
Context.CurrentIndex++;
result.Advanced = true;
Log(
$"catch-up passed index={finishedIndex} -> {Context.CurrentIndex}, reportNode={report.Node.Node}");
}
Persist();
return true;
}
/// <summary>
/// 以车辆实际报到站为准释放身后锁;逻辑 index 若超前于物理站,不得放掉车辆仍在的站。
/// </summary>
private void ReleaseLocksBehindVehicle(Fass2StateReport report)
{
if (Context.Plan?.SiteIds == null || Context.Plan.SiteIds.Count == 0)
{
return;
}
var keepIndex = Context.CurrentIndex;
if (report?.Node != null)
{
var reportIndex = FindPlanIndexByNode(report.Node.Node, 0);
if (reportIndex >= 0)
{
// 取更靠后的“尚未离开”位置,避免 CurrentIndex 超前时把物理站 Leave 掉
keepIndex = Math.Min(keepIndex, reportIndex);
}
}
_callbacks.ReleaseTrafficBehind?.Invoke(Context.Plan.SiteIds, keepIndex);
}
private int FindPlanIndexByNode(ushort node, int fromIndex)
{
if (Context.Plan?.Nodes == null)
{
return -1;
}
fromIndex = Math.Max(0, fromIndex);
for (var i = fromIndex; i < Context.Plan.Nodes.Count; i++)
{
if (Context.Plan.Nodes[i].Node == node)
{
return i;
}
}
return -1;
}
private int ResolveCurrentSiteId(Fass2StateReport report) private int ResolveCurrentSiteId(Fass2StateReport report)
{ {
if (Context.CurrentIndex > 0 && Context.Plan?.SiteIds != null && if (Context.CurrentIndex > 0 && Context.Plan?.SiteIds != null &&
@@ -528,7 +1123,7 @@ namespace StandardScene.Magnetic.Tasking
if (report?.Node != null && report.Node.Node != 0) if (report?.Node != null && report.Node.Node != 0)
{ {
var site = _callbacks.ResolveSite(report.Node.Node); var site = _callbacks.ResolveSite?.Invoke(report.Node.Node);
if (site != null) if (site != null)
{ {
return site.id; return site.id;
@@ -558,12 +1153,115 @@ namespace StandardScene.Magnetic.Tasking
result.Faulted = true; result.Faulted = true;
result.Message = reason; result.Message = reason;
Log($"task fault: {reason}"); Log($"task fault: {reason}");
TryFinalizeTraffic();
Persist(); Persist();
Faulted?.Invoke(Context, reason); Faulted?.Invoke(Context, reason);
_completionSource?.TrySetException(new InvalidOperationException(reason)); _completionSource?.TrySetException(new InvalidOperationException(reason));
return result; return result;
} }
private void TryFinalizeTraffic()
{
if (Context.Plan?.SiteIds == null || Context.Plan.SiteIds.Count == 0)
{
return;
}
var index = Math.Max(0, Math.Min(Context.CurrentIndex, Context.Plan.SiteIds.Count - 1));
_callbacks.FinalizeTraffic?.Invoke(Context.Plan.SiteIds[index]);
}
private void RememberRoute(Fass2TaskPlan plan)
{
if (plan?.SiteIds == null || plan.SiteIds.Count == 0)
{
Context.RouteSiteIds = Array.Empty<int>();
return;
}
var copy = new int[plan.SiteIds.Count];
for (var i = 0; i < plan.SiteIds.Count; i++)
{
copy[i] = plan.SiteIds[i];
}
Context.RouteSiteIds = copy;
}
private IReadOnlyList<int> GetRouteSiteIds()
{
if (Context.Plan?.SiteIds != null && Context.Plan.SiteIds.Count > 0)
{
return Context.Plan.SiteIds;
}
return Context.RouteSiteIds ?? Array.Empty<int>();
}
private bool TryFindIndexOnRouteUnlocked(int siteId, out int index)
{
index = -1;
var sites = GetRouteSiteIds();
if (sites == null || sites.Count == 0 || siteId <= 0)
{
return false;
}
var from = Math.Max(0, Math.Min(Context.CurrentIndex, sites.Count - 1));
for (var i = from; i < sites.Count; i++)
{
if (sites[i] == siteId)
{
index = i;
return true;
}
}
for (var i = from - 1; i >= 0; i--)
{
if (sites[i] == siteId)
{
index = i;
return true;
}
}
return false;
}
private bool TryEnsurePlanUnlocked()
{
if (Context.Plan?.Nodes != null && Context.Plan.Nodes.Count > 0)
{
RememberRoute(Context.Plan);
return true;
}
if (_callbacks.BuildPlan == null || Context.GoalSiteId <= 0)
{
return false;
}
var startId = Context.StartSiteId > 0 ? Context.StartSiteId : Context.GoalSiteId;
try
{
Context.Plan = _callbacks.BuildPlan(startId, Context.GoalSiteId, Context.DefaultSpeed);
}
catch (Exception ex)
{
Log($"reconnect rebuild plan failed: {ex.Message}");
return false;
}
if (Context.Plan?.Nodes == null || Context.Plan.Nodes.Count == 0)
{
return false;
}
RememberRoute(Context.Plan);
return true;
}
private void Persist() private void Persist()
{ {
_callbacks.Persist?.Invoke(Context); _callbacks.Persist?.Invoke(Context);
@@ -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 namespace StandardScene.Magnetic.Tasking
{ {
/// <summary> /// <summary>
/// MagFass2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/magfass2_yyyyMMdd.log /// Mag2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/mag2_yyyyMMdd.log
/// </summary> /// </summary>
public static class MagFass2CarFileLogger public static class Mag2CarFileLogger
{ {
private static readonly ConcurrentDictionary<ushort, object> CarLocks = new ConcurrentDictionary<ushort, object>(); private static readonly ConcurrentDictionary<ushort, object> CarLocks = new ConcurrentDictionary<ushort, object>();
private static string _baseDirectory = "logs"; private static string _baseDirectory = "logs";
@@ -27,7 +27,7 @@ namespace StandardScene.Magnetic.Tasking
public static string GetCurrentLogFilePath(ushort vehicleCode) 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); return Path.Combine(GetCarLogDirectory(vehicleCode), fileName);
} }
@@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>MagFass2Car Guide</title> <title>Mag2Car Guide</title>
<style> <style>
@page { size: A4; margin: 18mm 16mm; } @page { size: A4; margin: 18mm 16mm; }
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; color: #222; line-height: 1.55; font-size: 11pt; } body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; color: #222; line-height: 1.55; font-size: 11pt; }
@@ -25,10 +25,10 @@
</style> </style>
</head> </head>
<body> <body>
<h1>MagFass2CarFASS 2.0)使用指南</h1> <h1>Mag2CarFASS 2.0)使用指南</h1>
<blockquote><p>适用对象:第一次接触磁导航 + FASS 2.0 的调试/实施人员 </p></blockquote> <blockquote><p>适用对象:第一次接触磁导航 + FASS 2.0 的调试/实施人员 </p></blockquote>
<blockquote><p>插件:`StandardScene.Magnetic.dll` </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> <blockquote><p>最后更新:2026-07</p></blockquote>
<hr/> <hr/>
<h2>目录</h2> <h2>目录</h2>
@@ -57,7 +57,7 @@
</ol> </ol>
<hr/> <hr/>
<h2>1. 这套系统能做什么</h2> <h2>1. 这套系统能做什么</h2>
<p><code>MagFass2Car</code> 是 StandardScene **磁导航平台插件**里的 FASS 2.0 车型,主要能力:</p> <p><code>Mag2Car</code> 是 StandardScene **磁导航平台插件**里的 FASS 2.0 车型,主要能力:</p>
<table> <table>
<thead><tr> <thead><tr>
<th>能力</th> <th>能力</th>
@@ -96,12 +96,12 @@
<h3>步骤 1:加载插件</h3> <h3>步骤 1:加载插件</h3>
<p>确认场景已加载 <code>scene.mag</code><code>StandardScene.Magnetic.scene.json</code>),提供:</p> <p>确认场景已加载 <code>scene.mag</code><code>StandardScene.Magnetic.scene.json</code>),提供:</p>
<ul> <ul>
<li>车型:<code>MagCar</code><code>MagFass2Car</code></li> <li>车型:<code>MagCar</code><code>Mag2Car</code></li>
<li>进程:<code>EventCarMission</code><code>MagFass2LoopMission</code></li> <li>进程:<code>EventCarMission</code><code>Mag2LoopMission</code></li>
</ul> </ul>
<h3>步骤 2:添加一辆车</h3> <h3>步骤 2:添加一辆车</h3>
<ol> <ol>
<li>在场景中添加车型 **<code>MagFass2Car</code>**</li> <li>在场景中添加车型 **<code>Mag2Car</code>**</li>
<li>填写基本参数(UDP 模式示例):</li> <li>填写基本参数(UDP 模式示例):</li>
</ol> </ol>
<table> <table>
@@ -150,8 +150,8 @@
<p>**方式 A — 手动 Go(Web/调度下发)** </p> <p>**方式 A — 手动 Go(Web/调度下发)** </p>
<p>用系统自带的「去某站」功能,内部会编译脚本,例如:</p> <p>用系统自带的「去某站」功能,内部会编译脚本,例如:</p>
<pre><code> <pre><code>
agv.MagFass2Go(1, 2, 0.2); agv.Mag2Go(1, 2, 0.2);
agv.MagFass2Go(2, 5, 0.2); agv.Mag2Go(2, 5, 0.2);
</code></pre> </code></pre>
<p>**方式 B — LoopMission 环线** </p> <p>**方式 B — LoopMission 环线** </p>
<ol> <ol>
@@ -165,11 +165,11 @@ agv.MagFass2Go(2, 5, 0.2);
<pre><code> <pre><code>
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ 业务层:LoopMission / 手动 Go / 脚本 │ │ 业务层:LoopMission / 手动 Go / 脚本 │
│ (分配 goalSite 或编译多行 MagFass2Go │ (分配 goalSite 或编译多行 Mag2Go
└────────────────────────┬────────────────────────────────┘ └────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ MagFass2Car 执行层 │ │ Mag2Car 执行层 │
&#183; 脚本拦截(Loop + goalSite)→ 一次全程任务 │ &#183; 脚本拦截(Loop + goalSite)→ 一次全程任务 │
&#183; Fass2TaskBuilder:路径 + fields → 节点序列 │ &#183; Fass2TaskBuilder:路径 + fields → 节点序列 │
&#183; Fass2TaskStateMachine:滑动窗口下发 + 动作闭环 │ &#183; Fass2TaskStateMachine:滑动窗口下发 + 动作闭环 │
@@ -186,7 +186,7 @@ agv.MagFass2Go(2, 5, 0.2);
<p>**关键理解:**</p> <p>**关键理解:**</p>
<ul> <ul>
<li>**LoopMissionCore** 只负责「给谁分配哪个目标站」(写 <code>goalSite</code> 标签)</li> <li>**LoopMissionCore** 只负责「给谁分配哪个目标站」(写 <code>goalSite</code> 标签)</li>
<li>**MagFass2Car** 负责「怎么走过去」(拼包、下发、等到站)</li> <li>**Mag2Car** 负责「怎么走过去」(拼包、下发、等到站)</li>
<li>**阶段 4** 后,Loop 不再逐行执行多段脚本,而是**拦截为一次全程任务**(可关)</li> <li>**阶段 4** 后,Loop 不再逐行执行多段脚本,而是**拦截为一次全程任务**(可关)</li>
</ul> </ul>
<hr/> <hr/>
@@ -195,8 +195,8 @@ agv.MagFass2Go(2, 5, 0.2);
<p>文件:<code>StandardScene.Magnetic.scene.json</code></p> <p>文件:<code>StandardScene.Magnetic.scene.json</code></p>
<pre><code> <pre><code>
{ {
&quot;carTypes&quot;: [ &quot;MagCar&quot;, &quot;MagFass2Car&quot; ], &quot;carTypes&quot;: [ &quot;MagCar&quot;, &quot;Mag2Car&quot; ],
&quot;missionTypes&quot;: [ &quot;EventCarMission&quot;, &quot;MagFass2LoopMission&quot; ] &quot;missionTypes&quot;: [ &quot;EventCarMission&quot;, &quot;Mag2LoopMission&quot; ]
} }
</code></pre> </code></pre>
<h3>4.2 选哪个 Mission</h3> <h3>4.2 选哪个 Mission</h3>
@@ -210,21 +210,21 @@ agv.MagFass2Go(2, 5, 0.2);
<tbody><tr> <tbody><tr>
<td><code>LoopMission</code></td> <td><code>LoopMission</code></td>
<td>Core</td> <td>Core</td>
<td>通用环线,MagFass2Car 同样可用</td> <td>通用环线,Mag2Car 同样可用</td>
</tr> </tr>
<tr> <tr>
<td><code>磁导航FASS2环线</code><code>MagFass2LoopMission</code></td> <td><code>磁导航FASS2环线</code><code>Mag2LoopMission</code></td>
<td>Magnetic 插件</td> <td>Magnetic 插件</td>
<td>逻辑与 LoopMission 相同,名称标识磁导航 FASS2 场景</td> <td>逻辑与 LoopMission 相同,名称标识磁导航 FASS2 场景</td>
</tr> </tr>
<tr> <tr>
<td><code>FASS车辆事件进程</code><code>EventCarMission</code></td> <td><code>FASS车辆事件进程</code><code>EventCarMission</code></td>
<td>Magnetic 插件</td> <td>Magnetic 插件</td>
<td>**仅 MagCarFASS 1.0**,不驱动 MagFass2Car</td> <td>**仅 MagCarFASS 1.0**,不驱动 Mag2Car</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<blockquote><p>新手建议:MagFass2Car 环线用 **`LoopMission`** 或 **`磁导航FASS2环线`** 均可。</p></blockquote> <blockquote><p>新手建议:Mag2Car 环线用 **`LoopMission`** 或 **`磁导航FASS2环线`** 均可。</p></blockquote>
<h3>4.3 tasklist.json 最小示例</h3> <h3>4.3 tasklist.json 最小示例</h3>
<pre><code> <pre><code>
{ {
@@ -253,7 +253,7 @@ agv.MagFass2Go(2, 5, 0.2);
<p>含义:车到站 1 → 自动分配目标站 2;到站 2 → 自动分配目标站 1,形成环线。</p> <p>含义:车到站 1 → 自动分配目标站 2;到站 2 → 自动分配目标站 1,形成环线。</p>
<hr/> <hr/>
<h2>5. 车辆参数说明</h2> <h2>5. 车辆参数说明</h2>
<p>在场景编辑器中选中 <code>MagFass2Car</code>,常用字段如下。</p> <p>在场景编辑器中选中 <code>Mag2Car</code>,常用字段如下。</p>
<h3>5.1 通讯</h3> <h3>5.1 通讯</h3>
<table> <table>
<thead><tr> <thead><tr>
@@ -426,8 +426,8 @@ agv.MagFass2Go(2, 5, 0.2);
<p>**流程**</p> <p>**流程**</p>
<pre><code> <pre><code>
FindRoute → Compile → 多行脚本: FindRoute → Compile → 多行脚本:
agv.MagFass2Go(1,2,speed); agv.Mag2Go(1,2,speed);
agv.MagFass2Go(2,3,speed); agv.Mag2Go(2,3,speed);
... ...
→ SelfEvaluating 逐行执行 → SelfEvaluating 逐行执行
→ 每行:锁终点 → 发 0xB1 → 等到站 → 释放起点 → 每行:锁终点 → 发 0xB1 → 等到站 → 释放起点
@@ -435,7 +435,7 @@ FindRoute → Compile → 多行脚本:
<p>**特点**:每**条边**一段任务,多次 <code>taskId</code>,与交管「逐段锁点」一致。</p> <p>**特点**:每**条边**一段任务,多次 <code>taskId</code>,与交管「逐段锁点」一致。</p>
<hr/> <hr/>
<h3>方式 2:Loop 全程任务(阶段 4,默认)</h3> <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> <p>**流程**</p>
<pre><code> <pre><code>
LoopMission 写 goalSite LoopMission 写 goalSite
@@ -446,7 +446,7 @@ LoopMission 写 goalSite
</code></pre> </code></pre>
<p>**特点**:业务上一次任务,协议上仍遵守每帧 ≤10 站。</p> <p>**特点**:业务上一次任务,协议上仍遵守每帧 ≤10 站。</p>
<hr/> <hr/>
<h3>方式 3:单段 MagFass2Go(脚本一行)</h3> <h3>方式 3:单段 Mag2Go(脚本一行)</h3>
<p>**触发**:脚本里只有一行,或无 <code>goalSite</code> 的测试。</p> <p>**触发**:脚本里只有一行,或无 <code>goalSite</code> 的测试。</p>
<p>**流程**:与方式 1 的单行相同,状态机只跑 <code>src → dst</code> 一段。</p> <p>**流程**:与方式 1 的单行相同,状态机只跑 <code>src → dst</code> 一段。</p>
<hr/> <hr/>
@@ -631,7 +631,7 @@ LoopMission 写 goalSite
</tr> </tr>
<tr> <tr>
<td>车辆开关</td> <td>车辆开关</td>
<td><code>MagFass2Car.UseTagValueAsNode = true</code></td> <td><code>Mag2Car.UseTagValueAsNode = true</code></td>
</tr> </tr>
<tr> <tr>
<td>示例</td> <td>示例</td>
@@ -1295,7 +1295,7 @@ LoopMission 写 goalSite
<td><code>Magnet</code></td> <td><code>Magnet</code></td>
<td>bool</td> <td>bool</td>
<td></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>
<tr> <tr>
<td><code>ReverseDst</code></td> <td><code>ReverseDst</code></td>
@@ -1433,7 +1433,7 @@ Fass2_Obstacle = 2
2. 匹配 tasklistcurrentStationId = N → 分配 targetStationId 2. 匹配 tasklistcurrentStationId = N → 分配 targetStationId
3. 写 tagsgoalSite = 目标站 ID 3. 写 tagsgoalSite = 目标站 ID
4. LoopStartAction 调用 GoSite 4. LoopStartAction 调用 GoSite
5. MagFass2Car 拦截 → 全程任务到 goalSite 5. Mag2Car 拦截 → 全程任务到 goalSite
6. 到达后 GoSite 清理 goalSite / occupied / loopAssigned 6. 到达后 GoSite 清理 goalSite / occupied / loopAssigned
7. 下一轮任务匹配 7. 下一轮任务匹配
</code></pre> </code></pre>
@@ -1462,13 +1462,13 @@ Fass2_Obstacle = 2
<ul> <ul>
<li>**不需要改 Core**<code>LoopMission</code> 逻辑完全沿用</li> <li>**不需要改 Core**<code>LoopMission</code> 逻辑完全沿用</li>
<li><code>磁导航FASS2环线</code> 仅为场景标识,行为与 <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> </ul>
<h3>9.4 关闭 Loop 拦截(恢复多段脚本)</h3> <h3>9.4 关闭 Loop 拦截(恢复多段脚本)</h3>
<pre><code> <pre><code>
EnableLoopTaskDrive = false EnableLoopTaskDrive = false
</code></pre> </code></pre>
<p>Loop 仍走 Compile 多行 <code>MagFass2Go</code>,每行一段边。</p> <p>Loop 仍走 Compile 多行 <code>Mag2Go</code>,每行一段边。</p>
<hr/> <hr/>
<h2>10. 任务状态机与协议下发</h2> <h2>10. 任务状态机与协议下发</h2>
<h3>10.1 状态流转</h3> <h3>10.1 状态流转</h3>
@@ -1595,7 +1595,7 @@ Idle → Planning → Moving → AtStation → SegmentDone → … → Complete
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p>日志前缀:<code>[MagFass2Car:车名(id)]</code>,任务状态机:<code>task-sm</code></p> <p>日志前缀:<code>[Mag2Car:车名(id)]</code>,任务状态机:<code>task-sm</code></p>
<hr/> <hr/>
<h2>12. 常见问题排查</h2> <h2>12. 常见问题排查</h2>
<h3>Q1:车显示离线(UDP</h3> <h3>Q1:车显示离线(UDP</h3>
@@ -1698,13 +1698,13 @@ Idle → Planning → Moving → AtStation → SegmentDone → … → Complete
<p>看日志:</p> <p>看日志:</p>
<ul> <ul>
<li><code>loop goal drive intercept</code> → 全程任务(阶段 4</li> <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> </ul>
<h3>Q6:与 MagCar 混用注意事项</h3> <h3>Q6:与 MagCar 混用注意事项</h3>
<ul> <ul>
<li><code>EventCarMission</code> **只控制 MagCar**</li> <li><code>EventCarMission</code> **只控制 MagCar**</li>
<li>同场景两种车型时,tasklist 和车辆类型要对应</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> </ul>
<hr/> <hr/>
<h2>13. 参数推荐与检查清单</h2> <h2>13. 参数推荐与检查清单</h2>
@@ -1722,7 +1722,7 @@ MoveTimeoutSeconds = 120
<h3>13.2 上线前检查清单</h3> <h3>13.2 上线前检查清单</h3>
<ul> <ul>
<li>[ ] 插件 <code>StandardScene.Magnetic.dll</code> 已加载</li> <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>[ ] <code>VehicleCode</code>、IP、端口与车体一致</li>
<li>[ ] UDP 能收到 100B,日志有 <code>0x10</code> 应答</li> <li>[ ] UDP 能收到 100B,日志有 <code>0x10</code> 应答</li>
<li>[ ] 关键站点 <code>TagValue</code> / <code>Fass2_*</code> 已配置</li> <li>[ ] 关键站点 <code>TagValue</code> / <code>Fass2_*</code> 已配置</li>
@@ -1785,7 +1785,7 @@ MoveTimeoutSeconds = 120
</thead> </thead>
<tbody><tr> <tbody><tr>
<td>车型</td> <td>车型</td>
<td><code>CarTypes/MagFass2Car.cs</code></td> <td><code>CarTypes/Mag2Car.cs</code></td>
</tr> </tr>
<tr> <tr>
<td>协议</td> <td>协议</td>
@@ -1805,7 +1805,7 @@ MoveTimeoutSeconds = 120
</tr> </tr>
<tr> <tr>
<td>环线 Mission</td> <td>环线 Mission</td>
<td><code>Chained/MagFass2LoopMission.cs</code></td> <td><code>Chained/Mag2LoopMission.cs</code></td>
</tr> </tr>
<tr> <tr>
<td>站点字段常量</td> <td>站点字段常量</td>
@@ -1,8 +1,8 @@
# MagFass2CarFASS 2.0)使用指南 # Mag2CarFASS 2.0)使用指南
> 适用对象:第一次接触磁导航 + FASS 2.0 的调试/实施人员 > 适用对象:第一次接触磁导航 + FASS 2.0 的调试/实施人员
> 插件:`StandardScene.Magnetic.dll` > 插件:`StandardScene.Magnetic.dll`
> 车型:`MagFass2Car` > 车型:`Mag2Car`
> 最后更新:2026-07 > 最后更新:2026-07
--- ---
@@ -33,7 +33,7 @@
## 1. 这套系统能做什么 ## 1. 这套系统能做什么
`MagFass2Car` 是 StandardScene **磁导航平台插件**里的 FASS 2.0 车型,主要能力: `Mag2Car` 是 StandardScene **磁导航平台插件**里的 FASS 2.0 车型,主要能力:
| 能力 | 说明 | | 能力 | 说明 |
|------|------| |------|------|
@@ -54,12 +54,12 @@
确认场景已加载 `scene.mag``StandardScene.Magnetic.scene.json`),提供: 确认场景已加载 `scene.mag``StandardScene.Magnetic.scene.json`),提供:
- 车型:`MagCar``MagFass2Car` - 车型:`MagCar``Mag2Car`
- 进程:`EventCarMission``MagFass2LoopMission` - 进程:`EventCarMission``Mag2LoopMission`
### 步骤 2:添加一辆车 ### 步骤 2:添加一辆车
1. 在场景中添加车型 **`MagFass2Car`** 1. 在场景中添加车型 **`Mag2Car`**
2. 填写基本参数(UDP 模式示例): 2. 填写基本参数(UDP 模式示例):
| 参数 | 示例值 | 含义 | | 参数 | 示例值 | 含义 |
@@ -86,8 +86,8 @@
用系统自带的「去某站」功能,内部会编译脚本,例如: 用系统自带的「去某站」功能,内部会编译脚本,例如:
```text ```text
agv.MagFass2Go(1, 2, 0.2); agv.Mag2Go(1, 2, 0.2);
agv.MagFass2Go(2, 5, 0.2); agv.Mag2Go(2, 5, 0.2);
``` ```
**方式 B — LoopMission 环线** **方式 B — LoopMission 环线**
@@ -103,11 +103,11 @@ agv.MagFass2Go(2, 5, 0.2);
```text ```text
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ 业务层:LoopMission / 手动 Go / 脚本 │ │ 业务层:LoopMission / 手动 Go / 脚本 │
│ (分配 goalSite 或编译多行 MagFass2Go │ (分配 goalSite 或编译多行 Mag2Go
└────────────────────────┬────────────────────────────────┘ └────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ MagFass2Car 执行层 │ │ Mag2Car 执行层 │
│ · 脚本拦截(Loop + goalSite)→ 一次全程任务 │ │ · 脚本拦截(Loop + goalSite)→ 一次全程任务 │
│ · Fass2TaskBuilder:路径 + fields → 节点序列 │ │ · Fass2TaskBuilder:路径 + fields → 节点序列 │
│ · Fass2TaskStateMachine:滑动窗口下发 + 动作闭环 │ │ · Fass2TaskStateMachine:滑动窗口下发 + 动作闭环 │
@@ -125,7 +125,7 @@ agv.MagFass2Go(2, 5, 0.2);
**关键理解:** **关键理解:**
- **LoopMissionCore** 只负责「给谁分配哪个目标站」(写 `goalSite` 标签) - **LoopMissionCore** 只负责「给谁分配哪个目标站」(写 `goalSite` 标签)
- **MagFass2Car** 负责「怎么走过去」(拼包、下发、等到站) - **Mag2Car** 负责「怎么走过去」(拼包、下发、等到站)
- **阶段 4** 后,Loop 不再逐行执行多段脚本,而是**拦截为一次全程任务**(可关) - **阶段 4** 后,Loop 不再逐行执行多段脚本,而是**拦截为一次全程任务**(可关)
--- ---
@@ -138,8 +138,8 @@ agv.MagFass2Go(2, 5, 0.2);
```json ```json
{ {
"carTypes": [ "MagCar", "MagFass2Car" ], "carTypes": [ "MagCar", "Mag2Car" ],
"missionTypes": [ "EventCarMission", "MagFass2LoopMission" ] "missionTypes": [ "EventCarMission", "Mag2LoopMission" ]
} }
``` ```
@@ -147,11 +147,11 @@ agv.MagFass2Go(2, 5, 0.2);
| Mission | 来源 | 适用 | | Mission | 来源 | 适用 |
|---------|------|------| |---------|------|------|
| `LoopMission` | Core | 通用环线,MagFass2Car 同样可用 | | `LoopMission` | Core | 通用环线,Mag2Car 同样可用 |
| `磁导航FASS2环线``MagFass2LoopMission` | Magnetic 插件 | 逻辑与 LoopMission 相同,名称标识磁导航 FASS2 场景 | | `磁导航FASS2环线``Mag2LoopMission` | Magnetic 插件 | 逻辑与 LoopMission 相同,名称标识磁导航 FASS2 场景 |
| `FASS车辆事件进程``EventCarMission` | Magnetic 插件 | **仅 MagCarFASS 1.0**,不驱动 MagFass2Car | | `FASS车辆事件进程``EventCarMission` | Magnetic 插件 | **仅 MagCarFASS 1.0**,不驱动 Mag2Car |
> 新手建议:MagFass2Car 环线用 **`LoopMission`** 或 **`磁导航FASS2环线`** 均可。 > 新手建议:Mag2Car 环线用 **`LoopMission`** 或 **`磁导航FASS2环线`** 均可。
### 4.3 tasklist.json 最小示例 ### 4.3 tasklist.json 最小示例
@@ -186,7 +186,7 @@ agv.MagFass2Go(2, 5, 0.2);
## 5. 车辆参数说明 ## 5. 车辆参数说明
在场景编辑器中选中 `MagFass2Car`,常用字段如下。 在场景编辑器中选中 `Mag2Car`,常用字段如下。
### 5.1 通讯 ### 5.1 通讯
@@ -269,8 +269,8 @@ agv.MagFass2Go(2, 5, 0.2);
```text ```text
FindRoute → Compile → 多行脚本: FindRoute → Compile → 多行脚本:
agv.MagFass2Go(1,2,speed); agv.Mag2Go(1,2,speed);
agv.MagFass2Go(2,3,speed); agv.Mag2Go(2,3,speed);
... ...
→ SelfEvaluating 逐行执行 → SelfEvaluating 逐行执行
→ 每行:锁终点 → 发 0xB1 → 等到站 → 释放起点 → 每行:锁终点 → 发 0xB1 → 等到站 → 释放起点
@@ -282,7 +282,7 @@ FindRoute → Compile → 多行脚本:
### 方式 2:Loop 全程任务(阶段 4,默认) ### 方式 2:Loop 全程任务(阶段 4,默认)
**触发**`goalSite` 标签 + 编译脚本含 `MagFass2Go` + `EnableLoopTaskDrive=true` **触发**`goalSite` 标签 + 编译脚本含 `Mag2Go` + `EnableLoopTaskDrive=true`
**流程** **流程**
@@ -298,7 +298,7 @@ LoopMission 写 goalSite
--- ---
### 方式 3:单段 MagFass2Go(脚本一行) ### 方式 3:单段 Mag2Go(脚本一行)
**触发**:脚本里只有一行,或无 `goalSite` 的测试。 **触发**:脚本里只有一行,或无 `goalSite` 的测试。
@@ -370,7 +370,7 @@ LoopMission 写 goalSite
|------|------| |------|------|
| 映射协议字段 | `Node`(节点号) | | 映射协议字段 | `Node`(节点号) |
| 何时配置 | 磁导航地标号与地图 `site.id` **不一致**时必须配置 | | 何时配置 | 磁导航地标号与地图 `site.id` **不一致**时必须配置 |
| 车辆开关 | `MagFass2Car.UseTagValueAsNode = true` | | 车辆开关 | `Mag2Car.UseTagValueAsNode = true` |
| 示例 | `TagValue = 10086` | | 示例 | `TagValue = 10086` |
| 注意 | 未配置且 `UseTagValueAsNode=false` 时,直接用 `site.id` 作为节点号 | | 注意 | 未配置且 `UseTagValueAsNode=false` 时,直接用 `site.id` 作为节点号 |
@@ -387,9 +387,9 @@ LoopMission 写 goalSite
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| 映射协议字段 | 无(当前版本不直接写入节点块) | | 映射协议字段 | 无(写入节点块) |
| 规划值 | `None` / `Button` / `Plc` / `Api` | | 规划值 | `None` / `Button` / `Plc` / `Api` |
| 当前状态 | 字段已定义,**等待放行联动逻辑待扩展**;可通过修改 `Fass2_StartStop` 或外部改 fields 触发重发 | | 当前状态 | 人工/外部放行源预留。**管控区按车放行**请用 `Fass2_StartStop=12` + `Fass2_ControlArea`(站点列表),不要靠改地图 `Fass2_StartStop` 放行 |
--- ---
@@ -401,8 +401,8 @@ LoopMission 写 goalSite
|----|---------------------------|----------| |----|---------------------------|----------|
| `1` | 过站(不停) | 路径中间站、通道站 | | `1` | 过站(不停) | 路径中间站、通道站 |
| `2` | 普通停车 | 工位、等待点、装卸点 | | `2` | 普通停车 | 工位、等待点、装卸点 |
| `11` | 管控启动 | 管控区入口,需授权后启动 | | `11` | 管控启动 | 本车放行后写入 **0xA1 / 后续 0xB1**(地图站点仍保持 12 |
| `12` | 管控停止 | 管控区停车等待 | | `12` | 管控停止 | 管控区停车,等调度按车放行 |
| `22` | 精准停止 | 装配、对接,精度要求高 | | `22` | 精准停止 | 装配、对接,精度要求高 |
**默认规则**(未显式配置 `Fass2_StartStop` 时,`Fass2TaskBuilder` 自动决定): **默认规则**(未显式配置 `Fass2_StartStop` 时,`Fass2TaskBuilder` 自动决定):
@@ -413,7 +413,17 @@ LoopMission 写 goalSite
| 末站(goalSite | `2`(停车) | | 末站(goalSite | `2`(停车) |
| 末站 + `Fass2_PrecisionStop=true` | `22`(精准停) | | 末站 + `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` 关系** **与 `Fass2_PrecisionStop` 关系**
@@ -614,6 +624,9 @@ LoopMission 写 goalSite
| `Fass2_Tray` | byte | `Tray` | 是(非 0 时) | | `Fass2_Tray` | byte | `Tray` | 是(非 0 时) |
| `Fass2_Shutdown` | byte | `Shutdown` | 是(非 0 时) | | `Fass2_Shutdown` | byte | `Shutdown` | 是(非 0 时) |
| `Fass2_WaitMode` | string | — | 预留 | | `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 备选 | | `magSelect` | byte | `Byroad` | 磁导航 coder 分叉序号;`Fass2_Byroad` 为空时作为 Byroad 备选 |
| `Fass2_Direction` | byte | `Direction` | 边级车头旋转;站点未覆盖时生效 | | `Fass2_Direction` | byte | `Direction` | 边级车头旋转;站点未覆盖时生效 |
| `Fass2_Orientation` | byte | `Orientation` | 边级朝向;站点未覆盖时生效 | | `Fass2_Orientation` | byte | `Orientation` | 边级朝向;站点未覆盖时生效 |
| `Magnet` | bool | — | `true` 启用 `MagneticTrackCoder``agv.MagGo` 等),与 `MagFass2Go` 模板独立 | | `Magnet` | bool | — | `true` 启用 `MagneticTrackCoder``agv.MagGo` 等),与 `Mag2Go` 模板独立 |
| `ReverseDst` | int | — | 倒车目的地站 ID;磁 coder 跳过该边 | | `ReverseDst` | int | — | 倒车目的地站 ID;磁 coder 跳过该边 |
**距离 `Distance`**:由地图坐标自动计算(上一站与本站欧氏距离,mm 取整),一般无需手配。 **距离 `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 型磁分叉口 #### 场景 FY 型磁分叉口
**站点**(推荐在岔口站配): **站点**(推荐在岔口站配):
@@ -792,7 +820,7 @@ Fass2_Obstacle = 2
2. 匹配 tasklistcurrentStationId = N → 分配 targetStationId 2. 匹配 tasklistcurrentStationId = N → 分配 targetStationId
3. 写 tagsgoalSite = 目标站 ID 3. 写 tagsgoalSite = 目标站 ID
4. LoopStartAction 调用 GoSite 4. LoopStartAction 调用 GoSite
5. MagFass2Car 拦截 → 全程任务到 goalSite 5. Mag2Car 拦截 → 全程任务到 goalSite
6. 到达后 GoSite 清理 goalSite / occupied / loopAssigned 6. 到达后 GoSite 清理 goalSite / occupied / loopAssigned
7. 下一轮任务匹配 7. 下一轮任务匹配
``` ```
@@ -809,7 +837,7 @@ Fass2_Obstacle = 2
- **不需要改 Core**`LoopMission` 逻辑完全沿用 - **不需要改 Core**`LoopMission` 逻辑完全沿用
- `磁导航FASS2环线` 仅为场景标识,行为与 `LoopMission` 相同 - `磁导航FASS2环线` 仅为场景标识,行为与 `LoopMission` 相同
- 真正区别在 **车型** 是否 `MagFass2Car``EnableLoopTaskDrive=true` - 真正区别在 **车型** 是否 `Mag2Car``EnableLoopTaskDrive=true`
### 9.4 关闭 Loop 拦截(恢复多段脚本) ### 9.4 关闭 Loop 拦截(恢复多段脚本)
@@ -817,7 +845,7 @@ Fass2_Obstacle = 2
EnableLoopTaskDrive = false EnableLoopTaskDrive = false
``` ```
Loop 仍走 Compile 多行 `MagFass2Go`,每行一段边。 Loop 仍走 Compile 多行 `Mag2Go`,每行一段边。
--- ---
@@ -890,7 +918,7 @@ Idle → Planning → Moving → AtStation → SegmentDone → … → Complete
| 重置UDP监听 | — | 重新注册 UDP 会话 | | 重置UDP监听 | — | 重新注册 UDP 会话 |
| 重置TCP连接 | — | 关闭长连接(TCP 模式) | | 重置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 - `loop goal drive intercept` → 全程任务(阶段 4
- `script begin` + 多行 `MagFass2Go` → 多段脚本 - `script begin` + 多行 `Mag2Go` → 多段脚本
### Q6:与 MagCar 混用注意事项 ### Q6:与 MagCar 混用注意事项
- `EventCarMission` **只控制 MagCar** - `EventCarMission` **只控制 MagCar**
- 同场景两种车型时,tasklist 和车辆类型要对应 - 同场景两种车型时,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 上线前检查清单 ### 13.2 上线前检查清单
- [ ] 插件 `StandardScene.Magnetic.dll` 已加载 - [ ] 插件 `StandardScene.Magnetic.dll` 已加载
- [ ] 车型为 `MagFass2Car`(不是 `MagCar` - [ ] 车型为 `Mag2Car`(不是 `MagCar`
- [ ] `VehicleCode`、IP、端口与车体一致 - [ ] `VehicleCode`、IP、端口与车体一致
- [ ] UDP 能收到 100B,日志有 `0x10` 应答 - [ ] UDP 能收到 100B,日志有 `0x10` 应答
- [ ] 关键站点 `TagValue` / `Fass2_*` 已配置 - [ ] 关键站点 `TagValue` / `Fass2_*` 已配置
@@ -993,12 +1021,12 @@ MoveTimeoutSeconds = 120
| 模块 | 路径 | | 模块 | 路径 |
|------|------| |------|------|
| 车型 | `CarTypes/MagFass2Car.cs` | | 车型 | `CarTypes/Mag2Car.cs` |
| 协议 | `Protocol/Fass2Protocol.cs``Fass2UdpHub.cs` | | 协议 | `Protocol/Fass2Protocol.cs``Fass2UdpHub.cs` |
| 组包 | `Tasking/Fass2TaskBuilder.cs` | | 组包 | `Tasking/Fass2TaskBuilder.cs` |
| 状态机 | `Tasking/Fass2TaskStateMachine.cs` | | 状态机 | `Tasking/Fass2TaskStateMachine.cs` |
| 动作判定 | `Tasking/Fass2ActionResolver.cs` | | 动作判定 | `Tasking/Fass2ActionResolver.cs` |
| 环线 Mission | `Chained/MagFass2LoopMission.cs` | | 环线 Mission | `Chained/Mag2LoopMission.cs` |
| 站点字段常量 | `Tasking/Fass2SiteFields.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( param(
[string]$InputMd = "$PSScriptRoot\MagFass2Car使用指南.md", [string]$InputMd = "$PSScriptRoot\Mag2Car使用指南.md",
[string]$OutputPdf = "$PSScriptRoot\MagFass2Car使用指南.pdf" [string]$OutputPdf = "$PSScriptRoot\Mag2Car使用指南.pdf"
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -147,7 +147,7 @@ $html = @"
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>MagFass2Car Guide</title> <title>Mag2Car Guide</title>
<style> <style>
@page { size: A4; margin: 18mm 16mm; } @page { size: A4; margin: 18mm 16mm; }
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; color: #222; line-height: 1.55; font-size: 11pt; } 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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulator", "StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj", "{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulator", "StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj", "{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7A745509-1593-4044-BA49-9B6B0A35B505}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {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|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.ActiveCfg = Release|Any CPU
{7A745509-1593-4044-BA49-9B6B0A35B505}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{7E5050AA-0001-4A11-9C22-0A0B0C0D0E01}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{7E5050AA-0005-4A11-9C22-0A0B0C0D0E05}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{7E5050AA-0006-4A11-9C22-0A0B0C0D0E06}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE 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