Files
HC_APTBS/ViewModels/AngleDisplayViewModel.cs
LucianoDev 69bfda54e1 feat: redesign Bench page with Fluent card layout and radial advance monitor
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>
2026-04-20 17:45:59 +02:00

318 lines
13 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (0360) as a double.</summary>
[ObservableProperty] private double _injEncoderDegreesValue;
/// <summary>Lock angle target (0360 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 (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);
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 0360, 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);
}
}