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.
- cellpy_figure/__init__.py +16 -0
- cellpy_figure/facets.py +49 -0
- cellpy_figure/io.py +448 -0
- cellpy_figure/model.py +140 -0
- cellpy_figure/mpl.py +324 -0
- cellpy_figure/plotly.py +404 -0
- cellpy_figure/presets.py +97 -0
- cellpy_figure/py.typed +0 -0
- cellpy_figure/schema/__init__.py +22 -0
- cellpy_figure/schema/figure-1.json +72 -0
- cellpy_figure-0.1.0.dist-info/METADATA +48 -0
- cellpy_figure-0.1.0.dist-info/RECORD +14 -0
- cellpy_figure-0.1.0.dist-info/WHEEL +4 -0
- cellpy_figure-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
cellpy_figure/facets.py
ADDED
|
@@ -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)
|