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.
Files changed (39) hide show
  1. PyMieSimX/__init__.py +5 -0
  2. PyMieSimX/__main__.py +35 -0
  3. PyMieSimX/gui/__init__.py +5 -0
  4. PyMieSimX/gui/assets/card-state.js +41 -0
  5. PyMieSimX/gui/assets/dashboard.css +3352 -0
  6. PyMieSimX/gui/assets/default-settings.json +32 -0
  7. PyMieSimX/gui/assets/logo.png +0 -0
  8. PyMieSimX/gui/assets/pymiesim-favicon.svg +6 -0
  9. PyMieSimX/gui/assets/pymiesim-logo-dark.svg +15 -0
  10. PyMieSimX/gui/assets/pymiesim-logo.svg +39 -0
  11. PyMieSimX/gui/components/__init__.py +5 -0
  12. PyMieSimX/gui/components/cards.py +64 -0
  13. PyMieSimX/gui/defaults.py +18 -0
  14. PyMieSimX/gui/interface.py +688 -0
  15. PyMieSimX/gui/layout.py +329 -0
  16. PyMieSimX/gui/pages/__init__.py +9 -0
  17. PyMieSimX/gui/pages/citation.py +66 -0
  18. PyMieSimX/gui/pages/documentation.py +132 -0
  19. PyMieSimX/gui/pages/experiment/__init__.py +5 -0
  20. PyMieSimX/gui/pages/experiment/page.py +49 -0
  21. PyMieSimX/gui/pages/experiment/sections/__init__.py +5 -0
  22. PyMieSimX/gui/pages/experiment/sections/configuration.py +42 -0
  23. PyMieSimX/gui/pages/home.py +152 -0
  24. PyMieSimX/gui/pages/install_local.py +110 -0
  25. PyMieSimX/gui/pages/settings.py +87 -0
  26. PyMieSimX/gui/pages/single/__init__.py +5 -0
  27. PyMieSimX/gui/pages/single/page.py +28 -0
  28. PyMieSimX/gui/pages/single/sections/__init__.py +6 -0
  29. PyMieSimX/gui/pages/single/sections/representations.py +38 -0
  30. PyMieSimX/gui/pages/single/sections/setup.py +27 -0
  31. PyMieSimX/gui/parsing.py +149 -0
  32. PyMieSimX/gui/schemas.py +118 -0
  33. PyMieSimX/gui/services.py +735 -0
  34. pymiesimx-0.1.0.dist-info/METADATA +90 -0
  35. pymiesimx-0.1.0.dist-info/RECORD +39 -0
  36. pymiesimx-0.1.0.dist-info/WHEEL +5 -0
  37. pymiesimx-0.1.0.dist-info/entry_points.txt +2 -0
  38. pymiesimx-0.1.0.dist-info/licenses/LICENSE +21 -0
  39. 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}'.")