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/bfir.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""BFPlot Intermediate Representation: semantic plot records plus typed buffers.
|
|
2
|
+
|
|
3
|
+
BFIR is the boundary between plotting-library adapters and the BFPlot writer. Any
|
|
4
|
+
producer that builds a valid ``BfirDocument`` can be compiled into a BFPlot result.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
# Fields a timeline update may replace, by target kind.
|
|
16
|
+
ANIMATABLE = {
|
|
17
|
+
"figure": {"title", "size_inches", "dpi", "facecolor"},
|
|
18
|
+
"axes": {"title", "x", "y", "z", "view", "bounds", "facecolor"},
|
|
19
|
+
"artist": {"data", "props"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BfirValidationError(ValueError):
|
|
24
|
+
"""Raised when a BFIR document violates the producer/compiler contract."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class BfirBuffer:
|
|
29
|
+
"""An immutable, C-contiguous numeric array owned by a BFIR document."""
|
|
30
|
+
|
|
31
|
+
array: np.ndarray
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_value(cls, value: Any) -> BfirBuffer:
|
|
35
|
+
source = np.asanyarray(value)
|
|
36
|
+
if np.ma.isMaskedArray(source):
|
|
37
|
+
source = source.filled(np.nan)
|
|
38
|
+
if source.dtype.kind not in "biuf":
|
|
39
|
+
raise TypeError(f"unsupported BFIR buffer dtype: {source.dtype}")
|
|
40
|
+
# Copy so later changes to the producer's array cannot leak into BFIR.
|
|
41
|
+
array = np.array(source, copy=True, order="C", subok=False)
|
|
42
|
+
array.setflags(write=False)
|
|
43
|
+
return cls(array)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class BfirDocument:
|
|
48
|
+
"""A plot document whose arrays are ``{"$buffer": id}`` references into ``buffers``."""
|
|
49
|
+
|
|
50
|
+
document: dict[str, Any]
|
|
51
|
+
buffers: dict[str, BfirBuffer]
|
|
52
|
+
|
|
53
|
+
def validate(self) -> None:
|
|
54
|
+
errors = _errors(self)
|
|
55
|
+
if errors:
|
|
56
|
+
raise BfirValidationError("invalid BFIR:\n- " + "\n- ".join(errors))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class BfirBuilder:
|
|
61
|
+
"""Collect buffers while building a document, then validate the result."""
|
|
62
|
+
|
|
63
|
+
_buffers: dict[str, BfirBuffer] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def buffer(self, value: Any) -> dict[str, str]:
|
|
66
|
+
buffer_id = f"buffer-{len(self._buffers)}"
|
|
67
|
+
self._buffers[buffer_id] = BfirBuffer.from_value(value)
|
|
68
|
+
return {"$buffer": buffer_id}
|
|
69
|
+
|
|
70
|
+
def build(self, document: dict[str, Any]) -> BfirDocument:
|
|
71
|
+
bfir = BfirDocument(document, dict(self._buffers))
|
|
72
|
+
bfir.validate()
|
|
73
|
+
return bfir
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _number(value: Any) -> bool:
|
|
77
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _errors(bfir: BfirDocument) -> list[str]:
|
|
81
|
+
errors: list[str] = []
|
|
82
|
+
document = bfir.document if isinstance(bfir.document, dict) else {}
|
|
83
|
+
if not isinstance(bfir.document, dict):
|
|
84
|
+
errors.append("document must be an object")
|
|
85
|
+
|
|
86
|
+
ids: dict[str, set[str]] = {"figure": set(), "axes": set(), "artist": set()}
|
|
87
|
+
owners: dict[str, str] = {}
|
|
88
|
+
names: set[str] = set()
|
|
89
|
+
references: list[tuple[str, str, str]] = []
|
|
90
|
+
current_figure = ""
|
|
91
|
+
|
|
92
|
+
def register(kind: str, record: Any, location: str) -> bool:
|
|
93
|
+
if not isinstance(record, dict):
|
|
94
|
+
errors.append(f"{location} must be an object")
|
|
95
|
+
return False
|
|
96
|
+
value = record.get("id")
|
|
97
|
+
if not isinstance(value, str) or not value:
|
|
98
|
+
errors.append(f"{location}.id must be a non-empty string")
|
|
99
|
+
elif any(value in known for known in ids.values()):
|
|
100
|
+
errors.append(f"duplicate id {value!r} at {location}")
|
|
101
|
+
else:
|
|
102
|
+
ids[kind].add(value)
|
|
103
|
+
owners[value] = value if kind == "figure" else current_figure
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def records(parent: dict[str, Any], key: str, location: str) -> list[Any]:
|
|
107
|
+
value = parent.get(key)
|
|
108
|
+
if not isinstance(value, list):
|
|
109
|
+
errors.append(f"{location}.{key} must be an array")
|
|
110
|
+
return []
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
for f, figure in enumerate(records(document, "figures", "document")):
|
|
114
|
+
location = f"document.figures[{f}]"
|
|
115
|
+
if not register("figure", figure, location):
|
|
116
|
+
continue
|
|
117
|
+
current_figure = figure.get("id")
|
|
118
|
+
if "name" in figure:
|
|
119
|
+
name = figure["name"]
|
|
120
|
+
if not isinstance(name, str) or not name:
|
|
121
|
+
errors.append(f"{location}.name must be a non-empty string")
|
|
122
|
+
elif name in names:
|
|
123
|
+
errors.append(f"duplicate figure name {name!r} at {location}")
|
|
124
|
+
names.add(name)
|
|
125
|
+
for a, axes in enumerate(records(figure, "axes", location)):
|
|
126
|
+
axes_location = f"{location}.axes[{a}]"
|
|
127
|
+
if not register("axes", axes, axes_location):
|
|
128
|
+
continue
|
|
129
|
+
if "parent_axes_id" in axes:
|
|
130
|
+
references.append(("axes", axes["parent_axes_id"], "parent_axes_id"))
|
|
131
|
+
colorbar = axes.get("colorbar")
|
|
132
|
+
if isinstance(colorbar, dict) and "mappable" in colorbar:
|
|
133
|
+
references.append(("artist", colorbar["mappable"], "colorbar mappable"))
|
|
134
|
+
for r, artist in enumerate(records(axes, "artists", axes_location)):
|
|
135
|
+
artist_location = f"{axes_location}.artists[{r}]"
|
|
136
|
+
if not register("artist", artist, artist_location):
|
|
137
|
+
continue
|
|
138
|
+
if artist.get("axes_id") != axes.get("id"):
|
|
139
|
+
errors.append(f"{artist_location}.axes_id must reference its containing axes")
|
|
140
|
+
if not isinstance(artist.get("type"), str):
|
|
141
|
+
errors.append(f"{artist_location}.type must be a string")
|
|
142
|
+
if not isinstance(artist.get("props"), dict) or not isinstance(
|
|
143
|
+
artist.get("data"), dict
|
|
144
|
+
):
|
|
145
|
+
errors.append(f"{artist_location}.props and .data must be objects")
|
|
146
|
+
|
|
147
|
+
for kind, target, what in references:
|
|
148
|
+
if target not in ids[kind]:
|
|
149
|
+
errors.append(f"dangling {what} {target!r}")
|
|
150
|
+
|
|
151
|
+
if "timeline" in document:
|
|
152
|
+
errors.append("document.timeline is no longer supported; use document.timelines")
|
|
153
|
+
if "timelines" in document:
|
|
154
|
+
errors.extend(_timelines_errors(document["timelines"], ids, owners))
|
|
155
|
+
|
|
156
|
+
used: set[str] = set()
|
|
157
|
+
|
|
158
|
+
def visit(value: Any, location: str) -> None:
|
|
159
|
+
if isinstance(value, dict):
|
|
160
|
+
if "$buffer" not in value:
|
|
161
|
+
for key, item in value.items():
|
|
162
|
+
visit(item, f"{location}.{key}")
|
|
163
|
+
elif set(value) != {"$buffer"} or not isinstance(value["$buffer"], str):
|
|
164
|
+
errors.append(f"{location} must be a single string $buffer reference")
|
|
165
|
+
elif value["$buffer"] not in bfir.buffers:
|
|
166
|
+
errors.append(f"dangling buffer reference {value['$buffer']!r} at {location}")
|
|
167
|
+
else:
|
|
168
|
+
used.add(value["$buffer"])
|
|
169
|
+
elif isinstance(value, list):
|
|
170
|
+
for index, item in enumerate(value):
|
|
171
|
+
visit(item, f"{location}[{index}]")
|
|
172
|
+
|
|
173
|
+
visit(document, "document")
|
|
174
|
+
if unused := set(bfir.buffers) - used:
|
|
175
|
+
errors.append(f"unreferenced buffers: {', '.join(sorted(unused))}")
|
|
176
|
+
for buffer_id, buffer in bfir.buffers.items():
|
|
177
|
+
if not isinstance(buffer, BfirBuffer):
|
|
178
|
+
errors.append(f"buffers[{buffer_id!r}] must be a BfirBuffer")
|
|
179
|
+
return errors
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _timelines_errors(
|
|
183
|
+
timelines: Any, ids: dict[str, set[str]], owners: dict[str, str]
|
|
184
|
+
) -> list[str]:
|
|
185
|
+
if not isinstance(timelines, list):
|
|
186
|
+
return ["document.timelines must be an array"]
|
|
187
|
+
errors: list[str] = []
|
|
188
|
+
claimed: set[str] = set()
|
|
189
|
+
for t, timeline in enumerate(timelines):
|
|
190
|
+
location = f"document.timelines[{t}]"
|
|
191
|
+
if not isinstance(timeline, dict):
|
|
192
|
+
errors.append(f"{location} must be an object")
|
|
193
|
+
continue
|
|
194
|
+
figures = timeline.get("figures")
|
|
195
|
+
if not (isinstance(figures, list) and figures and all(isinstance(f, str) for f in figures)):
|
|
196
|
+
errors.append(f"{location}.figures must be a non-empty string array")
|
|
197
|
+
figures = []
|
|
198
|
+
for figure in figures:
|
|
199
|
+
if figure not in ids["figure"]:
|
|
200
|
+
errors.append(f"{location}.figures references unknown figure {figure!r}")
|
|
201
|
+
elif figure in claimed:
|
|
202
|
+
errors.append(f"figure {figure!r} belongs to more than one timeline")
|
|
203
|
+
claimed.add(figure)
|
|
204
|
+
errors.extend(_timeline_errors(timeline, location, ids, owners, set(figures)))
|
|
205
|
+
return errors
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _timeline_errors(
|
|
209
|
+
timeline: dict[str, Any],
|
|
210
|
+
prefix: str,
|
|
211
|
+
ids: dict[str, set[str]],
|
|
212
|
+
owners: dict[str, str],
|
|
213
|
+
figures: set[str],
|
|
214
|
+
) -> list[str]:
|
|
215
|
+
errors: list[str] = []
|
|
216
|
+
fps, duration, frames = timeline.get("fps"), timeline.get("duration"), timeline.get("frames")
|
|
217
|
+
if not _number(fps) or fps <= 0:
|
|
218
|
+
errors.append(f"{prefix}.fps must be a positive finite number")
|
|
219
|
+
if not _number(duration) or duration < 0:
|
|
220
|
+
errors.append(f"{prefix}.duration must be a non-negative finite number")
|
|
221
|
+
if "loop" in timeline and not isinstance(timeline["loop"], bool):
|
|
222
|
+
errors.append(f"{prefix}.loop must be a boolean")
|
|
223
|
+
if not isinstance(frames, list):
|
|
224
|
+
return [*errors, f"{prefix}.frames must be an array"]
|
|
225
|
+
|
|
226
|
+
last = 0.0
|
|
227
|
+
for f, frame in enumerate(frames):
|
|
228
|
+
location = f"{prefix}.frames[{f}]"
|
|
229
|
+
if not isinstance(frame, dict):
|
|
230
|
+
errors.append(f"{location} must be an object")
|
|
231
|
+
continue
|
|
232
|
+
time = frame.get("time")
|
|
233
|
+
if not _number(time) or time <= last:
|
|
234
|
+
errors.append(f"{location}.time must be finite and strictly increasing")
|
|
235
|
+
else:
|
|
236
|
+
last = float(time)
|
|
237
|
+
updates = frame.get("updates")
|
|
238
|
+
if not isinstance(updates, list):
|
|
239
|
+
errors.append(f"{location}.updates must be an array")
|
|
240
|
+
continue
|
|
241
|
+
for u, update in enumerate(updates):
|
|
242
|
+
where = f"{location}.updates[{u}]"
|
|
243
|
+
if not isinstance(update, dict):
|
|
244
|
+
errors.append(f"{where} must be an object")
|
|
245
|
+
continue
|
|
246
|
+
kind, path = update.get("target_kind"), update.get("path")
|
|
247
|
+
if kind not in ANIMATABLE:
|
|
248
|
+
errors.append(f"{where}.target_kind is unsupported")
|
|
249
|
+
elif update.get("target") not in ids[kind]:
|
|
250
|
+
errors.append(f"{where}.target is dangling")
|
|
251
|
+
elif owners[update["target"]] not in figures:
|
|
252
|
+
errors.append(f"{where}.target is outside this timeline's figures")
|
|
253
|
+
if not (
|
|
254
|
+
isinstance(path, list) and path and all(isinstance(p, str) and p for p in path)
|
|
255
|
+
):
|
|
256
|
+
errors.append(f"{where}.path must be a non-empty string array")
|
|
257
|
+
elif kind in ANIMATABLE and path[0] not in ANIMATABLE[kind]:
|
|
258
|
+
errors.append(f"{where}.path is not allowed for {kind}")
|
|
259
|
+
if "value" not in update:
|
|
260
|
+
errors.append(f"{where}.value is required")
|
|
261
|
+
if _number(duration) and float(duration) != last:
|
|
262
|
+
errors.append(f"{prefix}.duration must equal the final frame time (or 0)")
|
|
263
|
+
return errors
|
bfplot/bfir.schema.json
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/bfichera/bfplot/blob/main/src/bfplot/bfir.schema.json",
|
|
4
|
+
"title": "BFPlot Intermediate Representation",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["document", "buffers"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"document": {"$ref": "#/$defs/document"},
|
|
10
|
+
"buffers": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": {"$ref": "#/$defs/bufferDescriptor"}
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"$defs": {
|
|
16
|
+
"document": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": ["figures"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"figures": {"type": "array", "items": {"$ref": "#/$defs/figure"}},
|
|
21
|
+
"timelines": {"type": "array", "items": {"$ref": "#/$defs/timeline"}}
|
|
22
|
+
},
|
|
23
|
+
"additionalProperties": true
|
|
24
|
+
},
|
|
25
|
+
"figure": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["id", "axes"],
|
|
28
|
+
"properties": {
|
|
29
|
+
"id": {"type": "string", "minLength": 1},
|
|
30
|
+
"name": {"type": "string", "minLength": 1},
|
|
31
|
+
"axes": {"type": "array", "items": {"$ref": "#/$defs/axes"}}
|
|
32
|
+
},
|
|
33
|
+
"additionalProperties": true
|
|
34
|
+
},
|
|
35
|
+
"axes": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"required": ["id", "artists"],
|
|
38
|
+
"properties": {
|
|
39
|
+
"id": {"type": "string", "minLength": 1},
|
|
40
|
+
"artists": {"type": "array", "items": {"$ref": "#/$defs/artist"}},
|
|
41
|
+
"parent_axes_id": {"type": "string"},
|
|
42
|
+
"colorbar": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {"mappable": {"type": "string"}},
|
|
45
|
+
"additionalProperties": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"additionalProperties": true
|
|
49
|
+
},
|
|
50
|
+
"artist": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"required": ["id", "axes_id", "type", "props", "data"],
|
|
53
|
+
"properties": {
|
|
54
|
+
"id": {"type": "string", "minLength": 1},
|
|
55
|
+
"axes_id": {"type": "string", "minLength": 1},
|
|
56
|
+
"type": {"type": "string", "minLength": 1},
|
|
57
|
+
"props": {"type": "object"},
|
|
58
|
+
"data": {"type": "object"}
|
|
59
|
+
},
|
|
60
|
+
"additionalProperties": true
|
|
61
|
+
},
|
|
62
|
+
"timeline": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["figures", "fps", "duration", "frames"],
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"properties": {
|
|
67
|
+
"figures": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
|
68
|
+
"fps": {"type": "number", "exclusiveMinimum": 0},
|
|
69
|
+
"duration": {"type": "number", "minimum": 0},
|
|
70
|
+
"loop": {"type": "boolean"},
|
|
71
|
+
"frames": {"type": "array", "items": {"$ref": "#/$defs/timelineFrame"}}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"timelineFrame": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"required": ["time", "updates"],
|
|
77
|
+
"additionalProperties": false,
|
|
78
|
+
"properties": {
|
|
79
|
+
"time": {"type": "number", "exclusiveMinimum": 0},
|
|
80
|
+
"updates": {"type": "array", "items": {"$ref": "#/$defs/timelineUpdate"}}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"timelineUpdate": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"required": ["target_kind", "target", "path", "value"],
|
|
86
|
+
"additionalProperties": false,
|
|
87
|
+
"properties": {
|
|
88
|
+
"target_kind": {"enum": ["artist", "axes", "figure"]},
|
|
89
|
+
"target": {"type": "string", "minLength": 1},
|
|
90
|
+
"path": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
|
91
|
+
"value": {}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"bufferReference": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"required": ["$buffer"],
|
|
97
|
+
"properties": {"$buffer": {"type": "string", "minLength": 1}},
|
|
98
|
+
"additionalProperties": false
|
|
99
|
+
},
|
|
100
|
+
"bufferDescriptor": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"required": ["dtype", "shape"],
|
|
103
|
+
"properties": {
|
|
104
|
+
"dtype": {"type": "string"},
|
|
105
|
+
"shape": {"type": "array", "items": {"type": "integer", "minimum": 0}}
|
|
106
|
+
},
|
|
107
|
+
"additionalProperties": false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|