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.
Files changed (93) hide show
  1. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PKG-INFO +21 -5
  2. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/dashboard.css +17 -0
  3. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/callback_helpers.py +2 -2
  4. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/callbacks.py +42 -22
  5. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/computation.py +88 -12
  6. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/layout.py +4 -3
  7. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/models.py +3 -2
  8. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/experiment/page.py +6 -0
  9. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/validation.py +1 -0
  10. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/PKG-INFO +21 -5
  11. pymiesimx-0.1.4/PyMieSimX.egg-info/scm_version.json +8 -0
  12. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/README.rst +20 -4
  13. pymiesimx-0.1.4/tests/browser/test_dashboard.py +50 -0
  14. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_dash_callbacks.py +124 -8
  15. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_services.py +118 -0
  16. pymiesimx-0.1.3/PyMieSimX.egg-info/scm_version.json +0 -8
  17. pymiesimx-0.1.3/tests/browser/test_dashboard.py +0 -28
  18. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.coveragerc +0 -0
  19. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.flake8 +0 -0
  20. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/dependabot.yml +0 -0
  21. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/deploy_PyPi.yml +0 -0
  22. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/deploy_anaconda.yml +0 -0
  23. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/deploy_coverage.yml +0 -0
  24. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/quality.yml +0 -0
  25. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.github/workflows/tests.yml +0 -0
  26. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.gitignore +0 -0
  27. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/.pre-commit-config.yaml +0 -0
  28. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/CONTRIBUTING.md +0 -0
  29. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/Dockerfile +0 -0
  30. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/LICENSE +0 -0
  31. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/__init__.py +0 -0
  32. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/__main__.py +0 -0
  33. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/api.py +0 -0
  34. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/application/__init__.py +0 -0
  35. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/application/wsgi.py +0 -0
  36. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/__init__.py +0 -0
  37. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/admin.py +0 -0
  38. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/card-state.js +0 -0
  39. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/default-settings.json +0 -0
  40. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/home-spf-radial.gif +0 -0
  41. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/home-spf-radial.png +0 -0
  42. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/logo.png +0 -0
  43. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/materials-stock.json +0 -0
  44. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/pymiesim-favicon.svg +0 -0
  45. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/pymiesim-logo-dark.svg +0 -0
  46. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/assets/pymiesim-logo.svg +0 -0
  47. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/components/__init__.py +0 -0
  48. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/components/cards.py +0 -0
  49. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/defaults.py +0 -0
  50. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/experiment_service.py +0 -0
  51. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/interface.py +0 -0
  52. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/jobs.py +0 -0
  53. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/material_catalog.py +0 -0
  54. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/__init__.py +0 -0
  55. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/citation.py +0 -0
  56. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/documentation.py +0 -0
  57. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/experiment/__init__.py +0 -0
  58. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/experiment/sections/__init__.py +0 -0
  59. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/experiment/sections/configuration.py +0 -0
  60. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/field_syntax.py +0 -0
  61. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/home.py +0 -0
  62. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/install_local.py +0 -0
  63. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/sellmeier.py +0 -0
  64. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/settings.py +0 -0
  65. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/__init__.py +0 -0
  66. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/page.py +0 -0
  67. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/sections/__init__.py +0 -0
  68. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/sections/representations.py +0 -0
  69. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/pages/single/sections/setup.py +0 -0
  70. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/parsing.py +0 -0
  71. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/plotting.py +0 -0
  72. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/schemas.py +0 -0
  73. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/services.py +0 -0
  74. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/single_service.py +0 -0
  75. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX/gui/usage_metrics.py +0 -0
  76. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/SOURCES.txt +0 -0
  77. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/dependency_links.txt +0 -0
  78. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/entry_points.txt +0 -0
  79. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/requires.txt +0 -0
  80. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/scm_file_list.json +0 -0
  81. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/PyMieSimX.egg-info/top_level.txt +0 -0
  82. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/conda.recipe/meta.yaml +0 -0
  83. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/makefile +0 -0
  84. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/pyproject.toml +0 -0
  85. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/pytest.ini +0 -0
  86. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/setup.cfg +0 -0
  87. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/cli.py +0 -0
  88. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_admin.py +0 -0
  89. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_api.py +0 -0
  90. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tests/gui/test_usage_metrics.py +0 -0
  91. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tools/generate_home_spf.py +0 -0
  92. {pymiesimx-0.1.3 → pymiesimx-0.1.4}/tools/next_release_version.py +0 -0
  93. {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
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 through a polling status indicator. Results are guarded by both
120
- an in-memory dataframe limit and a serialized Dash-payload limit. Large-result
121
- warnings are logged before execution; oversized results are rejected with an
122
- actionable message asking you to reduce the sweep.
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 current_measure in measures else measures[0]
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
- return no_update, no_update, True, False
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
- return no_update, no_update, False, no_update
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
- filename = f"pymiesim_{measure or 'result'}.csv"
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
- Input("single-projection", "value"),
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
- dataframe = setup.get(measure, drop_unique_level=True)
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 != measure]
539
+ parameter_columns = [column for column in frame.columns if column not in measures]
533
540
 
534
541
  result: ExperimentResult = {
535
- "measure": 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
- if measure not in valid_measures:
665
- issues.append(ValidationIssue("measure", f"'{measure}' is not available for this configuration."))
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
- if projection == "polar" and _is_angular_unit(xaxis_unit):
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 projection == "polar" and _is_angular_unit(xaxis_unit) else xaxis_title,
839
- yaxis_title=None if projection == "polar" and _is_angular_unit(xaxis_unit) else yaxis_title,
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": "Measure", "value": result["measure"]},
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("Y axis", htmlFor="measure-select"),
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 None,
197
- clearable=False,
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
  ),
@@ -12,6 +12,7 @@ from PyMieSimX.gui.computation import (
12
12
  _value_cardinality,
13
13
  estimate_result_size,
14
14
  estimate_sweep_size,
15
+ normalize_measures,
15
16
  validate_experiment_inputs,
16
17
  )
17
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyMieSimX
3
- Version: 0.1.3
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 through a polling status indicator. Results are guarded by both
120
- an in-memory dataframe limit and a serialized Dash-payload limit. Large-result
121
- warnings are logged before execution; oversized results are rejected with an
122
- actionable message asking you to reduce the sweep.
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
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.1.4",
3
+ "distance": 0,
4
+ "node": "g2e29bb996302211b093871a257400195c74429e2",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-02"
8
+ }
@@ -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 through a polling status indicator. Results are guarded by both
90
- an in-memory dataframe limit and a serialized Dash-payload limit. Large-result
91
- warnings are logged before execution; oversized results are rejected with an
92
- actionable message asking you to reduce the sweep.
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