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/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
@@ -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
+ }