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