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,688 @@
1
+ """Dash interface for the PyMieSim experiment dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ import webbrowser
8
+ from typing import Any
9
+
10
+ from dash import ALL, MATCH, Dash, Input, Output, State, dcc, no_update
11
+
12
+ from PyMieSimX.gui.layout import THEME_DARK, THEME_LIGHT, build_page_with_footer, create_layout, render_fields
13
+ from PyMieSimX.gui.defaults import DEFAULT_APPLICATION_SETTINGS, DEFAULT_PARTICLE_PLOT_SETTINGS, DEFAULT_SWEEP_PLOT_SETTINGS
14
+ from PyMieSimX.gui.pages.documentation import build_documentation_page
15
+ from PyMieSimX.gui.pages.citation import build_citation_page
16
+ from PyMieSimX.gui.pages.home import build_home_page
17
+ from PyMieSimX.gui.pages.install_local import build_install_local_page
18
+ from PyMieSimX.gui.pages.experiment import build_experiment_page
19
+ from PyMieSimX.gui.pages.settings import build_settings_page
20
+ from PyMieSimX.gui.pages.single import build_single_page
21
+ from PyMieSimX.gui.schemas import DETECTOR_FIELDS, SCATTERER_FIELDS, SINGLE_SCATTERER_FIELDS, SINGLE_SOURCE_FIELDS, SOURCE_FIELDS
22
+ from PyMieSimX.gui.services import (
23
+ available_measures,
24
+ apply_plot_settings,
25
+ build_figure,
26
+ export_result_to_csv,
27
+ export_single_result_to_csv,
28
+ infer_variable_fields,
29
+ build_single_figure,
30
+ run_experiment,
31
+ _parse_field_value,
32
+ _validate_positive_field,
33
+ _is_angular_unit,
34
+ )
35
+
36
+
37
+ LOGGER = logging.getLogger(__name__)
38
+
39
+
40
+ def create_dash_app() -> Dash:
41
+ """Create and configure the experiment dashboard Dash application."""
42
+ LOGGER.debug("Creating Dash application")
43
+ app = Dash(
44
+ __name__,
45
+ title="PyMieSim Parameter Sweep Lab",
46
+ assets_folder=str(Path(__file__).with_name("assets")),
47
+ suppress_callback_exceptions=True,
48
+ )
49
+ app.index_string = app.index_string.replace(
50
+ "{%favicon%}",
51
+ '<link rel="icon" type="image/svg+xml" href="/assets/pymiesim-favicon.svg?v=3">'
52
+ '<link rel="shortcut icon" type="image/svg+xml" href="/assets/pymiesim-favicon.svg?v=3">',
53
+ )
54
+
55
+ initial_measures = available_measures("SphereSet", "PhotodiodeSet")
56
+ LOGGER.debug("Initial measures loaded: %s", initial_measures)
57
+ app.layout = create_layout(initial_measures)
58
+ _register_callbacks(app, initial_measures)
59
+ LOGGER.debug("Dash application initialized with %d callbacks", len(app.callback_map))
60
+ return app
61
+
62
+
63
+ class OpticalSetupGUI:
64
+ """Backward-compatible wrapper around the new Dash experiment dashboard."""
65
+
66
+ def __init__(self):
67
+ """Initialize the Dash application wrapper."""
68
+ LOGGER.debug("Initializing OpticalSetupGUI wrapper")
69
+ self.app = app
70
+
71
+ def run(
72
+ self,
73
+ host: str = "0.0.0.0",
74
+ port: str = "8050",
75
+ open_browser: bool = False,
76
+ debug: bool = False,
77
+ ):
78
+ """Run the dashboard server."""
79
+ url = f"http://{host}:{port}/"
80
+ LOGGER.debug("Running dashboard at %s debug=%s open_browser=%s", url, debug, open_browser)
81
+
82
+ if open_browser:
83
+ webbrowser.open(url, new=2)
84
+
85
+ self.app.run(debug=debug, host=host, port=port)
86
+
87
+
88
+ def _register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
89
+ """Register all dashboard callbacks."""
90
+ LOGGER.debug("Registering dashboard callbacks")
91
+
92
+ @app.callback(
93
+ Output("theme-link", "href"),
94
+ Output("theme-store", "data"),
95
+ Output("settings-theme-mode", "value"),
96
+ Output("sidebar-logo", "src"),
97
+ Input("settings-theme-mode", "value"),
98
+ State("theme-store", "data"),
99
+ )
100
+ def _sync_theme(settings_theme: str | None, stored_theme: dict | None):
101
+ theme_mode = settings_theme
102
+ if theme_mode not in {"light", "dark"}:
103
+ theme_mode = (stored_theme or {}).get("theme", DEFAULT_APPLICATION_SETTINGS["theme"])
104
+ mode = "light" if theme_mode == "light" else "dark"
105
+ logo = "/assets/pymiesim-logo.svg" if mode == "light" else "/assets/pymiesim-logo-dark.svg"
106
+ return (THEME_LIGHT if mode == "light" else THEME_DARK), {"theme": mode}, mode, logo
107
+
108
+ @app.callback(
109
+ Output("page-content", "children"),
110
+ Output("sidebar-link-home", "className"),
111
+ Output("sidebar-link-experiment", "className"),
112
+ Output("sidebar-link-single", "className"),
113
+ Output("sidebar-link-documentation", "className"),
114
+ Output("sidebar-link-settings", "className"),
115
+ Output("home-visit-count", "data"),
116
+ Input("url", "pathname"),
117
+ State("home-visit-count", "data"),
118
+ State("experiment-run-count", "data"),
119
+ State("single-run-count", "data"),
120
+ State("theme-store", "data"),
121
+ State("plot-settings-store", "data"),
122
+ )
123
+ def _route_pages(pathname: str | None, home_visits: int, experiment_runs: int, single_runs: int, theme_store: dict | None, plot_settings: dict | None):
124
+ """Render only the selected route page inside the persistent shell."""
125
+ route = pathname or "/"
126
+ active = {
127
+ "home": "sidebar-link",
128
+ "experiment": "sidebar-link",
129
+ "single": "sidebar-link",
130
+ "documentation": "sidebar-link",
131
+ "settings": "sidebar-link",
132
+ }
133
+ home_visits = int(home_visits or 0)
134
+ if route == "/":
135
+ home_visits += 1
136
+ metrics = {
137
+ "home_page_visits": home_visits,
138
+ "experiment_runs": int(experiment_runs or 0),
139
+ "single_runs": int(single_runs or 0),
140
+ }
141
+ if route == "/documentation":
142
+ active["documentation"] += " active"
143
+ return build_page_with_footer(build_documentation_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
144
+ if route == "/citation":
145
+ active["home"] += " active"
146
+ return build_page_with_footer(build_citation_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
147
+ if route == "/documentation/install-local":
148
+ active["documentation"] += " active"
149
+ return build_page_with_footer(build_install_local_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
150
+ if route == "/settings":
151
+ active["settings"] += " active"
152
+ return build_page_with_footer(build_settings_page((theme_store or {}).get("theme", "light"), plot_settings or {})), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
153
+ if route == "/single":
154
+ active["single"] += " active"
155
+ return build_page_with_footer(build_single_page((plot_settings or {}).get("particle_explorer", {}))), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
156
+ if route == "/experiment":
157
+ active["experiment"] += " active"
158
+ return build_page_with_footer(build_experiment_page(default_measure_options, plot_settings or {})), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
159
+ active["home"] += " active"
160
+ return build_page_with_footer(build_home_page(metrics)), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
161
+
162
+ @app.callback(
163
+ Output("plot-settings-store", "data"),
164
+ Input("settings-particle-font-size", "value", allow_optional=True),
165
+ Input("settings-particle-line-width", "value", allow_optional=True),
166
+ Input("settings-particle-graph-height", "value", allow_optional=True),
167
+ Input("settings-particle-template", "value", allow_optional=True),
168
+ Input("settings-particle-coordinates", "value", allow_optional=True),
169
+ Input("settings-particle-x-scale", "value", allow_optional=True),
170
+ Input("settings-particle-legend", "value", allow_optional=True),
171
+ Input("settings-particle-grid", "value", allow_optional=True),
172
+ Input("settings-particle-log-y", "value", allow_optional=True),
173
+ Input("settings-sweep-font-size", "value", allow_optional=True),
174
+ Input("settings-sweep-line-width", "value", allow_optional=True),
175
+ Input("settings-sweep-graph-height", "value", allow_optional=True),
176
+ Input("settings-sweep-template", "value", allow_optional=True),
177
+ Input("settings-sweep-coordinates", "value", allow_optional=True),
178
+ Input("settings-sweep-x-scale", "value", allow_optional=True),
179
+ Input("settings-sweep-legend", "value", allow_optional=True),
180
+ Input("settings-sweep-grid", "value", allow_optional=True),
181
+ Input("settings-sweep-log-y", "value", allow_optional=True),
182
+ Input("plot-single-x-scale", "value", allow_optional=True),
183
+ Input("plot-single-y-scale", "value", allow_optional=True),
184
+ Input("plot-single-font-size", "value", allow_optional=True),
185
+ Input("plot-single-line-width", "value", allow_optional=True),
186
+ Input("plot-single-legend", "value", allow_optional=True),
187
+ Input("plot-single-grid", "value", allow_optional=True),
188
+ Input("plot-experiment-x-scale", "value", allow_optional=True),
189
+ Input("plot-experiment-y-scale", "value", allow_optional=True),
190
+ Input("plot-experiment-font-size", "value", allow_optional=True),
191
+ Input("plot-experiment-line-width", "value", allow_optional=True),
192
+ Input("plot-experiment-legend", "value", allow_optional=True),
193
+ Input("plot-experiment-grid", "value", allow_optional=True),
194
+ State("plot-settings-store", "data"),
195
+ )
196
+ def _sync_plot_settings(*values):
197
+ stored_settings = values[-1] or {}
198
+ particle_values = values[:9]
199
+ sweep_values = values[9:18]
200
+ single_local_values = values[18:24]
201
+ experiment_local_values = values[24:30]
202
+
203
+ def merge(defaults, key, settings_values):
204
+ current = {**defaults, **(stored_settings.get(key, {}) if isinstance(stored_settings, dict) else {})}
205
+ names = ("font_size", "line_width", "graph_height", "template", "coordinate_system", "x_scale", "show_legend", "show_grid", "log_y")
206
+ for name, value in zip(names, settings_values):
207
+ if value is not None:
208
+ current[name] = value
209
+ return current
210
+
211
+ def merge_local(current, local_values):
212
+ names = ("x_scale", "log_y", "font_size", "line_width", "show_legend", "show_grid")
213
+ for name, value in zip(names, local_values):
214
+ if value is not None:
215
+ current[name] = value
216
+ return current
217
+
218
+ particle = merge(DEFAULT_PARTICLE_PLOT_SETTINGS, "particle_explorer", particle_values)
219
+ sweep = merge(DEFAULT_SWEEP_PLOT_SETTINGS, "parameter_sweep", sweep_values)
220
+ return {
221
+ "particle_explorer": merge_local(particle, single_local_values),
222
+ "parameter_sweep": merge_local(sweep, experiment_local_values),
223
+ }
224
+
225
+ @app.callback(
226
+ Output({"kind": "field", "section": MATCH, "name": ALL}, "className"),
227
+ Input({"kind": "field", "section": MATCH, "name": ALL}, "value"),
228
+ State({"kind": "field", "section": MATCH, "name": ALL}, "id"),
229
+ State("source-type", "value", allow_optional=True),
230
+ State("scatterer-type", "value", allow_optional=True),
231
+ State("detector-type", "value", allow_optional=True),
232
+ State("single-source-type", "value", allow_optional=True),
233
+ State("single-scatterer-type", "value", allow_optional=True),
234
+ )
235
+ def _validate_fields(values, field_ids, source_type, scatterer_type, detector_type, single_source_type, single_scatterer_type):
236
+ """Mark schema-invalid dynamic inputs without interrupting the form."""
237
+ if not field_ids:
238
+ return []
239
+
240
+ selected_types = {
241
+ "source": source_type,
242
+ "scatterer": scatterer_type,
243
+ "detector": detector_type,
244
+ "single-source": single_source_type,
245
+ "single-scatterer": single_scatterer_type,
246
+ }
247
+ schema_groups = {
248
+ "source": SOURCE_FIELDS,
249
+ "scatterer": SCATTERER_FIELDS,
250
+ "detector": DETECTOR_FIELDS,
251
+ "single-source": SINGLE_SOURCE_FIELDS,
252
+ "single-scatterer": SINGLE_SCATTERER_FIELDS,
253
+ }
254
+
255
+ classes = []
256
+ for field_id, raw_value in zip(field_ids, values):
257
+ section = field_id["section"]
258
+ selected_type = selected_types.get(section)
259
+ field_specs = schema_groups.get(section, {}).get(selected_type, ())
260
+ field = next((spec for spec in field_specs if spec.name == field_id["name"]), None)
261
+ valid = field is not None
262
+
263
+ if field is not None:
264
+ empty = raw_value is None or str(raw_value).strip() == ""
265
+ valid = field.optional and empty
266
+ if not empty:
267
+ try:
268
+ parsed_value = _parse_field_value(field.kind, raw_value, field.unit)
269
+ if field.name in {"wavelength", "optical_power", "numerical_aperture", "amplitude", "diameter", "core_diameter", "shell_thickness", "sampling"}:
270
+ _validate_positive_field(field.name, parsed_value)
271
+ valid = True
272
+ except (TypeError, ValueError, KeyError):
273
+ valid = False
274
+
275
+ is_detector_default = section == "detector" and field is not None and field.name in {"polarization_filter", "medium"} and (raw_value is None or str(raw_value).strip() in {"", field.default})
276
+ classes.append("field-input field-input-default" if valid and is_detector_default else "field-input" if valid else "field-input field-input-invalid")
277
+
278
+ return classes
279
+
280
+ @app.callback(Output("source-fields", "children"), Input("source-type", "value"))
281
+ def _render_source_fields(source_type: str):
282
+ LOGGER.debug("Rendering source fields for %s", source_type)
283
+ return render_fields("source", source_type)
284
+
285
+ @app.callback(Output("scatterer-fields", "children"), Input("scatterer-type", "value"))
286
+ def _render_scatterer_fields(scatterer_type: str):
287
+ LOGGER.debug("Rendering scatterer fields for %s", scatterer_type)
288
+ return render_fields("scatterer", scatterer_type)
289
+
290
+ @app.callback(Output("detector-fields", "children"), Input("detector-type", "value"))
291
+ def _render_detector_fields(detector_type: str):
292
+ LOGGER.debug("Rendering detector fields for %s", detector_type)
293
+ return render_fields("detector", detector_type)
294
+
295
+ @app.callback(Output("single-source-fields", "children"), Input("single-source-type", "value"))
296
+ def _render_single_source_fields(source_type: str):
297
+ return render_fields("single-source", source_type)
298
+
299
+ @app.callback(Output("single-scatterer-fields", "children"), Input("single-scatterer-type", "value"))
300
+ def _render_single_scatterer_fields(scatterer_type: str):
301
+ return render_fields("single-scatterer", scatterer_type)
302
+
303
+ @app.callback(
304
+ Output("measure-select", "options"),
305
+ Output("measure-select", "value"),
306
+ Output("detector-coupling-alert", "style"),
307
+ Input("scatterer-type", "value"),
308
+ Input("detector-type", "value"),
309
+ State("measure-select", "value"),
310
+ )
311
+ def _update_measure_options(scatterer_type: str, detector_type: str, current_measure: str | None):
312
+ LOGGER.debug(
313
+ "Updating measure options for scatterer=%s detector=%s current=%s",
314
+ scatterer_type,
315
+ detector_type,
316
+ current_measure,
317
+ )
318
+ measures = available_measures(scatterer_type, detector_type)
319
+ options = [{"label": measure, "value": measure} for measure in measures]
320
+ value = current_measure if current_measure in measures else measures[0]
321
+ alert_style = {} if detector_type == "None" else {"display": "none"}
322
+ return options, value, alert_style
323
+
324
+ @app.callback(
325
+ Output("x-axis-select", "options"),
326
+ Output("x-axis-select", "value"),
327
+ Input("source-type", "value"),
328
+ Input({"kind": "field", "section": "source", "name": ALL}, "value"),
329
+ Input({"kind": "field", "section": "source", "name": ALL}, "id"),
330
+ Input("scatterer-type", "value"),
331
+ Input({"kind": "field", "section": "scatterer", "name": ALL}, "value"),
332
+ Input({"kind": "field", "section": "scatterer", "name": ALL}, "id"),
333
+ Input("detector-type", "value"),
334
+ Input({"kind": "field", "section": "detector", "name": ALL}, "value"),
335
+ Input({"kind": "field", "section": "detector", "name": ALL}, "id"),
336
+ State("x-axis-select", "value"),
337
+ )
338
+ def _update_x_axis_options(
339
+ source_type: str,
340
+ source_values: list[str],
341
+ source_ids: list[dict[str, str]],
342
+ scatterer_type: str,
343
+ scatterer_values: list[str],
344
+ scatterer_ids: list[dict[str, str]],
345
+ detector_type: str,
346
+ detector_values: list[str],
347
+ detector_ids: list[dict[str, str]],
348
+ current_x_axis: str | None,
349
+ ):
350
+ LOGGER.debug(
351
+ "Updating x-axis options from form state source=%s scatterer=%s detector=%s current=%s",
352
+ source_type,
353
+ scatterer_type,
354
+ detector_type,
355
+ current_x_axis,
356
+ )
357
+
358
+ variable_fields = infer_variable_fields(
359
+ source_type=source_type,
360
+ source_values=_pair_ids_with_values(source_ids, source_values),
361
+ scatterer_type=scatterer_type,
362
+ scatterer_values=_pair_ids_with_values(scatterer_ids, scatterer_values),
363
+ detector_type=detector_type,
364
+ detector_values=_pair_ids_with_values(detector_ids, detector_values),
365
+ )
366
+
367
+ options = [{"label": field_name, "value": field_name} for field_name in variable_fields]
368
+ value = current_x_axis if current_x_axis in variable_fields else (variable_fields[0] if variable_fields else None)
369
+ return options, value
370
+
371
+ @app.callback(
372
+ Output("experiment-result", "data"),
373
+ Output("experiment-run-count", "data"),
374
+ Output("experiment-computation-status", "children"),
375
+ Input("source-type", "value"),
376
+ Input({"kind": "field", "section": "source", "name": ALL}, "value"),
377
+ State({"kind": "field", "section": "source", "name": ALL}, "id"),
378
+ Input("scatterer-type", "value"),
379
+ Input({"kind": "field", "section": "scatterer", "name": ALL}, "value"),
380
+ State({"kind": "field", "section": "scatterer", "name": ALL}, "id"),
381
+ Input("detector-type", "value"),
382
+ Input({"kind": "field", "section": "detector", "name": ALL}, "value"),
383
+ State({"kind": "field", "section": "detector", "name": ALL}, "id"),
384
+ Input("measure-select", "value"),
385
+ State("experiment-run-count", "data"),
386
+ prevent_initial_call=True,
387
+ )
388
+ def _run_experiment(
389
+ source_type: str,
390
+ source_values: list[str],
391
+ source_ids: list[dict[str, str]],
392
+ scatterer_type: str,
393
+ scatterer_values: list[str],
394
+ scatterer_ids: list[dict[str, str]],
395
+ detector_type: str,
396
+ detector_values: list[str],
397
+ detector_ids: list[dict[str, str]],
398
+ measure: str,
399
+ experiment_runs: int,
400
+ ):
401
+ next_experiment_runs = int(experiment_runs or 0)
402
+
403
+ LOGGER.debug(
404
+ "Auto-running parameter sweep with source=%s scatterer=%s detector=%s measure=%s",
405
+ source_type,
406
+ scatterer_type,
407
+ detector_type,
408
+ measure,
409
+ )
410
+
411
+ try:
412
+ result = run_experiment(
413
+ source_type=source_type,
414
+ source_values=_pair_ids_with_values(source_ids, source_values),
415
+ scatterer_type=scatterer_type,
416
+ scatterer_values=_pair_ids_with_values(scatterer_ids, scatterer_values),
417
+ detector_type=detector_type,
418
+ detector_values=_pair_ids_with_values(detector_ids, detector_values),
419
+ measure=measure,
420
+ )
421
+ except Exception:
422
+ LOGGER.debug("Skipping incomplete or invalid auto-run input", exc_info=True)
423
+ return no_update, next_experiment_runs, ""
424
+
425
+ LOGGER.debug(
426
+ "Experiment run finished rows=%d x_axis_options=%s",
427
+ result["row_count"],
428
+ result["parameter_columns"],
429
+ )
430
+
431
+ return result, next_experiment_runs + 1, ""
432
+
433
+ @app.callback(
434
+ Output("csv-download", "data"),
435
+ Input("export-csv", "n_clicks"),
436
+ State("experiment-result", "data"),
437
+ State("measure-select", "value"),
438
+ prevent_initial_call=True,
439
+ )
440
+ def _export_csv(n_clicks: int, result: dict | None, measure: str | None):
441
+ LOGGER.debug("CSV export requested for measure=%s", measure)
442
+
443
+ if not n_clicks:
444
+ LOGGER.debug("Ignoring CSV callback without an explicit button click")
445
+ return no_update
446
+
447
+ csv_content = export_result_to_csv(result)
448
+
449
+ if not csv_content:
450
+ LOGGER.debug("No CSV exported because result content is empty")
451
+ return no_update
452
+
453
+ filename = f"pymiesim_{measure or 'result'}.csv"
454
+ LOGGER.debug("CSV export generated filename=%s", filename)
455
+ return dcc.send_string(csv_content, filename)
456
+
457
+ @app.callback(
458
+ Output("single-csv-download", "data"),
459
+ Input("export-single-csv", "n_clicks"),
460
+ State("single-result", "data"),
461
+ prevent_initial_call=True,
462
+ )
463
+ def _export_single_csv(n_clicks: int, result: dict | None):
464
+ if not n_clicks:
465
+ return no_update
466
+
467
+ csv_content = export_single_result_to_csv(result)
468
+ if not csv_content:
469
+ return no_update
470
+
471
+ return dcc.send_string(csv_content, "pymiesim_particle_explorer.csv")
472
+
473
+ @app.callback(
474
+ Output("result-graph", "figure"),
475
+ Input("experiment-result", "data"),
476
+ Input("x-axis-select", "value"),
477
+ Input("plot-settings-store", "data"),
478
+ Input("theme-store", "data"),
479
+ Input("plot-experiment-x-scale", "value", allow_optional=True),
480
+ Input("plot-experiment-y-scale", "value", allow_optional=True),
481
+ Input("plot-experiment-font-size", "value", allow_optional=True),
482
+ Input("plot-experiment-line-width", "value", allow_optional=True),
483
+ Input("plot-experiment-legend", "value", allow_optional=True),
484
+ Input("plot-experiment-grid", "value", allow_optional=True),
485
+ Input("plot-experiment-projection", "value", allow_optional=True),
486
+ )
487
+ def _update_outputs(result: dict | None, x_axis: str | None, plot_settings: dict | None, theme_store: dict | None, *local_values):
488
+ LOGGER.debug("Updating outputs for x_axis=%s result_present=%s", x_axis, bool(result))
489
+ projection = local_values[-1] or "cartesian"
490
+ sweep_settings = (plot_settings or {}).get("parameter_sweep", plot_settings or {})
491
+ sweep_settings = _merge_local_plot_values(sweep_settings, local_values[:-1])
492
+ return build_figure(result, x_axis, plot_settings=sweep_settings, theme=(theme_store or {}).get("theme", "light"), projection=projection)
493
+
494
+ @app.callback(
495
+ Output("single-result", "data"),
496
+ Output("single-run-count", "data"),
497
+ Output("single-computation-status", "children"),
498
+ Input("single-source-type", "value"),
499
+ Input({"kind": "field", "section": "single-source", "name": ALL}, "value"),
500
+ State({"kind": "field", "section": "single-source", "name": ALL}, "id"),
501
+ Input("single-scatterer-type", "value"),
502
+ Input({"kind": "field", "section": "single-scatterer", "name": ALL}, "value"),
503
+ State({"kind": "field", "section": "single-scatterer", "name": ALL}, "id"),
504
+ Input("single-representation", "value"),
505
+ Input("single-projection", "value"),
506
+ Input("single-sampling", "value"),
507
+ Input("single-nearfield-mode", "value"),
508
+ Input("single-include-incident-field", "value"),
509
+ State("single-run-count", "data"),
510
+ )
511
+ def _run_single(
512
+ source_type: str,
513
+ source_values: list[str],
514
+ source_ids: list[dict[str, str]],
515
+ scatterer_type: str,
516
+ scatterer_values: list[str],
517
+ scatterer_ids: list[dict[str, str]],
518
+ representation: str,
519
+ projection: str,
520
+ sampling: int,
521
+ nearfield_mode: list[str] | str | None,
522
+ include_incident_field: list[str] | None,
523
+ single_runs: int,
524
+ ):
525
+ next_single_runs = int(single_runs or 0)
526
+ nearfield_values = nearfield_mode if isinstance(nearfield_mode, list) else ([nearfield_mode] if nearfield_mode else [])
527
+ try:
528
+ figure, summary = build_single_figure(
529
+ source_type=source_type,
530
+ source_values=_pair_ids_with_values(source_ids, source_values),
531
+ scatterer_type=scatterer_type,
532
+ scatterer_values=_pair_ids_with_values(scatterer_ids, scatterer_values),
533
+ representation=representation,
534
+ projection=projection,
535
+ sampling=sampling or 120,
536
+ nearfield_mode="absolute" if "absolute" in nearfield_values else "real",
537
+ include_incident_field="include" in (include_incident_field or []),
538
+ )
539
+ except Exception:
540
+ LOGGER.exception("Single representation render failed")
541
+ return None, next_single_runs, ""
542
+
543
+ return {"figure": figure.to_plotly_json(), "summary": summary}, next_single_runs + 1, ""
544
+
545
+ @app.callback(
546
+ Output("single-representation", "options"),
547
+ Output("single-representation", "value"),
548
+ Output("single-nearfield-availability-alert", "style"),
549
+ Output("single-nearfield-availability-alert", "children"),
550
+ Input("single-scatterer-type", "value"),
551
+ State("single-representation", "value"),
552
+ )
553
+ def _update_single_representation_options(scatterer_type: str, current_representation: str | None):
554
+ nearfield_supported = scatterer_type == "Sphere"
555
+ nearfield_values = {"nearfields", "nearfields_ex", "nearfields_ey", "nearfields_ez"}
556
+ options = [
557
+ {"label": "S1 / S2 amplitudes", "value": "s1s2"},
558
+ {"label": "Stokes I intensity", "value": "stokes"},
559
+ {"label": "Stokes Q", "value": "stokes_q"},
560
+ {"label": "Stokes U", "value": "stokes_u"},
561
+ {"label": "Stokes V", "value": "stokes_v"},
562
+ {"label": "Scattering phase function", "value": "spf"},
563
+ {"label": "Far-field intensity", "value": "farfields"},
564
+ {"label": "Near-field E", "value": "nearfields", "disabled": not nearfield_supported},
565
+ {"label": "Near-field Ex", "value": "nearfields_ex", "disabled": not nearfield_supported},
566
+ {"label": "Near-field Ey", "value": "nearfields_ey", "disabled": not nearfield_supported},
567
+ {"label": "Near-field Ez", "value": "nearfields_ez", "disabled": not nearfield_supported},
568
+ ]
569
+ value = current_representation if current_representation and (nearfield_supported or current_representation not in nearfield_values) else "s1s2"
570
+ alert_style = {} if nearfield_supported else {"display": "block"}
571
+ alert = "Near-field calculations are currently available for spherical particles only."
572
+ return options, value, alert_style, alert
573
+
574
+ @app.callback(
575
+ Output("single-projection", "options"),
576
+ Output("single-projection", "value"),
577
+ Output("single-nearfield-mode-field", "style"),
578
+ Output("single-nearfield-incident-field", "style"),
579
+ Output("single-nearfield-controls-row", "style"),
580
+ Input("single-representation", "value"),
581
+ State("single-projection", "value"),
582
+ )
583
+ def _update_single_projection_options(representation: str, current_projection: str | None):
584
+ if representation == "s1s2":
585
+ options = [{"label": "2D plot", "value": "2d"}, {"label": "Polar 1D", "value": "polar_1d"}]
586
+ hidden = {"display": "none"}
587
+ return options, current_projection if current_projection == "polar_1d" else "2d", hidden, hidden, hidden
588
+
589
+ supports_3d = representation in {"stokes", "stokes_q", "stokes_u", "stokes_v", "spf", "farfields"}
590
+ supports_heatmap = supports_3d or str(representation or "").startswith("nearfields")
591
+ options = [{"label": "2D heatmap" if supports_heatmap else "2D plot", "value": "2d"}]
592
+ if supports_3d:
593
+ options.extend([
594
+ {"label": "3D sphere", "value": "3d"},
595
+ {"label": "3D radial surface", "value": "3d_radial"},
596
+ ])
597
+ is_nearfield = str(representation or "").startswith("nearfields")
598
+ nearfield_style = {"display": "block" if is_nearfield else "none"}
599
+ return options, current_projection if supports_3d and current_projection in {"3d", "3d_radial"} else "2d", nearfield_style, nearfield_style, nearfield_style
600
+
601
+ @app.callback(
602
+ Output("single-plot-options-container", "children"),
603
+ Input("single-representation", "value"),
604
+ Input("single-projection", "value"),
605
+ State("plot-settings-store", "data"),
606
+ )
607
+ def _update_single_plot_options(representation: str, projection: str, plot_settings: dict | None):
608
+ particle_settings = (plot_settings or {}).get("particle_explorer", plot_settings or {})
609
+ from PyMieSimX.gui.layout import _plot_options_card
610
+
611
+ return _plot_options_card("single", particle_settings, representation, projection)
612
+
613
+ @app.callback(
614
+ Output("experiment-plot-options-container", "children"),
615
+ Input("x-axis-select", "value"),
616
+ Input("experiment-result", "data"),
617
+ State("plot-settings-store", "data"),
618
+ State("plot-experiment-projection", "value", allow_optional=True),
619
+ )
620
+ def _update_experiment_plot_options(x_axis: str | None, result: dict | None, plot_settings: dict | None, current_projection: str | None):
621
+ sweep_settings = (plot_settings or {}).get("parameter_sweep", plot_settings or {})
622
+ units = (result or {}).get("units", {})
623
+ xaxis_unit = units.get(x_axis)
624
+ if xaxis_unit is None and x_axis:
625
+ matching_units = [value for key, value in units.items() if key.rsplit(":", 1)[-1] == x_axis]
626
+ xaxis_unit = matching_units[0] if matching_units else None
627
+ is_angle = _is_angular_unit(xaxis_unit)
628
+ options = [{"label": "Cartesian", "value": "cartesian"}]
629
+ if is_angle:
630
+ options.append({"label": "Polar", "value": "polar"})
631
+ projection = current_projection if is_angle and current_projection == "polar" else "cartesian"
632
+ from PyMieSimX.gui.layout import _plot_options_card
633
+
634
+ return _plot_options_card("experiment", sweep_settings, projection=projection, projection_options=options)
635
+
636
+ @app.callback(
637
+ Output("single-graph", "figure"),
638
+ Input("single-result", "data"),
639
+ Input("plot-settings-store", "data"),
640
+ Input("theme-store", "data"),
641
+ Input("plot-single-x-scale", "value", allow_optional=True),
642
+ Input("plot-single-y-scale", "value", allow_optional=True),
643
+ Input("plot-single-font-size", "value", allow_optional=True),
644
+ Input("plot-single-line-width", "value", allow_optional=True),
645
+ Input("plot-single-legend", "value", allow_optional=True),
646
+ Input("plot-single-grid", "value", allow_optional=True),
647
+ )
648
+ def _update_single_outputs(result: dict | None, plot_settings: dict | None, theme_store: dict | None, *local_values):
649
+ particle_settings = (plot_settings or {}).get("particle_explorer", plot_settings or {})
650
+ particle_settings = _merge_local_plot_values(particle_settings, local_values)
651
+ if not result:
652
+ return build_single_empty_figure(plot_settings=particle_settings, theme=(theme_store or {}).get("theme", "light"))
653
+ from plotly.graph_objects import Figure
654
+
655
+ return apply_plot_settings(Figure(result["figure"]), particle_settings, (theme_store or {}).get("theme", "light"))
656
+
657
+
658
+ def _pair_ids_with_values(ids: list[dict[str, str]], values: list[str]) -> dict[str, str]:
659
+ """Convert dynamic Dash field IDs and values into a flat mapping."""
660
+ return {field_id["name"]: value for field_id, value in zip(ids, values)}
661
+
662
+
663
+ def _merge_local_plot_values(settings: dict | None, values: tuple[Any, ...]) -> dict:
664
+ """Overlay values from a plot-local options card onto stored preferences."""
665
+ merged = dict(settings or {})
666
+ names = ("x_scale", "log_y", "font_size", "line_width", "show_legend", "show_grid")
667
+ for name, value in zip(names, values):
668
+ if value is not None:
669
+ merged[name] = value
670
+ return merged
671
+
672
+
673
+ def build_single_empty_figure(plot_settings: dict | None = None, theme: str = "light"):
674
+ """Return the initial representation canvas."""
675
+ from plotly.graph_objects import Figure
676
+
677
+ figure = Figure()
678
+ figure.update_layout(template="plotly_white", xaxis_visible=False, yaxis_visible=False, annotations=[{"text": "Configure a setup to inspect a representation.", "xref": "paper", "yref": "paper", "x": 0.5, "y": 0.5, "showarrow": False}])
679
+ return apply_plot_settings(figure, plot_settings, theme)
680
+
681
+
682
+ app = create_dash_app()
683
+ server = app.server
684
+
685
+
686
+ if __name__ == "__main__":
687
+ _gui = OpticalSetupGUI()
688
+ _gui.run(host="0.0.0.0", port="8050", open_browser=False, debug=True)