Three-column layout replacing the old HMI grid: - BenchRpmCommandCard: inline numeric input, 2×4 preset grid, Start/Stop - BenchActuatorsCard: direction toggle, oil pump, temperature PID, misc relays with FluentStateToggle showing checked state via AccentFillColor - BenchLiveDataCard: 2×5 KPI tiles (RPM, P1, P2, Q-Delivery, Q-Over, temps) - BenchChartsCard: 2×2 compact chart grid (Delivery, Over, P1, P2) - AdvanceMonitorCard: RadialAngleGauge custom FrameworkElement + PSG/INJ readouts, Δ° lock offset input, Zero PSG / Zero INJ buttons Supporting changes: - AngleDisplayViewModel: promote _currentManualDegrees, _isLockSet to [ObservableProperty]; add PsgRelativeDegrees, InjEncoderDegreesValue, TargetLockAngle, IsRunningMode (29/31 hysteresis); computed PrimaryGaugeAngle, TargetAngleForGauge, SecondaryGaugeAngle - BenchControlViewModel: add IsDirectionLeft computed property, SetDirectionRightCommand, SetDirectionLeftCommand, ApplyRpmCommand - FlowmeterChartView: add IsCompact DP (false default) for 90px compact height - Styles.xaml: add IsChecked trigger to FluentStateToggle (accent fill + white text) - Strings.en/es.xaml: add all new card and actuator string keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
13 KiB
C#
318 lines
13 KiB
C#
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;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(PrimaryGaugeAngle))]
|
||
[NotifyPropertyChangedFor(nameof(SecondaryGaugeAngle))]
|
||
private double _currentManualDegrees;
|
||
private double _lockAngleValue;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(TargetAngleForGauge))]
|
||
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;
|
||
|
||
// ── Numeric doubles for radial advance-monitor control ────────────────────
|
||
|
||
/// <summary>PSG relative angle as a double for gauge binding.</summary>
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(PrimaryGaugeAngle))]
|
||
private double _psgRelativeDegrees;
|
||
|
||
/// <summary>INJ encoder absolute degrees (0–360) as a double.</summary>
|
||
[ObservableProperty] private double _injEncoderDegreesValue;
|
||
|
||
/// <summary>Lock angle target (0–360 degrees) as a double for gauge binding.</summary>
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(TargetAngleForGauge))]
|
||
private double _targetLockAngle;
|
||
|
||
/// <summary>True when bench RPM ≥ 31, hysteresis-cleared below 29.</summary>
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(PrimaryGaugeAngle))]
|
||
[NotifyPropertyChangedFor(nameof(SecondaryGaugeAngle))]
|
||
private bool _isRunningMode;
|
||
|
||
// ── Constructor ───────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Creates the angle display ViewModel, reading encoder resolution from configuration.
|
||
/// </summary>
|
||
public AngleDisplayViewModel(IConfigurationService configService)
|
||
{
|
||
_encoderResolution = configService.Settings.EncoderResolution;
|
||
}
|
||
|
||
// ── Computed for radial gauge ─────────────────────────────────────────────
|
||
|
||
/// <summary>Primary thumb angle: manual-wheel angle in hand-mode, PSG relative angle when running.</summary>
|
||
public double PrimaryGaugeAngle => IsRunningMode ? PsgRelativeDegrees : CurrentManualDegrees;
|
||
|
||
/// <summary>Target thumb angle for the radial gauge, or null when no lock has been set yet.</summary>
|
||
public double? TargetAngleForGauge => IsLockSet ? TargetLockAngle : (double?)null;
|
||
|
||
/// <summary>Ghost secondary thumb angle: manual-wheel position shown when running, null in hand-mode (primary already shows it).</summary>
|
||
public double? SecondaryGaugeAngle => IsRunningMode ? CurrentManualDegrees : (double?)null;
|
||
|
||
// ── 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;
|
||
|
||
if (!IsRunningMode && rpm >= 31.0)
|
||
IsRunningMode = true;
|
||
else if (IsRunningMode && rpm < 29.0)
|
||
IsRunningMode = false;
|
||
|
||
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 (0–360).</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);
|
||
TargetLockAngle = _lockAngleValue;
|
||
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;
|
||
|
||
PsgRelativeDegrees = relDeg;
|
||
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 0–360, NO 180 normalization).
|
||
_injEncoderDegrees = _injRaw * (360.0 / _encoderResolution);
|
||
InjEncoderDegreesValue = _injEncoderDegrees;
|
||
|
||
// 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);
|
||
TargetLockAngle = _lockAngleValue;
|
||
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);
|
||
}
|
||
}
|