Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
552 changes: 552 additions & 0 deletions Data/Migrations/20251209203710_RemoveUnusedImageDataFields.Designer.cs

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions Data/Migrations/20251209203710_RemoveUnusedImageDataFields.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace SatOps.Data.migrations
{
/// <inheritdoc />
public partial class RemoveUnusedImageDataFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImageHeight",
table: "image_data");

migrationBuilder.DropColumn(
name: "ImageWidth",
table: "image_data");

migrationBuilder.DropColumn(
name: "Metadata",
table: "image_data");

migrationBuilder.UpdateData(
table: "ground_stations",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "ApplicationId", "CreatedAt", "UpdatedAt" },
values: new object[] { new Guid("250749f0-4728-4a87-8d94-a83df2bffe77"), new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6090), new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6090) });

migrationBuilder.UpdateData(
table: "satellites",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "CreatedAt", "LastUpdate" },
values: new object[] { new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6260), new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6260) });

migrationBuilder.UpdateData(
table: "satellites",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "CreatedAt", "LastUpdate" },
values: new object[] { new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6260), new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6260) });
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ImageHeight",
table: "image_data",
type: "integer",
nullable: true);

migrationBuilder.AddColumn<int>(
name: "ImageWidth",
table: "image_data",
type: "integer",
nullable: true);

migrationBuilder.AddColumn<string>(
name: "Metadata",
table: "image_data",
type: "jsonb",
nullable: true);

migrationBuilder.UpdateData(
table: "ground_stations",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "ApplicationId", "CreatedAt", "UpdatedAt" },
values: new object[] { new Guid("c4bd6c61-8e11-43cf-868a-fdd0a4be81b2"), new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380), new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380) });

migrationBuilder.UpdateData(
table: "satellites",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "CreatedAt", "LastUpdate" },
values: new object[] { new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710), new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710) });

migrationBuilder.UpdateData(
table: "satellites",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "CreatedAt", "LastUpdate" },
values: new object[] { new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710), new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710) });
}
}
}
23 changes: 7 additions & 16 deletions Data/Migrations/SatOpsDbContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<int>("GroundStationId")
.HasColumnType("integer");

b.Property<int?>("ImageHeight")
.HasColumnType("integer");

b.Property<int?>("ImageWidth")
.HasColumnType("integer");

b.Property<double?>("Latitude")
.HasPrecision(9, 6)
.HasColumnType("double precision");
Expand All @@ -137,9 +131,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasPrecision(9, 6)
.HasColumnType("double precision");

b.Property<string>("Metadata")
.HasColumnType("jsonb");

b.Property<DateTime>("ReceivedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
Expand Down Expand Up @@ -207,10 +198,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
Id = 1,
ApiKeyHash = "",
ApplicationId = new Guid("c4bd6c61-8e11-43cf-868a-fdd0a4be81b2"),
CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380),
ApplicationId = new Guid("250749f0-4728-4a87-8d94-a83df2bffe77"),
CreatedAt = new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6090),
Name = "Aarhus",
UpdatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380)
UpdatedAt = new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6090)
});
});

Expand Down Expand Up @@ -322,8 +313,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
new
{
Id = 1,
CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
LastUpdate = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
CreatedAt = new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6260),
LastUpdate = new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6260),
Name = "International Space Station (ISS)",
NoradId = 25544,
Status = 0,
Expand All @@ -333,8 +324,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
new
{
Id = 2,
CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
LastUpdate = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
CreatedAt = new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6260),
LastUpdate = new DateTime(2025, 12, 9, 20, 37, 9, 846, DateTimeKind.Utc).AddTicks(6260),
Name = "SENTINEL-2C",
NoradId = 60989,
Status = 0,
Expand Down
1 change: 0 additions & 1 deletion Data/SatOpsDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.Property(e => e.ReceivedAt).HasDefaultValueSql("timezone('utc', now())");
entity.Property(e => e.Latitude).HasPrecision(9, 6);
entity.Property(e => e.Longitude).HasPrecision(9, 6);
entity.Property(e => e.Metadata).HasColumnType("jsonb");

// --- Relationships ---
// Images are dependent data. Cascade deletes are appropriate.
Expand Down
145 changes: 128 additions & 17 deletions Modules/FlightPlan/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text.Json.Serialization;
using SatOps.Modules.FlightPlan.Commands;
using SatOps.Configuration;
using Microsoft.Extensions.Logging;

namespace SatOps.Modules.FlightPlan
{
Expand Down Expand Up @@ -203,11 +204,22 @@ public static (bool IsValid, List<string> Errors) ValidateAll(this List<Command>
/// Calculates execution times for commands that require it (e.g., TriggerCaptureCommand).
/// This should be called before CompileAllToCsh() for flight plans containing such commands.
/// </summary>
/// <param name="commands">The list of commands to calculate execution times for.</param>
/// <param name="satellite">The satellite to use for orbital calculations.</param>
/// <param name="imagingCalculation">The imaging calculation service.</param>
/// <param name="options">Configuration options for imaging calculations.</param>
/// <param name="blockedTimes">Optional list of blocked time windows (from other active flight plans) to avoid conflicts.</param>
/// <param name="conflictMargin">Time margin around blocked times to avoid conflicts. Defaults to 2 minutes.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
/// <param name="commandReceptionTime">Optional start time for searching. Defaults to UTC now.</param>
public static async Task CalculateExecutionTimesAsync(
this List<Command> commands,
Satellite.Satellite satellite,
IImagingCalculation imagingCalculation,
ImagingCalculationOptions options,
List<DateTime>? blockedTimes = null,
TimeSpan? conflictMargin = null,
ILogger? logger = null,
DateTime? commandReceptionTime = null)
{
if (string.IsNullOrWhiteSpace(satellite.TleLine1) || string.IsNullOrWhiteSpace(satellite.TleLine2))
Expand All @@ -218,9 +230,20 @@ public static async Task CalculateExecutionTimesAsync(
var tle = new SGPdotNET.TLE.Tle(satellite.Name, satellite.TleLine1, satellite.TleLine2);
var sgp4Satellite = new SGPdotNET.Observation.Satellite(tle);
var receptionTime = commandReceptionTime ?? DateTime.UtcNow;
var margin = conflictMargin ?? TimeSpan.FromMinutes(2);
var maxRetries = 10; // Limit retries to avoid infinite loops

logger?.LogDebug("Starting execution time calculation for {CommandCount} commands on satellite '{SatelliteName}'",
commands.Count, satellite.Name);
logger?.LogDebug("Blocked times from other flight plans: {BlockedCount}", blockedTimes?.Count ?? 0);

// Collect execution times from this flight plan's commands to avoid internal conflicts
var usedTimes = new List<DateTime>();
var commandIndex = 0;

foreach (var command in commands)
{
commandIndex++;
if (command is TriggerCaptureCommand captureCommand && captureCommand.RequiresExecutionTimeCalculation)
{
if (captureCommand.CaptureLocation == null)
Expand All @@ -229,38 +252,126 @@ public static async Task CalculateExecutionTimesAsync(
"TriggerCaptureCommand requires CaptureLocation to calculate execution time.");
}

logger?.LogDebug("Processing command {Index}/{Total}: TRIGGER_CAPTURE at ({Lat:F4}, {Lon:F4})",
commandIndex, commands.Count,
captureCommand.CaptureLocation.Latitude,
captureCommand.CaptureLocation.Longitude);

// Create target coordinate
var targetCoordinate = new SGPdotNET.CoordinateSystem.GeodeticCoordinate(
SGPdotNET.Util.Angle.FromDegrees(captureCommand.CaptureLocation.Latitude),
SGPdotNET.Util.Angle.FromDegrees(captureCommand.CaptureLocation.Longitude),
0); // Ground level


var maxSearchDuration = TimeSpan.FromHours(options.MaxSearchDurationHours);
var minOffNadirDegrees = options.MaxOffNadirDegrees;
var searchStartTime = receptionTime;
var retryCount = 0;

ImagingCalculation.ImagingOpportunity? validOpportunity = null;

var imagingOpportunity = await Task.Run(() =>
imagingCalculation.FindBestImagingOpportunity(
sgp4Satellite,
targetCoordinate,
receptionTime,
maxSearchDuration
)
);

// Check if the opportunity is within acceptable off-nadir angle
if (imagingOpportunity.OffNadirDegrees > minOffNadirDegrees)
while (validOpportunity == null && retryCount < maxRetries)
{
var imagingOpportunity = await Task.Run(() =>
imagingCalculation.FindBestImagingOpportunity(
sgp4Satellite,
targetCoordinate,
searchStartTime,
maxSearchDuration
)
);

logger?.LogDebug("Found imaging opportunity at {Time:O} with off-nadir {OffNadir:F2}° (attempt {Attempt})",
imagingOpportunity.ImagingTime, imagingOpportunity.OffNadirDegrees, retryCount + 1);

// Check if the opportunity is within acceptable off-nadir angle
if (imagingOpportunity.OffNadirDegrees > minOffNadirDegrees)
{
logger?.LogWarning("Imaging opportunity rejected: off-nadir {OffNadir:F2}° exceeds limit of {Limit}°",
imagingOpportunity.OffNadirDegrees, minOffNadirDegrees);
throw new InvalidOperationException(
$"No imaging opportunity found within the off-nadir limit of {minOffNadirDegrees} degrees. " +
$"Best opportunity found was {imagingOpportunity.OffNadirDegrees:F2} degrees off-nadir at {imagingOpportunity.ImagingTime:yyyy-MM-dd HH:mm:ss} UTC. " +
$"Consider increasing MaxOffNadirDegrees or choosing a different target location.");
}

// Check for conflicts with blocked times from other flight plans
var hasConflict = false;
DateTime? conflictTime = null;
var conflictSource = "";

if (blockedTimes != null)
{
foreach (var blockedTime in blockedTimes)
{
var timeDiff = Math.Abs((imagingOpportunity.ImagingTime - blockedTime).TotalSeconds);
if (timeDiff < margin.TotalSeconds)
{
hasConflict = true;
conflictTime = blockedTime;
conflictSource = "another flight plan";
break;
}
}
}

// Also check for conflicts with other commands in this same flight plan
if (!hasConflict)
{
foreach (var usedTime in usedTimes)
{
var timeDiff = Math.Abs((imagingOpportunity.ImagingTime - usedTime).TotalSeconds);
if (timeDiff < margin.TotalSeconds)
{
hasConflict = true;
conflictTime = usedTime;
conflictSource = "this flight plan";
break;
}
}
}

if (hasConflict && conflictTime.HasValue)
{
logger?.LogInformation(
"Conflict detected at {OpportunityTime:O} with command from {Source} at {ConflictTime:O}. " +
"Searching for next opportunity after {NewStartTime:O}",
imagingOpportunity.ImagingTime, conflictSource, conflictTime.Value,
conflictTime.Value.Add(margin));

// Move search start time past the conflict and try again
searchStartTime = conflictTime.Value.Add(margin).Add(TimeSpan.FromSeconds(1));
retryCount++;
}
else
{
validOpportunity = imagingOpportunity;
}
}

if (validOpportunity == null)
{
logger?.LogError(
"Failed to find non-conflicting imaging opportunity for target at ({Lat:F4}, {Lon:F4}) after {Retries} attempts",
captureCommand.CaptureLocation.Latitude, captureCommand.CaptureLocation.Longitude, maxRetries);
throw new InvalidOperationException(
$"No imaging opportunity found within the off-nadir limit of {minOffNadirDegrees} degrees. " +
$"Best opportunity found was {imagingOpportunity.OffNadirDegrees:F2} degrees off-nadir at {imagingOpportunity.ImagingTime:yyyy-MM-dd HH:mm:ss} UTC. " +
$"Consider increasing MaxOffNadirDegrees or choosing a different target location.");
$"Could not find a non-conflicting imaging opportunity for target at " +
$"({captureCommand.CaptureLocation.Latitude:F4}, {captureCommand.CaptureLocation.Longitude:F4}) " +
$"after {maxRetries} attempts. Consider rescheduling conflicting flight plans.");
}

// Set the calculated execution time
captureCommand.ExecutionTime = imagingOpportunity.ImagingTime;
// Set the calculated execution time and track it
captureCommand.ExecutionTime = validOpportunity.ImagingTime;
usedTimes.Add(validOpportunity.ImagingTime);

logger?.LogInformation(
"Scheduled TRIGGER_CAPTURE for ({Lat:F4}, {Lon:F4}) at {Time:O} with off-nadir {OffNadir:F2}°",
captureCommand.CaptureLocation.Latitude, captureCommand.CaptureLocation.Longitude,
validOpportunity.ImagingTime, validOpportunity.OffNadirDegrees);
}
}

logger?.LogDebug("Execution time calculation completed. Assigned {Count} execution times.", usedTimes.Count);
}

/// <summary>
Expand Down
Loading
Loading