feat: add AngleDisplay MVVM UserControl for advance monitoring

Refactors the old AngleDisplay code-behind into a clean MVVM implementation
with AngleDisplayViewModel (PSG/INJ/Manual encoder angles, zero references,
lock angle calculation with tolerance color coding) and a pure XAML view.
Wires LockAngleFaseReady and PsgModeFaseReady test phase events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 15:16:15 +02:00
parent e343006f45
commit d34e81163a
5 changed files with 449 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
using System;
using System.Globalization;
using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// ViewModel for the advance monitoring display showing PSG, Injector, and Manual
/// encoder angles with zero-reference tracking and lock angle calculation.
/// </summary>
public sealed partial class AngleDisplayViewModel : ObservableObject
{
private const string NaN = "- -";
private const double LockAngleTolerance = 0.1;
private readonly int _encoderResolution;
// ── Internal state ────────────────────────────────────────────────────────
private double _psgRaw;
private bool _psgWorking;
private double _injRaw;
private bool _injWorking;
private double _manualRaw;
private double _currentRpm;
private bool _isDirectionRight;
private double _zeroPsgEncoder;
private double _zeroInjDegrees;
private double _injEncoderDegrees;
private double _currentManualDegrees;
private double _lockAngleValue;
private bool _isLockSet;
private bool _isManualVisible;
// ── Observable properties (bound by View) ─────────────────────────────────
/// <summary>Formatted relative PSG angle or "- -" when sensor offline.</summary>
[ObservableProperty] private string _psgRelativeAngle = NaN;
/// <summary>Formatted raw PSG encoder degrees or "- -" when sensor offline.</summary>
[ObservableProperty] private string _psgEncoderAngle = NaN;
/// <summary>Foreground brush for PSG angle text (White = OK, Red = offline).</summary>
[ObservableProperty] private Brush _psgAngleForeground = Brushes.White;
/// <summary>Formatted relative INJ angle or "- -" when sensor offline.</summary>
[ObservableProperty] private string _injRelativeAngle = NaN;
/// <summary>Formatted raw INJ encoder degrees or "- -" when sensor offline.</summary>
[ObservableProperty] private string _injEncoderAngle = NaN;
/// <summary>Foreground brush for INJ angle text (White = OK, Red = offline).</summary>
[ObservableProperty] private Brush _injAngleForeground = Brushes.White;
/// <summary>Manual encoder angle in degrees, or "-" when RPM >= 30.</summary>
[ObservableProperty] private string _manualAngleText = "-";
/// <summary>Delta offset input for lock angle calculation (two-way bound to TextBox).</summary>
[ObservableProperty] private string _lockAngleDeltaInput = "0";
/// <summary>Computed lock angle result display.</summary>
[ObservableProperty] private string _lockAngleDisplay = "00.0";
/// <summary>Foreground brush for the lock angle display (Green/Red/White).</summary>
[ObservableProperty] private Brush _lockAngleForeground = Brushes.White;
// ── Constructor ───────────────────────────────────────────────────────────
/// <summary>
/// Creates the angle display ViewModel, reading encoder resolution from configuration.
/// </summary>
public AngleDisplayViewModel(IConfigurationService configService)
{
_encoderResolution = configService.Settings.EncoderResolution;
}
// ── Public update (called from MainViewModel.OnRefreshTick) ───────────────
/// <summary>
/// Feeds all encoder channel values and recalculates display properties.
/// Must be called on the UI thread.
/// </summary>
public void Update(
double psgRaw, bool psgWorking,
double injRaw, bool injWorking,
double manualRaw, double rpm,
bool isDirectionRight)
{
_psgRaw = psgRaw;
_psgWorking = psgWorking;
_injRaw = injRaw;
_injWorking = injWorking;
_manualRaw = manualRaw;
_currentRpm = rpm;
_isDirectionRight = isDirectionRight;
RecalculatePsg();
RecalculateInj();
RecalculateManual();
}
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Sets the current PSG encoder value as the zero reference.</summary>
[RelayCommand]
private void SetPsgZero()
{
_zeroPsgEncoder = _psgRaw;
RecalculatePsg();
}
/// <summary>Sets the current INJ encoder degrees as the zero reference.</summary>
[RelayCommand]
private void SetInjZero()
{
_zeroInjDegrees = _injEncoderDegrees;
RecalculateInj();
}
// ── Public methods for test phase integration ─────────────────────────────
/// <summary>
/// Sets the lock angle delta from the pump definition and returns the computed
/// lock angle result. Called when the LockAngleFaseReady event fires.
/// </summary>
/// <param name="pumpLockAngle">Angle offset from <see cref="Models.PumpDefinition.LockAngle"/>.</param>
/// <returns>Computed lock angle in degrees (0360).</returns>
public double SetLockAngle(double pumpLockAngle)
{
LockAngleDeltaInput = pumpLockAngle.ToString(CultureInfo.InvariantCulture);
return _lockAngleValue;
}
/// <summary>
/// Zeros the PSG encoder from a test phase (PsgModeFaseReady event).
/// </summary>
public void SetPsgZeroFromTest()
{
SetPsgZero();
}
// ── Lock angle delta change handler ───────────────────────────────────────
partial void OnLockAngleDeltaInputChanged(string value)
{
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double delta))
{
_lockAngleValue = CalculateLockAngle(delta);
_isLockSet = true;
LockAngleDisplay = FormatAngle(_lockAngleValue);
}
RecalculateLockColor();
}
// ── Private recalculation methods ─────────────────────────────────────────
private void RecalculatePsg()
{
if (!_psgWorking)
{
PsgRelativeAngle = NaN;
PsgEncoderAngle = NaN;
PsgAngleForeground = Brushes.Red;
return;
}
PsgAngleForeground = Brushes.White;
double sign = DirectionSign;
// Raw encoder → degrees, normalized to ±180.
double rawDeg = _psgRaw * (360.0 / _encoderResolution);
if (rawDeg > 180) rawDeg = 360 - rawDeg;
rawDeg *= sign;
// Relative angle from zero reference.
double relDeg = (_psgRaw - _zeroPsgEncoder) * (360.0 / _encoderResolution);
if (relDeg > 180) relDeg = 360 - relDeg;
relDeg *= sign;
PsgEncoderAngle = FormatAngle(rawDeg);
PsgRelativeAngle = FormatAngle(relDeg);
}
private void RecalculateInj()
{
if (!_injWorking)
{
InjRelativeAngle = NaN;
InjEncoderAngle = NaN;
InjAngleForeground = Brushes.Red;
return;
}
InjAngleForeground = Brushes.White;
double sign = DirectionSign;
// INJ encoder → degrees (full 0360, NO 180 normalization).
_injEncoderDegrees = _injRaw * (360.0 / _encoderResolution);
// Relative angle from zero reference.
double relDeg = (_injEncoderDegrees - _zeroInjDegrees) * sign;
InjEncoderAngle = FormatAngle(_injEncoderDegrees);
InjRelativeAngle = FormatAngle(relDeg);
// Recompute lock angle if already set (INJ encoder changed).
if (_isLockSet &&
double.TryParse(LockAngleDeltaInput, NumberStyles.Float,
CultureInfo.InvariantCulture, out double delta))
{
_lockAngleValue = CalculateLockAngle(delta);
LockAngleDisplay = FormatAngle(_lockAngleValue);
}
}
private void RecalculateManual()
{
_currentManualDegrees = _manualRaw * (360.0 / _encoderResolution);
if (_currentRpm < 30)
{
ManualAngleText = FormatAngle(_currentManualDegrees);
_isManualVisible = true;
}
else if (_isManualVisible)
{
ManualAngleText = "-";
_isManualVisible = false;
}
RecalculateLockColor();
}
private void RecalculateLockColor()
{
if (!_isManualVisible)
{
LockAngleForeground = Brushes.White;
return;
}
if (_isLockSet && Math.Abs(_lockAngleValue - _currentManualDegrees) <= LockAngleTolerance)
LockAngleForeground = Brushes.Green;
else if (_isLockSet)
LockAngleForeground = Brushes.Red;
else
LockAngleForeground = Brushes.White;
}
private double CalculateLockAngle(double angleToAdd)
{
double result = (_injEncoderDegrees + angleToAdd) % 360;
if (result < 0) result += 360;
return result;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private double DirectionSign => _isDirectionRight ? -1.0 : 1.0;
private static string FormatAngle(double degrees)
=> degrees.ToString("F1", CultureInfo.InvariantCulture);
}
}

View File

@@ -66,6 +66,9 @@ namespace HC_APTBS.ViewModels
/// <summary>ViewModel for the two flowmeter real-time charts (Q-Delivery, Q-Over).</summary>
public FlowmeterChartViewModel FlowmeterChart { get; } = new();
/// <summary>ViewModel for the encoder angle monitoring display (PSG, INJ, Manual, Lock Angle).</summary>
public AngleDisplayViewModel AngleDisplay { get; }
/// <summary>ViewModel for the first pump status display (Status word).</summary>
public StatusDisplayViewModel StatusDisplay1 { get; } = new();
@@ -98,6 +101,7 @@ namespace HC_APTBS.ViewModels
DfiViewModel = new DfiManageViewModel(kwpService, configService);
PumpControl = new PumpControlViewModel(benchService);
BenchControl = new BenchControlViewModel(benchService, configService);
AngleDisplay = new AngleDisplayViewModel(configService);
// React to pump changes from the identification child VM.
PumpIdentification.PumpChanged += OnPumpChanged;
@@ -144,6 +148,15 @@ namespace HC_APTBS.ViewModels
FlowmeterChart.SetTolerance(paramName, value, tolerance);
});
// Angle display: lock angle and PSG zero from test phases
_bench.LockAngleFaseReady += () => App.Current.Dispatcher.Invoke(() =>
{
if (CurrentPump != null)
CurrentPump.LockAngleResult = AngleDisplay.SetLockAngle(CurrentPump.LockAngle);
});
_bench.PsgModeFaseReady += () => App.Current.Dispatcher.Invoke(
() => AngleDisplay.SetPsgZeroFromTest());
// Unlock service status → verbose display
_unlock.StatusChanged += msg => App.Current.Dispatcher.Invoke(
() => VerboseStatus = msg);
@@ -481,6 +494,16 @@ namespace HC_APTBS.ViewModels
Pressure = _bench.ReadBenchParameter(BenchParameterNames.Pressure);
PsgEncoderValue = _bench.ReadBenchParameter(BenchParameterNames.PsgEncoderValue);
// Feed the angle display with all three encoder channels + status.
AngleDisplay.Update(
PsgEncoderValue,
_bench.ReadBenchParameter(BenchParameterNames.PsgEncoderWorking) == 1,
_bench.ReadBenchParameter(BenchParameterNames.InjEncoderValue),
_bench.ReadBenchParameter(BenchParameterNames.InjEncoderWorking) == 1,
_bench.ReadBenchParameter(BenchParameterNames.ManualEncoderValue),
BenchRpm,
BenchControl.IsDirectionRight);
// Feed flowmeter charts and refresh bench controls.
FlowmeterChart.AddSamples(QDelivery, QOver);
BenchControl.RefreshFromTick();