bfplot 1.0.0__py3-none-any.whl
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.
- bfplot/__init__.py +38 -0
- bfplot/adapters/__init__.py +1 -0
- bfplot/adapters/matplotlib.py +543 -0
- bfplot/api.py +196 -0
- bfplot/artifact.py +302 -0
- bfplot/bfir.py +263 -0
- bfplot/bfir.schema.json +110 -0
- bfplot/preview.py +553 -0
- bfplot/py.typed +0 -0
- bfplot/result.py +231 -0
- bfplot/temporal.py +212 -0
- bfplot-1.0.0.dist-info/METADATA +220 -0
- bfplot-1.0.0.dist-info/RECORD +15 -0
- bfplot-1.0.0.dist-info/WHEEL +4 -0
- bfplot-1.0.0.dist-info/licenses/LICENSE +21 -0
bfplot/result.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
from matplotlib.figure import Figure
|
|
9
|
+
|
|
10
|
+
from .adapters.matplotlib import translate_figures
|
|
11
|
+
from .artifact import prepare_destination, read_hash, write_result
|
|
12
|
+
from .bfir import BfirDocument
|
|
13
|
+
from .temporal import Animation, FigureSource
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def figures_from(figure: FigureSource) -> list[Figure]:
|
|
17
|
+
if figure is None:
|
|
18
|
+
return [plt.figure(number) for number in plt.get_fignums()]
|
|
19
|
+
if isinstance(figure, Figure):
|
|
20
|
+
return [figure]
|
|
21
|
+
figures = list(figure)
|
|
22
|
+
if not all(isinstance(item, Figure) for item in figures):
|
|
23
|
+
raise TypeError("figure must be a Matplotlib Figure or iterable of Figures")
|
|
24
|
+
return figures
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Collection:
|
|
28
|
+
"""Named figures and any number of animations, combined into one BFIR document."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._groups: list[BfirDocument | Animation] = []
|
|
32
|
+
self._names: set[str] = set()
|
|
33
|
+
self._next_figure = 0
|
|
34
|
+
self._next_artist = 0
|
|
35
|
+
self._closed = False
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def names(self) -> list[str]:
|
|
39
|
+
"""Names of the figures captured so far, in result order."""
|
|
40
|
+
names: list[str] = []
|
|
41
|
+
for group in self._groups:
|
|
42
|
+
document = group.initial_bfir if isinstance(group, Animation) else group
|
|
43
|
+
names.extend(
|
|
44
|
+
figure["name"] for figure in document.document["figures"] if "name" in figure
|
|
45
|
+
)
|
|
46
|
+
return names
|
|
47
|
+
|
|
48
|
+
def add(
|
|
49
|
+
self,
|
|
50
|
+
figure: FigureSource = None,
|
|
51
|
+
*,
|
|
52
|
+
name: str | Iterable[str | None] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Capture the current state of one or more figures.
|
|
55
|
+
|
|
56
|
+
``name`` labels a single figure, or gives one name per figure for several. When
|
|
57
|
+
omitted, a figure's Matplotlib label (``plt.figure("spectrum")``) is used if it
|
|
58
|
+
is unique within the result.
|
|
59
|
+
"""
|
|
60
|
+
self._check_open()
|
|
61
|
+
figures = figures_from(figure)
|
|
62
|
+
names = self._claim_names(figures, name)
|
|
63
|
+
document, self._next_artist = translate_figures(
|
|
64
|
+
figures,
|
|
65
|
+
names=names,
|
|
66
|
+
first_figure=self._next_figure,
|
|
67
|
+
first_artist=self._next_artist,
|
|
68
|
+
)
|
|
69
|
+
self._next_figure += len(figures)
|
|
70
|
+
self._groups.append(document)
|
|
71
|
+
|
|
72
|
+
def animation(
|
|
73
|
+
self,
|
|
74
|
+
figure: FigureSource = None,
|
|
75
|
+
*,
|
|
76
|
+
name: str | Iterable[str | None] | None = None,
|
|
77
|
+
fps: float = 30,
|
|
78
|
+
loop: bool = True,
|
|
79
|
+
) -> Animation:
|
|
80
|
+
"""Record a timeline for some figures of this result.
|
|
81
|
+
|
|
82
|
+
Each animation has its own timeline and playback controls. Its figures appear
|
|
83
|
+
where the animation started; figures added with ``add()`` stay static.
|
|
84
|
+
"""
|
|
85
|
+
self._check_open()
|
|
86
|
+
figures = figures_from(figure)
|
|
87
|
+
names = self._claim_names(figures, name)
|
|
88
|
+
first_figure, first_artist = self._next_figure, self._next_artist
|
|
89
|
+
next_artist = first_artist
|
|
90
|
+
|
|
91
|
+
def translator(source: FigureSource) -> BfirDocument:
|
|
92
|
+
nonlocal next_artist
|
|
93
|
+
document, next_artist = translate_figures(
|
|
94
|
+
figures_from(source),
|
|
95
|
+
names=names,
|
|
96
|
+
first_figure=first_figure,
|
|
97
|
+
first_artist=first_artist,
|
|
98
|
+
)
|
|
99
|
+
return document
|
|
100
|
+
|
|
101
|
+
recording = Animation(figures, fps=fps, loop=loop, translator=translator)
|
|
102
|
+
self._next_figure += len(figures)
|
|
103
|
+
self._next_artist = next_artist
|
|
104
|
+
self._groups.append(recording)
|
|
105
|
+
return recording
|
|
106
|
+
|
|
107
|
+
def _check_open(self) -> None:
|
|
108
|
+
if self._closed:
|
|
109
|
+
raise RuntimeError("result is closed")
|
|
110
|
+
|
|
111
|
+
def _finish(self) -> BfirDocument:
|
|
112
|
+
for group in self._groups:
|
|
113
|
+
if isinstance(group, Animation):
|
|
114
|
+
group.close()
|
|
115
|
+
self._closed = True
|
|
116
|
+
if not self._groups:
|
|
117
|
+
raise ValueError("a result needs at least one figure")
|
|
118
|
+
return self._document()
|
|
119
|
+
|
|
120
|
+
def _claim_names(
|
|
121
|
+
self, figures: list[Figure], name: str | Iterable[str | None] | None
|
|
122
|
+
) -> list[str | None]:
|
|
123
|
+
if isinstance(name, str):
|
|
124
|
+
if len(figures) != 1:
|
|
125
|
+
raise ValueError("a single name needs exactly one figure; pass a list of names")
|
|
126
|
+
requested: list[str | None] = [name]
|
|
127
|
+
elif name is None:
|
|
128
|
+
requested = [None] * len(figures)
|
|
129
|
+
else:
|
|
130
|
+
requested = list(name)
|
|
131
|
+
if len(requested) != len(figures):
|
|
132
|
+
raise ValueError("pass one name per figure")
|
|
133
|
+
claimed: list[str | None] = []
|
|
134
|
+
pending = set(self._names)
|
|
135
|
+
for figure, explicit in zip(figures, requested, strict=True):
|
|
136
|
+
if explicit is not None:
|
|
137
|
+
if not isinstance(explicit, str) or not explicit:
|
|
138
|
+
raise ValueError("figure names must be non-empty strings")
|
|
139
|
+
if explicit in pending:
|
|
140
|
+
raise ValueError(f"figure name {explicit!r} is already used in this result")
|
|
141
|
+
chosen: str | None = explicit
|
|
142
|
+
else:
|
|
143
|
+
label = figure.get_label()
|
|
144
|
+
chosen = label if label and label not in pending else None
|
|
145
|
+
if chosen is not None:
|
|
146
|
+
pending.add(chosen)
|
|
147
|
+
claimed.append(chosen)
|
|
148
|
+
self._names = pending
|
|
149
|
+
return claimed
|
|
150
|
+
|
|
151
|
+
def _document(self) -> BfirDocument:
|
|
152
|
+
figures: list[dict[str, Any]] = []
|
|
153
|
+
buffers: dict[str, Any] = {}
|
|
154
|
+
timelines: list[dict[str, Any]] = []
|
|
155
|
+
for group in self._groups:
|
|
156
|
+
document = group.finalized_bfir if isinstance(group, Animation) else group
|
|
157
|
+
renamed: dict[str, str] = {}
|
|
158
|
+
for buffer_id, buffer in document.buffers.items():
|
|
159
|
+
renamed[buffer_id] = f"buffer-{len(buffers)}"
|
|
160
|
+
buffers[renamed[buffer_id]] = buffer
|
|
161
|
+
content = _rename_buffers(document.document, renamed)
|
|
162
|
+
figures.extend(content["figures"])
|
|
163
|
+
timelines.extend(content.get("timelines", []))
|
|
164
|
+
content: dict[str, Any] = {"figures": figures}
|
|
165
|
+
if timelines:
|
|
166
|
+
content["timelines"] = timelines
|
|
167
|
+
return BfirDocument(content, buffers)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Result(Collection):
|
|
171
|
+
"""One BFPlot output: named figures and any number of animations.
|
|
172
|
+
|
|
173
|
+
Figures are captured when they are added. Nothing is written until the result
|
|
174
|
+
closes, and a result whose ``with`` block raises writes nothing. An existing BFPlot
|
|
175
|
+
result at the same path is replaced; any other existing file or directory is
|
|
176
|
+
reported when the result is created, before the script does expensive work.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, path: str | Path, *, preview: bool = True, _stacklevel: int = 1):
|
|
180
|
+
super().__init__()
|
|
181
|
+
self.path = Path(path)
|
|
182
|
+
self.preview = bool(preview)
|
|
183
|
+
prepare_destination(self.path, stacklevel=_stacklevel + 1)
|
|
184
|
+
self._written: Path | None = None
|
|
185
|
+
self._hash: str | None = None
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def hash(self) -> str | None:
|
|
189
|
+
"""The written result's hash (``"sha256:..."``), or ``None`` until it is closed."""
|
|
190
|
+
return self._hash
|
|
191
|
+
|
|
192
|
+
def close(self) -> Path:
|
|
193
|
+
"""Write the result and return its path."""
|
|
194
|
+
if self._written is not None:
|
|
195
|
+
return self._written
|
|
196
|
+
self._check_open()
|
|
197
|
+
document = self._finish()
|
|
198
|
+
self._written = write_result(self.path, document, preview=self.preview)
|
|
199
|
+
self._hash = read_hash(self._written)
|
|
200
|
+
return self._written
|
|
201
|
+
|
|
202
|
+
def abort(self) -> None:
|
|
203
|
+
"""Discard the result without writing anything."""
|
|
204
|
+
self._closed = True
|
|
205
|
+
for group in self._groups:
|
|
206
|
+
if isinstance(group, Animation) and not group.closed:
|
|
207
|
+
group.abort()
|
|
208
|
+
|
|
209
|
+
def __enter__(self) -> Result:
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def __exit__(self, exception_type: Any, *_: Any) -> None:
|
|
213
|
+
if exception_type is None:
|
|
214
|
+
self.close()
|
|
215
|
+
else:
|
|
216
|
+
self.abort()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _rename_buffers(value: Any, renamed: dict[str, str]) -> Any:
|
|
220
|
+
if isinstance(value, dict):
|
|
221
|
+
if set(value) == {"$buffer"}:
|
|
222
|
+
return {"$buffer": renamed[value["$buffer"]]}
|
|
223
|
+
return {key: _rename_buffers(item, renamed) for key, item in value.items()}
|
|
224
|
+
if isinstance(value, list):
|
|
225
|
+
return [_rename_buffers(item, renamed) for item in value]
|
|
226
|
+
return value
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def result(path: str | Path, *, preview: bool = True) -> Result:
|
|
230
|
+
"""Collect figures and animations into one output at ``path``."""
|
|
231
|
+
return Result(path, preview=preview, _stacklevel=2)
|
bfplot/temporal.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from matplotlib.figure import Figure
|
|
11
|
+
|
|
12
|
+
from .bfir import ANIMATABLE, BfirDocument
|
|
13
|
+
|
|
14
|
+
FigureSource = Figure | Iterable[Figure] | None
|
|
15
|
+
Translator = Callable[[FigureSource], BfirDocument]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Animation:
|
|
19
|
+
"""Record mutable Matplotlib figures as a timeline of semantic BFIR updates.
|
|
20
|
+
|
|
21
|
+
Each ``capture()`` translates the figures again and stores only the fields that
|
|
22
|
+
changed. Figures, axes and artists cannot be added or removed while recording.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
figure: FigureSource,
|
|
28
|
+
*,
|
|
29
|
+
fps: float = 30,
|
|
30
|
+
loop: bool = True,
|
|
31
|
+
translator: Translator,
|
|
32
|
+
finisher: Callable[[BfirDocument], Path] | None = None,
|
|
33
|
+
):
|
|
34
|
+
if not math.isfinite(fps) or fps <= 0:
|
|
35
|
+
raise ValueError("fps must be a positive finite number")
|
|
36
|
+
self.figure = figure
|
|
37
|
+
self.fps = float(fps)
|
|
38
|
+
self.loop = bool(loop)
|
|
39
|
+
self._translator = translator
|
|
40
|
+
self._finisher = finisher
|
|
41
|
+
self._closed = False
|
|
42
|
+
self._result: Path | None = None
|
|
43
|
+
self._final: BfirDocument | None = None
|
|
44
|
+
self._initial = translator(figure)
|
|
45
|
+
self._previous = self._initial
|
|
46
|
+
self._buffers = dict(self._initial.buffers)
|
|
47
|
+
self._frames: list[dict[str, Any]] = []
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def result(self) -> Path | None:
|
|
51
|
+
"""The written result, or ``None`` while recording or inside ``bfplot.result()``."""
|
|
52
|
+
return self._result
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def closed(self) -> bool:
|
|
56
|
+
return self._closed
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def frame_count(self) -> int:
|
|
60
|
+
return len(self._frames)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def initial_bfir(self) -> BfirDocument:
|
|
64
|
+
"""A copy of the scene that playback starts from."""
|
|
65
|
+
initial = self._initial
|
|
66
|
+
return BfirDocument(copy.deepcopy(initial.document), dict(initial.buffers))
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def finalized_bfir(self) -> BfirDocument:
|
|
70
|
+
"""The temporal BFIR document produced by ``close()``."""
|
|
71
|
+
if self._final is None:
|
|
72
|
+
raise RuntimeError("animation has not been closed")
|
|
73
|
+
return self._final
|
|
74
|
+
|
|
75
|
+
def capture(self, *, time: float | None = None) -> None:
|
|
76
|
+
"""Capture the current figure state at a strictly increasing time (default: next frame)."""
|
|
77
|
+
if self._closed:
|
|
78
|
+
raise RuntimeError("animation is closed")
|
|
79
|
+
last = self._frames[-1]["time"] if self._frames else 0.0
|
|
80
|
+
timestamp = last + 1 / self.fps if time is None else float(time)
|
|
81
|
+
if not math.isfinite(timestamp):
|
|
82
|
+
raise ValueError("frame time must be finite")
|
|
83
|
+
if timestamp <= last:
|
|
84
|
+
raise ValueError("frame times must be strictly increasing and greater than zero")
|
|
85
|
+
current = self._translator(self.figure)
|
|
86
|
+
updates = _Diff(self._previous, current, self._buffers).updates
|
|
87
|
+
self._frames.append({"time": timestamp, "updates": updates})
|
|
88
|
+
self._previous = current
|
|
89
|
+
|
|
90
|
+
def close(self) -> Path | None:
|
|
91
|
+
"""Finish recording; a standalone animation is written and its path returned."""
|
|
92
|
+
if self._final is not None:
|
|
93
|
+
return self._result
|
|
94
|
+
if self._closed:
|
|
95
|
+
raise RuntimeError("animation was aborted")
|
|
96
|
+
self._closed = True
|
|
97
|
+
duration = self._frames[-1]["time"] if self._frames else 0.0
|
|
98
|
+
document = copy.deepcopy(self._initial.document)
|
|
99
|
+
figure_ids = [figure["id"] for figure in document["figures"]]
|
|
100
|
+
document["timelines"] = [
|
|
101
|
+
{
|
|
102
|
+
"figures": figure_ids,
|
|
103
|
+
"fps": self.fps,
|
|
104
|
+
"duration": duration,
|
|
105
|
+
"loop": self.loop,
|
|
106
|
+
"frames": self._frames,
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
final = BfirDocument(document, self._buffers)
|
|
110
|
+
final.validate()
|
|
111
|
+
self._final = final
|
|
112
|
+
if self._finisher is not None:
|
|
113
|
+
self._result = self._finisher(final)
|
|
114
|
+
return self._result
|
|
115
|
+
|
|
116
|
+
def abort(self) -> None:
|
|
117
|
+
"""Stop recording without writing anything."""
|
|
118
|
+
self._closed = True
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> Animation:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(self, exception_type: Any, *_: Any) -> None:
|
|
124
|
+
if exception_type is None:
|
|
125
|
+
self.close()
|
|
126
|
+
else:
|
|
127
|
+
self.abort()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _Diff:
|
|
131
|
+
"""The timeline updates that turn ``old`` into ``new``.
|
|
132
|
+
|
|
133
|
+
Changed buffers are copied into ``buffers`` (the animation's buffer table) under
|
|
134
|
+
fresh IDs so that every frame's arrays remain addressable.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, old: BfirDocument, new: BfirDocument, buffers: dict[str, Any]):
|
|
138
|
+
self.old, self.new, self.buffers = old, new, buffers
|
|
139
|
+
self.updates: list[dict[str, Any]] = []
|
|
140
|
+
for old_figure, new_figure in self._pair(old.document, new.document, "figures"):
|
|
141
|
+
self._fields("figure", old_figure, new_figure)
|
|
142
|
+
for old_axes, new_axes in self._pair(old_figure, new_figure, "axes"):
|
|
143
|
+
self._fields("axes", old_axes, new_axes)
|
|
144
|
+
for old_artist, new_artist in self._pair(old_axes, new_axes, "artists"):
|
|
145
|
+
if old_artist["type"] != new_artist["type"]:
|
|
146
|
+
raise ValueError("animation cannot change an artist type")
|
|
147
|
+
for section in sorted(ANIMATABLE["artist"]):
|
|
148
|
+
before, after = old_artist[section], new_artist[section]
|
|
149
|
+
if before.keys() != after.keys():
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"animation cannot add or remove artist {section} fields"
|
|
152
|
+
)
|
|
153
|
+
for key in before:
|
|
154
|
+
self._compare("artist", old_artist["id"], [section, key], before, after)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _pair(old: dict[str, Any], new: dict[str, Any], key: str) -> list[tuple[Any, Any]]:
|
|
158
|
+
before = {item["id"]: item for item in old[key]}
|
|
159
|
+
after = {item["id"]: item for item in new[key]}
|
|
160
|
+
if before.keys() != after.keys():
|
|
161
|
+
hint = "; update visibility instead" if key == "artists" else ""
|
|
162
|
+
raise ValueError(f"animation cannot add or remove {key}{hint}")
|
|
163
|
+
return [(item, after[item_id]) for item_id, item in before.items()]
|
|
164
|
+
|
|
165
|
+
def _fields(self, kind: str, old: dict[str, Any], new: dict[str, Any]) -> None:
|
|
166
|
+
for field in sorted(ANIMATABLE[kind]):
|
|
167
|
+
if (field in old) != (field in new):
|
|
168
|
+
raise ValueError(f"animation cannot add or remove {kind} field {field!r}")
|
|
169
|
+
if field in old:
|
|
170
|
+
self._compare(kind, old["id"], [field], old, new)
|
|
171
|
+
|
|
172
|
+
def _compare(
|
|
173
|
+
self, kind: str, target: str, path: list[str], old: dict[str, Any], new: dict[str, Any]
|
|
174
|
+
) -> None:
|
|
175
|
+
key = path[-1]
|
|
176
|
+
if not self._equal(old[key], new[key]):
|
|
177
|
+
value = self._import(new[key])
|
|
178
|
+
self.updates.append(
|
|
179
|
+
{"target_kind": kind, "target": target, "path": path, "value": value}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _equal(self, left: Any, right: Any) -> bool:
|
|
183
|
+
if isinstance(left, dict) and "$buffer" in left:
|
|
184
|
+
if not (isinstance(right, dict) and "$buffer" in right):
|
|
185
|
+
return False
|
|
186
|
+
first = self.old.buffers[left["$buffer"]].array
|
|
187
|
+
second = self.new.buffers[right["$buffer"]].array
|
|
188
|
+
return (
|
|
189
|
+
first.dtype == second.dtype
|
|
190
|
+
and first.shape == second.shape
|
|
191
|
+
and np.array_equal(first, second, equal_nan=True)
|
|
192
|
+
)
|
|
193
|
+
if type(left) is not type(right):
|
|
194
|
+
return False
|
|
195
|
+
if isinstance(left, dict):
|
|
196
|
+
return left.keys() == right.keys() and all(
|
|
197
|
+
self._equal(left[key], right[key]) for key in left
|
|
198
|
+
)
|
|
199
|
+
if isinstance(left, list):
|
|
200
|
+
return len(left) == len(right) and all(map(self._equal, left, right))
|
|
201
|
+
return left == right
|
|
202
|
+
|
|
203
|
+
def _import(self, value: Any) -> Any:
|
|
204
|
+
if isinstance(value, dict):
|
|
205
|
+
if "$buffer" in value:
|
|
206
|
+
buffer_id = f"buffer-{len(self.buffers)}"
|
|
207
|
+
self.buffers[buffer_id] = self.new.buffers[value["$buffer"]]
|
|
208
|
+
return {"$buffer": buffer_id}
|
|
209
|
+
return {key: self._import(item) for key, item in value.items()}
|
|
210
|
+
if isinstance(value, list):
|
|
211
|
+
return [self._import(item) for item in value]
|
|
212
|
+
return copy.deepcopy(value)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bfplot
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Interactive, reproducible scientific plots, with a Python implementation for Matplotlib
|
|
5
|
+
Project-URL: Homepage, https://bfichera.github.io/bfplot/
|
|
6
|
+
Project-URL: Documentation, https://bfichera.github.io/bfplot/
|
|
7
|
+
Project-URL: Repository, https://github.com/bfichera/bfplot
|
|
8
|
+
Project-URL: Issues, https://github.com/bfichera/bfplot/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/bfichera/bfplot/releases
|
|
10
|
+
Author: BFPlot contributors
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: matplotlib,plotting,reproducibility,scientific,visualization
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: Matplotlib
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: matplotlib>=3.7
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Requires-Dist: zstandard>=0.21
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# BFPlot
|
|
28
|
+
|
|
29
|
+
> **[Read the documentation →](https://bfichera.github.io/bfplot/)**
|
|
30
|
+
|
|
31
|
+
Basically I was tired of saving `matplotlib` outputs as non-interactive static files. I also [hate jupyter notebooks](https://www.youtube.com/watch?v=7jiPeIFXb6U)! `bfplot` lets you save `matplotlib` figures:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import matplotlib.pyplot as plt
|
|
35
|
+
import bfplot
|
|
36
|
+
|
|
37
|
+
fig, ax = plt.subplots()
|
|
38
|
+
ax.plot([1, 2, 3, 4], [1, 4, 9, 16], marker="o")
|
|
39
|
+
|
|
40
|
+
bfplot.savefig("my_plot.bfplot", fig)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
in a format that you can open up and interact with later using an integrated viewer app:
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ bfplot view my_plot.bfplot
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Here's what the viewer looks like:
|
|
50
|
+
|
|
51
|
+

|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
Install the `bfplot` app, which has the viewer built in, on macOS or Linux:
|
|
56
|
+
|
|
57
|
+
```console
|
|
58
|
+
$ curl -LsSf https://github.com/bfichera/bfplot/releases/latest/download/install.sh | sh
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then install the Python interface (Python 3.10+):
|
|
62
|
+
|
|
63
|
+
```console
|
|
64
|
+
$ python -m pip install bfplot
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
See [Installation](https://bfichera.github.io/bfplot/getting-started/installation/) for uninstalling and other options.
|
|
68
|
+
|
|
69
|
+
## Building from source
|
|
70
|
+
|
|
71
|
+
On other platforms, or to build everything yourself, you need:
|
|
72
|
+
|
|
73
|
+
- Python 3.10+ (pip 25.1+ if you want the development dependency groups)
|
|
74
|
+
- Rust 1.88+ (install with [rustup](https://rustup.rs))
|
|
75
|
+
- Node.js, only if you change the viewer
|
|
76
|
+
|
|
77
|
+
Clone the repository and install both parts into a virtual environment:
|
|
78
|
+
|
|
79
|
+
```console
|
|
80
|
+
$ git clone https://github.com/bfichera/bfplot.git
|
|
81
|
+
$ cd bfplot
|
|
82
|
+
$ python -m venv .venv
|
|
83
|
+
$ . .venv/bin/activate
|
|
84
|
+
$ python -m pip install --upgrade pip
|
|
85
|
+
$ python -m pip install ./crates/bfplot-cli -e .
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`./crates/bfplot-cli` compiles the `bfplot` app with Cargo (through maturin) and installs it into the same environment as the Python library, where `bfplot.show()` finds it even if the environment is not activated. `-e .` installs the Python library in editable mode. Check both parts:
|
|
89
|
+
|
|
90
|
+
```console
|
|
91
|
+
$ python -c "import bfplot; print(bfplot.__version__)"
|
|
92
|
+
$ bfplot --version
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
To build only the app, without Python:
|
|
96
|
+
|
|
97
|
+
```console
|
|
98
|
+
$ cargo build --release --locked -p bfplot-cli
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The binary is written to `target/release/bfplot`; copy it anywhere on your `PATH`.
|
|
102
|
+
|
|
103
|
+
For the development setup, tests and linters, see [Contributing](https://github.com/bfichera/bfplot/blob/main/CONTRIBUTING.md).
|
|
104
|
+
|
|
105
|
+
## Results
|
|
106
|
+
|
|
107
|
+
The code here:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
with bfplot.result("results/fit.bfplot") as result:
|
|
111
|
+
result.add(fig_spectrum, name="spectrum")
|
|
112
|
+
result.add(fig_residuals, name="residuals")
|
|
113
|
+
```
|
|
114
|
+
produces a directory called `results/fit.bfplot` with the following structure:
|
|
115
|
+
```text
|
|
116
|
+
results/fit.bfplot/
|
|
117
|
+
├── manifest.toml
|
|
118
|
+
├── .gitignore
|
|
119
|
+
└── objects/
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The `.gitignore` file keeps the data (in `objects/`) out of version control.
|
|
123
|
+
|
|
124
|
+
By default, `manifest.toml` ends with a rough ASCII/Braille drawing of each figure, written as `#` comments. It lets you see the plot in a diff or commit without opening the viewer. Pass `preview=False` to leave it out.
|
|
125
|
+
|
|
126
|
+
```toml
|
|
127
|
+
# Preview (approximate; regenerated on every save)
|
|
128
|
+
#
|
|
129
|
+
# ── calibration · Detector calibration ──────────────────────────────────
|
|
130
|
+
# 1.1┌──────────────── Response ─────────────────┐
|
|
131
|
+
# s │⠐⠲⣄⡴⠒⠲⡄ ⣠⠖⠂ │
|
|
132
|
+
# i │ ⢠⠟⣆ ⠙⣆ ⣰⠃ │
|
|
133
|
+
# g │⢀⠏ ⠘⣆ ⠘⡆ ⣰⠃ │
|
|
134
|
+
# -1.1└───────────────────────────────────────────┘
|
|
135
|
+
# 0 6.3
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Animations
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
with bfplot.animation("wave.bfplot", fig, fps=30) as recording:
|
|
142
|
+
for phase in np.linspace(0, 2 * np.pi, 180, endpoint=False):
|
|
143
|
+
line.set_ydata(np.sin(x + phase))
|
|
144
|
+
recording.capture()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Hashes
|
|
148
|
+
|
|
149
|
+
Every result has one hash, recorded in its manifest. Two results have the same hash exactly when everything in them is identical.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
bfplot.hash("results/fit.bfplot") # 'sha256:769fe097...'
|
|
153
|
+
bfplot.hash(fig) # the same figure, hashed without writing
|
|
154
|
+
bfplot.same("old/fit.bfplot", "new/fit.bfplot")
|
|
155
|
+
bfplot.verify("results/fit.bfplot") # check every object, return the hash
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```console
|
|
159
|
+
$ bfplot hash results/fit.bfplot
|
|
160
|
+
sha256:769fe0976cfeecf73414d7b467013c6cde561405bce6c5af5828836e03016e37
|
|
161
|
+
$ bfplot compare old/fit.bfplot new/fit.bfplot
|
|
162
|
+
different
|
|
163
|
+
sha256:769fe097... old/fit.bfplot
|
|
164
|
+
sha256:45a78207... new/fit.bfplot
|
|
165
|
+
figures[0].axes[0].title: "Tent" -> "Tall tent"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Serving
|
|
169
|
+
|
|
170
|
+
`bfplot view` serves a result and opens it in your browser. `bfplot serve` does the same without opening a browser. By default the server listens on `127.0.0.1:8080`; use `--port` to change the port.
|
|
171
|
+
|
|
172
|
+
To serve remotely, bind to another address explicitly:
|
|
173
|
+
|
|
174
|
+
```console
|
|
175
|
+
$ bfplot serve my_plot.bfplot --bind 0.0.0.0 --port 9000
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Example: Using BFPlot with psana (SLAC S3DF)
|
|
179
|
+
|
|
180
|
+
On S3DF (`ssh <username>@s3df.slac.stanford.edu`, then to your psana node), start the server without a browser:
|
|
181
|
+
|
|
182
|
+
```console
|
|
183
|
+
$ bfplot serve /path/to/my_plot.bfplot --port 9000
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
On your own machine, forward the port through S3DF to the node running the server:
|
|
187
|
+
|
|
188
|
+
```console
|
|
189
|
+
$ ssh -L 9000:<psana-node>:9000 <username>@s3df.slac.stanford.edu
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Then open <http://127.0.0.1:9000> in your local browser. If you run the server on the login node itself, use `localhost` in place of `<psana-node>`.
|
|
193
|
+
|
|
194
|
+
## Supported plots
|
|
195
|
+
|
|
196
|
+
Lines, scatter plots, images, error bars, filled regions, histograms, contours, text, patches and common 3D plots. Anything else is shown as a visible placeholder, never silently dropped.
|
|
197
|
+
|
|
198
|
+
To see them all, run the [example gallery](https://github.com/bfichera/bfplot/blob/main/examples/gallery.py): `python examples/gallery.py`, then `bfplot view gallery.bfplot`.
|
|
199
|
+
|
|
200
|
+
| Images, contours and colorbars | Three-dimensional geometry |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
|  |  |
|
|
203
|
+
|
|
204
|
+
| Dense data |
|
|
205
|
+
| --- |
|
|
206
|
+
|  |
|
|
207
|
+
|
|
208
|
+
The first two gallery figures are independent animations: each has its own play button, timeline slider and frame rate, shown in the left rail while that figure is on screen.
|
|
209
|
+
|
|
210
|
+
## Security
|
|
211
|
+
|
|
212
|
+
Only open bfplot results from sources you trust.
|
|
213
|
+
|
|
214
|
+
## More
|
|
215
|
+
|
|
216
|
+
- [Contributing](https://github.com/bfichera/bfplot/blob/main/CONTRIBUTING.md)
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT License: [LICENSE](https://github.com/bfichera/bfplot/blob/main/LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
bfplot/__init__.py,sha256=3IjdsUrpQnPWzpPtDbmesg2mhT4XTyt-VfLftHl-XY8,630
|
|
2
|
+
bfplot/api.py,sha256=h6enDQ8WJIpd3skqsctFwmkI9lydTorZ4S4-1pms1nc,6097
|
|
3
|
+
bfplot/artifact.py,sha256=M6yCHVsxZWNRlsh3TM5PCHE-o88TpwzDobDmOh5gR2I,11183
|
|
4
|
+
bfplot/bfir.py,sha256=7Ga518wa3lRkQ00nX1djX0wtuj6WfMAqYmVH_BJ9RTI,10923
|
|
5
|
+
bfplot/bfir.schema.json,sha256=YiWOOfgmZ_TyRyep9wB9pZT7ngVILg9EYuPaAn6LIJY,3677
|
|
6
|
+
bfplot/preview.py,sha256=81qm3c95hEYlzs5-f1fDYEv4dkD7HTAFj0Tr-D0gt6g,22916
|
|
7
|
+
bfplot/result.py,sha256=J3O7X-_s2G1tYlrjIyTnPKJVZvubXDaYES6-o3psS3s,8615
|
|
8
|
+
bfplot/temporal.py,sha256=XABRnuh8WcPVK6E3I8-7wPVKkvfzgC3kNihfFwC5dcM,8507
|
|
9
|
+
bfplot/adapters/__init__.py,sha256=BGfxJ4NVAT5SSvi_cm8YmwhJjyAfRnzhoraRyx_Ep34,82
|
|
10
|
+
bfplot/adapters/matplotlib.py,sha256=yXHuCgC8dNSc3NQsbuSMrZ2-7VVW92M59b53nWXybjE,21956
|
|
11
|
+
bfplot/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
bfplot-1.0.0.dist-info/METADATA,sha256=VeNl5wAXF73TINPip6iYTz42zZ7oWBmkW2WkmgaqR9Q,8252
|
|
13
|
+
bfplot-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
bfplot-1.0.0.dist-info/licenses/LICENSE,sha256=8REjcyQ-BVwBMIhJQlfq1vUST8Z4URtceoWaTOWYBXQ,1070
|
|
15
|
+
bfplot-1.0.0.dist-info/RECORD,,
|