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.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/autotag.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Save-time promotion of labeled ordinary matplotlib artists (plan §3).
|
|
2
|
+
|
|
3
|
+
A conventional labeled plot — ``ax.plot(t, y, label="Control")`` plus only ``fp.save`` — should
|
|
4
|
+
yield a named series with exact data, not an anonymous ``extra.*``. This module promotes raw
|
|
5
|
+
artists to series marks under strict rules:
|
|
6
|
+
|
|
7
|
+
- **Identity comes only from a public artist label** the user authored (non-empty, not
|
|
8
|
+
``_``-private). Never from color, draw order, geometry shape, linestyle or legend position.
|
|
9
|
+
- **Data comes only from the artist's own exact public state** (``get_data()``,
|
|
10
|
+
``get_offsets()``, bar patch geometry). Nothing is reconstructed or guessed.
|
|
11
|
+
- **Ambiguity declines**: duplicate labels, or a label colliding with an explicitly tagged
|
|
12
|
+
series, leave every candidate in the honest ``extra.*`` fallback with one actionable warning.
|
|
13
|
+
- **Explicit helpers/tags always win** — an artist already in the registry is never touched.
|
|
14
|
+
|
|
15
|
+
Runs inside :func:`fluxplot.save` before gid assignment; the orphan sweep in ``tagger.py``
|
|
16
|
+
still rescues whatever this module (deliberately) does not claim.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import warnings as _warnings
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from . import ids as _ids
|
|
25
|
+
from .descriptors import Mark
|
|
26
|
+
|
|
27
|
+
#: Additive manifest provenance for auto-promoted series: how identity and data were captured.
|
|
28
|
+
AUTO_CAPTURE = {"identity": "artist-label", "data": "artist"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_colorbar_axes(ax) -> bool:
|
|
32
|
+
"""True if this Axes is a colorbar (added by fig.colorbar), not a plot area."""
|
|
33
|
+
return getattr(ax, "_colorbar", None) is not None or ax.get_label() == "<colorbar>"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _public_label(artist) -> str | None:
|
|
37
|
+
"""The artist's label iff it is public identity (non-empty, not ``_child0``/``_nolegend_``)."""
|
|
38
|
+
lab = artist.get_label()
|
|
39
|
+
if not isinstance(lab, str):
|
|
40
|
+
return None
|
|
41
|
+
if not lab.strip() or lab.startswith("_"):
|
|
42
|
+
return None
|
|
43
|
+
return lab
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _finite_offsets(coll):
|
|
47
|
+
"""Exact Nx2 finite offsets, or None (masked/non-finite would shift per-point indices)."""
|
|
48
|
+
off = coll.get_offsets()
|
|
49
|
+
if np.ma.isMaskedArray(off) and np.ma.is_masked(off):
|
|
50
|
+
return None
|
|
51
|
+
arr = np.asarray(off, dtype=float)
|
|
52
|
+
if arr.ndim != 2 or arr.shape[-1] != 2 or arr.size == 0 or not np.isfinite(arr).all():
|
|
53
|
+
return None
|
|
54
|
+
return arr
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract_xy(artist):
|
|
58
|
+
from .data import artist_xy
|
|
59
|
+
return artist_xy(artist)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def promote_labeled(fig, reg) -> list[str]:
|
|
63
|
+
"""Promote safe labeled raw artists on ``fig`` into ``reg``. Returns warning strings."""
|
|
64
|
+
from matplotlib.collections import PathCollection, PolyCollection
|
|
65
|
+
from matplotlib.container import BarContainer
|
|
66
|
+
from matplotlib.lines import Line2D
|
|
67
|
+
|
|
68
|
+
notes: list[str] = []
|
|
69
|
+
already = {id(a) for m in reg.marks for a in m.artists}
|
|
70
|
+
|
|
71
|
+
# candidates in deterministic order: per axes → lines, collections, bar containers
|
|
72
|
+
candidates: list[tuple[str, str, object]] = [] # (label, adapter, artist/container)
|
|
73
|
+
for ax in fig.axes:
|
|
74
|
+
if is_colorbar_axes(ax):
|
|
75
|
+
continue
|
|
76
|
+
for ln in ax.lines:
|
|
77
|
+
if id(ln) in already or not isinstance(ln, Line2D):
|
|
78
|
+
continue
|
|
79
|
+
lab = _public_label(ln)
|
|
80
|
+
if lab is not None:
|
|
81
|
+
candidates.append((lab, "line", ln))
|
|
82
|
+
for coll in ax.collections:
|
|
83
|
+
if id(coll) in already:
|
|
84
|
+
continue
|
|
85
|
+
lab = _public_label(coll)
|
|
86
|
+
if lab is None:
|
|
87
|
+
continue
|
|
88
|
+
if isinstance(coll, PolyCollection):
|
|
89
|
+
candidates.append((lab, "area", coll))
|
|
90
|
+
elif isinstance(coll, PathCollection):
|
|
91
|
+
candidates.append((lab, "point", coll))
|
|
92
|
+
for cont in getattr(ax, "containers", []):
|
|
93
|
+
if not isinstance(cont, BarContainer) or not cont.patches:
|
|
94
|
+
continue
|
|
95
|
+
if any(id(p) in already for p in cont.patches):
|
|
96
|
+
continue
|
|
97
|
+
lab = _public_label(cont)
|
|
98
|
+
if lab is not None:
|
|
99
|
+
candidates.append((lab, "bar", cont))
|
|
100
|
+
|
|
101
|
+
# ambiguity: duplicate candidate labels, or a label whose slug collides with an
|
|
102
|
+
# explicitly tagged series → decline ALL involved candidates (extra.* keeps them honest)
|
|
103
|
+
explicit_slugs = {_ids.series_root(m.series) for m in reg.marks if m.series is not None}
|
|
104
|
+
slug_counts: dict[str, int] = {}
|
|
105
|
+
for lab, _, _ in candidates:
|
|
106
|
+
slug = _ids.series_root(lab)
|
|
107
|
+
slug_counts[slug] = slug_counts.get(slug, 0) + 1
|
|
108
|
+
dropped = sorted(
|
|
109
|
+
{
|
|
110
|
+
lab
|
|
111
|
+
for lab, _, _ in candidates
|
|
112
|
+
if slug_counts[_ids.series_root(lab)] > 1 or _ids.series_root(lab) in explicit_slugs
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
if dropped:
|
|
116
|
+
msg = (
|
|
117
|
+
f"fluxplot: not auto-promoting artist label(s) {dropped}: each duplicates another "
|
|
118
|
+
"artist's label or an explicitly tagged series, so identity is ambiguous. The "
|
|
119
|
+
"artists stay addressable as extra.*; give them distinct labels, or use the fp.* "
|
|
120
|
+
"helpers / fp.tag(series=...) for explicit identity."
|
|
121
|
+
)
|
|
122
|
+
_warnings.warn(msg, UserWarning, stacklevel=3)
|
|
123
|
+
notes.append(msg)
|
|
124
|
+
skip = {_ids.series_root(lab) for lab in dropped}
|
|
125
|
+
|
|
126
|
+
for lab, adapter, art in candidates:
|
|
127
|
+
if _ids.series_root(lab) in skip:
|
|
128
|
+
continue
|
|
129
|
+
capture = {"capture": dict(AUTO_CAPTURE)}
|
|
130
|
+
if adapter == "line":
|
|
131
|
+
fx, fy = extract_xy(art)
|
|
132
|
+
reg.add(
|
|
133
|
+
Mark(role="line", series=lab, kind="line", x=fx, y=fy, label=lab,
|
|
134
|
+
artists=[art], live_data=True, data=capture)
|
|
135
|
+
)
|
|
136
|
+
elif adapter == "point":
|
|
137
|
+
arr = _finite_offsets(art)
|
|
138
|
+
if arr is None:
|
|
139
|
+
msg = (
|
|
140
|
+
f"fluxplot: not auto-promoting scatter {lab!r}: offsets are masked or "
|
|
141
|
+
"non-finite, so per-point indices would not match the drawn points. It "
|
|
142
|
+
"stays addressable as extra.*; use fp.scatter for explicit tagging."
|
|
143
|
+
)
|
|
144
|
+
_warnings.warn(msg, UserWarning, stacklevel=3)
|
|
145
|
+
notes.append(msg)
|
|
146
|
+
continue
|
|
147
|
+
reg.add(
|
|
148
|
+
Mark(role="point", series=lab, kind="scatter",
|
|
149
|
+
x=[float(v) for v in arr[:, 0]], y=[float(v) for v in arr[:, 1]],
|
|
150
|
+
label=lab, artists=[art], indexed=True, live_data=True, data=capture)
|
|
151
|
+
)
|
|
152
|
+
elif adapter == "area":
|
|
153
|
+
# exact fill geometry lives in the SVG; the original y1/y2 vectors are not
|
|
154
|
+
# recoverable from the artist, and we do not invent them
|
|
155
|
+
reg.add(Mark(role="area", series=lab, kind="area", label=lab, artists=[art], live_data=True, data=capture))
|
|
156
|
+
elif adapter == "bar":
|
|
157
|
+
from .data import bar_data
|
|
158
|
+
cx, cy, bar = bar_data(art.patches, getattr(art, "orientation", "vertical"))
|
|
159
|
+
capture["bar"] = bar
|
|
160
|
+
reg.add(
|
|
161
|
+
Mark(role="bar", series=lab, kind="bar", x=cx, y=cy, label=lab,
|
|
162
|
+
artists=list(art.patches), indexed=True, live_data=True, data=capture)
|
|
163
|
+
)
|
|
164
|
+
return notes
|
fluxplot/base.mplstyle
ADDED
|
File without changes
|
fluxplot/brackets.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""``fp.brackets``: significance brackets drawn from ``fp.stats`` rows, auto-stacked, with the test
|
|
2
|
+
behind every star recorded in the manifest.
|
|
3
|
+
|
|
4
|
+
>>> rows = fp.stats.pairwise(fp.stats.welch_hedges, {"ctl": ctl, "drug": drug, "sham": sham})
|
|
5
|
+
>>> gb = fp.glowbar(data=df, x="group", y="value", ax=ax)
|
|
6
|
+
>>> gb.brackets(rows) # or fp.brackets(ax, rows, positions=gb.positions)
|
|
7
|
+
|
|
8
|
+
Each post-hoc row names its pair in ``groups``; ``positions`` maps a group name to its x. Brackets
|
|
9
|
+
are placed shortest first, each one step above the data it spans and above every bracket it
|
|
10
|
+
overlaps in x, so no bracket ever crosses another or sits on the points. The manifest overlay of
|
|
11
|
+
each bracket carries ``stats`` — test, statistic, raw and corrected p, correction, effect size and
|
|
12
|
+
its CI, sizes — so the figure states exactly which test each star came from.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
__all__ = ["brackets", "stars", "format_p", "THRESHOLDS"]
|
|
22
|
+
|
|
23
|
+
#: the default star thresholds: ``p < 0.001`` → ``***``, ``p < 0.01`` → ``**``, ``p < 0.05`` → ``*``
|
|
24
|
+
THRESHOLDS = ((0.001, "***"), (0.01, "**"), (0.05, "*"))
|
|
25
|
+
|
|
26
|
+
_CORRECTION_OF = {"p_corrected_holm": "holm", "p_corrected_bh": "bh", "p-value": "none"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def stars(p: float, thresholds: Sequence[Tuple[float, str]] = THRESHOLDS, ns: str = "ns") -> str:
|
|
30
|
+
"""The star label of a p-value: the first threshold it falls under, else ``ns``."""
|
|
31
|
+
if p is None or not math.isfinite(p):
|
|
32
|
+
return ns
|
|
33
|
+
for cut, label in sorted(thresholds, key=lambda t: t[0]):
|
|
34
|
+
if p < cut:
|
|
35
|
+
return label
|
|
36
|
+
return ns
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_p(p: float, floor: float = 0.001) -> str:
|
|
40
|
+
"""``p < 0.001`` below ``floor``, else ``p = 0.003`` (three decimals)."""
|
|
41
|
+
if p is None or not math.isfinite(p):
|
|
42
|
+
return "p = n/a"
|
|
43
|
+
if p < floor:
|
|
44
|
+
return f"p < {floor:g}"
|
|
45
|
+
return f"p = {p:.3f}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _positions_from_ticks(ax) -> Dict[str, float]:
|
|
49
|
+
return {lbl.get_text(): float(tick) for tick, lbl in zip(ax.get_xticks(), ax.get_xticklabels())
|
|
50
|
+
if lbl.get_text()}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _data_top(ax, x_lo: float, x_hi: float) -> Optional[float]:
|
|
54
|
+
"""The highest data y drawn between ``x_lo`` and ``x_hi`` (inclusive) on ``ax``, from lines,
|
|
55
|
+
collections (offsets or path vertices) and patches — the things a bracket must clear."""
|
|
56
|
+
from matplotlib.collections import Collection
|
|
57
|
+
from matplotlib.patches import Patch
|
|
58
|
+
top = -np.inf
|
|
59
|
+
|
|
60
|
+
def take(x, y):
|
|
61
|
+
nonlocal top
|
|
62
|
+
x, y = np.asarray(x, dtype=float).ravel(), np.asarray(y, dtype=float).ravel()
|
|
63
|
+
keep = np.isfinite(x) & np.isfinite(y) & (x >= x_lo) & (x <= x_hi)
|
|
64
|
+
if keep.any():
|
|
65
|
+
top = max(top, float(y[keep].max()))
|
|
66
|
+
|
|
67
|
+
for ln in ax.lines:
|
|
68
|
+
if ln.get_transform() is ax.transData or ln.get_transform().contains_branch(ax.transData):
|
|
69
|
+
take(ln.get_xdata(orig=False), ln.get_ydata(orig=False))
|
|
70
|
+
for coll in ax.collections:
|
|
71
|
+
if not isinstance(coll, Collection):
|
|
72
|
+
continue
|
|
73
|
+
offsets = coll.get_offsets()
|
|
74
|
+
if offsets is not None and len(offsets) and coll.get_offset_transform() is ax.transData:
|
|
75
|
+
off = np.ma.filled(np.ma.asarray(offsets, dtype=float), np.nan)
|
|
76
|
+
take(off[:, 0], off[:, 1])
|
|
77
|
+
elif coll.get_transform() is ax.transData or coll.get_transform().contains_branch(ax.transData):
|
|
78
|
+
for path in coll.get_paths():
|
|
79
|
+
v = path.vertices
|
|
80
|
+
take(v[:, 0], v[:, 1])
|
|
81
|
+
for patch in ax.patches:
|
|
82
|
+
if not isinstance(patch, Patch):
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
v = patch.get_path().transformed(patch.get_patch_transform()).vertices
|
|
86
|
+
except Exception: # a patch with no data-space geometry
|
|
87
|
+
continue
|
|
88
|
+
take(v[:, 0], v[:, 1])
|
|
89
|
+
return None if not np.isfinite(top) else top
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _stats_payload(row: dict, p_column: str) -> dict:
|
|
93
|
+
lo, hi = row.get("effect_size_ci_low"), row.get("effect_size_ci_high")
|
|
94
|
+
if lo is None and isinstance(row.get("effect_size_95_CI"), str):
|
|
95
|
+
try:
|
|
96
|
+
lo, hi = (float(v) for v in row["effect_size_95_CI"].strip("[]").split(","))
|
|
97
|
+
except ValueError:
|
|
98
|
+
lo = hi = None
|
|
99
|
+
n = [row.get("n_a"), row.get("n_b")] if row.get("n_a") is not None else row.get("n_total")
|
|
100
|
+
out = {
|
|
101
|
+
"test": row.get("sig_test_used"),
|
|
102
|
+
"statistic": row.get("test_statistic_value"),
|
|
103
|
+
"p": row.get("p-value"),
|
|
104
|
+
"pCorrected": row.get(p_column),
|
|
105
|
+
"correction": _CORRECTION_OF.get(p_column, p_column),
|
|
106
|
+
"effectSizeMethod": row.get("effect_size_method"),
|
|
107
|
+
"effectSize": row.get("effect_size_value"),
|
|
108
|
+
"ciLow": lo,
|
|
109
|
+
"ciHigh": hi,
|
|
110
|
+
"n": n,
|
|
111
|
+
}
|
|
112
|
+
if row.get("dof") is not None:
|
|
113
|
+
out["dof"] = row["dof"]
|
|
114
|
+
if row.get("alternative"):
|
|
115
|
+
out["alternative"] = row["alternative"]
|
|
116
|
+
return {k: v for k, v in out.items() if v is not None}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def brackets(ax, rows: Iterable[dict], *, positions: Optional[Dict[Any, float]] = None,
|
|
120
|
+
pairs: Optional[Iterable[Tuple[Any, Any]]] = None,
|
|
121
|
+
label: Union[str, Callable[[dict], str]] = "stars", p_column: str = "p_corrected_holm",
|
|
122
|
+
thresholds: Sequence[Tuple[float, str]] = THRESHOLDS, ns: bool = True,
|
|
123
|
+
top: Optional[float] = None, step: float = 0.06, tip: float = 0.02, **line_kw) -> list:
|
|
124
|
+
"""Draw one significance bracket per ``fp.stats`` row, stacked so none overlap.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
ax
|
|
129
|
+
The axes the compared groups are plotted on.
|
|
130
|
+
rows
|
|
131
|
+
Reporting rows whose ``groups`` is the pair ``[a, b]`` (``fp.stats.pairwise``,
|
|
132
|
+
``tukey_hsd``, ``games_howell``, ``dunn``; or any two-group row given ``names=``).
|
|
133
|
+
positions
|
|
134
|
+
``{group name: x}``. Default: the axes' x tick labels (a glowbar / fluxbox / categorical
|
|
135
|
+
axis), which must name every group; ``GlowbarResult.positions`` and
|
|
136
|
+
``FluxboxResult.positions`` give it directly.
|
|
137
|
+
pairs
|
|
138
|
+
Draw only these ``(a, b)`` pairs (either order), in this order of priority when several
|
|
139
|
+
rows name the same pair. Default: every row.
|
|
140
|
+
label
|
|
141
|
+
``"stars"`` (``***``/``**``/``*``/``ns`` by ``thresholds``), ``"p"`` (``p = 0.003`` /
|
|
142
|
+
``p < 0.001``), ``"both"`` (stars over the p), or a callable ``row -> str``.
|
|
143
|
+
p_column
|
|
144
|
+
The row column the label and the stacking read: ``"p_corrected_holm"`` (default),
|
|
145
|
+
``"p_corrected_bh"`` or ``"p-value"``.
|
|
146
|
+
thresholds
|
|
147
|
+
``((p, stars), …)``: the first threshold ``p`` falls under gives its label.
|
|
148
|
+
ns
|
|
149
|
+
Draw a bracket labelled ``ns`` for non-significant pairs (default). ``False`` omits them.
|
|
150
|
+
top
|
|
151
|
+
Start the stack at this y instead of above the data each bracket spans.
|
|
152
|
+
step, tip
|
|
153
|
+
Vertical step between brackets and the tip height, as fractions of the y range (on a log
|
|
154
|
+
axis, of the range in decades).
|
|
155
|
+
**line_kw
|
|
156
|
+
Passed to :func:`fluxplot.significance_bracket` (``color``, ``linewidth``, ``text_kw``, …).
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
list
|
|
161
|
+
The bracket line artists, in drawing order (shortest span first). Each carries the
|
|
162
|
+
manifest ``stats`` payload of its row.
|
|
163
|
+
"""
|
|
164
|
+
from .api import significance_bracket
|
|
165
|
+
rows = list(rows)
|
|
166
|
+
from_ticks = positions is None
|
|
167
|
+
if from_ticks:
|
|
168
|
+
positions = _positions_from_ticks(ax)
|
|
169
|
+
pos = {str(k): float(v) for k, v in positions.items()}
|
|
170
|
+
wanted = None if pairs is None else [(str(a), str(b)) for a, b in pairs]
|
|
171
|
+
|
|
172
|
+
items = []
|
|
173
|
+
for row in rows:
|
|
174
|
+
groups = row.get("groups")
|
|
175
|
+
if not groups or len(groups) != 2:
|
|
176
|
+
raise ValueError("brackets: every row needs groups=[a, b]; run the test with names=(a, b) or use fp.stats.pairwise")
|
|
177
|
+
a, b = (str(g) for g in groups)
|
|
178
|
+
if wanted is not None and (a, b) not in wanted and (b, a) not in wanted:
|
|
179
|
+
continue
|
|
180
|
+
for g in (a, b):
|
|
181
|
+
if g not in pos:
|
|
182
|
+
if from_ticks:
|
|
183
|
+
raise ValueError(f"brackets: positions= is needed — the x tick labels do not name group {g!r}")
|
|
184
|
+
raise KeyError(f"brackets: group {g!r} has no position; positions has {sorted(pos)}")
|
|
185
|
+
if p_column not in row:
|
|
186
|
+
raise KeyError(f"brackets: rows have no {p_column!r} column (have {sorted(row)})")
|
|
187
|
+
p = row[p_column]
|
|
188
|
+
significant = p is not None and math.isfinite(p) and p < max(t for t, _ in thresholds)
|
|
189
|
+
if not significant and not ns:
|
|
190
|
+
continue
|
|
191
|
+
items.append((a, b, row, p))
|
|
192
|
+
if wanted is not None:
|
|
193
|
+
order = {pair: i for i, pair in enumerate(wanted)}
|
|
194
|
+
items.sort(key=lambda it: min(order.get((it[0], it[1]), len(order)), order.get((it[1], it[0]), len(order))))
|
|
195
|
+
# shortest span first: a long bracket then rises above the short ones it covers
|
|
196
|
+
items.sort(key=lambda it: abs(pos[it[0]] - pos[it[1]]))
|
|
197
|
+
|
|
198
|
+
log = ax.get_yscale() == "log"
|
|
199
|
+
y0, y1 = ax.get_ylim()
|
|
200
|
+
if log:
|
|
201
|
+
span = abs(math.log10(y1) - math.log10(y0)) or 1.0
|
|
202
|
+
rise = lambda y, frac: y * 10 ** (frac * span) # noqa: E731
|
|
203
|
+
else:
|
|
204
|
+
span = abs(y1 - y0) or 1.0
|
|
205
|
+
rise = lambda y, frac: y + frac * span # noqa: E731
|
|
206
|
+
xs = sorted(set(pos.values()))
|
|
207
|
+
half = (min(np.diff(xs)) / 2.0) if len(xs) > 1 else 0.5
|
|
208
|
+
|
|
209
|
+
placed: List[Tuple[float, float, float]] = [] # (x_lo, x_hi, y of the bracket base)
|
|
210
|
+
out = []
|
|
211
|
+
for a, b, row, p in items:
|
|
212
|
+
x_lo, x_hi = sorted((pos[a], pos[b]))
|
|
213
|
+
if top is not None:
|
|
214
|
+
base = float(top)
|
|
215
|
+
else:
|
|
216
|
+
data_top = _data_top(ax, x_lo - half, x_hi + half)
|
|
217
|
+
base = rise(data_top if data_top is not None else y0, step)
|
|
218
|
+
for px_lo, px_hi, py in placed:
|
|
219
|
+
if x_lo <= px_hi and px_lo <= x_hi: # x-spans overlap (a shared category counts)
|
|
220
|
+
base = max(base, rise(py, step))
|
|
221
|
+
if callable(label):
|
|
222
|
+
text = str(label(row))
|
|
223
|
+
elif label == "stars":
|
|
224
|
+
text = stars(p, thresholds)
|
|
225
|
+
elif label == "p":
|
|
226
|
+
text = format_p(p)
|
|
227
|
+
elif label == "both":
|
|
228
|
+
text = f"{stars(p, thresholds)}\n{format_p(p)}"
|
|
229
|
+
else:
|
|
230
|
+
raise ValueError(f"brackets: label must be 'stars', 'p', 'both' or a callable, got {label!r}")
|
|
231
|
+
height = rise(base, tip) - base
|
|
232
|
+
br = significance_bracket(ax, x0=pos[a], x1=pos[b], y=base, label=text, between=(a, b), p=p,
|
|
233
|
+
name=f"{a}-{b}", height=height, stats=_stats_payload(row, p_column),
|
|
234
|
+
**line_kw)
|
|
235
|
+
placed.append((x_lo, x_hi, base))
|
|
236
|
+
out.append(br)
|
|
237
|
+
if out: # make room: the top bracket must lie inside the axes
|
|
238
|
+
highest = max(rise(py, tip) for _, _, py in placed)
|
|
239
|
+
top_tip = rise(highest, step / 2)
|
|
240
|
+
if top_tip > y1:
|
|
241
|
+
ax.set_ylim(y0, top_tip)
|
|
242
|
+
return out
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Deterministic standards-compliant JSON, preserving scientific float precision.
|
|
2
|
+
|
|
3
|
+
Missing observations are encoded by the data adapters, never by the serializer.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _native(obj):
|
|
10
|
+
if isinstance(obj, dict):
|
|
11
|
+
return {str(k): _native(v) for k, v in obj.items()}
|
|
12
|
+
if isinstance(obj, (list, tuple)):
|
|
13
|
+
return [_native(v) for v in obj]
|
|
14
|
+
if hasattr(obj, 'tolist'):
|
|
15
|
+
return _native(obj.tolist())
|
|
16
|
+
if isinstance(obj, float) and obj == 0:
|
|
17
|
+
return 0.0
|
|
18
|
+
return obj
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dumps(obj) -> str:
|
|
22
|
+
return json.dumps(_native(obj), ensure_ascii=False, allow_nan=False,
|
|
23
|
+
sort_keys=True, indent=2, separators=(',', ': ')) + '\n'
|
fluxplot/capture.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Capture each axis's data↔SVG-pixel mapping + scale type (spec §6).
|
|
2
|
+
|
|
3
|
+
Consumers must NOT have to reconstruct matplotlib's transform. We store, per axis, explicit
|
|
4
|
+
``(data, svg)`` anchor pairs plus the scale type — two anchors + scale is the complete, transform-free
|
|
5
|
+
contract a morph / resize / agent needs. The conversion is the dpi-proof *fraction* method verified
|
|
6
|
+
in ``NOTES_matplotlib_svg.md`` §3 (exact round-trip against emitted marker positions).
|
|
7
|
+
|
|
8
|
+
Call :func:`capture_axes` AFTER ``fig.canvas.draw()`` (so layout/transforms are final) and BEFORE
|
|
9
|
+
the controlled render.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def svg_viewbox(fig) -> tuple[float, float]:
|
|
17
|
+
"""SVG user-space size in points — always ``figsize_inches * 72`` regardless of dpi."""
|
|
18
|
+
return fig.get_figwidth() * 72.0, fig.get_figheight() * 72.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def data_to_svg(ax, fig, x: float, y: float) -> tuple[float, float]:
|
|
22
|
+
"""Map a data point to SVG user-space coordinates (origin top-left, points)."""
|
|
23
|
+
disp = ax.transData.transform((x, y))
|
|
24
|
+
w_px, h_px = fig.bbox.width, fig.bbox.height
|
|
25
|
+
vbw, vbh = svg_viewbox(fig)
|
|
26
|
+
sx = disp[0] / w_px * vbw
|
|
27
|
+
sy = (1.0 - disp[1] / h_px) * vbh
|
|
28
|
+
return float(sx), float(sy)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _log_base(axis) -> float:
|
|
32
|
+
try:
|
|
33
|
+
return float(axis.get_transform().base)
|
|
34
|
+
except Exception:
|
|
35
|
+
return 10.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tick_kinds(axis):
|
|
39
|
+
"""Portable names for an axis' major tick locator and formatter classes: the consumer that
|
|
40
|
+
re-ticks a view (or redraws a colour key) picks the matching scheme."""
|
|
41
|
+
from matplotlib import ticker
|
|
42
|
+
loc, fmt = axis.get_major_locator(), axis.get_major_formatter()
|
|
43
|
+
if isinstance(loc, ticker.LogLocator):
|
|
44
|
+
locator = 'log'
|
|
45
|
+
elif isinstance(loc, ticker.FixedLocator):
|
|
46
|
+
locator = 'fixed'
|
|
47
|
+
elif isinstance(loc, ticker.MultipleLocator):
|
|
48
|
+
locator = 'multiple'
|
|
49
|
+
elif type(loc).__module__ == 'matplotlib.dates':
|
|
50
|
+
locator = 'date'
|
|
51
|
+
elif type(loc).__module__ == 'matplotlib.category':
|
|
52
|
+
locator = 'category'
|
|
53
|
+
else:
|
|
54
|
+
locator = 'auto'
|
|
55
|
+
if isinstance(fmt, ticker.LogFormatter):
|
|
56
|
+
formatter = 'log'
|
|
57
|
+
elif isinstance(fmt, ticker.PercentFormatter):
|
|
58
|
+
formatter = 'percent'
|
|
59
|
+
elif isinstance(fmt, ticker.ScalarFormatter):
|
|
60
|
+
formatter = 'sci' if fmt.get_offset() else 'plain'
|
|
61
|
+
elif isinstance(fmt, ticker.FixedFormatter):
|
|
62
|
+
formatter = 'fixed'
|
|
63
|
+
elif type(fmt).__module__ == 'matplotlib.dates':
|
|
64
|
+
formatter = 'date'
|
|
65
|
+
elif type(fmt).__module__ == 'matplotlib.category':
|
|
66
|
+
formatter = 'category'
|
|
67
|
+
else:
|
|
68
|
+
formatter = 'custom'
|
|
69
|
+
return locator, formatter
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _axis_capture(ax, fig, which: str) -> dict:
|
|
73
|
+
if which == "x":
|
|
74
|
+
scale = ax.get_xscale()
|
|
75
|
+
lo, hi = ax.get_xlim()
|
|
76
|
+
label = ax.get_xlabel()
|
|
77
|
+
y_ref = ax.get_ylim()[0]
|
|
78
|
+
endpoints = [(lo, y_ref), (hi, y_ref)]
|
|
79
|
+
mpl_axis = ax.xaxis
|
|
80
|
+
else:
|
|
81
|
+
scale = ax.get_yscale()
|
|
82
|
+
lo, hi = ax.get_ylim()
|
|
83
|
+
label = ax.get_ylabel()
|
|
84
|
+
x_ref = ax.get_xlim()[0]
|
|
85
|
+
endpoints = [(x_ref, lo), (x_ref, hi)]
|
|
86
|
+
mpl_axis = ax.yaxis
|
|
87
|
+
|
|
88
|
+
out: dict = {
|
|
89
|
+
"scale": scale,
|
|
90
|
+
"label": label,
|
|
91
|
+
"domain": [float(lo), float(hi)],
|
|
92
|
+
"anchors": [],
|
|
93
|
+
}
|
|
94
|
+
if scale == "log":
|
|
95
|
+
out["base"] = _log_base(mpl_axis)
|
|
96
|
+
|
|
97
|
+
supported = getattr(ax, "name", "rectilinear") == "rectilinear" and scale in ("linear", "log")
|
|
98
|
+
out["supported"] = supported
|
|
99
|
+
out["tickLocator"], out["tickFormatter"] = tick_kinds(mpl_axis)
|
|
100
|
+
out["ticks"] = [{"value": float(v), "label": t.get_text()}
|
|
101
|
+
for v, t in zip(mpl_axis.get_ticklocs(), mpl_axis.get_ticklabels()) if np.isfinite(v)]
|
|
102
|
+
converter = getattr(mpl_axis, "get_converter", lambda: getattr(mpl_axis, "converter", None))()
|
|
103
|
+
module = type(converter).__module__ if converter else ""
|
|
104
|
+
if module == "matplotlib.dates":
|
|
105
|
+
from matplotlib.dates import get_epoch
|
|
106
|
+
out["units"] = {"kind": "date", "epoch": get_epoch(), "unit": "day"}
|
|
107
|
+
elif module == "matplotlib.category":
|
|
108
|
+
out["units"] = {"kind": "category"}
|
|
109
|
+
for (dx, dy), data_val in zip(endpoints, (lo, hi)):
|
|
110
|
+
if not supported:
|
|
111
|
+
break
|
|
112
|
+
sx, sy = data_to_svg(ax, fig, dx, dy)
|
|
113
|
+
out["anchors"].append({"data": float(data_val), "svg": sx if which == "x" else sy})
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def capture_axes(ax, fig, twins=()) -> dict:
|
|
118
|
+
"""Return ``{"x": {...}, "y": {...}, "pixelBox": {...}}`` for one Axes — plus ``"y2"`` /
|
|
119
|
+
``"x2"`` for each twin axes (``[(axes, "y2" | "x2")]``) sharing its frame."""
|
|
120
|
+
# plot-area rectangle in SVG coords (convenience; the SVG clipPath stays authoritative).
|
|
121
|
+
vbw, vbh = svg_viewbox(fig)
|
|
122
|
+
box = ax.get_window_extent()
|
|
123
|
+
sx0, sx1 = box.x0 / fig.bbox.width * vbw, box.x1 / fig.bbox.width * vbw
|
|
124
|
+
sy0, sy1 = (1 - box.y1 / fig.bbox.height) * vbh, (1 - box.y0 / fig.bbox.height) * vbh
|
|
125
|
+
out = {
|
|
126
|
+
"projection": getattr(ax, "name", "rectilinear"),
|
|
127
|
+
"x": _axis_capture(ax, fig, "x"),
|
|
128
|
+
"y": _axis_capture(ax, fig, "y"),
|
|
129
|
+
"pixelBox": {
|
|
130
|
+
"x0": min(sx0, sx1),
|
|
131
|
+
"y0": min(sy0, sy1),
|
|
132
|
+
"x1": max(sx0, sx1),
|
|
133
|
+
"y1": max(sy0, sy1),
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
for twin, which in twins:
|
|
137
|
+
if which not in out: # the first twin of each kind; a third value axis has no slot
|
|
138
|
+
out[which] = _axis_capture(twin, fig, "y" if which == "y2" else "x")
|
|
139
|
+
functions = getattr(twin, "_functions", None)
|
|
140
|
+
if functions is not None and callable(functions[0]):
|
|
141
|
+
# a secondary axis: the parent → secondary transform as samples across the parent's range
|
|
142
|
+
lo, hi = (ax.get_xlim() if which == "x2" else ax.get_ylim())
|
|
143
|
+
grid = np.linspace(lo, hi, 9)
|
|
144
|
+
try:
|
|
145
|
+
mapped = np.asarray(functions[0](grid), dtype=float)
|
|
146
|
+
out[which]["secondary"] = {"of": "x" if which == "x2" else "y",
|
|
147
|
+
"samples": [[float(a), float(b)] for a, b in zip(grid, mapped) if np.isfinite(b)]}
|
|
148
|
+
except Exception: # a transform that rejects the grid: the axis stays, unsampled
|
|
149
|
+
out[which]["secondary"] = {"of": "x" if which == "x2" else "y", "samples": []}
|
|
150
|
+
return out
|