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.
- diffract/__init__.py +52 -0
- diffract/configs/fast_speed_without_disk.ini +48 -0
- diffract/configs/hybrid.ini +74 -0
- diffract/configs/sqlite.ini +62 -0
- diffract/containers.py +405 -0
- diffract/core/__init__.py +23 -0
- diffract/core/cache/__init__.py +24 -0
- diffract/core/cache/containers.py +93 -0
- diffract/core/cache/interface.py +117 -0
- diffract/core/cache/redis_manager.py +464 -0
- diffract/core/cache/simple_manager.py +478 -0
- diffract/core/compute/__init__.py +46 -0
- diffract/core/compute/config.py +48 -0
- diffract/core/compute/containers.py +81 -0
- diffract/core/compute/decorator.py +125 -0
- diffract/core/compute/exceptions.py +31 -0
- diffract/core/compute/execution/__init__.py +14 -0
- diffract/core/compute/execution/_types.py +70 -0
- diffract/core/compute/execution/aggregation.py +74 -0
- diffract/core/compute/execution/aggregation_runner.py +362 -0
- diffract/core/compute/execution/enums.py +38 -0
- diffract/core/compute/execution/executor.py +125 -0
- diffract/core/compute/execution/parameter_runner.py +125 -0
- diffract/core/compute/execution/restrictions.py +61 -0
- diffract/core/compute/execution/strategy.py +118 -0
- diffract/core/compute/execution/utils.py +112 -0
- diffract/core/compute/extensions/__init__.py +0 -0
- diffract/core/compute/extensions/power_law.py +1051 -0
- diffract/core/compute/extensions/rmt.py +150 -0
- diffract/core/compute/extensions/utils.py +36 -0
- diffract/core/compute/field_signature.py +183 -0
- diffract/core/compute/kernels/__init__.py +48 -0
- diffract/core/compute/kernels/alignment.py +106 -0
- diffract/core/compute/kernels/heavy_tailed.py +394 -0
- diffract/core/compute/kernels/marchenko_pastur.py +99 -0
- diffract/core/compute/kernels/mat_decomposition.py +101 -0
- diffract/core/compute/kernels/mat_properties.py +53 -0
- diffract/core/compute/kernels/model_quality.py +27 -0
- diffract/core/compute/kernels/norms.py +88 -0
- diffract/core/compute/kernels/ranks.py +35 -0
- diffract/core/compute/kernels/tracy_widom.py +36 -0
- diffract/core/compute/metadata.py +67 -0
- diffract/core/compute/registry.py +485 -0
- diffract/core/constants.py +147 -0
- diffract/core/data/__init__.py +32 -0
- diffract/core/data/interface.py +304 -0
- diffract/core/data/metadata/__init__.py +15 -0
- diffract/core/data/metadata/containers.py +60 -0
- diffract/core/data/metadata/interface.py +208 -0
- diffract/core/data/metadata/sqlite_index.py +604 -0
- diffract/core/data/nn/__init__.py +43 -0
- diffract/core/data/nn/aggregates/__init__.py +35 -0
- diffract/core/data/nn/aggregates/metadata.py +96 -0
- diffract/core/data/nn/aggregates/proxy.py +57 -0
- diffract/core/data/nn/aggregates/repository.py +87 -0
- diffract/core/data/nn/aggregates/view.py +307 -0
- diffract/core/data/nn/containers.py +86 -0
- diffract/core/data/nn/extractors/__init__.py +49 -0
- diffract/core/data/nn/extractors/base.py +300 -0
- diffract/core/data/nn/extractors/factory.py +234 -0
- diffract/core/data/nn/extractors/flax.py +112 -0
- diffract/core/data/nn/extractors/handlers/__init__.py +39 -0
- diffract/core/data/nn/extractors/handlers/base.py +110 -0
- diffract/core/data/nn/extractors/handlers/flax_handlers.py +71 -0
- diffract/core/data/nn/extractors/handlers/numpy_handlers.py +63 -0
- diffract/core/data/nn/extractors/handlers/onnx_handlers.py +67 -0
- diffract/core/data/nn/extractors/handlers/tensorflow_handlers.py +82 -0
- diffract/core/data/nn/extractors/handlers/torch_handlers.py +124 -0
- diffract/core/data/nn/extractors/interface.py +56 -0
- diffract/core/data/nn/extractors/numpy.py +94 -0
- diffract/core/data/nn/extractors/onnx.py +108 -0
- diffract/core/data/nn/extractors/tensorflow.py +99 -0
- diffract/core/data/nn/extractors/torch.py +204 -0
- diffract/core/data/nn/params/__init__.py +27 -0
- diffract/core/data/nn/params/interface.py +402 -0
- diffract/core/data/nn/params/metadata.py +110 -0
- diffract/core/data/nn/params/proxy.py +93 -0
- diffract/core/data/nn/params/repository.py +45 -0
- diffract/core/data/nn/params/schema.py +82 -0
- diffract/core/data/nn/params/view.py +393 -0
- diffract/core/data/proxy.py +246 -0
- diffract/core/data/repository.py +227 -0
- diffract/core/data/utils.py +33 -0
- diffract/core/data/view.py +389 -0
- diffract/core/export/__init__.py +27 -0
- diffract/core/export/containers.py +47 -0
- diffract/core/export/exporters.py +191 -0
- diffract/core/export/formatters/__init__.py +23 -0
- diffract/core/export/formatters/base.py +68 -0
- diffract/core/export/formatters/dict_formatter.py +38 -0
- diffract/core/export/formatters/json_formatter.py +58 -0
- diffract/core/export/formatters/list_formatter.py +41 -0
- diffract/core/export/formatters/pandas_formatter.py +86 -0
- diffract/core/export/formatters/polars_formatter.py +86 -0
- diffract/core/export/formatters/registry.py +84 -0
- diffract/core/export/interface.py +105 -0
- diffract/core/parallel/__init__.py +21 -0
- diffract/core/parallel/containers.py +57 -0
- diffract/core/parallel/execution.py +91 -0
- diffract/core/parallel/runtime.py +91 -0
- diffract/core/storage/__init__.py +35 -0
- diffract/core/storage/base_manager.py +330 -0
- diffract/core/storage/containers.py +122 -0
- diffract/core/storage/hdf5_manager.py +856 -0
- diffract/core/storage/hybrid_manager.py +218 -0
- diffract/core/storage/interface.py +222 -0
- diffract/core/storage/metadata.py +89 -0
- diffract/core/storage/ram_manager.py +159 -0
- diffract/core/storage/sqlite_manager.py +1062 -0
- diffract/core/storage/zarr_manager.py +992 -0
- diffract/core/utils/__init__.py +45 -0
- diffract/core/utils/build.py +46 -0
- diffract/core/utils/exceptions.py +11 -0
- diffract/core/utils/hashing.py +90 -0
- diffract/core/utils/imports.py +292 -0
- diffract/core/utils/math.py +15 -0
- diffract/py.typed +0 -0
- diffract/session/__init__.py +24 -0
- diffract/session/errors.py +17 -0
- diffract/session/field_cache.py +216 -0
- diffract/session/namespaces/__init__.py +15 -0
- diffract/session/namespaces/compute/__init__.py +219 -0
- diffract/session/namespaces/models/__init__.py +282 -0
- diffract/session/namespaces/models/parameters/__init__.py +133 -0
- diffract/session/namespaces/models/parameters/meta_patcher.py +167 -0
- diffract/session/namespaces/results/__init__.py +378 -0
- diffract/session/namespaces/results/eraser.py +129 -0
- diffract/session/namespaces/results/injester.py +249 -0
- diffract/session/namespaces/utils/__init__.py +141 -0
- diffract/session/namespaces/utils/merger.py +295 -0
- diffract/session/namespaces/viz/__init__.py +91 -0
- diffract/session/namespaces/viz/_utils.py +14 -0
- diffract/session/namespaces/viz/box.py +207 -0
- diffract/session/namespaces/viz/grid.py +160 -0
- diffract/session/namespaces/viz/heatmap.py +164 -0
- diffract/session/namespaces/viz/scatter.py +202 -0
- diffract/session/namespaces/viz/sparkline.py +270 -0
- diffract/session/namespaces/viz/violin.py +212 -0
- diffract/session/session.py +260 -0
- diffract/session/utils.py +81 -0
- diffract/viz/__init__.py +42 -0
- diffract/viz/data/__init__.py +34 -0
- diffract/viz/data/detection.py +57 -0
- diffract/viz/data/extraction.py +117 -0
- diffract/viz/data/filtering.py +57 -0
- diffract/viz/data/ordering.py +129 -0
- diffract/viz/data/provider.py +99 -0
- diffract/viz/data/types.py +98 -0
- diffract/viz/plots/__init__.py +37 -0
- diffract/viz/plots/base/__init__.py +20 -0
- diffract/viz/plots/base/axis.py +219 -0
- diffract/viz/plots/base/coloraxis.py +133 -0
- diffract/viz/plots/base/configurator.py +18 -0
- diffract/viz/plots/base/jitter.py +368 -0
- diffract/viz/plots/base/line.py +197 -0
- diffract/viz/plots/base/marker.py +278 -0
- diffract/viz/plots/base/overlay.py +14 -0
- diffract/viz/plots/base/plot.py +110 -0
- diffract/viz/plots/base/update.py +50 -0
- diffract/viz/plots/boxplot.py +283 -0
- diffract/viz/plots/cluster.py +374 -0
- diffract/viz/plots/heatmap.py +168 -0
- diffract/viz/plots/scatter.py +233 -0
- diffract/viz/plots/sparkline.py +405 -0
- diffract/viz/plots/subplots/__init__.py +32 -0
- diffract/viz/plots/subplots/coloraxis.py +339 -0
- diffract/viz/plots/subplots/factory.py +713 -0
- diffract/viz/plots/subplots/grid.py +214 -0
- diffract/viz/plots/subplots/layout.py +370 -0
- diffract/viz/plots/subplots/spec.py +39 -0
- diffract/viz/plots/violin.py +298 -0
- diffract/viz/renderer.py +513 -0
- diffract/viz/styling/__init__.py +78 -0
- diffract/viz/styling/palettes/__init__.py +20 -0
- diffract/viz/styling/palettes/bundle.py +16 -0
- diffract/viz/styling/palettes/color.py +83 -0
- diffract/viz/styling/palettes/dashes.py +27 -0
- diffract/viz/styling/palettes/symbols.py +42 -0
- diffract/viz/styling/resolvers/__init__.py +11 -0
- diffract/viz/styling/resolvers/categorical.py +68 -0
- diffract/viz/styling/resolvers/color.py +77 -0
- diffract/viz/styling/resolvers/numeric.py +108 -0
- diffract/viz/styling/sources.py +27 -0
- diffract/viz/styling/theme/__init__.py +29 -0
- diffract/viz/styling/theme/applier.py +114 -0
- diffract/viz/styling/theme/components.py +66 -0
- diffract/viz/styling/theme/presets.py +58 -0
- diffract/viz/styling/theme/theme.py +36 -0
- diffract_core-0.2.1.dist-info/METADATA +530 -0
- diffract_core-0.2.1.dist-info/RECORD +193 -0
- diffract_core-0.2.1.dist-info/WHEEL +4 -0
- diffract_core-0.2.1.dist-info/licenses/LICENSE +201 -0
- 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)
|