PyMieSimX 0.1.3__tar.gz → 0.1.4__tar.gz
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-0.1.3 → pymiesimx-0.1.4}/PKG-INFO +21 -5
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/dashboard.css +17 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/callback_helpers.py +2 -2
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/callbacks.py +42 -22
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/computation.py +88 -12
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/layout.py +4 -3
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/models.py +3 -2
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/experiment/page.py +6 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/validation.py +1 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/PKG-INFO +21 -5
- pymiesimx-0.1.4/PyMieSimX.egg-info/scm_version.json +8 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/README.rst +20 -4
- pymiesimx-0.1.4/tests/browser/test_dashboard.py +50 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_dash_callbacks.py +124 -8
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_services.py +118 -0
- pymiesimx-0.1.3/PyMieSimX.egg-info/scm_version.json +0 -8
- pymiesimx-0.1.3/tests/browser/test_dashboard.py +0 -28
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.coveragerc +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.flake8 +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/dependabot.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/deploy_PyPi.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/deploy_anaconda.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/deploy_coverage.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/quality.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/tests.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.gitignore +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.pre-commit-config.yaml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/CONTRIBUTING.md +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/Dockerfile +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/LICENSE +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/__main__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/api.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/application/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/application/wsgi.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/admin.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/card-state.js +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/default-settings.json +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/home-spf-radial.gif +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/home-spf-radial.png +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/logo.png +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/materials-stock.json +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/pymiesim-favicon.svg +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/pymiesim-logo-dark.svg +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/pymiesim-logo.svg +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/components/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/components/cards.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/defaults.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/experiment_service.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/interface.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/jobs.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/material_catalog.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/citation.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/documentation.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/experiment/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/experiment/sections/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/experiment/sections/configuration.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/field_syntax.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/home.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/install_local.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/sellmeier.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/settings.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/page.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/sections/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/sections/representations.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/sections/setup.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/parsing.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/plotting.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/schemas.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/services.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/single_service.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/usage_metrics.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/SOURCES.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/dependency_links.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/entry_points.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/requires.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/scm_file_list.json +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/top_level.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/conda.recipe/meta.yaml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/makefile +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/pyproject.toml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/pytest.ini +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/setup.cfg +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/cli.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_admin.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_api.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_usage_metrics.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tools/generate_home_spf.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tools/next_release_version.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tools/release_tag.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyMieSimX
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Graphical Dash interface for PyMieSim.
|
|
5
5
|
Author-email: Martin Poinsinet de Sivry-Houle <martin.poinsinet.de.sivry@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -112,14 +112,30 @@ The computational API can be used without constructing the Dash application::
|
|
|
112
112
|
``PyMieSimX.create_dash_app`` and ``PyMieSimX.OpticalSetupGUI`` remain
|
|
113
113
|
available for applications that need the graphical interface.
|
|
114
114
|
|
|
115
|
+
Multiple measures
|
|
116
|
+
-----------------
|
|
117
|
+
|
|
118
|
+
The Parameter Sweep Measures selector accepts several quantities in one run.
|
|
119
|
+
For example, select ``Qsca``, ``Qabs``, and ``Qext`` to compare scattering,
|
|
120
|
+
absorption, and extinction efficiencies. Measures with the same units share a
|
|
121
|
+
plot; different units use separate panels with a shared X axis. CSV exports
|
|
122
|
+
contain a column for each computed measure.
|
|
123
|
+
|
|
124
|
+
The Python API accepts either a single name or a list, for example
|
|
125
|
+
``run_experiment(..., measure=["Qsca", "Qabs", "Qext"])``. Results include
|
|
126
|
+
``measures`` with every computed name; ``measure`` retains the first name for
|
|
127
|
+
compatibility with existing single-measure callers. Size validation accounts
|
|
128
|
+
for the additional result columns.
|
|
129
|
+
|
|
115
130
|
Background calculations and limits
|
|
116
131
|
-----------------------------------
|
|
117
132
|
|
|
118
133
|
Parameter sweeps are submitted to a bounded background worker and reported to
|
|
119
|
-
the dashboard
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
the dashboard with visible queued, running, completed, and error messages beside
|
|
135
|
+
Run. The completed-run card shows the number of result rows. Oversized sweeps are
|
|
136
|
+
rejected with guidance when you click Run. Results are guarded by both an
|
|
137
|
+
in-memory dataframe limit and a serialized Dash-payload limit, with estimated
|
|
138
|
+
size validation before execution and actual size checks after computation.
|
|
123
139
|
|
|
124
140
|
Run the command-line launcher with ``--debug`` to enable detailed logs. Log
|
|
125
141
|
messages use the format ``timestamp | level | logger | message`` and include
|
|
@@ -2952,6 +2952,23 @@ button.material-name-select:focus-visible {
|
|
|
2952
2952
|
color: var(--error);
|
|
2953
2953
|
}
|
|
2954
2954
|
|
|
2955
|
+
.status-banner.warning {
|
|
2956
|
+
background: rgba(210, 145, 20, 0.14);
|
|
2957
|
+
color: var(--ink);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
.experiment-run-feedback {
|
|
2961
|
+
display: grid;
|
|
2962
|
+
gap: 8px;
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
.experiment-run-feedback .status-banner {
|
|
2966
|
+
position: static;
|
|
2967
|
+
margin: 0;
|
|
2968
|
+
font-size: 0.9rem;
|
|
2969
|
+
overflow-wrap: anywhere;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2955
2972
|
.summary-grid {
|
|
2956
2973
|
display: grid;
|
|
2957
2974
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
@@ -14,7 +14,7 @@ from PyMieSimX.gui.models import ExperimentResult, MessageLevel, SingleResult
|
|
|
14
14
|
from PyMieSimX.gui.services import ExperimentValidationError, build_single_figure, run_experiment
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
_STATUS_ICONS = {"idle": "\u25cb", "success": "\u2713", "error": "\u26a0"}
|
|
17
|
+
_STATUS_ICONS = {"idle": "\u25cb", "success": "\u2713", "warning": "\u26a0", "error": "\u26a0"}
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def status_banner(level: str, message: str):
|
|
@@ -65,7 +65,7 @@ def execute_experiment_callback(
|
|
|
65
65
|
detector_type: str,
|
|
66
66
|
detector_values: list[str],
|
|
67
67
|
detector_ids: list[dict[str, str]],
|
|
68
|
-
measure: str,
|
|
68
|
+
measure: str | list[str],
|
|
69
69
|
run_count: int | None,
|
|
70
70
|
) -> CallbackExecution:
|
|
71
71
|
"""Execute a parameter sweep and convert errors into UI-ready state."""
|
|
@@ -23,8 +23,10 @@ from PyMieSimX.gui.callback_helpers import (
|
|
|
23
23
|
execute_single_callback,
|
|
24
24
|
merge_local_plot_values as _merge_local_plot_values,
|
|
25
25
|
pair_ids_with_values as _pair_ids_with_values,
|
|
26
|
+
status_banner,
|
|
26
27
|
)
|
|
27
28
|
from PyMieSimX.gui.jobs import experiment_jobs
|
|
29
|
+
from PyMieSimX.gui.validation import normalize_measures
|
|
28
30
|
from PyMieSimX.gui import usage_metrics
|
|
29
31
|
from PyMieSimX.gui.admin import build_admin_page, collect_admin_dashboard_data, dashboard_values, is_admin_access_granted
|
|
30
32
|
from PyMieSimX.gui.services import (
|
|
@@ -417,7 +419,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
417
419
|
Input("detector-type", "value"),
|
|
418
420
|
State("measure-select", "value"),
|
|
419
421
|
)
|
|
420
|
-
def _update_measure_options(scatterer_type: str, detector_type: str, current_measure: str | None):
|
|
422
|
+
def _update_measure_options(scatterer_type: str, detector_type: str, current_measure: str | list[str] | None):
|
|
421
423
|
LOGGER.debug(
|
|
422
424
|
"Updating measure options for scatterer=%s detector=%s current=%s",
|
|
423
425
|
scatterer_type,
|
|
@@ -426,7 +428,9 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
426
428
|
)
|
|
427
429
|
measures = available_measures(scatterer_type, detector_type)
|
|
428
430
|
options = [{"label": measure, "value": measure} for measure in measures]
|
|
429
|
-
value = current_measure if
|
|
431
|
+
value = [name for name in normalize_measures(current_measure) if name in measures]
|
|
432
|
+
if not value and current_measure != []:
|
|
433
|
+
value = measures[:1]
|
|
430
434
|
alert_style = {} if detector_type == "None" else {"display": "none"}
|
|
431
435
|
return options, value, alert_style
|
|
432
436
|
|
|
@@ -481,6 +485,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
481
485
|
Output("experiment-job", "data"),
|
|
482
486
|
Output("experiment-job-poll", "disabled", allow_duplicate=True),
|
|
483
487
|
Output("run-experiment-button", "disabled", allow_duplicate=True),
|
|
488
|
+
Output("experiment-run-status", "children", allow_duplicate=True),
|
|
484
489
|
Input("run-experiment-button", "n_clicks"),
|
|
485
490
|
State("source-type", "value"),
|
|
486
491
|
State({"kind": "field", "section": "source", "name": ALL}, "value"),
|
|
@@ -506,12 +511,12 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
506
511
|
detector_type: str,
|
|
507
512
|
detector_values: list[str],
|
|
508
513
|
detector_ids: list[dict[str, str]],
|
|
509
|
-
measure: str,
|
|
514
|
+
measure: str | list[str],
|
|
510
515
|
previous_job: dict | None,
|
|
511
516
|
):
|
|
512
517
|
# Dynamic page insertion can invoke callbacks without a button click.
|
|
513
518
|
if not _run_clicks:
|
|
514
|
-
return no_update, no_update, no_update
|
|
519
|
+
return no_update, no_update, no_update, no_update
|
|
515
520
|
|
|
516
521
|
LOGGER.debug(
|
|
517
522
|
"Preparing background parameter sweep source=%s scatterer=%s detector=%s measure=%s",
|
|
@@ -534,9 +539,9 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
534
539
|
measure=measure,
|
|
535
540
|
)
|
|
536
541
|
if issues:
|
|
537
|
-
message = " ".join(issue.message for issue in issues)
|
|
542
|
+
message = " ".join(f"{issue.field}: {issue.message}" for issue in issues)
|
|
538
543
|
LOGGER.info("Experiment submission rejected: %s", message)
|
|
539
|
-
return None, True, False
|
|
544
|
+
return None, True, False, status_banner("error", f"Cannot run: {message}")
|
|
540
545
|
|
|
541
546
|
if previous_job:
|
|
542
547
|
experiment_jobs.cancel(previous_job.get("job_id"))
|
|
@@ -547,24 +552,30 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
547
552
|
scatterer_values=scatterer_mapping,
|
|
548
553
|
detector_type=detector_type,
|
|
549
554
|
detector_values=detector_mapping,
|
|
550
|
-
)
|
|
551
|
-
job_id = experiment_jobs.submit(
|
|
552
|
-
source_type=source_type,
|
|
553
|
-
source_values=source_mapping,
|
|
554
|
-
scatterer_type=scatterer_type,
|
|
555
|
-
scatterer_values=scatterer_mapping,
|
|
556
|
-
detector_type=detector_type,
|
|
557
|
-
detector_values=detector_mapping,
|
|
558
555
|
measure=measure,
|
|
559
556
|
)
|
|
557
|
+
try:
|
|
558
|
+
job_id = experiment_jobs.submit(
|
|
559
|
+
source_type=source_type,
|
|
560
|
+
source_values=source_mapping,
|
|
561
|
+
scatterer_type=scatterer_type,
|
|
562
|
+
scatterer_values=scatterer_mapping,
|
|
563
|
+
detector_type=detector_type,
|
|
564
|
+
detector_values=detector_mapping,
|
|
565
|
+
measure=measure,
|
|
566
|
+
)
|
|
567
|
+
except Exception as error: # noqa: BLE001 - submission failures must reach the user
|
|
568
|
+
LOGGER.exception("Failed to queue experiment")
|
|
569
|
+
return None, True, False, status_banner("error", f"Could not start the sweep: {error}. Click Run to retry.")
|
|
560
570
|
LOGGER.info("Experiment queued job_id=%s rows_estimate=%d bytes_estimate=%d", job_id, estimate.rows, estimate.estimated_bytes)
|
|
561
|
-
return {"job_id": job_id}, False, True
|
|
571
|
+
return {"job_id": job_id}, False, True, status_banner("idle", "Queued. Waiting for a computation worker…")
|
|
562
572
|
|
|
563
573
|
@app.callback(
|
|
564
574
|
Output("experiment-result", "data"),
|
|
565
575
|
Output("experiment-run-count", "data"),
|
|
566
576
|
Output("experiment-job-poll", "disabled"),
|
|
567
577
|
Output("run-experiment-button", "disabled"),
|
|
578
|
+
Output("experiment-run-status", "children"),
|
|
568
579
|
Input("experiment-job-poll", "n_intervals"),
|
|
569
580
|
State("experiment-job", "data"),
|
|
570
581
|
State("experiment-run-count", "data"),
|
|
@@ -573,20 +584,24 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
573
584
|
def _poll_experiment_job(_n_intervals: int, job_data: dict | None, experiment_runs: int):
|
|
574
585
|
snapshot = experiment_jobs.snapshot((job_data or {}).get("job_id"))
|
|
575
586
|
if snapshot is None:
|
|
576
|
-
|
|
587
|
+
message = status_banner("error", "Run interrupted: the background job is no longer available. Click Run to retry.") if job_data else no_update
|
|
588
|
+
return no_update, no_update, True, False, message
|
|
577
589
|
status = snapshot["status"]
|
|
578
590
|
LOGGER.debug("Polling experiment job_id=%s status=%s", snapshot["job_id"], status)
|
|
579
591
|
if status in {"pending", "running"}:
|
|
580
|
-
|
|
592
|
+
message = "Queued. Waiting for a computation worker…" if status == "pending" else "Running. Computing your parameter sweep…"
|
|
593
|
+
return no_update, no_update, False, True, status_banner("idle", message)
|
|
581
594
|
if status == "succeeded":
|
|
582
595
|
result = snapshot["result"]
|
|
583
596
|
try:
|
|
584
597
|
usage_metrics.record_experiment_run()
|
|
585
598
|
except Exception:
|
|
586
599
|
LOGGER.exception("Failed to record PyMieSimX experiment-run metric.")
|
|
587
|
-
return result, int(experiment_runs or 0) + 1, True, False
|
|
600
|
+
return result, int(experiment_runs or 0) + 1, True, False, status_banner("success", f"Completed. {result['row_count']:,} result rows.")
|
|
601
|
+
if status == "cancelled":
|
|
602
|
+
return no_update, no_update, True, False, status_banner("warning", "Run cancelled. Click Run to start another sweep.")
|
|
588
603
|
LOGGER.warning("Experiment job failed job_id=%s error=%s", snapshot["job_id"], snapshot["error"])
|
|
589
|
-
return no_update, no_update, True, False
|
|
604
|
+
return no_update, no_update, True, False, status_banner("error", f"Run failed: {snapshot['error'] or 'An unexpected computation error occurred.'}")
|
|
590
605
|
|
|
591
606
|
@app.callback(
|
|
592
607
|
Output("csv-download", "data"),
|
|
@@ -595,7 +610,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
595
610
|
State("measure-select", "value"),
|
|
596
611
|
prevent_initial_call=True,
|
|
597
612
|
)
|
|
598
|
-
def _export_csv(n_clicks: int, result: dict | None, measure: str | None):
|
|
613
|
+
def _export_csv(n_clicks: int, result: dict | None, measure: str | list[str] | None):
|
|
599
614
|
LOGGER.debug("CSV export requested for measure=%s", measure)
|
|
600
615
|
|
|
601
616
|
if not n_clicks:
|
|
@@ -608,7 +623,8 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
608
623
|
LOGGER.debug("No CSV exported because result content is empty")
|
|
609
624
|
return no_update
|
|
610
625
|
|
|
611
|
-
|
|
626
|
+
exported_measures = (result or {}).get("measures") or [(result or {}).get("measure", "result")]
|
|
627
|
+
filename = f"pymiesim_{'_'.join(exported_measures)}.csv"
|
|
612
628
|
LOGGER.debug("CSV export generated filename=%s", filename)
|
|
613
629
|
return dcc.send_string(csv_content, filename)
|
|
614
630
|
|
|
@@ -661,7 +677,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
661
677
|
Output("single-result", "data"),
|
|
662
678
|
Output("single-run-count", "data"),
|
|
663
679
|
Input("run-single-button", "n_clicks"),
|
|
664
|
-
|
|
680
|
+
State("single-projection", "value"),
|
|
665
681
|
State("single-source-type", "value"),
|
|
666
682
|
State({"kind": "field", "section": "single-source", "name": ALL}, "value"),
|
|
667
683
|
State({"kind": "field", "section": "single-source", "name": ALL}, "id"),
|
|
@@ -673,6 +689,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
673
689
|
State("single-nearfield-mode", "value"),
|
|
674
690
|
State("single-include-incident-field", "value"),
|
|
675
691
|
State("single-run-count", "data"),
|
|
692
|
+
prevent_initial_call=True,
|
|
676
693
|
)
|
|
677
694
|
def _run_single(
|
|
678
695
|
_run_clicks: int,
|
|
@@ -689,6 +706,9 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
689
706
|
include_incident_field: list[str] | None,
|
|
690
707
|
single_runs: int,
|
|
691
708
|
):
|
|
709
|
+
if not _run_clicks:
|
|
710
|
+
return no_update, no_update
|
|
711
|
+
|
|
692
712
|
execution = execute_single_callback(
|
|
693
713
|
source_type=source_type,
|
|
694
714
|
source_values=source_values,
|
|
@@ -459,6 +459,12 @@ def infer_variable_fields(
|
|
|
459
459
|
return variable_fields
|
|
460
460
|
|
|
461
461
|
|
|
462
|
+
def normalize_measures(measure: str | list[str] | None) -> list[str]:
|
|
463
|
+
"""Accept legacy single-measure input and preserve unique selection order."""
|
|
464
|
+
values = [measure] if isinstance(measure, str) else (measure or [])
|
|
465
|
+
return list(dict.fromkeys(values))
|
|
466
|
+
|
|
467
|
+
|
|
462
468
|
def run_experiment(
|
|
463
469
|
*,
|
|
464
470
|
source_type: str,
|
|
@@ -467,7 +473,7 @@ def run_experiment(
|
|
|
467
473
|
scatterer_values: Dict[str, Any],
|
|
468
474
|
detector_type: str,
|
|
469
475
|
detector_values: Dict[str, Any],
|
|
470
|
-
measure: str,
|
|
476
|
+
measure: str | list[str],
|
|
471
477
|
) -> ExperimentResult:
|
|
472
478
|
"""Execute the selected experiment and serialize the result for Dash."""
|
|
473
479
|
LOGGER.debug(
|
|
@@ -505,7 +511,8 @@ def run_experiment(
|
|
|
505
511
|
|
|
506
512
|
LOGGER.debug("Experiment setup instantiated: %r", setup)
|
|
507
513
|
|
|
508
|
-
|
|
514
|
+
measures = normalize_measures(measure)
|
|
515
|
+
dataframe = setup.get(*measures, drop_unique_level=True)
|
|
509
516
|
LOGGER.debug("Experiment returned dataframe with shape %s", getattr(dataframe, "shape", None))
|
|
510
517
|
if hasattr(dataframe, "as_dataframe"):
|
|
511
518
|
# Native LabeledArray results: convert and drop the "section:" column prefixes
|
|
@@ -529,10 +536,11 @@ def run_experiment(
|
|
|
529
536
|
for column in frame.columns:
|
|
530
537
|
frame[column] = frame[column].map(serialize_value)
|
|
531
538
|
|
|
532
|
-
parameter_columns = [column for column in frame.columns if column
|
|
539
|
+
parameter_columns = [column for column in frame.columns if column not in measures]
|
|
533
540
|
|
|
534
541
|
result: ExperimentResult = {
|
|
535
|
-
"measure":
|
|
542
|
+
"measure": measures[0],
|
|
543
|
+
"measures": measures,
|
|
536
544
|
"units": units,
|
|
537
545
|
"columns": [str(column) for column in frame.columns],
|
|
538
546
|
"parameter_columns": parameter_columns,
|
|
@@ -591,6 +599,7 @@ def estimate_result_size(
|
|
|
591
599
|
scatterer_values: Dict[str, Any],
|
|
592
600
|
detector_type: str,
|
|
593
601
|
detector_values: Dict[str, Any],
|
|
602
|
+
measure: str | list[str] | None = None,
|
|
594
603
|
) -> ResultSizeEstimate:
|
|
595
604
|
"""Estimate serialized result size before starting a computation."""
|
|
596
605
|
row_count = estimate_sweep_size(
|
|
@@ -601,7 +610,7 @@ def estimate_result_size(
|
|
|
601
610
|
detector_type=detector_type,
|
|
602
611
|
detector_values=detector_values,
|
|
603
612
|
)
|
|
604
|
-
column_count = 1 + sum(
|
|
613
|
+
column_count = max(1, len(normalize_measures(measure))) + sum(
|
|
605
614
|
len(field_groups.get(selected_type, ()))
|
|
606
615
|
for field_groups, selected_type in (
|
|
607
616
|
((SOURCE_FIELDS, source_type), (SCATTERER_FIELDS, scatterer_type), (DETECTOR_FIELDS, detector_type))
|
|
@@ -627,7 +636,7 @@ def validate_experiment_inputs(
|
|
|
627
636
|
scatterer_values: Dict[str, Any],
|
|
628
637
|
detector_type: str,
|
|
629
638
|
detector_values: Dict[str, Any],
|
|
630
|
-
measure: str,
|
|
639
|
+
measure: str | list[str] | None,
|
|
631
640
|
) -> list[ValidationIssue]:
|
|
632
641
|
"""Validate all experiment fields and return actionable user-facing issues."""
|
|
633
642
|
issues: list[ValidationIssue] = []
|
|
@@ -659,10 +668,14 @@ def validate_experiment_inputs(
|
|
|
659
668
|
except (TypeError, ValueError, KeyError) as error:
|
|
660
669
|
issues.append(ValidationIssue(field.name, str(error)))
|
|
661
670
|
|
|
671
|
+
measures = normalize_measures(measure)
|
|
672
|
+
if not measures:
|
|
673
|
+
issues.append(ValidationIssue("measure", "Select at least one measure."))
|
|
662
674
|
if scatterer_type in SCATTERER_TYPES and detector_type in DETECTOR_TYPES:
|
|
663
675
|
valid_measures = available_measures(scatterer_type, detector_type)
|
|
664
|
-
|
|
665
|
-
|
|
676
|
+
for selected_measure in measures:
|
|
677
|
+
if selected_measure not in valid_measures:
|
|
678
|
+
issues.append(ValidationIssue("measure", f"'{selected_measure}' is not available for this configuration."))
|
|
666
679
|
|
|
667
680
|
if not issues:
|
|
668
681
|
sweep_size = estimate_sweep_size(
|
|
@@ -688,6 +701,7 @@ def validate_experiment_inputs(
|
|
|
688
701
|
scatterer_values=scatterer_values,
|
|
689
702
|
detector_type=detector_type,
|
|
690
703
|
detector_values=detector_values,
|
|
704
|
+
measure=measures,
|
|
691
705
|
)
|
|
692
706
|
if result_estimate.estimated_bytes > MAX_RESULT_PAYLOAD_BYTES:
|
|
693
707
|
issues.append(
|
|
@@ -769,11 +783,17 @@ def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_setting
|
|
|
769
783
|
apply_plot_settings(figure, plot_settings, theme)
|
|
770
784
|
return figure
|
|
771
785
|
|
|
786
|
+
measures = result.get("measures") or [result["measure"]]
|
|
787
|
+
if len(measures) > 1:
|
|
788
|
+
return _build_multi_measure_figure(result, x_axis, plot_settings, theme, projection)
|
|
789
|
+
|
|
772
790
|
frame = pd.DataFrame(result["rows"])
|
|
773
791
|
measure = result["measure"]
|
|
774
792
|
parameter_columns = result["parameter_columns"]
|
|
775
793
|
units = result.get("units", {})
|
|
776
794
|
LOGGER.debug("Figure frame columns=%s row_count=%d", list(frame.columns), len(frame))
|
|
795
|
+
extra_axes = []
|
|
796
|
+
use_polar = False
|
|
777
797
|
|
|
778
798
|
if not parameter_columns:
|
|
779
799
|
resolved_x_axis = "index"
|
|
@@ -805,7 +825,8 @@ def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_setting
|
|
|
805
825
|
plot_kwargs["color"] = series_column
|
|
806
826
|
plot_kwargs["line_group"] = series_column
|
|
807
827
|
|
|
808
|
-
|
|
828
|
+
use_polar = (projection == "polar" or (plot_settings or {}).get("coordinate_system") == "polar") and _is_angular_unit(xaxis_unit)
|
|
829
|
+
if use_polar:
|
|
809
830
|
figure = go.Figure()
|
|
810
831
|
groups = frame.groupby(series_column, sort=False) if extra_axes else [(measure, frame)]
|
|
811
832
|
for series_name, group in groups:
|
|
@@ -835,8 +856,8 @@ def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_setting
|
|
|
835
856
|
plot_bgcolor="white",
|
|
836
857
|
margin={"l": 40, "r": 20, "t": 50, "b": 40},
|
|
837
858
|
title=f"{measure} response",
|
|
838
|
-
xaxis_title=None if
|
|
839
|
-
yaxis_title=None if
|
|
859
|
+
xaxis_title=None if use_polar else xaxis_title,
|
|
860
|
+
yaxis_title=None if use_polar else yaxis_title,
|
|
840
861
|
legend_title_text="",
|
|
841
862
|
meta={
|
|
842
863
|
"polar_axis_unit": xaxis_unit,
|
|
@@ -848,6 +869,61 @@ def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_setting
|
|
|
848
869
|
return figure
|
|
849
870
|
|
|
850
871
|
|
|
872
|
+
def _build_multi_measure_figure(result, x_axis, plot_settings, theme, projection) -> go.Figure:
|
|
873
|
+
"""Overlay comparable measures and give different units their own axes."""
|
|
874
|
+
from plotly.subplots import make_subplots
|
|
875
|
+
|
|
876
|
+
measures = result["measures"]
|
|
877
|
+
units = result.get("units", {})
|
|
878
|
+
groups: dict[str, list[str]] = {}
|
|
879
|
+
figures = {}
|
|
880
|
+
for measure in measures:
|
|
881
|
+
groups.setdefault(units.get(measure, ""), []).append(measure)
|
|
882
|
+
figures[measure] = build_figure(
|
|
883
|
+
{**result, "measure": measure, "measures": [measure]},
|
|
884
|
+
x_axis, plot_settings, theme, projection,
|
|
885
|
+
)
|
|
886
|
+
first = figures[measures[0]]
|
|
887
|
+
is_polar = bool(first.data and first.data[0].type == "scatterpolar")
|
|
888
|
+
row_count = len(groups)
|
|
889
|
+
figure = make_subplots(
|
|
890
|
+
rows=row_count, cols=1, shared_xaxes=not is_polar,
|
|
891
|
+
specs=[[{"type": "polar" if is_polar else "xy"}] for _ in groups],
|
|
892
|
+
subplot_titles=[", ".join(names) for names in groups.values()],
|
|
893
|
+
vertical_spacing=min(0.08, 0.5 / row_count),
|
|
894
|
+
)
|
|
895
|
+
for row, (unit, names) in enumerate(groups.items(), start=1):
|
|
896
|
+
for measure in names:
|
|
897
|
+
color = px.colors.qualitative.Plotly[measures.index(measure) % len(px.colors.qualitative.Plotly)]
|
|
898
|
+
for series_index, trace in enumerate(figures[measure].data):
|
|
899
|
+
trace.name = measure if not trace.name or trace.name == measure else f"{measure} | {trace.name}"
|
|
900
|
+
trace.legendgroup = measure
|
|
901
|
+
trace.showlegend = True
|
|
902
|
+
if getattr(trace, "line", None) is not None:
|
|
903
|
+
trace.line.color = color
|
|
904
|
+
if "dash" in trace.line._valid_props:
|
|
905
|
+
trace.line.dash = ("solid", "dash", "dot", "dashdot", "longdash", "longdashdot")[series_index % 6]
|
|
906
|
+
if getattr(trace, "marker", None) is not None:
|
|
907
|
+
trace.marker.color = color
|
|
908
|
+
figure.add_trace(trace, row=row, col=1)
|
|
909
|
+
if is_polar:
|
|
910
|
+
key = "polar" if row == 1 else f"polar{row}"
|
|
911
|
+
figure.update_layout({key: figures[names[0]].layout.polar.to_plotly_json()})
|
|
912
|
+
else:
|
|
913
|
+
display_unit = {"dimensionless": "", "meter ** 2": "m²", "watt": "W"}.get(unit, unit)
|
|
914
|
+
title = f"Value [{display_unit}]" if display_unit else "Value"
|
|
915
|
+
figure.update_yaxes(title_text=title, row=row, col=1)
|
|
916
|
+
figure.update_layout(meta={**(first.layout.meta or {}), "measures": measures}, barmode="group")
|
|
917
|
+
apply_plot_settings(figure, plot_settings, theme, polar_allowed=False)
|
|
918
|
+
settings = {**DEFAULT_PLOT_SETTINGS, **(plot_settings or {})}
|
|
919
|
+
if not is_polar:
|
|
920
|
+
figure.update_xaxes(showgrid=bool(settings["show_grid"]), type=settings["x_scale"])
|
|
921
|
+
figure.update_yaxes(showgrid=bool(settings["show_grid"]), type="log" if settings["log_y"] else "linear")
|
|
922
|
+
figure.update_xaxes(title_text=first.layout.xaxis.title.text, row=row_count, col=1)
|
|
923
|
+
figure.update_layout(height=max(int(settings["graph_height"]), 300 * row_count + 125))
|
|
924
|
+
return figure
|
|
925
|
+
|
|
926
|
+
|
|
851
927
|
def _resolve_x_axis(x_axis: str | None, parameter_columns: list[str]) -> str:
|
|
852
928
|
"""Resolve a selector field name against namespaced result columns."""
|
|
853
929
|
if x_axis in parameter_columns:
|
|
@@ -1010,7 +1086,7 @@ def build_summary(result: dict[str, Any] | None) -> list[dict[str, str]]:
|
|
|
1010
1086
|
LOGGER.debug("Building summary cards for measure=%s", result["measure"])
|
|
1011
1087
|
|
|
1012
1088
|
return [
|
|
1013
|
-
{"label": "
|
|
1089
|
+
{"label": "Measures", "value": ", ".join(result.get("measures") or [result["measure"]])},
|
|
1014
1090
|
{"label": "Rows", "value": str(result["row_count"])},
|
|
1015
1091
|
{"label": "Axes", "value": ", ".join(result["parameter_columns"]) or "none"},
|
|
1016
1092
|
]
|
|
@@ -188,13 +188,14 @@ def _x_axis_card(default_measure_options: list[str]):
|
|
|
188
188
|
html.Div(
|
|
189
189
|
className="graph-axis-field",
|
|
190
190
|
children=[
|
|
191
|
-
html.Label("
|
|
191
|
+
html.Label("Measures", htmlFor="measure-select"),
|
|
192
192
|
dcc.Dropdown(
|
|
193
193
|
id="measure-select",
|
|
194
194
|
className="dashboard-dropdown graph-axis-control",
|
|
195
195
|
options=[{"label": measure, "value": measure} for measure in default_measure_options],
|
|
196
|
-
value=default_measure_options[0] if default_measure_options else
|
|
197
|
-
|
|
196
|
+
value=[default_measure_options[0]] if default_measure_options else [],
|
|
197
|
+
multi=True,
|
|
198
|
+
clearable=True,
|
|
198
199
|
searchable=False,
|
|
199
200
|
optionHeight=38,
|
|
200
201
|
maxHeight=200,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Typed data contracts shared by the computation and Dash layers."""
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Any, Literal, Mapping, TypedDict
|
|
4
|
+
from typing import Any, Literal, Mapping, NotRequired, TypedDict
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
FieldValues = Mapping[str, Any]
|
|
@@ -16,6 +16,7 @@ class ExperimentResult(TypedDict):
|
|
|
16
16
|
columns: list[str]
|
|
17
17
|
parameter_columns: list[str]
|
|
18
18
|
measure: str
|
|
19
|
+
measures: NotRequired[list[str]]
|
|
19
20
|
units: dict[str, str]
|
|
20
21
|
row_count: int
|
|
21
22
|
|
|
@@ -49,7 +50,7 @@ class ExperimentRequest:
|
|
|
49
50
|
scatterer_values: FieldValues
|
|
50
51
|
detector_type: str
|
|
51
52
|
detector_values: FieldValues
|
|
52
|
-
measure: str
|
|
53
|
+
measure: str | list[str]
|
|
53
54
|
|
|
54
55
|
def as_kwargs(self) -> dict[str, Any]:
|
|
55
56
|
"""Return keyword arguments accepted by the compatibility API."""
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from dash import dcc, html
|
|
5
5
|
|
|
6
|
+
from PyMieSimX.gui.callback_helpers import status_banner
|
|
6
7
|
from PyMieSimX.gui.layout import PLOT_CONFIG, _plot_options_card, _x_axis_card, build_plot_options_sidebar, build_tabbed_sidebar
|
|
7
8
|
|
|
8
9
|
from .sections import build_detector_section, build_scatterer_section, build_source_section
|
|
@@ -43,6 +44,11 @@ def build_experiment_page(default_measure_options: list[str], plot_settings: dic
|
|
|
43
44
|
),
|
|
44
45
|
],
|
|
45
46
|
),
|
|
47
|
+
html.Div(
|
|
48
|
+
id="experiment-run-status",
|
|
49
|
+
className="experiment-run-feedback",
|
|
50
|
+
children=status_banner("idle", "Ready. Click Run to start a sweep."),
|
|
51
|
+
),
|
|
46
52
|
html.Section(className="panel graph-panel", children=[dcc.Loading(id="result-graph-loading", type="circle", color="#4f8df7", custom_spinner=html.Div("Computing…", className="plot-computing-indicator"), delay_show=150, delay_hide=150, children=dcc.Graph(id="result-graph", config=PLOT_CONFIG))]),
|
|
47
53
|
],
|
|
48
54
|
),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyMieSimX
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Graphical Dash interface for PyMieSim.
|
|
5
5
|
Author-email: Martin Poinsinet de Sivry-Houle <martin.poinsinet.de.sivry@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -112,14 +112,30 @@ The computational API can be used without constructing the Dash application::
|
|
|
112
112
|
``PyMieSimX.create_dash_app`` and ``PyMieSimX.OpticalSetupGUI`` remain
|
|
113
113
|
available for applications that need the graphical interface.
|
|
114
114
|
|
|
115
|
+
Multiple measures
|
|
116
|
+
-----------------
|
|
117
|
+
|
|
118
|
+
The Parameter Sweep Measures selector accepts several quantities in one run.
|
|
119
|
+
For example, select ``Qsca``, ``Qabs``, and ``Qext`` to compare scattering,
|
|
120
|
+
absorption, and extinction efficiencies. Measures with the same units share a
|
|
121
|
+
plot; different units use separate panels with a shared X axis. CSV exports
|
|
122
|
+
contain a column for each computed measure.
|
|
123
|
+
|
|
124
|
+
The Python API accepts either a single name or a list, for example
|
|
125
|
+
``run_experiment(..., measure=["Qsca", "Qabs", "Qext"])``. Results include
|
|
126
|
+
``measures`` with every computed name; ``measure`` retains the first name for
|
|
127
|
+
compatibility with existing single-measure callers. Size validation accounts
|
|
128
|
+
for the additional result columns.
|
|
129
|
+
|
|
115
130
|
Background calculations and limits
|
|
116
131
|
-----------------------------------
|
|
117
132
|
|
|
118
133
|
Parameter sweeps are submitted to a bounded background worker and reported to
|
|
119
|
-
the dashboard
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
the dashboard with visible queued, running, completed, and error messages beside
|
|
135
|
+
Run. The completed-run card shows the number of result rows. Oversized sweeps are
|
|
136
|
+
rejected with guidance when you click Run. Results are guarded by both an
|
|
137
|
+
in-memory dataframe limit and a serialized Dash-payload limit, with estimated
|
|
138
|
+
size validation before execution and actual size checks after computation.
|
|
123
139
|
|
|
124
140
|
Run the command-line launcher with ``--debug`` to enable detailed logs. Log
|
|
125
141
|
messages use the format ``timestamp | level | logger | message`` and include
|
|
@@ -82,14 +82,30 @@ The computational API can be used without constructing the Dash application::
|
|
|
82
82
|
``PyMieSimX.create_dash_app`` and ``PyMieSimX.OpticalSetupGUI`` remain
|
|
83
83
|
available for applications that need the graphical interface.
|
|
84
84
|
|
|
85
|
+
Multiple measures
|
|
86
|
+
-----------------
|
|
87
|
+
|
|
88
|
+
The Parameter Sweep Measures selector accepts several quantities in one run.
|
|
89
|
+
For example, select ``Qsca``, ``Qabs``, and ``Qext`` to compare scattering,
|
|
90
|
+
absorption, and extinction efficiencies. Measures with the same units share a
|
|
91
|
+
plot; different units use separate panels with a shared X axis. CSV exports
|
|
92
|
+
contain a column for each computed measure.
|
|
93
|
+
|
|
94
|
+
The Python API accepts either a single name or a list, for example
|
|
95
|
+
``run_experiment(..., measure=["Qsca", "Qabs", "Qext"])``. Results include
|
|
96
|
+
``measures`` with every computed name; ``measure`` retains the first name for
|
|
97
|
+
compatibility with existing single-measure callers. Size validation accounts
|
|
98
|
+
for the additional result columns.
|
|
99
|
+
|
|
85
100
|
Background calculations and limits
|
|
86
101
|
-----------------------------------
|
|
87
102
|
|
|
88
103
|
Parameter sweeps are submitted to a bounded background worker and reported to
|
|
89
|
-
the dashboard
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
the dashboard with visible queued, running, completed, and error messages beside
|
|
105
|
+
Run. The completed-run card shows the number of result rows. Oversized sweeps are
|
|
106
|
+
rejected with guidance when you click Run. Results are guarded by both an
|
|
107
|
+
in-memory dataframe limit and a serialized Dash-payload limit, with estimated
|
|
108
|
+
size validation before execution and actual size checks after computation.
|
|
93
109
|
|
|
94
110
|
Run the command-line launcher with ``--debug`` to enable detailed logs. Log
|
|
95
111
|
messages use the format ``timestamp | level | logger | message`` and include
|