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
|
@@ -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")
|