Initial commit

This commit is contained in:
2026-07-03 16:31:37 +07:00
commit 899c7c637d
1939 changed files with 641750 additions and 0 deletions

View File

@@ -0,0 +1,245 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Controllers;
/// <summary>
/// REST API controller for parameter sets management
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class ParameterSetsController(
IParameterManager parameterManager,
ILogger<ParameterSetsController> logger) : ControllerBase
{
/// <summary>
/// Get all parameter sets
/// </summary>
[HttpGet]
public async Task<ActionResult<List<NavigationParameterSet>>> GetAll()
{
try
{
var parameterSets = await parameterManager.GetAllAsync();
return Ok(parameterSets);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting all parameter sets");
return StatusCode(500, new { error = "Failed to retrieve parameter sets" });
}
}
/// <summary>
/// Get parameter set by ID
/// </summary>
[HttpGet("{id:guid}")]
public async Task<ActionResult<NavigationParameterSet>> GetById(Guid id)
{
try
{
var parameterSet = await parameterManager.GetByIdAsync(id);
if (parameterSet == null)
return NotFound(new { error = $"Parameter set with ID {id} not found" });
return Ok(parameterSet);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting parameter set {Id}", id);
return StatusCode(500, new { error = "Failed to retrieve parameter set" });
}
}
/// <summary>
/// Get parameter set by name
/// </summary>
[HttpGet("name/{name}")]
public async Task<ActionResult<NavigationParameterSet>> GetByName(string name)
{
try
{
var parameterSet = await parameterManager.GetByNameAsync(name);
if (parameterSet == null)
return NotFound(new { error = $"Parameter set with name '{name}' not found" });
return Ok(parameterSet);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting parameter set {Name}", name);
return StatusCode(500, new { error = "Failed to retrieve parameter set" });
}
}
/// <summary>
/// Create new parameter set
/// </summary>
[HttpPost]
public async Task<ActionResult<NavigationParameterSet>> Create([FromBody] NavigationParameterSet parameterSet)
{
try
{
// Validate
if(await parameterManager.GetByNameAsync(parameterSet.Name) is not null) return StatusCode(500, new { error = "Paramter name is existed" });
var validation = parameterManager.Validate(parameterSet);
if (!validation.IsValid)
{
return BadRequest(new
{
error = "Validation failed",
errors = validation.Errors,
warnings = validation.Warnings
});
}
var id = await parameterManager.SaveAsync(parameterSet);
var created = await parameterManager.GetByIdAsync(id);
return CreatedAtAction(nameof(GetById), new { id }, created);
}
catch (Exception ex)
{
logger.LogError(ex, "Error creating parameter set");
return StatusCode(500, new { error = "Failed to create parameter set" });
}
}
/// <summary>
/// Update existing parameter set
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult> Update(Guid id, [FromBody] NavigationParameterSet parameterSet)
{
try
{
var existing = await parameterManager.GetByIdAsync(id);
if (existing == null)
return NotFound(new { error = $"Parameter set with ID {id} not found" });
// Validate
var validation = parameterManager.Validate(parameterSet);
if (!validation.IsValid)
{
return BadRequest(new
{
error = "Validation failed",
errors = validation.Errors,
warnings = validation.Warnings
});
}
existing.ControllerType = parameterSet.ControllerType;
existing.PurePursuitConfig = parameterSet.PurePursuitConfig;
existing.StanleyConfig = parameterSet.StanleyConfig ?? new StanleyConfig();
existing.EstimatorConfig = parameterSet.EstimatorConfig;
existing.NavigationConfig = parameterSet.NavigationConfig;
existing.MovePidConfig = parameterSet.MovePidConfig;
existing.RotatePidConfig = parameterSet.RotatePidConfig;
existing.SignalConfig = parameterSet.SignalConfig;
existing.MotorDynamicsConfig = parameterSet.MotorDynamicsConfig;
await parameterManager.UpdateAsync(existing);
return NoContent();
}
catch (Exception ex)
{
logger.LogError(ex, "Error updating parameter set {Id}", id);
return StatusCode(500, new { error = "Failed to update parameter set" });
}
}
/// <summary>
/// Delete parameter set
/// </summary>
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
try
{
var existing = await parameterManager.GetByIdAsync(id);
if (existing == null)
return NotFound(new { error = $"Parameter set with ID {id} not found" });
await parameterManager.DeleteAsync(id);
return NoContent();
}
catch (Exception ex)
{
logger.LogError(ex, "Error deleting parameter set {Id}", id);
return StatusCode(500, new { error = "Failed to delete parameter set" });
}
}
/// <summary>
/// Validate parameter set
/// </summary>
[HttpPost("validate")]
public ActionResult<ValidationResult> Validate([FromBody] NavigationParameterSet parameterSet)
{
try
{
var validation = parameterManager.Validate(parameterSet);
return Ok(validation);
}
catch (Exception ex)
{
logger.LogError(ex, "Error validating parameter set");
return StatusCode(500, new { error = "Failed to validate parameter set" });
}
}
/// <summary>
/// Get default preset
/// </summary>
[HttpGet("presets/default")]
public ActionResult<NavigationParameterSet> GetDefaultPreset()
{
try
{
var preset = parameterManager.GetDefaultPreset();
return Ok(preset);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting default preset");
return StatusCode(500, new { error = "Failed to get default preset" });
}
}
/// <summary>
/// Get aggressive preset
/// </summary>
[HttpGet("presets/aggressive")]
public ActionResult<NavigationParameterSet> GetAggressivePreset()
{
try
{
var preset = parameterManager.GetAggressivePreset();
return Ok(preset);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting aggressive preset");
return StatusCode(500, new { error = "Failed to get aggressive preset" });
}
}
/// <summary>
/// Get smooth preset
/// </summary>
[HttpGet("presets/smooth")]
public ActionResult<NavigationParameterSet> GetSmoothPreset()
{
try
{
var preset = parameterManager.GetSmoothPreset();
return Ok(preset);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting smooth preset");
return StatusCode(500, new { error = "Failed to get smooth preset" });
}
}
}

View File

@@ -0,0 +1,239 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.NavigationTune.Data;
using RobotNet10.NavigationTune.Shared.Models;
using StraightLineScenario = RobotNet10.NavigationTune.Scenarios.StraightLineScenario;
using CircleScenario = RobotNet10.NavigationTune.Scenarios.CircleScenario;
using CustomPathScenario = RobotNet10.NavigationTune.Scenarios.CustomPathScenario;
namespace RobotNet10.NavigationTune.Controllers;
/// <summary>
/// DTO for TestScenario API responses
/// </summary>
public class TestScenarioDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public TrajectoryType Type { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsDefault { get; set; }
public string ConfigJson { get; set; } = string.Empty;
public static TestScenarioDto FromTestScenario(TestScenario scenario, TestScenarioEntity entity)
{
return new TestScenarioDto
{
Id = scenario.Id,
Name = scenario.Name,
Description = scenario.Description,
Type = scenario.Type,
CreatedAt = scenario.CreatedAt,
IsDefault = scenario.IsDefault,
ConfigJson = entity.ConfigJson
};
}
}
/// <summary>
/// Request DTO for Create/Update scenario. Uses Type + ConfigJson to avoid deserializing abstract TestScenario.
/// </summary>
public class CreateOrUpdateScenarioRequest
{
public Guid? Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public TrajectoryType Type { get; set; }
public bool IsDefault { get; set; }
/// <summary>JSON string of the scenario-specific config (serialized StraightLineScenario, CircleScenario, or CustomPathScenario).</summary>
public string ConfigJson { get; set; } = string.Empty;
}
/// <summary>
/// REST API controller for test scenarios management
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class ScenariosController(
IScenarioRepository scenarioRepository,
ILogger<ScenariosController> logger) : ControllerBase
{
private readonly IScenarioRepository _scenarioRepository = scenarioRepository;
private readonly ILogger<ScenariosController> _logger = logger;
/// <summary>
/// Get all scenarios
/// </summary>
[HttpGet]
public async Task<ActionResult<List<TestScenarioDto>>> GetAll()
{
try
{
var entities = await _scenarioRepository.GetAllEntitiesAsync();
var dtos = new List<TestScenarioDto>();
foreach (var entity in entities)
{
var scenario = entity.ToTestScenario();
dtos.Add(TestScenarioDto.FromTestScenario(scenario, entity));
}
return Ok(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting all scenarios");
return StatusCode(500, new { error = "Failed to retrieve scenarios" });
}
}
/// <summary>
/// Get default scenarios
/// </summary>
[HttpGet("defaults")]
public async Task<ActionResult<List<TestScenarioDto>>> GetDefaults()
{
try
{
var entities = await _scenarioRepository.GetDefaultScenarioEntitiesAsync();
var dtos = new List<TestScenarioDto>();
foreach (var entity in entities)
{
var scenario = entity.ToTestScenario();
dtos.Add(TestScenarioDto.FromTestScenario(scenario, entity));
}
return Ok(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting default scenarios");
return StatusCode(500, new { error = "Failed to retrieve default scenarios" });
}
}
/// <summary>
/// Get scenario by ID
/// </summary>
[HttpGet("{id:guid}")]
public async Task<ActionResult<TestScenarioDto>> GetById(Guid id)
{
try
{
var entity = await _scenarioRepository.GetEntityByIdAsync(id);
if (entity == null)
return NotFound(new { error = $"Scenario with ID {id} not found" });
var scenario = entity.ToTestScenario();
return Ok(TestScenarioDto.FromTestScenario(scenario, entity));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting scenario {Id}", id);
return StatusCode(500, new { error = "Failed to retrieve scenario" });
}
}
/// <summary>
/// Create new scenario. Accepts Type + ConfigJson to avoid deserializing abstract TestScenario.
/// </summary>
[HttpPost]
public async Task<ActionResult<TestScenarioDto>> Create([FromBody] CreateOrUpdateScenarioRequest request)
{
try
{
var scenario = DeserializeScenarioFromRequest(request);
if (scenario.Id == Guid.Empty)
scenario.Id = Guid.NewGuid();
var id = await _scenarioRepository.SaveAsync(scenario);
var entity = await _scenarioRepository.GetEntityByIdAsync(id);
if (entity == null)
return StatusCode(500, new { error = "Failed to retrieve created scenario" });
var dto = TestScenarioDto.FromTestScenario(entity.ToTestScenario(), entity);
return CreatedAtAction(nameof(GetById), new { id }, dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating scenario");
return StatusCode(500, new { error = "Failed to create scenario" });
}
}
/// <summary>
/// Update existing scenario. Accepts Type + ConfigJson to avoid deserializing abstract TestScenario.
/// </summary>
[HttpPut("{id:guid}")]
public async Task<ActionResult> Update(Guid id, [FromBody] CreateOrUpdateScenarioRequest request)
{
try
{
var existing = await _scenarioRepository.GetEntityByIdAsync(id);
if (existing == null)
return NotFound(new { error = $"Scenario with ID {id} not found" });
var scenario = DeserializeScenarioFromRequest(request);
scenario.Id = id;
await _scenarioRepository.UpdateAsync(scenario);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating scenario {Id}", id);
return StatusCode(500, new { error = "Failed to update scenario" });
}
}
/// <summary>
/// Deserialize ConfigJson to concrete TestScenario based on Type (avoids abstract type deserialization).
/// </summary>
private static TestScenario DeserializeScenarioFromRequest(CreateOrUpdateScenarioRequest request)
{
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
TestScenario scenario = request.Type switch
{
TrajectoryType.StraightLine => JsonSerializer.Deserialize<StraightLineScenario>(request.ConfigJson, jsonOptions)
?? new StraightLineScenario(),
TrajectoryType.Circle => JsonSerializer.Deserialize<CircleScenario>(request.ConfigJson, jsonOptions)
?? new CircleScenario(),
TrajectoryType.Custom => JsonSerializer.Deserialize<CustomPathScenario>(request.ConfigJson, jsonOptions)
?? new CustomPathScenario(),
_ => throw new NotSupportedException($"Scenario type {request.Type} is not supported")
};
scenario.Id = request.Id ?? Guid.Empty;
scenario.Name = request.Name;
scenario.Description = request.Description;
scenario.Type = request.Type;
scenario.IsDefault = request.IsDefault;
return scenario;
}
/// <summary>
/// Delete scenario. Only custom (non-default) scenarios can be deleted.
/// </summary>
[HttpDelete("{id:guid}")]
public async Task<ActionResult> Delete(Guid id)
{
try
{
var existing = await _scenarioRepository.GetByIdAsync(id);
if (existing == null)
return NotFound(new { error = $"Scenario with ID {id} not found" });
if (existing.IsDefault)
return BadRequest(new { error = "Cannot delete default scenario. Only custom scenarios can be deleted." });
await _scenarioRepository.DeleteAsync(id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting scenario {Id}", id);
return StatusCode(500, new { error = "Failed to delete scenario" });
}
}
}

View File

@@ -0,0 +1,194 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Controllers;
/// <summary>
/// REST API controller for test runs (test history)
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class TestRunsController : ControllerBase
{
private readonly ITestRepository _testRepository;
private readonly ILogger<TestRunsController> _logger;
public TestRunsController(
ITestRepository testRepository,
ILogger<TestRunsController> logger)
{
_testRepository = testRepository;
_logger = logger;
}
/// <summary>
/// Get test runs with optional pagination (returns DTO to avoid abstract TestScenario deserialization on client).
/// Use skip/take for paging; totalCount is returned for paginator.
/// </summary>
[HttpGet]
public async Task<ActionResult<PagedResult<TestRunDto>>> GetAll(
[FromQuery] int? skip = null,
[FromQuery] int? take = null,
[FromQuery] int? limit = null)
{
try
{
int skipVal = skip ?? 0;
int takeVal;
if (limit.HasValue && limit.Value > 0 && !take.HasValue)
takeVal = limit.Value;
else
takeVal = Math.Min(take ?? 50, 500);
var totalCount = await _testRepository.GetCountAsync();
var testRuns = await _testRepository.GetPagedAsync(skipVal, takeVal);
var items = testRuns.Select(ToDto).ToList();
return Ok(new PagedResult<TestRunDto> { TotalCount = totalCount, Items = items });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting all test runs");
return StatusCode(500, new { error = "Failed to retrieve test runs" });
}
}
/// <summary>
/// Get test run by ID
/// </summary>
[HttpGet("{id:guid}")]
public async Task<ActionResult<TestRunDto>> GetById(Guid id)
{
try
{
var testRun = await _testRepository.GetByIdAsync(id);
if (testRun == null)
return NotFound(new { error = $"Test run with ID {id} not found" });
return Ok(ToDto(testRun));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting test run {Id}", id);
return StatusCode(500, new { error = "Failed to retrieve test run" });
}
}
/// <summary>
/// Get test runs by scenario ID
/// </summary>
[HttpGet("scenario/{scenarioId}")]
public async Task<ActionResult<List<TestRunDto>>> GetByScenario(Guid scenarioId)
{
try
{
var testRuns = await _testRepository.GetByScenarioAsync(scenarioId);
return Ok(testRuns.Select(ToDto).ToList());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting test runs for scenario {ScenarioId}", scenarioId);
return StatusCode(500, new { error = "Failed to retrieve test runs" });
}
}
/// <summary>
/// Get test runs by parameter set ID
/// </summary>
[HttpGet("parameterset/{parameterSetId}")]
public async Task<ActionResult<List<TestRunDto>>> GetByParameterSet(Guid parameterSetId)
{
try
{
var testRuns = await _testRepository.GetByParameterSetAsync(parameterSetId);
return Ok(testRuns.Select(ToDto).ToList());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting test runs for parameter set {ParameterSetId}", parameterSetId);
return StatusCode(500, new { error = "Failed to retrieve test runs" });
}
}
/// <summary>
/// Get test runs by date range
/// </summary>
[HttpGet("daterange")]
public async Task<ActionResult<List<TestRunDto>>> GetByDateRange(
[FromQuery] DateTime from,
[FromQuery] DateTime to)
{
try
{
var testRuns = await _testRepository.GetByDateRangeAsync(from, to);
return Ok(testRuns.Select(ToDto).ToList());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting test runs for date range {From} to {To}", from, to);
return StatusCode(500, new { error = "Failed to retrieve test runs" });
}
}
/// <summary>
/// Delete test run
/// </summary>
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
try
{
var existing = await _testRepository.GetByIdAsync(id);
if (existing == null)
return NotFound(new { error = $"Test run with ID {id} not found" });
await _testRepository.DeleteAsync(id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting test run {Id}", id);
return StatusCode(500, new { error = "Failed to delete test run" });
}
}
/// <summary>
/// Delete multiple test runs by IDs
/// </summary>
[HttpPost("delete-batch")]
public async Task<ActionResult> DeleteBatch([FromBody] DeleteBatchRequest request)
{
if (request?.Ids == null || request.Ids.Count == 0)
return BadRequest(new { error = "Ids is required and must not be empty" });
try
{
await _testRepository.DeleteManyAsync(request.Ids);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting test runs batch");
return StatusCode(500, new { error = "Failed to delete test runs" });
}
}
private static TestRunDto ToDto(TestRun r)
{
return new TestRunDto
{
Id = r.Id,
ScenarioId = r.ScenarioId,
ScenarioName = r.Scenario?.Name ?? string.Empty,
ParameterSetId = r.ParameterSetId,
ParameterSetName = r.ParameterSet?.Name ?? string.Empty,
StartTime = r.StartTime,
EndTime = r.EndTime,
Status = r.Status,
Duration = r.Duration,
Notes = r.Notes,
ErrorMessage = r.ErrorMessage,
Metrics = r.Metrics,
SafetyViolations = r.SafetyViolations ?? new List<SafetyViolation>()
};
}
}

View File

@@ -0,0 +1,152 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Controllers;
/// <summary>
/// REST API controller for tuning advisor - re-analyze tests and apply suggestions
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class TuningAdvisorController(
ITuningAdvisor tuningAdvisor,
ITestRepository testRepository,
IParameterManager parameterManager,
ILogger<TuningAdvisorController> logger) : ControllerBase
{
/// <summary>
/// Re-analyze a completed test run using telemetry provided in the request body.
/// Note: Telemetry data is not persisted to database, so the client must supply it.
/// The primary analysis is done automatically in the orchestrator after test completion.
/// </summary>
[HttpPost("analyze/{testRunId}")]
public async Task<ActionResult<TuningReport>> AnalyzeTestRun(
Guid testRunId, [FromBody] List<TelemetryData>? telemetry = null)
{
try
{
var testRun = await testRepository.GetByIdAsync(testRunId);
if (testRun == null)
return NotFound(new { error = $"Test run {testRunId} not found" });
if (testRun.Status != TestStatus.Completed)
return BadRequest(new { error = "Only completed tests can be analyzed" });
var parameterSet = await parameterManager.GetByIdAsync(testRun.ParameterSetId);
if (parameterSet == null)
return NotFound(new { error = $"Parameter set {testRun.ParameterSetId} not found" });
if (telemetry == null || telemetry.Count < 10)
return BadRequest(new { error = "Telemetry data is required for analysis (min 10 samples). Telemetry is not stored in the database; it is only available during test execution via SignalR." });
var metrics = testRun.Metrics;
if (metrics == null)
return BadRequest(new { error = "No metrics available for this test run" });
var report = tuningAdvisor.Analyze(telemetry, metrics, parameterSet);
report.TestRunId = testRunId;
return Ok(report);
}
catch (Exception ex)
{
logger.LogError(ex, "Error analyzing test run {TestRunId}", testRunId);
return StatusCode(500, new { error = "Failed to analyze test run", details = ex.Message });
}
}
/// <summary>
/// Apply selected suggestions to a parameter set (returns new set, does not persist).
/// </summary>
[HttpPost("apply")]
public async Task<ActionResult<NavigationParameterSet>> ApplySuggestions(
[FromBody] ApplySuggestionsRequest request)
{
try
{
var parameterSet = await parameterManager.GetByIdAsync(request.ParameterSetId);
if (parameterSet == null)
return NotFound(new { error = $"Parameter set {request.ParameterSetId} not found" });
var selectedSuggestions = request.Report.Suggestions
.Where(s => request.SuggestionIds.Contains(s.Id))
.ToList();
if (selectedSuggestions.Count == 0)
return BadRequest(new { error = "No valid suggestions selected" });
var modified = tuningAdvisor.ApplyAllSuggestions(parameterSet, selectedSuggestions);
return Ok(modified);
}
catch (Exception ex)
{
logger.LogError(ex, "Error applying suggestions");
return StatusCode(500, new { error = "Failed to apply suggestions", details = ex.Message });
}
}
/// <summary>
/// Apply suggestions and save as a new parameter set.
/// </summary>
[HttpPost("apply-and-save")]
public async Task<ActionResult<NavigationParameterSet>> ApplyAndSave(
[FromBody] ApplyAndSaveRequest request)
{
try
{
var parameterSet = await parameterManager.GetByIdAsync(request.ParameterSetId);
if (parameterSet == null)
return NotFound(new { error = $"Parameter set {request.ParameterSetId} not found" });
var selectedSuggestions = request.Report.Suggestions
.Where(s => request.SuggestionIds.Contains(s.Id))
.ToList();
if (selectedSuggestions.Count == 0)
return BadRequest(new { error = "No valid suggestions selected" });
var modified = tuningAdvisor.ApplyAllSuggestions(parameterSet, selectedSuggestions);
// Create new parameter set with unique name
modified.Id = Guid.NewGuid();
modified.Name = request.NewParameterSetName
?? $"{parameterSet.Name}_tuned_{DateTime.UtcNow:yyyyMMdd_HHmmss}";
modified.Description = $"Auto-tuned from '{parameterSet.Name}'. Applied {selectedSuggestions.Count} suggestion(s).";
modified.IsDefault = false;
modified.CreatedAt = DateTime.UtcNow;
modified.Version = parameterSet.Version + 1;
await parameterManager.SaveAsync(modified);
return Ok(modified);
}
catch (Exception ex)
{
logger.LogError(ex, "Error applying and saving suggestions");
return StatusCode(500, new { error = "Failed to apply and save suggestions", details = ex.Message });
}
}
}
/// <summary>
/// Request model for applying suggestions to a parameter set.
/// </summary>
public class ApplySuggestionsRequest
{
public Guid ParameterSetId { get; set; }
public List<Guid> SuggestionIds { get; set; } = new();
public TuningReport Report { get; set; } = null!;
}
/// <summary>
/// Request model for applying suggestions and saving as a new parameter set.
/// </summary>
public class ApplyAndSaveRequest
{
public Guid ParameterSetId { get; set; }
public List<Guid> SuggestionIds { get; set; } = new();
public TuningReport Report { get; set; } = null!;
public string? NewParameterSetName { get; set; }
}

View File

@@ -0,0 +1,254 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using RobotNet10.NavigationTune.Data;
using RobotNet10.NavigationTune.Hubs;
using RobotNet10.NavigationTune.Services;
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Controllers;
/// <summary>
/// REST API controller for test execution and control
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class TuningController(
ITuningOrchestrator orchestrator,
IParameterManager parameterManager,
IScenarioRepository scenarioRepository,
ILogger<TuningController> logger) : ControllerBase
{
/// <summary>
/// Execute a single test
/// </summary>
[HttpPost("execute")]
public async Task<ActionResult<TestExecutionResult>> ExecuteTest(
[FromBody] ExecuteTestRequest request,
CancellationToken cancellationToken = default)
{
try
{
// Load scenario and parameter set
var scenario = await scenarioRepository.GetByIdAsync(request.ScenarioId);
if (scenario == null)
return NotFound(new { error = $"Scenario with ID {request.ScenarioId} not found" });
var parameterSet = await parameterManager.GetByIdAsync(request.ParameterSetId);
if (parameterSet == null)
return NotFound(new { error = $"Parameter set with ID {request.ParameterSetId} not found" });
// Get connection ID from request headers or query
var connectionId = Request.Headers["X-Connection-Id"].FirstOrDefault()
?? Request.Query["connectionId"].FirstOrDefault();
// Start test and return immediately so UI can enable Stop/Pause; completion is sent via SignalR (ReceiveTestResult)
var result = await orchestrator.StartTestAsync(
scenario,
parameterSet,
connectionId,
request.TestRunId,
cancellationToken);
return Ok(result);
}
catch (InvalidOperationException ex)
{
logger.LogWarning(ex, "Invalid operation executing test");
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
logger.LogError(ex, "Error executing test");
return StatusCode(500, new { error = "Failed to execute test", details = ex.Message });
}
}
/// <summary>
/// Run batch tests with multiple scenarios
/// </summary>
[HttpPost("batch")]
public async Task<ActionResult<BatchTestResult>> RunBatchTests(
[FromBody] BatchTestRequest request,
CancellationToken cancellationToken = default)
{
try
{
// Load parameter set
var parameterSet = await parameterManager.GetByIdAsync(request.ParameterSetId);
if (parameterSet == null)
return NotFound(new { error = $"Parameter set with ID {request.ParameterSetId} not found" });
// Load scenarios
var scenarios = new List<TestScenario>();
foreach (var scenarioId in request.ScenarioIds)
{
var scenario = await scenarioRepository.GetByIdAsync(scenarioId);
if (scenario == null)
{
logger.LogWarning("Scenario {ScenarioId} not found, skipping", scenarioId);
continue;
}
scenarios.Add(scenario);
}
if (scenarios.Count == 0)
return BadRequest(new { error = "No valid scenarios found" });
// Execute batch
var result = await orchestrator.RunBatchTestsAsync(scenarios, parameterSet, cancellationToken);
return Ok(result);
}
catch (Exception ex)
{
logger.LogError(ex, "Error running batch tests");
return StatusCode(500, new { error = "Failed to run batch tests", details = ex.Message });
}
}
/// <summary>
/// Compare multiple parameter configurations
/// </summary>
[HttpPost("compare")]
public async Task<ActionResult<ComparisonResult>> CompareConfigurations(
[FromBody] CompareConfigurationsRequest request,
CancellationToken cancellationToken = default)
{
try
{
// Load scenario
var scenario = await scenarioRepository.GetByIdAsync(request.ScenarioId);
if (scenario == null)
return NotFound(new { error = $"Scenario with ID {request.ScenarioId} not found" });
// Load parameter sets
var parameterSets = new List<NavigationParameterSet>();
foreach (var parameterSetId in request.ParameterSetIds)
{
var parameterSet = await parameterManager.GetByIdAsync(parameterSetId);
if (parameterSet == null)
{
logger.LogWarning("Parameter set {ParameterSetId} not found, skipping", parameterSetId);
continue;
}
parameterSets.Add(parameterSet);
}
if (parameterSets.Count == 0)
return BadRequest(new { error = "No valid parameter sets found" });
// Compare
var result = await orchestrator.CompareConfigurationsAsync(parameterSets, scenario, cancellationToken);
return Ok(result);
}
catch (Exception ex)
{
logger.LogError(ex, "Error comparing configurations");
return StatusCode(500, new { error = "Failed to compare configurations", details = ex.Message });
}
}
/// <summary>
/// Pause running test
/// </summary>
[HttpPost("pause/{testRunId}")]
public ActionResult PauseTest(string testRunId)
{
try
{
orchestrator.PauseTest(testRunId);
return Ok(new { message = "Test paused" });
}
catch (Exception ex)
{
logger.LogError(ex, "Error pausing test {TestRunId}", testRunId);
return StatusCode(500, new { error = "Failed to pause test" });
}
}
/// <summary>
/// Resume paused test
/// </summary>
[HttpPost("resume/{testRunId}")]
public ActionResult ResumeTest(string testRunId)
{
try
{
orchestrator.ResumeTest(testRunId);
return Ok(new { message = "Test resumed" });
}
catch (Exception ex)
{
logger.LogError(ex, "Error resuming test {TestRunId}", testRunId);
return StatusCode(500, new { error = "Failed to resume test" });
}
}
/// <summary>
/// Stop running test
/// </summary>
[HttpPost("stop/{testRunId}")]
public ActionResult StopTest(string testRunId)
{
try
{
orchestrator.StopTest(testRunId);
return Ok(new { message = "Test stopped" });
}
catch (Exception ex)
{
logger.LogError(ex, "Error stopping test {TestRunId}", testRunId);
return StatusCode(500, new { error = "Failed to stop test" });
}
}
/// <summary>
/// Emergency stop
/// </summary>
[HttpPost("emergency-stop/{testRunId}")]
public ActionResult EmergencyStop(string testRunId)
{
try
{
orchestrator.EmergencyStop(testRunId);
return Ok(new { message = "Emergency stop activated" });
}
catch (Exception ex)
{
logger.LogError(ex, "Error emergency stopping test {TestRunId}", testRunId);
return StatusCode(500, new { error = "Failed to emergency stop test" });
}
}
}
/// <summary>
/// Request model for executing a single test.
/// When TestRunId is provided, client should call JoinTestSession(TestRunId) before execute so real-time telemetry is received.
/// </summary>
public class ExecuteTestRequest
{
public Guid ScenarioId { get; set; }
public Guid ParameterSetId { get; set; }
/// <summary>Optional. If set, this Id is used for the test run and for SignalR group test_{TestRunId}.</summary>
public Guid? TestRunId { get; set; }
}
/// <summary>
/// Request model for batch test execution
/// </summary>
public class BatchTestRequest
{
public List<Guid> ScenarioIds { get; set; } = new();
public Guid ParameterSetId { get; set; }
}
/// <summary>
/// Request model for configuration comparison
/// </summary>
public class CompareConfigurationsRequest
{
public Guid ScenarioId { get; set; }
public List<Guid> ParameterSetIds { get; set; } = new();
}

View File

@@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using RobotNet10.NavigationTune.Execution;
namespace RobotNet10.NavigationTune.Controllers;
/// <summary>
/// REST API controller for manual velocity control
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class VelocityControlController(
IVelocityProvider? velocityProvider,
ILogger<VelocityControlController> logger) : ControllerBase
{
private readonly IVelocityProvider? _velocityProvider = velocityProvider;
private readonly ILogger<VelocityControlController> _logger = logger;
/// <summary>
/// Set manual velocity command
/// </summary>
[HttpPost("set-velocity")]
public ActionResult SetVelocity([FromBody] VelocityCommandRequest request)
{
try
{
if (_velocityProvider == null)
{
return BadRequest(new { error = "Velocity provider is not available. Robot connection required." });
}
_velocityProvider.SetVelocity(request.LinearVelocity, request.AngularVelocity);
return Ok(new { message = "Velocity command sent", linear = request.LinearVelocity, angular = request.AngularVelocity });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error setting velocity");
return StatusCode(500, new { error = "Failed to set velocity", details = ex.Message });
}
}
/// <summary>
/// Stop all velocities (set to zero)
/// </summary>
[HttpPost("stop")]
public ActionResult Stop()
{
try
{
if (_velocityProvider == null)
{
return BadRequest(new { error = "Velocity provider is not available. Robot connection required." });
}
_velocityProvider.SetVelocity(0, 0);
return Ok(new { message = "All velocities stopped" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping velocity");
return StatusCode(500, new { error = "Failed to stop velocity", details = ex.Message });
}
}
/// <summary>
/// Get current velocity
/// </summary>
[HttpGet("current")]
public ActionResult GetCurrentVelocity()
{
try
{
if (_velocityProvider == null)
{
return BadRequest(new { error = "Velocity provider is not available. Robot connection required." });
}
var (linear, angular) = _velocityProvider.GetActualVelocity();
return Ok(new { linear, angular });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting current velocity");
return StatusCode(500, new { error = "Failed to get current velocity", details = ex.Message });
}
}
}
/// <summary>
/// Request model for velocity command
/// </summary>
public class VelocityCommandRequest
{
public double LinearVelocity { get; set; }
public double AngularVelocity { get; set; }
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Data;
/// <summary>
/// Seed default parameter sets
/// </summary>
public static class DefaultDataSeeder
{
public static async Task SeedAsync(TuningDbContext context)
{
// Seed default parameter set if not exists
if (!await context.ParameterSets.AnyAsync(p => p.IsDefault))
{
var defaultParams = new NavigationParameterSet
{
Id = Guid.NewGuid(),
Name = "Default",
Description = "Default parameter set",
IsDefault = true,
Version = 1
};
context.ParameterSets.Add(defaultParams);
}
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using RobotNet10.NavigationTune.Data;
using RobotNet10.NavigationTune.Shared.Models;
using RobotNet10.NavigationTune.Scenarios;
namespace RobotNet10.NavigationTune.Data;
/// <summary>
/// Repository for test scenarios
/// </summary>
public interface IScenarioRepository
{
Task<TestScenario?> GetByIdAsync(Guid id);
Task<List<TestScenario>> GetAllAsync();
Task<List<TestScenario>> GetDefaultScenariosAsync();
Task<TestScenarioEntity?> GetEntityByIdAsync(Guid id);
Task<List<TestScenarioEntity>> GetAllEntitiesAsync();
Task<List<TestScenarioEntity>> GetDefaultScenarioEntitiesAsync();
Task<Guid> SaveAsync(TestScenario scenario);
Task UpdateAsync(TestScenario scenario);
Task DeleteAsync(Guid id);
}
public class ScenarioRepository(TuningDbContext context) : IScenarioRepository
{
public async Task<TestScenario?> GetByIdAsync(Guid id)
{
var entity = await context.TestScenarios.FindAsync(id);
return entity?.ToTestScenario();
}
public async Task<List<TestScenario>> GetAllAsync()
{
var entities = await context.TestScenarios
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
return [.. entities.Select(e => e.ToTestScenario())];
}
public async Task<List<TestScenario>> GetDefaultScenariosAsync()
{
var entities = await context.TestScenarios
.Where(s => s.IsDefault)
.OrderBy(s => s.Name)
.ToListAsync();
return [.. entities.Select(e => e.ToTestScenario())];
}
public async Task<TestScenarioEntity?> GetEntityByIdAsync(Guid id)
{
return await context.TestScenarios.FindAsync(id);
}
public async Task<List<TestScenarioEntity>> GetAllEntitiesAsync()
{
return await context.TestScenarios
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
public async Task<List<TestScenarioEntity>> GetDefaultScenarioEntitiesAsync()
{
return await context.TestScenarios
.Where(s => s.IsDefault)
.OrderBy(s => s.Name)
.ToListAsync();
}
public async Task<Guid> SaveAsync(TestScenario scenario)
{
var entity = TestScenarioEntity.FromTestScenario(scenario);
context.TestScenarios.Add(entity);
await context.SaveChangesAsync();
return entity.Id;
}
public async Task UpdateAsync(TestScenario scenario)
{
var existing = await context.TestScenarios.FindAsync(scenario.Id) ?? throw new InvalidOperationException($"Scenario with ID {scenario.Id} not found.");
var updated = TestScenarioEntity.FromTestScenario(scenario);
existing.Name = updated.Name;
existing.Description = updated.Description;
existing.Type = updated.Type;
existing.IsDefault = updated.IsDefault;
existing.ConfigJson = updated.ConfigJson;
await context.SaveChangesAsync();
}
public async Task DeleteAsync(Guid id)
{
var entity = await context.TestScenarios.FindAsync(id);
if (entity != null)
{
context.TestScenarios.Remove(entity);
await context.SaveChangesAsync();
}
}
}

View File

@@ -0,0 +1,258 @@
using Microsoft.EntityFrameworkCore;
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Data;
/// <summary>
/// Repository for test runs
/// </summary>
public class TestRepository(TuningDbContext context) : ITestRepository
{
public async Task<TestRun?> GetByIdAsync(Guid id)
{
var testRun = await context.TestRuns
.Include(r => r.Metrics)
.Include(r => r.SafetyViolations)
.FirstOrDefaultAsync(r => r.Id == id);
if (testRun != null)
{
// Load scenario and parameter set separately
var scenarioEntity = await context.TestScenarios.FindAsync(testRun.ScenarioId);
if (scenarioEntity != null)
testRun.Scenario = scenarioEntity.ToTestScenario();
testRun.ParameterSet = await context.ParameterSets.FindAsync(testRun.ParameterSetId);
}
return testRun;
}
public async Task<List<TestRun>> GetAllAsync()
{
var testRuns = await context.TestRuns
.Include(r => r.Metrics)
.OrderByDescending(r => r.StartTime)
.ToListAsync();
// Load scenarios and parameter sets
foreach (var testRun in testRuns)
{
var scenarioEntity = await context.TestScenarios.FindAsync(testRun.ScenarioId);
if (scenarioEntity != null)
testRun.Scenario = scenarioEntity.ToTestScenario();
testRun.ParameterSet = await context.ParameterSets.FindAsync(testRun.ParameterSetId);
}
return testRuns;
}
public async Task<int> GetCountAsync()
{
return await context.TestRuns.CountAsync();
}
public async Task<List<TestRun>> GetPagedAsync(int skip, int take)
{
var testRuns = await context.TestRuns
.Include(r => r.Metrics)
.OrderByDescending(r => r.StartTime)
.Skip(skip)
.Take(take)
.ToListAsync();
var scenarioIds = testRuns.Select(r => r.ScenarioId).Distinct().ToList();
var parameterSetIds = testRuns.Select(r => r.ParameterSetId).Distinct().ToList();
var scenarios = await context.TestScenarios
.Where(s => scenarioIds.Contains(s.Id))
.ToListAsync();
var parameterSets = await context.ParameterSets
.Where(p => parameterSetIds.Contains(p.Id))
.ToListAsync();
foreach (var testRun in testRuns)
{
var scenarioEntity = scenarios.FirstOrDefault(s => s.Id == testRun.ScenarioId);
if (scenarioEntity != null)
testRun.Scenario = scenarioEntity.ToTestScenario();
testRun.ParameterSet = parameterSets.FirstOrDefault(p => p.Id == testRun.ParameterSetId);
}
return testRuns;
}
public async Task<List<TestRun>> GetByScenarioAsync(Guid scenarioId)
{
var testRuns = await context.TestRuns
.Include(r => r.Metrics)
.Where(r => r.ScenarioId == scenarioId)
.OrderByDescending(r => r.StartTime)
.ToListAsync();
// Load scenarios and parameter sets
var scenarioEntity = await context.TestScenarios.FindAsync(scenarioId);
foreach (var testRun in testRuns)
{
if (scenarioEntity != null)
testRun.Scenario = scenarioEntity.ToTestScenario();
testRun.ParameterSet = await context.ParameterSets.FindAsync(testRun.ParameterSetId);
}
return testRuns;
}
public async Task<List<TestRun>> GetByParameterSetAsync(Guid parameterSetId)
{
var testRuns = await context.TestRuns
.Include(r => r.Metrics)
.Where(r => r.ParameterSetId == parameterSetId)
.OrderByDescending(r => r.StartTime)
.ToListAsync();
// Load scenarios and parameter sets
var parameterSet = await context.ParameterSets.FindAsync(parameterSetId);
foreach (var testRun in testRuns)
{
var scenarioEntity = await context.TestScenarios.FindAsync(testRun.ScenarioId);
if (scenarioEntity != null)
testRun.Scenario = scenarioEntity.ToTestScenario();
testRun.ParameterSet = parameterSet;
}
return testRuns;
}
public async Task<List<TestRun>> GetByDateRangeAsync(DateTime from, DateTime to)
{
var testRuns = await context.TestRuns
.Include(r => r.Metrics)
.Where(r => r.StartTime >= from && r.StartTime <= to)
.OrderByDescending(r => r.StartTime)
.ToListAsync();
// Load scenarios and parameter sets
var scenarioIds = testRuns.Select(r => r.ScenarioId).Distinct().ToList();
var parameterSetIds = testRuns.Select(r => r.ParameterSetId).Distinct().ToList();
var scenarios = await context.TestScenarios
.Where(s => scenarioIds.Contains(s.Id))
.ToListAsync();
var parameterSets = await context.ParameterSets
.Where(p => parameterSetIds.Contains(p.Id))
.ToListAsync();
foreach (var testRun in testRuns)
{
var scenarioEntity = scenarios.FirstOrDefault(s => s.Id == testRun.ScenarioId);
if (scenarioEntity != null)
testRun.Scenario = scenarioEntity.ToTestScenario();
testRun.ParameterSet = parameterSets.FirstOrDefault(p => p.Id == testRun.ParameterSetId);
}
return testRuns;
}
public async Task<Guid> SaveAsync(TestRun testRun)
{
context.TestRuns.Add(testRun);
await context.SaveChangesAsync();
return testRun.Id;
}
public async Task UpdateAsync(TestRun testRun)
{
var entry = context.Entry(testRun);
if (entry.State == EntityState.Detached)
context.TestRuns.Update(testRun);
await context.SaveChangesAsync();
}
/// <summary>
/// Update test run from execution result without loading/replacing navigation properties,
/// to avoid DbUpdateConcurrencyException when entity was created in another scope.
/// </summary>
public async Task UpdateFromResultAsync(
Guid testRunId,
TestStatus status,
DateTime? endTime,
double duration,
string? errorMessage,
TestMetrics? metrics,
List<SafetyViolation>? safetyViolations)
{
var testRun = await context.TestRuns
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == testRunId);
if (testRun == null)
return;
testRun.Status = status;
testRun.EndTime = endTime;
testRun.Duration = duration;
testRun.ErrorMessage = errorMessage;
context.TestRuns.Update(testRun);
await context.SaveChangesAsync();
var existingMetrics = await context.TestMetrics.Where(m => m.TestRunId == testRunId).ToListAsync();
if (existingMetrics.Count > 0)
context.TestMetrics.RemoveRange(existingMetrics);
var existingViolations = await context.SafetyViolations.Where(v => v.TestRunId == testRunId).ToListAsync();
if (existingViolations.Count > 0)
context.SafetyViolations.RemoveRange(existingViolations);
if (metrics != null)
{
metrics.TestRunId = testRunId;
if (metrics.Id == default)
metrics.Id = Guid.NewGuid();
context.TestMetrics.Add(metrics);
}
if (safetyViolations != null && safetyViolations.Count > 0)
{
foreach (var v in safetyViolations)
{
v.TestRunId = testRunId;
if (v.Id == default)
v.Id = Guid.NewGuid();
}
context.SafetyViolations.AddRange(safetyViolations);
}
await context.SaveChangesAsync();
}
public async Task DeleteAsync(Guid id)
{
var testRun = await GetByIdAsync(id);
if (testRun != null)
{
context.TestRuns.Remove(testRun);
await context.SaveChangesAsync();
}
}
public async Task DeleteManyAsync(IEnumerable<Guid> ids)
{
var idList = ids.Distinct().ToList();
if (idList.Count == 0)
return;
var toRemove = await context.TestRuns
.Where(r => idList.Contains(r.Id))
.ToListAsync();
if (toRemove.Count > 0)
{
context.TestRuns.RemoveRange(toRemove);
await context.SaveChangesAsync();
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Text.Json;
using RobotNet10.NavigationTune.Shared.Models;
using StraightLineScenario = RobotNet10.NavigationTune.Scenarios.StraightLineScenario;
using CircleScenario = RobotNet10.NavigationTune.Scenarios.CircleScenario;
using CustomPathScenario = RobotNet10.NavigationTune.Scenarios.CustomPathScenario;
namespace RobotNet10.NavigationTune.Data;
/// <summary>
/// Entity for storing TestScenario in database
/// Since TestScenario is abstract, we store config as JSON
/// </summary>
public class TestScenarioEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public TrajectoryType Type { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsDefault { get; set; }
/// <summary>
/// JSON config for scenario-specific properties
/// </summary>
public string ConfigJson { get; set; } = string.Empty;
/// <summary>
/// Convert to TestScenario instance
/// </summary>
public TestScenario ToTestScenario()
{
return Type switch
{
TrajectoryType.StraightLine => JsonSerializer.Deserialize<StraightLineScenario>(ConfigJson)
?? new StraightLineScenario { Id = Id, Name = Name, Description = Description, Type = Type, CreatedAt = CreatedAt, IsDefault = IsDefault },
TrajectoryType.Circle => JsonSerializer.Deserialize<CircleScenario>(ConfigJson)
?? new CircleScenario { Id = Id, Name = Name, Description = Description, Type = Type, CreatedAt = CreatedAt, IsDefault = IsDefault },
TrajectoryType.Custom => JsonSerializer.Deserialize<CustomPathScenario>(ConfigJson)
?? new CustomPathScenario { Id = Id, Name = Name, Description = Description, Type = Type, CreatedAt = CreatedAt, IsDefault = IsDefault },
_ => throw new NotSupportedException($"Scenario type {Type} is not supported")
};
}
/// <summary>
/// Create from TestScenario
/// </summary>
public static TestScenarioEntity FromTestScenario(TestScenario scenario)
{
return new TestScenarioEntity
{
Id = scenario.Id,
Name = scenario.Name,
Description = scenario.Description,
Type = scenario.Type,
CreatedAt = scenario.CreatedAt,
IsDefault = scenario.IsDefault,
ConfigJson = scenario.Type switch
{
TrajectoryType.StraightLine => JsonSerializer.Serialize((StraightLineScenario)scenario),
TrajectoryType.Circle => JsonSerializer.Serialize((CircleScenario)scenario),
TrajectoryType.Custom => JsonSerializer.Serialize((CustomPathScenario)scenario),
_ => "{}"
}
};
}
}

View File

@@ -0,0 +1,207 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Text.Json;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Data;
/// <summary>
/// Database context for tuning system
/// </summary>
public class TuningDbContext(DbContextOptions<TuningDbContext> options) : DbContext(options)
{
public DbSet<NavigationParameterSet> ParameterSets { get; set; }
public DbSet<TestScenarioEntity> TestScenarios { get; set; }
public DbSet<TestRun> TestRuns { get; set; }
public DbSet<TestMetrics> TestMetrics { get; set; }
public DbSet<SafetyViolation> SafetyViolations { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// JSON converter for complex types
var jsonOptions = new JsonSerializerOptions { WriteIndented = false };
// ParameterSets - Configure complex types as JSON
modelBuilder.Entity<NavigationParameterSet>(entity =>
{
entity.ToTable("parameter_sets");
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.CreatedAt).IsRequired();
entity.Property(e => e.UpdatedAt);
entity.Property(e => e.IsDefault);
entity.Property(e => e.Version);
entity.Property(e => e.ControllerType).HasConversion<int>(); // Store enum as int
// Store complex types as JSON
entity.Property(e => e.MovePidConfig)
.HasConversion(
v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<PIDConfig>(v, jsonOptions) ?? new PIDConfig())
.HasColumnType("TEXT"); // SQLite uses TEXT, PostgreSQL will use jsonb
entity.Property(e => e.RotatePidConfig)
.HasConversion(
v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<PIDConfig>(v, jsonOptions) ?? new PIDConfig())
.HasColumnType("TEXT");
entity.Property(e => e.PurePursuitConfig)
.HasConversion(
v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<PurePursuitConfig>(v, jsonOptions) ?? new PurePursuitConfig())
.HasColumnType("TEXT");
entity.Property(e => e.StanleyConfig)
.HasConversion(
v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<StanleyConfig>(v, jsonOptions) ?? new StanleyConfig())
.HasColumnType("TEXT");
entity.Property(e => e.EstimatorConfig)
.HasConversion(
v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<VelocityEstimatorConfig>(v, jsonOptions) ?? new VelocityEstimatorConfig())
.HasColumnType("TEXT");
entity.Property(e => e.SignalConfig)
.HasConversion(
v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<VelocitySignalProcessingConfig>(v, jsonOptions) ?? new VelocitySignalProcessingConfig())
.HasColumnType("TEXT");
entity.Property(e => e.MotorDynamicsConfig)
.HasConversion(
v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<MotorDynamicsConfig>(v, jsonOptions) ?? new MotorDynamicsConfig())
.HasColumnType("TEXT");
entity.Property(e => e.NavigationConfig)
.HasConversion(
v => JsonSerializer.Serialize(v, jsonOptions),
v => JsonSerializer.Deserialize<NavigationConfig>(v, jsonOptions) ?? new NavigationConfig())
.HasColumnType("TEXT");
entity.HasIndex(e => e.Name);
entity.HasIndex(e => e.IsDefault);
entity.HasIndex(e => e.CreatedAt);
});
// TestScenarios - Store config as JSON since TestScenario is abstract
modelBuilder.Entity<TestScenarioEntity>(entity =>
{
entity.ToTable("test_scenarios");
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.Type).IsRequired();
entity.Property(e => e.ConfigJson).IsRequired().HasColumnType("TEXT");
entity.Property(e => e.CreatedAt).IsRequired();
entity.Property(e => e.IsDefault);
entity.HasIndex(e => e.Type);
entity.HasIndex(e => e.Name);
entity.HasIndex(e => e.CreatedAt);
});
// TestRuns
modelBuilder.Entity<TestRun>(entity =>
{
entity.ToTable("test_runs");
entity.HasKey(e => e.Id);
// Foreign keys
entity.Property(e => e.ScenarioId).IsRequired();
entity.Property(e => e.ParameterSetId).IsRequired();
// Navigation properties - Ignore since we load separately to avoid circular dependencies
entity.Ignore(e => e.Scenario);
entity.Ignore(e => e.ParameterSet);
// Properties
entity.Property(e => e.StartTime).IsRequired();
entity.Property(e => e.EndTime);
entity.Property(e => e.Status).IsRequired().HasConversion<int>();
entity.Property(e => e.Duration);
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.Property(e => e.ErrorMessage).HasMaxLength(1000);
// Relationships
entity.HasOne(e => e.Metrics)
.WithOne()
.HasForeignKey<TestMetrics>(m => m.TestRunId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(e => e.SafetyViolations)
.WithOne()
.HasForeignKey(v => v.TestRunId)
.OnDelete(DeleteBehavior.Cascade);
// Indexes
entity.HasIndex(e => e.ScenarioId);
entity.HasIndex(e => e.ParameterSetId);
entity.HasIndex(e => e.StartTime);
entity.HasIndex(e => e.Status);
entity.HasIndex(e => new { e.ScenarioId, e.ParameterSetId });
});
// TestMetrics
modelBuilder.Entity<TestMetrics>(entity =>
{
entity.ToTable("test_metrics");
entity.HasKey(e => e.Id);
// Foreign key
entity.Property(e => e.TestRunId).IsRequired();
// All metrics are double/double
entity.Property(e => e.CrossTrackErrorRMS);
entity.Property(e => e.CrossTrackErrorPeak);
entity.Property(e => e.CrossTrackErrorMean);
entity.Property(e => e.CrossTrackErrorStdDev);
entity.Property(e => e.HeadingErrorRMS);
entity.Property(e => e.HeadingErrorPeak);
entity.Property(e => e.GoalPositionError);
entity.Property(e => e.GoalHeadingError);
entity.Property(e => e.VelocityStdDev);
entity.Property(e => e.AccelerationStdDev);
entity.Property(e => e.PathLengthRatio);
entity.Property(e => e.CompletionTime);
entity.Property(e => e.AverageSpeed);
entity.Property(e => e.MaxSpeed);
entity.Property(e => e.OverallScore);
entity.Property(e => e.TrackingScore);
entity.Property(e => e.SmoothnessScore);
entity.Property(e => e.EfficiencyScore);
entity.Property(e => e.PassedCriteria);
// Unique index on TestRunId (one-to-one relationship)
entity.HasIndex(e => e.TestRunId).IsUnique();
});
// SafetyViolations
modelBuilder.Entity<SafetyViolation>(entity =>
{
entity.ToTable("safety_violations");
entity.HasKey(e => e.Id);
// Foreign key
entity.Property(e => e.TestRunId).IsRequired();
// Properties
entity.Property(e => e.Timestamp).IsRequired();
entity.Property(e => e.Type).IsRequired().HasConversion<int>();
entity.Property(e => e.Severity).IsRequired().HasConversion<int>();
entity.Property(e => e.Value);
entity.Property(e => e.Threshold);
entity.Property(e => e.Message).IsRequired().HasMaxLength(500);
// Indexes
entity.HasIndex(e => e.TestRunId);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => new { e.TestRunId, e.Timestamp });
});
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace RobotNet10.NavigationTune.Data;
public static class TuningDbExtensions
{
extension (IServiceProvider serviceProvider)
{
public async Task SeedTuningDbAsync()
{
using var scope = serviceProvider.CreateScope();
using var appDb = scope.ServiceProvider.GetRequiredService<TuningDbContext>();
await appDb.Database.MigrateAsync();
await appDb.Database.EnsureCreatedAsync();
await appDb.SaveChangesAsync();
await DefaultDataSeeder.SeedAsync(appDb);
}
}
}

View File

@@ -0,0 +1,16 @@
namespace RobotNet10.NavigationTune.Execution;
/// <summary>
/// Adapter to wrap ILocalization from RobotApp
/// This will be implemented in the application layer that has access to RobotApp
/// </summary>
public class LocalizationAdapter(Func<double> getX, Func<double> getY, Func<double> getTheta) : ILocalizationProvider
{
private readonly Func<double> _getX = getX;
private readonly Func<double> _getY = getY;
private readonly Func<double> _getTheta = getTheta;
public double X => _getX();
public double Y => _getY();
public double Theta => _getTheta(); // radians
}

View File

@@ -0,0 +1,833 @@
using Microsoft.Extensions.Logging;
using RobotNet10.Common;
using RobotNet10.NavigationTune.Navigation.Core;
using RobotNet10.NavigationTune.Services;
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Execution;
/// <summary>
/// Navigation phases for state machine
/// </summary>
public enum NavigationPhase
{
InitialRotation, // Rotating to face initial lookahead point
PathFollowing, // Following path using Pure Pursuit
FinalRotation, // Rotating to final goal heading
Completed // Navigation complete
}
/// <summary>
/// Test executor implementation
/// Executes test scenarios using simplified navigation controllers; control loop runs on WatchThread (50Hz).
/// </summary>
public class TestExecutor : ITestExecutor
{
private const int ControlLoopFrequency = 50; // 50Hz
private const double Dt = 1.0 / ControlLoopFrequency;
private static readonly int ControlLoopIntervalMs = 1000 / ControlLoopFrequency;
private readonly ILocalizationProvider _localization;
private readonly IVelocityProvider _velocityProvider;
private readonly SafetyMonitor _safetyMonitor;
private readonly SafetyConfig _safetyConfig;
private readonly ILogger<TestExecutor>? _logger;
private TestStatus _status = TestStatus.Preparing;
private TestScenario? _currentScenario;
private NavigationParameterSet? _currentParameters;
private ReferencePath? _referencePath;
private List<TelemetryData> _telemetryData = new();
private CancellationTokenSource? _cancellationTokenSource;
// Controllers
private PID? _movePid;
private PID? _rotatePid;
private PurePursuitSimplified? _purePursuit;
private StanleySimplified? _stanley;
private VelocityEstimatorSimplified? _velocityEstimator;
// State
private Pose2D _currentPose;
private Twist2D _currentTwist;
private double _linearVelocityCommand = 0;
private double _angularVelocityCommand = 0;
private DateTime _startTime;
private double _totalDistanceTraveled = 0;
private Pose2D _lastPose;
private int _telemetryCreateFrequency = 15;
private int _telemetryCreateCount = 0;
// Navigation phase state machine
private NavigationPhase _navigationPhase = NavigationPhase.InitialRotation;
private double _targetHeading = 0; // Target heading for rotation phases
// WatchThread control loop
private WatchThread<TestExecutor>? _controlLoopThread;
private TaskCompletionSource<TestExecutionResult>? _tcs;
private volatile bool _controlLoopDone;
private double _oldDistanceToGoal;
private int _stallCounter;
public TestExecutor(
ILocalizationProvider localization,
IVelocityProvider velocityProvider,
SafetyConfig? safetyConfig = null,
ILogger<TestExecutor>? logger = null)
{
_localization = localization;
_velocityProvider = velocityProvider;
_safetyConfig = safetyConfig ?? new SafetyConfig();
_safetyMonitor = new SafetyMonitor(_safetyConfig);
_logger = logger;
}
public async Task<TestExecutionResult> ExecuteAsync(
TestScenario scenario,
NavigationParameterSet parameters,
Action<TelemetryData>? onTelemetryUpdate = null,
CancellationToken cancellationToken = default,
Action<TestExecutionResult>? onComplete = null)
{
if (_status == TestStatus.Running)
throw new InvalidOperationException("Test is already running");
_currentScenario = scenario;
_currentParameters = parameters;
_status = TestStatus.Preparing;
_telemetryData.Clear();
_safetyMonitor.Reset();
_controlLoopDone = false;
_oldDistanceToGoal = 0;
_stallCounter = 0;
_navigationPhase = NavigationPhase.InitialRotation;
// Generate reference path
var pathPoints = scenario.GenerateReferencePath();
_referencePath = new ReferencePath
{
Points = pathPoints,
TotalLength = pathPoints.Count > 0 ? pathPoints[^1].DistanceFromStart : 0
};
// Initialize controllers
_movePid = new PID(parameters.MovePidConfig);
_rotatePid = new PID(parameters.RotatePidConfig);
// Initialize path following controller based on selected type
if (parameters.ControllerType == PathFollowingController.Stanley)
{
_stanley = new StanleySimplified(parameters.StanleyConfig);
_stanley.SetPath(pathPoints);
Console.WriteLine("Using Stanley Controller for path following");
}
else
{
_purePursuit = new PurePursuitSimplified(parameters.PurePursuitConfig, parameters.StanleyConfig);
_purePursuit.SetPath(pathPoints);
Console.WriteLine("Using Pure Pursuit Controller for path following");
}
_velocityEstimator = new VelocityEstimatorSimplified(
parameters.EstimatorConfig,
parameters.SignalConfig,
parameters.MotorDynamicsConfig
);
// Initialize pose from localization
_currentPose = new Pose2D(_localization.X, _localization.Y, _localization.Theta);
_lastPose = _currentPose;
_startTime = DateTime.UtcNow;
// Calculate initial target heading for initial rotation phase
_targetHeading = CalculateInitialTargetHeading();
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_status = TestStatus.Running;
if (_currentScenario != null)
{
var goalPose = _currentScenario.GetGoalPose();
Console.WriteLine($"Starting test execution towards goal at ({goalPose.X}, {goalPose.Y})");
}
_velocityProvider.SetAcceleration(parameters.NavigationConfig.Acceleration);
_velocityProvider.SetDeceleration(parameters.NavigationConfig.Deceleration);
if (onComplete != null)
{
// Fire-and-forget: run control loop on WatchThread, return immediately with Running
var thread = new WatchThread<TestExecutor>(
ControlLoopIntervalMs,
() =>
{
if (_controlLoopDone) return;
try
{
if (!RunOneControlTick(onTelemetryUpdate))
{
_controlLoopDone = true;
var result = BuildFinalResult();
var t = _controlLoopThread;
_controlLoopThread = null;
onComplete(result);
_ = Task.Run(() =>
{
Thread.Sleep(50);
t?.Stop();
});
}
}
catch (OperationCanceledException)
{
_status = TestStatus.Aborted;
_velocityProvider.SetVelocity(0, 0);
_controlLoopDone = true;
var result = BuildFinalResult();
var t = _controlLoopThread;
_controlLoopThread = null;
onComplete(result);
_ = Task.Run(() => { Thread.Sleep(50); t?.Stop(); });
}
catch (Exception ex)
{
_logger?.LogError(ex, "Control loop error");
_status = TestStatus.Error;
_velocityProvider.SetVelocity(0, 0);
_controlLoopDone = true;
var result = BuildFinalResult();
result.ErrorMessage = ex.Message;
var t = _controlLoopThread;
_controlLoopThread = null;
onComplete(result);
_ = Task.Run(() => { Thread.Sleep(50); t?.Stop(); });
}
},
_logger);
_controlLoopThread = thread;
thread.Start();
return new TestExecutionResult
{
TestRunId = Guid.Empty,
Status = TestStatus.Running,
StartTime = _startTime
};
}
// Await mode: run on WatchThread and wait for TCS
_tcs = new TaskCompletionSource<TestExecutionResult>(TaskCreationOptions.RunContinuationsAsynchronously);
var watchThread = new WatchThread<TestExecutor>(
ControlLoopIntervalMs,
() =>
{
if (_controlLoopDone) return;
try
{
if (!RunOneControlTick(onTelemetryUpdate))
{
_controlLoopDone = true;
var result = BuildFinalResult();
_tcs?.TrySetResult(result);
var t = _controlLoopThread;
_controlLoopThread = null;
_ = Task.Run(() => { Thread.Sleep(50); t?.Stop(); });
}
}
catch (OperationCanceledException)
{
_status = TestStatus.Aborted;
_velocityProvider.SetVelocity(0, 0);
_controlLoopDone = true;
_tcs?.TrySetResult(BuildFinalResult());
var t = _controlLoopThread;
_controlLoopThread = null;
_ = Task.Run(() => { Thread.Sleep(50); t?.Stop(); });
}
catch (Exception ex)
{
_logger?.LogError(ex, "Control loop error");
_status = TestStatus.Error;
_velocityProvider.SetVelocity(0, 0);
_controlLoopDone = true;
var errResult = BuildFinalResult();
errResult.ErrorMessage = ex.Message;
_tcs?.TrySetResult(errResult);
var t = _controlLoopThread;
_controlLoopThread = null;
_ = Task.Run(() => { Thread.Sleep(50); t?.Stop(); });
}
},
_logger);
_controlLoopThread = watchThread;
watchThread.Start();
try
{
return await _tcs.Task;
}
finally
{
_controlLoopThread?.Stop();
_controlLoopThread = null;
}
}
/// <summary>
/// One tick of the control loop (50Hz). Returns false when done (goal/cancel/error/stall/safety).
/// Implements state machine: InitialRotation -> PathFollowing -> FinalRotation -> Completed
/// </summary>
private bool RunOneControlTick(Action<TelemetryData>? onTelemetryUpdate)
{
if (_currentScenario is null) return false;
if (_cancellationTokenSource?.Token.IsCancellationRequested == true)
{
_status = TestStatus.Aborted;
_velocityProvider.SetVelocity(0, 0);
return false;
}
var goalPose = _currentScenario.GetGoalPose();
// Read current state
_currentPose = new Pose2D(_localization.X, _localization.Y, _localization.Theta);
var (linearVel, angularVel) = _velocityProvider.GetActualVelocity();
var distanceToGoal = Pose2D.Distance(_currentPose, goalPose);
// Estimate hybrid velocity
var vHybrid = _velocityEstimator!.EstimateVelocity(
_linearVelocityCommand,
linearVel,
Dt);
var confidence = _velocityEstimator.GetConfidence();
_currentTwist = new Twist2D(vHybrid, angularVel);
// Create telemetry and check safety
var commandTwist = new Twist2D(_linearVelocityCommand, _angularVelocityCommand);
var telemetry = CreateTelemetryData(_currentPose, _currentTwist, commandTwist, distanceToGoal);
if (!_safetyMonitor.CheckSafety(telemetry, _referencePath!))
{
_status = TestStatus.EmergencyStopped;
_velocityProvider.SetVelocity(0, 0);
Console.WriteLine("Emergency stop triggered due to safety violation.");
return false;
}
if (_telemetryCreateCount++ >= (ControlLoopFrequency / _telemetryCreateFrequency))
{
_telemetryData.Add(telemetry);
_telemetryCreateCount = 0;
}
var reachedRadius = _currentParameters?.NavigationConfig.ReachedRadius ?? 0.015f;
// State machine logic
switch (_navigationPhase)
{
case NavigationPhase.InitialRotation:
{
// Check if initial rotation is needed
double headingError = NavigationMath.NormalizeAngle(_targetHeading - _currentPose.Theta);
double initialRotationThresholdRad = (_currentParameters?.NavigationConfig.InitialRotationThreshold ?? 5.0f) * Math.PI / 180.0;
if (Math.Abs(headingError) < initialRotationThresholdRad)
{
// Heading is good enough, skip to path following
Console.WriteLine($"Initial heading acceptable ({Math.Abs(headingError) * 180 / Math.PI:F2}°), skipping initial rotation");
_navigationPhase = NavigationPhase.PathFollowing;
_rotatePid!.Reset();
}
else
{
// Perform initial rotation
if (PerformInitialRotation())
{
// Rotation complete, move to path following
_navigationPhase = NavigationPhase.PathFollowing;
_rotatePid!.Reset();
}
}
onTelemetryUpdate?.Invoke(telemetry);
_totalDistanceTraveled += (float)Pose2D.Distance(_lastPose, _currentPose);
_lastPose = _currentPose;
return true;
}
case NavigationPhase.PathFollowing:
{
if (distanceToGoal <= reachedRadius)
{
// Position reached, move to final rotation
_targetHeading = CalculateFinalTargetHeading();
_navigationPhase = NavigationPhase.FinalRotation;
_rotatePid!.Reset();
Console.WriteLine($"Position reached, starting final rotation to {_targetHeading * 180 / Math.PI:F2}°. Current pose: [{_currentPose.X} - {_currentPose.Y}], Goal: [{goalPose.X} - {goalPose.Y}], Distance: {distanceToGoal}");
onTelemetryUpdate?.Invoke(telemetry);
_totalDistanceTraveled += (float)Pose2D.Distance(_lastPose, _currentPose);
_lastPose = _currentPose;
return true;
}
// Stall detection (only during path following)
if (_oldDistanceToGoal >= distanceToGoal) _stallCounter = 0;
else if (distanceToGoal < 0.3)
{
_stallCounter++;
if (_stallCounter >= ControlLoopFrequency * 0.1)
{
_status = TestStatus.Error;
_velocityProvider.SetVelocity(0, 0);
Console.WriteLine("Test execution stalled: no progress towards goal.");
_targetHeading = CalculateFinalTargetHeading();
_navigationPhase = NavigationPhase.FinalRotation;
_rotatePid!.Reset();
return true;
}
}
_oldDistanceToGoal = distanceToGoal;
// Calculate max linear velocity with PID deceleration
double maxLinearVel;
if (distanceToGoal > 5.0)
maxLinearVel = _currentParameters!.NavigationConfig.MaxLinearVelocity;
else
{
var pidOutput = _movePid!.PID_step(distanceToGoal,
_currentParameters!.NavigationConfig.MaxLinearVelocity,
_currentParameters.NavigationConfig.MinLinearVelocity,
Dt);
maxLinearVel = pidOutput;
}
// Path following using selected controller
double linearVelCmd, angularVelCmd;
if (_currentParameters!.ControllerType == PathFollowingController.Stanley)
{
// Stanley Controller
(linearVelCmd, angularVelCmd) = _stanley!.CalculateVelocity(
_currentPose.X,
_currentPose.Y,
_currentPose.Theta,
vHybrid,
maxLinearVel);
}
else
{
// Pure Pursuit Controller
(linearVelCmd, angularVelCmd) = _purePursuit!.CalculateAngularVelocity(
_currentPose.X,
_currentPose.Y,
_currentPose.Theta,
vHybrid,
maxLinearVel);
}
var linearVelSign = Math.Sign(linearVelCmd);
_linearVelocityCommand = (float)Math.Clamp(
Math.Abs(linearVelCmd),
_currentParameters!.NavigationConfig.MinLinearVelocity,
_currentParameters.NavigationConfig.MaxLinearVelocity);
_angularVelocityCommand = (float)Math.Clamp(
angularVelCmd,
-_currentParameters.NavigationConfig.MaxAngularVelocity,
_currentParameters.NavigationConfig.MaxAngularVelocity);
_velocityProvider.SetVelocity(linearVelSign * _linearVelocityCommand, _angularVelocityCommand);
onTelemetryUpdate?.Invoke(telemetry);
_totalDistanceTraveled += (float)Pose2D.Distance(_lastPose, _currentPose);
_lastPose = _currentPose;
return true;
}
case NavigationPhase.FinalRotation:
{
// Perform final rotation to goal heading
if (PerformFinalRotation())
{
// Final rotation complete, navigation done
_navigationPhase = NavigationPhase.Completed;
_status = TestStatus.Completed;
_velocityProvider.SetVelocity(0, 0);
Console.WriteLine("Navigation complete!");
return false;
}
onTelemetryUpdate?.Invoke(telemetry);
_totalDistanceTraveled += (float)Pose2D.Distance(_lastPose, _currentPose);
_lastPose = _currentPose;
return true;
}
case NavigationPhase.Completed:
{
_status = TestStatus.Completed;
_velocityProvider.SetVelocity(0, 0);
return false;
}
default:
return false;
}
}
private TestExecutionResult BuildFinalResult()
{
var endTime = DateTime.UtcNow;
var duration = (endTime - _startTime).TotalSeconds;
return new TestExecutionResult
{
TestRunId = Guid.NewGuid(),
Status = _status,
TelemetryData = _telemetryData,
SafetyViolations = _safetyMonitor.GetViolations(),
StartTime = _startTime,
EndTime = endTime,
Duration = duration
};
}
private TelemetryData CreateTelemetryData(Pose2D pose, Twist2D twist, Twist2D commandTwist, double distanceToGoal)
{
var closestPoint = _referencePath!.GetClosestPoint(pose);
var cte = closestPoint != null
? Math.Sqrt(Math.Pow(pose.X - closestPoint.X, 2) + Math.Pow(pose.Y - closestPoint.Y, 2))
: 0;
// Calculate heading error from Direction
// Reference heading is calculated from direction of movement (tangent to path)
double headingError = 0;
double refTheta = 0;
if (closestPoint != null)
{
// Find next point to determine direction
int closestIndex = _referencePath.Points.IndexOf(closestPoint);
if (closestIndex >= 0 && closestIndex < _referencePath.Points.Count - 1)
{
var nextPoint = _referencePath.Points[closestIndex + 1];
double dx = nextPoint.X - closestPoint.X;
double dy = nextPoint.Y - closestPoint.Y;
refTheta = Math.Atan2(dy, dx);
}
else if (closestIndex > 0)
{
// Use previous point
var prevPoint = _referencePath.Points[closestIndex - 1];
double dx = closestPoint.X - prevPoint.X;
double dy = closestPoint.Y - prevPoint.Y;
refTheta = Math.Atan2(dy, dx);
}
// Adjust for backward direction
if (closestPoint.Direction == RobotDirection.BACKWARD)
{
refTheta = NavigationMath.NormalizeAngle(refTheta + Math.PI);
}
refTheta = NavigationMath.NormalizeAngle(refTheta);
headingError = NavigationMath.NormalizeAngle(pose.Theta - refTheta);
}
// Lookahead distance only applicable for Pure Pursuit
var lookaheadDistance = 0.0;
if (_currentParameters?.ControllerType == PathFollowingController.PurePursuit)
{
lookaheadDistance = _purePursuit?.GetCurrentLookahead(
twist.Linear,
_velocityEstimator?.GetConfidence() ?? 1.0
) ?? 0;
}
var refPose = closestPoint != null
? new Pose2D(closestPoint.X, closestPoint.Y, refTheta)
: pose;
return new TelemetryData
{
TimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
RobotPose = pose,
RobotTwist = twist,
CommandTwist = commandTwist,
ReferencePose = refPose,
CrossTrackError = cte,
HeadingError = headingError,
LookaheadDistance = lookaheadDistance,
ModelConfidence = _velocityEstimator?.GetConfidence() ?? 1.0,
DistanceToGoal = distanceToGoal,
Phase = DetermineCurrentPhase(distanceToGoal)
};
}
private TelemetryPhase DetermineCurrentPhase(double distanceToGoal)
{
return _navigationPhase switch
{
NavigationPhase.InitialRotation => TelemetryPhase.InitialRotation,
NavigationPhase.PathFollowing => DeterminePathFollowingSubPhase(distanceToGoal),
NavigationPhase.FinalRotation => TelemetryPhase.FinalRotation,
NavigationPhase.Completed => TelemetryPhase.Completed,
_ => TelemetryPhase.PathFollowing
};
}
private TelemetryPhase DeterminePathFollowingSubPhase(double distanceToGoal)
{
if (_purePursuit != null && _purePursuit.IsInFinalApproach)
return TelemetryPhase.FinalApproach;
if (_stanley != null && _currentParameters != null &&
distanceToGoal < _currentParameters.StanleyConfig.GoalApproachDistance)
return TelemetryPhase.FinalApproach;
return TelemetryPhase.PathFollowing;
}
public void Pause()
{
if (_status == TestStatus.Running)
{
_status = TestStatus.Paused;
_velocityProvider.SetVelocity(0, 0);
}
}
public void Resume()
{
if (_status == TestStatus.Paused)
{
_status = TestStatus.Running;
}
}
public void Stop()
{
_cancellationTokenSource?.Cancel();
_velocityProvider.SetVelocity(0, 0);
_status = TestStatus.Aborted;
Console.WriteLine("Test execution stopped by user.");
}
public void EmergencyStop()
{
_cancellationTokenSource?.Cancel();
_velocityProvider.SetVelocity(0, 0);
_status = TestStatus.EmergencyStopped;
}
public TestStatus GetStatus() => _status;
public TestProgress GetProgress()
{
if (_currentScenario == null || _referencePath == null)
return new TestProgress();
var goalPose = _currentScenario.GetGoalPose();
var distanceToGoal = Pose2D.Distance(_currentPose, goalPose);
var totalDistance = _referencePath.TotalLength;
var progressPercent = totalDistance > 0
? 1.0 - (distanceToGoal / totalDistance)
: 0;
var elapsedTime = (DateTime.UtcNow - _startTime).TotalSeconds;
var estimatedTimeRemaining = progressPercent > 0.01f
? elapsedTime / progressPercent - elapsedTime
: 0;
return new TestProgress
{
ProgressPercent = Math.Clamp(progressPercent, 0, 1),
DistanceTraveled = _totalDistanceTraveled,
DistanceToGoal = distanceToGoal,
ElapsedTime = elapsedTime,
EstimatedTimeRemaining = estimatedTimeRemaining
};
}
/// <summary>
/// Calculate initial target heading to first lookahead point
/// Returns the angle from current position to the lookahead point on the path
/// </summary>
private double CalculateInitialTargetHeading()
{
if (_referencePath == null || _referencePath.Points.Count < 2)
return _currentPose.Theta;
// Get closest point on path
var closestPoint = _referencePath.GetClosestPoint(_currentPose);
if (closestPoint == null)
return _currentPose.Theta;
int closestIndex = _referencePath.Points.IndexOf(closestPoint);
if (closestIndex < 0)
return _currentPose.Theta;
// Calculate a simple lookahead distance (use minimum lookahead)
double lookaheadDistance = (_currentParameters?.PurePursuitConfig.LookaheadMin + _currentParameters?.PurePursuitConfig.LookaheadMax ) / 2 ?? 1;
// Find target point at lookahead distance
PathPoint? targetPoint = FindTargetPointAtDistance(closestIndex, lookaheadDistance);
if (targetPoint == null)
targetPoint = _referencePath.Points[^1]; // Use goal if no target found
// Calculate angle to target point
double dx = targetPoint.X - _currentPose.X;
double dy = targetPoint.Y - _currentPose.Y;
double heading = Math.Atan2(dy, dx);
// Adjust for backward direction
if (targetPoint.Direction == RobotDirection.BACKWARD)
{
heading = NavigationMath.NormalizeAngle(heading + Math.PI);
}
return NavigationMath.NormalizeAngle(heading);
}
/// <summary>
/// Calculate final target heading at goal (from second-to-last waypoint to goal)
/// Similar to PurePursuitSimplified.CalculateGoalHeading()
/// </summary>
private double CalculateFinalTargetHeading()
{
if (_referencePath == null || _referencePath.Points.Count < 2)
return _currentPose.Theta;
var goalPoint = _referencePath.Points[^1];
var secondToLast = _referencePath.Points[^2];
double dx = goalPoint.X - secondToLast.X;
double dy = goalPoint.Y - secondToLast.Y;
double heading = Math.Atan2(dy, dx);
// Adjust for backward direction
if (goalPoint.Direction == RobotDirection.BACKWARD)
{
heading = NavigationMath.NormalizeAngle(heading + Math.PI);
}
return NavigationMath.NormalizeAngle(heading);
}
/// <summary>
/// Find target point at lookahead distance from current position
/// Simplified version for initial rotation calculation
/// </summary>
private PathPoint? FindTargetPointAtDistance(int startIndex, double lookaheadDistance)
{
if (_referencePath == null || startIndex >= _referencePath.Points.Count - 1)
return null;
double accumulatedDistance = 0;
for (int i = startIndex; i < _referencePath.Points.Count - 1; i++)
{
double dx = _referencePath.Points[i + 1].X - _referencePath.Points[i].X;
double dy = _referencePath.Points[i + 1].Y - _referencePath.Points[i].Y;
double segmentLength = Math.Sqrt(dx * dx + dy * dy);
if (accumulatedDistance + segmentLength >= lookaheadDistance)
{
// Interpolate within this segment
double t = (lookaheadDistance - accumulatedDistance) / segmentLength;
return new PathPoint
{
X = _referencePath.Points[i].X + t * (_referencePath.Points[i + 1].X - _referencePath.Points[i].X),
Y = _referencePath.Points[i].Y + t * (_referencePath.Points[i + 1].Y - _referencePath.Points[i].Y),
Direction = _referencePath.Points[i].Direction
};
}
accumulatedDistance += segmentLength;
}
return null;
}
/// <summary>
/// Perform initial rotation to face the initial lookahead point
/// Returns true if rotation is complete, false if still rotating
/// </summary>
private bool PerformInitialRotation()
{
// Calculate heading error
double headingError = NavigationMath.NormalizeAngle(_targetHeading - _currentPose.Theta);
// Check if rotation is complete (within heading tolerance)
double headingToleranceRad = (_currentParameters?.PurePursuitConfig.HeadingTolerance ?? 3.0f) * Math.PI / 180.0;
if (Math.Abs(headingError) < headingToleranceRad)
{
_velocityProvider.SetVelocity(0, 0);
Console.WriteLine($"Initial rotation complete. Heading error: {Math.Abs(headingError) * 180 / Math.PI:F2}<7D>");
return true;
}
// Use PID to control rotation
double angularVelCmd = _rotatePid!.PID_step(
headingError,
_currentParameters!.NavigationConfig.RotateAngularVelocity,
-_currentParameters!.NavigationConfig.RotateAngularVelocity,
Dt);
_angularVelocityCommand = (float)angularVelCmd;
_velocityProvider.SetVelocity(0, _angularVelocityCommand);
Console.WriteLine($"Initial rotation: HeadingError={headingError * 180 / Math.PI:F2}<7D>, AngVel={_angularVelocityCommand:F3}");
return false;
}
/// <summary>
/// Perform final rotation to face the goal heading
/// Returns true if rotation is complete, false if still rotating
/// </summary>
private bool PerformFinalRotation()
{
// Calculate heading error
double headingError = NavigationMath.NormalizeAngle(_targetHeading - _currentPose.Theta);
// Check if rotation is complete (within heading tolerance)
double headingToleranceRad = (_currentParameters?.PurePursuitConfig.HeadingTolerance ?? 3.0f) * Math.PI / 180.0;
if (Math.Abs(headingError) < headingToleranceRad)
{
_velocityProvider.SetVelocity(0, 0);
Console.WriteLine($"Final rotation complete. Heading error: {Math.Abs(headingError) * 180 / Math.PI:F2}<7D>");
return true;
}
// Use PID to control rotation
double angularVelCmd = _rotatePid!.PID_step(
headingError,
_currentParameters!.NavigationConfig.RotateAngularVelocity,
-_currentParameters!.NavigationConfig.RotateAngularVelocity,
Dt);
_angularVelocityCommand = (float)angularVelCmd;
_velocityProvider.SetVelocity(0, _angularVelocityCommand);
Console.WriteLine($"Final rotation: HeadingError={headingError * 180 / Math.PI:F2}<7D>, AngVel={_angularVelocityCommand:F3}");
return false;
}
}
/// <summary>
/// Interface for localization provider (abstraction for ILocalization)
/// </summary>
public interface ILocalizationProvider
{
double X { get; }
double Y { get; }
double Theta { get; } // radians
}
/// <summary>
/// Interface for velocity provider (abstraction for IVelocityController)
/// </summary>
public interface IVelocityProvider
{
(double Linear, double Angular) GetActualVelocity();
void SetVelocity(double linearVel, double angularVel);
double GetModelConfidence();
void SetAcceleration(double acc);
void SetDeceleration(double dec);
}

View File

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

View File

@@ -0,0 +1,44 @@
namespace RobotNet10.NavigationTune.Execution;
/// <summary>
/// Adapter to wrap IVelocityController from RobotApp
/// This will be implemented in the application layer that has access to RobotApp
/// </summary>
public class VelocityControllerAdapter(
Func<(double Linear, double Angular)> getActualVelocity,
Action<double, double> setVelocity,
Func<double> getModelConfidence,
Action<double>? setAcceleration = null,
Action<double>? setDeceleration = null) : IVelocityProvider
{
private readonly Func<(double Linear, double Angular)> _getActualVelocity = getActualVelocity;
private readonly Action<double, double> _setVelocity = setVelocity;
private readonly Func<double> _getModelConfidence = getModelConfidence;
private readonly Action<double>? _setAcceleration = setAcceleration;
private readonly Action<double>? _setDeceleration = setDeceleration;
public (double Linear, double Angular) GetActualVelocity()
{
return _getActualVelocity();
}
public void SetVelocity(double linearVel, double angularVel)
{
_setVelocity(linearVel, angularVel);
}
public double GetModelConfidence()
{
return _getModelConfidence();
}
public void SetAcceleration(double acc)
{
_setAcceleration?.Invoke(acc);
}
public void SetDeceleration(double dec)
{
_setDeceleration?.Invoke(dec);
}
}

View File

@@ -0,0 +1,87 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RobotNet10.NavigationTune.Data;
using RobotNet10.NavigationTune.Execution;
using RobotNet10.NavigationTune.Interfaces;
using RobotNet10.NavigationTune.Services;
using RobotNet10.NavigationTune.Shared.Interfaces;
namespace RobotNet10.NavigationTune.Extensions;
/// <summary>
/// Extension methods for service registration
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Add Navigation Tuning services
/// </summary>
public static IServiceCollection AddNavigationTuning(
this IServiceCollection services,
Action<DbContextOptionsBuilder> dbContextOptions)
{
services.AddDbContext<TuningDbContext>(dbContextOptions);
// Repositories
services.AddScoped<ITestRepository, TestRepository>();
services.AddScoped<IScenarioRepository, ScenarioRepository>();
// Services
services.AddScoped<IParameterManager, ParameterManager>();
services.AddScoped<IMetricsCalculator, MetricsCalculator>();
services.AddScoped<ITuningAdvisor, TuningAdvisor>();
// Execution
services.AddScoped<ITestExecutor, TestExecutor>();
services.AddScoped<ITuningNavigation, TuningNavigation>();
// Singleton: so Stop/EMC Stop (different HTTP request) can cancel the running test
services.AddSingleton<IRunningTestCancellationRegistry, RunningTestCancellationRegistry>();
// Orchestrator
services.AddScoped<ITuningOrchestrator, TuningOrchestrator>();
// SignalR Hub: allow NaN/Infinity in JSON so telemetry/metrics with non-finite floats do not abort the connection
services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
});
return services;
}
/// <summary>
/// Add Navigation Tuning with real robot dependencies
/// </summary>
public static IServiceCollection AddNavigationTuningWithRobot(
this IServiceCollection services,
Action<DbContextOptionsBuilder> dbContextOptions)
{
// Add base services
services.AddNavigationTuning(dbContextOptions);
// Note: Adapters for real robot should be registered in the application layer
// that has access to RobotApp.Interfaces.ILocalization and IVelocityController
// Example:
// services.AddScoped<ILocalizationProvider>(sp =>
// {
// var localization = sp.GetRequiredService<RobotNet10.RobotApp.Interfaces.ILocalization>();
// return new LocalizationAdapter(
// () => localization.X,
// () => localization.Y,
// () => localization.Theta
// );
// });
return services;
}
/// <summary>
/// Map SignalR hubs
/// Note: This should be called in the application's Program.cs or Startup.cs
/// Example: app.MapHub<TuningHub>("/tuninghub");
/// </summary>
}

View File

@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.SignalR;
using RobotNet10.NavigationTune.Shared.Hubs;
namespace RobotNet10.NavigationTune.Hubs;
/// <summary>
/// SignalR Hub for real-time tuning updates
/// </summary>
public class TuningHub : Hub
{
/// <summary>
/// Join test session
/// </summary>
public async Task JoinTestSession(string testRunId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"test_{testRunId}");
}
/// <summary>
/// Leave test session
/// </summary>
public async Task LeaveTestSession(string testRunId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"test_{testRunId}");
}
}

View File

@@ -0,0 +1,12 @@
namespace RobotNet10.NavigationTune.Interfaces;
/// <summary>
/// Singleton registry to cancel a running test by testRunId.
/// Stop/EMC Stop API runs in a different HTTP scope than the test; this allows cancelling the correct test.
/// </summary>
public interface IRunningTestCancellationRegistry
{
void Register(Guid testRunId, CancellationTokenSource cts);
bool TryCancel(Guid testRunId);
void Unregister(Guid testRunId);
}

View File

@@ -0,0 +1,58 @@
using RobotNet10.Shared.Numbers;
using SysNum = System.Numerics;
namespace RobotNet10.NavigationTune.Navigation.Core;
/// <summary>
/// Circular buffer để lưu lịch sử (fixed size)
/// </summary>
public class CircularBuffer<T>(int capacity) where T : SysNum.INumber<T>
{
private readonly T[] _buffer = new T[capacity];
private int _head = 0;
private int _count = 0;
private readonly int _capacity = capacity;
public int Count => _count;
public int Capacity => _capacity;
public void Add(T item)
{
_buffer[_head] = item;
_head = (_head + 1) % _capacity;
if (_count < _capacity)
_count++;
}
public void Clear()
{
_head = 0;
_count = 0;
Array.Clear(_buffer, 0, _capacity);
}
public T[] ToArray()
{
T[] result = new T[_count];
for (int i = 0; i < _count; i++)
{
int index = (_head - _count + i + _capacity) % _capacity;
result[i] = _buffer[index];
}
return result;
}
public double Average()
{
if (_count == 0) return 0;
T sum = T.Zero;
for (int i = 0; i < _count; i++)
{
int index = (_head - _count + i + _capacity) % _capacity;
sum += _buffer[index];
}
return double.CreateChecked(sum) / _count;
}
}

View File

@@ -0,0 +1,63 @@
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Navigation.Core;
/// <summary>
/// Mô hình động học của motor driver
/// First-order system: v(t) = v_cmd × (1 - e^(-(t-δ)/τ))
/// </summary>
public class MotorDynamicsModel
{
public double Tau { get; set; }
public double Delta { get; set; }
/// <summary>
/// Constructor với giá trị mặc định
/// </summary>
public MotorDynamicsModel()
{
Tau = 0.3; // 300ms time constant
Delta = 0.05f; // 50ms delay
}
public MotorDynamicsModel(MotorDynamicsConfig config)
{
Tau = config.Tau;
Delta = config.Delta;
}
/// <summary>
/// Predict vận tốc tại thời điểm tương lai
/// </summary>
/// <param name="vCmd">Velocity command đã gửi</param>
/// <param name="vActual">Velocity thực tế hiện tại</param>
/// <param name="timeAhead">Thời gian dự đoán về tương lai (s)</param>
/// <returns>Vận tốc dự đoán</returns>
public double PredictVelocity(double vCmd, double vActual, double timeAhead)
{
// Nếu thời gian dự đoán < delay
// → Motor chưa bắt đầu phản ứng
if (timeAhead < Delta)
{
return vActual;
}
// Thời gian hiệu dụng (sau khi trừ delay)
double effectiveTime = timeAhead - Delta;
// First-order system response
// response = 1 - e^(-t/τ)
double response = 1.0 - Math.Exp(-effectiveTime / Tau);
// Velocity prediction
// v_future = v_actual + (v_cmd - v_actual) × response
double vPredicted = vActual + (vCmd - vActual) * response;
return vPredicted;
}
public override string ToString()
{
return $"MotorModel(τ={Tau:F3}s, δ={Delta:F3}s)";
}
}

View File

@@ -0,0 +1,27 @@
namespace RobotNet10.NavigationTune.Navigation.Core;
/// <summary>
/// Shared math utilities for navigation algorithms
/// </summary>
public static class NavigationMath
{
/// <summary>
/// Normalize angle to [-π, π]
/// </summary>
public static double NormalizeAngle(double angle)
{
while (angle > Math.PI) angle -= 2 * Math.PI;
while (angle < -Math.PI) angle += 2 * Math.PI;
return angle;
}
/// <summary>
/// Calculate Euclidean distance between two points
/// </summary>
public static double CalculateDistance(double x1, double y1, double x2, double y2)
{
double dx = x2 - x1;
double dy = y2 - y1;
return Math.Sqrt(dx * dx + dy * dy);
}
}

View File

@@ -0,0 +1,69 @@
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Navigation.Core;
public class PID(PIDConfig config)
{
private double Kp = config.Kp;
private double Ki = config.Ki;
private double Kd = config.Kd;
private double IntegralZone = config.IntegralZone;
private double _prevError;
private double _integral;
public PID WithKp(double kp)
{
Kp = kp;
return this;
}
public PID WithKi(double ki)
{
Ki = ki;
return this;
}
public PID WithKd(double kd)
{
Kd = kd;
return this;
}
public PID WithIntegralZone(double integralZone)
{
IntegralZone = integralZone;
return this;
}
public double PID_step(double error, double max, double min, double timeSample)
{
double integralStep = 0.5 * (error + _prevError) * timeSample;
bool inIntegralZone = IntegralZone <= 0 || Math.Abs(error) <= IntegralZone;
if (inIntegralZone)
_integral += integralStep;
else
_integral = 0;
double derivative = (error - _prevError) / timeSample;
_prevError = error;
double Out = Kp * error
+ Ki * _integral
+ Kd * derivative;
// Anti-windup
double clamped = Math.Clamp(Out, min, max);
if (clamped != Out && inIntegralZone)
_integral -= integralStep;
return clamped;
}
public void Reset()
{
_prevError = 0;
_integral = 0;
}
}

View File

@@ -0,0 +1,477 @@
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Navigation.Core;
/// <summary>
/// Simplified Pure Pursuit controller for tuning system
/// Works with simple PathPoint list instead of OrderNode/Edge
/// </summary>
public class PurePursuitSimplified(PurePursuitConfig config, StanleyConfig stanleyConfig)
{
private List<PathPoint> _waypoints = new();
private int _currentWaypointIndex = 0;
private int _currentWaypointAheadIndex = 0;
private PathPoint? _goalPoint;
public bool IsInFinalApproach { get; private set; }
/// <summary>
/// Set path from PathPoint list
/// </summary>
public void SetPath(List<PathPoint> waypoints)
{
if (waypoints.Count < 2)
throw new ArgumentException("Path must have at least 2 waypoints", nameof(waypoints));
_waypoints = waypoints;
_currentWaypointIndex = 0;
_currentWaypointAheadIndex = 0;
_goalPoint = waypoints[^1];
}
/// <summary>
/// Update goal point
/// </summary>
public void UpdateGoal(PathPoint goal)
{
_goalPoint = goal;
}
/// <summary>
/// Get closest waypoint to current position
/// </summary>
public (PathPoint point, int index) GetClosestWaypoint(double x, double y)
{
if (_waypoints.Count == 0)
throw new InvalidOperationException("Path not set");
double minDistance = double.MaxValue;
int closestIndex = 0;
// Start search from current index for efficiency
for (int i = _currentWaypointIndex; i < _waypoints.Count; i++)
{
double dx = x - _waypoints[i].X;
double dy = y - _waypoints[i].Y;
double distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
// Also check previous waypoints in case robot moved backwards
for (int i = 0; i < _currentWaypointIndex; i++)
{
double dx = x - _waypoints[i].X;
double dy = y - _waypoints[i].Y;
double distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
_currentWaypointIndex = closestIndex;
return (_waypoints[closestIndex], closestIndex);
}
/// <summary>
/// Calculate adaptive lookahead distance based on velocity, confidence, distance to goal, and path curvature
/// Lookahead adapts to:
/// 1. Velocity (faster = look further ahead)
/// 2. Distance to goal (near goal = shorter lookahead for precision)
/// 3. Path curvature (sharp curves = shorter lookahead for tighter tracking)
/// 4. Confidence (low confidence = shorter lookahead for safety)
/// </summary>
private double GetLookaheadDistance(double vHybrid, double robotX, double robotY, int closestIndex)
{
// 1. Base lookahead from velocity
double baseLookahead = config.LookaheadMin + config.Kdd * Math.Abs(vHybrid);
// 2. Distance-to-goal adaptation
double distanceToGoal = CalculateDistanceToGoal(robotX, robotY);
double goalFactor = 1.0;
if (distanceToGoal < config.GoalRegionDistance)
{
// Gradually reduce lookahead as we approach goal
// At goal: factor = 0.5, At GoalRegionDistance: factor = 1.0
goalFactor = 0.5 + 0.5 * (distanceToGoal / config.GoalRegionDistance);
}
// 3. Curvature adaptation
double curvature = CalculateCurvature(closestIndex);
// curvatureFactor ranges from 1.0 (straight) to ~0.33 (very sharp curve with KCurvature=2.0)
double curvatureFactor = 1.0 / (1.0 + config.KCurvature * curvature);
// 5. Combine all factors
double adaptiveLookahead = baseLookahead * goalFactor * curvatureFactor;
double minLookahead = config.LookaheadMin;
double maxLookahead = config.LookaheadMax;
if(distanceToGoal < config.GoalRegionDistance && Math.Abs(vHybrid) > 0.0)
{
// 6. Apply velocity-based dynamic limits
// Minimum: look at least 0.3 seconds ahead or 0.5m (whichever is larger)
minLookahead = Math.Max(config.LookaheadMin, Math.Abs(vHybrid) * config.MinLookaheadTimeRatio);
// Maximum: look at most 2 seconds ahead or LookaheadMax (whichever is smaller)
maxLookahead = Math.Min(config.LookaheadMax, Math.Abs(vHybrid) * config.MaxLookaheadTimeRatio);
// Ensure min < max
if (minLookahead > maxLookahead)
minLookahead = maxLookahead;
}
adaptiveLookahead = Math.Clamp(adaptiveLookahead, minLookahead, maxLookahead);
if(adaptiveLookahead is double.NaN || adaptiveLookahead <= 0)
{
adaptiveLookahead = config.LookaheadMin;
}
//Console.WriteLine($"Lookahead Calc: Base={baseLookahead:F3}, GoalF={goalFactor:F3}, CurvF={curvatureFactor:F3}, Curvature={curvature:F3}, Lookahead={adaptiveLookahead:F3}, Min={minLookahead:F3}, Max={maxLookahead:F3}, DTG={distanceToGoal:F3}, vHybrid={vHybrid:F3}");
return adaptiveLookahead;
}
/// <summary>
/// Calculate angular velocity using Pure Pursuit algorithm
/// </summary>
public (double linear, double angular) CalculateAngularVelocity(
double robotX,
double robotY,
double robotTheta,
double actualLinearVelocity,
double maxLinearVelocity)
{
if (_waypoints.Count < 2 || _goalPoint == null)
throw new InvalidOperationException("Path not properly initialized");
// Get closest waypoint
(PathPoint closesPoint, int closestIndex) = GetClosestWaypoint(robotX, robotY);
// Calculate adaptive lookahead distance
double lookaheadDistance = GetLookaheadDistance(actualLinearVelocity, robotX, robotY, closestIndex);
// Find target point at lookahead distance
PathPoint? targetPoint = FindTargetPoint(closestIndex, lookaheadDistance);
targetPoint ??= _goalPoint;
// Normalize theta
// Backward adjustment for Pure Pursuit
if (targetPoint.Direction == RobotDirection.BACKWARD) robotTheta += Math.PI;
robotTheta = NavigationMath.NormalizeAngle(robotTheta);
// Check for final approach
if (targetPoint.X == _goalPoint.X && targetPoint.Y == _goalPoint.Y)
{
double distanceToGoal = CalculateDistanceToGoal(robotX, robotY);
if (distanceToGoal <= config.FinalApproachThreshold)
{
IsInFinalApproach = true;
}
}
// When approaching goal, switch to Stanley controller for precise CTE-based tracking
// Reuse closesPoint (already computed above) and normalized robotTheta
if (IsInFinalApproach) return FinalApproachController(robotX, robotY, robotTheta, _goalPoint, actualLinearVelocity, maxLinearVelocity, targetPoint.Direction == RobotDirection.BACKWARD);
// Calculate angle to target
double dx = targetPoint.X - robotX;
double dy = targetPoint.Y - robotY;
double alpha = Math.Atan2(dy, dx) - robotTheta;
// Normalize angle to [-π, π]
while (alpha > Math.PI) alpha -= 2 * Math.PI;
while (alpha < -Math.PI) alpha += 2 * Math.PI;
// Pure Pursuit formula: ω = 2 * v * sin(α) / L
double angularVelocity = 2.0 * actualLinearVelocity * Math.Sin(alpha) / lookaheadDistance;
// Clamp to max angular velocity
if (Math.Abs(angularVelocity) > config.MaxAngularVelocity)
{
angularVelocity = Math.Sign(angularVelocity) * config.MaxAngularVelocity;
}
if (targetPoint.Direction == RobotDirection.BACKWARD)
{
maxLinearVelocity = -maxLinearVelocity;
angularVelocity = -angularVelocity;
}
Console.WriteLine($"PP: CurP=({robotX:F3},{robotY:F3}, {robotTheta:F3}), Alpha={alpha * 180 / Math.PI:F2} deg, AVel={actualLinearVelocity:F5}, LVel={maxLinearVelocity:F2}, AnVel={angularVelocity:F5}");
return (maxLinearVelocity, angularVelocity);
}
/// <summary>
/// Find target point at lookahead distance from current position
/// </summary>
private PathPoint? FindTargetPoint(int startIndex, double lookaheadDistance)
{
if (startIndex >= _waypoints.Count - 1)
return _goalPoint;
double accumulatedDistance = 0;
for (int i = startIndex; i < _waypoints.Count - 1; i++)
{
double dx = _waypoints[i + 1].X - _waypoints[i].X;
double dy = _waypoints[i + 1].Y - _waypoints[i].Y;
double segmentLength = Math.Sqrt(dx * dx + dy * dy);
if (accumulatedDistance + segmentLength >= lookaheadDistance)
{
// Interpolate within this segment
double t = (lookaheadDistance - accumulatedDistance) / segmentLength;
return new PathPoint
{
X = _waypoints[i].X + t * (_waypoints[i + 1].X - _waypoints[i].X),
Y = _waypoints[i].Y + t * (_waypoints[i + 1].Y - _waypoints[i].Y),
Direction = _waypoints[i].Direction,
DistanceFromStart = _waypoints[i].DistanceFromStart + (lookaheadDistance - accumulatedDistance)
};
}
accumulatedDistance += segmentLength;
}
return _goalPoint;
}
/// <summary>
/// Get current lookahead distance (for debugging/testing)
/// Note: This simplified version doesn't account for curvature/distance adaptations
/// For actual adaptive lookahead, use the version called within CalculateAngularVelocity
/// </summary>
public double GetCurrentLookahead(double linearVelocity, double confidence)
{
// Simplified version for backward compatibility
// Uses center of path as reference point
double lookahead = config.LookaheadMin + config.Kdd * Math.Abs(linearVelocity);
lookahead = Math.Clamp(lookahead, config.LookaheadMin, config.LookaheadMax);
return lookahead;
}
/// <summary>
/// Calculate distance from robot to goal point
/// </summary>
private double CalculateDistanceToGoal(double robotX, double robotY)
{
if (_goalPoint == null)
return double.MaxValue;
double dx = _goalPoint.X - robotX;
double dy = _goalPoint.Y - robotY;
return Math.Sqrt(dx * dx + dy * dy);
}
/// <summary>
/// Calculate path curvature at given waypoint index using 3-point circle fitting (Menger curvature)
/// Returns curvature in 1/meters (larger value = sharper curve)
/// </summary>
private double CalculateCurvature(int index)
{
// Need at least 3 points for curvature calculation
if (_waypoints.Count < 3 || index <= 0 || index >= _waypoints.Count - 1)
return 0.0;
var p1 = _waypoints[index - 1];
var p2 = _waypoints[index];
var p3 = _waypoints[index + 1];
// Calculate vectors
double dx1 = p2.X - p1.X;
double dy1 = p2.Y - p1.Y;
double dx2 = p3.X - p2.X;
double dy2 = p3.Y - p2.Y;
// Cross product magnitude (2 * triangle area)
double cross = Math.Abs(dx1 * dy2 - dy1 * dx2);
// Side lengths of triangle
double a = Math.Sqrt(dx1 * dx1 + dy1 * dy1);
double b = Math.Sqrt(dx2 * dx2 + dy2 * dy2);
double c = Math.Sqrt((p3.X - p1.X) * (p3.X - p1.X) + (p3.Y - p1.Y) * (p3.Y - p1.Y));
// Menger curvature formula: k = 4 * Area / (a * b * c)
// Area of triangle = cross / 2, so k = 2 * cross / (a * b * c)
double curvature = 2.0 * cross / (a * b * c + 1e-9); // Add small epsilon to avoid division by zero
return curvature;
}
/// <summary>
/// Stanley-based final approach controller
/// When robot enters goal region (IsInFinalApproach), uses Stanley algorithm for precise CTE-based tracking
/// Reuses closestWaypoint and normalized robotTheta from CalculateAngularVelocity
/// </summary>
private (double linear, double angular) FinalApproachController(
double robotX,
double robotY,
double robotTheta,
PathPoint goal,
double actualLinearVelocity,
double maxLinearVelocity,
bool isBackward)
{
// Calculate front axle position
double frontX = robotX + stanleyConfig.WheelBase * Math.Cos(robotTheta);
double frontY = robotY + stanleyConfig.WheelBase * Math.Sin(robotTheta);
// Find closest point on path to front axle
(PathPoint closestPoint, int closestIndex) = GetClosestAheadWaypoint(frontX, frontY);
// Calculate heading at closest point (path direction)
double pathHeading = CalculateStanleyPathHeading(closestIndex);
// Calculate cross-track error (signed distance from front axle to path)
double crossTrackError = CalculateStanleyCrossTrackError(frontX, frontY, closestPoint, pathHeading);
if (isBackward) crossTrackError = -crossTrackError;
// Calculate heading error (path heading - robot heading)
double headingError = NavigationMath.NormalizeAngle(pathHeading - robotTheta);
// Calculate curvature at closest point (for feedforward)
double curvature = 0;
if (stanleyConfig.EnableCurvatureFeedforward)
{
curvature = CalculateCurvature(closestIndex);
}
// Adaptive K gain: increase when close to goal for tighter tracking
double distanceToGoal = NavigationMath.CalculateDistance(robotX, robotY, goal.X, goal.Y);
double adaptiveK = stanleyConfig.K;
if (distanceToGoal < stanleyConfig.GoalApproachDistance)
{
// Linearly increase K from K to K*GoalGainMultiplier as distance decreases
double approachRatio = 1.0 - (distanceToGoal / stanleyConfig.GoalApproachDistance);
adaptiveK = stanleyConfig.K * (1.0 + approachRatio * (stanleyConfig.GoalGainMultiplier - 1.0));
}
// Stanley formula: δ = ψ + arctan(K × e / (v + Ks))
double crossTrackTerm = Math.Atan2(adaptiveK * crossTrackError, Math.Abs(actualLinearVelocity) + stanleyConfig.Ks);
// Add curvature feedforward if enabled
double curvatureTerm = 0;
if (stanleyConfig.EnableCurvatureFeedforward && curvature != 0)
{
curvatureTerm = stanleyConfig.KCurvatureFF * Math.Atan(curvature * stanleyConfig.WheelBase);
}
// Total steering angle
double steeringAngle = headingError + crossTrackTerm + curvatureTerm;
// Clamp to maximum steering angle
steeringAngle = Math.Clamp(steeringAngle, -stanleyConfig.MaxSteeringAngle, stanleyConfig.MaxSteeringAngle);
// Convert steering angle to angular velocity using bicycle model
double angularVelocity = (actualLinearVelocity * Math.Tan(steeringAngle)) / stanleyConfig.WheelBase;
// Apply direction
if (isBackward)
{
maxLinearVelocity = -maxLinearVelocity;
}
// Debug output
Console.WriteLine($"FA-Stanley: Pose=({robotX:F3},{robotY:F3},{robotTheta * 180 / Math.PI:F1}°), " +
$"CTE={crossTrackError:F3}m, HeadErr={headingError * 180 / Math.PI:F1}°, " +
$"Curv={curvature:F3}, SteerAng={steeringAngle * 180 / Math.PI:F1}°, " +
$"K={adaptiveK:F2}, DTG={distanceToGoal:F3}m, " +
$"LVel={maxLinearVelocity:F2}, AVel={angularVelocity:F3}");
return (maxLinearVelocity, angularVelocity);
}
#region Stanley Helper Methods (for FinalApproachController)
/// <summary>
/// Get closest waypoint ahead to given position (for Stanley front axle tracking)
/// </summary>
private (PathPoint point, int index) GetClosestAheadWaypoint(double x, double y)
{
if (_waypoints.Count == 0)
throw new InvalidOperationException("Path not set");
double minDistance = double.MaxValue;
int closestIndex = 0;
// Start search from current index for efficiency
for (int i = _currentWaypointAheadIndex; i < _waypoints.Count; i++)
{
double distance = NavigationMath.CalculateDistance(x, y, _waypoints[i].X, _waypoints[i].Y);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
// Also check previous waypoints in case robot moved backwards
for (int i = 0; i < _currentWaypointAheadIndex; i++)
{
double distance = NavigationMath.CalculateDistance(x, y, _waypoints[i].X, _waypoints[i].Y);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
_currentWaypointAheadIndex = closestIndex;
return (_waypoints[closestIndex], closestIndex);
}
/// <summary>
/// Calculate path heading at given waypoint index (for Stanley)
/// Uses current point and next point to determine direction
/// </summary>
private double CalculateStanleyPathHeading(int index)
{
if (index >= _waypoints.Count - 1)
{
// Last point - use previous segment direction
if (index > 0)
{
double dx = _waypoints[index].X - _waypoints[index - 1].X;
double dy = _waypoints[index].Y - _waypoints[index - 1].Y;
return Math.Atan2(dy, dx);
}
return 0;
}
// Use current to next point
double dxNext = _waypoints[index + 1].X - _waypoints[index].X;
double dyNext = _waypoints[index + 1].Y - _waypoints[index].Y;
return Math.Atan2(dyNext, dxNext);
}
/// <summary>
/// Calculate signed cross-track error (for Stanley)
/// Positive: front axle is to the left of path
/// Negative: front axle is to the right of path
/// </summary>
private static double CalculateStanleyCrossTrackError(double frontX, double frontY, PathPoint closestPoint, double pathHeading)
{
// Vector from closest point to front axle
double dx = frontX - closestPoint.X;
double dy = frontY - closestPoint.Y;
// Path direction vector
double pathDx = Math.Cos(pathHeading);
double pathDy = Math.Sin(pathHeading);
// Cross product to get signed perpendicular distance
// positive = left, negative = right
double crossTrackError = dx * pathDy - dy * pathDx;
return crossTrackError;
}
#endregion
}

View File

@@ -0,0 +1,311 @@
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Navigation.Core;
/// <summary>
/// Simplified Stanley controller for path tracking
/// Uses cross-track error + heading error for steering control
/// References:
/// - Stanford's DARPA Grand Challenge winner
/// - Better than Pure Pursuit at high speeds and curved paths
/// </summary>
public class StanleySimplified(StanleyConfig config)
{
private List<PathPoint> _waypoints = new();
private int _currentWaypointAheadIndex = 0;
private int _currentWaypointIndex = 0;
private PathPoint? _goalPoint;
/// <summary>
/// Set path from PathPoint list
/// </summary>
public void SetPath(List<PathPoint> waypoints)
{
if (waypoints.Count < 2)
throw new ArgumentException("Path must have at least 2 waypoints", nameof(waypoints));
_waypoints = waypoints;
_currentWaypointAheadIndex = 0;
_currentWaypointIndex = 0;
_goalPoint = waypoints[^1];
}
/// <summary>
/// Update goal point
/// </summary>
public void UpdateGoal(PathPoint goal)
{
_goalPoint = goal;
}
/// <summary>
/// Calculate velocities using Stanley controller
/// Returns (linear, angular) velocity commands
/// </summary>
public (double linear, double angular) CalculateVelocity(
double robotX,
double robotY,
double robotTheta,
double actualLinearVelocity,
double maxLinearVelocity)
{
if (_waypoints.Count < 2 || _goalPoint == null)
throw new InvalidOperationException("Path not properly initialized");
// Get closest waypoint
(PathPoint closestCurrentPoint, _) = GetClosestWaypoint(robotX, robotY);
// Handle backward motion
bool isBackward = closestCurrentPoint.Direction == RobotDirection.BACKWARD;
if (isBackward)
{
// Adjust for backward motion
robotTheta += Math.PI;
robotTheta = NavigationMath.NormalizeAngle(robotTheta);
}
// Calculate front axle position
double frontX = robotX + config.WheelBase * Math.Cos(robotTheta);
double frontY = robotY + config.WheelBase * Math.Sin(robotTheta);
// Find closest point on path to front axle
(PathPoint closestPoint, int closestIndex) = GetClosestAheadWaypoint(frontX, frontY);
// Calculate heading at closest point (path direction)
double pathHeading = CalculatePathHeading(closestIndex);
// Calculate cross-track error (signed distance from front axle to path)
double crossTrackError = CalculateCrossTrackError(frontX, frontY, closestPoint, pathHeading);
if(isBackward) crossTrackError = -crossTrackError;
// Calculate heading error (path heading - robot heading)
double headingError = NavigationMath.NormalizeAngle(pathHeading - robotTheta);
// Calculate curvature at closest point (for feedforward)
double curvature = 0;
if (config.EnableCurvatureFeedforward)
{
curvature = CalculateCurvature(closestIndex);
}
// Adaptive K gain: increase when close to goal for tighter tracking
double distanceToGoal = NavigationMath.CalculateDistance(robotX, robotY, _goalPoint.X, _goalPoint.Y);
double adaptiveK = config.K;
if (distanceToGoal < config.GoalApproachDistance)
{
// Linearly increase K from K to K*GoalGainMultiplier as distance decreases
double approachRatio = 1.0 - (distanceToGoal / config.GoalApproachDistance);
adaptiveK = config.K * (1.0 + approachRatio * (config.GoalGainMultiplier - 1.0));
}
// Stanley formula: δ = ψ + arctan(K × e / (v + Ks))
double crossTrackTerm = Math.Atan2(adaptiveK * crossTrackError, Math.Abs(actualLinearVelocity) + config.Ks);
// Add curvature feedforward if enabled
double curvatureTerm = 0;
if (config.EnableCurvatureFeedforward && curvature != 0)
{
curvatureTerm = config.KCurvatureFF * Math.Atan(curvature * config.WheelBase);
}
// Total steering angle
double steeringAngle = headingError + crossTrackTerm + curvatureTerm;
// Clamp to maximum steering angle
steeringAngle = Math.Clamp(steeringAngle, -config.MaxSteeringAngle, config.MaxSteeringAngle);
// Convert steering angle to angular velocity
// At low speeds, bicycle model (ω = v*tan(δ)/L) produces near-zero angular velocity
// even when large steering correction is needed.
// Solution: blend bicycle model with direct proportional control based on speed.
double angularVelocity;
angularVelocity = (actualLinearVelocity * Math.Tan(steeringAngle)) / config.WheelBase;
// Apply direction
if (isBackward)
{
maxLinearVelocity = -maxLinearVelocity;
}
// Debug output
Console.WriteLine($"Stanley: Front=({frontX:F3},{frontY:F3}), Closest=({closestPoint.X:F3},{closestPoint.Y:F3}), " +
$"Pose=({robotX:F3},{robotY:F3},{robotTheta * 180 / Math.PI:F1}°), " +
$"CTE={crossTrackError:F3}m, HeadErr={headingError * 180 / Math.PI:F1}°, " +
$"Curv={curvature:F3}, SteerAng={steeringAngle * 180 / Math.PI:F1}°, " +
$"K={adaptiveK:F2}, DTG={distanceToGoal:F3}m, " +
$"LVel={maxLinearVelocity:F2}, AVel={angularVelocity:F3}");
return (maxLinearVelocity, angularVelocity);
}
/// <summary>
/// Get closest waypoint to given position
/// </summary>
private (PathPoint point, int index) GetClosestWaypoint(double x, double y)
{
if (_waypoints.Count == 0)
throw new InvalidOperationException("Path not set");
double minDistance = double.MaxValue;
int closestIndex = 0;
// Start search from current index for efficiency
for (int i = _currentWaypointIndex; i < _waypoints.Count; i++)
{
double distance = NavigationMath.CalculateDistance(x, y, _waypoints[i].X, _waypoints[i].Y);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
// Also check previous waypoints in case robot moved backwards
for (int i = 0; i < _currentWaypointIndex; i++)
{
double distance = NavigationMath.CalculateDistance(x, y, _waypoints[i].X, _waypoints[i].Y);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
_currentWaypointIndex = closestIndex;
return (_waypoints[closestIndex], closestIndex);
}
/// <summary>
/// Get closest waypoint to given position
/// </summary>
private (PathPoint point, int index) GetClosestAheadWaypoint(double x, double y)
{
if (_waypoints.Count == 0)
throw new InvalidOperationException("Path not set");
double minDistance = double.MaxValue;
int closestIndex = 0;
// Start search from current index for efficiency
for (int i = _currentWaypointAheadIndex; i < _waypoints.Count; i++)
{
double distance = NavigationMath.CalculateDistance(x, y, _waypoints[i].X, _waypoints[i].Y);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
// Also check previous waypoints in case robot moved backwards
for (int i = 0; i < _currentWaypointAheadIndex; i++)
{
double distance = NavigationMath.CalculateDistance(x, y, _waypoints[i].X, _waypoints[i].Y);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
_currentWaypointAheadIndex = closestIndex;
return (_waypoints[closestIndex], closestIndex);
}
/// <summary>
/// Calculate path heading at given waypoint index
/// Uses current point and next point to determine direction
/// </summary>
private double CalculatePathHeading(int index)
{
if (index >= _waypoints.Count - 1)
{
// Last point - use previous segment direction
if (index > 0)
{
double dx = _waypoints[index].X - _waypoints[index - 1].X;
double dy = _waypoints[index].Y - _waypoints[index - 1].Y;
return Math.Atan2(dy, dx);
}
return 0;
}
// Use current to next point
double dxNext = _waypoints[index + 1].X - _waypoints[index].X;
double dyNext = _waypoints[index + 1].Y - _waypoints[index].Y;
return Math.Atan2(dyNext, dxNext);
}
/// <summary>
/// Calculate signed cross-track error
/// Positive: front axle is to the left of path
/// Negative: front axle is to the right of path
/// </summary>
private double CalculateCrossTrackError(double frontX, double frontY, PathPoint closestPoint, double pathHeading)
{
// Vector from closest point to front axle
double dx = frontX - closestPoint.X;
double dy = frontY - closestPoint.Y;
// Path direction vector
double pathDx = Math.Cos(pathHeading);
double pathDy = Math.Sin(pathHeading);
// Cross product to get signed perpendicular distance
// positive = left, negative = right
double crossTrackError = dx * pathDy - dy * pathDx;
return crossTrackError;
}
/// <summary>
/// Calculate path curvature at given waypoint index
/// Uses Menger curvature (3-point circle fitting)
/// Returns curvature in 1/meters (larger value = sharper curve)
/// </summary>
private double CalculateCurvature(int index)
{
// Need at least 3 points for curvature calculation
if (_waypoints.Count < 3 || index <= 0 || index >= _waypoints.Count - 1)
return 0.0;
var p1 = _waypoints[index - 1];
var p2 = _waypoints[index];
var p3 = _waypoints[index + 1];
// Calculate vectors
double dx1 = p2.X - p1.X;
double dy1 = p2.Y - p1.Y;
double dx2 = p3.X - p2.X;
double dy2 = p3.Y - p2.Y;
// Cross product magnitude (2 * triangle area)
double cross = Math.Abs(dx1 * dy2 - dy1 * dx2);
// Side lengths of triangle
double a = Math.Sqrt(dx1 * dx1 + dy1 * dy1);
double b = Math.Sqrt(dx2 * dx2 + dy2 * dy2);
double c = Math.Sqrt((p3.X - p1.X) * (p3.X - p1.X) + (p3.Y - p1.Y) * (p3.Y - p1.Y));
// Menger curvature formula: k = 4 * Area / (a * b * c)
// Area of triangle = cross / 2, so k = 2 * cross / (a * b * c)
double curvature = 2.0 * cross / (a * b * c + 1e-9); // Add small epsilon to avoid division by zero
return curvature;
}
/// <summary>
/// Get current waypoint index (for debugging)
/// </summary>
public int GetCurrentWaypointIndex() => _currentWaypointIndex;
/// <summary>
/// Get goal point
/// </summary>
public PathPoint? GetGoalPoint() => _goalPoint;
}

View File

@@ -0,0 +1,172 @@
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Navigation.Core;
/// <summary>
/// Simplified Velocity Estimator for tuning system
/// Combines encoder measurements with motor dynamics model
/// </summary>
public class VelocityEstimatorSimplified
{
private readonly VelocityEstimatorConfig _estimatorConfig;
private readonly VelocitySignalProcessingConfig _signalConfig;
private readonly MotorDynamicsModel _motorModel;
private readonly CircularBuffer<double> _predictionErrors;
private double _filteredEncoderVel = 0;
private double _currentConfidence = 1.0;
private double _blendRatio;
public VelocityEstimatorSimplified(
VelocityEstimatorConfig estimatorConfig,
VelocitySignalProcessingConfig signalConfig,
MotorDynamicsConfig motorConfig)
{
_estimatorConfig = estimatorConfig;
_signalConfig = signalConfig;
_motorModel = new MotorDynamicsModel(motorConfig);
_predictionErrors = new CircularBuffer<double>(20);
_blendRatio = estimatorConfig.DefaultBlendRatio;
}
/// <summary>
/// Estimate velocity using hybrid approach
/// </summary>
public double EstimateVelocity(
double vCmd,
double vActual,
double dt)
{
// 1. Filter encoder velocity (exponential moving average)
_filteredEncoderVel = LowPassFilter(vActual, _filteredEncoderVel, _signalConfig.AlphaFilter);
// 2. Predict velocity using model
double predictionHorizon = CalculatePredictionHorizon(_filteredEncoderVel);
double vModel = _motorModel.PredictVelocity(vCmd, _filteredEncoderVel, predictionHorizon);
// 3. Calculate tracking error
double trackingError = CalculateTrackingError(vModel, _filteredEncoderVel);
// 4. Adaptive blending based on tracking quality
_blendRatio = CalculateAdaptiveBlendRatio(trackingError);
// 5. Update confidence
UpdateModelConfidence(vModel, _filteredEncoderVel);
// 6. Hybrid estimation
double vHybrid = _blendRatio * vModel + (1.0 - _blendRatio) * _filteredEncoderVel;
return vHybrid;
}
/// <summary>
/// Get current model confidence
/// </summary>
public double GetConfidence()
{
return _currentConfidence;
}
/// <summary>
/// Reset estimator state
/// </summary>
public void Reset()
{
_filteredEncoderVel = 0;
_currentConfidence = 1.0;
_blendRatio = _estimatorConfig.DefaultBlendRatio;
_predictionErrors.Clear();
}
/// <summary>
/// Low-pass filter for encoder velocity
/// </summary>
private static double LowPassFilter(double newValue, double oldValue, double alpha)
{
if (alpha < 0 || alpha > 1)
throw new ArgumentException("Alpha must be between 0 and 1", nameof(alpha));
return alpha * newValue + (1.0 - alpha) * oldValue;
}
/// <summary>
/// Calculate prediction horizon based on lookahead
/// </summary>
private double CalculatePredictionHorizon(double vActual)
{
// Simple estimation: use a fixed time horizon
// In real implementation, this would be based on Pure Pursuit lookahead
double predictionTime = 0.5; // 500ms default
if (Math.Abs(vActual) > 0.1)
{
// Adjust based on velocity
predictionTime = Math.Clamp(0.3 / Math.Abs(vActual), 0.1, 2.0);
}
return predictionTime;
}
/// <summary>
/// Calculate tracking error (normalized)
/// </summary>
private static double CalculateTrackingError(double vModel, double vActual)
{
double error = Math.Abs(vModel - vActual);
double normalizedError = error / Math.Max(Math.Abs(vActual), 0.1);
return normalizedError;
}
/// <summary>
/// Calculate adaptive blend ratio based on tracking quality
/// </summary>
private double CalculateAdaptiveBlendRatio(double trackingError)
{
double blendRatio;
if (trackingError < _estimatorConfig.GoodTrackingThreshold)
{
// Good tracking → trust model more
blendRatio = _estimatorConfig.GoodTrackingBlend;
}
else if (trackingError < _estimatorConfig.ModerateTrackingThreshold)
{
// Moderate tracking → balanced
blendRatio = _estimatorConfig.ModerateTrackingBlend;
}
else
{
// Poor tracking → trust encoder more
blendRatio = _estimatorConfig.PoorTrackingBlend;
}
// Adjust based on confidence
blendRatio *= _currentConfidence;
// Clamp to valid range
blendRatio = Math.Clamp(blendRatio, _estimatorConfig.MinBlendRatio, _estimatorConfig.MaxBlendRatio);
return blendRatio;
}
/// <summary>
/// Update model confidence based on prediction accuracy
/// </summary>
private void UpdateModelConfidence(double vPredicted, double vActual)
{
double predError = Math.Abs(vPredicted - vActual) / Math.Max(Math.Abs(vActual), 0.1);
_predictionErrors.Add(predError);
if (_predictionErrors.Count > 0)
{
double avgError = _predictionErrors.Average();
double newConfidence = Math.Clamp(1.0 - avgError, 0.0, 1.0);
// Smooth update with decay
_currentConfidence = _estimatorConfig.ConfidenceDecayRate * _currentConfidence +
(1.0 - _estimatorConfig.ConfidenceDecayRate) * newConfidence;
_currentConfidence = Math.Max(_currentConfidence, _estimatorConfig.MinConfidence);
}
}
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"RobotNet10.NavigationTune": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:60963;http://localhost:60964"
}
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RobotNet10.Common\RobotNet10.Common.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet.VDA5050\RobotNet.VDA5050.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet10.NavigationTune.Shared\RobotNet10.NavigationTune.Shared.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet10.Shared\RobotNet10.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,80 @@
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Scenarios;
/// <summary>
/// Circle test scenario
/// </summary>
public class CircleScenario : TestScenario
{
public double Radius { get; set; } = 2.0; // meters
public double CenterX { get; set; } = 0.0;
public double CenterY { get; set; } = 0.0;
public double StartAngle { get; set; } = 0.0; // radians
public bool Clockwise { get; set; } = true;
public double Resolution { get; set; } = 0.05; // meters between points
public CircleScenario()
{
Name = $"Circle {Radius}m Radius";
Description = $"Robot moves in a circle with radius {Radius}m";
Type = TrajectoryType.Circle;
}
public override List<PathPoint> GenerateReferencePath()
{
var points = new List<PathPoint>();
double circumference = 2 * Math.PI * Radius;
int numPoints = (int)(circumference / Resolution);
double angleStep = 2 * Math.PI / numPoints;
if (!Clockwise)
angleStep = -angleStep;
var direction = Clockwise ? RobotDirection.FORWARD : RobotDirection.BACKWARD;
double distance = 0.0;
for (int i = 0; i <= numPoints; i++)
{
double angle = StartAngle + i * angleStep;
double x = CenterX + Radius * Math.Cos(angle);
double y = CenterY + Radius * Math.Sin(angle);
distance = i * Resolution;
if (distance > circumference) distance = circumference;
points.Add(new PathPoint
{
X = x,
Y = y,
Direction = direction,
DistanceFromStart = distance
});
}
return points;
}
public override bool IsGoalReached(Pose2D currentPose, double tolerance = 0.05f)
{
// Check if robot is close to start position
double dx = currentPose.X - (CenterX + Radius * Math.Cos(StartAngle));
double dy = currentPose.Y - (CenterY + Radius * Math.Sin(StartAngle));
double distance = Math.Sqrt(dx * dx + dy * dy);
if (distance <= tolerance) Console.WriteLine($"Robot is goal reached. Current pose: [{currentPose.X} - {currentPose.Y}], Goal: [{CenterX + Radius * Math.Cos(StartAngle)} - {CenterY + Radius * Math.Sin(StartAngle)}], Distance: {distance}");
return distance <= tolerance;
}
public override Pose2D GetGoalPose()
{
// Goal is back at start position
double goalX = CenterX + Radius * Math.Cos(StartAngle);
double goalY = CenterY + Radius * Math.Sin(StartAngle);
double goalTheta = StartAngle + (Clockwise ? Math.PI / 2 : -Math.PI / 2);
while (goalTheta > Math.PI) goalTheta -= 2 * Math.PI;
while (goalTheta < -Math.PI) goalTheta += 2 * Math.PI;
return new Pose2D(goalX, goalY, goalTheta);
}
}

View File

@@ -0,0 +1,182 @@
using RobotNet10.Common;
using RobotNet10.Common.Models;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Scenarios;
/// <summary>
/// Custom path scenario with user-defined edges
/// </summary>
public class CustomPathScenario : TestScenario
{
/// <summary>
/// List of edges defining the path
/// </summary>
public List<PathEdge> Edges { get; set; } = [];
/// <summary>
/// Resolution for splitting edges into points (meters)
/// </summary>
public double Resolution { get; set; } = 0.05; // meters between points
public CustomPathScenario()
{
Name = "Custom Path";
Description = "User-defined path with custom edges";
Type = TrajectoryType.Custom;
}
public override List<PathPoint> GenerateReferencePath()
{
var points = new List<PathPoint>();
if (Edges.Count == 0)
return points;
// Process each edge
foreach (var edge in Edges)
{
var edgePoints = SplitEdge(edge, Resolution);
if (edgePoints.Count == 0)
continue;
double cumulativeDistance;
// Adjust cumulative distance for first point
if (points.Count > 0)
{
// Calculate distance from last point to first point of this edge
double dx = edgePoints[0].X - points[^1].X;
double dy = edgePoints[0].Y - points[^1].Y;
double connectionDistance = Math.Sqrt(dx * dx + dy * dy);
cumulativeDistance = points[^1].DistanceFromStart + connectionDistance;
}
else
{
cumulativeDistance = 0.0;
}
// Add points from this edge
for (int i = 0; i < edgePoints.Count; i++)
{
if (i == 0 && points.Count > 0)
{
// Skip first point if it's the same as last point (edge connection)
double dx = edgePoints[i].X - points[^1].X;
double dy = edgePoints[i].Y - points[^1].Y;
if (Math.Sqrt(dx * dx + dy * dy) < 0.001)
continue;
}
if (i > 0)
{
// Calculate distance from previous point
double dx = edgePoints[i].X - edgePoints[i - 1].X;
double dy = edgePoints[i].Y - edgePoints[i - 1].Y;
double segmentDistance = Math.Sqrt(dx * dx + dy * dy);
cumulativeDistance += segmentDistance;
}
points.Add(new PathPoint
{
X = edgePoints[i].X,
Y = edgePoints[i].Y,
Direction = edge.Direction,
DistanceFromStart = cumulativeDistance
});
}
}
return points;
}
/// <summary>
/// Split an edge into points based on resolution
/// </summary>
private static List<(double X, double Y)> SplitEdge(PathEdge edge, double resolution)
{
var points = new List<(double X, double Y)>();
// Calculate edge length
SpaceEdge spaceEdge = new()
{
StartX = edge.StartX,
StartY = edge.StartY,
EndX = edge.EndX,
EndY = edge.EndY,
Degree = edge.Degree,
ControlPoint1X = edge.ControlPoint1X ?? 0,
ControlPoint1Y = edge.ControlPoint1Y ?? 0,
ControlPoint2X = edge.ControlPoint2X ?? 0,
ControlPoint2Y = edge.ControlPoint2Y ?? 0,
};
double edgeLength = SpaceCompute.GetEdgeLength(spaceEdge, 0.1);
if (edgeLength <= 0)
{
points.Add((edge.StartX, edge.StartY));
return points;
}
// Calculate number of points based on resolution
int numPoints = Math.Max(1, (int)(edgeLength / resolution));
for (int i = 0; i <= numPoints; i++)
{
double t = numPoints > 0 ? i * 1.0 / numPoints : 0.0;
var point = SpaceCompute.BezierPoint(t, spaceEdge);
points.Add((point.X, point.Y));
}
return points;
}
public override bool IsGoalReached(Pose2D currentPose, double tolerance = 0.05f)
{
if (Edges.Count == 0)
return false;
var goal = GetGoalPose();
double distance = Pose2D.Distance(currentPose, goal);
if (distance <= tolerance) Console.WriteLine($"Robot is goal reached. Current pose: [{currentPose.X} - {currentPose.Y}], Goal: [{goal.X} - {goal.Y}], Distance: {distance}");
return distance <= tolerance;
}
public override Pose2D GetGoalPose()
{
if (Edges.Count == 0)
return new Pose2D(0, 0, 0);
var lastEdge = Edges[^1];
// Calculate theta from direction (FORWARD = 0, BACKWARD = PI)
double goalTheta = 0;
if (Edges.Count > 0)
{
// Calculate direction from last edge
if (Edges.Count > 1)
{
var prevEdge = Edges[^2];
double dx = lastEdge.EndX - prevEdge.EndX;
double dy = lastEdge.EndY - prevEdge.EndY;
goalTheta = Math.Atan2(dy, dx);
}
else
{
double dx = lastEdge.EndX - lastEdge.StartX;
double dy = lastEdge.EndY - lastEdge.StartY;
goalTheta = Math.Atan2(dy, dx);
}
}
return new Pose2D(lastEdge.EndX, lastEdge.EndY, goalTheta);
}
private static double NormalizeAngle(double angle)
{
while (angle > Math.PI) angle -= 2 * Math.PI;
while (angle < -Math.PI) angle += 2 * Math.PI;
return angle;
}
}

View File

@@ -0,0 +1,102 @@
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Scenarios;
/// <summary>
/// Straight line test scenario
/// </summary>
public class StraightLineScenario : TestScenario
{
public double Length { get; set; } = 10.0; // meters
public double StartX { get; set; } = 0.0;
public double StartY { get; set; } = 0.0;
public double StartTheta { get; set; } = 0.0; // radians
public double Resolution { get; set; } = 0.05; // meters between points
public StraightLineScenario()
{
Name = "Straight Line 10m";
Description = "Robot moves in a straight line for 10 meters";
Type = TrajectoryType.StraightLine;
}
public override List<PathPoint> GenerateReferencePath()
{
var points = new List<PathPoint>();
double absLength = Math.Abs(Length);
bool isBackward = Length < 0;
// For backward movement, reverse the direction
double directionTheta = isBackward ? StartTheta + Math.PI : StartTheta;
// Normalize directionTheta to [-π, π]
while (directionTheta > Math.PI) directionTheta -= 2 * Math.PI;
while (directionTheta < -Math.PI) directionTheta += 2 * Math.PI;
var direction = isBackward ? RobotDirection.BACKWARD : RobotDirection.FORWARD;
// Start point
points.Add(new PathPoint
{
X = StartX,
Y = StartY,
Direction = direction,
DistanceFromStart = 0.0
});
// Generate intermediate points
int numPoints = (int)(absLength / Resolution);
for (int i = 1; i <= numPoints; i++)
{
double distance = i * Resolution;
if (distance > absLength) distance = absLength;
points.Add(new PathPoint
{
X = StartX + distance * Math.Cos(directionTheta),
Y = StartY + distance * Math.Sin(directionTheta),
Direction = direction,
DistanceFromStart = distance
});
}
// Ensure end point is exactly at absLength
if (points[^1].DistanceFromStart < absLength)
{
points.Add(new PathPoint
{
X = StartX + absLength * Math.Cos(directionTheta),
Y = StartY + absLength * Math.Sin(directionTheta),
Direction = direction,
DistanceFromStart = absLength
});
}
return points;
}
public override bool IsGoalReached(Pose2D currentPose, double tolerance = 0.05f)
{
var goal = GetGoalPose();
double distance = Pose2D.Distance(currentPose, goal);
if (distance <= tolerance) Console.WriteLine($"Robot is goal reached. Current pose: [{currentPose.X} - {currentPose.Y}], Goal: [{goal.X} - {goal.Y}], Distance: {distance}");
return distance <= tolerance;
}
public override Pose2D GetGoalPose()
{
double absLength = Math.Abs(Length);
bool isBackward = Length < 0;
double directionTheta = isBackward ? StartTheta + Math.PI : StartTheta;
// Normalize directionTheta to [-π, π]
while (directionTheta > Math.PI) directionTheta -= 2 * Math.PI;
while (directionTheta < -Math.PI) directionTheta += 2 * Math.PI;
return new Pose2D(
StartX + absLength * Math.Cos(directionTheta),
StartY + absLength * Math.Sin(directionTheta),
directionTheta
);
}
}

View File

@@ -0,0 +1,509 @@
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Services;
/// <summary>
/// Metrics calculator implementation
/// </summary>
public class MetricsCalculator : IMetricsCalculator
{
public TestMetrics CalculateMetrics(
List<TelemetryData> telemetryData,
ReferencePath referencePath)
{
if (telemetryData.Count == 0)
throw new ArgumentException("Telemetry data cannot be empty", nameof(telemetryData));
var tracking = CalculateTrackingAccuracy(telemetryData, referencePath);
var smoothness = CalculateSmoothness(telemetryData);
var efficiency = CalculateEfficiency(telemetryData, referencePath);
var metrics = new TestMetrics
{
// Tracking Accuracy
CrossTrackErrorRMS = tracking.CrossTrackErrorRMS,
CrossTrackErrorPeak = tracking.CrossTrackErrorPeak,
CrossTrackErrorMean = tracking.CrossTrackErrorMean,
CrossTrackErrorStdDev = tracking.CrossTrackErrorStdDev,
HeadingErrorRMS = tracking.HeadingErrorRMS,
HeadingErrorPeak = tracking.HeadingErrorPeak,
GoalPositionError = tracking.GoalPositionError,
GoalHeadingError = tracking.GoalHeadingError,
// Smoothness
VelocityStdDev = smoothness.VelocityStdDev,
AccelerationStdDev = smoothness.AccelerationStdDev,
// Efficiency
PathLengthRatio = efficiency.PathLengthRatio,
CompletionTime = efficiency.CompletionTime,
AverageSpeed = efficiency.AverageSpeed,
MaxSpeed = efficiency.MaxSpeed
};
// Calculate scores
var weights = new ScoringWeights();
metrics.TrackingScore = CalculateTrackingScore(tracking);
metrics.SmoothnessScore = CalculateSmoothnessScore(smoothness);
metrics.EfficiencyScore = CalculateEfficiencyScore(efficiency);
metrics.OverallScore = CalculateOverallScore(metrics, weights);
// Check if passed criteria
metrics.PassedCriteria = CheckAcceptanceCriteria(metrics);
// Ensure no NaN/Infinity so SignalR and DB serialization do not fail
SanitizeMetrics(metrics);
return metrics;
}
private static double ToFinite(double value, double fallback = 0)
{
return double.IsFinite(value) ? value : fallback;
}
private static void SanitizeMetrics(TestMetrics m)
{
m.CrossTrackErrorRMS = ToFinite(m.CrossTrackErrorRMS);
m.CrossTrackErrorPeak = ToFinite(m.CrossTrackErrorPeak);
m.CrossTrackErrorMean = ToFinite(m.CrossTrackErrorMean);
m.CrossTrackErrorStdDev = ToFinite(m.CrossTrackErrorStdDev);
m.HeadingErrorRMS = ToFinite(m.HeadingErrorRMS);
m.HeadingErrorPeak = ToFinite(m.HeadingErrorPeak);
m.GoalPositionError = ToFinite(m.GoalPositionError);
m.GoalHeadingError = ToFinite(m.GoalHeadingError);
m.VelocityStdDev = ToFinite(m.VelocityStdDev);
m.AccelerationStdDev = ToFinite(m.AccelerationStdDev);
m.PathLengthRatio = ToFinite(m.PathLengthRatio, 1);
m.CompletionTime = ToFinite(m.CompletionTime);
m.AverageSpeed = ToFinite(m.AverageSpeed);
m.MaxSpeed = ToFinite(m.MaxSpeed);
m.OverallScore = ToFinite(m.OverallScore);
m.TrackingScore = ToFinite(m.TrackingScore);
m.SmoothnessScore = ToFinite(m.SmoothnessScore);
m.EfficiencyScore = ToFinite(m.EfficiencyScore);
}
public TrackingAccuracyMetrics CalculateTrackingAccuracy(
List<TelemetryData> telemetryData,
ReferencePath referencePath)
{
var cteValues = new List<double>();
var headingErrors = new List<double>();
foreach (var data in telemetryData)
{
cteValues.Add(data.CrossTrackError);
headingErrors.Add(Math.Abs(data.HeadingError));
}
// Calculate RMS
double cteRMS = CalculateRMS(cteValues);
double headingRMS = CalculateRMS(headingErrors);
// Goal accuracy (last 10% of data)
int goalSampleCount = Math.Max(1, telemetryData.Count / 10);
var finalData = telemetryData.TakeLast(goalSampleCount).ToList();
double goalPositionError = finalData.Average(d => d.DistanceToGoal);
double goalHeadingError = finalData.Average(d => Math.Abs(d.HeadingError));
return new TrackingAccuracyMetrics
{
CrossTrackErrorRMS = cteRMS,
CrossTrackErrorPeak = cteValues.Max(),
CrossTrackErrorMean = cteValues.Average(),
CrossTrackErrorStdDev = CalculateStdDev(cteValues),
HeadingErrorRMS = headingRMS,
HeadingErrorPeak = headingErrors.Max(),
GoalPositionError = goalPositionError,
GoalHeadingError = goalHeadingError
};
}
/// <summary>
/// Nominal control loop period (50Hz) in seconds.
/// </summary>
private const double NominalDtSeconds = 1.0 / 50.0;
/// <summary>
/// Max dt (s) for smoothness calculation.
/// </summary>
private const double MaxDtSeconds = 0.5;
/// <summary>
/// Percentage of samples to trim from start/end to remove transient periods (startup/shutdown).
/// 5% means skip first 5% and last 5% of trajectory.
/// </summary>
private const double TransientTrimPercent = 0.05f;
/// <summary>
/// Maximum physically plausible acceleration for the robot (m/s²).
/// Velocity changes exceeding this per sample are considered outliers.
/// </summary>
private const double MaxPlausibleAcceleration = 3.0;
/// <summary>
/// Threshold multiplier for spike detection.
/// A point is considered a spike if it deviates from neighbors by more than
/// SpikeThresholdMultiplier * median_change_of_neighbors.
/// </summary>
private const double SpikeThresholdMultiplier = 3.0;
/// <summary>
/// Minimum absolute deviation (m/s) to consider as potential spike.
/// Prevents small natural variations from being filtered.
/// </summary>
private const double MinSpikeDeviation = 0.02f;
public SmoothnessMetrics CalculateSmoothness(List<TelemetryData> telemetryData)
{
if (telemetryData.Count < 3)
return new SmoothnessMetrics();
// Step 1: Trim transient periods (startup/shutdown)
var stableData = TrimTransientPeriod(telemetryData, TransientTrimPercent);
if (stableData.Count < 3)
return new SmoothnessMetrics();
// Step 2: Calculate dt from stable data
long totalSpanMs = stableData[^1].TimestampMs - stableData[0].TimestampMs;
int intervalCount = stableData.Count - 1;
double avgDtSeconds = intervalCount > 0 && totalSpanMs > 0
? (totalSpanMs / 1000.0) / intervalCount
: NominalDtSeconds;
double dt = Math.Clamp(avgDtSeconds, NominalDtSeconds, MaxDtSeconds);
// Step 3: Extract and clean velocity data (multi-stage filtering)
var rawVelocities = stableData.Select(d => d.RobotTwist.Linear).ToList();
// Stage 3a: Remove single-cycle spikes first (noise from sensor glitches)
var despikedVelocities = RemoveSingleCycleSpikes(rawVelocities);
// Stage 3b: Remove remaining outliers using acceleration-based detection
var velocities = RemoveVelocityOutliers(despikedVelocities, dt, MaxPlausibleAcceleration);
// Step 4: Calculate accelerations (linear: m/s²)
var accelerations = new List<double>();
for (int i = 1; i < velocities.Count; i++)
{
double accel = (velocities[i] - velocities[i - 1]) / dt;
accelerations.Add(accel);
}
// Step 5: Compute standard deviations
return new SmoothnessMetrics
{
VelocityStdDev = CalculateStdDev(velocities),
AccelerationStdDev = accelerations.Count > 0 ? CalculateStdDev(accelerations) : 0
};
}
/// <summary>
/// Trim transient periods from start and end of trajectory.
/// Transient periods (startup/shutdown) naturally have high velocity/acceleration variance.
/// </summary>
private static List<TelemetryData> TrimTransientPeriod(List<TelemetryData> data, double trimPercent)
{
if (data.Count < 10) return data; // Too short to trim
int trimCount = Math.Max(1, (int)(data.Count * trimPercent));
int startIndex = trimCount;
int endIndex = data.Count - trimCount;
if (endIndex <= startIndex) return data; // Would result in empty list
return data.Skip(startIndex).Take(endIndex - startIndex).ToList();
}
/// <summary>
/// Remove single-cycle spikes from velocity data using multi-pass filtering.
/// A spike is detected when a single point deviates significantly from both neighbors,
/// while the neighbors themselves are consistent with each other.
///
/// Detection criteria for point i:
/// 1. |v[i] - v[i-1]| > threshold (large jump from previous)
/// 2. |v[i] - v[i+1]| > threshold (large jump to next)
/// 3. |v[i+1] - v[i-1]| <= threshold (neighbors are consistent)
///
/// When spike is detected, replace with average of neighbors.
/// Multi-pass ensures consecutive spikes are also handled.
/// </summary>
private static List<double> RemoveSingleCycleSpikes(List<double> velocities)
{
if (velocities.Count < 3)
return [.. velocities];
var current = velocities;
const int maxPasses = 3; // Multiple passes for consecutive spikes
for (int pass = 0; pass < maxPasses; pass++)
{
var cleaned = RemoveSingleCycleSpikesOnePass(current);
// Check if any changes were made
bool changed = false;
for (int i = 0; i < current.Count && !changed; i++)
{
if (Math.Abs(current[i] - cleaned[i]) > 1e-9)
changed = true;
}
current = cleaned;
if (!changed) break; // No more spikes found
}
return current;
}
/// <summary>
/// Single pass of spike removal.
/// </summary>
private static List<double> RemoveSingleCycleSpikesOnePass(List<double> velocities)
{
var cleaned = new List<double>(velocities.Count) { velocities[0] };
// Calculate median absolute change for adaptive threshold
var changes = new List<double>();
for (int i = 1; i < velocities.Count; i++)
{
double change = Math.Abs(velocities[i] - velocities[i - 1]);
if (change > 1e-9) // Ignore zero changes
changes.Add(change);
}
double medianChange = changes.Count > 0 ? GetMedian(changes) : 0.01f;
double spikeThreshold = Math.Max(SpikeThresholdMultiplier * medianChange, MinSpikeDeviation);
// Process middle points using 3-point window
for (int i = 1; i < velocities.Count - 1; i++)
{
double prev = cleaned[^1]; // Use already-cleaned previous value
double curr = velocities[i];
double next = velocities[i + 1];
double changeToPrev = Math.Abs(curr - prev);
double changeToNext = Math.Abs(curr - next);
double neighborConsistency = Math.Abs(next - prev);
// Spike detection: current deviates from both neighbors, but neighbors are consistent
bool isSpike = changeToPrev > spikeThreshold &&
changeToNext > spikeThreshold &&
neighborConsistency <= spikeThreshold;
if (isSpike)
{
// Replace spike with average of neighbors
cleaned.Add((prev + next) / 2.0);
}
else
{
cleaned.Add(curr);
}
}
cleaned.Add(velocities[^1]); // Keep last point
return cleaned;
}
/// <summary>
/// Calculate median of a list.
/// </summary>
private static double GetMedian(List<double> values)
{
if (values.Count == 0) return 0;
var sorted = values.OrderBy(v => v).ToList();
int mid = sorted.Count / 2;
if (sorted.Count % 2 == 0)
return (sorted[mid - 1] + sorted[mid]) / 2.0;
else
return sorted[mid];
}
/// <summary>
/// Remove velocity outliers using acceleration-based detection.
/// If velocity change between consecutive samples exceeds physically plausible acceleration,
/// the point is considered an outlier and interpolated.
/// </summary>
private static List<double> RemoveVelocityOutliers(List<double> velocities, double dt, double maxAcceleration)
{
if (velocities.Count < 2) return velocities;
var cleaned = new List<double>(velocities.Count) { velocities[0] };
double maxVelocityChange = maxAcceleration * dt;
for (int i = 1; i < velocities.Count; i++)
{
double change = Math.Abs(velocities[i] - cleaned[^1]);
if (change <= maxVelocityChange)
{
// Normal change, keep the value
cleaned.Add(velocities[i]);
}
else
{
// Outlier detected - use linear interpolation
// Look ahead to find next valid point
double interpolatedValue = InterpolateOutlier(velocities, cleaned, i, maxVelocityChange);
cleaned.Add(interpolatedValue);
}
}
return cleaned;
}
/// <summary>
/// Interpolate an outlier value by looking at surrounding valid points.
/// </summary>
private static double InterpolateOutlier(List<double> original, List<double> cleaned, int outlierIndex, double maxChange)
{
double lastValid = cleaned[^1];
// Look ahead to find next valid point (within 5 samples)
for (int lookAhead = 1; lookAhead <= Math.Min(5, original.Count - outlierIndex - 1); lookAhead++)
{
int nextIndex = outlierIndex + lookAhead;
double nextValue = original[nextIndex];
double totalChange = Math.Abs(nextValue - lastValid);
double allowedChange = maxChange * (lookAhead + 1);
if (totalChange <= allowedChange)
{
// Found a valid point - interpolate linearly
double step = (nextValue - lastValid) / (lookAhead + 1);
return lastValid + step;
}
}
// No valid point found - use last valid value (hold)
return lastValid;
}
public EfficiencyMetrics CalculateEfficiency(
List<TelemetryData> telemetryData,
ReferencePath referencePath)
{
if (telemetryData.Count < 2)
return new EfficiencyMetrics();
// Calculate actual path length
double actualPathLength = 0;
for (int i = 1; i < telemetryData.Count; i++)
{
double dx = telemetryData[i].RobotPose.X - telemetryData[i - 1].RobotPose.X;
double dy = telemetryData[i].RobotPose.Y - telemetryData[i - 1].RobotPose.Y;
actualPathLength += Math.Sqrt(dx * dx + dy * dy);
}
// Reference path length
double referencePathLength = referencePath.TotalLength;
// Completion time
long duration = telemetryData[^1].TimestampMs - telemetryData[0].TimestampMs;
double completionTime = duration / 1000.0;
// Speeds
var speeds = telemetryData.Select(d => Math.Abs(d.RobotTwist.Linear)).ToList();
return new EfficiencyMetrics
{
PathLengthRatio = referencePathLength > 0 ? actualPathLength / referencePathLength : 1.0,
CompletionTime = completionTime,
AverageSpeed = speeds.Average(),
MaxSpeed = speeds.Max()
};
}
public double CalculateOverallScore(TestMetrics metrics, ScoringWeights weights)
{
double score = 100.0;
// Tracking accuracy penalties (50% weight)
score -= weights.TrackingAccuracy * (
NormalizePenalty(metrics.CrossTrackErrorRMS, 0.10f, 20f) +
NormalizePenalty(metrics.HeadingErrorRMS, 10f * Deg2Rad, 20f) +
NormalizePenalty(metrics.GoalPositionError, 0.05f, 10f)
);
// Smoothness penalties (30% weight)
score -= weights.Smoothness * (
NormalizePenalty(metrics.VelocityStdDev, 0.1, 15f) +
NormalizePenalty(metrics.AccelerationStdDev, 0.5, 15f)
);
// Efficiency penalties (20% weight)
score -= weights.Efficiency * (
NormalizePenalty(metrics.PathLengthRatio - 1.0, 0.15f, 20f)
);
return Math.Max(0, score);
}
private double CalculateTrackingScore(TrackingAccuracyMetrics tracking)
{
double score = 100.0;
score -= NormalizePenalty(tracking.CrossTrackErrorRMS, 0.10f, 40f);
score -= NormalizePenalty(tracking.HeadingErrorRMS, 10f * Deg2Rad, 40f);
score -= NormalizePenalty(tracking.GoalPositionError, 0.05f, 20f);
return Math.Max(0, score);
}
private double CalculateSmoothnessScore(SmoothnessMetrics smoothness)
{
double score = 100.0;
score -= NormalizePenalty(smoothness.VelocityStdDev, 0.1, 50f);
score -= NormalizePenalty(smoothness.AccelerationStdDev, 0.5, 50f);
return Math.Max(0, score);
}
private double CalculateEfficiencyScore(EfficiencyMetrics efficiency)
{
double score = 100.0;
score -= NormalizePenalty(efficiency.PathLengthRatio - 1.0, 0.15f, 100f);
return Math.Max(0, score);
}
private bool CheckAcceptanceCriteria(TestMetrics metrics)
{
// Primary criteria (tracking)
if (metrics.CrossTrackErrorRMS > 0.10f) return false;
if (metrics.CrossTrackErrorPeak > 0.20f) return false;
if (metrics.HeadingErrorRMS > 10f * Deg2Rad) return false;
if (metrics.GoalPositionError > 0.05f) return false;
// Secondary criteria (efficiency)
if (metrics.PathLengthRatio > 1.15f) return false;
return true;
}
private double NormalizePenalty(double actual, double threshold, double maxPenalty)
{
if (!double.IsFinite(actual)) return maxPenalty;
if (actual <= threshold) return 0;
double excess = actual - threshold;
double penalty = (excess / threshold) * maxPenalty;
return Math.Min(penalty, maxPenalty);
}
private double CalculateRMS(List<double> values)
{
if (values.Count == 0) return 0;
double sumSquares = values.Sum(v => v * v);
return Math.Sqrt(sumSquares / values.Count);
}
private double CalculateStdDev(List<double> values)
{
if (values.Count == 0) return 0;
double mean = values.Average();
double variance = values.Average(v => (v - mean) * (v - mean));
return Math.Sqrt(variance);
}
private const double Deg2Rad = Math.PI / 180.0;
}

View File

@@ -0,0 +1,198 @@
using Microsoft.EntityFrameworkCore;
using RobotNet10.NavigationTune.Data;
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Services;
/// <summary>
/// Parameter manager implementation
/// </summary>
public class ParameterManager(TuningDbContext context) : IParameterManager
{
private readonly TuningDbContext _context = context;
public async Task<NavigationParameterSet?> GetByNameAsync(string name)
{
return await _context.ParameterSets
.FirstOrDefaultAsync(p => p.Name == name);
}
public async Task<NavigationParameterSet?> GetByIdAsync(Guid id)
{
return await _context.ParameterSets.FindAsync(id);
}
public async Task<List<NavigationParameterSet>> GetAllAsync()
{
return await _context.ParameterSets
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<Guid> SaveAsync(NavigationParameterSet parameterSet)
{
if (parameterSet.Id == Guid.Empty)
parameterSet.Id = Guid.NewGuid();
parameterSet.CreatedAt = DateTime.UtcNow;
_context.ParameterSets.Add(parameterSet);
await _context.SaveChangesAsync();
return parameterSet.Id;
}
public async Task UpdateAsync(NavigationParameterSet parameterSet)
{
parameterSet.UpdatedAt = DateTime.UtcNow;
_context.ParameterSets.Update(parameterSet);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(Guid id)
{
var parameterSet = await GetByIdAsync(id);
if (parameterSet != null)
{
_context.ParameterSets.Remove(parameterSet);
await _context.SaveChangesAsync();
}
}
public ValidationResult Validate(NavigationParameterSet parameterSet)
{
var result = new ValidationResult { IsValid = true };
// Validate PID bounds
if (parameterSet.MovePidConfig.Kp < 0.1 || parameterSet.MovePidConfig.Kp > 5.0)
result.AddError($"Move PID Kp must be between 0.1 and 5.0, got {parameterSet.MovePidConfig.Kp}");
if (parameterSet.MovePidConfig.Ki < 0 || parameterSet.MovePidConfig.Ki > 2.0)
result.AddError($"Move PID Ki must be between 0 and 2.0, got {parameterSet.MovePidConfig.Ki}");
if (parameterSet.MovePidConfig.Kd < 0 || parameterSet.MovePidConfig.Kd > 1.0)
result.AddError($"Move PID Kd must be between 0 and 1.0, got {parameterSet.MovePidConfig.Kd}");
// Validate Pure Pursuit
if (parameterSet.PurePursuitConfig.LookaheadMax <= parameterSet.PurePursuitConfig.LookaheadMin)
result.AddError("LookaheadMax must be greater than LookaheadMin");
if (parameterSet.PurePursuitConfig.Kdd < 0.3 || parameterSet.PurePursuitConfig.Kdd > 2.0)
result.AddError($"Kdd must be between 0.3 and 2.0, got {parameterSet.PurePursuitConfig.Kdd}");
// Validate velocity limits
if (parameterSet.NavigationConfig.MaxLinearVelocity > 2.0)
result.AddWarning("MaxLinearVelocity > 2.0 m/s may be unsafe");
// Validate blend ratios
if (parameterSet.EstimatorConfig.GoodTrackingBlend < parameterSet.EstimatorConfig.PoorTrackingBlend)
result.AddError("GoodTrackingBlend must be greater than PoorTrackingBlend (trust encoder more when tracking is good)");
return result;
}
public NavigationParameterSet GetDefaultPreset()
{
return new NavigationParameterSet
{
Name = "Default",
Description = "Default parameter set",
IsDefault = true,
ControllerType = PathFollowingController.PurePursuit, // Default to Pure Pursuit
MovePidConfig = new PIDConfig { Kp = 1.0, Ki = 0.0001, Kd = 0.6 },
RotatePidConfig = new PIDConfig { Kp = 10.0, Ki = 0.01, Kd = 0.1 },
PurePursuitConfig = new PurePursuitConfig
{
LookaheadMin = 0.3,
Kdd = 1.0,
LookaheadMax = 2.0,
MaxAngularVelocity = 1.5,
ResolutionSplit = 0.05f,
FinalApproachThreshold = 0.2,
HeadingTolerance = 3.0,
GoalRegionDistance = 1.5,
KCurvature = 2.0,
MinLookaheadTimeRatio = 0.3,
MaxLookaheadTimeRatio = 2.0
},
StanleyConfig = new StanleyConfig
{
K = 2.5,
Ks = 0.1,
WheelBase = 0.5,
MaxSteeringAngle = 0.5,
EnableCurvatureFeedforward = true,
KCurvatureFF = 1.0,
GoalTolerance = 0.05,
HeadingTolerance = 5.0,
ResolutionSplit = 0.05,
GoalApproachDistance = 1.0,
GoalGainMultiplier = 2.0,
LowSpeedThreshold = 0.3,
LowSpeedAngularGain = 1.5
},
EstimatorConfig = new VelocityEstimatorConfig(),
SignalConfig = new VelocitySignalProcessingConfig(),
MotorDynamicsConfig = new MotorDynamicsConfig { Tau = 0.3, Delta = 0.05f },
NavigationConfig = new NavigationConfig
{
MaxLinearVelocity = 1.5,
MaxAngularVelocity = 6.0,
MinLinearVelocity = 0.1,
RotateAngularVelocity = 1.0,
ReachedRadius = 0.015,
InitialRotationThreshold = 5.0,
Acceleration = 0.5,
Deceleration = 0.5
}
};
}
public NavigationParameterSet GetAggressivePreset()
{
var preset = GetDefaultPreset();
preset.Name = "Aggressive";
preset.Description = "Aggressive tuning for fast response";
preset.MovePidConfig.Kp = 1.5;
preset.MovePidConfig.Ki = 0.2;
preset.MovePidConfig.Kd = 0.02;
preset.PurePursuitConfig.Kdd = 0.8f;
return preset;
}
public NavigationParameterSet GetSmoothPreset()
{
var preset = GetDefaultPreset();
preset.Name = "Smooth";
preset.Description = "Smooth tuning for gentle motion";
preset.MovePidConfig.Kp = 0.6;
preset.MovePidConfig.Ki = 0.05;
preset.MovePidConfig.Kd = 0.3;
preset.PurePursuitConfig.Kdd = 1.5;
preset.SignalConfig.AlphaFilter = 0.2;
return preset;
}
public NavigationParameterSet GetStanleyPreset()
{
var preset = GetDefaultPreset();
preset.Name = "Stanley";
preset.Description = "Stanley controller for high-speed path tracking";
preset.ControllerType = PathFollowingController.Stanley;
// Stanley-specific tuning
preset.StanleyConfig.K = 2.5;
preset.StanleyConfig.Ks = 0.1;
preset.StanleyConfig.WheelBase = 0.6;
preset.StanleyConfig.MaxSteeringAngle = 0.5;
preset.StanleyConfig.EnableCurvatureFeedforward = true;
preset.StanleyConfig.KCurvatureFF = 1.0;
preset.StanleyConfig.GoalTolerance = 0.05;
preset.StanleyConfig.HeadingTolerance = 5.0;
preset.StanleyConfig.GoalApproachDistance = 1.0;
preset.StanleyConfig.GoalGainMultiplier = 2.0;
preset.StanleyConfig.LowSpeedThreshold = 0.3;
preset.StanleyConfig.LowSpeedAngularGain = 1.5;
return preset;
}
}

View File

@@ -0,0 +1,39 @@
using System.Collections.Concurrent;
using RobotNet10.NavigationTune.Interfaces;
namespace RobotNet10.NavigationTune.Services;
/// <summary>
/// Singleton registry mapping testRunId to CancellationTokenSource so Stop/EMC Stop can cancel the running test.
/// </summary>
public class RunningTestCancellationRegistry : IRunningTestCancellationRegistry
{
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _map = new();
public void Register(Guid testRunId, CancellationTokenSource cts)
{
_map[testRunId] = cts;
}
public bool TryCancel(Guid testRunId)
{
if (_map.TryRemove(testRunId, out var cts))
{
try
{
cts.Cancel();
return true;
}
catch (ObjectDisposedException) { return false; }
}
return false;
}
public void Unregister(Guid testRunId)
{
if (_map.TryRemove(testRunId, out var cts))
{
try { cts.Dispose(); } catch (ObjectDisposedException) { }
}
}
}

View File

@@ -0,0 +1,116 @@
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Services;
/// <summary>
/// Safety monitor for test execution
/// </summary>
public class SafetyMonitor(SafetyConfig config)
{
private readonly List<SafetyViolation> _violations = new();
private DateTime? _trackingErrorStart;
/// <summary>
/// Check safety conditions
/// </summary>
public bool CheckSafety(TelemetryData telemetry, ReferencePath referencePath)
{
bool isSafe = true;
// 1. Check cross-track error
if (telemetry.CrossTrackError > config.MaxCrossTrackError)
{
LogViolation(new SafetyViolation
{
Type = ViolationType.CrossTrackError,
Severity = ViolationSeverity.Critical,
Value = telemetry.CrossTrackError,
Threshold = config.MaxCrossTrackError,
Message = $"CTE {telemetry.CrossTrackError:F3}m exceeds limit {config.MaxCrossTrackError:F3}m",
Timestamp = DateTime.UtcNow
});
isSafe = false;
}
// 2. Check heading error
if (Math.Abs(telemetry.HeadingError) > config.MaxHeadingError)
{
LogViolation(new SafetyViolation
{
Type = ViolationType.HeadingError,
Severity = ViolationSeverity.Critical,
Value = Math.Abs(telemetry.HeadingError),
Threshold = config.MaxHeadingError,
Message = $"Heading error {telemetry.HeadingError * 180 / Math.PI:F1}° exceeds limit",
Timestamp = DateTime.UtcNow
});
isSafe = false;
}
// 3. Check velocity limits
if (Math.Abs(telemetry.RobotTwist.Linear) > config.MaxLinearVelocity * 1.1)
{
LogViolation(new SafetyViolation
{
Type = ViolationType.VelocityLimit,
Severity = ViolationSeverity.Warning,
Value = Math.Abs(telemetry.RobotTwist.Linear),
Threshold = config.MaxLinearVelocity,
Message = $"Linear velocity {telemetry.RobotTwist.Linear:F2} m/s exceeds limit",
Timestamp = DateTime.UtcNow
});
}
// 4. Check sustained tracking error
if (telemetry.CrossTrackError > config.MaxCrossTrackError * 0.5)
{
_trackingErrorStart ??= DateTime.UtcNow;
var duration = (DateTime.UtcNow - _trackingErrorStart.Value).TotalMilliseconds;
if (duration > config.MaxTrackingErrorDuration)
{
LogViolation(new SafetyViolation
{
Type = ViolationType.SustainedTrackingError,
Severity = ViolationSeverity.Critical,
Value = duration,
Threshold = config.MaxTrackingErrorDuration,
Message = $"Tracking error sustained for {duration:F0}ms",
Timestamp = DateTime.UtcNow
});
isSafe = false;
}
}
else
{
_trackingErrorStart = null;
}
return isSafe;
}
public List<SafetyViolation> GetViolations() => _violations;
public void Reset()
{
_violations.Clear();
_trackingErrorStart = null;
}
private void LogViolation(SafetyViolation violation)
{
_violations.Add(violation);
}
}
/// <summary>
/// Safety configuration
/// </summary>
public class SafetyConfig
{
public double MaxCrossTrackError { get; set; } = 0.5; // meters
public double MaxHeadingError { get; set; } = 45f * Math.PI / 180f; // radians (45 degrees)
public double MaxLinearVelocity { get; set; } = 1.5; // m/s
public double MaxAngularVelocity { get; set; } = 6.0; // rad/s
public int MaxTrackingErrorDuration { get; set; } = 3000; // milliseconds
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
using BatchTestResult = RobotNet10.NavigationTune.Shared.Models.BatchTestResult;
using ComparisonResult = RobotNet10.NavigationTune.Shared.Models.ComparisonResult;
namespace RobotNet10.NavigationTune.Services;
/// <summary>
/// Main orchestrator for tuning operations
/// </summary>
public interface ITuningOrchestrator
{
/// <summary>
/// Start a test and return immediately with testRunId and status Running.
/// Test runs in background; completion is notified via SignalR (ReceiveTestResult).
/// Use this for UI single-test execution so Stop/Pause buttons become active right away.
/// </summary>
Task<TestExecutionResult> StartTestAsync(
TestScenario scenario,
NavigationParameterSet parameters,
string? connectionId = null,
Guid? testRunId = null,
CancellationToken cancellationToken = default
);
Task<TestExecutionResult> RunSingleTestAsync(
TestScenario scenario,
NavigationParameterSet parameters,
string? connectionId = null,
Guid? testRunId = null,
CancellationToken cancellationToken = default
);
Task<BatchTestResult> RunBatchTestsAsync(
List<TestScenario> scenarios,
NavigationParameterSet parameters,
CancellationToken cancellationToken = default
);
Task<ComparisonResult> CompareConfigurationsAsync(
List<NavigationParameterSet> parameterSets,
TestScenario scenario,
CancellationToken cancellationToken = default
);
void PauseTest(string testRunId);
void ResumeTest(string testRunId);
void StopTest(string testRunId);
void EmergencyStop(string testRunId);
}

View File

@@ -0,0 +1,369 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RobotNet10.NavigationTune.Hubs;
using RobotNet10.NavigationTune.Interfaces;
using RobotNet10.NavigationTune.Shared.Hubs;
using RobotNet10.NavigationTune.Shared.Interfaces;
using RobotNet10.NavigationTune.Shared.Models;
namespace RobotNet10.NavigationTune.Services;
/// <summary>
/// Tuning orchestrator implementation
/// </summary>
public class TuningOrchestrator(
ITuningNavigation tuningNavigation,
IMetricsCalculator metricsCalculator,
ITestRepository testRepository,
IParameterManager parameterManager,
IServiceScopeFactory scopeFactory,
IRunningTestCancellationRegistry cancellationRegistry,
IHubContext<TuningHub>? hubContext = null,
ILogger<TuningOrchestrator>? logger = null) : ITuningOrchestrator
{
private readonly ITuningNavigation _tuningNavigation = tuningNavigation;
private readonly IMetricsCalculator _metricsCalculator = metricsCalculator;
private readonly ITestRepository _testRepository = testRepository;
private readonly IParameterManager _parameterManager = parameterManager;
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly IRunningTestCancellationRegistry _cancellationRegistry = cancellationRegistry;
private readonly IHubContext<TuningHub>? _hubContext = hubContext;
private readonly ILogger<TuningOrchestrator>? _logger = logger;
private volatile bool _isTestRunning;
/// <summary>
/// Start test and return immediately with testRunId and status Running.
/// Control loop runs on WatchThread; completion is sent via SignalR (ReceiveTestResult).
/// </summary>
public async Task<TestExecutionResult> StartTestAsync(
TestScenario scenario,
NavigationParameterSet parameters,
string? connectionId = null,
Guid? testRunId = null,
CancellationToken cancellationToken = default)
{
if (_isTestRunning)
throw new InvalidOperationException("A test is already running. Stop it before starting another.");
var validation = _parameterManager.Validate(parameters);
if (!validation.IsValid)
throw new InvalidOperationException($"Invalid parameters: {string.Join(", ", validation.Errors)}");
var testRun = new TestRun
{
Id = testRunId ?? Guid.NewGuid(),
ScenarioId = scenario.Id,
ParameterSetId = parameters.Id,
StartTime = DateTime.UtcNow,
Status = TestStatus.Preparing
};
if (connectionId != null && _hubContext != null)
await _hubContext.Groups.AddToGroupAsync(connectionId, $"test_{testRun.Id}", cancellationToken);
await _testRepository.SaveAsync(testRun);
// Notify UI immediately so Stop/Pause buttons become active
if (_hubContext != null)
{
await _hubContext.Clients.Group($"test_{testRun.Id}")
.SendAsync("ReceiveTestStatus", new TestStatusUpdateDto
{
TestRunId = testRun.Id,
Status = TestStatus.Running,
ProgressPercent = 0,
Message = "Running"
}, cancellationToken: cancellationToken);
}
_isTestRunning = true;
// Register CTS so Stop/EMC Stop (different HTTP request) can cancel this test.
// Do NOT use "using var cts" - the CTS must stay alive until the test completes (onComplete calls Unregister which disposes it).
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_cancellationRegistry.Register(testRun.Id, cts);
// Execute on WatchThread; returns immediately; onComplete runs when test finishes
var runningResult = await _tuningNavigation.ExecuteTestAsync(
scenario,
parameters,
testRun.Id,
cts.Token,
onComplete: result => _ = SaveResultAndNotifyAsync(testRun.Id, testRun.StartTime, result, parameters));
return runningResult;
}
private async Task SaveResultAndNotifyAsync(Guid testRunId, DateTime startTime, TestExecutionResult result, NavigationParameterSet? parameters = null)
{
// Use new scope: completion runs on WatchThread after HTTP request may have ended
using var scope = _scopeFactory.CreateScope();
var testRepository = scope.ServiceProvider.GetRequiredService<ITestRepository>();
try
{
await testRepository.UpdateFromResultAsync(
testRunId,
result.Status,
result.EndTime,
result.Duration,
result.ErrorMessage,
result.Metrics,
result.SafetyViolations);
// Generate tuning suggestions if test completed with enough telemetry
if (result.Status == TestStatus.Completed &&
result.TelemetryData?.Count > 10 &&
result.Metrics != null &&
parameters != null)
{
try
{
var tuningAdvisor = scope.ServiceProvider.GetRequiredService<ITuningAdvisor>();
result.TuningReport = tuningAdvisor.Analyze(
result.TelemetryData,
result.Metrics,
parameters);
result.TuningReport.TestRunId = testRunId;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Tuning advisor analysis failed for test {TestRunId}", testRunId);
}
}
if (_hubContext != null)
{
await _hubContext.Clients.Group($"test_{testRunId}")
.SendAsync("ReceiveTestResult", result);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error saving test result");
if (_hubContext != null)
{
var errorResult = new TestExecutionResult
{
TestRunId = testRunId,
Status = TestStatus.Error,
ErrorMessage = ex.Message,
StartTime = startTime,
EndTime = DateTime.UtcNow
};
await _hubContext.Clients.Group($"test_{testRunId}")
.SendAsync("ReceiveTestResult", errorResult);
}
}
finally
{
_cancellationRegistry.Unregister(testRunId);
_isTestRunning = false;
}
}
public async Task<TestExecutionResult> RunSingleTestAsync(
TestScenario scenario,
NavigationParameterSet parameters,
string? connectionId = null,
Guid? testRunId = null,
CancellationToken cancellationToken = default)
{
// Validate parameters
var validation = _parameterManager.Validate(parameters);
if (!validation.IsValid)
{
throw new InvalidOperationException($"Invalid parameters: {string.Join(", ", validation.Errors)}");
}
// Create test run record (use provided testRunId so client can join group before execute and receive real-time telemetry)
var testRun = new TestRun
{
Id = testRunId ?? Guid.NewGuid(),
ScenarioId = scenario.Id,
ParameterSetId = parameters.Id,
StartTime = DateTime.UtcNow,
Status = TestStatus.Preparing
};
// Join SignalR group if connectionId provided
if (connectionId != null && _hubContext != null)
{
await _hubContext.Groups.AddToGroupAsync(connectionId, $"test_{testRun.Id}", cancellationToken);
}
try
{
// Execute test (pass testRun.Id so real-time telemetry is sent to group test_{testRun.Id})
var result = await _tuningNavigation.ExecuteTestAsync(scenario, parameters, testRun.Id, cancellationToken);
// Update test run
testRun.Status = result.Status;
testRun.EndTime = result.EndTime;
testRun.Duration = result.Duration;
testRun.ErrorMessage = result.ErrorMessage;
testRun.SafetyViolations = result.SafetyViolations;
testRun.Metrics = result.Metrics;
// Save to database
await _testRepository.SaveAsync(testRun);
// Generate tuning suggestions
if (result.Status == TestStatus.Completed &&
result.TelemetryData?.Count > 10 &&
result.Metrics != null)
{
try
{
using var scope = _scopeFactory.CreateScope();
var tuningAdvisor = scope.ServiceProvider.GetRequiredService<ITuningAdvisor>();
result.TuningReport = tuningAdvisor.Analyze(
result.TelemetryData,
result.Metrics,
parameters);
result.TuningReport.TestRunId = testRun.Id;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Tuning advisor analysis failed for test {TestRunId}", testRun.Id);
}
}
// Publish completion
if (_hubContext != null)
{
await _hubContext.Clients.Group($"test_{testRun.Id}")
.SendAsync("ReceiveTestResult", result, cancellationToken: cancellationToken);
}
return result;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error executing test");
testRun.Status = TestStatus.Error;
testRun.ErrorMessage = ex.Message;
testRun.EndTime = DateTime.UtcNow;
await _testRepository.SaveAsync(testRun);
throw;
}
}
public async Task<BatchTestResult> RunBatchTestsAsync(
List<TestScenario> scenarios,
NavigationParameterSet parameters,
CancellationToken cancellationToken = default)
{
var results = new List<TestExecutionResult>();
var batchId = Guid.NewGuid();
_logger?.LogInformation("Starting batch test with {Count} scenarios", scenarios.Count);
for (int i = 0; i < scenarios.Count; i++)
{
if (cancellationToken.IsCancellationRequested)
{
_logger?.LogWarning("Batch test cancelled at scenario {Index}", i);
break;
}
var scenario = scenarios[i];
try
{
var result = await RunSingleTestAsync(scenario, parameters, cancellationToken: cancellationToken);
results.Add(result);
_logger?.LogInformation(
"Completed scenario {Index}/{Total}: {Name}",
i + 1,
scenarios.Count,
scenario.Name
);
}
catch (Exception ex)
{
_logger?.LogError(
ex,
"Failed scenario {Index}/{Total}: {Name}",
i + 1,
scenarios.Count,
scenario.Name
);
// Continue with remaining scenarios
}
}
var batchResult = new BatchTestResult
{
BatchId = batchId,
Parameters = parameters,
Results = results,
SuccessCount = results.Count(r => r.Status == TestStatus.Completed),
FailureCount = results.Count(r => r.Status != TestStatus.Completed),
AverageScore = results.Where(r => r.Metrics != null).Average(r => r.Metrics!.OverallScore)
};
return batchResult;
}
public async Task<ComparisonResult> CompareConfigurationsAsync(
List<NavigationParameterSet> parameterSets,
TestScenario scenario,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, TestExecutionResult>();
foreach (var parameters in parameterSets)
{
try
{
var result = await RunSingleTestAsync(scenario, parameters, cancellationToken: cancellationToken);
results[parameters.Name] = result;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to test configuration {Name}", parameters.Name);
}
}
var comparison = new ComparisonResult
{
Scenario = scenario,
Configurations = parameterSets,
Results = results,
BestConfiguration = results
.Where(r => r.Value.Metrics != null)
.OrderByDescending(r => r.Value.Metrics!.OverallScore)
.FirstOrDefault()
.Key ?? string.Empty
};
return comparison;
}
public void PauseTest(string testRunId)
{
_tuningNavigation.Pause();
}
public void ResumeTest(string testRunId)
{
_tuningNavigation.Resume();
}
public void StopTest(string testRunId)
{
if (Guid.TryParse(testRunId, out var id) && _cancellationRegistry.TryCancel(id))
return;
_tuningNavigation.Stop();
}
public void EmergencyStop(string testRunId)
{
if (Guid.TryParse(testRunId, out var id) && _cancellationRegistry.TryCancel(id))
return;
_tuningNavigation.EmergencyStop();
}
}