diffract-core 0.2.1__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 (193) hide show
  1. diffract/__init__.py +52 -0
  2. diffract/configs/fast_speed_without_disk.ini +48 -0
  3. diffract/configs/hybrid.ini +74 -0
  4. diffract/configs/sqlite.ini +62 -0
  5. diffract/containers.py +405 -0
  6. diffract/core/__init__.py +23 -0
  7. diffract/core/cache/__init__.py +24 -0
  8. diffract/core/cache/containers.py +93 -0
  9. diffract/core/cache/interface.py +117 -0
  10. diffract/core/cache/redis_manager.py +464 -0
  11. diffract/core/cache/simple_manager.py +478 -0
  12. diffract/core/compute/__init__.py +46 -0
  13. diffract/core/compute/config.py +48 -0
  14. diffract/core/compute/containers.py +81 -0
  15. diffract/core/compute/decorator.py +125 -0
  16. diffract/core/compute/exceptions.py +31 -0
  17. diffract/core/compute/execution/__init__.py +14 -0
  18. diffract/core/compute/execution/_types.py +70 -0
  19. diffract/core/compute/execution/aggregation.py +74 -0
  20. diffract/core/compute/execution/aggregation_runner.py +362 -0
  21. diffract/core/compute/execution/enums.py +38 -0
  22. diffract/core/compute/execution/executor.py +125 -0
  23. diffract/core/compute/execution/parameter_runner.py +125 -0
  24. diffract/core/compute/execution/restrictions.py +61 -0
  25. diffract/core/compute/execution/strategy.py +118 -0
  26. diffract/core/compute/execution/utils.py +112 -0
  27. diffract/core/compute/extensions/__init__.py +0 -0
  28. diffract/core/compute/extensions/power_law.py +1051 -0
  29. diffract/core/compute/extensions/rmt.py +150 -0
  30. diffract/core/compute/extensions/utils.py +36 -0
  31. diffract/core/compute/field_signature.py +183 -0
  32. diffract/core/compute/kernels/__init__.py +48 -0
  33. diffract/core/compute/kernels/alignment.py +106 -0
  34. diffract/core/compute/kernels/heavy_tailed.py +394 -0
  35. diffract/core/compute/kernels/marchenko_pastur.py +99 -0
  36. diffract/core/compute/kernels/mat_decomposition.py +101 -0
  37. diffract/core/compute/kernels/mat_properties.py +53 -0
  38. diffract/core/compute/kernels/model_quality.py +27 -0
  39. diffract/core/compute/kernels/norms.py +88 -0
  40. diffract/core/compute/kernels/ranks.py +35 -0
  41. diffract/core/compute/kernels/tracy_widom.py +36 -0
  42. diffract/core/compute/metadata.py +67 -0
  43. diffract/core/compute/registry.py +485 -0
  44. diffract/core/constants.py +147 -0
  45. diffract/core/data/__init__.py +32 -0
  46. diffract/core/data/interface.py +304 -0
  47. diffract/core/data/metadata/__init__.py +15 -0
  48. diffract/core/data/metadata/containers.py +60 -0
  49. diffract/core/data/metadata/interface.py +208 -0
  50. diffract/core/data/metadata/sqlite_index.py +604 -0
  51. diffract/core/data/nn/__init__.py +43 -0
  52. diffract/core/data/nn/aggregates/__init__.py +35 -0
  53. diffract/core/data/nn/aggregates/metadata.py +96 -0
  54. diffract/core/data/nn/aggregates/proxy.py +57 -0
  55. diffract/core/data/nn/aggregates/repository.py +87 -0
  56. diffract/core/data/nn/aggregates/view.py +307 -0
  57. diffract/core/data/nn/containers.py +86 -0
  58. diffract/core/data/nn/extractors/__init__.py +49 -0
  59. diffract/core/data/nn/extractors/base.py +300 -0
  60. diffract/core/data/nn/extractors/factory.py +234 -0
  61. diffract/core/data/nn/extractors/flax.py +112 -0
  62. diffract/core/data/nn/extractors/handlers/__init__.py +39 -0
  63. diffract/core/data/nn/extractors/handlers/base.py +110 -0
  64. diffract/core/data/nn/extractors/handlers/flax_handlers.py +71 -0
  65. diffract/core/data/nn/extractors/handlers/numpy_handlers.py +63 -0
  66. diffract/core/data/nn/extractors/handlers/onnx_handlers.py +67 -0
  67. diffract/core/data/nn/extractors/handlers/tensorflow_handlers.py +82 -0
  68. diffract/core/data/nn/extractors/handlers/torch_handlers.py +124 -0
  69. diffract/core/data/nn/extractors/interface.py +56 -0
  70. diffract/core/data/nn/extractors/numpy.py +94 -0
  71. diffract/core/data/nn/extractors/onnx.py +108 -0
  72. diffract/core/data/nn/extractors/tensorflow.py +99 -0
  73. diffract/core/data/nn/extractors/torch.py +204 -0
  74. diffract/core/data/nn/params/__init__.py +27 -0
  75. diffract/core/data/nn/params/interface.py +402 -0
  76. diffract/core/data/nn/params/metadata.py +110 -0
  77. diffract/core/data/nn/params/proxy.py +93 -0
  78. diffract/core/data/nn/params/repository.py +45 -0
  79. diffract/core/data/nn/params/schema.py +82 -0
  80. diffract/core/data/nn/params/view.py +393 -0
  81. diffract/core/data/proxy.py +246 -0
  82. diffract/core/data/repository.py +227 -0
  83. diffract/core/data/utils.py +33 -0
  84. diffract/core/data/view.py +389 -0
  85. diffract/core/export/__init__.py +27 -0
  86. diffract/core/export/containers.py +47 -0
  87. diffract/core/export/exporters.py +191 -0
  88. diffract/core/export/formatters/__init__.py +23 -0
  89. diffract/core/export/formatters/base.py +68 -0
  90. diffract/core/export/formatters/dict_formatter.py +38 -0
  91. diffract/core/export/formatters/json_formatter.py +58 -0
  92. diffract/core/export/formatters/list_formatter.py +41 -0
  93. diffract/core/export/formatters/pandas_formatter.py +86 -0
  94. diffract/core/export/formatters/polars_formatter.py +86 -0
  95. diffract/core/export/formatters/registry.py +84 -0
  96. diffract/core/export/interface.py +105 -0
  97. diffract/core/parallel/__init__.py +21 -0
  98. diffract/core/parallel/containers.py +57 -0
  99. diffract/core/parallel/execution.py +91 -0
  100. diffract/core/parallel/runtime.py +91 -0
  101. diffract/core/storage/__init__.py +35 -0
  102. diffract/core/storage/base_manager.py +330 -0
  103. diffract/core/storage/containers.py +122 -0
  104. diffract/core/storage/hdf5_manager.py +856 -0
  105. diffract/core/storage/hybrid_manager.py +218 -0
  106. diffract/core/storage/interface.py +222 -0
  107. diffract/core/storage/metadata.py +89 -0
  108. diffract/core/storage/ram_manager.py +159 -0
  109. diffract/core/storage/sqlite_manager.py +1062 -0
  110. diffract/core/storage/zarr_manager.py +992 -0
  111. diffract/core/utils/__init__.py +45 -0
  112. diffract/core/utils/build.py +46 -0
  113. diffract/core/utils/exceptions.py +11 -0
  114. diffract/core/utils/hashing.py +90 -0
  115. diffract/core/utils/imports.py +292 -0
  116. diffract/core/utils/math.py +15 -0
  117. diffract/py.typed +0 -0
  118. diffract/session/__init__.py +24 -0
  119. diffract/session/errors.py +17 -0
  120. diffract/session/field_cache.py +216 -0
  121. diffract/session/namespaces/__init__.py +15 -0
  122. diffract/session/namespaces/compute/__init__.py +219 -0
  123. diffract/session/namespaces/models/__init__.py +282 -0
  124. diffract/session/namespaces/models/parameters/__init__.py +133 -0
  125. diffract/session/namespaces/models/parameters/meta_patcher.py +167 -0
  126. diffract/session/namespaces/results/__init__.py +378 -0
  127. diffract/session/namespaces/results/eraser.py +129 -0
  128. diffract/session/namespaces/results/injester.py +249 -0
  129. diffract/session/namespaces/utils/__init__.py +141 -0
  130. diffract/session/namespaces/utils/merger.py +295 -0
  131. diffract/session/namespaces/viz/__init__.py +91 -0
  132. diffract/session/namespaces/viz/_utils.py +14 -0
  133. diffract/session/namespaces/viz/box.py +207 -0
  134. diffract/session/namespaces/viz/grid.py +160 -0
  135. diffract/session/namespaces/viz/heatmap.py +164 -0
  136. diffract/session/namespaces/viz/scatter.py +202 -0
  137. diffract/session/namespaces/viz/sparkline.py +270 -0
  138. diffract/session/namespaces/viz/violin.py +212 -0
  139. diffract/session/session.py +260 -0
  140. diffract/session/utils.py +81 -0
  141. diffract/viz/__init__.py +42 -0
  142. diffract/viz/data/__init__.py +34 -0
  143. diffract/viz/data/detection.py +57 -0
  144. diffract/viz/data/extraction.py +117 -0
  145. diffract/viz/data/filtering.py +57 -0
  146. diffract/viz/data/ordering.py +129 -0
  147. diffract/viz/data/provider.py +99 -0
  148. diffract/viz/data/types.py +98 -0
  149. diffract/viz/plots/__init__.py +37 -0
  150. diffract/viz/plots/base/__init__.py +20 -0
  151. diffract/viz/plots/base/axis.py +219 -0
  152. diffract/viz/plots/base/coloraxis.py +133 -0
  153. diffract/viz/plots/base/configurator.py +18 -0
  154. diffract/viz/plots/base/jitter.py +368 -0
  155. diffract/viz/plots/base/line.py +197 -0
  156. diffract/viz/plots/base/marker.py +278 -0
  157. diffract/viz/plots/base/overlay.py +14 -0
  158. diffract/viz/plots/base/plot.py +110 -0
  159. diffract/viz/plots/base/update.py +50 -0
  160. diffract/viz/plots/boxplot.py +283 -0
  161. diffract/viz/plots/cluster.py +374 -0
  162. diffract/viz/plots/heatmap.py +168 -0
  163. diffract/viz/plots/scatter.py +233 -0
  164. diffract/viz/plots/sparkline.py +405 -0
  165. diffract/viz/plots/subplots/__init__.py +32 -0
  166. diffract/viz/plots/subplots/coloraxis.py +339 -0
  167. diffract/viz/plots/subplots/factory.py +713 -0
  168. diffract/viz/plots/subplots/grid.py +214 -0
  169. diffract/viz/plots/subplots/layout.py +370 -0
  170. diffract/viz/plots/subplots/spec.py +39 -0
  171. diffract/viz/plots/violin.py +298 -0
  172. diffract/viz/renderer.py +513 -0
  173. diffract/viz/styling/__init__.py +78 -0
  174. diffract/viz/styling/palettes/__init__.py +20 -0
  175. diffract/viz/styling/palettes/bundle.py +16 -0
  176. diffract/viz/styling/palettes/color.py +83 -0
  177. diffract/viz/styling/palettes/dashes.py +27 -0
  178. diffract/viz/styling/palettes/symbols.py +42 -0
  179. diffract/viz/styling/resolvers/__init__.py +11 -0
  180. diffract/viz/styling/resolvers/categorical.py +68 -0
  181. diffract/viz/styling/resolvers/color.py +77 -0
  182. diffract/viz/styling/resolvers/numeric.py +108 -0
  183. diffract/viz/styling/sources.py +27 -0
  184. diffract/viz/styling/theme/__init__.py +29 -0
  185. diffract/viz/styling/theme/applier.py +114 -0
  186. diffract/viz/styling/theme/components.py +66 -0
  187. diffract/viz/styling/theme/presets.py +58 -0
  188. diffract/viz/styling/theme/theme.py +36 -0
  189. diffract_core-0.2.1.dist-info/METADATA +530 -0
  190. diffract_core-0.2.1.dist-info/RECORD +193 -0
  191. diffract_core-0.2.1.dist-info/WHEEL +4 -0
  192. diffract_core-0.2.1.dist-info/licenses/LICENSE +201 -0
  193. diffract_core-0.2.1.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from typing import Any, Literal
5
+
6
+ import numpy as np
7
+ import plotly.graph_objects as go
8
+
9
+ from diffract.viz.data import (
10
+ DataType,
11
+ Entry,
12
+ FieldRef,
13
+ )
14
+ from diffract.viz.plots.base.axis import AxisType, SupportsAxis
15
+ from diffract.viz.plots.base.jitter import SupportsJitter
16
+ from diffract.viz.plots.base.marker import SupportsMarker
17
+ from diffract.viz.plots.base.plot import Plot
18
+ from diffract.viz.styling import (
19
+ CategoricalPropertyResolver,
20
+ NumericPropertyResolver,
21
+ ResolvedColor,
22
+ )
23
+
24
+
25
+ @dataclass(kw_only=True)
26
+ class BoxPlot(
27
+ Plot,
28
+ SupportsJitter,
29
+ SupportsAxis("x", AxisType.CATEGORICAL),
30
+ SupportsAxis("y", AxisType.NUMERIC),
31
+ SupportsMarker,
32
+ ):
33
+ """Box plot for a scalar or vector numeric field."""
34
+
35
+ y: FieldRef
36
+ y_rescale_range: tuple[float, float] | None = None
37
+ y_rescale_traces_separately: bool = False
38
+
39
+ x: FieldRef
40
+
41
+ box_width: float = 0.5
42
+ boxpoints: Literal["all", "outliers", False] = "outliers"
43
+
44
+ def _build_traces_data(
45
+ self, entries: dict[str, Entry]
46
+ ) -> dict[str, dict[str, Any]]:
47
+ """Build trace data dict for each group (category on x-axis)."""
48
+ x_resolver = CategoricalPropertyResolver()
49
+ y_resolver = NumericPropertyResolver(self.y_rescale_range)
50
+
51
+ # The x axis is categorical by design; without an explicit
52
+ # data_type digit-like string fields (layer_id, head_id) would be
53
+ # detected as numeric and rejected.
54
+ x_ref = replace(self.x, data_type=self.x.data_type or DataType.CATEGORICAL)
55
+ x_values = x_resolver.resolve(x_ref, entries)
56
+ y_values = y_resolver.resolve(self.y, entries)
57
+
58
+ sorted_categories = self._get_sorted_categories(x_values)
59
+ category_order = self._get_effective_category_order(sorted_categories)
60
+ groups = self._group_by_category(x_values, y_values, category_order)
61
+
62
+ # 1) Resolve globally for all entries
63
+ resolved_size = self._resolve_marker_size(entries, self._theme)
64
+ resolved_opacity = self._resolve_marker_opacity(entries, self._theme)
65
+ resolved_symbol = self._resolve_marker_symbol(entries, self._theme)
66
+ resolved_color = self._resolve_marker_color(entries, self._theme)
67
+ resolved_jitter_color = self._resolve_jitter_color(entries, self._theme)
68
+
69
+ # 2) Build per-category traces
70
+ traces_data: dict[str, dict[str, Any]] = {}
71
+
72
+ for idx, category in enumerate(category_order):
73
+ trace_id = f"box_{category}"
74
+
75
+ trace_y = groups[category]
76
+ if self.y_rescale_traces_separately:
77
+ trace_y = y_resolver._normalize(trace_y)
78
+
79
+ trace_data: dict[str, Any] = {
80
+ "category": category,
81
+ "category_idx": idx,
82
+ "jitter_x_center": float(idx),
83
+ "jitter_xaxis": "x2",
84
+ "y_values": trace_y,
85
+ }
86
+
87
+ # 3) Slice resolved values for this category and add to trace
88
+ self._add_marker_size_to_trace(
89
+ trace_data,
90
+ self._pick_category_values(x_values, y_values, category, resolved_size),
91
+ )
92
+ self._add_marker_opacity_to_trace(
93
+ trace_data,
94
+ self._pick_category_values(
95
+ x_values, y_values, category, resolved_opacity
96
+ ),
97
+ )
98
+ self._add_marker_symbol_to_trace(
99
+ trace_data,
100
+ self._pick_category_scalar(x_values, category, resolved_symbol),
101
+ )
102
+ self._add_marker_color_to_trace(
103
+ trace_data,
104
+ self._pick_category_color(x_values, y_values, category, resolved_color),
105
+ )
106
+
107
+ if resolved_jitter_color is not None:
108
+ self._add_jitter_color_to_trace(
109
+ trace_data,
110
+ self._pick_category_color(
111
+ x_values, y_values, category, resolved_jitter_color
112
+ ),
113
+ )
114
+
115
+ traces_data[trace_id] = trace_data
116
+
117
+ return traces_data
118
+
119
+ def _get_sorted_categories(self, x_values: list[Any]) -> list[str]:
120
+ """Get unique categories sorted according to x ordering."""
121
+ unique_categories = list(dict.fromkeys(x_values))
122
+ order_indices = self.x.ordering.argsort(unique_categories)
123
+ return [str(unique_categories[i]) for i in order_indices]
124
+
125
+ def _get_effective_category_order(self, sorted_categories: list[str]) -> list[str]:
126
+ """Resolve category order used for both traces and jitter centers."""
127
+ if self.x_categoryorder != "array" or self.x_categoryarray is None:
128
+ return sorted_categories
129
+
130
+ preferred = [str(category) for category in self.x_categoryarray]
131
+ preferred_existing = [
132
+ category for category in preferred if category in sorted_categories
133
+ ]
134
+ remaining = [
135
+ category
136
+ for category in sorted_categories
137
+ if category not in preferred_existing
138
+ ]
139
+ return preferred_existing + remaining
140
+
141
+ def _group_by_category(
142
+ self, x_values: list[Any], y_values: list[Any], categories: list[str]
143
+ ) -> dict[str, list[float]]:
144
+ """Group y-values by their x category.
145
+
146
+ Scalar y-values are appended as single floats.
147
+ Array y-values (``np.ndarray``) are exploded into individual floats,
148
+ so that each element of the vector becomes a separate observation
149
+ in the box (useful for per-parameter spectra like ESD).
150
+ """
151
+ groups: dict[str, list[float]] = {cat: [] for cat in categories}
152
+
153
+ for x_val, y_val in zip(x_values, y_values, strict=False):
154
+ if y_val is None:
155
+ continue
156
+ cat = str(x_val)
157
+ if isinstance(y_val, np.ndarray):
158
+ groups[cat].extend(y_val.ravel().tolist())
159
+ else:
160
+ groups[cat].append(float(y_val))
161
+
162
+ return groups
163
+
164
+ def _build_figure(self) -> go.Figure:
165
+ """Build the plotly figure with box traces."""
166
+ fig = go.Figure()
167
+
168
+ if self._traces_data is None:
169
+ return fig
170
+
171
+ for trace_id, trace_data in self._traces_data.items():
172
+ category = trace_data["category"]
173
+ y_values = trace_data["y_values"]
174
+
175
+ if not y_values:
176
+ continue
177
+
178
+ fig.add_trace(
179
+ go.Box(
180
+ name=category,
181
+ x=[category] * len(y_values),
182
+ y=y_values,
183
+ boxpoints=self.boxpoints,
184
+ width=self.box_width,
185
+ meta={"trace_id": trace_id},
186
+ )
187
+ )
188
+
189
+ # Set title
190
+ if self.title:
191
+ fig.update_layout(title=self.title)
192
+ else:
193
+ fig.update_layout(title=f"{self.y.field} by {self.x.field}")
194
+
195
+ categoryorder = self.x_categoryorder or "array"
196
+
197
+ if categoryorder == "array":
198
+ categoryarray = self.x_categoryarray or [
199
+ trace_data["category"]
200
+ for trace_data in sorted(
201
+ self._traces_data.values(),
202
+ key=lambda data: data["category_idx"],
203
+ )
204
+ ]
205
+ else:
206
+ categoryarray = None
207
+
208
+ half_span = max(0.5, abs(self.jitter_offset) + self.jitter_width + 0.05)
209
+ fig.update_layout(
210
+ xaxis=dict(
211
+ categoryorder=categoryorder,
212
+ categoryarray=categoryarray,
213
+ range=[-half_span, len(self._traces_data) - 1 + 0.5 * half_span],
214
+ )
215
+ )
216
+
217
+ return fig
218
+
219
+ @staticmethod
220
+ def _pick_category_values(
221
+ x_values: list[Any],
222
+ y_values: list[Any],
223
+ category: str,
224
+ resolved: float | list[float] | None,
225
+ ) -> float | list[float] | None:
226
+ """Slice a resolved numeric value for a specific category.
227
+
228
+ If resolved is scalar or None — return as-is.
229
+ If resolved is a per-entry list — filter to entries matching this category.
230
+ """
231
+ if resolved is None or not isinstance(resolved, list):
232
+ return resolved
233
+ return [
234
+ val
235
+ for x_val, y_val, val in zip(x_values, y_values, resolved, strict=False)
236
+ if str(x_val) == category and y_val is not None
237
+ ]
238
+
239
+ @staticmethod
240
+ def _pick_category_scalar(
241
+ x_values: list[Any],
242
+ category: str,
243
+ resolved: str | list[str] | None,
244
+ ) -> str | None:
245
+ """Pick a single categorical value for a category (first match).
246
+
247
+ If resolved is scalar or None — return as-is.
248
+ If resolved is a per-entry list — return the first matching value.
249
+ """
250
+ if resolved is None or not isinstance(resolved, list):
251
+ return resolved
252
+ for x_val, val in zip(x_values, resolved, strict=False):
253
+ if str(x_val) == category:
254
+ return val
255
+ return None
256
+
257
+ @staticmethod
258
+ def _pick_category_color(
259
+ x_values: list[Any],
260
+ y_values: list[Any],
261
+ category: str,
262
+ resolved_color: ResolvedColor,
263
+ ) -> ResolvedColor:
264
+ """Build a per-category ResolvedColor from a global ResolvedColor."""
265
+ if resolved_color.values is not None:
266
+ filtered = [
267
+ c_val
268
+ for x_val, y_val, c_val in zip(
269
+ x_values, y_values, resolved_color.values, strict=False
270
+ )
271
+ if str(x_val) == category and y_val is not None
272
+ ]
273
+ return ResolvedColor(values=filtered)
274
+
275
+ if resolved_color.color is not None:
276
+ if isinstance(resolved_color.color, list):
277
+ for x_val, color in zip(x_values, resolved_color.color, strict=False):
278
+ if str(x_val) == category:
279
+ return ResolvedColor(color=color)
280
+ return ResolvedColor()
281
+ return resolved_color
282
+
283
+ return resolved_color
@@ -0,0 +1,374 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import suppress
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ import numpy as np
8
+ import plotly.graph_objects as go
9
+
10
+ from diffract.core.data.nn.params.schema import ParameterType
11
+ from diffract.viz.data import Entry, EntryContext
12
+ from diffract.viz.data.extraction import get_field_value
13
+ from diffract.viz.plots.base.plot import Plot
14
+ from diffract.viz.styling import (
15
+ AxesStyle,
16
+ DefaultColorPalette,
17
+ DefaultDashPalette,
18
+ DefaultSymbolPalette,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from diffract.session import Session
23
+ from diffract.viz.styling import Theme
24
+
25
+
26
+ @dataclass(kw_only=True)
27
+ class ClusterBarChart(Plot):
28
+ """Clustered line chart for array-like fields (e.g. singular values).
29
+
30
+ For each parameter, the array field is binned into ``num_bins`` histogram
31
+ counts over the global value range (linear or exponential edges). Parameters
32
+ are grouped by the ``group_by`` metadata keys, aggregated (mean, and std when
33
+ ``draw_statistics``) across the parameters in each group, and drawn as one
34
+ lines+markers trace per group.
35
+
36
+ Use ``color_by`` / ``dash_by`` / ``marker_by`` to style traces by a metadata
37
+ key. When ``draw_statistics`` is set, per-group std is drawn below zero.
38
+ """
39
+
40
+ field: str
41
+
42
+ group_by: list[str] = field(default_factory=lambda: ["model_id"])
43
+ aggregate_by: str | None = None
44
+
45
+ parameter_uids: list[str] | None = None
46
+ parameter_names: list[str] | None = None
47
+ parameter_types: list[str] | None = None
48
+ model_ids: list[str] | None = None
49
+
50
+ num_bins: int = 20
51
+ binning: Literal["linear", "exponential"] = "exponential"
52
+ left_bound: float | None = None
53
+ right_bound: float | None = None
54
+
55
+ draw_statistics: bool = False
56
+
57
+ mode: Literal["lines", "markers", "lines+markers"] = "lines+markers"
58
+
59
+ color_by: str | None = None
60
+ dash_by: str | None = None
61
+ marker_by: str | None = None
62
+
63
+ legend_format: str | None = None
64
+ legend_keys: list[str] | None = None
65
+
66
+ def render(self, session: Session, theme: Theme | None = None) -> go.Figure:
67
+ """Render the chart, applying plot-level parameter/model filtering."""
68
+ if self._has_filter():
69
+ ptypes = (
70
+ [ParameterType.from_string(p) for p in self.parameter_types]
71
+ if self.parameter_types
72
+ else None
73
+ )
74
+ session = session.filter(
75
+ param_ids=self.parameter_uids,
76
+ param_names=self.parameter_names,
77
+ param_types=ptypes,
78
+ model_ids=self.model_ids,
79
+ )
80
+ return super().render(session, theme)
81
+
82
+ def _has_filter(self) -> bool:
83
+ return any(
84
+ value is not None
85
+ for value in (
86
+ self.parameter_uids,
87
+ self.parameter_names,
88
+ self.parameter_types,
89
+ self.model_ids,
90
+ )
91
+ )
92
+
93
+ def _collect_fields_to_fetch(self) -> list[str]:
94
+ return [self.field]
95
+
96
+ def _group_keys(self) -> list[str]:
97
+ group_by = list(self.group_by)
98
+ if self.aggregate_by is not None and self.aggregate_by not in group_by:
99
+ group_by.append(self.aggregate_by)
100
+ return group_by
101
+
102
+ def _build_traces_data(
103
+ self, entries: dict[str, Entry]
104
+ ) -> dict[str, dict[str, Any]]:
105
+ """Bin arrays per parameter, group them, and aggregate per group."""
106
+ group_by = self._group_keys()
107
+
108
+ keyed_arrays, all_values = self._collect_arrays(entries, group_by)
109
+ if not keyed_arrays:
110
+ return {}
111
+
112
+ edges = self._make_edges(all_values)
113
+
114
+ grouped: dict[tuple[tuple[str, str], ...], list[np.ndarray]] = {}
115
+ for key, arr in keyed_arrays:
116
+ counts = self._binned_counts(arr, edges)
117
+ if self.aggregate_by is not None:
118
+ group_key = tuple(kv for kv in key if kv[0] != self.aggregate_by)
119
+ else:
120
+ group_key = key
121
+ grouped.setdefault(group_key, []).append(counts)
122
+
123
+ groups_sorted = sorted(grouped.items(), key=lambda kv: str(kv[0]))
124
+
125
+ color_map = self._build_style_map(groups_sorted, self.color_by)
126
+ dash_map = self._build_style_map(groups_sorted, self.dash_by)
127
+ marker_map = self._build_style_map(groups_sorted, self.marker_by)
128
+
129
+ color_palette = DefaultColorPalette()
130
+ dash_palette = DefaultDashPalette()
131
+ symbol_palette = DefaultSymbolPalette()
132
+
133
+ # Numeric bin centers keep the x axis quantitative, so linear or
134
+ # log axis types position the bins honestly.
135
+ if self.binning == "exponential":
136
+ x = np.sqrt(edges[:-1] * edges[1:])
137
+ else:
138
+ x = (edges[:-1] + edges[1:]) / 2.0
139
+
140
+ traces_data: dict[str, dict[str, Any]] = {}
141
+ for idx, (group_key, counts_list) in enumerate(groups_sorted):
142
+ matrix = np.stack(counts_list, axis=0)
143
+ trace_data: dict[str, Any] = {
144
+ "x": x,
145
+ "y_mean": np.nanmean(matrix, axis=0),
146
+ "name": self._legend_name(group_key),
147
+ }
148
+
149
+ if self.draw_statistics and matrix.shape[0] > 1:
150
+ trace_data["y_std"] = np.nanstd(matrix, axis=0)
151
+
152
+ color = self._pick_style(group_key, self.color_by, color_map, color_palette)
153
+ dash = self._pick_style(group_key, self.dash_by, dash_map, dash_palette)
154
+ symbol = self._pick_style(
155
+ group_key, self.marker_by, marker_map, symbol_palette
156
+ )
157
+ if color is not None:
158
+ trace_data["line_color"] = color
159
+ if dash is not None:
160
+ trace_data["line_dash"] = dash
161
+ if symbol is not None:
162
+ trace_data["marker_symbol"] = symbol
163
+
164
+ traces_data[f"cluster_{idx}"] = trace_data
165
+
166
+ traces_data["__edges__"] = {"edges": edges}
167
+ return traces_data
168
+
169
+ def _collect_arrays(
170
+ self,
171
+ entries: dict[str, Entry],
172
+ group_by: list[str],
173
+ ) -> tuple[list[tuple[tuple[tuple[str, str], ...], np.ndarray]], list[float]]:
174
+ keyed_arrays: list[tuple[tuple[tuple[str, str], ...], np.ndarray]] = []
175
+ all_values: list[float] = []
176
+
177
+ for entry in entries.values():
178
+ ctx = EntryContext.from_entry(entry)
179
+ arr = _as_1d_float_array(self._get_field(ctx))
180
+ if arr.size == 0:
181
+ continue
182
+
183
+ key = self._group_key_for(ctx, group_by)
184
+ if key is None:
185
+ continue
186
+
187
+ keyed_arrays.append((key, arr))
188
+ all_values.extend(arr.tolist())
189
+
190
+ return keyed_arrays, all_values
191
+
192
+ def _get_field(self, ctx: EntryContext) -> Any:
193
+ try:
194
+ return get_field_value(ctx, self.field)
195
+ except ValueError:
196
+ return None
197
+
198
+ @staticmethod
199
+ def _group_key_for(
200
+ ctx: EntryContext,
201
+ group_by: list[str],
202
+ ) -> tuple[tuple[str, str], ...] | None:
203
+ kvs: list[tuple[str, str]] = []
204
+ for key in group_by:
205
+ value = ctx.fields.get(key)
206
+ if value is None:
207
+ return None
208
+ kvs.append((key, str(value)))
209
+ return tuple(kvs)
210
+
211
+ def _make_edges(self, all_values: list[float]) -> np.ndarray:
212
+ values = np.asarray(all_values, dtype=np.float64)
213
+ left = (
214
+ float(np.nanmin(values))
215
+ if self.left_bound is None
216
+ else float(self.left_bound)
217
+ )
218
+ right = (
219
+ float(np.nanmax(values))
220
+ if self.right_bound is None
221
+ else float(self.right_bound)
222
+ )
223
+ if self.num_bins <= 0:
224
+ raise ValueError("num_bins must be > 0")
225
+ if right <= left:
226
+ right = left + 1.0
227
+
228
+ if self.binning == "linear":
229
+ return np.linspace(left, right, self.num_bins + 1, dtype=np.float64)
230
+
231
+ eps = 1e-12
232
+ left = max(left, eps)
233
+ right = max(right, left + eps)
234
+ return np.geomspace(left, right, self.num_bins + 1, dtype=np.float64)
235
+
236
+ @staticmethod
237
+ def _binned_counts(values: np.ndarray, edges: np.ndarray) -> np.ndarray:
238
+ counts, _ = np.histogram(values, bins=edges)
239
+ return counts.astype(np.float64)
240
+
241
+ @staticmethod
242
+ def _build_style_map(
243
+ groups_sorted: list[tuple[tuple[tuple[str, str], ...], Any]],
244
+ style_key: str | None,
245
+ ) -> list[str]:
246
+ if style_key is None:
247
+ return []
248
+ values: list[str] = []
249
+ for group_key, _ in groups_sorted:
250
+ for k, v in group_key:
251
+ if k == style_key and v not in values:
252
+ values.append(v)
253
+ return values
254
+
255
+ @staticmethod
256
+ def _pick_style(
257
+ group_key: tuple[tuple[str, str], ...],
258
+ style_key: str | None,
259
+ all_values: list[str],
260
+ palette: Any,
261
+ ) -> str | None:
262
+ if style_key is None:
263
+ return None
264
+ for k, v in group_key:
265
+ if k == style_key:
266
+ if isinstance(palette, DefaultColorPalette):
267
+ return palette.get_color(v, all_values)
268
+ candidates = (
269
+ palette.dashes
270
+ if isinstance(palette, DefaultDashPalette)
271
+ else palette.symbols
272
+ )
273
+ idx = all_values.index(v) if v in all_values else 0
274
+ return candidates[idx % len(candidates)]
275
+ return None
276
+
277
+ def _legend_name(self, group_key: tuple[tuple[str, str], ...]) -> str:
278
+ key_dict = dict(group_key)
279
+ if self.legend_format:
280
+ return self.legend_format.format(**key_dict)
281
+ if self.legend_keys:
282
+ return ", ".join(
283
+ f"{k}={key_dict[k]}" for k in self.legend_keys if k in key_dict
284
+ )
285
+ return ", ".join(f"{k}={v}" for k, v in group_key)
286
+
287
+ def _build_figure(self) -> go.Figure:
288
+ """Build the Plotly figure with one lines+markers trace per group."""
289
+ fig = go.Figure()
290
+
291
+ if not self._traces_data:
292
+ fig.update_layout(title=self.title or self.field)
293
+ return fig
294
+
295
+ for trace_id, trace_data in self._traces_data.items():
296
+ if trace_id == "__edges__":
297
+ continue
298
+
299
+ line = {}
300
+ if trace_data.get("line_color") is not None:
301
+ line["color"] = trace_data["line_color"]
302
+ if trace_data.get("line_dash") is not None:
303
+ line["dash"] = trace_data["line_dash"]
304
+
305
+ marker = {}
306
+ if trace_data.get("marker_symbol") is not None:
307
+ marker["symbol"] = trace_data["marker_symbol"]
308
+
309
+ fig.add_trace(
310
+ go.Scatter(
311
+ x=trace_data["x"],
312
+ y=trace_data["y_mean"],
313
+ mode=self.mode,
314
+ name=trace_data["name"],
315
+ line=line or None,
316
+ marker=marker or None,
317
+ legendgroup=trace_data["name"],
318
+ meta={"trace_id": trace_id},
319
+ )
320
+ )
321
+
322
+ if "y_std" in trace_data:
323
+ fig.add_trace(
324
+ go.Scatter(
325
+ x=trace_data["x"],
326
+ y=-trace_data["y_std"],
327
+ mode=self.mode,
328
+ name=f"{trace_data['name']} (std)",
329
+ showlegend=False,
330
+ line={"color": trace_data.get("line_color"), "dash": "dash"},
331
+ legendgroup=trace_data["name"],
332
+ meta={"trace_id": f"{trace_id}_std"},
333
+ )
334
+ )
335
+
336
+ if self.draw_statistics:
337
+ fig.add_hline(y=0, line_width=1, line={"color": "lightgrey"})
338
+
339
+ axes_style = self._theme.axes if self._theme else AxesStyle()
340
+ axes_kwargs = {
341
+ "showline": axes_style.show_line,
342
+ "mirror": axes_style.mirror,
343
+ "linecolor": axes_style.line_color,
344
+ "showgrid": axes_style.show_grid,
345
+ "gridcolor": axes_style.grid_color,
346
+ }
347
+ fig.update_xaxes(title=self.field, **axes_kwargs)
348
+ fig.update_yaxes(**axes_kwargs)
349
+ fig.update_layout(title=self.title or f"Cluster bar chart ({self.field})")
350
+ return fig
351
+
352
+
353
+ def _as_1d_float_array(value: Any) -> np.ndarray:
354
+ if value is None:
355
+ return np.asarray([], dtype=np.float64)
356
+ if isinstance(value, np.ndarray):
357
+ if value.size == 0:
358
+ return np.asarray([], dtype=np.float64)
359
+ return value.astype(np.float64, copy=False).ravel()
360
+ if isinstance(value, (list, tuple)):
361
+ if not value:
362
+ return np.asarray([], dtype=np.float64)
363
+ try:
364
+ return np.asarray(value, dtype=np.float64).ravel()
365
+ except (TypeError, ValueError):
366
+ out: list[float] = []
367
+ for item in value:
368
+ with suppress(TypeError, ValueError):
369
+ out.append(float(item))
370
+ return np.asarray(out, dtype=np.float64)
371
+ try:
372
+ return np.asarray([float(value)], dtype=np.float64)
373
+ except (TypeError, ValueError):
374
+ return np.asarray([], dtype=np.float64)