feat: 硬件虚拟化与CNC联动集成 - 运动控制/射线源模拟实现,CNC执行联动增强

This commit is contained in:
zhengxuan.zhang
2026-05-21 15:59:38 +08:00
parent 05c41a9a21
commit 43d0e7fa89
15 changed files with 1292 additions and 9 deletions
@@ -0,0 +1,27 @@
using XP.Hardware.MotionControl.Abstractions;
namespace XP.Hardware.MotionControl.Implementations
{
/// <summary>
/// 虚拟轴复位实现 | Simulated Axis Reset Implementation
/// 所有操作为空操作,始终报告复位已完成
/// All operations are no-ops, always reports reset as done
/// </summary>
public class SimulatedAxisReset : IAxisReset
{
/// <inheritdoc/>
public bool IsResetDone => true;
/// <inheritdoc/>
public MotionResult Reset()
{
return MotionResult.Ok();
}
/// <inheritdoc/>
public void UpdateStatus()
{
// 虚拟轴复位无需从 PLC 轮询状态 | No PLC polling needed for simulated axis reset
}
}
}
@@ -0,0 +1,21 @@
using XP.Hardware.MotionControl.Abstractions;
namespace XP.Hardware.MotionControl.Implementations
{
/// <summary>
/// 虚拟摇杆实现 | Simulated Joystick Implementation
/// 所有操作为空操作,始终报告摇杆未激活
/// All operations are no-ops, always reports joystick as inactive
/// </summary>
public class SimulatedJoystick : IJoystick
{
/// <inheritdoc/>
public bool IsJoystickActive => false;
/// <inheritdoc/>
public void UpdateStatus()
{
// 虚拟摇杆无需从 PLC 轮询状态 | No PLC polling needed for simulated joystick
}
}
}
@@ -0,0 +1,223 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
namespace XP.Hardware.MotionControl.Implementations
{
/// <summary>
/// 虚拟直线轴实现 | Simulated Linear Axis Implementation
/// 通过后台任务线性插值模拟轴移动,无需真实 PLC 硬件
/// Simulates axis movement via background task with linear interpolation, no real PLC required
/// </summary>
public class SimulatedLinearAxis : LinearAxisBase
{
private const int UpdateIntervalMs = 20;
private readonly double _defaultSpeed;
private readonly object _lock = new object();
private CancellationTokenSource _moveCts;
private Task _moveTask;
private double _jogSpeedPercent = 50.0;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="axisId">轴标识 | Axis identifier</param>
/// <param name="min">最小位置(mm| Minimum position (mm)</param>
/// <param name="max">最大位置(mm| Maximum position (mm)</param>
/// <param name="origin">原点偏移(mm| Origin offset (mm)</param>
/// <param name="defaultSpeed">默认速度(mm/s| Default speed (mm/s)</param>
public SimulatedLinearAxis(AxisId axisId, double min, double max, double origin, double defaultSpeed = 50.0)
: base(axisId, min, max, origin)
{
_defaultSpeed = defaultSpeed;
}
/// <inheritdoc/>
public override MotionResult MoveToTarget(double target, double? speed = null)
{
if (!ValidateTarget(target))
return MotionResult.Fail($"[Simulated] 目标位置 {target} 超出范围 [{_min}, {_max}]");
lock (_lock)
{
if (Status == AxisStatus.Moving)
{
// 取消当前移动,启动新移动
CancelCurrentMove();
}
var moveSpeed = speed ?? _defaultSpeed;
Status = AxisStatus.Moving;
_moveCts = new CancellationTokenSource();
var token = _moveCts.Token;
_moveTask = Task.Run(() => ExecuteMove(target, moveSpeed, token), token);
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult Stop()
{
lock (_lock)
{
CancelCurrentMove();
Status = AxisStatus.Idle;
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult Home()
{
lock (_lock)
{
if (Status == AxisStatus.Moving)
{
CancelCurrentMove();
}
Status = AxisStatus.Homing;
_moveCts = new CancellationTokenSource();
var token = _moveCts.Token;
_moveTask = Task.Run(async () =>
{
await ExecuteMove(0, _defaultSpeed, token);
if (!token.IsCancellationRequested)
{
Status = AxisStatus.Idle;
}
}, token);
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult JogStart(bool positive)
{
lock (_lock)
{
if (Status == AxisStatus.Moving || Status == AxisStatus.Homing)
{
CancelCurrentMove();
}
var target = positive ? _max : _min;
var jogSpeed = _defaultSpeed * (_jogSpeedPercent / 100.0);
Status = AxisStatus.Moving;
_moveCts = new CancellationTokenSource();
var token = _moveCts.Token;
_moveTask = Task.Run(() => ExecuteMove(target, jogSpeed, token), token);
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult JogStop()
{
lock (_lock)
{
CancelCurrentMove();
Status = AxisStatus.Idle;
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult SetJogSpeed(double speedPercent)
{
if (speedPercent < 0 || speedPercent > 100)
return MotionResult.Fail($"[Simulated] Jog 速度百分比 {speedPercent} 超出范围 [0, 100]");
_jogSpeedPercent = speedPercent;
return MotionResult.Ok();
}
/// <inheritdoc/>
public override void UpdateStatus()
{
// 虚拟轴无需从 PLC 轮询状态 | No PLC polling needed for simulated axis
}
/// <summary>
/// 执行线性插值移动 | Execute linear interpolation move
/// </summary>
private async Task ExecuteMove(double target, double speed, CancellationToken token)
{
var startPosition = ActualPosition;
var distance = target - startPosition;
if (Math.Abs(distance) < 0.001)
{
Status = AxisStatus.Idle;
return;
}
var totalTime = Math.Abs(distance) / speed; // seconds
var elapsed = 0.0;
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(UpdateIntervalMs, token);
}
catch (TaskCanceledException)
{
return;
}
elapsed += UpdateIntervalMs / 1000.0;
if (elapsed >= totalTime)
{
ActualPosition = target;
UpdateLimitFlags();
Status = AxisStatus.Idle;
return;
}
var progress = elapsed / totalTime;
ActualPosition = startPosition + distance * progress;
UpdateLimitFlags();
}
}
/// <summary>
/// 取消当前移动任务 | Cancel current move task
/// </summary>
private void CancelCurrentMove()
{
if (_moveCts != null)
{
_moveCts.Cancel();
_moveCts.Dispose();
_moveCts = null;
}
}
/// <summary>
/// 更新限位标志 | Update limit flags
/// </summary>
private void UpdateLimitFlags()
{
PositiveLimitHit = ActualPosition >= _max;
NegativeLimitHit = ActualPosition <= _min;
}
}
}
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
using XP.Hardware.MotionControl.Config;
namespace XP.Hardware.MotionControl.Implementations
{
/// <summary>
/// 虚拟运动系统实现 | Simulated Motion System Implementation
/// 根据 MotionControlConfig 创建虚拟轴实例,无需真实 PLC 硬件
/// Creates simulated axis instances from MotionControlConfig, no real PLC required
/// </summary>
public class SimulatedMotionSystem : IMotionSystem
{
private readonly Dictionary<AxisId, ILinearAxis> _linearAxes = new();
private readonly Dictionary<RotaryAxisId, IRotaryAxis> _rotaryAxes = new();
private readonly ISafetyDoor _safetyDoor;
private readonly IJoystick _joystick;
private readonly IAxisReset _axisReset;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="config">运动控制配置 | Motion control configuration</param>
public SimulatedMotionSystem(MotionControlConfig config)
{
if (config == null) throw new ArgumentNullException(nameof(config));
// 创建直线轴 | Create linear axes
if (config.LinearAxes.TryGetValue(AxisId.SourceZ, out var sz))
_linearAxes[AxisId.SourceZ] = new SimulatedLinearAxis(AxisId.SourceZ, sz.Min, sz.Max, sz.Origin);
if (config.LinearAxes.TryGetValue(AxisId.DetectorZ, out var dz))
_linearAxes[AxisId.DetectorZ] = new SimulatedLinearAxis(AxisId.DetectorZ, dz.Min, dz.Max, dz.Origin);
if (config.LinearAxes.TryGetValue(AxisId.StageX, out var sx))
_linearAxes[AxisId.StageX] = new SimulatedLinearAxis(AxisId.StageX, sx.Min, sx.Max, sx.Origin);
if (config.LinearAxes.TryGetValue(AxisId.StageY, out var sy))
_linearAxes[AxisId.StageY] = new SimulatedLinearAxis(AxisId.StageY, sy.Min, sy.Max, sy.Origin);
// 创建旋转轴 | Create rotary axes
if (config.RotaryAxes.TryGetValue(RotaryAxisId.DetectorSwing, out var ds))
_rotaryAxes[RotaryAxisId.DetectorSwing] = new SimulatedRotaryAxis(RotaryAxisId.DetectorSwing, ds.Min, ds.Max, ds.Enabled);
if (config.RotaryAxes.TryGetValue(RotaryAxisId.StageRotation, out var sr))
_rotaryAxes[RotaryAxisId.StageRotation] = new SimulatedRotaryAxis(RotaryAxisId.StageRotation, sr.Min, sr.Max, sr.Enabled);
if (config.RotaryAxes.TryGetValue(RotaryAxisId.FixtureRotation, out var fr))
_rotaryAxes[RotaryAxisId.FixtureRotation] = new SimulatedRotaryAxis(RotaryAxisId.FixtureRotation, fr.Min, fr.Max, fr.Enabled);
// 创建辅助设备 | Create helper devices
_safetyDoor = new SimulatedSafetyDoor();
_joystick = new SimulatedJoystick();
_axisReset = new SimulatedAxisReset();
}
/// <inheritdoc/>
public ISafetyDoor SafetyDoor => _safetyDoor;
/// <inheritdoc/>
public IJoystick Joystick => _joystick;
/// <inheritdoc/>
public IAxisReset AxisReset => _axisReset;
/// <inheritdoc/>
public IReadOnlyDictionary<AxisId, ILinearAxis> LinearAxes => _linearAxes;
/// <inheritdoc/>
public IReadOnlyDictionary<RotaryAxisId, IRotaryAxis> RotaryAxes => _rotaryAxes;
/// <inheritdoc/>
public ILinearAxis GetLinearAxis(AxisId axisId)
{
if (_linearAxes.TryGetValue(axisId, out var axis)) return axis;
throw new KeyNotFoundException($"[Simulated] 未找到直线轴 {axisId} | Linear axis {axisId} not found");
}
/// <inheritdoc/>
public IRotaryAxis GetRotaryAxis(RotaryAxisId axisId)
{
if (_rotaryAxes.TryGetValue(axisId, out var axis)) return axis;
throw new KeyNotFoundException($"[Simulated] 未找到旋转轴 {axisId} | Rotary axis {axisId} not found");
}
/// <inheritdoc/>
public void UpdateAllStatus()
{
// 虚拟运动系统无需从 PLC 轮询状态 | No PLC polling needed for simulated motion system
}
}
}
@@ -0,0 +1,211 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
namespace XP.Hardware.MotionControl.Implementations
{
/// <summary>
/// 虚拟旋转轴实现 | Simulated Rotary Axis Implementation
/// 通过后台任务线性插值模拟旋转轴移动,无需真实 PLC 硬件
/// Simulates rotary axis movement via background task with linear interpolation, no real PLC required
/// </summary>
public class SimulatedRotaryAxis : RotaryAxisBase
{
private const int UpdateIntervalMs = 20;
private readonly double _defaultSpeed;
private readonly object _lock = new object();
private CancellationTokenSource _moveCts;
private Task _moveTask;
private double _jogSpeedPercent = 50.0;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="axisId">轴标识 | Axis identifier</param>
/// <param name="minAngle">最小角度(度)| Minimum angle (degrees)</param>
/// <param name="maxAngle">最大角度(度)| Maximum angle (degrees)</param>
/// <param name="enabled">是否启用 | Is enabled</param>
/// <param name="defaultSpeed">默认角速度(度/秒)| Default angular speed (degrees/s)</param>
public SimulatedRotaryAxis(RotaryAxisId axisId, double minAngle, double maxAngle, bool enabled, double defaultSpeed = 30.0)
: base(axisId, minAngle, maxAngle, enabled)
{
_defaultSpeed = defaultSpeed;
}
/// <inheritdoc/>
public override MotionResult MoveToTarget(double targetAngle, double? speed = null)
{
if (!ValidateTarget(targetAngle))
return MotionResult.Fail($"[Simulated] 目标角度 {targetAngle} 超出范围 [{_minAngle}, {_maxAngle}]");
lock (_lock)
{
if (Status == AxisStatus.Moving)
{
CancelCurrentMove();
}
var moveSpeed = speed ?? _defaultSpeed;
Status = AxisStatus.Moving;
_moveCts = new CancellationTokenSource();
var token = _moveCts.Token;
_moveTask = Task.Run(() => ExecuteMove(targetAngle, moveSpeed, token), token);
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult Stop()
{
lock (_lock)
{
CancelCurrentMove();
Status = AxisStatus.Idle;
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult Home()
{
lock (_lock)
{
if (Status == AxisStatus.Moving)
{
CancelCurrentMove();
}
Status = AxisStatus.Homing;
_moveCts = new CancellationTokenSource();
var token = _moveCts.Token;
_moveTask = Task.Run(async () =>
{
await ExecuteMove(0, _defaultSpeed, token);
if (!token.IsCancellationRequested)
{
Status = AxisStatus.Idle;
}
}, token);
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult JogStart(bool positive)
{
lock (_lock)
{
if (Status == AxisStatus.Moving || Status == AxisStatus.Homing)
{
CancelCurrentMove();
}
var target = positive ? _maxAngle : _minAngle;
var jogSpeed = _defaultSpeed * (_jogSpeedPercent / 100.0);
Status = AxisStatus.Moving;
_moveCts = new CancellationTokenSource();
var token = _moveCts.Token;
_moveTask = Task.Run(() => ExecuteMove(target, jogSpeed, token), token);
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult JogStop()
{
lock (_lock)
{
CancelCurrentMove();
Status = AxisStatus.Idle;
}
return MotionResult.Ok();
}
/// <inheritdoc/>
public override MotionResult SetJogSpeed(double speedPercent)
{
if (speedPercent < 0 || speedPercent > 100)
return MotionResult.Fail($"[Simulated] Jog 速度百分比 {speedPercent} 超出范围 [0, 100]");
_jogSpeedPercent = speedPercent;
return MotionResult.Ok();
}
/// <inheritdoc/>
public override void UpdateStatus()
{
// 虚拟轴无需从 PLC 轮询状态 | No PLC polling needed for simulated axis
}
/// <summary>
/// 执行线性插值旋转移动 | Execute linear interpolation rotary move
/// </summary>
private async Task ExecuteMove(double targetAngle, double speed, CancellationToken token)
{
var startAngle = ActualAngle;
var distance = targetAngle - startAngle;
if (Math.Abs(distance) < 0.001)
{
Status = AxisStatus.Idle;
return;
}
var totalTime = Math.Abs(distance) / speed; // seconds
var elapsed = 0.0;
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(UpdateIntervalMs, token);
}
catch (TaskCanceledException)
{
return;
}
elapsed += UpdateIntervalMs / 1000.0;
if (elapsed >= totalTime)
{
ActualAngle = targetAngle;
Status = AxisStatus.Idle;
return;
}
var progress = elapsed / totalTime;
ActualAngle = startAngle + distance * progress;
}
}
/// <summary>
/// 取消当前移动任务 | Cancel current move task
/// </summary>
private void CancelCurrentMove()
{
if (_moveCts != null)
{
_moveCts.Cancel();
_moveCts.Dispose();
_moveCts = null;
}
}
}
}
@@ -0,0 +1,45 @@
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
namespace XP.Hardware.MotionControl.Implementations
{
/// <summary>
/// 虚拟安全门实现 | Simulated Safety Door Implementation
/// 始终报告门已关闭(安全状态),所有操作返回成功
/// Always reports door as closed (safe state), all operations return success
/// </summary>
public class SimulatedSafetyDoor : ISafetyDoor
{
/// <inheritdoc/>
public DoorStatus Status { get; private set; } = DoorStatus.Closed;
/// <inheritdoc/>
public bool IsInterlocked => false;
/// <inheritdoc/>
public MotionResult Open()
{
Status = DoorStatus.Open;
return MotionResult.Ok();
}
/// <inheritdoc/>
public MotionResult Close()
{
Status = DoorStatus.Closed;
return MotionResult.Ok();
}
/// <inheritdoc/>
public MotionResult Stop()
{
return MotionResult.Ok();
}
/// <inheritdoc/>
public void UpdateStatus()
{
// 虚拟安全门无需从 PLC 轮询状态 | No PLC polling needed for simulated safety door
}
}
}
@@ -1,5 +1,7 @@
using Prism.Ioc;
using Prism.Modularity;
using System;
using System.Configuration;
using System.Resources;
using XP.Common.Localization;
using XP.Common.Localization.Interfaces;
@@ -31,8 +33,18 @@ namespace XP.Hardware.MotionControl.Module
// 注册几何计算器(单例)| Register geometry calculator (singleton)
containerRegistry.RegisterSingleton<GeometryCalculator>();
// 注册运动系统(单例)| Register motion system (singleton)
containerRegistry.RegisterSingleton<IMotionSystem, PlcMotionSystem>();
// 根据配置选择运动系统实现 | Select motion system implementation based on config
var motionType = ConfigurationManager.AppSettings["MotionControl:Type"] ?? "PLC";
if (motionType.Equals("Simulated", StringComparison.OrdinalIgnoreCase))
{
containerRegistry.RegisterSingleton<IMotionSystem, SimulatedMotionSystem>();
System.Console.WriteLine("[MotionControlModule] [Simulated] 使用虚拟运动系统 | Using simulated motion system");
}
else
{
containerRegistry.RegisterSingleton<IMotionSystem, PlcMotionSystem>();
System.Console.WriteLine("[MotionControlModule] 使用PLC运动系统 | Using PLC motion system");
}
// 注册运动控制业务服务(单例)| Register motion control service (singleton)
containerRegistry.RegisterSingleton<IMotionControlService, MotionControlService>();
@@ -57,9 +69,18 @@ namespace XP.Hardware.MotionControl.Module
// Initialize LocalizationHelper to use ILocalizationService for string lookup (supports Fallback Chain)
LocalizationHelper.Initialize(localizationService);
// 启动 PLC 状态轮询 | Start PLC status polling
var motionService = containerProvider.Resolve<IMotionControlService>();
motionService.StartPolling();
// 仅在非虚拟模式下启动 PLC 状态轮询 | Only start PLC polling when not in simulated mode
var motionSystem = containerProvider.Resolve<IMotionSystem>();
if (motionSystem is not SimulatedMotionSystem)
{
var motionService = containerProvider.Resolve<IMotionControlService>();
motionService.StartPolling();
System.Console.WriteLine("[MotionControlModule] PLC 轮询已启动 | PLC polling started");
}
else
{
System.Console.WriteLine("[MotionControlModule] [Simulated] 跳过 PLC 轮询 | Skipping PLC polling (simulated mode)");
}
System.Console.WriteLine("[MotionControlModule] 模块已初始化 | Module initialized");
}
@@ -54,6 +54,7 @@ namespace XP.Hardware.RaySource.Factories
var instance = sourceType.ToUpperInvariant() switch
{
"COMET225" => CreateComet225RaySource(),
"SIMULATED" => new SimulatedXRaySource(_eventAggregator, _logger),
_ => throw new NotSupportedException($"不支持的射线源类型: {sourceType}")
};
@@ -90,7 +91,8 @@ namespace XP.Hardware.RaySource.Factories
{
return new List<string>
{
"Comet225"
"Comet225",
"Simulated"
};
}
@@ -0,0 +1,236 @@
using System;
using Prism.Events;
using XP.Common.Logging.Interfaces;
using XP.Hardware.RaySource.Abstractions;
using XP.Hardware.RaySource.Abstractions.Enums;
using XP.Hardware.RaySource.Abstractions.Events;
namespace XP.Hardware.RaySource.Implementations
{
/// <summary>
/// 模拟射线源实现 | Simulated X-Ray Source Implementation
/// 用于开发和调试环境,无需真实硬件 | For development and debugging without real hardware
/// </summary>
public class SimulatedXRaySource : XRaySourceBase
{
#region
private readonly IEventAggregator _eventAggregator;
private readonly ILoggerService _logger;
#endregion
#region
private bool _isOn;
private float _setVoltage;
private float _setCurrent;
private float _setFocus;
private readonly Random _random = new Random();
#endregion
#region
/// <summary>
/// 构造函数,注入依赖
/// </summary>
/// <param name="eventAggregator">Prism 事件聚合器</param>
/// <param name="logger">日志服务</param>
public SimulatedXRaySource(IEventAggregator eventAggregator, ILoggerService logger)
{
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_logger = logger?.ForModule<SimulatedXRaySource>() ?? throw new ArgumentNullException(nameof(logger));
}
public override string SourceName => "Simulated X-Ray Source";
#endregion
#region IXRaySource
/// <summary>
/// 初始化射线源
/// </summary>
public override XRayResult Initialize()
{
_isInitialized = true;
_logger.Info("[Simulated] 射线源初始化成功");
return XRayResult.Ok();
}
/// <summary>
/// 连接 PVI 变量
/// </summary>
public override XRayResult ConnectVariables()
{
_isConnected = true;
_logger.Info("[Simulated] PVI 变量连接成功");
return XRayResult.Ok();
}
/// <summary>
/// 开启射线
/// </summary>
public override XRayResult TurnOn()
{
_isOn = true;
_eventAggregator.GetEvent<RaySourceStatusChangedEvent>().Publish(RaySourceStatus.Opened);
_logger.Info("[Simulated] 射线源已开启");
return XRayResult.Ok();
}
/// <summary>
/// 关闭射线
/// </summary>
public override XRayResult TurnOff()
{
_isOn = false;
_eventAggregator.GetEvent<RaySourceStatusChangedEvent>().Publish(RaySourceStatus.Closed);
_logger.Info("[Simulated] 射线源已关闭");
return XRayResult.Ok();
}
/// <summary>
/// 设置电压(kV
/// </summary>
public override XRayResult SetVoltage(float voltage)
{
_setVoltage = voltage;
_logger.Info("[Simulated] 设置电压: {Voltage} kV", voltage);
return XRayResult.Ok();
}
/// <summary>
/// 设置电流(μA
/// </summary>
public override XRayResult SetCurrent(float current)
{
_setCurrent = current;
_logger.Info("[Simulated] 设置电流: {Current} μA", current);
return XRayResult.Ok();
}
/// <summary>
/// 设置焦点
/// </summary>
public override XRayResult SetFocus(float focus)
{
_setFocus = focus;
return XRayResult.Ok();
}
/// <summary>
/// 读取实际电压值(模拟 ±2% 波动)
/// </summary>
public override XRayResult ReadVoltage()
{
float noise = (float)(_random.NextDouble() * 0.04 - 0.02); // -0.02 to +0.02
float simulatedVoltage = _setVoltage * (1 + noise);
return XRayResult.Ok(simulatedVoltage);
}
/// <summary>
/// 读取实际电流值(模拟 ±2% 波动)
/// </summary>
public override XRayResult ReadCurrent()
{
float noise = (float)(_random.NextDouble() * 0.04 - 0.02); // -0.02 to +0.02
float simulatedCurrent = _setCurrent * (1 + noise);
return XRayResult.Ok(simulatedCurrent);
}
/// <summary>
/// 读取系统状态
/// </summary>
public override XRayResult ReadSystemStatus()
{
return XRayResult.Ok();
}
/// <summary>
/// 检查错误状态
/// </summary>
public override XRayResult CheckErrors()
{
return XRayResult.Ok();
}
/// <summary>
/// TXI 开启
/// </summary>
public override XRayResult TxiOn()
{
_logger.Info("[Simulated] TXI 已开启");
return XRayResult.Ok();
}
/// <summary>
/// TXI 关闭
/// </summary>
public override XRayResult TxiOff()
{
_logger.Info("[Simulated] TXI 已关闭");
return XRayResult.Ok();
}
/// <summary>
/// 暖机设置
/// </summary>
public override XRayResult WarmUp()
{
_logger.Info("[Simulated] 暖机完成");
return XRayResult.Ok();
}
/// <summary>
/// 训机设置
/// </summary>
public override XRayResult Training()
{
_logger.Info("[Simulated] 训机完成");
return XRayResult.Ok();
}
/// <summary>
/// 灯丝校准
/// </summary>
public override XRayResult FilamentCalibration()
{
_logger.Info("[Simulated] 灯丝校准完成");
return XRayResult.Ok();
}
/// <summary>
/// 全部电压自动定心
/// </summary>
public override XRayResult AutoCenter()
{
_logger.Info("[Simulated] 自动定心完成");
return XRayResult.Ok();
}
/// <summary>
/// 设置功率模式
/// </summary>
public override XRayResult SetPowerMode(int mode)
{
_logger.Info("[Simulated] 设置功率模式: {Mode}", mode);
return XRayResult.Ok();
}
/// <summary>
/// 完全关闭设备,重置所有状态
/// </summary>
public override XRayResult CloseOff()
{
_isOn = false;
_isConnected = false;
_isInitialized = false;
_logger.Info("[Simulated] 射线源已完全关闭");
return XRayResult.Ok();
}
#endregion
}
}
@@ -0,0 +1,81 @@
using System.Threading.Tasks;
using Xunit;
using XP.Hardware.MotionControl.Abstractions.Enums;
using XP.Hardware.MotionControl.Implementations;
namespace XplorePlane.Tests.Hardware
{
public class SimulatedLinearAxisTests
{
[Fact]
public async Task MoveToTarget_SetsStatusToMoving_ThenIdle()
{
// Arrange - use fast speed for quicker test
var axis = new SimulatedLinearAxis(AxisId.StageX, min: 0, max: 100, origin: 0, defaultSpeed: 200);
// Act
var result = axis.MoveToTarget(50);
// Assert - should be Moving immediately after call
Assert.True(result.Success);
Assert.Equal(AxisStatus.Moving, axis.Status);
// Wait for move to complete (50mm at 200mm/s = 250ms, add generous buffer)
await Task.Delay(1000);
Assert.Equal(AxisStatus.Idle, axis.Status);
Assert.Equal(50.0, axis.ActualPosition, precision: 1);
}
[Fact]
public void MoveToTarget_OutOfRange_ReturnsFail()
{
// Arrange
var axis = new SimulatedLinearAxis(AxisId.StageX, min: 0, max: 100, origin: 0);
// Act
var result = axis.MoveToTarget(150);
// Assert
Assert.False(result.Success);
Assert.NotNull(result.ErrorMessage);
}
[Fact]
public async Task Stop_DuringMove_KeepsCurrentPosition()
{
// Arrange - use slow speed so we can stop mid-move
var axis = new SimulatedLinearAxis(AxisId.StageX, min: 0, max: 100, origin: 0, defaultSpeed: 10);
// Act - start a long move (100mm at 10mm/s = 10s)
axis.MoveToTarget(100);
await Task.Delay(200); // Let it move a bit
axis.Stop();
// Assert
Assert.Equal(AxisStatus.Idle, axis.Status);
Assert.True(axis.ActualPosition > 0, "Position should have moved from 0");
Assert.True(axis.ActualPosition < 100, "Position should not have reached target");
}
[Fact]
public async Task Home_MovesToZero()
{
// Arrange - move to 50 first with fast speed
var axis = new SimulatedLinearAxis(AxisId.StageX, min: 0, max: 100, origin: 0, defaultSpeed: 200);
axis.MoveToTarget(50);
await Task.Delay(1000); // Wait for move to complete
Assert.Equal(50.0, axis.ActualPosition, precision: 1);
// Act
axis.Home();
await Task.Delay(1000); // Wait for home to complete
// Assert
Assert.Equal(0.0, axis.ActualPosition, precision: 1);
Assert.Equal(AxisStatus.Idle, axis.Status);
}
}
}
@@ -0,0 +1,65 @@
using System.Threading.Tasks;
using Xunit;
using XP.Hardware.MotionControl.Abstractions.Enums;
using XP.Hardware.MotionControl.Implementations;
namespace XplorePlane.Tests.Hardware
{
public class SimulatedRotaryAxisTests
{
[Fact]
public async Task MoveToTarget_SetsStatusToMoving_ThenIdle()
{
// Arrange - use fast speed for quicker test
var axis = new SimulatedRotaryAxis(
RotaryAxisId.DetectorSwing, minAngle: -180, maxAngle: 180, enabled: true, defaultSpeed: 200);
// Act
var result = axis.MoveToTarget(90);
// Assert - should be Moving immediately after call
Assert.True(result.Success);
Assert.Equal(AxisStatus.Moving, axis.Status);
// Wait for move to complete (90° at 200°/s = 450ms, add generous buffer)
await Task.Delay(1000);
Assert.Equal(AxisStatus.Idle, axis.Status);
Assert.Equal(90.0, axis.ActualAngle, precision: 1);
}
[Fact]
public void MoveToTarget_OutOfRange_ReturnsFail()
{
// Arrange
var axis = new SimulatedRotaryAxis(
RotaryAxisId.DetectorSwing, minAngle: -180, maxAngle: 180, enabled: true);
// Act
var result = axis.MoveToTarget(200);
// Assert
Assert.False(result.Success);
Assert.NotNull(result.ErrorMessage);
}
[Fact]
public async Task Stop_DuringMove_KeepsCurrentAngle()
{
// Arrange - use slow speed so we can stop mid-move
var axis = new SimulatedRotaryAxis(
RotaryAxisId.DetectorSwing, minAngle: -180, maxAngle: 180, enabled: true, defaultSpeed: 10);
// Act - start a long move (180° at 10°/s = 18s)
axis.MoveToTarget(180);
await Task.Delay(200); // Let it move a bit
axis.Stop();
// Assert
Assert.Equal(AxisStatus.Idle, axis.Status);
Assert.True(axis.ActualAngle > 0, "Angle should have moved from 0");
Assert.True(axis.ActualAngle < 180, "Angle should not have reached target");
}
}
}
@@ -0,0 +1,120 @@
using Moq;
using Prism.Events;
using Xunit;
using XP.Common.Logging.Interfaces;
using XP.Hardware.RaySource.Abstractions.Enums;
using XP.Hardware.RaySource.Abstractions.Events;
using XP.Hardware.RaySource.Implementations;
namespace XplorePlane.Tests.Hardware
{
public class SimulatedXRaySourceTests
{
private readonly Mock<IEventAggregator> _mockEventAggregator;
private readonly Mock<ILoggerService> _mockLogger;
private readonly Mock<RaySourceStatusChangedEvent> _mockStatusEvent;
private readonly SimulatedXRaySource _source;
public SimulatedXRaySourceTests()
{
_mockEventAggregator = new Mock<IEventAggregator>();
_mockLogger = new Mock<ILoggerService>();
_mockStatusEvent = new Mock<RaySourceStatusChangedEvent>();
// Setup logger to return itself for ForModule<T>()
_mockLogger.Setup(l => l.ForModule<SimulatedXRaySource>()).Returns(_mockLogger.Object);
// Setup event aggregator to return the mock event
_mockEventAggregator
.Setup(ea => ea.GetEvent<RaySourceStatusChangedEvent>())
.Returns(_mockStatusEvent.Object);
_source = new SimulatedXRaySource(_mockEventAggregator.Object, _mockLogger.Object);
}
[Fact]
public void Initialize_SetsInitializedTrue()
{
// Act
var initResult = _source.Initialize();
var connectResult = _source.ConnectVariables();
// Assert
Assert.True(initResult.Success);
Assert.True(connectResult.Success);
Assert.True(_source.IsConnected);
}
[Fact]
public void TurnOn_PublishesOpenedEvent()
{
// Arrange
_source.Initialize();
_source.ConnectVariables();
// Act
var result = _source.TurnOn();
// Assert
Assert.True(result.Success);
_mockStatusEvent.Verify(
e => e.Publish(RaySourceStatus.Opened),
Times.Once);
}
[Fact]
public void TurnOff_PublishesClosedEvent()
{
// Arrange
_source.Initialize();
_source.ConnectVariables();
_source.TurnOn();
// Act
var result = _source.TurnOff();
// Assert
Assert.True(result.Success);
_mockStatusEvent.Verify(
e => e.Publish(RaySourceStatus.Closed),
Times.Once);
}
[Fact]
public void ReadVoltage_ReturnsWithinTwoPercentOfSetValue()
{
// Arrange
_source.Initialize();
_source.ConnectVariables();
_source.SetVoltage(100f);
// Act & Assert - read multiple times to verify range
for (int i = 0; i < 20; i++)
{
var result = _source.ReadVoltage();
Assert.True(result.Success);
float voltage = result.GetFloat();
Assert.InRange(voltage, 98f, 102f);
}
}
[Fact]
public void CloseOff_ResetsState()
{
// Arrange
_source.Initialize();
_source.ConnectVariables();
_source.TurnOn();
Assert.True(_source.IsConnected);
// Act
var result = _source.CloseOff();
// Assert
Assert.True(result.Success);
Assert.False(_source.IsConnected);
}
}
}
@@ -37,6 +37,7 @@
<ItemGroup>
<ProjectReference Include="..\XP.Common\XP.Common.csproj" />
<ProjectReference Include="..\XP.Hardware.MotionControl\XP.Hardware.MotionControl.csproj" />
<ProjectReference Include="..\XP.Hardware.RaySource\XP.Hardware.RaySource.csproj" />
<ProjectReference Include="..\XplorePlane\XplorePlane.csproj" />
</ItemGroup>
+6 -2
View File
@@ -31,8 +31,8 @@
<!-- 射线源配置 -->
<!-- 射线源类型 | Ray Source Type -->
<!-- 可选值: Comet225 | Available values: Comet225 -->
<add key="RaySource:SourceType" value="Comet225" />
<!-- 可选值: Comet225, Simulated | Available values: Comet225, Simulated -->
<add key="RaySource:SourceType" value="Simulated" />
<add key="RaySource:SerialNumber" value="SN08602861" />
<add key="RaySource:TotalLifeThreshold" value="10" />
<!-- PVI通讯参数 | PVI Communication Parameters -->
@@ -130,6 +130,10 @@
<!-- 自动重连 | Auto Reconnection -->
<add key="Plc:bReConnect" value="true" />
<!-- 运动控制类型: PLC | Simulated | Motion control type -->
<!-- 切换为 Simulated 可在无硬件环境下验证运动控制链路 | Switch to Simulated to verify motion control without hardware -->
<add key="MotionControl:Type" value="Simulated" />
<!-- 直线轴配置(单位:mm| Linear axis config (unit: mm) -->
<add key="MotionControl:SourceZ:Min" value="-500" />
<add key="MotionControl:SourceZ:Max" value="500" />
+133 -1
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
@@ -10,6 +11,10 @@ using System.Windows.Media.Imaging;
using Prism.Events;
using XP.Common.Converters;
using XP.Common.Logging.Interfaces;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
using XP.Hardware.MotionControl.Services;
using XP.Hardware.RaySource.Services;
using XplorePlane.Events;
using XplorePlane.Helpers;
using XplorePlane.Models;
@@ -33,6 +38,9 @@ namespace XplorePlane.Services.Cnc
private readonly IImageProcessingService _imageProcessingService;
private readonly IEventAggregator _eventAggregator;
private readonly IImagePersistenceService _imagePersistenceService;
private readonly IMotionControlService _motionControlService;
private readonly IMotionSystem _motionSystem;
private readonly IRaySourceService _raySourceService;
// Task 4.2: volatile field so reads/writes are not reordered across threads
private volatile CancellationTokenSource _executionCts;
@@ -45,7 +53,10 @@ namespace XplorePlane.Services.Cnc
IPipelineExecutionService pipelineExecutionService,
IImageProcessingService imageProcessingService,
IEventAggregator eventAggregator,
IImagePersistenceService imagePersistenceService)
IImagePersistenceService imagePersistenceService,
IMotionControlService motionControlService = null,
IMotionSystem motionSystem = null,
IRaySourceService raySourceService = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -55,6 +66,9 @@ namespace XplorePlane.Services.Cnc
_imageProcessingService = imageProcessingService;
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_imagePersistenceService = imagePersistenceService ?? throw new ArgumentNullException(nameof(imagePersistenceService));
_motionControlService = motionControlService;
_motionSystem = motionSystem;
_raySourceService = raySourceService;
// Task 4.3: subscribe to DetectorDisconnectedEvent on a background thread
_eventAggregator.GetEvent<DetectorDisconnectedEvent>()
@@ -144,6 +158,7 @@ namespace XplorePlane.Services.Cnc
if (linkedCts.Token.IsCancellationRequested)
{
cancelled = true;
_motionControlService?.StopAll();
_logger.ForModule<CncExecutionService>().Info(
"Multi-position execution cancelled at position {0}/{1}",
positionIndex, totalPositions);
@@ -177,6 +192,37 @@ namespace XplorePlane.Services.Cnc
string savedImagePath = null;
string nodeErrorMessage = null;
// ── Step 0: Move to target position (motion integration) ──
var moveResult = await MoveToPositionAsync(sp.MotionState, linkedCts.Token);
if (!moveResult.Success)
{
_logger.ForModule<CncExecutionService>().Warn(
"Motion move failed for node '{0}' at index {1}: {2}",
sp.Name, positionIndex, moveResult.ErrorMessage);
progress?.Report(new CncNodeExecutionProgress(
sp.Id, NodeExecutionState.Failed,
PositionIndex: positionIndex, TotalPositions: totalPositions));
allSucceeded = false;
positionResults.Add(new PositionResult
{
NodeName = sp.Name,
NodeIndex = sp.Index,
Status = "Failed",
ErrorMessage = $"Motion failed: {moveResult.ErrorMessage}"
});
continue;
}
// Wait for all axes to settle
var settled = await WaitForAxesSettledAsync(linkedCts.Token);
if (!settled)
{
_logger.ForModule<CncExecutionService>().Warn(
"Axes did not settle within timeout for node '{0}' at index {1}",
sp.Name, positionIndex);
// Continue anyway - axes may be close enough
}
// ── Step 1: Image Acquisition (with error tolerance - Task 5.4) ──
BitmapSource positionImage = null;
@@ -584,6 +630,92 @@ namespace XplorePlane.Services.Cnc
}
}
/// <summary>
/// Moves all axes to the target position specified by the MotionState.
/// MotionState positions are in micrometers (μm); ILinearAxis uses millimeters (mm).
/// Returns MotionResult.Ok() if motion service is not available (graceful degradation).
/// </summary>
private Task<MotionResult> MoveToPositionAsync(MotionState target, CancellationToken ct)
{
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);
var stageYResult = _motionControlService.MoveToTarget(AxisId.StageY, target.StageY / 1000.0);
if (!stageYResult.Success) return Task.FromResult(stageYResult);
var sourceZResult = _motionControlService.MoveToTarget(AxisId.SourceZ, target.SourceZ / 1000.0);
if (!sourceZResult.Success) return Task.FromResult(sourceZResult);
var detectorZResult = _motionControlService.MoveToTarget(AxisId.DetectorZ, target.DetectorZ / 1000.0);
if (!detectorZResult.Success) return Task.FromResult(detectorZResult);
// Rotary axes: angles are already in degrees, no conversion needed
var detectorSwingResult = _motionControlService.MoveRotaryToTarget(RotaryAxisId.DetectorSwing, target.DetectorSwing);
if (!detectorSwingResult.Success) return Task.FromResult(detectorSwingResult);
var stageRotationResult = _motionControlService.MoveRotaryToTarget(RotaryAxisId.StageRotation, target.StageRotation);
if (!stageRotationResult.Success) return Task.FromResult(stageRotationResult);
var fixtureRotationResult = _motionControlService.MoveRotaryToTarget(RotaryAxisId.FixtureRotation, target.FixtureRotation);
if (!fixtureRotationResult.Success) return Task.FromResult(fixtureRotationResult);
return Task.FromResult(MotionResult.Ok());
}
/// <summary>
/// Polls all axes until they are settled (Status == Idle).
/// Returns true when all axes are idle, false on timeout (30s).
/// Returns true immediately if motion system is not available (graceful degradation).
/// </summary>
private async Task<bool> WaitForAxesSettledAsync(CancellationToken ct)
{
if (_motionSystem == null)
return true;
var sw = Stopwatch.StartNew();
const int pollIntervalMs = 50;
const int timeoutMs = 30_000;
while (sw.ElapsedMilliseconds < timeoutMs)
{
ct.ThrowIfCancellationRequested();
bool allIdle = true;
foreach (var axis in _motionSystem.LinearAxes.Values)
{
if (axis.Status != AxisStatus.Idle)
{
allIdle = false;
break;
}
}
if (allIdle)
{
foreach (var axis in _motionSystem.RotaryAxes.Values)
{
if (axis.Status != AxisStatus.Idle)
{
allIdle = false;
break;
}
}
}
if (allIdle)
return true;
await Task.Delay(pollIntervalMs, ct);
}
return false;
}
private BitmapSource TryGetSourceImage()
{
// ── 优先级 1MainViewportService 中的手动图像或当前显示图像 ──