Initial commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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