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,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)
|