View Javadoc
1   package org.opentrafficsim.animation.graphs;
2   
3   import java.util.ArrayList;
4   import java.util.Iterator;
5   import java.util.LinkedHashMap;
6   import java.util.List;
7   import java.util.Map;
8   import java.util.Map.Entry;
9   import java.util.SortedSet;
10  import java.util.TreeSet;
11  
12  import org.djunits.value.vdouble.scalar.Duration;
13  import org.djunits.value.vdouble.scalar.Length;
14  import org.djutils.event.EventType;
15  import org.djutils.exceptions.Throw;
16  import org.djutils.metadata.MetaData;
17  import org.djutils.metadata.ObjectDescriptor;
18  import org.opentrafficsim.animation.graphs.FundamentalDiagram.FdPaintState;
19  import org.opentrafficsim.animation.graphs.FundamentalDiagram.FdSeries;
20  import org.opentrafficsim.kpi.interfaces.LaneData;
21  import org.opentrafficsim.kpi.sampling.Sampler;
22  import org.opentrafficsim.kpi.sampling.SpaceTimeRegion;
23  import org.opentrafficsim.kpi.sampling.Trajectory;
24  import org.opentrafficsim.kpi.sampling.Trajectory.SpaceTimeView;
25  import org.opentrafficsim.kpi.sampling.TrajectoryGroup;
26  
27  /**
28   * Data source for a fundamental diagram.
29   * <p>
30   * Copyright (c) 2026-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
31   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
32   * </p>
33   * @author Alexander Verbraeck
34   * @author Peter Knoppers
35   * @author Wouter Schakel
36   */
37  public abstract class FdDataSource extends PlotDelegate<FdPaintState, FundamentalDiagram>
38  {
39  
40      /** Updates per period. */
41      public static final EventType UPDATES_PER_PERIOD = new EventType("UPDATES_PER_PERIOD", new MetaData("Updates per period",
42              "Updates per period", new ObjectDescriptor("Updates per period", "Updates per period", Integer.class)));
43  
44      /** Aggregation period. */
45      public static final EventType AGGREGATION_PERIOD = new EventType("AGGREGATION_PERIOD", new MetaData("Aggregation period",
46              "Aggregation period", new ObjectDescriptor("Aggregation period", "Aggregation period", Duration.class)));
47  
48      /** Aggregation periods. */
49      static final PlotSetting<Duration> AGGREGATION_PERIODS =
50              PlotSetting.of(new double[] {5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 900.0}, Duration::ofSI, 3);
51  
52      /** Updates per period. */
53      static final PlotSetting<Integer> UPDATES = PlotSetting.of(List.of(1, 2, 3, 5, 10), 0);
54  
55      /** Updates per period. */
56      private int updatesPerPeriod = 1;
57  
58      /** Aggregation period. */
59      private Duration aggregationPeriod;
60  
61      /** Aggregation period setting. */
62      private final PlotSetting<Duration> aggregationPeriodSetting;
63  
64      /** Updates per period setting. */
65      private final PlotSetting<Integer> updatesPerPeriodSetting;
66  
67      /**
68       * Constructor.
69       * @param aggregationInterval aggregation interval
70       * @param delay delay
71       * @param plotScheduler plot scheduler
72       */
73      public FdDataSource(final Duration aggregationInterval, final Duration delay, final PlotScheduler plotScheduler)
74      {
75          this(aggregationInterval, delay, plotScheduler, AGGREGATION_PERIODS, UPDATES);
76      }
77  
78      /**
79       * Constructor.
80       * @param aggregationInterval aggregation interval
81       * @param delay delay
82       * @param plotScheduler plot scheduler
83       * @param aggregationPeriodSetting setting for aggregation period
84       * @param updatesPerPeriodSetting setting for updates per period
85       */
86      public FdDataSource(final Duration aggregationInterval, final Duration delay, final PlotScheduler plotScheduler,
87              final PlotSetting<Duration> aggregationPeriodSetting, final PlotSetting<Integer> updatesPerPeriodSetting)
88      {
89          super(aggregationInterval, delay, plotScheduler);
90          this.aggregationPeriod = aggregationInterval;
91          this.aggregationPeriodSetting = aggregationPeriodSetting;
92          this.updatesPerPeriodSetting = updatesPerPeriodSetting;
93      }
94  
95      /**
96       * Returns the aggregation period setting.
97       * @return aggregation period setting
98       */
99      public PlotSetting<Duration> getAggregationPeriodSetting()
100     {
101         return this.aggregationPeriodSetting;
102     }
103 
104     /**
105      * Returns the updates per period setting.
106      * @return updates per period setting
107      */
108     public PlotSetting<Integer> getUpdatesPerPeriodSetting()
109     {
110         return this.updatesPerPeriodSetting;
111     }
112 
113     /**
114      * The updates per period.
115      * @return updates per period
116      */
117     public int getUpdatesPerPeriod()
118     {
119         return this.updatesPerPeriod;
120     }
121 
122     /**
123      * Changes the updates per period.
124      * @param n updates per period
125      */
126     public void setUpdatesPerPeriod(final int n)
127     {
128         synchronized (this)
129         {
130             this.updatesPerPeriod = n;
131             Duration update = this.aggregationPeriod.divide(this.updatesPerPeriod);
132             getPlots().forEach((p) -> p.offerUpdateInterval(update));
133             invalidateTimeSpan();
134         }
135         fireEvent(UPDATES_PER_PERIOD, new Object[] {n});
136     }
137 
138     /**
139      * The aggregation period.
140      * @return aggregation period
141      */
142     public Duration getAggregationPeriod()
143     {
144         return this.aggregationPeriod;
145     }
146 
147     /**
148      * Changes the aggregation period.
149      * @param aggregationPeriod aggregation period
150      */
151     public void setAggregationPeriod(final Duration aggregationPeriod)
152     {
153         synchronized (this)
154         {
155             this.aggregationPeriod = aggregationPeriod;
156             Duration update = this.aggregationPeriod.divide(this.updatesPerPeriod);
157             getPlots().forEach((p) -> p.offerUpdateInterval(update));
158             invalidateTimeSpan();
159         }
160         fireEvent(AGGREGATION_PERIOD, new Object[] {aggregationPeriod});
161     }
162 
163     /**
164      * Returns the update interval.
165      * @return update interval
166      */
167     public Duration getUpdateInterval()
168     {
169         return getAggregationPeriod().divide(getUpdatesPerPeriod());
170     }
171 
172     @Override
173     public void calculatePaintStateUnsafe(final Duration time)
174     {
175         FdPaintState paintState = getPaintState(time);
176         for (FundamentalDiagram plot : getPlots())
177         {
178             plot.offerPaintState(paintState);
179         }
180     }
181 
182     /**
183      * Hook for a meta-source that combines fundamental diagram sources.
184      * @param time current time
185      * @return paint state
186      */
187     abstract FdPaintState getPaintState(Duration time);
188 
189     /**
190      * Returns the number of series (i.e. lanes or 1 for aggregated).
191      * @return number of series
192      */
193     abstract int getNumberOfSeries();
194 
195     /**
196      * Returns a name of the series.
197      * @param series series number
198      * @return name of the series
199      */
200     abstract String getName(int series);
201 
202     /**
203      * Returns whether this source aggregates lanes.
204      * @return whether this source aggregates lanes
205      */
206     abstract boolean isAggregate();
207 
208     /**
209      * Sets the name of the series when aggregated, e.g. for legend. Default is "Aggregate".
210      * @param aggregateName name of the series when aggregated
211      */
212     public abstract void setAggregateName(String aggregateName);
213 
214     /**
215      * Creates a {@link FdDataSource} from a sampler and positions.
216      * @param sampler sampler
217      * @param plotScheduler plot scheduler
218      * @param crossSection cross section
219      * @param aggregateLanes whether to aggregate the positions
220      * @param aggregationTime aggregation time (and update time)
221      * @param harmonic harmonic mean
222      * @return source for a fundamental diagram from a sampler and positions
223      * @param <L> LaneData
224      */
225     public static <L extends LaneData<L>> FdDataSource sourceFromSampler(final Sampler<?, L> sampler,
226             final PlotScheduler plotScheduler, final GraphCrossSection<L> crossSection, final boolean aggregateLanes,
227             final Duration aggregationTime, final boolean harmonic)
228     {
229         return new CrossSectionFdDataSource<>(sampler, plotScheduler, crossSection, aggregateLanes, aggregationTime, harmonic);
230     }
231 
232     /**
233      * Creates a {@link FdDataSource} from a sampler and positions.
234      * @param sampler sampler
235      * @param plotScheduler plot scheduler
236      * @param path cross section
237      * @param aggregateLanes whether to aggregate the positions
238      * @param aggregationTime aggregation time (and update time)
239      * @return source for a fundamental diagram from a sampler and positions
240      * @param <L> LaneData
241      */
242     public static <L extends LaneData<L>> FdDataSource sourceFromSampler(final Sampler<?, L> sampler,
243             final PlotScheduler plotScheduler, final GraphPath<L> path, final boolean aggregateLanes,
244             final Duration aggregationTime)
245     {
246         return new PathFdDataSource<>(sampler, plotScheduler, path, aggregateLanes, aggregationTime);
247     }
248 
249     /**
250      * Combines multiple sources in to one source.
251      * @param sources sources coupled to their names for in the legend
252      * @return combined source
253      */
254     public static FdDataSource combinedSource(final Map<String, FdDataSource> sources)
255     {
256         return new MultiFdSource(sources);
257     }
258 
259     /**
260      * Fundamental diagram source based on a cross section.
261      * @param <L> lane data type
262      * @param <S> underlying source type
263      */
264     private static final class CrossSectionFdDataSource<L extends LaneData<L>, S extends GraphCrossSection<L>>
265             extends AbstractFdDataSource<L, S>
266     {
267 
268         /** Margin to check GTU has passed the location. */
269         private static final Length EPS = Length.ofSI(1e-9);
270 
271         /** Harmonic mean. */
272         private final boolean harmonic;
273 
274         /**
275          * Constructor.
276          * @param sampler sampler
277          * @param plotScheduler plot scheduler
278          * @param crossSection cross section
279          * @param aggregateLanes whether to aggregate the lanes
280          * @param aggregationPeriod initial aggregation period
281          * @param harmonic harmonic mean
282          */
283         private CrossSectionFdDataSource(final Sampler<?, L> sampler, final PlotScheduler plotScheduler, final S crossSection,
284                 final boolean aggregateLanes, final Duration aggregationPeriod, final boolean harmonic)
285         {
286             super(sampler, plotScheduler, crossSection, aggregateLanes, aggregationPeriod);
287             this.harmonic = harmonic;
288         }
289 
290         @Override
291         protected void getMeasurements(final Trajectory<?> trajectory, final Duration startTime, final Duration endTime,
292                 final Length length, final int series, final double[] measurements)
293         {
294             Length x = getSpace().position(series);
295             if (GraphUtil.considerTrajectory(trajectory, x.minus(EPS), x.plus(EPS)))
296             {
297                 // detailed check
298                 if (trajectory.getSpeedAtPosition(x).si != 0.0) // prevent edge case
299                 {
300                     Duration t = trajectory.getTimeAtPosition(x);
301                     if (t.si >= startTime.si && t.si < endTime.si)
302                     {
303                         measurements[0] = 1; // first = count
304                         measurements[1] = // second = sum of (inverted) speeds
305                                 this.harmonic ? 1.0 / trajectory.getSpeedAtPosition(x).si : trajectory.getSpeedAtPosition(x).si;
306                     }
307                 }
308             }
309         }
310 
311         @Override
312         protected double getVehicleCount(final double first, final double second)
313         {
314             return first; // is divided by aggregation period by caller
315         }
316 
317         @Override
318         protected double getSpeed(final double first, final double second)
319         {
320             return this.harmonic ? first / second : second / first;
321         }
322 
323         @Override
324         public String toString()
325         {
326             return "CrossSectionFdDataSource [harmonic=" + this.harmonic + "]";
327         }
328 
329     }
330 
331     /**
332      * Fundamental diagram source based on a path. Density, speed and flow over the entire path are calculated per lane.
333      * @param <L> lane data type
334      * @param <S> underlying source type
335      */
336     private static final class PathFdDataSource<L extends LaneData<L>, S extends GraphPath<L>>
337             extends AbstractFdDataSource<L, S>
338     {
339 
340         /**
341          * Constructor.
342          * @param sampler sampler
343          * @param plotScheduler plot scheduler
344          * @param path path
345          * @param aggregateLanes whether to aggregate the lanes
346          * @param aggregationPeriod initial aggregation period
347          */
348         private PathFdDataSource(final Sampler<?, L> sampler, final PlotScheduler plotScheduler, final S path,
349                 final boolean aggregateLanes, final Duration aggregationPeriod)
350         {
351             super(sampler, plotScheduler, path, aggregateLanes, aggregationPeriod);
352         }
353 
354         @Override
355         protected void getMeasurements(final Trajectory<?> trajectory, final Duration startTime, final Duration endTime,
356                 final Length length, final int series, final double[] measurements)
357         {
358             SpaceTimeView stv = trajectory.getSpaceTimeView(Length.ZERO, length, startTime, endTime);
359             measurements[0] = stv.distance().si; // first = total traveled distance
360             measurements[1] = stv.time().si; // second = total traveled time
361         }
362 
363         @Override
364         protected double getVehicleCount(final double first, final double second)
365         {
366             return first / getSpace().getTotalLength().si; // is divided by aggregation period by caller
367         }
368 
369         @Override
370         protected double getSpeed(final double first, final double second)
371         {
372             return first / second;
373         }
374 
375         @Override
376         public String toString()
377         {
378             return "PathFdDataSource []";
379         }
380 
381     }
382 
383     /**
384      * Abstract class that deals with updating and recalculating the fundamental diagram.
385      * @param <L> lane data type
386      * @param <S> underlying source type
387      */
388     private abstract static class AbstractFdDataSource<L extends LaneData<L>, S extends AbstractGraphSpace<L>>
389             extends FdDataSource
390     {
391 
392         /** Period number of last calculated period. */
393         private int periodNumber = -1;
394 
395         /** Last update time. */
396         private Duration lastUpdateTime;
397 
398         /** Number of series. */
399         private final int nSeries;
400 
401         /** First data. */
402         private double[][] firstMeasurement;
403 
404         /** Second data. */
405         private double[][] secondMeasurement;
406 
407         /** The sampler. */
408         private final Sampler<?, L> sampler;
409 
410         /** Space. */
411         private final S space;
412 
413         /** Whether to aggregate the lanes. */
414         private final boolean aggregateLanes;
415 
416         /** Name of the series when aggregated. */
417         private String aggregateName = "Aggregate";
418 
419         /** For each series (lane), the highest trajectory number (n) below which all trajectories were also handled (0:n). */
420         private Map<L, Integer> lastConsecutivelyAssignedTrajectories = new LinkedHashMap<>();
421 
422         /** For each series (lane), a list of handled trajectories above n, excluding n+1. */
423         private Map<L, SortedSet<Integer>> assignedTrajectories = new LinkedHashMap<>();
424 
425         /**
426          * Constructor.
427          * @param sampler Sampler<?, ?>; sampler
428          * @param plotScheduler plot scheduler
429          * @param space space
430          * @param aggregateLanes whether to aggregate the lanes
431          * @param aggregationPeriod initial aggregation period
432          */
433         private AbstractFdDataSource(final Sampler<?, L> sampler, final PlotScheduler plotScheduler, final S space,
434                 final boolean aggregateLanes, final Duration aggregationPeriod)
435         {
436             super(aggregationPeriod, Duration.ONE, plotScheduler);
437             this.sampler = sampler;
438             this.space = space;
439             this.aggregateLanes = aggregateLanes;
440             this.nSeries = aggregateLanes ? 1 : space.getNumberOfSeries();
441             // create and register kpi lane directions
442             for (L laneDirection : space)
443             {
444                 sampler.registerSpaceTimeRegion(new SpaceTimeRegion<>(laneDirection, Length.ZERO, laneDirection.getLength(),
445                         sampler.now(), Duration.ofSI(Double.MAX_VALUE)));
446 
447                 // info per kpi lane direction
448                 this.lastConsecutivelyAssignedTrajectories.put(laneDirection, -1);
449                 this.assignedTrajectories.put(laneDirection, new TreeSet<>());
450             }
451             this.firstMeasurement = new double[this.nSeries][10];
452             this.secondMeasurement = new double[this.nSeries][10];
453         }
454 
455         /**
456          * Returns the space.
457          * @return space
458          */
459         protected S getSpace()
460         {
461             return this.space;
462         }
463 
464         @Override
465         public Duration getDelay()
466         {
467             return Duration.ONE;
468         }
469 
470         @Override
471         public FdPaintState getPaintState(final Duration time)
472         {
473             boolean redo;
474             Duration aggregationPeriod;
475             synchronized (AbstractFdDataSource.this)
476             {
477                 redo = getAndResetInvalidTimeSpan();
478                 aggregationPeriod = getAggregationPeriod();
479             }
480 
481             if (redo)
482             {
483                 this.periodNumber = -1;
484                 this.firstMeasurement = new double[AbstractFdDataSource.this.nSeries][10];
485                 this.secondMeasurement = new double[AbstractFdDataSource.this.nSeries][10];
486                 this.lastConsecutivelyAssignedTrajectories.clear();
487                 this.assignedTrajectories.clear();
488                 for (L lane : AbstractFdDataSource.this.space)
489                 {
490                     AbstractFdDataSource.this.lastConsecutivelyAssignedTrajectories.put(lane, -1);
491                     AbstractFdDataSource.this.assignedTrajectories.put(lane, new TreeSet<>());
492                 }
493                 this.lastUpdateTime = null;
494             }
495 
496             while ((AbstractFdDataSource.this.periodNumber + 2) * aggregationPeriod.si <= time.si)
497             {
498                 increaseTime(Duration.ofSI((this.periodNumber + 2) * aggregationPeriod.si), aggregationPeriod);
499             }
500 
501             return buildPaintState(aggregationPeriod);
502         }
503 
504         /**
505          * Add time slice to data.
506          * @param time end time of slice
507          * @param aggregationPeriod aggregation period
508          */
509         private void increaseTime(final Duration time, final Duration aggregationPeriod)
510         {
511             if (time.si < getAggregationPeriod().si)
512             {
513                 // skip periods that fall below 0.0 time
514                 return;
515             }
516             this.lastUpdateTime = time;
517 
518             // ensure capacity
519             int nextPeriod = this.periodNumber + 1;
520             if (nextPeriod >= this.firstMeasurement[0].length - 1)
521             {
522                 for (int i = 0; i < this.nSeries; i++)
523                 {
524                     this.firstMeasurement[i] = GraphUtil.ensureCapacity(this.firstMeasurement[i], nextPeriod + 1);
525                     this.secondMeasurement[i] = GraphUtil.ensureCapacity(this.secondMeasurement[i], nextPeriod + 1);
526                 }
527             }
528 
529             // loop positions and trajectories
530             Duration startTime = time.minus(aggregationPeriod);
531             double first = 0;
532             double second = 0.0;
533             for (int series = 0; series < this.space.getNumberOfSeries(); series++)
534             {
535                 Iterator<L> it = this.space.iterator(series);
536                 while (it.hasNext())
537                 {
538                     L lane = it.next();
539                     if (!this.sampler.getSamplerData().contains(lane))
540                     {
541                         // sampler has not yet started to record on this lane
542                         continue;
543                     }
544                     TrajectoryGroup<?> trajectoryGroup = this.sampler.getSamplerData().getTrajectoryGroup(lane).get();
545                     int last = this.lastConsecutivelyAssignedTrajectories.get(lane);
546                     SortedSet<Integer> assigned = this.assignedTrajectories.get(lane);
547                     if (!this.aggregateLanes)
548                     {
549                         first = 0.0;
550                         second = 0.0;
551                     }
552 
553                     int i = 0;
554                     for (Trajectory<?> trajectory : trajectoryGroup.getTrajectories())
555                     {
556                         // we can skip all assigned trajectories, which are all up to and including 'last' and all in 'assigned'
557                         if (i > last && !assigned.contains(i))
558                         {
559                             // quickly filter
560                             if (GraphUtil.considerTrajectory(trajectory, startTime, time))
561                             {
562                                 double[] measurements = new double[2];
563                                 getMeasurements(trajectory, startTime, time, lane.getLength(), series, measurements);
564                                 first += measurements[0];
565                                 second += measurements[1];
566                             }
567                             if (trajectory.getT(trajectory.size() - 1) < startTime.si - getDelay().si)
568                             {
569                                 assigned.add(i);
570                             }
571                         }
572                         i++;
573                     }
574                     if (!this.aggregateLanes)
575                     {
576                         this.firstMeasurement[series][nextPeriod] = first;
577                         this.secondMeasurement[series][nextPeriod] = second;
578                     }
579 
580                     // consolidate list of assigned trajectories in 'all up to n' and 'these specific ones beyond n'
581                     if (!assigned.isEmpty())
582                     {
583                         int possibleNextLastAssigned = assigned.first();
584                         while (possibleNextLastAssigned == last + 1) // consecutive or very first
585                         {
586                             last = possibleNextLastAssigned;
587                             assigned.remove(possibleNextLastAssigned);
588                             possibleNextLastAssigned = assigned.isEmpty() ? -1 : assigned.first();
589                         }
590                         this.lastConsecutivelyAssignedTrajectories.put(lane, last);
591                     }
592                 }
593             }
594             if (this.aggregateLanes)
595             {
596                 // whatever we measured, it was summed and can be normalized per line like this
597                 this.firstMeasurement[0][nextPeriod] = first / this.space.getNumberOfSeries();
598                 this.secondMeasurement[0][nextPeriod] = second / this.space.getNumberOfSeries();
599             }
600             this.periodNumber = nextPeriod;
601         }
602 
603         /**
604          * Builds the current state of data for painting.
605          * @param aggregationPeriod aggregation period
606          * @return current paint state
607          */
608         private FdPaintState buildPaintState(final Duration aggregationPeriod)
609         {
610             FdSeries[] series = new FdSeries[getNumberOfSeries()];
611             int n = this.periodNumber + 1;
612             for (int i = 0; i < getNumberOfSeries(); i++)
613             {
614                 float[] q = new float[n];
615                 float[] v = new float[n];
616                 float[] k = new float[n];
617                 for (int j = 0; j < n; j++)
618                 {
619                     q[j] = (float) (3600 * getItemFlow(i, j, aggregationPeriod));
620                     v[j] = (float) (3.6 * getItemSpeed(i, j));
621                     k[j] = q[j] / v[j];
622                 }
623                 FdSeries serie = new FdSeries(q, v, k);
624                 series[i] = serie;
625             }
626             return new FdPaintState(series, this.lastUpdateTime);
627         }
628 
629         @Override
630         public int getNumberOfSeries()
631         {
632             return this.nSeries;
633         }
634 
635         @Override
636         public void setAggregateName(final String aggregateName)
637         {
638             this.aggregateName = aggregateName;
639         }
640 
641         @Override
642         public String getName(final int series)
643         {
644             if (this.aggregateLanes)
645             {
646                 return this.aggregateName;
647             }
648             return this.space.getName(series);
649         }
650 
651         @Override
652         public final boolean isAggregate()
653         {
654             return this.aggregateLanes;
655         }
656 
657         /**
658          * Returns the flow value for the given item.
659          * @param series series
660          * @param item item in series
661          * @param aggregationPeriod aggregation period
662          * @return flow value for item
663          */
664         private double getItemFlow(final int series, final int item, final Duration aggregationPeriod)
665         {
666             return getVehicleCount(this.firstMeasurement[series][item], this.secondMeasurement[series][item])
667                     / aggregationPeriod.si;
668         }
669 
670         /**
671          * Returns the speed value for the given item.
672          * @param series series
673          * @param item item in series
674          * @return speed value for item
675          */
676         private double getItemSpeed(final int series, final int item)
677         {
678             return getSpeed(this.firstMeasurement[series][item], this.secondMeasurement[series][item]);
679         }
680 
681         /**
682          * Returns the first and the second measurement of a trajectory. For a cross-section this is 1 and the vehicle speed if
683          * the trajectory crosses the location, and for a path it is the traveled distance and the traveled time. If the
684          * trajectory didn't cross the cross section or space-time range, both should be 0.
685          * @param trajectory trajectory
686          * @param startTime start time of aggregation period
687          * @param endTime end time of aggregation period
688          * @param length length of the section (to cut off possible lane overshoot of trajectories)
689          * @param series series number in the section
690          * @param measurements array with length 2 to place the first and second measurement in
691          */
692         protected abstract void getMeasurements(Trajectory<?> trajectory, Duration startTime, Duration endTime, Length length,
693                 int series, double[] measurements);
694 
695         /**
696          * Returns the vehicle count of two related measurement values. For a cross section: vehicle count & sum of speeds (or
697          * sum of inverted speeds for the harmonic mean). For a path: total traveled distance & total traveled time.
698          * <p>
699          * The value will be divided by the aggregation time to calculate flow. Hence, for a cross section the first measurement
700          * should be returned, while for a path the first measurement divided by the section length should be returned. That
701          * will end up to equate to {@code q = sum(x)/XT}.
702          * @param first first measurement value
703          * @param second second measurement value
704          * @return flow
705          */
706         protected abstract double getVehicleCount(double first, double second);
707 
708         /**
709          * Returns the speed of two related measurement values. For a cross section: vehicle count & sum of speeds (or sum of
710          * inverted speeds for the harmonic mean). For a path: total traveled distance & total traveled time.
711          * @param first first measurement value
712          * @param second second measurement value
713          * @return speed
714          */
715         protected abstract double getSpeed(double first, double second);
716 
717     }
718 
719     /**
720      * Class to group multiple sources in plot.
721      */
722     private static final class MultiFdSource extends FdDataSource
723     {
724 
725         /** Sources. */
726         private FdDataSource[] sources;
727 
728         /** Source names. */
729         private String[] sourceNames;
730 
731         /**
732          * Constructor.
733          * @param sources sources
734          */
735         private MultiFdSource(final Map<String, FdDataSource> sources)
736         {
737             super(sources.values().iterator().next().getAggregationPeriod(), Duration.ONE,
738                     sources.values().iterator().next().getPlotScheduler());
739             Throw.when(sources == null || sources.size() == 0, IllegalArgumentException.class,
740                     "At least 1 source is required.");
741             this.sources = new FdDataSource[sources.size()];
742             this.sourceNames = new String[sources.size()];
743             int index = 0;
744             for (Entry<String, FdDataSource> entry : sources.entrySet())
745             {
746                 this.sources[index] = entry.getValue();
747                 this.sourceNames[index] = entry.getKey();
748                 index++;
749             }
750         }
751 
752         /**
753          * Returns from a series number overall, the index of the sub-source and the series index in that source.
754          * @param series overall series number
755          * @return index of the sub-source and the series index in that source
756          */
757         private int[] getSourceAndSeries(final int series)
758         {
759             int source = 0;
760             int sourceSeries = series;
761             while (sourceSeries >= this.sources[source].getNumberOfSeries())
762             {
763                 sourceSeries -= this.sources[source].getNumberOfSeries();
764                 source++;
765             }
766             return new int[] {source, sourceSeries};
767         }
768 
769         @Override
770         public void setAggregationPeriod(final Duration period)
771         {
772             for (FdDataSource source : this.sources)
773             {
774                 source.setAggregationPeriod(period);
775             }
776         }
777 
778         @Override
779         public void setUpdatesPerPeriod(final int n)
780         {
781             for (FdDataSource source : this.sources)
782             {
783                 source.setUpdatesPerPeriod(n);
784             }
785         }
786 
787         @Override
788         public Duration getDelay()
789         {
790             return this.sources[0].getDelay();
791         }
792 
793         @Override
794         public int getNumberOfSeries()
795         {
796             int numberOfSeries = 0;
797             for (FdDataSource source : this.sources)
798             {
799                 numberOfSeries += source.getNumberOfSeries();
800             }
801             return numberOfSeries;
802         }
803 
804         @Override
805         public String getName(final int series)
806         {
807             int[] ss = getSourceAndSeries(series);
808             return this.sourceNames[ss[0]]
809                     + (this.sources[ss[0]].isAggregate() ? "" : ": " + this.sources[ss[0]].getName(ss[1]));
810         }
811 
812         @Override
813         public FdPaintState getPaintState(final Duration time)
814         {
815             List<FdPaintState> paintStates = new ArrayList<>();
816             int n = 0;
817             for (FdDataSource source : this.sources)
818             {
819                 FdPaintState state = source.getPaintState(time);
820                 paintStates.add(state);
821                 n += state.getSeriesCount();
822             }
823             FdSeries[] series = new FdSeries[n];
824             int i = 0;
825             for (FdPaintState state : paintStates)
826             {
827                 System.arraycopy(state.fdSeries(), 0, series, i, state.getSeriesCount());
828                 i += state.getSeriesCount();
829             }
830             return new FdPaintState(series, time);
831         }
832 
833         @Override
834         public boolean isAggregate()
835         {
836             return false;
837         }
838 
839         @Override
840         public void setAggregateName(final String aggregateName)
841         {
842             // invalid for this source type
843         }
844 
845     }
846 
847 }