From 6682d9763c931e16ba54413bbd783af128518154 Mon Sep 17 00:00:00 2001 From: Christian de Jonge Date: Mon, 17 Feb 2025 13:06:53 +0100 Subject: [PATCH] Introduce inspection area polygon --- .../InspectionAreaControllerTests.cs | 114 ++ .../MissionSchedulingControllerTests.cs | 33 +- .../api.test/Database/DatabaseUtilities.cs | 14 +- .../Services/InspectionAreaService.cs | 107 ++ .../Controllers/InspectionAreaController.cs | 52 +- .../MissionSchedulingController.cs | 66 +- .../Models/CreateInspectionAreaQuery.cs | 5 +- .../Models/InspectionAreaResponse.cs | 2 + backend/api/Database/Models/InspectionArea.cs | 2 + ...71354_AddInspectionAreaPolygon.Designer.cs | 1159 +++++++++++++++++ ...20250217071354_AddInspectionAreaPolygon.cs | 28 + .../FlotillaDbContextModelSnapshot.cs | 3 + backend/api/Program.cs | 1 - backend/api/Services/InspectionAreaService.cs | 118 +- backend/api/Services/InstallationService.cs | 35 +- backend/api/Services/LocalizationService.cs | 96 -- .../api/Services/MissionSchedulingService.cs | 10 +- .../Services/Models/InspectionAreaPolygon.cs | 24 + backend/api/Utilities/Exceptions.cs | 2 + 19 files changed, 1716 insertions(+), 155 deletions(-) create mode 100644 backend/api.test/Services/InspectionAreaService.cs create mode 100644 backend/api/Migrations/20250217071354_AddInspectionAreaPolygon.Designer.cs create mode 100644 backend/api/Migrations/20250217071354_AddInspectionAreaPolygon.cs delete mode 100644 backend/api/Services/LocalizationService.cs create mode 100644 backend/api/Services/Models/InspectionAreaPolygon.cs diff --git a/backend/api.test/Controllers/InspectionAreaControllerTests.cs b/backend/api.test/Controllers/InspectionAreaControllerTests.cs index 077f0925..1496e4f1 100644 --- a/backend/api.test/Controllers/InspectionAreaControllerTests.cs +++ b/backend/api.test/Controllers/InspectionAreaControllerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; @@ -152,5 +153,118 @@ public async Task CheckThatMissionDefinitionIsCreatedInInspectionAreaWhenSchedul ) ); } + + [Fact] + public async Task TestUpdatingInspectionAreaPolygon() + { + // Arrange + var installation = await DatabaseUtilities.NewInstallation(); + var plant = await DatabaseUtilities.NewPlant(installation.InstallationCode); + var inspectionArea = await DatabaseUtilities.NewInspectionArea( + installation.InstallationCode, + plant.PlantCode + ); + + var jsonString = + @"{ + ""zmin"": 0, + ""zmax"": 10, + ""positions"": [ + { ""x"": 0, ""y"": 0 }, + { ""x"": 0, ""y"": 10 }, + { ""x"": 10, ""y"": 10 }, + { ""x"": 10, ""y"": 0 } + ] + }"; + + var content = new StringContent(jsonString, null, "application/json"); + + var expecedJsonString = await content.ReadAsStringAsync(); + expecedJsonString = expecedJsonString.Replace("\n", "").Replace(" ", ""); + + // Act + var response = await Client.PatchAsync( + $"/inspectionAreas/{inspectionArea.Id}/area-polygon", + content + ); + var inspectionAreaResponse = await response.Content.ReadFromJsonAsync( + SerializerOptions + ); + + // Assert + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(expecedJsonString, inspectionAreaResponse!.AreaPolygonJson!); + } + + [Fact] + public async Task ScheduleMissionOutsideInspectionAreaPolygonFails() + { + // Arrange + var installation = await DatabaseUtilities.NewInstallation(); + var plant = await DatabaseUtilities.NewPlant(installation.InstallationCode); + var inspectionArea = await DatabaseUtilities.NewInspectionArea( + installation.InstallationCode, + plant.PlantCode + ); + var jsonString = + @"{ + ""zmin"": 0, + ""zmax"": 10, + ""positions"": [ + { ""x"": 0, ""y"": 0 }, + { ""x"": 0, ""y"": 10 }, + { ""x"": 10, ""y"": 10 }, + { ""x"": 10, ""y"": 0 } + ] + }"; + + var content = new StringContent(jsonString, null, "application/json"); + var response = await Client.PatchAsync( + $"/inspectionAreas/{inspectionArea.Id}/area-polygon", + content + ); + + Assert.True(response.IsSuccessStatusCode); + + var robot = await DatabaseUtilities.NewRobot(RobotStatus.Available, installation); + + var inspection = new CustomInspectionQuery + { + AnalysisType = AnalysisType.CarSeal, + InspectionTarget = new Position(), + InspectionType = InspectionType.Image, + }; + var tasks = new List + { + new() + { + Inspection = inspection, + TagId = "test", + RobotPose = new Pose(11, 11, 11, 0, 0, 0, 1), // Position outside polygon + TaskOrder = 0, + }, + }; + var missionQuery = new CustomMissionQuery + { + RobotId = robot.Id, + DesiredStartTime = DateTime.UtcNow, + InstallationCode = installation.InstallationCode, + InspectionAreaName = inspectionArea.Name, + Name = "TestMission", + Tasks = tasks, + }; + + var missionContent = new StringContent( + JsonSerializer.Serialize(missionQuery), + null, + "application/json" + ); + + // Act + var missionResponse = await Client.PostAsync("/missions/custom", missionContent); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, missionResponse.StatusCode); + } } } diff --git a/backend/api.test/Controllers/MissionSchedulingControllerTests.cs b/backend/api.test/Controllers/MissionSchedulingControllerTests.cs index 6464ed52..864831d8 100644 --- a/backend/api.test/Controllers/MissionSchedulingControllerTests.cs +++ b/backend/api.test/Controllers/MissionSchedulingControllerTests.cs @@ -10,6 +10,7 @@ using Api.Controllers.Models; using Api.Database.Models; using Api.Services; +using Api.Services.Models; using Api.Test.Database; using Microsoft.Extensions.DependencyInjection; using Testcontainers.PostgreSql; @@ -325,16 +326,42 @@ public async Task CheckThatMissionFailsIfRobotIsNotInSameInspectionAreaAsMission var installation = await DatabaseUtilities.NewInstallation(); var plant = await DatabaseUtilities.NewPlant(installation.InstallationCode); + var inspectionPolygonOne = new InspectionAreaPolygon + { + ZMin = 0, + ZMax = 10, + Positions = + [ + new XYPosition { X = 0, Y = 0 }, + new XYPosition { X = 0, Y = 10 }, + new XYPosition { X = 10, Y = 10 }, + new XYPosition { X = 10, Y = 0 }, + ], + }; var inspectionAreaOne = await DatabaseUtilities.NewInspectionArea( installation.InstallationCode, plant.PlantCode, - "InspectionAreaOne" + "InspectionAreaOne", + inspectionPolygonOne ); + var inspectionPolygonTwo = new InspectionAreaPolygon + { + ZMin = 0, + ZMax = 10, + Positions = + [ + new XYPosition { X = 11, Y = 11 }, + new XYPosition { X = 11, Y = 20 }, + new XYPosition { X = 20, Y = 20 }, + new XYPosition { X = 20, Y = 11 }, + ], + }; var inspectionAreaTwo = await DatabaseUtilities.NewInspectionArea( installation.InstallationCode, plant.PlantCode, - "InspectionAreaTwo" + "InspectionAreaTwo", + inspectionPolygonTwo ); var robot = await DatabaseUtilities.NewRobot( @@ -357,7 +384,7 @@ public async Task CheckThatMissionFailsIfRobotIsNotInSameInspectionAreaAsMission // Act const string CustomMissionsUrl = "/missions/custom"; var missionResponse = await Client.PostAsync(CustomMissionsUrl, content); - Assert.Equal(HttpStatusCode.Conflict, missionResponse.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, missionResponse.StatusCode); } private static CustomMissionQuery CreateDefaultCustomMissionQuery( diff --git a/backend/api.test/Database/DatabaseUtilities.cs b/backend/api.test/Database/DatabaseUtilities.cs index a034f8c8..ffef6178 100644 --- a/backend/api.test/Database/DatabaseUtilities.cs +++ b/backend/api.test/Database/DatabaseUtilities.cs @@ -4,6 +4,7 @@ using Api.Database.Context; using Api.Database.Models; using Api.Services; +using Api.Services.Models; using Api.Test.Mocks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -33,7 +34,11 @@ public class DatabaseUtilities public DatabaseUtilities(FlotillaDbContext context) { _accessRoleService = new AccessRoleService(context, new HttpContextAccessor()); - _installationService = new InstallationService(context, _accessRoleService); + _installationService = new InstallationService( + context, + _accessRoleService, + new Mock>().Object + ); _missionTaskService = new MissionTaskService( context, new Mock>().Object @@ -44,7 +49,8 @@ public DatabaseUtilities(FlotillaDbContext context) _installationService, _plantService, _accessRoleService, - new MockSignalRService() + new MockSignalRService(), + new Mock>().Object ); _areaService = new AreaService( context, @@ -150,7 +156,8 @@ public async Task NewPlant(string installationCode) public async Task NewInspectionArea( string installationCode, string plantCode, - string inspectionAreaName = "" + string inspectionAreaName = "", + InspectionAreaPolygon? areaPolygonJson = null ) { if (string.IsNullOrEmpty(inspectionAreaName)) @@ -160,6 +167,7 @@ public async Task NewInspectionArea( InstallationCode = installationCode, PlantCode = plantCode, Name = inspectionAreaName, + AreaPolygonJson = areaPolygonJson, }; return await _inspectionAreaService.Create(createInspectionAreaQuery); diff --git a/backend/api.test/Services/InspectionAreaService.cs b/backend/api.test/Services/InspectionAreaService.cs new file mode 100644 index 00000000..f73cf125 --- /dev/null +++ b/backend/api.test/Services/InspectionAreaService.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Api.Controllers.Models; +using Api.Database.Models; +using Api.Services; +using Api.Test.Database; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.PostgreSql; +using Xunit; + +namespace Api.Test.Services +{ + public class InspectionAreaServiceTest : IAsyncLifetime + { + public required DatabaseUtilities DatabaseUtilities; + public required PostgreSqlContainer Container; + public required IMissionRunService MissionRunService; + public required IInspectionAreaService InspectionAreaService; + + public async Task InitializeAsync() + { + (Container, string connectionString, var connection) = + await TestSetupHelpers.ConfigurePostgreSqlDatabase(); + var factory = TestSetupHelpers.ConfigureWebApplicationFactory( + postgreSqlConnectionString: connectionString + ); + var serviceProvider = TestSetupHelpers.ConfigureServiceProvider(factory); + + DatabaseUtilities = new DatabaseUtilities( + TestSetupHelpers.ConfigurePostgreSqlContext(connectionString) + ); + MissionRunService = serviceProvider.GetRequiredService(); + InspectionAreaService = serviceProvider.GetRequiredService(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task TestTasksInsidePolygon() + { + var installation = await DatabaseUtilities.NewInstallation(); + var plant = await DatabaseUtilities.NewPlant(installation.InstallationCode); + var inspectionArea = await DatabaseUtilities.NewInspectionArea( + installation.InstallationCode, + plant.PlantCode + ); + inspectionArea.AreaPolygonJson = + @"{ + ""zmin"": 0, + ""zmax"": 10, + ""positions"": [ + { ""x"": 0, ""y"": 0 }, + { ""x"": 0, ""y"": 10 }, + { ""x"": 10, ""y"": 10 }, + { ""x"": 10, ""y"": 0 } + ] + }"; + + List missionTasks = + [ + new(new Pose(1, 1, 1, 0, 0, 0, 1), MissionTaskType.Inspection), + new(new Pose(2, 2, 2, 0, 0, 0, 1), MissionTaskType.ReturnHome), + ]; + + var testBool = InspectionAreaService.MissionTasksAreInsideInspectionAreaPolygon( + missionTasks, + inspectionArea + ); + Assert.True(testBool); + } + + [Fact] + public async Task TestTasksOutsidePolygon() + { + var installation = await DatabaseUtilities.NewInstallation(); + var plant = await DatabaseUtilities.NewPlant(installation.InstallationCode); + var inspectionArea = await DatabaseUtilities.NewInspectionArea( + installation.InstallationCode, + plant.PlantCode + ); + inspectionArea.AreaPolygonJson = + @"{ + ""zmin"": 0, + ""zmax"": 10, + ""positions"": [ + { ""x"": 0, ""y"": 0 }, + { ""x"": 0, ""y"": 10 }, + { ""x"": 10, ""y"": 10 }, + { ""x"": 10, ""y"": 0 } + ] + }"; + List missionTasks = + [ + new(new Pose(1, 1, 1, 0, 0, 0, 1), MissionTaskType.ReturnHome), + new(new Pose(11, 11, 11, 0, 0, 0, 1), MissionTaskType.ReturnHome), + ]; + + var testBool = InspectionAreaService.MissionTasksAreInsideInspectionAreaPolygon( + missionTasks, + inspectionArea + ); + Assert.False(testBool); + } + } +} diff --git a/backend/api/Controllers/InspectionAreaController.cs b/backend/api/Controllers/InspectionAreaController.cs index 8bcb244b..db492267 100644 --- a/backend/api/Controllers/InspectionAreaController.cs +++ b/backend/api/Controllers/InspectionAreaController.cs @@ -1,7 +1,9 @@ -using Api.Controllers.Models; +using System.Text.Json; +using Api.Controllers.Models; using Api.Database.Models; using Api.Services; -using Azure; +using Api.Services.Models; +using Api.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -151,6 +153,43 @@ public async Task< } } + /// + /// Update the inspection area json polygon + /// + [HttpPatch] + [Authorize(Roles = Role.Any)] + [Route("{inspectionAreaId}/area-polygon")] + [ProducesResponseType(typeof(ActionResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> UpdateInspectionAreaJsonPolygon( + [FromRoute] string inspectionAreaId, + [FromBody] InspectionAreaPolygon areaPolygonJson + ) + { + try + { + var inspectionArea = await inspectionAreaService.ReadById( + inspectionAreaId, + readOnly: true + ); + if (inspectionArea == null) + return NotFound($"Could not find inspection area with id {inspectionAreaId}"); + + var jsonString = JsonSerializer.Serialize(areaPolygonJson); + inspectionArea.AreaPolygonJson = jsonString; + var updatedInspectionArea = await inspectionAreaService.Update(inspectionArea); + return Ok(inspectionArea); + } + catch (Exception e) + { + logger.LogError(e, "Error during updating inspection area polygon"); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + /// /// Add a new inspection area /// @@ -163,7 +202,7 @@ public async Task< [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task> Create( [FromBody] CreateInspectionAreaQuery inspectionArea ) @@ -216,10 +255,15 @@ await inspectionAreaService.ReadByInstallationAndPlantAndName( new InspectionAreaResponse(newInspectionArea) ); } + catch (InvalidPolygonException e) + { + logger.LogError(e, "Invalid polygon"); + return BadRequest("Invalid polygon"); + } catch (Exception e) { logger.LogError(e, "Error while creating new inspection area"); - throw; + return StatusCode(StatusCodes.Status500InternalServerError); } } diff --git a/backend/api/Controllers/MissionSchedulingController.cs b/backend/api/Controllers/MissionSchedulingController.cs index 9492fe08..8d8d31ff 100644 --- a/backend/api/Controllers/MissionSchedulingController.cs +++ b/backend/api/Controllers/MissionSchedulingController.cs @@ -20,7 +20,6 @@ public class MissionSchedulingController( ILogger logger, IMapService mapService, IStidService stidService, - ILocalizationService localizationService, IRobotService robotService, ISourceService sourceService, IInspectionAreaService inspectionAreaService @@ -173,7 +172,7 @@ [FromBody] ScheduleMissionQuery scheduledMissionQuery try { - await localizationService.EnsureRobotIsOnSameInstallationAsMission( + await installationService.AssertRobotIsOnSameInstallationAsMission( robot, missionDefinition ); @@ -361,6 +360,18 @@ [.. missionInspectionAreaNames] return NotFound($"No area found for mission '{missionDefinition.Name}'."); } + if ( + !inspectionAreaService.MissionTasksAreInsideInspectionAreaPolygon( + missionTasks, + area.InspectionArea + ) + ) + { + return BadRequest( + $"The tasks of the mission are not inside the inspection area of the robot" + ); + } + var source = await sourceService.CheckForExistingSource( scheduledMissionQuery.MissionSourceId ); @@ -436,19 +447,6 @@ [.. missionInspectionAreaNames] await missionDefinitionService.Create(scheduledMissionDefinition); } - if ( - missionRun.Robot.CurrentInspectionArea != null - && !await localizationService.RobotIsOnSameInspectionAreaAsMission( - missionRun.Robot.Id, - missionRun.InspectionArea!.Id - ) - ) - { - return Conflict( - $"The robot {missionRun.Robot.Name} is assumed to be in a different inspection area so the mission was not scheduled." - ); - } - MissionRun newMissionRun; try { @@ -479,7 +477,6 @@ [.. missionInspectionAreaNames] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task> Create( [FromBody] CustomMissionQuery customMissionQuery ) @@ -535,6 +532,18 @@ [FromBody] CustomMissionQuery customMissionQuery ); } + if ( + !inspectionAreaService.MissionTasksAreInsideInspectionAreaPolygon( + missionTasks, + inspectionArea + ) + ) + { + return BadRequest( + $"The tasks of the mission are not inside the inspection area of the robot" + ); + } + var source = await sourceService.CheckForExistingSourceFromTasks(missionTasks); MissionDefinition? existingMissionDefinition = null; @@ -598,7 +607,7 @@ [.. missionTasks.Select(t => t.RobotPose.Position)], try { - await localizationService.EnsureRobotIsOnSameInstallationAsMission( + await installationService.AssertRobotIsOnSameInstallationAsMission( robot, customMissionDefinition ); @@ -634,29 +643,6 @@ await localizationService.EnsureRobotIsOnSameInstallationAsMission( { scheduledMission.SetEstimatedTaskDuration(); } - else if ( - scheduledMission.Robot.CurrentInspectionArea != null - && !await localizationService.RobotIsOnSameInspectionAreaAsMission( - scheduledMission.Robot.Id, - scheduledMission.InspectionArea.Id - ) - ) - { - scheduledMission.SetEstimatedTaskDuration(); - } - - if ( - scheduledMission.Robot.CurrentInspectionArea != null - && !await localizationService.RobotIsOnSameInspectionAreaAsMission( - scheduledMission.Robot.Id, - scheduledMission.InspectionArea.Id - ) - ) - { - return Conflict( - $"The robot {scheduledMission.Robot.Name} is assumed to be in a different inspection area so the mission was not scheduled." - ); - } newMissionRun = await missionRunService.Create(scheduledMission); } diff --git a/backend/api/Controllers/Models/CreateInspectionAreaQuery.cs b/backend/api/Controllers/Models/CreateInspectionAreaQuery.cs index 44a29bd2..2d9d0bc5 100644 --- a/backend/api/Controllers/Models/CreateInspectionAreaQuery.cs +++ b/backend/api/Controllers/Models/CreateInspectionAreaQuery.cs @@ -1,9 +1,12 @@ -namespace Api.Controllers.Models +using Api.Services.Models; + +namespace Api.Controllers.Models { public struct CreateInspectionAreaQuery { public string InstallationCode { get; set; } public string PlantCode { get; set; } public string Name { get; set; } + public InspectionAreaPolygon? AreaPolygonJson { get; set; } } } diff --git a/backend/api/Controllers/Models/InspectionAreaResponse.cs b/backend/api/Controllers/Models/InspectionAreaResponse.cs index 948b9ed7..49aa1956 100644 --- a/backend/api/Controllers/Models/InspectionAreaResponse.cs +++ b/backend/api/Controllers/Models/InspectionAreaResponse.cs @@ -12,6 +12,7 @@ public class InspectionAreaResponse public string PlantCode { get; set; } public string InstallationCode { get; set; } + public string? AreaPolygonJson { get; set; } [JsonConstructor] #nullable disable @@ -25,6 +26,7 @@ public InspectionAreaResponse(InspectionArea inspectionArea) InspectionAreaName = inspectionArea.Name; PlantCode = inspectionArea.Plant.PlantCode; InstallationCode = inspectionArea.Installation.InstallationCode; + AreaPolygonJson = inspectionArea.AreaPolygonJson; } } } diff --git a/backend/api/Database/Models/InspectionArea.cs b/backend/api/Database/Models/InspectionArea.cs index 77f9e997..0f08e580 100644 --- a/backend/api/Database/Models/InspectionArea.cs +++ b/backend/api/Database/Models/InspectionArea.cs @@ -19,5 +19,7 @@ public class InspectionArea [Required] [MaxLength(200)] public string Name { get; set; } + + public string? AreaPolygonJson { get; set; } = ""; } } diff --git a/backend/api/Migrations/20250217071354_AddInspectionAreaPolygon.Designer.cs b/backend/api/Migrations/20250217071354_AddInspectionAreaPolygon.Designer.cs new file mode 100644 index 00000000..8c895839 --- /dev/null +++ b/backend/api/Migrations/20250217071354_AddInspectionAreaPolygon.Designer.cs @@ -0,0 +1,1159 @@ +// +using System; +using Api.Database.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Api.Migrations +{ + [DbContext(typeof(FlotillaDbContext))] + [Migration("20250217071354_AddInspectionAreaPolygon")] + partial class AddInspectionAreaPolygon + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Api.Database.Models.AccessRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("AccessLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstallationId") + .HasColumnType("text"); + + b.Property("RoleName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstallationId"); + + b.ToTable("AccessRoles"); + }); + + modelBuilder.Entity("Api.Database.Models.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("InspectionAreaId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstallationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PlantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InspectionAreaId"); + + b.HasIndex("InstallationId"); + + b.HasIndex("PlantId"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Api.Database.Models.Inspection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("AnalysisType") + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("InspectionTargetName") + .HasColumnType("text"); + + b.Property("InspectionType") + .IsRequired() + .HasColumnType("text"); + + b.Property("InspectionUrl") + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("IsarInspectionId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsarTaskId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("VideoDuration") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.ToTable("Inspections"); + }); + + modelBuilder.Entity("Api.Database.Models.InspectionArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("AreaPolygonJson") + .HasColumnType("text"); + + b.Property("InstallationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PlantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstallationId"); + + b.HasIndex("PlantId"); + + b.ToTable("InspectionAreas"); + }); + + modelBuilder.Entity("Api.Database.Models.InspectionFinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Finding") + .IsRequired() + .HasColumnType("text"); + + b.Property("InspectionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InspectionId") + .HasColumnType("text"); + + b.Property("IsarTaskId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InspectionId"); + + b.ToTable("InspectionFindings"); + }); + + modelBuilder.Entity("Api.Database.Models.Installation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("InstallationCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("InstallationCode") + .IsUnique(); + + b.ToTable("Installations"); + }); + + modelBuilder.Entity("Api.Database.Models.MissionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("InspectionAreaId") + .HasColumnType("text"); + + b.Property("InspectionFrequency") + .HasColumnType("bigint"); + + b.Property("InstallationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeprecated") + .HasColumnType("boolean"); + + b.Property("LastSuccessfulRunId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InspectionAreaId"); + + b.HasIndex("LastSuccessfulRunId"); + + b.HasIndex("SourceId"); + + b.ToTable("MissionDefinitions"); + }); + + modelBuilder.Entity("Api.Database.Models.MissionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Description") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DesiredStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedTaskDuration") + .HasColumnType("bigint"); + + b.Property("InspectionAreaId") + .HasColumnType("text"); + + b.Property("InstallationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeprecated") + .HasColumnType("boolean"); + + b.Property("IsarMissionId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MissionId") + .HasColumnType("text"); + + b.Property("MissionRunType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RobotId") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusReason") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("InspectionAreaId"); + + b.HasIndex("RobotId"); + + b.ToTable("MissionRuns"); + }); + + modelBuilder.Entity("Api.Database.Models.MissionTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("InspectionId") + .HasColumnType("text"); + + b.Property("IsarTaskId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MissionRunId") + .HasColumnType("text"); + + b.Property("PoseId") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TagId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TagLink") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TaskOrder") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InspectionId"); + + b.HasIndex("MissionRunId"); + + b.ToTable("MissionTasks"); + }); + + modelBuilder.Entity("Api.Database.Models.Plant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("InstallationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PlantCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("Id"); + + b.HasIndex("InstallationId"); + + b.HasIndex("PlantCode") + .IsUnique(); + + b.ToTable("Plants"); + }); + + modelBuilder.Entity("Api.Database.Models.Robot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("real"); + + b.Property("BatteryState") + .HasColumnType("text"); + + b.Property("CurrentInspectionAreaId") + .HasColumnType("text"); + + b.Property("CurrentInstallationId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CurrentMissionId") + .HasColumnType("text"); + + b.Property("Deprecated") + .HasColumnType("boolean"); + + b.Property("FlotillaStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsarConnected") + .HasColumnType("boolean"); + + b.Property("IsarId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MissionQueueFrozen") + .HasColumnType("boolean"); + + b.Property("ModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("PressureLevel") + .HasColumnType("real"); + + b.Property("RobotCapabilities") + .HasColumnType("text"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CurrentInspectionAreaId"); + + b.HasIndex("CurrentInstallationId"); + + b.HasIndex("ModelId"); + + b.ToTable("Robots"); + }); + + modelBuilder.Entity("Api.Database.Models.RobotModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("AverageDurationPerTag") + .HasColumnType("real"); + + b.Property("BatteryMissionStartThreshold") + .HasColumnType("real"); + + b.Property("BatteryWarningThreshold") + .HasColumnType("real"); + + b.Property("LowerPressureWarningThreshold") + .HasColumnType("real"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpperPressureWarningThreshold") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("Type") + .IsUnique(); + + b.ToTable("RobotModels"); + }); + + modelBuilder.Entity("Api.Database.Models.Source", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("CustomMissionTasks") + .HasColumnType("text"); + + b.Property("SourceId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sources"); + }); + + modelBuilder.Entity("Api.Database.Models.UserInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Oid") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("UserInfos"); + }); + + modelBuilder.Entity("Api.Services.MissionLoaders.TagInspectionMetadata", b => + { + b.Property("TagId") + .HasColumnType("text"); + + b.HasKey("TagId"); + + b.ToTable("TagInspectionMetadata"); + }); + + modelBuilder.Entity("Api.Database.Models.AccessRole", b => + { + b.HasOne("Api.Database.Models.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId"); + + b.Navigation("Installation"); + }); + + modelBuilder.Entity("Api.Database.Models.Area", b => + { + b.HasOne("Api.Database.Models.InspectionArea", "InspectionArea") + .WithMany() + .HasForeignKey("InspectionAreaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Api.Database.Models.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Api.Database.Models.Plant", "Plant") + .WithMany() + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Api.Database.Models.MapMetadata", "MapMetadata", b1 => + { + b1.Property("AreaId") + .HasColumnType("text"); + + b1.Property("MapName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.HasKey("AreaId"); + + b1.ToTable("Areas"); + + b1.WithOwner() + .HasForeignKey("AreaId"); + + b1.OwnsOne("Api.Database.Models.Boundary", "Boundary", b2 => + { + b2.Property("MapMetadataAreaId") + .HasColumnType("text"); + + b2.Property("X1") + .HasColumnType("double precision"); + + b2.Property("X2") + .HasColumnType("double precision"); + + b2.Property("Y1") + .HasColumnType("double precision"); + + b2.Property("Y2") + .HasColumnType("double precision"); + + b2.Property("Z1") + .HasColumnType("double precision"); + + b2.Property("Z2") + .HasColumnType("double precision"); + + b2.HasKey("MapMetadataAreaId"); + + b2.ToTable("Areas"); + + b2.WithOwner() + .HasForeignKey("MapMetadataAreaId"); + }); + + b1.OwnsOne("Api.Database.Models.TransformationMatrices", "TransformationMatrices", b2 => + { + b2.Property("MapMetadataAreaId") + .HasColumnType("text"); + + b2.Property("C1") + .HasColumnType("double precision"); + + b2.Property("C2") + .HasColumnType("double precision"); + + b2.Property("D1") + .HasColumnType("double precision"); + + b2.Property("D2") + .HasColumnType("double precision"); + + b2.HasKey("MapMetadataAreaId"); + + b2.ToTable("Areas"); + + b2.WithOwner() + .HasForeignKey("MapMetadataAreaId"); + }); + + b1.Navigation("Boundary") + .IsRequired(); + + b1.Navigation("TransformationMatrices") + .IsRequired(); + }); + + b.Navigation("InspectionArea"); + + b.Navigation("Installation"); + + b.Navigation("MapMetadata") + .IsRequired(); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("Api.Database.Models.Inspection", b => + { + b.OwnsOne("Api.Database.Models.Position", "InspectionTarget", b1 => + { + b1.Property("InspectionId") + .HasColumnType("text"); + + b1.Property("X") + .HasColumnType("real"); + + b1.Property("Y") + .HasColumnType("real"); + + b1.Property("Z") + .HasColumnType("real"); + + b1.HasKey("InspectionId"); + + b1.ToTable("Inspections"); + + b1.WithOwner() + .HasForeignKey("InspectionId"); + }); + + b.Navigation("InspectionTarget") + .IsRequired(); + }); + + modelBuilder.Entity("Api.Database.Models.InspectionArea", b => + { + b.HasOne("Api.Database.Models.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Api.Database.Models.Plant", "Plant") + .WithMany() + .HasForeignKey("PlantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Plant"); + }); + + modelBuilder.Entity("Api.Database.Models.InspectionFinding", b => + { + b.HasOne("Api.Database.Models.Inspection", null) + .WithMany("InspectionFindings") + .HasForeignKey("InspectionId"); + }); + + modelBuilder.Entity("Api.Database.Models.MissionDefinition", b => + { + b.HasOne("Api.Database.Models.InspectionArea", "InspectionArea") + .WithMany() + .HasForeignKey("InspectionAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Api.Database.Models.MissionRun", "LastSuccessfulRun") + .WithMany() + .HasForeignKey("LastSuccessfulRunId"); + + b.HasOne("Api.Database.Models.Source", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Api.Database.Models.MapMetadata", "Map", b1 => + { + b1.Property("MissionDefinitionId") + .HasColumnType("text"); + + b1.Property("MapName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.HasKey("MissionDefinitionId"); + + b1.ToTable("MissionDefinitions"); + + b1.WithOwner() + .HasForeignKey("MissionDefinitionId"); + + b1.OwnsOne("Api.Database.Models.Boundary", "Boundary", b2 => + { + b2.Property("MapMetadataMissionDefinitionId") + .HasColumnType("text"); + + b2.Property("X1") + .HasColumnType("double precision"); + + b2.Property("X2") + .HasColumnType("double precision"); + + b2.Property("Y1") + .HasColumnType("double precision"); + + b2.Property("Y2") + .HasColumnType("double precision"); + + b2.Property("Z1") + .HasColumnType("double precision"); + + b2.Property("Z2") + .HasColumnType("double precision"); + + b2.HasKey("MapMetadataMissionDefinitionId"); + + b2.ToTable("MissionDefinitions"); + + b2.WithOwner() + .HasForeignKey("MapMetadataMissionDefinitionId"); + }); + + b1.OwnsOne("Api.Database.Models.TransformationMatrices", "TransformationMatrices", b2 => + { + b2.Property("MapMetadataMissionDefinitionId") + .HasColumnType("text"); + + b2.Property("C1") + .HasColumnType("double precision"); + + b2.Property("C2") + .HasColumnType("double precision"); + + b2.Property("D1") + .HasColumnType("double precision"); + + b2.Property("D2") + .HasColumnType("double precision"); + + b2.HasKey("MapMetadataMissionDefinitionId"); + + b2.ToTable("MissionDefinitions"); + + b2.WithOwner() + .HasForeignKey("MapMetadataMissionDefinitionId"); + }); + + b1.Navigation("Boundary") + .IsRequired(); + + b1.Navigation("TransformationMatrices") + .IsRequired(); + }); + + b.Navigation("InspectionArea"); + + b.Navigation("LastSuccessfulRun"); + + b.Navigation("Map"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("Api.Database.Models.MissionRun", b => + { + b.HasOne("Api.Database.Models.InspectionArea", "InspectionArea") + .WithMany() + .HasForeignKey("InspectionAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Api.Database.Models.Robot", "Robot") + .WithMany() + .HasForeignKey("RobotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InspectionArea"); + + b.Navigation("Robot"); + }); + + modelBuilder.Entity("Api.Database.Models.MissionTask", b => + { + b.HasOne("Api.Database.Models.Inspection", "Inspection") + .WithMany() + .HasForeignKey("InspectionId"); + + b.HasOne("Api.Database.Models.MissionRun", null) + .WithMany("Tasks") + .HasForeignKey("MissionRunId"); + + b.OwnsOne("Api.Services.Models.IsarZoomDescription", "IsarZoomDescription", b1 => + { + b1.Property("MissionTaskId") + .HasColumnType("text"); + + b1.Property("ObjectHeight") + .HasColumnType("double precision") + .HasAnnotation("Relational:JsonPropertyName", "objectHeight"); + + b1.Property("ObjectWidth") + .HasColumnType("double precision") + .HasAnnotation("Relational:JsonPropertyName", "objectWidth"); + + b1.HasKey("MissionTaskId"); + + b1.ToTable("MissionTasks"); + + b1.WithOwner() + .HasForeignKey("MissionTaskId"); + }); + + b.OwnsOne("Api.Database.Models.Pose", "RobotPose", b1 => + { + b1.Property("MissionTaskId") + .HasColumnType("text"); + + b1.HasKey("MissionTaskId"); + + b1.ToTable("MissionTasks"); + + b1.WithOwner() + .HasForeignKey("MissionTaskId"); + + b1.OwnsOne("Api.Database.Models.Orientation", "Orientation", b2 => + { + b2.Property("PoseMissionTaskId") + .HasColumnType("text"); + + b2.Property("W") + .HasColumnType("real"); + + b2.Property("X") + .HasColumnType("real"); + + b2.Property("Y") + .HasColumnType("real"); + + b2.Property("Z") + .HasColumnType("real"); + + b2.HasKey("PoseMissionTaskId"); + + b2.ToTable("MissionTasks"); + + b2.WithOwner() + .HasForeignKey("PoseMissionTaskId"); + }); + + b1.OwnsOne("Api.Database.Models.Position", "Position", b2 => + { + b2.Property("PoseMissionTaskId") + .HasColumnType("text"); + + b2.Property("X") + .HasColumnType("real"); + + b2.Property("Y") + .HasColumnType("real"); + + b2.Property("Z") + .HasColumnType("real"); + + b2.HasKey("PoseMissionTaskId"); + + b2.ToTable("MissionTasks"); + + b2.WithOwner() + .HasForeignKey("PoseMissionTaskId"); + }); + + b1.Navigation("Orientation") + .IsRequired(); + + b1.Navigation("Position") + .IsRequired(); + }); + + b.Navigation("Inspection"); + + b.Navigation("IsarZoomDescription"); + + b.Navigation("RobotPose") + .IsRequired(); + }); + + modelBuilder.Entity("Api.Database.Models.Plant", b => + { + b.HasOne("Api.Database.Models.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Installation"); + }); + + modelBuilder.Entity("Api.Database.Models.Robot", b => + { + b.HasOne("Api.Database.Models.InspectionArea", "CurrentInspectionArea") + .WithMany() + .HasForeignKey("CurrentInspectionAreaId"); + + b.HasOne("Api.Database.Models.Installation", "CurrentInstallation") + .WithMany() + .HasForeignKey("CurrentInstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Api.Database.Models.RobotModel", "Model") + .WithMany() + .HasForeignKey("ModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Api.Database.Models.DocumentInfo", "Documentation", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.Property("RobotId") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Url") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.HasKey("Id"); + + b1.HasIndex("RobotId"); + + b1.ToTable("DocumentInfo"); + + b1.WithOwner() + .HasForeignKey("RobotId"); + }); + + b.OwnsOne("Api.Database.Models.Pose", "Pose", b1 => + { + b1.Property("RobotId") + .HasColumnType("text"); + + b1.HasKey("RobotId"); + + b1.ToTable("Robots"); + + b1.WithOwner() + .HasForeignKey("RobotId"); + + b1.OwnsOne("Api.Database.Models.Orientation", "Orientation", b2 => + { + b2.Property("PoseRobotId") + .HasColumnType("text"); + + b2.Property("W") + .HasColumnType("real"); + + b2.Property("X") + .HasColumnType("real"); + + b2.Property("Y") + .HasColumnType("real"); + + b2.Property("Z") + .HasColumnType("real"); + + b2.HasKey("PoseRobotId"); + + b2.ToTable("Robots"); + + b2.WithOwner() + .HasForeignKey("PoseRobotId"); + }); + + b1.OwnsOne("Api.Database.Models.Position", "Position", b2 => + { + b2.Property("PoseRobotId") + .HasColumnType("text"); + + b2.Property("X") + .HasColumnType("real"); + + b2.Property("Y") + .HasColumnType("real"); + + b2.Property("Z") + .HasColumnType("real"); + + b2.HasKey("PoseRobotId"); + + b2.ToTable("Robots"); + + b2.WithOwner() + .HasForeignKey("PoseRobotId"); + }); + + b1.Navigation("Orientation") + .IsRequired(); + + b1.Navigation("Position") + .IsRequired(); + }); + + b.Navigation("CurrentInspectionArea"); + + b.Navigation("CurrentInstallation"); + + b.Navigation("Documentation"); + + b.Navigation("Model"); + + b.Navigation("Pose") + .IsRequired(); + }); + + modelBuilder.Entity("Api.Services.MissionLoaders.TagInspectionMetadata", b => + { + b.OwnsOne("Api.Services.Models.IsarZoomDescription", "ZoomDescription", b1 => + { + b1.Property("TagInspectionMetadataTagId") + .HasColumnType("text"); + + b1.Property("ObjectHeight") + .HasColumnType("double precision") + .HasAnnotation("Relational:JsonPropertyName", "objectHeight"); + + b1.Property("ObjectWidth") + .HasColumnType("double precision") + .HasAnnotation("Relational:JsonPropertyName", "objectWidth"); + + b1.HasKey("TagInspectionMetadataTagId"); + + b1.ToTable("TagInspectionMetadata"); + + b1.WithOwner() + .HasForeignKey("TagInspectionMetadataTagId"); + }); + + b.Navigation("ZoomDescription"); + }); + + modelBuilder.Entity("Api.Database.Models.Inspection", b => + { + b.Navigation("InspectionFindings"); + }); + + modelBuilder.Entity("Api.Database.Models.MissionRun", b => + { + b.Navigation("Tasks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/api/Migrations/20250217071354_AddInspectionAreaPolygon.cs b/backend/api/Migrations/20250217071354_AddInspectionAreaPolygon.cs new file mode 100644 index 00000000..7705bb44 --- /dev/null +++ b/backend/api/Migrations/20250217071354_AddInspectionAreaPolygon.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Api.Migrations +{ + /// + public partial class AddInspectionAreaPolygon : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AreaPolygonJson", + table: "InspectionAreas", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AreaPolygonJson", + table: "InspectionAreas"); + } + } +} diff --git a/backend/api/Migrations/FlotillaDbContextModelSnapshot.cs b/backend/api/Migrations/FlotillaDbContextModelSnapshot.cs index c11f1d2f..cc12ec68 100644 --- a/backend/api/Migrations/FlotillaDbContextModelSnapshot.cs +++ b/backend/api/Migrations/FlotillaDbContextModelSnapshot.cs @@ -134,6 +134,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("text"); + b.Property("AreaPolygonJson") + .HasColumnType("text"); + b.Property("InstallationId") .IsRequired() .HasColumnType("text"); diff --git a/backend/api/Program.cs b/backend/api/Program.cs index 5c17af31..7d5dc965 100644 --- a/backend/api/Program.cs +++ b/backend/api/Program.cs @@ -82,7 +82,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/backend/api/Services/InspectionAreaService.cs b/backend/api/Services/InspectionAreaService.cs index 2b5f2109..68eea6eb 100644 --- a/backend/api/Services/InspectionAreaService.cs +++ b/backend/api/Services/InspectionAreaService.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Text.Json; using Api.Controllers.Models; using Api.Database.Context; using Api.Database.Models; +using Api.Services.Models; using Api.Utilities; using Microsoft.EntityFrameworkCore; @@ -32,6 +34,11 @@ public Task> ReadByInstallation( bool readOnly = true ); + public bool MissionTasksAreInsideInspectionAreaPolygon( + List missionTasks, + InspectionArea inspectionArea + ); + public Task Create(CreateInspectionAreaQuery newInspectionArea); public Task Update(InspectionArea inspectionArea); @@ -56,7 +63,8 @@ public class InspectionAreaService( IInstallationService installationService, IPlantService plantService, IAccessRoleService accessRoleService, - ISignalRService signalRService + ISignalRService signalRService, + ILogger logger ) : IInspectionAreaService { public async Task> ReadAll(bool readOnly = true) @@ -127,6 +135,88 @@ public async Task> ReadByInstallation( .FirstOrDefaultAsync(); } + public bool MissionTasksAreInsideInspectionAreaPolygon( + List missionTasks, + InspectionArea inspectionArea + ) + { + if (string.IsNullOrEmpty(inspectionArea.AreaPolygonJson)) + { + logger.LogWarning( + "No polygon defined for inspection area {inspectionAreaName}", + inspectionArea.Name + ); + return true; + } + + var inspectionAreaPolygon = JsonSerializer.Deserialize( + inspectionArea.AreaPolygonJson + ); + + if (inspectionAreaPolygon == null) + { + logger.LogWarning( + "Invalid polygon defined for inspection area {inspectionAreaName}", + inspectionArea.Name + ); + return true; + } + + foreach (var missionTask in missionTasks) + { + var robotPosition = missionTask.RobotPose.Position; + if ( + !IsPositionInsidePolygon( + inspectionAreaPolygon.Positions, + robotPosition, + inspectionAreaPolygon.ZMin, + inspectionAreaPolygon.ZMax + ) + ) + { + return false; + } + } + + return true; + } + + private static bool IsPositionInsidePolygon( + List polygon, + Position position, + double zMin, + double zMax + ) + { + var x = position.X; + var y = position.Y; + var z = position.Z; + + if (z < zMin || z > zMax) + { + return false; + } + + // Ray-casting algorithm for checking if the point is inside the polygon + var inside = false; + for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++) + { + var xi = polygon[i].X; + var yi = polygon[i].Y; + var xj = polygon[j].X; + var yj = polygon[j].Y; + + var intersect = + ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); + if (intersect) + { + inside = !inside; + } + } + + return inside; + } + public async Task Create(CreateInspectionAreaQuery newInspectionAreaQuery) { var installation = @@ -160,11 +250,29 @@ await plantService.ReadByInstallationAndPlantCode( ); } + string inspectionAreaPolygon = ""; + if (newInspectionAreaQuery.AreaPolygonJson != null) + { + try + { + inspectionAreaPolygon = JsonSerializer.Serialize( + newInspectionAreaQuery.AreaPolygonJson + ); + } + catch (Exception) + { + throw new InvalidPolygonException( + "The polygon is invalid and could not be parsed" + ); + } + } + var inspectionArea = new InspectionArea { Name = newInspectionAreaQuery.Name, Installation = installation, Plant = plant, + AreaPolygonJson = inspectionAreaPolygon, }; context.Entry(inspectionArea.Installation).State = EntityState.Unchanged; @@ -183,6 +291,14 @@ await plantService.ReadByInstallationAndPlantCode( public async Task Update(InspectionArea inspectionArea) { + if (inspectionArea.Installation is not null) + { + context.Entry(inspectionArea.Installation).State = EntityState.Unchanged; + } + if (inspectionArea.Plant is not null) + { + context.Entry(inspectionArea.Plant).State = EntityState.Unchanged; + } var entry = context.Update(inspectionArea); await ApplyDatabaseUpdate(inspectionArea.Installation); _ = signalRService.SendMessageAsync( diff --git a/backend/api/Services/InstallationService.cs b/backend/api/Services/InstallationService.cs index 721e46ff..d1a53d0b 100644 --- a/backend/api/Services/InstallationService.cs +++ b/backend/api/Services/InstallationService.cs @@ -2,6 +2,7 @@ using Api.Controllers.Models; using Api.Database.Context; using Api.Database.Models; +using Api.Utilities; using Microsoft.EntityFrameworkCore; namespace Api.Services @@ -23,6 +24,11 @@ public interface IInstallationService public abstract Task Delete(string id); + public Task AssertRobotIsOnSameInstallationAsMission( + Robot robot, + MissionDefinition missionDefinition + ); + public void DetachTracking(FlotillaDbContext context, Installation installation); } @@ -38,7 +44,8 @@ public interface IInstallationService )] public class InstallationService( FlotillaDbContext context, - IAccessRoleService accessRoleService + IAccessRoleService accessRoleService, + ILogger logger ) : IInstallationService { public async Task> ReadAll(bool readOnly = true) @@ -137,6 +144,32 @@ public async Task Update(Installation installation) return installation; } + public async Task AssertRobotIsOnSameInstallationAsMission( + Robot robot, + MissionDefinition missionDefinition + ) + { + var missionInstallation = await ReadByInstallationCode( + missionDefinition.InstallationCode + ); + + if (missionInstallation is null) + { + string errorMessage = + $"Could not find installation for installation code {missionDefinition.InstallationCode}"; + logger.LogError("{Message}", errorMessage); + throw new InstallationNotFoundException(errorMessage); + } + + if (robot.CurrentInstallation.Id != missionInstallation.Id) + { + string errorMessage = + $"The robot {robot.Name} is on installation {robot.CurrentInstallation.Name} which is not the same as the mission installation {missionInstallation.Name}"; + logger.LogError("{Message}", errorMessage); + throw new RobotNotInSameInstallationAsMissionException(errorMessage); + } + } + public void DetachTracking(FlotillaDbContext context, Installation installation) { context.Entry(installation).State = EntityState.Detached; diff --git a/backend/api/Services/LocalizationService.cs b/backend/api/Services/LocalizationService.cs deleted file mode 100644 index b8f0fee8..00000000 --- a/backend/api/Services/LocalizationService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Api.Database.Models; -using Api.Utilities; - -namespace Api.Services -{ - public interface ILocalizationService - { - public Task EnsureRobotIsOnSameInstallationAsMission( - Robot robot, - MissionDefinition missionDefinition - ); - public Task RobotIsOnSameInspectionAreaAsMission( - string robotId, - string inspectionAreaId - ); - } - - public class LocalizationService( - ILogger logger, - IRobotService robotService, - IInstallationService installationService, - IInspectionAreaService inspectionAreaService - ) : ILocalizationService - { - public async Task EnsureRobotIsOnSameInstallationAsMission( - Robot robot, - MissionDefinition missionDefinition - ) - { - var missionInstallation = await installationService.ReadByInstallationCode( - missionDefinition.InstallationCode, - readOnly: true - ); - - if (missionInstallation is null) - { - string errorMessage = - $"Could not find installation for installation code {missionDefinition.InstallationCode}"; - logger.LogError("{Message}", errorMessage); - throw new InstallationNotFoundException(errorMessage); - } - - if (robot.CurrentInstallation.Id != missionInstallation.Id) - { - string errorMessage = - $"The robot {robot.Name} is on installation {robot.CurrentInstallation.Name} which is not the same as the mission installation {missionInstallation.Name}"; - logger.LogError("{Message}", errorMessage); - throw new RobotNotInSameInstallationAsMissionException(errorMessage); - } - } - - public async Task RobotIsOnSameInspectionAreaAsMission( - string robotId, - string inspectionAreaId - ) - { - var robot = await robotService.ReadById(robotId, readOnly: true); - if (robot is null) - { - string errorMessage = $"The robot with ID {robotId} was not found"; - logger.LogError("{Message}", errorMessage); - throw new RobotNotFoundException(errorMessage); - } - - if (robot.CurrentInspectionArea is null) - { - const string ErrorMessage = - "The robot is not associated with an inspection area and a mission may not be started"; - logger.LogError("{Message}", ErrorMessage); - throw new RobotCurrentAreaMissingException(ErrorMessage); - } - - var missionInspectionArea = await inspectionAreaService.ReadById( - inspectionAreaId, - readOnly: true - ); - if (missionInspectionArea is null) - { - const string ErrorMessage = - "The mission does not have an associated inspection area"; - logger.LogError("{Message}", ErrorMessage); - throw new InspectionAreaNotFoundException(ErrorMessage); - } - - if (robot.CurrentInspectionArea is null) - { - const string ErrorMessage = - "The robot area is not associated with any inspection area"; - logger.LogError("{Message}", ErrorMessage); - throw new InspectionAreaNotFoundException(ErrorMessage); - } - - return robot.CurrentInspectionArea.Id == missionInspectionArea.Id; - } - } -} diff --git a/backend/api/Services/MissionSchedulingService.cs b/backend/api/Services/MissionSchedulingService.cs index 6a8cf77d..e456653f 100644 --- a/backend/api/Services/MissionSchedulingService.cs +++ b/backend/api/Services/MissionSchedulingService.cs @@ -35,10 +35,10 @@ public class MissionSchedulingService( IMissionRunService missionRunService, IRobotService robotService, IIsarService isarService, - ILocalizationService localizationService, IReturnToHomeService returnToHomeService, ISignalRService signalRService, - IErrorHandlingService errorHandlingService + IErrorHandlingService errorHandlingService, + IInspectionAreaService inspectionAreaService ) : IMissionSchedulingService { public async Task StartNextMissionRunIfSystemIsAvailable(Robot robot) @@ -103,9 +103,9 @@ await robotService.UpdateCurrentInspectionArea( robot.CurrentInspectionArea = missionRun.InspectionArea!; } else if ( - !await localizationService.RobotIsOnSameInspectionAreaAsMission( - robot.Id, - missionRun.InspectionArea!.Id + !inspectionAreaService.MissionTasksAreInsideInspectionAreaPolygon( + (List)missionRun.Tasks, + missionRun.InspectionArea ) ) { diff --git a/backend/api/Services/Models/InspectionAreaPolygon.cs b/backend/api/Services/Models/InspectionAreaPolygon.cs new file mode 100644 index 00000000..15a955eb --- /dev/null +++ b/backend/api/Services/Models/InspectionAreaPolygon.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Api.Services.Models; + +public class InspectionAreaPolygon +{ + [JsonPropertyName("zmin")] + public double ZMin { get; set; } + + [JsonPropertyName("zmax")] + public double ZMax { get; set; } + + [JsonPropertyName("positions")] + public List Positions { get; set; } = []; +} + +public class XYPosition +{ + [JsonPropertyName("x")] + public double X { get; set; } + + [JsonPropertyName("y")] + public double Y { get; set; } +} diff --git a/backend/api/Utilities/Exceptions.cs b/backend/api/Utilities/Exceptions.cs index 09c12ea2..b00e3713 100644 --- a/backend/api/Utilities/Exceptions.cs +++ b/backend/api/Utilities/Exceptions.cs @@ -71,4 +71,6 @@ public class RobotCurrentAreaMissingException(string message) : Exception(messag public class UnsupportedRobotCapabilityException(string message) : Exception(message) { } public class DatabaseUpdateException(string message) : Exception(message) { } + + public class InvalidPolygonException(string message) : Exception(message) { } }