PyMieSimX 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PyMieSimX/__init__.py +5 -0
- PyMieSimX/__main__.py +35 -0
- PyMieSimX/gui/__init__.py +5 -0
- PyMieSimX/gui/assets/card-state.js +41 -0
- PyMieSimX/gui/assets/dashboard.css +3352 -0
- PyMieSimX/gui/assets/default-settings.json +32 -0
- PyMieSimX/gui/assets/logo.png +0 -0
- PyMieSimX/gui/assets/pymiesim-favicon.svg +6 -0
- PyMieSimX/gui/assets/pymiesim-logo-dark.svg +15 -0
- PyMieSimX/gui/assets/pymiesim-logo.svg +39 -0
- PyMieSimX/gui/components/__init__.py +5 -0
- PyMieSimX/gui/components/cards.py +64 -0
- PyMieSimX/gui/defaults.py +18 -0
- PyMieSimX/gui/interface.py +688 -0
- PyMieSimX/gui/layout.py +329 -0
- PyMieSimX/gui/pages/__init__.py +9 -0
- PyMieSimX/gui/pages/citation.py +66 -0
- PyMieSimX/gui/pages/documentation.py +132 -0
- PyMieSimX/gui/pages/experiment/__init__.py +5 -0
- PyMieSimX/gui/pages/experiment/page.py +49 -0
- PyMieSimX/gui/pages/experiment/sections/__init__.py +5 -0
- PyMieSimX/gui/pages/experiment/sections/configuration.py +42 -0
- PyMieSimX/gui/pages/home.py +152 -0
- PyMieSimX/gui/pages/install_local.py +110 -0
- PyMieSimX/gui/pages/settings.py +87 -0
- PyMieSimX/gui/pages/single/__init__.py +5 -0
- PyMieSimX/gui/pages/single/page.py +28 -0
- PyMieSimX/gui/pages/single/sections/__init__.py +6 -0
- PyMieSimX/gui/pages/single/sections/representations.py +38 -0
- PyMieSimX/gui/pages/single/sections/setup.py +27 -0
- PyMieSimX/gui/parsing.py +149 -0
- PyMieSimX/gui/schemas.py +118 -0
- PyMieSimX/gui/services.py +735 -0
- pymiesimx-0.1.0.dist-info/METADATA +90 -0
- pymiesimx-0.1.0.dist-info/RECORD +39 -0
- pymiesimx-0.1.0.dist-info/WHEEL +5 -0
- pymiesimx-0.1.0.dist-info/entry_points.txt +2 -0
- pymiesimx-0.1.0.dist-info/licenses/LICENSE +21 -0
- pymiesimx-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""Pure backend services for the experiment dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import plotly.express as px
|
|
11
|
+
import plotly.graph_objects as go
|
|
12
|
+
|
|
13
|
+
from PyMieSim.experiment import Setup
|
|
14
|
+
from PyMieSim.experiment.detector_set import CoherentModeSet, PhotodiodeSet
|
|
15
|
+
from PyMieSim.experiment.scatterer_set import CoreShellSet, InfiniteCylinderSet, SphereSet
|
|
16
|
+
from PyMieSim.experiment.source_set import GaussianSet, PlaneWaveSet
|
|
17
|
+
from PyMieSim.single import scatterer as single_scatterer
|
|
18
|
+
from PyMieSim.single import source as single_source
|
|
19
|
+
from PyMieSim.single import Setup as SingleSetup
|
|
20
|
+
from PyMieSim.units import ureg
|
|
21
|
+
|
|
22
|
+
from PyMieSimX.gui.parsing import (
|
|
23
|
+
parse_material_values,
|
|
24
|
+
parse_mode_numbers,
|
|
25
|
+
parse_numeric_expression,
|
|
26
|
+
parse_polarization,
|
|
27
|
+
parse_quantity_expression,
|
|
28
|
+
serialize_value,
|
|
29
|
+
)
|
|
30
|
+
from PyMieSimX.gui.defaults import DEFAULT_PLOT_SETTINGS
|
|
31
|
+
from PyMieSimX.gui.schemas import (
|
|
32
|
+
DETECTOR_FIELDS,
|
|
33
|
+
SCATTERER_FIELDS,
|
|
34
|
+
SINGLE_SCATTERER_FIELDS,
|
|
35
|
+
SINGLE_SOURCE_FIELDS,
|
|
36
|
+
SOURCE_FIELDS,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
_POSITIVE_FIELDS = frozenset({
|
|
43
|
+
"wavelength",
|
|
44
|
+
"optical_power",
|
|
45
|
+
"numerical_aperture",
|
|
46
|
+
"amplitude",
|
|
47
|
+
"diameter",
|
|
48
|
+
"core_diameter",
|
|
49
|
+
"shell_thickness",
|
|
50
|
+
"sampling",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SOURCE_TYPES = {
|
|
55
|
+
"GaussianSet": GaussianSet,
|
|
56
|
+
"PlaneWaveSet": PlaneWaveSet,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
SCATTERER_TYPES = {
|
|
60
|
+
"SphereSet": SphereSet,
|
|
61
|
+
"InfiniteCylinderSet": InfiniteCylinderSet,
|
|
62
|
+
"CoreShellSet": CoreShellSet,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
DETECTOR_TYPES = {
|
|
66
|
+
"None": None,
|
|
67
|
+
"PhotodiodeSet": PhotodiodeSet,
|
|
68
|
+
"CoherentModeSet": CoherentModeSet,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
SINGLE_SOURCE_TYPES = {"Gaussian": single_source.Gaussian, "PlaneWave": single_source.PlaneWave}
|
|
72
|
+
SINGLE_SCATTERER_TYPES = {
|
|
73
|
+
"Sphere": single_scatterer.Sphere,
|
|
74
|
+
"InfiniteCylinder": single_scatterer.InfiniteCylinder,
|
|
75
|
+
"CoreShell": single_scatterer.CoreShell,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def available_measures(scatterer_type: str, detector_type: str) -> list[str]:
|
|
80
|
+
"""Return measures valid for the selected experiment configuration."""
|
|
81
|
+
LOGGER.debug("Computing available measures for scatterer=%s detector=%s", scatterer_type, detector_type)
|
|
82
|
+
measures = list(SCATTERER_TYPES[scatterer_type].available_measure_list)
|
|
83
|
+
|
|
84
|
+
if detector_type == "None":
|
|
85
|
+
measures = [measure for measure in measures if measure != "coupling"]
|
|
86
|
+
|
|
87
|
+
return measures
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_source_set(source_type: str, raw_values: Dict[str, Any]) -> Any:
|
|
91
|
+
"""Build the selected source set from raw dashboard values."""
|
|
92
|
+
LOGGER.debug("Building source set %s with raw values: %s", source_type, raw_values)
|
|
93
|
+
return SOURCE_TYPES[source_type](**_parse_section_fields(SOURCE_FIELDS[source_type], raw_values))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_scatterer_set(scatterer_type: str, raw_values: Dict[str, Any]) -> Any:
|
|
97
|
+
"""Build the selected scatterer set from raw dashboard values."""
|
|
98
|
+
LOGGER.debug("Building scatterer set %s with raw values: %s", scatterer_type, raw_values)
|
|
99
|
+
return SCATTERER_TYPES[scatterer_type](**_parse_section_fields(SCATTERER_FIELDS[scatterer_type], raw_values))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_detector_set(detector_type: str, raw_values: Dict[str, Any]) -> Any:
|
|
103
|
+
"""Build the selected detector set from raw dashboard values."""
|
|
104
|
+
LOGGER.debug("Building detector set %s with raw values: %s", detector_type, raw_values)
|
|
105
|
+
detector_class = DETECTOR_TYPES[detector_type]
|
|
106
|
+
|
|
107
|
+
if detector_class is None:
|
|
108
|
+
LOGGER.debug("Detector type is None; running detector-free experiment")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
return detector_class(**_parse_section_fields(DETECTOR_FIELDS[detector_type], raw_values))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_single_setup(
|
|
115
|
+
*, source_type: str, source_values: Dict[str, Any], scatterer_type: str, scatterer_values: Dict[str, Any]
|
|
116
|
+
) -> Any:
|
|
117
|
+
"""Build a single-scatterer setup from the representation tab fields."""
|
|
118
|
+
source = SINGLE_SOURCE_TYPES[source_type](**_parse_section_fields(SINGLE_SOURCE_FIELDS[source_type], source_values))
|
|
119
|
+
scatterer = SINGLE_SCATTERER_TYPES[scatterer_type](**_parse_section_fields(SINGLE_SCATTERER_FIELDS[scatterer_type], scatterer_values))
|
|
120
|
+
return SingleSetup(scatterer=scatterer, source=source)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_single_figure(
|
|
124
|
+
*,
|
|
125
|
+
source_type: str,
|
|
126
|
+
source_values: Dict[str, Any],
|
|
127
|
+
scatterer_type: str,
|
|
128
|
+
scatterer_values: Dict[str, Any],
|
|
129
|
+
representation: str,
|
|
130
|
+
sampling: int,
|
|
131
|
+
projection: str = "2d",
|
|
132
|
+
nearfield_mode: str = "absolute",
|
|
133
|
+
include_incident_field: bool = True,
|
|
134
|
+
plot_settings: dict[str, Any] | None = None,
|
|
135
|
+
theme: str = "light",
|
|
136
|
+
) -> tuple[go.Figure, dict[str, str]]:
|
|
137
|
+
"""Compute one single-scatterer representation and turn it into Plotly."""
|
|
138
|
+
setup = build_single_setup(
|
|
139
|
+
source_type=source_type,
|
|
140
|
+
source_values=source_values,
|
|
141
|
+
scatterer_type=scatterer_type,
|
|
142
|
+
scatterer_values=scatterer_values,
|
|
143
|
+
)
|
|
144
|
+
sampling = max(24, min(int(sampling), 300))
|
|
145
|
+
figure = go.Figure()
|
|
146
|
+
|
|
147
|
+
if representation == "s1s2":
|
|
148
|
+
angles = np.linspace(-np.pi, np.pi, sampling) * ureg.radian
|
|
149
|
+
s1, s2 = setup.get_s1s2(angles=angles)
|
|
150
|
+
x = angles.to(ureg.degree).magnitude
|
|
151
|
+
if projection == "polar_1d":
|
|
152
|
+
figure.add_trace(go.Scatterpolar(theta=x, r=np.abs(s1.magnitude), mode="lines", name="|S1|"))
|
|
153
|
+
figure.add_trace(go.Scatterpolar(theta=x, r=np.abs(s2.magnitude), mode="lines", name="|S2|"))
|
|
154
|
+
figure.update_layout(polar={"angularaxis": {"direction": "counterclockwise", "rotation": 90}, "radialaxis": {"title": {"text": ""}}})
|
|
155
|
+
else:
|
|
156
|
+
figure.add_trace(go.Scatter(x=x, y=np.abs(s1.magnitude), mode="lines", name="|S1|"))
|
|
157
|
+
figure.add_trace(go.Scatter(x=x, y=np.abs(s2.magnitude), mode="lines", name="|S2|"))
|
|
158
|
+
figure.update_xaxes(title="Scattering angle [degree]")
|
|
159
|
+
figure.update_yaxes(title=f"Amplitude [{_unit_label(s1, 'meter')}]")
|
|
160
|
+
figure.update_layout(meta={"polar_axis_unit": "degree"})
|
|
161
|
+
title = "S1 / S2 scattering amplitudes"
|
|
162
|
+
elif representation.startswith("nearfields"):
|
|
163
|
+
if scatterer_type != "Sphere":
|
|
164
|
+
raise ValueError("Near-field representations are currently available for spheres only.")
|
|
165
|
+
nearfield_components = {
|
|
166
|
+
"nearfields": "|E|",
|
|
167
|
+
"nearfields_ex": "Ex",
|
|
168
|
+
"nearfields_ey": "Ey",
|
|
169
|
+
"nearfields_ez": "Ez",
|
|
170
|
+
}
|
|
171
|
+
component = nearfield_components.get(representation, "|E|")
|
|
172
|
+
nearfields = setup.get_representation("nearfields")
|
|
173
|
+
field_type = "total" if include_incident_field else "scattered"
|
|
174
|
+
field = nearfields.compute(component, type=field_type, sampling=sampling)[component]
|
|
175
|
+
complex_values = np.asarray(field, dtype=complex)
|
|
176
|
+
values = np.abs(complex_values) if nearfield_mode != "real" else complex_values.real
|
|
177
|
+
x = np.asarray(nearfields.u.to(ureg.nanometer).magnitude, dtype=float)
|
|
178
|
+
y = np.asarray(nearfields.v.to(ureg.nanometer).magnitude, dtype=float)
|
|
179
|
+
figure = go.Figure(
|
|
180
|
+
data=go.Heatmap(
|
|
181
|
+
x=x,
|
|
182
|
+
y=y,
|
|
183
|
+
z=values,
|
|
184
|
+
colorscale="Viridis",
|
|
185
|
+
colorbar={"title": "V/m"},
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
figure.update_layout(yaxis={"scaleanchor": "x", "scaleratio": 1})
|
|
189
|
+
figure.update_xaxes(title="Plane u [nanometer]")
|
|
190
|
+
figure.update_yaxes(title="Plane v [nanometer]")
|
|
191
|
+
value_label = "|" if nearfield_mode != "real" else "Re("
|
|
192
|
+
value_suffix = "|" if nearfield_mode != "real" else ")"
|
|
193
|
+
field_label = "total" if include_incident_field else "scattered"
|
|
194
|
+
title = f"{field_label.title()} near-field {value_label}{component.strip('|')}{value_suffix}"
|
|
195
|
+
else:
|
|
196
|
+
backend_representation = "stokes" if representation == "stokes" or representation.startswith("stokes_") else representation
|
|
197
|
+
representation_object = setup.get_representation(backend_representation, sampling=sampling)
|
|
198
|
+
if representation == "stokes" or representation.startswith("stokes_"):
|
|
199
|
+
field = representation.rsplit("_", 1)[-1].upper() if "_" in representation else "I"
|
|
200
|
+
quantity = getattr(representation_object, field)
|
|
201
|
+
values = quantity.magnitude
|
|
202
|
+
value_label = f"Stokes {field}"
|
|
203
|
+
elif representation == "spf":
|
|
204
|
+
field = "SPF"
|
|
205
|
+
quantity = representation_object.SPF
|
|
206
|
+
values = getattr(quantity, "magnitude", quantity)
|
|
207
|
+
value_label = "Scattering intensity"
|
|
208
|
+
elif representation == "farfields":
|
|
209
|
+
field = "total_intensity"
|
|
210
|
+
quantity = np.abs(representation_object.E_phi) ** 2 + np.abs(representation_object.E_theta) ** 2
|
|
211
|
+
values = quantity.magnitude
|
|
212
|
+
value_label = "Intensity"
|
|
213
|
+
else:
|
|
214
|
+
raise ValueError(f"Representation '{representation}' is not supported by the dashboard.")
|
|
215
|
+
|
|
216
|
+
values = np.asarray(values)
|
|
217
|
+
value_unit = _unit_label(quantity, "dimensionless")
|
|
218
|
+
if values.ndim == 2:
|
|
219
|
+
azimuth = np.linspace(-180.0, 180.0, values.shape[1])
|
|
220
|
+
polar = np.linspace(-90.0, 90.0, values.shape[0])
|
|
221
|
+
if projection in {"3d", "3d_radial"}:
|
|
222
|
+
azimuth_grid, polar_grid = np.meshgrid(np.deg2rad(azimuth), np.deg2rad(polar))
|
|
223
|
+
sphere_x = np.cos(polar_grid) * np.cos(azimuth_grid)
|
|
224
|
+
sphere_y = np.cos(polar_grid) * np.sin(azimuth_grid)
|
|
225
|
+
sphere_z = np.sin(polar_grid)
|
|
226
|
+
surface_values = np.abs(values)
|
|
227
|
+
if projection == "3d_radial":
|
|
228
|
+
maximum = float(np.nanmax(surface_values)) if surface_values.size else 0.0
|
|
229
|
+
radius = surface_values / maximum if maximum > 0.0 else np.ones_like(surface_values)
|
|
230
|
+
surface_x = sphere_x * radius
|
|
231
|
+
surface_y = sphere_y * radius
|
|
232
|
+
surface_z = sphere_z * radius
|
|
233
|
+
else:
|
|
234
|
+
surface_x, surface_y, surface_z = sphere_x, sphere_y, sphere_z
|
|
235
|
+
figure = go.Figure(data=go.Surface(x=surface_x, y=surface_y, z=surface_z, surfacecolor=surface_values, colorscale="Viridis", colorbar={"title": value_unit}))
|
|
236
|
+
figure.update_layout(scene={"xaxis_title": "x", "yaxis_title": "y", "zaxis_title": "z", "aspectmode": "cube"})
|
|
237
|
+
else:
|
|
238
|
+
figure = go.Figure(data=go.Heatmap(x=azimuth, y=polar, z=values, colorscale="Viridis", colorbar={"title": value_unit}))
|
|
239
|
+
figure.update_xaxes(title="Azimuth angle [degree]")
|
|
240
|
+
figure.update_yaxes(title="Polar angle [degree]")
|
|
241
|
+
else:
|
|
242
|
+
figure.add_trace(go.Scatter(y=values.ravel(), mode="lines", name=field))
|
|
243
|
+
figure.update_xaxes(title="Scattering angle [degree]")
|
|
244
|
+
figure.update_yaxes(title=f"{value_label} [{value_unit}]")
|
|
245
|
+
title = f"{value_label} representation" if representation.startswith("stokes") else f"{representation.title()} representation"
|
|
246
|
+
|
|
247
|
+
figure.update_layout(
|
|
248
|
+
template="plotly_white",
|
|
249
|
+
paper_bgcolor="rgba(0, 0, 0, 0)",
|
|
250
|
+
plot_bgcolor="white",
|
|
251
|
+
margin={"l": 45, "r": 20, "t": 55, "b": 45},
|
|
252
|
+
title=title,
|
|
253
|
+
legend_title_text="",
|
|
254
|
+
)
|
|
255
|
+
apply_plot_settings(figure, plot_settings, theme)
|
|
256
|
+
return figure, {"Representation": representation.upper(), "Sampling": str(sampling), "Scatterer": scatterer_type}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _unit_label(value: Any, fallback: str) -> str:
|
|
260
|
+
"""Return a compact display label for a Pint quantity's units."""
|
|
261
|
+
units = getattr(value, "units", None)
|
|
262
|
+
return str(units) if units is not None else fallback
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def infer_variable_fields(
|
|
266
|
+
*,
|
|
267
|
+
source_type: str,
|
|
268
|
+
source_values: Dict[str, Any],
|
|
269
|
+
scatterer_type: str,
|
|
270
|
+
scatterer_values: Dict[str, Any],
|
|
271
|
+
detector_type: str,
|
|
272
|
+
detector_values: Dict[str, Any],
|
|
273
|
+
) -> list[str]:
|
|
274
|
+
"""Return field names whose current input expands to more than one value."""
|
|
275
|
+
variable_fields: list[str] = []
|
|
276
|
+
|
|
277
|
+
variable_fields.extend(_infer_variable_fields_for_section(SOURCE_FIELDS[source_type], source_values))
|
|
278
|
+
variable_fields.extend(_infer_variable_fields_for_section(SCATTERER_FIELDS[scatterer_type], scatterer_values))
|
|
279
|
+
|
|
280
|
+
if detector_type != "None":
|
|
281
|
+
variable_fields.extend(_infer_variable_fields_for_section(DETECTOR_FIELDS[detector_type], detector_values))
|
|
282
|
+
|
|
283
|
+
LOGGER.debug("Detected variable fields from current inputs: %s", variable_fields)
|
|
284
|
+
return variable_fields
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def run_experiment(
|
|
288
|
+
*,
|
|
289
|
+
source_type: str,
|
|
290
|
+
source_values: Dict[str, Any],
|
|
291
|
+
scatterer_type: str,
|
|
292
|
+
scatterer_values: Dict[str, Any],
|
|
293
|
+
detector_type: str,
|
|
294
|
+
detector_values: Dict[str, Any],
|
|
295
|
+
measure: str,
|
|
296
|
+
) -> dict[str, Any]:
|
|
297
|
+
"""Execute the selected experiment and serialize the result for Dash."""
|
|
298
|
+
LOGGER.debug(
|
|
299
|
+
"Starting experiment run source=%s scatterer=%s detector=%s measure=%s",
|
|
300
|
+
source_type,
|
|
301
|
+
scatterer_type,
|
|
302
|
+
detector_type,
|
|
303
|
+
measure,
|
|
304
|
+
)
|
|
305
|
+
source_set = build_source_set(source_type, source_values)
|
|
306
|
+
scatterer_set = build_scatterer_set(scatterer_type, scatterer_values)
|
|
307
|
+
detector_set = build_detector_set(detector_type, detector_values)
|
|
308
|
+
|
|
309
|
+
LOGGER.debug("Source set built: %r", source_set)
|
|
310
|
+
LOGGER.debug("Scatterer set built: %r", scatterer_set)
|
|
311
|
+
LOGGER.debug("Detector set built: %r", detector_set)
|
|
312
|
+
|
|
313
|
+
setup = Setup(
|
|
314
|
+
scatterer_set=scatterer_set,
|
|
315
|
+
source_set=source_set,
|
|
316
|
+
detector_set=detector_set,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
LOGGER.debug("Experiment setup instantiated: %r", setup)
|
|
320
|
+
|
|
321
|
+
dataframe = setup.get(measure, drop_unique_level=True)
|
|
322
|
+
LOGGER.debug("Experiment returned dataframe with shape %s", getattr(dataframe, "shape", None))
|
|
323
|
+
frame = pd.DataFrame(dataframe).copy()
|
|
324
|
+
units = {key: str(value) for key, value in dataframe.attrs.get("units", {}).items()}
|
|
325
|
+
|
|
326
|
+
for column in frame.columns:
|
|
327
|
+
frame[column] = frame[column].map(serialize_value)
|
|
328
|
+
|
|
329
|
+
parameter_columns = [column for column in frame.columns if column != measure]
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
"measure": measure,
|
|
333
|
+
"units": units,
|
|
334
|
+
"parameter_columns": parameter_columns,
|
|
335
|
+
"rows": frame.to_dict("records"),
|
|
336
|
+
"row_count": len(frame),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def export_result_to_csv(result: dict[str, Any] | None) -> str:
|
|
341
|
+
"""Serialize a stored result payload to CSV text."""
|
|
342
|
+
if not result or not result.get("rows"):
|
|
343
|
+
LOGGER.debug("CSV export requested without result rows")
|
|
344
|
+
return ""
|
|
345
|
+
|
|
346
|
+
frame = pd.DataFrame(result["rows"])
|
|
347
|
+
LOGGER.debug("Exporting %d rows to CSV", len(frame))
|
|
348
|
+
return frame.to_csv(index=False)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def export_single_result_to_csv(result: dict[str, Any] | None) -> str:
|
|
352
|
+
"""Serialize a Particle Explorer figure payload to CSV text."""
|
|
353
|
+
if not result or not result.get("figure"):
|
|
354
|
+
LOGGER.debug("Particle Explorer CSV export requested without a result")
|
|
355
|
+
return ""
|
|
356
|
+
|
|
357
|
+
traces = result["figure"].get("data", [])
|
|
358
|
+
rows: list[dict[str, Any]] = []
|
|
359
|
+
for trace in traces:
|
|
360
|
+
name = trace.get("name", "value")
|
|
361
|
+
if "z" in trace:
|
|
362
|
+
z_values = np.asarray(trace["z"])
|
|
363
|
+
x_values = trace.get("x") or list(range(z_values.shape[1]))
|
|
364
|
+
y_values = trace.get("y") or list(range(z_values.shape[0]))
|
|
365
|
+
for y_index, y_value in enumerate(y_values):
|
|
366
|
+
for x_index, x_value in enumerate(x_values):
|
|
367
|
+
rows.append({"series": name, "x": x_value, "y": y_value, "value": z_values[y_index, x_index]})
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
y_values = trace.get("y", [])
|
|
371
|
+
x_values = trace.get("x") or list(range(len(y_values)))
|
|
372
|
+
rows.extend({"series": name, "x": x_value, "value": y_value} for x_value, y_value in zip(x_values, y_values))
|
|
373
|
+
|
|
374
|
+
return pd.DataFrame(rows).to_csv(index=False) if rows else ""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_settings: dict[str, Any] | None = None, theme: str = "light", projection: str = "cartesian") -> go.Figure:
|
|
378
|
+
"""Build a Plotly figure from serialized experiment results."""
|
|
379
|
+
LOGGER.debug("Building figure for x_axis=%s", x_axis)
|
|
380
|
+
if not result or not result.get("rows"):
|
|
381
|
+
LOGGER.debug("No result rows available; returning empty figure")
|
|
382
|
+
figure = go.Figure()
|
|
383
|
+
figure.update_layout(
|
|
384
|
+
template="plotly_white",
|
|
385
|
+
xaxis_visible=False,
|
|
386
|
+
yaxis_visible=False,
|
|
387
|
+
annotations=[
|
|
388
|
+
{
|
|
389
|
+
"text": "Run an experiment to visualize the result.",
|
|
390
|
+
"xref": "paper",
|
|
391
|
+
"yref": "paper",
|
|
392
|
+
"x": 0.5,
|
|
393
|
+
"y": 0.5,
|
|
394
|
+
"showarrow": False,
|
|
395
|
+
"font": {"size": 16},
|
|
396
|
+
}
|
|
397
|
+
],
|
|
398
|
+
)
|
|
399
|
+
apply_plot_settings(figure, plot_settings, theme)
|
|
400
|
+
return figure
|
|
401
|
+
|
|
402
|
+
frame = pd.DataFrame(result["rows"])
|
|
403
|
+
measure = result["measure"]
|
|
404
|
+
parameter_columns = result["parameter_columns"]
|
|
405
|
+
units = result.get("units", {})
|
|
406
|
+
LOGGER.debug("Figure frame columns=%s row_count=%d", list(frame.columns), len(frame))
|
|
407
|
+
|
|
408
|
+
if not parameter_columns:
|
|
409
|
+
resolved_x_axis = "index"
|
|
410
|
+
figure = px.bar(frame.assign(index=[0] * len(frame)), x="index", y=measure)
|
|
411
|
+
else:
|
|
412
|
+
resolved_x_axis = _resolve_x_axis(x_axis, parameter_columns)
|
|
413
|
+
xaxis_unit = units.get(resolved_x_axis)
|
|
414
|
+
if xaxis_unit is None:
|
|
415
|
+
matching_units = [value for key, value in units.items() if key.rsplit(":", 1)[-1] == resolved_x_axis]
|
|
416
|
+
xaxis_unit = matching_units[0] if matching_units else None
|
|
417
|
+
extra_axes = [column for column in parameter_columns if column != resolved_x_axis]
|
|
418
|
+
plot_kwargs: dict[str, Any] = {"x": resolved_x_axis, "y": measure}
|
|
419
|
+
|
|
420
|
+
if extra_axes:
|
|
421
|
+
series_column = extra_axes[0]
|
|
422
|
+
frame = frame.copy()
|
|
423
|
+
|
|
424
|
+
if len(extra_axes) > 1:
|
|
425
|
+
series_column = "__series__"
|
|
426
|
+
frame[series_column] = frame[extra_axes].apply(
|
|
427
|
+
lambda row: " | ".join(_format_plot_value(value) for value in row),
|
|
428
|
+
axis=1,
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
frame[series_column] = frame[series_column].map(
|
|
432
|
+
_format_plot_value
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
plot_kwargs["color"] = series_column
|
|
436
|
+
plot_kwargs["line_group"] = series_column
|
|
437
|
+
|
|
438
|
+
if projection == "polar" and _is_angular_unit(xaxis_unit):
|
|
439
|
+
figure = go.Figure()
|
|
440
|
+
groups = frame.groupby(series_column, sort=False) if extra_axes else [(measure, frame)]
|
|
441
|
+
for series_name, group in groups:
|
|
442
|
+
# Plotly's polar theta values are degrees by default. The
|
|
443
|
+
# experiment data can legitimately be returned in radians,
|
|
444
|
+
# so convert at this boundary instead of letting a 0..2*pi
|
|
445
|
+
# sweep collapse into a tiny line around 0 degrees.
|
|
446
|
+
theta = _polar_theta_degrees(group[resolved_x_axis], xaxis_unit)
|
|
447
|
+
radius = pd.to_numeric(group[measure], errors="coerce").to_numpy(dtype=float)
|
|
448
|
+
valid = np.isfinite(theta) & np.isfinite(radius)
|
|
449
|
+
theta = theta[valid]
|
|
450
|
+
radius = radius[valid]
|
|
451
|
+
order = np.argsort(theta)
|
|
452
|
+
figure.add_trace(go.Scatterpolar(theta=theta[order], r=radius[order], mode="lines", name=str(series_name)))
|
|
453
|
+
figure.update_layout(polar={"angularaxis": {"direction": "counterclockwise", "rotation": 90}})
|
|
454
|
+
else:
|
|
455
|
+
figure = px.line(frame, **plot_kwargs)
|
|
456
|
+
|
|
457
|
+
measure_unit = units.get(measure)
|
|
458
|
+
xaxis_unit = locals().get("xaxis_unit", units.get(resolved_x_axis))
|
|
459
|
+
yaxis_title = f"{measure} [{measure_unit}]" if measure_unit else measure
|
|
460
|
+
xaxis_title = f"{resolved_x_axis} [{xaxis_unit}]" if xaxis_unit else resolved_x_axis
|
|
461
|
+
|
|
462
|
+
figure.update_layout(
|
|
463
|
+
template="plotly_white",
|
|
464
|
+
paper_bgcolor="rgba(0, 0, 0, 0)",
|
|
465
|
+
plot_bgcolor="white",
|
|
466
|
+
margin={"l": 40, "r": 20, "t": 50, "b": 40},
|
|
467
|
+
title=f"{measure} response",
|
|
468
|
+
xaxis_title=None if projection == "polar" and _is_angular_unit(xaxis_unit) else xaxis_title,
|
|
469
|
+
yaxis_title=None if projection == "polar" and _is_angular_unit(xaxis_unit) else yaxis_title,
|
|
470
|
+
legend_title_text="",
|
|
471
|
+
meta={
|
|
472
|
+
"polar_axis_unit": xaxis_unit,
|
|
473
|
+
"legend_description": " | ".join(extra_axes),
|
|
474
|
+
},
|
|
475
|
+
)
|
|
476
|
+
apply_plot_settings(figure, plot_settings, theme)
|
|
477
|
+
|
|
478
|
+
return figure
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _resolve_x_axis(x_axis: str | None, parameter_columns: list[str]) -> str:
|
|
482
|
+
"""Resolve a selector field name against namespaced result columns."""
|
|
483
|
+
if x_axis in parameter_columns:
|
|
484
|
+
return x_axis
|
|
485
|
+
|
|
486
|
+
if x_axis:
|
|
487
|
+
matching_columns = [column for column in parameter_columns if column.rsplit(":", 1)[-1] == x_axis]
|
|
488
|
+
if matching_columns:
|
|
489
|
+
return matching_columns[0]
|
|
490
|
+
|
|
491
|
+
return parameter_columns[0]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def apply_plot_settings(
|
|
495
|
+
figure: go.Figure,
|
|
496
|
+
plot_settings: dict[str, Any] | None = None,
|
|
497
|
+
theme: str = "light",
|
|
498
|
+
polar_allowed: bool | None = None,
|
|
499
|
+
) -> go.Figure:
|
|
500
|
+
"""Apply persisted visual preferences to an existing Plotly figure."""
|
|
501
|
+
settings = {**DEFAULT_PLOT_SETTINGS, **(plot_settings or {})}
|
|
502
|
+
template = settings["template"]
|
|
503
|
+
if template == "match-theme":
|
|
504
|
+
template = "plotly_dark" if theme == "dark" else "plotly_white"
|
|
505
|
+
is_dark = template == "plotly_dark"
|
|
506
|
+
figure.update_layout(
|
|
507
|
+
template=template,
|
|
508
|
+
font={"size": settings["font_size"]},
|
|
509
|
+
height=max(300, int(settings["graph_height"])),
|
|
510
|
+
showlegend=bool(settings["show_legend"]),
|
|
511
|
+
legend={
|
|
512
|
+
"orientation": "h",
|
|
513
|
+
"x": 0,
|
|
514
|
+
"xanchor": "left",
|
|
515
|
+
"y": -0.38,
|
|
516
|
+
"yanchor": "top",
|
|
517
|
+
"bgcolor": "rgba(32, 37, 43, 0.82)" if is_dark else "rgba(255, 255, 255, 0.92)",
|
|
518
|
+
"bordercolor": "rgba(255, 255, 255, 0.24)" if is_dark else "rgba(96, 110, 123, 0.28)",
|
|
519
|
+
"borderwidth": 1,
|
|
520
|
+
"entrywidthmode": "pixels",
|
|
521
|
+
"entrywidth": 170,
|
|
522
|
+
"font": {"size": max(10, int(settings["font_size"] * 0.85)), "color": "#f3f5f7" if is_dark else "#26323b"},
|
|
523
|
+
},
|
|
524
|
+
margin={"b": 125},
|
|
525
|
+
title_text="",
|
|
526
|
+
paper_bgcolor="rgba(0, 0, 0, 0)",
|
|
527
|
+
plot_bgcolor="#20252b" if is_dark else "white",
|
|
528
|
+
xaxis={"showgrid": bool(settings["show_grid"]), "type": settings["x_scale"]},
|
|
529
|
+
yaxis={"showgrid": bool(settings["show_grid"]), "type": "log" if settings["log_y"] else "linear"},
|
|
530
|
+
)
|
|
531
|
+
metadata = figure.layout.meta if isinstance(figure.layout.meta, dict) else {}
|
|
532
|
+
legend_description = metadata.get("legend_description")
|
|
533
|
+
if legend_description and settings["show_legend"]:
|
|
534
|
+
figure.add_annotation(
|
|
535
|
+
text=legend_description,
|
|
536
|
+
x=0,
|
|
537
|
+
xref="paper",
|
|
538
|
+
y=-0.29,
|
|
539
|
+
yref="paper",
|
|
540
|
+
xanchor="left",
|
|
541
|
+
yanchor="top",
|
|
542
|
+
showarrow=False,
|
|
543
|
+
font={"size": max(10, int(settings["font_size"] * 0.78)), "color": "#f3f5f7" if is_dark else "#52616d"},
|
|
544
|
+
)
|
|
545
|
+
for trace in figure.data:
|
|
546
|
+
if getattr(trace, "line", None) is not None:
|
|
547
|
+
trace.line.width = settings["line_width"]
|
|
548
|
+
|
|
549
|
+
if figure.layout.polar is not None:
|
|
550
|
+
figure.update_layout(
|
|
551
|
+
polar={
|
|
552
|
+
"angularaxis": {"showgrid": bool(settings["show_grid"])},
|
|
553
|
+
"radialaxis": {"showgrid": bool(settings["show_grid"]), "type": "log" if settings["log_y"] else "linear"},
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
if polar_allowed is None:
|
|
557
|
+
metadata = figure.layout.meta if isinstance(figure.layout.meta, dict) else {}
|
|
558
|
+
polar_allowed = _is_angular_unit(metadata.get("polar_axis_unit"))
|
|
559
|
+
|
|
560
|
+
if settings["coordinate_system"] == "polar" and polar_allowed:
|
|
561
|
+
_convert_traces_to_polar(figure, bool(settings["show_grid"]), bool(settings["log_y"]))
|
|
562
|
+
return figure
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _is_angular_unit(unit: Any) -> bool:
|
|
566
|
+
"""Return whether a serialized unit represents radians or degrees."""
|
|
567
|
+
normalized = str(unit or "").strip().lower().replace("°", "degree")
|
|
568
|
+
return normalized in {"rad", "radian", "radians", "deg", "degree", "degrees"}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _polar_theta_degrees(values: Any, unit: Any) -> np.ndarray:
|
|
572
|
+
"""Convert serialized angular values to the degree convention used by Plotly."""
|
|
573
|
+
theta = pd.to_numeric(values, errors="coerce").to_numpy(dtype=float)
|
|
574
|
+
normalized = str(unit or "").strip().lower().replace("°", "degree")
|
|
575
|
+
if normalized in {"rad", "radian", "radians"}:
|
|
576
|
+
theta = np.rad2deg(theta)
|
|
577
|
+
return np.mod(theta, 360.0)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _format_plot_value(value: Any) -> str:
|
|
581
|
+
"""Keep legend values readable without exposing floating-point noise."""
|
|
582
|
+
try:
|
|
583
|
+
numeric = float(value)
|
|
584
|
+
except (TypeError, ValueError):
|
|
585
|
+
return str(value)
|
|
586
|
+
|
|
587
|
+
if not np.isfinite(numeric):
|
|
588
|
+
return str(value)
|
|
589
|
+
return f"{numeric:.4g}"
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _convert_traces_to_polar(figure: go.Figure, show_grid: bool, log_y: bool) -> None:
|
|
593
|
+
"""Convert compatible line traces to a polar subplot in place."""
|
|
594
|
+
converted_traces = []
|
|
595
|
+
converted = False
|
|
596
|
+
for trace in figure.data:
|
|
597
|
+
if trace.type not in {"scatter", "scattergl"} or trace.y is None:
|
|
598
|
+
converted_traces.append(trace)
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
# A Cartesian Scatter cannot be updated with ``r`` and ``theta``;
|
|
602
|
+
# Plotly requires a Scatterpolar trace for those properties. Copy the
|
|
603
|
+
# existing trace attributes so styling, hover text, and names survive.
|
|
604
|
+
properties = trace.to_plotly_json()
|
|
605
|
+
properties.pop("type", None)
|
|
606
|
+
x_values = properties.pop("x", None)
|
|
607
|
+
y_values = properties.pop("y")
|
|
608
|
+
properties["r"] = list(y_values)
|
|
609
|
+
properties["theta"] = list(x_values) if x_values is not None else list(
|
|
610
|
+
np.linspace(0, 360, len(y_values), endpoint=False)
|
|
611
|
+
)
|
|
612
|
+
polar_properties = go.Scatterpolar._valid_props
|
|
613
|
+
converted_traces.append(
|
|
614
|
+
go.Scatterpolar(**{key: value for key, value in properties.items() if key in polar_properties})
|
|
615
|
+
)
|
|
616
|
+
converted = True
|
|
617
|
+
|
|
618
|
+
if not converted:
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
# Plotly only permits assigning existing trace objects to ``figure.data``.
|
|
622
|
+
# Clear the original traces, then add the newly constructed polar traces.
|
|
623
|
+
figure.data = ()
|
|
624
|
+
figure.add_traces(converted_traces)
|
|
625
|
+
figure.update_layout(
|
|
626
|
+
polar={
|
|
627
|
+
"angularaxis": {"showgrid": show_grid},
|
|
628
|
+
"radialaxis": {"showgrid": show_grid, "type": "log" if log_y else "linear"},
|
|
629
|
+
},
|
|
630
|
+
xaxis_visible=False,
|
|
631
|
+
yaxis_visible=False,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def build_summary(result: dict[str, Any] | None) -> list[dict[str, str]]:
|
|
636
|
+
"""Build compact summary metadata for the dashboard result cards."""
|
|
637
|
+
if not result:
|
|
638
|
+
return []
|
|
639
|
+
|
|
640
|
+
LOGGER.debug("Building summary cards for measure=%s", result["measure"])
|
|
641
|
+
|
|
642
|
+
return [
|
|
643
|
+
{"label": "Measure", "value": result["measure"]},
|
|
644
|
+
{"label": "Rows", "value": str(result["row_count"])},
|
|
645
|
+
{"label": "Axes", "value": ", ".join(result["parameter_columns"]) or "none"},
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _parse_section_fields(field_specs: tuple[Any, ...], raw_values: Dict[str, Any]) -> Dict[str, Any]:
|
|
650
|
+
"""Parse raw text inputs according to the schema for one section."""
|
|
651
|
+
parsed_values: Dict[str, Any] = {}
|
|
652
|
+
|
|
653
|
+
for field in field_specs:
|
|
654
|
+
raw_value = raw_values.get(field.name, field.default)
|
|
655
|
+
|
|
656
|
+
LOGGER.debug("Parsing field %s raw_value=%r optional=%s", field.name, raw_value, field.optional)
|
|
657
|
+
|
|
658
|
+
if field.optional and (raw_value is None or str(raw_value).strip() == ""):
|
|
659
|
+
LOGGER.debug("Skipping optional empty field %s", field.name)
|
|
660
|
+
continue
|
|
661
|
+
|
|
662
|
+
parsed_value = _parse_field_value(field.kind, raw_value, field.unit)
|
|
663
|
+
if field.name in _POSITIVE_FIELDS:
|
|
664
|
+
_validate_positive_field(field.name, parsed_value)
|
|
665
|
+
parsed_values[field.name] = parsed_value
|
|
666
|
+
|
|
667
|
+
return parsed_values
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _validate_positive_field(name: str, value: Any) -> None:
|
|
671
|
+
"""Reject empty or non-positive values before they reach the C++ layer."""
|
|
672
|
+
if value is None:
|
|
673
|
+
raise ValueError(f"{name} must be positive.")
|
|
674
|
+
|
|
675
|
+
magnitudes = getattr(value, "magnitude", value)
|
|
676
|
+
values = np.asarray(magnitudes, dtype=float)
|
|
677
|
+
if values.size == 0 or not np.all(np.isfinite(values)) or np.any(values <= 0):
|
|
678
|
+
raise ValueError(f"{name} must be positive.")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _infer_variable_fields_for_section(field_specs: tuple[Any, ...], raw_values: Dict[str, Any]) -> list[str]:
|
|
682
|
+
"""Return field names that currently expand to multiple values in one section."""
|
|
683
|
+
variable_fields: list[str] = []
|
|
684
|
+
|
|
685
|
+
for field in field_specs:
|
|
686
|
+
raw_value = raw_values.get(field.name, field.default)
|
|
687
|
+
|
|
688
|
+
if field.optional and (raw_value is None or str(raw_value).strip() == ""):
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
parsed_value = _parse_field_value(field.kind, raw_value, field.unit)
|
|
693
|
+
except Exception:
|
|
694
|
+
LOGGER.debug("Skipping variable-field inference for invalid field %s=%r", field.name, raw_value, exc_info=True)
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
if _value_cardinality(parsed_value) > 1:
|
|
698
|
+
variable_fields.append(field.name)
|
|
699
|
+
|
|
700
|
+
return variable_fields
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _value_cardinality(value: Any) -> int:
|
|
704
|
+
"""Return how many effective values an already parsed field contains."""
|
|
705
|
+
if value is None:
|
|
706
|
+
return 0
|
|
707
|
+
|
|
708
|
+
if isinstance(value, (str, bytes)):
|
|
709
|
+
return 1
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
return len(value)
|
|
713
|
+
except TypeError:
|
|
714
|
+
return 1
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _parse_field_value(kind: str, raw_value: Any, unit: Any) -> Any:
|
|
718
|
+
"""Dispatch parsing based on field type."""
|
|
719
|
+
LOGGER.debug("Dispatching parser for kind=%s raw_value=%r unit=%r", kind, raw_value, unit)
|
|
720
|
+
if kind == "quantity":
|
|
721
|
+
return parse_quantity_expression(raw_value, unit)
|
|
722
|
+
if kind == "numeric":
|
|
723
|
+
return parse_numeric_expression(raw_value)
|
|
724
|
+
if kind == "integer":
|
|
725
|
+
return parse_numeric_expression(raw_value, integer=True)
|
|
726
|
+
if kind == "polarization":
|
|
727
|
+
return parse_polarization(raw_value, unit)
|
|
728
|
+
if kind == "material":
|
|
729
|
+
return parse_material_values(raw_value, medium=False)
|
|
730
|
+
if kind == "medium":
|
|
731
|
+
return parse_material_values(raw_value, medium=True)
|
|
732
|
+
if kind == "mode":
|
|
733
|
+
return parse_mode_numbers(raw_value)
|
|
734
|
+
|
|
735
|
+
raise ValueError(f"Unsupported field kind '{kind}'.")
|