From e98b16fc84638e291692e9dc4e4b49e8a8f4a6e3 Mon Sep 17 00:00:00 2001 From: Paul Hebble Date: Tue, 7 Feb 2017 18:46:38 -0600 Subject: [PATCH] Assorted fixes and requested changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle low-inclination (<30°) retrograde orbits - Reset display when a transfer expires - Calculate ejection burns in foreground so sorting works on first load - United States customary units option - Fix issue with target not appearing in list - Another patch to try to suppress phantom maneuver nodes --- README.md | 3 +- src/AstrogationModel.cs | 25 +++++++----- src/AstrogationView.cs | 11 +----- src/Astrogator.cs | 51 +++++++++++++++---------- src/PhysicsTools.cs | 80 +++++++++++++++++++++++++++++---------- src/Settings.cs | 22 +++++++++++ src/SettingsView.cs | 18 +++++++++ src/TransferModel.cs | 84 +++++++++++++++++++++++++++-------------- src/TransferView.cs | 24 ++++++++---- src/ViewTools.cs | 39 +++++++++++++++++++ 10 files changed, 262 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index cb1844f..c08e835 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Click the wrench to open and close the settings panel: | Automatically focus destination | When this is enabled, clicking the maneuver icon will change the map view focus. If the default maneuvers create an encounter with the desired body, then that body will be focused so you can fine tune your arrival; otherwise the destination's parent body will be focused so you can establish the encounter. | | Automatically edit ejection node | When this is enabled, clicking the maneuver icon will leave the first node open for editing. | | Automatically edit plane change node | If you enable this, then the second node will be opened for editing instead of the first. | +| Units: Metric | Shows delta V in m/s (meters per second) | +| Units: United States Customary | Shows delta V in mph (miles per hour) | ## Known limitations @@ -72,7 +74,6 @@ Click the wrench to open and close the settings panel: - Useful numbers for launches/landed - Return-to-LKO burns from Mun and Minmus -- Allow low-inclination retrograde orbits - Represent starting point with ITargetable - Split CalculateEjectionBurn into transfer versus ejection functions diff --git a/src/AstrogationModel.cs b/src/AstrogationModel.cs index ca3feb7..2cf637e 100644 --- a/src/AstrogationModel.cs +++ b/src/AstrogationModel.cs @@ -55,8 +55,9 @@ public AstrogationModel(CelestialBody b = null, Vessel v = null) public bool badInclination { get { // Orbit.inclination is in degrees - return vessel != null - && Math.Abs(vessel.orbit.inclination * Mathf.Deg2Rad) > maxInclination; + // A bad inclination is one that is closer to Tau/4 than the limit is + return vessel?.orbit != null + && Math.Abs(0.25 * Tau - Math.Abs(vessel.orbit.inclination * Mathf.Deg2Rad)) < 0.25 * Tau - maxInclination; } } @@ -66,7 +67,7 @@ public bool badInclination { public bool retrogradeOrbit { get { return vessel != null - && Math.Abs(vessel.orbit.inclination * Mathf.Deg2Rad) > 0.5 * Math.PI; + && Math.Abs(vessel.orbit.inclination * Mathf.Deg2Rad) > 0.25 * Tau; } } @@ -148,6 +149,7 @@ private void CreateTransfers(CelestialBody body, Vessel vessel) int discoveryOrder = 0; CelestialBody origin = StartBody(body, vessel); + CelestialBody targetBody = FlightGlobals.fetch.VesselTarget as CelestialBody; for (CelestialBody b = origin, toSkip = null; b != null; @@ -156,31 +158,34 @@ private void CreateTransfers(CelestialBody body, Vessel vessel) // Skip the first body unless we can actually transfer to its children // (i.e., we have a vessel) if (vessel != null || toSkip != null) { - DbgFmt("Plotting transfers around {0}", b.theName); + DbgFmt("Checking transfers around {0}", b.theName); int numBodies = b.orbitingBodies.Count; for (int i = 0; i < numBodies; ++i) { CelestialBody satellite = b.orbitingBodies[i]; if (satellite != toSkip) { - DbgFmt("Plotting transfer to {0}", satellite.theName); + DbgFmt("Allocating transfer to {0}", satellite.theName); transfers.Add(new TransferModel(origin, satellite, vessel, ++discoveryOrder)); - DbgFmt("Finalized transfer to {0}", satellite.theName); - if (satellite == FlightGlobals.fetch.VesselTarget as CelestialBody) { - foundTarget = true; - } - if (toSkip == FlightGlobals.fetch.VesselTarget as CelestialBody) { + if (satellite == targetBody) { + DbgFmt("Found target as satellite"); foundTarget = true; } } } DbgFmt("Exhausted transfers around {0}", b.theName); } + + if (toSkip == targetBody && targetBody != null) { + DbgFmt("Found target as toSkip"); + foundTarget = true; + } } if (!foundTarget && FlightGlobals.ActiveVessel != null && FlightGlobals.fetch.VesselTarget != null) { + DbgFmt("Allocating transfer to {0}", FlightGlobals.fetch.VesselTarget.GetName()); transfers.Insert(0, new TransferModel(origin, FlightGlobals.fetch.VesselTarget, vessel, -1)); } diff --git a/src/AstrogationView.cs b/src/AstrogationView.cs index bd0bbd4..c8375c1 100644 --- a/src/AstrogationView.cs +++ b/src/AstrogationView.cs @@ -181,7 +181,7 @@ private void createRows() Settings.Instance.DescendingSort ); for (int i = 0; i < transfers.Count; ++i) { - AddChild(new TransferView(transfers[i])); + AddChild(new TransferView(transfers[i], resetCallback)); } } @@ -191,8 +191,7 @@ private bool ErrorCondition { || model.transfers.Count == 0 || model.badInclination || model.hyperbolicOrbit - || model.notOrbiting - || model.retrogradeOrbit; + || model.notOrbiting; } } @@ -209,12 +208,6 @@ private string subTitle { "{0} is landed. Launch to orbit to see transfer info.", TheName(model.vessel) ); - } else if (model.retrogradeOrbit) { - return string.Format( - "Orbit is retrograde, must be prograde with inclination below {1:0.}°", - Math.Abs(model.vessel.orbit.inclination), - AstrogationModel.maxInclination * Mathf.Rad2Deg - ); } else if (model.badInclination) { return string.Format( "Inclination is {0:0.0}°, accuracy too low past {1:0.}°", diff --git a/src/Astrogator.cs b/src/Astrogator.cs index a513e14..26219e4 100644 --- a/src/Astrogator.cs +++ b/src/Astrogator.cs @@ -216,6 +216,9 @@ private void StartLoadingModel(CelestialBody b = null, Vessel v = null, bool fro model.Reset(b, v); } + // Do the easy calculations in the foreground so the view can sort properly right away + CalculateEjectionBurns(); + DbgFmt("Delegating load to background"); BackgroundWorker bgworker = new BackgroundWorker(); @@ -232,32 +235,39 @@ private void bw_LoadModel(object sender, DoWorkEventArgs e) { lock (bgLoadMutex) { DbgFmt("Beginning background model load"); + CalculatePlaneChangeBurns(); + DbgFmt("Finished background model load"); + } + } + + private void CalculateEjectionBurns() + { + // Blast through the ejection burns so the popup has numbers ASAP + for (int i = 0; i < model.transfers.Count; ++i) { + try { + model.transfers[i].CalculateEjectionBurn(); + } catch (Exception ex) { + DbgExc("Problem with background load of ejection burn", ex); + } + } + } - // Blast through the ejection burns so the popup has numbers ASAP + private void CalculatePlaneChangeBurns() + { + if (flightReady + && Settings.Instance.GeneratePlaneChangeBurns + && Settings.Instance.AddPlaneChangeDeltaV) { for (int i = 0; i < model.transfers.Count; ++i) { try { - model.transfers[i].CalculateEjectionBurn(); + Thread.Sleep(200); + model.transfers[i].CalculatePlaneChangeBurn(); } catch (Exception ex) { - DbgExc("Problem with background load of ejection burn", ex); - } - } - // Now get the plane change burns if we need them for the on-screen numbers. - if (flightReady - && Settings.Instance.GeneratePlaneChangeBurns - && Settings.Instance.AddPlaneChangeDeltaV) { - for (int i = 0; i < model.transfers.Count; ++i) { - try { - Thread.Sleep(200); - model.transfers[i].CalculatePlaneChangeBurn(); - } catch (Exception ex) { - DbgExc("Problem with background load of plane change burn", ex); - - // If a route calculation crashes, it can leave behind a temporary node. - ClearManeuverNodes(); - } + DbgExc("Problem with background load of plane change burn", ex); + + // If a route calculation crashes, it can leave behind a temporary node. + ClearManeuverNodes(); } } - DbgFmt("Finished background model load"); } } @@ -369,6 +379,7 @@ private void OnTargetChanged() // Refresh the model so it can reflect the latest target data if (model != null) { if (!model.HasDestination(FlightGlobals.fetch.VesselTarget)) { + DbgFmt("Reloading model and view on target change"); StartLoadingModel(model.body, model.vessel); ResetView(); } diff --git a/src/PhysicsTools.cs b/src/PhysicsTools.cs index f57d944..962d8fd 100644 --- a/src/PhysicsTools.cs +++ b/src/PhysicsTools.cs @@ -73,12 +73,23 @@ public static double DeltaVToOrbit(CelestialBody body, Vessel vessel) /// public static double AbsolutePhaseAngle(Orbit o, double t) { + // Longitude of Ascending Node: Angle between an absolute direction and the ascending node. + // Argument of Periapsis: Angle between ascending node and periapsis. + // True Anomaly: Angle between periapsis and current location of the body. + // Sum: Angle describing absolute location of the body. + // Only the True Anomaly is in radians by default. + + // https://upload.wikimedia.org/wikipedia/commons/e/eb/Orbit1.svg + // The ArgOfPer and TruAno move you the normal amount at Inc=0, + // zero at Inc=PI/2, and backwards at Inc=PI. + // That's a cosine as far as I can tell. + double cosInc = Math.Cos(o.inclination * Mathf.Deg2Rad); return clamp( Mathf.Deg2Rad * ( o.LAN - + o.argumentOfPeriapsis + + o.argumentOfPeriapsis * cosInc ) - + o.TrueAnomalyAtUT(t) + + o.TrueAnomalyAtUT(t) * cosInc ); } @@ -136,7 +147,7 @@ public static double EjectionAngle(CelestialBody parent, double periapsis, doubl // Angle between parent's prograde and the vessel's prograde at burn // Should be between PI/2 and PI - return 1.5*Math.PI - theta; + return 0.75 * Tau - theta; } /// @@ -202,16 +213,31 @@ public static double BurnToEscape(CelestialBody parent, Orbit fromOrbit, double /// public static double TimeAtAngleFromMidnight(Orbit parentOrbit, Orbit satOrbit, double minTime, double angle) { - double satTrueAnomaly = clamp( - Mathf.Deg2Rad * ( - parentOrbit.LAN - + parentOrbit.argumentOfPeriapsis - - satOrbit.LAN - - satOrbit.argumentOfPeriapsis - ) - + parentOrbit.TrueAnomalyAtUT(minTime) - + angle - ); + double satTrueAnomaly; + if (satOrbit.GetRelativeInclination(parentOrbit) < 90f) { + satTrueAnomaly = clamp( + Mathf.Deg2Rad * ( + parentOrbit.LAN + + parentOrbit.argumentOfPeriapsis + - satOrbit.LAN + - satOrbit.argumentOfPeriapsis + ) + + parentOrbit.TrueAnomalyAtUT(minTime) + + angle + ); + } else { + satTrueAnomaly = clamp( + Mathf.Deg2Rad * ( + - parentOrbit.LAN + - parentOrbit.argumentOfPeriapsis + + satOrbit.LAN + - satOrbit.argumentOfPeriapsis + ) + - parentOrbit.TrueAnomalyAtUT(minTime) + + angle + + Math.PI + ); + } double nextTime = satOrbit.GetUTforTrueAnomaly(satTrueAnomaly, Planetarium.GetUniversalTime()); int numOrbits = (int)Math.Ceiling((minTime - nextTime) / satOrbit.period); return nextTime + numOrbits * satOrbit.period; @@ -347,8 +373,16 @@ public static double TimeOfPlaneChange(Orbit currentOrbit, Orbit targetOrbit, do /// public static double PlaneChangeDeltaV(Orbit currentOrbit, Orbit targetOrbit, double nodeTime, bool ascendingNode) { - return (ascendingNode ? -1.0 : 1.0) - * DeltaVToMatchPlanes(currentOrbit, targetOrbit, nodeTime).magnitude; + // DeltaVToMatchPlanes is precise, but it tries to flip orbits if they're going + // in opposite directions. + // In fact, we don't care if one is prograde and the other retrograde. + if (currentOrbit.GetRelativeInclination(targetOrbit) < 90f) { + return (ascendingNode ? -1.0 : 1.0) + * DeltaVToMatchPlanes(currentOrbit, targetOrbit, nodeTime).magnitude; + } else { + return (ascendingNode ? 1.0 : -1.0) + * DeltaVToMatchPlanes(currentOrbit, targetOrbit, nodeTime).magnitude; + } } /// @@ -363,10 +397,18 @@ public static double PlaneChangeDeltaV(Orbit currentOrbit, Orbit targetOrbit, do /// public static Vector3d DeltaVToMatchPlanes(Orbit o, Orbit target, double burnUT) { - Vector3d desiredHorizontal = Vector3d.Cross(target.SwappedOrbitNormal(), o.Up(burnUT)); - Vector3d actualHorizontalVelocity = Vector3d.Exclude(o.Up(burnUT), o.SwappedOrbitalVelocityAtUT(burnUT)); - Vector3d desiredHorizontalVelocity = actualHorizontalVelocity.magnitude * desiredHorizontal; - return desiredHorizontalVelocity - actualHorizontalVelocity; + if (o.GetRelativeInclination(target) < 90f) { + Vector3d desiredHorizontal = Vector3d.Cross(target.SwappedOrbitNormal(), o.Up(burnUT)); + Vector3d actualHorizontalVelocity = Vector3d.Exclude(o.Up(burnUT), o.SwappedOrbitalVelocityAtUT(burnUT)); + Vector3d desiredHorizontalVelocity = actualHorizontalVelocity.magnitude * desiredHorizontal; + return desiredHorizontalVelocity - actualHorizontalVelocity; + } else { + // Try to match a retrograde orbit with a prograde one + Vector3d desiredHorizontal = Vector3d.Cross(o.Up(burnUT), target.SwappedOrbitNormal()); + Vector3d actualHorizontalVelocity = Vector3d.Exclude(o.Up(burnUT), o.SwappedOrbitalVelocityAtUT(burnUT)); + Vector3d desiredHorizontalVelocity = actualHorizontalVelocity.magnitude * desiredHorizontal; + return desiredHorizontalVelocity - actualHorizontalVelocity; + } } } diff --git a/src/Settings.cs b/src/Settings.cs index 3cf00bd..c065063 100644 --- a/src/Settings.cs +++ b/src/Settings.cs @@ -49,6 +49,7 @@ private const string ShowSettingsKey = "ShowSettings", TransferSortKey = "TransferSort", DescendingSortKey = "DescendingSort", + DisplayUnitsKey = "DisplayUnits", GeneratePlaneChangeBurnsKey = "GeneratePlaneChangeBurns", AddPlaneChangeDeltaVKey = "AddPlaneChangeDeltaV", @@ -316,6 +317,27 @@ public bool DescendingSort { } } + /// + /// Unit system for display of physical quantities. + /// + public DisplayUnitsEnum DisplayUnits { + get { + DisplayUnitsEnum du = DisplayUnitsEnum.Metric; + string savedValue = ""; + if (config.TryGetValue(DisplayUnitsKey, ref savedValue)) { + du = ParseEnum(savedValue, DisplayUnitsEnum.Metric); + } + return du; + } + set { + if (config.HasValue(DisplayUnitsKey)) { + config.SetValue(DisplayUnitsKey, value.ToString()); + } else { + config.AddValue(DisplayUnitsKey, value.ToString()); + } + } + } + } } diff --git a/src/SettingsView.cs b/src/SettingsView.cs index 616d896..2076b3b 100644 --- a/src/SettingsView.cs +++ b/src/SettingsView.cs @@ -77,6 +77,24 @@ public SettingsView() (bool b) => { Settings.Instance.AutoEditPlaneChangeNode = b; } )); + AddChild(LabelWithStyleAndSize( + "Units:", + midHdrStyle, + mainWindowMinWidth, rowHeight + )); + + AddChild(new DialogGUIToggle( + () => Settings.Instance.DisplayUnits == DisplayUnitsEnum.Metric, + "Metric", + (bool b) => { if (b) Settings.Instance.DisplayUnits = DisplayUnitsEnum.Metric; } + )); + + AddChild(new DialogGUIToggle( + () => Settings.Instance.DisplayUnits == DisplayUnitsEnum.UnitedStatesCustomary, + "United States Customary", + (bool b) => { if (b) Settings.Instance.DisplayUnits = DisplayUnitsEnum.UnitedStatesCustomary; } + )); + } catch (Exception ex) { DbgExc("Problem constructing settings view", ex); } diff --git a/src/TransferModel.cs b/src/TransferModel.cs index 08834be..396bf48 100644 --- a/src/TransferModel.cs +++ b/src/TransferModel.cs @@ -105,38 +105,55 @@ private BurnModel GenerateEjectionBurn(Orbit currentOrbit) // Note which body is boss in the zone where we transfer transferParent = immediateDestination.GetOrbit().referenceBody; - double optimalPhaseAngle = clamp(Math.PI * ( - 1 - Math.Pow( - (currentOrbit.semiMajorAxis + immediateDestination.GetOrbit().semiMajorAxis) - / (2 * immediateDestination.GetOrbit().semiMajorAxis), - 1.5) - )); + double now = Planetarium.GetUniversalTime(); // How many radians the phase angle increases or decreases by each second - double phaseAnglePerSecond = - (Tau / immediateDestination.GetOrbit().period) - - (Tau / currentOrbit.period); - - double currentPhaseAngle = clamp( - Mathf.Deg2Rad * ( - immediateDestination.GetOrbit().LAN - + immediateDestination.GetOrbit().argumentOfPeriapsis - - currentOrbit.LAN - - currentOrbit.argumentOfPeriapsis - ) - + immediateDestination.GetOrbit().trueAnomaly - - currentOrbit.trueAnomaly - ); + double phaseAnglePerSecond, angleToMakeUp; + if (currentOrbit.GetRelativeInclination(immediateDestination.GetOrbit()) < 90f) { + + // Normal prograde orbits + double optimalPhaseAngle = clamp(Math.PI * ( + 1 - Math.Pow( + (currentOrbit.semiMajorAxis + immediateDestination.GetOrbit().semiMajorAxis) + / (2 * immediateDestination.GetOrbit().semiMajorAxis), + 1.5) + )); + double currentPhaseAngle = clamp( + AbsolutePhaseAngle(immediateDestination.GetOrbit(), now) + - AbsolutePhaseAngle(currentOrbit, now)); + angleToMakeUp = currentPhaseAngle - optimalPhaseAngle; + phaseAnglePerSecond = + (Tau / immediateDestination.GetOrbit().period) + - (Tau / currentOrbit.period); + // This whole section borrowed from Kerbal Alarm Clock; thanks, TriggerAu! + if (angleToMakeUp > 0 && phaseAnglePerSecond > 0) + angleToMakeUp -= Tau; + if (angleToMakeUp < 0 && phaseAnglePerSecond < 0) + angleToMakeUp += Tau; + + } else { - // This whole section borrowed from Kerbal Alarm Clock; thanks, TriggerAu! - double angleToMakeUp = currentPhaseAngle - optimalPhaseAngle; - if (angleToMakeUp > 0 && phaseAnglePerSecond > 0) - angleToMakeUp -= Tau; - if (angleToMakeUp < 0 && phaseAnglePerSecond < 0) - angleToMakeUp += Tau; + // Special logic needed for retrograde orbits + // The phase angle is the opposite part of the unit circle + double optimalPhaseAngle = Tau - clamp(Math.PI * ( + 1 - Math.Pow( + (currentOrbit.semiMajorAxis + immediateDestination.GetOrbit().semiMajorAxis) + / (2 * immediateDestination.GetOrbit().semiMajorAxis), + 1.5) + )); + // The phase angle always decreases by the sum of the angular velocities + double currentPhaseAngle = Tau - clamp( + AbsolutePhaseAngle(immediateDestination.GetOrbit(), now) + - AbsolutePhaseAngle(currentOrbit, now)); + angleToMakeUp = clamp(currentPhaseAngle - optimalPhaseAngle); + phaseAnglePerSecond = + (Tau / immediateDestination.GetOrbit().period) + + (Tau / currentOrbit.period); + + } double timeTillBurn = Math.Abs(angleToMakeUp / phaseAnglePerSecond); - double ejectionBurnTime = Planetarium.GetUniversalTime() + timeTillBurn; + double ejectionBurnTime = now + timeTillBurn; double arrivalTime = ejectionBurnTime + 0.5 * OrbitalPeriod( immediateDestination.GetOrbit().referenceBody, immediateDestination.GetOrbit().semiMajorAxis, @@ -359,7 +376,18 @@ public bool Refresh() if (ejectionBurn != null) { if (ejectionBurn.atTime < Planetarium.GetUniversalTime()) { CalculateEjectionBurn(); - CalculatePlaneChangeBurn(); + + // Apply the same filters we do everywhere else to suppress phantom nodes + if (Settings.Instance.GeneratePlaneChangeBurns + && Settings.Instance.AddPlaneChangeDeltaV) { + + try { + CalculatePlaneChangeBurn(); + } catch (Exception ex) { + DbgExc("Problem with plane change at expiration", ex); + ClearManeuverNodes(); + } + } return true; } else { return false; diff --git a/src/TransferView.cs b/src/TransferView.cs index af4e30c..bdf3cd9 100644 --- a/src/TransferView.cs +++ b/src/TransferView.cs @@ -16,10 +16,12 @@ public class TransferView : DialogGUIHorizontalLayout { /// Construct a view for the given model. /// /// Model for which to construct a view - public TransferView(TransferModel m) + /// Callback to call when a UI layout change may be needed + public TransferView(TransferModel m, AstrogationView.ResetCallback reset) : base() { model = m; + resetCallback = reset; CreateLayout(); } @@ -27,6 +29,7 @@ public TransferView(TransferModel m) private TransferModel model { get; set; } private double lastUniversalTime { get; set; } private DateTimeParts timeToWait { get; set; } + private AstrogationView.ResetCallback resetCallback { get; set; } private void CreateLayout() { @@ -105,7 +108,12 @@ public bool Refresh() bool modelNeedsUIUpdate = model.Refresh(); double now = Math.Floor(Planetarium.GetUniversalTime()); - if ((modelNeedsUIUpdate || lastUniversalTime != now) && model.ejectionBurn != null) { + if (modelNeedsUIUpdate) { + // We have a new ejection burn, so we might need a totally new view + // because the sort could be wrong now. + resetCallback(); + return true; + } else if (lastUniversalTime != now && model.ejectionBurn != null) { timeToWait = new DateTimeParts(model.ejectionBurn.atTime - Planetarium.GetUniversalTime()); lastUniversalTime = now; return true; @@ -190,13 +198,13 @@ public string getDeltaV() if (model.ejectionBurn == null) { return LoadingText; } else if (model.planeChangeBurn == null || !Settings.Instance.AddPlaneChangeDeltaV) { - return TimePieceString("{0} m/s", - Math.Abs(Math.Floor(model.ejectionBurn.totalDeltaV)), - false, "N/A"); + return FormatSpeed( + model.ejectionBurn.totalDeltaV, + Settings.Instance.DisplayUnits); } else { - return TimePieceString("{0} m/s", - Math.Abs(Math.Floor(model.ejectionBurn.totalDeltaV + model.planeChangeBurn.totalDeltaV)), - false, "Done"); + return FormatSpeed( + model.ejectionBurn.totalDeltaV + model.planeChangeBurn.totalDeltaV, + Settings.Instance.DisplayUnits); } } diff --git a/src/ViewTools.cs b/src/ViewTools.cs index 1d84c0d..8c5c87d 100644 --- a/src/ViewTools.cs +++ b/src/ViewTools.cs @@ -750,6 +750,45 @@ public static DialogGUIButton iconButton(Sprite icon, UIStyle style, string tool tooltipText = tooltip }; } + + /// + /// Symbols representing systems of physical units + /// + public enum DisplayUnitsEnum { + + /// + /// Système International d'Unités + /// https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Metric_system_adoption_map.svg/2000px-Metric_system_adoption_map.svg.png + /// + Metric, + + /// + /// Time-tested traditional units. + /// https://i.imgur.com/h5UsGxK.png + /// + UnitedStatesCustomary + } + + /// + /// Format a speed value for display + /// + /// Speed value in m/s + /// Type of units to use + /// + /// Converted number string with no decimal places concatenated + /// with short unit string. + /// + public static string FormatSpeed(double speed, DisplayUnitsEnum units) { + const double METERS_PER_SECOND_PER_MILES_PER_HOUR = 0.44704; + switch (units) { + case DisplayUnitsEnum.UnitedStatesCustomary: + return string.Format("{0:0} mph", speed / METERS_PER_SECOND_PER_MILES_PER_HOUR); + default: + case DisplayUnitsEnum.Metric: + return string.Format("{0:0} m/s", speed); + } + } + } ///