jarvisplot 1.3.3__tar.gz → 1.3.5__tar.gz

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 (77) hide show
  1. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/JarvisPLOT.egg-info/PKG-INFO +1 -1
  2. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/JarvisPLOT.egg-info/SOURCES.txt +1 -0
  3. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/PKG-INFO +1 -1
  4. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/adapters_rect.py +16 -131
  5. jarvisplot-1.3.5/jarvisplot/Figure/colorbar_runtime.py +393 -0
  6. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/figure.py +180 -86
  7. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/layer_runtime.py +47 -20
  8. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/preprocessor.py +53 -3
  9. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/preprocessor_runtime.py +76 -1
  10. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/profile_runtime.py +126 -0
  11. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/gambit/2x1/rect_cmap.json +2 -2
  12. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/core.py +2 -145
  13. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/core_runtime.py +122 -8
  14. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/utils/interpolator.py +1 -0
  15. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/pyproject.toml +1 -1
  16. jarvisplot-1.3.5/tests/test_figure_contracts.py +482 -0
  17. jarvisplot-1.3.5/tests/test_interpolator_contracts.py +9 -0
  18. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/tests/test_pathing_contracts.py +25 -0
  19. jarvisplot-1.3.3/jarvisplot/Figure/colorbar_runtime.py +0 -221
  20. jarvisplot-1.3.3/tests/test_figure_contracts.py +0 -237
  21. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/JarvisPLOT.egg-info/dependency_links.txt +0 -0
  22. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/JarvisPLOT.egg-info/entry_points.txt +0 -0
  23. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/JarvisPLOT.egg-info/requires.txt +0 -0
  24. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/JarvisPLOT.egg-info/top_level.txt +0 -0
  25. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/README.md +0 -0
  26. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/__init__.py +0 -0
  27. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/adapters.py +0 -0
  28. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/adapters_ternary.py +0 -0
  29. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/cards/std_axes_adapter_config.json +0 -0
  30. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/config_runtime.py +0 -0
  31. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/data_pipelines.py +0 -0
  32. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/helper.py +0 -0
  33. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/layout_runtime.py +0 -0
  34. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/load_data.py +0 -0
  35. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/method_registry.py +0 -0
  36. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/Figure/style_runtime.py +0 -0
  37. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/__init__.py +0 -0
  38. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cache_store.py +0 -0
  39. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/1x1/ternary.json +0 -0
  40. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/2x1/rect.json +0 -0
  41. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/2x1/rect5x1.json +0 -0
  42. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/2x1/rect_cmap.json +0 -0
  43. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/2x1/ternary.json +0 -0
  44. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/2x1/ternary_cmap.json +0 -0
  45. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/4x1/rect.json +0 -0
  46. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/4x1/rect_cmap.json +0 -0
  47. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/4x1/ternary.json +0 -0
  48. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/a4paper/4x1/ternary_cmap.json +0 -0
  49. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/args.json +0 -0
  50. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/colors/colormaps.json +0 -0
  51. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/default/output.json +0 -0
  52. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/gambit/1x1/ternary.json +0 -0
  53. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/gambit/2x1/ternary.json +0 -0
  54. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/gambit/2x1/ternary_cmap.json +0 -0
  55. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/icons/JarvisHEP.png +0 -0
  56. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/icons/gambit.png +0 -0
  57. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/icons/gambit_small.png +0 -0
  58. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cards/style_preference.json +0 -0
  59. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/cli.py +0 -0
  60. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/client.py +0 -0
  61. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/config.py +0 -0
  62. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/core_assets.py +0 -0
  63. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/data_loader.py +0 -0
  64. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/data_loader_hdf5.py +0 -0
  65. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/data_loader_runtime.py +0 -0
  66. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/data_loader_summary.py +0 -0
  67. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/inner_func.py +0 -0
  68. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/memtrace.py +0 -0
  69. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/utils/__init__.py +0 -0
  70. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/utils/cmaps.py +0 -0
  71. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/utils/expression.py +0 -0
  72. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/jarvisplot/utils/pathing.py +0 -0
  73. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/setup.cfg +0 -0
  74. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/tests/test_data_loader_contracts.py +0 -0
  75. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/tests/test_docs_status_labels.py +0 -0
  76. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/tests/test_parse_data_metadata.py +0 -0
  77. {jarvisplot-1.3.3 → jarvisplot-1.3.5}/tests/test_template_json.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: JarvisPLOT
3
- Version: 1.3.3
3
+ Version: 1.3.5
4
4
  Summary: Jarvis-PLOT: YAML-driven plotting engine
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -67,6 +67,7 @@ jarvisplot/utils/pathing.py
67
67
  tests/test_data_loader_contracts.py
68
68
  tests/test_docs_status_labels.py
69
69
  tests/test_figure_contracts.py
70
+ tests/test_interpolator_contracts.py
70
71
  tests/test_parse_data_metadata.py
71
72
  tests/test_pathing_contracts.py
72
73
  tests/test_template_json.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: JarvisPLOT
3
- Version: 1.3.3
3
+ Version: 1.3.5
4
4
  Summary: Jarvis-PLOT: YAML-driven plotting engine
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -10,6 +10,7 @@ from matplotlib.axes import Axes
10
10
  from matplotlib.collections import PolyCollection
11
11
  #
12
12
  from .helper import _auto_clip, _mask_by_extend, voronoi_finite_polygons_2d, _clip_poly_to_rect
13
+ from .profile_runtime import grid_profile_mesh
13
14
 
14
15
 
15
16
  # —— Basic Adapter: Forward to the underlying Axes, merge default parameters, perform automatic clipping ——
@@ -94,142 +95,26 @@ class StdAxesAdapter:
94
95
  xscale = str(kw.pop("xscale", self.ax.get_xscale())).lower()
95
96
  yscale = str(kw.pop("yscale", self.ax.get_yscale())).lower()
96
97
 
97
- ix = None
98
- iy = None
99
- cols = getattr(df, "columns", [])
100
- if df is not None and ("__grid_ix__" in cols) and ("__grid_iy__" in cols):
101
- try:
102
- ix = np.asarray(df["__grid_ix__"], dtype=np.int32)
103
- iy = np.asarray(df["__grid_iy__"], dtype=np.int32)
104
- if "__grid_bin__" in cols and grid_bin is None:
105
- grid_bin = int(np.asarray(df["__grid_bin__"])[0])
106
- if "__grid_xmin__" in cols and "__grid_xmax__" in cols and xlim is None:
107
- xlim = [
108
- float(np.asarray(df["__grid_xmin__"])[0]),
109
- float(np.asarray(df["__grid_xmax__"])[0]),
110
- ]
111
- if "__grid_ymin__" in cols and "__grid_ymax__" in cols and ylim is None:
112
- ylim = [
113
- float(np.asarray(df["__grid_ymin__"])[0]),
114
- float(np.asarray(df["__grid_ymax__"])[0]),
115
- ]
116
- if "__grid_xscale__" in cols:
117
- xscale = str(np.asarray(df["__grid_xscale__"])[0]).lower()
118
- if "__grid_yscale__" in cols:
119
- yscale = str(np.asarray(df["__grid_yscale__"])[0]).lower()
120
- if (not objective_from_style) and ("__grid_objective__" in cols):
121
- objective = str(np.asarray(df["__grid_objective__"])[0]).lower()
122
- except Exception:
123
- ix, iy = None, None
124
-
125
- if grid_bin is None:
126
- if ix is not None and ix.size > 0:
127
- try:
128
- grid_bin = int(max(np.nanmax(ix), np.nanmax(iy)) + 1)
129
- except Exception:
130
- grid_bin = None
131
- if grid_bin is None:
132
- # fallback when metadata is absent
133
- grid_bin = max(1, int(np.sqrt(max(len(x), 1))))
134
- grid_bin = max(int(grid_bin), 1)
135
-
136
98
  if xlim is None:
137
99
  xlim = [float(self.ax.get_xlim()[0]), float(self.ax.get_xlim()[1])]
138
100
  if ylim is None:
139
101
  ylim = [float(self.ax.get_ylim()[0]), float(self.ax.get_ylim()[1])]
140
-
141
- def _grid_edges(lo, hi, n, scale):
142
- lo = float(lo)
143
- hi = float(hi)
144
- if str(scale).lower() == "log":
145
- tiny = np.finfo(float).tiny
146
- lo = max(lo, tiny)
147
- hi = max(hi, lo * 10.0)
148
- if hi == lo:
149
- hi = lo * 10.0
150
- return np.geomspace(lo, hi, n + 1)
151
- if hi == lo:
152
- hi = lo + 1.0
153
- return np.linspace(lo, hi, n + 1)
154
-
155
- def _norm(arr, lim, scale):
156
- arr = np.asarray(arr, dtype=float)
157
- lo = float(lim[0])
158
- hi = float(lim[1])
159
- if str(scale).lower() == "log":
160
- tiny = np.finfo(float).tiny
161
- lo = max(lo, tiny)
162
- hi = max(hi, lo * 10.0)
163
- den = np.log(hi) - np.log(lo)
164
- if den == 0:
165
- den = 1.0
166
- arr = np.where(arr > 0, arr, np.nan)
167
- return (np.log(arr) - np.log(lo)) / den
168
- den = hi - lo
169
- if den == 0:
170
- den = 1.0
171
- return (arr - lo) / den
172
-
173
- n = min(len(x), len(y), len(z))
174
- if n == 0:
175
- return []
176
- x = x[:n]
177
- y = y[:n]
178
- z = z[:n]
179
-
180
- if ix is None or iy is None:
181
- xn = _norm(x, xlim, xscale)
182
- yn = _norm(y, ylim, yscale)
183
- valid = np.isfinite(xn) & np.isfinite(yn)
184
- if not np.any(valid):
185
- return []
186
- xv = np.clip(xn[valid], 0.0, 1.0 - 1e-12)
187
- yv = np.clip(yn[valid], 0.0, 1.0 - 1e-12)
188
- ix = (xv * grid_bin).astype(np.int32)
189
- iy = (yv * grid_bin).astype(np.int32)
190
- zv = z[valid]
191
- else:
192
- ix = np.asarray(ix, dtype=np.int32)[:n]
193
- iy = np.asarray(iy, dtype=np.int32)[:n]
194
- valid = np.isfinite(x) & np.isfinite(y) & np.isfinite(z)
195
- valid &= np.isfinite(ix) & np.isfinite(iy)
196
- if not np.any(valid):
197
- return []
198
- ix = ix[valid]
199
- iy = iy[valid]
200
- zv = z[valid]
201
-
202
- try:
203
- import pandas as _pd
204
- zkey = np.where(np.isfinite(zv), zv, np.inf if objective == "min" else -np.inf)
205
- tmp = _pd.DataFrame({"ix": ix, "iy": iy, "z": zv, "zkey": zkey})
206
- if objective == "min":
207
- loc = tmp.groupby(["ix", "iy"], sort=False)["zkey"].idxmin()
208
- else:
209
- loc = tmp.groupby(["ix", "iy"], sort=False)["zkey"].idxmax()
210
- pick = tmp.loc[loc, ["ix", "iy", "z"]]
211
- ix_u = np.asarray(pick["ix"], dtype=np.int32)
212
- iy_u = np.asarray(pick["iy"], dtype=np.int32)
213
- z_u = np.asarray(pick["z"], dtype=float)
214
- except Exception:
215
- ix_u = np.asarray(ix, dtype=np.int32)
216
- iy_u = np.asarray(iy, dtype=np.int32)
217
- z_u = np.asarray(zv, dtype=float)
218
-
219
- x_edges = _grid_edges(xlim[0], xlim[1], grid_bin, xscale)
220
- y_edges = _grid_edges(ylim[0], ylim[1], grid_bin, yscale)
221
-
222
- in_range = (ix_u >= 0) & (ix_u < grid_bin) & (iy_u >= 0) & (iy_u < grid_bin)
223
- if not np.any(in_range):
102
+ mesh = grid_profile_mesh(
103
+ x,
104
+ y,
105
+ z,
106
+ df=df,
107
+ grid_bin=grid_bin,
108
+ xlim=xlim,
109
+ ylim=ylim,
110
+ xscale=xscale,
111
+ yscale=yscale,
112
+ objective=objective,
113
+ objective_from_style=objective_from_style,
114
+ )
115
+ if mesh is None:
224
116
  return []
225
- ix_u = ix_u[in_range]
226
- iy_u = iy_u[in_range]
227
- z_u = z_u[in_range]
228
-
229
- grid = np.full((grid_bin, grid_bin), np.nan, dtype=float)
230
- if len(ix_u) > 0:
231
- grid[iy_u, ix_u] = z_u
232
- grid = np.ma.masked_invalid(grid)
117
+ x_edges, y_edges, grid = mesh
233
118
 
234
119
  kw.setdefault("shading", "flat")
235
120
  if edgecolor is not None:
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, Optional
4
+
5
+ import numpy as np
6
+ import matplotlib.colors as mcolors
7
+
8
+ from ..utils.expression import eval_dataframe_expression
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Frame config helpers
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def axc_is_horizontal(frame: Mapping[str, Any], cb_name: str = "axc") -> bool:
16
+ cb_cfg = frame.get(cb_name, {}) if isinstance(frame, Mapping) else {}
17
+ return str(cb_cfg.get("orientation", "")).lower() == "horizontal"
18
+
19
+
20
+ def axc_color_config(frame: Mapping[str, Any], cb_name: str = "axc") -> dict:
21
+ """Return normalized color config for a named colorbar axis.
22
+
23
+ Reads frame[cb_name].color. A value of None or the string "auto" means
24
+ auto-range from data. Falls back to legacy frame[cb_name].xscale /
25
+ yscale when 'scale' is absent from the color block.
26
+ """
27
+ out: dict[str, Any] = {}
28
+ cb_cfg = frame.get(cb_name, {}) if isinstance(frame, Mapping) else {}
29
+ if not isinstance(cb_cfg, dict):
30
+ cb_cfg = {}
31
+ color_cfg = cb_cfg.get("color", {})
32
+ if not isinstance(color_cfg, dict):
33
+ color_cfg = {}
34
+
35
+ if "cmap" in color_cfg:
36
+ out["cmap"] = color_cfg.get("cmap")
37
+
38
+ for key in ("vmin", "vmax"):
39
+ raw = color_cfg.get(key, None)
40
+ if raw is None or str(raw).strip().lower() == "auto":
41
+ out[key] = None # explicit auto → leave as None
42
+ else:
43
+ try:
44
+ val = float(raw)
45
+ if np.isfinite(val):
46
+ out[key] = val
47
+ # non-finite treated as auto (leave as None)
48
+ except Exception:
49
+ pass # non-numeric treated as auto
50
+
51
+ scale = color_cfg.get("scale", None)
52
+ if scale is None:
53
+ is_h = axc_is_horizontal(frame, cb_name)
54
+ if is_h:
55
+ scale = cb_cfg.get("xscale", cb_cfg.get("yscale", None))
56
+ else:
57
+ scale = cb_cfg.get("yscale", cb_cfg.get("xscale", None))
58
+ if isinstance(scale, str):
59
+ scale = scale.strip().lower()
60
+ if scale:
61
+ out["scale"] = scale
62
+
63
+ return out
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Scalar helpers
68
+ # ---------------------------------------------------------------------------
69
+
70
+ def _coerce_scalar(v) -> Optional[float]:
71
+ try:
72
+ vv = float(v)
73
+ if np.isfinite(vv):
74
+ return vv
75
+ except Exception:
76
+ pass
77
+ return None
78
+
79
+
80
+ def _resolve_norm(nv, *, vmin=None, vmax=None, logger=None):
81
+ if nv is None:
82
+ return None
83
+ if isinstance(nv, mcolors.Normalize):
84
+ return nv
85
+ if isinstance(nv, str):
86
+ key = nv.strip().lower()
87
+ if key in {"log", "lognorm"}:
88
+ if (vmin is None) or (vmax is None) or (vmin <= 0) or (vmax <= 0):
89
+ if logger:
90
+ logger.warning("axc log scale requires positive vmin/vmax; fallback to linear Normalize.")
91
+ return mcolors.Normalize(vmin=vmin, vmax=vmax)
92
+ return mcolors.LogNorm(vmin=vmin, vmax=vmax)
93
+ if key in {"twoslopenorm", "diverging"}:
94
+ return mcolors.TwoSlopeNorm(vcenter=0.0, vmin=vmin, vmax=vmax)
95
+ if key in {"symlog", "symlognorm"}:
96
+ return mcolors.SymLogNorm(linthresh=1.0, linscale=1.0, base=10, vmin=vmin, vmax=vmax)
97
+ return mcolors.Normalize(vmin=vmin, vmax=vmax)
98
+ if isinstance(nv, dict):
99
+ t = str(nv.get("type", "Normalize")).strip().lower()
100
+ _vmin = nv.get("vmin", vmin)
101
+ _vmax = nv.get("vmax", vmax)
102
+ if t in {"log", "lognorm"}:
103
+ if (_vmin is None) or (_vmax is None) or (_vmin <= 0) or (_vmax <= 0):
104
+ if logger:
105
+ logger.warning("axc log norm requires positive vmin/vmax; fallback to linear Normalize.")
106
+ return mcolors.Normalize(vmin=_vmin, vmax=_vmax)
107
+ return mcolors.LogNorm(vmin=_vmin, vmax=_vmax)
108
+ if t in {"twoslopenorm", "diverging"}:
109
+ vcenter = nv.get("vcenter", 0.0)
110
+ return mcolors.TwoSlopeNorm(vcenter=vcenter, vmin=_vmin, vmax=_vmax)
111
+ if t in {"symlog", "symlognorm"}:
112
+ linthresh = nv.get("linthresh", 1.0)
113
+ linscale = nv.get("linscale", 1.0)
114
+ base = nv.get("base", 10)
115
+ return mcolors.SymLogNorm(
116
+ linthresh=linthresh, linscale=linscale, base=base,
117
+ vmin=_vmin, vmax=_vmax,
118
+ )
119
+ return mcolors.Normalize(vmin=_vmin, vmax=_vmax)
120
+ return mcolors.Normalize(vmin=vmin, vmax=vmax)
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Pre-scan helpers
125
+ # ---------------------------------------------------------------------------
126
+
127
+ _COLORED_Z_METHODS = frozenset({
128
+ "contour", "contourf", "tricontour", "tricontourf",
129
+ "tripcolor", "tripcolor_axes",
130
+ "pcolor", "pcolormesh", "imshow",
131
+ "voronoi", "voronoif",
132
+ "grid_profile", "grid_profiling",
133
+ })
134
+
135
+
136
+ def layer_uses_color(style: dict, coor: dict, method_key: str) -> bool:
137
+ """Return True if this layer will need a colorbar."""
138
+ return (
139
+ bool(style.get("cmap"))
140
+ or ("c" in coor)
141
+ or (("z" in coor) and (method_key in _COLORED_Z_METHODS))
142
+ )
143
+
144
+
145
+ def collect_layer_color_range(df, coor: dict, style: dict, *, scale: str | None = None):
146
+ """Extract (data_min, data_max) for the colour channel of a single layer.
147
+
148
+ Returns (None, None) when no finite data is available.
149
+ """
150
+ arr = None
151
+ if isinstance(coor.get("z"), dict) and "expr" in coor["z"]:
152
+ arr = eval_dataframe_expression(df, coor["z"]["expr"], logger=None)
153
+ elif isinstance(coor.get("c"), dict) and "expr" in coor["c"]:
154
+ arr = eval_dataframe_expression(df, coor["c"]["expr"], logger=None)
155
+ elif "z" in style and not isinstance(style.get("z"), str):
156
+ arr = style.get("z")
157
+ elif "c" in style and not isinstance(style.get("c"), str):
158
+ arr = style.get("c")
159
+
160
+ if arr is None:
161
+ return None, None
162
+ try:
163
+ arr = np.asarray(arr, dtype=float)
164
+ arr = arr[np.isfinite(arr)]
165
+ if str(scale or "").strip().lower() == "log":
166
+ arr = arr[arr > 0]
167
+ if arr.size == 0:
168
+ return None, None
169
+ return float(np.min(arr)), float(np.max(arr))
170
+ except Exception:
171
+ return None, None
172
+
173
+
174
+ def _validate_colorbar_limits(vmin, vmax, scale: str, logger=None):
175
+ """Validate vmin/vmax; replace invalid values with None (auto).
176
+
177
+ Rules applied in order:
178
+ 1. Non-finite value → warn, fall back to auto.
179
+ 2. log scale + value <= 0 → warn, fall back to auto for that value.
180
+ 3. Both set but vmin >= vmax → warn, fall back to auto for both.
181
+ """
182
+ scale = str(scale or "").strip().lower()
183
+
184
+ # Rule 1: finiteness
185
+ if vmin is not None and not np.isfinite(vmin):
186
+ if logger:
187
+ logger.warning(f"colorbar vmin={vmin!r} is not finite; falling back to auto.")
188
+ vmin = None
189
+ if vmax is not None and not np.isfinite(vmax):
190
+ if logger:
191
+ logger.warning(f"colorbar vmax={vmax!r} is not finite; falling back to auto.")
192
+ vmax = None
193
+
194
+ # Rule 2: log scale positivity
195
+ if scale == "log":
196
+ if vmin is not None and vmin <= 0:
197
+ if logger:
198
+ logger.warning(
199
+ f"colorbar log scale requires vmin > 0, got vmin={vmin}; "
200
+ "falling back to auto."
201
+ )
202
+ vmin = None
203
+ if vmax is not None and vmax <= 0:
204
+ if logger:
205
+ logger.warning(
206
+ f"colorbar log scale requires vmax > 0, got vmax={vmax}; "
207
+ "falling back to auto."
208
+ )
209
+ vmax = None
210
+
211
+ # Rule 3: ordering
212
+ if vmin is not None and vmax is not None and vmin >= vmax:
213
+ if logger:
214
+ logger.warning(
215
+ f"colorbar vmin={vmin} >= vmax={vmax}; "
216
+ "falling back to auto for both."
217
+ )
218
+ vmin = None
219
+ vmax = None
220
+
221
+ return vmin, vmax
222
+
223
+
224
+ def precompute_colorbar_cb(color_cfg: dict, data_ranges: list, logger=None) -> dict:
225
+ """Build the complete _cb state dict from frame color config + data ranges.
226
+
227
+ vmin and vmax are resolved independently:
228
+ - Explicit value in frame config that passes validation → used as-is.
229
+ - Missing or invalid value → auto from the data range of bound layers.
230
+
231
+ This function is called once per colorbar during the pre-scan phase in
232
+ Figure._prescan_colorbar_ranges(), before any layer is rendered. After
233
+ this call _cb is treated as read-only during rendering.
234
+
235
+ Args:
236
+ color_cfg: result of axc_color_config() for this colorbar.
237
+ data_ranges: list of (data_min, data_max) collected from pre-scanned layers.
238
+ logger: optional loguru logger.
239
+
240
+ Returns:
241
+ Complete _cb dict to be stored on the colorbar axes object.
242
+ """
243
+ scale = str(color_cfg.get("scale", "linear")).strip().lower()
244
+
245
+ cb: dict[str, Any] = {
246
+ "mode": "auto",
247
+ "levels": None,
248
+ "vmin": None,
249
+ "vmax": None,
250
+ "norm": None,
251
+ "cmap": color_cfg.get("cmap"),
252
+ "used": bool(data_ranges),
253
+ }
254
+
255
+ if not data_ranges:
256
+ return cb
257
+
258
+ # --- Resolve vmin (explicit wins; absent → auto from data) ---
259
+ frame_vmin = color_cfg.get("vmin")
260
+ if frame_vmin is not None:
261
+ cb["vmin"] = frame_vmin
262
+ else:
263
+ valid_mins = [r[0] for r in data_ranges if r[0] is not None]
264
+ cb["vmin"] = min(valid_mins) if valid_mins else None
265
+
266
+ # --- Resolve vmax (explicit wins; absent → auto from data) ---
267
+ frame_vmax = color_cfg.get("vmax")
268
+ if frame_vmax is not None:
269
+ cb["vmax"] = frame_vmax
270
+ else:
271
+ valid_maxes = [r[1] for r in data_ranges if r[1] is not None]
272
+ cb["vmax"] = max(valid_maxes) if valid_maxes else None
273
+
274
+ # --- Validate (may reset invalid values back to None) ---
275
+ cb["vmin"], cb["vmax"] = _validate_colorbar_limits(
276
+ cb["vmin"], cb["vmax"], scale, logger=logger
277
+ )
278
+
279
+ # --- Second-pass auto: if validation reset a value, refill from data ---
280
+ if cb["vmin"] is None:
281
+ valid_mins = [r[0] for r in data_ranges if r[0] is not None]
282
+ cb["vmin"] = min(valid_mins) if valid_mins else None
283
+ if cb["vmax"] is None:
284
+ valid_maxes = [r[1] for r in data_ranges if r[1] is not None]
285
+ cb["vmax"] = max(valid_maxes) if valid_maxes else None
286
+
287
+ # --- Build norm ---
288
+ if cb["vmin"] is not None and cb["vmax"] is not None:
289
+ if scale in {"linear", "norm", "normalize", ""}:
290
+ resolved = mcolors.Normalize(vmin=cb["vmin"], vmax=cb["vmax"])
291
+ elif scale == "log":
292
+ resolved = _resolve_norm("lognorm", vmin=cb["vmin"], vmax=cb["vmax"], logger=logger)
293
+ else:
294
+ resolved = _resolve_norm(scale, vmin=cb["vmin"], vmax=cb["vmax"], logger=logger)
295
+ if resolved is None:
296
+ resolved = mcolors.Normalize(vmin=cb["vmin"], vmax=cb["vmax"])
297
+
298
+ cb["norm"] = resolved
299
+ if isinstance(resolved, mcolors.LogNorm):
300
+ cb["mode"] = "log"
301
+ elif isinstance(resolved, mcolors.TwoSlopeNorm):
302
+ cb["mode"] = "diverging"
303
+ elif isinstance(resolved, mcolors.SymLogNorm):
304
+ cb["mode"] = "log"
305
+ else:
306
+ cb["mode"] = "norm"
307
+
308
+ return cb
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Render-time attachment (read-only after pre-scan)
313
+ # ---------------------------------------------------------------------------
314
+
315
+ def collect_and_attach_colorbar(
316
+ fig, style: dict, coor: dict, method_key: str, df, cb_name: str = "axc"
317
+ ) -> dict:
318
+ """Attach pre-computed colorbar norm/cmap to the layer style dict.
319
+
320
+ Called once per layer during rendering. By this point _cb has already
321
+ been fully built by Figure._prescan_colorbar_ranges() so this function
322
+ is read-only — it never mutates _cb.
323
+
324
+ The pre-scan / read-only split ensures:
325
+ - colorbar limits are determined by ALL bound layers, not just those
326
+ rendered so far (no render-order dependency).
327
+ - the final colorbar is always predictable from the YAML alone:
328
+ explicit frame.axc.color.vmin/vmax win; absent values fall back to
329
+ the data-driven auto range.
330
+ """
331
+ axc = fig.axes.get(cb_name)
332
+ if axc is None or not hasattr(axc, "_cb"):
333
+ return style
334
+
335
+ if not isinstance(axc._cb, dict):
336
+ axc._cb = {
337
+ "mode": "auto",
338
+ "levels": None,
339
+ "vmin": None,
340
+ "vmax": None,
341
+ "norm": None,
342
+ "cmap": None,
343
+ "used": False,
344
+ }
345
+
346
+ if not axc._cb.get("used"):
347
+ frame = getattr(fig, "frame", {})
348
+ color_cfg = axc_color_config(frame, cb_name)
349
+ data_range = collect_layer_color_range(
350
+ df,
351
+ coor,
352
+ style,
353
+ scale=color_cfg.get("scale"),
354
+ )
355
+ if data_range != (None, None):
356
+ axc._cb.update(
357
+ precompute_colorbar_cb(color_cfg, [data_range], logger=getattr(fig, "logger", None))
358
+ )
359
+
360
+ if not axc._cb.get("used"):
361
+ return style
362
+
363
+ s = dict(style)
364
+
365
+ # Attach cmap
366
+ chosen_cmap = axc._cb.get("cmap")
367
+ if chosen_cmap is not None:
368
+ s["cmap"] = chosen_cmap
369
+
370
+ # Attach norm (strip raw vmin/vmax so matplotlib uses the norm object)
371
+ norm = axc._cb.get("norm")
372
+ if norm is not None:
373
+ s["norm"] = norm
374
+ s.pop("vmin", None)
375
+ s.pop("vmax", None)
376
+ s.pop("mode", None)
377
+
378
+ # Contour levels (lazily generated on first contour layer, then reused)
379
+ if (
380
+ method_key in ("contour", "contourf", "tricontour", "tricontourf")
381
+ and axc._cb.get("levels") is None
382
+ and axc._cb.get("vmin") is not None
383
+ and axc._cb.get("vmax") is not None
384
+ ):
385
+ lv = s.get("levels", 10)
386
+ if isinstance(lv, int):
387
+ axc._cb["levels"] = np.linspace(axc._cb["vmin"], axc._cb["vmax"], lv)
388
+ elif hasattr(lv, "__len__"):
389
+ axc._cb["levels"] = lv
390
+ if axc._cb.get("levels") is not None:
391
+ s["levels"] = axc._cb["levels"]
392
+
393
+ return s