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
12 changes: 12 additions & 0 deletions Modules/FlightPlan/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,18 @@ public static async Task CalculateExecutionTimesAsync(
)
);

if (imagingOpportunity == null)
{
// If null, the satellite never rises above the horizon for this target
// within the search duration. Retrying won't help.
logger?.LogError("No satellite pass found for target at ({Lat:F4}, {Lon:F4}) within {Duration} hours.",
captureCommand.CaptureLocation.Latitude, captureCommand.CaptureLocation.Longitude, options.MaxSearchDurationHours);

throw new InvalidOperationException(
$"No satellite pass detected for target ({captureCommand.CaptureLocation.Latitude:F4}, " +
$"{captureCommand.CaptureLocation.Longitude:F4}) within the {options.MaxSearchDurationHours}-hour search window.");
}

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

Expand Down
266 changes: 125 additions & 141 deletions Modules/FlightPlan/ImagingCalculation.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using SGPdotNET.CoordinateSystem;
using SGPdotNET.Propagation;

namespace SatOps.Modules.FlightPlan
{
public interface IImagingCalculation
{
ImagingCalculation.ImagingOpportunity FindBestImagingOpportunity(
ImagingCalculation.ImagingOpportunity? FindBestImagingOpportunity(
SGPdotNET.Observation.Satellite satellite,
GeodeticCoordinate target,
DateTime commandReceptionTime,
Expand All @@ -14,10 +15,22 @@ TimeSpan maxSearchDuration

public class ImagingCalculation(ILogger<ImagingCalculation> logger) : IImagingCalculation
{
public class Candidate
private readonly struct PassPoint
{
public DateTime Time { get; set; } = DateTime.MinValue;
public double OffNadirDegrees { get; set; } = double.MaxValue;
public DateTime Time { get; }
public double OffNadirDegrees { get; }
public double SlantRangeKm { get; }
public GeodeticCoordinate? SatelliteGeo { get; }

public PassPoint(DateTime time, double offNadir, double slantRange, GeodeticCoordinate? geo)
{
Time = time;
OffNadirDegrees = offNadir;
SlantRangeKm = slantRange;
SatelliteGeo = geo;
}

public static PassPoint Empty => new(DateTime.MinValue, double.MaxValue, 0, null);
}

public class ImagingOpportunity
Expand All @@ -32,27 +45,10 @@ public class ImagingOpportunity
}

/// <summary>
/// Core algorithm: Find the best imaging opportunity using SGP4 orbital propagation with off-nadir calculations
///
/// Algorithm Steps:
/// 1. Start from command reception time
/// 2. Use coarse time steps (120s) to find candidate windows
/// 3. Refine around candidates with fine steps (2s) for better accuracy
/// 4. Refine around candidates with finest steps (0.1s) for precision
/// 5. For each time step:
/// - Use SGP4 to calculate satellite position
/// - Convert ECI coordinates to geodetic using SGPdotNET's built-in ToGeodetic() method
/// - Calculate dynamic max distance based on altitude and off-nadir angle
/// - Calculate off-nadir angle using fast approximation: atan(groundDistance / altitude)
/// - Check if within acceptable off-nadir range
/// 6. Return the best opportunity within range
///
/// Note: All units are correctly handled by SGPdotNET library:
/// - DistanceTo() returns kilometers
/// - GeodeticCoordinate.Altitude is in kilometers
/// - Latitude/Longitude angles use .Degrees property for conversion
/// Find the best imaging opportunity using SGP4 orbital propagation.
/// Returns null if no valid opportunity is found within maxSearchDuration.
/// </summary>
public ImagingOpportunity FindBestImagingOpportunity(
public ImagingOpportunity? FindBestImagingOpportunity(
SGPdotNET.Observation.Satellite satellite,
GeodeticCoordinate target,
DateTime commandReceptionTime,
Expand All @@ -65,161 +61,149 @@ TimeSpan maxSearchDuration

var currentTime = commandReceptionTime;
var searchEndTime = commandReceptionTime.Add(maxSearchDuration);
DateTime bestTime = DateTime.Now;
var position = satellite.Predict(currentTime);

// Step 1: Coarse search to find candidate windows
var top5Candidates = new List<Candidate>();
for (int i = 0; i < 5; i++)
{
top5Candidates.Add(new Candidate());
}
var top5Candidates = new PassPoint[5];
Array.Fill(top5Candidates, PassPoint.Empty);

// Create array of exception times for logging/debugging if needed
var exceptionTimes = new List<DateTime>();
var exceptionCount = 0;

while (currentTime <= searchEndTime)
PassPoint Evaluate(DateTime t)
{
try
{
// Get satellite position at current time using SGP4
position = satellite.Predict(currentTime);
var satEci = satellite.Predict(t);

var offNadirDeg = OffNadirDegrees(target, position, currentTime);

var worstCandidate = top5Candidates.MaxBy(c => c.OffNadirDegrees);
var targetEci = target.ToEci(t);

// Check if the current time step is better than our worst candidate.
if (worstCandidate != null && offNadirDeg < worstCandidate.OffNadirDegrees)
{
// Replace the worst candidate's values.
worstCandidate.Time = currentTime;
worstCandidate.OffNadirDegrees = offNadirDeg;
}
}
catch (Exception)
{
exceptionTimes.Add(currentTime);
}
// Create Range Vector (Target Position - Sat Position)
var rangeVector = targetEci.Position - satEci.Position;

currentTime = currentTime.Add(coarseTimeStep);
}
var slantRangeKm = rangeVector.Length;

// Step 2: Refinement around best candidates
for (int i = 0; i < top5Candidates.Count; i++)
{
var bestOffNadir = top5Candidates[i].OffNadirDegrees;
var refineStart = top5Candidates[i].Time.Subtract(coarseTimeStep);
var refineEnd = top5Candidates[i].Time.Add(coarseTimeStep);
currentTime = refineStart;
while (currentTime <= refineEnd)
{
try
{
position = satellite.Predict(currentTime);
// Calculate Off-Nadir Angle
// Use Dot Product formula: A . B = |A| * |B| * cos(angle)

var offNadirDeg = OffNadirDegrees(target, position, currentTime);
// Vector A: Satellite Position (Vector from Earth Center -> Satellite)
// Vector B: Range Vector (Vector from Satellite -> Target)
var satPosVector = satEci.Position;

if (offNadirDeg < bestOffNadir)
{
bestTime = currentTime;
bestOffNadir = offNadirDeg;
}
}
catch (Exception)
{
// Skip this time step if calculation fails
exceptionTimes.Add(currentTime);
}
// Calculate the Dot Product
var dot = satPosVector.Dot(rangeVector);

// Calculate magnitudes
var magSat = satPosVector.Length;
var magRange = rangeVector.Length; // same as slantRangeKm

// Calculate Cosine of the angle
// "UP" is SatPos and "DOWN" is Range.
var cosTheta = dot / (magSat * magRange);

currentTime = currentTime.Add(refiningTimeStep);
// Clamp for floating point safety
if (cosTheta > 1.0) cosTheta = 1.0;
if (cosTheta < -1.0) cosTheta = -1.0;

// Acos gives radians.
// Since the vectors point in opposite general directions,
// The Off-Nadir angle is PI (180 deg) minus the calculated angle.
var angleRadians = Math.Acos(cosTheta);
var offNadirRadians = Math.PI - angleRadians;
var offNadirDeg = offNadirRadians * (180.0 / Math.PI);

// Horizon Check
if (!target.CanSee(satEci))
{
return PassPoint.Empty;
}

top5Candidates[i].Time = bestTime;
top5Candidates[i].OffNadirDegrees = bestOffNadir;
return new PassPoint(t, offNadirDeg, slantRangeKm, satEci.ToGeodetic());
}

// Step 3: Final refinement around best time found
for (int i = 0; i < top5Candidates.Count; i++)
// Step 1: Coarse Search (120s step)
while (currentTime <= searchEndTime)
{
var bestOffNadir = top5Candidates[i].OffNadirDegrees;
var refineStart = top5Candidates[i].Time.Subtract(refiningTimeStep);
var refineEnd = top5Candidates[i].Time.Add(refiningTimeStep);
currentTime = refineStart;
while (currentTime <= refineEnd)
var currentPoint = Evaluate(currentTime);
if (currentPoint.Time != DateTime.MinValue)
{
try
// Basic eviction policy: Keep the top 5 lowest Off-Nadir angles
int worstIndex = -1;
double worstOffNadir = double.MinValue;
for (int i = 0; i < top5Candidates.Length; i++)
{
position = satellite.Predict(currentTime);

var offNadirDeg = OffNadirDegrees(target, position, currentTime);

if (offNadirDeg < bestOffNadir)
if (top5Candidates[i].OffNadirDegrees > worstOffNadir)
{
bestTime = currentTime;
bestOffNadir = offNadirDeg;
worstOffNadir = top5Candidates[i].OffNadirDegrees;
worstIndex = i;
}
}
catch (Exception)
{
// Skip this time step if calculation fails
exceptionTimes.Add(currentTime);
}

currentTime = currentTime.Add(finalTimeStep);
if (worstIndex != -1 && currentPoint.OffNadirDegrees < worstOffNadir)
top5Candidates[worstIndex] = currentPoint;
}
currentTime = currentTime.Add(coarseTimeStep);
}

top5Candidates[i].Time = bestTime;
top5Candidates[i].OffNadirDegrees = bestOffNadir;
// Step 2: Refinement (2s step)
for (int i = 0; i < top5Candidates.Length; i++)
{
if (top5Candidates[i].Time == DateTime.MinValue) continue;
var bestLocal = top5Candidates[i];
var start = bestLocal.Time.Subtract(coarseTimeStep);
var end = bestLocal.Time.Add(coarseTimeStep);
var t = start;
while (t <= end)
{
var p = Evaluate(t);
if (p.Time != DateTime.MinValue && p.OffNadirDegrees < bestLocal.OffNadirDegrees)
bestLocal = p;
t = t.Add(refiningTimeStep);
}
top5Candidates[i] = bestLocal;
}

// Log the exception times if needed for debugging
if (exceptionTimes.Count > 0)
// Step 3: Final Refinement (0.1s step)
for (int i = 0; i < top5Candidates.Length; i++)
{
logger.LogWarning("Exceptions occurred during SGP4 propagation at {ExceptionCount} time steps.", exceptionTimes.Count);
if (top5Candidates[i].Time == DateTime.MinValue) continue;
var bestLocal = top5Candidates[i];
var start = bestLocal.Time.Subtract(refiningTimeStep);
var end = bestLocal.Time.Add(refiningTimeStep);
var t = start;
while (t <= end)
{
var p = Evaluate(t);
if (p.Time != DateTime.MinValue && p.OffNadirDegrees < bestLocal.OffNadirDegrees)
bestLocal = p;
t = t.Add(finalTimeStep);
}
top5Candidates[i] = bestLocal;
}

var bestCandidate = top5Candidates.OrderBy(c => c.OffNadirDegrees).First();
if (exceptionCount > 0)
logger.LogWarning("Exceptions occurred during SGP4 propagation at {ExceptionCount} steps.", exceptionCount);

position = satellite.Predict(bestCandidate.Time);
var eciCoordinate = new EciCoordinate(bestCandidate.Time, position.Position, position.Velocity);
var geodetic = eciCoordinate.ToGeodetic();
var satelliteCoordinate = new GeodeticCoordinate(
geodetic.Latitude,
geodetic.Longitude,
geodetic.Altitude);
// Select absolute best
var bestCandidate = top5Candidates
.Where(c => c.Time != DateTime.MinValue)
.OrderBy(c => c.OffNadirDegrees)
.FirstOrDefault();

var finalGeo = bestCandidate.SatelliteGeo;
if (bestCandidate.Time == DateTime.MinValue || finalGeo == null)
{
return null;
}

var groundDistanceKm = target.DistanceTo(satelliteCoordinate);
var slantRangeKm = Math.Sqrt(groundDistanceKm * groundDistanceKm + geodetic.Altitude * geodetic.Altitude);
// Arc length = Radius * Angle(radians)
var groundDistanceKm = SgpConstants.EarthRadiusKm * target.AngleTo(finalGeo).Radians;

return new ImagingOpportunity
{
ImagingTime = bestCandidate.Time,
DistanceKm = groundDistanceKm,
OffNadirDegrees = bestCandidate.OffNadirDegrees,
SlantRangeKm = slantRangeKm,
SatelliteLatitude = geodetic.Latitude.Degrees,
SatelliteLongitude = geodetic.Longitude.Degrees,
SatelliteAltitudeKm = geodetic.Altitude
SlantRangeKm = bestCandidate.SlantRangeKm,
SatelliteLatitude = finalGeo.Latitude.Degrees,
SatelliteLongitude = finalGeo.Longitude.Degrees,
SatelliteAltitudeKm = finalGeo.Altitude
};
}

private double OffNadirDegrees(GeodeticCoordinate target, EciCoordinate satellite, DateTime currentTime)
{
// Create EciCoordinate from the prediction result and convert to geodetic using SGPdotNET's built-in method
var eciCoordinate = new EciCoordinate(currentTime, satellite.Position, satellite.Velocity);
var geodetic = eciCoordinate.ToGeodetic();

// Calculate distance between satellite ground track and target using SGPdotNET's built-in method
var satelliteCoordinate = new GeodeticCoordinate(
geodetic.Latitude,
geodetic.Longitude,
geodetic.Altitude);
var groundDistanceKm = target.DistanceTo(satelliteCoordinate);

var offNadirRad = Math.Atan(groundDistanceKm / geodetic.Altitude);

return offNadirRad * 180.0 / Math.PI;
}
}
}
14 changes: 14 additions & 0 deletions Modules/FlightPlan/Service.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,20 @@ public async Task<ImagingTimingResponseDto> GetImagingOpportunity(int satelliteI
maxSearchDuration
);

if (imagingOpportunity == null)
{
return new ImagingTimingResponseDto
{
Possible = false,
Message = $"No satellite pass found over target ({targetLatitude:F4}, {targetLongitude:F4}) within the next {imagingOptions.Value.MaxSearchDurationHours} hours.",
TleAgeWarning = tleAge.TotalHours > 48,
TleAgeHours = tleAge.TotalHours,
ImagingTime = DateTime.MinValue,
OffNadirDegrees = 0,
SatelliteAltitudeKm = 0
};
}

var result = new ImagingTimingResponseDto
{
Possible = true,
Expand Down
Loading
Loading