using System.Threading.Channels; using Microsoft.AspNetCore.SignalR; using RobotNet10.NavigationTune.Hubs; using RobotNet10.NavigationTune.Shared.Hubs; using RobotNet10.NavigationTune.Shared.Interfaces; using RobotNet10.NavigationTune.Shared.Models; using RobotNet10.NavigationTune.Services; namespace RobotNet10.NavigationTune.Execution; /// /// Tuning Navigation wrapper implementation. /// Navigation algorithm runs in its own loop; telemetry is published on a dedicated publisher loop. /// public class TuningNavigation( ITestExecutor testExecutor, IMetricsCalculator metricsCalculator, IHubContext? hubContext = null) : ITuningNavigation { private readonly ITestExecutor _testExecutor = testExecutor; private readonly IMetricsCalculator _metricsCalculator = metricsCalculator; private readonly IHubContext? _hubContext = hubContext; private TestScenario? _currentScenario; private NavigationParameterSet? _currentParameters; private TestExecutionResult? _currentResult; private Guid _currentTestRunId; // Set at start of run so telemetry can be pushed during execution private Task? _executionTask; private Task? _telemetryPublisherTask; private Channel? _telemetryChannel; public event Action? OnTelemetryUpdate; public event Action? OnSafetyViolation; public event Action? OnStatusChanged; // Safety violation handler private void HandleSafetyViolation(SafetyViolation violation) { OnSafetyViolation?.Invoke(violation); PublishSafetyEvent(violation); } public bool IsTestRunning => _executionTask != null && !_executionTask.IsCompleted; public async Task ExecuteTestAsync( TestScenario scenario, NavigationParameterSet parameters, Guid? testRunId = null, CancellationToken cancellationToken = default, Action? onComplete = null) { if (IsTestRunning) throw new InvalidOperationException("Test is already running"); _currentScenario = scenario; _currentParameters = parameters; _currentTestRunId = testRunId ?? Guid.Empty; // Dedicated channel for telemetry: navigation loop only enqueues (no I/O in algorithm thread) _telemetryChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); // Start dedicated publisher loop (runs independently from navigation algorithm) _telemetryPublisherTask = RunTelemetryPublisherLoopAsync(cancellationToken); if (onComplete != null) { // Fire-and-forget: control loop runs on WatchThread; return immediately with Running _ = _testExecutor.ExecuteAsync( scenario, parameters, telemetry => _telemetryChannel.Writer.TryWrite(telemetry), cancellationToken, onComplete: result => { FinishExecution(result); _telemetryChannel?.Writer.Complete(); _executionTask = null; onComplete(result); }); return new TestExecutionResult { TestRunId = _currentTestRunId, Status = TestStatus.Running, StartTime = DateTime.UtcNow }; } // Await mode _executionTask = _testExecutor.ExecuteAsync( scenario, parameters, telemetry => _telemetryChannel.Writer.TryWrite(telemetry), cancellationToken ); try { _currentResult = await _executionTask; FinishExecution(_currentResult); return _currentResult; } finally { _telemetryChannel?.Writer.Complete(); if (_telemetryPublisherTask != null) { try { await _telemetryPublisherTask; } catch (OperationCanceledException) { } } _telemetryPublisherTask = null; _telemetryChannel = null; _executionTask = null; } } private void FinishExecution(TestExecutionResult result) { if (_currentTestRunId != Guid.Empty) result.TestRunId = _currentTestRunId; if (result.TelemetryData.Count > 0 && _currentScenario != null) { var pathPoints = _currentScenario.GenerateReferencePath(); var referencePath = new ReferencePath { Points = pathPoints, TotalLength = pathPoints.Count > 0 ? pathPoints[^1].DistanceFromStart : 0 }; result.Metrics = _metricsCalculator.CalculateMetrics(result.TelemetryData, referencePath); } OnStatusChanged?.Invoke(result.Status); PublishTestStatus(result.Status, result.TestRunId); } /// /// Dedicated loop for publishing telemetry (and raising events). Runs independently from navigation algorithm. /// private async Task RunTelemetryPublisherLoopAsync(CancellationToken cancellationToken) { if (_telemetryChannel == null) return; var reader = _telemetryChannel.Reader; try { await foreach (var telemetry in reader.ReadAllAsync(cancellationToken)) { OnTelemetryUpdate?.Invoke(telemetry); PublishTelemetryUpdate(telemetry); } } catch (OperationCanceledException) { } } public TestProgress GetProgress() { return _testExecutor.GetProgress(); } public void Pause() { _testExecutor.Pause(); OnStatusChanged?.Invoke(TestStatus.Paused); PublishTestStatus(TestStatus.Paused, _currentTestRunId != Guid.Empty ? _currentTestRunId : _currentResult?.TestRunId ?? Guid.Empty); } public void Resume() { _testExecutor.Resume(); OnStatusChanged?.Invoke(TestStatus.Running); PublishTestStatus(TestStatus.Running, _currentTestRunId != Guid.Empty ? _currentTestRunId : _currentResult?.TestRunId ?? Guid.Empty); } public void Stop() { _testExecutor.Stop(); OnStatusChanged?.Invoke(TestStatus.Aborted); PublishTestStatus(TestStatus.Aborted, _currentTestRunId != Guid.Empty ? _currentTestRunId : _currentResult?.TestRunId ?? Guid.Empty); } public void EmergencyStop() { _testExecutor.EmergencyStop(); OnStatusChanged?.Invoke(TestStatus.EmergencyStopped); PublishTestStatus(TestStatus.EmergencyStopped, _currentTestRunId != Guid.Empty ? _currentTestRunId : _currentResult?.TestRunId ?? Guid.Empty); } private void PublishTelemetryUpdate(TelemetryData telemetry) { if (_hubContext == null) return; var groupId = _currentTestRunId != Guid.Empty ? _currentTestRunId : _currentResult?.TestRunId ?? Guid.Empty; if (groupId == Guid.Empty) return; var dto = new TelemetryUpdateDto { TimestampMs = telemetry.TimestampMs, X = telemetry.RobotPose.X, Y = telemetry.RobotPose.Y, Theta = telemetry.RobotPose.Theta, LinearVelocity = telemetry.RobotTwist.Linear, AngularVelocity = telemetry.RobotTwist.Angular, CrossTrackError = telemetry.CrossTrackError, HeadingError = telemetry.HeadingError, DistanceToGoal = telemetry.DistanceToGoal, CommandedLinearVelocity = telemetry.CommandTwist.Linear, CommandedAngularVelocity = telemetry.CommandTwist.Angular }; _ = _hubContext.Clients.Group($"test_{groupId}") .SendAsync("ReceiveTelemetry", dto); } private void PublishTestStatus(TestStatus status, Guid testRunId) { if (_hubContext == null) return; var progress = GetProgress(); var dto = new TestStatusUpdateDto { TestRunId = testRunId, Status = status, ProgressPercent = progress.ProgressPercent, Message = status.ToString() }; _ = _hubContext.Clients.Group($"test_{testRunId}") .SendAsync("ReceiveTestStatus", dto); } private void PublishSafetyEvent(SafetyViolation violation) { if (_hubContext == null) return; var groupId = _currentTestRunId != Guid.Empty ? _currentTestRunId : _currentResult?.TestRunId ?? Guid.Empty; if (groupId == Guid.Empty) return; var dto = new SafetyEventDto { TestRunId = groupId, Type = violation.Type, Severity = violation.Severity, Message = violation.Message, Timestamp = violation.Timestamp }; _ = _hubContext.Clients.Group($"test_{groupId}") .SendAsync("ReceiveSafetyEvent", dto); } }