Initial commit
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using RobotNet10.NavigationTune.Shared.Hubs;
|
||||
using RobotNet10.NavigationTune.Shared.Interfaces;
|
||||
|
||||
namespace RobotNet10.NavigationTuneUI.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client for Tuning Hub
|
||||
/// </summary>
|
||||
public class TuningHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
// Events
|
||||
public event Action<TelemetryUpdateDto>? TelemetryUpdated;
|
||||
public event Action<TestStatusUpdateDto>? TestStatusUpdated;
|
||||
public event Action<SafetyEventDto>? SafetyEventReceived;
|
||||
public event Action<TestExecutionResult>? TestResultReceived;
|
||||
public event Action<HubConnectionState>? ConnectionStateChanged;
|
||||
|
||||
public TuningHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/tuninghub");
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
// Subscribe to server messages
|
||||
_hubConnection.On<TelemetryUpdateDto>("ReceiveTelemetry", dto => TelemetryUpdated?.Invoke(dto));
|
||||
_hubConnection.On<TestStatusUpdateDto>("ReceiveTestStatus", dto => TestStatusUpdated?.Invoke(dto));
|
||||
_hubConnection.On<SafetyEventDto>("ReceiveSafetyEvent", dto => SafetyEventReceived?.Invoke(dto));
|
||||
_hubConnection.On<TestExecutionResult>("ReceiveTestResult", result => TestResultReceived?.Invoke(result));
|
||||
|
||||
_hubConnection.Reconnecting += async (ex) =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += async (id) =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += async (ex) =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task JoinTestSessionAsync(string testRunId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("JoinTestSession", testRunId);
|
||||
}
|
||||
|
||||
public async Task LeaveTestSessionAsync(string testRunId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("LeaveTestSession", testRunId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using MudBlazor
|
||||
|
||||
<MudCard Style="width: 100%; height: 600px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
|
||||
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Test Metrics</MudText>
|
||||
@if (Metrics != null)
|
||||
{
|
||||
<MudChip T="string"
|
||||
Color="@(Metrics.PassedCriteria ? Color.Success : Color.Warning)"
|
||||
Size="Size.Small">
|
||||
@(Metrics.PassedCriteria ? "Passed" : "Failed")
|
||||
</MudChip>
|
||||
}
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;">
|
||||
@if (Metrics == null)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No metrics available</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTabs Elevation="0">
|
||||
<MudTabPanel Text="Overall Score">
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.h5" Class="mb-3">Overall Score: @Metrics.OverallScore.ToString("F1")</MudText>
|
||||
<MudProgressLinear Value="@Metrics.OverallScore"
|
||||
Color="@GetScoreColor(Metrics.OverallScore)"
|
||||
Class="mt-2"
|
||||
Style="height: 30px;" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Tracking Score</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.TrackingScore.ToString("F1")</MudText>
|
||||
<MudProgressLinear Value="@Metrics.TrackingScore"
|
||||
Color="@GetScoreColor(Metrics.TrackingScore)"
|
||||
Class="mt-2" />
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Smoothness Score</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.SmoothnessScore.ToString("F1")</MudText>
|
||||
<MudProgressLinear Value="@Metrics.SmoothnessScore"
|
||||
Color="@GetScoreColor(Metrics.SmoothnessScore)"
|
||||
Class="mt-2" />
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Efficiency Score</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.EfficiencyScore.ToString("F1")</MudText>
|
||||
<MudProgressLinear Value="@Metrics.EfficiencyScore"
|
||||
Color="@GetScoreColor(Metrics.EfficiencyScore)"
|
||||
Class="mt-2" />
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Tracking Accuracy">
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Cross-Track Error RMS (m)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.CrossTrackErrorRMS.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Cross-Track Error Peak (m)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.CrossTrackErrorPeak.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Heading Error RMS (rad)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.HeadingErrorRMS.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Heading Error Peak (rad)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.HeadingErrorPeak.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Goal Position Error (m)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.GoalPositionError.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Goal Heading Error (rad)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.GoalHeadingError.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Smoothness">
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Velocity Std Dev (m/s)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.VelocityStdDev.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Acceleration Std Dev (m/s²)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.AccelerationStdDev.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Efficiency">
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Path Length Ratio</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.PathLengthRatio.ToString("F4")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Completion Time (s)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.CompletionTime.ToString("F2")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Average Speed (m/s)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.AverageSpeed.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-3">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Max Speed (m/s)</MudText>
|
||||
<MudText Typo="Typo.h6">@Metrics.MaxSpeed.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter] public TestMetrics? Metrics { get; set; }
|
||||
|
||||
private Color GetScoreColor(double score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 80 => Color.Success,
|
||||
>= 60 => Color.Warning,
|
||||
_ => Color.Error
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Components
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-4" Style="height: calc(100vh - 120px); overflow-y: auto;">
|
||||
<TestHistoryViewer @ref="TestHistoryViewerRef"
|
||||
OnShowMetrics="OnShowMetrics" />
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<TestMetrics?> OnShowMetrics { get; set; }
|
||||
|
||||
public TestHistoryViewer? TestHistoryViewerRef { get; private set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Components
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-4" Style="height: calc(100vh - 120px); overflow-y: auto;">
|
||||
<ParameterSetManager ParameterSets="@ParameterSets"
|
||||
OnParameterSetSelected="OnParameterSetSelected"
|
||||
OnParameterSetCreated="OnParameterSetCreated"
|
||||
OnParameterSetUpdated="OnParameterSetUpdated"
|
||||
OnParameterSetDeleted="OnParameterSetDeleted" />
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<NavigationParameterSet> ParameterSets { get; set; } = new();
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetSelected { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetCreated { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetUpdated { get; set; }
|
||||
[Parameter] public EventCallback<Guid> OnParameterSetDeleted { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@using RobotNet10.NavigationTuneUI.Components
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="pa-4" Style="height: calc(100vh - 120px); overflow-y: auto;">
|
||||
<VelocityControl />
|
||||
</MudContainer>
|
||||
@@ -0,0 +1,94 @@
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.NavigationTuneUI.Clients
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Components
|
||||
|
||||
<MudGrid Spacing="2" Style="overflow-y: auto; width: 100%; height: calc(100vh - 120px); padding: 8px;">
|
||||
<!-- 1. Test Execution Control -->
|
||||
<MudItem xs="12" md="6">
|
||||
<TestExecutionControl HubClient="@HubClient"
|
||||
AvailableScenarios="@Scenarios"
|
||||
AvailableParameterSets="@ParameterSets"
|
||||
OnStartTest="OnStartTest"
|
||||
OnPauseTest="OnPauseTest"
|
||||
OnResumeTest="OnResumeTest"
|
||||
OnStopTest="OnStopTest"
|
||||
OnEmergencyStop="OnEmergencyStop"
|
||||
OnScenarioSelected="OnScenarioSelected" />
|
||||
</MudItem>
|
||||
|
||||
<!-- 2. Parameter Configuration -->
|
||||
<MudItem xs="12" md="6">
|
||||
<ParameterTuningEditor ParameterSet="@CurrentParameterSet"
|
||||
AvailableParameterSets="@ParameterSets"
|
||||
OnSaveClicked="OnSaveParameters"
|
||||
OnResetClicked="OnResetParameters"
|
||||
OnParameterSetChanged="OnParameterSetChanged" />
|
||||
</MudItem>
|
||||
|
||||
<!-- 3. Telemetry Charts (full width) -->
|
||||
<MudItem xs="12">
|
||||
<TelemetryChartPanel HubClient="@HubClient" TestRunId="@CurrentTestRunId" />
|
||||
</MudItem>
|
||||
|
||||
<!-- 4. Real-Time Monitor -->
|
||||
<MudItem xs="12" md="6">
|
||||
<RealTimeMonitor HubClient="@HubClient" TestRunId="@CurrentTestRunId" />
|
||||
</MudItem>
|
||||
|
||||
<!-- 5. Metrics Visualization -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MetricsVisualization Metrics="@CurrentMetrics" />
|
||||
</MudItem>
|
||||
|
||||
<!-- 6. Tuning Advisor -->
|
||||
@if (TuningReport != null)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<TuningAdvisorPanel Report="@TuningReport"
|
||||
CurrentParameters="@CurrentParameterSet"
|
||||
TestRunId="@_parsedTestRunId"
|
||||
OnApplySuggestions="OnApplySuggestions"
|
||||
OnSaveNewParameterSet="OnSaveNewParameterSet" />
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- 7. Scenario Configuration -->
|
||||
<MudItem xs="12">
|
||||
<ScenarioEditor SelectedScenario="@SelectedScenario"
|
||||
OnScenarioChanged="OnScenarioChanged"
|
||||
OnScenarioCreated="OnScenarioCreated"
|
||||
OnScenarioDeleted="OnScenarioDeleted" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
[Parameter] public TuningHubClient? HubClient { get; set; }
|
||||
[Parameter] public List<TestScenario> Scenarios { get; set; } = new();
|
||||
[Parameter] public List<NavigationParameterSet> ParameterSets { get; set; } = new();
|
||||
[Parameter] public NavigationParameterSet CurrentParameterSet { get; set; } = new();
|
||||
[Parameter] public TestScenario? SelectedScenario { get; set; }
|
||||
[Parameter] public TestMetrics? CurrentMetrics { get; set; }
|
||||
[Parameter] public string? CurrentTestRunId { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<(Guid ScenarioId, Guid ParameterSetId)> OnStartTest { get; set; }
|
||||
[Parameter] public EventCallback OnPauseTest { get; set; }
|
||||
[Parameter] public EventCallback OnResumeTest { get; set; }
|
||||
[Parameter] public EventCallback OnStopTest { get; set; }
|
||||
[Parameter] public EventCallback OnEmergencyStop { get; set; }
|
||||
[Parameter] public EventCallback<TestScenario> OnScenarioSelected { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnSaveParameters { get; set; }
|
||||
[Parameter] public EventCallback OnResetParameters { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetChanged { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<TestScenario> OnScenarioChanged { get; set; }
|
||||
[Parameter] public EventCallback<TestScenario> OnScenarioCreated { get; set; }
|
||||
[Parameter] public EventCallback<Guid> OnScenarioDeleted { get; set; }
|
||||
|
||||
[Parameter] public TuningReport? TuningReport { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnApplySuggestions { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnSaveNewParameterSet { get; set; }
|
||||
|
||||
private Guid? _parsedTestRunId => Guid.TryParse(CurrentTestRunId, out var id) ? id : null;
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Services
|
||||
@using RobotNet10.NavigationTuneUI.Helpers
|
||||
@using MudBlazor
|
||||
|
||||
<MudCard Style="width: 100%; height: 600px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
|
||||
<MudCardHeader Style="box-sizing: border-box; flex-shrink: 0;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Parameter Sets Manager</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Manage your parameter configurations</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
Color="Color.Success"
|
||||
OnClick="OpenCreateDialog">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-1" />
|
||||
New Parameter Set
|
||||
</MudButton>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
@if (ParameterSets.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense>
|
||||
No parameter sets found. Click "New Parameter Set" to create one.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Hover="true" Dense="true" FixedHeader="true" Style="height: 490px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Created</th>
|
||||
<th>Version</th>
|
||||
<th>Default</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var paramSet in ParameterSets)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<MudText Typo="Typo.body1">@paramSet.Name</MudText>
|
||||
</td>
|
||||
<td>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
@(string.IsNullOrEmpty(paramSet.Description) ? "-" : paramSet.Description)
|
||||
</MudText>
|
||||
</td>
|
||||
<td>
|
||||
<MudText Typo="Typo.body2">
|
||||
@paramSet.CreatedAt.ToString("yyyy-MM-dd HH:mm")
|
||||
</MudText>
|
||||
</td>
|
||||
<td>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Info">v@paramSet.Version</MudChip>
|
||||
</td>
|
||||
<td>
|
||||
@if (paramSet.IsDefault)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">Default</MudChip>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<MudStack Row="true" Spacing="1">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => OnSelectParameterSet(paramSet))"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => DuplicateParameterSet(paramSet))"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => DeleteParameterSet(paramSet))"
|
||||
Disabled="@paramSet.IsDefault" />
|
||||
</MudStack>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<MudDialog @bind-Visible="@_isCreateDialogVisible">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_editingParameterSet?.Id == Guid.Empty ? "Create New" : "Edit") Parameter Set</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="@_editingParameterSet.Name"
|
||||
Label="Name"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Name is required" />
|
||||
<MudTextField @bind-Value="@_editingParameterSet.Description"
|
||||
Label="Description"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3" />
|
||||
<MudCheckBox @bind-Value="@_editingParameterSet.IsDefault"
|
||||
Label="Set as default parameter set" />
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _isCreateDialogVisible = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveParameterSetDialog">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<MudDialog @bind-Visible="@_isDeleteDialogVisible">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Delete Parameter Set</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText>Are you sure you want to delete parameter set "@_parameterSetToDelete?.Name"?</MudText>
|
||||
<MudAlert Severity="Severity.Warning" Class="mt-3">
|
||||
This action cannot be undone.
|
||||
</MudAlert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _isDeleteDialogVisible = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="ConfirmDelete">Delete</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<NavigationParameterSet> ParameterSets { get; set; } = new();
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetSelected { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetCreated { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetUpdated { get; set; }
|
||||
[Parameter] public EventCallback<Guid> OnParameterSetDeleted { get; set; }
|
||||
|
||||
[Inject] private TuningApiService ApiService { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private bool _isCreateDialogVisible = false;
|
||||
private bool _isDeleteDialogVisible = false;
|
||||
private NavigationParameterSet _editingParameterSet = new();
|
||||
private NavigationParameterSet? _parameterSetToDelete;
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
_editingParameterSet = new NavigationParameterSet
|
||||
{
|
||||
Id = Guid.Empty,
|
||||
Name = $"Parameter Set {DateTime.Now:yyyyMMdd_HHmmss}",
|
||||
Description = "",
|
||||
IsDefault = false,
|
||||
Version = 1
|
||||
};
|
||||
_isCreateDialogVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task SaveParameterSetDialog()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_editingParameterSet.Name))
|
||||
{
|
||||
Snackbar.Add("Tên parameter set không được để trống", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingParameterSet.Id == Guid.Empty)
|
||||
{
|
||||
// Create new
|
||||
var created = await ApiService.CreateParameterSetAsync(_editingParameterSet);
|
||||
await OnParameterSetCreated.InvokeAsync(created);
|
||||
Snackbar.Add($"Đã tạo parameter set: {created.Name}", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing
|
||||
await ApiService.UpdateParameterSetAsync(_editingParameterSet.Id, _editingParameterSet);
|
||||
await OnParameterSetUpdated.InvokeAsync(_editingParameterSet);
|
||||
Snackbar.Add($"Đã cập nhật parameter set: {_editingParameterSet.Name}", Severity.Success);
|
||||
}
|
||||
|
||||
_isCreateDialogVisible = false;
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSelectParameterSet(NavigationParameterSet parameterSet)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OnParameterSetSelected.HasDelegate)
|
||||
{
|
||||
await OnParameterSetSelected.InvokeAsync(parameterSet);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("OnParameterSetSelected event không được bind", Severity.Warning);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi chọn parameter set: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void DuplicateParameterSet(NavigationParameterSet source)
|
||||
{
|
||||
try
|
||||
{
|
||||
_editingParameterSet = CloneParameterSet(source);
|
||||
_editingParameterSet.Id = Guid.Empty; // New ID
|
||||
_editingParameterSet.Name = $"{source.Name} (Copy)";
|
||||
_editingParameterSet.Description = source.Description;
|
||||
_editingParameterSet.CreatedAt = DateTime.UtcNow;
|
||||
_editingParameterSet.UpdatedAt = null;
|
||||
_editingParameterSet.IsDefault = false;
|
||||
_isCreateDialogVisible = true;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi duplicate parameter set: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteParameterSet(NavigationParameterSet parameterSet)
|
||||
{
|
||||
try
|
||||
{
|
||||
_parameterSetToDelete = parameterSet;
|
||||
_isDeleteDialogVisible = true;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi xóa parameter set: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfirmDelete()
|
||||
{
|
||||
if (_parameterSetToDelete == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await ApiService.DeleteParameterSetAsync(_parameterSetToDelete.Id);
|
||||
await OnParameterSetDeleted.InvokeAsync(_parameterSetToDelete.Id);
|
||||
Snackbar.Add($"Đã xóa parameter set: {_parameterSetToDelete.Name}", Severity.Success);
|
||||
_isDeleteDialogVisible = false;
|
||||
_parameterSetToDelete = null;
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private NavigationParameterSet CloneParameterSet(NavigationParameterSet source)
|
||||
{
|
||||
return new NavigationParameterSet
|
||||
{
|
||||
Name = source.Name,
|
||||
Description = source.Description,
|
||||
ControllerType = source.ControllerType,
|
||||
MovePidConfig = new PIDConfig
|
||||
{
|
||||
Kp = source.MovePidConfig.Kp,
|
||||
Ki = source.MovePidConfig.Ki,
|
||||
Kd = source.MovePidConfig.Kd
|
||||
},
|
||||
RotatePidConfig = new PIDConfig
|
||||
{
|
||||
Kp = source.RotatePidConfig.Kp,
|
||||
Ki = source.RotatePidConfig.Ki,
|
||||
Kd = source.RotatePidConfig.Kd
|
||||
},
|
||||
PurePursuitConfig = new PurePursuitConfig
|
||||
{
|
||||
LookaheadMin = source.PurePursuitConfig.LookaheadMin,
|
||||
LookaheadMax = source.PurePursuitConfig.LookaheadMax,
|
||||
Kdd = source.PurePursuitConfig.Kdd,
|
||||
MaxAngularVelocity = source.PurePursuitConfig.MaxAngularVelocity,
|
||||
ResolutionSplit = source.PurePursuitConfig.ResolutionSplit,
|
||||
FinalApproachThreshold = source.PurePursuitConfig.FinalApproachThreshold,
|
||||
HeadingTolerance = source.PurePursuitConfig.HeadingTolerance,
|
||||
GoalRegionDistance = source.PurePursuitConfig.GoalRegionDistance,
|
||||
KCurvature = source.PurePursuitConfig.KCurvature,
|
||||
MinLookaheadTimeRatio = source.PurePursuitConfig.MinLookaheadTimeRatio,
|
||||
MaxLookaheadTimeRatio = source.PurePursuitConfig.MaxLookaheadTimeRatio
|
||||
},
|
||||
StanleyConfig = new StanleyConfig
|
||||
{
|
||||
K = source.StanleyConfig.K,
|
||||
Ks = source.StanleyConfig.Ks,
|
||||
WheelBase = source.StanleyConfig.WheelBase,
|
||||
MaxSteeringAngle = source.StanleyConfig.MaxSteeringAngle,
|
||||
EnableCurvatureFeedforward = source.StanleyConfig.EnableCurvatureFeedforward,
|
||||
KCurvatureFF = source.StanleyConfig.KCurvatureFF,
|
||||
GoalTolerance = source.StanleyConfig.GoalTolerance,
|
||||
HeadingTolerance = source.StanleyConfig.HeadingTolerance,
|
||||
ResolutionSplit = source.StanleyConfig.ResolutionSplit,
|
||||
GoalApproachDistance = source.StanleyConfig.GoalApproachDistance,
|
||||
GoalGainMultiplier = source.StanleyConfig.GoalGainMultiplier,
|
||||
LowSpeedThreshold = source.StanleyConfig.LowSpeedThreshold,
|
||||
LowSpeedAngularGain = source.StanleyConfig.LowSpeedAngularGain
|
||||
},
|
||||
EstimatorConfig = new VelocityEstimatorConfig
|
||||
{
|
||||
MinBlendRatio = source.EstimatorConfig.MinBlendRatio,
|
||||
MaxBlendRatio = source.EstimatorConfig.MaxBlendRatio,
|
||||
DefaultBlendRatio = source.EstimatorConfig.DefaultBlendRatio,
|
||||
GoodTrackingBlend = source.EstimatorConfig.GoodTrackingBlend,
|
||||
ModerateTrackingBlend = source.EstimatorConfig.ModerateTrackingBlend,
|
||||
PoorTrackingBlend = source.EstimatorConfig.PoorTrackingBlend,
|
||||
GoodTrackingThreshold = source.EstimatorConfig.GoodTrackingThreshold,
|
||||
ModerateTrackingThreshold = source.EstimatorConfig.ModerateTrackingThreshold,
|
||||
ConfidenceDecayRate = source.EstimatorConfig.ConfidenceDecayRate,
|
||||
MinConfidence = source.EstimatorConfig.MinConfidence
|
||||
},
|
||||
SignalConfig = new VelocitySignalProcessingConfig
|
||||
{
|
||||
AlphaFilter = source.SignalConfig.AlphaFilter,
|
||||
NoiseThreshold = source.SignalConfig.NoiseThreshold
|
||||
},
|
||||
MotorDynamicsConfig = new MotorDynamicsConfig
|
||||
{
|
||||
Tau = source.MotorDynamicsConfig.Tau,
|
||||
Delta = source.MotorDynamicsConfig.Delta
|
||||
},
|
||||
NavigationConfig = new NavigationConfig
|
||||
{
|
||||
MaxLinearVelocity = source.NavigationConfig.MaxLinearVelocity,
|
||||
MinLinearVelocity = source.NavigationConfig.MinLinearVelocity,
|
||||
MaxAngularVelocity = source.NavigationConfig.MaxAngularVelocity,
|
||||
RotateAngularVelocity = source.NavigationConfig.RotateAngularVelocity,
|
||||
ReachedRadius = source.NavigationConfig.ReachedRadius,
|
||||
InitialRotationThreshold = source.NavigationConfig.InitialRotationThreshold,
|
||||
Acceleration = source.NavigationConfig.Acceleration,
|
||||
Deceleration = source.NavigationConfig.Deceleration
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using MudBlazor
|
||||
|
||||
<MudCard Style="width: 100%; height: 400px; ">
|
||||
<MudCardHeader Class="pb-0" Style="flex-shrink: 0; box-sizing: border-box;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Parameter Configuration</MudText>
|
||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||
<MudSelect T="Guid"
|
||||
Label="Parameter Set"
|
||||
Variant="Variant.Outlined"
|
||||
Dense="true"
|
||||
Margin="Margin.Dense"
|
||||
Value="@ParameterSet.Id"
|
||||
ValueChanged="@HandleParameterSetSelectionChanged"
|
||||
Style="width: 50px;">
|
||||
<MudSelectItem Value="@Guid.Empty">New Parameter Set</MudSelectItem>
|
||||
@foreach (var paramSet in AvailableParameterSets)
|
||||
{
|
||||
<MudSelectItem Value="@paramSet.Id">@paramSet.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="PathFollowingController" @bind-Value="@ParameterSet.ControllerType"
|
||||
Label="Controller Type"
|
||||
Variant="Variant.Outlined"
|
||||
Dense="true"
|
||||
Margin="Margin.Dense"
|
||||
Style="min-width: 140px;">
|
||||
<MudSelectItem T="PathFollowingController" Value="@PathFollowingController.PurePursuit">Pure Pursuit</MudSelectItem>
|
||||
<MudSelectItem T="PathFollowingController" Value="@PathFollowingController.Stanley">Stanley</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<div class="aligns-item-center mt-2">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" OnClick="OnSave">Save</MudButton>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="OnReset">Reset</MudButton>
|
||||
</div>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0" Style="flex: 1; overflow: hidden; box-sizing: border-box;">
|
||||
<MudTabs Elevation="0">
|
||||
<MudTabPanel Text="PID Controllers">
|
||||
<MudGrid Spacing="1" Style="overflow-y:auto; height: 200px">
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">Move PID</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.MovePidConfig.Kp"
|
||||
Label="Kp"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="100"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.MovePidConfig.Ki"
|
||||
Label="Ki"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="1"
|
||||
Step="0.0001"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.MovePidConfig.Kd"
|
||||
Label="Kd"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="10"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">Rotate PID</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.RotatePidConfig.Kp"
|
||||
Label="Kp"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="100"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.RotatePidConfig.Ki"
|
||||
Label="Ki"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="1"
|
||||
Step="0.001"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.RotatePidConfig.Kd"
|
||||
Label="Kd"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="10"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Pure Pursuit">
|
||||
<MudGrid Spacing="1" Class="mt-1" Style="overflow-y:auto; height: 200px">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.LookaheadMin"
|
||||
Label="Lookahead Min (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.LookaheadMax"
|
||||
Label="Lookahead Max (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="10.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.Kdd"
|
||||
Label="Kdd"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.MaxAngularVelocity"
|
||||
Label="Max Angular Velocity (rad/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="10.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.ResolutionSplit"
|
||||
Label="Resolution Split (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="0.5"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Distance between interpolated points
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.FinalApproachThreshold"
|
||||
Label="Final Approach Threshold (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Distance to goal to switch to final approach controller
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.HeadingTolerance"
|
||||
Label="Heading Tolerance (deg)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="15.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Heading accuracy for goal reached
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">Adaptive lookahead</MudText>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.GoalRegionDistance"
|
||||
Label="Goal Region Distance (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Start reducing lookahead at this distance from goal
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.KCurvature"
|
||||
Label="K Curvature"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Curvature sensitivity (higher = more reduction on curves)
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.MinLookaheadTimeRatio"
|
||||
Label="Min Lookahead Time Ratio"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Min lookahead = velocity × this ratio (s)
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.MaxLookaheadTimeRatio"
|
||||
Label="Max Lookahead Time Ratio"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Max lookahead = velocity × this ratio (s)
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Velocity Estimator">
|
||||
<MudGrid Spacing="1" Class="mt-1" Style="height: 200px; overflow-y:auto">
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Blend Ratios</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.MinBlendRatio"
|
||||
Label="Min Blend"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.MaxBlendRatio"
|
||||
Label="Max Blend"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.DefaultBlendRatio"
|
||||
Label="Default Blend"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Tracking Thresholds</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.GoodTrackingThreshold"
|
||||
Label="Good Threshold (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.ModerateTrackingThreshold"
|
||||
Label="Moderate Threshold (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="2.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Tracking Blend Ratios</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.GoodTrackingBlend"
|
||||
Label="Good Tracking Blend"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.ModerateTrackingBlend"
|
||||
Label="Moderate Tracking Blend"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.PoorTrackingBlend"
|
||||
Label="Poor Tracking Blend"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Confidence Settings</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.ConfidenceDecayRate"
|
||||
Label="Confidence Decay Rate"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.MinConfidence"
|
||||
Label="Min Confidence"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Signal Processing</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.SignalConfig.AlphaFilter"
|
||||
Label="Alpha Filter"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.SignalConfig.NoiseThreshold"
|
||||
Label="Noise Threshold"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Motor Dynamics">
|
||||
<MudGrid Spacing="1" Class="mt-1" Style="overflow-y:auto; height: 200px">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.MotorDynamicsConfig.Tau"
|
||||
Label="Time Constant τ (s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="2.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Time to reach 63.2% of target velocity
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.MotorDynamicsConfig.Delta"
|
||||
Label="Pure Delay δ (s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.0"
|
||||
Max="0.5"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Delay before motor starts responding
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Navigation Limits">
|
||||
<MudGrid Spacing="1" Class="mt-1" Style="overflow-y:auto; height: 200px">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.MaxLinearVelocity"
|
||||
Label="Max Linear Velocity (m/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.MinLinearVelocity"
|
||||
Label="Min Linear Velocity (m/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.MaxAngularVelocity"
|
||||
Label="Max Angular Velocity (rad/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="10.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.RotateAngularVelocity"
|
||||
Label="Rotate Angular Velocity (rad/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.ReachedRadius"
|
||||
Label="Reached Radius (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="0.5"
|
||||
Step="0.001"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Goal reached tolerance
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.InitialRotationThreshold"
|
||||
Label="Initial Rotation Threshold (deg)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="90.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Min heading error to rotate in place before moving
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.Acceleration"
|
||||
Label="Acceleration (m/s²)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Step="0.1"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
How quickly the robot is allowed to reach target linear speed
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.Deceleration"
|
||||
Label="Deceleration (m/s²)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Step="0.1"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
How quickly the robot is allowed to slow down / stop
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Stanley">
|
||||
<MudGrid Spacing="1" Class="mt-1" Style="overflow-y:auto; height: 200px">
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Chỉ áp dụng khi Controller Type = Stanley</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.K"
|
||||
Label="K (cross-track gain)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="10.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.Ks"
|
||||
Label="Ks (softening, m/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="1.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.WheelBase"
|
||||
Label="Wheelbase (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="3.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.MaxSteeringAngle"
|
||||
Label="Max Steering Angle (rad)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="1.5"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSwitch T="bool" @bind-Value="@ParameterSet.StanleyConfig.EnableCurvatureFeedforward"
|
||||
Label="Curvature feedforward"
|
||||
Color="Color.Primary" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.KCurvatureFF"
|
||||
Label="K Curvature FF"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="2.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.GoalTolerance"
|
||||
Label="Goal Tolerance (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="0.2"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.HeadingTolerance"
|
||||
Label="Heading Tolerance (deg)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="30.0"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.ResolutionSplit"
|
||||
Label="Resolution Split (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="0.2"
|
||||
Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">Goal approach</MudText>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.GoalApproachDistance"
|
||||
Label="Goal Approach Distance (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Distance to start increasing K near goal</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.GoalGainMultiplier"
|
||||
Label="Goal Gain Multiplier"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">K multiplier at goal</MudText>
|
||||
</MudItem>
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">Low speed control</MudText>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.LowSpeedThreshold"
|
||||
Label="Low Speed Threshold (m/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="2.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Below this speed, direct angular control blends in</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.LowSpeedAngularGain"
|
||||
Label="Low Speed Angular Gain"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Max="5.0"
|
||||
Margin="Margin.Dense" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Angular gain at low speed</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter] public NavigationParameterSet ParameterSet { get; set; } = new();
|
||||
[Parameter] public List<NavigationParameterSet> AvailableParameterSets { get; set; } = new();
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnSaveClicked { get; set; }
|
||||
[Parameter] public EventCallback OnResetClicked { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetChanged { get; set; }
|
||||
|
||||
private NavigationParameterSet _originalParameterSet = new();
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ParameterSet != null)
|
||||
{
|
||||
_originalParameterSet = CloneParameterSet(ParameterSet);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleParameterSetSelectionChanged(Guid parameterSetId)
|
||||
{
|
||||
if (parameterSetId == Guid.Empty)
|
||||
{
|
||||
// New parameter set
|
||||
var newSet = new NavigationParameterSet
|
||||
{
|
||||
Id = Guid.Empty,
|
||||
Name = $"Parameter Set {DateTime.Now:yyyyMMdd_HHmmss}",
|
||||
Description = "",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await OnParameterSetChanged.InvokeAsync(newSet);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Load existing parameter set
|
||||
var selected = AvailableParameterSets.FirstOrDefault(p => p.Id == parameterSetId);
|
||||
if (selected != null)
|
||||
{
|
||||
await OnParameterSetChanged.InvokeAsync(selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSave()
|
||||
{
|
||||
OnSaveClicked.InvokeAsync(ParameterSet);
|
||||
}
|
||||
|
||||
private void OnReset()
|
||||
{
|
||||
ParameterSet = CloneParameterSet(_originalParameterSet);
|
||||
OnResetClicked.InvokeAsync();
|
||||
}
|
||||
|
||||
private NavigationParameterSet CloneParameterSet(NavigationParameterSet source)
|
||||
{
|
||||
// Simple clone - in production, use proper deep cloning
|
||||
return new NavigationParameterSet
|
||||
{
|
||||
Id = source.Id,
|
||||
Name = source.Name,
|
||||
Description = source.Description,
|
||||
ControllerType = source.ControllerType,
|
||||
MovePidConfig = new PIDConfig
|
||||
{
|
||||
Kp = source.MovePidConfig.Kp,
|
||||
Ki = source.MovePidConfig.Ki,
|
||||
Kd = source.MovePidConfig.Kd
|
||||
},
|
||||
RotatePidConfig = new PIDConfig
|
||||
{
|
||||
Kp = source.RotatePidConfig.Kp,
|
||||
Ki = source.RotatePidConfig.Ki,
|
||||
Kd = source.RotatePidConfig.Kd
|
||||
},
|
||||
PurePursuitConfig = new PurePursuitConfig
|
||||
{
|
||||
LookaheadMin = source.PurePursuitConfig.LookaheadMin,
|
||||
LookaheadMax = source.PurePursuitConfig.LookaheadMax,
|
||||
Kdd = source.PurePursuitConfig.Kdd,
|
||||
MaxAngularVelocity = source.PurePursuitConfig.MaxAngularVelocity,
|
||||
ResolutionSplit = source.PurePursuitConfig.ResolutionSplit,
|
||||
FinalApproachThreshold = source.PurePursuitConfig.FinalApproachThreshold,
|
||||
HeadingTolerance = source.PurePursuitConfig.HeadingTolerance,
|
||||
GoalRegionDistance = source.PurePursuitConfig.GoalRegionDistance,
|
||||
KCurvature = source.PurePursuitConfig.KCurvature,
|
||||
MinLookaheadTimeRatio = source.PurePursuitConfig.MinLookaheadTimeRatio,
|
||||
MaxLookaheadTimeRatio = source.PurePursuitConfig.MaxLookaheadTimeRatio
|
||||
},
|
||||
StanleyConfig = new StanleyConfig
|
||||
{
|
||||
K = source.StanleyConfig.K,
|
||||
Ks = source.StanleyConfig.Ks,
|
||||
WheelBase = source.StanleyConfig.WheelBase,
|
||||
MaxSteeringAngle = source.StanleyConfig.MaxSteeringAngle,
|
||||
EnableCurvatureFeedforward = source.StanleyConfig.EnableCurvatureFeedforward,
|
||||
KCurvatureFF = source.StanleyConfig.KCurvatureFF,
|
||||
GoalTolerance = source.StanleyConfig.GoalTolerance,
|
||||
HeadingTolerance = source.StanleyConfig.HeadingTolerance,
|
||||
ResolutionSplit = source.StanleyConfig.ResolutionSplit,
|
||||
GoalApproachDistance = source.StanleyConfig.GoalApproachDistance,
|
||||
GoalGainMultiplier = source.StanleyConfig.GoalGainMultiplier,
|
||||
LowSpeedThreshold = source.StanleyConfig.LowSpeedThreshold,
|
||||
LowSpeedAngularGain = source.StanleyConfig.LowSpeedAngularGain
|
||||
},
|
||||
EstimatorConfig = new VelocityEstimatorConfig
|
||||
{
|
||||
MinBlendRatio = source.EstimatorConfig.MinBlendRatio,
|
||||
MaxBlendRatio = source.EstimatorConfig.MaxBlendRatio,
|
||||
DefaultBlendRatio = source.EstimatorConfig.DefaultBlendRatio,
|
||||
GoodTrackingThreshold = source.EstimatorConfig.GoodTrackingThreshold,
|
||||
ModerateTrackingThreshold = source.EstimatorConfig.ModerateTrackingThreshold,
|
||||
GoodTrackingBlend = source.EstimatorConfig.GoodTrackingBlend,
|
||||
ModerateTrackingBlend = source.EstimatorConfig.ModerateTrackingBlend,
|
||||
PoorTrackingBlend = source.EstimatorConfig.PoorTrackingBlend,
|
||||
ConfidenceDecayRate = source.EstimatorConfig.ConfidenceDecayRate,
|
||||
MinConfidence = source.EstimatorConfig.MinConfidence
|
||||
},
|
||||
SignalConfig = new VelocitySignalProcessingConfig
|
||||
{
|
||||
AlphaFilter = source.SignalConfig.AlphaFilter,
|
||||
NoiseThreshold = source.SignalConfig.NoiseThreshold
|
||||
},
|
||||
MotorDynamicsConfig = new MotorDynamicsConfig
|
||||
{
|
||||
Tau = source.MotorDynamicsConfig.Tau,
|
||||
Delta = source.MotorDynamicsConfig.Delta
|
||||
},
|
||||
NavigationConfig = new NavigationConfig
|
||||
{
|
||||
MaxLinearVelocity = source.NavigationConfig.MaxLinearVelocity,
|
||||
MinLinearVelocity = source.NavigationConfig.MinLinearVelocity,
|
||||
MaxAngularVelocity = source.NavigationConfig.MaxAngularVelocity,
|
||||
RotateAngularVelocity = source.NavigationConfig.RotateAngularVelocity,
|
||||
ReachedRadius = source.NavigationConfig.ReachedRadius,
|
||||
InitialRotationThreshold = source.NavigationConfig.InitialRotationThreshold,
|
||||
Acceleration = source.NavigationConfig.Acceleration,
|
||||
Deceleration = source.NavigationConfig.Deceleration
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Helpers
|
||||
@using MudBlazor
|
||||
|
||||
<MudCard Style="width: 100%; height: 442px; display: flex; flex-direction: column; overflow: hidden;">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Path Editor</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Create custom path with edges</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="AddEdge">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-1" />
|
||||
Add Edge
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="ClearPath" Color="Color.Warning">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Clear" Class="mr-1" />
|
||||
Clear All
|
||||
</MudButton>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="flex: 1; overflow-x: auto; overflow-y: auto;">
|
||||
<MudGrid Spacing="3">
|
||||
<!-- Edges List -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">Edges (@Edges.Count)</MudText>
|
||||
@if (Edges.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense>
|
||||
No edges. Click "Add Edge" to start creating a path.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudExpansionPanels>
|
||||
@for (int i = 0; i < Edges.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var edge = Edges[index];
|
||||
<MudExpansionPanel Text="@($"Edge {index + 1} (Degree {edge.Degree})")">
|
||||
<MudGrid Spacing="2">
|
||||
<!-- Degree Selection -->
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="int"
|
||||
Label="Curve Degree"
|
||||
Variant="Variant.Outlined"
|
||||
Value="@edge.Degree"
|
||||
ValueChanged="@(val => UpdateEdgeDegree(index, val))">
|
||||
<MudSelectItem Value="1">1 - Linear (Straight Line)</MudSelectItem>
|
||||
<MudSelectItem Value="2">2 - Quadratic Bezier</MudSelectItem>
|
||||
<MudSelectItem Value="3">3 - Cubic Bezier</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
<!-- Direction Selection -->
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="RobotDirection"
|
||||
Label="Movement Direction"
|
||||
Variant="Variant.Outlined"
|
||||
Value="@edge.Direction"
|
||||
ValueChanged="@(val => UpdateEdgeDirection(index, val))">
|
||||
<MudSelectItem Value="@RobotDirection.FORWARD">Forward</MudSelectItem>
|
||||
<MudSelectItem Value="@RobotDirection.BACKWARD">Backward</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
<!-- Start Point -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Start Point</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double"
|
||||
Value="@edge.StartX"
|
||||
ValueChanged="@(val => UpdateEdgeStartX(index, val))"
|
||||
Label="Start X (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double"
|
||||
Value="@edge.StartY"
|
||||
ValueChanged="@(val => UpdateEdgeStartY(index, val))"
|
||||
Label="Start Y (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.1" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- End Point -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">End Point</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double"
|
||||
Value="@edge.EndX"
|
||||
ValueChanged="@(val => UpdateEdgeEndX(index, val))"
|
||||
Label="End X (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double"
|
||||
Value="@edge.EndY"
|
||||
ValueChanged="@(val => UpdateEdgeEndY(index, val))"
|
||||
Label="End Y (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.1" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- Control Point 1 (for Degree 2 and 3) -->
|
||||
@if (edge.Degree >= 2)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Control Point 1</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double?"
|
||||
Value="@edge.ControlPoint1X"
|
||||
ValueChanged="@(val => UpdateEdgeControl1X(index, val))"
|
||||
Label="CP1 X (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double?"
|
||||
Value="@edge.ControlPoint1Y"
|
||||
ValueChanged="@(val => UpdateEdgeControl1Y(index, val))"
|
||||
Label="CP1 Y (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.1" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- Control Point 2 (for Degree 3 only) -->
|
||||
@if (edge.Degree == 3)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Control Point 2</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double?"
|
||||
Value="@edge.ControlPoint2X"
|
||||
ValueChanged="@(val => UpdateEdgeControl2X(index, val))"
|
||||
Label="CP2 X (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudNumericField T="double?"
|
||||
Value="@edge.ControlPoint2Y"
|
||||
ValueChanged="@(val => UpdateEdgeControl2Y(index, val))"
|
||||
Label="CP2 Y (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.1" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- Edge Actions -->
|
||||
<MudItem xs="12">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => RemoveEdge(index))">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Delete" Class="mr-1" />
|
||||
Delete Edge
|
||||
</MudButton>
|
||||
@if (index > 0)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => MoveEdgeUp(index))">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowUpward" Class="mr-1" />
|
||||
Move Up
|
||||
</MudButton>
|
||||
}
|
||||
@if (index < Edges.Count - 1)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => MoveEdgeDown(index))">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Class="mr-1" />
|
||||
Move Down
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
</MudItem>
|
||||
|
||||
<!-- Path Configuration -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">Path Configuration</MudText>
|
||||
<MudStack Spacing="3">
|
||||
<MudNumericField T="double"
|
||||
Value="@Resolution"
|
||||
ValueChanged="@(val => UpdateResolution(val))"
|
||||
Label="Resolution (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.01"
|
||||
Min="0.01"
|
||||
Max="0.5"
|
||||
HelperText="Distance between interpolated points" />
|
||||
|
||||
<MudDivider />
|
||||
|
||||
<MudText Typo="Typo.body2" Class="mb-2">Path Statistics</MudText>
|
||||
<MudAlert Severity="Severity.Info" Dense>
|
||||
<MudText Typo="Typo.body2">
|
||||
Total Edges: @Edges.Count<br/>
|
||||
Estimated Path Length: @GetPathLength():F2 m<br/>
|
||||
Estimated Points: ~@GetEstimatedPointCount()
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudDivider Class="my-2" />
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">Quick Actions</MudText>
|
||||
<MudStack Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
OnClick="AddStraightLine">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ShowChart" Class="mr-1" />
|
||||
Add Straight Line (5m)
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
OnClick="AddQuadraticCurve">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="mr-1" />
|
||||
Add Quadratic Curve
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
OnClick="AddCubicCurve">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="mr-1" />
|
||||
Add Cubic Curve
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter] public CustomPathScenario? Scenario { get; set; }
|
||||
[Parameter] public EventCallback<CustomPathScenario> OnScenarioChanged { get; set; }
|
||||
|
||||
private List<PathEdge> Edges { get; set; } = new();
|
||||
private double Resolution { get; set; } = 0.05;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Scenario != null)
|
||||
{
|
||||
Edges = new List<PathEdge>(Scenario.Edges);
|
||||
Resolution = Scenario.Resolution;
|
||||
}
|
||||
else
|
||||
{
|
||||
Edges = new List<PathEdge>();
|
||||
Resolution = 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (firstRender && Scenario != null)
|
||||
{
|
||||
Edges = new List<PathEdge>(Scenario.Edges);
|
||||
Resolution = Scenario.Resolution;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddEdge()
|
||||
{
|
||||
double startX = 0, startY = 0, endX = 1, endY = 0;
|
||||
|
||||
// If there are existing edges, connect to the last edge's end
|
||||
if (Edges.Count > 0)
|
||||
{
|
||||
var lastEdge = Edges[^1];
|
||||
startX = lastEdge.EndX;
|
||||
startY = lastEdge.EndY;
|
||||
endX = startX + 1.0; // Default: 1m forward
|
||||
endY = startY;
|
||||
}
|
||||
|
||||
Edges.Add(new PathEdge
|
||||
{
|
||||
StartX = startX,
|
||||
StartY = startY,
|
||||
EndX = endX,
|
||||
EndY = endY,
|
||||
Degree = 1, // Default to linear
|
||||
Direction = RobotDirection.FORWARD // Default to forward
|
||||
});
|
||||
UpdateScenario();
|
||||
}
|
||||
|
||||
private void RemoveEdge(int index)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count)
|
||||
{
|
||||
Edges.RemoveAt(index);
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveEdgeUp(int index)
|
||||
{
|
||||
if (index > 0 && index < Edges.Count)
|
||||
{
|
||||
var temp = Edges[index];
|
||||
Edges[index] = Edges[index - 1];
|
||||
Edges[index - 1] = temp;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveEdgeDown(int index)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count - 1)
|
||||
{
|
||||
var temp = Edges[index];
|
||||
Edges[index] = Edges[index + 1];
|
||||
Edges[index + 1] = temp;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeDegree(int index, int? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count && value.HasValue)
|
||||
{
|
||||
Edges[index].Degree = Math.Clamp(value.Value, 1, 3);
|
||||
|
||||
// Clear control points if degree is reduced
|
||||
if (Edges[index].Degree < 2)
|
||||
{
|
||||
Edges[index].ControlPoint1X = null;
|
||||
Edges[index].ControlPoint1Y = null;
|
||||
}
|
||||
if (Edges[index].Degree < 3)
|
||||
{
|
||||
Edges[index].ControlPoint2X = null;
|
||||
Edges[index].ControlPoint2Y = null;
|
||||
}
|
||||
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeDirection(int index, RobotDirection? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count && value.HasValue)
|
||||
{
|
||||
Edges[index].Direction = value.Value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeStartX(int index, double? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count && value.HasValue)
|
||||
{
|
||||
Edges[index].StartX = value.Value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeStartY(int index, double? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count && value.HasValue)
|
||||
{
|
||||
Edges[index].StartY = value.Value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeEndX(int index, double? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count && value.HasValue)
|
||||
{
|
||||
Edges[index].EndX = value.Value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeEndY(int index, double? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count && value.HasValue)
|
||||
{
|
||||
Edges[index].EndY = value.Value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeControl1X(int index, double? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count)
|
||||
{
|
||||
Edges[index].ControlPoint1X = value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeControl1Y(int index, double? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count)
|
||||
{
|
||||
Edges[index].ControlPoint1Y = value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeControl2X(int index, double? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count)
|
||||
{
|
||||
Edges[index].ControlPoint2X = value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEdgeControl2Y(int index, double? value)
|
||||
{
|
||||
if (index >= 0 && index < Edges.Count)
|
||||
{
|
||||
Edges[index].ControlPoint2Y = value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateResolution(double? value)
|
||||
{
|
||||
if (value.HasValue)
|
||||
{
|
||||
Resolution = value.Value;
|
||||
UpdateScenario();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearPath()
|
||||
{
|
||||
Edges.Clear();
|
||||
UpdateScenario();
|
||||
}
|
||||
|
||||
private void AddStraightLine()
|
||||
{
|
||||
double startX = 0, startY = 0, endX = 5, endY = 0;
|
||||
|
||||
if (Edges.Count > 0)
|
||||
{
|
||||
var lastEdge = Edges[^1];
|
||||
startX = lastEdge.EndX;
|
||||
startY = lastEdge.EndY;
|
||||
endX = startX + 5.0;
|
||||
endY = startY;
|
||||
}
|
||||
|
||||
Edges.Add(new PathEdge
|
||||
{
|
||||
StartX = startX,
|
||||
StartY = startY,
|
||||
EndX = endX,
|
||||
EndY = endY,
|
||||
Degree = 1,
|
||||
Direction = RobotDirection.FORWARD
|
||||
});
|
||||
UpdateScenario();
|
||||
}
|
||||
|
||||
private void AddQuadraticCurve()
|
||||
{
|
||||
double startX = 0, startY = 0, endX = 3, endY = 2;
|
||||
double cp1X = 1.5, cp1Y = 0;
|
||||
|
||||
if (Edges.Count > 0)
|
||||
{
|
||||
var lastEdge = Edges[^1];
|
||||
startX = lastEdge.EndX;
|
||||
startY = lastEdge.EndY;
|
||||
endX = startX + 3.0;
|
||||
endY = startY + 2.0;
|
||||
cp1X = startX + 1.5;
|
||||
cp1Y = startY;
|
||||
}
|
||||
|
||||
Edges.Add(new PathEdge
|
||||
{
|
||||
StartX = startX,
|
||||
StartY = startY,
|
||||
EndX = endX,
|
||||
EndY = endY,
|
||||
Degree = 2,
|
||||
ControlPoint1X = cp1X,
|
||||
ControlPoint1Y = cp1Y,
|
||||
Direction = RobotDirection.FORWARD
|
||||
});
|
||||
UpdateScenario();
|
||||
}
|
||||
|
||||
private void AddCubicCurve()
|
||||
{
|
||||
double startX = 0, startY = 0, endX = 4, endY = 2;
|
||||
double cp1X = 1, cp1Y = 0;
|
||||
double cp2X = 3, cp2Y = 2;
|
||||
|
||||
if (Edges.Count > 0)
|
||||
{
|
||||
var lastEdge = Edges[^1];
|
||||
startX = lastEdge.EndX;
|
||||
startY = lastEdge.EndY;
|
||||
endX = startX + 4.0;
|
||||
endY = startY + 2.0;
|
||||
cp1X = startX + 1.0;
|
||||
cp1Y = startY;
|
||||
cp2X = startX + 3.0;
|
||||
cp2Y = startY + 2.0;
|
||||
}
|
||||
|
||||
Edges.Add(new PathEdge
|
||||
{
|
||||
StartX = startX,
|
||||
StartY = startY,
|
||||
EndX = endX,
|
||||
EndY = endY,
|
||||
Degree = 3,
|
||||
ControlPoint1X = cp1X,
|
||||
ControlPoint1Y = cp1Y,
|
||||
ControlPoint2X = cp2X,
|
||||
ControlPoint2Y = cp2Y,
|
||||
Direction = RobotDirection.FORWARD
|
||||
});
|
||||
UpdateScenario();
|
||||
}
|
||||
|
||||
private double GetPathLength()
|
||||
{
|
||||
if (Edges.Count == 0) return 0.0;
|
||||
|
||||
double totalLength = 0.0;
|
||||
foreach (var edge in Edges)
|
||||
{
|
||||
if (edge.Degree == 1)
|
||||
{
|
||||
double dx = edge.EndX - edge.StartX;
|
||||
double dy = edge.EndY - edge.StartY;
|
||||
totalLength += Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Approximate curve length
|
||||
const int samples = 20;
|
||||
double length = 0.0;
|
||||
double prevX = edge.StartX, prevY = edge.StartY;
|
||||
|
||||
for (int i = 1; i <= samples; i++)
|
||||
{
|
||||
double t = 1.0 * i / samples;
|
||||
double x, y;
|
||||
|
||||
if (edge.Degree == 2)
|
||||
{
|
||||
double oneMinusT = 1.0 - t;
|
||||
x = oneMinusT * oneMinusT * edge.StartX +
|
||||
2 * oneMinusT * t * (edge.ControlPoint1X ?? edge.StartX) +
|
||||
t * t * edge.EndX;
|
||||
y = oneMinusT * oneMinusT * edge.StartY +
|
||||
2 * oneMinusT * t * (edge.ControlPoint1Y ?? edge.StartY) +
|
||||
t * t * edge.EndY;
|
||||
}
|
||||
else // Degree 3
|
||||
{
|
||||
double oneMinusT = 1.0 - t;
|
||||
double oneMinusT2 = oneMinusT * oneMinusT;
|
||||
double oneMinusT3 = oneMinusT2 * oneMinusT;
|
||||
double t2 = t * t;
|
||||
double t3 = t2 * t;
|
||||
|
||||
x = oneMinusT3 * edge.StartX +
|
||||
3 * oneMinusT2 * t * (edge.ControlPoint1X ?? edge.StartX) +
|
||||
3 * oneMinusT * t2 * (edge.ControlPoint2X ?? edge.EndX) +
|
||||
t3 * edge.EndX;
|
||||
y = oneMinusT3 * edge.StartY +
|
||||
3 * oneMinusT2 * t * (edge.ControlPoint1Y ?? edge.StartY) +
|
||||
3 * oneMinusT * t2 * (edge.ControlPoint2Y ?? edge.EndY) +
|
||||
t3 * edge.EndY;
|
||||
}
|
||||
|
||||
double dx = x - prevX;
|
||||
double dy = y - prevY;
|
||||
length += Math.Sqrt(dx * dx + dy * dy);
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
|
||||
totalLength += length;
|
||||
}
|
||||
}
|
||||
|
||||
return totalLength;
|
||||
}
|
||||
|
||||
private int GetEstimatedPointCount()
|
||||
{
|
||||
if (Resolution <= 0) return 0;
|
||||
return (int)(GetPathLength() / Resolution) + Edges.Count;
|
||||
}
|
||||
|
||||
private void UpdateScenario()
|
||||
{
|
||||
if (Scenario != null)
|
||||
{
|
||||
Scenario.Edges = new List<PathEdge>(Edges);
|
||||
Scenario.Resolution = Resolution;
|
||||
OnScenarioChanged.InvokeAsync(Scenario);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Hubs
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Clients
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using MudBlazor
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<MudCard Style="width: 100%; height: 600px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
|
||||
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Real-Time Telemetry</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
@if (IsConnected)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Size="Size.Small">Connected</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Error" Size="Size.Small">Disconnected</MudChip>
|
||||
}
|
||||
</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Size="Size.Small"
|
||||
OnClick="ClearData"
|
||||
Disabled="@(!IsConnected)">
|
||||
</MudIconButton>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;">
|
||||
<MudGrid Spacing="1">
|
||||
<!-- Robot Pose -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">Robot Pose</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">X (m)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.X.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Y (m)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.Y.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Theta (rad)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.Theta.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- Velocity -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">Velocity</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Linear (m/s)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.LinearVelocity.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Angular (rad/s)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.AngularVelocity.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- Errors -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">Tracking Errors</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Cross-Track Error (m)</MudText>
|
||||
<MudText Typo="Typo.h6" Color="@GetErrorColor(CurrentTelemetry?.CrossTrackError ?? 0)">
|
||||
@(CurrentTelemetry?.CrossTrackError.ToString("F4") ?? "0.0000")
|
||||
</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Heading Error (rad)</MudText>
|
||||
<MudText Typo="Typo.h6" Color="@GetErrorColor(CurrentTelemetry?.HeadingError ?? 0)">
|
||||
@(CurrentTelemetry?.HeadingError.ToString("F4") ?? "0.0000")
|
||||
</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- Commands -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">Velocity Commands</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Linear Command (m/s)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.LinearVelocity.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Angular Command (rad/s)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.AngularVelocity.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- Status -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">Status</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Distance to Goal (m)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.DistanceToGoal.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Distance to Goal (m)</MudText>
|
||||
<MudText Typo="Typo.h6">@CurrentTelemetry?.DistanceToGoal.ToString("F3")</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- Data Count -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Total data points: @TelemetryHistory.Count
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter] public TuningHubClient? HubClient { get; set; }
|
||||
[Parameter] public string? TestRunId { get; set; }
|
||||
|
||||
private TelemetryUpdateDto? CurrentTelemetry { get; set; }
|
||||
private List<TelemetryUpdateDto> TelemetryHistory { get; set; } = new();
|
||||
private bool IsConnected => HubClient?.IsConnected ?? false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (HubClient != null)
|
||||
{
|
||||
HubClient.TelemetryUpdated += OnTelemetryUpdated;
|
||||
HubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
if (!string.IsNullOrEmpty(TestRunId))
|
||||
{
|
||||
await HubClient.JoinTestSessionAsync(TestRunId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTelemetryUpdated(TelemetryUpdateDto telemetry)
|
||||
{
|
||||
CurrentTelemetry = telemetry;
|
||||
TelemetryHistory.Add(telemetry);
|
||||
|
||||
// Keep only last 1000 points
|
||||
if (TelemetryHistory.Count > 1000)
|
||||
{
|
||||
TelemetryHistory.RemoveAt(0);
|
||||
}
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private Color GetErrorColor(double error)
|
||||
{
|
||||
var absError = Math.Abs(error);
|
||||
if (absError < 0.05f) return Color.Success;
|
||||
if (absError < 0.15f) return Color.Warning;
|
||||
return Color.Error;
|
||||
}
|
||||
|
||||
private Color GetConfidenceColor(double confidence)
|
||||
{
|
||||
return confidence switch
|
||||
{
|
||||
>= 0.7 => Color.Success,
|
||||
>= 0.4 => Color.Warning,
|
||||
_ => Color.Error
|
||||
};
|
||||
}
|
||||
|
||||
private void ClearData()
|
||||
{
|
||||
TelemetryHistory.Clear();
|
||||
CurrentTelemetry = null;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (HubClient != null)
|
||||
{
|
||||
HubClient.TelemetryUpdated -= OnTelemetryUpdated;
|
||||
HubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
|
||||
if (!string.IsNullOrEmpty(TestRunId))
|
||||
{
|
||||
await HubClient.LeaveTestSessionAsync(TestRunId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Services
|
||||
@using RobotNet10.NavigationTuneUI.Helpers
|
||||
@using RobotNet10.NavigationTuneUI.Components
|
||||
@using MudBlazor
|
||||
@inject TuningApiService ApiService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard Style="width: 100%; height: 600px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
|
||||
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Scenario Configuration</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Custom path editor</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Success" OnClick="CreateCustomPath">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-1" />
|
||||
New Custom Path
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="SaveScenario" Disabled="@(SelectedScenario == null)">
|
||||
Save
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="ResetScenario" Disabled="@(SelectedScenario == null)">
|
||||
Reset
|
||||
</MudButton>
|
||||
@if (SelectedScenario != null && !SelectedScenario.IsDefault)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error" OnClick="OpenDeleteDialog">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Delete" Class="mr-1" />
|
||||
Delete
|
||||
</MudButton>
|
||||
}
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="flex: 1; overflow-x: auto; overflow-y: auto; box-sizing: border-box;">
|
||||
@if (SelectedScenario == null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-2">
|
||||
No scenario selected. Click "New Custom Path" to create one.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2" Class="mb-3">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField T="string" Label="Scenario name" Variant="Variant.Outlined"
|
||||
Value="@SelectedScenario.Name"
|
||||
ValueChanged="@(v => UpdateScenarioName(v))"
|
||||
Immediate="true" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField T="string" Label="Description" Variant="Variant.Outlined"
|
||||
Value="@SelectedScenario.Description"
|
||||
ValueChanged="@(v => UpdateScenarioDescription(v))"
|
||||
Immediate="true" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<PathEditor Scenario="@GetCustomPathScenario()" OnScenarioChanged="HandleCustomPathChanged" />
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<!-- Delete scenario confirmation -->
|
||||
<MudDialog @bind-Visible="_isDeleteDialogVisible">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Delete scenario</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText>Are you sure you want to delete "@SelectedScenario?.Name"? This action cannot be undone.</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _isDeleteDialogVisible = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="ConfirmDeleteScenario">Delete</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[Parameter] public TestScenario? SelectedScenario { get; set; }
|
||||
[Parameter] public EventCallback<TestScenario> OnScenarioChanged { get; set; }
|
||||
[Parameter] public EventCallback<Guid> OnScenarioDeleted { get; set; }
|
||||
[Parameter] public EventCallback<TestScenario> OnScenarioCreated { get; set; }
|
||||
|
||||
private CustomPathScenario? _customPathScenario;
|
||||
private bool _isDeleteDialogVisible;
|
||||
|
||||
private CustomPathScenario? GetCustomPathScenario()
|
||||
{
|
||||
if (SelectedScenario is CustomPathScenario custom)
|
||||
return custom;
|
||||
|
||||
if (_customPathScenario == null)
|
||||
{
|
||||
var id = (SelectedScenario != null && SelectedScenario.Id != Guid.Empty) ? SelectedScenario.Id : Guid.Empty;
|
||||
_customPathScenario = new CustomPathScenario
|
||||
{
|
||||
Id = id,
|
||||
Name = SelectedScenario?.Name ?? "Custom Path",
|
||||
Description = SelectedScenario?.Description ?? "User-defined path",
|
||||
Type = TrajectoryType.Custom,
|
||||
Edges = new List<PathEdge>(),
|
||||
Resolution = 0.05
|
||||
};
|
||||
}
|
||||
|
||||
return _customPathScenario;
|
||||
}
|
||||
|
||||
private void UpdateScenarioName(string? value)
|
||||
{
|
||||
if (SelectedScenario != null)
|
||||
{
|
||||
SelectedScenario.Name = value ?? string.Empty;
|
||||
OnScenarioChanged.InvokeAsync(SelectedScenario);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateScenarioDescription(string? value)
|
||||
{
|
||||
if (SelectedScenario != null)
|
||||
{
|
||||
SelectedScenario.Description = value ?? string.Empty;
|
||||
OnScenarioChanged.InvokeAsync(SelectedScenario);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCustomPathChanged(CustomPathScenario scenario)
|
||||
{
|
||||
SelectedScenario = scenario;
|
||||
await OnScenarioChanged.InvokeAsync(scenario);
|
||||
}
|
||||
|
||||
private async Task SaveScenario()
|
||||
{
|
||||
if (SelectedScenario == null) return;
|
||||
|
||||
var scenarioToSave = GetCustomPathScenario();
|
||||
if (scenarioToSave == null) return;
|
||||
|
||||
// Sync base metadata
|
||||
scenarioToSave.Name = SelectedScenario.Name;
|
||||
scenarioToSave.Description = SelectedScenario.Description;
|
||||
scenarioToSave.Id = SelectedScenario.Id;
|
||||
scenarioToSave.IsDefault = SelectedScenario.IsDefault;
|
||||
|
||||
try
|
||||
{
|
||||
if (scenarioToSave.Id == Guid.Empty)
|
||||
{
|
||||
var created = await ApiService.CreateScenarioAsync(scenarioToSave);
|
||||
SelectedScenario = created;
|
||||
_customPathScenario = created as CustomPathScenario;
|
||||
await OnScenarioChanged.InvokeAsync(created);
|
||||
await OnScenarioCreated.InvokeAsync(created);
|
||||
Snackbar.Add($"Created scenario: {created.Name}", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ApiService.UpdateScenarioAsync(scenarioToSave.Id, scenarioToSave);
|
||||
SelectedScenario = scenarioToSave;
|
||||
_customPathScenario = scenarioToSave;
|
||||
Snackbar.Add($"Updated scenario: {scenarioToSave.Name}", Severity.Success);
|
||||
}
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Cannot save scenario: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error saving scenario: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetScenario()
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
Snackbar.Add("Scenario reset to original values", Severity.Info);
|
||||
}
|
||||
|
||||
private void OpenDeleteDialog()
|
||||
{
|
||||
if (SelectedScenario != null && !SelectedScenario.IsDefault)
|
||||
_isDeleteDialogVisible = true;
|
||||
}
|
||||
|
||||
private async Task ConfirmDeleteScenario()
|
||||
{
|
||||
if (SelectedScenario == null || SelectedScenario.IsDefault) return;
|
||||
var idToDelete = SelectedScenario.Id;
|
||||
try
|
||||
{
|
||||
await ApiService.DeleteScenarioAsync(idToDelete);
|
||||
_isDeleteDialogVisible = false;
|
||||
await OnScenarioDeleted.InvokeAsync(idToDelete);
|
||||
Snackbar.Add("Scenario deleted", Severity.Success);
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Cannot delete scenario: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error deleting scenario: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateCustomPath()
|
||||
{
|
||||
var customPath = new CustomPathScenario
|
||||
{
|
||||
Id = Guid.Empty,
|
||||
Name = $"Custom Path {DateTime.Now:HHmmss}",
|
||||
Description = "User-defined custom path",
|
||||
Type = TrajectoryType.Custom,
|
||||
Edges = new List<PathEdge>
|
||||
{
|
||||
new PathEdge
|
||||
{
|
||||
StartX = 0,
|
||||
StartY = 0,
|
||||
EndX = 1,
|
||||
EndY = 0,
|
||||
Degree = 1
|
||||
}
|
||||
},
|
||||
Resolution = 0.05
|
||||
};
|
||||
|
||||
SelectedScenario = customPath;
|
||||
_customPathScenario = customPath;
|
||||
await OnScenarioChanged.InvokeAsync(customPath);
|
||||
await OnScenarioCreated.InvokeAsync(customPath);
|
||||
Snackbar.Add("New Custom Path created. Add edges to build your path.", Severity.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
@using ApexCharts
|
||||
@using RobotNet10.NavigationTune.Shared.Hubs
|
||||
@using RobotNet10.NavigationTuneUI.Clients
|
||||
@using System.Timers
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<MudCard Style="width: 100%;">
|
||||
<MudCardHeader Style="flex-shrink: 0;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Telemetry Charts</MudText>
|
||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">
|
||||
@(_dataPoints.Count) data points
|
||||
</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudButton Variant="Variant.Text" Size="MudBlazor.Size.Small" OnClick="ClearData"
|
||||
StartIcon="@Icons.Material.Filled.RestartAlt">
|
||||
Clear
|
||||
</MudButton>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudGrid Spacing="2">
|
||||
<!-- Chart 1: Cross-Track Error -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Cross-Track Error</MudText>
|
||||
<ApexChart @ref="_cteChart"
|
||||
TItem="ChartDataPoint"
|
||||
Options="_cteOptions"
|
||||
Height="280">
|
||||
<ApexPointSeries TItem="ChartDataPoint"
|
||||
Items="_dataPoints"
|
||||
SeriesType="SeriesType.Line"
|
||||
Name="CTE (m)"
|
||||
XValue="@(p => (decimal)p.ElapsedSeconds)"
|
||||
YValue="@(p => (decimal)p.CrossTrackError)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
|
||||
<!-- Chart 2: Heading Error -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Heading Error</MudText>
|
||||
<ApexChart @ref="_headingChart"
|
||||
TItem="ChartDataPoint"
|
||||
Options="_headingOptions"
|
||||
Height="280">
|
||||
<ApexPointSeries TItem="ChartDataPoint"
|
||||
Items="_dataPoints"
|
||||
SeriesType="SeriesType.Line"
|
||||
Name="Heading Error (rad)"
|
||||
XValue="@(p => (decimal)p.ElapsedSeconds)"
|
||||
YValue="@(p => (decimal)p.HeadingError)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
|
||||
<!-- Chart 3: Linear Velocity (Actual vs Command) -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Linear Velocity</MudText>
|
||||
<ApexChart @ref="_linearVelChart"
|
||||
TItem="ChartDataPoint"
|
||||
Options="_linearVelOptions"
|
||||
Height="280">
|
||||
<ApexPointSeries TItem="ChartDataPoint"
|
||||
Items="_dataPoints"
|
||||
SeriesType="SeriesType.Line"
|
||||
Name="Actual (m/s)"
|
||||
XValue="@(p => (decimal)p.ElapsedSeconds)"
|
||||
YValue="@(p => (decimal)p.LinearVelocity)"
|
||||
OrderBy="p => p.X" />
|
||||
<ApexPointSeries TItem="ChartDataPoint"
|
||||
Items="_dataPoints"
|
||||
SeriesType="SeriesType.Line"
|
||||
Name="Command (m/s)"
|
||||
XValue="@(p => (decimal)p.ElapsedSeconds)"
|
||||
YValue="@(p => (decimal)p.CommandedLinearVelocity)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
|
||||
<!-- Chart 4: Angular Velocity (Actual vs Command) -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Angular Velocity</MudText>
|
||||
<ApexChart @ref="_angularVelChart"
|
||||
TItem="ChartDataPoint"
|
||||
Options="_angularVelOptions"
|
||||
Height="280">
|
||||
<ApexPointSeries TItem="ChartDataPoint"
|
||||
Items="_dataPoints"
|
||||
SeriesType="SeriesType.Line"
|
||||
Name="Actual (rad/s)"
|
||||
XValue="@(p => (decimal)p.ElapsedSeconds)"
|
||||
YValue="@(p => (decimal)p.AngularVelocity)"
|
||||
OrderBy="p => p.X" />
|
||||
<ApexPointSeries TItem="ChartDataPoint"
|
||||
Items="_dataPoints"
|
||||
SeriesType="SeriesType.Line"
|
||||
Name="Command (rad/s)"
|
||||
XValue="@(p => (decimal)p.ElapsedSeconds)"
|
||||
YValue="@(p => (decimal)p.CommandedAngularVelocity)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
|
||||
<!-- Chart 5: Distance to Goal -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Distance to Goal</MudText>
|
||||
<ApexChart @ref="_distanceChart"
|
||||
TItem="ChartDataPoint"
|
||||
Options="_distanceOptions"
|
||||
Height="280">
|
||||
<ApexPointSeries TItem="ChartDataPoint"
|
||||
Items="_dataPoints"
|
||||
SeriesType="SeriesType.Line"
|
||||
Name="Distance (m)"
|
||||
XValue="@(p => (decimal)p.ElapsedSeconds)"
|
||||
YValue="@(p => (decimal)p.DistanceToGoal)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
|
||||
<!-- Chart 6: Robot Trajectory (X-Y) -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Robot Trajectory</MudText>
|
||||
<ApexChart @ref="_trajectoryChart"
|
||||
TItem="ChartDataPoint"
|
||||
Options="_trajectoryOptions"
|
||||
Height="280">
|
||||
<ApexPointSeries TItem="ChartDataPoint"
|
||||
Items="_dataPoints"
|
||||
SeriesType="SeriesType.Line"
|
||||
Name="Robot Path"
|
||||
XValue="@(p => (decimal)p.X)"
|
||||
YValue="@(p => (decimal)p.Y)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter] public TuningHubClient? HubClient { get; set; }
|
||||
[Parameter] public string? TestRunId { get; set; }
|
||||
|
||||
private const int MaxDataPoints = 500;
|
||||
private const int ChartUpdateIntervalMs = 250;
|
||||
|
||||
private ApexChart<ChartDataPoint>? _cteChart;
|
||||
private ApexChart<ChartDataPoint>? _headingChart;
|
||||
private ApexChart<ChartDataPoint>? _linearVelChart;
|
||||
private ApexChart<ChartDataPoint>? _angularVelChart;
|
||||
private ApexChart<ChartDataPoint>? _distanceChart;
|
||||
private ApexChart<ChartDataPoint>? _trajectoryChart;
|
||||
|
||||
private ApexChartOptions<ChartDataPoint> _cteOptions = new();
|
||||
private ApexChartOptions<ChartDataPoint> _headingOptions = new();
|
||||
private ApexChartOptions<ChartDataPoint> _linearVelOptions = new();
|
||||
private ApexChartOptions<ChartDataPoint> _angularVelOptions = new();
|
||||
private ApexChartOptions<ChartDataPoint> _distanceOptions = new();
|
||||
private ApexChartOptions<ChartDataPoint> _trajectoryOptions = new();
|
||||
|
||||
private List<ChartDataPoint> _dataPoints = new();
|
||||
private long _firstTimestampMs = 0;
|
||||
private string? _previousTestRunId;
|
||||
private Timer? _updateTimer;
|
||||
private bool _hasPendingData = false;
|
||||
private bool _isDisposed = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ConfigureChartOptions();
|
||||
StartUpdateTimer();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (HubClient != null)
|
||||
HubClient.TelemetryUpdated += OnTelemetryUpdated;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (TestRunId != _previousTestRunId)
|
||||
{
|
||||
_previousTestRunId = TestRunId;
|
||||
ClearDataInternal();
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureChartOptions()
|
||||
{
|
||||
var axisColor = "#009933";
|
||||
|
||||
var baseGrid = () => new Grid
|
||||
{
|
||||
BorderColor = "#00993330",
|
||||
StrokeDashArray = 4,
|
||||
Xaxis = new GridXAxis { Lines = new Lines { Show = false } },
|
||||
Yaxis = new GridYAxis { Lines = new Lines { Show = true } }
|
||||
};
|
||||
|
||||
var baseChart = () => new Chart
|
||||
{
|
||||
ForeColor = axisColor,
|
||||
Animations = new Animations { Enabled = false },
|
||||
Toolbar = new Toolbar
|
||||
{
|
||||
Show = true,
|
||||
Tools = new Tools
|
||||
{
|
||||
Zoom = true,
|
||||
Zoomin = true,
|
||||
Zoomout = true,
|
||||
Pan = true,
|
||||
Reset = true,
|
||||
Download = false
|
||||
}
|
||||
},
|
||||
Zoom = new Zoom { Enabled = true }
|
||||
};
|
||||
|
||||
var baseXAxis = () => new XAxis
|
||||
{
|
||||
Title = new AxisTitle
|
||||
{
|
||||
Text = "Time (s)",
|
||||
Style = new AxisTitleStyle { Color = axisColor }
|
||||
},
|
||||
Labels = new XAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return parseFloat(val).toFixed(1); }",
|
||||
Style = new AxisLabelStyle { Colors = axisColor }
|
||||
}
|
||||
};
|
||||
|
||||
var baseYAxisStyle = () => (
|
||||
TitleStyle: new AxisTitleStyle { Color = axisColor },
|
||||
LabelStyle: new AxisLabelStyle { Colors = axisColor }
|
||||
);
|
||||
|
||||
var baseTooltip = () => new Tooltip
|
||||
{
|
||||
Shared = true,
|
||||
X = new TooltipX { Format = "0.1f" },
|
||||
Y = new TooltipY
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(4); }"
|
||||
}
|
||||
};
|
||||
|
||||
// Chart 1: Cross-Track Error — blue
|
||||
_cteOptions.Chart = baseChart();
|
||||
_cteOptions.Grid = baseGrid();
|
||||
_cteOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_cteOptions.Xaxis = baseXAxis();
|
||||
_cteOptions.Colors = new List<string> { "#0D47A1" };
|
||||
_cteOptions.Tooltip = baseTooltip();
|
||||
var cteYStyle = baseYAxisStyle();
|
||||
_cteOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "CTE (m)", Style = cteYStyle.TitleStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = cteYStyle.LabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3
|
||||
}
|
||||
};
|
||||
|
||||
// Chart 2: Heading Error — orange
|
||||
_headingOptions.Chart = baseChart();
|
||||
_headingOptions.Grid = baseGrid();
|
||||
_headingOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_headingOptions.Xaxis = baseXAxis();
|
||||
_headingOptions.Colors = new List<string> { "#E65100" };
|
||||
_headingOptions.Tooltip = baseTooltip();
|
||||
var headYStyle = baseYAxisStyle();
|
||||
_headingOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Heading Error (rad)", Style = headYStyle.TitleStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = headYStyle.LabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3
|
||||
}
|
||||
};
|
||||
|
||||
// Chart 3: Linear Velocity — solid green (Actual), dashed (Command)
|
||||
_linearVelOptions.Chart = baseChart();
|
||||
_linearVelOptions.Grid = baseGrid();
|
||||
_linearVelOptions.Stroke = new Stroke
|
||||
{
|
||||
Curve = Curve.Smooth,
|
||||
Width = 3,
|
||||
DashArray = new List<double> { 0, 6 }
|
||||
};
|
||||
_linearVelOptions.Xaxis = baseXAxis();
|
||||
_linearVelOptions.Colors = new List<string> { "#1B5E20", "#388E3C" };
|
||||
_linearVelOptions.Tooltip = baseTooltip();
|
||||
var linVelYStyle = baseYAxisStyle();
|
||||
_linearVelOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Linear Vel (m/s)", Style = linVelYStyle.TitleStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = linVelYStyle.LabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3
|
||||
}
|
||||
};
|
||||
|
||||
// Chart 4: Angular Velocity — solid purple (Actual), dashed (Command)
|
||||
_angularVelOptions.Chart = baseChart();
|
||||
_angularVelOptions.Grid = baseGrid();
|
||||
_angularVelOptions.Stroke = new Stroke
|
||||
{
|
||||
Curve = Curve.Smooth,
|
||||
Width = 3,
|
||||
DashArray = new List<double> { 0, 6 }
|
||||
};
|
||||
_angularVelOptions.Xaxis = baseXAxis();
|
||||
_angularVelOptions.Colors = new List<string> { "#4A148C", "#7B1FA2" };
|
||||
_angularVelOptions.Tooltip = baseTooltip();
|
||||
var angVelYStyle = baseYAxisStyle();
|
||||
_angularVelOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Angular Vel (rad/s)", Style = angVelYStyle.TitleStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = angVelYStyle.LabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3
|
||||
}
|
||||
};
|
||||
|
||||
// Chart 5: Distance to Goal — red
|
||||
_distanceOptions.Chart = baseChart();
|
||||
_distanceOptions.Grid = baseGrid();
|
||||
_distanceOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_distanceOptions.Xaxis = baseXAxis();
|
||||
_distanceOptions.Colors = new List<string> { "#B71C1C" };
|
||||
_distanceOptions.Tooltip = baseTooltip();
|
||||
var distYStyle = baseYAxisStyle();
|
||||
_distanceOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Distance (m)", Style = distYStyle.TitleStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(2); }",
|
||||
Style = distYStyle.LabelStyle
|
||||
},
|
||||
DecimalsInFloat = 2,
|
||||
Min = 0
|
||||
}
|
||||
};
|
||||
|
||||
// Chart 6: Robot Trajectory (X-Y) — teal path with markers
|
||||
_trajectoryOptions.Chart = baseChart();
|
||||
_trajectoryOptions.Grid = baseGrid();
|
||||
_trajectoryOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_trajectoryOptions.Xaxis = new XAxis
|
||||
{
|
||||
Title = new AxisTitle
|
||||
{
|
||||
Text = "X (m)",
|
||||
Style = new AxisTitleStyle { Color = axisColor }
|
||||
},
|
||||
Labels = new XAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return parseFloat(val).toFixed(2); }",
|
||||
Style = new AxisLabelStyle { Colors = axisColor }
|
||||
}
|
||||
};
|
||||
_trajectoryOptions.Colors = new List<string> { "#00695C" };
|
||||
_trajectoryOptions.Tooltip = new Tooltip
|
||||
{
|
||||
Shared = true,
|
||||
X = new TooltipX { Format = "0.3f" },
|
||||
Y = new TooltipY
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }"
|
||||
}
|
||||
};
|
||||
_trajectoryOptions.Markers = new Markers
|
||||
{
|
||||
Size = 2,
|
||||
Shape = ShapeEnum.Circle
|
||||
};
|
||||
var trajYStyle = baseYAxisStyle();
|
||||
_trajectoryOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Y (m)", Style = trajYStyle.TitleStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = trajYStyle.LabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void StartUpdateTimer()
|
||||
{
|
||||
_updateTimer = new Timer(ChartUpdateIntervalMs);
|
||||
_updateTimer.Elapsed += async (s, e) => await OnTimerElapsed();
|
||||
_updateTimer.AutoReset = true;
|
||||
_updateTimer.Enabled = true;
|
||||
}
|
||||
|
||||
private void OnTelemetryUpdated(TelemetryUpdateDto dto)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (_firstTimestampMs == 0)
|
||||
_firstTimestampMs = dto.TimestampMs;
|
||||
|
||||
var point = new ChartDataPoint
|
||||
{
|
||||
ElapsedSeconds = (dto.TimestampMs - _firstTimestampMs) / 1000.0,
|
||||
X = dto.X,
|
||||
Y = dto.Y,
|
||||
Theta = dto.Theta,
|
||||
CrossTrackError = dto.CrossTrackError,
|
||||
HeadingError = dto.HeadingError,
|
||||
LinearVelocity = dto.LinearVelocity,
|
||||
AngularVelocity = dto.AngularVelocity,
|
||||
CommandedLinearVelocity = dto.CommandedLinearVelocity,
|
||||
CommandedAngularVelocity = dto.CommandedAngularVelocity,
|
||||
DistanceToGoal = dto.DistanceToGoal
|
||||
};
|
||||
|
||||
_dataPoints.Add(point);
|
||||
|
||||
// Trim to rolling buffer
|
||||
if (_dataPoints.Count > MaxDataPoints)
|
||||
_dataPoints.RemoveRange(0, _dataPoints.Count - MaxDataPoints);
|
||||
|
||||
_hasPendingData = true;
|
||||
}
|
||||
|
||||
private async Task OnTimerElapsed()
|
||||
{
|
||||
if (_isDisposed || !_hasPendingData) return;
|
||||
_hasPendingData = false;
|
||||
|
||||
try
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_cteChart != null) await _cteChart.UpdateSeriesAsync(true);
|
||||
if (_headingChart != null) await _headingChart.UpdateSeriesAsync(true);
|
||||
if (_linearVelChart != null) await _linearVelChart.UpdateSeriesAsync(true);
|
||||
if (_angularVelChart != null) await _angularVelChart.UpdateSeriesAsync(true);
|
||||
if (_distanceChart != null) await _distanceChart.UpdateSeriesAsync(true);
|
||||
if (_trajectoryChart != null) await _trajectoryChart.UpdateSeriesAsync(true);
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
private async Task ClearData()
|
||||
{
|
||||
ClearDataInternal();
|
||||
try
|
||||
{
|
||||
if (_cteChart != null) await _cteChart.UpdateSeriesAsync(true);
|
||||
if (_headingChart != null) await _headingChart.UpdateSeriesAsync(true);
|
||||
if (_linearVelChart != null) await _linearVelChart.UpdateSeriesAsync(true);
|
||||
if (_angularVelChart != null) await _angularVelChart.UpdateSeriesAsync(true);
|
||||
if (_distanceChart != null) await _distanceChart.UpdateSeriesAsync(true);
|
||||
if (_trajectoryChart != null) await _trajectoryChart.UpdateSeriesAsync(true);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
private void ClearDataInternal()
|
||||
{
|
||||
_dataPoints.Clear();
|
||||
_firstTimestampMs = 0;
|
||||
_hasPendingData = false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_isDisposed = true;
|
||||
|
||||
if (_updateTimer != null)
|
||||
{
|
||||
_updateTimer.Stop();
|
||||
_updateTimer.Dispose();
|
||||
_updateTimer = null;
|
||||
}
|
||||
|
||||
if (HubClient != null)
|
||||
HubClient.TelemetryUpdated -= OnTelemetryUpdated;
|
||||
}
|
||||
|
||||
private class ChartDataPoint
|
||||
{
|
||||
public double ElapsedSeconds { get; set; }
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double Theta { get; set; }
|
||||
public double CrossTrackError { get; set; }
|
||||
public double HeadingError { get; set; }
|
||||
public double LinearVelocity { get; set; }
|
||||
public double AngularVelocity { get; set; }
|
||||
public double CommandedLinearVelocity { get; set; }
|
||||
public double CommandedAngularVelocity { get; set; }
|
||||
public double DistanceToGoal { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTune.Shared.Hubs
|
||||
@using RobotNet10.NavigationTuneUI.Clients
|
||||
@using MudBlazor
|
||||
|
||||
<MudCard Style="width: 100%; height: 400px;">
|
||||
<MudCardHeader Style="box-sizing: border-box; flex-shrink: 0;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Test Execution Control</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
@if (CurrentStatus != null)
|
||||
{
|
||||
<MudChip T="string"
|
||||
Color="@GetStatusColor(CurrentStatus.Status)"
|
||||
Size="Size.Small">
|
||||
@CurrentStatus.Status
|
||||
</MudChip>
|
||||
}
|
||||
</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;">
|
||||
<MudStack Spacing="3">
|
||||
<!-- Scenario Selection -->
|
||||
<MudItem xs="12">
|
||||
<MudSelect T="Guid"
|
||||
Label="Test Scenario"
|
||||
Variant="Variant.Outlined"
|
||||
Value="@SelectedScenarioId"
|
||||
ValueChanged="@OnScenarioIdChanged">
|
||||
@foreach (var scenario in AvailableScenarios)
|
||||
{
|
||||
<MudSelectItem Value="@scenario.Id">@scenario.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
<!-- Parameter Set Selection -->
|
||||
<MudItem xs="12">
|
||||
<MudSelect T="Guid"
|
||||
Label="Parameter Set"
|
||||
Variant="Variant.Outlined"
|
||||
@bind-Value="SelectedParameterSetId">
|
||||
@foreach (var paramSet in AvailableParameterSets)
|
||||
{
|
||||
<MudSelectItem Value="@paramSet.Id">@paramSet.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
OnClick="StartTest"
|
||||
Disabled="@(!CanStartTest)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Class="mr-2" />
|
||||
Start
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Warning"
|
||||
OnClick="PauseTest"
|
||||
Disabled="@(!CanPauseTest)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Pause" Class="mr-2" />
|
||||
Pause
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Info"
|
||||
OnClick="ResumeTest"
|
||||
Disabled="@(!CanResumeTest)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Class="mr-2" />
|
||||
Resume
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Default"
|
||||
OnClick="StopTest">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-2" />
|
||||
Stop
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
OnClick="EmergencyStop"
|
||||
Disabled="@(!CanEmergencyStop)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Emergency" Class="mr-2" />
|
||||
EMC Stop
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<!-- Progress -->
|
||||
@if (CurrentStatus != null)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Progress</MudText>
|
||||
<MudProgressLinear Value="@(CurrentStatus.ProgressPercent * 100)"
|
||||
Color="Color.Primary"
|
||||
Class="mt-2" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1">
|
||||
@(CurrentStatus.ProgressPercent.ToString("P1"))
|
||||
</MudText>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- Status Message -->
|
||||
@if (!string.IsNullOrEmpty(CurrentStatus?.Message))
|
||||
{
|
||||
<MudAlert Severity="@GetStatusSeverity(CurrentStatus.Status)" Dense>
|
||||
@CurrentStatus.Message
|
||||
</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter] public TuningHubClient? HubClient { get; set; }
|
||||
[Parameter] public List<TestScenario> AvailableScenarios { get; set; } = new();
|
||||
[Parameter] public List<NavigationParameterSet> AvailableParameterSets { get; set; } = new();
|
||||
[Parameter] public EventCallback<(Guid ScenarioId, Guid ParameterSetId)> OnStartTest { get; set; }
|
||||
[Parameter] public EventCallback OnPauseTest { get; set; }
|
||||
[Parameter] public EventCallback OnResumeTest { get; set; }
|
||||
[Parameter] public EventCallback OnStopTest { get; set; }
|
||||
[Parameter] public EventCallback OnEmergencyStop { get; set; }
|
||||
[Parameter] public EventCallback<TestScenario> OnScenarioSelected { get; set; }
|
||||
|
||||
private Guid SelectedScenarioId { get; set; } = Guid.Empty;
|
||||
private Guid SelectedParameterSetId { get; set; } = Guid.Empty;
|
||||
private TestStatusUpdateDto? CurrentStatus { get; set; }
|
||||
|
||||
private bool CanStartTest => SelectedScenarioId != Guid.Empty
|
||||
&& SelectedParameterSetId != Guid.Empty
|
||||
&& (CurrentStatus == null || CurrentStatus.Status == TestStatus.Completed
|
||||
|| CurrentStatus.Status == TestStatus.Error
|
||||
|| CurrentStatus.Status == TestStatus.Aborted);
|
||||
|
||||
private bool CanPauseTest => CurrentStatus?.Status == TestStatus.Running;
|
||||
private bool CanResumeTest => CurrentStatus?.Status == TestStatus.Paused;
|
||||
private bool CanStopTest => CurrentStatus?.Status == TestStatus.Running
|
||||
|| CurrentStatus?.Status == TestStatus.Paused;
|
||||
private bool CanEmergencyStop => CurrentStatus?.Status == TestStatus.Running
|
||||
|| CurrentStatus?.Status == TestStatus.Paused;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (HubClient != null)
|
||||
{
|
||||
HubClient.TestStatusUpdated += OnTestStatusUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTestStatusUpdated(TestStatusUpdateDto status)
|
||||
{
|
||||
CurrentStatus = status;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task StartTest()
|
||||
{
|
||||
if (SelectedScenarioId != Guid.Empty && SelectedParameterSetId != Guid.Empty)
|
||||
{
|
||||
await OnStartTest.InvokeAsync((SelectedScenarioId, SelectedParameterSetId));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PauseTest()
|
||||
{
|
||||
await OnPauseTest.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task ResumeTest()
|
||||
{
|
||||
await OnResumeTest.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task StopTest()
|
||||
{
|
||||
await OnStopTest.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task EmergencyStop()
|
||||
{
|
||||
await OnEmergencyStop.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnScenarioIdChanged(Guid scenarioId)
|
||||
{
|
||||
SelectedScenarioId = scenarioId;
|
||||
var scenario = AvailableScenarios.FirstOrDefault(s => s.Id == scenarioId);
|
||||
if (scenario != null)
|
||||
{
|
||||
await OnScenarioSelected.InvokeAsync(scenario);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private Color GetStatusColor(TestStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
TestStatus.Running => Color.Success,
|
||||
TestStatus.Paused => Color.Warning,
|
||||
TestStatus.Completed => Color.Info,
|
||||
TestStatus.Error => Color.Error,
|
||||
TestStatus.EmergencyStopped => Color.Error,
|
||||
TestStatus.Aborted => Color.Default,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Severity GetStatusSeverity(TestStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
TestStatus.Running => Severity.Success,
|
||||
TestStatus.Paused => Severity.Warning,
|
||||
TestStatus.Completed => Severity.Info,
|
||||
TestStatus.Error => Severity.Error,
|
||||
TestStatus.EmergencyStopped => Severity.Error,
|
||||
_ => Severity.Info
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Services
|
||||
@using RobotNet10.NavigationTuneUI.Helpers
|
||||
@using MudBlazor
|
||||
@inject TuningApiService ApiService
|
||||
|
||||
<MudCard Style="width: 100%; min-height: 400px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
|
||||
<MudCardHeader Style="box-sizing: border-box; flex-shrink: 0;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Lịch sử test</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Danh sách các lần chạy test đã lưu</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Size="Size.Small"
|
||||
OnClick="LoadTestRunsAsync"
|
||||
title="Làm mới" />
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="flex: 1; overflow: auto; box-sizing: border-box;">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-2" />
|
||||
}
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense Class="mb-2">@_error</MudAlert>
|
||||
}
|
||||
@if (!_loading && _totalCount == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense>
|
||||
Chưa có lịch sử test. Chạy test từ "Test Execution Control" để lưu kết quả.
|
||||
</MudAlert>
|
||||
}
|
||||
else if (!_loading)
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.body2">Hiển thị: </MudText>
|
||||
<MudSelect T="int" Value="_pageSize" ValueChanged="OnPageSizeChanged" Style="max-width: 80px;" Dense="true">
|
||||
<MudSelectItem T="int" Value="10">10</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="20">20</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="50">50</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudText Typo="Typo.body2">@((_currentPage - 1) * _pageSize + 1)-@(Math.Min(_currentPage * _pageSize, _totalCount)) / @_totalCount</MudText>
|
||||
</MudStack>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
Disabled="@(_selectedRuns.Count == 0)"
|
||||
OnClick="DeleteSelectedAsync">
|
||||
Xóa đã chọn (@_selectedRuns.Count)
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudTable T="TestRunDto"
|
||||
Items="@TestRuns"
|
||||
Hover="true"
|
||||
Dense="true"
|
||||
FixedHeader="true"
|
||||
MultiSelection="true"
|
||||
@bind-SelectedItems="_selectedRuns"
|
||||
Style="min-height: 320px"
|
||||
Breakpoint="Breakpoint.Sm">
|
||||
<HeaderContent>
|
||||
<MudTh>Thời gian</MudTh>
|
||||
<MudTh>Scenario</MudTh>
|
||||
<MudTh>Parameter set</MudTh>
|
||||
<MudTh>Trạng thái</MudTh>
|
||||
<MudTh>Thời lượng</MudTh>
|
||||
<MudTh>Điểm</MudTh>
|
||||
<MudTh Style="width: 90px;">Thao tác</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Thời gian"><MudText Typo="Typo.body2">@context.StartTime.ToString("yyyy-MM-dd HH:mm:ss")</MudText></MudTd>
|
||||
<MudTd DataLabel="Scenario"><MudText Typo="Typo.body2">@(context.ScenarioName ?? context.ScenarioId.ToString("N")[..8])</MudText></MudTd>
|
||||
<MudTd DataLabel="Parameter set"><MudText Typo="Typo.body2">@(context.ParameterSetName ?? context.ParameterSetId.ToString("N")[..8])</MudText></MudTd>
|
||||
<MudTd DataLabel="Trạng thái"><MudChip T="string" Size="Size.Small" Color="@GetStatusColor(context.Status)">@GetStatusText(context.Status)</MudChip></MudTd>
|
||||
<MudTd DataLabel="Thời lượng"><MudText Typo="Typo.body2">@(context.Duration.ToString("F1"))s</MudText></MudTd>
|
||||
<MudTd DataLabel="Điểm">
|
||||
@if (context.Metrics != null)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@(context.Metrics.OverallScore.ToString("F1"))</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Thao tác">
|
||||
<MudStack Row="true" Spacing="1">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Visibility" Size="Size.Small" OnClick="@(() => ViewDetail(context))" title="Xem chi tiết" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteRun(context))" title="Xóa" />
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_totalPages > 1)
|
||||
{
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mt-2">
|
||||
<MudPagination Count="@_totalPages" Selected="@_currentPage" SelectedChanged="OnPageChanged" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@* Dialog chi tiết test run *@
|
||||
<MudDialog @bind-Visible="_detailDialogOpen" Options="@(new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true })">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Chi tiết test</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
@if (_selectedRun != null)
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.body2"><strong>Thời gian:</strong> @_selectedRun.StartTime.ToString("yyyy-MM-dd HH:mm:ss")</MudText>
|
||||
<MudText Typo="Typo.body2"><strong>Scenario:</strong> @(_selectedRun.ScenarioName ?? "-")</MudText>
|
||||
<MudText Typo="Typo.body2"><strong>Parameter set:</strong> @(_selectedRun.ParameterSetName ?? "-")</MudText>
|
||||
<MudText Typo="Typo.body2"><strong>Trạng thái:</strong> @GetStatusText(_selectedRun.Status)</MudText>
|
||||
<MudText Typo="Typo.body2"><strong>Thời lượng:</strong> @(_selectedRun.Duration.ToString("F1"))s</MudText>
|
||||
@if (!string.IsNullOrEmpty(_selectedRun.ErrorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense>@_selectedRun.ErrorMessage</MudAlert>
|
||||
}
|
||||
@if (_selectedRun.Metrics != null)
|
||||
{
|
||||
<MudDivider />
|
||||
<MudText Typo="Typo.subtitle1">Metrics</MudText>
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Overall: @(_selectedRun.Metrics.OverallScore.ToString("F1"))</MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Tracking: @(_selectedRun.Metrics.TrackingScore.ToString("F1"))</MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Smoothness: @(_selectedRun.Metrics.SmoothnessScore.ToString("F1"))</MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Efficiency: @(_selectedRun.Metrics.EfficiencyScore.ToString("F1"))</MudText></MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseDetailDialog">Đóng</MudButton>
|
||||
@if (_selectedRun != null && _selectedRun.Metrics != null)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ShowMetricsInDashboard">
|
||||
Hiển thị trong Metrics
|
||||
</MudButton>
|
||||
}
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<TestMetrics?> OnShowMetrics { get; set; }
|
||||
|
||||
private List<TestRunDto> TestRuns { get; set; } = new();
|
||||
private int _totalCount;
|
||||
private int _currentPage = 1;
|
||||
private int _pageSize = 20;
|
||||
private int _totalPages => _pageSize > 0 ? (int)Math.Ceiling(1.0 * _totalCount / _pageSize) : 0;
|
||||
private HashSet<TestRunDto> _selectedRuns { get; set; } = new();
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
private bool _detailDialogOpen;
|
||||
private TestRunDto? _selectedRun;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadPageAsync(1);
|
||||
}
|
||||
|
||||
private async Task LoadPageAsync(int page)
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var skip = (page - 1) * _pageSize;
|
||||
var paged = await ApiService.GetTestRunsPagedAsync(skip, _pageSize);
|
||||
TestRuns = paged.Items ?? new List<TestRunDto>();
|
||||
_totalCount = paged.TotalCount;
|
||||
_currentPage = _totalCount > 0 ? Math.Clamp(page, 1, _totalPages) : 1;
|
||||
_selectedRuns.Clear();
|
||||
if (TestRuns.Count == 0 && page > 1)
|
||||
await LoadPageAsync(page - 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ErrorHelper.GetErrorMessage(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnPageChanged(int page)
|
||||
{
|
||||
_currentPage = page;
|
||||
await LoadPageAsync(page);
|
||||
}
|
||||
|
||||
private async Task OnPageSizeChanged(int newSize)
|
||||
{
|
||||
_pageSize = newSize;
|
||||
_currentPage = 1;
|
||||
await LoadPageAsync(1);
|
||||
}
|
||||
|
||||
private async Task LoadTestRunsAsync()
|
||||
{
|
||||
await LoadPageAsync(_currentPage);
|
||||
}
|
||||
|
||||
private async Task DeleteSelectedAsync()
|
||||
{
|
||||
if (_selectedRuns.Count == 0) return;
|
||||
try
|
||||
{
|
||||
await ApiService.DeleteTestRunsAsync(_selectedRuns.Select(r => r.Id));
|
||||
_selectedRuns.Clear();
|
||||
await LoadPageAsync(_currentPage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ErrorHelper.GetErrorMessage(ex);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewDetail(TestRunDto run)
|
||||
{
|
||||
_selectedRun = run;
|
||||
_detailDialogOpen = true;
|
||||
}
|
||||
|
||||
private void CloseDetailDialog()
|
||||
{
|
||||
_detailDialogOpen = false;
|
||||
_selectedRun = null;
|
||||
}
|
||||
|
||||
private async Task ShowMetricsInDashboard()
|
||||
{
|
||||
if (_selectedRun?.Metrics != null)
|
||||
{
|
||||
await OnShowMetrics.InvokeAsync(_selectedRun.Metrics);
|
||||
CloseDetailDialog();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteRun(TestRunDto run)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ApiService.DeleteTestRunAsync(run.Id);
|
||||
_selectedRuns.Remove(run);
|
||||
TestRuns.Remove(run);
|
||||
_totalCount--;
|
||||
if (TestRuns.Count == 0 && _currentPage > 1)
|
||||
await LoadPageAsync(_currentPage - 1);
|
||||
else
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ErrorHelper.GetErrorMessage(ex);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static Color GetStatusColor(TestStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
TestStatus.Completed => Color.Success,
|
||||
TestStatus.Running => Color.Info,
|
||||
TestStatus.Paused => Color.Warning,
|
||||
TestStatus.Aborted => Color.Default,
|
||||
TestStatus.Error => Color.Error,
|
||||
TestStatus.EmergencyStopped => Color.Error,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStatusText(TestStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
TestStatus.Preparing => "Chuẩn bị",
|
||||
TestStatus.Running => "Đang chạy",
|
||||
TestStatus.Paused => "Tạm dừng",
|
||||
TestStatus.Completed => "Hoàn thành",
|
||||
TestStatus.Aborted => "Đã dừng",
|
||||
TestStatus.Error => "Lỗi",
|
||||
TestStatus.EmergencyStopped => "Dừng khẩn",
|
||||
_ => status.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gọi từ parent khi test mới hoàn thành để refresh danh sách.
|
||||
/// </summary>
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
await LoadPageAsync(_currentPage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTuneUI.Services
|
||||
@using RobotNet10.NavigationTuneUI.Helpers
|
||||
@inject TuningApiService ApiService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard Style="width: 100%; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
|
||||
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AutoFixHigh" />
|
||||
<MudText Typo="Typo.h6">Tuning Advisor</MudText>
|
||||
@if (Report != null && Report.HasCriticalIssues)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Error" Size="Size.Small">Critical</MudChip>
|
||||
}
|
||||
else if (Report != null && Report.HighPriorityCount > 0)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Warning" Size="Size.Small">@Report.HighPriorityCount High</MudChip>
|
||||
}
|
||||
else if (Report != null && Report.Suggestions.Count > 0)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Info" Size="Size.Small">@Report.Suggestions.Count Suggestions</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
@if (Report != null)
|
||||
{
|
||||
<MudTooltip Text="Re-analyze">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="HandleReAnalyze"
|
||||
Disabled="@_isProcessing" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
|
||||
<MudCardContent Style="flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;">
|
||||
@if (Report == null)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Chưa có dữ liệu phân tích. Chạy test để nhận gợi ý tuning tự động.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Overall Assessment *@
|
||||
<MudAlert Severity="@GetOverallSeverity()" Dense="true" Class="mb-3">
|
||||
@Report.OverallAssessment
|
||||
</MudAlert>
|
||||
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||
Controller: @Report.ControllerType | Samples: @Report.TelemetrySamplesAnalyzed | @Report.GeneratedAt.ToString("HH:mm:ss")
|
||||
</MudText>
|
||||
|
||||
@* Detected Patterns *@
|
||||
@if (Report.DetectedPatterns.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Elevation="0" Class="mb-3">
|
||||
<MudExpansionPanel Text="@($"Detected Patterns ({Report.DetectedPatterns.Count})")"
|
||||
Expanded="false"
|
||||
Dense="true">
|
||||
@foreach (var pattern in Report.DetectedPatterns.OrderByDescending(p => p.Severity))
|
||||
{
|
||||
<MudAlert Severity="@GetPatternSeverity(pattern.Severity)"
|
||||
Dense="true" Class="mb-1" NoIcon="false">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Color="@GetPatternChipColor(pattern.Severity)">
|
||||
@pattern.Category
|
||||
</MudChip>
|
||||
<MudText Typo="Typo.body2">@pattern.Description</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
(Severity: @pattern.Severity.ToString("F2"))
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudAlert>
|
||||
}
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
|
||||
@* Suggestions Table *@
|
||||
@if (Report.Suggestions.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Suggestions</MudText>
|
||||
<MudTable Items="@Report.Suggestions"
|
||||
Dense="true"
|
||||
Hover="true"
|
||||
Bordered="false"
|
||||
Striped="true"
|
||||
Elevation="0"
|
||||
T="TuningSuggestion">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 40px;"></MudTh>
|
||||
<MudTh>Priority</MudTh>
|
||||
<MudTh>Parameter</MudTh>
|
||||
<MudTh Style="text-align: right;">Current</MudTh>
|
||||
<MudTh Style="text-align: center;"></MudTh>
|
||||
<MudTh Style="text-align: right;">Suggested</MudTh>
|
||||
<MudTh Style="text-align: right;">Change</MudTh>
|
||||
<MudTh Style="text-align: center;">Confidence</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudCheckBox T="bool"
|
||||
Value="@IsSelected(context)"
|
||||
ValueChanged="@(v => ToggleSelection(context, v))"
|
||||
Dense="true"
|
||||
Color="Color.Primary" />
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetPriorityColor(context.Priority)">
|
||||
@context.Priority
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudTooltip Text="@context.Reason">
|
||||
<MudText Typo="Typo.body2">@context.ParameterDisplayName</MudText>
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
<MudTd Style="text-align: right;">
|
||||
<MudText Typo="Typo.body2">@context.CurrentValue.ToString("F4")</MudText>
|
||||
</MudTd>
|
||||
<MudTd Style="text-align: center;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" Size="Size.Small" />
|
||||
</MudTd>
|
||||
<MudTd Style="text-align: right;">
|
||||
<MudText Typo="Typo.body2" Color="Color.Primary">
|
||||
<b>@context.SuggestedValue.ToString("F4")</b>
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd Style="text-align: right;">
|
||||
<MudText Typo="Typo.body2"
|
||||
Color="@(context.ChangePercent > 0 ? Color.Success : Color.Error)">
|
||||
@(context.ChangePercent > 0 ? "+" : "")@context.ChangePercent.ToString("F1")%
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd Style="text-align: center;">
|
||||
<MudProgressLinear Value="@(context.Confidence * 100)"
|
||||
Color="@GetConfidenceColor(context.Confidence)"
|
||||
Style="width: 60px; height: 8px; display: inline-block;" />
|
||||
<MudText Typo="Typo.caption">@((context.Confidence * 100).ToString("F0"))%</MudText>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<ChildRowContent>
|
||||
<MudGrid Class="pa-2">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Reason</MudText>
|
||||
<MudText Typo="Typo.body2">@context.Reason</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Expected Impact</MudText>
|
||||
<MudText Typo="Typo.body2">@context.ExpectedImpact</MudText>
|
||||
</MudItem>
|
||||
@if (context.RelatedPatterns.Count > 0)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Related Patterns</MudText>
|
||||
<MudStack Row="true" Spacing="1">
|
||||
@foreach (var pattern in context.RelatedPatterns)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@pattern</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</ChildRowContent>
|
||||
</MudTable>
|
||||
|
||||
@* Action Buttons *@
|
||||
<MudStack Row="true" Spacing="2" Class="mt-3" Justify="Justify.FlexEnd">
|
||||
<MudButton Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
OnClick="SelectAll">
|
||||
Select All
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
OnClick="DeselectAll">
|
||||
Deselect All
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Preview"
|
||||
OnClick="HandleApplySelected"
|
||||
Disabled="@(_selectedIds.Count == 0 || _isProcessing)"
|
||||
Size="Size.Small">
|
||||
Apply Selected (Preview)
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="HandleApplyAndSave"
|
||||
Disabled="@(_selectedIds.Count == 0 || _isProcessing)"
|
||||
Size="Size.Small">
|
||||
Apply & Save as New
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Success" Dense="true">
|
||||
Không phát hiện vấn đề. Tham số hiện tại hoạt động tốt.
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_isProcessing)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mt-2" />
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter] public TuningReport? Report { get; set; }
|
||||
[Parameter] public NavigationParameterSet? CurrentParameters { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnApplySuggestions { get; set; }
|
||||
[Parameter] public EventCallback<NavigationParameterSet> OnSaveNewParameterSet { get; set; }
|
||||
[Parameter] public Guid? TestRunId { get; set; }
|
||||
|
||||
private HashSet<Guid> _selectedIds = new();
|
||||
private bool _isProcessing;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Auto-select high+ priority suggestions when new report arrives
|
||||
if (Report != null && _selectedIds.Count == 0)
|
||||
{
|
||||
_selectedIds = Report.Suggestions
|
||||
.Where(s => s.Priority >= SuggestionPriority.High)
|
||||
.Select(s => s.Id)
|
||||
.ToHashSet();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSelected(TuningSuggestion suggestion) => _selectedIds.Contains(suggestion.Id);
|
||||
|
||||
private void ToggleSelection(TuningSuggestion suggestion, bool selected)
|
||||
{
|
||||
if (selected)
|
||||
_selectedIds.Add(suggestion.Id);
|
||||
else
|
||||
_selectedIds.Remove(suggestion.Id);
|
||||
}
|
||||
|
||||
private void SelectAll()
|
||||
{
|
||||
if (Report != null)
|
||||
_selectedIds = Report.Suggestions.Select(s => s.Id).ToHashSet();
|
||||
}
|
||||
|
||||
private void DeselectAll() => _selectedIds.Clear();
|
||||
|
||||
private async Task HandleApplySelected()
|
||||
{
|
||||
if (Report == null || CurrentParameters == null || _selectedIds.Count == 0)
|
||||
return;
|
||||
|
||||
_isProcessing = true;
|
||||
try
|
||||
{
|
||||
var modified = await ApiService.ApplySuggestionsAsync(
|
||||
CurrentParameters.Id, _selectedIds.ToList(), Report);
|
||||
|
||||
if (modified != null)
|
||||
{
|
||||
await OnApplySuggestions.InvokeAsync(modified);
|
||||
Snackbar.Add($"Đã áp dụng {_selectedIds.Count} gợi ý (preview)", Severity.Success);
|
||||
}
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi áp dụng gợi ý: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleApplyAndSave()
|
||||
{
|
||||
if (Report == null || CurrentParameters == null || _selectedIds.Count == 0)
|
||||
return;
|
||||
|
||||
_isProcessing = true;
|
||||
try
|
||||
{
|
||||
var saved = await ApiService.ApplyAndSaveSuggestionsAsync(
|
||||
CurrentParameters.Id, _selectedIds.ToList(), Report);
|
||||
|
||||
if (saved != null)
|
||||
{
|
||||
await OnSaveNewParameterSet.InvokeAsync(saved);
|
||||
Snackbar.Add($"Đã lưu bộ thông số mới: {saved.Name}", Severity.Success);
|
||||
}
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi lưu bộ thông số: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleReAnalyze()
|
||||
{
|
||||
if (TestRunId == null)
|
||||
return;
|
||||
|
||||
_isProcessing = true;
|
||||
try
|
||||
{
|
||||
var newReport = await ApiService.AnalyzeTestRunAsync(TestRunId.Value);
|
||||
if (newReport != null)
|
||||
{
|
||||
Report = newReport;
|
||||
_selectedIds = newReport.Suggestions
|
||||
.Where(s => s.Priority >= SuggestionPriority.High)
|
||||
.Select(s => s.Id)
|
||||
.ToHashSet();
|
||||
Snackbar.Add("Phân tích lại hoàn tất", Severity.Success);
|
||||
}
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi phân tích: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Severity GetOverallSeverity()
|
||||
{
|
||||
if (Report == null) return Severity.Info;
|
||||
if (Report.HasCriticalIssues) return Severity.Error;
|
||||
if (Report.HighPriorityCount > 0) return Severity.Warning;
|
||||
if (Report.Suggestions.Count > 0) return Severity.Info;
|
||||
return Severity.Success;
|
||||
}
|
||||
|
||||
private static Severity GetPatternSeverity(double severity)
|
||||
{
|
||||
if (severity >= 0.7) return Severity.Error;
|
||||
if (severity >= 0.4) return Severity.Warning;
|
||||
return Severity.Info;
|
||||
}
|
||||
|
||||
private static Color GetPatternChipColor(double severity)
|
||||
{
|
||||
if (severity >= 0.7) return Color.Error;
|
||||
if (severity >= 0.4) return Color.Warning;
|
||||
return Color.Info;
|
||||
}
|
||||
|
||||
private static Color GetPriorityColor(SuggestionPriority priority) => priority switch
|
||||
{
|
||||
SuggestionPriority.Critical => Color.Error,
|
||||
SuggestionPriority.High => Color.Warning,
|
||||
SuggestionPriority.Medium => Color.Info,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
private static Color GetConfidenceColor(double confidence)
|
||||
{
|
||||
if (confidence >= 0.7) return Color.Success;
|
||||
if (confidence >= 0.4) return Color.Warning;
|
||||
return Color.Error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.NavigationTuneUI.Clients
|
||||
@using RobotNet10.NavigationTune.Shared.Models
|
||||
@using RobotNet10.NavigationTune.Shared.Hubs
|
||||
@using RobotNet10.NavigationTune.Shared.Interfaces
|
||||
@using RobotNet10.NavigationTuneUI.Components
|
||||
@using RobotNet10.NavigationTuneUI.Components.Pages
|
||||
@using RobotNet10.NavigationTuneUI.Services
|
||||
@using RobotNet10.NavigationTuneUI.Helpers
|
||||
@inject TuningHubClient TuningHub
|
||||
@inject TuningApiService ApiService
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Navigation Tuning</PageTitle>
|
||||
|
||||
<MudToolBar Dense="true" Class="mud-theme-primary" Style="flex-shrink: 0;">
|
||||
<MudText Typo="Typo.h6" Class="mr-4" Style="color: inherit;">Navigation Tuning</MudText>
|
||||
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
|
||||
<MudButton Variant="@(CurrentPage == "test" ? Variant.Filled : Variant.Text)"
|
||||
Color="@(CurrentPage == "test" ? Color.Dark : Color.Inherit)"
|
||||
OnClick='@(() => CurrentPage = "test")'
|
||||
StartIcon="@Icons.Material.Filled.Science"
|
||||
Size="Size.Small"
|
||||
Class="mr-1">
|
||||
Test
|
||||
</MudButton>
|
||||
<MudButton Variant="@(CurrentPage == "manager" ? Variant.Filled : Variant.Text)"
|
||||
Color="@(CurrentPage == "manager" ? Color.Dark : Color.Inherit)"
|
||||
OnClick='@(() => CurrentPage = "manager")'
|
||||
StartIcon="@Icons.Material.Filled.Settings"
|
||||
Size="Size.Small"
|
||||
Class="mr-1">
|
||||
Manager
|
||||
</MudButton>
|
||||
<MudButton Variant="@(CurrentPage == "history" ? Variant.Filled : Variant.Text)"
|
||||
Color="@(CurrentPage == "history" ? Color.Dark : Color.Inherit)"
|
||||
OnClick='@(() => CurrentPage = "history")'
|
||||
StartIcon="@Icons.Material.Filled.History"
|
||||
Size="Size.Small"
|
||||
Class="mr-1">
|
||||
History
|
||||
</MudButton>
|
||||
<MudButton Variant="@(CurrentPage == "motor" ? Variant.Filled : Variant.Text)"
|
||||
Color="@(CurrentPage == "motor" ? Color.Dark : Color.Inherit)"
|
||||
OnClick='@(() => CurrentPage = "motor")'
|
||||
StartIcon="@Icons.Material.Filled.Speed"
|
||||
Size="Size.Small">
|
||||
Motor Test
|
||||
</MudButton>
|
||||
</MudToolBar>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@switch (CurrentPage)
|
||||
{
|
||||
case "test":
|
||||
<TestPage HubClient="@TuningHub"
|
||||
Scenarios="@Scenarios"
|
||||
ParameterSets="@ParameterSets"
|
||||
CurrentParameterSet="@CurrentParameterSet"
|
||||
SelectedScenario="@SelectedScenario"
|
||||
CurrentMetrics="@CurrentMetrics"
|
||||
CurrentTestRunId="@CurrentTestRunId"
|
||||
TuningReport="@CurrentTuningReport"
|
||||
OnStartTest="HandleStartTest"
|
||||
OnPauseTest="HandlePauseTest"
|
||||
OnResumeTest="HandleResumeTest"
|
||||
OnStopTest="HandleStopTest"
|
||||
OnEmergencyStop="HandleEmergencyStop"
|
||||
OnScenarioSelected="HandleScenarioSelected"
|
||||
OnSaveParameters="HandleSaveParameters"
|
||||
OnResetParameters="HandleResetParameters"
|
||||
OnParameterSetChanged="HandleParameterSetChanged"
|
||||
OnScenarioChanged="HandleScenarioChanged"
|
||||
OnScenarioCreated="HandleScenarioCreated"
|
||||
OnScenarioDeleted="HandleScenarioDeleted"
|
||||
OnApplySuggestions="HandleApplySuggestions"
|
||||
OnSaveNewParameterSet="HandleSaveNewParameterSet" />
|
||||
break;
|
||||
case "manager":
|
||||
<ManagerPage ParameterSets="@ParameterSets"
|
||||
OnParameterSetSelected="HandleParameterSetSelected"
|
||||
OnParameterSetCreated="HandleParameterSetCreated"
|
||||
OnParameterSetUpdated="HandleParameterSetUpdated"
|
||||
OnParameterSetDeleted="HandleParameterSetDeleted" />
|
||||
break;
|
||||
case "history":
|
||||
<HistoryPage @ref="_historyPage"
|
||||
OnShowMetrics="HandleShowMetricsFromHistory" />
|
||||
break;
|
||||
case "motor":
|
||||
<MotorTestPage />
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private string CurrentPage { get; set; } = "test";
|
||||
private List<TestScenario> Scenarios { get; set; } = new();
|
||||
private List<NavigationParameterSet> ParameterSets { get; set; } = new();
|
||||
private NavigationParameterSet CurrentParameterSet { get; set; } = new();
|
||||
private TestScenario? SelectedScenario { get; set; }
|
||||
private TestMetrics? CurrentMetrics { get; set; }
|
||||
private string? CurrentTestRunId { get; set; }
|
||||
private TuningReport? CurrentTuningReport { get; set; }
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private bool IsTestRunning { get; set; } = false;
|
||||
private HistoryPage? _historyPage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Start SignalR connection
|
||||
await TuningHub.StartAsync();
|
||||
TuningHub.TestResultReceived += OnTestResultReceived;
|
||||
TuningHub.TestStatusUpdated += OnTestStatusUpdated;
|
||||
|
||||
// Load data from API
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add(ex.Message, Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khởi tạo hệ thống: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var scenariosTask = ApiService.GetScenariosAsync();
|
||||
var parameterSetsTask = ApiService.GetParameterSetsAsync();
|
||||
|
||||
await Task.WhenAll(scenariosTask, parameterSetsTask);
|
||||
|
||||
Scenarios = await scenariosTask;
|
||||
ParameterSets = await parameterSetsTask;
|
||||
|
||||
// If no parameter sets, load default preset
|
||||
if (ParameterSets.Count == 0)
|
||||
{
|
||||
var defaultPreset = await ApiService.GetDefaultPresetAsync();
|
||||
ParameterSets.Add(defaultPreset);
|
||||
}
|
||||
|
||||
// Set current parameter set to first one or default
|
||||
if (ParameterSets.Count > 0)
|
||||
{
|
||||
CurrentParameterSet = ParameterSets[0];
|
||||
}
|
||||
|
||||
// Set selected scenario to first one
|
||||
if (Scenarios.Count > 0)
|
||||
{
|
||||
SelectedScenario = Scenarios[0];
|
||||
}
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Không thể tải dữ liệu: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi tải dữ liệu: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStartTest((Guid ScenarioId, Guid ParameterSetId) args)
|
||||
{
|
||||
try
|
||||
{
|
||||
IsTestRunning = true;
|
||||
|
||||
// Leave previous test session if any
|
||||
if (!string.IsNullOrEmpty(CurrentTestRunId))
|
||||
{
|
||||
await TuningHub.LeaveTestSessionAsync(CurrentTestRunId);
|
||||
}
|
||||
|
||||
// Generate testRunId and join SignalR group BEFORE starting test so real-time telemetry is received
|
||||
var testRunId = Guid.NewGuid();
|
||||
CurrentTestRunId = testRunId.ToString();
|
||||
await TuningHub.JoinTestSessionAsync(CurrentTestRunId);
|
||||
|
||||
var connectionId = TuningHub.ConnectionState == HubConnectionState.Connected
|
||||
? TuningHub.GetHashCode().ToString() // Simple connection identifier
|
||||
: null;
|
||||
|
||||
var result = await ApiService.ExecuteTestAsync(args.ScenarioId, args.ParameterSetId, testRunId, connectionId);
|
||||
|
||||
// Keep CurrentTestRunId in sync with result (server may return same id)
|
||||
CurrentTestRunId = result.TestRunId.ToString();
|
||||
|
||||
var scenarioName = Scenarios.FirstOrDefault(s => s.Id == args.ScenarioId)?.Name ?? "Unknown";
|
||||
Snackbar.Add($"Đã bắt đầu test: {scenarioName} (ID: {result.TestRunId})", Severity.Success);
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
IsTestRunning = false;
|
||||
Snackbar.Add($"Không thể bắt đầu test: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsTestRunning = false;
|
||||
Snackbar.Add($"Lỗi khi bắt đầu test: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandlePauseTest()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(CurrentTestRunId))
|
||||
return;
|
||||
|
||||
await ApiService.PauseTestAsync(CurrentTestRunId);
|
||||
Snackbar.Add("Test đã được tạm dừng", Severity.Warning);
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Không thể tạm dừng test: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tạm dừng test: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleResumeTest()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(CurrentTestRunId))
|
||||
return;
|
||||
|
||||
await ApiService.ResumeTestAsync(CurrentTestRunId);
|
||||
Snackbar.Add("Test đã được tiếp tục", Severity.Info);
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Không thể tiếp tục test: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tiếp tục test: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStopTest()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(CurrentTestRunId))
|
||||
return;
|
||||
|
||||
await ApiService.StopTestAsync(CurrentTestRunId);
|
||||
IsTestRunning = false;
|
||||
Snackbar.Add("Test đã được dừng", Severity.Info);
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Không thể dừng test: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi dừng test: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleEmergencyStop()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(CurrentTestRunId))
|
||||
return;
|
||||
|
||||
await ApiService.EmergencyStopAsync(CurrentTestRunId);
|
||||
IsTestRunning = false;
|
||||
Snackbar.Add("Dừng khẩn cấp đã được kích hoạt!", Severity.Error);
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Không thể kích hoạt dừng khẩn cấp: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi kích hoạt dừng khẩn cấp: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSaveParameters(NavigationParameterSet parameterSet)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Ensure all required fields are set
|
||||
if (parameterSet == null)
|
||||
{
|
||||
Snackbar.Add("Parameter set không hợp lệ", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure all config objects are initialized
|
||||
parameterSet.MovePidConfig ??= new PIDConfig();
|
||||
parameterSet.RotatePidConfig ??= new PIDConfig();
|
||||
parameterSet.PurePursuitConfig ??= new PurePursuitConfig();
|
||||
parameterSet.StanleyConfig ??= new StanleyConfig();
|
||||
parameterSet.EstimatorConfig ??= new VelocityEstimatorConfig();
|
||||
parameterSet.SignalConfig ??= new VelocitySignalProcessingConfig();
|
||||
parameterSet.MotorDynamicsConfig ??= new MotorDynamicsConfig();
|
||||
parameterSet.NavigationConfig ??= new NavigationConfig();
|
||||
|
||||
// Validate first
|
||||
var validation = await ApiService.ValidateParameterSetAsync(parameterSet);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errorList = validation.Errors?.Any() == true
|
||||
? string.Join("\n", validation.Errors)
|
||||
: "Có lỗi validation không xác định";
|
||||
Snackbar.Add($"Thông số không hợp lệ:\n{errorList}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if name is provided for new parameter set
|
||||
if (parameterSet.Id == Guid.Empty && string.IsNullOrWhiteSpace(parameterSet.Name))
|
||||
{
|
||||
Snackbar.Add("Vui lòng nhập tên cho bộ thông số mới", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parameterSet.Id == Guid.Empty)
|
||||
{
|
||||
// Create new - ensure name is set
|
||||
if (string.IsNullOrWhiteSpace(parameterSet.Name))
|
||||
{
|
||||
parameterSet.Name = $"Parameter Set {DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
}
|
||||
var created = await ApiService.CreateParameterSetAsync(parameterSet);
|
||||
CurrentParameterSet = CloneParameterSet(created);
|
||||
ParameterSets.Add(created);
|
||||
Snackbar.Add($"Đã tạo bộ thông số mới: {created.Name}", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing - ensure ID is preserved
|
||||
var clonedSet = CloneParameterSet(parameterSet);
|
||||
clonedSet.Id = parameterSet.Id; // Ensure ID is preserved
|
||||
clonedSet.CreatedAt = parameterSet.CreatedAt; // Preserve original creation date
|
||||
clonedSet.Version = parameterSet.Version; // Preserve version
|
||||
|
||||
await ApiService.UpdateParameterSetAsync(parameterSet.Id, clonedSet);
|
||||
CurrentParameterSet = CloneParameterSet(clonedSet);
|
||||
|
||||
// Update in list
|
||||
var index = ParameterSets.FindIndex(p => p.Id == parameterSet.Id);
|
||||
if (index >= 0)
|
||||
ParameterSets[index] = CloneParameterSet(clonedSet);
|
||||
|
||||
Snackbar.Add($"Đã cập nhật bộ thông số: {parameterSet.Name}", Severity.Success);
|
||||
}
|
||||
|
||||
// Reload parameter sets
|
||||
ParameterSets = await ApiService.GetParameterSetsAsync();
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Không thể lưu thông số: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi lưu thông số: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleResetParameters()
|
||||
{
|
||||
CurrentParameterSet = new NavigationParameterSet();
|
||||
Snackbar.Add("Đã đặt lại thông số về mặc định", Severity.Info);
|
||||
}
|
||||
|
||||
private async Task HandleParameterSetSelected(NavigationParameterSet parameterSet)
|
||||
{
|
||||
CurrentParameterSet = CloneParameterSet(parameterSet);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleParameterSetCreated(NavigationParameterSet parameterSet)
|
||||
{
|
||||
ParameterSets = await ApiService.GetParameterSetsAsync();
|
||||
CurrentParameterSet = parameterSet;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleParameterSetUpdated(NavigationParameterSet parameterSet)
|
||||
{
|
||||
ParameterSets = await ApiService.GetParameterSetsAsync();
|
||||
var index = ParameterSets.FindIndex(p => p.Id == parameterSet.Id);
|
||||
if (index >= 0)
|
||||
{
|
||||
ParameterSets[index] = parameterSet;
|
||||
}
|
||||
if (CurrentParameterSet.Id == parameterSet.Id)
|
||||
{
|
||||
CurrentParameterSet = CloneParameterSet(parameterSet);
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleParameterSetDeleted(Guid parameterSetId)
|
||||
{
|
||||
ParameterSets = await ApiService.GetParameterSetsAsync();
|
||||
if (CurrentParameterSet.Id == parameterSetId)
|
||||
{
|
||||
CurrentParameterSet = ParameterSets.Count > 0 ? ParameterSets[0] : new NavigationParameterSet();
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleParameterSetChanged(NavigationParameterSet parameterSet)
|
||||
{
|
||||
CurrentParameterSet = CloneParameterSet(parameterSet);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleApplySuggestions(NavigationParameterSet modified)
|
||||
{
|
||||
// Apply suggestions as preview: update current parameter set in UI without saving
|
||||
CurrentParameterSet = CloneParameterSet(modified);
|
||||
Snackbar.Add("Đã áp dụng gợi ý vào tham số hiện tại (chưa lưu)", Severity.Info);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleSaveNewParameterSet(NavigationParameterSet saved)
|
||||
{
|
||||
// New parameter set was saved by the API; reload list and switch to it
|
||||
ParameterSets = await ApiService.GetParameterSetsAsync();
|
||||
CurrentParameterSet = CloneParameterSet(saved);
|
||||
Snackbar.Add($"Đã lưu bộ thông số mới: {saved.Name}", Severity.Success);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private NavigationParameterSet CloneParameterSet(NavigationParameterSet source)
|
||||
{
|
||||
return new NavigationParameterSet
|
||||
{
|
||||
Id = source.Id,
|
||||
Name = source.Name,
|
||||
Description = source.Description,
|
||||
CreatedAt = source.CreatedAt,
|
||||
UpdatedAt = source.UpdatedAt,
|
||||
IsDefault = source.IsDefault,
|
||||
Version = source.Version,
|
||||
ControllerType = source.ControllerType,
|
||||
MovePidConfig = new PIDConfig
|
||||
{
|
||||
Kp = source.MovePidConfig.Kp,
|
||||
Ki = source.MovePidConfig.Ki,
|
||||
Kd = source.MovePidConfig.Kd
|
||||
},
|
||||
RotatePidConfig = new PIDConfig
|
||||
{
|
||||
Kp = source.RotatePidConfig.Kp,
|
||||
Ki = source.RotatePidConfig.Ki,
|
||||
Kd = source.RotatePidConfig.Kd
|
||||
},
|
||||
PurePursuitConfig = new PurePursuitConfig
|
||||
{
|
||||
LookaheadMin = source.PurePursuitConfig.LookaheadMin,
|
||||
LookaheadMax = source.PurePursuitConfig.LookaheadMax,
|
||||
Kdd = source.PurePursuitConfig.Kdd,
|
||||
MaxAngularVelocity = source.PurePursuitConfig.MaxAngularVelocity,
|
||||
ResolutionSplit = source.PurePursuitConfig.ResolutionSplit,
|
||||
FinalApproachThreshold = source.PurePursuitConfig.FinalApproachThreshold,
|
||||
HeadingTolerance = source.PurePursuitConfig.HeadingTolerance,
|
||||
GoalRegionDistance = source.PurePursuitConfig.GoalRegionDistance,
|
||||
KCurvature = source.PurePursuitConfig.KCurvature,
|
||||
MinLookaheadTimeRatio = source.PurePursuitConfig.MinLookaheadTimeRatio,
|
||||
MaxLookaheadTimeRatio = source.PurePursuitConfig.MaxLookaheadTimeRatio
|
||||
},
|
||||
StanleyConfig = new StanleyConfig
|
||||
{
|
||||
K = source.StanleyConfig.K,
|
||||
Ks = source.StanleyConfig.Ks,
|
||||
WheelBase = source.StanleyConfig.WheelBase,
|
||||
MaxSteeringAngle = source.StanleyConfig.MaxSteeringAngle,
|
||||
EnableCurvatureFeedforward = source.StanleyConfig.EnableCurvatureFeedforward,
|
||||
KCurvatureFF = source.StanleyConfig.KCurvatureFF,
|
||||
GoalTolerance = source.StanleyConfig.GoalTolerance,
|
||||
HeadingTolerance = source.StanleyConfig.HeadingTolerance,
|
||||
ResolutionSplit = source.StanleyConfig.ResolutionSplit,
|
||||
GoalApproachDistance = source.StanleyConfig.GoalApproachDistance,
|
||||
GoalGainMultiplier = source.StanleyConfig.GoalGainMultiplier,
|
||||
LowSpeedThreshold = source.StanleyConfig.LowSpeedThreshold,
|
||||
LowSpeedAngularGain = source.StanleyConfig.LowSpeedAngularGain
|
||||
},
|
||||
EstimatorConfig = new VelocityEstimatorConfig
|
||||
{
|
||||
MinBlendRatio = source.EstimatorConfig.MinBlendRatio,
|
||||
MaxBlendRatio = source.EstimatorConfig.MaxBlendRatio,
|
||||
DefaultBlendRatio = source.EstimatorConfig.DefaultBlendRatio,
|
||||
GoodTrackingBlend = source.EstimatorConfig.GoodTrackingBlend,
|
||||
ModerateTrackingBlend = source.EstimatorConfig.ModerateTrackingBlend,
|
||||
PoorTrackingBlend = source.EstimatorConfig.PoorTrackingBlend,
|
||||
GoodTrackingThreshold = source.EstimatorConfig.GoodTrackingThreshold,
|
||||
ModerateTrackingThreshold = source.EstimatorConfig.ModerateTrackingThreshold,
|
||||
ConfidenceDecayRate = source.EstimatorConfig.ConfidenceDecayRate,
|
||||
MinConfidence = source.EstimatorConfig.MinConfidence
|
||||
},
|
||||
SignalConfig = new VelocitySignalProcessingConfig
|
||||
{
|
||||
AlphaFilter = source.SignalConfig.AlphaFilter,
|
||||
NoiseThreshold = source.SignalConfig.NoiseThreshold
|
||||
},
|
||||
MotorDynamicsConfig = new MotorDynamicsConfig
|
||||
{
|
||||
Tau = source.MotorDynamicsConfig.Tau,
|
||||
Delta = source.MotorDynamicsConfig.Delta
|
||||
},
|
||||
NavigationConfig = new NavigationConfig
|
||||
{
|
||||
MaxLinearVelocity = source.NavigationConfig.MaxLinearVelocity,
|
||||
MinLinearVelocity = source.NavigationConfig.MinLinearVelocity,
|
||||
MaxAngularVelocity = source.NavigationConfig.MaxAngularVelocity,
|
||||
RotateAngularVelocity = source.NavigationConfig.RotateAngularVelocity,
|
||||
ReachedRadius = source.NavigationConfig.ReachedRadius,
|
||||
InitialRotationThreshold = source.NavigationConfig.InitialRotationThreshold,
|
||||
Acceleration = source.NavigationConfig.Acceleration,
|
||||
Deceleration = source.NavigationConfig.Deceleration
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async void OnTestResultReceived(TestExecutionResult result)
|
||||
{
|
||||
CurrentMetrics = result.Metrics;
|
||||
CurrentTestRunId = result.TestRunId.ToString();
|
||||
CurrentTuningReport = result.TuningReport;
|
||||
IsTestRunning = false;
|
||||
if (_historyPage?.TestHistoryViewerRef != null)
|
||||
_ = _historyPage.TestHistoryViewerRef.RefreshAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnTestStatusUpdated(TestStatusUpdateDto status)
|
||||
{
|
||||
if (status.Status == TestStatus.Completed || status.Status == TestStatus.Aborted ||
|
||||
status.Status == TestStatus.Error || status.Status == TestStatus.EmergencyStopped)
|
||||
{
|
||||
IsTestRunning = false;
|
||||
}
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleScenarioChanged(TestScenario scenario)
|
||||
{
|
||||
SelectedScenario = scenario;
|
||||
|
||||
// Update in scenarios list
|
||||
var index = Scenarios.FindIndex(s => s.Id == scenario.Id);
|
||||
if (index >= 0)
|
||||
{
|
||||
Scenarios[index] = scenario;
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleScenarioSelected(TestScenario scenario)
|
||||
{
|
||||
SelectedScenario = scenario;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleScenarioCreated(TestScenario scenario)
|
||||
{
|
||||
// Add new scenario to list
|
||||
if (!Scenarios.Any(s => s.Id == scenario.Id))
|
||||
{
|
||||
Scenarios.Add(scenario);
|
||||
}
|
||||
SelectedScenario = scenario;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleScenarioDeleted(Guid scenarioId)
|
||||
{
|
||||
Scenarios.RemoveAll(s => s.Id == scenarioId);
|
||||
if (SelectedScenario?.Id == scenarioId)
|
||||
SelectedScenario = Scenarios.FirstOrDefault();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void HandleShowMetricsFromHistory(TestMetrics? metrics)
|
||||
{
|
||||
CurrentMetrics = metrics;
|
||||
CurrentPage = "test"; // Switch to test page to show metrics
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
@using RobotNet10.NavigationTuneUI.Services
|
||||
@using RobotNet10.NavigationTuneUI.Helpers
|
||||
@using MudBlazor
|
||||
@inject TuningApiService ApiService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard Style="width: 100%; height: 675px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
|
||||
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Manual Velocity Control</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Direct robot control</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="flex: 1; overflow-x: auto; overflow-y: auto; box-sizing: border-box;">
|
||||
<MudGrid Spacing="3">
|
||||
<!-- Linear Velocity Control (Vertical Slider) -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-3">Linear Velocity (m/s)</MudText>
|
||||
<MudStack AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Forward</MudText>
|
||||
|
||||
<!-- Vertical Slider Container -->
|
||||
<div style="height: 300px; display: flex; align-items: center; justify-content: center; position: relative;">
|
||||
<MudSlider T="double"
|
||||
Value="@LinearVelocity"
|
||||
ValueChanged="@OnLinearVelocityChanged"
|
||||
Min="@(-MaxLinearVelocity)"
|
||||
Max="@MaxLinearVelocity"
|
||||
Step="0.01"
|
||||
Vertical="true"
|
||||
Immediate="true"
|
||||
Class="vertical-slider align-content-center"
|
||||
Size="Size.Large"
|
||||
ValueLabel=" true"
|
||||
Style="width: 300px"
|
||||
ValueLabelFormat="@($"{LinearVelocity:F2} m/s")">
|
||||
</MudSlider>
|
||||
</div>
|
||||
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Backward</MudText>
|
||||
|
||||
<!-- Linear Velocity Display and Reset -->
|
||||
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center" Justify="@Justify.Center">
|
||||
<MudNumericField T="double"
|
||||
Value="@LinearVelocity"
|
||||
ValueChanged="@OnLinearVelocityChanged"
|
||||
Label="Linear (m/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="@(-MaxLinearVelocity)"
|
||||
Max="@MaxLinearVelocity"
|
||||
Step="0.01"
|
||||
Immediate="true"
|
||||
Margin="Margin.Dense"
|
||||
Style="width: 120px;" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Class="mt-1"
|
||||
OnClick="ResetLinearVelocity">
|
||||
Reset
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<!-- Angular Velocity Control (Horizontal Slider) -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-3">Angular Velocity (rad/s)</MudText>
|
||||
<MudStack AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center" Justify="@Justify.Center" Style="width: 100%;">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Left</MudText>
|
||||
|
||||
<!-- Horizontal Slider -->
|
||||
<MudSlider T="double"
|
||||
Value="@AngularVelocity"
|
||||
ValueChanged="@OnAngularVelocityChanged"
|
||||
Min="@(-MaxAngularVelocity)"
|
||||
Max="@MaxAngularVelocity"
|
||||
Step="0.01"
|
||||
Size="Size.Large"
|
||||
Immediate="true"
|
||||
Class="horizontal-slider"
|
||||
ValueLabel=" true"
|
||||
Style="flex: 1; max-width: 300px;"
|
||||
ValueLabelFormat="@($"{AngularVelocity:F2} rad/s")">
|
||||
</MudSlider>
|
||||
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Right</MudText>
|
||||
</MudStack>
|
||||
|
||||
<!-- Angular Velocity Display and Reset -->
|
||||
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center" Justify="@Justify.Center">
|
||||
<MudNumericField T="double"
|
||||
Value="@AngularVelocity"
|
||||
ValueChanged="@OnAngularVelocityChanged"
|
||||
Label="Angular (rad/s)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="@(-MaxAngularVelocity)"
|
||||
Max="@MaxAngularVelocity"
|
||||
Step="0.01"
|
||||
Immediate="true"
|
||||
Margin="Margin.Dense"
|
||||
Style="width: 120px;" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Class="mt-1"
|
||||
OnClick="ResetAngularVelocity">
|
||||
Reset
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<!-- Stop Button -->
|
||||
<MudItem xs="12">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Justify="@Justify.Center" Spacing="2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.Stop"
|
||||
OnClick="StopAllVelocities"
|
||||
Style="min-width: 150px;">
|
||||
Stop All
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<!-- Current Velocity Display -->
|
||||
<MudItem xs="12">
|
||||
<MudDivider Class="my-2" />
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="text-center">
|
||||
Current: Linear = @CurrentLinear.ToString("F2") m/s, Angular = @CurrentAngular.ToString("F2") rad/s
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
private double LinearVelocity { get; set; } = 0.0;
|
||||
private double AngularVelocity { get; set; } = 0.0;
|
||||
private double CurrentLinear { get; set; } = 0.0;
|
||||
private double CurrentAngular { get; set; } = 0.0;
|
||||
|
||||
private const double MaxLinearVelocity = 1.5; // m/s
|
||||
private const double MaxAngularVelocity = 3.0; // rad/s
|
||||
|
||||
private PeriodicTimer? _velocityUpdateTimer;
|
||||
private CancellationTokenSource? _timersCts;
|
||||
private Task? _velocityUpdateLoopTask;
|
||||
|
||||
private bool _isUpdating = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
_timersCts = new CancellationTokenSource();
|
||||
|
||||
// 1) Periodically update displayed current velocity (2Hz)
|
||||
_velocityUpdateTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
|
||||
_velocityUpdateLoopTask = RunVelocityUpdateLoopAsync(_timersCts.Token);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
|
||||
}
|
||||
|
||||
private async Task OnLinearVelocityChanged(double value)
|
||||
{
|
||||
if (_isUpdating) return;
|
||||
|
||||
LinearVelocity = value;
|
||||
await SendVelocityCommand();
|
||||
}
|
||||
|
||||
private async Task OnAngularVelocityChanged(double value)
|
||||
{
|
||||
if (_isUpdating) return;
|
||||
|
||||
AngularVelocity = value;
|
||||
await SendVelocityCommand();
|
||||
}
|
||||
|
||||
private async Task SendVelocityCommand()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ApiService.SetVelocityAsync(LinearVelocity, AngularVelocity);
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Không thể gửi lệnh tốc độ: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi gửi lệnh tốc độ: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunVelocityUpdateLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_velocityUpdateTimer == null) return;
|
||||
|
||||
// Initial update immediately
|
||||
await UpdateCurrentVelocity();
|
||||
|
||||
while (await _velocityUpdateTimer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
await UpdateCurrentVelocity();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// expected on dispose
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail - connection might not be available
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetLinearVelocity()
|
||||
{
|
||||
_isUpdating = true;
|
||||
LinearVelocity = 0.0;
|
||||
_isUpdating = false;
|
||||
await SendVelocityCommand();
|
||||
Snackbar.Add("Đã reset tốc độ tịnh tiến về 0", Severity.Info);
|
||||
}
|
||||
|
||||
private async Task ResetAngularVelocity()
|
||||
{
|
||||
_isUpdating = true;
|
||||
AngularVelocity = 0.0;
|
||||
_isUpdating = false;
|
||||
await SendVelocityCommand();
|
||||
Snackbar.Add("Đã reset tốc độ xoay về 0", Severity.Info);
|
||||
}
|
||||
|
||||
private async Task StopAllVelocities()
|
||||
{
|
||||
_isUpdating = true;
|
||||
LinearVelocity = 0.0;
|
||||
AngularVelocity = 0.0;
|
||||
_isUpdating = false;
|
||||
|
||||
try
|
||||
{
|
||||
await ApiService.StopVelocityAsync();
|
||||
Snackbar.Add("Đã dừng tất cả tốc độ", Severity.Success);
|
||||
}
|
||||
catch (ApiException ex)
|
||||
{
|
||||
Snackbar.Add($"Không thể dừng robot: {ex.Message}", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi dừng robot: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateCurrentVelocity()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (linear, angular) = await ApiService.GetCurrentVelocityAsync();
|
||||
CurrentLinear = linear;
|
||||
CurrentAngular = angular;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail - connection might not be available
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Stop timers/loops first so we don't keep sending commands after disposal.
|
||||
try
|
||||
{
|
||||
_timersCts?.Cancel();
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
if (_velocityUpdateLoopTask != null)
|
||||
await _velocityUpdateLoopTask;
|
||||
}
|
||||
catch { }
|
||||
|
||||
try { _velocityUpdateTimer?.Dispose(); } catch { }
|
||||
try { _timersCts?.Dispose(); } catch { }
|
||||
|
||||
// Stop velocities when component is disposed (navigate away, refresh, etc.)
|
||||
try
|
||||
{
|
||||
await ApiService.StopVelocityAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors on dispose
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace RobotNet10.NavigationTuneUI.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Custom exception for API errors with meaningful messages
|
||||
/// </summary>
|
||||
public class ApiException : Exception
|
||||
{
|
||||
public int? StatusCode { get; }
|
||||
|
||||
public ApiException(string message, int? statusCode = null) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public ApiException(string message, Exception innerException, int? statusCode = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.NavigationTuneUI.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for extracting meaningful error messages from HTTP responses
|
||||
/// </summary>
|
||||
public static class ErrorHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Extract error message from HTTP response
|
||||
/// </summary>
|
||||
public static async Task<string> GetErrorMessageAsync(HttpResponseMessage response)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to read as JSON error object
|
||||
var errorObject = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
// Check for common error formats
|
||||
if (errorObject.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Format: { error: "message" }
|
||||
if (errorObject.TryGetProperty("error", out var errorProp) &&
|
||||
errorProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var errorMessage = errorProp.GetString();
|
||||
|
||||
// If there's a details field, append it
|
||||
if (errorObject.TryGetProperty("details", out var detailsProp) &&
|
||||
detailsProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var details = detailsProp.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(details))
|
||||
{
|
||||
return $"{errorMessage}: {details}";
|
||||
}
|
||||
}
|
||||
|
||||
// If there's an errors array (validation errors), append them
|
||||
if (errorObject.TryGetProperty("errors", out var errorsProp) &&
|
||||
errorsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var errors = errorsProp.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.ToList();
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return $"{errorMessage}\n{string.Join("\n", errors)}";
|
||||
}
|
||||
}
|
||||
|
||||
return errorMessage ?? GetDefaultErrorMessage(response.StatusCode);
|
||||
}
|
||||
|
||||
// Format: { message: "message" }
|
||||
if (errorObject.TryGetProperty("message", out var messageProp) &&
|
||||
messageProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return messageProp.GetString() ?? GetDefaultErrorMessage(response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read as plain text
|
||||
var textContent = await response.Content.ReadAsStringAsync();
|
||||
if (!string.IsNullOrWhiteSpace(textContent))
|
||||
{
|
||||
return textContent;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If parsing fails, fall back to status code message
|
||||
}
|
||||
|
||||
return GetDefaultErrorMessage(response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get default error message based on HTTP status code
|
||||
/// </summary>
|
||||
private static string GetDefaultErrorMessage(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => "Yêu cầu không hợp lệ. Vui lòng kiểm tra lại dữ liệu đầu vào.",
|
||||
HttpStatusCode.Unauthorized => "Bạn chưa được xác thực. Vui lòng đăng nhập lại.",
|
||||
HttpStatusCode.Forbidden => "Bạn không có quyền thực hiện thao tác này.",
|
||||
HttpStatusCode.NotFound => "Không tìm thấy tài nguyên yêu cầu.",
|
||||
HttpStatusCode.Conflict => "Xung đột dữ liệu. Có thể tài nguyên đã tồn tại.",
|
||||
HttpStatusCode.InternalServerError => "Lỗi máy chủ. Vui lòng thử lại sau.",
|
||||
HttpStatusCode.ServiceUnavailable => "Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.",
|
||||
HttpStatusCode.RequestTimeout => "Yêu cầu quá thời gian chờ. Vui lòng thử lại.",
|
||||
_ => $"Lỗi không xác định ({(int)statusCode} {statusCode})"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract error message from exception
|
||||
/// </summary>
|
||||
public static string GetErrorMessage(Exception ex)
|
||||
{
|
||||
// For HttpRequestException, try to extract more meaningful message
|
||||
if (ex is HttpRequestException httpEx)
|
||||
{
|
||||
// Check if it's a connection error
|
||||
if (httpEx.Message.Contains("connection") || httpEx.Message.Contains("refused"))
|
||||
{
|
||||
return "Không thể kết nối đến máy chủ. Vui lòng kiểm tra kết nối mạng.";
|
||||
}
|
||||
|
||||
// Check if it's a timeout
|
||||
if (httpEx.Message.Contains("timeout"))
|
||||
{
|
||||
return "Yêu cầu quá thời gian chờ. Vui lòng thử lại.";
|
||||
}
|
||||
|
||||
return httpEx.Message;
|
||||
}
|
||||
|
||||
// For TaskCanceledException (often timeout)
|
||||
if (ex is TaskCanceledException)
|
||||
{
|
||||
return "Yêu cầu quá thời gian chờ. Vui lòng thử lại.";
|
||||
}
|
||||
|
||||
// For JsonException
|
||||
if (ex is JsonException)
|
||||
{
|
||||
return "Lỗi xử lý dữ liệu từ máy chủ. Vui lòng thử lại.";
|
||||
}
|
||||
|
||||
// For InvalidOperationException
|
||||
if (ex is InvalidOperationException)
|
||||
{
|
||||
return ex.Message;
|
||||
}
|
||||
|
||||
// Default: return exception message
|
||||
return ex.Message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.Json" Version="10.0.3" />
|
||||
<PackageReference Include="Blazor-ApexCharts" Version="4.0.0" />
|
||||
<PackageReference Include="MudBlazor" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\RobotNet10.NavigationTune.Shared\RobotNet10.NavigationTune.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using MudBlazor
|
||||
Reference in New Issue
Block a user