PyMieSimX 0.1.3__tar.gz → 0.1.5__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.5}/PKG-INFO +21 -5
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/dashboard.css +17 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/callback_helpers.py +2 -2
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/callbacks.py +53 -22
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/computation.py +90 -13
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/layout.py +4 -3
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/models.py +3 -2
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/experiment/page.py +6 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/validation.py +1 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/PKG-INFO +21 -5
- pymiesimx-0.1.5/PyMieSimX.egg-info/scm_version.json +8 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/README.rst +20 -4
- pymiesimx-0.1.5/tests/browser/test_dashboard.py +50 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_dash_callbacks.py +137 -8
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_services.py +121 -1
- 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.5}/.coveragerc +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.flake8 +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/dependabot.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/deploy_PyPi.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/deploy_anaconda.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/deploy_coverage.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/quality.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/tests.yml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.gitignore +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.pre-commit-config.yaml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/CONTRIBUTING.md +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/Dockerfile +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/LICENSE +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/__main__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/api.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/application/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/application/wsgi.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/admin.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/card-state.js +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/default-settings.json +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/home-spf-radial.gif +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/home-spf-radial.png +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/logo.png +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/materials-stock.json +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/pymiesim-favicon.svg +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/pymiesim-logo-dark.svg +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/pymiesim-logo.svg +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/components/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/components/cards.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/defaults.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/experiment_service.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/interface.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/jobs.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/material_catalog.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/citation.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/documentation.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/experiment/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/experiment/sections/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/experiment/sections/configuration.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/field_syntax.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/home.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/install_local.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/sellmeier.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/settings.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/page.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/sections/__init__.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/sections/representations.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/sections/setup.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/parsing.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/plotting.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/schemas.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/services.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/single_service.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/usage_metrics.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/SOURCES.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/dependency_links.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/entry_points.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/requires.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/scm_file_list.json +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/top_level.txt +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/conda.recipe/meta.yaml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/makefile +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/pyproject.toml +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/pytest.ini +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/setup.cfg +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/cli.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_admin.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_api.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_usage_metrics.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tools/generate_home_spf.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tools/next_release_version.py +0 -0
- {pymiesimx-0.1.3 → pymiesimx-0.1.5}/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.5
|
|
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,11 @@ 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,
|
|
693
|
+
running=[
|
|
694
|
+
(Output("single-graph-loading", "display"), "show", "auto"),
|
|
695
|
+
(Output("run-single-button", "disabled"), True, False),
|
|
696
|
+
],
|
|
676
697
|
)
|
|
677
698
|
def _run_single(
|
|
678
699
|
_run_clicks: int,
|
|
@@ -689,6 +710,9 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
689
710
|
include_incident_field: list[str] | None,
|
|
690
711
|
single_runs: int,
|
|
691
712
|
):
|
|
713
|
+
if not _run_clicks:
|
|
714
|
+
return no_update, no_update
|
|
715
|
+
|
|
692
716
|
execution = execute_single_callback(
|
|
693
717
|
source_type=source_type,
|
|
694
718
|
source_values=source_values,
|
|
@@ -712,6 +736,13 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
|
|
|
712
736
|
LOGGER.exception("Failed to record PyMieSimX particle-explorer metric.")
|
|
713
737
|
return execution.result, execution.run_count
|
|
714
738
|
|
|
739
|
+
@app.callback(
|
|
740
|
+
Output("result-graph-loading", "display"),
|
|
741
|
+
Input("run-experiment-button", "disabled"),
|
|
742
|
+
)
|
|
743
|
+
def _show_experiment_computing(run_disabled: bool):
|
|
744
|
+
return "show" if run_disabled else "auto"
|
|
745
|
+
|
|
715
746
|
@app.callback(
|
|
716
747
|
Output("single-representation", "options"),
|
|
717
748
|
Output("single-representation", "value"),
|
|
@@ -336,7 +336,8 @@ def build_single_figure(
|
|
|
336
336
|
colorscale = PYMIESIM_BLUE_BLACK_RED
|
|
337
337
|
x = np.asarray(nearfields.u.to(ureg.nanometer).magnitude, dtype=float)
|
|
338
338
|
y = np.asarray(nearfields.v.to(ureg.nanometer).magnitude, dtype=float)
|
|
339
|
-
|
|
339
|
+
# PyMieSim indexes fields as (u, v); Plotly indexes z as (y, x).
|
|
340
|
+
heatmap_kwargs = {"x": x, "y": y, "z": values.T, "colorscale": colorscale, "colorbar": {"title": "V/m"}}
|
|
340
341
|
if not is_absolute:
|
|
341
342
|
maximum = float(np.max(np.abs(values)))
|
|
342
343
|
maximum = maximum if maximum > 0 else 1.0
|
|
@@ -459,6 +460,12 @@ def infer_variable_fields(
|
|
|
459
460
|
return variable_fields
|
|
460
461
|
|
|
461
462
|
|
|
463
|
+
def normalize_measures(measure: str | list[str] | None) -> list[str]:
|
|
464
|
+
"""Accept legacy single-measure input and preserve unique selection order."""
|
|
465
|
+
values = [measure] if isinstance(measure, str) else (measure or [])
|
|
466
|
+
return list(dict.fromkeys(values))
|
|
467
|
+
|
|
468
|
+
|
|
462
469
|
def run_experiment(
|
|
463
470
|
*,
|
|
464
471
|
source_type: str,
|
|
@@ -467,7 +474,7 @@ def run_experiment(
|
|
|
467
474
|
scatterer_values: Dict[str, Any],
|
|
468
475
|
detector_type: str,
|
|
469
476
|
detector_values: Dict[str, Any],
|
|
470
|
-
measure: str,
|
|
477
|
+
measure: str | list[str],
|
|
471
478
|
) -> ExperimentResult:
|
|
472
479
|
"""Execute the selected experiment and serialize the result for Dash."""
|
|
473
480
|
LOGGER.debug(
|
|
@@ -505,7 +512,8 @@ def run_experiment(
|
|
|
505
512
|
|
|
506
513
|
LOGGER.debug("Experiment setup instantiated: %r", setup)
|
|
507
514
|
|
|
508
|
-
|
|
515
|
+
measures = normalize_measures(measure)
|
|
516
|
+
dataframe = setup.get(*measures, drop_unique_level=True)
|
|
509
517
|
LOGGER.debug("Experiment returned dataframe with shape %s", getattr(dataframe, "shape", None))
|
|
510
518
|
if hasattr(dataframe, "as_dataframe"):
|
|
511
519
|
# Native LabeledArray results: convert and drop the "section:" column prefixes
|
|
@@ -529,10 +537,11 @@ def run_experiment(
|
|
|
529
537
|
for column in frame.columns:
|
|
530
538
|
frame[column] = frame[column].map(serialize_value)
|
|
531
539
|
|
|
532
|
-
parameter_columns = [column for column in frame.columns if column
|
|
540
|
+
parameter_columns = [column for column in frame.columns if column not in measures]
|
|
533
541
|
|
|
534
542
|
result: ExperimentResult = {
|
|
535
|
-
"measure":
|
|
543
|
+
"measure": measures[0],
|
|
544
|
+
"measures": measures,
|
|
536
545
|
"units": units,
|
|
537
546
|
"columns": [str(column) for column in frame.columns],
|
|
538
547
|
"parameter_columns": parameter_columns,
|
|
@@ -591,6 +600,7 @@ def estimate_result_size(
|
|
|
591
600
|
scatterer_values: Dict[str, Any],
|
|
592
601
|
detector_type: str,
|
|
593
602
|
detector_values: Dict[str, Any],
|
|
603
|
+
measure: str | list[str] | None = None,
|
|
594
604
|
) -> ResultSizeEstimate:
|
|
595
605
|
"""Estimate serialized result size before starting a computation."""
|
|
596
606
|
row_count = estimate_sweep_size(
|
|
@@ -601,7 +611,7 @@ def estimate_result_size(
|
|
|
601
611
|
detector_type=detector_type,
|
|
602
612
|
detector_values=detector_values,
|
|
603
613
|
)
|
|
604
|
-
column_count = 1 + sum(
|
|
614
|
+
column_count = max(1, len(normalize_measures(measure))) + sum(
|
|
605
615
|
len(field_groups.get(selected_type, ()))
|
|
606
616
|
for field_groups, selected_type in (
|
|
607
617
|
((SOURCE_FIELDS, source_type), (SCATTERER_FIELDS, scatterer_type), (DETECTOR_FIELDS, detector_type))
|
|
@@ -627,7 +637,7 @@ def validate_experiment_inputs(
|
|
|
627
637
|
scatterer_values: Dict[str, Any],
|
|
628
638
|
detector_type: str,
|
|
629
639
|
detector_values: Dict[str, Any],
|
|
630
|
-
measure: str,
|
|
640
|
+
measure: str | list[str] | None,
|
|
631
641
|
) -> list[ValidationIssue]:
|
|
632
642
|
"""Validate all experiment fields and return actionable user-facing issues."""
|
|
633
643
|
issues: list[ValidationIssue] = []
|
|
@@ -659,10 +669,14 @@ def validate_experiment_inputs(
|
|
|
659
669
|
except (TypeError, ValueError, KeyError) as error:
|
|
660
670
|
issues.append(ValidationIssue(field.name, str(error)))
|
|
661
671
|
|
|
672
|
+
measures = normalize_measures(measure)
|
|
673
|
+
if not measures:
|
|
674
|
+
issues.append(ValidationIssue("measure", "Select at least one measure."))
|
|
662
675
|
if scatterer_type in SCATTERER_TYPES and detector_type in DETECTOR_TYPES:
|
|
663
676
|
valid_measures = available_measures(scatterer_type, detector_type)
|
|
664
|
-
|
|
665
|
-
|
|
677
|
+
for selected_measure in measures:
|
|
678
|
+
if selected_measure not in valid_measures:
|
|
679
|
+
issues.append(ValidationIssue("measure", f"'{selected_measure}' is not available for this configuration."))
|
|
666
680
|
|
|
667
681
|
if not issues:
|
|
668
682
|
sweep_size = estimate_sweep_size(
|
|
@@ -688,6 +702,7 @@ def validate_experiment_inputs(
|
|
|
688
702
|
scatterer_values=scatterer_values,
|
|
689
703
|
detector_type=detector_type,
|
|
690
704
|
detector_values=detector_values,
|
|
705
|
+
measure=measures,
|
|
691
706
|
)
|
|
692
707
|
if result_estimate.estimated_bytes > MAX_RESULT_PAYLOAD_BYTES:
|
|
693
708
|
issues.append(
|
|
@@ -769,11 +784,17 @@ def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_setting
|
|
|
769
784
|
apply_plot_settings(figure, plot_settings, theme)
|
|
770
785
|
return figure
|
|
771
786
|
|
|
787
|
+
measures = result.get("measures") or [result["measure"]]
|
|
788
|
+
if len(measures) > 1:
|
|
789
|
+
return _build_multi_measure_figure(result, x_axis, plot_settings, theme, projection)
|
|
790
|
+
|
|
772
791
|
frame = pd.DataFrame(result["rows"])
|
|
773
792
|
measure = result["measure"]
|
|
774
793
|
parameter_columns = result["parameter_columns"]
|
|
775
794
|
units = result.get("units", {})
|
|
776
795
|
LOGGER.debug("Figure frame columns=%s row_count=%d", list(frame.columns), len(frame))
|
|
796
|
+
extra_axes = []
|
|
797
|
+
use_polar = False
|
|
777
798
|
|
|
778
799
|
if not parameter_columns:
|
|
779
800
|
resolved_x_axis = "index"
|
|
@@ -805,7 +826,8 @@ def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_setting
|
|
|
805
826
|
plot_kwargs["color"] = series_column
|
|
806
827
|
plot_kwargs["line_group"] = series_column
|
|
807
828
|
|
|
808
|
-
|
|
829
|
+
use_polar = (projection == "polar" or (plot_settings or {}).get("coordinate_system") == "polar") and _is_angular_unit(xaxis_unit)
|
|
830
|
+
if use_polar:
|
|
809
831
|
figure = go.Figure()
|
|
810
832
|
groups = frame.groupby(series_column, sort=False) if extra_axes else [(measure, frame)]
|
|
811
833
|
for series_name, group in groups:
|
|
@@ -835,8 +857,8 @@ def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_setting
|
|
|
835
857
|
plot_bgcolor="white",
|
|
836
858
|
margin={"l": 40, "r": 20, "t": 50, "b": 40},
|
|
837
859
|
title=f"{measure} response",
|
|
838
|
-
xaxis_title=None if
|
|
839
|
-
yaxis_title=None if
|
|
860
|
+
xaxis_title=None if use_polar else xaxis_title,
|
|
861
|
+
yaxis_title=None if use_polar else yaxis_title,
|
|
840
862
|
legend_title_text="",
|
|
841
863
|
meta={
|
|
842
864
|
"polar_axis_unit": xaxis_unit,
|
|
@@ -848,6 +870,61 @@ def build_figure(result: dict[str, Any] | None, x_axis: str | None, plot_setting
|
|
|
848
870
|
return figure
|
|
849
871
|
|
|
850
872
|
|
|
873
|
+
def _build_multi_measure_figure(result, x_axis, plot_settings, theme, projection) -> go.Figure:
|
|
874
|
+
"""Overlay comparable measures and give different units their own axes."""
|
|
875
|
+
from plotly.subplots import make_subplots
|
|
876
|
+
|
|
877
|
+
measures = result["measures"]
|
|
878
|
+
units = result.get("units", {})
|
|
879
|
+
groups: dict[str, list[str]] = {}
|
|
880
|
+
figures = {}
|
|
881
|
+
for measure in measures:
|
|
882
|
+
groups.setdefault(units.get(measure, ""), []).append(measure)
|
|
883
|
+
figures[measure] = build_figure(
|
|
884
|
+
{**result, "measure": measure, "measures": [measure]},
|
|
885
|
+
x_axis, plot_settings, theme, projection,
|
|
886
|
+
)
|
|
887
|
+
first = figures[measures[0]]
|
|
888
|
+
is_polar = bool(first.data and first.data[0].type == "scatterpolar")
|
|
889
|
+
row_count = len(groups)
|
|
890
|
+
figure = make_subplots(
|
|
891
|
+
rows=row_count, cols=1, shared_xaxes=not is_polar,
|
|
892
|
+
specs=[[{"type": "polar" if is_polar else "xy"}] for _ in groups],
|
|
893
|
+
subplot_titles=[", ".join(names) for names in groups.values()],
|
|
894
|
+
vertical_spacing=min(0.08, 0.5 / row_count),
|
|
895
|
+
)
|
|
896
|
+
for row, (unit, names) in enumerate(groups.items(), start=1):
|
|
897
|
+
for measure in names:
|
|
898
|
+
color = px.colors.qualitative.Plotly[measures.index(measure) % len(px.colors.qualitative.Plotly)]
|
|
899
|
+
for series_index, trace in enumerate(figures[measure].data):
|
|
900
|
+
trace.name = measure if not trace.name or trace.name == measure else f"{measure} | {trace.name}"
|
|
901
|
+
trace.legendgroup = measure
|
|
902
|
+
trace.showlegend = True
|
|
903
|
+
if getattr(trace, "line", None) is not None:
|
|
904
|
+
trace.line.color = color
|
|
905
|
+
if "dash" in trace.line._valid_props:
|
|
906
|
+
trace.line.dash = ("solid", "dash", "dot", "dashdot", "longdash", "longdashdot")[series_index % 6]
|
|
907
|
+
if getattr(trace, "marker", None) is not None:
|
|
908
|
+
trace.marker.color = color
|
|
909
|
+
figure.add_trace(trace, row=row, col=1)
|
|
910
|
+
if is_polar:
|
|
911
|
+
key = "polar" if row == 1 else f"polar{row}"
|
|
912
|
+
figure.update_layout({key: figures[names[0]].layout.polar.to_plotly_json()})
|
|
913
|
+
else:
|
|
914
|
+
display_unit = {"dimensionless": "", "meter ** 2": "m²", "watt": "W"}.get(unit, unit)
|
|
915
|
+
title = f"Value [{display_unit}]" if display_unit else "Value"
|
|
916
|
+
figure.update_yaxes(title_text=title, row=row, col=1)
|
|
917
|
+
figure.update_layout(meta={**(first.layout.meta or {}), "measures": measures}, barmode="group")
|
|
918
|
+
apply_plot_settings(figure, plot_settings, theme, polar_allowed=False)
|
|
919
|
+
settings = {**DEFAULT_PLOT_SETTINGS, **(plot_settings or {})}
|
|
920
|
+
if not is_polar:
|
|
921
|
+
figure.update_xaxes(showgrid=bool(settings["show_grid"]), type=settings["x_scale"])
|
|
922
|
+
figure.update_yaxes(showgrid=bool(settings["show_grid"]), type="log" if settings["log_y"] else "linear")
|
|
923
|
+
figure.update_xaxes(title_text=first.layout.xaxis.title.text, row=row_count, col=1)
|
|
924
|
+
figure.update_layout(height=max(int(settings["graph_height"]), 300 * row_count + 125))
|
|
925
|
+
return figure
|
|
926
|
+
|
|
927
|
+
|
|
851
928
|
def _resolve_x_axis(x_axis: str | None, parameter_columns: list[str]) -> str:
|
|
852
929
|
"""Resolve a selector field name against namespaced result columns."""
|
|
853
930
|
if x_axis in parameter_columns:
|
|
@@ -1010,7 +1087,7 @@ def build_summary(result: dict[str, Any] | None) -> list[dict[str, str]]:
|
|
|
1010
1087
|
LOGGER.debug("Building summary cards for measure=%s", result["measure"])
|
|
1011
1088
|
|
|
1012
1089
|
return [
|
|
1013
|
-
{"label": "
|
|
1090
|
+
{"label": "Measures", "value": ", ".join(result.get("measures") or [result["measure"]])},
|
|
1014
1091
|
{"label": "Rows", "value": str(result["row_count"])},
|
|
1015
1092
|
{"label": "Axes", "value": ", ".join(result["parameter_columns"]) or "none"},
|
|
1016
1093
|
]
|
|
@@ -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.5
|
|
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
|