diff --git a/Bonsai.Harp.Visualizers/TimelineGraphBuilder.cs b/Bonsai.Harp.Visualizers/TimelineGraphBuilder.cs index 5c7a6e6..3af7d80 100644 --- a/Bonsai.Harp.Visualizers/TimelineGraphBuilder.cs +++ b/Bonsai.Harp.Visualizers/TimelineGraphBuilder.cs @@ -37,7 +37,6 @@ internal class VisualizerController public override Expression Build(IEnumerable arguments) { var source = arguments.First(); - var parameterType = source.Type.GetGenericArguments()[0]; Controller = new VisualizerController { TimeSpan = TimeSpan, diff --git a/Bonsai.Harp.Visualizers/TimelineGraphVisualizer.cs b/Bonsai.Harp.Visualizers/TimelineGraphVisualizer.cs index bc352b7..b9cd18e 100644 --- a/Bonsai.Harp.Visualizers/TimelineGraphVisualizer.cs +++ b/Bonsai.Harp.Visualizers/TimelineGraphVisualizer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reflection; @@ -65,31 +66,33 @@ public override void Load(IServiceProvider provider) var currentTime = 0.0; var absoluteMinTime = double.MaxValue; + var registerMap = new Dictionary(); CompositeDisposable subscriptions = new(); view.HandleCreated += delegate { subscriptions.Add(controller.Registers.Subscribe(register => { var label = GetRegisterInfo(register, out int address); - var color = GraphControl.GetColor(address); - var points = new BoundedPointPairList(); - view.BeginInvoke((Action)(() => - { - var series = view.Graph.CreateSeries(label, points, color); - view.Graph.GraphPane.CurveList.Add(series); - view.Graph.Invalidate(); - })); - subscriptions.Add(register - .Select(message => message.GetTimestamp()) .Buffer(() => timerTick) .Subscribe(buffer => { if (buffer.Count == 0) return; - foreach (var timestamp in buffer) + foreach (var message in buffer) { + var address = message.Address; + var timestamp = message.GetTimestamp(); absoluteMinTime = Math.Min(absoluteMinTime, timestamp); currentTime = Math.Max(currentTime, timestamp); + if (!registerMap.TryGetValue(address, out var points)) + { + points = new BoundedPointPairList(); + registerMap.Add(address, points); + var color = GraphControl.GetColor(address); + var series = view.Graph.CreateSeries(label, points, color); + view.Graph.GraphPane.CurveList.Add(series); + } + points.Add(timestamp, address); } diff --git a/Bonsai.Harp.Visualizers/TriggerTimelineGraphBuilder.cs b/Bonsai.Harp.Visualizers/TriggerTimelineGraphBuilder.cs new file mode 100644 index 0000000..ff0e517 --- /dev/null +++ b/Bonsai.Harp.Visualizers/TriggerTimelineGraphBuilder.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Linq.Expressions; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Bonsai.Expressions; + +namespace Bonsai.Harp.Visualizers +{ + /// + /// Represents an operator that configures a visualizer to plot each Harp message + /// in the sequence in a synchronized rolling graph. + /// + [TypeVisualizer(typeof(TriggerTimelineGraphVisualizer))] + [Description("A visualizer that plots each Harp message in the sequence in a synchronized rolling graph.")] + public class TriggerTimelineGraphBuilder : ExpressionBuilder + { + static readonly Range argumentRange = Range.Create(lowerBound: 2, upperBound: 2); + + /// + /// Gets the range of input arguments that this expression builder accepts. + /// + public override Range ArgumentRange + { + get { return argumentRange; } + } + + /// + /// Gets or sets the optional maximum time range captured in the timeline graph. + /// If no time span is specified, all data points will be displayed. + /// + [Category("Range")] + [Description("The optional maximum time range captured in the timeline graph. If no time span is specified, all data points will be displayed.")] + public double? TimeSpan { get; set; } + + internal VisualizerController Controller { get; set; } + + internal class VisualizerController + { + internal double? TimeSpan; + internal ReplaySubject, Timestamped>> Triggers; + } + + internal struct LabeledRegister + { + public string Label; + public int Address; + + public LabeledRegister(string label, int address) + { + Label = label; + Address = address; + } + } + + /// + public override Expression Build(IEnumerable arguments) + { + var sources = arguments.ToArray(); + var triggerType = sources[1].Type.GetGenericArguments()[0]; + if (!triggerType.IsGenericType || triggerType.GetGenericTypeDefinition() != typeof(Timestamped<>)) + { + throw new InvalidOperationException("The trigger input must be Harp timestamped."); + } + + triggerType = triggerType.GetGenericArguments()[0]; + Controller = new VisualizerController + { + TimeSpan = TimeSpan, + Triggers = new() + }; + var combinator = Expression.Constant(this); + return Expression.Call(combinator, nameof(Process), new[] { triggerType }, sources); + } + + IObservable Process( + IObservable source, + Func, IObservable>> selector, + IObservable> trigger) + { + return source.Publish(ps => trigger.Publish(pt => ps.Merge( + selector(ps).Window(pt).Skip(1).Zip(pt, (window, offset) => + TimelineObservable.Create( + Timestamped.Create(Convert.ToDouble(offset.Value), offset.Seconds), + window)) + .Do(Controller.Triggers) + .IgnoreElements() + .Cast()))); + } + + IObservable Process( + IObservable source, + IObservable> trigger) + { + return Process(source, ps => ps.Select( + message => Timestamped.Create( + new LabeledRegister(message.Address.ToString(), message.Address), + message.GetTimestamp())), + trigger); + } + + IObservable> Process( + IObservable> source, + IObservable> trigger) + { + return Process(source, ps => ps.SelectMany(group => group.Select( + message => Timestamped.Create( + new LabeledRegister(message.Address.ToString(), message.Address), + message.GetTimestamp()))), + trigger); + } + + IObservable> Process( + IObservable> source, + IObservable> trigger) + { + return Process(source, ps => ps.SelectMany(group => group.Select( + message => Timestamped.Create( + new LabeledRegister(group.Key.Name, message.Address), + message.GetTimestamp()))), + trigger); + } + + static class TimelineObservable + { + public static IGroupedObservable, Timestamped> Create( + Timestamped key, IObservable> source) + { + return new TimelineObservable(key, source); + } + } + + class TimelineObservable : IGroupedObservable, Timestamped> + { + readonly IObservable> timestamps; + + public TimelineObservable(Timestamped key, IObservable> source) + { + Key = key; + timestamps = source; + } + + public Timestamped Key { get; } + + public IDisposable Subscribe(IObserver> observer) + { + return timestamps.Subscribe(observer); + } + } + } +} diff --git a/Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs b/Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs new file mode 100644 index 0000000..de18801 --- /dev/null +++ b/Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reflection; +using System.Windows.Forms; +using Bonsai.Design; +using Bonsai.Design.Visualizers; +using Bonsai.Expressions; +using ZedGraph; + +namespace Bonsai.Harp.Visualizers +{ + /// + /// Provides a type visualizer to display a sequence of Harp messages as a synchronized rolling graph. + /// + public class TriggerTimelineGraphVisualizer : DialogTypeVisualizer + { + const int TargetInterval = 1000 / 50; + TriggerTimelineGraphBuilder.VisualizerController controller; + TimelineGraphView view; + Timer timer; + + /// + /// Gets or sets the maximum time range, in seconds, displayed at any one moment in the graph. + /// + public double TimeSpan { get; set; } + + static string GetRegisterInfo(IObservable register, out int address) + { + switch (register) + { + case IGroupedObservable addressRegister: + address = addressRegister.Key; + return address.ToString(); + case IGroupedObservable typedRegister: + var addressField = typedRegister.Key.GetField(nameof(WhoAmI.Address), BindingFlags.Static | BindingFlags.Public); + address = (int)addressField.GetValue(null); + return typedRegister.Key.Name; + default: + throw new ArgumentException("Unsupported register type.", nameof(register)); + } + } + + /// + public override void Load(IServiceProvider provider) + { + var context = (ITypeVisualizerContext)provider.GetService(typeof(ITypeVisualizerContext)); + var timelineBuilder = (TriggerTimelineGraphBuilder)ExpressionBuilder.GetVisualizerElement(context.Source).Builder; + controller = timelineBuilder.Controller; + + timer = new Timer(); + timer.Interval = TargetInterval; + var timerTick = Observable.FromEventPattern( + handler => timer.Tick += handler, + handler => timer.Tick -= handler); + timer.Start(); + + view = new TimelineGraphView(); + view.Dock = DockStyle.Fill; + view.Graph.AutoScaleX = false; + view.TimeSpan = controller.TimeSpan.GetValueOrDefault(TimeSpan); + view.CanEditTimeSpan = !controller.TimeSpan.HasValue; + GraphHelper.FormatTimeAxis(view.Graph.GraphPane.XAxis); + GraphHelper.SetAxisLabel(view.Graph.GraphPane.XAxis, "Time"); + GraphHelper.SetAxisLabel(view.Graph.GraphPane.YAxis, "Trial"); + if (view.TimeSpan > 0) + { + view.Graph.XMin = 0; + view.Graph.XMax = view.TimeSpan; + } + + var currentTime = 0.0; + var registerMap = new Dictionary(); + CompositeDisposable subscriptions = new(); + view.HandleCreated += delegate + { + subscriptions.Add(controller.Triggers.Subscribe(group => + { + var trigger = group.Key; + subscriptions.Add(group + .Buffer(() => timerTick) + .Subscribe(buffer => + { + if (buffer.Count == 0) return; + foreach (var message in buffer) + { + var register = message.Value; + var timestamp = message.Seconds - trigger.Seconds; + currentTime = Math.Max(currentTime, timestamp); + if (!registerMap.TryGetValue(register.Label, out var points)) + { + points = new PointPairList(); + registerMap.Add(register.Label, points); + var color = GraphControl.GetColor(register.Address); + var series = view.Graph.CreateSeries(register.Label, points, color); + view.Graph.GraphPane.CurveList.Add(series); + } + + points.Add(timestamp, trigger.Value); + } + + if (view.TimeSpan <= 0) + { + view.Graph.XMin = 0; + view.Graph.XMax = currentTime; + } + view.Graph.Invalidate(); + })); + })); + }; + + view.HandleDestroyed += delegate + { + subscriptions.Dispose(); + TimeSpan = view.TimeSpan; + }; + + var visualizerService = (IDialogTypeVisualizerService)provider.GetService(typeof(IDialogTypeVisualizerService)); + visualizerService?.AddControl(view); + } + + /// + public override void Show(object value) + { + } + + /// + public override IObservable Visualize(IObservable> source, IServiceProvider provider) + { + return Observable.Empty(); + } + + /// + public override void Unload() + { + view?.Dispose(); + timer?.Dispose(); + view = null; + controller = null; + } + } +}