From 1bf4afe99c89d5532dcac0cd475fe46543e67c33 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 21 Nov 2023 15:47:28 +0000 Subject: [PATCH 1/3] Simplify mapping between register and curve items --- .../TimelineGraphBuilder.cs | 1 - .../TimelineGraphVisualizer.cs | 25 +++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) 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); } From 041919d45ee99b9e163c97522b34f4f6dcbd845b Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 21 Nov 2023 15:48:26 +0000 Subject: [PATCH 2/3] Add prototype implementation of triggered timeline --- .../TriggerTimelineGraphBuilder.cs | 153 ++++++++++++++++++ .../TriggerTimelineGraphVisualizer.cs | 147 +++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 Bonsai.Harp.Visualizers/TriggerTimelineGraphBuilder.cs create mode 100644 Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs 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..3cf1898 --- /dev/null +++ b/Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs @@ -0,0 +1,147 @@ +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; + +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"); + + var currentTime = 0.0; + var absoluteMinTime = double.MaxValue; + 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; + absoluteMinTime = Math.Min(absoluteMinTime, timestamp); + currentTime = Math.Max(currentTime, timestamp); + if (!registerMap.TryGetValue(register.Label, out var points)) + { + points = new BoundedPointPairList(); + 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) + { + var relativeMinTime = currentTime - view.TimeSpan; + foreach (var series in view.Graph.GraphPane.CurveList) + { + ((BoundedPointPairList)series.Points).SetBounds( + relativeMinTime, + double.MaxValue); + } + view.Graph.XMin = Math.Max(absoluteMinTime, relativeMinTime); + } + else view.Graph.XMin = absoluteMinTime; + 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; + } + } +} From ef72052fe95086c77891f39d18cf2cf3abfdd000 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 21 Nov 2023 15:57:08 +0000 Subject: [PATCH 3/3] Fix axis range when time span is specified --- .../TriggerTimelineGraphVisualizer.cs | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs b/Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs index 3cf1898..de18801 100644 --- a/Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs +++ b/Bonsai.Harp.Visualizers/TriggerTimelineGraphVisualizer.cs @@ -7,6 +7,7 @@ using Bonsai.Design; using Bonsai.Design.Visualizers; using Bonsai.Expressions; +using ZedGraph; namespace Bonsai.Harp.Visualizers { @@ -63,10 +64,14 @@ public override void Load(IServiceProvider provider) 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 absoluteMinTime = double.MaxValue; - var registerMap = new Dictionary(); + var registerMap = new Dictionary(); CompositeDisposable subscriptions = new(); view.HandleCreated += delegate { @@ -82,11 +87,10 @@ public override void Load(IServiceProvider provider) { var register = message.Value; var timestamp = message.Seconds - trigger.Seconds; - absoluteMinTime = Math.Min(absoluteMinTime, timestamp); currentTime = Math.Max(currentTime, timestamp); if (!registerMap.TryGetValue(register.Label, out var points)) { - points = new BoundedPointPairList(); + points = new PointPairList(); registerMap.Add(register.Label, points); var color = GraphControl.GetColor(register.Address); var series = view.Graph.CreateSeries(register.Label, points, color); @@ -96,19 +100,11 @@ public override void Load(IServiceProvider provider) points.Add(timestamp, trigger.Value); } - if (view.TimeSpan > 0) + if (view.TimeSpan <= 0) { - var relativeMinTime = currentTime - view.TimeSpan; - foreach (var series in view.Graph.GraphPane.CurveList) - { - ((BoundedPointPairList)series.Points).SetBounds( - relativeMinTime, - double.MaxValue); - } - view.Graph.XMin = Math.Max(absoluteMinTime, relativeMinTime); + view.Graph.XMin = 0; + view.Graph.XMax = currentTime; } - else view.Graph.XMin = absoluteMinTime; - view.Graph.XMax = currentTime; view.Graph.Invalidate(); })); }));