fluxplot 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.
Files changed (65) hide show
  1. fluxplot/__init__.py +115 -0
  2. fluxplot/_fieldmap.py +97 -0
  3. fluxplot/_mesh_reduce.py +54 -0
  4. fluxplot/_scene3d_size.py +95 -0
  5. fluxplot/_viewer/THIRD-PARTY.txt +23 -0
  6. fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
  7. fluxplot/_viewer/stamp.json +4 -0
  8. fluxplot/api.py +1196 -0
  9. fluxplot/autotag.py +164 -0
  10. fluxplot/base.mplstyle +0 -0
  11. fluxplot/brackets.py +242 -0
  12. fluxplot/canonical_json.py +23 -0
  13. fluxplot/capture.py +150 -0
  14. fluxplot/colorcheck.py +285 -0
  15. fluxplot/colors.py +727 -0
  16. fluxplot/colorscale.py +477 -0
  17. fluxplot/data.py +178 -0
  18. fluxplot/definitions/colormaps.json +1639 -0
  19. fluxplot/definitions/flexoki.tokens.json +2571 -0
  20. fluxplot/definitions/palettes.json +2547 -0
  21. fluxplot/descriptors.py +87 -0
  22. fluxplot/fields.py +611 -0
  23. fluxplot/fits.py +240 -0
  24. fluxplot/glb.py +84 -0
  25. fluxplot/ids.py +173 -0
  26. fluxplot/images.py +362 -0
  27. fluxplot/integrity.py +27 -0
  28. fluxplot/manifest.py +788 -0
  29. fluxplot/mesh3d.py +376 -0
  30. fluxplot/panels.py +284 -0
  31. fluxplot/postprocess.py +638 -0
  32. fluxplot/presets.py +66 -0
  33. fluxplot/provenance.py +177 -0
  34. fluxplot/raster.py +295 -0
  35. fluxplot/recipe.py +178 -0
  36. fluxplot/render.py +66 -0
  37. fluxplot/roles.py +147 -0
  38. fluxplot/scene3d.py +386 -0
  39. fluxplot/scene3d_manifest.py +112 -0
  40. fluxplot/scene3d_viewer.py +633 -0
  41. fluxplot/schemas/.gitkeep +0 -0
  42. fluxplot/schemas/manifest.schema.json +2479 -0
  43. fluxplot/schemas/recipe.schema.json +179 -0
  44. fluxplot/schemas/scene3d.schema.json +461 -0
  45. fluxplot/seaborn_adapters.py +323 -0
  46. fluxplot/signature_fluxplots/__init__.py +18 -0
  47. fluxplot/signature_fluxplots/_colour.py +412 -0
  48. fluxplot/signature_fluxplots/fluxbox.py +433 -0
  49. fluxplot/signature_fluxplots/glowbar.py +769 -0
  50. fluxplot/signature_fluxplots/hexmatrix.py +927 -0
  51. fluxplot/stats/__init__.py +63 -0
  52. fluxplot/stats/_common.py +196 -0
  53. fluxplot/stats/multi_group.py +443 -0
  54. fluxplot/stats/paired.py +209 -0
  55. fluxplot/stats/two_group.py +149 -0
  56. fluxplot/style.py +469 -0
  57. fluxplot/surface.py +487 -0
  58. fluxplot/surface3d.py +197 -0
  59. fluxplot/tagger.py +561 -0
  60. fluxplot/version.py +19 -0
  61. fluxplot-0.1.0.dist-info/METADATA +1199 -0
  62. fluxplot-0.1.0.dist-info/RECORD +65 -0
  63. fluxplot-0.1.0.dist-info/WHEEL +4 -0
  64. fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/recipe.py ADDED
@@ -0,0 +1,178 @@
1
+ """Assemble the ``*.recipe.json`` provenance sidecar (spec §11.3, v0 "enough to re-run here").
2
+
3
+ The recipe is the **provenance surface**, deliberately separate from the deterministic manifest:
4
+ timestamps and content hashes (which vary run-to-run) live here, so the SVG + manifest stay
5
+ byte-stable for morph/diff while provenance stays honest.
6
+
7
+ When the producing ``script`` is known, the recipe also carries a small **re-run block**
8
+ (``command``/``args``/``cwd``/``output``) so Flux's ``rerun-plot`` (and the in-app *Regenerate*
9
+ button) can reproduce the plot. Those paths are written **relative** so the recipe survives the
10
+ project being moved or copied as a whole; only the interpreter (``command``) is absolute, and it is
11
+ overridable via ``recipe["command"]``.
12
+
13
+ The script no longer has to be recorded by hand: when the caller does not pass one,
14
+ :mod:`fluxplot.provenance` discovers it from the running interpreter (safe deterministic rules
15
+ only — see plan §1). Explicit fields always win; ``recipe=False`` suppresses discovery entirely
16
+ for notebooks, generated figures and privacy-sensitive callers. The recipe then says *how* the
17
+ script was determined (``provenance.scriptDiscovery``: automatic / explicit / unavailable) and
18
+ never claims to know inputs or parameters it cannot know — there is no automatic input discovery.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import os
24
+ import sys
25
+ from datetime import datetime, timezone
26
+
27
+ from . import provenance as _provenance
28
+
29
+
30
+ def _now_iso() -> str:
31
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
32
+
33
+
34
+ def _script_block(script) -> dict | None:
35
+ if script is None:
36
+ return None
37
+ if isinstance(script, dict):
38
+ return script
39
+ return {"path": str(script)}
40
+
41
+
42
+ def _hash_input(inp, base_dir):
43
+ path = inp if isinstance(inp, str) else inp.get("path")
44
+ role = "data" if isinstance(inp, str) else inp.get("role", "data")
45
+ entry = {"role": role, "path": path}
46
+ full = path if base_dir is None else os.path.join(base_dir, path)
47
+ try:
48
+ with open(full, "rb") as f:
49
+ digest, size = hashlib.sha256(), 0
50
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
51
+ digest.update(chunk)
52
+ size += len(chunk)
53
+ entry["sha256"] = digest.hexdigest()
54
+ entry["bytes"] = size
55
+ except OSError:
56
+ entry["sha256"] = None
57
+ return entry
58
+
59
+
60
+ def build_recipe(
61
+ recipe: dict | bool | None,
62
+ *,
63
+ plot_name: str,
64
+ svg_filename: str | None = None,
65
+ glb_filename: str | None = None,
66
+ manifest_filename: str,
67
+ spec_version: str,
68
+ base_dir: str | None = None,
69
+ recipe_dir: str | None = None,
70
+ now: str | None = None,
71
+ ) -> dict:
72
+ if (svg_filename is None) == (glb_filename is None):
73
+ raise ValueError("provide exactly one svg_filename or glb_filename")
74
+ output_name = glb_filename if glb_filename is not None else svg_filename
75
+ # recipe semantics (plan §1): None → automatic provenance; False → explicitly suppress it
76
+ # (still writes a valid, non-rerunnable recipe); a dict → explicit fields win, an inferred
77
+ # script only fills a *missing* script. Inputs are never discovered automatically.
78
+ suppress = recipe is False
79
+ recipe = {} if recipe in (None, False) else dict(recipe)
80
+
81
+ script = _script_block(recipe.get("script"))
82
+ notebook = None
83
+ if script is not None:
84
+ discovery = "explicit"
85
+ elif recipe.get("notebook"):
86
+ # a notebook cell made the plot: recorded (path, cell, hash), not rerunnable by Flux yet
87
+ notebook = _provenance.notebook_block(recipe["notebook"], recipe.get("cell"))
88
+ discovery = "notebook"
89
+ elif not suppress:
90
+ found = _provenance.discover_script()
91
+ if found:
92
+ script = {"path": found}
93
+ discovery = "automatic"
94
+ else:
95
+ nb = _provenance.discover_notebook()
96
+ if nb:
97
+ notebook = _provenance.notebook_block(nb, recipe.get("cell"))
98
+ discovery = "notebook"
99
+ else:
100
+ discovery = "unavailable"
101
+ else:
102
+ discovery = "suppressed"
103
+
104
+ inputs = recipe.get("inputs", []) or []
105
+ out = {
106
+ "spec": "fluxplot/recipe",
107
+ "schemaVersion": spec_version,
108
+ "plot": plot_name,
109
+ "outputs": {("glb" if glb_filename is not None else "svg"): output_name, "manifest": manifest_filename},
110
+ "generatedAt": now or _now_iso(),
111
+ "script": script,
112
+ "params": recipe.get("params", {}),
113
+ "inputs": [_hash_input(i, base_dir) for i in inputs],
114
+ "env": None, # v0: full environment capture deferred (spec §11.3)
115
+ }
116
+ if notebook is not None:
117
+ out["notebook"] = notebook
118
+ if not suppress:
119
+ out["provenance"] = _provenance.build_provenance(
120
+ script.get("path") if script else (notebook["path"] if notebook else None), discovery
121
+ )
122
+ if notebook is not None and notebook["sha256"] is None:
123
+ out["provenance"].pop("scriptSha256", None)
124
+
125
+ # Re-run block — what flux-core's runRecipe needs to reproduce the plot. It resolves
126
+ # `cwd` and `output` against the recipe's OWN directory, runs `command args` (appending
127
+ # params as `--key value` and exporting them as $FLUX_PARAMS), so the script re-emits the
128
+ # SVG in place. Emitted only when we know which script produced the plot. Paths are written
129
+ # relative (to the recipe dir / the run cwd) for portability; the interpreter is absolute
130
+ # and overridable via recipe["command"].
131
+ script = out["script"]
132
+ if script and script.get("path") and recipe_dir is not None:
133
+ cwd_now = os.path.abspath(os.path.join(recipe_dir, recipe["cwd"])) if recipe.get("cwd") else os.getcwd()
134
+ script_abs = os.path.abspath(script["path"])
135
+ svg_abs = os.path.join(recipe_dir, output_name)
136
+ out["command"] = recipe.get("command") or sys.executable or "python"
137
+ out["args"] = list(recipe["args"]) if "args" in recipe else [os.path.relpath(script_abs, cwd_now)]
138
+ out["cwd"] = os.path.relpath(cwd_now, recipe_dir)
139
+ out["output"] = recipe.get("output", os.path.relpath(svg_abs, recipe_dir))
140
+ elif recipe.get("command"): # explicit command without a script — pass through (back-compat)
141
+ out["command"] = recipe["command"]
142
+ for key in ("args", "cwd", "output"):
143
+ if key in recipe:
144
+ out[key] = recipe[key]
145
+ return out
146
+
147
+
148
+ def params(defaults: dict | None = None) -> dict:
149
+ """Merge ``defaults`` with any ``FLUX_PARAMS`` (JSON) provided in the environment.
150
+
151
+ Read tunables through this so ``flux rerun-plot <recipe> --key value`` (and the in-app
152
+ *Regenerate* button, which set ``$FLUX_PARAMS``) can re-run the script with overrides::
153
+
154
+ import fluxplot as fp
155
+ p = fp.params({"test": "t-test", "smooth": False})
156
+ if p["test"] == "mann-whitney":
157
+ ...
158
+ """
159
+ import json as _json
160
+
161
+ out = dict(defaults or {})
162
+ raw = os.environ.get("FLUX_PARAMS")
163
+ if raw:
164
+ try:
165
+ out.update(_json.loads(raw))
166
+ except Exception:
167
+ pass
168
+ # `flux rerun-plot --__fluxplot__ '{…}'` hands the colour controls over as one JSON string;
169
+ # the block is always a mapping to the helpers.
170
+ controls = out.get("__fluxplot__")
171
+ if isinstance(controls, str):
172
+ try:
173
+ out["__fluxplot__"] = _json.loads(controls)
174
+ except ValueError:
175
+ raise ValueError("FLUX_PARAMS __fluxplot__ is not valid JSON") from None
176
+ if not isinstance(out["__fluxplot__"], dict):
177
+ raise ValueError("FLUX_PARAMS __fluxplot__ must be a JSON object keyed by colour-control key")
178
+ return out
fluxplot/render.py ADDED
@@ -0,0 +1,66 @@
1
+ """Deterministic SVG rendering (P5). Where byte-stability is won or lost.
2
+
3
+ See ``NOTES_matplotlib_svg.md`` for the verified determinism knobs.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import io
8
+
9
+ import matplotlib.pyplot as plt
10
+
11
+ # rcParams that make matplotlib's SVG output deterministic + editable/addressable.
12
+ DETERMINISTIC_RCPARAMS = {
13
+ # keep text as real <text> referencing fonts by name → editable, restyleable, addressable,
14
+ # and font-version-independent (the 'path' default outlines glyphs into nondeterministic d's).
15
+ "svg.fonttype": "none",
16
+ "savefig.bbox": None,
17
+ # path simplification is deterministic given a pinned threshold; pin both explicitly.
18
+ "path.simplify": True,
19
+ "path.simplify_threshold": 0.111111,
20
+ }
21
+
22
+
23
+ def render_svg(fig, hashsalt: str, dpi=None) -> bytes:
24
+ """Render ``fig`` to SVG bytes deterministically.
25
+
26
+ ``hashsalt`` fixes matplotlib's hashed ids (clip-paths, marker glyphs, ``<defs>``); without it
27
+ they are salted with a fresh uuid4 each run. ``metadata={'Date': None}`` drops the timestamp.
28
+ We never pass ``bbox_inches='tight'`` — it would crop/shift the viewBox *after* coordinate
29
+ anchors were captured, invalidating the data↔pixel mapping.
30
+
31
+ ``dpi`` sets the resolution of **rasterized artists only** (see ``raster.py``). SVG user
32
+ space is points, so vector geometry is dpi-invariant — verified in
33
+ ``NOTES_matplotlib_svg.md`` §6 and pinned by ``tests/test_rasterize.py``. Pass it only
34
+ when something is actually rasterized, so ordinary saves keep matplotlib's default.
35
+ """
36
+ rc = dict(DETERMINISTIC_RCPARAMS)
37
+ rc["svg.hashsalt"] = hashsalt
38
+ buf = io.BytesIO()
39
+ extra = {"dpi": dpi} if dpi else {}
40
+ with plt.rc_context(rc):
41
+ fig.savefig(buf, format="svg", metadata={"Date": None}, bbox_inches=None, **extra)
42
+ return buf.getvalue()
43
+
44
+
45
+ from contextlib import contextmanager
46
+
47
+
48
+ @contextmanager
49
+ def final_layout(fig):
50
+ """Lay out with the SVG renderer, then freeze it through capture and export.
51
+
52
+ Agg and SVG have different font metrics. Public draw_without_rendering avoids
53
+ rasterizing data just to obtain the correct text/layout metrics.
54
+ """
55
+ from matplotlib.backends.backend_svg import FigureCanvasSVG
56
+ canvas, dpi, engine = fig.canvas, fig.dpi, fig.get_layout_engine()
57
+ try:
58
+ FigureCanvasSVG(fig)
59
+ fig.set_dpi(72)
60
+ fig.draw_without_rendering()
61
+ fig.set_layout_engine('none')
62
+ yield
63
+ finally:
64
+ fig.set_layout_engine(engine)
65
+ fig.set_dpi(dpi)
66
+ fig.set_canvas(canvas)
fluxplot/roles.py ADDED
@@ -0,0 +1,147 @@
1
+ """Role vocabulary revision 1 (spec §5, §10). A versioned core + a namespaced ``x-`` extension mechanism.
2
+
3
+ Unknown roles are NOT rejected — they degrade gracefully (P4): they still get a stable id and a
4
+ ``data-role`` and are listed in the manifest; consumers that don't recognize them treat them as
5
+ opaque addressable groups.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ ROLE_VERSION = 1 # Local vocabulary revision; not a serialized schema version.
10
+
11
+ CORE_ROLES = frozenset(
12
+ {
13
+ # containers
14
+ "figure",
15
+ "panel",
16
+ "plot-area",
17
+ "legend",
18
+ "colorbar",
19
+ "title",
20
+ "subtitle",
21
+ # scaffold / guides
22
+ "axis",
23
+ "spine",
24
+ "tick",
25
+ "tick-label",
26
+ "axis-title",
27
+ "gridline",
28
+ "background",
29
+ # data marks (geoms)
30
+ "series",
31
+ "mesh", "pane", "scalebar",
32
+ "image",
33
+ "line",
34
+ "point",
35
+ "bar",
36
+ "area",
37
+ "errorbar",
38
+ "box",
39
+ "violin",
40
+ "contour",
41
+ # composite sub-parts (box / violin / errorbar internals)
42
+ "whisker",
43
+ "cap",
44
+ "flier",
45
+ "median",
46
+ "mean",
47
+ "segment",
48
+ # overlays
49
+ "annotation",
50
+ "reference-line",
51
+ "highlight-region",
52
+ "significance-bracket",
53
+ "label",
54
+ "caption",
55
+ # untagged user-drawn artists swept into addressable "extra" content
56
+ "extra",
57
+ # legend internals
58
+ "legend-entry",
59
+ "legend-swatch",
60
+ "legend-label",
61
+ # a manifest-only container that groups sibling parts (e.g. "all x tick labels")
62
+ "group",
63
+ }
64
+ )
65
+
66
+
67
+ # Data-kind hints (text | line | shape | container) per core role — authored truth for
68
+ # consumers (Flux's part editors pick the property set by kind: a tick-label edits like a
69
+ # text object, a gridline like a line, …). Deliberate omission: "extra" is heterogeneous
70
+ # (a swept artist can be a line, a collection or a patch), so its kind is inferred from
71
+ # the concrete artist at sweep time (see ``descriptors.artist_kind``). Unknown / ``x-``
72
+ # roles are likewise inferred per-artist where possible, else the hint is omitted.
73
+ KIND_BY_ROLE = {
74
+ "mesh": "shape", "pane": "shape", "scalebar": "line",
75
+ # containers
76
+ "figure": "container",
77
+ "panel": "container",
78
+ "plot-area": "container",
79
+ "legend": "container",
80
+ "colorbar": "container",
81
+ "axis": "container",
82
+ "series": "container",
83
+ "group": "container",
84
+ "legend-entry": "container",
85
+ # text
86
+ "title": "text",
87
+ "subtitle": "text",
88
+ "tick-label": "text",
89
+ "axis-title": "text",
90
+ "legend-label": "text",
91
+ "annotation": "text",
92
+ "label": "text",
93
+ "caption": "text",
94
+ # line
95
+ "line": "line",
96
+ "spine": "line",
97
+ "tick": "line",
98
+ "gridline": "line",
99
+ "reference-line": "line",
100
+ "errorbar": "line",
101
+ "whisker": "line",
102
+ "cap": "line",
103
+ "median": "line",
104
+ "mean": "line",
105
+ "segment": "line",
106
+ "significance-bracket": "line",
107
+ # shape
108
+ "image": "shape",
109
+ "area": "shape",
110
+ "bar": "shape",
111
+ "point": "shape",
112
+ "box": "shape",
113
+ "violin": "shape",
114
+ "contour": "shape",
115
+ "flier": "shape",
116
+ "background": "shape",
117
+ "highlight-region": "shape",
118
+ "legend-swatch": "shape",
119
+ # members of colour-mapped fields: a heatmap cell, a contour band / level path, a hexagon
120
+ "cell": "shape",
121
+ "contour-level": "shape",
122
+ "x-hex": "shape",
123
+ }
124
+
125
+
126
+ def kind_for_role(role: str):
127
+ """The data-kind hint for a role, or ``None`` when the role carries no static kind."""
128
+ return KIND_BY_ROLE.get(role)
129
+
130
+
131
+ def is_core(role: str) -> bool:
132
+ return role in CORE_ROLES
133
+
134
+
135
+ def is_extension(role: str) -> bool:
136
+ return role.startswith("x-")
137
+
138
+
139
+ def validate(role: str) -> str:
140
+ """Return the role unchanged if it is a recognized core role or a well-formed ``x-`` extension.
141
+
142
+ Unknown bare roles are allowed (graceful degradation) but normalized into the ``x-`` namespace
143
+ so the core vocabulary stays closed and self-describing.
144
+ """
145
+ if is_core(role) or is_extension(role):
146
+ return role
147
+ return f"x-{role}"