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.
Files changed (93) hide show
  1. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PKG-INFO +21 -5
  2. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/dashboard.css +17 -0
  3. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/callback_helpers.py +2 -2
  4. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/callbacks.py +53 -22
  5. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/computation.py +90 -13
  6. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/layout.py +4 -3
  7. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/models.py +3 -2
  8. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/experiment/page.py +6 -0
  9. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/validation.py +1 -0
  10. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/PKG-INFO +21 -5
  11. pymiesimx-0.1.5/PyMieSimX.egg-info/scm_version.json +8 -0
  12. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/README.rst +20 -4
  13. pymiesimx-0.1.5/tests/browser/test_dashboard.py +50 -0
  14. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_dash_callbacks.py +137 -8
  15. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_services.py +121 -1
  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.5}/.coveragerc +0 -0
  19. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.flake8 +0 -0
  20. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/dependabot.yml +0 -0
  21. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/deploy_PyPi.yml +0 -0
  22. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/deploy_anaconda.yml +0 -0
  23. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/deploy_coverage.yml +0 -0
  24. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/quality.yml +0 -0
  25. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.github/workflows/tests.yml +0 -0
  26. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.gitignore +0 -0
  27. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/.pre-commit-config.yaml +0 -0
  28. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/CONTRIBUTING.md +0 -0
  29. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/Dockerfile +0 -0
  30. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/LICENSE +0 -0
  31. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/__init__.py +0 -0
  32. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/__main__.py +0 -0
  33. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/api.py +0 -0
  34. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/application/__init__.py +0 -0
  35. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/application/wsgi.py +0 -0
  36. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/__init__.py +0 -0
  37. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/admin.py +0 -0
  38. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/card-state.js +0 -0
  39. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/default-settings.json +0 -0
  40. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/home-spf-radial.gif +0 -0
  41. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/home-spf-radial.png +0 -0
  42. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/logo.png +0 -0
  43. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/materials-stock.json +0 -0
  44. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/pymiesim-favicon.svg +0 -0
  45. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/pymiesim-logo-dark.svg +0 -0
  46. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/assets/pymiesim-logo.svg +0 -0
  47. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/components/__init__.py +0 -0
  48. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/components/cards.py +0 -0
  49. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/defaults.py +0 -0
  50. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/experiment_service.py +0 -0
  51. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/interface.py +0 -0
  52. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/jobs.py +0 -0
  53. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/material_catalog.py +0 -0
  54. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/__init__.py +0 -0
  55. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/citation.py +0 -0
  56. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/documentation.py +0 -0
  57. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/experiment/__init__.py +0 -0
  58. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/experiment/sections/__init__.py +0 -0
  59. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/experiment/sections/configuration.py +0 -0
  60. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/field_syntax.py +0 -0
  61. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/home.py +0 -0
  62. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/install_local.py +0 -0
  63. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/sellmeier.py +0 -0
  64. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/settings.py +0 -0
  65. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/__init__.py +0 -0
  66. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/page.py +0 -0
  67. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/sections/__init__.py +0 -0
  68. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/sections/representations.py +0 -0
  69. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/pages/single/sections/setup.py +0 -0
  70. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/parsing.py +0 -0
  71. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/plotting.py +0 -0
  72. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/schemas.py +0 -0
  73. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/services.py +0 -0
  74. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/single_service.py +0 -0
  75. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX/gui/usage_metrics.py +0 -0
  76. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/SOURCES.txt +0 -0
  77. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/dependency_links.txt +0 -0
  78. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/entry_points.txt +0 -0
  79. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/requires.txt +0 -0
  80. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/scm_file_list.json +0 -0
  81. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/PyMieSimX.egg-info/top_level.txt +0 -0
  82. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/conda.recipe/meta.yaml +0 -0
  83. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/makefile +0 -0
  84. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/pyproject.toml +0 -0
  85. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/pytest.ini +0 -0
  86. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/setup.cfg +0 -0
  87. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/cli.py +0 -0
  88. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_admin.py +0 -0
  89. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_api.py +0 -0
  90. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tests/gui/test_usage_metrics.py +0 -0
  91. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tools/generate_home_spf.py +0 -0
  92. {pymiesimx-0.1.3 → pymiesimx-0.1.5}/tools/next_release_version.py +0 -0
  93. {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
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 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,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
- heatmap_kwargs = {"x": x, "y": y, "z": values, "colorscale": colorscale, "colorbar": {"title": "V/m"}}
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
- dataframe = setup.get(measure, drop_unique_level=True)
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 != measure]
540
+ parameter_columns = [column for column in frame.columns if column not in measures]
533
541
 
534
542
  result: ExperimentResult = {
535
- "measure": 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
- if measure not in valid_measures:
665
- issues.append(ValidationIssue("measure", f"'{measure}' is not available for this configuration."))
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
- if projection == "polar" and _is_angular_unit(xaxis_unit):
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 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,
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": "Measure", "value": result["measure"]},
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("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.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 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.5",
3
+ "distance": 0,
4
+ "node": "gd609c6cff7e32e562d54244f48e9fe337e64b543",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-02"
8
+ }