feat: CNC 编排新增硬件控制能力(位置硬件还原 + 四类动作节点)
在 CNC 编排层补齐 P0 硬件控制能力,使检测编排可覆盖设备测试与校准流程。 位置节点硬件状态还原(核心): - SavePositionNode 新增可选探测器采集参数快照,兼容旧 .xp 文件 - 执行到位后按序还原射线源电压/电流与探测器参数(先电压后电流) - 射线未开弹窗提醒并暂停;还原失败弹窗提示并暂停不继续;服务不可用降级跳过 - 新增 ICncInteractionService 封装弹窗交互,便于单元测试 四类硬件动作节点: - 射线源控制:开/关/设参/暖机/训机,安全时序 + 稳定等待 - 探测器校正:暗场/增益/坏像素/自动 - 安全门控制:开门/关门,复用内置联锁 - 几何设置:按 FDD 或放大倍率反算定位 模型、编辑器与工具栏接线: - CncNodeType 追加四枚举值 + 四类节点 record + 多态判别符 + 值域校验 - 编辑器新增插入命令、类型判定、图标与工具栏入口 - IDetectorService 预留当前采集参数读取接口(暂返回 null,待硬件层完善) 系统设置: - 新增数据存储根目录与日志目录配置及浏览按钮 - 修复保存时重复打开配置文件导致的配置冲突异常 测试: - 新增 33 个单元测试,覆盖序列化往返、旧文件兼容、节点分派/降级/失败、 位置还原全路径与混合程序执行
This commit is contained in:
@@ -75,3 +75,4 @@ Dump/
|
||||
Report/
|
||||
XplorePlane.Tests/TestResults/
|
||||
ReleaseFiles/win-x64/
|
||||
XPData/
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace XP.Hardware.Detector.Abstractions
|
||||
{
|
||||
/// <summary>
|
||||
/// 探测器可下发采集参数 | Detector down-loadable acquisition parameters
|
||||
/// 用于上层(如 CNC 位置节点)捕获并还原探测器采集参数。
|
||||
/// </summary>
|
||||
/// <param name="BinningIndex">Binning 索引 | Binning index</param>
|
||||
/// <param name="Pga">PGA 灵敏度值 | PGA sensitivity value</param>
|
||||
/// <param name="FrameRate">帧率 | Frame rate</param>
|
||||
public record DetectorAcquisitionParameters(
|
||||
int BinningIndex,
|
||||
int Pga,
|
||||
decimal FrameRate);
|
||||
}
|
||||
@@ -647,6 +647,17 @@ namespace XP.Hardware.Detector.Services
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// TODO(待硬件层完善):当前硬件层未维护「运行时当前采集参数」的可读状态,暂返回 null。
|
||||
/// 硬件层补齐 Binning/PGA/帧率的当前值读取后,应在此返回真实参数。
|
||||
/// 返回 null 时,上层(CNC 位置节点)会跳过探测器参数快照捕获与还原。
|
||||
/// </remarks>
|
||||
public DetectorAcquisitionParameters GetCurrentAcquisitionParameters()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取探测器实例或抛出异常 | Get detector instance or throw exception
|
||||
/// </summary>
|
||||
|
||||
@@ -136,5 +136,17 @@ namespace XP.Hardware.Detector.Services
|
||||
/// </summary>
|
||||
/// <returns>校正能力描述,未初始化时返回默认值 | Correction capabilities, default if not initialized</returns>
|
||||
CorrectionCapabilities GetCorrectionCapabilities();
|
||||
|
||||
/// <summary>
|
||||
/// 读取当前探测器采集参数(Binning/PGA/帧率),供上层捕获并还原。
|
||||
/// Get current detector acquisition parameters for upper-layer capture/restore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// TODO(待硬件层完善):当前硬件层尚未维护「运行时当前采集参数」的可读状态,
|
||||
/// 默认实现返回 null。硬件层补齐后应返回真实的当前 Binning/PGA/帧率。
|
||||
/// 上层(CNC 位置节点)在返回 null 时会跳过探测器参数快照捕获与还原。
|
||||
/// </remarks>
|
||||
/// <returns>当前采集参数;暂不可读时返回 null | Current acquisition parameters, or null if not available yet</returns>
|
||||
DetectorAcquisitionParameters GetCurrentAcquisitionParameters();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using Prism.Events;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XP.Hardware.Detector.Abstractions;
|
||||
using XP.Hardware.Detector.Services;
|
||||
using XP.Hardware.MotionControl.Abstractions;
|
||||
using XP.Hardware.MotionControl.Services;
|
||||
using XP.Hardware.RaySource.Abstractions;
|
||||
using XP.Hardware.RaySource.Services;
|
||||
using XplorePlane.Events;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services;
|
||||
using XplorePlane.Services.AppState;
|
||||
using XplorePlane.Services.Cnc;
|
||||
using XplorePlane.Services.InspectionArchive;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
using Xunit;
|
||||
|
||||
namespace XplorePlane.Tests.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// CNC 硬件控制节点执行 + 位置节点硬件状态还原的单元测试。
|
||||
/// 覆盖:几何/门/射线源/探测器校正动作节点,以及 SavePosition 的还原路径。
|
||||
/// </summary>
|
||||
public class CncHardwareNodeExecutionTests
|
||||
{
|
||||
private sealed class Harness
|
||||
{
|
||||
public CncExecutionService Service;
|
||||
public Mock<IInspectionArchiveService> Archive;
|
||||
public List<CncNodeExecutionProgress> Reports = new();
|
||||
public IProgress<CncNodeExecutionProgress> Progress;
|
||||
|
||||
public NodeExecutionState FinalState(Guid id) =>
|
||||
Reports.Where(r => r.NodeId == id).Select(r => r.State).LastOrDefault();
|
||||
}
|
||||
|
||||
private static Harness CreateHarness(
|
||||
IMotionControlService motion = null,
|
||||
IRaySourceService ray = null,
|
||||
IDetectorService detector = null,
|
||||
ICncInteractionService interaction = null)
|
||||
{
|
||||
var archive = new Mock<IInspectionArchiveService>();
|
||||
archive.Setup(a => a.BeginRunAsync(It.IsAny<InspectionRunRecord>(), It.IsAny<InspectionAssetWriteRequest>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
archive.Setup(a => a.CompleteRunAsync(It.IsAny<Guid>(), It.IsAny<bool?>(), It.IsAny<DateTime?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
archive.Setup(a => a.AppendCaptureNodeAsync(
|
||||
It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<int>(), It.IsAny<string>(),
|
||||
It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var logger = new Mock<ILoggerService>();
|
||||
logger.Setup(l => l.ForModule<CncExecutionService>()).Returns(logger.Object);
|
||||
|
||||
var appState = new Mock<IAppStateService>();
|
||||
appState.SetupGet(s => s.MotionState).Returns(MotionState.Default);
|
||||
appState.SetupGet(s => s.RaySourceState).Returns(RaySourceState.Default);
|
||||
appState.SetupGet(s => s.DetectorState).Returns(DetectorState.Default);
|
||||
|
||||
var eventAgg = new Mock<IEventAggregator>();
|
||||
eventAgg.Setup(e => e.GetEvent<DetectorDisconnectedEvent>()).Returns(new DetectorDisconnectedEvent());
|
||||
|
||||
var service = new CncExecutionService(
|
||||
archive.Object, logger.Object, null, appState.Object,
|
||||
null, null, eventAgg.Object, null,
|
||||
motion, null, ray, null, detector, interaction);
|
||||
|
||||
var harness = new Harness { Service = service, Archive = archive };
|
||||
harness.Progress = new SynchronousProgress<CncNodeExecutionProgress>(p => harness.Reports.Add(p));
|
||||
return harness;
|
||||
}
|
||||
|
||||
private static CncProgram Prog(params CncNode[] nodes) =>
|
||||
new CncProgram(Guid.NewGuid(), "T", DateTime.UtcNow, DateTime.UtcNow, nodes.ToList().AsReadOnly());
|
||||
|
||||
private static XRayResult RayOk() => new XRayResult { Success = true };
|
||||
|
||||
// ── 几何节点 ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GeometryNode_ByFdd_CallsApplyGeometry()
|
||||
{
|
||||
var motion = new Mock<IMotionControlService>();
|
||||
motion.Setup(m => m.ApplyGeometry(It.IsAny<double>(), It.IsAny<double>())).Returns(MotionResult.Ok());
|
||||
var h = CreateHarness(motion: motion.Object);
|
||||
var node = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, Fod: 50, Fdd: 200, WaitForSettled: false);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
motion.Verify(m => m.ApplyGeometry(50, 200), Times.Once);
|
||||
motion.Verify(m => m.ApplyGeometryByMagnification(It.IsAny<double>(), It.IsAny<double>()), Times.Never);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeometryNode_ByMagnification_CallsApplyGeometryByMagnification()
|
||||
{
|
||||
var motion = new Mock<IMotionControlService>();
|
||||
motion.Setup(m => m.ApplyGeometryByMagnification(It.IsAny<double>(), It.IsAny<double>())).Returns(MotionResult.Ok());
|
||||
var h = CreateHarness(motion: motion.Object);
|
||||
var node = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByMagnification, Fod: 50, Magnification: 4, WaitForSettled: false);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
motion.Verify(m => m.ApplyGeometryByMagnification(50, 4), Times.Once);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeometryNode_MotionServiceNull_NodeSucceedsDegraded()
|
||||
{
|
||||
var h = CreateHarness(motion: null);
|
||||
var node = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, Fod: 50, Fdd: 200, WaitForSettled: false);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeometryNode_Failure_NodeFails()
|
||||
{
|
||||
var motion = new Mock<IMotionControlService>();
|
||||
motion.Setup(m => m.ApplyGeometry(It.IsAny<double>(), It.IsAny<double>())).Returns(MotionResult.Fail("几何越界"));
|
||||
var h = CreateHarness(motion: motion.Object);
|
||||
var node = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, Fod: 50, Fdd: 200, WaitForSettled: false);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
Assert.Equal(NodeExecutionState.Failed, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
// ── 安全门节点 ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DoorControlNode_Open_CallsOpenDoor()
|
||||
{
|
||||
var motion = new Mock<IMotionControlService>();
|
||||
motion.Setup(m => m.OpenDoor()).Returns(MotionResult.Ok());
|
||||
var h = CreateHarness(motion: motion.Object);
|
||||
var node = new DoorControlNode(Guid.NewGuid(), 0, "门", DoorAction.Open, WaitForComplete: false);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
motion.Verify(m => m.OpenDoor(), Times.Once);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoorControlNode_Close_CallsCloseDoor()
|
||||
{
|
||||
var motion = new Mock<IMotionControlService>();
|
||||
motion.Setup(m => m.CloseDoor()).Returns(MotionResult.Ok());
|
||||
var h = CreateHarness(motion: motion.Object);
|
||||
var node = new DoorControlNode(Guid.NewGuid(), 0, "门", DoorAction.Close, WaitForComplete: false);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
motion.Verify(m => m.CloseDoor(), Times.Once);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoorControlNode_InterlockFailure_NodeFails()
|
||||
{
|
||||
var motion = new Mock<IMotionControlService>();
|
||||
motion.Setup(m => m.OpenDoor()).Returns(MotionResult.Fail("联锁拒绝"));
|
||||
var h = CreateHarness(motion: motion.Object);
|
||||
var node = new DoorControlNode(Guid.NewGuid(), 0, "门", DoorAction.Open, WaitForComplete: false);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
Assert.Equal(NodeExecutionState.Failed, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
// ── 射线源节点 ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RaySourceControlNode_SetParameters_VoltageBeforeCurrent()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var ray = new Mock<IRaySourceService>();
|
||||
ray.Setup(r => r.SetVoltage(It.IsAny<float>())).Returns(RayOk()).Callback(() => calls.Add("V"));
|
||||
ray.Setup(r => r.SetCurrent(It.IsAny<float>())).Returns(RayOk()).Callback(() => calls.Add("C"));
|
||||
var h = CreateHarness(ray: ray.Object);
|
||||
var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.SetParameters, Voltage: 120, Current: 200);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
ray.Verify(r => r.SetVoltage(120f), Times.Once);
|
||||
ray.Verify(r => r.SetCurrent(200f), Times.Once);
|
||||
Assert.Equal(new[] { "V", "C" }, calls);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RaySourceControlNode_TurnOff_CallsTurnOff()
|
||||
{
|
||||
var ray = new Mock<IRaySourceService>();
|
||||
ray.Setup(r => r.TurnOff()).Returns(RayOk());
|
||||
var h = CreateHarness(ray: ray.Object);
|
||||
var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.TurnOff, Voltage: 0, Current: 0);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
ray.Verify(r => r.TurnOff(), Times.Once);
|
||||
ray.Verify(r => r.SetVoltage(It.IsAny<float>()), Times.Never);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RaySourceControlNode_ServiceNull_NodeSucceedsDegraded()
|
||||
{
|
||||
var h = CreateHarness(ray: null);
|
||||
var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.SetParameters, Voltage: 120, Current: 200);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RaySourceControlNode_SetVoltageFailure_NodeFails()
|
||||
{
|
||||
var ray = new Mock<IRaySourceService>();
|
||||
ray.Setup(r => r.SetVoltage(It.IsAny<float>())).Returns(XRayResult.Error("电压超限"));
|
||||
var h = CreateHarness(ray: ray.Object);
|
||||
var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.SetParameters, Voltage: 999, Current: 200);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
ray.Verify(r => r.SetCurrent(It.IsAny<float>()), Times.Never);
|
||||
Assert.Equal(NodeExecutionState.Failed, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
// ── 探测器校正节点 ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DetectorCorrectionNode_Dark_CallsDarkCorrection()
|
||||
{
|
||||
var detector = new Mock<IDetectorService>();
|
||||
detector.Setup(d => d.DarkCorrectionAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DetectorResult.Success());
|
||||
var h = CreateHarness(detector: detector.Object);
|
||||
var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Dark, FrameCount: 16);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
detector.Verify(d => d.DarkCorrectionAsync(16, It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectorCorrectionNode_Auto_CallsAutoCorrection()
|
||||
{
|
||||
var detector = new Mock<IDetectorService>();
|
||||
detector.Setup(d => d.AutoCorrectionAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DetectorResult.Success());
|
||||
var h = CreateHarness(detector: detector.Object);
|
||||
var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Auto, FrameCount: 10);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
detector.Verify(d => d.AutoCorrectionAsync(10, It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectorCorrectionNode_BadPixel_IgnoresFrameCount()
|
||||
{
|
||||
var detector = new Mock<IDetectorService>();
|
||||
detector.Setup(d => d.BadPixelCorrectionAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DetectorResult.Success());
|
||||
var h = CreateHarness(detector: detector.Object);
|
||||
var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.BadPixel, FrameCount: 99);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
detector.Verify(d => d.BadPixelCorrectionAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectorCorrectionNode_ServiceNull_NodeSucceedsDegraded()
|
||||
{
|
||||
var h = CreateHarness(detector: null);
|
||||
var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Auto, FrameCount: 10);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectorCorrectionNode_Failure_NodeFails()
|
||||
{
|
||||
var detector = new Mock<IDetectorService>();
|
||||
detector.Setup(d => d.AutoCorrectionAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DetectorResult.Failure("校正失败"));
|
||||
var h = CreateHarness(detector: detector.Object);
|
||||
var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Auto, FrameCount: 10);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
Assert.Equal(NodeExecutionState.Failed, h.FinalState(node.Id));
|
||||
}
|
||||
|
||||
// ── 位置节点硬件状态还原 ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SavePosition_RestoresVoltageThenCurrent()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var ray = new Mock<IRaySourceService>();
|
||||
ray.SetupGet(r => r.IsXRayOn).Returns(true);
|
||||
ray.Setup(r => r.SetVoltage(It.IsAny<float>())).Returns(RayOk()).Callback(() => calls.Add("V"));
|
||||
ray.Setup(r => r.SetCurrent(It.IsAny<float>())).Returns(RayOk()).Callback(() => calls.Add("C"));
|
||||
var h = CreateHarness(ray: ray.Object);
|
||||
// RaySourceState.Power 存电流(μA):Voltage=100kV, Current=150μA
|
||||
var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
|
||||
RaySourceState: new RaySourceState(IsOn: true, Voltage: 100, Power: 150));
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
ray.Verify(r => r.SetVoltage(100f), Times.Once);
|
||||
ray.Verify(r => r.SetCurrent(150f), Times.Once);
|
||||
Assert.Equal(new[] { "V", "C" }, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavePosition_RayNotOn_PromptsConfirmAndAbortsWhenDeclined()
|
||||
{
|
||||
var ray = new Mock<IRaySourceService>();
|
||||
ray.SetupGet(r => r.IsXRayOn).Returns(false); // 当前未开
|
||||
var interaction = new Mock<ICncInteractionService>();
|
||||
interaction.Setup(i => i.ConfirmRayOnAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false); // 操作员放弃
|
||||
var h = CreateHarness(ray: ray.Object, interaction: interaction.Object);
|
||||
var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
|
||||
RaySourceState: new RaySourceState(IsOn: true, Voltage: 100, Power: 150));
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
interaction.Verify(i => i.ConfirmRayOnAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
// 放弃后中止,不应再下发电压/电流
|
||||
ray.Verify(r => r.SetVoltage(It.IsAny<float>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavePosition_RestoreFailure_NotifiesAndSkipsCurrent()
|
||||
{
|
||||
var ray = new Mock<IRaySourceService>();
|
||||
ray.SetupGet(r => r.IsXRayOn).Returns(true);
|
||||
ray.Setup(r => r.SetVoltage(It.IsAny<float>())).Returns(XRayResult.Error("电压故障"));
|
||||
var interaction = new Mock<ICncInteractionService>();
|
||||
interaction.Setup(i => i.NotifyRestoreFailureAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
var h = CreateHarness(ray: ray.Object, interaction: interaction.Object);
|
||||
var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
|
||||
RaySourceState: new RaySourceState(IsOn: true, Voltage: 100, Power: 150));
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
interaction.Verify(i => i.NotifyRestoreFailureAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
ray.Verify(r => r.SetCurrent(It.IsAny<float>()), Times.Never); // 电压失败后不再下发电流
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavePosition_WithDetectorAcq_AppliesParameters()
|
||||
{
|
||||
var detector = new Mock<IDetectorService>();
|
||||
detector.Setup(d => d.ApplyParametersAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<decimal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DetectorResult.Success());
|
||||
var h = CreateHarness(detector: detector.Object);
|
||||
var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
|
||||
RaySourceState: null,
|
||||
DetectorAcq: new DetectorAcquisitionSnapshot(BinningIndex: 2, Pga: 3, FrameRate: 15m));
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
detector.Verify(d => d.ApplyParametersAsync(2, 3, 15m, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavePosition_LegacyNoDetectorAcq_SkipsDetectorRestore()
|
||||
{
|
||||
var detector = new Mock<IDetectorService>();
|
||||
var h = CreateHarness(detector: detector.Object);
|
||||
var node = new SavePositionNode(Guid.NewGuid(), 0, "位置", MotionState.Default,
|
||||
RaySourceState: null, DetectorAcq: null);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(node), h.Progress, CancellationToken.None);
|
||||
|
||||
detector.Verify(d => d.ApplyParametersAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<decimal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── 集成:混合硬件程序多节点执行 ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Integration_GeometryThenCorrection_BothExecutedInOrder()
|
||||
{
|
||||
var motion = new Mock<IMotionControlService>();
|
||||
motion.Setup(m => m.ApplyGeometry(It.IsAny<double>(), It.IsAny<double>())).Returns(MotionResult.Ok());
|
||||
var detector = new Mock<IDetectorService>();
|
||||
detector.Setup(d => d.AutoCorrectionAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DetectorResult.Success());
|
||||
var h = CreateHarness(motion: motion.Object, detector: detector.Object);
|
||||
|
||||
var geo = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, 50, 200, WaitForSettled: false);
|
||||
var corr = new DetectorCorrectionNode(Guid.NewGuid(), 1, "校正", DetectorCorrectionType.Auto, 10);
|
||||
|
||||
await h.Service.ExecuteAsync(Prog(geo, corr), h.Progress, CancellationToken.None);
|
||||
|
||||
motion.Verify(m => m.ApplyGeometry(50, 200), Times.Once);
|
||||
detector.Verify(d => d.AutoCorrectionAsync(10, It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(geo.Id));
|
||||
Assert.Equal(NodeExecutionState.Succeeded, h.FinalState(corr.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,5 +45,171 @@ namespace XplorePlane.Tests.Services
|
||||
var savePosition = Assert.IsType<SavePositionNode>(Assert.Single(deserialized.Nodes));
|
||||
Assert.True(savePosition.SaveImage);
|
||||
}
|
||||
|
||||
private static CncProgramService CreateService()
|
||||
{
|
||||
var appState = new Mock<IAppStateService>();
|
||||
appState.SetupGet(s => s.MotionState).Returns(MotionState.Default);
|
||||
appState.SetupGet(s => s.RaySourceState).Returns(RaySourceState.Default);
|
||||
appState.SetupGet(s => s.DetectorState).Returns(DetectorState.Default);
|
||||
|
||||
var raySource = new Mock<IRaySourceService>();
|
||||
var logger = new Mock<ILoggerService>();
|
||||
logger.Setup(l => l.ForModule<CncProgramService>()).Returns(logger.Object);
|
||||
|
||||
var permissionService = new Mock<IPermissionService>();
|
||||
permissionService.Setup(p => p.HasPermission(It.IsAny<Permission>())).Returns(true);
|
||||
|
||||
return new CncProgramService(appState.Object, raySource.Object, logger.Object, permissionService.Object);
|
||||
}
|
||||
|
||||
private static CncProgram Roundtrip(CncProgramService service, params CncNode[] nodes)
|
||||
{
|
||||
var program = new CncProgram(
|
||||
Guid.NewGuid(), "Program", DateTime.UtcNow, DateTime.UtcNow,
|
||||
new List<CncNode>(nodes).AsReadOnly());
|
||||
var json = service.Serialize(program);
|
||||
return service.Deserialize(json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeserialize_RaySourceControlNode_PreservesAllFields()
|
||||
{
|
||||
var service = CreateService();
|
||||
var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源控制_0",
|
||||
RaySourceAction.TurnOn, Voltage: 120, Current: 200, WaitForStable: true, StableTimeoutMs: 8000);
|
||||
|
||||
var result = Assert.IsType<RaySourceControlNode>(Assert.Single(Roundtrip(service, node).Nodes));
|
||||
Assert.Equal(RaySourceAction.TurnOn, result.Action);
|
||||
Assert.Equal(120, result.Voltage);
|
||||
Assert.Equal(200, result.Current);
|
||||
Assert.True(result.WaitForStable);
|
||||
Assert.Equal(8000, result.StableTimeoutMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeserialize_DetectorCorrectionNode_PreservesAllFields()
|
||||
{
|
||||
var service = CreateService();
|
||||
var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "探测器校正_0",
|
||||
DetectorCorrectionType.Dark, FrameCount: 20);
|
||||
|
||||
var result = Assert.IsType<DetectorCorrectionNode>(Assert.Single(Roundtrip(service, node).Nodes));
|
||||
Assert.Equal(DetectorCorrectionType.Dark, result.CorrectionType);
|
||||
Assert.Equal(20, result.FrameCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeserialize_DoorControlNode_PreservesAllFields()
|
||||
{
|
||||
var service = CreateService();
|
||||
var node = new DoorControlNode(Guid.NewGuid(), 0, "安全门_0",
|
||||
DoorAction.Open, WaitForComplete: true, TimeoutMs: 12000);
|
||||
|
||||
var result = Assert.IsType<DoorControlNode>(Assert.Single(Roundtrip(service, node).Nodes));
|
||||
Assert.Equal(DoorAction.Open, result.Action);
|
||||
Assert.True(result.WaitForComplete);
|
||||
Assert.Equal(12000, result.TimeoutMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeserialize_GeometryNode_PreservesAllFields()
|
||||
{
|
||||
var service = CreateService();
|
||||
var node = new GeometryNode(Guid.NewGuid(), 0, "几何_0",
|
||||
GeometryMode.ByMagnification, Fod: 50.5, Fdd: 0, Magnification: 4.0, WaitForSettled: true);
|
||||
|
||||
var result = Assert.IsType<GeometryNode>(Assert.Single(Roundtrip(service, node).Nodes));
|
||||
Assert.Equal(GeometryMode.ByMagnification, result.Mode);
|
||||
Assert.Equal(50.5, result.Fod);
|
||||
Assert.Equal(4.0, result.Magnification);
|
||||
Assert.True(result.WaitForSettled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeserialize_SavePositionWithDetectorAcq_PreservesSnapshot()
|
||||
{
|
||||
var service = CreateService();
|
||||
var node = new SavePositionNode(Guid.NewGuid(), 0, "检测位置_0", MotionState.Default,
|
||||
RaySourceState: new RaySourceState(true, 100, 150),
|
||||
SaveImage: true,
|
||||
DetectorAcq: new DetectorAcquisitionSnapshot(BinningIndex: 1, Pga: 3, FrameRate: 15m));
|
||||
|
||||
var result = Assert.IsType<SavePositionNode>(Assert.Single(Roundtrip(service, node).Nodes));
|
||||
Assert.NotNull(result.DetectorAcq);
|
||||
Assert.Equal(1, result.DetectorAcq.BinningIndex);
|
||||
Assert.Equal(3, result.DetectorAcq.Pga);
|
||||
Assert.Equal(15m, result.DetectorAcq.FrameRate);
|
||||
Assert.Equal(100, result.RaySourceState.Voltage);
|
||||
Assert.Equal(150, result.RaySourceState.Power);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacySavePositionWithoutDetectorAcq_LoadsWithNullSnapshot()
|
||||
{
|
||||
var service = CreateService();
|
||||
// 模拟旧 .xp:SavePosition 节点不含 DetectorAcq 字段
|
||||
var legacyJson = @"{
|
||||
""Id"": ""11111111-1111-1111-1111-111111111111"",
|
||||
""Name"": ""LegacyProgram"",
|
||||
""CreatedAt"": ""2024-01-01T00:00:00Z"",
|
||||
""UpdatedAt"": ""2024-01-01T00:00:00Z"",
|
||||
""Nodes"": [
|
||||
{
|
||||
""$type"": ""SavePosition"",
|
||||
""Id"": ""22222222-2222-2222-2222-222222222222"",
|
||||
""Index"": 0,
|
||||
""Name"": ""检测位置_0"",
|
||||
""MotionState"": null,
|
||||
""SaveImage"": true
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
var deserialized = service.Deserialize(legacyJson);
|
||||
var savePosition = Assert.IsType<SavePositionNode>(Assert.Single(deserialized.Nodes));
|
||||
Assert.Null(savePosition.DetectorAcq);
|
||||
Assert.True(savePosition.SaveImage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_DetectorCorrectionFrameCountOutOfRange_IsClamped()
|
||||
{
|
||||
var service = CreateService();
|
||||
// FrameCount 超上限(9999)应被截断到 1000
|
||||
var node = new DetectorCorrectionNode(Guid.NewGuid(), 0, "校正", DetectorCorrectionType.Dark, FrameCount: 9999);
|
||||
var result = Assert.IsType<DetectorCorrectionNode>(Assert.Single(Roundtrip(service, node).Nodes));
|
||||
Assert.Equal(1000, result.FrameCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_RaySourceStableTimeoutOutOfRange_IsClamped()
|
||||
{
|
||||
var service = CreateService();
|
||||
// StableTimeoutMs 超上限(999999)应被截断到 600000
|
||||
var node = new RaySourceControlNode(Guid.NewGuid(), 0, "射线源", RaySourceAction.TurnOn, 120, 200, WaitForStable: true, StableTimeoutMs: 999999);
|
||||
var result = Assert.IsType<RaySourceControlNode>(Assert.Single(Roundtrip(service, node).Nodes));
|
||||
Assert.Equal(600000, result.StableTimeoutMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeserialize_MixedHardwareProgram_PreservesAllNodes()
|
||||
{
|
||||
var service = CreateService();
|
||||
var geo = new GeometryNode(Guid.NewGuid(), 0, "几何", GeometryMode.ByFdd, 50, 200);
|
||||
var pos = new SavePositionNode(Guid.NewGuid(), 1, "位置", MotionState.Default,
|
||||
RaySourceState: new RaySourceState(true, 100, 150),
|
||||
DetectorAcq: new DetectorAcquisitionSnapshot(1, 2, 15m));
|
||||
var corr = new DetectorCorrectionNode(Guid.NewGuid(), 2, "校正", DetectorCorrectionType.Auto, 10);
|
||||
|
||||
var rt = Roundtrip(service, geo, pos, corr);
|
||||
|
||||
Assert.Equal(3, rt.Nodes.Count);
|
||||
Assert.IsType<GeometryNode>(rt.Nodes[0]);
|
||||
var posRt = Assert.IsType<SavePositionNode>(rt.Nodes[1]);
|
||||
Assert.IsType<DetectorCorrectionNode>(rt.Nodes[2]);
|
||||
Assert.NotNull(posRt.DetectorAcq);
|
||||
Assert.Equal(15m, posRt.DetectorAcq.FrameRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,13 @@ namespace XplorePlane.Tests.Services
|
||||
var mark = CreateMark(InspectionMarkType.Fail, InspectionMarkStatus.Fail, "缺陷-中文备注");
|
||||
mark.SnapshotImageRelativePath = "documentation/marks/001_abcd1234/snapshot.bmp";
|
||||
await archive.SaveMarkAsync(map, mark);
|
||||
run.MapId = map.MapId;
|
||||
run.MapSchemaVersion = map.SchemaVersion;
|
||||
run.OverviewImageRelativePath = map.OverviewImageRelativePath;
|
||||
run.ImageWidth = map.ImageWidth;
|
||||
run.ImageHeight = map.ImageHeight;
|
||||
run.MapUpdatedAt = map.UpdatedAtUtc;
|
||||
await archive.UpdateRunMapAsync(run);
|
||||
|
||||
await archive.CompleteRunAsync(run.RunId, overallPass: true);
|
||||
|
||||
@@ -245,6 +252,12 @@ namespace XplorePlane.Tests.Services
|
||||
var detail = JsonSerializer.Deserialize<InspectionRunDetail>(json, opts);
|
||||
Assert.NotNull(detail);
|
||||
Assert.Equal(2, detail.SchemaVersion);
|
||||
Assert.NotNull(detail.Map);
|
||||
Assert.Equal(map.MapId, detail.Map.MapId);
|
||||
Assert.Equal("documentation/inspection-map.xpmap", detail.Map.DocumentPath);
|
||||
Assert.Equal("documentation/overview.bmp", detail.Map.OverviewImagePath);
|
||||
Assert.Equal("NavigationCameraOverview", detail.Map.OverviewImageRole);
|
||||
Assert.Equal("DetectorRegionImage", detail.Map.LinkedImageRole);
|
||||
Assert.NotNull(detail.Batch);
|
||||
Assert.Equal(2, detail.Batch.TotalPositions);
|
||||
var roundTrippedMark = Assert.Single(detail.Marks);
|
||||
|
||||
@@ -76,7 +76,15 @@ namespace XplorePlane.Tests.Services
|
||||
PipelineId = pipelineA.Id,
|
||||
PipelineName = pipelineA.Name,
|
||||
NodePass = true,
|
||||
DurationMs = 135
|
||||
DurationMs = 135,
|
||||
PositionState = new InspectionPositionStateRecord
|
||||
{
|
||||
Motion = new MotionState(100, 200, 300, 400, 5, 600, 0, 0, 0, 0, 0, 0),
|
||||
RaySource = new RaySourceReading { IsOn = true, VoltageKv = 90, CurrentUa = 120, PowerW = 10.8 },
|
||||
Detector = new DetectorState(true, true, 30, "111x144"),
|
||||
ImageInfo = "111x144",
|
||||
CapturedAtUtc = startedAt.AddSeconds(1)
|
||||
}
|
||||
},
|
||||
new[]
|
||||
{
|
||||
@@ -184,12 +192,24 @@ namespace XplorePlane.Tests.Services
|
||||
Assert.Equal(3, detail.Metrics.Count);
|
||||
Assert.Equal(4, detail.Assets.Count);
|
||||
Assert.Equal(2, detail.PipelineSnapshots.Count);
|
||||
var state = Assert.Single(detail.PositionStates);
|
||||
Assert.Equal(node1Id, state.NodeId);
|
||||
Assert.Equal(100, state.Motion.StageX);
|
||||
Assert.Equal(90, state.RaySource.VoltageKv);
|
||||
Assert.Equal(120, state.RaySource.CurrentUa);
|
||||
Assert.Equal("111x144", state.Detector.Resolution);
|
||||
Assert.Contains(detail.Nodes, n => n.NodeId == node1Id && n.NodePass);
|
||||
Assert.Contains(detail.Nodes, n => n.NodeId == node2Id && !n.NodePass);
|
||||
Assert.All(detail.PipelineSnapshots, snapshot => Assert.False(string.IsNullOrWhiteSpace(snapshot.PipelineHash)));
|
||||
|
||||
var manifestPath = Path.Combine(_tempRoot, "assets", detail.Run.ResultRootPath.Replace('/', Path.DirectorySeparatorChar), "manifest.json");
|
||||
Assert.True(File.Exists(manifestPath));
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath);
|
||||
using var manifest = JsonDocument.Parse(manifestJson);
|
||||
var positionState = manifest.RootElement.GetProperty("PositionStates")[0];
|
||||
Assert.Equal(90, positionState.GetProperty("RaySource").GetProperty("VoltageKv").GetDouble());
|
||||
Assert.Equal(120, positionState.GetProperty("RaySource").GetProperty("CurrentUa").GetDouble());
|
||||
Assert.Equal(100, positionState.GetProperty("Motion").GetProperty("StageX").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -406,6 +406,9 @@ namespace XplorePlane
|
||||
// 若配置为模拟探测器,自动初始化并启动采集(无需用户手动操作)
|
||||
await TryAutoStartSimulatedDetectorAsync();
|
||||
|
||||
// 若配置为虚拟相机,自动连接并启动实时预览(无需用户手动操作)
|
||||
TryAutoStartSimulatedCamera();
|
||||
|
||||
// [DEV] 相机状态通知已屏蔽
|
||||
// try
|
||||
// {
|
||||
@@ -475,6 +478,30 @@ namespace XplorePlane
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 若 config.json 中 CameraType = Simulated,自动连接虚拟相机并启动实时预览。
|
||||
/// </summary>
|
||||
private void TryAutoStartSimulatedCamera()
|
||||
{
|
||||
try
|
||||
{
|
||||
var camera = Container.Resolve<ICameraController>();
|
||||
if (camera is not XP.Camera.SimulatedCameraController)
|
||||
return;
|
||||
|
||||
Log.Information("[SimulatedCamera] 检测到虚拟相机模式,自动连接...");
|
||||
|
||||
var navVm = Container.Resolve<NavigationPropertyPanelViewModel>();
|
||||
navVm.OnCameraReady();
|
||||
|
||||
Log.Information("[SimulatedCamera] 虚拟相机已自动连接并启动实时预览");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "[SimulatedCamera] 虚拟相机自动启动异常");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行授权检查,授权失败时显示错误消息 | Perform license check, show error message on failure
|
||||
/// </summary>
|
||||
@@ -661,6 +688,7 @@ namespace XplorePlane
|
||||
|
||||
// ── CNC / 矩阵编排 / 测量数据服务(单例)──
|
||||
containerRegistry.RegisterSingleton<ICncProgramService, CncProgramService>();
|
||||
containerRegistry.RegisterSingleton<ICncInteractionService, CncInteractionService>();
|
||||
containerRegistry.RegisterSingleton<IMatrixService, MatrixService>();
|
||||
containerRegistry.RegisterSingleton<IMeasurementDataService, MeasurementDataService>();
|
||||
// 统一资产路径解析器(无状态单例,结果存储与标记图存储共用)
|
||||
|
||||
@@ -19,7 +19,12 @@ namespace XplorePlane.Models
|
||||
PauseDialog,
|
||||
WaitDelay,
|
||||
CompleteProgram,
|
||||
Reset
|
||||
Reset,
|
||||
// ── 硬件控制节点(P0)| Hardware control nodes ──
|
||||
RaySourceControl,
|
||||
DetectorCorrection,
|
||||
DoorControl,
|
||||
Geometry
|
||||
}
|
||||
|
||||
// ── CNC 节点基类与派生类型 | CNC Node Base & Derived Types ────────
|
||||
@@ -38,6 +43,10 @@ namespace XplorePlane.Models
|
||||
[JsonDerivedType(typeof(WaitDelayNode), "WaitDelay")]
|
||||
[JsonDerivedType(typeof(CompleteProgramNode), "CompleteProgram")]
|
||||
[JsonDerivedType(typeof(ResetNode), "Reset")]
|
||||
[JsonDerivedType(typeof(RaySourceControlNode), "RaySourceControl")]
|
||||
[JsonDerivedType(typeof(DetectorCorrectionNode), "DetectorCorrection")]
|
||||
[JsonDerivedType(typeof(DoorControlNode), "DoorControl")]
|
||||
[JsonDerivedType(typeof(GeometryNode), "Geometry")]
|
||||
public abstract record CncNode(
|
||||
Guid Id,
|
||||
int Index,
|
||||
@@ -90,7 +99,8 @@ namespace XplorePlane.Models
|
||||
MotionState MotionState,
|
||||
RaySourceState RaySourceState = null,
|
||||
bool SaveImage = false,
|
||||
string ManualImagePath = "") : CncNode(Id, Index, CncNodeType.SavePosition, Name);
|
||||
string ManualImagePath = "",
|
||||
DetectorAcquisitionSnapshot DetectorAcq = null) : CncNode(Id, Index, CncNodeType.SavePosition, Name);
|
||||
|
||||
/// <summary>检测模块节点 | Inspection module node</summary>
|
||||
public record InspectionModuleNode(
|
||||
@@ -135,6 +145,103 @@ namespace XplorePlane.Models
|
||||
int Index,
|
||||
string Name) : CncNode(Id, Index, CncNodeType.Reset, Name);
|
||||
|
||||
// ── 硬件控制节点(P0)| Hardware Control Nodes ────────────────────
|
||||
|
||||
/// <summary>探测器可下发采集参数快照(用于位置节点还原)| Detector down-loadable acquisition parameters snapshot (for position node restore)</summary>
|
||||
public record DetectorAcquisitionSnapshot(
|
||||
int BinningIndex,
|
||||
int Pga,
|
||||
decimal FrameRate);
|
||||
|
||||
/// <summary>射线源动作 | X-ray source action</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum RaySourceAction
|
||||
{
|
||||
/// <summary>开射线 | Turn on X-ray</summary>
|
||||
TurnOn,
|
||||
/// <summary>关射线 | Turn off X-ray</summary>
|
||||
TurnOff,
|
||||
/// <summary>设置参数(电压+电流,不改变开关状态)| Set voltage/current without changing on/off state</summary>
|
||||
SetParameters,
|
||||
/// <summary>暖机 | Warm-up</summary>
|
||||
WarmUp,
|
||||
/// <summary>训机 | Training</summary>
|
||||
Training
|
||||
}
|
||||
|
||||
/// <summary>射线源控制节点 | X-ray source control node</summary>
|
||||
public record RaySourceControlNode(
|
||||
Guid Id,
|
||||
int Index,
|
||||
string Name,
|
||||
RaySourceAction Action,
|
||||
double Voltage, // 电压(kV)| Voltage (kV)
|
||||
double Current, // 电流(μA)| Current (μA)
|
||||
bool WaitForStable = true,
|
||||
int StableTimeoutMs = 10000) : CncNode(Id, Index, CncNodeType.RaySourceControl, Name);
|
||||
|
||||
/// <summary>探测器校正类型 | Detector correction type</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum DetectorCorrectionType
|
||||
{
|
||||
/// <summary>暗场校正 | Dark field correction</summary>
|
||||
Dark,
|
||||
/// <summary>增益(亮场)校正 | Gain (bright field) correction</summary>
|
||||
Gain,
|
||||
/// <summary>坏像素校正 | Bad pixel correction</summary>
|
||||
BadPixel,
|
||||
/// <summary>自动校正(暗场+增益+坏像素)| Auto correction (dark + gain + bad pixel)</summary>
|
||||
Auto
|
||||
}
|
||||
|
||||
/// <summary>探测器校正节点 | Detector correction node</summary>
|
||||
public record DetectorCorrectionNode(
|
||||
Guid Id,
|
||||
int Index,
|
||||
string Name,
|
||||
DetectorCorrectionType CorrectionType,
|
||||
int FrameCount = 10) : CncNode(Id, Index, CncNodeType.DetectorCorrection, Name);
|
||||
|
||||
/// <summary>安全门动作 | Safety door action</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum DoorAction
|
||||
{
|
||||
/// <summary>开门 | Open door</summary>
|
||||
Open,
|
||||
/// <summary>关门 | Close door</summary>
|
||||
Close
|
||||
}
|
||||
|
||||
/// <summary>安全门控制节点 | Safety door control node</summary>
|
||||
public record DoorControlNode(
|
||||
Guid Id,
|
||||
int Index,
|
||||
string Name,
|
||||
DoorAction Action,
|
||||
bool WaitForComplete = true,
|
||||
int TimeoutMs = 15000) : CncNode(Id, Index, CncNodeType.DoorControl, Name);
|
||||
|
||||
/// <summary>几何设置模式 | Geometry setup mode</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum GeometryMode
|
||||
{
|
||||
/// <summary>由 FOD + FDD 反算 | Inverse from FOD + FDD</summary>
|
||||
ByFdd,
|
||||
/// <summary>由 FOD + 放大倍率反算 | Inverse from FOD + magnification</summary>
|
||||
ByMagnification
|
||||
}
|
||||
|
||||
/// <summary>几何设置节点 | Geometry setup node</summary>
|
||||
public record GeometryNode(
|
||||
Guid Id,
|
||||
int Index,
|
||||
string Name,
|
||||
GeometryMode Mode,
|
||||
double Fod, // 焦点-物体距离(mm)| Focus-object distance (mm)
|
||||
double Fdd = 0, // 焦点-探测器距离(mm)| Focus-detector distance (mm)
|
||||
double Magnification = 0,
|
||||
bool WaitForSettled = true) : CncNode(Id, Index, CncNodeType.Geometry, Name);
|
||||
|
||||
// ── CNC 程序 | CNC Program ────────────────────────────────────────
|
||||
|
||||
/// <summary>CNC 程序(不可变)| CNC program (immutable)</summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace XplorePlane.Models
|
||||
{
|
||||
@@ -130,6 +131,12 @@ namespace XplorePlane.Models
|
||||
public long DurationMs { get; set; }
|
||||
/// <summary>节点分类,默认检测模块。</summary>
|
||||
public InspectionNodeKind NodeKind { get; set; } = InspectionNodeKind.Inspection;
|
||||
|
||||
/// <summary>
|
||||
/// 节点执行/采集时的硬件状态。用于 manifest 的 PositionStates 段,不直接写入 Nodes 段。
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public InspectionPositionStateRecord PositionState { get; set; }
|
||||
}
|
||||
|
||||
public class InspectionMetricResult
|
||||
@@ -218,10 +225,14 @@ namespace XplorePlane.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// manifest 顶层架构版本。读取端默认按 1 处理(兼容历史 manifest);
|
||||
/// 写入端在 WriteManifest 时强制置为 2(含 map 字段 / Marks / Batch)。
|
||||
/// 写入端在 WriteManifest 时强制置为 2(含 map 字段 / Map 摘要 / Marks / Batch)。
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public InspectionRunRecord Run { get; set; } = new();
|
||||
/// <summary>
|
||||
/// 检查图定位摘要。manifest 是 CNC 运行总账;Map 指向可打开/可编辑的 .xpmap 标记图。
|
||||
/// </summary>
|
||||
public InspectionMapManifestSummary Map { get; set; }
|
||||
public IReadOnlyList<InspectionNodeResult> Nodes { get; set; } = Array.Empty<InspectionNodeResult>();
|
||||
public IReadOnlyList<InspectionMetricResult> Metrics { get; set; } = Array.Empty<InspectionMetricResult>();
|
||||
public IReadOnlyList<InspectionAssetRecord> Assets { get; set; } = Array.Empty<InspectionAssetRecord>();
|
||||
@@ -233,6 +244,12 @@ namespace XplorePlane.Models
|
||||
/// </summary>
|
||||
public IReadOnlyList<InspectionMarkRecord> Marks { get; set; } = Array.Empty<InspectionMarkRecord>();
|
||||
|
||||
/// <summary>
|
||||
/// 每个检测/采图位置的硬件状态归档。manifest 是追溯总账,位置状态放这里;
|
||||
/// .xpmap 只负责总览图上的可视化标记。
|
||||
/// </summary>
|
||||
public IReadOnlyList<InspectionPositionStateRecord> PositionStates { get; set; } = Array.Empty<InspectionPositionStateRecord>();
|
||||
|
||||
/// <summary>
|
||||
/// 多点采图批次统计段(由本次运行的 Capture 节点派生,语义等价原 summary.json)。
|
||||
/// 无 Capture 节点时为 null。
|
||||
@@ -240,6 +257,52 @@ namespace XplorePlane.Models
|
||||
public InspectionBatchSummary Batch { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// manifest.json 的检查图摘要:只描述 .xpmap/overview 的位置和职责,不替代 .xpmap 文档。
|
||||
/// </summary>
|
||||
public class InspectionMapManifestSummary
|
||||
{
|
||||
public Guid MapId { get; set; }
|
||||
public int SchemaVersion { get; set; }
|
||||
public string DocumentPath { get; set; } = string.Empty;
|
||||
public string OverviewImagePath { get; set; } = string.Empty;
|
||||
public string OverviewImageRole { get; set; } = "NavigationCameraOverview";
|
||||
public string MarkRole { get; set; } = "OverviewPositionMarkers";
|
||||
public string LinkedImageRole { get; set; } = "DetectorRegionImage";
|
||||
public string StateSnapshotRole { get; set; } = "PerPositionHardwareStatesInManifestPositionStates";
|
||||
public DateTime? UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// manifest.json 的位置状态记录:绑定某个 CNC 节点,保存运动位置、射线源电压/电流和探测器状态。
|
||||
/// </summary>
|
||||
public class InspectionPositionStateRecord
|
||||
{
|
||||
public Guid RunId { get; set; }
|
||||
public Guid NodeId { get; set; }
|
||||
public int NodeIndex { get; set; }
|
||||
public string NodeName { get; set; } = string.Empty;
|
||||
public InspectionNodeKind NodeKind { get; set; } = InspectionNodeKind.Inspection;
|
||||
public DateTime CapturedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public MotionState Motion { get; set; } = MotionState.Default;
|
||||
public RaySourceReading RaySource { get; set; } = new();
|
||||
public DetectorState Detector { get; set; } = DetectorState.Default;
|
||||
public string ImageInfo { get; set; } = string.Empty;
|
||||
public string SourceImagePath { get; set; } = string.Empty;
|
||||
public string ResultImagePath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 归档用射线源读数。字段名明确表达单位,避免复用 RaySourceState.Power 时产生电流/功率歧义。
|
||||
/// </summary>
|
||||
public class RaySourceReading
|
||||
{
|
||||
public bool IsOn { get; set; }
|
||||
public double VoltageKv { get; set; }
|
||||
public double CurrentUa { get; set; }
|
||||
public double PowerW { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 多点采图批次统计(manifest.json 的 Batch 段)。由 NodeKind=Capture 的节点派生,
|
||||
/// 语义与原 summary.json 等价。
|
||||
|
||||
@@ -12,6 +12,8 @@ using System.Windows.Media.Imaging;
|
||||
using Prism.Events;
|
||||
using XP.Common.Converters;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XP.Hardware.Detector.Abstractions;
|
||||
using XP.Hardware.Detector.Services;
|
||||
using XP.Hardware.MotionControl.Abstractions;
|
||||
using XP.Hardware.MotionControl.Abstractions.Enums;
|
||||
using XP.Hardware.MotionControl.Services;
|
||||
@@ -48,6 +50,8 @@ namespace XplorePlane.Services.Cnc
|
||||
private readonly IMotionControlService _motionControlService; // 运动控制(可选)
|
||||
private readonly IMotionSystem _motionSystem; // 运动系统状态查询(可选)
|
||||
private readonly IRaySourceService _raySourceService; // 射线源控制(可选)
|
||||
private readonly IDetectorService _detectorService; // 探测器控制(可选)
|
||||
private readonly ICncInteractionService _interactionService; // 执行期人机交互(可选)
|
||||
|
||||
// 当前执行的取消令牌源(volatile 保证跨线程可见性)
|
||||
// 探测器断连事件通过此字段取消正在执行的程序
|
||||
@@ -65,7 +69,9 @@ namespace XplorePlane.Services.Cnc
|
||||
IMotionControlService motionControlService = null,
|
||||
IMotionSystem motionSystem = null,
|
||||
IRaySourceService raySourceService = null,
|
||||
CncInspectionMarkAdapter cncMarkAdapter = null)
|
||||
CncInspectionMarkAdapter cncMarkAdapter = null,
|
||||
IDetectorService detectorService = null,
|
||||
ICncInteractionService interactionService = null)
|
||||
{
|
||||
_archive = archive ?? throw new ArgumentNullException(nameof(archive));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -77,6 +83,8 @@ namespace XplorePlane.Services.Cnc
|
||||
_motionControlService = motionControlService;
|
||||
_motionSystem = motionSystem;
|
||||
_raySourceService = raySourceService;
|
||||
_detectorService = detectorService;
|
||||
_interactionService = interactionService;
|
||||
_cncMarkAdapter = cncMarkAdapter ?? new CncInspectionMarkAdapter();
|
||||
|
||||
// Task 4.3: subscribe to DetectorDisconnectedEvent on a background thread
|
||||
@@ -192,6 +200,16 @@ namespace XplorePlane.Services.Cnc
|
||||
}
|
||||
await WaitForAxesSettledAsync(linkedCts.Token);
|
||||
|
||||
// ── 还原该位置保存的硬件状态(射线源电压/电流 + 探测器采集参数)──
|
||||
var restoreOutcome = await RestoreHardwareStateAsync(sp, linkedCts.Token);
|
||||
if (restoreOutcome == RestoreOutcome.AbortPaused)
|
||||
{
|
||||
// 射线未开被放弃 或 还原失败:暂停并终止运行,不采集不检测
|
||||
cancelled = true;
|
||||
positionResults.Add(new PositionResult { NodeName = sp.Name, NodeIndex = sp.Index, Status = "Paused", ErrorMessage = "硬件状态还原中止" });
|
||||
break;
|
||||
}
|
||||
|
||||
BitmapSource positionImage = null;
|
||||
if (!string.IsNullOrEmpty(sp.ManualImagePath))
|
||||
{
|
||||
@@ -275,6 +293,23 @@ namespace XplorePlane.Services.Cnc
|
||||
catch (OperationCanceledException) { cancelled = true; }
|
||||
break;
|
||||
|
||||
// ── 硬件动作节点(P0 补充)──
|
||||
case RaySourceControlNode rc:
|
||||
nodeSucceeded = await ExecuteRaySourceControlAsync(rc, linkedCts.Token);
|
||||
break;
|
||||
|
||||
case DetectorCorrectionNode dc:
|
||||
nodeSucceeded = await ExecuteDetectorCorrectionAsync(dc, linkedCts.Token);
|
||||
break;
|
||||
|
||||
case DoorControlNode door:
|
||||
nodeSucceeded = await ExecuteDoorControlAsync(door, linkedCts.Token);
|
||||
break;
|
||||
|
||||
case GeometryNode geo:
|
||||
nodeSucceeded = await ExecuteGeometryAsync(geo, linkedCts.Token);
|
||||
break;
|
||||
|
||||
case CompleteProgramNode:
|
||||
progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Succeeded));
|
||||
goto endLoop;
|
||||
@@ -415,6 +450,276 @@ namespace XplorePlane.Services.Cnc
|
||||
await WaitForAxesSettledAsync(ct);
|
||||
}
|
||||
|
||||
// ── 位置节点硬件状态还原 | Position node hardware state restore ──────
|
||||
|
||||
/// <summary>硬件状态还原结果。</summary>
|
||||
private enum RestoreOutcome
|
||||
{
|
||||
/// <summary>还原成功(或部分因服务不可用而降级跳过)。</summary>
|
||||
Ok,
|
||||
/// <summary>因服务不可用整体降级跳过。</summary>
|
||||
SkippedDegraded,
|
||||
/// <summary>射线未开被放弃 或 还原失败:暂停并终止运行。</summary>
|
||||
AbortPaused
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 还原位置节点保存的硬件状态:先处理射线未开提醒,再还原射线源电压/电流,最后还原探测器采集参数。
|
||||
/// 遵循「不自动开关射线、先电压后电流」安全规则;服务不可用时降级跳过;下发失败时弹窗暂停并终止。
|
||||
/// </summary>
|
||||
private async Task<RestoreOutcome> RestoreHardwareStateAsync(SavePositionNode sp, CancellationToken ct)
|
||||
{
|
||||
// 1) 射线未开提醒(需求 1.5):快照要求射线开、当前未开 → 弹窗暂停等待操作员
|
||||
if (sp.RaySourceState?.IsOn == true && !IsRayCurrentlyOn())
|
||||
{
|
||||
if (_interactionService == null)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn(
|
||||
"位置 '{0}' 需要射线开启但当前未开,且无交互服务,无法提醒(降级继续)", sp.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
bool proceed = await _interactionService.ConfirmRayOnAsync(sp.Name, ct);
|
||||
if (!proceed || !IsRayCurrentlyOn())
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("位置 '{0}' 射线仍未开启,中止该位置", sp.Name);
|
||||
return RestoreOutcome.AbortPaused;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 还原射线源电压/电流(不改开关;先电压后电流)(需求 1.4)
|
||||
if (sp.RaySourceState != null)
|
||||
{
|
||||
if (_raySourceService == null)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("射线源服务不可用,跳过参数还原(仿真降级)");
|
||||
}
|
||||
else
|
||||
{
|
||||
var vr = _raySourceService.SetVoltage((float)sp.RaySourceState.Voltage);
|
||||
if (!vr.Success)
|
||||
return await FailPauseAsync("设置电压失败", vr.ErrorMessage, ct);
|
||||
|
||||
// 注:SavePosition 约定 RaySourceState.Power 字段存储电流值(μA)
|
||||
var cr = _raySourceService.SetCurrent((float)sp.RaySourceState.Power);
|
||||
if (!cr.Success)
|
||||
return await FailPauseAsync("设置电流失败", cr.ErrorMessage, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 还原探测器采集参数(可下发参数)(需求 1.6)
|
||||
if (sp.DetectorAcq != null)
|
||||
{
|
||||
if (_detectorService == null)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("探测器服务不可用,跳过参数还原(仿真降级)");
|
||||
}
|
||||
else
|
||||
{
|
||||
var dr = await _detectorService.ApplyParametersAsync(
|
||||
sp.DetectorAcq.BinningIndex, sp.DetectorAcq.Pga, sp.DetectorAcq.FrameRate, ct);
|
||||
if (!dr.IsSuccess)
|
||||
return await FailPauseAsync("应用探测器参数失败", dr.ErrorMessage, ct);
|
||||
}
|
||||
}
|
||||
|
||||
return RestoreOutcome.Ok;
|
||||
}
|
||||
|
||||
/// <summary>读取当前射线是否开启(服务不可用/异常时视为未开)。</summary>
|
||||
private bool IsRayCurrentlyOn()
|
||||
{
|
||||
try { return _raySourceService?.IsXRayOn ?? false; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <summary>还原下发失败:记录警告、弹窗提示并返回暂停终止。</summary>
|
||||
private async Task<RestoreOutcome> FailPauseAsync(string title, string detail, CancellationToken ct)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("硬件状态还原失败:{0} - {1}", title, detail);
|
||||
if (_interactionService != null)
|
||||
await _interactionService.NotifyRestoreFailureAsync(title, detail ?? string.Empty, ct);
|
||||
return RestoreOutcome.AbortPaused;
|
||||
}
|
||||
|
||||
// ── 硬件动作节点执行 | Hardware action node execution ─────────────────
|
||||
|
||||
/// <summary>执行射线源控制节点(开/关/设参/暖机/训机)。服务不可用时降级跳过,失败返回 false。</summary>
|
||||
private async Task<bool> ExecuteRaySourceControlAsync(RaySourceControlNode node, CancellationToken ct)
|
||||
{
|
||||
if (_raySourceService == null)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("射线源服务不可用,射线源控制节点跳过(仿真降级)");
|
||||
return true;
|
||||
}
|
||||
|
||||
var log = _logger.ForModule<CncExecutionService>();
|
||||
switch (node.Action)
|
||||
{
|
||||
case RaySourceAction.SetParameters:
|
||||
case RaySourceAction.TurnOn:
|
||||
{
|
||||
// 安全规则:先电压后电流;开启态不调压调流由射线源服务内部约束
|
||||
var vr = _raySourceService.SetVoltage((float)node.Voltage);
|
||||
if (!vr.Success) { log.Warn("设置电压失败:{0}", vr.ErrorMessage); return false; }
|
||||
var cr = _raySourceService.SetCurrent((float)node.Current);
|
||||
if (!cr.Success) { log.Warn("设置电流失败:{0}", cr.ErrorMessage); return false; }
|
||||
|
||||
if (node.Action == RaySourceAction.TurnOn)
|
||||
{
|
||||
var on = _raySourceService.TurnOn();
|
||||
if (!on.Success) { log.Warn("开射线失败:{0}", on.ErrorMessage); return false; }
|
||||
if (node.WaitForStable)
|
||||
await WaitForRayStableAsync(node.Voltage, node.Current, node.StableTimeoutMs, ct);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case RaySourceAction.TurnOff:
|
||||
{
|
||||
var off = _raySourceService.TurnOff();
|
||||
if (!off.Success) { log.Warn("关射线失败:{0}", off.ErrorMessage); return false; }
|
||||
return true;
|
||||
}
|
||||
case RaySourceAction.WarmUp:
|
||||
{
|
||||
var r = _raySourceService.WarmUp();
|
||||
if (!r.Success) { log.Warn("暖机失败:{0}", r.ErrorMessage); return false; }
|
||||
return true;
|
||||
}
|
||||
case RaySourceAction.Training:
|
||||
{
|
||||
var r = _raySourceService.Training();
|
||||
if (!r.Success) { log.Warn("训机失败:{0}", r.ErrorMessage); return false; }
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>轮询实际电压/电流直到进入目标误差阈值或超时。</summary>
|
||||
private async Task WaitForRayStableAsync(double targetV, double targetC, int timeoutMs, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
const int pollMs = 200;
|
||||
const double vTol = 2.0; // kV
|
||||
const double cTol = 20.0; // μA
|
||||
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
double v = 0, c = 0;
|
||||
var vr = _raySourceService.ReadVoltage();
|
||||
if (vr?.Success == true) v = vr.GetFloat();
|
||||
var cr = _raySourceService.ReadCurrent();
|
||||
if (cr?.Success == true) c = cr.GetFloat();
|
||||
|
||||
if (Math.Abs(v - targetV) <= vTol && Math.Abs(c - targetC) <= cTol)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Info("射线已稳定,耗时 {0}ms", sw.ElapsedMilliseconds);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(pollMs, ct);
|
||||
}
|
||||
|
||||
_logger.ForModule<CncExecutionService>().Warn("等待射线稳定超时({0}ms),继续执行", timeoutMs);
|
||||
}
|
||||
|
||||
/// <summary>执行探测器校正节点。服务不可用时降级跳过,失败返回 false。</summary>
|
||||
private async Task<bool> ExecuteDetectorCorrectionAsync(DetectorCorrectionNode node, CancellationToken ct)
|
||||
{
|
||||
if (_detectorService == null)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("探测器服务不可用,校正节点跳过(仿真降级)");
|
||||
return true;
|
||||
}
|
||||
|
||||
DetectorResult result = node.CorrectionType switch
|
||||
{
|
||||
DetectorCorrectionType.Dark => await _detectorService.DarkCorrectionAsync(node.FrameCount, ct),
|
||||
DetectorCorrectionType.Gain => await _detectorService.GainCorrectionAsync(node.FrameCount, ct),
|
||||
DetectorCorrectionType.BadPixel => await _detectorService.BadPixelCorrectionAsync(ct),
|
||||
DetectorCorrectionType.Auto => await _detectorService.AutoCorrectionAsync(node.FrameCount, ct),
|
||||
_ => DetectorResult.Success("无操作")
|
||||
};
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("探测器校正失败:{0}", result.ErrorMessage);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>执行安全门控制节点。服务不可用时降级跳过,失败返回 false。</summary>
|
||||
private async Task<bool> ExecuteDoorControlAsync(DoorControlNode node, CancellationToken ct)
|
||||
{
|
||||
if (_motionControlService == null)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("运动服务不可用,门控节点跳过(仿真降级)");
|
||||
return true;
|
||||
}
|
||||
|
||||
// OpenDoor 内含联锁检查:联锁有效时会被拒绝
|
||||
var result = node.Action == DoorAction.Open
|
||||
? _motionControlService.OpenDoor()
|
||||
: _motionControlService.CloseDoor();
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("门控指令失败:{0}", result.ErrorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.WaitForComplete)
|
||||
await WaitForDoorSettledAsync(node, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 等待门到位。
|
||||
/// TODO(待硬件层完善):当前运动层未暴露门状态信号的可读到位判定,
|
||||
/// 暂以可取消的短暂等待占位,硬件层补齐门状态后应改为轮询到位/超时。
|
||||
/// </summary>
|
||||
private async Task WaitForDoorSettledAsync(DoorControlNode node, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 保守等待,给物理门动作留出时间;上限不超过节点配置超时
|
||||
int waitMs = Math.Min(node.TimeoutMs, 2000);
|
||||
await Task.Delay(waitMs, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
}
|
||||
|
||||
/// <summary>执行几何设置节点。服务不可用时降级跳过,失败返回 false。</summary>
|
||||
private async Task<bool> ExecuteGeometryAsync(GeometryNode node, CancellationToken ct)
|
||||
{
|
||||
if (_motionControlService == null)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("运动服务不可用,几何节点跳过(仿真降级)");
|
||||
return true;
|
||||
}
|
||||
|
||||
var result = node.Mode == GeometryMode.ByFdd
|
||||
? _motionControlService.ApplyGeometry(node.Fod, node.Fdd)
|
||||
: _motionControlService.ApplyGeometryByMagnification(node.Fod, node.Magnification);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("几何设置失败:{0}", result.ErrorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.WaitForSettled)
|
||||
await WaitForAxesSettledAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 将所有轴移动到目标位置。
|
||||
/// MotionState 中的坐标单位为微米(μm),运动控制接口使用毫米(mm),需除以 1000。
|
||||
@@ -425,7 +730,6 @@ namespace XplorePlane.Services.Cnc
|
||||
{
|
||||
if (_motionControlService == null)
|
||||
return Task.FromResult(MotionResult.Ok());
|
||||
|
||||
// Linear axes: convert μm → mm (divide by 1000.0)
|
||||
var stageXResult = _motionControlService.MoveToTarget(AxisId.StageX, target.StageX / 1000.0);
|
||||
if (!stageXResult.Success) return Task.FromResult(stageXResult);
|
||||
@@ -602,7 +906,9 @@ namespace XplorePlane.Services.Cnc
|
||||
NodeId = inspectionNode.Id,
|
||||
NodeIndex = inspectionNode.Index,
|
||||
NodeName = inspectionNode.Name,
|
||||
PipelineName = inspectionNode.Pipeline?.Name ?? string.Empty
|
||||
PipelineName = inspectionNode.Pipeline?.Name ?? string.Empty,
|
||||
NodeKind = InspectionNodeKind.Inspection,
|
||||
PositionState = CreatePositionState(runId, inspectionNode, sourceImage)
|
||||
};
|
||||
|
||||
PipelineExecutionSnapshot pipelineSnapshot = null;
|
||||
@@ -733,6 +1039,75 @@ namespace XplorePlane.Services.Cnc
|
||||
return resultImage;
|
||||
}
|
||||
|
||||
private InspectionPositionStateRecord CreatePositionState(Guid runId, InspectionModuleNode node, BitmapSource sourceImage)
|
||||
{
|
||||
try
|
||||
{
|
||||
var motion = _appStateService?.MotionState ?? MotionState.Default;
|
||||
var ray = _appStateService?.RaySourceState ?? RaySourceState.Default;
|
||||
var detector = _appStateService?.DetectorState ?? DetectorState.Default;
|
||||
var currentUa = TryReadRayCurrent();
|
||||
var powerW = ray.Power;
|
||||
if (powerW <= 0 && ray.Voltage > 0 && currentUa > 0)
|
||||
{
|
||||
powerW = ray.Voltage * currentUa / 1000.0;
|
||||
}
|
||||
|
||||
return new InspectionPositionStateRecord
|
||||
{
|
||||
RunId = runId,
|
||||
NodeId = node.Id,
|
||||
NodeIndex = node.Index,
|
||||
NodeName = node.Name,
|
||||
NodeKind = InspectionNodeKind.Inspection,
|
||||
CapturedAtUtc = DateTime.UtcNow,
|
||||
Motion = motion,
|
||||
RaySource = new RaySourceReading
|
||||
{
|
||||
IsOn = ray.IsOn,
|
||||
VoltageKv = ray.Voltage,
|
||||
CurrentUa = currentUa,
|
||||
PowerW = powerW
|
||||
},
|
||||
Detector = detector,
|
||||
ImageInfo = sourceImage == null ? string.Empty : $"{sourceImage.PixelWidth}x{sourceImage.PixelHeight}"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("采集 CNC 位置状态失败,manifest 将写入默认状态:{0}", ex.Message);
|
||||
return new InspectionPositionStateRecord
|
||||
{
|
||||
RunId = runId,
|
||||
NodeId = node.Id,
|
||||
NodeIndex = node.Index,
|
||||
NodeName = node.Name,
|
||||
NodeKind = InspectionNodeKind.Inspection,
|
||||
CapturedAtUtc = DateTime.UtcNow,
|
||||
ImageInfo = sourceImage == null ? string.Empty : $"{sourceImage.PixelWidth}x{sourceImage.PixelHeight}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private double TryReadRayCurrent()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = _raySourceService?.ReadCurrent();
|
||||
if (result?.Success == true)
|
||||
{
|
||||
return result.GetFloat();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.ForModule<CncExecutionService>().Warn("读取射线源电流失败,manifest 位置状态将尝试由功率估算:{0}", ex.Message);
|
||||
}
|
||||
|
||||
var ray = _appStateService?.RaySourceState ?? RaySourceState.Default;
|
||||
return ray.Voltage > 0 ? ray.Power * 1000.0 / ray.Voltage : 0d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从流水线最后一步的 OutputData 中提取检测指标(BGA空洞率、孔隙测量等)。
|
||||
/// 写入 manifest.json 的 Metrics 部分。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
|
||||
namespace XplorePlane.Services.Cnc
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="ICncInteractionService"/> 的 WPF 实现,基于 Dispatcher + MessageBox 实现模态暂停。
|
||||
/// 沿用 PauseDialogNode 的模态对话框模式。
|
||||
/// </summary>
|
||||
public class CncInteractionService : ICncInteractionService
|
||||
{
|
||||
private readonly ILoggerService _logger;
|
||||
|
||||
public CncInteractionService(ILoggerService logger = null)
|
||||
{
|
||||
_logger = logger?.ForModule<CncInteractionService>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ConfirmRayOnAsync(string positionName, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher == null)
|
||||
{
|
||||
// 无 UI 环境(如测试/无头运行):保守放弃,避免静默采集
|
||||
_logger?.Warn("[CncInteraction] 无 Dispatcher,射线未开确认默认放弃");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger?.Warn("[CncInteraction] 位置 '{0}' 需要射线开启但当前未开,弹窗提醒操作员", positionName);
|
||||
|
||||
var result = await dispatcher.InvokeAsync(() =>
|
||||
MessageBox.Show(
|
||||
$"检测位置「{positionName}」需要射线开启,但当前射线未开。\n\n请手动开启射线后点击「确定」继续,或点击「取消」停止程序。",
|
||||
"射线未开启",
|
||||
MessageBoxButton.OKCancel,
|
||||
MessageBoxImage.Warning));
|
||||
|
||||
return result == MessageBoxResult.OK;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task NotifyRestoreFailureAsync(string title, string detail, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher == null)
|
||||
{
|
||||
_logger?.Warn("[CncInteraction] 硬件还原失败(无 UI):{0} - {1}", title, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger?.Warn("[CncInteraction] 硬件还原失败:{0} - {1}", title, detail);
|
||||
|
||||
await dispatcher.InvokeAsync(() =>
|
||||
MessageBox.Show(
|
||||
$"{detail}\n\n程序已暂停,请处理后重新运行。",
|
||||
title,
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using XP.Common.Logging.Interfaces;
|
||||
using XP.Hardware.Detector.Services;
|
||||
using XP.Hardware.RaySource.Services;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.AppState;
|
||||
@@ -26,6 +27,7 @@ namespace XplorePlane.Services.Cnc
|
||||
private readonly IRaySourceService _raySourceService;
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly IDetectorService _detectorService;
|
||||
|
||||
// ── 序列化配置 | Serialization options ──
|
||||
private static readonly JsonSerializerOptions CncJsonOptions = new()
|
||||
@@ -39,7 +41,8 @@ namespace XplorePlane.Services.Cnc
|
||||
IAppStateService appStateService,
|
||||
IRaySourceService raySourceService,
|
||||
ILoggerService logger,
|
||||
IPermissionService permissionService)
|
||||
IPermissionService permissionService,
|
||||
IDetectorService detectorService = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(appStateService);
|
||||
ArgumentNullException.ThrowIfNull(raySourceService);
|
||||
@@ -50,6 +53,7 @@ namespace XplorePlane.Services.Cnc
|
||||
_raySourceService = raySourceService;
|
||||
_logger = logger.ForModule<CncProgramService>();
|
||||
_permissionService = permissionService;
|
||||
_detectorService = detectorService; // 可选:用于捕获探测器采集参数快照
|
||||
|
||||
_logger.Info("CncProgramService 已初始化 | CncProgramService initialized");
|
||||
}
|
||||
@@ -129,6 +133,22 @@ namespace XplorePlane.Services.Cnc
|
||||
CncNodeType.Reset => new ResetNode(
|
||||
id, defaultIndex, "复位"),
|
||||
|
||||
// 射线源控制:默认设参数,电压/电流取当前值 | Ray source control: default SetParameters
|
||||
CncNodeType.RaySourceControl => CreateRaySourceControlNode(id, defaultIndex),
|
||||
|
||||
// 探测器校正:默认自动校正、10 帧 | Detector correction: default Auto, 10 frames
|
||||
CncNodeType.DetectorCorrection => new DetectorCorrectionNode(
|
||||
id, defaultIndex, $"探测器校正_{defaultIndex}",
|
||||
CorrectionType: DetectorCorrectionType.Auto, FrameCount: 10),
|
||||
|
||||
// 安全门:默认开门 | Door control: default Open
|
||||
CncNodeType.DoorControl => new DoorControlNode(
|
||||
id, defaultIndex, $"安全门_{defaultIndex}",
|
||||
Action: DoorAction.Open),
|
||||
|
||||
// 几何设置:默认按 FDD,取当前几何值 | Geometry: default ByFdd
|
||||
CncNodeType.Geometry => CreateGeometryNode(id, defaultIndex),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, $"不支持的节点类型 | Unsupported node type: {type}")
|
||||
};
|
||||
|
||||
@@ -379,6 +399,12 @@ namespace XplorePlane.Services.Cnc
|
||||
/// <summary>等待延时最大值(毫秒)| Wait delay maximum (ms)</summary>
|
||||
private const int WaitDelayMax = 300000;
|
||||
|
||||
// ── 硬件节点值域常量 | Hardware node value-range constants ──
|
||||
private const int FrameCountMin = 1;
|
||||
private const int FrameCountMax = 1000;
|
||||
private const int TimeoutMsMin = 0;
|
||||
private const int TimeoutMsMax = 600000;
|
||||
|
||||
// ── 内部辅助方法 | Internal helper methods ──
|
||||
|
||||
/// <summary>
|
||||
@@ -388,22 +414,43 @@ namespace XplorePlane.Services.Cnc
|
||||
Math.Clamp(value, WaitDelayMin, WaitDelayMax);
|
||||
|
||||
/// <summary>
|
||||
/// 对反序列化后的节点列表应用值域校验(如 WaitDelay 截断)
|
||||
/// Apply value range validation to deserialized nodes (e.g. WaitDelay clamping)
|
||||
/// 对反序列化后的节点列表应用值域校验(如 WaitDelay 截断、硬件节点参数下限)
|
||||
/// Apply value range validation to deserialized nodes.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<CncNode> ValidateNodes(IReadOnlyList<CncNode> nodes)
|
||||
{
|
||||
var result = new List<CncNode>(nodes.Count);
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node is WaitDelayNode wdn)
|
||||
switch (node)
|
||||
{
|
||||
var clamped = ClampWaitDelay(wdn.DelayMilliseconds);
|
||||
result.Add(clamped != wdn.DelayMilliseconds ? wdn with { DelayMilliseconds = clamped } : wdn);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(node);
|
||||
case WaitDelayNode wdn:
|
||||
{
|
||||
var clamped = ClampWaitDelay(wdn.DelayMilliseconds);
|
||||
result.Add(clamped != wdn.DelayMilliseconds ? wdn with { DelayMilliseconds = clamped } : wdn);
|
||||
break;
|
||||
}
|
||||
case DetectorCorrectionNode dcn:
|
||||
{
|
||||
var clamped = Math.Clamp(dcn.FrameCount, FrameCountMin, FrameCountMax);
|
||||
result.Add(clamped != dcn.FrameCount ? dcn with { FrameCount = clamped } : dcn);
|
||||
break;
|
||||
}
|
||||
case RaySourceControlNode rcn:
|
||||
{
|
||||
var clamped = Math.Clamp(rcn.StableTimeoutMs, TimeoutMsMin, TimeoutMsMax);
|
||||
result.Add(clamped != rcn.StableTimeoutMs ? rcn with { StableTimeoutMs = clamped } : rcn);
|
||||
break;
|
||||
}
|
||||
case DoorControlNode dcn2:
|
||||
{
|
||||
var clamped = Math.Clamp(dcn2.TimeoutMs, TimeoutMsMin, TimeoutMsMax);
|
||||
result.Add(clamped != dcn2.TimeoutMs ? dcn2 with { TimeoutMs = clamped } : dcn2);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
result.Add(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result.AsReadOnly();
|
||||
@@ -485,11 +532,46 @@ namespace XplorePlane.Services.Cnc
|
||||
double current = Math.Round(TryReadCurrent(), 0);
|
||||
var saveRayState = new RaySourceState(rayState.IsOn, Math.Round(rayState.Voltage, 0), current);
|
||||
|
||||
// 捕获探测器采集参数快照(Binning/PGA/帧率)。
|
||||
// 待硬件层完善:GetCurrentAcquisitionParameters 目前返回 null,此时 DetectorAcq 为 null,
|
||||
// 执行时将跳过探测器参数还原,不影响运动与射线源还原。
|
||||
DetectorAcquisitionSnapshot detectorAcq = null;
|
||||
var acqParams = _detectorService?.GetCurrentAcquisitionParameters();
|
||||
if (acqParams != null)
|
||||
detectorAcq = new DetectorAcquisitionSnapshot(acqParams.BinningIndex, acqParams.Pga, acqParams.FrameRate);
|
||||
|
||||
return new SavePositionNode(
|
||||
id, index, $"检测位置_{index}",
|
||||
MotionState: RoundMotionState(_appStateService.MotionState),
|
||||
RaySourceState: saveRayState,
|
||||
SaveImage: false);
|
||||
SaveImage: false,
|
||||
DetectorAcq: detectorAcq);
|
||||
}
|
||||
|
||||
/// <summary>创建射线源控制节点(默认设参数,电压/电流取当前值)| Create ray source control node</summary>
|
||||
private RaySourceControlNode CreateRaySourceControlNode(Guid id, int index)
|
||||
{
|
||||
var rayState = _appStateService.RaySourceState;
|
||||
double voltage = Math.Round(rayState?.Voltage ?? 0, 0);
|
||||
double current = Math.Round(TryReadCurrent(), 0);
|
||||
return new RaySourceControlNode(
|
||||
id, index, $"射线源控制_{index}",
|
||||
Action: RaySourceAction.SetParameters,
|
||||
Voltage: voltage,
|
||||
Current: current);
|
||||
}
|
||||
|
||||
/// <summary>创建几何设置节点(默认按 FDD,取当前几何值,μm→mm)| Create geometry node</summary>
|
||||
private GeometryNode CreateGeometryNode(Guid id, int index)
|
||||
{
|
||||
// MotionState 中 FOD/FDD 单位为 μm,几何节点使用 mm,需除以 1000
|
||||
var motion = _appStateService.MotionState ?? MotionState.Default;
|
||||
return new GeometryNode(
|
||||
id, index, $"几何设置_{index}",
|
||||
Mode: GeometryMode.ByFdd,
|
||||
Fod: Math.Round(motion.FOD / 1000.0, 3),
|
||||
Fdd: Math.Round(motion.FDD / 1000.0, 3),
|
||||
Magnification: Math.Round(motion.Magnification, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace XplorePlane.Services.Cnc
|
||||
{
|
||||
/// <summary>
|
||||
/// CNC 执行期的人机交互服务,封装弹窗提醒与暂停确认,便于单元测试 Mock。
|
||||
/// CNC execution-time interaction service, wraps dialog prompts for testability.
|
||||
/// </summary>
|
||||
public interface ICncInteractionService
|
||||
{
|
||||
/// <summary>
|
||||
/// 提醒操作员当前位置需要射线开启但射线未开,暂停等待其处理。
|
||||
/// 返回 true 表示操作员已确认继续(应已手动开启射线),false 表示放弃。
|
||||
/// Prompt operator that the position requires X-ray on while it is off; blocks until confirmed.
|
||||
/// Returns true if the operator confirms to continue, false to abort.
|
||||
/// </summary>
|
||||
Task<bool> ConfirmRayOnAsync(string positionName, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 提示硬件状态还原失败,暂停程序(模态提示,用户确认后返回)。
|
||||
/// Notify that a hardware state restore failed; blocks (modal) until acknowledged.
|
||||
/// </summary>
|
||||
Task NotifyRestoreFailureAsync(string title, string detail, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS inspection_node_results (
|
||||
status TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
node_kind TEXT NOT NULL DEFAULT 'Inspection',
|
||||
position_state_json TEXT NULL,
|
||||
PRIMARY KEY (run_id, node_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_inspection_nodes_run_id ON inspection_node_results(run_id);
|
||||
@@ -167,10 +168,10 @@ VALUES (
|
||||
private const string InsertNodeSql = @"
|
||||
INSERT OR REPLACE INTO inspection_node_results (
|
||||
run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash,
|
||||
node_pass, source_image_path, result_image_path, status, duration_ms, node_kind)
|
||||
node_pass, source_image_path, result_image_path, status, duration_ms, node_kind, position_state_json)
|
||||
VALUES (
|
||||
@run_id, @node_id, @node_index, @node_name, @pipeline_id, @pipeline_name, @pipeline_version_hash,
|
||||
@node_pass, @source_image_path, @result_image_path, @status, @duration_ms, @node_kind)";
|
||||
@node_pass, @source_image_path, @result_image_path, @status, @duration_ms, @node_kind, @position_state_json)";
|
||||
|
||||
private const string InsertMetricSql = @"
|
||||
INSERT OR REPLACE INTO inspection_metric_results (
|
||||
@@ -395,6 +396,17 @@ WHERE run_id = @run_id";
|
||||
nodeResult.Status = assetFailureMode.Value;
|
||||
}
|
||||
|
||||
if (nodeResult.PositionState != null)
|
||||
{
|
||||
nodeResult.PositionState.RunId = nodeResult.RunId;
|
||||
nodeResult.PositionState.NodeId = nodeResult.NodeId;
|
||||
nodeResult.PositionState.NodeIndex = nodeResult.NodeIndex;
|
||||
nodeResult.PositionState.NodeName = nodeResult.NodeName;
|
||||
nodeResult.PositionState.NodeKind = nodeResult.NodeKind;
|
||||
nodeResult.PositionState.SourceImagePath = nodeResult.SourceImagePath;
|
||||
nodeResult.PositionState.ResultImagePath = nodeResult.ResultImagePath;
|
||||
}
|
||||
|
||||
var metricsList = (metrics ?? Enumerable.Empty<InspectionMetricResult>())
|
||||
.Select(metric =>
|
||||
{
|
||||
@@ -430,7 +442,8 @@ WHERE run_id = @run_id";
|
||||
["result_image_path"] = nodeResult.ResultImagePath,
|
||||
["status"] = nodeResult.Status.ToString(),
|
||||
["duration_ms"] = nodeResult.DurationMs,
|
||||
["node_kind"] = nodeResult.NodeKind.ToString()
|
||||
["node_kind"] = nodeResult.NodeKind.ToString(),
|
||||
["position_state_json"] = nodeResult.PositionState == null ? null : JsonSerializer.Serialize(nodeResult.PositionState, JsonOptions)
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (!saveNode.IsSuccess)
|
||||
@@ -836,7 +849,7 @@ WHERE run_id = @run_id";
|
||||
var run = MapRun(runs[0]);
|
||||
|
||||
var (nodeResult, nodeRows) = await _db.QueryListAsync<InspectionNodeRow>(
|
||||
"SELECT run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, node_pass, source_image_path, result_image_path, status, duration_ms, node_kind FROM inspection_node_results WHERE run_id = @run_id ORDER BY node_index ASC",
|
||||
"SELECT run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, node_pass, source_image_path, result_image_path, status, duration_ms, node_kind, position_state_json FROM inspection_node_results WHERE run_id = @run_id ORDER BY node_index ASC",
|
||||
new Dictionary<string, object> { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false);
|
||||
|
||||
var (metricResult, metricRows) = await _db.QueryListAsync<InspectionMetricRow>(
|
||||
@@ -860,14 +873,21 @@ WHERE run_id = @run_id";
|
||||
var marks = await QueryMarksAsync(new InspectionMarkQuery { RunId = runId }).ConfigureAwait(false);
|
||||
var events = await QueryRunEventsAsync(runId).ConfigureAwait(false);
|
||||
|
||||
var nodes = nodeRows.Select(MapNode).ToList().AsReadOnly();
|
||||
|
||||
return new InspectionRunDetail
|
||||
{
|
||||
Run = run,
|
||||
Nodes = nodeRows.Select(MapNode).ToList().AsReadOnly(),
|
||||
Nodes = nodes,
|
||||
Metrics = metricRows.Select(MapMetric).ToList().AsReadOnly(),
|
||||
Assets = assetRows.Select(MapAsset).ToList().AsReadOnly(),
|
||||
PipelineSnapshots = snapshotRows.Select(MapSnapshot).ToList().AsReadOnly(),
|
||||
Marks = marks,
|
||||
PositionStates = nodes
|
||||
.Select(n => n.PositionState)
|
||||
.Where(s => s != null)
|
||||
.ToList()
|
||||
.AsReadOnly(),
|
||||
Events = events
|
||||
};
|
||||
}
|
||||
@@ -954,6 +974,7 @@ WHERE run_id = @run_id";
|
||||
("inspection_node_results", "status", "TEXT NOT NULL DEFAULT 'Pending'"),
|
||||
("inspection_node_results", "duration_ms", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("inspection_node_results", "node_kind", "TEXT NOT NULL DEFAULT 'Inspection'"),
|
||||
("inspection_node_results", "position_state_json", "TEXT NULL"),
|
||||
];
|
||||
|
||||
private async Task EnsureInitializedAsync()
|
||||
@@ -1123,7 +1144,9 @@ WHERE run_id = @run_id";
|
||||
{
|
||||
// 由本次运行的 Capture 节点派生 Batch 段(语义等价原 summary.json)。
|
||||
detail.Batch = DeriveBatchSummary(detail);
|
||||
// 写入端固定为 SchemaVersion=2(含 Run 上 map 字段 + Marks + Batch)。
|
||||
// manifest 是运行归档总账;Map 段只做 .xpmap 标记图定位与职责说明。
|
||||
detail.Map = DeriveMapSummary(detail.Run);
|
||||
// 写入端固定为 SchemaVersion=2(含 Run 上 map 字段 + Map 摘要 + Marks + Batch)。
|
||||
detail.SchemaVersion = 2;
|
||||
|
||||
var runDirectory = Path.Combine(_baseDirectory, detail.Run.ResultRootPath);
|
||||
@@ -1133,6 +1156,23 @@ WHERE run_id = @run_id";
|
||||
await File.WriteAllTextAsync(manifestPath, json).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static InspectionMapManifestSummary DeriveMapSummary(InspectionRunRecord run)
|
||||
{
|
||||
if (run?.MapId == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InspectionMapManifestSummary
|
||||
{
|
||||
MapId = run.MapId.Value,
|
||||
SchemaVersion = run.MapSchemaVersion,
|
||||
DocumentPath = "documentation/inspection-map.xpmap",
|
||||
OverviewImagePath = run.OverviewImageRelativePath ?? string.Empty,
|
||||
UpdatedAtUtc = run.MapUpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由本次运行的 Capture 节点(NodeKind=Capture)派生多点采图批次统计。
|
||||
/// 无 Capture 节点时返回 null。
|
||||
@@ -1379,6 +1419,19 @@ WHERE run_id = @run_id";
|
||||
private static InspectionNodeResult MapNode(InspectionNodeRow row)
|
||||
{
|
||||
_ = Enum.TryParse<InspectionNodeStatus>(row.status, out var status);
|
||||
InspectionPositionStateRecord positionState = null;
|
||||
if (!string.IsNullOrWhiteSpace(row.position_state_json))
|
||||
{
|
||||
try
|
||||
{
|
||||
positionState = JsonSerializer.Deserialize<InspectionPositionStateRecord>(row.position_state_json, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
positionState = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new InspectionNodeResult
|
||||
{
|
||||
RunId = Guid.Parse(row.run_id),
|
||||
@@ -1393,7 +1446,8 @@ WHERE run_id = @run_id";
|
||||
ResultImagePath = row.result_image_path,
|
||||
Status = status,
|
||||
DurationMs = row.duration_ms,
|
||||
NodeKind = Enum.TryParse<InspectionNodeKind>(row.node_kind, ignoreCase: true, out var nodeKind) ? nodeKind : InspectionNodeKind.Inspection
|
||||
NodeKind = Enum.TryParse<InspectionNodeKind>(row.node_kind, ignoreCase: true, out var nodeKind) ? nodeKind : InspectionNodeKind.Inspection,
|
||||
PositionState = positionState
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1498,6 +1552,7 @@ WHERE run_id = @run_id";
|
||||
public string status { get; set; } = string.Empty;
|
||||
public long duration_ms { get; set; }
|
||||
public string node_kind { get; set; } = "Inspection";
|
||||
public string position_state_json { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal class InspectionMetricRow
|
||||
|
||||
@@ -93,6 +93,11 @@ namespace XplorePlane.ViewModels.Cnc
|
||||
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram), () => !IsRunning && CanEditCncProgram);
|
||||
InsertResetCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.Reset), () => !IsRunning && CanEditCncProgram);
|
||||
|
||||
InsertRaySourceControlCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.RaySourceControl), () => !IsRunning && CanEditCncProgram);
|
||||
InsertDetectorCorrectionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.DetectorCorrection), () => !IsRunning && CanEditCncProgram);
|
||||
InsertDoorControlCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.DoorControl), () => !IsRunning && CanEditCncProgram);
|
||||
InsertGeometryCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.Geometry), () => !IsRunning && CanEditCncProgram);
|
||||
|
||||
DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode)
|
||||
.ObservesProperty(() => SelectedNode);
|
||||
MoveNodeUpCommand = new DelegateCommand<CncNodeViewModel>(ExecuteMoveNodeUp);
|
||||
@@ -216,6 +221,10 @@ namespace XplorePlane.ViewModels.Cnc
|
||||
public DelegateCommand InsertWaitDelayCommand { get; }
|
||||
public DelegateCommand InsertCompleteProgramCommand { get; }
|
||||
public DelegateCommand InsertResetCommand { get; }
|
||||
public DelegateCommand InsertRaySourceControlCommand { get; }
|
||||
public DelegateCommand InsertDetectorCorrectionCommand { get; }
|
||||
public DelegateCommand InsertDoorControlCommand { get; }
|
||||
public DelegateCommand InsertGeometryCommand { get; }
|
||||
public DelegateCommand DeleteNodeCommand { get; }
|
||||
public DelegateCommand<CncNodeViewModel> MoveNodeUpCommand { get; }
|
||||
public DelegateCommand<CncNodeViewModel> MoveNodeDownCommand { get; }
|
||||
@@ -630,6 +639,10 @@ namespace XplorePlane.ViewModels.Cnc
|
||||
InsertWaitDelayCommand.RaiseCanExecuteChanged();
|
||||
InsertCompleteProgramCommand.RaiseCanExecuteChanged();
|
||||
InsertResetCommand.RaiseCanExecuteChanged();
|
||||
InsertRaySourceControlCommand.RaiseCanExecuteChanged();
|
||||
InsertDetectorCorrectionCommand.RaiseCanExecuteChanged();
|
||||
InsertDoorControlCommand.RaiseCanExecuteChanged();
|
||||
InsertGeometryCommand.RaiseCanExecuteChanged();
|
||||
DeleteNodeCommand.RaiseCanExecuteChanged();
|
||||
MoveNodeUpCommand.RaiseCanExecuteChanged();
|
||||
MoveNodeDownCommand.RaiseCanExecuteChanged();
|
||||
|
||||
@@ -119,6 +119,10 @@ namespace XplorePlane.ViewModels.Cnc
|
||||
public bool IsWaitDelay => _model is WaitDelayNode;
|
||||
public bool IsCompleteProgram => _model is CompleteProgramNode;
|
||||
public bool IsReset => _model is ResetNode;
|
||||
public bool IsRaySourceControl => _model is RaySourceControlNode;
|
||||
public bool IsDetectorCorrection => _model is DetectorCorrectionNode;
|
||||
public bool IsDoorControl => _model is DoorControlNode;
|
||||
public bool IsGeometry => _model is GeometryNode;
|
||||
public bool IsPositionChild => _model is InspectionModuleNode or InspectionMarkerNode;
|
||||
public bool IsMotionSnapshotNode => _model is ReferencePointNode or SaveNodeNode or SaveNodeWithImageNode or SavePositionNode;
|
||||
public string RelationTag => _model switch
|
||||
@@ -535,6 +539,10 @@ namespace XplorePlane.ViewModels.Cnc
|
||||
CncNodeType.WaitDelay => "/Assets/Icons/wait.png",
|
||||
CncNodeType.CompleteProgram => "/Assets/Icons/finish.png",
|
||||
CncNodeType.Reset => "/Assets/Icons/Home.png",
|
||||
CncNodeType.RaySourceControl => "/Assets/Icons/reference.png",
|
||||
CncNodeType.DetectorCorrection => "/Assets/Icons/Module.png",
|
||||
CncNodeType.DoorControl => "/Assets/Icons/message.png",
|
||||
CncNodeType.Geometry => "/Assets/Icons/add-pos.png",
|
||||
_ => "/Assets/Icons/cnc.png",
|
||||
};
|
||||
}
|
||||
@@ -695,6 +703,10 @@ namespace XplorePlane.ViewModels.Cnc
|
||||
RaisePropertyChanged(nameof(IsWaitDelay));
|
||||
RaisePropertyChanged(nameof(IsCompleteProgram));
|
||||
RaisePropertyChanged(nameof(IsReset));
|
||||
RaisePropertyChanged(nameof(IsRaySourceControl));
|
||||
RaisePropertyChanged(nameof(IsDetectorCorrection));
|
||||
RaisePropertyChanged(nameof(IsDoorControl));
|
||||
RaisePropertyChanged(nameof(IsGeometry));
|
||||
RaisePropertyChanged(nameof(IsPositionChild));
|
||||
RaisePropertyChanged(nameof(IsMotionSnapshotNode));
|
||||
RaisePropertyChanged(nameof(RelationTag));
|
||||
|
||||
@@ -3,12 +3,15 @@ using Prism.Commands;
|
||||
using Prism.Mvvm;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using XP.Common.GeneralForm.Views;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.InspectionDocumentation;
|
||||
using XplorePlane.Services.MainViewport;
|
||||
|
||||
namespace XplorePlane.ViewModels.InspectionDocumentation
|
||||
{
|
||||
@@ -19,6 +22,7 @@ namespace XplorePlane.ViewModels.InspectionDocumentation
|
||||
private readonly IInspectionReportGenerator _reportGenerator;
|
||||
private readonly IInspectionRunDocumentationContext _runContext;
|
||||
private readonly ITraceabilityService _traceabilityService;
|
||||
private readonly IMainViewportService _mainViewportService;
|
||||
private InspectionMapDocument? _currentMap;
|
||||
private InspectionMarkRecord? _selectedMark;
|
||||
private InspectionMarkType _selectedMarkType = InspectionMarkType.Pass;
|
||||
@@ -32,13 +36,15 @@ namespace XplorePlane.ViewModels.InspectionDocumentation
|
||||
IInspectionMapStore mapStore,
|
||||
IInspectionReportGenerator reportGenerator,
|
||||
IInspectionRunDocumentationContext runContext,
|
||||
ITraceabilityService traceabilityService)
|
||||
ITraceabilityService traceabilityService,
|
||||
IMainViewportService mainViewportService)
|
||||
{
|
||||
_documentationService = documentationService ?? throw new ArgumentNullException(nameof(documentationService));
|
||||
_mapStore = mapStore ?? throw new ArgumentNullException(nameof(mapStore));
|
||||
_reportGenerator = reportGenerator ?? throw new ArgumentNullException(nameof(reportGenerator));
|
||||
_runContext = runContext ?? throw new ArgumentNullException(nameof(runContext));
|
||||
_traceabilityService = traceabilityService ?? throw new ArgumentNullException(nameof(traceabilityService));
|
||||
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
|
||||
|
||||
NewMapCommand = new DelegateCommand(ExecuteNewMap);
|
||||
SaveMapCommand = new DelegateCommand(async () => await ExecuteSaveMapAsync(), () => CurrentMap != null);
|
||||
@@ -219,7 +225,10 @@ namespace XplorePlane.ViewModels.InspectionDocumentation
|
||||
CurrentMap = result.Document;
|
||||
TraceabilityId = CurrentMap.TraceabilityId;
|
||||
_runContext.UseRun(CurrentMap.RunId, CurrentMap.ResultRootPath);
|
||||
StatusText = result.Warnings.Count == 0 ? "检查图已加载" : $"检查图已加载,{result.Warnings.Count} 个资产警告";
|
||||
var overviewLoaded = LoadOverviewIntoViewport(CurrentMap);
|
||||
StatusText = result.Warnings.Count == 0
|
||||
? overviewLoaded ? "检查图已加载,已显示总览图" : "检查图已加载,但总览图未找到"
|
||||
: overviewLoaded ? $"检查图已加载,已显示总览图,{result.Warnings.Count} 个资产警告" : $"检查图已加载,{result.Warnings.Count} 个资产警告";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -228,6 +237,37 @@ namespace XplorePlane.ViewModels.InspectionDocumentation
|
||||
}
|
||||
}
|
||||
|
||||
private bool LoadOverviewIntoViewport(InspectionMapDocument map)
|
||||
{
|
||||
if (map == null || string.IsNullOrWhiteSpace(map.OverviewImageRelativePath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var relative = map.OverviewImageRelativePath
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar);
|
||||
var overviewPath = Path.Combine(_mapStore.GetRunRootDirectory(map), relative);
|
||||
if (!File.Exists(overviewPath))
|
||||
return false;
|
||||
|
||||
var image = new BitmapImage();
|
||||
image.BeginInit();
|
||||
image.UriSource = new Uri(overviewPath, UriKind.Absolute);
|
||||
image.CacheOption = BitmapCacheOption.OnLoad;
|
||||
image.EndInit();
|
||||
image.Freeze();
|
||||
|
||||
_mainViewportService.SetManualImage(image, overviewPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText = $"加载检查图总览图失败:{ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteSaveReportAsync()
|
||||
{
|
||||
if (CurrentMap == null)
|
||||
|
||||
@@ -8,6 +8,7 @@ using XP.Common.Logging.Interfaces;
|
||||
using XplorePlane.Events;
|
||||
using XplorePlane.Models;
|
||||
using XplorePlane.Services.Permission;
|
||||
using XplorePlane.Services.Storage;
|
||||
using PermissionEnum = XplorePlane.Models.Permission;
|
||||
|
||||
namespace XplorePlane.ViewModels.Setting
|
||||
@@ -17,10 +18,13 @@ namespace XplorePlane.ViewModels.Setting
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IXpDataPathService _dataPathService;
|
||||
|
||||
public DelegateCommand SaveCommand { get; }
|
||||
public DelegateCommand CancelCommand { get; }
|
||||
public DelegateCommand ResetToDefaultCommand { get; }
|
||||
public DelegateCommand BrowseDataRootPathCommand { get; }
|
||||
public DelegateCommand BrowseLogPathCommand { get; }
|
||||
|
||||
public string[] RaySourceTypes { get; } =
|
||||
{
|
||||
@@ -54,11 +58,12 @@ namespace XplorePlane.ViewModels.Setting
|
||||
private set => SetProperty(ref _isFactorySettingsVisible, value);
|
||||
}
|
||||
|
||||
public SettingsViewModel(ILoggerService logger, IPermissionService permissionService, IEventAggregator eventAggregator)
|
||||
public SettingsViewModel(ILoggerService logger, IPermissionService permissionService, IEventAggregator eventAggregator, IXpDataPathService dataPathService)
|
||||
{
|
||||
_logger = logger?.ForModule<SettingsViewModel>() ?? throw new ArgumentNullException(nameof(logger));
|
||||
_permissionService = permissionService ?? throw new ArgumentNullException(nameof(permissionService));
|
||||
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
|
||||
_dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
|
||||
|
||||
_logger.Info("SettingsViewModel 构造函数被调用 | SettingsViewModel constructor called");
|
||||
|
||||
@@ -66,6 +71,8 @@ namespace XplorePlane.ViewModels.Setting
|
||||
CancelCommand = new DelegateCommand(ExecuteCancel);
|
||||
ResetToDefaultCommand = new DelegateCommand(ExecuteResetToDefault);
|
||||
BrowseCameraImageCommand = new DelegateCommand(ExecuteBrowseCameraImage);
|
||||
BrowseDataRootPathCommand = new DelegateCommand(ExecuteBrowseDataRootPath);
|
||||
BrowseLogPathCommand = new DelegateCommand(ExecuteBrowseLogPath);
|
||||
|
||||
_logger.Debug("Commands initialized: SaveCommand={SaveCommand}, CancelCommand={CancelCommand}, ResetToDefaultCommand={ResetToDefaultCommand}",
|
||||
SaveCommand != null, CancelCommand != null, ResetToDefaultCommand != null);
|
||||
@@ -89,6 +96,18 @@ namespace XplorePlane.ViewModels.Setting
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region 数据存储根目录
|
||||
private string _dataRootPath;
|
||||
/// <summary>
|
||||
/// 数据存储根目录,所有子目录(Logs、DataBase、DetectorImages 等)基于此路径。
|
||||
/// </summary>
|
||||
public string DataRootPath
|
||||
{
|
||||
get => _dataRootPath;
|
||||
set => SetProperty(ref _dataRootPath, value);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Serilog日志配置
|
||||
private string _serilogLogPath;
|
||||
public string SerilogLogPath
|
||||
@@ -355,6 +374,9 @@ namespace XplorePlane.ViewModels.Setting
|
||||
Language = GetAppSetting("Language", "ZhCN");
|
||||
_logger.Debug("Loaded Language: {Language}", Language);
|
||||
|
||||
// 数据存储根目录
|
||||
DataRootPath = _dataPathService.RootPath;
|
||||
|
||||
// Serilog日志配置
|
||||
SerilogLogPath = GetAppSetting("Serilog:LogPath", "D:\\XPData\\Logs");
|
||||
SerilogMinimumLevel = GetAppSetting("Serilog:MinimumLevel", "Debug");
|
||||
@@ -469,6 +491,9 @@ namespace XplorePlane.ViewModels.Setting
|
||||
config.Save(ConfigurationSaveMode.Modified);
|
||||
ConfigurationManager.RefreshSection("appSettings");
|
||||
|
||||
// 数据存储根目录(独立保存,避免与上面的 config 对象冲突)
|
||||
_dataPathService.SaveRootPath(DataRootPath);
|
||||
|
||||
// 导航相机配置保存到 config.json
|
||||
SaveCameraConfigToJson();
|
||||
|
||||
@@ -531,6 +556,48 @@ namespace XplorePlane.ViewModels.Setting
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteBrowseDataRootPath()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||
{
|
||||
Title = "选择数据存储根目录",
|
||||
InitialDirectory = System.IO.Directory.Exists(DataRootPath) ? DataRootPath : _dataPathService.DefaultRootPath
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
DataRootPath = dialog.FolderName;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "浏览数据根目录失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteBrowseLogPath()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||
{
|
||||
Title = "选择日志存储目录",
|
||||
InitialDirectory = System.IO.Directory.Exists(SerilogLogPath) ? SerilogLogPath : _dataPathService.RootPath
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
SerilogLogPath = dialog.FolderName;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "浏览日志目录失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoleChanged(RoleChangedPayload payload)
|
||||
{
|
||||
RefreshSettingsVisibility();
|
||||
|
||||
@@ -344,6 +344,42 @@
|
||||
<TextBlock Text="复位" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Command="{Binding InsertRaySourceControlCommand}"
|
||||
Style="{StaticResource TreeToolbarButtonCompact}"
|
||||
ToolTip="射线源控制:开/关射线、设电压电流、暖机/训机">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Image Source="/Assets/Icons/reference.png" Style="{StaticResource TreeToolbarIcon}" />
|
||||
<TextBlock Text="射线源" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Command="{Binding InsertDetectorCorrectionCommand}"
|
||||
Style="{StaticResource TreeToolbarButtonCompact}"
|
||||
ToolTip="探测器校正:暗场/增益/坏像素/自动">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Image Source="/Assets/Icons/Module.png" Style="{StaticResource TreeToolbarIcon}" />
|
||||
<TextBlock Text="探测器校正" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Command="{Binding InsertDoorControlCommand}"
|
||||
Style="{StaticResource TreeToolbarButtonCompact}"
|
||||
ToolTip="安全门控制:开门/关门">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Image Source="/Assets/Icons/message.png" Style="{StaticResource TreeToolbarIcon}" />
|
||||
<TextBlock Text="安全门" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button
|
||||
Command="{Binding InsertGeometryCommand}"
|
||||
Style="{StaticResource TreeToolbarButtonCompact}"
|
||||
ToolTip="几何设置:按 FDD 或放大倍率定位">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Image Source="/Assets/Icons/add-pos.png" Style="{StaticResource TreeToolbarIcon}" />
|
||||
<TextBlock Text="几何设置" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -126,6 +126,40 @@
|
||||
Content="启动时默认显示实时图像" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Text="数据存储" />
|
||||
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#666"
|
||||
TextWrapping="Wrap"
|
||||
Text="所有数据文件(日志、数据库、探测器图像、转储等)的根目录。子目录将自动创建。" />
|
||||
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Style="{StaticResource LabelStyle}"
|
||||
Text="存储根目录" />
|
||||
<TextBox Grid.Column="1"
|
||||
Style="{StaticResource TextBoxStyle}"
|
||||
Text="{Binding DataRootPath, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button Grid.Column="2"
|
||||
Content="浏览..."
|
||||
Width="70"
|
||||
Height="30"
|
||||
Margin="8,0,0,0"
|
||||
Command="{Binding BrowseDataRootPathCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
@@ -222,6 +256,7 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Style="{StaticResource LabelStyle}"
|
||||
@@ -229,6 +264,12 @@
|
||||
<TextBox Grid.Column="1"
|
||||
Style="{StaticResource TextBoxStyle}"
|
||||
Text="{Binding SerilogLogPath}" />
|
||||
<Button Grid.Column="2"
|
||||
Content="浏览..."
|
||||
Width="70"
|
||||
Height="30"
|
||||
Margin="8,0,0,0"
|
||||
Command="{Binding BrowseLogPathCommand}" />
|
||||
</Grid>
|
||||
|
||||
<Grid Margin="0,10,0,0">
|
||||
|
||||
Reference in New Issue
Block a user