Saving, reading, loading
ds.save() is more than a renderer: it embeds provenance, the resolved theme, any statistics, and
your data into the exported files, so every figure is its own lab-notebook entry. ds.read() and
ds.load() pull that information back.
Saving
Section titled “Saving”import dysonsphere as ds
ds.theme()# ... build `chart` ...
ds.save(chart, "figure")By default this writes figure.svg and figure.json. Control the outputs with format= (any of
"svg", "png", "json", "html" - a string or a list) and background= ("light", "dark",
or both). A two-background export gets _light / _dark suffixes; a single background gets clean
names.
ds.save(chart, "figure", format=["svg", "png", "json"], background=["light", "dark"])ds.save(chart, "figure", format="html") # a self-contained interactive HTML pageThe SVG post-processing means a notebook’s inline preview (Altair renders straight from Vega-Lite)
can differ subtly from the saved file. ds.show(chart) renders a chart through the save()
pipeline so the preview matches the exported figure exactly.
The SVG is post-processed for publication: <g> wrappers are flattened for Illustrator, axis ticks
and log/power minor ticks are snapped to exact positions, and superscript exponents are realigned.
What gets embedded
Section titled “What gets embedded”When saveMetadata=True (the default), save() writes a dysonsphere metadata block into all
three formats:
- provenance - who/when/what plus the full rendering environment: the OS, the versions of Python, Altair, vl-convert (the renderer), dysonsphere (and any extensions that produced the figure), numpy, scipy, and polars - then the machine-identity checksums.
- theme - the resolved
ds.theme()arguments, so the look can be reproduced exactly. - statistics - the structured records from any
add_comparisons()/add_correlation()on the chart (exact p-values, effect sizes, descriptives). - report - the human-readable version of those statistics.
What the embedded metadata looks like
Section titled “What the embedded metadata looks like”This is not hand-waving - here is the actual block from a real save. Take a strip plot with a
Kruskal-Wallis comparison on the classic cars dataset:
chart = ds.mark_strip(cars, "Origin", "Miles_per_Gallon", origins) + ds.add_comparisons( cars, "Origin", "Miles_per_Gallon", [("USA", "Europe"), ("USA", "Japan")], test="kruskal", omnibusVerbose=True, categories=origins,)ds.save(chart, "mpg_by_origin", format="json")ds.read("mpg_by_origin.json", what="metadata") returns a dict with four keys - provenance,
statistics, theme, and report. The provenance leads with who/when/what, then the full
rendering environment (the OS and vl-convert pin how text was measured, beyond the library
versions), and ends with the machine-identity checksums:
{ "user": "dkkung", "script": "mpg_by_origin.py", "timestamp": "2026-07-06T23:47:54Z", "environment": { "os": "macOS-26.5.2-arm64-arm-64bit-Mach-O", "python": "3.13.12", "altair": "6.2.1", "vl_convert": "1.9.0.post1", "dysonsphere": "3.1.0", "numpy": "2.4.6", "scipy": "1.17.1", "polars": "1.41.2" }, "vegaliteChecksum": "sha256:1ea9d534f84a33c6b550d805d9ad967f0e36f415b2aa4b491dc2402cfd57e841", "exportIdentifier": "af751cd3-43cb-4076-8751-02508e54ae38", "dataChecksum": ["sha256:281a94603a8549cda02866679f34c8d0d5ed490d3944c5ab881423ff388b52e8"]}The three checksums answer different questions: dataChecksum (one per dataframe,
order-independent) is the data identity - two charts of the same numbers match even if drawn
differently; vegaliteChecksum is the exact spec content; exportIdentifier is shared by
every file from one save() call. A figure produced by an extension (say
ds.biology.volcano) additionally records "dysonsphere-extensions": {"biology": "0.1.0"} in
environment - only extensions that actually built the chart, not merely installed ones.
The statistics entry is a list of structured records - exact p-values and effect sizes, not the rounded on-plot labels. One omnibus record from the chart above (trimmed):
{ "kind": "omnibus", "dataChecksum": "sha256:d99fd99e862f635c276ed548bd0fb28602ba50e51bf7437b3847ea83c241e01c", "test": "kruskal", "groups": [ {"label": "Europe", "n": 70, "mean": 27.89, "sd": 6.72, "median": 26.5, "q1": 24.0, "q3": 30.65}, {"label": "Japan", "n": 79, "mean": 30.45, "sd": 6.09, "median": 31.6, "q1": 25.7, "q3": 34.05}, {"label": "USA", "n": 249, "mean": 20.08, "sd": 6.40, "median": 18.5, "q1": 15.0, "q3": 24.0} ], "omnibus": { "name": "Kruskal-Wallis", "statistic": {"symbol": "H", "value": 134.46, "df": [2]}, "pvalue": 6.354e-30, "effect": {"name": "epsilon_squared", "symbol": "ε²", "value": 0.339} }, "comparisons": { "test": "dunn", "correction": null, "pairs": [ {"group1": "Europe", "group2": "USA", "pvalue": 5.32e-14, "effect": {"symbol": "r", "value": -0.615}}, {"group1": "Japan", "group2": "USA", "pvalue": 5.93e-25, "effect": {"symbol": "r", "value": -0.746}} ] }}And the report key is that same content rendered as the plain-text lab note you get from
ds.read("mpg_by_origin.json") (the default what="report"):
Statistics | Omnibus | Kruskal-Wallis─────────────────────────────────────
H(2) = 134, P = 6.35e-30Effect size: ε² = 0.339
Group descriptives: Europe n=70 mean=27.9 sd=6.72 median=26.5 IQR=[24, 30.6] range=[16.2, 44.3] Japan n=79 mean=30.5 sd=6.09 median=31.6 IQR=[25.7, 34] range=[18, 46.6] USA n=249 mean=20.1 sd=6.4 median=18.5 IQR=[15, 24] range=[9, 39]
Post-hoc (dunn): Europe vs Japan P = 0.0555 r = 0.267 Europe vs USA P = 5.32e-14 r = -0.615 Japan vs USA P = 5.93e-25 r = -0.746
Provenance──────────
Generated by dkkung with mpg_by_origin.py on 2026-07-06T23:47:54Z usingmacOS-26.5.2-arm64-arm-64bit-Mach-O, Python 3.13.12, Altair 6.2.1, vl-convert 1.9.0.post1,dysonsphere 3.1.0, NumPy 2.4.6, SciPy 1.17.1, Polars 1.41.2.Data checksum: sha256:281a9460…Vega-Lite spec checksum: sha256:1ea9d534…Export identifier: af751cd3-43cb-4076-8751-02508e54ae38The data itself round-trips too. ds.read("mpg_by_origin.json", what="data") returns the
original 398-row dataframe - every column Altair inlined, including transform columns like
jitter_x (the honest data of record), with dysonsphere’s own sidecar layers (bracket
coordinates, error bars) filtered out.
Reading it back
Section titled “Reading it back”ds.read("figure.json") # the human-readable report (default)ds.read("figure.json", what="statistics") # structured records (list of dicts)ds.read("figure.json", what="metadata") # the whole dysonsphere block (shown above)ds.read("figure.json", what="data") # the original DataFrameread() works on JSON, SVG, and PNG for metadata and reports; what="data" is JSON-only (only the
JSON carries the full data). what="data" returns the user’s frame, filtering out dysonsphere’s
internal sidecar datasets; pick the output type with output= ("polars", "pandas", "duckdb",
or "records").
Rebuilding the chart
Section titled “Rebuilding the chart”chart = ds.load("figure.json") # an Altair object, theme re-appliedspec = ds.load("figure.json", raw=True) # the untouched Vega-Lite spec dictload() is JSON-only (it needs the full spec) and re-applies the baked theme so the reconstructed
chart renders identically.
Statistics without a file
Section titled “Statistics without a file”add_comparisons() and add_correlation() can surface their report directly:
chart = base + ds.add_comparisons(df, "group", "value", pairs, test="anova", report=True)# report=True prints it; save="dir/" writes a timestamped .txtSee the Saving & loading and Reading exports reference pages for the full API.