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.
- mr_dapa-0.4.0/LICENSE +21 -0
- mr_dapa-0.4.0/PKG-INFO +16 -0
- mr_dapa-0.4.0/README.md +106 -0
- mr_dapa-0.4.0/mr_dapa/__init__.py +58 -0
- mr_dapa-0.4.0/mr_dapa/adapters/__init__.py +5 -0
- mr_dapa-0.4.0/mr_dapa/adapters/base.py +7 -0
- mr_dapa-0.4.0/mr_dapa/adapters/csv_adapter.py +50 -0
- mr_dapa-0.4.0/mr_dapa/adapters/json_adapter.py +11 -0
- mr_dapa-0.4.0/mr_dapa/adapters/multi_file_adapter.py +16 -0
- mr_dapa-0.4.0/mr_dapa/adapters/numpy_adapter.py +34 -0
- mr_dapa-0.4.0/mr_dapa/components/base.py +30 -0
- mr_dapa-0.4.0/mr_dapa/components/components.py +5 -0
- mr_dapa-0.4.0/mr_dapa/components/fill.py +147 -0
- mr_dapa-0.4.0/mr_dapa/components/lines.py +162 -0
- mr_dapa-0.4.0/mr_dapa/components/map.py +115 -0
- mr_dapa-0.4.0/mr_dapa/components/scatter.py +96 -0
- mr_dapa-0.4.0/mr_dapa/drawers/animation.py +102 -0
- mr_dapa-0.4.0/mr_dapa/drawers/base.py +147 -0
- mr_dapa-0.4.0/mr_dapa/drawers/drawers.py +5 -0
- mr_dapa-0.4.0/mr_dapa/drawers/static_global.py +38 -0
- mr_dapa-0.4.0/mr_dapa/drawers/static_group.py +37 -0
- mr_dapa-0.4.0/mr_dapa/drawers/static_separate.py +51 -0
- mr_dapa-0.4.0/mr_dapa/helpers/base_interpreter.py +135 -0
- mr_dapa-0.4.0/mr_dapa/helpers/grid_layout.py +138 -0
- mr_dapa-0.4.0/mr_dapa/helpers/loader.py +21 -0
- mr_dapa-0.4.0/mr_dapa/registry.py +49 -0
- mr_dapa-0.4.0/mr_dapa/style.py +87 -0
- mr_dapa-0.4.0/mr_dapa.egg-info/PKG-INFO +16 -0
- mr_dapa-0.4.0/mr_dapa.egg-info/SOURCES.txt +39 -0
- mr_dapa-0.4.0/mr_dapa.egg-info/dependency_links.txt +1 -0
- mr_dapa-0.4.0/mr_dapa.egg-info/requires.txt +11 -0
- mr_dapa-0.4.0/mr_dapa.egg-info/top_level.txt +1 -0
- mr_dapa-0.4.0/pyproject.toml +32 -0
- mr_dapa-0.4.0/setup.cfg +4 -0
- mr_dapa-0.4.0/tests/test_adapters.py +205 -0
- mr_dapa-0.4.0/tests/test_components.py +184 -0
- mr_dapa-0.4.0/tests/test_drawers.py +210 -0
- mr_dapa-0.4.0/tests/test_grid_layout.py +106 -0
- mr_dapa-0.4.0/tests/test_interpreter.py +224 -0
- mr_dapa-0.4.0/tests/test_loader.py +44 -0
- 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
|
mr_dapa-0.4.0/README.md
ADDED
|
@@ -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,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,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
|