cellpy-figure 0.1.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.
@@ -0,0 +1,16 @@
1
+ """A figure bundle a coauthor can redraw without installing cellpy."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from cellpy_figure.io import BundleError, open_figure
6
+ from cellpy_figure.model import Encoding, Figure, Panel, Provenance
7
+
8
+ __all__ = [
9
+ "BundleError",
10
+ "Encoding",
11
+ "Figure",
12
+ "Panel",
13
+ "Provenance",
14
+ "open_figure",
15
+ "__version__",
16
+ ]
@@ -0,0 +1,49 @@
1
+ """Split a figure table into one row of facet panels.
2
+
3
+ Renderers share this split so matplotlib and Plotly keep the same panel
4
+ order. Drawing stays in each renderer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pandas as pd
10
+
11
+ from cellpy_figure.io import BundleError
12
+
13
+
14
+ def facet_slices(
15
+ frame: pd.DataFrame, field: str | None
16
+ ) -> list[tuple[object, pd.DataFrame]]:
17
+ """Return ``(title, rows)`` pairs in first-seen order.
18
+
19
+ Args:
20
+ frame: The figure table.
21
+ field: Facet column. ``None`` means the whole table is one panel.
22
+
23
+ Returns:
24
+ One pair when there is no facet. Otherwise one pair per non-null
25
+ facet value, in the order those values first appear.
26
+
27
+ Raises:
28
+ BundleError: The facet column has no values to draw.
29
+ """
30
+ if field is None:
31
+ return [(None, frame)]
32
+ column = frame[field]
33
+ order: list[object] = []
34
+ seen: set[object] = set()
35
+ for value in column.tolist():
36
+ if _missing(value) or value in seen:
37
+ continue
38
+ seen.add(value)
39
+ order.append(value)
40
+ if not order:
41
+ raise BundleError(f"panel 1 facet column {field!r} has no values to draw.")
42
+ return [(value, frame.loc[column == value]) for value in order]
43
+
44
+
45
+ def _missing(value: object) -> bool:
46
+ try:
47
+ return bool(pd.isna(value))
48
+ except (TypeError, ValueError):
49
+ return False
cellpy_figure/io.py ADDED
@@ -0,0 +1,448 @@
1
+ """Read and write a ``.cfz`` bundle.
2
+
3
+ A bundle is a zip with ``manifest.json``, ``figure.json``, ``data.parquet``,
4
+ and ``README.txt``. The schema is format version 1, as specified in
5
+ ``PLAN.md``. Rendering is not part of this module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ from io import BytesIO
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from zipfile import ZIP_DEFLATED, ZipFile, is_zipfile
17
+
18
+ import pandas as pd
19
+
20
+ from cellpy_figure.model import Encoding, Figure, Panel, Provenance
21
+
22
+ log = logging.getLogger("cellpy_figure")
23
+
24
+ FORMAT_NAME = "cellpy-figure"
25
+ FORMAT_VERSION = 1
26
+ SUPPORTED_FORMAT_VERSIONS = frozenset({FORMAT_VERSION})
27
+
28
+ _MEMBERS = ("manifest.json", "figure.json", "data.parquet", "README.txt")
29
+ _MANIFEST_KEYS = frozenset({"format", "format_version", "created"})
30
+ _FIGURE_KEYS = frozenset({"title", "panels", "provenance", "extensions"})
31
+ _PANEL_KEYS = frozenset({"x", "y", "color", "stroke", "facet"})
32
+ _ENCODING_KEYS = frozenset({"field", "label", "unit", "type", "legend_title", "scale"})
33
+ _PROVENANCE_KEYS = frozenset({"producer", "producer_version", "note", "source"})
34
+ _COLOR_TYPES = frozenset({"nominal", "quantitative"})
35
+ _OPTIONAL_CHANNELS = ("color", "stroke", "facet")
36
+
37
+ README_TEXT = """\
38
+ pip install "cellpy-figure[mpl]"
39
+
40
+ This file is a cellpy-figure bundle. It holds the plotted table and the
41
+ encodings that drew it, not a frozen image.
42
+
43
+ import cellpy_figure
44
+
45
+ figure = cellpy_figure.open_figure("figure.cfz")
46
+ drawn = figure.to_matplotlib(preset="notebook")
47
+ drawn.savefig("figure.pdf")
48
+ """
49
+
50
+
51
+ class BundleError(ValueError):
52
+ """A ``.cfz`` bundle cannot be written or read."""
53
+
54
+
55
+ def write_figure(figure: Figure, path: str) -> None:
56
+ """Write ``figure`` to a ``.cfz`` bundle at ``path``.
57
+
58
+ Args:
59
+ figure: Figure to store. ``data`` is a pandas or polars frame.
60
+ ``provenance`` is required.
61
+ path: Destination path. An existing file is replaced.
62
+
63
+ Raises:
64
+ BundleError: The figure is not a valid format-version-1 bundle, or
65
+ the path cannot be written.
66
+ """
67
+ if FORMAT_VERSION not in SUPPORTED_FORMAT_VERSIONS:
68
+ supported = ", ".join(str(v) for v in sorted(SUPPORTED_FORMAT_VERSIONS))
69
+ raise BundleError(
70
+ f"refusing to write format_version {FORMAT_VERSION}; "
71
+ f"this package writes format_version {supported}."
72
+ )
73
+ frame = _as_pandas(figure.data)
74
+ if not figure.panels:
75
+ raise BundleError("a figure needs at least one panel.")
76
+ _check_columns(frame, figure.panels)
77
+ spec = _figure_to_json(figure)
78
+ manifest = {
79
+ "format": FORMAT_NAME,
80
+ "format_version": FORMAT_VERSION,
81
+ "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
82
+ }
83
+ table = BytesIO()
84
+ try:
85
+ frame.to_parquet(table, index=False, engine="pyarrow")
86
+ except Exception as exc:
87
+ raise BundleError(f"could not write data.parquet ({exc}).") from exc
88
+ try:
89
+ with ZipFile(Path(path), "w", compression=ZIP_DEFLATED) as archive:
90
+ archive.writestr("manifest.json", _dumps(manifest))
91
+ archive.writestr("figure.json", _dumps(spec))
92
+ archive.writestr("data.parquet", table.getvalue())
93
+ archive.writestr("README.txt", README_TEXT)
94
+ except OSError as exc:
95
+ raise BundleError(f"could not write {path} ({exc}).") from exc
96
+
97
+
98
+ def open_figure(path: str) -> Figure:
99
+ """Open a ``.cfz`` bundle.
100
+
101
+ Args:
102
+ path: Path to the bundle.
103
+
104
+ Returns:
105
+ The figure, with ``data`` as a ``pandas.DataFrame``. The table index
106
+ is not stored.
107
+
108
+ Raises:
109
+ BundleError: The file is missing, not a format-version-1 bundle, or
110
+ disagrees with its table.
111
+ """
112
+ location = Path(path)
113
+ if not location.is_file():
114
+ raise BundleError(f"no such bundle: {path}")
115
+ if not is_zipfile(location):
116
+ raise BundleError(f"not a .cfz bundle (not a zip): {path}")
117
+ with ZipFile(location) as archive:
118
+ names = archive.namelist()
119
+ _check_members(names)
120
+ manifest = _load_json(archive, "manifest.json")
121
+ _check_manifest(manifest)
122
+ spec = _load_json(archive, "figure.json")
123
+ figure = _figure_from_json(spec)
124
+ try:
125
+ figure.data = pd.read_parquet(
126
+ BytesIO(archive.read("data.parquet")), engine="pyarrow"
127
+ )
128
+ except Exception as exc:
129
+ raise BundleError(f"data.parquet could not be read ({exc}).") from exc
130
+ _check_columns(figure.data, figure.panels)
131
+ if figure.extensions:
132
+ log.debug("opened a bundle with extensions; contents are not interpreted")
133
+ return figure
134
+
135
+
136
+ def _dumps(payload: dict[str, Any]) -> str:
137
+ return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
138
+
139
+
140
+ def _check_members(names: list[str]) -> None:
141
+ if len(names) != len(set(names)):
142
+ raise BundleError("bundle contains duplicate members.")
143
+ missing = [name for name in _MEMBERS if name not in names]
144
+ extra = [name for name in names if name not in _MEMBERS]
145
+ if missing:
146
+ raise BundleError(f"bundle is missing {', '.join(missing)}.")
147
+ if extra:
148
+ unexpected = ", ".join(repr(name) for name in extra)
149
+ expected = ", ".join(_MEMBERS)
150
+ raise BundleError(
151
+ f"bundle contains unexpected member {unexpected}. Expected {expected}."
152
+ )
153
+
154
+
155
+ def _load_json(archive: ZipFile, name: str) -> dict[str, Any]:
156
+ try:
157
+ text = archive.read(name).decode("utf-8")
158
+ except UnicodeDecodeError as exc:
159
+ raise BundleError(f"{name} is not UTF-8.") from exc
160
+ try:
161
+ payload = json.loads(text)
162
+ except json.JSONDecodeError as exc:
163
+ raise BundleError(f"{name} is not valid JSON ({exc}).") from exc
164
+ if not isinstance(payload, dict):
165
+ raise BundleError(f"{name} must be a JSON object.")
166
+ return payload
167
+
168
+
169
+ def _check_manifest(manifest: dict[str, Any]) -> None:
170
+ _reject_unknown(manifest, _MANIFEST_KEYS, "manifest.json")
171
+ if manifest.get("format") != FORMAT_NAME:
172
+ raise BundleError(
173
+ f"manifest format is {manifest.get('format')!r}; expected {FORMAT_NAME!r}."
174
+ )
175
+ if "format_version" not in manifest:
176
+ raise BundleError(
177
+ "manifest.json is missing format_version; "
178
+ f"cellpy-figure reads format_version {FORMAT_VERSION}."
179
+ )
180
+ found = manifest["format_version"]
181
+ if isinstance(found, bool) or not isinstance(found, int):
182
+ raise BundleError(
183
+ f"this file uses format_version {found!r}; "
184
+ f"cellpy-figure reads format_version {FORMAT_VERSION}."
185
+ )
186
+ if found not in SUPPORTED_FORMAT_VERSIONS:
187
+ raise BundleError(
188
+ f"this file uses format_version {found}; "
189
+ f"cellpy-figure reads format_version {FORMAT_VERSION}."
190
+ )
191
+ created = manifest.get("created")
192
+ if not isinstance(created, str) or not created:
193
+ raise BundleError("manifest.json created must be a UTC timestamp string.")
194
+
195
+
196
+ def _as_pandas(data: Any) -> pd.DataFrame:
197
+ if isinstance(data, pd.DataFrame):
198
+ frame = data
199
+ else:
200
+ module = type(data).__module__.split(".", 1)[0]
201
+ to_pandas = getattr(data, "to_pandas", None)
202
+ if module == "polars" and callable(to_pandas):
203
+ frame = to_pandas()
204
+ if not isinstance(frame, pd.DataFrame):
205
+ raise BundleError(
206
+ "polars to_pandas() did not return a pandas.DataFrame."
207
+ )
208
+ else:
209
+ raise BundleError(
210
+ "figure data must be a pandas.DataFrame or a polars.DataFrame; "
211
+ f"got {type(data).__module__}.{type(data).__name__}."
212
+ )
213
+ if not all(isinstance(column, str) for column in frame.columns):
214
+ raise BundleError("column names must be strings.")
215
+ return frame
216
+
217
+
218
+ def _check_columns(frame: pd.DataFrame, panels: list[Panel]) -> None:
219
+ columns = set(frame.columns)
220
+ listed = ", ".join(frame.columns) or "none"
221
+ for index, panel in enumerate(panels, start=1):
222
+ channels: list[tuple[str, Encoding | None]] = [
223
+ ("x", panel.x),
224
+ ("y", panel.y),
225
+ ("color", panel.color),
226
+ ("stroke", panel.stroke),
227
+ ("facet", panel.facet),
228
+ ]
229
+ for name, encoding in channels:
230
+ if encoding is None:
231
+ continue
232
+ if encoding.field not in columns:
233
+ raise BundleError(
234
+ f"panel {index} {name} names column {encoding.field!r}, "
235
+ f"which is not in the table (columns: {listed})."
236
+ )
237
+
238
+
239
+ def _figure_to_json(figure: Figure) -> dict[str, Any]:
240
+ if figure.provenance is None:
241
+ raise BundleError(
242
+ "provenance is required to write a bundle; set producer and producer_version."
243
+ )
244
+ if not isinstance(figure.extensions, dict):
245
+ raise BundleError("extensions must be a JSON object.")
246
+ try:
247
+ json.dumps(figure.extensions)
248
+ except TypeError as exc:
249
+ raise BundleError(f"extensions must be JSON-serializable ({exc}).") from exc
250
+ spec: dict[str, Any] = {}
251
+ if figure.title is not None:
252
+ if not isinstance(figure.title, str):
253
+ raise BundleError("title must be a string.")
254
+ spec["title"] = figure.title
255
+ spec["panels"] = [
256
+ _panel_to_json(panel, index)
257
+ for index, panel in enumerate(figure.panels, start=1)
258
+ ]
259
+ spec["provenance"] = _provenance_to_json(figure.provenance)
260
+ spec["extensions"] = figure.extensions
261
+ return spec
262
+
263
+
264
+ def _panel_to_json(panel: Panel, index: int) -> dict[str, Any]:
265
+ raw: dict[str, Any] = {
266
+ "x": _encoding_to_json(panel.x, index, "x"),
267
+ "y": _encoding_to_json(panel.y, index, "y"),
268
+ }
269
+ for name in _OPTIONAL_CHANNELS:
270
+ encoding = getattr(panel, name)
271
+ if encoding is not None:
272
+ raw[name] = _encoding_to_json(encoding, index, name)
273
+ return raw
274
+
275
+
276
+ def _encoding_to_json(encoding: Encoding, index: int, channel: str) -> dict[str, Any]:
277
+ where = f"panel {index} {channel}"
278
+ if not isinstance(encoding, Encoding):
279
+ raise BundleError(f"{where} must be an Encoding.")
280
+ if not isinstance(encoding.field, str) or not encoding.field:
281
+ raise BundleError(f"{where} field must be a column name.")
282
+ raw: dict[str, Any] = {"field": encoding.field}
283
+ for attr in ("label", "unit", "legend_title"):
284
+ value = getattr(encoding, attr)
285
+ if value is None:
286
+ continue
287
+ if not isinstance(value, str):
288
+ raise BundleError(f"{where} {attr} must be a string.")
289
+ raw[attr] = value
290
+ if encoding.type is not None:
291
+ if encoding.type not in _COLOR_TYPES:
292
+ raise BundleError(
293
+ f"{where} type is {encoding.type!r}; expected nominal or quantitative."
294
+ )
295
+ raw["type"] = encoding.type
296
+ if encoding.scale is not None:
297
+ raw["scale"] = _scale_to_json(encoding.scale, where)
298
+ return raw
299
+
300
+
301
+ def _scale_to_json(scale: dict[str, str], where: str) -> dict[str, str]:
302
+ if not isinstance(scale, dict) or any(
303
+ not isinstance(key, str) or not isinstance(value, str)
304
+ for key, value in scale.items()
305
+ ):
306
+ raise BundleError(f"{where} scale must map strings to strings.")
307
+ return dict(scale)
308
+
309
+
310
+ def _provenance_to_json(provenance: Provenance) -> dict[str, Any]:
311
+ raw: dict[str, Any] = {
312
+ "producer": _required_string(provenance.producer, "provenance.producer"),
313
+ "producer_version": _required_string(
314
+ provenance.producer_version, "provenance.producer_version"
315
+ ),
316
+ }
317
+ for attr in ("note", "source"):
318
+ value = _optional_string(getattr(provenance, attr), f"provenance.{attr}")
319
+ if value is not None:
320
+ raw[attr] = value
321
+ return raw
322
+
323
+
324
+ def _required_string(value: Any, where: str) -> str:
325
+ if not isinstance(value, str) or not value:
326
+ raise BundleError(f"{where} must be a non-empty string.")
327
+ return value
328
+
329
+
330
+ def _optional_string(value: Any, where: str) -> str | None:
331
+ if value is None:
332
+ return None
333
+ if not isinstance(value, str):
334
+ raise BundleError(f"{where} must be a string.")
335
+ return value
336
+
337
+
338
+ def _figure_from_json(spec: dict[str, Any]) -> Figure:
339
+ _reject_unknown(spec, _FIGURE_KEYS, "figure.json")
340
+ if "panels" not in spec:
341
+ raise BundleError("figure.json is missing panels.")
342
+ if not isinstance(spec["panels"], list) or not spec["panels"]:
343
+ raise BundleError("a figure needs at least one panel.")
344
+ if "provenance" not in spec:
345
+ raise BundleError("figure.json is missing provenance.")
346
+ title = spec.get("title")
347
+ if title is not None and not isinstance(title, str):
348
+ raise BundleError("title must be a string.")
349
+ if "extensions" in spec and not isinstance(spec["extensions"], dict):
350
+ raise BundleError("extensions must be a JSON object.")
351
+ return Figure(
352
+ data=None,
353
+ panels=[
354
+ _panel_from_json(panel, index)
355
+ for index, panel in enumerate(spec["panels"], start=1)
356
+ ],
357
+ title=title,
358
+ provenance=_provenance_from_json(spec["provenance"]),
359
+ extensions=spec.get("extensions", {}),
360
+ )
361
+
362
+
363
+ def _panel_from_json(raw: Any, index: int) -> Panel:
364
+ where = f"panel {index}"
365
+ if not isinstance(raw, dict):
366
+ raise BundleError(f"{where} must be a JSON object.")
367
+ _reject_unknown(raw, _PANEL_KEYS, where)
368
+ for channel in ("x", "y"):
369
+ if channel not in raw:
370
+ raise BundleError(f"{where} is missing {channel}.")
371
+ return Panel(
372
+ x=_encoding_from_json(raw["x"], index, "x"),
373
+ y=_encoding_from_json(raw["y"], index, "y"),
374
+ color=_optional_encoding(raw, index, "color"),
375
+ stroke=_optional_encoding(raw, index, "stroke"),
376
+ facet=_optional_encoding(raw, index, "facet"),
377
+ )
378
+
379
+
380
+ def _optional_encoding(
381
+ raw: dict[str, Any], index: int, channel: str
382
+ ) -> Encoding | None:
383
+ if channel not in raw:
384
+ return None
385
+ return _encoding_from_json(raw[channel], index, channel)
386
+
387
+
388
+ def _encoding_from_json(raw: Any, index: int, channel: str) -> Encoding:
389
+ where = f"panel {index} {channel}"
390
+ if not isinstance(raw, dict):
391
+ raise BundleError(f"{where} must be a JSON object.")
392
+ _reject_unknown(raw, _ENCODING_KEYS, where)
393
+ if "field" not in raw:
394
+ raise BundleError(f"{where} is missing field.")
395
+ field = raw["field"]
396
+ if not isinstance(field, str) or not field:
397
+ raise BundleError(f"{where} field must be a column name.")
398
+ encoding_type = raw.get("type")
399
+ if encoding_type is not None and encoding_type not in _COLOR_TYPES:
400
+ raise BundleError(
401
+ f"{where} type is {encoding_type!r}; expected nominal or quantitative."
402
+ )
403
+ scale = raw.get("scale")
404
+ if scale is not None:
405
+ scale = _scale_to_json(scale, where)
406
+ for attr in ("label", "unit", "legend_title"):
407
+ if attr in raw and not isinstance(raw[attr], str):
408
+ raise BundleError(f"{where} {attr} must be a string.")
409
+ return Encoding(
410
+ field=field,
411
+ label=raw.get("label"),
412
+ unit=raw.get("unit"),
413
+ type=encoding_type,
414
+ legend_title=raw.get("legend_title"),
415
+ scale=scale,
416
+ )
417
+
418
+
419
+ def _provenance_from_json(raw: Any) -> Provenance:
420
+ if not isinstance(raw, dict):
421
+ raise BundleError("provenance must be a JSON object.")
422
+ _reject_unknown(raw, _PROVENANCE_KEYS, "provenance")
423
+ for key in ("producer", "producer_version"):
424
+ if key not in raw:
425
+ raise BundleError(f"provenance is missing {key}.")
426
+ return Provenance(
427
+ producer=_required_string(raw["producer"], "provenance.producer"),
428
+ producer_version=_required_string(
429
+ raw["producer_version"], "provenance.producer_version"
430
+ ),
431
+ note=_optional_string(raw.get("note"), "provenance.note"),
432
+ source=_optional_string(raw.get("source"), "provenance.source"),
433
+ )
434
+
435
+
436
+ def _reject_unknown(
437
+ payload: dict[str, Any], allowed: frozenset[str], where: str
438
+ ) -> None:
439
+ extra = sorted(set(payload) - allowed)
440
+ if not extra:
441
+ return
442
+ found = ", ".join(repr(key) for key in extra)
443
+ known = ", ".join(sorted(allowed))
444
+ label = "key" if len(extra) == 1 else "keys"
445
+ raise BundleError(
446
+ f"{where} has unknown {label} {found}. Known keys: {known}. "
447
+ "Other producer data belongs in extensions."
448
+ )
cellpy_figure/model.py ADDED
@@ -0,0 +1,140 @@
1
+ """Figure model: a tidy table plus the encodings that draw it.
2
+
3
+ These types are the in-memory shape ``PLAN.md`` describes. ``Figure.write``
4
+ stores that shape in a ``.cfz`` bundle. ``to_matplotlib`` and ``to_plotly``
5
+ draw one panel, or one facet row when that panel has a facet encoding.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal
12
+
13
+ ColorType = Literal["nominal", "quantitative"]
14
+
15
+
16
+ @dataclass
17
+ class Encoding:
18
+ """One visual channel, bound to a column of the plotted table.
19
+
20
+ Args:
21
+ field: Column name in the figure table.
22
+ label: Axis title. Used for x and y. Ignored by color and stroke
23
+ when ``legend_title`` is set.
24
+ unit: Unit string appended to an axis label, such as ``mAh/g``.
25
+ type: ``nominal`` (one colour per category) or ``quantitative``
26
+ (a sequential scale). Meaningful for color.
27
+ legend_title: Title drawn above the legend for this channel.
28
+ scale: Explicit map from category to a renderer value, such as
29
+ ``{"charge": "solid", "discharge": "dashed"}``.
30
+ """
31
+
32
+ field: str
33
+ label: str | None = None
34
+ unit: str | None = None
35
+ type: ColorType | None = None
36
+ legend_title: str | None = None
37
+ scale: dict[str, str] | None = None
38
+
39
+
40
+ @dataclass
41
+ class Panel:
42
+ """One panel: x and y, plus optional color, stroke, and facet encodings."""
43
+
44
+ x: Encoding
45
+ y: Encoding
46
+ color: Encoding | None = None
47
+ stroke: Encoding | None = None
48
+ facet: Encoding | None = None
49
+
50
+
51
+ @dataclass
52
+ class Provenance:
53
+ """Who wrote the bundle, and whether the table is a filtered sample.
54
+
55
+ Args:
56
+ producer: Short name of the writer, such as ``cellpy``.
57
+ producer_version: Version of that writer.
58
+ note: Free text. Use it to say the table is plotted rows only.
59
+ source: Path or hash of the file the writer read, when known.
60
+ """
61
+
62
+ producer: str
63
+ producer_version: str
64
+ note: str | None = None
65
+ source: str | None = None
66
+
67
+
68
+ @dataclass
69
+ class Figure:
70
+ """A plotted table and the panels that describe how to draw it.
71
+
72
+ Args:
73
+ data: The plotted rows. A ``pandas.DataFrame`` or a
74
+ ``polars.DataFrame``. ``write`` stores the table as parquet and
75
+ drops the index.
76
+ panels: One or more panels sharing ``data``.
77
+ title: Figure title.
78
+ provenance: Writer identity. Required once the bundle is written.
79
+ extensions: Producer-specific payload. Readers ignore it.
80
+ """
81
+
82
+ data: Any
83
+ panels: list[Panel]
84
+ title: str | None = None
85
+ provenance: Provenance | None = None
86
+ extensions: dict[str, Any] = field(default_factory=dict)
87
+
88
+ def write(self, path: str) -> None:
89
+ """Write this figure to a ``.cfz`` bundle.
90
+
91
+ Args:
92
+ path: Destination path. An existing file is replaced.
93
+
94
+ Raises:
95
+ BundleError: The figure is not a valid bundle, or the path
96
+ cannot be written.
97
+ """
98
+ from cellpy_figure.io import write_figure
99
+
100
+ write_figure(self, path)
101
+
102
+ def to_matplotlib(self, preset: str = "notebook", **overrides: Any) -> Any:
103
+ """Draw this figure with matplotlib.
104
+
105
+ Args:
106
+ preset: Name of a preset in ``cellpy_figure.presets``.
107
+ overrides: ``width_mm`` and ``height_mm`` replace the preset size.
108
+
109
+ Returns:
110
+ A ``matplotlib.figure.Figure``.
111
+
112
+ Raises:
113
+ ImportError: Matplotlib is not installed.
114
+ BundleError: The figure has several panels, or an encoding does
115
+ not match the table.
116
+ ValueError: ``preset`` or an override is not valid.
117
+ """
118
+ from cellpy_figure.mpl import render
119
+
120
+ return render(self, preset=preset, **overrides)
121
+
122
+ def to_plotly(self, preset: str = "notebook", **overrides: Any) -> Any:
123
+ """Draw this figure with Plotly.
124
+
125
+ Args:
126
+ preset: Name of a preset in ``cellpy_figure.presets``.
127
+ overrides: ``width_mm`` and ``height_mm`` replace the preset size.
128
+
129
+ Returns:
130
+ A ``plotly.graph_objects.Figure``.
131
+
132
+ Raises:
133
+ ImportError: Plotly is not installed.
134
+ BundleError: The figure has several panels, or an encoding does
135
+ not match the table.
136
+ ValueError: ``preset`` or an override is not valid.
137
+ """
138
+ from cellpy_figure.plotly import render
139
+
140
+ return render(self, preset=preset, **overrides)