jarvisplot 1.3.9__tar.gz → 1.3.10__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 (86) hide show
  1. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/JarvisPLOT.egg-info/PKG-INFO +1 -1
  2. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/JarvisPLOT.egg-info/SOURCES.txt +6 -0
  3. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/PKG-INFO +1 -1
  4. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/adapters_rect.py +25 -10
  5. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/adapters_ternary.py +0 -8
  6. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/colorbar_runtime.py +0 -1
  7. jarvisplot-1.3.10/jarvisplot/Figure/density_cell_runtime.py +427 -0
  8. jarvisplot-1.3.10/jarvisplot/Figure/interp_2d_runtime.py +493 -0
  9. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/layer_runtime.py +147 -17
  10. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/method_registry.py +0 -1
  11. jarvisplot-1.3.10/jarvisplot/Figure/posterior_hpd.py +159 -0
  12. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/preprocessor.py +167 -29
  13. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/preprocessor_runtime.py +35 -41
  14. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/profile_runtime.py +25 -13
  15. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/core.py +5 -1
  16. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/core_runtime.py +157 -9
  17. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/data_loader_runtime.py +39 -20
  18. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/utils/expression.py +12 -0
  19. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/pyproject.toml +1 -1
  20. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/tests/test_data_loader_contracts.py +5 -5
  21. jarvisplot-1.3.10/tests/test_density_cell_transform.py +147 -0
  22. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/tests/test_figure_contracts.py +63 -4
  23. jarvisplot-1.3.10/tests/test_interp_2d_transform.py +204 -0
  24. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/tests/test_jpcontour_methods.py +22 -0
  25. jarvisplot-1.3.10/tests/test_posterior_hpd.py +161 -0
  26. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/JarvisPLOT.egg-info/dependency_links.txt +0 -0
  27. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/JarvisPLOT.egg-info/entry_points.txt +0 -0
  28. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/JarvisPLOT.egg-info/requires.txt +0 -0
  29. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/JarvisPLOT.egg-info/top_level.txt +0 -0
  30. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/README.md +0 -0
  31. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/__init__.py +0 -0
  32. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/cards/std_axes_adapter_config.json +0 -0
  33. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/config_runtime.py +0 -0
  34. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/data_pipelines.py +0 -0
  35. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/dynesty_runtime.py +0 -0
  36. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/figure.py +0 -0
  37. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/helper.py +0 -0
  38. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/interp_natural_neighbor.py +0 -0
  39. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/interp_natural_neighbor_exact.py +0 -0
  40. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/layout_runtime.py +0 -0
  41. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/Figure/style_runtime.py +0 -0
  42. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/__init__.py +0 -0
  43. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cache_store.py +0 -0
  44. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/1x1/ternary.json +0 -0
  45. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/2x1/dynesty_runplot.json +0 -0
  46. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/2x1/rect.json +0 -0
  47. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/2x1/rect5x1.json +0 -0
  48. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/2x1/rect_cmap.json +0 -0
  49. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/2x1/ternary.json +0 -0
  50. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/2x1/ternary_cmap.json +0 -0
  51. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/4x1/rect.json +0 -0
  52. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/4x1/rect_cmap.json +0 -0
  53. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/4x1/ternary.json +0 -0
  54. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/a4paper/4x1/ternary_cmap.json +0 -0
  55. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/args.json +0 -0
  56. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/colors/colormaps.json +0 -0
  57. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/default/output.json +0 -0
  58. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/gambit/1x1/ternary.json +0 -0
  59. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/gambit/2x1/rect_cmap.json +0 -0
  60. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/gambit/2x1/ternary.json +0 -0
  61. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/gambit/2x1/ternary_cmap.json +0 -0
  62. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/icons/JarvisHEP.png +0 -0
  63. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/icons/gambit.png +0 -0
  64. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/icons/gambit_small.png +0 -0
  65. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cards/style_preference.json +0 -0
  66. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/cli.py +0 -0
  67. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/client.py +0 -0
  68. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/config.py +0 -0
  69. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/core_assets.py +0 -0
  70. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/data_loader.py +0 -0
  71. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/data_loader_hdf5.py +0 -0
  72. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/data_loader_summary.py +0 -0
  73. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/inner_func.py +0 -0
  74. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/memtrace.py +0 -0
  75. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/utils/__init__.py +0 -0
  76. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/utils/cmaps.py +0 -0
  77. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/utils/dataframes.py +0 -0
  78. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/utils/interpolator.py +0 -0
  79. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/jarvisplot/utils/pathing.py +0 -0
  80. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/setup.cfg +0 -0
  81. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/tests/test_docs_status_labels.py +0 -0
  82. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/tests/test_interp_natural_neighbor.py +0 -0
  83. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/tests/test_interpolator_contracts.py +0 -0
  84. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/tests/test_parse_data_metadata.py +0 -0
  85. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/tests/test_pathing_contracts.py +0 -0
  86. {jarvisplot-1.3.9 → jarvisplot-1.3.10}/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.9
3
+ Version: 1.3.10
4
4
  Summary: Jarvis-PLOT: YAML-driven plotting engine
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -26,14 +26,17 @@ jarvisplot/Figure/adapters_ternary.py
26
26
  jarvisplot/Figure/colorbar_runtime.py
27
27
  jarvisplot/Figure/config_runtime.py
28
28
  jarvisplot/Figure/data_pipelines.py
29
+ jarvisplot/Figure/density_cell_runtime.py
29
30
  jarvisplot/Figure/dynesty_runtime.py
30
31
  jarvisplot/Figure/figure.py
31
32
  jarvisplot/Figure/helper.py
33
+ jarvisplot/Figure/interp_2d_runtime.py
32
34
  jarvisplot/Figure/interp_natural_neighbor.py
33
35
  jarvisplot/Figure/interp_natural_neighbor_exact.py
34
36
  jarvisplot/Figure/layer_runtime.py
35
37
  jarvisplot/Figure/layout_runtime.py
36
38
  jarvisplot/Figure/method_registry.py
39
+ jarvisplot/Figure/posterior_hpd.py
37
40
  jarvisplot/Figure/preprocessor.py
38
41
  jarvisplot/Figure/preprocessor_runtime.py
39
42
  jarvisplot/Figure/profile_runtime.py
@@ -68,11 +71,14 @@ jarvisplot/utils/expression.py
68
71
  jarvisplot/utils/interpolator.py
69
72
  jarvisplot/utils/pathing.py
70
73
  tests/test_data_loader_contracts.py
74
+ tests/test_density_cell_transform.py
71
75
  tests/test_docs_status_labels.py
72
76
  tests/test_figure_contracts.py
77
+ tests/test_interp_2d_transform.py
73
78
  tests/test_interp_natural_neighbor.py
74
79
  tests/test_interpolator_contracts.py
75
80
  tests/test_jpcontour_methods.py
76
81
  tests/test_parse_data_metadata.py
77
82
  tests/test_pathing_contracts.py
83
+ tests/test_posterior_hpd.py
78
84
  tests/test_template_json.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: JarvisPLOT
3
- Version: 1.3.9
3
+ Version: 1.3.10
4
4
  Summary: Jarvis-PLOT: YAML-driven plotting engine
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -11,8 +11,9 @@ from matplotlib.axes import Axes
11
11
  from matplotlib.collections import PolyCollection
12
12
  #
13
13
  from .helper import _auto_clip, _mask_by_extend, voronoi_finite_polygons_2d, _clip_poly_to_rect
14
- from .profile_runtime import grid_profile_mesh
14
+ from .profile_runtime import regular_grid_mesh
15
15
  from .interp_natural_neighbor import resolve_backend
16
+ from .posterior_hpd import prepare_hpd_contour_style
16
17
 
17
18
  DEFAULT_JP_SAMPLE_GRID = 500
18
19
 
@@ -371,19 +372,20 @@ class StdAxesAdapter:
371
372
  artists = self.ax.scatter(x, y, **kw)
372
373
  return _auto_clip(artists, self.ax, self._clip_path)
373
374
 
374
- def grid_profile(self, **kwargs):
375
- """Grid-cell partition rendering for profiled scalar fields.
375
+ def pcolormesh(self, *args, **kwargs):
376
+ """Render regular-grid fields, including compact tables with __grid_* metadata."""
377
+ if args:
378
+ kw = self._merge("pcolormesh", kwargs)
379
+ artist = self.ax.pcolormesh(*args, **kw)
380
+ return _auto_clip(artist, self.ax, self._clip_path)
376
381
 
377
- Compared with tripcolor, this draws axis-aligned cells (like a regular
378
- partition of the plane), closer to Voronoi-style region coloring.
379
- """
380
382
  x = np.asarray(kwargs.pop("x"), dtype=float)
381
383
  y = np.asarray(kwargs.pop("y"), dtype=float)
382
384
  z = np.asarray(kwargs.pop("z", np.zeros_like(x)), dtype=float)
383
385
  df = kwargs.pop("__df__", None)
384
- kw = self._merge("grid_profile", kwargs)
386
+ kw = self._merge("pcolormesh", kwargs)
385
387
 
386
- # Strip kwargs that are not used by grid_profile rendering.
388
+ # Strip kwargs that are not used by pcolormesh rendering.
387
389
  for k in ("shading", "levels", "extend", "space", "marker", "s", "c"):
388
390
  kw.pop(k, None)
389
391
 
@@ -411,7 +413,7 @@ class StdAxesAdapter:
411
413
  xlim = [float(self.ax.get_xlim()[0]), float(self.ax.get_xlim()[1])]
412
414
  if ylim is None:
413
415
  ylim = [float(self.ax.get_ylim()[0]), float(self.ax.get_ylim()[1])]
414
- mesh = grid_profile_mesh(
416
+ mesh = regular_grid_mesh(
415
417
  x,
416
418
  y,
417
419
  z,
@@ -441,7 +443,6 @@ class StdAxesAdapter:
441
443
  if zorder is not None:
442
444
  kw.setdefault("zorder", zorder)
443
445
 
444
- # histo2d-like rendering via pcolormesh
445
446
  if norm is not None:
446
447
  artist = self.ax.pcolormesh(
447
448
  x_edges,
@@ -482,7 +483,13 @@ class StdAxesAdapter:
482
483
 
483
484
  def contour(self, *args, **kwargs):
484
485
  kw = self._merge("contour", kwargs)
486
+ label_map = kw.pop("_hpd_label_map", None)
485
487
  artists = self.ax.contour(*args, **kw)
488
+ if label_map:
489
+ try:
490
+ self.ax.clabel(artists, fmt=label_map)
491
+ except Exception:
492
+ pass
486
493
  return _auto_clip(artists, self.ax, self._clip_path)
487
494
 
488
495
  def contourf(self, *args, **kwargs):
@@ -510,6 +517,7 @@ class StdAxesAdapter:
510
517
  """Scattered-data contour plot rendered in axes-fraction coordinates."""
511
518
  kw = self._merge("contour", kwargs)
512
519
  kw.pop("transform", None)
520
+ logger = kw.pop("_logger", None)
513
521
  X, Y, Z = self._interpolate_jpcontour_grid(
514
522
  x,
515
523
  y,
@@ -524,6 +532,8 @@ class StdAxesAdapter:
524
532
  diagnostics=diagnostics,
525
533
  backend_options=backend_options,
526
534
  )
535
+ kw = prepare_hpd_contour_style(Z, X, Y, kw, logger=logger)
536
+ label_map = kw.pop("_hpd_label_map", None)
527
537
  artists = self.ax.contour(
528
538
  X,
529
539
  Y,
@@ -532,6 +542,11 @@ class StdAxesAdapter:
532
542
  transform=self.ax.transAxes,
533
543
  **kw,
534
544
  )
545
+ if label_map:
546
+ try:
547
+ self.ax.clabel(artists, fmt=label_map)
548
+ except Exception:
549
+ pass
535
550
  return _auto_clip(artists, self.ax, self._clip_path)
536
551
 
537
552
  def jpcontourf(
@@ -54,14 +54,6 @@ class TernaryAxesAdapter(StdAxesAdapter):
54
54
  kwargs['y'] = y
55
55
  return super().scatter(**kwargs)
56
56
 
57
- def grid_profile(self, **kwargs):
58
- if {"left", "right", "bottom"}.issubset(kwargs.keys()):
59
- x, y = self._lbr_to_xy(kwargs.pop('left'), kwargs.pop('right'), kwargs.pop('bottom'))
60
- kwargs['x'] = x
61
- kwargs['y'] = y
62
- return super().grid_profile(**kwargs)
63
-
64
-
65
57
  def plot(self, **kwargs):
66
58
  if {"left", "right", "bottom"}.issubset(kwargs.keys()):
67
59
  x, y = self._lbr_to_xy(kwargs.pop('left'), kwargs.pop('right'), kwargs.pop('bottom'))
@@ -144,7 +144,6 @@ _COLORED_Z_METHODS = frozenset({
144
144
  "tripcolor", "tripcolor_axes",
145
145
  "pcolor", "pcolormesh", "imshow",
146
146
  "voronoi", "voronoif",
147
- "grid_profile",
148
147
  })
149
148
 
150
149
  _CONTOUR_LIKE_METHODS = frozenset({
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Mapping, Sequence, Tuple
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from ..memtrace import memtrace_checkpoint
10
+ from ..utils.expression import eval_dataframe_expression
11
+
12
+
13
+ _DENSITY_CELL_TYPES = {"make_density_core"}
14
+
15
+
16
+ class _ScaledBwMethod:
17
+ def __init__(self, factor: float, base: str):
18
+ self.factor = float(factor)
19
+ self.base = str(base).strip().lower()
20
+
21
+ def __call__(self, kde) -> float:
22
+ if self.base == "silverman":
23
+ return self.factor * float(kde.silverman_factor())
24
+ return self.factor * float(kde.scotts_factor())
25
+
26
+ def __str__(self) -> str:
27
+ return f"{self.factor:g}*{self.base}"
28
+
29
+
30
+ def is_density_cell_transform(step: Any) -> bool:
31
+ if not isinstance(step, Mapping):
32
+ return False
33
+ if any(name in step for name in _DENSITY_CELL_TYPES):
34
+ return True
35
+ return str(step.get("type", "")).strip().lower() in _DENSITY_CELL_TYPES
36
+
37
+
38
+ def density_cell_config(step: Mapping[str, Any]) -> Mapping[str, Any]:
39
+ for name in _DENSITY_CELL_TYPES:
40
+ if name in step:
41
+ cfg = step.get(name)
42
+ return cfg if isinstance(cfg, Mapping) else {}
43
+ if str(step.get("type", "")).strip().lower() in _DENSITY_CELL_TYPES:
44
+ return step
45
+ return {}
46
+
47
+
48
+ def _logger_emit(logger, level: str, message: str) -> None:
49
+ if logger is None:
50
+ return
51
+ try:
52
+ getattr(logger, level)(message)
53
+ except Exception:
54
+ pass
55
+
56
+
57
+ def _coord_cfg(cfg: Mapping[str, Any], key: str) -> Mapping[str, Any]:
58
+ coors = cfg.get("coordinates", {})
59
+ if not isinstance(coors, Mapping):
60
+ coors = {}
61
+ spec = coors.get(key, cfg.get(key, None))
62
+ if isinstance(spec, Mapping):
63
+ return spec
64
+ if spec is None:
65
+ return {}
66
+ return {"name": str(spec)}
67
+
68
+
69
+ def _output_name(cfg: Mapping[str, Any], key: str, default: str) -> str:
70
+ name = _coord_cfg(cfg, key).get("name", default)
71
+ text = str(name).strip()
72
+ return text or default
73
+
74
+
75
+ def _resolve_array(df: pd.DataFrame, cfg: Mapping[str, Any], key: str, default: str, logger, *, required: bool) -> np.ndarray:
76
+ spec = _coord_cfg(cfg, key)
77
+ expr = spec.get("expr")
78
+ if expr is not None:
79
+ return np.asarray(eval_dataframe_expression(df, expr, logger=logger, allow_column=True), dtype=float)
80
+ name = str(spec.get("name", default)).strip()
81
+ if not name:
82
+ if required:
83
+ raise ValueError(f"make_density_core requires a non-empty coordinate for '{key}'.")
84
+ return np.ones(int(df.shape[0]), dtype=float)
85
+ if name not in df.columns:
86
+ if required:
87
+ raise KeyError(f"make_density_core missing required column '{name}'.")
88
+ return np.ones(int(df.shape[0]), dtype=float)
89
+ return np.asarray(df[name], dtype=float)
90
+
91
+
92
+ def _axis_lim(values: np.ndarray, cfg: Mapping[str, Any], key: str) -> Tuple[float, float]:
93
+ spec = _coord_cfg(cfg, key)
94
+ lim = spec.get("lim", spec.get("limits", None))
95
+ domain = cfg.get("domain", {})
96
+ if not isinstance(domain, Mapping):
97
+ domain = {}
98
+ if lim is None:
99
+ lim = domain.get(f"{key}lim", cfg.get(f"{key}lim", None))
100
+ if lim is None:
101
+ finite = np.asarray(values, dtype=float)
102
+ finite = finite[np.isfinite(finite)]
103
+ if finite.size == 0:
104
+ raise ValueError(f"make_density_core cannot infer {key} limits from non-finite data.")
105
+ lo = float(np.min(finite))
106
+ hi = float(np.max(finite))
107
+ if lo == hi:
108
+ hi = lo + 1.0
109
+ return lo, hi
110
+ if not isinstance(lim, Sequence) or len(lim) != 2:
111
+ raise ValueError(f"make_density_core coordinates.{key}.lim must have two values.")
112
+ lo, hi = float(lim[0]), float(lim[1])
113
+ if not (np.isfinite(lo) and np.isfinite(hi) and hi > lo):
114
+ raise ValueError(f"make_density_core coordinates.{key}.lim requires finite lo < hi.")
115
+ return lo, hi
116
+
117
+
118
+ def _axis_scale(cfg: Mapping[str, Any], key: str) -> str:
119
+ spec = _coord_cfg(cfg, key)
120
+ domain = cfg.get("domain", {})
121
+ if not isinstance(domain, Mapping):
122
+ domain = {}
123
+ return str(spec.get("scale", domain.get(f"{key}scale", cfg.get(f"{key}scale", "linear")))).strip().lower()
124
+
125
+
126
+ def _project_axis(values: np.ndarray, lim: Tuple[float, float], scale: str, key: str) -> np.ndarray:
127
+ lo, hi = lim
128
+ values = np.asarray(values, dtype=float)
129
+ if scale == "log":
130
+ if lo <= 0 or hi <= 0:
131
+ raise ValueError(f"make_density_core {key} log scale requires positive limits.")
132
+ out = np.full(values.shape, np.nan, dtype=float)
133
+ mask = values > 0
134
+ out[mask] = (np.log(values[mask]) - np.log(lo)) / (np.log(hi) - np.log(lo))
135
+ return out
136
+ return (values - lo) / (hi - lo)
137
+
138
+
139
+ def _unproject_axis(values: np.ndarray, lim: Tuple[float, float], scale: str) -> np.ndarray:
140
+ lo, hi = lim
141
+ values = np.asarray(values, dtype=float)
142
+ if scale == "log":
143
+ return np.exp(np.log(lo) + values * (np.log(hi) - np.log(lo)))
144
+ return lo + values * (hi - lo)
145
+
146
+
147
+ def _grid_shape(cfg: Mapping[str, Any], default: int = 128) -> Tuple[int, int]:
148
+ grid = cfg.get("grid", {})
149
+ if not isinstance(grid, Mapping):
150
+ grid = {}
151
+ bins = grid.get("bins", grid.get("bin", cfg.get("bins", cfg.get("bin", None))))
152
+ if bins is not None:
153
+ nx = ny = int(bins)
154
+ else:
155
+ nx = int(grid.get("nx", cfg.get("nx", default)))
156
+ ny = int(grid.get("ny", cfg.get("ny", default)))
157
+ if nx < 2 or ny < 2:
158
+ raise ValueError("make_density_core grid requires nx >= 2 and ny >= 2.")
159
+ return nx, ny
160
+
161
+
162
+ def _bw_method(cfg: Mapping[str, Any]):
163
+ bw = cfg.get("bw_method", cfg.get("bandwidth", None))
164
+ if bw is None:
165
+ return None
166
+ if isinstance(bw, str):
167
+ text = bw.strip().lower()
168
+ if text in {"", "default", "none"}:
169
+ return None
170
+ if text in {"scott", "silverman"}:
171
+ return text
172
+ match = re.fullmatch(r"([0-9]*\.?[0-9]+)\s*\*\s*(scott|silverman)", text)
173
+ if match:
174
+ factor = float(match.group(1))
175
+ if not np.isfinite(factor) or factor <= 0:
176
+ raise ValueError("make_density_core scaled bw_method factor must be positive and finite.")
177
+ return _ScaledBwMethod(factor, match.group(2))
178
+ value = float(text)
179
+ if not np.isfinite(value) or value <= 0:
180
+ raise ValueError("make_density_core float bw_method must be positive and finite.")
181
+ return value
182
+ value = float(bw)
183
+ if not np.isfinite(value) or value <= 0:
184
+ raise ValueError("make_density_core float bw_method must be positive and finite.")
185
+ return value
186
+
187
+
188
+ def _prepare_samples(df: pd.DataFrame, cfg: Mapping[str, Any], logger):
189
+ x = _resolve_array(df, cfg, "x", "x", logger, required=True)
190
+ y = _resolve_array(df, cfg, "y", "y", logger, required=True)
191
+ weight_required = bool(_coord_cfg(cfg, "weight"))
192
+ w = _resolve_array(df, cfg, "weight", "weight", logger, required=weight_required)
193
+ n = min(len(x), len(y), len(w), int(df.shape[0]))
194
+ x = np.asarray(x[:n], dtype=float)
195
+ y = np.asarray(y[:n], dtype=float)
196
+ w = np.asarray(w[:n], dtype=float)
197
+ valid = np.isfinite(x) & np.isfinite(y) & np.isfinite(w) & (w > 0)
198
+ if not np.any(valid):
199
+ raise ValueError("make_density_core got no finite samples with positive finite weights.")
200
+ if not np.all(valid):
201
+ _logger_emit(logger, "warning", f"make_density_core dropped {int((~valid).sum())} invalid or non-positive samples.")
202
+ return x[valid], y[valid], w[valid], n
203
+
204
+
205
+ def _normalize(values: np.ndarray, *, enabled: bool) -> np.ndarray:
206
+ values = np.asarray(values, dtype=float)
207
+ if not enabled:
208
+ return values
209
+ total = float(np.sum(values))
210
+ if not np.isfinite(total) or total <= 0:
211
+ raise ValueError("make_density_core cannot normalize non-positive or non-finite total weight.")
212
+ return values / total
213
+
214
+
215
+ def _grid_density_cell(x, y, w, cfg, logger):
216
+ xlim = _axis_lim(x, cfg, "x")
217
+ ylim = _axis_lim(y, cfg, "y")
218
+ xscale = _axis_scale(cfg, "x")
219
+ yscale = _axis_scale(cfg, "y")
220
+ nx, ny = _grid_shape(cfg)
221
+ xp = _project_axis(x, xlim, xscale, "x")
222
+ yp = _project_axis(y, ylim, yscale, "y")
223
+ inside = np.isfinite(xp) & np.isfinite(yp) & (xp >= 0.0) & (xp <= 1.0) & (yp >= 0.0) & (yp <= 1.0)
224
+ if not np.any(inside):
225
+ raise ValueError("make_density_core grid found no positive-mass samples inside the domain.")
226
+ if not np.all(inside):
227
+ _logger_emit(logger, "warning", f"make_density_core grid ignored {int((~inside).sum())} samples outside the domain.")
228
+ ix = np.clip(xp[inside], 0.0, 1.0 - 1e-12)
229
+ iy = np.clip(yp[inside], 0.0, 1.0 - 1e-12)
230
+ ix = (ix * nx).astype(np.int32)
231
+ iy = (iy * ny).astype(np.int32)
232
+ mass = np.zeros((ny, nx), dtype=float)
233
+ np.add.at(mass, (iy, ix), w[inside])
234
+ gx = (np.arange(nx, dtype=float) + 0.5) / float(nx)
235
+ gy = (np.arange(ny, dtype=float) + 0.5) / float(ny)
236
+ xg, yg = np.meshgrid(_unproject_axis(gx, xlim, xscale), _unproject_axis(gy, ylim, yscale))
237
+ return xg.ravel(), yg.ravel(), mass.ravel()
238
+
239
+
240
+ def _bridson_points(radius: float, *, seed: int | None = None, k: int = 30) -> np.ndarray:
241
+ rng = np.random.default_rng(seed)
242
+ cell = radius / np.sqrt(2.0)
243
+ nx = max(1, int(np.ceil(1.0 / cell)))
244
+ ny = max(1, int(np.ceil(1.0 / cell)))
245
+ grid = -np.ones((ny, nx), dtype=np.int64)
246
+ points: list[tuple[float, float]] = []
247
+ active: list[int] = []
248
+
249
+ def _grid_pos(pt):
250
+ gx = min(nx - 1, max(0, int(pt[0] / cell)))
251
+ gy = min(ny - 1, max(0, int(pt[1] / cell)))
252
+ return gx, gy
253
+
254
+ def _far_enough(pt) -> bool:
255
+ gx, gy = _grid_pos(pt)
256
+ r2 = radius * radius
257
+ for yy in range(max(0, gy - 2), min(ny, gy + 3)):
258
+ for xx in range(max(0, gx - 2), min(nx, gx + 3)):
259
+ idx = int(grid[yy, xx])
260
+ if idx < 0:
261
+ continue
262
+ px, py = points[idx]
263
+ if (pt[0] - px) ** 2 + (pt[1] - py) ** 2 < r2:
264
+ return False
265
+ return True
266
+
267
+ first = (float(rng.random()), float(rng.random()))
268
+ points.append(first)
269
+ gx, gy = _grid_pos(first)
270
+ grid[gy, gx] = 0
271
+ active.append(0)
272
+ while active:
273
+ slot = int(rng.integers(0, len(active)))
274
+ idx = active[slot]
275
+ base = points[idx]
276
+ accepted = False
277
+ for _ in range(int(k)):
278
+ rr = radius * (1.0 + rng.random())
279
+ theta = 2.0 * np.pi * rng.random()
280
+ cand = (base[0] + rr * np.cos(theta), base[1] + rr * np.sin(theta))
281
+ if cand[0] < 0.0 or cand[0] > 1.0 or cand[1] < 0.0 or cand[1] > 1.0:
282
+ continue
283
+ if not _far_enough(cand):
284
+ continue
285
+ points.append((float(cand[0]), float(cand[1])))
286
+ new_idx = len(points) - 1
287
+ gx, gy = _grid_pos(cand)
288
+ grid[gy, gx] = new_idx
289
+ active.append(new_idx)
290
+ accepted = True
291
+ break
292
+ if not accepted:
293
+ active.pop(slot)
294
+ return np.asarray(points, dtype=float)
295
+
296
+
297
+ def _bridson_density_cell(x, y, w, cfg, logger):
298
+ xlim = _axis_lim(x, cfg, "x")
299
+ ylim = _axis_lim(y, cfg, "y")
300
+ xscale = _axis_scale(cfg, "x")
301
+ yscale = _axis_scale(cfg, "y")
302
+ xp = _project_axis(x, xlim, xscale, "x")
303
+ yp = _project_axis(y, ylim, yscale, "y")
304
+ inside = np.isfinite(xp) & np.isfinite(yp) & (xp >= 0.0) & (xp <= 1.0) & (yp >= 0.0) & (yp <= 1.0)
305
+ if not np.any(inside):
306
+ raise ValueError("make_density_core bridson found no positive-mass samples inside the domain.")
307
+ if not np.all(inside):
308
+ _logger_emit(logger, "warning", f"make_density_core bridson ignored {int((~inside).sum())} samples outside the domain.")
309
+ bridson = cfg.get("bridson", {})
310
+ if not isinstance(bridson, Mapping):
311
+ bridson = {}
312
+ bin_value = int(bridson.get("bin", bridson.get("bins", cfg.get("bin", 32))))
313
+ if bin_value <= 0:
314
+ raise ValueError("make_density_core bridson bin must be positive.")
315
+ radius = 1.0 / float(bin_value)
316
+ seed = bridson.get("seed", cfg.get("seed", None))
317
+ seed = None if seed is None else int(seed)
318
+ k = int(bridson.get("k", bridson.get("candidates", 30)))
319
+ support = _bridson_points(radius, seed=seed, k=k)
320
+ samples = np.column_stack([xp[inside], yp[inside]])
321
+ try:
322
+ from scipy.spatial import cKDTree
323
+
324
+ _, idx = cKDTree(support).query(samples, k=1)
325
+ except Exception:
326
+ diff = samples[:, None, :] - support[None, :, :]
327
+ idx = np.argmin(np.sum(diff * diff, axis=2), axis=1)
328
+ mass = np.zeros(int(support.shape[0]), dtype=float)
329
+ np.add.at(mass, np.asarray(idx, dtype=np.int64), w[inside])
330
+ xs = _unproject_axis(support[:, 0], xlim, xscale)
331
+ ys = _unproject_axis(support[:, 1], ylim, yscale)
332
+ return xs, ys, mass
333
+
334
+
335
+ def _kde_core(x, y, w, cfg, logger):
336
+ xlim = _axis_lim(x, cfg, "x")
337
+ ylim = _axis_lim(y, cfg, "y")
338
+ xscale = _axis_scale(cfg, "x")
339
+ yscale = _axis_scale(cfg, "y")
340
+ if xscale != "linear" or yscale != "linear":
341
+ raise ValueError("make_density_core method=kde currently requires linear x/y scales.")
342
+ nx, ny = _grid_shape(cfg)
343
+ if x.shape[0] < 3:
344
+ raise ValueError("make_density_core method=kde requires at least three finite 2D samples.")
345
+ pts = np.vstack([x, y])
346
+ if np.linalg.matrix_rank(pts - pts.mean(axis=1, keepdims=True)) < 2:
347
+ raise ValueError("make_density_core method=kde requires non-collinear 2D samples.")
348
+ weights = _normalize(w, enabled=True)
349
+ try:
350
+ from scipy.stats import gaussian_kde
351
+ except Exception as e:
352
+ raise ImportError("make_density_core method=kde requires scipy.stats.gaussian_kde.") from e
353
+ kde = gaussian_kde(dataset=pts, bw_method=_bw_method(cfg), weights=weights)
354
+ xmin, xmax = xlim
355
+ ymin, ymax = ylim
356
+ dx = (xmax - xmin) / float(nx)
357
+ dy = (ymax - ymin) / float(ny)
358
+ x_centers = np.linspace(xmin + 0.5 * dx, xmax - 0.5 * dx, nx)
359
+ y_centers = np.linspace(ymin + 0.5 * dy, ymax - 0.5 * dy, ny)
360
+ xg, yg = np.meshgrid(x_centers, y_centers)
361
+ density = np.asarray(kde(np.vstack([xg.ravel(), yg.ravel()])), dtype=float).reshape(ny, nx)
362
+ density = np.maximum(np.where(np.isfinite(density), density, 0.0), 0.0)
363
+ mass = density.ravel() * dx * dy
364
+ return xg.ravel(), yg.ravel(), mass
365
+
366
+
367
+ def _diagnostics(*, method: str, input_count: int, output_count: int, normalize: bool, names: Tuple[str, str, str], total: float) -> str:
368
+ return (
369
+ "make_density_core diagnostics:\n"
370
+ f"\t method \t-> {method}\n"
371
+ f"\t input_samples \t-> {input_count}\n"
372
+ f"\t output_support \t-> {output_count}\n"
373
+ f"\t normalize \t-> {normalize}\n"
374
+ f"\t output_columns \t-> {names[0]}, {names[1]}, {names[2]}\n"
375
+ f"\t output_weight_sum \t-> {total:.17g}"
376
+ )
377
+
378
+
379
+ def density_cell(df: pd.DataFrame, cfg: Mapping[str, Any], logger=None) -> pd.DataFrame:
380
+ """
381
+ Construct a minimal posterior density-support table from raw samples.
382
+
383
+ The output contains exactly three columns: support x, support y, and
384
+ aggregated posterior mass/weight. Geometry, density division, smoothing, and
385
+ plotting preparation are intentionally left to later transforms/rendering.
386
+ """
387
+ if not isinstance(df, pd.DataFrame):
388
+ raise TypeError("make_density_core expects a pandas DataFrame.")
389
+ cfg = cfg if isinstance(cfg, Mapping) else {}
390
+ method = str(cfg.get("method", "grid")).strip().lower()
391
+ if method not in {"grid", "bridson", "kde"}:
392
+ raise ValueError("make_density_core method must be one of: grid, bridson, kde.")
393
+ memtrace_checkpoint(logger, "make_density_core.before", df)
394
+ x, y, w, input_count = _prepare_samples(df, cfg, logger)
395
+ if method == "grid":
396
+ xs, ys, mass = _grid_density_cell(x, y, w, cfg, logger)
397
+ elif method == "bridson":
398
+ xs, ys, mass = _bridson_density_cell(x, y, w, cfg, logger)
399
+ else:
400
+ xs, ys, mass = _kde_core(x, y, w, cfg, logger)
401
+ normalize = bool(cfg.get("normalize", True))
402
+ mass = _normalize(mass, enabled=normalize)
403
+ names = (
404
+ _output_name(cfg, "x", "x"),
405
+ _output_name(cfg, "y", "y"),
406
+ _output_name(cfg, "weight", "weight"),
407
+ )
408
+ out = pd.DataFrame({names[0]: np.asarray(xs, dtype=float), names[1]: np.asarray(ys, dtype=float), names[2]: mass})
409
+ if list(out.columns) != list(names):
410
+ out = out.loc[:, list(names)]
411
+ msg = _diagnostics(
412
+ method=method,
413
+ input_count=int(input_count),
414
+ output_count=int(out.shape[0]),
415
+ normalize=normalize,
416
+ names=names,
417
+ total=float(np.sum(out[names[2]].to_numpy(dtype=float))),
418
+ )
419
+ if bool(cfg.get("diagnostics", True)):
420
+ _logger_emit(logger, "warning", msg)
421
+ memtrace_checkpoint(
422
+ logger,
423
+ "make_density_core.after",
424
+ out,
425
+ extra={"method": method, "rows": int(out.shape[0]), "weight_sum": float(np.sum(out[names[2]]))},
426
+ )
427
+ return out