feat: CNC 编排新增硬件控制能力(位置硬件还原 + 四类动作节点)

在 CNC 编排层补齐 P0 硬件控制能力,使检测编排可覆盖设备测试与校准流程。

位置节点硬件状态还原(核心):
- SavePositionNode 新增可选探测器采集参数快照,兼容旧 .xp 文件
- 执行到位后按序还原射线源电压/电流与探测器参数(先电压后电流)
- 射线未开弹窗提醒并暂停;还原失败弹窗提示并暂停不继续;服务不可用降级跳过
- 新增 ICncInteractionService 封装弹窗交互,便于单元测试

四类硬件动作节点:
- 射线源控制:开/关/设参/暖机/训机,安全时序 + 稳定等待
- 探测器校正:暗场/增益/坏像素/自动
- 安全门控制:开门/关门,复用内置联锁
- 几何设置:按 FDD 或放大倍率反算定位

模型、编辑器与工具栏接线:
- CncNodeType 追加四枚举值 + 四类节点 record + 多态判别符 + 值域校验
- 编辑器新增插入命令、类型判定、图标与工具栏入口
- IDetectorService 预留当前采集参数读取接口(暂返回 null,待硬件层完善)

系统设置:
- 新增数据存储根目录与日志目录配置及浏览按钮
- 修复保存时重复打开配置文件导致的配置冲突异常

测试:
- 新增 33 个单元测试,覆盖序列化往返、旧文件兼容、节点分派/降级/失败、
  位置还原全路径与混合程序执行
This commit is contained in:
zhengxuan.zhang
2026-07-03 14:09:24 +08:00
parent 94576fd1a7
commit b95941cb50
22 changed files with 1704 additions and 28 deletions
+1
View File
@@ -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();
// 模拟旧 .xpSavePosition 节点不含 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]
+28
View File
@@ -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>();
// 统一资产路径解析器(无状态单例,结果存储与标记图存储共用)
+109 -2
View File
@@ -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>
+64 -1
View File
@@ -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 等价。
+378 -3
View File
@@ -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));
}
}
}
+93 -11
View File
@@ -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();
+36
View File
@@ -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">