更新UI
This commit is contained in:
427
COFTester/Controls/RealtimeForceChart.cs
Normal file
427
COFTester/Controls/RealtimeForceChart.cs
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using LiveChartsCore.Defaults;
|
||||||
|
|
||||||
|
namespace COFTester.Controls;
|
||||||
|
|
||||||
|
public sealed class RealtimeForceChart : FrameworkElement
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty SamplesProperty = DependencyProperty.Register(
|
||||||
|
nameof(Samples),
|
||||||
|
typeof(IEnumerable),
|
||||||
|
typeof(RealtimeForceChart),
|
||||||
|
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnCollectionSourceChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty PeakLineSamplesProperty = DependencyProperty.Register(
|
||||||
|
nameof(PeakLineSamples),
|
||||||
|
typeof(IEnumerable),
|
||||||
|
typeof(RealtimeForceChart),
|
||||||
|
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnCollectionSourceChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty AverageLineSamplesProperty = DependencyProperty.Register(
|
||||||
|
nameof(AverageLineSamples),
|
||||||
|
typeof(IEnumerable),
|
||||||
|
typeof(RealtimeForceChart),
|
||||||
|
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnCollectionSourceChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty CurrentPointSamplesProperty = DependencyProperty.Register(
|
||||||
|
nameof(CurrentPointSamples),
|
||||||
|
typeof(IEnumerable),
|
||||||
|
typeof(RealtimeForceChart),
|
||||||
|
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnCollectionSourceChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty XMaxProperty = DependencyProperty.Register(
|
||||||
|
nameof(XMax),
|
||||||
|
typeof(double),
|
||||||
|
typeof(RealtimeForceChart),
|
||||||
|
new FrameworkPropertyMetadata(1d, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty YMaxProperty = DependencyProperty.Register(
|
||||||
|
nameof(YMax),
|
||||||
|
typeof(double),
|
||||||
|
typeof(RealtimeForceChart),
|
||||||
|
new FrameworkPropertyMetadata(0.5d, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty TravelMmProperty = DependencyProperty.Register(
|
||||||
|
nameof(TravelMm),
|
||||||
|
typeof(double),
|
||||||
|
typeof(RealtimeForceChart),
|
||||||
|
new FrameworkPropertyMetadata(1d, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty SledMassGramsProperty = DependencyProperty.Register(
|
||||||
|
nameof(SledMassGrams),
|
||||||
|
typeof(double),
|
||||||
|
typeof(RealtimeForceChart),
|
||||||
|
new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||||
|
|
||||||
|
private const double NewtonToGramForce = 101.9716213;
|
||||||
|
private const double GravityMetersPerSecondSquared = 9.80665;
|
||||||
|
|
||||||
|
private readonly Pen _axisPen = new(new SolidColorBrush(Color.FromRgb(54, 54, 54)), 1);
|
||||||
|
private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromRgb(170, 170, 170)), 0.9);
|
||||||
|
private readonly Pen _minorGridPen = new(new SolidColorBrush(Color.FromRgb(216, 216, 216)), 0.7);
|
||||||
|
private readonly Pen _curvePen = new(new SolidColorBrush(Color.FromRgb(18, 22, 25)), 1.7);
|
||||||
|
private readonly Pen _peakPen = new(new SolidColorBrush(Color.FromRgb(86, 86, 86)), 1) { DashStyle = DashStyles.Dash };
|
||||||
|
private readonly Pen _averagePen = new(new SolidColorBrush(Color.FromRgb(120, 120, 120)), 1) { DashStyle = DashStyles.Dash };
|
||||||
|
private readonly Brush _labelBrush = new SolidColorBrush(Color.FromRgb(63, 63, 63));
|
||||||
|
private readonly Brush _titleBrush = new SolidColorBrush(Color.FromRgb(34, 34, 34));
|
||||||
|
private readonly Brush _chartBackgroundBrush = new SolidColorBrush(Color.FromRgb(252, 253, 248));
|
||||||
|
private readonly Pen _chartBorderPen = new(new SolidColorBrush(Color.FromRgb(110, 112, 104)), 1);
|
||||||
|
private readonly Brush _pointBrush = new SolidColorBrush(Color.FromRgb(18, 22, 25));
|
||||||
|
private readonly Pen _pointPen = new(new SolidColorBrush(Color.FromRgb(247, 249, 251)), 1.8);
|
||||||
|
|
||||||
|
public IEnumerable? Samples
|
||||||
|
{
|
||||||
|
get => (IEnumerable?)GetValue(SamplesProperty);
|
||||||
|
set => SetValue(SamplesProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable? PeakLineSamples
|
||||||
|
{
|
||||||
|
get => (IEnumerable?)GetValue(PeakLineSamplesProperty);
|
||||||
|
set => SetValue(PeakLineSamplesProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable? AverageLineSamples
|
||||||
|
{
|
||||||
|
get => (IEnumerable?)GetValue(AverageLineSamplesProperty);
|
||||||
|
set => SetValue(AverageLineSamplesProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable? CurrentPointSamples
|
||||||
|
{
|
||||||
|
get => (IEnumerable?)GetValue(CurrentPointSamplesProperty);
|
||||||
|
set => SetValue(CurrentPointSamplesProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double XMax
|
||||||
|
{
|
||||||
|
get => (double)GetValue(XMaxProperty);
|
||||||
|
set => SetValue(XMaxProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double YMax
|
||||||
|
{
|
||||||
|
get => (double)GetValue(YMaxProperty);
|
||||||
|
set => SetValue(YMaxProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double TravelMm
|
||||||
|
{
|
||||||
|
get => (double)GetValue(TravelMmProperty);
|
||||||
|
set => SetValue(TravelMmProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double SledMassGrams
|
||||||
|
{
|
||||||
|
get => (double)GetValue(SledMassGramsProperty);
|
||||||
|
set => SetValue(SledMassGramsProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnRender(DrawingContext drawingContext)
|
||||||
|
{
|
||||||
|
base.OnRender(drawingContext);
|
||||||
|
|
||||||
|
var width = ActualWidth;
|
||||||
|
var height = ActualHeight;
|
||||||
|
if (width < 120 || height < 90)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plot = new Rect(78, 36, Math.Max(1, width - 220), Math.Max(1, height - 104));
|
||||||
|
var samples = ReadPoints(Samples);
|
||||||
|
var currentPoint = ReadPoints(CurrentPointSamples).LastOrDefault();
|
||||||
|
var xMax = ResolveAxisMax(XMax, TravelMm, samples.Select(point => point.X));
|
||||||
|
var yMax = ResolveAxisMax(YMax, 0.5, samples.Select(point => point.Y));
|
||||||
|
|
||||||
|
drawingContext.DrawRectangle(_chartBackgroundBrush, _chartBorderPen, new Rect(0.5, 0.5, Math.Max(0, width - 1), Math.Max(0, height - 1)));
|
||||||
|
DrawGridAndAxes(drawingContext, plot, xMax, yMax);
|
||||||
|
DrawReferenceLine(drawingContext, plot, xMax, yMax, ReadLineValue(PeakLineSamples), _peakPen);
|
||||||
|
DrawReferenceLine(drawingContext, plot, xMax, yMax, ReadLineValue(AverageLineSamples), _averagePen);
|
||||||
|
DrawCurve(drawingContext, plot, xMax, yMax, samples);
|
||||||
|
|
||||||
|
if (currentPoint is not null)
|
||||||
|
{
|
||||||
|
var point = ToScreen(currentPoint, plot, xMax, yMax);
|
||||||
|
drawingContext.DrawEllipse(_pointBrush, _pointPen, point, 5.5, 5.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnCollectionSourceChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var chart = (RealtimeForceChart)dependencyObject;
|
||||||
|
if (e.OldValue is INotifyCollectionChanged oldCollection)
|
||||||
|
{
|
||||||
|
oldCollection.CollectionChanged -= chart.OnCollectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.NewValue is INotifyCollectionChanged newCollection)
|
||||||
|
{
|
||||||
|
newCollection.CollectionChanged += chart.OnCollectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawGridAndAxes(DrawingContext drawingContext, Rect plot, double xMax, double yMax)
|
||||||
|
{
|
||||||
|
drawingContext.DrawRectangle(null, _axisPen, plot);
|
||||||
|
|
||||||
|
var xDivisions = ResolveXAxisDivisions(plot.Width);
|
||||||
|
const int xMinorDivisions = 5;
|
||||||
|
for (var index = 0; index <= xDivisions; index++)
|
||||||
|
{
|
||||||
|
var x = plot.Left + plot.Width * index / xDivisions;
|
||||||
|
drawingContext.DrawLine(index == 0 ? _axisPen : _gridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
|
||||||
|
if (index < xDivisions)
|
||||||
|
{
|
||||||
|
for (var minorIndex = 1; minorIndex < xMinorDivisions; minorIndex++)
|
||||||
|
{
|
||||||
|
var minorX = x + plot.Width / xDivisions * minorIndex / xMinorDivisions;
|
||||||
|
drawingContext.DrawLine(_minorGridPen, new Point(minorX, plot.Top), new Point(minorX, plot.Bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawCenteredText(
|
||||||
|
drawingContext,
|
||||||
|
(xMax * index / xDivisions).ToString("0.#", CultureInfo.InvariantCulture),
|
||||||
|
x,
|
||||||
|
plot.Bottom + 8,
|
||||||
|
11,
|
||||||
|
_labelBrush,
|
||||||
|
plot.Left,
|
||||||
|
plot.Right);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int yDivisions = 10;
|
||||||
|
const int yMinorDivisions = 3;
|
||||||
|
var normalForceN = ResolveNormalForceN();
|
||||||
|
for (var index = 0; index <= yDivisions; index++)
|
||||||
|
{
|
||||||
|
var y = plot.Bottom - plot.Height * index / yDivisions;
|
||||||
|
drawingContext.DrawLine(index == 0 ? _axisPen : _gridPen, new Point(plot.Left, y), new Point(plot.Right, y));
|
||||||
|
if (index < yDivisions)
|
||||||
|
{
|
||||||
|
for (var minorIndex = 1; minorIndex < yMinorDivisions; minorIndex++)
|
||||||
|
{
|
||||||
|
var minorY = y - plot.Height / yDivisions * minorIndex / yMinorDivisions;
|
||||||
|
drawingContext.DrawLine(_minorGridPen, new Point(plot.Left, minorY), new Point(plot.Right, minorY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawRightAlignedText(
|
||||||
|
drawingContext,
|
||||||
|
FormatForceGramLabel(yMax * index / yDivisions),
|
||||||
|
plot.Left - 10,
|
||||||
|
y - 8,
|
||||||
|
11,
|
||||||
|
_labelBrush,
|
||||||
|
4);
|
||||||
|
DrawText(
|
||||||
|
drawingContext,
|
||||||
|
FormatCoefficientLabel(yMax * index / yDivisions, normalForceN),
|
||||||
|
plot.Right + 8,
|
||||||
|
y - 8,
|
||||||
|
11,
|
||||||
|
_labelBrush);
|
||||||
|
}
|
||||||
|
|
||||||
|
var xAxisTitleY = Math.Min(plot.Bottom + 30, ActualHeight - 18);
|
||||||
|
DrawText(drawingContext, "Time [sec]", plot.Right - 58, xAxisTitleY, 12, _titleBrush);
|
||||||
|
DrawText(drawingContext, "Friction force [gf]", plot.Left, plot.Top - 25, 12, _titleBrush);
|
||||||
|
DrawText(drawingContext, "Coefficient of friction", plot.Right - 8, plot.Top - 25, 11, _titleBrush);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawReferenceLine(DrawingContext drawingContext, Rect plot, double xMax, double yMax, double? value, Pen pen)
|
||||||
|
{
|
||||||
|
if (value is null || value <= 0 || !IsFinite(value.Value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var y = ToScreen(new ChartPoint(0, value.Value), plot, xMax, yMax).Y;
|
||||||
|
drawingContext.DrawLine(pen, new Point(plot.Left, y), new Point(plot.Right, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawCurve(DrawingContext drawingContext, Rect plot, double xMax, double yMax, IReadOnlyList<ChartPoint> samples)
|
||||||
|
{
|
||||||
|
if (samples.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (samples.Count == 1)
|
||||||
|
{
|
||||||
|
drawingContext.DrawEllipse(_pointBrush, _pointPen, ToScreen(samples[0], plot, xMax, yMax), 4, 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var geometry = new StreamGeometry();
|
||||||
|
using (var context = geometry.Open())
|
||||||
|
{
|
||||||
|
context.BeginFigure(ToScreen(samples[0], plot, xMax, yMax), false, false);
|
||||||
|
for (var index = 1; index < samples.Count; index++)
|
||||||
|
{
|
||||||
|
context.LineTo(ToScreen(samples[index], plot, xMax, yMax), true, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.Freeze();
|
||||||
|
drawingContext.DrawGeometry(null, _curvePen, geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveXAxisDivisions(double plotWidth)
|
||||||
|
{
|
||||||
|
if (plotWidth >= 700)
|
||||||
|
{
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plotWidth >= 520)
|
||||||
|
{
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plotWidth >= 360)
|
||||||
|
{
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawCenteredText(
|
||||||
|
DrawingContext drawingContext,
|
||||||
|
string text,
|
||||||
|
double centerX,
|
||||||
|
double y,
|
||||||
|
double fontSize,
|
||||||
|
Brush brush,
|
||||||
|
double leftLimit,
|
||||||
|
double rightLimit)
|
||||||
|
{
|
||||||
|
var formattedText = CreateFormattedText(text, fontSize, brush);
|
||||||
|
var x = Math.Clamp(centerX - formattedText.Width / 2, leftLimit, Math.Max(leftLimit, rightLimit - formattedText.Width));
|
||||||
|
drawingContext.DrawText(formattedText, new Point(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawText(DrawingContext drawingContext, string text, double x, double y, double fontSize, Brush brush)
|
||||||
|
{
|
||||||
|
drawingContext.DrawText(CreateFormattedText(text, fontSize, brush), new Point(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawRightAlignedText(
|
||||||
|
DrawingContext drawingContext,
|
||||||
|
string text,
|
||||||
|
double rightX,
|
||||||
|
double y,
|
||||||
|
double fontSize,
|
||||||
|
Brush brush,
|
||||||
|
double leftLimit)
|
||||||
|
{
|
||||||
|
var formattedText = CreateFormattedText(text, fontSize, brush);
|
||||||
|
var x = Math.Max(leftLimit, rightX - formattedText.Width);
|
||||||
|
drawingContext.DrawText(formattedText, new Point(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private FormattedText CreateFormattedText(string text, double fontSize, Brush brush)
|
||||||
|
{
|
||||||
|
return new FormattedText(
|
||||||
|
text,
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
FlowDirection.LeftToRight,
|
||||||
|
new Typeface("Microsoft YaHei UI"),
|
||||||
|
fontSize,
|
||||||
|
brush,
|
||||||
|
VisualTreeHelper.GetDpi(this).PixelsPerDip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Point ToScreen(ChartPoint point, Rect plot, double xMax, double yMax)
|
||||||
|
{
|
||||||
|
var x = plot.Left + plot.Width * Math.Clamp(point.X / xMax, 0, 1);
|
||||||
|
var y = plot.Bottom - plot.Height * Math.Clamp(point.Y / yMax, 0, 1);
|
||||||
|
return new Point(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ResolveAxisMax(double configuredMax, double fallback, IEnumerable<double> values)
|
||||||
|
{
|
||||||
|
var max = IsFinite(configuredMax) && configuredMax > 0 ? configuredMax : fallback;
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (IsFinite(value))
|
||||||
|
{
|
||||||
|
max = Math.Max(max, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Max(max, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveNormalForceN()
|
||||||
|
{
|
||||||
|
return IsFinite(SledMassGrams) && SledMassGrams > 0
|
||||||
|
? SledMassGrams / 1000d * GravityMetersPerSecondSquared
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatForceGramLabel(double forceN)
|
||||||
|
{
|
||||||
|
var forceGf = forceN * NewtonToGramForce;
|
||||||
|
return forceGf >= 100
|
||||||
|
? forceGf.ToString("0", CultureInfo.InvariantCulture)
|
||||||
|
: forceGf.ToString("0.#", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatCoefficientLabel(double forceN, double normalForceN)
|
||||||
|
{
|
||||||
|
return normalForceN > 0
|
||||||
|
? (forceN / normalForceN).ToString("0.000", CultureInfo.InvariantCulture)
|
||||||
|
: "0.000";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? ReadLineValue(IEnumerable? source)
|
||||||
|
{
|
||||||
|
return ReadPoints(source).FirstOrDefault(point => point.Y > 0)?.Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ChartPoint> ReadPoints(IEnumerable? source)
|
||||||
|
{
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<ChartPoint>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var points = new List<ChartPoint>();
|
||||||
|
foreach (var item in source)
|
||||||
|
{
|
||||||
|
if (item is not ObservablePoint point ||
|
||||||
|
point.X is not { } x ||
|
||||||
|
point.Y is not { } y ||
|
||||||
|
!IsFinite(x) ||
|
||||||
|
!IsFinite(y))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
points.Add(new ChartPoint(x, Math.Max(0, y)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFinite(double value)
|
||||||
|
{
|
||||||
|
return !double.IsNaN(value) && !double.IsInfinity(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ChartPoint(double X, double Y);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,26 +4,38 @@ using System.Windows.Controls;
|
|||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using COFTester.ViewModels;
|
using COFTester.ViewModels;
|
||||||
using LiveChartsCore.Kernel;
|
|
||||||
|
|
||||||
namespace COFTester;
|
namespace COFTester;
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan RealtimeChartRenderPulseDuration = TimeSpan.FromSeconds(1);
|
||||||
|
private static readonly TimeSpan RealtimeChartStartupRenderPulseDuration = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
private readonly MainViewModel _viewModel = new();
|
private readonly MainViewModel _viewModel = new();
|
||||||
|
private readonly DispatcherTimer _realtimeChartRenderTimer;
|
||||||
private bool _isRealtimeChartLayoutRefreshQueued;
|
private bool _isRealtimeChartLayoutRefreshQueued;
|
||||||
|
private DateTime _realtimeChartRenderUntilUtc = DateTime.MinValue;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
|
_realtimeChartRenderTimer = new DispatcherTimer(DispatcherPriority.Render, Dispatcher)
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(33)
|
||||||
|
};
|
||||||
|
_realtimeChartRenderTimer.Tick += RealtimeChartRenderTimer_Tick;
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = _viewModel;
|
DataContext = _viewModel;
|
||||||
_viewModel.RealtimeChartInvalidated += ViewModel_RealtimeChartInvalidated;
|
_viewModel.RealtimeChartInvalidated += ViewModel_RealtimeChartInvalidated;
|
||||||
QueueRealtimeChartLayoutRefresh();
|
RequestRealtimeChartRender(RealtimeChartStartupRenderPulseDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
_viewModel.RealtimeChartInvalidated -= ViewModel_RealtimeChartInvalidated;
|
_viewModel.RealtimeChartInvalidated -= ViewModel_RealtimeChartInvalidated;
|
||||||
|
_realtimeChartRenderTimer.Stop();
|
||||||
|
_realtimeChartRenderTimer.Tick -= RealtimeChartRenderTimer_Tick;
|
||||||
_viewModel.Dispose();
|
_viewModel.Dispose();
|
||||||
base.OnClosed(e);
|
base.OnClosed(e);
|
||||||
}
|
}
|
||||||
@@ -31,29 +43,34 @@ public partial class MainWindow : Window
|
|||||||
protected override void OnContentRendered(EventArgs e)
|
protected override void OnContentRendered(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnContentRendered(e);
|
base.OnContentRendered(e);
|
||||||
QueueRealtimeChartLayoutRefresh();
|
RequestRealtimeChartRender(RealtimeChartStartupRenderPulseDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnStateChanged(EventArgs e)
|
protected override void OnStateChanged(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnStateChanged(e);
|
base.OnStateChanged(e);
|
||||||
QueueRealtimeChartLayoutRefresh();
|
RequestRealtimeChartRender(RealtimeChartRenderPulseDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowRealtimeTab_Click(object sender, RoutedEventArgs e)
|
private void ShowRealtimeTab_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
MainWorkspaceTabs.SelectedIndex = 0;
|
MainWorkspaceTabs.SelectedIndex = 0;
|
||||||
QueueRealtimeChartLayoutRefresh();
|
RequestRealtimeChartRender(RealtimeChartRenderPulseDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowHistoryTab_Click(object sender, RoutedEventArgs e)
|
private void ShowHistoryTab_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
MainWorkspaceTabs.SelectedIndex = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowConfigTab_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
MainWorkspaceTabs.SelectedIndex = 1;
|
MainWorkspaceTabs.SelectedIndex = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RealtimeForceChart_Loaded(object sender, RoutedEventArgs e)
|
private void RealtimeForceChart_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
QueueRealtimeChartLayoutRefresh();
|
RequestRealtimeChartRender(RealtimeChartStartupRenderPulseDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RealtimeForceChart_SizeChanged(object sender, SizeChangedEventArgs e)
|
private void RealtimeForceChart_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||||
@@ -63,18 +80,20 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QueueRealtimeChartLayoutRefresh();
|
RequestRealtimeChartRender(RealtimeChartRenderPulseDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ViewModel_RealtimeChartInvalidated(object? sender, EventArgs e)
|
private void ViewModel_RealtimeChartInvalidated(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (!Dispatcher.CheckAccess())
|
if (!Dispatcher.CheckAccess())
|
||||||
{
|
{
|
||||||
Dispatcher.BeginInvoke(new Action(QueueRealtimeChartLayoutRefresh), DispatcherPriority.Render);
|
Dispatcher.BeginInvoke(
|
||||||
|
new Action(() => RequestRealtimeChartRender(RealtimeChartRenderPulseDuration)),
|
||||||
|
DispatcherPriority.Render);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QueueRealtimeChartLayoutRefresh();
|
RequestRealtimeChartRender(RealtimeChartRenderPulseDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TableMotionButton_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
private void TableMotionButton_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
@@ -126,6 +145,32 @@ public partial class MainWindow : Window
|
|||||||
Dispatcher.BeginInvoke(RunRealtimeChartLayoutRefreshSequence, DispatcherPriority.Loaded);
|
Dispatcher.BeginInvoke(RunRealtimeChartLayoutRefreshSequence, DispatcherPriority.Loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RequestRealtimeChartRender(TimeSpan duration)
|
||||||
|
{
|
||||||
|
var renderUntil = DateTime.UtcNow + duration;
|
||||||
|
if (renderUntil > _realtimeChartRenderUntilUtc)
|
||||||
|
{
|
||||||
|
_realtimeChartRenderUntilUtc = renderUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueRealtimeChartLayoutRefresh();
|
||||||
|
if (!_realtimeChartRenderTimer.IsEnabled)
|
||||||
|
{
|
||||||
|
_realtimeChartRenderTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RealtimeChartRenderTimer_Tick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
RefreshRealtimeChartLayout();
|
||||||
|
if (DateTime.UtcNow < _realtimeChartRenderUntilUtc)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_realtimeChartRenderTimer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
private void RunRealtimeChartLayoutRefreshSequence()
|
private void RunRealtimeChartLayoutRefreshSequence()
|
||||||
{
|
{
|
||||||
RefreshRealtimeChartLayout();
|
RefreshRealtimeChartLayout();
|
||||||
@@ -149,11 +194,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
RealtimeForceChart.InvalidateMeasure();
|
RealtimeForceChart.InvalidateMeasure();
|
||||||
RealtimeForceChart.InvalidateArrange();
|
RealtimeForceChart.InvalidateArrange();
|
||||||
|
RealtimeForceChart.InvalidateVisual();
|
||||||
RealtimeForceChart.UpdateLayout();
|
RealtimeForceChart.UpdateLayout();
|
||||||
RealtimeForceChart.CoreChart.Update(new ChartUpdateParams
|
|
||||||
{
|
|
||||||
IsAutomaticUpdate = false,
|
|
||||||
Throttling = false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
COFTester/Models/ReciprocatingFrictionRecord.cs
Normal file
44
COFTester/Models/ReciprocatingFrictionRecord.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace COFTester.Models;
|
||||||
|
|
||||||
|
public sealed class ReciprocatingFrictionRecord
|
||||||
|
{
|
||||||
|
public int Index { get; init; }
|
||||||
|
|
||||||
|
public double? StaticCoefficient { get; init; }
|
||||||
|
|
||||||
|
public double? KineticCoefficient { get; init; }
|
||||||
|
|
||||||
|
public double? StaticForceN { get; init; }
|
||||||
|
|
||||||
|
public double? KineticForceN { get; init; }
|
||||||
|
|
||||||
|
public bool HasData => StaticCoefficient.HasValue ||
|
||||||
|
KineticCoefficient.HasValue ||
|
||||||
|
StaticForceN.HasValue ||
|
||||||
|
KineticForceN.HasValue;
|
||||||
|
|
||||||
|
public string StaticCoefficientLabel => FormatValue(StaticCoefficient, "F3");
|
||||||
|
|
||||||
|
public string KineticCoefficientLabel => FormatValue(KineticCoefficient, "F3");
|
||||||
|
|
||||||
|
public string StaticForceLabel => FormatValue(StaticForceN, "F3");
|
||||||
|
|
||||||
|
public string KineticForceLabel => FormatValue(KineticForceN, "F3");
|
||||||
|
|
||||||
|
public static ReciprocatingFrictionRecord Empty(int index)
|
||||||
|
{
|
||||||
|
return new ReciprocatingFrictionRecord
|
||||||
|
{
|
||||||
|
Index = index
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatValue(double? value, string format)
|
||||||
|
{
|
||||||
|
return value is { } number && double.IsFinite(number)
|
||||||
|
? number.ToString(format, CultureInfo.InvariantCulture)
|
||||||
|
: "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
24
COFTester/Models/ReciprocatingRecordGridSection.cs
Normal file
24
COFTester/Models/ReciprocatingRecordGridSection.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace COFTester.Models;
|
||||||
|
|
||||||
|
public sealed class ReciprocatingRecordGridSection
|
||||||
|
{
|
||||||
|
public int StartIndex { get; init; }
|
||||||
|
|
||||||
|
public int EndIndex { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<ReciprocatingRecordGridRow> Rows { get; init; } = Array.Empty<ReciprocatingRecordGridRow>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReciprocatingRecordGridRow
|
||||||
|
{
|
||||||
|
public string Header { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public IReadOnlyList<ReciprocatingRecordGridCell> Cells { get; init; } = Array.Empty<ReciprocatingRecordGridCell>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReciprocatingRecordGridCell
|
||||||
|
{
|
||||||
|
public string Value { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsHeader { get; init; }
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public sealed class TestRecipe : ObservableObject
|
|||||||
private double _speedMmPerMin;
|
private double _speedMmPerMin;
|
||||||
private double _travelMm;
|
private double _travelMm;
|
||||||
private int _replicateCount = 0;
|
private int _replicateCount = 0;
|
||||||
|
private int _reciprocatingCount = 50;
|
||||||
private string _specimenDescription = string.Empty;
|
private string _specimenDescription = string.Empty;
|
||||||
|
|
||||||
public string ProductCode
|
public string ProductCode
|
||||||
@@ -85,6 +86,12 @@ public sealed class TestRecipe : ObservableObject
|
|||||||
set => SetProperty(ref _replicateCount, value);
|
set => SetProperty(ref _replicateCount, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int ReciprocatingCount
|
||||||
|
{
|
||||||
|
get => _reciprocatingCount;
|
||||||
|
set => SetProperty(ref _reciprocatingCount, value);
|
||||||
|
}
|
||||||
|
|
||||||
public string SpecimenDescription
|
public string SpecimenDescription
|
||||||
{
|
{
|
||||||
get => _specimenDescription;
|
get => _specimenDescription;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public sealed class TestRecipeSnapshot
|
|||||||
|
|
||||||
public int ReplicateCount { get; init; }
|
public int ReplicateCount { get; init; }
|
||||||
|
|
||||||
|
public int ReciprocatingCount { get; init; } = 50;
|
||||||
|
|
||||||
public string SpecimenDescription { get; init; } = string.Empty;
|
public string SpecimenDescription { get; init; } = string.Empty;
|
||||||
|
|
||||||
public static TestRecipeSnapshot FromRecipe(TestRecipe recipe)
|
public static TestRecipeSnapshot FromRecipe(TestRecipe recipe)
|
||||||
@@ -41,6 +43,7 @@ public sealed class TestRecipeSnapshot
|
|||||||
SpeedMmPerMin = recipe.SpeedMmPerMin,
|
SpeedMmPerMin = recipe.SpeedMmPerMin,
|
||||||
TravelMm = recipe.TravelMm,
|
TravelMm = recipe.TravelMm,
|
||||||
ReplicateCount = recipe.ReplicateCount,
|
ReplicateCount = recipe.ReplicateCount,
|
||||||
|
ReciprocatingCount = recipe.ReciprocatingCount,
|
||||||
SpecimenDescription = recipe.SpecimenDescription
|
SpecimenDescription = recipe.SpecimenDescription
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ namespace COFTester.Services;
|
|||||||
|
|
||||||
public sealed class ModbusProcessDataReader
|
public sealed class ModbusProcessDataReader
|
||||||
{
|
{
|
||||||
|
private const ushort ReciprocatingRecordCountAddress = 500;
|
||||||
|
private const ushort ReciprocatingRecordStartAddress = 502;
|
||||||
|
private const int ReciprocatingRecordValueCount = 4;
|
||||||
|
private const int ReciprocatingRecordRegisterCount = ReciprocatingRecordValueCount * PlcRegisterEncoding.FloatRegisterCount;
|
||||||
|
private const ushort MaxReciprocatingRegistersPerRead = 120;
|
||||||
|
|
||||||
private static readonly PlcResultRegisterMap DefaultResultRegisterMap = new(
|
private static readonly PlcResultRegisterMap DefaultResultRegisterMap = new(
|
||||||
SlaveAddress: 1,
|
SlaveAddress: 1,
|
||||||
StartAddress: 460,
|
StartAddress: 460,
|
||||||
@@ -132,6 +138,62 @@ public sealed class ModbusProcessDataReader
|
|||||||
horizontalPosition);
|
horizontalPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ReciprocatingFrictionRecord>> ReadReciprocatingRecordsAsync(
|
||||||
|
int requestedCount,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var master = _connectionService.Master ?? throw new InvalidOperationException("Modbus master is not connected.");
|
||||||
|
var configuredCount = Math.Max(1, requestedCount);
|
||||||
|
var countRegisters = await master.ReadHoldingRegistersAsync(
|
||||||
|
_resultRegisterMap.SlaveAddress,
|
||||||
|
ReciprocatingRecordCountAddress,
|
||||||
|
1,
|
||||||
|
cancellationToken);
|
||||||
|
var plcCount = countRegisters.Length == 0 ? configuredCount : countRegisters[0];
|
||||||
|
var recordCount = plcCount <= 0 ? configuredCount : Math.Min(plcCount, configuredCount);
|
||||||
|
if (recordCount == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<ReciprocatingFrictionRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxAddressableRecordCount = Math.Max(0, (ushort.MaxValue - ReciprocatingRecordStartAddress + 1) / ReciprocatingRecordRegisterCount);
|
||||||
|
var recordsToRead = Math.Min(recordCount, maxAddressableRecordCount);
|
||||||
|
var records = new List<ReciprocatingFrictionRecord>(recordsToRead);
|
||||||
|
var recordsPerRead = Math.Max(1, MaxReciprocatingRegistersPerRead / ReciprocatingRecordRegisterCount);
|
||||||
|
var nextRecordOffset = 0;
|
||||||
|
|
||||||
|
while (nextRecordOffset < recordsToRead)
|
||||||
|
{
|
||||||
|
var chunkRecordCount = Math.Min(recordsPerRead, recordsToRead - nextRecordOffset);
|
||||||
|
var chunkStartAddress = checked((ushort)(ReciprocatingRecordStartAddress + nextRecordOffset * ReciprocatingRecordRegisterCount));
|
||||||
|
var chunkRegisterCount = checked((ushort)(chunkRecordCount * ReciprocatingRecordRegisterCount));
|
||||||
|
var registers = await ReadHoldingRegisterBlockAsync(
|
||||||
|
master,
|
||||||
|
_resultRegisterMap.SlaveAddress,
|
||||||
|
chunkStartAddress,
|
||||||
|
chunkRegisterCount,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
for (var index = 0; index < chunkRecordCount; index++)
|
||||||
|
{
|
||||||
|
var baseOffset = index * ReciprocatingRecordRegisterCount;
|
||||||
|
var recordNumber = nextRecordOffset + index + 1;
|
||||||
|
records.Add(new ReciprocatingFrictionRecord
|
||||||
|
{
|
||||||
|
Index = recordNumber,
|
||||||
|
StaticCoefficient = ReadRecordFloat(registers, chunkStartAddress, baseOffset),
|
||||||
|
KineticCoefficient = ReadRecordFloat(registers, chunkStartAddress, baseOffset + 2),
|
||||||
|
StaticForceN = ReadRecordFloat(registers, chunkStartAddress, baseOffset + 4),
|
||||||
|
KineticForceN = ReadRecordFloat(registers, chunkStartAddress, baseOffset + 6)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRecordOffset += chunkRecordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<ushort[]> ReadHoldingRegisterBlockAsync(
|
private static async Task<ushort[]> ReadHoldingRegisterBlockAsync(
|
||||||
IModbusMaster master,
|
IModbusMaster master,
|
||||||
byte slaveAddress,
|
byte slaveAddress,
|
||||||
@@ -154,6 +216,12 @@ public sealed class ModbusProcessDataReader
|
|||||||
return PlcRegisterEncoding.ReadFloat(registers, offset, $"D{address}");
|
return PlcRegisterEncoding.ReadFloat(registers, offset, $"D{address}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static double ReadRecordFloat(ushort[] registers, ushort chunkStartAddress, int offset)
|
||||||
|
{
|
||||||
|
var address = checked((ushort)(chunkStartAddress + offset));
|
||||||
|
return PlcRegisterEncoding.ReadFloat(registers, offset, $"D{address}");
|
||||||
|
}
|
||||||
|
|
||||||
private static ushort Min(params ushort[] values)
|
private static ushort Min(params ushort[] values)
|
||||||
{
|
{
|
||||||
return values.Min();
|
return values.Min();
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
|
using ClosedXML.Excel.Drawings;
|
||||||
using COFTester.Models;
|
using COFTester.Models;
|
||||||
using DocumentFormat.OpenXml;
|
using DocumentFormat.OpenXml;
|
||||||
using DocumentFormat.OpenXml.Packaging;
|
using DocumentFormat.OpenXml.Packaging;
|
||||||
@@ -29,7 +33,7 @@ public sealed class RunExportService
|
|||||||
|
|
||||||
public RunExportService(string exportRootDirectory)
|
public RunExportService(string exportRootDirectory)
|
||||||
{
|
{
|
||||||
_reportDirectory = Path.Combine(exportRootDirectory, "reports");
|
_reportDirectory = ResolveDefaultReportDirectory(exportRootDirectory);
|
||||||
Directory.CreateDirectory(_reportDirectory);
|
Directory.CreateDirectory(_reportDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,20 +58,15 @@ public sealed class RunExportService
|
|||||||
Directory.CreateDirectory(outputDirectory);
|
Directory.CreateDirectory(outputDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
WorksheetChartLayout chartLayout;
|
|
||||||
using (var workbook = new XLWorkbook())
|
using (var workbook = new XLWorkbook())
|
||||||
{
|
{
|
||||||
BuildSummarySheet(workbook.Worksheets.Add("报表汇总"), exportRuns, exportStatistics);
|
BuildSummarySheet(workbook.Worksheets.Add("报表汇总"), exportRuns, exportStatistics);
|
||||||
BuildRawDataSheet(workbook.Worksheets.Add("原始数据"), exportRuns);
|
BuildRawDataSheet(workbook.Worksheets.Add("原始数据"), exportRuns);
|
||||||
chartLayout = BuildChartSheet(workbook.Worksheets.Add("曲线图"), exportRuns);
|
BuildChartSheet(workbook.Worksheets.Add("曲线图"), exportRuns);
|
||||||
workbook.SaveAs(outputPath);
|
workbook.SaveAs(outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartLayout.Charts.Count > 0)
|
ValidateWorkbook(outputPath);
|
||||||
{
|
|
||||||
InsertNativeCharts(outputPath, chartLayout);
|
|
||||||
}
|
|
||||||
|
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +75,26 @@ public sealed class RunExportService
|
|||||||
return _reportDirectory;
|
return _reportDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveDefaultReportDirectory(string exportRootDirectory)
|
||||||
|
{
|
||||||
|
var desktopDirectory = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
||||||
|
if (!string.IsNullOrWhiteSpace(desktopDirectory))
|
||||||
|
{
|
||||||
|
return desktopDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(exportRootDirectory, "reports");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateWorkbook(string workbookPath)
|
||||||
|
{
|
||||||
|
using var document = SpreadsheetDocument.Open(workbookPath, false);
|
||||||
|
if (document.WorkbookPart?.Workbook.Sheets is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("导出的 Excel 文件缺少工作表。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string BuildHistoricalComparisonReportFileName(IReadOnlyList<PersistedRunData> runs)
|
public string BuildHistoricalComparisonReportFileName(IReadOnlyList<PersistedRunData> runs)
|
||||||
{
|
{
|
||||||
var exportRuns = BuildExportRuns(runs);
|
var exportRuns = BuildExportRuns(runs);
|
||||||
@@ -354,17 +373,18 @@ public sealed class RunExportService
|
|||||||
worksheet.Cell(row, 3).Value = runs[index].CurveColorHex;
|
worksheet.Cell(row, 3).Value = runs[index].CurveColorHex;
|
||||||
worksheet.Cell(row, 4).Value = runs[index].ValidSamples.Count;
|
worksheet.Cell(row, 4).Value = runs[index].ValidSamples.Count;
|
||||||
worksheet.Cell(row, 5).Value = IsChartableRun(runs[index])
|
worksheet.Cell(row, 5).Value = IsChartableRun(runs[index])
|
||||||
? "已绘制总览与单图"
|
? "已记录图片曲线与原始数据"
|
||||||
: "采样点不足,未绘制曲线";
|
: "采样点不足,保留原始数据";
|
||||||
worksheet.Cell(row, 3).Style.Fill.BackgroundColor = ToXlColor(runs[index].CurveColorHex);
|
worksheet.Cell(row, 3).Style.Fill.BackgroundColor = ToXlColor(runs[index].CurveColorHex);
|
||||||
worksheet.Cell(row, 3).Style.Font.FontColor = XLColor.White;
|
worksheet.Cell(row, 3).Style.Font.FontColor = XLColor.White;
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyChartColumnWidths(worksheet);
|
ApplyChartColumnWidths(worksheet);
|
||||||
var chartLayout = BuildChartDataArea(worksheet, runs);
|
BuildChartDataArea(worksheet, runs);
|
||||||
|
InsertCurveChartImages(worksheet, runs);
|
||||||
ApplyPageLayout(worksheet, CalculateChartSheetLastRow(runs), "$A$1:$T$");
|
ApplyPageLayout(worksheet, CalculateChartSheetLastRow(runs), "$A$1:$T$");
|
||||||
worksheet.SheetView.FreezeRows(1);
|
worksheet.SheetView.FreezeRows(1);
|
||||||
return chartLayout;
|
return new WorksheetChartLayout(worksheet.Name, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BuildStatisticsPanel(IXLWorksheet worksheet, IReadOnlyList<ExportCurveData> runs)
|
private static void BuildStatisticsPanel(IXLWorksheet worksheet, IReadOnlyList<ExportCurveData> runs)
|
||||||
@@ -505,6 +525,324 @@ public sealed class RunExportService
|
|||||||
return new WorksheetChartLayout(worksheet.Name, charts);
|
return new WorksheetChartLayout(worksheet.Name, charts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void InsertCurveChartImages(IXLWorksheet worksheet, IReadOnlyList<ExportCurveData> runs)
|
||||||
|
{
|
||||||
|
var chartableRuns = runs.Where(IsChartableRun).ToArray();
|
||||||
|
if (chartableRuns.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = Math.Max(10, runs.Count + 8);
|
||||||
|
InsertCurveChartImage(
|
||||||
|
worksheet,
|
||||||
|
row,
|
||||||
|
"历史实时摩擦曲线总览(图片记录)",
|
||||||
|
"历史实时摩擦曲线总览",
|
||||||
|
"CurveOverview",
|
||||||
|
chartableRuns,
|
||||||
|
showLegend: true);
|
||||||
|
|
||||||
|
row += 38;
|
||||||
|
foreach (var run in chartableRuns)
|
||||||
|
{
|
||||||
|
InsertCurveChartImage(
|
||||||
|
worksheet,
|
||||||
|
row,
|
||||||
|
$"{BuildCurveLabel(run)} 实时摩擦曲线(图片记录)",
|
||||||
|
$"{BuildCurveLabel(run)} 力值曲线",
|
||||||
|
$"Curve{run.Sequence:D2}",
|
||||||
|
[run],
|
||||||
|
showLegend: false);
|
||||||
|
row += 36;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InsertCurveChartImage(
|
||||||
|
IXLWorksheet worksheet,
|
||||||
|
int titleRow,
|
||||||
|
string sheetTitle,
|
||||||
|
string chartTitle,
|
||||||
|
string pictureName,
|
||||||
|
IReadOnlyList<ExportCurveData> runs,
|
||||||
|
bool showLegend)
|
||||||
|
{
|
||||||
|
worksheet.Cell(titleRow, 1).Value = sheetTitle;
|
||||||
|
worksheet.Cell(titleRow, 1).Style.Font.Bold = true;
|
||||||
|
worksheet.Cell(titleRow, 1).Style.Font.FontSize = 12;
|
||||||
|
worksheet.Cell(titleRow, 1).Style.Font.FontColor = XLColor.FromHtml("#21313D");
|
||||||
|
|
||||||
|
var pngBytes = RenderCurveChartPng(chartTitle, runs, showLegend);
|
||||||
|
var imageStream = new MemoryStream(pngBytes);
|
||||||
|
worksheet.AddPicture(imageStream, XLPictureFormat.Png, pictureName)
|
||||||
|
.MoveTo(worksheet.Cell(titleRow + 1, 1))
|
||||||
|
.WithSize(1060, 548);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] RenderCurveChartPng(string title, IReadOnlyList<ExportCurveData> runs, bool showLegend)
|
||||||
|
{
|
||||||
|
const int width = 1280;
|
||||||
|
const int height = 660;
|
||||||
|
var visual = new DrawingVisual();
|
||||||
|
|
||||||
|
using (var drawingContext = visual.RenderOpen())
|
||||||
|
{
|
||||||
|
var backgroundBrush = CreateBrush("#F8FBFD");
|
||||||
|
var titleBrush = CreateBrush("#21313D");
|
||||||
|
var labelBrush = CreateBrush("#4A5C6C");
|
||||||
|
var axisTitleBrush = CreateBrush("#21313D");
|
||||||
|
var gridPen = CreatePen("#D1DBE3", 1);
|
||||||
|
var minorGridPen = CreatePen("#E5ECF2", 1);
|
||||||
|
var axisPen = CreatePen("#4A5C6C", 1.4);
|
||||||
|
var bandBrush = new SolidColorBrush(Color.FromArgb(20, 0, 105, 180));
|
||||||
|
var bandPen = new Pen(new SolidColorBrush(Color.FromArgb(80, 0, 105, 180)), 1);
|
||||||
|
var pointBrush = CreateBrush("#0069B4");
|
||||||
|
var pointStrokePen = CreatePen("#F7F9FB", 2.5);
|
||||||
|
|
||||||
|
drawingContext.DrawRectangle(backgroundBrush, null, new Rect(0, 0, width, height));
|
||||||
|
|
||||||
|
var plot = new Rect(98, 82, width - 140, height - 170);
|
||||||
|
var xMax = ResolveExportChartXMax(runs);
|
||||||
|
var yMax = ResolveExportChartYMax(runs);
|
||||||
|
|
||||||
|
DrawExportText(drawingContext, title, 32, 42, 28, titleBrush, bold: true);
|
||||||
|
DrawExportGridAndAxes(drawingContext, plot, xMax, yMax, labelBrush, axisTitleBrush, gridPen, minorGridPen, axisPen);
|
||||||
|
DrawExportKineticBand(drawingContext, plot, xMax, runs.Max(item => item.Data.Recipe.TravelMm), bandBrush, bandPen);
|
||||||
|
|
||||||
|
for (var index = 0; index < runs.Count; index++)
|
||||||
|
{
|
||||||
|
var run = runs[index];
|
||||||
|
DrawExportCurve(drawingContext, plot, xMax, yMax, run, pointBrush, pointStrokePen);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLegend)
|
||||||
|
{
|
||||||
|
DrawExportLegend(drawingContext, runs, labelBrush);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
|
||||||
|
target.Render(visual);
|
||||||
|
|
||||||
|
var encoder = new PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(target));
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
encoder.Save(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawExportGridAndAxes(
|
||||||
|
DrawingContext drawingContext,
|
||||||
|
Rect plot,
|
||||||
|
double xMax,
|
||||||
|
double yMax,
|
||||||
|
Brush labelBrush,
|
||||||
|
Brush axisTitleBrush,
|
||||||
|
Pen gridPen,
|
||||||
|
Pen minorGridPen,
|
||||||
|
Pen axisPen)
|
||||||
|
{
|
||||||
|
drawingContext.DrawRectangle(null, axisPen, plot);
|
||||||
|
|
||||||
|
const int xDivisions = 5;
|
||||||
|
for (var index = 0; index <= xDivisions; index++)
|
||||||
|
{
|
||||||
|
var x = plot.Left + plot.Width * index / xDivisions;
|
||||||
|
drawingContext.DrawLine(index == 0 ? axisPen : minorGridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
|
||||||
|
DrawCenteredExportText(
|
||||||
|
drawingContext,
|
||||||
|
(xMax * index / xDivisions).ToString("0.#", CultureInfo.InvariantCulture),
|
||||||
|
x,
|
||||||
|
plot.Bottom + 28,
|
||||||
|
17,
|
||||||
|
labelBrush);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int yDivisions = 5;
|
||||||
|
for (var index = 0; index <= yDivisions; index++)
|
||||||
|
{
|
||||||
|
var y = plot.Bottom - plot.Height * index / yDivisions;
|
||||||
|
drawingContext.DrawLine(index == 0 ? axisPen : gridPen, new Point(plot.Left, y), new Point(plot.Right, y));
|
||||||
|
DrawExportText(
|
||||||
|
drawingContext,
|
||||||
|
(yMax * index / yDivisions).ToString("0.000", CultureInfo.InvariantCulture),
|
||||||
|
18,
|
||||||
|
y - 10,
|
||||||
|
17,
|
||||||
|
labelBrush);
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawCenteredExportText(drawingContext, "位移 / mm", plot.Left + plot.Width / 2, plot.Bottom + 58, 18, axisTitleBrush, bold: true);
|
||||||
|
DrawExportText(drawingContext, "力值 / N", 18, plot.Top - 30, 18, axisTitleBrush, bold: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawExportKineticBand(
|
||||||
|
DrawingContext drawingContext,
|
||||||
|
Rect plot,
|
||||||
|
double xMax,
|
||||||
|
double travelMm,
|
||||||
|
Brush fillBrush,
|
||||||
|
Pen strokePen)
|
||||||
|
{
|
||||||
|
if (!IsFinite(travelMm) || travelMm <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var xStart = Math.Clamp(travelMm * 0.35, 0, xMax);
|
||||||
|
var xEnd = Math.Clamp(travelMm, 0, xMax);
|
||||||
|
if (xEnd <= xStart)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = MapX(xStart, plot, xMax);
|
||||||
|
var right = MapX(xEnd, plot, xMax);
|
||||||
|
var rect = new Rect(left, plot.Top, right - left, plot.Height);
|
||||||
|
drawingContext.DrawRectangle(fillBrush, strokePen, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawExportCurve(
|
||||||
|
DrawingContext drawingContext,
|
||||||
|
Rect plot,
|
||||||
|
double xMax,
|
||||||
|
double yMax,
|
||||||
|
ExportCurveData run,
|
||||||
|
Brush pointBrush,
|
||||||
|
Pen pointStrokePen)
|
||||||
|
{
|
||||||
|
var points = run.ValidSamples
|
||||||
|
.Where(sample => IsFinite(sample.DisplacementMm) && IsFinite(sample.ForceN))
|
||||||
|
.Select(sample => new Point(
|
||||||
|
MapX(sample.DisplacementMm, plot, xMax),
|
||||||
|
MapY(sample.ForceN, plot, yMax)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (points.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var curvePen = new Pen(CreateBrush(run.CurveColorHex), 3.2);
|
||||||
|
if (points.Length == 1)
|
||||||
|
{
|
||||||
|
drawingContext.DrawEllipse(pointBrush, pointStrokePen, points[0], 5, 5);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var geometry = new StreamGeometry();
|
||||||
|
using (var context = geometry.Open())
|
||||||
|
{
|
||||||
|
context.BeginFigure(points[0], false, false);
|
||||||
|
for (var index = 1; index < points.Length; index++)
|
||||||
|
{
|
||||||
|
context.LineTo(points[index], true, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.Freeze();
|
||||||
|
drawingContext.DrawGeometry(null, curvePen, geometry);
|
||||||
|
drawingContext.DrawEllipse(pointBrush, pointStrokePen, points[^1], 5, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawExportLegend(DrawingContext drawingContext, IReadOnlyList<ExportCurveData> runs, Brush labelBrush)
|
||||||
|
{
|
||||||
|
const double startX = 760;
|
||||||
|
var y = 31d;
|
||||||
|
for (var index = 0; index < runs.Count && index < 8; index++)
|
||||||
|
{
|
||||||
|
var x = index < 4 ? startX : startX + 250;
|
||||||
|
var rowY = y + (index % 4) * 24;
|
||||||
|
drawingContext.DrawLine(new Pen(CreateBrush(runs[index].CurveColorHex), 3), new Point(x, rowY - 6), new Point(x + 32, rowY - 6));
|
||||||
|
DrawExportText(drawingContext, BuildLegendLabel(runs[index]), x + 40, rowY - 18, 17, labelBrush);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ResolveExportChartXMax(IReadOnlyList<ExportCurveData> runs)
|
||||||
|
{
|
||||||
|
var travelMax = runs.Select(item => item.Data.Recipe.TravelMm).Where(IsFinite).DefaultIfEmpty(0).Max();
|
||||||
|
var sampleMax = runs.SelectMany(item => item.ValidSamples).Select(sample => sample.DisplacementMm).Where(IsFinite).DefaultIfEmpty(0).Max();
|
||||||
|
return Math.Max(Math.Max(travelMax, sampleMax) + 5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ResolveExportChartYMax(IReadOnlyList<ExportCurveData> runs)
|
||||||
|
{
|
||||||
|
var forceMax = runs.SelectMany(item => item.ValidSamples).Select(sample => sample.ForceN).Where(IsFinite).DefaultIfEmpty(0).Max();
|
||||||
|
return Math.Max(forceMax * 1.15, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double MapX(double x, Rect plot, double xMax)
|
||||||
|
{
|
||||||
|
return plot.Left + plot.Width * Math.Clamp(x / xMax, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double MapY(double y, Rect plot, double yMax)
|
||||||
|
{
|
||||||
|
return plot.Bottom - plot.Height * Math.Clamp(y / yMax, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawCenteredExportText(
|
||||||
|
DrawingContext drawingContext,
|
||||||
|
string text,
|
||||||
|
double centerX,
|
||||||
|
double y,
|
||||||
|
double fontSize,
|
||||||
|
Brush brush,
|
||||||
|
bool bold = false)
|
||||||
|
{
|
||||||
|
var formattedText = CreateFormattedText(text, fontSize, brush, bold);
|
||||||
|
drawingContext.DrawText(formattedText, new Point(centerX - formattedText.Width / 2, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawExportText(
|
||||||
|
DrawingContext drawingContext,
|
||||||
|
string text,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double fontSize,
|
||||||
|
Brush brush,
|
||||||
|
bool bold = false)
|
||||||
|
{
|
||||||
|
drawingContext.DrawText(CreateFormattedText(text, fontSize, brush, bold), new Point(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FormattedText CreateFormattedText(string text, double fontSize, Brush brush, bool bold)
|
||||||
|
{
|
||||||
|
return new FormattedText(
|
||||||
|
text,
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
FlowDirection.LeftToRight,
|
||||||
|
new Typeface(
|
||||||
|
new FontFamily("Microsoft YaHei UI"),
|
||||||
|
FontStyles.Normal,
|
||||||
|
bold ? FontWeights.Bold : FontWeights.Normal,
|
||||||
|
FontStretches.Normal),
|
||||||
|
fontSize,
|
||||||
|
brush,
|
||||||
|
1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Brush CreateBrush(string hex)
|
||||||
|
{
|
||||||
|
var brush = new SolidColorBrush(ToWpfColor(hex));
|
||||||
|
brush.Freeze();
|
||||||
|
return brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Pen CreatePen(string hex, double thickness)
|
||||||
|
{
|
||||||
|
var pen = new Pen(CreateBrush(hex), thickness);
|
||||||
|
pen.Freeze();
|
||||||
|
return pen;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color ToWpfColor(string hex)
|
||||||
|
{
|
||||||
|
return ColorConverter.ConvertFromString(hex) is Color color
|
||||||
|
? color
|
||||||
|
: Color.FromRgb(0, 105, 180);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsChartableRun(ExportCurveData item)
|
private static bool IsChartableRun(ExportCurveData item)
|
||||||
{
|
{
|
||||||
return item.ValidSamples.Count >= 2;
|
return item.ValidSamples.Count >= 2;
|
||||||
@@ -513,8 +851,8 @@ public sealed class RunExportService
|
|||||||
private static int CalculateChartSheetLastRow(IReadOnlyList<ExportCurveData> runs)
|
private static int CalculateChartSheetLastRow(IReadOnlyList<ExportCurveData> runs)
|
||||||
{
|
{
|
||||||
var chartCount = runs.Count(IsChartableRun);
|
var chartCount = runs.Count(IsChartableRun);
|
||||||
var renderedChartCount = chartCount == 0 ? 0 : chartCount + 1;
|
var renderedImageCount = chartCount == 0 ? 0 : chartCount + 1;
|
||||||
return Math.Max(24, runs.Count + 22 + renderedChartCount * 13);
|
return Math.Max(40, runs.Count + 12 + renderedImageCount * 38);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyDefaultWorksheetStyle(IXLWorksheet worksheet)
|
private static void ApplyDefaultWorksheetStyle(IXLWorksheet worksheet)
|
||||||
|
|||||||
@@ -52,11 +52,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
private const ushort RegisterLiftSpeed = 310;
|
private const ushort RegisterLiftSpeed = 310;
|
||||||
private const ushort RegisterLiftDisplacement = 320;
|
private const ushort RegisterLiftDisplacement = 320;
|
||||||
private static readonly TimeSpan RealtimeChartRefreshInterval = TimeSpan.FromMilliseconds(250);
|
private static readonly TimeSpan RealtimeChartRefreshInterval = TimeSpan.FromMilliseconds(250);
|
||||||
private const double RealtimeChartDisplacementStepMm = 0.5;
|
private const double RealtimeChartTimeStepSeconds = 0.05;
|
||||||
private const double RealtimeChartXAxisPaddingMm = 5;
|
private const double RealtimeChartXAxisPaddingSeconds = 1;
|
||||||
private const double MinimumEmptyChartXAxisMaxLimit = 1;
|
private const double MinimumEmptyChartXAxisMaxLimit = 1;
|
||||||
private const double EmptyChartYAxisMaxLimit = 0.5;
|
private const double EmptyChartYAxisMaxLimit = 0.5;
|
||||||
private const double EmptyChartPointForceN = 0.001;
|
private const double EmptyChartPointForceN = 0.001;
|
||||||
|
private const int ReciprocatingRecordSectionSize = 15;
|
||||||
|
private static readonly TimeSpan ReciprocatingRecordRefreshInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
private readonly DispatcherTimer _timer;
|
private readonly DispatcherTimer _timer;
|
||||||
private readonly DispatcherTimer _deviceReconnectTimer;
|
private readonly DispatcherTimer _deviceReconnectTimer;
|
||||||
@@ -127,6 +129,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
private bool _isPlcCommandBusy;
|
private bool _isPlcCommandBusy;
|
||||||
private bool _isSyncingRecipeFromPlc;
|
private bool _isSyncingRecipeFromPlc;
|
||||||
private bool _activeRunStartedByPlc;
|
private bool _activeRunStartedByPlc;
|
||||||
|
private bool _reciprocatingRecordReadFailureLogged;
|
||||||
private ushort? _activeTableMotionCoil;
|
private ushort? _activeTableMotionCoil;
|
||||||
private ushort? _pendingTableMotionStopCoil;
|
private ushort? _pendingTableMotionStopCoil;
|
||||||
private MachineRuntimeState _machineRuntimeState = MachineRuntimeState.Idle;
|
private MachineRuntimeState _machineRuntimeState = MachineRuntimeState.Idle;
|
||||||
@@ -135,9 +138,11 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
private double _kineticForceSum;
|
private double _kineticForceSum;
|
||||||
private int _kineticSampleCount;
|
private int _kineticSampleCount;
|
||||||
private double _runStartHorizontalPositionMm = double.NaN;
|
private double _runStartHorizontalPositionMm = double.NaN;
|
||||||
private double _lastRealtimeChartDisplacementMm = double.NaN;
|
private double _lastRealtimeChartElapsedSeconds = double.NaN;
|
||||||
private double _realtimeChartMaxDisplacementMm;
|
private double _realtimeChartMaxElapsedSeconds;
|
||||||
|
private double _currentRealtimeChartElapsedSeconds;
|
||||||
private DateTime _lastRealtimeChartRefreshAt = DateTime.MinValue;
|
private DateTime _lastRealtimeChartRefreshAt = DateTime.MinValue;
|
||||||
|
private DateTime _lastReciprocatingRecordRefreshAt = DateTime.MinValue;
|
||||||
private double _lastForceXAxisMaxLimit;
|
private double _lastForceXAxisMaxLimit;
|
||||||
private double _lastForceYAxisMaxLimit;
|
private double _lastForceYAxisMaxLimit;
|
||||||
|
|
||||||
@@ -147,6 +152,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
Recipe.PropertyChanged += OnRecipePropertyChanged;
|
Recipe.PropertyChanged += OnRecipePropertyChanged;
|
||||||
TestModes = ["膜/膜", "膜/其他材料", "片材/片材", "片材/其他材料"];
|
TestModes = ["膜/膜", "膜/其他材料", "片材/片材", "片材/其他材料"];
|
||||||
RunHistory = [];
|
RunHistory = [];
|
||||||
|
ReciprocatingRecords = [];
|
||||||
|
ReciprocatingRecordSections = [];
|
||||||
CalibrationItems = [];
|
CalibrationItems = [];
|
||||||
EventLog = [];
|
EventLog = [];
|
||||||
var appStorageDirectory = Path.Combine(
|
var appStorageDirectory = Path.Combine(
|
||||||
@@ -252,6 +259,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
ForceSections = [_kineticBand];
|
ForceSections = [_kineticBand];
|
||||||
|
|
||||||
ShowEmptyRealtimeChartFrame();
|
ShowEmptyRealtimeChartFrame();
|
||||||
|
ResetReciprocatingRecordTable();
|
||||||
LoadHistoryFromDatabase();
|
LoadHistoryFromDatabase();
|
||||||
UpdateDerivedValues();
|
UpdateDerivedValues();
|
||||||
AddInfoEvent("系统启动,正在初始化 PLC 通信与本地数据存储。");
|
AddInfoEvent("系统启动,正在初始化 PLC 通信与本地数据存储。");
|
||||||
@@ -268,6 +276,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
public ObservableCollection<RunRecord> RunHistory { get; }
|
public ObservableCollection<RunRecord> RunHistory { get; }
|
||||||
|
|
||||||
|
public ObservableCollection<ReciprocatingFrictionRecord> ReciprocatingRecords { get; }
|
||||||
|
|
||||||
|
public ObservableCollection<ReciprocatingRecordGridSection> ReciprocatingRecordSections { get; }
|
||||||
|
|
||||||
public ObservableCollection<CalibrationItem> CalibrationItems { get; }
|
public ObservableCollection<CalibrationItem> CalibrationItems { get; }
|
||||||
|
|
||||||
public ObservableCollection<SystemEvent> EventLog { get; }
|
public ObservableCollection<SystemEvent> EventLog { get; }
|
||||||
@@ -280,6 +292,18 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
public RectangularSection[] ForceSections { get; }
|
public RectangularSection[] ForceSections { get; }
|
||||||
|
|
||||||
|
public ObservableCollection<ObservablePoint> RealtimeForceSamples => _forceSamples;
|
||||||
|
|
||||||
|
public ObservableCollection<ObservablePoint> RealtimePeakLineSamples => _peakLineSamples;
|
||||||
|
|
||||||
|
public ObservableCollection<ObservablePoint> RealtimeAverageLineSamples => _averageLineSamples;
|
||||||
|
|
||||||
|
public ObservableCollection<ObservablePoint> RealtimeCurrentPointSamples => _currentPointSample;
|
||||||
|
|
||||||
|
public double RealtimeChartXMax => _lastForceXAxisMaxLimit;
|
||||||
|
|
||||||
|
public double RealtimeChartYMax => _lastForceYAxisMaxLimit;
|
||||||
|
|
||||||
public RelayCommand StartCommand => _startCommand;
|
public RelayCommand StartCommand => _startCommand;
|
||||||
|
|
||||||
public RelayCommand PauseCommand => _pauseCommand;
|
public RelayCommand PauseCommand => _pauseCommand;
|
||||||
@@ -381,6 +405,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
public string DeviceEndpoint => _deviceConnectionService.Endpoint;
|
public string DeviceEndpoint => _deviceConnectionService.Endpoint;
|
||||||
|
|
||||||
|
public string PlcAddressSummary =>
|
||||||
|
$"站号 {ModbusSlaveAddress};参数 D{RegisterSledMassGrams}/D{RegisterReplicateCount}/D{RegisterHorizontalSpeedSetpoint}/D{RegisterHorizontalTravelSetpoint};结果 D460;往复记录占位 D500/D502。";
|
||||||
|
|
||||||
public string DeviceLastConnectedAtLabel =>
|
public string DeviceLastConnectedAtLabel =>
|
||||||
_deviceLastConnectedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "尚未连接";
|
_deviceLastConnectedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "尚未连接";
|
||||||
|
|
||||||
@@ -568,6 +595,19 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
public string TravelDescriptor => $"{GetDisplayRecipeSnapshot().TravelMm:F0} mm 总行程";
|
public string TravelDescriptor => $"{GetDisplayRecipeSnapshot().TravelMm:F0} mm 总行程";
|
||||||
|
|
||||||
|
public string MeasurementTitle =>
|
||||||
|
string.IsNullOrWhiteSpace(Recipe.ProductCode)
|
||||||
|
? Recipe.BatchNumber
|
||||||
|
: Recipe.ProductCode;
|
||||||
|
|
||||||
|
public string MeasurementTimeLabel => DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss");
|
||||||
|
|
||||||
|
public string MeasurementComment =>
|
||||||
|
$"{Recipe.TestMode} | {Recipe.CounterfaceMaterial} | {Recipe.Direction}";
|
||||||
|
|
||||||
|
public string ReciprocatingRecordSummary =>
|
||||||
|
$"设定 {GetConfiguredReciprocatingCount()} 次;已读取 {ReciprocatingRecords.Count(item => item.HasData)} 次";
|
||||||
|
|
||||||
public int NextRunIndex
|
public int NextRunIndex
|
||||||
{
|
{
|
||||||
get => _nextRunIndex;
|
get => _nextRunIndex;
|
||||||
@@ -758,11 +798,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
CurrentStaticCoefficient = 0;
|
CurrentStaticCoefficient = 0;
|
||||||
CurrentKineticCoefficient = 0;
|
CurrentKineticCoefficient = 0;
|
||||||
TrialProgressPercent = 0;
|
TrialProgressPercent = 0;
|
||||||
_kineticBand.Xi = Recipe.TravelMm * 0.35;
|
var emptyChartXMax = GetEmptyChartXAxisMaxLimit();
|
||||||
_kineticBand.Xj = Recipe.TravelMm;
|
_kineticBand.Xi = 0;
|
||||||
|
_kineticBand.Xj = emptyChartXMax;
|
||||||
_kineticBand.Yi = 0;
|
_kineticBand.Yi = 0;
|
||||||
_kineticBand.Yj = 1;
|
_kineticBand.Yj = 1;
|
||||||
SetAxisLimits(GetEmptyChartXAxisMaxLimit(), EmptyChartYAxisMaxLimit);
|
SetAxisLimits(emptyChartXMax, EmptyChartYAxisMaxLimit);
|
||||||
NotifyRealtimeChartChanged();
|
NotifyRealtimeChartChanged();
|
||||||
_deviceDataReader.Initialize(Recipe);
|
_deviceDataReader.Initialize(Recipe);
|
||||||
AddInfoEvent($"批次 {Recipe.BatchNumber} 第 {NextRunIndex} 轮开始。模式={Recipe.TestMode}, 水平速度={Recipe.SpeedMmPerMin:F0} mm/min");
|
AddInfoEvent($"批次 {Recipe.BatchNumber} 第 {NextRunIndex} 轮开始。模式={Recipe.TestMode}, 水平速度={Recipe.SpeedMmPerMin:F0} mm/min");
|
||||||
@@ -912,6 +953,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
UpdateLiveProcessSnapshot(frame);
|
UpdateLiveProcessSnapshot(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await RefreshReciprocatingRecordsFromPlcIfDueAsync();
|
||||||
|
|
||||||
if (_machineRuntimeState != MachineRuntimeState.Running)
|
if (_machineRuntimeState != MachineRuntimeState.Running)
|
||||||
{
|
{
|
||||||
RaiseStatusProperties();
|
RaiseStatusProperties();
|
||||||
@@ -1001,36 +1044,47 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
RaiseStatusProperties();
|
RaiseStatusProperties();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddRealtimeChartSample(double displacementMm, double forceN)
|
private void AddRealtimeChartSample(double elapsedSeconds, double forceN)
|
||||||
{
|
{
|
||||||
TrackRealtimeChartBounds(displacementMm);
|
TrackRealtimeChartBounds(elapsedSeconds);
|
||||||
|
|
||||||
var sample = new ObservablePoint(displacementMm, forceN);
|
var sample = new ObservablePoint(elapsedSeconds, forceN);
|
||||||
if (_forceSamples.Count == 0 || !IsFinite(_lastRealtimeChartDisplacementMm))
|
if (_forceSamples.Count == 0 || !IsFinite(_lastRealtimeChartElapsedSeconds))
|
||||||
{
|
{
|
||||||
_forceSamples.Add(sample);
|
_forceSamples.Add(sample);
|
||||||
_lastRealtimeChartDisplacementMm = displacementMm;
|
_lastRealtimeChartElapsedSeconds = elapsedSeconds;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.Abs(displacementMm - _lastRealtimeChartDisplacementMm) >= RealtimeChartDisplacementStepMm)
|
if (Math.Abs(elapsedSeconds - _lastRealtimeChartElapsedSeconds) >= RealtimeChartTimeStepSeconds)
|
||||||
{
|
{
|
||||||
_forceSamples.Add(sample);
|
_forceSamples.Add(sample);
|
||||||
_lastRealtimeChartDisplacementMm = displacementMm;
|
_lastRealtimeChartElapsedSeconds = elapsedSeconds;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_forceSamples[^1] = sample;
|
_forceSamples[^1] = sample;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TrackRealtimeChartBounds(double displacementMm)
|
private void TrackRealtimeChartBounds(double elapsedSeconds)
|
||||||
{
|
{
|
||||||
if (IsFinite(displacementMm))
|
if (IsFinite(elapsedSeconds))
|
||||||
{
|
{
|
||||||
_realtimeChartMaxDisplacementMm = Math.Max(_realtimeChartMaxDisplacementMm, displacementMm);
|
_currentRealtimeChartElapsedSeconds = elapsedSeconds;
|
||||||
|
_realtimeChartMaxElapsedSeconds = Math.Max(_realtimeChartMaxElapsedSeconds, elapsedSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static double CalculateElapsedSeconds(DateTime capturedAt, IReadOnlyList<RawSampleRecord> samples)
|
||||||
|
{
|
||||||
|
if (samples.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Max(0, (capturedAt - samples[0].CapturedAt).TotalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
private bool AppendRunningSample(ProcessFrame frame, out bool isCompleted)
|
private bool AppendRunningSample(ProcessFrame frame, out bool isCompleted)
|
||||||
{
|
{
|
||||||
isCompleted = false;
|
isCompleted = false;
|
||||||
@@ -1050,18 +1104,20 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
CurrentDisplacementMm = displacementMm;
|
CurrentDisplacementMm = displacementMm;
|
||||||
CurrentForceN = forceN;
|
CurrentForceN = forceN;
|
||||||
var isFirstChartSample = _forceSamples.Count == 0;
|
var isFirstChartSample = _forceSamples.Count == 0;
|
||||||
|
var capturedAt = DateTime.Now;
|
||||||
_currentRunSamples.Add(new RawSampleRecord
|
_currentRunSamples.Add(new RawSampleRecord
|
||||||
{
|
{
|
||||||
RunId = _activeRunId,
|
RunId = _activeRunId,
|
||||||
SampleIndex = _currentRunSamples.Count + 1,
|
SampleIndex = _currentRunSamples.Count + 1,
|
||||||
CapturedAt = DateTime.Now,
|
CapturedAt = capturedAt,
|
||||||
DisplacementMm = displacementMm,
|
DisplacementMm = displacementMm,
|
||||||
ForceN = forceN,
|
ForceN = forceN,
|
||||||
SpeedMmPerMin = frame.SpeedMmPerMin
|
SpeedMmPerMin = frame.SpeedMmPerMin
|
||||||
});
|
});
|
||||||
|
|
||||||
AddRealtimeChartSample(displacementMm, forceN);
|
var elapsedSeconds = CalculateElapsedSeconds(capturedAt, _currentRunSamples);
|
||||||
UpdateCurrentPoint(displacementMm, forceN);
|
AddRealtimeChartSample(elapsedSeconds, forceN);
|
||||||
|
UpdateCurrentPoint(elapsedSeconds, forceN);
|
||||||
UpdatePreviewResult(displacementMm, forceN);
|
UpdatePreviewResult(displacementMm, forceN);
|
||||||
StaticCoefficient1 = frame.StaticCoefficient1;
|
StaticCoefficient1 = frame.StaticCoefficient1;
|
||||||
KineticCoefficient1 = frame.KineticCoefficient1;
|
KineticCoefficient1 = frame.KineticCoefficient1;
|
||||||
@@ -1174,8 +1230,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
private double CalculateRealtimeXAxisMaxLimit()
|
private double CalculateRealtimeXAxisMaxLimit()
|
||||||
{
|
{
|
||||||
var visibleMax = Math.Max(_realtimeChartMaxDisplacementMm, CurrentDisplacementMm);
|
var visibleMax = Math.Max(_realtimeChartMaxElapsedSeconds, _currentRealtimeChartElapsedSeconds);
|
||||||
return Math.Max(visibleMax + RealtimeChartXAxisPaddingMm, 1);
|
return Math.Max(visibleMax + RealtimeChartXAxisPaddingSeconds, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateAxisLimits(double xMax, double yMax)
|
private void UpdateAxisLimits(double xMax, double yMax)
|
||||||
@@ -1199,6 +1255,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(ForceXAxes));
|
OnPropertyChanged(nameof(ForceXAxes));
|
||||||
OnPropertyChanged(nameof(ForceYAxes));
|
OnPropertyChanged(nameof(ForceYAxes));
|
||||||
|
OnPropertyChanged(nameof(RealtimeChartXMax));
|
||||||
|
OnPropertyChanged(nameof(RealtimeChartYMax));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,6 +1268,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
ForceYAxes[0].MaxLimit = yMax;
|
ForceYAxes[0].MaxLimit = yMax;
|
||||||
OnPropertyChanged(nameof(ForceXAxes));
|
OnPropertyChanged(nameof(ForceXAxes));
|
||||||
OnPropertyChanged(nameof(ForceYAxes));
|
OnPropertyChanged(nameof(ForceYAxes));
|
||||||
|
OnPropertyChanged(nameof(RealtimeChartXMax));
|
||||||
|
OnPropertyChanged(nameof(RealtimeChartYMax));
|
||||||
NotifyRealtimeChartChanged();
|
NotifyRealtimeChartChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1225,9 +1285,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
private bool UpdateKineticBand(double yMax)
|
private bool UpdateKineticBand(double yMax)
|
||||||
{
|
{
|
||||||
var travelMm = GetActiveTravelMm();
|
const double xi = 0;
|
||||||
var xi = travelMm * 0.35;
|
var xj = Math.Max(_lastForceXAxisMaxLimit, 1);
|
||||||
var xj = travelMm;
|
|
||||||
|
|
||||||
var changed = HasMeaningfulChange(_kineticBand.Xi ?? double.NaN, xi)
|
var changed = HasMeaningfulChange(_kineticBand.Xi ?? double.NaN, xi)
|
||||||
|| HasMeaningfulChange(_kineticBand.Xj ?? double.NaN, xj)
|
|| HasMeaningfulChange(_kineticBand.Xj ?? double.NaN, xj)
|
||||||
@@ -1261,12 +1320,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
[
|
[
|
||||||
new Axis
|
new Axis
|
||||||
{
|
{
|
||||||
Name = "位移 / mm",
|
Name = "Time / sec",
|
||||||
NameTextSize = 12,
|
NameTextSize = 12,
|
||||||
TextSize = 11,
|
TextSize = 11,
|
||||||
MinLimit = 0,
|
MinLimit = 0,
|
||||||
MaxLimit = Math.Max(maxLimit, 1),
|
MaxLimit = Math.Max(maxLimit, 1),
|
||||||
MinStep = 25,
|
MinStep = 1,
|
||||||
LabelsPaint = new SolidColorPaint(new SKColor(74, 92, 108)),
|
LabelsPaint = new SolidColorPaint(new SKColor(74, 92, 108)),
|
||||||
NamePaint = new SolidColorPaint(new SKColor(33, 49, 61)),
|
NamePaint = new SolidColorPaint(new SKColor(33, 49, 61)),
|
||||||
SeparatorsPaint = new SolidColorPaint(new SKColor(209, 219, 227), 1),
|
SeparatorsPaint = new SolidColorPaint(new SKColor(209, 219, 227), 1),
|
||||||
@@ -1282,7 +1341,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
[
|
[
|
||||||
new Axis
|
new Axis
|
||||||
{
|
{
|
||||||
Name = "力值 / N",
|
Name = "Friction force / N",
|
||||||
NameTextSize = 12,
|
NameTextSize = 12,
|
||||||
TextSize = 11,
|
TextSize = 11,
|
||||||
MinLimit = 0,
|
MinLimit = 0,
|
||||||
@@ -1305,7 +1364,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var xEnd = Math.Max(_lastForceXAxisMaxLimit, CurrentDisplacementMm);
|
var xEnd = Math.Max(_lastForceXAxisMaxLimit, CalculateRealtimeXAxisMaxLimit());
|
||||||
if (target.Count == 2)
|
if (target.Count == 2)
|
||||||
{
|
{
|
||||||
target[0] = new ObservablePoint(0, y);
|
target[0] = new ObservablePoint(0, y);
|
||||||
@@ -1324,8 +1383,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
_kineticForceSum = 0;
|
_kineticForceSum = 0;
|
||||||
_kineticSampleCount = 0;
|
_kineticSampleCount = 0;
|
||||||
_runStartHorizontalPositionMm = double.NaN;
|
_runStartHorizontalPositionMm = double.NaN;
|
||||||
_lastRealtimeChartDisplacementMm = double.NaN;
|
_lastRealtimeChartElapsedSeconds = double.NaN;
|
||||||
_realtimeChartMaxDisplacementMm = 0;
|
_realtimeChartMaxElapsedSeconds = 0;
|
||||||
|
_currentRealtimeChartElapsedSeconds = 0;
|
||||||
_lastRealtimeChartRefreshAt = DateTime.MinValue;
|
_lastRealtimeChartRefreshAt = DateTime.MinValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1373,11 +1433,125 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
OnPropertyChanged(nameof(ExportReportSummary));
|
OnPropertyChanged(nameof(ExportReportSummary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RefreshReciprocatingRecordsFromPlcIfDueAsync()
|
||||||
|
{
|
||||||
|
if (!_deviceConnectionService.IsConnected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (now - _lastReciprocatingRecordRefreshAt < ReciprocatingRecordRefreshInterval)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastReciprocatingRecordRefreshAt = now;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var records = await _deviceDataReader.ReadReciprocatingRecordsAsync(GetConfiguredReciprocatingCount());
|
||||||
|
UpdateReciprocatingRecordTable(records);
|
||||||
|
_reciprocatingRecordReadFailureLogged = false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_reciprocatingRecordReadFailureLogged)
|
||||||
|
{
|
||||||
|
AddWarningEvent($"往复记录读取失败,已保留占位表: {ex.Message}");
|
||||||
|
_reciprocatingRecordReadFailureLogged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetReciprocatingRecordTable()
|
||||||
|
{
|
||||||
|
ReciprocatingRecords.Clear();
|
||||||
|
for (var index = 1; index <= GetConfiguredReciprocatingCount(); index++)
|
||||||
|
{
|
||||||
|
ReciprocatingRecords.Add(ReciprocatingFrictionRecord.Empty(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildReciprocatingRecordSections();
|
||||||
|
OnPropertyChanged(nameof(ReciprocatingRecordSummary));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateReciprocatingRecordTable(IReadOnlyList<ReciprocatingFrictionRecord> records)
|
||||||
|
{
|
||||||
|
var byIndex = records
|
||||||
|
.Where(record => record.Index > 0)
|
||||||
|
.GroupBy(record => record.Index)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First());
|
||||||
|
ReciprocatingRecords.Clear();
|
||||||
|
for (var index = 1; index <= GetConfiguredReciprocatingCount(); index++)
|
||||||
|
{
|
||||||
|
ReciprocatingRecords.Add(byIndex.TryGetValue(index, out var record)
|
||||||
|
? record
|
||||||
|
: ReciprocatingFrictionRecord.Empty(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildReciprocatingRecordSections();
|
||||||
|
OnPropertyChanged(nameof(ReciprocatingRecordSummary));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildReciprocatingRecordSections()
|
||||||
|
{
|
||||||
|
ReciprocatingRecordSections.Clear();
|
||||||
|
for (var start = 0; start < ReciprocatingRecords.Count; start += ReciprocatingRecordSectionSize)
|
||||||
|
{
|
||||||
|
var sectionRecords = ReciprocatingRecords
|
||||||
|
.Skip(start)
|
||||||
|
.Take(ReciprocatingRecordSectionSize)
|
||||||
|
.ToArray();
|
||||||
|
if (sectionRecords.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReciprocatingRecordSections.Add(new ReciprocatingRecordGridSection
|
||||||
|
{
|
||||||
|
StartIndex = sectionRecords[0].Index,
|
||||||
|
EndIndex = sectionRecords[^1].Index,
|
||||||
|
Rows =
|
||||||
|
[
|
||||||
|
BuildReciprocatingRecordGridRow(string.Empty, sectionRecords.Select(record => record.Index.ToString()), isHeader: true),
|
||||||
|
BuildReciprocatingRecordGridRow("cofs", sectionRecords.Select(record => record.StaticCoefficientLabel)),
|
||||||
|
BuildReciprocatingRecordGridRow("cofk", sectionRecords.Select(record => record.KineticCoefficientLabel)),
|
||||||
|
BuildReciprocatingRecordGridRow("Fs[N]", sectionRecords.Select(record => record.StaticForceLabel)),
|
||||||
|
BuildReciprocatingRecordGridRow("Fk[N]", sectionRecords.Select(record => record.KineticForceLabel))
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReciprocatingRecordGridRow BuildReciprocatingRecordGridRow(
|
||||||
|
string header,
|
||||||
|
IEnumerable<string> values,
|
||||||
|
bool isHeader = false)
|
||||||
|
{
|
||||||
|
return new ReciprocatingRecordGridRow
|
||||||
|
{
|
||||||
|
Header = header,
|
||||||
|
Cells = values
|
||||||
|
.Select(value => new ReciprocatingRecordGridCell { Value = value, IsHeader = isHeader })
|
||||||
|
.ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetConfiguredReciprocatingCount()
|
||||||
|
{
|
||||||
|
return Math.Max(1, Recipe.ReciprocatingCount);
|
||||||
|
}
|
||||||
|
|
||||||
private void RaiseStatusProperties()
|
private void RaiseStatusProperties()
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CurrentForceDescriptor));
|
OnPropertyChanged(nameof(CurrentForceDescriptor));
|
||||||
OnPropertyChanged(nameof(TravelDescriptor));
|
OnPropertyChanged(nameof(TravelDescriptor));
|
||||||
|
OnPropertyChanged(nameof(MeasurementTitle));
|
||||||
|
OnPropertyChanged(nameof(MeasurementTimeLabel));
|
||||||
|
OnPropertyChanged(nameof(MeasurementComment));
|
||||||
OnPropertyChanged(nameof(TraceabilitySummary));
|
OnPropertyChanged(nameof(TraceabilitySummary));
|
||||||
|
OnPropertyChanged(nameof(ReciprocatingRecordSummary));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RaiseCommandStates()
|
private void RaiseCommandStates()
|
||||||
@@ -1645,14 +1819,11 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
TrialProgressPercent = Math.Min(100, CurrentDisplacementMm / Math.Max(data.Recipe.TravelMm, 1) * 100);
|
TrialProgressPercent = Math.Min(100, CurrentDisplacementMm / Math.Max(data.Recipe.TravelMm, 1) * 100);
|
||||||
|
|
||||||
UpdateReferenceLines();
|
UpdateReferenceLines();
|
||||||
var historyMaxX = data.Samples.Count == 0
|
var historyMaxX = ResolveSamplesDurationSeconds(data.Samples);
|
||||||
? CurrentDisplacementMm
|
|
||||||
: data.Samples.Max(sample => sample.DisplacementMm);
|
|
||||||
var yMax = Math.Max(CurrentPeakForceN * 1.15, 0.5);
|
var yMax = Math.Max(CurrentPeakForceN * 1.15, 0.5);
|
||||||
SetAxisLimits(Math.Max(historyMaxX + 5, 1), yMax);
|
SetAxisLimits(Math.Max(historyMaxX + RealtimeChartXAxisPaddingSeconds, 1), yMax);
|
||||||
var historyStartX = data.Samples.Count == 0 ? 0 : data.Samples.Min(sample => sample.DisplacementMm);
|
_kineticBand.Xi = 0;
|
||||||
_kineticBand.Xi = historyStartX + data.Recipe.TravelMm * 0.35;
|
_kineticBand.Xj = Math.Max(historyMaxX, 1);
|
||||||
_kineticBand.Xj = historyStartX + data.Recipe.TravelMm;
|
|
||||||
_kineticBand.Yi = 0;
|
_kineticBand.Yi = 0;
|
||||||
_kineticBand.Yj = yMax;
|
_kineticBand.Yj = yMax;
|
||||||
OnPropertyChanged(nameof(ForceSections));
|
OnPropertyChanged(nameof(ForceSections));
|
||||||
@@ -1687,15 +1858,16 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
private void LoadSamplesIntoChart(IReadOnlyList<RawSampleRecord> samples)
|
private void LoadSamplesIntoChart(IReadOnlyList<RawSampleRecord> samples)
|
||||||
{
|
{
|
||||||
ClearRealtimeChartSamples();
|
ClearRealtimeChartSamples();
|
||||||
|
var firstCapturedAt = samples.Count == 0 ? DateTime.MinValue : samples[0].CapturedAt;
|
||||||
foreach (var sample in samples)
|
foreach (var sample in samples)
|
||||||
{
|
{
|
||||||
_forceSamples.Add(new ObservablePoint(sample.DisplacementMm, sample.ForceN));
|
_forceSamples.Add(new ObservablePoint(Math.Max(0, (sample.CapturedAt - firstCapturedAt).TotalSeconds), sample.ForceN));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (samples.Count > 0)
|
if (samples.Count > 0)
|
||||||
{
|
{
|
||||||
var lastSample = samples[^1];
|
var lastSample = samples[^1];
|
||||||
UpdateCurrentPoint(lastSample.DisplacementMm, lastSample.ForceN);
|
UpdateCurrentPoint(Math.Max(0, (lastSample.CapturedAt - firstCapturedAt).TotalSeconds), lastSample.ForceN);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1709,9 +1881,10 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
{
|
{
|
||||||
ClearRealtimeChartSamples();
|
ClearRealtimeChartSamples();
|
||||||
_currentPointSample.Add(new ObservablePoint(0, EmptyChartPointForceN));
|
_currentPointSample.Add(new ObservablePoint(0, EmptyChartPointForceN));
|
||||||
SetAxisLimits(GetEmptyChartXAxisMaxLimit(), EmptyChartYAxisMaxLimit);
|
var xMax = GetEmptyChartXAxisMaxLimit();
|
||||||
_kineticBand.Xi = Recipe.TravelMm * 0.35;
|
SetAxisLimits(xMax, EmptyChartYAxisMaxLimit);
|
||||||
_kineticBand.Xj = Recipe.TravelMm;
|
_kineticBand.Xi = 0;
|
||||||
|
_kineticBand.Xj = xMax;
|
||||||
_kineticBand.Yi = 0;
|
_kineticBand.Yi = 0;
|
||||||
_kineticBand.Yj = EmptyChartYAxisMaxLimit;
|
_kineticBand.Yj = EmptyChartYAxisMaxLimit;
|
||||||
OnPropertyChanged(nameof(ForceSections));
|
OnPropertyChanged(nameof(ForceSections));
|
||||||
@@ -1733,7 +1906,24 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
private double GetEmptyChartXAxisMaxLimit()
|
private double GetEmptyChartXAxisMaxLimit()
|
||||||
{
|
{
|
||||||
return Math.Max(Recipe.TravelMm, MinimumEmptyChartXAxisMaxLimit);
|
return Math.Max(ResolveEstimatedTestDurationSeconds(TestRecipeSnapshot.FromRecipe(Recipe)), MinimumEmptyChartXAxisMaxLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ResolveSamplesDurationSeconds(IReadOnlyList<RawSampleRecord> samples)
|
||||||
|
{
|
||||||
|
if (samples.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Max(0, (samples[^1].CapturedAt - samples[0].CapturedAt).TotalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ResolveEstimatedTestDurationSeconds(TestRecipeSnapshot recipe)
|
||||||
|
{
|
||||||
|
return recipe.SpeedMmPerMin > 0
|
||||||
|
? Math.Max(1, recipe.TravelMm / recipe.SpeedMmPerMin * 60d)
|
||||||
|
: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ResetRealtimePresentation()
|
private void ResetRealtimePresentation()
|
||||||
@@ -1760,6 +1950,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
CurrentKineticCoefficient = 0;
|
CurrentKineticCoefficient = 0;
|
||||||
TrialProgressPercent = 0;
|
TrialProgressPercent = 0;
|
||||||
|
|
||||||
|
ResetReciprocatingRecordTable();
|
||||||
ShowEmptyRealtimeChartFrame();
|
ShowEmptyRealtimeChartFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1867,6 +2058,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
RefreshEmptyRealtimeChartFrameIfVisible();
|
RefreshEmptyRealtimeChartFrameIfVisible();
|
||||||
RaiseStatusProperties();
|
RaiseStatusProperties();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.PropertyName == nameof(TestRecipe.ReciprocatingCount))
|
||||||
|
{
|
||||||
|
ResetReciprocatingRecordTable();
|
||||||
|
RaiseStatusProperties();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SyncRecipeFromPlcAsync()
|
private async Task SyncRecipeFromPlcAsync()
|
||||||
@@ -2254,6 +2451,11 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||||||
yield return "重复次数仅支持 0(单次) 或 1(双次)。";
|
yield return "重复次数仅支持 0(单次) 或 1(双次)。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Recipe.ReciprocatingCount < 1)
|
||||||
|
{
|
||||||
|
yield return "往复次数必须大于等于 1。";
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Brush BrushFromHex(string hex)
|
private static Brush BrushFromHex(string hex)
|
||||||
|
|||||||
Reference in New Issue
Block a user