Initial commit

This commit is contained in:
2026-07-03 16:37:12 +07:00
commit 63b8c1ea8b
1931 changed files with 640587 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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