Generic Time Series Programming
Overview
WhiskerToolbox provides a unified interface for working with different time series data types through C++20 concepts and generic filter utilities. This enables writing algorithms that work across all time series types while maintaining type safety at compile time.
This document covers:
- Element Accessor Pattern - Standardized
.time(),.id(),.value()methods - C++20 Concepts - Type constraints for generic programming
- Filter Utilities - Generic functions for filtering by time and EntityId
- Migration Guide - Updating code from old element access patterns
Element Types Overview
Each time series type provides element access through two methods:
| Method | Return Type | Use Case |
|---|---|---|
elements() |
std::pair<TimeFrameIndex, Element> |
Backward-compatible pair iteration |
elementsView() |
Element type directly | Concept-compliant generic algorithms |
Element Type Reference
| Series Type | Element Type | Has EntityId | Primary Value |
|---|---|---|---|
AnalogTimeSeries |
TimeValuePoint |
❌ | float |
RaggedAnalogTimeSeries |
FlatElement |
❌ | float |
RaggedTimeSeries<T> |
RaggedElement<T> |
✅ | T (e.g., Mask2D) |
DigitalEventSeries |
EventWithId |
✅ | TimeFrameIndex |
DigitalIntervalSeries |
IntervalWithId |
✅ | Interval |
Standardized Accessor Methods
All element types provide these standardized accessors:
// Common to all element types (TimeSeriesElement concept)
TimeFrameIndex time() const; // Returns the time point
// Additional for entity-bearing types (EntityElement concept)
EntityId id() const; // Returns the entity identifier
// Additional for value-bearing types (ValueElement concept)
auto value() const; // Returns the primary data valueExample: AnalogTimeSeries::TimeValuePoint
struct TimeValuePoint {
[[nodiscard]] TimeFrameIndex time() const { return _time; }
[[nodiscard]] float value() const { return _value; }
private:
TimeFrameIndex _time;
float _value;
};Example: DigitalEventSeries::EventWithId
struct EventWithId {
[[nodiscard]] TimeFrameIndex time() const { return _event_time; }
[[nodiscard]] EntityId id() const { return _entity_id; }
[[nodiscard]] TimeFrameIndex value() const { return _event_time; }
private:
TimeFrameIndex _event_time;
EntityId _entity_id;
};Example: DigitalIntervalSeries::IntervalWithId
struct IntervalWithId {
[[nodiscard]] TimeFrameIndex time() const {
return TimeFrameIndex(_interval.start);
}
[[nodiscard]] EntityId id() const { return _entity_id; }
[[nodiscard]] Interval const& value() const { return _interval; }
private:
Interval _interval;
EntityId _entity_id;
};C++20 Concepts
The TimeSeriesConcepts.hpp header defines concepts for generic programming:
#include "DataManager/utils/TimeSeriesConcepts.hpp"
using namespace WhiskerToolbox::Concepts;Concept Hierarchy
TimeSeriesElement<T>
├── requires: t.time() → TimeFrameIndex
│
├─ EntityElement<T>
│ └── requires: t.id() → EntityId
│
├─ ValueElement<T, V>
│ └── requires: t.value() → V
│
└─ FullElement<T, V>
└── requires: both EntityElement and ValueElement<T, V>
Writing Generic Functions
Time-Based Operations (All Types)
// Works with ALL time series element types
template<TimeSeriesElement T>
void printTime(T const& elem) {
std::cout << "Time: " << elem.time().getValue() << "\n";
}
// Usage with any series type
for (auto const& elem : analogSeries->elementsView()) {
printTime(elem); // TimeValuePoint
}
for (auto const& elem : eventSeries->elementsView()) {
printTime(elem); // EventWithId
}Entity-Based Operations (Types with EntityIds)
// Only compiles for entity-bearing types
template<EntityElement T>
void printEntity(T const& elem) {
std::cout << "EntityId: " << elem.id().getValue() << "\n";
}
// Works:
for (auto const& elem : eventSeries->elementsView()) {
printEntity(elem); // EventWithId has id()
}
// Compile error:
for (auto const& elem : analogSeries->elementsView()) {
printEntity(elem); // TimeValuePoint does NOT have id()
}Full Element Operations
// For types with both EntityId and Value
template<typename V, FullElement<V> T>
void processFullElement(T const& elem) {
auto t = elem.time();
auto id = elem.id();
auto val = elem.value();
// ... process all three
}Utility Functions
The concepts header provides utility functions:
// Extract time from any TimeSeriesElement
template<TimeSeriesElement T>
TimeFrameIndex getTime(T const& elem);
// Extract EntityId from any EntityElement
template<EntityElement T>
EntityId getEntityId(T const& elem);
// Check if element is in time range [start, end]
template<TimeSeriesElement T>
bool isInTimeRange(T const& elem, TimeFrameIndex start, TimeFrameIndex end);
// Check if element's EntityId is in a set
template<EntityElement T>
bool isInEntitySet(T const& elem, std::unordered_set<EntityId> const& ids);Generic Filter Utilities
The TimeSeriesFilters.hpp header provides lazy, composable filtering:
#include "DataManager/utils/TimeSeriesFilters.hpp"
using namespace WhiskerToolbox::Filters;Filter Functions
filterByTimeRange
Works with any TimeSeriesElement:
auto series = std::make_shared<AnalogTimeSeries>();
// ... populate ...
// Lazy view - no data copied
auto filtered = filterByTimeRange(
series->elementsView(),
TimeFrameIndex(100),
TimeFrameIndex(200));
for (auto const& elem : filtered) {
// Only elements with time in [100, 200]
}filterByEntityIds
Works only with EntityElement (compile-time enforced):
auto events = std::make_shared<DigitalEventSeries>();
// ... populate ...
std::unordered_set<EntityId> selected{EntityId(1), EntityId(3)};
auto filtered = filterByEntityIds(events->elementsView(), selected);
for (auto const& event : filtered) {
// Only events with EntityId 1 or 3
}filterByTimeRangeAndEntityIds
Combined filtering for efficiency:
auto filtered = filterByTimeRangeAndEntityIds(
series->elementsView(),
TimeFrameIndex(100),
TimeFrameIndex(500),
entity_ids);Composition
Filters are lazy views that can be composed:
// Chain multiple filters
auto result = filterByTimeRange(
filterByEntityIds(series->view(), ids),
start, end);
// With standard ranges
auto processed = series->elementsView()
| std::views::filter([](auto const& e) { return e.time() > TimeFrameIndex(100); })
| std::views::transform([](auto const& e) { return e.value(); });Materialization
When you need a vector instead of a lazy view:
// Convert view to vector
auto vec = materializeToVector(filterByTimeRange(elements, start, end));
// Now vec is a std::vector<ElementType>Utility Functions
Counting
// Count elements in time range
auto count = countInTimeRange(series->elementsView(), start, end);
// Count elements with matching EntityIds
auto count = countWithEntityIds(series->elementsView(), entity_ids);Predicates
// Check if any element exists in range
bool hasData = anyInTimeRange(series->elementsView(), start, end);
// Check if all elements are in range
bool allIn = allInTimeRange(series->elementsView(), start, end);Extraction
// Get all times as a view
auto times = extractTimes(series->elementsView());
// Get all EntityIds as a view
auto ids = extractEntityIds(entitySeries->elementsView());
// Get unique EntityIds as a set
auto uniqueIds = uniqueEntityIds(entitySeries->elementsView());Bounds
// Find time bounds
auto bounds = timeBounds(series->elementsView());
if (bounds) {
auto [minTime, maxTime] = *bounds;
}
// Find min/max separately
auto minT = minTime(series->elementsView());
auto maxT = maxTime(series->elementsView());Practical Examples
Example 1: Generic Statistics
#include "DataManager/utils/TimeSeriesConcepts.hpp"
using namespace WhiskerToolbox::Concepts;
// Compute statistics for any time series with float values
template<std::ranges::input_range R>
requires ValueElement<std::ranges::range_value_t<R>, float>
struct Stats {
float mean = 0;
float min = std::numeric_limits<float>::max();
float max = std::numeric_limits<float>::lowest();
size_t count = 0;
explicit Stats(R&& range) {
for (auto const& elem : range) {
float val = elem.value();
mean += val;
min = std::min(min, val);
max = std::max(max, val);
++count;
}
if (count > 0) mean /= count;
}
};
// Usage:
auto stats = Stats(analogSeries->elementsView());
auto filteredStats = Stats(filterByTimeRange(
analogSeries->elementsView(), start, end));Example 2: Entity-Aware Processing
#include "DataManager/utils/TimeSeriesFilters.hpp"
using namespace WhiskerToolbox::Filters;
// Process only selected entities in a time window
void processSelectedMasks(
MaskData const& masks,
std::unordered_set<EntityId> const& selected_entities,
TimeFrameIndex start,
TimeFrameIndex end)
{
auto filtered = filterByTimeRangeAndEntityIds(
masks.elementsView(),
start, end,
selected_entities);
for (auto const& elem : filtered) {
auto const& mask = elem.value(); // Mask2D const&
auto entityId = elem.id();
auto time = elem.time();
// Process mask...
}
}Example 3: Cross-Type Operations
// Find overlapping time ranges across different series types
template<TimeSeriesElement T1, TimeSeriesElement T2>
std::optional<std::pair<TimeFrameIndex, TimeFrameIndex>>
findOverlap(T1 const& range1, T2 const& range2)
{
auto bounds1 = timeBounds(range1);
auto bounds2 = timeBounds(range2);
if (!bounds1 || !bounds2) return std::nullopt;
auto [min1, max1] = *bounds1;
auto [min2, max2] = *bounds2;
auto overlapStart = std::max(min1, min2);
auto overlapEnd = std::min(max1, max2);
if (overlapStart <= overlapEnd) {
return std::make_pair(overlapStart, overlapEnd);
}
return std::nullopt;
}
// Usage with different series types:
auto overlap = findOverlap(
analogSeries->elementsView(),
maskData.elementsView());Migration Guide
Old Pattern → New Pattern
Direct Member Access → Accessor Methods
Before (still works, but deprecated):
// Old: Direct struct member access
for (auto [time, entry] : series.elements()) {
float val = entry.value; // Direct member
}After (preferred):
// New: Accessor method
for (auto const& elem : series.elementsView()) {
float val = elem.value(); // Accessor method
}Type-Specific Code → Generic Code
Before:
// Separate functions for each type
void processAnalog(AnalogTimeSeries const& series) {
for (auto [time, point] : series.elements()) {
// Analog-specific code
}
}
void processEvents(DigitalEventSeries const& series) {
for (auto [time, event] : series.elements()) {
// Event-specific code
}
}After:
// Single generic function
template<TimeSeriesElement T>
void processAnyTimeSeries(std::ranges::input_range auto&& range) {
for (auto const& elem : range) {
auto time = elem.time();
// Works with any time series type
}
}
// Usage:
processAnyTimeSeries(analogSeries->elementsView());
processAnyTimeSeries(eventSeries->elementsView());Manual Filtering → Filter Utilities
Before:
// Manual filtering
std::vector<EventWithId> filtered;
for (auto const& event : events.view()) {
if (event.time() >= start && event.time() <= end) {
if (ids.contains(event.id())) {
filtered.push_back(event);
}
}
}After:
// Using filter utilities
auto filtered = materializeToVector(
filterByTimeRangeAndEntityIds(events.view(), start, end, ids));Backward Compatibility
The old patterns continue to work. The new accessor methods are additive - they don’t break existing code.
| Old Pattern | Status | Notes |
|---|---|---|
elem.value (member) |
✅ Works | Renamed to _value, but still accessible |
elem.time (member) |
✅ Works | Renamed to _time, but still accessible |
series.elements() |
✅ Works | Returns pairs for structured bindings |
series.view() |
✅ Works | Returns element objects directly |
The new elementsView() method is added for concept-compliant iteration, but elements() remains for backward compatibility with code using auto [time, elem] structured bindings.
Performance Considerations
Lazy Views vs Materialization
Filter functions return lazy views that don’t copy data:
// GOOD: Lazy, no allocation
auto view = filterByTimeRange(series->elementsView(), start, end);
for (auto const& elem : view) { /* ... */ }
// AVOID: Unnecessary materialization
auto vec = materializeToVector(filterByTimeRange(series->elementsView(), start, end));
for (auto const& elem : vec) { /* ... */ } // Vector copy overheadWhen to materialize:
- Need random access to filtered elements
- Need to iterate multiple times
- Need data to outlive the source series
- Passing to APIs that require vectors
Cache-Optimized Iteration
All time series types use cache optimization for fast iteration:
// Fast: Uses cached contiguous pointers
for (auto const& elem : series->elementsView()) {
// Zero virtual dispatch when storage is contiguous
}
// Also fast: elements() uses same optimization
for (auto [time, elem] : series->elements()) {
// Cache-optimized path
}Header Files
| Header | Contents |
|---|---|
DataManager/utils/TimeSeriesConcepts.hpp |
Concepts and utility functions |
DataManager/utils/TimeSeriesFilters.hpp |
Generic filter functions |
DataManager/DigitalTimeSeries/EventWithId.hpp |
EventWithId element type |
DataManager/DigitalTimeSeries/IntervalWithId.hpp |
IntervalWithId element type |
See Also
- Data Structures Performance - Performance characteristics of storage backends
- Storage Strategy Roadmap - Complete storage refactoring documentation
- Time Frame - TimeFrameIndex and time conversion