mr-dapa 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. mr_dapa-0.4.0/LICENSE +21 -0
  2. mr_dapa-0.4.0/PKG-INFO +16 -0
  3. mr_dapa-0.4.0/README.md +106 -0
  4. mr_dapa-0.4.0/mr_dapa/__init__.py +58 -0
  5. mr_dapa-0.4.0/mr_dapa/adapters/__init__.py +5 -0
  6. mr_dapa-0.4.0/mr_dapa/adapters/base.py +7 -0
  7. mr_dapa-0.4.0/mr_dapa/adapters/csv_adapter.py +50 -0
  8. mr_dapa-0.4.0/mr_dapa/adapters/json_adapter.py +11 -0
  9. mr_dapa-0.4.0/mr_dapa/adapters/multi_file_adapter.py +16 -0
  10. mr_dapa-0.4.0/mr_dapa/adapters/numpy_adapter.py +34 -0
  11. mr_dapa-0.4.0/mr_dapa/components/base.py +30 -0
  12. mr_dapa-0.4.0/mr_dapa/components/components.py +5 -0
  13. mr_dapa-0.4.0/mr_dapa/components/fill.py +147 -0
  14. mr_dapa-0.4.0/mr_dapa/components/lines.py +162 -0
  15. mr_dapa-0.4.0/mr_dapa/components/map.py +115 -0
  16. mr_dapa-0.4.0/mr_dapa/components/scatter.py +96 -0
  17. mr_dapa-0.4.0/mr_dapa/drawers/animation.py +102 -0
  18. mr_dapa-0.4.0/mr_dapa/drawers/base.py +147 -0
  19. mr_dapa-0.4.0/mr_dapa/drawers/drawers.py +5 -0
  20. mr_dapa-0.4.0/mr_dapa/drawers/static_global.py +38 -0
  21. mr_dapa-0.4.0/mr_dapa/drawers/static_group.py +37 -0
  22. mr_dapa-0.4.0/mr_dapa/drawers/static_separate.py +51 -0
  23. mr_dapa-0.4.0/mr_dapa/helpers/base_interpreter.py +135 -0
  24. mr_dapa-0.4.0/mr_dapa/helpers/grid_layout.py +138 -0
  25. mr_dapa-0.4.0/mr_dapa/helpers/loader.py +21 -0
  26. mr_dapa-0.4.0/mr_dapa/registry.py +49 -0
  27. mr_dapa-0.4.0/mr_dapa/style.py +87 -0
  28. mr_dapa-0.4.0/mr_dapa.egg-info/PKG-INFO +16 -0
  29. mr_dapa-0.4.0/mr_dapa.egg-info/SOURCES.txt +39 -0
  30. mr_dapa-0.4.0/mr_dapa.egg-info/dependency_links.txt +1 -0
  31. mr_dapa-0.4.0/mr_dapa.egg-info/requires.txt +11 -0
  32. mr_dapa-0.4.0/mr_dapa.egg-info/top_level.txt +1 -0
  33. mr_dapa-0.4.0/pyproject.toml +32 -0
  34. mr_dapa-0.4.0/setup.cfg +4 -0
  35. mr_dapa-0.4.0/tests/test_adapters.py +205 -0
  36. mr_dapa-0.4.0/tests/test_components.py +184 -0
  37. mr_dapa-0.4.0/tests/test_drawers.py +210 -0
  38. mr_dapa-0.4.0/tests/test_grid_layout.py +106 -0
  39. mr_dapa-0.4.0/tests/test_interpreter.py +224 -0
  40. mr_dapa-0.4.0/tests/test_loader.py +44 -0
  41. mr_dapa-0.4.0/tests/test_style.py +143 -0
mr_dapa-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Yang Siyuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mr_dapa-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: mr-dapa
3
+ Version: 0.4.0
4
+ Summary: Multi-Robot Data Animation & Plotting Assistance
5
+ Requires-Python: >=3.9
6
+ License-File: LICENSE
7
+ Requires-Dist: numpy
8
+ Requires-Dist: matplotlib
9
+ Requires-Dist: tqdm
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == "dev"
12
+ Requires-Dist: pytest-cov; extra == "dev"
13
+ Requires-Dist: ruff; extra == "dev"
14
+ Provides-Extra: all
15
+ Requires-Dist: ffmpeg-python; extra == "all"
16
+ Dynamic: license-file
@@ -0,0 +1,106 @@
1
+ # mr-dapa — Multi-Robot Data Animation & Plotting Assistance
2
+
3
+ Rapid visualization of multi-agent time-series data. Researchers have N robots' timestamped data (possibly async, possibly from different sources) and need to quickly generate static plots and animations.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ pip install -e .
9
+ python examples/minimal/generate_data.py
10
+ python examples/minimal/main.py
11
+ ```
12
+
13
+ ## API Overview
14
+
15
+ ```python
16
+ import mr_dapa as mrdp
17
+
18
+ components = {
19
+ 'x': {'title': 'X Position', 'class': 'LinesComponent', 'keys': ['x']},
20
+ 'map': {'title': 'Map', 'class': 'MapComponent', 'limits': {"x": [-3, 7], "y": [-3, 7]}},
21
+ }
22
+
23
+ # Static plot — returns figure (no file saved)
24
+ fig = mrdp.StaticGlobalPlotDrawer(files=['data.json'], components=components).draw(['x', 'map'])
25
+
26
+ # Chain API for filtering
27
+ mrdp.StaticGlobalPlotDrawer(files=['data.json'], components=components) \
28
+ .set_id_list([1, 3]) \
29
+ .set_time_range((0.2, 0.5)) \
30
+ .draw(['x'], save=True) # save=True writes to file
31
+
32
+ # Animation
33
+ mrdp.AnimationDrawer(files=['data.json'], components=components) \
34
+ .draw(['x', 'map'], time_ratio=2, save=True)
35
+ ```
36
+
37
+ ### Draw Modes
38
+
39
+ | Drawer | Behavior |
40
+ |--------|----------|
41
+ | `StaticGlobalPlotDrawer` | All robots in shared subplots |
42
+ | `StaticSeparatePlotDrawer` | One figure per robot |
43
+ | `StaticGroupPlotDrawer` | All robots, per-robot subplots in one figure |
44
+ | `AnimationDrawer` | Time-based MP4 animation |
45
+
46
+ ### Components
47
+
48
+ | Component | Description |
49
+ |-----------|-------------|
50
+ | `LinesComponent` | Time-series line plots |
51
+ | `MapComponent` | 2D position map with trails |
52
+ | `ScatterComponent` | Scatter/phase plot (x vs y) |
53
+ | `FillComponent` | Filled area between values |
54
+
55
+ ### Adapters
56
+
57
+ ```python
58
+ from mr_dapa import CSVAdapter, MultiFileAdapter, NumPyAdapter
59
+
60
+ loader = mrdp.StaticGlobalPlotDrawer(files=['data.csv'], components=components, adapter=CSVAdapter())
61
+ loader = mrdp.StaticGlobalPlotDrawer(files=['r1.json', 'r2.json'], components=components, adapter=MultiFileAdapter())
62
+ ```
63
+
64
+ ## Data Format
65
+
66
+ Canonical JSON format (each robot has its own timestamp array — supports async data):
67
+
68
+ ```json
69
+ [
70
+ {
71
+ "id": 1,
72
+ "timestamp": [0.0, 0.02, 0.04],
73
+ "values": [
74
+ {"name": "X Position", "alias": "x", "unit": "m", "value": [1.0, 1.1, 1.2]},
75
+ {"name": "Y Position", "alias": "y", "unit": "m", "value": [2.0, 2.1, 2.2]}
76
+ ]
77
+ }
78
+ ]
79
+ ```
80
+
81
+ ## Requirements
82
+
83
+ - Python >= 3.9
84
+ - numpy, matplotlib, tqdm
85
+ - ffmpeg (for animation export)
86
+
87
+ ## Installation
88
+
89
+ ```bash
90
+ pip install -e .
91
+ pip install -e ".[dev]" # includes pytest, ruff
92
+ ```
93
+
94
+ ## Testing
95
+
96
+ ```bash
97
+ pytest tests/ # 145 tests
98
+ pytest tests/ -v # verbose
99
+ pytest tests/ -k "adapter" # filter by name
100
+ ```
101
+
102
+ Test structure: `conftest.py` provides shared fixtures (sample_data, interpreter, components_config). Each `test_*.py` covers one module.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,58 @@
1
+ from .drawers.drawers import (
2
+ StaticGlobalPlotDrawer,
3
+ StaticSeparatePlotDrawer,
4
+ StaticGroupPlotDrawer,
5
+ AnimationDrawer,
6
+ )
7
+
8
+ from .components.components import (
9
+ LinesComponent,
10
+ MapComponent,
11
+ ScatterComponent,
12
+ FillComponent,
13
+ )
14
+
15
+ from .adapters import (
16
+ DataAdapter,
17
+ JSONAdapter,
18
+ MultiFileAdapter,
19
+ CSVAdapter,
20
+ NumPyAdapter,
21
+ )
22
+
23
+ from .registry import (
24
+ register_component,
25
+ unregister_component,
26
+ get_component_class,
27
+ list_components,
28
+ )
29
+
30
+ from .components.base import BaseComponent
31
+
32
+ from .style import StyleConfig, get_style, get_palette
33
+
34
+ __version__ = '0.4.0'
35
+
36
+ __all__ = [
37
+ 'StaticGlobalPlotDrawer',
38
+ 'StaticSeparatePlotDrawer',
39
+ 'StaticGroupPlotDrawer',
40
+ 'AnimationDrawer',
41
+ 'LinesComponent',
42
+ 'MapComponent',
43
+ 'ScatterComponent',
44
+ 'FillComponent',
45
+ 'BaseComponent',
46
+ 'DataAdapter',
47
+ 'JSONAdapter',
48
+ 'MultiFileAdapter',
49
+ 'CSVAdapter',
50
+ 'NumPyAdapter',
51
+ 'register_component',
52
+ 'unregister_component',
53
+ 'get_component_class',
54
+ 'list_components',
55
+ 'StyleConfig',
56
+ 'get_style',
57
+ 'get_palette',
58
+ ]
@@ -0,0 +1,5 @@
1
+ from .base import DataAdapter as DataAdapter
2
+ from .json_adapter import JSONAdapter as JSONAdapter
3
+ from .multi_file_adapter import MultiFileAdapter as MultiFileAdapter
4
+ from .csv_adapter import CSVAdapter as CSVAdapter
5
+ from .numpy_adapter import NumPyAdapter as NumPyAdapter
@@ -0,0 +1,7 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+
4
+ @runtime_checkable
5
+ class DataAdapter(Protocol):
6
+ def load(self, source) -> list[dict]:
7
+ ...
@@ -0,0 +1,50 @@
1
+ import csv
2
+
3
+
4
+ class CSVAdapter:
5
+ def __init__(self, id_col='id', timestamp_col='timestamp', separator=','):
6
+ self.id_col = id_col
7
+ self.timestamp_col = timestamp_col
8
+ self.separator = separator
9
+
10
+ def load(self, source) -> list[dict]:
11
+ if not isinstance(source, str):
12
+ raise TypeError(f"CSVAdapter expects a file path, got {type(source)}")
13
+
14
+ robots = {}
15
+ with open(source, newline='') as f:
16
+ reader = csv.DictReader(f, delimiter=self.separator)
17
+ for row in reader:
18
+ robot_id = int(row[self.id_col])
19
+ if robot_id not in robots:
20
+ robots[robot_id] = {'timestamps': [], 'values': {}}
21
+
22
+ ts = float(row[self.timestamp_col])
23
+ robots[robot_id]['timestamps'].append(ts)
24
+
25
+ for col in row:
26
+ if col in (self.id_col, self.timestamp_col):
27
+ continue
28
+ if col not in robots[robot_id]['values']:
29
+ robots[robot_id]['values'][col] = {'timestamps': [], 'value': []}
30
+ robots[robot_id]['values'][col]['timestamps'].append(ts)
31
+ robots[robot_id]['values'][col]['value'].append(float(row[col]))
32
+
33
+ result = []
34
+ for robot_id in sorted(robots.keys()):
35
+ r = robots[robot_id]
36
+ values = []
37
+ for name, v in r['values'].items():
38
+ values.append({
39
+ 'name': name,
40
+ 'alias': name,
41
+ 'unit': '',
42
+ 'timestamp': v['timestamps'],
43
+ 'value': v['value'],
44
+ })
45
+ result.append({
46
+ 'id': robot_id,
47
+ 'timestamp': r['timestamps'],
48
+ 'values': values,
49
+ })
50
+ return result
@@ -0,0 +1,11 @@
1
+ import json
2
+
3
+
4
+ class JSONAdapter:
5
+ def load(self, source) -> list[dict]:
6
+ if isinstance(source, str):
7
+ with open(source) as f:
8
+ return json.load(f)
9
+ if isinstance(source, list):
10
+ return source
11
+ raise TypeError(f"JSONAdapter expects a file path or list, got {type(source)}")
@@ -0,0 +1,16 @@
1
+ import json
2
+
3
+
4
+ class MultiFileAdapter:
5
+ def load(self, source) -> list[dict]:
6
+ if isinstance(source, list) and len(source) > 0 and isinstance(source[0], str):
7
+ merged = []
8
+ for path in source:
9
+ with open(path) as f:
10
+ data = json.load(f)
11
+ if isinstance(data, list):
12
+ merged.extend(data)
13
+ else:
14
+ merged.append(data)
15
+ return merged
16
+ raise TypeError(f"MultiFileAdapter expects a list of file paths, got {type(source)}")
@@ -0,0 +1,34 @@
1
+ import numpy as np
2
+
3
+
4
+ class NumPyAdapter:
5
+ def load(self, source) -> list[dict]:
6
+ if not isinstance(source, dict):
7
+ raise TypeError(f"NumPyAdapter expects a dict, got {type(source)}")
8
+
9
+ result = []
10
+ for robot_id, robot_data in source.items():
11
+ robot_id = int(robot_id)
12
+ timestamp = robot_data.get('timestamp')
13
+ if timestamp is not None:
14
+ timestamp = np.asarray(timestamp).tolist()
15
+
16
+ values = []
17
+ for key, arr in robot_data.get('values', {}).items():
18
+ val_arr = np.asarray(arr).tolist()
19
+ ts = timestamp if timestamp is not None else list(range(len(val_arr)))
20
+ if isinstance(ts, np.ndarray):
21
+ ts = ts.tolist()
22
+ values.append({
23
+ 'name': key,
24
+ 'alias': key,
25
+ 'unit': robot_data.get('units', {}).get(key, ''),
26
+ 'timestamp': ts,
27
+ 'value': val_arr,
28
+ })
29
+
30
+ entry = {'id': robot_id, 'values': values}
31
+ if timestamp is not None:
32
+ entry['timestamp'] = timestamp
33
+ result.append(entry)
34
+ return result
@@ -0,0 +1,30 @@
1
+ class BaseComponent:
2
+ FIGSIZE = (6, 6)
3
+ expand = True
4
+ required_config_keys: dict[str, type] = {}
5
+
6
+ def __init__(self, ax, interpreter, title="", mode='static', **kwargs):
7
+ self.ax = ax
8
+ self.interpreter = interpreter
9
+ self.title = title
10
+ self.mode = mode
11
+ self.kwargs = kwargs
12
+
13
+ @classmethod
14
+ def validate_config(cls, name: str, config: dict) -> None:
15
+ for key, expected_type in cls.required_config_keys.items():
16
+ if key not in config:
17
+ raise ValueError(
18
+ f"Component '{name}' ({cls.__name__}): missing required key '{key}'"
19
+ )
20
+ if expected_type is not None and not isinstance(config[key], expected_type):
21
+ raise ValueError(
22
+ f"Component '{name}' ({cls.__name__}): key '{key}' must be "
23
+ f"{expected_type.__name__}, got {type(config[key]).__name__}"
24
+ )
25
+
26
+ def _initialize(self):
27
+ raise NotImplementedError
28
+
29
+ def update(self, timestamp):
30
+ return []
@@ -0,0 +1,5 @@
1
+ from .base import BaseComponent as BaseComponent
2
+ from .lines import LinesComponent as LinesComponent
3
+ from .map import MapComponent as MapComponent
4
+ from .scatter import ScatterComponent as ScatterComponent
5
+ from .fill import FillComponent as FillComponent
@@ -0,0 +1,147 @@
1
+ import numpy as np
2
+
3
+ from .base import BaseComponent
4
+
5
+
6
+ class FillComponent(BaseComponent):
7
+ FIGSIZE = (6, 6)
8
+ expand = True
9
+ required_config_keys = {'keys': list}
10
+
11
+ def __init__(self, ax, interpreter, title="", keys=None, mode='static', **kwargs):
12
+ super().__init__(ax, interpreter, title=title, mode=mode, **kwargs)
13
+ self.keys = keys or []
14
+ self.units = self.interpreter.get_units(self.keys)
15
+ self.single_unit = len(self.units) == 1
16
+
17
+ self.lines = {}
18
+ self.fill_collections = {}
19
+ self.markers = {}
20
+ self.value_texts = {}
21
+ self.vline = None
22
+ self.y_limits = None
23
+
24
+ self._initialize()
25
+
26
+ def _initialize(self):
27
+ self.ax.set_title(self.title)
28
+ self.ax.set_xlabel("Time (s)")
29
+ ylabel = "Values" + ((" (" + self.units[0] + ")") if self.single_unit else "")
30
+ self.ax.set_ylabel(ylabel)
31
+
32
+ fill_color = self.kwargs.get('fill_color', 'blue')
33
+ fill_alpha = self.kwargs.get('fill_alpha', 0.2)
34
+
35
+ for frame in self.interpreter.data:
36
+ upper_data = None
37
+ lower_data = None
38
+
39
+ for value in frame["values"]:
40
+ if value["alias"] in self.keys or value["name"] in self.keys:
41
+ if upper_data is None:
42
+ upper_data = value
43
+ else:
44
+ lower_data = value
45
+
46
+ if upper_data is not None and lower_data is not None:
47
+ label_suffix = f", Robot #{frame['id']}" if len(self.interpreter.id_list) > 1 else ""
48
+ line_upper, = self.ax.plot(
49
+ upper_data["timestamp"], upper_data["value"],
50
+ label=upper_data["alias"] + label_suffix, alpha=0.7
51
+ )
52
+ line_lower, = self.ax.plot(
53
+ lower_data["timestamp"], lower_data["value"],
54
+ label=lower_data["alias"] + label_suffix, alpha=0.7
55
+ )
56
+ self.lines[f"{frame['id']}-upper"] = line_upper
57
+ self.lines[f"{frame['id']}-lower"] = line_lower
58
+
59
+ fill = self.ax.fill_between(
60
+ upper_data["timestamp"],
61
+ upper_data["value"],
62
+ lower_data["value"],
63
+ color=fill_color,
64
+ alpha=fill_alpha
65
+ )
66
+ self.fill_collections[frame['id']] = fill
67
+
68
+ elif upper_data is not None:
69
+ label_suffix = f", Robot #{frame['id']}" if len(self.interpreter.id_list) > 1 else ""
70
+ line, = self.ax.plot(
71
+ upper_data["timestamp"], upper_data["value"],
72
+ label=upper_data["alias"] + label_suffix
73
+ )
74
+ self.lines[f"{frame['id']}-single"] = line
75
+
76
+ fill = self.ax.fill_between(
77
+ upper_data["timestamp"],
78
+ upper_data["value"], 0,
79
+ color=fill_color,
80
+ alpha=fill_alpha
81
+ )
82
+ self.fill_collections[frame['id']] = fill
83
+
84
+ if len(self.lines) > 1:
85
+ self.ax.legend(loc='best')
86
+
87
+ if self.mode == "animation":
88
+ self._animation_setup()
89
+
90
+ def _animation_setup(self):
91
+ self.y_limits = self.ax.get_ylim()
92
+ self.vline = self.ax.plot(
93
+ [self.interpreter.time_range[0], self.interpreter.time_range[1]],
94
+ [self.y_limits[0], self.y_limits[1]],
95
+ 'r--', alpha=0.3
96
+ )[0]
97
+
98
+ marker_style = dict(marker='*', color='red', alpha=0.7, markersize=10)
99
+ text_style = dict(
100
+ color='red', alpha=0.8, fontsize=9,
101
+ bbox=dict(facecolor='white', alpha=0.3, edgecolor='none')
102
+ )
103
+
104
+ for label_key, line in self.lines.items():
105
+ marker, = self.ax.plot([np.nan], [np.nan], **marker_style)
106
+ self.markers[label_key] = marker
107
+
108
+ text = self.ax.text(
109
+ np.nan, np.nan, '', **text_style,
110
+ verticalalignment='center',
111
+ horizontalalignment='left'
112
+ )
113
+ self.value_texts[label_key] = text
114
+
115
+ def update(self, timestamp):
116
+ artists = []
117
+
118
+ if self.vline is not None:
119
+ self.vline.set_data([timestamp, timestamp], self.y_limits)
120
+ artists.append(self.vline)
121
+
122
+ timespan = self.interpreter.time_range[1] - self.interpreter.time_range[0]
123
+ time_offset = timespan * 0.015
124
+ x_limits = self.ax.get_xlim()
125
+
126
+ for label_key, line in self.lines.items():
127
+ if label_key not in self.markers:
128
+ continue
129
+ index = np.searchsorted(line.get_xdata(), timestamp)
130
+
131
+ self.markers[label_key].set_data([timestamp], [line.get_ydata()[index]])
132
+ artists.append(self.markers[label_key])
133
+
134
+ if timestamp < (x_limits[0] + x_limits[1]) / 2:
135
+ self.value_texts[label_key].set_horizontalalignment('left')
136
+ self.value_texts[label_key].set_position(
137
+ (timestamp + time_offset, line.get_ydata()[index])
138
+ )
139
+ else:
140
+ self.value_texts[label_key].set_horizontalalignment('right')
141
+ self.value_texts[label_key].set_position(
142
+ (timestamp - time_offset, line.get_ydata()[index])
143
+ )
144
+ self.value_texts[label_key].set_text(f"{line.get_ydata()[index]:.4f}")
145
+ artists.append(self.value_texts[label_key])
146
+
147
+ return artists
@@ -0,0 +1,162 @@
1
+ import numpy as np
2
+
3
+ from .base import BaseComponent
4
+
5
+
6
+ class LinesComponent(BaseComponent):
7
+ required_config_keys = {'keys': list}
8
+
9
+ def __init__(self, ax, interpreter, title="", keys=None, mode='static', **kwargs):
10
+ super().__init__(ax, interpreter, title=title, mode=mode, **kwargs)
11
+ self.keys = keys or []
12
+ self.units = self.interpreter.get_units(self.keys)
13
+ self.single_unit = len(self.units) == 1
14
+
15
+ self.lines = {}
16
+ self.markers = {}
17
+ self.value_texts = {}
18
+ self.vline = None
19
+ self.y_limits = None
20
+
21
+ self._initialize()
22
+
23
+ def _make_label(self, robot_id, name):
24
+ return f"{robot_id}-{name}"
25
+
26
+ def _initialize(self):
27
+ self.ax.set_title(self.title)
28
+ self.ax.set_xlabel("Time (s)")
29
+ ylabel = "Values" + ((" (" + self.units[0] + ")") if self.single_unit else "")
30
+ self.ax.set_ylabel(ylabel)
31
+
32
+ if 'bounds' in self.kwargs:
33
+ for b in self.kwargs['bounds']:
34
+ self.ax.axhline(b, color='black', linestyle='--', alpha=0.3)
35
+
36
+ if 'range' in self.kwargs:
37
+ self.ax.axhspan(self.kwargs['range'][0], self.kwargs['range'][1], alpha=0.1, color='grey')
38
+
39
+ if 'show_zero_line' in self.kwargs and self.kwargs.get('show_zero_line', True):
40
+ self.ax.axhline(0, color='black', alpha=0.3, linestyle='--')
41
+
42
+ if 'milestones' in self.kwargs:
43
+ for m in self.kwargs['milestones']:
44
+ self.ax.axvline(m, color='grey', alpha=0.3, linestyle='--')
45
+
46
+ if 'bars' in self.kwargs:
47
+ for bar in self.kwargs['bars']:
48
+ self.ax.axhline(bar, color='black', linestyle='--', alpha=0.3)
49
+
50
+ marker_style = dict(marker='*', color='red', alpha=0.7, markersize=10)
51
+ text_style = dict(
52
+ color='red', alpha=0.8, fontsize=9,
53
+ bbox=dict(facecolor='white', alpha=0.3, edgecolor='none')
54
+ )
55
+
56
+ for robot in self.interpreter.data:
57
+ for value in robot["values"]:
58
+ if value["alias"] not in self.keys and value["name"] not in self.keys:
59
+ continue
60
+ marker, = self.ax.plot([np.nan], [np.nan], **marker_style)
61
+ self.markers[self._make_label(robot["id"], value["name"])] = marker
62
+
63
+ text = self.ax.text(
64
+ np.nan, np.nan, '', **text_style,
65
+ verticalalignment='center',
66
+ horizontalalignment='left'
67
+ )
68
+ self.value_texts[self._make_label(robot["id"], value["name"])] = text
69
+
70
+ for frame in self.interpreter.data:
71
+ for value in frame["values"]:
72
+ if value["alias"] not in self.keys and value["name"] not in self.keys:
73
+ continue
74
+ label = value["name"] if self.mode == "separate" else value["alias"]
75
+ if not self.single_unit:
76
+ label += f"({value['unit']})"
77
+ if len(self.interpreter.id_list) > 1:
78
+ label += f", Robot #{frame['id']}"
79
+ line, = self.ax.plot(
80
+ value["timestamp"], value["value"], label=label
81
+ )
82
+ self.lines[self._make_label(frame['id'], value["name"])] = line
83
+
84
+ if self.kwargs.get('fill', False):
85
+ for frame in self.interpreter.data:
86
+ for value in frame["values"]:
87
+ if value["alias"] not in self.keys and value["name"] not in self.keys:
88
+ continue
89
+ self.ax.fill_between(
90
+ value["timestamp"], value["value"], 0, alpha=0.15
91
+ )
92
+
93
+ if self.kwargs.get('show_min', False):
94
+ for frame in self.interpreter.data:
95
+ for value in frame["values"]:
96
+ if value["alias"] not in self.keys and value["name"] not in self.keys:
97
+ continue
98
+ min_idx = np.argmin(value["value"])
99
+ min_x = value["timestamp"][min_idx]
100
+ min_y = value["value"][min_idx]
101
+ self.ax.annotate(
102
+ f'min: {min_y:.2f}',
103
+ xy=(min_x, min_y),
104
+ xytext=(5, 5),
105
+ textcoords='offset points',
106
+ fontsize=8,
107
+ color='blue',
108
+ alpha=0.7
109
+ )
110
+
111
+ if len(self.lines) > 1:
112
+ self.ax.legend(loc='best')
113
+
114
+ if self.mode == "animation":
115
+ self._animation_setup()
116
+
117
+ def _animation_setup(self):
118
+ self.y_limits = self.ax.get_ylim()
119
+ self.vline = self.ax.plot(
120
+ [self.interpreter.time_range[0], self.interpreter.time_range[1]],
121
+ [self.y_limits[0], self.y_limits[1]],
122
+ 'r--', alpha=0.3
123
+ )[0]
124
+
125
+ def update(self, timestamp):
126
+ artists = []
127
+
128
+ if self.vline is not None:
129
+ self.vline.set_data([timestamp, timestamp], self.y_limits)
130
+ artists.append(self.vline)
131
+
132
+ timespan = self.interpreter.time_range[1] - self.interpreter.time_range[0]
133
+ time_offset = timespan * 0.015
134
+ x_limits = self.ax.get_xlim()
135
+
136
+ for label, line in self.lines.items():
137
+ index = np.searchsorted(line.get_xdata(), timestamp)
138
+
139
+ self.markers[label].set_data([timestamp], [line.get_ydata()[index]])
140
+ artists.append(self.markers[label])
141
+
142
+ if timestamp < (x_limits[0] + x_limits[1]) / 2:
143
+ self.value_texts[label].set_horizontalalignment('left')
144
+ self.value_texts[label].set_position(
145
+ (timestamp + time_offset, line.get_ydata()[index])
146
+ )
147
+ else:
148
+ self.value_texts[label].set_horizontalalignment('right')
149
+ self.value_texts[label].set_position(
150
+ (timestamp - time_offset, line.get_ydata()[index])
151
+ )
152
+
153
+ if len(self.lines) > 1:
154
+ self.value_texts[label].set_text(f"{label}: {line.get_ydata()[index]:.4f}")
155
+ else:
156
+ self.value_texts[label].set_text(f"{line.get_ydata()[index]:.4f}")
157
+ artists.append(self.value_texts[label])
158
+
159
+ if len(self.lines) > 1:
160
+ self.ax.legend(loc='best')
161
+
162
+ return artists