PageRenderTime 731ms CodeModel.GetById 342ms app.highlight 20ms RepoModel.GetById 285ms app.codeStats 1ms

/Utilities/Math/Graph.cs

#
C# | 484 lines | 322 code | 47 blank | 115 comment | 21 complexity | c47204f0646d7214007006599e0d038c MD5 | raw file
  1using System;
  2using System.Collections.Generic;
  3using System.IO;
  4using Delta.Utilities.Datatypes;
  5using Delta.Utilities.Helpers;
  6using NUnit.Framework;
  7
  8namespace Delta.Utilities.Math
  9{
 10	/// <summary>
 11	/// The graph is a list of points (X, Y) which are interpolated linearly
 12	/// when accessing a point between two existing points.
 13	/// </summary>
 14	public class Graph : ISaveLoadBinary
 15	{
 16		#region FromString (Static)
 17		/// <summary>
 18		/// From string
 19		/// </summary>
 20		public static Graph FromString(string graphString)
 21		{
 22			#region Validation
 23			if (String.IsNullOrEmpty(graphString))
 24			{
 25				// Return an empty graph to indicate the loading error
 26				return new Graph(Range.Zero).Add(0, 0);
 27			}
 28			#endregion
 29
 30			// Split input string
 31			string[] splitted = graphString.SplitAndTrim(";");
 32
 33			#region Validation
 34			if (splitted.Length < 2 &&
 35			    splitted.Length % 2 != 0)
 36			{
 37				Log.Warning("Invalid string token length: " + splitted.Length);
 38				// Return an empty graph to indicate the loading error
 39				return new Graph(Range.Zero).Add(0, 0);
 40			}
 41			#endregion
 42
 43			// Extract min and max x
 44			float minX = splitted[0].FromInvariantString(0.0f);
 45			float maxX = splitted[1].FromInvariantString(0.0f);
 46
 47			// Create graph and extract data points
 48			Graph result = new Graph(new Range(minX, maxX));
 49			for (int index = 2; index < splitted.Length; index += 2)
 50			{
 51				// parse the x offset
 52				float offset = splitted[index].FromInvariantString(0.0f);
 53				// and the y value
 54				float value = splitted[index + 1].FromInvariantString(0.0f);
 55				// Add it to the graph
 56				result.Add(offset, value);
 57			}
 58			return result;
 59		}
 60		#endregion
 61
 62		#region GraphRange (Public)
 63		/// <summary>
 64		/// The range in which our graph is used.
 65		/// </summary>
 66		public Range GraphRange;
 67		#endregion
 68
 69		#region Private
 70
 71		#region Points (Private)
 72		/// <summary>
 73		/// List of points which is sorted by the x values.
 74		/// </summary>
 75		private readonly SortedList<float, float> Points;
 76		#endregion
 77
 78		#region cachedSegments (Private)
 79		/// <summary>
 80		/// Cached segments, which prevents recreation of new segments
 81		/// each time we want a new value.
 82		/// </summary>
 83		private LinearSegment[] cachedSegments;
 84		#endregion
 85
 86		#endregion
 87
 88		#region Constructors
 89		/// <summary>
 90		/// Create a new graph with the default length of 0-1.
 91		/// </summary>
 92		public Graph()
 93		{
 94			GraphRange = Range.ZeroToOne;
 95			Points = new SortedList<float, float>();
 96			cachedSegments = new LinearSegment[0];
 97		}
 98
 99		/// <summary>
100		/// Create graph with a total x-axis length of setStartEndRange.
101		/// </summary>
102		public Graph(Range setStartEndRange)
103			: this()
104		{
105			GraphRange = setStartEndRange;
106		}
107
108		/// <summary>
109		/// Create a new graph with a given y-axis start value with the default
110		/// length of 0-1.
111		/// </summary>
112		public Graph(float yStartValue)
113			: this()
114		{
115			Add(0, yStartValue);
116		}
117		#endregion
118
119		#region ISaveLoadBinary Members
120		/// <summary>
121		/// Load the graph via the binary reader.
122		/// </summary>
123		/// <param name="reader">Reader for loading.</param>
124		public void Load(BinaryReader reader)
125		{
126			GraphRange.Load(reader);
127			int numberOfKeys = reader.ReadInt32();
128			for (int index = 0; index < numberOfKeys; index++)
129			{
130				Add(reader.ReadSingle(), reader.ReadSingle());
131			}
132		}
133
134		/// <summary>
135		/// Save the graph via the binary writer.
136		/// </summary>
137		/// <param name="writer">Writer for saving.</param>
138		public void Save(BinaryWriter writer)
139		{
140			GraphRange.Save(writer);
141			writer.Write(Points.Count);
142			foreach (KeyValuePair<float, float> pair in Points)
143			{
144				writer.Write(pair.Key);
145				writer.Write(pair.Value);
146			}
147		}
148		#endregion
149
150		#region GetIntegratedValue (Public)
151		/// <summary>
152		/// Get integrated value with given value offset
153		/// (add valueOffset to Y value)
154		/// This returns the area below the curve up to given offset.
155		/// Note: Only tested to work with graphs from 0 to 1 (x axis)
156		/// </summary>
157		public float GetIntegratedValue(float atOffset, float valueOffset)
158		{
159			bool exitAfterSegment = false;
160			float result = 0;
161			for (int index = 0; index < cachedSegments.Length; index++)
162			{
163				LinearSegment segment = GetSegment(index);
164				float startX = segment.Start.X;
165				float endX = segment.End.X;
166				if (atOffset <= endX)
167				{
168					endX = atOffset;
169					exitAfterSegment = true;
170				}
171				float startValue = segment.Start.Y + valueOffset;
172				float endValue = segment.GetValue(endX) + valueOffset;
173
174				float zeroOffset = float.NaN;
175				if (startValue != endValue)
176				{
177					// Calculate zero offset (by calculating the linear equation,
178					// then solving to zero)
179					float incline = (endValue - startValue) / (endX - startX);
180					float axisOffset = startValue - (startX * incline);
181					// get zero point: f(x) = incline * x + axisOffset
182					// == -axisOffset / incline
183					float zeroPoint = -axisOffset / incline;
184					// Now check if Zero point is inside start and end:
185					if (zeroPoint > startX &&
186					    zeroPoint < endX)
187					{
188						zeroOffset = zeroPoint;
189					}
190				}
191				if (float.IsNaN(zeroOffset))
192				{
193					// We do not have a zero point, so just integrate
194					result += Integrate(startX, startValue,
195						endX, endValue);
196				}
197				else
198				{
199					// We do have a zero point, so separately calculate for
200					// upper and lower segment
201					result += Integrate(startX, startValue,
202						zeroOffset, 0);
203					result += Integrate(zeroOffset, 0,
204						endX, endValue);
205				}
206				if (exitAfterSegment)
207				{
208					break;
209				}
210			}
211			return result;
212		}
213
214		/// <summary>
215		/// This returns the area below the curve up to given offset.
216		/// Note: Only tested to work with graphs from 0 to 1 (x axis),
217		/// </summary>
218		public float GetIntegratedValue(float atOffset)
219		{
220			return GetIntegratedValue(atOffset, 0.0f);
221		}
222		#endregion
223
224		#region GetValue (Public)
225		/// <summary>
226		/// Get the value at the specified x value.
227		/// </summary>
228		public float GetValue(float atValue)
229		{
230			int segmentIndex = 0;
231
232			// Find the current segment
233			for (int i = 0; i < Points.Count; i++)
234			{
235				if (atValue >= Points.Keys[i])
236				{
237					segmentIndex = i;
238				}
239				else
240				{
241					return GetSegment(segmentIndex).GetValue(atValue);
242				}
243			}
244
245			// just return the last segment if the requested value is beyond all
246			// segments.
247			return GetSegment(Points.Count - 2).GetValue(atValue);
248		}
249		#endregion
250
251		#region Add (Public)
252		/// <summary>
253		/// Add a segment at the specified x value with the y 'height'.
254		/// </summary>
255		public Graph Add(float xValue, float yValue)
256		{
257			xValue = MathHelper.Clamp(xValue, GraphRange.Start, GraphRange.End);
258
259			// we alter the existing point if we try to add another point at the
260			// exact same place.
261			int keyIndex = Points.Keys.IndexOf(xValue);
262			if (keyIndex >= 0)
263			{
264				Points[xValue] = yValue;
265			}
266			else
267			{
268				Points.Add(xValue, yValue);
269			}
270
271			// Update the size of the segment cache.
272			cachedSegments = new LinearSegment[Points.Count];
273			return this;
274		}
275		#endregion
276
277		#region ToString (Public)
278		/// <summary>
279		/// To string, will output everything with ';' separated strings.
280		/// </summary>
281		public override string ToString()
282		{
283			string result =
284				GraphRange.Start.ToInvariantString() + ";" +
285				GraphRange.End.ToInvariantString() + ";";
286
287			for (int index = 0; index < Points.Count; index++)
288			{
289				result +=
290					Points.Keys[index].ToInvariantString() + ";" +
291					Points.Values[index].ToInvariantString() + ";";
292			}
293			return result;
294		}
295		#endregion
296
297		#region Methods (Private)
298
299		#region Integrate
300		/// <summary>
301		/// Calculates area covered between start and end and x-axis.
302		/// Note: This method expects start and end Y to NOT cross the X axis.
303		/// (Both startY and endY need to have the same sign).
304		/// </summary>
305		private float Integrate(float startX, float startY, float endX, float endY)
306		{
307			bool isNegative = false;
308			if (startY < 0 ||
309			    endY < 0)
310			{
311				isNegative = true;
312				// move values into positive area, we just negate at the end.
313				startY = -startY;
314				endY = -endY;
315			}
316			// Swap highest point to always be the second one
317			if (endY < startY)
318			{
319				float temp = endY;
320				endY = startY;
321				startY = temp;
322			}
323			// Calculate area of rectangle and triangle, which is formed by the line
324			float rectangleArea = (endX - startX) * (startY);
325			float triangleArea = (endX - startX) * (endY - startY) / 2.0f;
326			// Add them together for final area
327			float area = rectangleArea + triangleArea;
328			// Now return negative or positive based on input values
329			return
330				isNegative
331					? -area
332					: area;
333		}
334		#endregion
335
336		#region GetSegment
337		/// <summary>
338		/// Get the segment at the index.
339		/// </summary>
340		private LinearSegment GetSegment(int index)
341		{
342			if (index >= cachedSegments.Length)
343			{
344				return LinearSegment.Zero;
345			}
346			if (cachedSegments[index] == LinearSegment.Zero)
347			{
348				LinearSegment segment = new LinearSegment(
349					new Point(Points.Keys[index], Points.Values[index]),
350					new Point(Points.Keys[index + 1], Points.Values[index + 1]));
351
352				return cachedSegments[index] = segment;
353			}
354
355			return cachedSegments[index];
356		}
357		#endregion
358
359		#endregion
360
361		/// <summary>
362		/// Unit tests for the Graph class.
363		/// </summary>
364		internal class GraphTests
365		{
366			#region TestGraphLengthConstructor (LongRunning)
367			/// <summary>
368			/// Test the graph with the length constructor.
369			/// </summary>
370			[Test, Category("LongRunning")]
371			public void TestGraphLengthConstructor()
372			{
373				Graph graph = new Graph(new Range(0, 10)).Add(0, 0);
374				graph.Add(5, 1);
375				graph.Add(10, 0);
376
377				Assert.Equal(graph.GetValue(0.0f), 0.0f);
378				Assert.Equal(graph.GetValue(5.0f), 1.0f);
379				Assert.Equal(graph.GetValue(7.5f), 0.5f);
380				Assert.Equal(graph.GetValue(10.0f), 0.0f);
381				Assert.Equal(graph.GetValue(10.5f), 0.0f);
382			}
383			#endregion
384
385			#region Integrate (LongRunning)
386			/// <summary>
387			/// Test the Integrate method
388			/// </summary>
389			[Test, Category("LongRunning")]
390			public void Integrate()
391			{
392				Graph graph = new Graph(new Range(0, 1));
393				graph.Add(0.0f, 1.0f);
394				graph.Add(1.0f, 1.0f);
395
396				// Test simple graphs
397				Assert.NearlyEqual(graph.GetIntegratedValue(1.0f, 0.0f), 1.0f);
398				Assert.NearlyEqual(graph.GetIntegratedValue(0.5f, 0.0f), 0.5f);
399				graph.Add(0.0f, 0.0f);
400				Assert.NearlyEqual(graph.GetIntegratedValue(1.0f, 0.0f), 0.5f);
401				graph.Add(0.0f, 0.5f);
402				Assert.NearlyEqual(graph.GetIntegratedValue(1.0f, 0.0f), 0.75f);
403
404				graph = new Graph(new Range(0, 1));
405				graph.Add(0.0f, -1.0f);
406				graph.Add(1.0f, 1.0f);
407				Assert.NearlyEqual(graph.GetIntegratedValue(1.0f, 0.0f), 0.0f);
408
409				graph = new Graph(new Range(0, 1));
410				graph.Add(0.0f, 0.0f);
411				graph.Add(1.0f, 1.0f);
412				Assert.NearlyEqual(graph.GetIntegratedValue(1.0f, -0.5f), 0.0f);
413				// 1/8 of whole area
414				Assert.NearlyEqual(graph.GetIntegratedValue(0.5f, -0.5f), -0.125f);
415				// 1/16
416				Assert.NearlyEqual(graph.GetIntegratedValue(0.75f, -0.5f), -0, 0625f);
417			}
418			#endregion
419
420			#region TestSaveAndLoad (LongRunning)
421			/// <summary>
422			/// Test save and load functionality of the Graph class
423			/// </summary>
424			[Test, Category("LongRunning")]
425			public void TestSaveAndLoad()
426			{
427				// Creation of the graph
428				Graph graph = new Graph(new Range(0, 5)).Add(0, 1);
429				graph.Add(1, 2);
430				graph.Add(5, 0.5f);
431
432				// Saving
433				MemoryStream savedStream = new MemoryStream();
434				BinaryWriter writer = new BinaryWriter(savedStream);
435				graph.Save(writer);
436				writer.Flush();
437				writer = null;
438				graph = null;
439
440				// Loading
441				savedStream.Position = 0;
442				BinaryReader reader = new BinaryReader(savedStream);
443				Graph loadedGraph = new Graph();
444				loadedGraph.Load(reader);
445
446				// Testing
447				Assert.Equal(loadedGraph.GraphRange.Start, 0);
448				Assert.Equal(loadedGraph.GraphRange.End, 5);
449				Assert.Equal(loadedGraph.GetValue(0), 1);
450				Assert.Equal(loadedGraph.GetValue(1), 2);
451				Assert.Equal(loadedGraph.GetValue(5), 0.5f);
452			}
453			#endregion
454
455			#region TestToStringFromString (LongRunning)
456			/// <summary>
457			/// Test ToString and FromString functionality of the Graph class
458			/// </summary>
459			[Test, Category("LongRunning")]
460			public void TestToStringFromString()
461			{
462				// Creation of the graph
463				Graph graph = new Graph(new Range(0, 5)).Add(0, 1);
464				graph.Add(1, 2);
465				graph.Add(5, 0.5f);
466
467				// Saving
468				string savedGraph = graph.ToString();
469
470				// Loading
471				Graph loadedGraph = FromString(savedGraph);
472
473				// Testing
474				Assert.Equal(loadedGraph.GraphRange.Start, 0);
475				Assert.Equal(loadedGraph.GraphRange.End, 5);
476				Assert.Equal(loadedGraph.GetValue(0), 1);
477				Assert.Equal(loadedGraph.GetValue(1), 2);
478				Assert.Equal(loadedGraph.GetValue(5), 0.5f);
479			}
480			#endregion
481		}
482	}
483}
484