diff --git a/Modules/FlightPlan/Command.cs b/Modules/FlightPlan/Command.cs index f97ab45..8e9ce42 100644 --- a/Modules/FlightPlan/Command.cs +++ b/Modules/FlightPlan/Command.cs @@ -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); diff --git a/Modules/FlightPlan/ImagingCalculation.cs b/Modules/FlightPlan/ImagingCalculation.cs index 381a3ef..21124d3 100644 --- a/Modules/FlightPlan/ImagingCalculation.cs +++ b/Modules/FlightPlan/ImagingCalculation.cs @@ -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, @@ -14,10 +15,22 @@ TimeSpan maxSearchDuration public class ImagingCalculation(ILogger 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 @@ -32,27 +45,10 @@ public class ImagingOpportunity } /// - /// 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. /// - public ImagingOpportunity FindBestImagingOpportunity( + public ImagingOpportunity? FindBestImagingOpportunity( SGPdotNET.Observation.Satellite satellite, GeodeticCoordinate target, DateTime commandReceptionTime, @@ -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(); - 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(); + 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; - } } } \ No newline at end of file diff --git a/Modules/FlightPlan/Service.cs b/Modules/FlightPlan/Service.cs index 9b70808..5542b6f 100644 --- a/Modules/FlightPlan/Service.cs +++ b/Modules/FlightPlan/Service.cs @@ -353,6 +353,20 @@ public async Task 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, diff --git a/Modules/Overpass/Repository.cs b/Modules/Overpass/Repository.cs index 6620c48..0921102 100644 --- a/Modules/Overpass/Repository.cs +++ b/Modules/Overpass/Repository.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using SatOps.Data; + namespace SatOps.Modules.Overpass { public interface IOverpassRepository @@ -80,17 +81,19 @@ public async Task> GetByTimeRangeAsync(int satelliteId, int groundS public async Task FindOverpassInTimeWindowAsync(int satelliteId, int groundStationId, DateTime startTime, DateTime endTime, int toleranceMinutes) { - // Find any existing overpass (assigned or not) that overlaps with the requested time window - // This prevents creating duplicate overpass records for the same physical satellite pass - // Uses tolerance to account for TLE data variations between calculations + // Pre-calculate boundaries in C# + var minStart = startTime.AddMinutes(-toleranceMinutes); + var maxStart = startTime.AddMinutes(toleranceMinutes); + var minEnd = endTime.AddMinutes(-toleranceMinutes); + var maxEnd = endTime.AddMinutes(toleranceMinutes); return await dbContext.Overpasses .Include(o => o.FlightPlan) .Where(o => o.SatelliteId == satelliteId && o.GroundStationId == groundStationId && - Math.Abs((o.StartTime - startTime).TotalMinutes) <= toleranceMinutes && - Math.Abs((o.EndTime - endTime).TotalMinutes) <= toleranceMinutes) - .OrderBy(o => Math.Abs((o.StartTime - startTime).TotalMinutes) + Math.Abs((o.EndTime - endTime).TotalMinutes)) + o.StartTime >= minStart && o.StartTime <= maxStart && + o.EndTime >= minEnd && o.EndTime <= maxEnd) + .OrderBy(o => o.StartTime) .FirstOrDefaultAsync(); } diff --git a/Modules/Overpass/Service.cs b/Modules/Overpass/Service.cs index e4c33d7..4d5c492 100644 --- a/Modules/Overpass/Service.cs +++ b/Modules/Overpass/Service.cs @@ -25,130 +25,209 @@ public async Task> CalculateOverpassesAsync(OverpassWind { try { - // First, check if we have stored overpasses in the requested time range + // 1. Load Stored Data var storedOverpasses = await overpassRepository.FindStoredOverpassesInTimeRange( request.SatelliteId, request.GroundStationId, request.StartTime, request.EndTime); - // Get satellite data for calculations and names var satellite = await satelliteService.GetAsync(request.SatelliteId); - if (satellite == null) - { - throw new ArgumentException($"Satellite with ID {request.SatelliteId} not found."); - } + if (satellite == null) throw new ArgumentException($"Satellite {request.SatelliteId} not found."); - // Get ground station data var groundStationEntity = await groundStationService.GetAsync(request.GroundStationId); - if (groundStationEntity == null) - { - throw new ArgumentException($"Ground station with ID {request.GroundStationId} not found."); - } + if (groundStationEntity == null) throw new ArgumentException($"Ground station {request.GroundStationId} not found."); if (string.IsNullOrEmpty(satellite.TleLine1) || string.IsNullOrEmpty(satellite.TleLine2)) - { throw new InvalidOperationException("Satellite TLE data is not available."); - } - - var tle1 = satellite.Name; - var tle2 = satellite.TleLine1; - var tle3 = satellite.TleLine2; - - var tle = new Tle(tle1, tle2, tle3); + // 2. Initialize SGP4 + var tle = new Tle(satellite.Name, satellite.TleLine1, satellite.TleLine2); var sat = new SGPdotNET.Observation.Satellite(tle); - var location = new GeodeticCoordinate( Angle.FromDegrees(groundStationEntity.Location.Latitude), Angle.FromDegrees(groundStationEntity.Location.Longitude), - groundStationEntity.Location.Altitude); // Assuming sea level for stored ground stations - + groundStationEntity.Location.Altitude); var groundStation = new SGPdotNET.Observation.GroundStation(location); + var overpassWindows = new List(); - var timeStep = TimeSpan.FromMinutes(1); // Check every minute - var currentTime = request.StartTime; - var inOverpass = false; - var overpassStart = DateTime.MinValue; - var maxElevation = 0.0; - var maxElevationTime = DateTime.MinValue; - var startAzimuth = 0.0; - while (currentTime <= request.EndTime) + // 3. Define Time Steps + var coarseStep = TimeSpan.FromSeconds(60); + var fineStep = TimeSpan.FromSeconds(1); // For detecting AOS/LOS edges + var precisionStep = TimeSpan.FromSeconds(0.1); // For detecting exact Max Elevation + + // Local function to get observation data at a specific time + (double Elevation, double Azimuth) GetObservation(DateTime t) { - var observation = groundStation.Observe(sat, currentTime); - var elevation = observation.Elevation.Degrees; - var azimuth = observation.Azimuth.Degrees; + var obs = groundStation.Observe(sat, t); + return (obs.Elevation.Degrees, obs.Azimuth.Degrees); + } - if (!inOverpass && elevation >= request.MinimumElevation) + // Local function: Given a coarse interval where we crossed the threshold, find the exact moment + DateTime RefineBoundaryTime(DateTime startWindow, DateTime endWindow, double threshold, bool rising) + { + var t = startWindow; + while (t <= endWindow) { - // Starting an overpass - inOverpass = true; - overpassStart = currentTime; - maxElevation = elevation; - maxElevationTime = currentTime; - startAzimuth = azimuth; + var (ele, _) = GetObservation(t); + // If rising (AOS), we want first point >= threshold + // If falling (LOS), we want first point < threshold (so the previous second was the end) + if (rising && ele >= threshold) return t; + if (!rising && ele < threshold) return t.Subtract(fineStep); + + t = t.Add(fineStep); } - else if (inOverpass && elevation >= request.MinimumElevation) + return rising ? endWindow : startWindow; + } + + // Local function: Given a rough peak time, find the exact peak using steps + (DateTime Time, double Elevation, double Azimuth) RefinePeak(DateTime roughPeakTime) + { + // Scan +/- 90 seconds around rough peak with 1s step + var bestTime = roughPeakTime; + var (maxEle, bestAz) = GetObservation(roughPeakTime); + + var startSearch = roughPeakTime.AddSeconds(-90); + var endSearch = roughPeakTime.AddSeconds(90); + + // Pass 1: Fine Step (1s) + for (var t = startSearch; t <= endSearch; t = t.Add(fineStep)) { - // Continuing overpass, check if this is the maximum elevation - if (elevation > maxElevation) + var (ele, az) = GetObservation(t); + if (ele > maxEle) { - maxElevation = elevation; - maxElevationTime = currentTime; + maxEle = ele; + bestTime = t; + bestAz = az; } } - else if (inOverpass && elevation <= request.MinimumElevation) - { - // Ending an overpass - inOverpass = false; - var durationSeconds = (int)(currentTime - overpassStart).TotalSeconds - 60; // Subtract the last minute where it went below minimum elevation - if (request.MinimumDurationSeconds.HasValue && durationSeconds < request.MinimumDurationSeconds.Value) + // Pass 2: Precision Step (0.1s) - scan +/- 2 seconds around the new best + var startPrecise = bestTime.AddSeconds(-2); + var endPrecise = bestTime.AddSeconds(2); + + for (var t = startPrecise; t <= endPrecise; t = t.Add(precisionStep)) + { + var (ele, az) = GetObservation(t); + if (ele > maxEle) { - // Skip this overpass as it doesn't meet the minimum duration - currentTime = currentTime.Add(timeStep); - continue; + maxEle = ele; + bestTime = t; + bestAz = az; } + } - overpassWindows.Add(new OverpassWindowDto + return (bestTime, maxEle, bestAz); + } + + // 4. Main Calculation Loop + var currentTime = request.StartTime; + + // Track state + bool isInsidePass = false; + DateTime potentialAosStart = DateTime.MinValue; + DateTime roughMaxTime = DateTime.MinValue; + double roughMaxEle = -999.0; + double startAzimuth = 0; + + // Pre-calculate previous point to detect transitions + var (prevEle, _) = GetObservation(currentTime); + + while (currentTime <= request.EndTime) + { + var nextTime = currentTime.Add(coarseStep); + var (currEle, currAz) = GetObservation(nextTime); + + // --- Check for AOS (Rising Edge) --- + // Scenario: We were below limit, now we are above (or equal) + if (!isInsidePass && currEle >= request.MinimumElevation) + { + // We crossed the threshold somewhere between currentTime and nextTime. + // 1. Refine the exact start time + var preciseStart = RefineBoundaryTime(currentTime, nextTime, request.MinimumElevation, true); + var (_, preciseStartAz) = GetObservation(preciseStart); + + isInsidePass = true; + potentialAosStart = preciseStart; + startAzimuth = preciseStartAz; + + // Initialize rough max + roughMaxEle = currEle; + roughMaxTime = nextTime; + } + // --- While Inside Pass --- + else if (isInsidePass) + { + // Track rough max + if (currEle > roughMaxEle) { - SatelliteId = satellite.Id, - SatelliteName = satellite.Name, - GroundStationId = groundStationEntity.Id, - GroundStationName = groundStationEntity.Name, - StartTime = overpassStart, - EndTime = currentTime, - MaxElevationTime = maxElevationTime, - MaxElevation = maxElevation, - DurationSeconds = durationSeconds, - StartAzimuth = startAzimuth, - EndAzimuth = azimuth - }); - - // Check if we have reached the maximum number of results - if (request.MaxResults.HasValue && overpassWindows.Count >= request.MaxResults.Value) + roughMaxEle = currEle; + roughMaxTime = nextTime; + } + + // --- Check for LOS (Falling Edge) --- + // Scenario: We dropped below limit (or reached end of search window) + bool fallingEdge = currEle < request.MinimumElevation; + bool endOfWindow = nextTime >= request.EndTime; + + if (fallingEdge || endOfWindow) { - break; + // 2. Refine the exact end time + // If it's a falling edge, the boundary is between current and next. + // If it's end of window, we just clamp to nextTime. + var preciseEnd = fallingEdge + ? RefineBoundaryTime(currentTime, nextTime, request.MinimumElevation, false) + : nextTime; + + var (_, preciseEndAz) = GetObservation(preciseEnd); + + // 3. Calculate Duration + var durationSeconds = (preciseEnd - potentialAosStart).TotalSeconds; + + // 4. Validate Duration + if (!request.MinimumDurationSeconds.HasValue || durationSeconds >= request.MinimumDurationSeconds.Value) + { + // 5. Find Exact Peak (TCA) + // We use the roughMaxTime found during the loop as the seed + var (preciseMaxTime, preciseMaxEle, _) = RefinePeak(roughMaxTime); + + overpassWindows.Add(new OverpassWindowDto + { + SatelliteId = satellite.Id, + SatelliteName = satellite.Name, + GroundStationId = groundStationEntity.Id, + GroundStationName = groundStationEntity.Name, + StartTime = potentialAosStart, + EndTime = preciseEnd, + MaxElevationTime = preciseMaxTime, + MaxElevation = preciseMaxEle, + DurationSeconds = durationSeconds, + StartAzimuth = startAzimuth, + EndAzimuth = preciseEndAz + }); + } + + // Reset state + isInsidePass = false; + roughMaxEle = -999.0; + roughMaxTime = DateTime.MinValue; + + if (request.MaxResults.HasValue && overpassWindows.Count >= request.MaxResults.Value) + { + break; + } } } - currentTime = currentTime.Add(timeStep); + prevEle = currEle; + currentTime = nextTime; } - // Merge calculated overpasses with stored overpasses and enrich with flight plan data - var mergedOverpasses = await MergeAndEnrichOverpasses(overpassWindows, storedOverpasses); - - return mergedOverpasses; - } - catch (ArgumentException) - { - throw; // Re-throw ArgumentException to be handled by the controller - } - catch (InvalidOperationException) - { - throw; // Re-throw InvalidOperationException to be handled by the controller + return await MergeAndEnrichOverpasses(overpassWindows, storedOverpasses); } + catch (ArgumentException) { throw; } + catch (InvalidOperationException) { throw; } catch (Exception ex) { throw new InvalidOperationException($"Error calculating overpasses: {ex.Message}", ex); @@ -168,7 +247,6 @@ public async Task> CalculateOverpassesAsync(OverpassWind string? tleLine2 = null, DateTime? tleUpdateTime = null) { - // Check if there's already an overpass in this time window var existingOverpass = await overpassRepository.FindOverpassInTimeWindowAsync( overpassWindow.SatelliteId, overpassWindow.GroundStationId, @@ -181,7 +259,7 @@ public async Task> CalculateOverpassesAsync(OverpassWind { return (false, null, $"An overpass is already assigned to flight plan '{existingOverpass.FlightPlan?.Name ?? "Unknown"}' (ID: {existingOverpass.FlightPlanId}) " + - $"in this time window. Each satellite pass can only be assigned to one flight plan."); + $"in this time window."); } var overpassEntity = new Entity @@ -210,13 +288,9 @@ private async Task> MergeAndEnrichOverpasses( List storedOverpasses) { var result = calculatedOverpasses; - - // For each calculated overpass, check if we already have a stored one foreach (var calculatedOverpass in result) { - var toleranceMinutes = 10; // Allow 10-minute tolerance for merging - - // Check if this calculated overpass is already in the database + var toleranceMinutes = 10; var storedOverpass = storedOverpasses.FirstOrDefault(co => co.SatelliteId == calculatedOverpass.SatelliteId && co.GroundStationId == calculatedOverpass.GroundStationId && @@ -240,8 +314,6 @@ private async Task> MergeAndEnrichOverpasses( } } } - - // Sort by start time return result.OrderBy(o => o.StartTime).ToList(); } } diff --git a/tests/SatOps.Tests/FlightPlan/ImagingCalculationTests.cs b/tests/SatOps.Tests/FlightPlan/ImagingCalculationTests.cs new file mode 100644 index 0000000..21bb6d4 --- /dev/null +++ b/tests/SatOps.Tests/FlightPlan/ImagingCalculationTests.cs @@ -0,0 +1,180 @@ +using Xunit; +using FluentAssertions; +using Moq; +using Microsoft.Extensions.Logging; +using SatOps.Modules.FlightPlan; +using SGPdotNET.CoordinateSystem; +using SGPdotNET.TLE; +using SGPdotNET.Propagation; + +namespace SatOps.Tests.FlightPlan +{ + public class ImagingCalculationTests + { + private readonly ImagingCalculation _sut; + private readonly Mock> _loggerMock; + + // A static, frozen TLE for the ISS allows for deterministic testing. + // Epoch: 2023-09-13 12:00:00 UTC + private const string TleLine1 = "1 25544U 98067A 23256.50000000 .00016717 00000+0 10270-3 0 9002"; + private const string TleLine2 = "2 25544 51.6416 118.9708 0004523 127.3888 322.4932 15.50202292415516"; + private readonly DateTime _epoch; + private readonly SGPdotNET.Observation.Satellite _satellite; + + public ImagingCalculationTests() + { + _loggerMock = new Mock>(); + _sut = new ImagingCalculation(_loggerMock.Object); + + var tle = new Tle("ISS", TleLine1, TleLine2); + _satellite = new SGPdotNET.Observation.Satellite(tle); + _epoch = tle.Epoch; + } + + [Fact] + public void FindBestImagingOpportunity_WhenTargetIsDirectlyBeneathSatellite_ReturnsZeroOffNadir() + { + // Arrange + // Predict where the satellite is exactly 10 minutes after epoch + var targetTime = _epoch.AddMinutes(10); + var satPosEci = _satellite.Predict(targetTime); + var satPosGeo = satPosEci.ToGeodetic(); + + // Place our target exactly at that lat/lon on the ground + var target = new GeodeticCoordinate(satPosGeo.Latitude, satPosGeo.Longitude, 0); + + // Search in a window that encompasses this time + var searchStart = targetTime.AddMinutes(-5); + var duration = TimeSpan.FromMinutes(10); + + // Act + var result = _sut.FindBestImagingOpportunity(_satellite, target, searchStart, duration); + + // Assert + result.Should().NotBeNull(); + + // The algorithm should converge exactly on the time we predicted + result!.ImagingTime.Should().BeCloseTo(targetTime, precision: TimeSpan.FromSeconds(0.5)); + + // Math Check: If target is directly underneath, Off-Nadir should be very low (though the earth rotates) + result.OffNadirDegrees.Should().BeApproximately(0, 0.2); + + // Math Check: Distance should be ~0 + result.DistanceKm.Should().BeApproximately(0, 1.0); // Within 1km + + // Math Check: Slant Range should equal Satellite Altitude (approx 415-420km for ISS) + result.SlantRangeKm.Should().BeApproximately(satPosGeo.Altitude, 1.0); + result.SatelliteAltitudeKm.Should().BeApproximately(satPosGeo.Altitude, 0.01); + } + + [Fact] + public void FindBestImagingOpportunity_WhenTargetIsOffsetByKnownDistance_ReturnsConsistentGeometry() + { + // Arrange + var targetTime = _epoch.AddMinutes(10); + var satPosEci = _satellite.Predict(targetTime); + var satPosGeo = satPosEci.ToGeodetic(); + + // Place target 1 degree latitude North of the satellite. + // Note: The satellite is moving on an inclined orbit (51.6°). Even though we place + // the target 1° North of the *snapshot* position, the satellite's path might + // bring it closer than 111km (or further) at the optimal imaging time. + var targetLat = satPosGeo.Latitude.Degrees + 1.0; + var target = new GeodeticCoordinate( + SGPdotNET.Util.Angle.FromDegrees(targetLat), + satPosGeo.Longitude, + 0); + + var searchStart = targetTime.AddMinutes(-2); + var duration = TimeSpan.FromMinutes(4); + + // Act + var result = _sut.FindBestImagingOpportunity(_satellite, target, searchStart, duration); + + // Assert + result.Should().NotBeNull(); + + // Verify reasonable distance. 1 deg lat is ~111km. + // The optimization algorithm will find the "Cross Track" distance, which + // should be <= the snapshot distance. + result!.DistanceKm.Should().BeGreaterThan(20); + result.DistanceKm.Should().BeLessThan(120); + + // Verify Geometric Consistency (Law of Sines check) + // Formula: Re / sin(eta) = d / sin(theta) + // Cross multiply: Re * sin(theta) = d * sin(eta) + // Re = Earth Radius, d = Slant Range + // theta = Central Angle (Ground distance in radians) + // eta = Off-Nadir Angle + + double Re = SgpConstants.EarthRadiusKm; + double groundAngleRad = result.DistanceKm / Re; // theta + double offNadirRad = result.OffNadirDegrees * Math.PI / 180.0; // eta + + double lhs = Re * Math.Sin(groundAngleRad); // Re * sin(theta) + double rhs = result.SlantRangeKm * Math.Sin(offNadirRad); // d * sin(eta) + + // Allow small epsilon for floating point variance and iterative precision + lhs.Should().BeApproximately(rhs, 3.0, + $"Law of Sines must hold: Re*sin(Ground)={lhs:F2} vs Slant*sin(OffNadir)={rhs:F2}"); + + // Verify Slant Range is logical (Hypotenuse > Altitude) + result.SlantRangeKm.Should().BeGreaterThan(result.SatelliteAltitudeKm); + } + + [Fact] + public void FindBestImagingOpportunity_WhenSatelliteNeverRises_ReturnsNull() + { + // Arrange + // Predict where sat is + var satPosEci = _satellite.Predict(_epoch); + var satPosGeo = satPosEci.ToGeodetic(); + + // Place target on the EXACT OPPOSITE side of the Earth (Antipode) + // If sat is at Lat 51, Target at -51. If Lon is 118, Target is 118-180. + var targetLat = -satPosGeo.Latitude.Degrees; + var targetLon = satPosGeo.Longitude.Degrees > 0 + ? satPosGeo.Longitude.Degrees - 180 + : satPosGeo.Longitude.Degrees + 180; + + var target = new GeodeticCoordinate( + SGPdotNET.Util.Angle.FromDegrees(targetLat), + SGPdotNET.Util.Angle.FromDegrees(targetLon), + 0); + + // Search a small window where we know the sat is on the other side + var duration = TimeSpan.FromMinutes(10); + + // Act + var result = _sut.FindBestImagingOpportunity(_satellite, target, _epoch, duration); + + // Assert + result.Should().BeNull("Satellite is on opposite side of Earth (occluded)"); + } + + [Fact] + public void FindBestImagingOpportunity_SearchConvergence_FindsPeakWithinWindow() + { + // Arrange + // We want to verify the algorithm iterates (Coarse -> Fine -> Finest). + // We pick a time where the pass happens exactly in the middle of a 10 minute window. + var peakTime = _epoch.AddMinutes(30); + var satPos = _satellite.Predict(peakTime).ToGeodetic(); + var target = new GeodeticCoordinate(satPos.Latitude, satPos.Longitude, 0); + + // Start searching 5 minutes BEFORE the peak + var searchStart = peakTime.AddMinutes(-5); + var duration = TimeSpan.FromMinutes(10); + + // Act + var result = _sut.FindBestImagingOpportunity(_satellite, target, searchStart, duration); + + // Assert + result.Should().NotBeNull(); + // The logic implies: Coarse steps (120s) -> Find window -> Refine (2s) -> Refine (0.1s). + // The result should be extremely close to the actual moment the satellite is overhead. + result!.ImagingTime.Should().BeCloseTo(peakTime, precision: TimeSpan.FromSeconds(0.2)); + result.OffNadirDegrees.Should().BeLessThan(0.1); + } + } +} \ No newline at end of file diff --git a/tests/SatOps.Tests/Overpass/OverpassServiceTests.cs b/tests/SatOps.Tests/Overpass/OverpassServiceTests.cs index f0cabd4..b0e7dab 100644 --- a/tests/SatOps.Tests/Overpass/OverpassServiceTests.cs +++ b/tests/SatOps.Tests/Overpass/OverpassServiceTests.cs @@ -6,7 +6,6 @@ using SatOps.Modules.Groundstation; using SatelliteEntity = SatOps.Modules.Satellite.Satellite; - namespace SatOps.Tests { public class OverpassServiceTests @@ -15,7 +14,6 @@ public class OverpassServiceTests private readonly Mock _mockSatelliteService; private readonly Mock _mockGroundStationService; private readonly Mock _mockOverpassRepository; - private const double Tolerance = 0.0001; public OverpassServiceTests() { @@ -33,38 +31,29 @@ public OverpassServiceTests() [Fact] public async Task GetStoredOverpassAsync_ShouldReturnEntity_WhenIdExists() { - // Arrange var existingId = 1; var expectedOverpass = new Entity { Id = existingId }; _mockOverpassRepository.Setup(repo => repo.GetByIdReadOnlyAsync(existingId)) .ReturnsAsync(expectedOverpass); - // Act var result = await _overpassService.GetStoredOverpassAsync(existingId); - - // Assert result.Should().BeEquivalentTo(expectedOverpass); } [Fact] public async Task GetStoredOverpassAsync_ShouldReturnNull_WhenIdDoesNotExist() { - // Arrange var nonExistingId = 999; _mockOverpassRepository.Setup(repo => repo.GetByIdReadOnlyAsync(nonExistingId)) .ReturnsAsync((Entity?)null); - // Act var result = await _overpassService.GetStoredOverpassAsync(nonExistingId); - - // Assert result.Should().BeNull(); } [Fact] public async Task FindOrCreateOverpassForFlightPlanAsync_ShouldCreateNewOverpass_WhenNoneExists() { - // Arrange var overpassWindowDto = new OverpassWindowDto { SatelliteId = 1, @@ -86,25 +75,21 @@ public async Task FindOrCreateOverpassForFlightPlanAsync_ShouldCreateNewOverpass _mockOverpassRepository.Setup(repo => repo.AddAsync(It.IsAny())) .ReturnsAsync((Entity e) => e); - // Act var (success, result, message) = await _overpassService.FindOrCreateOverpassForFlightPlanAsync( overpassWindowDto, flightPlanId, toleranceMinutes ); - // Assert success.Should().BeTrue(); result.Should().NotBeNull(); result!.FlightPlanId.Should().Be(flightPlanId); message.Should().Be("Overpass created and assigned successfully."); - _mockOverpassRepository.Verify(repo => repo.AddAsync(It.IsAny()), Times.Once); } [Fact] public async Task FindOrCreateOverpassForFlightPlanAsync_ShouldFail_WhenOverpassAlreadyExists() { - // Arrange var overpassWindowDto = new OverpassWindowDto { SatelliteId = 1, @@ -114,7 +99,6 @@ public async Task FindOrCreateOverpassForFlightPlanAsync_ShouldFail_WhenOverpass }; var flightPlanId = 101; var toleranceMinutes = 15; - var existingFlightPlan = new Modules.FlightPlan.FlightPlan { Id = 99, Name = "Existing Plan" }; var existingOverpass = new Entity { @@ -127,51 +111,40 @@ public async Task FindOrCreateOverpassForFlightPlanAsync_ShouldFail_WhenOverpass It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() )).ReturnsAsync(existingOverpass); - // Act var (success, result, message) = await _overpassService.FindOrCreateOverpassForFlightPlanAsync( overpassWindowDto, flightPlanId, toleranceMinutes ); - // Assert success.Should().BeFalse(); result.Should().BeNull(); message.Should().Contain("An overpass is already assigned to flight plan 'Existing Plan'"); - _mockOverpassRepository.Verify(repo => repo.AddAsync(It.IsAny()), Times.Never); } [Fact] public async Task CalculateOverpassesAsync_ShouldThrowArgumentException_WhenSatelliteNotFound() { - // Arrange var requestDto = new OverpassWindowsCalculationRequestDto { SatelliteId = 1, GroundStationId = 1 }; - _mockOverpassRepository.Setup(repo => repo.FindStoredOverpassesInTimeRange( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() )).ReturnsAsync(new List()); - _mockSatelliteService.Setup(s => s.GetAsync(requestDto.SatelliteId)).ReturnsAsync((SatelliteEntity?)null); - // Act & Assert await Assert.ThrowsAsync(() => _overpassService.CalculateOverpassesAsync(requestDto)); } [Fact] public async Task CalculateOverpassesAsync_ShouldThrowArgumentException_WhenGroundstationNotFound() { - // Arrange var requestDto = new OverpassWindowsCalculationRequestDto { SatelliteId = 1, GroundStationId = 1 }; var satellite = new SatelliteEntity { Id = requestDto.SatelliteId, Name = "TestSat" }; - _mockOverpassRepository.Setup(repo => repo.FindStoredOverpassesInTimeRange( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() )).ReturnsAsync(new List()); - _mockSatelliteService.Setup(s => s.GetAsync(requestDto.SatelliteId)).ReturnsAsync(satellite); _mockGroundStationService.Setup(s => s.GetAsync(requestDto.GroundStationId)).ReturnsAsync((GroundStation?)null); - // Act & Assert await Assert.ThrowsAsync(() => _overpassService.CalculateOverpassesAsync(requestDto)); } @@ -179,7 +152,6 @@ public async Task CalculateOverpassesAsync_ShouldThrowArgumentException_WhenGrou [MemberData(nameof(TleMissingData))] public async Task CalculateOverpassesAsync_ShouldThrowInvalidOperationException_WhenTLEDataIsMissing(string tleLine1, string tleLine2) { - // Arrange var requestDto = new OverpassWindowsCalculationRequestDto { SatelliteId = 1, GroundStationId = 1 }; var satellite = new SatelliteEntity { Id = 1, Name = "TestSat", TleLine1 = tleLine1, TleLine2 = tleLine2 }; var groundStation = new GroundStation { Id = 1, Name = "TestGS" }; @@ -187,13 +159,12 @@ public async Task CalculateOverpassesAsync_ShouldThrowInvalidOperationException_ _mockOverpassRepository.Setup(repo => repo.FindStoredOverpassesInTimeRange( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() )).ReturnsAsync(new List()); - _mockSatelliteService.Setup(s => s.GetAsync(1)).ReturnsAsync(satellite); _mockGroundStationService.Setup(s => s.GetAsync(1)).ReturnsAsync(groundStation); - // Act & Assert await Assert.ThrowsAsync(() => _overpassService.CalculateOverpassesAsync(requestDto)); } + [Fact] public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValidRequest() { @@ -205,24 +176,10 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValid StartTime = new DateTime(2025, 10, 27, 12, 0, 0, DateTimeKind.Utc), EndTime = new DateTime(2025, 10, 28, 12, 0, 0, DateTimeKind.Utc), }; - var satellite = new SatelliteEntity - { - Id = requestDto.SatelliteId, - Name = "TestSat", - TleLine1 = "1 25544U 98067A 21275.12345678 .00001234 00000-0 12345-6 0 9999", - TleLine2 = "2 25544 51.6432 348.7416 0007417 85.4084 274.7553 15.49112339210616", - }; - var groundStation = new GroundStation - { - Id = requestDto.GroundStationId, - Name = "TestGS", - Location = new Location - { - Latitude = 40.0, - Longitude = -74.0, - Altitude = 0.0 - } - }; + var satellite = GetTestSatellite(requestDto.SatelliteId); + var groundStation = GetTestGroundStation(requestDto.GroundStationId); + + var expectedOverpasses = new List { new() @@ -231,13 +188,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValid SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 540.0, - StartAzimuth = 193.56243709717364, - EndAzimuth = 64.04225383927546, - MaxElevation = 17.92577512997181, - StartTime = new DateTime(2025, 10, 27, 16, 46, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 16, 56, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 16, 50, 0, DateTimeKind.Utc), + DurationSeconds = 589.0, + StartAzimuth = 195.99849292826318, + EndAzimuth = 66.78450563781013, + MaxElevation = 18.31918531001896, + StartTime = new DateTime(2025, 10, 27, 16, 45, 31, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 16, 55, 20, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 16, 50, 25, 400, DateTimeKind.Utc), }, new() { @@ -245,13 +202,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValid SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 600.0, - StartAzimuth = 245.44239459556508, - EndAzimuth = 50.80675380708287, - MaxElevation = 48.66778766406786, - StartTime = new DateTime(2025, 10, 27, 18, 22, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 18, 33, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 18, 27, 0, DateTimeKind.Utc), + DurationSeconds = 649.0, + StartAzimuth = 244.60454080200276, + EndAzimuth = 49.76445647311203, + MaxElevation = 48.68301609025784, + StartTime = new DateTime(2025, 10, 27, 18, 21, 34, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 18, 32, 23, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 18, 26, 58, 400, DateTimeKind.Utc), }, new() { @@ -259,13 +216,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValid SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 540.0, - StartAzimuth = 286.7926496754286, - EndAzimuth = 51.7566488837168, - MaxElevation = 14.256324958843932, - StartTime = new DateTime(2025, 10, 27, 20, 0, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 20, 10, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 20, 4, 0, DateTimeKind.Utc), + DurationSeconds = 574.0, + StartAzimuth = 284.5047675922203, + EndAzimuth = 47.6105104621574, + MaxElevation = 14.492096705347953, + StartTime = new DateTime(2025, 10, 27, 19, 59, 37, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 20, 09, 11, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 20, 04, 24, 100, DateTimeKind.Utc), }, new() { @@ -273,13 +230,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValid SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 480.0, - StartAzimuth = 310.72385802357894, - EndAzimuth = 65.14455142181063, - MaxElevation = 11.942598228372539, - StartTime = new DateTime(2025, 10, 27, 21, 38, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 21, 47, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 21, 42, 0, DateTimeKind.Utc), + DurationSeconds = 550.0, + StartAzimuth = 308.99681433704643, + EndAzimuth = 64.52301104322532, + MaxElevation = 12.066939299307071, + StartTime = new DateTime(2025, 10, 27, 21, 37, 44, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 21, 46, 54, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 21, 42, 19, 800, DateTimeKind.Utc), }, new() { @@ -287,13 +244,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValid SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 600.0, - StartAzimuth = 313.5257429866042, - EndAzimuth = 103.0272058127901, + DurationSeconds = 626.0, + StartAzimuth = 312.67944176345304, + EndAzimuth = 100.5411623701663, MaxElevation = 27.593081105655784, - StartTime = new DateTime(2025, 10, 27, 23, 15, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 23, 26, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 23, 20, 0, DateTimeKind.Utc), + StartTime = new DateTime(2025, 10, 27, 23, 14, 46, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 23, 25, 12, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 23, 20, 0, 100, DateTimeKind.Utc), }, new() { @@ -301,13 +258,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValid SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 600.0, - StartAzimuth = 299.9138934245744, - EndAzimuth = 144.4620350778898, - MaxElevation = 37.78527400884674, - StartTime = new DateTime(2025, 10, 28, 0, 52, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 28, 1, 3, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 28, 0, 57, 0, DateTimeKind.Utc), + DurationSeconds = 633.0, + StartAzimuth = 300.9333400301767, + EndAzimuth = 146.18250543340628, + MaxElevation = 38.00350685637695, + StartTime = new DateTime(2025, 10, 28, 0, 51, 34, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 28, 1, 02, 07, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 28, 0, 56, 51, 700, DateTimeKind.Utc), }, new() { @@ -315,15 +272,16 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WhenValid SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 240.0, - StartAzimuth = 258.4486832681079, - EndAzimuth = 203.98277826626062, - MaxElevation = 1.9340954318687928, - StartTime = new DateTime(2025, 10, 28, 2, 31, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 28, 2, 36, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 28, 2, 33, 0, DateTimeKind.Utc), + DurationSeconds = 273.0, + StartAzimuth = 263.01011714326654, + EndAzimuth = 212.277304528398, + MaxElevation = 1.947414159709326, + StartTime = new DateTime(2025, 10, 28, 2, 30, 33, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 28, 2, 35, 06, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 28, 2, 32, 49, 200, DateTimeKind.Utc), } }; + _mockOverpassRepository.Setup(repo => repo.FindStoredOverpassesInTimeRange( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() )).ReturnsAsync(new List()); @@ -353,24 +311,9 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMinim EndTime = new DateTime(2025, 10, 28, 12, 0, 0, DateTimeKind.Utc), MinimumElevation = 20 }; - var satellite = new SatelliteEntity - { - Id = requestDto.SatelliteId, - Name = "TestSat", - TleLine1 = "1 25544U 98067A 21275.12345678 .00001234 00000-0 12345-6 0 9999", - TleLine2 = "2 25544 51.6432 348.7416 0007417 85.4084 274.7553 15.49112339210616", - }; - var groundStation = new GroundStation - { - Id = requestDto.GroundStationId, - Name = "TestGS", - Location = new Location - { - Latitude = 40.0, - Longitude = -74.0, - Altitude = 0.0 - } - }; + var satellite = GetTestSatellite(requestDto.SatelliteId); + var groundStation = GetTestGroundStation(requestDto.GroundStationId); + var expectedOverpasses = new List { new() @@ -379,13 +322,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMinim SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 240.0, - StartAzimuth = 259.41504317273916, - EndAzimuth = 42.55275439816543, - MaxElevation = 48.66778766406786, - StartTime = new DateTime(2025, 10, 27, 18, 25, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 18, 30, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 18, 27, 0, DateTimeKind.Utc), + DurationSeconds = 246.0, + StartAzimuth = 258.5628312283407, + EndAzimuth = 35.549444013450035, + MaxElevation = 48.68301609025784, + StartTime = new DateTime(2025, 10, 27, 18, 24, 55, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 18, 29, 01, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 18, 26, 58, 400, DateTimeKind.Utc), }, new() { @@ -393,13 +336,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMinim SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 120.0, - StartAzimuth = 355.30215840543156, - EndAzimuth = 77.4871552132818, + DurationSeconds = 174.0, + StartAzimuth = 345.1307007494323, + EndAzimuth = 68.11540811049373, MaxElevation = 27.593081105655784, - StartTime = new DateTime(2025, 10, 27, 23, 19, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 23, 22, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 23, 20, 0, DateTimeKind.Utc), + StartTime = new DateTime(2025, 10, 27, 23, 18, 33, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 23, 21, 27, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 23, 20, 00, 100, DateTimeKind.Utc), }, new() { @@ -407,34 +350,26 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMinim SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 120.0, - StartAzimuth = 260.37950007403276, - EndAzimuth = 162.050781306894, - MaxElevation = 37.78527400884674, - StartTime = new DateTime(2025, 10, 28, 0, 56, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 28, 0, 59, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 28, 0, 57, 0, DateTimeKind.Utc), + DurationSeconds = 221.0, + StartAzimuth = 281.5068101253377, + EndAzimuth = 165.80440850256264, + MaxElevation = 38.00350685637695, + StartTime = new DateTime(2025, 10, 28, 0, 55, 01, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 28, 0, 58, 42, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 28, 0, 56, 51, 700, DateTimeKind.Utc), } }; + _mockOverpassRepository.Setup(repo => repo.FindStoredOverpassesInTimeRange( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() )).ReturnsAsync([]); - _mockSatelliteService.Setup(s => s.GetAsync(requestDto.SatelliteId)) - .ReturnsAsync(satellite); - _mockGroundStationService.Setup(s => s.GetAsync(requestDto.GroundStationId)) - .ReturnsAsync(groundStation); + _mockSatelliteService.Setup(s => s.GetAsync(requestDto.SatelliteId)).ReturnsAsync(satellite); + _mockGroundStationService.Setup(s => s.GetAsync(requestDto.GroundStationId)).ReturnsAsync(groundStation); - // Act var result = await _overpassService.CalculateOverpassesAsync(requestDto); - // Assert result.Should().BeEquivalentTo(expectedOverpasses, options => - options.Using(ctx => - ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.0001) - ).WhenTypeIs() + options.Using(ctx => ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.0001)).WhenTypeIs() ); } @@ -450,24 +385,10 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMaxRe EndTime = new DateTime(2025, 10, 28, 12, 0, 0, DateTimeKind.Utc), MaxResults = 2 }; - var satellite = new SatelliteEntity - { - Id = requestDto.SatelliteId, - Name = "TestSat", - TleLine1 = "1 25544U 98067A 21275.12345678 .00001234 00000-0 12345-6 0 9999", - TleLine2 = "2 25544 51.6432 348.7416 0007417 85.4084 274.7553 15.49112339210616", - }; - var groundStation = new GroundStation - { - Id = requestDto.GroundStationId, - Name = "TestGS", - Location = new Location - { - Latitude = 40.0, - Longitude = -74.0, - Altitude = 0.0 - } - }; + var satellite = GetTestSatellite(requestDto.SatelliteId); + var groundStation = GetTestGroundStation(requestDto.GroundStationId); + + // Matches the first two results of ValidRequest var expectedOverpasses = new List { new() @@ -476,13 +397,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMaxRe SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 540.0, - StartAzimuth = 193.56243709717364, - EndAzimuth = 64.04225383927546, - MaxElevation = 17.92577512997181, - StartTime = new DateTime(2025, 10, 27, 16, 46, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 16, 56, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 16, 50, 0, DateTimeKind.Utc), + DurationSeconds = 589.0, + StartAzimuth = 195.99849292826318, + EndAzimuth = 66.78450563781013, + MaxElevation = 18.31918531001896, + StartTime = new DateTime(2025, 10, 27, 16, 45, 31, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 16, 55, 20, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 16, 50, 25, 400, DateTimeKind.Utc), }, new() { @@ -490,34 +411,26 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMaxRe SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 600.0, - StartAzimuth = 245.44239459556508, - EndAzimuth = 50.80675380708287, - MaxElevation = 48.66778766406786, - StartTime = new DateTime(2025, 10, 27, 18, 22, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 18, 33, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 18, 27, 0, DateTimeKind.Utc), + DurationSeconds = 649.0, + StartAzimuth = 244.60454080200276, + EndAzimuth = 49.76445647311203, + MaxElevation = 48.68301609025784, + StartTime = new DateTime(2025, 10, 27, 18, 21, 34, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 18, 32, 23, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 18, 26, 58, 400, DateTimeKind.Utc), } }; + _mockOverpassRepository.Setup(repo => repo.FindStoredOverpassesInTimeRange( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() )).ReturnsAsync([]); - _mockSatelliteService.Setup(s => s.GetAsync(requestDto.SatelliteId)) - .ReturnsAsync(satellite); - _mockGroundStationService.Setup(s => s.GetAsync(requestDto.GroundStationId)) - .ReturnsAsync(groundStation); + _mockSatelliteService.Setup(s => s.GetAsync(requestDto.SatelliteId)).ReturnsAsync(satellite); + _mockGroundStationService.Setup(s => s.GetAsync(requestDto.GroundStationId)).ReturnsAsync(groundStation); - // Act var result = await _overpassService.CalculateOverpassesAsync(requestDto); - // Assert result.Should().BeEquivalentTo(expectedOverpasses, options => - options.Using(ctx => - ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.0001) - ).WhenTypeIs() + options.Using(ctx => ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.0001)).WhenTypeIs() ); } @@ -533,24 +446,10 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMinim EndTime = new DateTime(2025, 10, 28, 12, 0, 0, DateTimeKind.Utc), MinimumDurationSeconds = 600 }; - var satellite = new SatelliteEntity - { - Id = requestDto.SatelliteId, - Name = "TestSat", - TleLine1 = "1 25544U 98067A 21275.12345678 .00001234 00000-0 12345-6 0 9999", - TleLine2 = "2 25544 51.6432 348.7416 0007417 85.4084 274.7553 15.49112339210616", - }; - var groundStation = new GroundStation - { - Id = requestDto.GroundStationId, - Name = "TestGS", - Location = new Location - { - Latitude = 40.0, - Longitude = -74.0, - Altitude = 0.0 - } - }; + var satellite = GetTestSatellite(requestDto.SatelliteId); + var groundStation = GetTestGroundStation(requestDto.GroundStationId); + + var expectedOverpasses = new List { new() @@ -559,13 +458,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMinim SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 600.0, - StartAzimuth = 245.44239459556508, - EndAzimuth = 50.80675380708287, - MaxElevation = 48.66778766406786, - StartTime = new DateTime(2025, 10, 27, 18, 22, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 18, 33, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 18, 27, 0, DateTimeKind.Utc), + DurationSeconds = 649.0, + StartAzimuth = 244.60454080200276, + EndAzimuth = 49.76445647311203, + MaxElevation = 48.68301609025784, + StartTime = new DateTime(2025, 10, 27, 18, 21, 34, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 18, 32, 23, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 18, 26, 58, 400, DateTimeKind.Utc), }, new() { @@ -573,13 +472,13 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMinim SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 600.0, - StartAzimuth = 313.5257429866042, - EndAzimuth = 103.0272058127901, + DurationSeconds = 626.0, + StartAzimuth = 312.67944176345304, + EndAzimuth = 100.5411623701663, MaxElevation = 27.593081105655784, - StartTime = new DateTime(2025, 10, 27, 23, 15, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 27, 23, 26, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 27, 23, 20, 0, DateTimeKind.Utc), + StartTime = new DateTime(2025, 10, 27, 23, 14, 46, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 27, 23, 25, 12, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 27, 23, 20, 0, 100, DateTimeKind.Utc), }, new() { @@ -587,44 +486,53 @@ public async Task CalculateOverpassesAsync_ReturnsCalculatedOverpasses_WithMinim SatelliteName = "TestSat", GroundStationId = requestDto.GroundStationId, GroundStationName = "TestGS", - DurationSeconds = 600.0, - StartAzimuth = 299.9138934245744, - EndAzimuth = 144.4620350778898, - MaxElevation = 37.78527400884674, - StartTime = new DateTime(2025, 10, 28, 0, 52, 0, DateTimeKind.Utc), - EndTime = new DateTime(2025, 10, 28, 1, 3, 0, DateTimeKind.Utc), - MaxElevationTime = new DateTime(2025, 10, 28, 0, 57, 0, DateTimeKind.Utc), + DurationSeconds = 633.0, + StartAzimuth = 300.9333400301767, + EndAzimuth = 146.18250543340628, + MaxElevation = 38.00350685637695, + StartTime = new DateTime(2025, 10, 28, 0, 51, 34, DateTimeKind.Utc), + EndTime = new DateTime(2025, 10, 28, 1, 02, 07, DateTimeKind.Utc), + MaxElevationTime = new DateTime(2025, 10, 28, 0, 56, 51, 700, DateTimeKind.Utc), } }; + _mockOverpassRepository.Setup(repo => repo.FindStoredOverpassesInTimeRange( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() )).ReturnsAsync([]); - _mockSatelliteService.Setup(s => s.GetAsync(requestDto.SatelliteId)) - .ReturnsAsync(satellite); - _mockGroundStationService.Setup(s => s.GetAsync(requestDto.GroundStationId)) - .ReturnsAsync(groundStation); + _mockSatelliteService.Setup(s => s.GetAsync(requestDto.SatelliteId)).ReturnsAsync(satellite); + _mockGroundStationService.Setup(s => s.GetAsync(requestDto.GroundStationId)).ReturnsAsync(groundStation); - // Act var result = await _overpassService.CalculateOverpassesAsync(requestDto); - // Assert result.Should().BeEquivalentTo(expectedOverpasses, options => - options.Using(ctx => - ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.0001) - ).WhenTypeIs() + options.Using(ctx => ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.0001)).WhenTypeIs() ); } - public static IEnumerable TleMissingData => - new List + // Helpers + + private static SatelliteEntity GetTestSatellite(int id) => new() { - new object?[] { "", "validTleLine2" }, - new object?[] { "validTleLine1", "" }, - new object?[] { null, "validTleLine2" }, - new object?[] { "validTleLine1", null } + Id = id, + Name = "TestSat", + TleLine1 = "1 25544U 98067A 21275.12345678 .00001234 00000-0 12345-6 0 9999", + TleLine2 = "2 25544 51.6432 348.7416 0007417 85.4084 274.7553 15.49112339210616", }; + + private static GroundStation GetTestGroundStation(int id) => new() + { + Id = id, + Name = "TestGS", + Location = new Location { Latitude = 40.0, Longitude = -74.0, Altitude = 0.0 } + }; + + public static IEnumerable TleMissingData => + new List + { + new object?[] { "", "validTleLine2" }, + new object?[] { "validTleLine1", "" }, + new object?[] { null, "validTleLine2" }, + new object?[] { "validTleLine1", null } + }; } } \ No newline at end of file