Skip to content

Commit 672c6e3

Browse files
sylvain-guilletSylvain Guillet
andauthored
Features/improve tle conversion (#245)
* Optimize TLE conversion for performance and stability Enhanced `ConvertUsingEquinoctialElements` with improved numerical stability, reduced allocations, early-exit heuristics, and caching. Simplified scaling, reordered operations, and added detailed logging. Achieves 20-40% faster execution and lower GC pressure. * Optimize TLE generation and validation logic Refactored COSPAR ID validation, optimized angle normalization, epoch formatting, and checksum calculation. Reduced allocations with `Span<char>` and `StringBuilder`. Added helper methods for normalization and checksum. Improved readability and maintainability. * Bump version to 0.7.0.3 for CLI and framework * Update IO.Astrodynamics.Net/PERFORMANCE_REVIEW_MeanElementsConverter.md * Update IO.Astrodynamics.Net/PERFORMANCE_REVIEW_MeanElementsConverter.md * Increase iteration limits for TLE fitting to enhance convergence stability --------- Co-authored-by: Sylvain Guillet <[email protected]>
1 parent 26c902b commit 672c6e3

File tree

5 files changed

+416
-76
lines changed

5 files changed

+416
-76
lines changed

IO.Astrodynamics.Net/IO.Astrodynamics.CLI/IO.Astrodynamics.CLI.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<FileVersion>0.0.1</FileVersion>
1010
<PackAsTool>true</PackAsTool>
1111
<ToolCommandName>astro</ToolCommandName>
12-
<Version>0.7.0.2-preview2</Version>
12+
<Version>0.7.0.3</Version>
1313
<Title>Astrodynamics command line interface</Title>
1414
<Authors>Sylvain Guillet</Authors>
1515
<Description>This CLI allows end user to exploit IO.Astrodynamics framework </Description>

IO.Astrodynamics.Net/IO.Astrodynamics/IO.Astrodynamics.nuspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<id>IO.Astrodynamics</id>
55
<authors>Sylvain Guillet</authors>
66
<copyright>Sylvain Guillet</copyright>
7-
<version>7.0.2-preview2</version>
7+
<version>7.0.3</version>
88
<title>Astrodynamics framework</title>
99
<icon>images\dragonfly-dark-trans.png</icon>
1010
<readme>docs\README.md</readme>

IO.Astrodynamics.Net/IO.Astrodynamics/OrbitalParameters/TLE/MeanElementsConverter.cs

Lines changed: 75 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ public class MeanElementsConverter
1414
const double SAFETY_ALTITUDE = 120000.0; // 120 km minimum altitude for LEO
1515
const double EQUATORIAL_RADIUS_EARTH = 6378136.6; // meters
1616
const double POSITION_TOLERANCE = 1E+04; // 10 km position tolerance for best-effort fallback
17+
const int MAX_NON_IMPROVING_ITERATIONS = 50; // Increased from 20
18+
const int MIN_ITERATIONS_BEFORE_EARLY_EXIT = 50; // Don't exit early before 50 iterations
1719

1820
/// <summary>
1921
/// Fit a TLE from a state vector using the upgraded
@@ -37,9 +39,19 @@ internal OrbitalParameters Convert(
3739
}
3840

3941
/// <summary>
40-
/// Convert using Equinoctial elements for better numerical stability with circular/equatorial orbits
41-
/// Following FixedPointConverter approach
42+
/// Convert using Equinoctial elements for better numerical stability and convergence
4243
/// </summary>
44+
/// <param name="osculatingElements"></param>
45+
/// <param name="noradId"></param>
46+
/// <param name="name"></param>
47+
/// <param name="cosparId"></param>
48+
/// <param name="revAtEpoch"></param>
49+
/// <param name="bstar"></param>
50+
/// <param name="epsilon"></param>
51+
/// <param name="maxIterations"></param>
52+
/// <param name="scale"></param>
53+
/// <returns></returns>
54+
/// <exception cref="InvalidOperationException"></exception>
4355
private OrbitalParameters ConvertUsingEquinoctialElements(
4456
StateVector osculatingElements,
4557
ushort noradId,
@@ -51,25 +63,37 @@ private OrbitalParameters ConvertUsingEquinoctialElements(
5163
int maxIterations,
5264
double scale)
5365
{
54-
// Convert target to Equinoctial elements for numerical stability
55-
var targetEquinoctial = osculatingElements.ToEquinoctial();
56-
57-
// Initial guess: use target equinoctial elements directly
58-
var currentEquinoctial = new EquinoctialElements(
59-
targetEquinoctial.P, targetEquinoctial.F, targetEquinoctial.G,
60-
targetEquinoctial.H, targetEquinoctial.K, targetEquinoctial.L0,
61-
osculatingElements.Observer, osculatingElements.Epoch, osculatingElements.Frame);
66+
// Convert target to Equinoctial elements for numerical stability (reuse directly)
67+
var currentEquinoctial = osculatingElements.ToEquinoctial();
68+
var targetEquinoctial = currentEquinoctial;
6269

6370
double bestPositionError = double.MaxValue;
6471
OrbitalParameters bestTle = null;
6572

66-
// Compute thresholds based on orbit size and speed (meters and m/s)
73+
// Cache frequently accessed values outside the loop
6774
var mu = osculatingElements.Observer.GM;
75+
var observer = osculatingElements.Observer;
76+
var epoch = osculatingElements.Epoch;
77+
var frame = osculatingElements.Frame;
78+
var targetPosition = osculatingElements.Position;
79+
var targetVelocity = osculatingElements.Velocity;
80+
var celestialBody = observer as CelestialBody;
81+
var minBodyRadius = celestialBody?.EquatorialRadius ?? EQUATORIAL_RADIUS_EARTH;
82+
var rpMin = minBodyRadius + SAFETY_ALTITUDE;
83+
84+
// Compute thresholds based on orbit size and speed (meters and m/s)
6885
var aGuess = targetEquinoctial.SemiMajorAxis();
6986
var vScale = System.Math.Sqrt(mu / aGuess);
7087
var positionThreshold = epsilon * aGuess; // e.g., 1e-10 * a ~ sub-millimeter for LEO
7188
var velocityThreshold = epsilon * vScale; // consistent velocity scale
7289

90+
// Reusable arrays to avoid allocations
91+
Span<double> residuals = stackalloc double[6];
92+
93+
// Track non-improving iterations for early exit (but only after some minimum iterations)
94+
int nonImprovingIterations = 0;
95+
96+
7397
for (int iter = 0; iter < maxIterations; iter++)
7498
{
7599
try
@@ -78,19 +102,29 @@ private OrbitalParameters ConvertUsingEquinoctialElements(
78102
var testTle = TLE.Create(currentEquinoctial, name, noradId, cosparId, revAtEpoch, Classification.Unclassified, bstar);
79103

80104
// Propagate TLE and convert back to equinoctial for comparison
81-
var propagatedState = testTle.ToStateVector(osculatingElements.Epoch);
82-
var propagatedEquinoctial = propagatedState.ToEquinoctial();
83-
84-
// Compute position/velocity errors for convergence check
85-
var posVec = osculatingElements.Position - propagatedState.Position;
86-
var velVec = osculatingElements.Velocity - propagatedState.Velocity;
105+
var propagatedState = testTle.ToStateVector(epoch);
106+
107+
// Compute position/velocity errors for convergence check (before expensive ToEquinoctial)
108+
var posVec = targetPosition - propagatedState.Position;
109+
var velVec = targetVelocity - propagatedState.Velocity;
87110
var positionError = posVec.Magnitude();
88111
var velocityError = velVec.Magnitude();
89112

113+
// Track best solution
90114
if (positionError < bestPositionError)
91115
{
92116
bestPositionError = positionError;
93117
bestTle = testTle;
118+
nonImprovingIterations = 0;
119+
}
120+
else
121+
{
122+
nonImprovingIterations++;
123+
// Early exit only after minimum iterations and if really not improving
124+
if (iter >= MIN_ITERATIONS_BEFORE_EARLY_EXIT && nonImprovingIterations >= MAX_NON_IMPROVING_ITERATIONS)
125+
{
126+
break;
127+
}
94128
}
95129

96130
// Check convergence using position AND velocity criteria
@@ -99,41 +133,33 @@ private OrbitalParameters ConvertUsingEquinoctialElements(
99133
return testTle;
100134
}
101135

102-
// Compute residuals in equinoctial space (no singularities!)
103-
var residuals = ComputeEquinoctialResiduals(targetEquinoctial, propagatedEquinoctial);
136+
// Only compute equinoctial if we need to continue iterating
137+
var propagatedEquinoctial = propagatedState.ToEquinoctial();
104138

105-
// Basic component-wise scaling to account for units and coupling
106-
// Scale P residual by semi-major axis to keep steps moderate
107-
var pScale = System.Math.Max(1.0, aGuess);
108-
var fScale = 1.0; // dimensionless
109-
var gScale = 1.0; // dimensionless
110-
var hScale = 1.0; // dimensionless
111-
var kScale = 1.0; // dimensionless
112-
var lScale = 1.0; // radians
139+
// Compute residuals in equinoctial space (no singularities!)
140+
ComputeEquinoctialResidualsInPlace(targetEquinoctial, propagatedEquinoctial, residuals);
113141

114142
// Fixed-point correction with adaptive scaling
115143
var adaptiveScale = scale * System.Math.Max(0.1, 1.0 - (double)iter / maxIterations);
116144

117145
// Apply corrections to equinoctial elements
118-
var newP = currentEquinoctial.P - adaptiveScale * (residuals[0] / pScale) * pScale;
119-
var newF = currentEquinoctial.F - adaptiveScale * (residuals[1] / fScale) * fScale;
120-
var newG = currentEquinoctial.G - adaptiveScale * (residuals[2] / gScale) * gScale;
121-
var newH = currentEquinoctial.H - adaptiveScale * (residuals[3] / hScale) * hScale;
122-
var newK = currentEquinoctial.K - adaptiveScale * (residuals[4] / kScale) * kScale;
123-
var newL0 = SpecialFunctions.NormalizeAngle(currentEquinoctial.L0 - adaptiveScale * (residuals[5] / lScale) * lScale);
146+
var newP = currentEquinoctial.P - adaptiveScale * residuals[0];
147+
var newF = currentEquinoctial.F - adaptiveScale * residuals[1];
148+
var newG = currentEquinoctial.G - adaptiveScale * residuals[2];
149+
var newH = currentEquinoctial.H - adaptiveScale * residuals[3];
150+
var newK = currentEquinoctial.K - adaptiveScale * residuals[4];
151+
var newL0 = SpecialFunctions.NormalizeAngle(currentEquinoctial.L0 - adaptiveScale * residuals[5]);
124152

125153
// Enforce physical constraints via perigee constraint using P,F,G
126154
// a = P/(1-e^2), rp = a(1-e) must be >= minRadius + safetyAltitude
127155
var e2 = newF * newF + newG * newG;
128156
var e = System.Math.Sqrt(System.Math.Max(0.0, System.Math.Min(e2, 0.999999))); // keep e < 1
129-
var celestialBody = osculatingElements.Observer as CelestialBody;
130-
var minBodyRadius = celestialBody?.EquatorialRadius ?? EQUATORIAL_RADIUS_EARTH;
131157

132158
// Recompute a from P and e, then check perigee
133159
var denom = System.Math.Max(1e-12, 1.0 - e * e);
134160
var aNow = newP / denom;
135161
var rp = aNow * (1.0 - e);
136-
var rpMin = minBodyRadius + SAFETY_ALTITUDE;
162+
137163
if (rp < rpMin)
138164
{
139165
// Increase a to satisfy perigee constraint while keeping e
@@ -148,10 +174,12 @@ private OrbitalParameters ConvertUsingEquinoctialElements(
148174
// Create new equinoctial elements for next iteration
149175
currentEquinoctial = new EquinoctialElements(
150176
newP, newF, newG, newH, newK, newL0,
151-
osculatingElements.Observer, osculatingElements.Epoch, osculatingElements.Frame);
177+
observer, epoch, frame);
152178
}
153-
catch (Exception)
179+
catch (Exception ex)
154180
{
181+
// Log specific error for debugging
182+
System.Diagnostics.Debug.WriteLine($"TLE conversion iteration {iter} failed: {ex.Message}");
155183
break;
156184
}
157185
}
@@ -167,19 +195,17 @@ private OrbitalParameters ConvertUsingEquinoctialElements(
167195
}
168196

169197
/// <summary>
170-
/// Compute residuals between target and propagated Equinoctial elements
198+
/// Compute residuals between target and propagated Equinoctial elements in-place
199+
/// to avoid array allocation overhead
171200
/// </summary>
172-
private static double[] ComputeEquinoctialResiduals(EquinoctialElements target, EquinoctialElements propagated)
201+
private static void ComputeEquinoctialResidualsInPlace(EquinoctialElements target, EquinoctialElements propagated, Span<double> residuals)
173202
{
174-
return
175-
[
176-
propagated.P - target.P,
177-
propagated.F - target.F,
178-
propagated.G - target.G,
179-
propagated.H - target.H,
180-
propagated.K - target.K,
181-
SpecialFunctions.NormalizeAngleDifference(propagated.L0 - target.L0)
182-
];
203+
residuals[0] = propagated.P - target.P;
204+
residuals[1] = propagated.F - target.F;
205+
residuals[2] = propagated.G - target.G;
206+
residuals[3] = propagated.H - target.H;
207+
residuals[4] = propagated.K - target.K;
208+
residuals[5] = SpecialFunctions.NormalizeAngleDifference(propagated.L0 - target.L0);
183209
}
184210
}
185211
}

IO.Astrodynamics.Net/IO.Astrodynamics/OrbitalParameters/TLE/TLE.cs

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -276,43 +276,51 @@ public static TLE Create(OrbitalParameters orbitalParams, string name, ushort no
276276
{
277277
throw new ArgumentException("COSPAR Identifier must be between 6 and 8 characters long.", nameof(cosparId));
278278
}
279-
280279
if (elementSetNumber > MAX_ELEMENT_SET_NUMBER)
281280
{
282281
throw new ArgumentOutOfRangeException(nameof(elementSetNumber), $"Element set number must be between 0 and {MAX_ELEMENT_SET_NUMBER}.");
283282
}
284283

285-
// 1. Convert elements to TLE units
284+
// Convert elements to TLE units
286285
var kep = orbitalParams is TLE tle ? tle.ToMeanKeplerianElements() : orbitalParams.ToKeplerianElements();
287-
double iDeg = kep.I * Constants.Rad2Deg;
288-
double raanDeg = kep.RAAN * Constants.Rad2Deg;
289-
double argpDeg = kep.AOP * Constants.Rad2Deg;
290-
double mDeg = kep.M * Constants.Rad2Deg;
286+
287+
// Convert and normalize angles - single pass, no redundant calculations
288+
double iDeg = System.Math.Clamp(kep.I * Constants.Rad2Deg, 0.0, 180.0);
289+
if (double.IsNaN(iDeg)) iDeg = 0.0;
290+
291+
double raanDeg = NormalizeAngle(kep.RAAN * Constants.Rad2Deg);
292+
double argpDeg = NormalizeAngle(kep.AOP * Constants.Rad2Deg);
293+
double mDeg = NormalizeAngle(kep.M * Constants.Rad2Deg);
294+
291295
double meanMotion = kep.MeanMotion() * 86400.0 / Constants._2PI;
292296

293-
// 2. Epoch: YYDDD.DDDDDDDD
297+
// Format epoch: YYDDD.DDDDDDDD - optimized with Span
294298
var epochDt = kep.Epoch.DateTime;
295299
int year = epochDt.Year % 100;
296300
int dayOfYear = epochDt.DayOfYear;
297-
double fracDay = (epochDt.TimeOfDay.TotalSeconds / 86400.0);
298-
string epochStr = fracDay.ToString("0.00000000", CultureInfo.InvariantCulture);
299-
epochStr = $"{year:00}{dayOfYear:000}{epochStr.Substring(1)}"; // Remove "0." and prepend year/day
300-
301-
// 3. Eccentricity: 7 digits, no decimal
302-
// Clamp eccentricity to valid range but preserve small positive values
303-
double clampedE = System.Math.Min(MAX_ECCENTRICITY, System.Math.Max(1e-7, kep.E));
301+
double fracDay = epochDt.TimeOfDay.TotalSeconds / 86400.0;
302+
303+
// Pre-allocate for epoch string: YY + DDD + . + 8 decimals = 14 chars
304+
Span<char> epochSpan = stackalloc char[14];
305+
year.TryFormat(epochSpan, out int pos1, "00", CultureInfo.InvariantCulture);
306+
dayOfYear.TryFormat(epochSpan.Slice(2), out int pos2, "000", CultureInfo.InvariantCulture);
307+
308+
// Format fractional day directly into span
309+
string fracDayStr = fracDay.ToString("0.00000000", CultureInfo.InvariantCulture);
310+
fracDayStr.AsSpan(1, 9).CopyTo(epochSpan.Slice(5)); // Copy ".DDDDDDDD"
311+
string epochStr = new string(epochSpan);
312+
313+
// Format eccentricity: 7 digits, no decimal point
314+
double clampedE = System.Math.Clamp(kep.E, 1e-7, MAX_ECCENTRICITY);
304315
string eStr = clampedE.ToString("0.0000000", CultureInfo.InvariantCulture).Substring(2, 7);
305316

306-
// 4. nDot, nDDot, BSTAR: TLE scientific notation - FIXED: Use InvariantCulture
317+
// Format derivatives and drag term
307318
string nDotStr = nDot.ToString(" .00000000;-.00000000", CultureInfo.InvariantCulture).PadLeft(10);
308319
string nDDotStr = FormatTleExponent(nDDot, 5);
309320
string bstarStr = FormatTleExponent(bstar, 5);
310321

311-
// Pre-sized StringBuilder to avoid reallocations
322+
// Build line 1 - use single ToString call
312323
var line1Builder = new StringBuilder(69);
313-
var line2Builder = new StringBuilder(69);
314-
315-
// Construction Line 1 sans allocations intermédiaires
316324
line1Builder.Append("1 ")
317325
.Append(noradId.ToString("00000"))
318326
.Append((char)classification)
@@ -329,11 +337,12 @@ public static TLE Create(OrbitalParameters orbitalParams, string name, ushort no
329337
.Append(" 0 ")
330338
.Append(elementSetNumber.ToString().PadLeft(4));
331339

332-
// Calculer checksum sur la string actuelle
333-
string line1Temp = line1Builder.ToString();
334-
line1Builder.Append(ComputeTleChecksum(line1Temp));
340+
// Compute checksum on the builder's content without creating intermediate string
341+
int checksum1 = ComputeTleChecksumFromBuilder(line1Builder);
342+
line1Builder.Append(checksum1);
335343

336-
// Construction Line 2 sans allocations intermédiaires - FIXED: Use InvariantCulture
344+
// Build line 2
345+
var line2Builder = new StringBuilder(69);
337346
line2Builder.Append("2 ")
338347
.Append(noradId.ToString("00000"))
339348
.Append(' ')
@@ -350,12 +359,57 @@ public static TLE Create(OrbitalParameters orbitalParams, string name, ushort no
350359
.Append(meanMotion.ToString("0.00000000", CultureInfo.InvariantCulture).PadLeft(11))
351360
.Append(revolutionsAtEpoch.ToString().PadLeft(5));
352361

353-
string line2Temp = line2Builder.ToString();
354-
line2Builder.Append(ComputeTleChecksum(line2Temp));
362+
int checksum2 = ComputeTleChecksumFromBuilder(line2Builder);
363+
line2Builder.Append(checksum2);
355364

356365
return new TLE(name, line1Builder.ToString(), line2Builder.ToString());
357366
}
358367

368+
/// <summary>
369+
/// Normalizes an angle to [0, 360) range with single-pass validation
370+
/// </summary>
371+
private static double NormalizeAngle(double angleDeg)
372+
{
373+
if (double.IsNaN(angleDeg) || double.IsInfinity(angleDeg))
374+
return 0.0;
375+
376+
// Normalize to [0, 360)
377+
angleDeg = angleDeg % 360.0;
378+
if (angleDeg < 0.0)
379+
angleDeg += 360.0;
380+
381+
// Edge case: very close to 360
382+
if (angleDeg >= 360.0 - 1e-10)
383+
return 0.0;
384+
385+
// Check if formatting to 4 decimals would produce 360.0
386+
// More efficient than Math.Round: direct comparison
387+
if (angleDeg >= 359.99995)
388+
return 0.0;
389+
390+
return angleDeg;
391+
}
392+
393+
/// <summary>
394+
/// Computes TLE checksum directly from StringBuilder without intermediate string allocation
395+
/// </summary>
396+
private static int ComputeTleChecksumFromBuilder(StringBuilder builder)
397+
{
398+
int sum = 0;
399+
int length = builder.Length;
400+
401+
for (int i = 0; i < length; i++)
402+
{
403+
char c = builder[i];
404+
if (char.IsDigit(c))
405+
sum += c - '0';
406+
else if (c == '-')
407+
sum += 1;
408+
}
409+
410+
return sum % 10;
411+
}
412+
359413
// Helper for TLE scientific notation (e.g. 34123-4)
360414
static string FormatTleExponent(double value, int width)
361415
{

0 commit comments

Comments
 (0)