Initial commit
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
_ => "{}"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"RobotNet10.NavigationTune": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:60963;http://localhost:60964"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user