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
@@ -0,0 +1,149 @@
1
+ """Tests for two independent (unpaired) samples.
2
+
3
+ Each function returns one row as a dict keyed by :data:`REPORT_COLUMNS`, so a set of comparisons
4
+ drops straight into a DataFrame (``pl.DataFrame([fp.stats.welch_hedges(a, b)])``) and from there
5
+ into a plot's ``_stats`` dissection as a CSV.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Tuple
10
+
11
+ import numpy as np
12
+ from scipy import optimize as _opt
13
+ from scipy import stats as _sp
14
+
15
+ from ._common import REPORT_COLUMNS, as_sample, check_alternative, report_row
16
+
17
+ __all__ = ["welch_hedges", "mann_whitney_cliff", "REPORT_COLUMNS"]
18
+
19
+ _Z = _sp.norm.ppf(0.975)
20
+
21
+
22
+ def hedges_unpooled(a: np.ndarray, b: np.ndarray) -> Tuple[float, float, float]:
23
+ """Hedges' g standardised by the non-pooled SD ``sqrt((var_a + var_b) / 2)`` with its Bonett
24
+ (2008) 95% CI: ``(g, lo, hi)``. Shared by :func:`welch_hedges` and the Games–Howell post hoc."""
25
+ n1, n2 = a.size, b.size
26
+ v1, v2 = a.var(ddof=1), b.var(ddof=1)
27
+ s = np.sqrt((v1 + v2) / 2)
28
+ if s == 0:
29
+ raise ValueError("both samples have zero variance; the effect size is undefined")
30
+ d = (a.mean() - b.mean()) / s
31
+ j = 1 - 3 / (4 * (n1 + n2 - 2) - 1)
32
+ se = np.sqrt(d**2 * (v1**2 / (n1 - 1) + v2**2 / (n2 - 1)) / (8 * s**4)
33
+ + (v1 / (n1 - 1) + v2 / (n2 - 1)) / s**2)
34
+ return float(j * d), float(d - _Z * se), float(d + _Z * se)
35
+
36
+
37
+ def cliffs_delta(a: np.ndarray, b: np.ndarray) -> Tuple[float, float, float]:
38
+ """Cliff's delta ``P(a > b) - P(a < b)`` with Newcombe's Method 5 95% CI: ``(delta, lo, hi)``.
39
+ Shared by :func:`mann_whitney_cliff` and Dunn's post hoc."""
40
+ delta = float(np.sign(a[:, None] - b[None, :]).mean()) # mean of sign(a_i - b_j) over all pairs
41
+ lo, hi = _newcombe_auc_bounds((delta + 1) / 2, a.size, b.size)
42
+ return delta, 2 * lo - 1, 2 * hi - 1
43
+
44
+
45
+ def welch_hedges(a: Any, b: Any, *, alternative: str = "two-sided", names=("a", "b")) -> Dict[str, Any]:
46
+ """Welch's t-test of ``a`` vs ``b`` with Hedges' g (non-pooled SD) and its 95% CI.
47
+
48
+ For independent samples compared on their arithmetic means. The effect size is standardized by
49
+ the non-pooled SD ``sqrt((var_a + var_b) / 2)``, so it stays meaningful when the group variances
50
+ differ. The estimate carries the small-sample bias correction
51
+ ``J = 1 - 3 / (4 (n_a + n_b - 2) - 1)``. The 95% CI is Bonett's (2008) unequal-variance interval
52
+ for the population value; it is not multiplied by ``J``, because the interval targets the
53
+ parameter itself and ``J`` would pull it off target (simulated coverage is 95–97% as is).
54
+
55
+ Signs follow ``a - b``: a positive statistic and effect size mean ``a`` has the larger mean.
56
+
57
+ Parameters
58
+ ----------
59
+ a, b
60
+ The two samples (any 1-D array-like of numbers). Each needs at least 2 finite values.
61
+ alternative
62
+ ``"two-sided"`` (default), ``"less"`` (``a`` has the smaller mean) or ``"greater"``. The
63
+ p-value follows it; the effect-size CI is always the two-sided 95% interval.
64
+ names
65
+ The names of the two groups, recorded in the row's ``groups`` (``("a", "b")`` by default).
66
+
67
+ Returns
68
+ -------
69
+ dict
70
+ One reporting row keyed by :data:`REPORT_COLUMNS`: the test name, the t statistic, the
71
+ p-value, the Welch–Satterthwaite degrees of freedom, the effect-size method, its value and
72
+ its 95% CI formatted as ``"[low, high]"`` (2 decimals) and as numbers, the sample sizes,
73
+ the group names and the alternative.
74
+
75
+ Example
76
+ -------
77
+ >>> row = fp.stats.welch_hedges(sd_values, sleep_values, names=("SD", "sleep"))
78
+ >>> pl.DataFrame([row]).write_csv("plots/_dissections/app/_stats/welch_ttest.csv")
79
+ """
80
+ a = as_sample(a, "a")
81
+ b = as_sample(b, "b")
82
+ g, lo, hi = hedges_unpooled(a, b)
83
+ t = _sp.ttest_ind(a, b, equal_var=False, alternative=check_alternative(alternative))
84
+ return report_row("Welch's t-test", t.statistic, t.pvalue, t.df, "Hedges' g (non-pooled SD)",
85
+ g, lo, hi, n=(a.size, b.size), groups=names, alternative=alternative)
86
+
87
+
88
+ def _newcombe_auc_bounds(theta: float, m: int, n: int) -> Tuple[float, float]:
89
+ """Newcombe's (2006) Method 5 95% interval for ``theta = P(a > b) + P(a = b) / 2``.
90
+
91
+ A score-type interval: the bounds are the ``t`` solving ``(t - theta)^2 = z^2 V(t)``, where
92
+ ``V`` is the Hanley–McNeil variance of the Mann–Whitney estimate with both sample sizes
93
+ replaced by ``N* = (m + n) / 2 - 1``. Unlike a Wald interval it stays inside ``[0, 1]`` and
94
+ does not collapse when the groups do not overlap (``theta`` = 0 or 1).
95
+ """
96
+ n_star = (m + n) / 2 - 1
97
+
98
+ def gap(t: float) -> float:
99
+ var = t * (1 - t) * (1 + (n_star - 1) * ((1 - t) / (2 - t) + t / (1 + t))) / (m * n)
100
+ return (t - theta) ** 2 - _Z**2 * var
101
+
102
+ # gap(0) = theta^2 > 0 and gap(1) = (1 - theta)^2 > 0 while gap(theta) < 0, so each bound is
103
+ # the one root on its side; at theta = 0 or 1 the bound on that side is the boundary itself
104
+ # (gap vanishes there), so search just inside it.
105
+ eps = 1e-12
106
+ lo = 0.0 if theta <= 0 else _opt.brentq(gap, 0.0, min(theta, 1 - eps), xtol=1e-14)
107
+ hi = 1.0 if theta >= 1 else _opt.brentq(gap, max(theta, eps), 1.0, xtol=1e-14)
108
+ return lo, hi
109
+
110
+
111
+ def mann_whitney_cliff(a: Any, b: Any, *, alternative: str = "two-sided", names=("a", "b")) -> Dict[str, Any]:
112
+ """Mann–Whitney U test of ``a`` vs ``b`` with Cliff's delta and its 95% CI.
113
+
114
+ For independent samples compared by rank (ordered categories, or a rank-based comparison chosen
115
+ before looking at the results). The p-value is scipy's two-sided ``mannwhitneyu``
116
+ (``method="auto"``: exact for small samples without ties, otherwise the normal approximation
117
+ with tie and continuity corrections).
118
+
119
+ Cliff's delta is ``P(a > b) - P(a < b)`` over all ``n_a * n_b`` cross-group pairs (ties count
120
+ as neither), which equals ``2 U_a / (n_a n_b) - 1``. Its 95% CI is Newcombe's (2006) Method 5
121
+ score interval for ``theta = (delta + 1) / 2``, mapped back to the delta scale. It stays inside
122
+ ``[-1, 1]`` and, unlike Cliff's own (1996) interval, does not collapse to a point when the
123
+ groups do not overlap at all (``delta = ±1``) — common with small samples.
124
+
125
+ Signs follow ``a - b``: a positive delta means values in ``a`` tend to be larger.
126
+
127
+ Parameters
128
+ ----------
129
+ a, b
130
+ The two samples (any 1-D array-like of numbers). Each needs at least 2 finite values.
131
+ alternative
132
+ ``"two-sided"`` (default), ``"less"`` or ``"greater"`` (the direction of ``a`` relative to
133
+ ``b``). The CI is always two-sided.
134
+ names
135
+ The names of the two groups, recorded in the row's ``groups``.
136
+
137
+ Returns
138
+ -------
139
+ dict
140
+ One reporting row keyed by :data:`REPORT_COLUMNS`. The statistic is ``U`` for ``a`` (the
141
+ number of pairs with ``a > b``, ties counting one half); ``dof`` is ``None`` (a rank test
142
+ has no degrees of freedom).
143
+ """
144
+ a = as_sample(a, "a")
145
+ b = as_sample(b, "b")
146
+ u = _sp.mannwhitneyu(a, b, alternative=check_alternative(alternative), method="auto")
147
+ delta, lo, hi = cliffs_delta(a, b)
148
+ return report_row("Mann–Whitney U test", u.statistic, u.pvalue, None, "Cliff's delta", delta,
149
+ lo, hi, n=(a.size, b.size), groups=names, alternative=alternative)
fluxplot/style.py ADDED
@@ -0,0 +1,469 @@
1
+ """fluxplot.style — a consistent, beautiful matplotlib house style (Flexoki + cmasher).
2
+
3
+ A thin, additive theming layer, in the same spirit as the rest of FluxPlot: you keep plotting in
4
+ ordinary matplotlib, and this gives every plot a coherent visual identity. It is **independent of
5
+ the semantic API** — use it for quick exploratory plots (``plt.plot``) just as happily as for the
6
+ publication figures you ``fp.save``. One import, and everything you make looks like it belongs to
7
+ the same set.
8
+
9
+ from fluxplot import style as fx
10
+ fx.use_light() # apply the theme (call before creating figures)
11
+
12
+ import matplotlib.pyplot as plt
13
+ fig, ax = plt.subplots()
14
+ ax.plot(x, y) # picks up the Flexoki color cycle automatically
15
+ fx.despine(ax); fx.title(ax, "My result", "a short subtitle")
16
+
17
+ What you get:
18
+ - the **Flexoki** palette (Steph Ango, https://stephango.com/flexoki) as :data:`FLEXOKI`, plus
19
+ light/dark categorical cycles installed as matplotlib's ``prop_cycle``;
20
+ - perceptually-uniform **continuous colormaps** (:data:`SEQUENTIAL`, :data:`DIVERGING`, …) from
21
+ the shipped cmasher collection, installed as matplotlib's default ``image.cmap`` so every
22
+ ``imshow`` / ``fp.heatmap`` without ``cmap=`` uses the house map;
23
+ - :func:`use_light` / :func:`use_dark` themes (clean, despined, sensible fonts/DPI);
24
+ - small helpers :func:`despine` and :func:`title`.
25
+
26
+ Notes
27
+ -----
28
+ - **Backend-agnostic:** this module never calls ``matplotlib.use(...)``, so it won't fight your
29
+ notebook/interactive backend. For headless scripts set ``MPLBACKEND=Agg`` (or call
30
+ ``matplotlib.use("Agg")``) yourself before importing pyplot.
31
+ - **It's yours to tune:** the rcParams live here, but every color definition comes from
32
+ :mod:`fluxplot.colors` — the canonical palette/colormap module. Edit hexes *there* and every
33
+ future plot (and this theme) follows.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import matplotlib as mpl
39
+
40
+ from .colors import flex as _flex
41
+ from .colors import maps as _maps
42
+
43
+ __all__ = [
44
+ "CYCLE_DARK",
45
+ "CYCLE_LIGHT",
46
+ "CYCLE_ORDER",
47
+ "CYCLIC",
48
+ "DEFAULT_DIVERGING",
49
+ "DIVERGING",
50
+ "FLEXOKI",
51
+ "FLEXOKI_DIVERGING",
52
+ "FLEXOKI_SEQUENTIAL",
53
+ "FLEXOKI_WARM",
54
+ "HAVE_CMASHER",
55
+ "SEQUENTIAL",
56
+ "SEQUENTIAL_WARM",
57
+ "SPECTRUM",
58
+ "TERRAIN",
59
+ "despine",
60
+ "title",
61
+ "use_dark",
62
+ "use_light",
63
+ "use_lighttable",
64
+ "use_paper",
65
+ "ACTIVE",
66
+ "THEMES",
67
+ "TOKEN_RCPARAMS",
68
+ "current_tokens",
69
+ "theme_record",
70
+ ]
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Flexoki palette (https://stephango.com/flexoki), built from the canonical
74
+ # definitions in fluxplot.colors. 600-weight accents read well on the light
75
+ # "paper" background; 400-weight ("*2") on dark. Retune in fluxplot.colors.
76
+ # ---------------------------------------------------------------------------
77
+ _BASE_LEVELS = (50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 850, 900, 950)
78
+ _ACCENTS = (
79
+ "red",
80
+ "orange",
81
+ "yellow",
82
+ "olive",
83
+ "green",
84
+ "cyan",
85
+ "blue",
86
+ "purple",
87
+ "magenta",
88
+ )
89
+
90
+ FLEXOKI = {"paper": _flex.paper, "black": _flex.black}
91
+ for _lvl in _BASE_LEVELS:
92
+ FLEXOKI[f"base{_lvl}"] = _flex.get(f"base-{_lvl}")["hex"]
93
+ for _name in _ACCENTS:
94
+ FLEXOKI[_name] = _flex.get(f"{_name}-600")["hex"] # primary, for light backgrounds
95
+ FLEXOKI[f"{_name}2"] = _flex.get(f"{_name}-400")[
96
+ "hex"
97
+ ] # lighter, for dark backgrounds
98
+
99
+ # Categorical cycles — a distinct, harmonious hue order. The active theme installs
100
+ # one as matplotlib's prop_cycle, so un-coloured series are assigned from it.
101
+ # The order keeps every ADJACENT pair apart for colour-deficient readers: measured with
102
+ # fluxplot.colorcheck (Machado 2009, CIEDE2000), the smallest adjacent distance under
103
+ # protanopia / deuteranopia / tritanopia is 20.4 in the light cycle and 15.4 in the dark one
104
+ # (the pre-2026-09-30 order had cyan next to magenta at 5.6 and orange next to green at 7.8).
105
+ # Greyscale separation cannot be fixed by ordering alone at one weight; a print-safe figure
106
+ # should still vary marker or line style. tests/test_colorcheck.py pins the adjacency.
107
+ CYCLE_ORDER = ("blue", "orange", "purple", "green", "magenta", "yellow", "cyan", "red")
108
+ CYCLE_LIGHT = [FLEXOKI[c] for c in CYCLE_ORDER]
109
+ CYCLE_DARK = [FLEXOKI[c + "2"] for c in CYCLE_ORDER]
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Continuous colormaps. The defaults are cmasher's perceptually-uniform maps (the
114
+ # scientifically honest choice for continuous data). The Flexoki maps — defined in
115
+ # fluxplot.colors, addressable by name (cmap="flexoki_diverging") — are linear ramps
116
+ # through palette anchors, pleasant but not perceptually uniform; e.g.
117
+ # FLEXOKI_DIVERGING is light-centred (blue–paper–red), handy for correlation matrices.
118
+ # ---------------------------------------------------------------------------
119
+ FLEXOKI_SEQUENTIAL = _maps.flexoki_sequential
120
+ FLEXOKI_WARM = _maps.flexoki_warm
121
+ FLEXOKI_DIVERGING = _maps.flexoki_diverging
122
+ TERRAIN = _maps.flexoki_terrain
123
+ SPECTRUM = _maps.flexoki_spectrum
124
+
125
+ # The perceptually-uniform house maps, from fluxplot's shipped cmasher definitions (the JSON
126
+ # collection in fluxplot.colors, so no package import at runtime and the very same colours Flux's
127
+ # picker shows). Tweak these picks to taste — any shipped map works (fx.maps.<name>):
128
+ # sequential: rainforest, ember, amber, gem, ocean, dusk, eclipse, …
129
+ # diverging: fusion, iceburn, redshift, wildfire, pride, …
130
+ # cyclic: infinity, emergence
131
+ HAVE_CMASHER = True # kept for callers that checked it; cmasher is a dependency
132
+ SEQUENTIAL = _maps.get("cmasher.rainforest")
133
+ SEQUENTIAL_WARM = _maps.get("cmasher.ember")
134
+ DIVERGING = _maps.get("cmasher.fusion")
135
+ CYCLIC = _maps.get("cmasher.infinity")
136
+
137
+ #: Name of the default diverging map — what helpers with ``center=`` use when no cmap is given.
138
+ DEFAULT_DIVERGING = DIVERGING.name
139
+
140
+ # Typography. We set the generic family + a fallback chain rather than a single
141
+ # face, so a missing font degrades silently to a sane default (no per-figure
142
+ # warnings). Arial (sans) / Georgia (serif) are used if present.
143
+ SANS_STACK = ["Arial", "Helvetica", "Lato", "Helvetica Neue", "DejaVu Sans"]
144
+ SERIF_STACK = [
145
+ "Georgia",
146
+ "Times New Roman",
147
+ "Latin Modern Roman",
148
+ "CMU Serif",
149
+ "DejaVu Serif",
150
+ ]
151
+
152
+
153
+ def _title_weight(serif: bool) -> str:
154
+ """The heaviest honest title weight: ``"medium"`` only when the family
155
+ matplotlib will actually resolve (the first installed face in the stack)
156
+ ships a 500 weight — otherwise ``"normal"``. Requesting an absent weight
157
+ made findfont print ``Failed to find font weight medium, now using 400.``
158
+ once per process (noise that buried real failures in long batch runs)
159
+ while rendering the exact same 400 anyway."""
160
+ from matplotlib import font_manager as _fm
161
+
162
+ stack = SERIF_STACK if serif else SANS_STACK
163
+ available: dict[str, set] = {}
164
+ for f in _fm.fontManager.ttflist:
165
+ available.setdefault(f.name.lower(), set()).add(f.weight)
166
+ for fam in stack:
167
+ weights = available.get(fam.lower())
168
+ if weights is None:
169
+ continue
170
+ has_medium = any(
171
+ w == "medium" or (isinstance(w, (int, float)) and 450 <= w <= 550)
172
+ for w in weights
173
+ )
174
+ return "medium" if has_medium else "normal"
175
+ return "normal"
176
+
177
+
178
+ def _base_rc(ink, muted, grid, paper, serif):
179
+ return {
180
+ "axes.spines.top": False,
181
+ "axes.spines.right": False,
182
+ "font.family": "serif" if serif else "sans-serif",
183
+ "font.sans-serif": SANS_STACK,
184
+ "font.serif": SERIF_STACK,
185
+ "font.size": 6, # 6pt as default
186
+ "axes.titlesize": 6, # 6pt font for axes titles
187
+ "axes.titleweight": _title_weight(serif),
188
+ "axes.titlepad": 12,
189
+ "axes.labelsize": 6, # 6pt font for axes labels
190
+ "axes.labelpad": 6,
191
+ "axes.labelcolor": ink,
192
+ "text.color": ink,
193
+ "axes.edgecolor": muted,
194
+ "axes.linewidth": 1.2, # 1.2 default linewidth for axes
195
+ "axes.facecolor": paper,
196
+ "figure.facecolor": paper,
197
+ "savefig.facecolor": paper,
198
+ "axes.grid": grid,
199
+ "axes.axisbelow": True,
200
+ "grid.color": FLEXOKI["base150"],
201
+ "grid.linewidth": 0.8,
202
+ "grid.alpha": 0.9,
203
+ "xtick.color": muted,
204
+ "ytick.color": muted,
205
+ "xtick.labelcolor": ink,
206
+ "ytick.labelcolor": ink,
207
+ "xtick.labelsize": 5,
208
+ "ytick.labelsize": 5,
209
+ "xtick.direction": "out",
210
+ "ytick.direction": "out",
211
+ "xtick.major.size": 4.5,
212
+ "ytick.major.size": 4.5,
213
+ "xtick.major.width": 1.0,
214
+ "ytick.major.width": 1.0,
215
+ "legend.frameon": False,
216
+ "legend.fontsize": 6,
217
+ "legend.handlelength": 1.5,
218
+ "lines.linewidth": 2.0, # default linewidth
219
+ "lines.markersize": 6.5,
220
+ "lines.solid_capstyle": "round",
221
+ "lines.markeredgewidth": 0.0,
222
+ "lines.dash_capstyle": "round",
223
+ "patch.linewidth": 0.0,
224
+ "figure.figsize": (2, 2), # default size when figsize isn't given
225
+ "figure.dpi": 100,
226
+ "savefig.dpi": 300,
227
+ "figure.constrained_layout.use": True,
228
+ "svg.fonttype": "none", # keep text as text in the SVG (FluxPlot-friendly)
229
+ }
230
+
231
+
232
+ def _exploratory_rc(ink, muted, grid, paper, serif):
233
+ return {
234
+ "axes.spines.top": False,
235
+ "axes.spines.right": False,
236
+ "font.family": "serif" if serif else "sans-serif",
237
+ "font.sans-serif": SANS_STACK,
238
+ "font.serif": SERIF_STACK,
239
+ "font.size": 12, # 6pt as default
240
+ "axes.titlesize": 12, # 6pt font for axes titles
241
+ "axes.titleweight": _title_weight(serif),
242
+ "axes.titlepad": 12,
243
+ "axes.labelsize": 12, # 6pt font for axes labels
244
+ "axes.labelpad": 6,
245
+ "axes.labelcolor": ink,
246
+ "text.color": ink,
247
+ "axes.edgecolor": muted,
248
+ "axes.linewidth": 1.2, # 1.2 default linewidth for axes
249
+ "axes.facecolor": paper,
250
+ "figure.facecolor": paper,
251
+ "savefig.facecolor": paper,
252
+ "axes.grid": grid,
253
+ "axes.axisbelow": True,
254
+ "grid.color": FLEXOKI["base150"],
255
+ "grid.linewidth": 0.8,
256
+ "grid.alpha": 0.9,
257
+ "xtick.color": muted,
258
+ "ytick.color": muted,
259
+ "xtick.labelcolor": ink,
260
+ "ytick.labelcolor": ink,
261
+ "xtick.labelsize": 10,
262
+ "ytick.labelsize": 10,
263
+ "xtick.direction": "out",
264
+ "ytick.direction": "out",
265
+ "xtick.major.size": 4.5,
266
+ "ytick.major.size": 4.5,
267
+ "xtick.major.width": 1.0,
268
+ "ytick.major.width": 1.0,
269
+ "legend.frameon": False,
270
+ "legend.fontsize": 10,
271
+ "legend.handlelength": 1.5,
272
+ "lines.linewidth": 2.0, # default linewidth
273
+ "lines.markersize": 6.5,
274
+ "lines.solid_capstyle": "round",
275
+ "lines.markeredgewidth": 0.0,
276
+ "lines.dash_capstyle": "round",
277
+ "patch.linewidth": 0.0,
278
+ "figure.figsize": (2, 2), # default size when figsize isn't given
279
+ "figure.dpi": 100,
280
+ "savefig.dpi": 300,
281
+ "figure.constrained_layout.use": True,
282
+ "svg.fonttype": "none", # keep text as text in the SVG (FluxPlot-friendly)
283
+ }
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # The theme record. Every use_* leaves ACTIVE = {"name", "tokens"} behind: the scaffold colours
288
+ # it set, by their role — what fp.save writes as manifest.style and what postprocess tags each
289
+ # scaffold element with (data-ink-fill / data-ink-stroke), so a consumer can restyle a plot's
290
+ # furniture to its own theme (a dark deck) without touching a single data colour.
291
+ # ---------------------------------------------------------------------------
292
+ #: rcParam → token name, in the order ambiguities are resolved (ink first).
293
+ TOKEN_RCPARAMS = (
294
+ ("ink", "text.color"),
295
+ ("label", "axes.labelcolor"),
296
+ ("tick", "xtick.color"),
297
+ ("axis", "axes.edgecolor"),
298
+ ("grid", "grid.color"),
299
+ ("plot", "axes.facecolor"),
300
+ ("paper", "figure.facecolor"),
301
+ )
302
+ THEMES = ("light", "lighttable", "paper", "dark")
303
+
304
+ #: The theme in force: ``{"name": "light", "tokens": {"ink": "#100f0f", …}}`` after a use_*.
305
+ ACTIVE: dict | None = None
306
+
307
+
308
+ def current_tokens() -> dict:
309
+ """The scaffold colours matplotlib will draw with right now, by token, as lowercase hex."""
310
+ from matplotlib.colors import to_hex
311
+ return {token: to_hex(mpl.rcParams[key]).lower() for token, key in TOKEN_RCPARAMS}
312
+
313
+
314
+ def theme_record() -> dict:
315
+ """``manifest.style``: the tokens as they are at save, and the theme's name when the
316
+ rcParams still match what that theme set (``None`` once they were changed by hand)."""
317
+ tokens = current_tokens()
318
+ name = ACTIVE["name"] if ACTIVE is not None and ACTIVE["tokens"] == tokens else None
319
+ return {"theme": name, "tokens": tokens}
320
+
321
+
322
+ def _record(name: str) -> None:
323
+ global ACTIVE
324
+ ACTIVE = {"name": name, "tokens": current_tokens()}
325
+
326
+
327
+ def _theme_override() -> str | None:
328
+ """``recipe.params().__fluxplot__.theme`` — the theme Flux asked a rerun to use instead."""
329
+ from .recipe import params
330
+ theme = (params().get("__fluxplot__") or {}).get("theme")
331
+ if theme is None:
332
+ return None
333
+ if theme not in THEMES:
334
+ raise ValueError(f"__fluxplot__.theme must be one of {', '.join(THEMES)}; got {theme!r}")
335
+ return theme
336
+
337
+
338
+ def _redirected(this: str, serif: bool, grid: bool) -> bool:
339
+ """Apply the recipe's theme override instead of ``this`` theme; True when it did."""
340
+ wanted = _theme_override()
341
+ if wanted is None or wanted == this:
342
+ return False
343
+ globals()["use_" + wanted](serif=serif, grid=grid, _override=False)
344
+ return True
345
+
346
+
347
+ def palette_override() -> str | None:
348
+ """``recipe.params().__fluxplot__.palette`` — the categorical palette Flux asked for."""
349
+ from .recipe import params
350
+ spec = (params().get("__fluxplot__") or {}).get("palette")
351
+ return str(spec) if spec else None
352
+
353
+
354
+ def _cycle(default: list) -> list:
355
+ """The prop cycle a theme installs: the recipe's palette when one is asked for."""
356
+ from .colors import palette_colors
357
+ spec = palette_override()
358
+ return list(palette_colors(spec)) if spec else default
359
+
360
+
361
+ def use_light(
362
+ ink: str = FLEXOKI["black"],
363
+ muted: str = FLEXOKI["base700"],
364
+ grid: bool = False,
365
+ paper: str = "#FFFFFF", # default needs to be pure white to comply with journals...
366
+ serif: bool = False,
367
+ _override: bool = True,
368
+ ) -> None:
369
+ """Apply the paper-background theme (the default look). Call before creating figures."""
370
+ if _override and _redirected("light", serif, grid):
371
+ return
372
+ mpl.rcParams.update(_base_rc(ink, muted, grid, paper, serif))
373
+ mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=_cycle(CYCLE_LIGHT))
374
+ mpl.rcParams["image.cmap"] = SEQUENTIAL.name
375
+ _record("light")
376
+
377
+
378
+ def use_lighttable(
379
+ ink: str = FLEXOKI["black"],
380
+ muted: str = FLEXOKI["base700"],
381
+ grid: bool = False,
382
+ paper: str = "#FFFFFF", # default needs to be pure white to comply with journals...
383
+ serif: bool = False,
384
+ _override: bool = True,
385
+ ) -> None:
386
+ """Apply the exploratory theme: the light look at screen sizes (12 pt type)."""
387
+ if _override and _redirected("lighttable", serif, grid):
388
+ return
389
+ mpl.rcParams.update(_exploratory_rc(ink, muted, grid, paper, serif))
390
+ mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=_cycle(CYCLE_LIGHT))
391
+ mpl.rcParams["image.cmap"] = SEQUENTIAL.name
392
+ _record("lighttable")
393
+
394
+
395
+ def use_paper(
396
+ ink: str = FLEXOKI["black"],
397
+ muted: str = FLEXOKI["base700"],
398
+ grid: bool = True,
399
+ paper: str = FLEXOKI["paper"],
400
+ serif: bool = False,
401
+ _override: bool = True,
402
+ ) -> None:
403
+ """Apply the warm Flexoki-paper theme (a gridded, cream ground)."""
404
+ if _override and _redirected("paper", serif, grid):
405
+ return
406
+ mpl.rcParams.update(_base_rc(ink, muted, grid, paper, serif))
407
+ mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=_cycle(CYCLE_LIGHT))
408
+ mpl.rcParams["image.cmap"] = SEQUENTIAL.name
409
+ _record("paper")
410
+
411
+
412
+ def use_dark(serif: bool = False, grid: bool = False, bg: str = "#1C1B1A", _override: bool = True) -> None:
413
+ """Apply the dark theme — for cosmic / nocturnal plots (star maps, attractors)."""
414
+ if _override and _redirected("dark", serif, grid):
415
+ return
416
+ ink, muted = FLEXOKI["base200"], FLEXOKI["base500"]
417
+ rc = _base_rc(ink, muted, grid, bg, serif)
418
+ rc.update(
419
+ {
420
+ "grid.color": FLEXOKI["base800"],
421
+ "grid.alpha": 0.6,
422
+ "xtick.labelcolor": ink,
423
+ "ytick.labelcolor": ink,
424
+ }
425
+ )
426
+ mpl.rcParams.update(rc)
427
+ mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=_cycle(CYCLE_DARK))
428
+ mpl.rcParams["image.cmap"] = SEQUENTIAL.name
429
+ _record("dark")
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # small Tufte helpers
434
+ # ---------------------------------------------------------------------------
435
+ def despine(ax, top=True, right=True, left=False, bottom=False) -> None:
436
+ """Hide chart-junk spines (Tufte-style). Defaults: drop top + right."""
437
+ for side, off in (
438
+ ("top", top),
439
+ ("right", right),
440
+ ("left", left),
441
+ ("bottom", bottom),
442
+ ):
443
+ ax.spines[side].set_visible(not off)
444
+
445
+
446
+ def title(ax, main, sub=None) -> None:
447
+ """A left-aligned title with an optional muted subtitle line.
448
+
449
+ The main title (a left title) is autotagged ``title`` by :func:`fluxplot.save`;
450
+ the subtitle is tagged ``subtitle`` so it is addressable in the scene graph.
451
+ """
452
+ if sub:
453
+ ax.set_title(f"{main}\n", loc="left")
454
+ ann = ax.annotate(
455
+ sub,
456
+ xy=(0, 1.0),
457
+ xycoords="axes fraction",
458
+ xytext=(0, 10),
459
+ textcoords="offset points",
460
+ ha="left",
461
+ va="bottom",
462
+ fontsize=10.5,
463
+ color=FLEXOKI["base500"],
464
+ )
465
+ from . import api as _api # lazy: style is imported during api's __init__
466
+
467
+ _api.tag(ann, role="subtitle", name="figure", text=sub)
468
+ else:
469
+ ax.set_title(main, loc="left")