PyMieSimX 0.1.6__tar.gz → 0.2.0__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.

Potentially problematic release.


This version of PyMieSimX might be problematic. Click here for more details.

Files changed (99) hide show
  1. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.github/workflows/deploy_anaconda.yml +8 -1
  2. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PKG-INFO +50 -3
  3. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/__init__.py +4 -0
  4. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/api.py +4 -0
  5. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/dashboard.css +18 -3
  6. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/callbacks.py +44 -13
  7. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/computation.py +2 -2
  8. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/interface.py +1 -1
  9. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/layout.py +5 -3
  10. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/documentation.py +6 -5
  11. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/experiment/__init__.py +1 -1
  12. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/experiment/page.py +2 -2
  13. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/field_syntax.py +1 -1
  14. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/home.py +53 -11
  15. pymiesimx-0.2.0/PyMieSimX/gui/pages/population.py +221 -0
  16. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/sellmeier.py +1 -1
  17. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/settings.py +2 -2
  18. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/parsing.py +11 -0
  19. pymiesimx-0.2.0/PyMieSimX/gui/population_service.py +186 -0
  20. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/schemas.py +6 -0
  21. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX.egg-info/PKG-INFO +50 -3
  22. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX.egg-info/SOURCES.txt +5 -1
  23. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX.egg-info/requires.txt +1 -1
  24. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX.egg-info/scm_file_list.json +5 -1
  25. pymiesimx-0.2.0/PyMieSimX.egg-info/scm_version.json +8 -0
  26. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/README.rst +48 -1
  27. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/conda.recipe/meta.yaml +4 -1
  28. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/pyproject.toml +1 -1
  29. pymiesimx-0.2.0/tests/browser/test_dashboard.py +364 -0
  30. pymiesimx-0.2.0/tests/gui/test_population.py +226 -0
  31. pymiesimx-0.2.0/tools/verify_conda_artifact.py +28 -0
  32. pymiesimx-0.1.6/PyMieSimX.egg-info/scm_version.json +0 -8
  33. pymiesimx-0.1.6/tests/browser/test_dashboard.py +0 -132
  34. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.coveragerc +0 -0
  35. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.flake8 +0 -0
  36. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.github/dependabot.yml +0 -0
  37. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.github/workflows/deploy_PyPi.yml +0 -0
  38. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.github/workflows/deploy_coverage.yml +0 -0
  39. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.github/workflows/quality.yml +0 -0
  40. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.github/workflows/tests.yml +0 -0
  41. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.gitignore +0 -0
  42. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/.pre-commit-config.yaml +0 -0
  43. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/AGENTS.md +0 -0
  44. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/CONTRIBUTING.md +0 -0
  45. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/Dockerfile +0 -0
  46. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/LICENSE +0 -0
  47. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/__main__.py +0 -0
  48. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/application/__init__.py +0 -0
  49. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/application/wsgi.py +0 -0
  50. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/__init__.py +0 -0
  51. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/admin.py +0 -0
  52. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/card-state.js +0 -0
  53. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/default-settings.json +0 -0
  54. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/home-spf-radial.gif +0 -0
  55. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/home-spf-radial.png +0 -0
  56. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/logo.png +0 -0
  57. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/materials-stock.json +0 -0
  58. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/pymiesim-favicon.svg +0 -0
  59. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/pymiesim-logo-dark.svg +0 -0
  60. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/assets/pymiesim-logo.svg +0 -0
  61. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/callback_helpers.py +0 -0
  62. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/components/__init__.py +0 -0
  63. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/components/cards.py +0 -0
  64. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/defaults.py +0 -0
  65. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/experiment_service.py +0 -0
  66. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/jobs.py +0 -0
  67. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/material_catalog.py +0 -0
  68. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/models.py +0 -0
  69. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/__init__.py +0 -0
  70. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/citation.py +0 -0
  71. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/experiment/sections/__init__.py +0 -0
  72. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/experiment/sections/configuration.py +0 -0
  73. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/install_local.py +0 -0
  74. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/single/__init__.py +0 -0
  75. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/single/page.py +0 -0
  76. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/single/sections/__init__.py +0 -0
  77. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/single/sections/representations.py +0 -0
  78. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/pages/single/sections/setup.py +0 -0
  79. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/plotting.py +0 -0
  80. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/services.py +0 -0
  81. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/single_service.py +0 -0
  82. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/usage_metrics.py +0 -0
  83. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX/gui/validation.py +0 -0
  84. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX.egg-info/dependency_links.txt +0 -0
  85. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX.egg-info/entry_points.txt +0 -0
  86. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/PyMieSimX.egg-info/top_level.txt +0 -0
  87. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/makefile +0 -0
  88. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/pytest.ini +0 -0
  89. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/setup.cfg +0 -0
  90. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tests/gui/cli.py +0 -0
  91. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tests/gui/test_admin.py +0 -0
  92. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tests/gui/test_api.py +0 -0
  93. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tests/gui/test_dash_callbacks.py +0 -0
  94. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tests/gui/test_home.py +0 -0
  95. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tests/gui/test_services.py +0 -0
  96. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tests/gui/test_usage_metrics.py +0 -0
  97. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tools/generate_home_spf.py +0 -0
  98. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tools/next_release_version.py +0 -0
  99. {pymiesimx-0.1.6 → pymiesimx-0.2.0}/tools/release_tag.py +0 -0
@@ -17,6 +17,13 @@ jobs:
17
17
  package_name: pymiesimx
18
18
  recipe_path: conda.recipe
19
19
  test_import: PyMieSimX
20
- test_command: "python -c 'import PyMieSimX' && pymiesimx --help"
20
+ # Shell startup in the reusable workflow reactivates base, even through
21
+ # conda run. Invoke the installed environment's interpreter directly.
22
+ test_command: >-
23
+ if [ -f '${test_prefix}/python.exe' ]; then
24
+ '${test_prefix}/python.exe' '${GITHUB_WORKSPACE}/tools/verify_conda_artifact.py';
25
+ else
26
+ '${test_prefix}/bin/python' '${GITHUB_WORKSPACE}/tools/verify_conda_artifact.py';
27
+ fi
21
28
  secrets:
22
29
  ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyMieSimX
3
- Version: 0.1.6
3
+ Version: 0.2.0
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
@@ -8,7 +8,7 @@ Project-URL: Repository, https://github.com/MartinPdeS/PyMieSimX
8
8
  Requires-Python: >=3.11
9
9
  Description-Content-Type: text/x-rst
10
10
  License-File: LICENSE
11
- Requires-Dist: PyMieSim<6,>=5.6.4
11
+ Requires-Dist: PyMieSim<6,>=5.7.1
12
12
  Requires-Dist: PyOptik<4,>=3.2.1
13
13
  Requires-Dist: dash<5,>=4.1.0
14
14
  Requires-Dist: gunicorn<24,>=23.0.0
@@ -50,6 +50,53 @@ optical setups, run parameter sweeps, explore individual particles, and download
50
50
  your results as CSV files. The hosted service provides direct access to the
51
51
  graphical interface; local installation is also available below.
52
52
 
53
+ Ensemble Optics
54
+ ---------------
55
+
56
+ Open **Ensemble Optics** to compute the properties of a homogeneous spherical
57
+ particle population at one vacuum wavelength, without a parameter sweep. The
58
+ collapsible Particle, Distribution, and Concentration side tabs hold the inputs;
59
+ the Model tab explains the assumptions and averaging. Choose a monodisperse,
60
+ truncated Gaussian, lognormal, or uniform number-based diameter distribution
61
+ and supply a number concentration (particles/mL) or volume fraction.
62
+ Gaussian width is a standard deviation in nm; lognormal width is a geometric
63
+ standard deviation (greater than 1 and at most 3). Gaussian bounds are positive
64
+ and the distribution is renormalized within them. Named optical materials are
65
+ resolved through PyOptik, or you can enter a particle index ``n+ik`` and a real
66
+ medium index directly. Both fields use the same RI/Material toggle as the other
67
+ workspaces; switching back to RI restores the previously entered index.
68
+
69
+ Results include scattering, particle absorption, extinction, reduced scattering
70
+ (in inverse millimeters), population single-scattering albedo, effective
71
+ anisotropy, averaged cross sections, and both number- and area-weighted
72
+ efficiencies. Cross sections are number averages; area efficiencies divide mean
73
+ cross section by mean projected area. Effective anisotropy is weighted by
74
+ scattering cross section. Population albedo is mean scattering cross section
75
+ divided by mean extinction cross section, rather than a number average of
76
+ individual albedos. Undefined ratios are displayed as undefined.
77
+
78
+ The calculation assumes independent scattering and excludes particle
79
+ interactions, multiple scattering, and host-medium absorption. Use dilute
80
+ populations. Preview the discrete number fractions, increase sampling to check
81
+ convergence, and export results with units, definitions, inputs, and weights.
82
+ PyMieSim 5.7.1 or newer supplies the distribution and averaging routines.
83
+
84
+ The same calculation is available without constructing the GUI::
85
+
86
+ from PyMieSimX import compute_population_optics, export_population_to_csv
87
+
88
+ result = compute_population_optics(
89
+ wavelength_nm=650,
90
+ material="1.5+0.01j",
91
+ medium="1.33",
92
+ distribution="lognormal",
93
+ diameter_nm=500,
94
+ width=1.2,
95
+ concentration=1e9,
96
+ concentration_basis="number",
97
+ )
98
+ csv_text = export_population_to_csv(result)
99
+
53
100
  Source-model note
54
101
  -----------------
55
102
 
@@ -129,7 +176,7 @@ available for applications that need the graphical interface.
129
176
  Multiple measures
130
177
  -----------------
131
178
 
132
- The Parameter Sweep Measures selector accepts several quantities in one run.
179
+ The Parameter Scan Measures selector accepts several quantities in one run.
133
180
  For example, select ``Qsca``, ``Qabs``, and ``Qext`` to compare scattering,
134
181
  absorption, and extinction efficiencies. Measures with the same units share a
135
182
  plot; different units use separate panels with a shared X axis. CSV exports
@@ -10,6 +10,8 @@ from PyMieSimX.api import (
10
10
  ResultSizeEstimate,
11
11
  ResultSizeLimitError,
12
12
  ValidationIssue,
13
+ compute_population_optics,
14
+ export_population_to_csv,
13
15
  available_measures,
14
16
  build_figure,
15
17
  build_single_figure,
@@ -27,6 +29,8 @@ except PackageNotFoundError:
27
29
  __version__ = "0+unknown"
28
30
 
29
31
  __all__ = [
32
+ "compute_population_optics",
33
+ "export_population_to_csv",
30
34
  "MAX_SWEEP_COMBINATIONS",
31
35
  "MAX_RESULT_FRAME_BYTES",
32
36
  "MAX_RESULT_PAYLOAD_BYTES",
@@ -19,7 +19,11 @@ from PyMieSimX.gui.services import (
19
19
  validate_experiment_inputs,
20
20
  )
21
21
 
22
+ from PyMieSimX.gui.population_service import compute_population_optics, export_population_to_csv
23
+
22
24
  __all__ = [
25
+ "compute_population_optics",
26
+ "export_population_to_csv",
23
27
  "MAX_SWEEP_COMBINATIONS",
24
28
  "MAX_RESULT_FRAME_BYTES",
25
29
  "MAX_RESULT_PAYLOAD_BYTES",
@@ -3717,8 +3717,8 @@ html .plot-options-card .plot-option-control.dashboard-dropdown .Select-control
3717
3717
  font-weight: 650;
3718
3718
  }
3719
3719
 
3720
- /* Match Particle Explorer representation controls to the compact Graph Axes
3721
- card used by Parameter Sweep. */
3720
+ /* Match Single Scatterer representation controls to the compact Graph Axes
3721
+ card used by Parameter Scan. */
3722
3722
  .single-workspace .representation-card {
3723
3723
  width: 100%;
3724
3724
  max-width: none;
@@ -3910,12 +3910,17 @@ html .single-workspace .representation-card {
3910
3910
  font-variant-numeric: tabular-nums;
3911
3911
  }
3912
3912
 
3913
+ .home-support-actions {
3914
+ display: flex;
3915
+ flex-direction: column;
3916
+ gap: 9px;
3917
+ }
3918
+
3913
3919
  .home-support-button {
3914
3920
  display: inline-flex;
3915
3921
  align-items: center;
3916
3922
  gap: 9px;
3917
3923
  flex: 0 0 auto;
3918
- margin-left: auto;
3919
3924
  padding: 10px 17px;
3920
3925
  border: 1px solid #e8c56b;
3921
3926
  border-radius: 9px;
@@ -3992,3 +3997,13 @@ html .single-workspace .representation-card {
3992
3997
  grid-template-columns: 1fr;
3993
3998
  }
3994
3999
  }
4000
+
4001
+ /* Ensemble Optics shares the workspace toolbar and collapsible side panels. */
4002
+ .population-workspace, .population-output { display: grid; gap: 20px; min-width: 0; }
4003
+ .population-page .field-block { margin-bottom: 14px; }
4004
+ .population-page .field-block small { display: block; margin-top: 6px; opacity: .75; }
4005
+ .population-page .field-input { width: 100%; }
4006
+ .population-page .documentation-table { width: 100%; }
4007
+ .population-page .documentation-table td { overflow-wrap: anywhere; }
4008
+ #population-status, #population-preview-error { margin-bottom: 12px; }
4009
+ #population-results { overflow-x: auto; }
@@ -18,7 +18,10 @@ from PyMieSimX.gui.pages.install_local import build_install_local_page
18
18
  from PyMieSimX.gui.pages.experiment import build_experiment_page
19
19
  from PyMieSimX.gui.pages.settings import build_settings_page
20
20
  from PyMieSimX.gui.pages.single import build_single_page
21
- from PyMieSimX.gui.schemas import DETECTOR_FIELDS, SCATTERER_FIELDS, SINGLE_SCATTERER_FIELDS, SINGLE_SOURCE_FIELDS, SOURCE_FIELDS
21
+ from PyMieSimX.gui.pages.population import build_population_page, register_population_callbacks
22
+ from PyMieSimX.gui.parsing import is_numeric_material_value
23
+ from PyMieSimX.gui.population_service import parse_population_material
24
+ from PyMieSimX.gui.schemas import POPULATION_OPTICAL_FIELDS, DETECTOR_FIELDS, SCATTERER_FIELDS, SINGLE_SCATTERER_FIELDS, SINGLE_SOURCE_FIELDS, SOURCE_FIELDS
22
25
  from PyMieSimX.gui.callback_helpers import (
23
26
  execute_single_callback,
24
27
  merge_local_plot_values as _merge_local_plot_values,
@@ -59,6 +62,7 @@ def _counter(value: object) -> int:
59
62
 
60
63
  def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
61
64
  """Register all dashboard callbacks."""
65
+ register_population_callbacks(app)
62
66
  LOGGER.debug("Registering dashboard callbacks")
63
67
 
64
68
  @app.callback(
@@ -151,6 +155,25 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
151
155
  return _handle_sidebar_tabs(tab_ids, ["yellow", "blue", "cyan", "purple"], current_class, current_active, "experiment-right-sidebar")
152
156
 
153
157
 
158
+ @app.callback(
159
+ Output("population-right-sidebar", "className"),
160
+ Output("population-right-sidebar-active", "data"),
161
+ *[Output(f"population-tab-{name}", "className") for name in ("particle", "distribution", "concentration", "model")],
162
+ *[Output(f"population-panel-{name}", "style") for name in ("particle", "distribution", "concentration", "model")],
163
+ *[Input(f"population-tab-{name}", "n_clicks") for name in ("particle", "distribution", "concentration", "model")],
164
+ Input("page-content", "n_clicks"),
165
+ Input("dashboard-sidebar", "n_clicks"),
166
+ Input("population-right-sidebar", "n_clicks"),
167
+ State("population-right-sidebar", "className"),
168
+ State("population-right-sidebar-active", "data"),
169
+ prevent_initial_call=True,
170
+ )
171
+ def _handle_population_sidebar_tabs(_particle_clicks, _distribution_clicks, _concentration_clicks, _model_clicks,
172
+ _page_clicks, _sidebar_clicks, _right_sidebar_clicks, current_class, current_active):
173
+ tab_ids = [f"population-tab-{name}" for name in ("particle", "distribution", "concentration", "model")]
174
+ return _handle_sidebar_tabs(tab_ids, ["blue", "yellow", "cyan", "purple"], current_class, current_active, "population-right-sidebar")
175
+
176
+
154
177
  @app.callback(
155
178
  Output("page-content", "children"),
156
179
  Output("sidebar-link-home", "className"),
@@ -158,6 +181,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
158
181
  Output("sidebar-link-single", "className"),
159
182
  Output("sidebar-link-documentation", "className"),
160
183
  Output("sidebar-link-settings", "className"),
184
+ Output("sidebar-link-population", "className"),
161
185
  Output("home-visit-count", "data"),
162
186
  Input("url", "pathname"),
163
187
  Input("url", "search"),
@@ -176,6 +200,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
176
200
  "single": "sidebar-link",
177
201
  "documentation": "sidebar-link",
178
202
  "settings": "sidebar-link",
203
+ "population": "sidebar-link",
179
204
  }
180
205
  home_visits = _counter(home_visits)
181
206
  if route == "/":
@@ -187,33 +212,36 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
187
212
  home_visits = float("nan")
188
213
  if route == "/admin":
189
214
  token = parse_qs((search or "").lstrip("?")).get("token", [None])[0]
190
- return build_page_with_footer(build_admin_page(token)), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
215
+ return build_page_with_footer(build_admin_page(token)), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
191
216
  if route == "/documentation":
192
217
  active["documentation"] += " active"
193
- return build_page_with_footer(build_documentation_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
218
+ return build_page_with_footer(build_documentation_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
194
219
  if route == "/citation":
195
220
  active["home"] += " active"
196
- return build_page_with_footer(build_citation_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
221
+ return build_page_with_footer(build_citation_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
197
222
  if route == "/documentation/install-local":
198
223
  active["documentation"] += " active"
199
- return build_page_with_footer(build_install_local_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
224
+ return build_page_with_footer(build_install_local_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
200
225
  if route == "/documentation/sellmeier":
201
226
  active["documentation"] += " active"
202
- return build_page_with_footer(build_sellmeier_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
227
+ return build_page_with_footer(build_sellmeier_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
203
228
  if route == "/documentation/field-syntax":
204
229
  active["documentation"] += " active"
205
- return build_page_with_footer(build_field_syntax_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
230
+ return build_page_with_footer(build_field_syntax_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
206
231
  if route == "/settings":
207
232
  active["settings"] += " active"
208
- return build_page_with_footer(build_settings_page((theme_store or {}).get("theme", "light"), plot_settings or {})), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
233
+ return build_page_with_footer(build_settings_page((theme_store or {}).get("theme", "light"), plot_settings or {})), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
209
234
  if route == "/single":
210
235
  active["single"] += " active"
211
- return build_page_with_footer(build_single_page((plot_settings or {}).get("particle_explorer", {}))), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
236
+ return build_page_with_footer(build_single_page((plot_settings or {}).get("particle_explorer", {}))), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
237
+ if route == "/population":
238
+ active["population"] += " active"
239
+ return build_page_with_footer(build_population_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
212
240
  if route == "/experiment":
213
241
  active["experiment"] += " active"
214
- return build_page_with_footer(build_experiment_page(default_measure_options, plot_settings or {})), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
242
+ return build_page_with_footer(build_experiment_page(default_measure_options, plot_settings or {})), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
215
243
  active["home"] += " active"
216
- return build_page_with_footer(build_home_page(home_visits=home_visits)), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
244
+ return build_page_with_footer(build_home_page(home_visits=home_visits)), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings", "population")), home_visits
217
245
 
218
246
  @app.callback(
219
247
  Output("admin-home-page-visits", "children"),
@@ -316,6 +344,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
316
344
  "detector": detector_type,
317
345
  "single-source": single_source_type,
318
346
  "single-scatterer": single_scatterer_type,
347
+ "population": "Sphere",
319
348
  }
320
349
  schema_groups = {
321
350
  "source": SOURCE_FIELDS,
@@ -323,6 +352,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
323
352
  "detector": DETECTOR_FIELDS,
324
353
  "single-source": SINGLE_SOURCE_FIELDS,
325
354
  "single-scatterer": SINGLE_SCATTERER_FIELDS,
355
+ "population": {"Sphere": POPULATION_OPTICAL_FIELDS},
326
356
  }
327
357
 
328
358
  classes = []
@@ -340,7 +370,8 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
340
370
  valid = field.optional and empty
341
371
  if not empty:
342
372
  try:
343
- parsed_value = _parse_field_value(field.kind, raw_value, field.unit)
373
+ parsed_value = (parse_population_material(raw_value, medium=field.name == "medium")
374
+ if section == "population" else _parse_field_value(field.kind, raw_value, field.unit))
344
375
  if field.name in {"wavelength", "optical_power", "numerical_aperture", "amplitude", "diameter", "core_diameter", "shell_thickness", "sampling"}:
345
376
  _validate_positive_field(field.name, parsed_value)
346
377
  valid = True
@@ -403,7 +434,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
403
434
  dropdown_style = {} if use_named_material else {"display": "none"}
404
435
  ri_value = stored_ri_value or "1.4"
405
436
 
406
- if current_value and not any(char.isalpha() for char in str(current_value)):
437
+ if current_value and is_numeric_material_value(current_value):
407
438
  ri_value = current_value
408
439
 
409
440
  if use_named_material and selected_material:
@@ -745,9 +745,9 @@ def _plotly_array(value: Any) -> np.ndarray:
745
745
 
746
746
 
747
747
  def export_single_result_to_csv(result: dict[str, Any] | None) -> str:
748
- """Serialize a Particle Explorer figure payload to CSV text."""
748
+ """Serialize a Single Scatterer figure payload to CSV text."""
749
749
  if not result or not result.get("figure"):
750
- LOGGER.debug("Particle Explorer CSV export requested without a result")
750
+ LOGGER.debug("Single Scatterer CSV export requested without a result")
751
751
  return ""
752
752
 
753
753
  traces = result["figure"].get("data", [])
@@ -38,7 +38,7 @@ def create_dash_app() -> Dash:
38
38
  """Create and configure the experiment dashboard Dash application."""
39
39
  application = Dash(
40
40
  __name__,
41
- title="PyMieSim Parameter Sweep Lab",
41
+ title="PyMieSim",
42
42
  assets_folder=str(Path(__file__).with_name("assets")),
43
43
  suppress_callback_exceptions=True,
44
44
  )
@@ -5,6 +5,7 @@ from dash import dcc, html
5
5
 
6
6
  from PyMieSimX.gui.defaults import DEFAULT_APPLICATION_SETTINGS, DEFAULT_PLOT_SETTINGS
7
7
  from PyMieSimX.gui.material_catalog import material_dropdown_options
8
+ from PyMieSimX.gui.parsing import is_numeric_material_value
8
9
  from PyMieSimX.gui.schemas import FieldSpec, SECTION_FIELDS, SINGLE_SCATTERER_FIELDS, SINGLE_SOURCE_FIELDS
9
10
 
10
11
 
@@ -305,8 +306,9 @@ def _build_sidebar():
305
306
  className="sidebar-nav",
306
307
  children=[
307
308
  _sidebar_link("Home", "/"),
308
- _sidebar_link("Particle Explorer", "/single"),
309
- _sidebar_link("Parameter Sweep", "/experiment"),
309
+ _sidebar_link("Single Scatterer", "/single"),
310
+ _sidebar_link("Parameter Scan", "/experiment"),
311
+ _sidebar_link("Ensemble Optics", "/population"),
310
312
  _sidebar_link("Documentation", "/documentation"),
311
313
  _sidebar_link("Settings", "/settings"),
312
314
  ],
@@ -360,7 +362,7 @@ def render_field(section: str, field_spec: FieldSpec):
360
362
  if field_spec.kind in {"material", "medium"}:
361
363
  is_medium = field_spec.kind == "medium"
362
364
  default_text = str(field_spec.default)
363
- default_is_named = any(char.isalpha() for char in default_text)
365
+ default_is_named = bool(default_text.strip()) and not is_numeric_material_value(default_text)
364
366
  named_options = material_dropdown_options(medium=is_medium)
365
367
  default_from_catalog = named_options[0]["value"] if named_options else None
366
368
  named_values = {option["value"] for option in named_options}
@@ -19,8 +19,8 @@ def build_documentation_page():
19
19
  html.Div(
20
20
  className="documentation-hero-actions",
21
21
  children=[
22
- html.A("Start a parameter sweep →", href="/experiment", className="home-button home-button-primary"),
23
- html.A("Open Particle Explorer →", href="/single", className="home-button home-button-outline"),
22
+ html.A("Open Parameter Scan →", href="/experiment", className="home-button home-button-primary"),
23
+ html.A("Open Single Scatterer →", href="/single", className="home-button home-button-outline"),
24
24
  ],
25
25
  ),
26
26
  ],
@@ -32,7 +32,7 @@ def build_documentation_page():
32
32
  html.Div(
33
33
  className="card-body documentation-quickstart-body",
34
34
  children=[
35
- _step("01", "Choose a workspace", "Use Parameter Sweep for parameter sweeps and detector coupling. Use Particle Explorer to inspect one optical setup."),
35
+ _step("01", "Choose a workspace", "Use Parameter Scan for parameter sweeps and detector coupling. Use Single Scatterer to inspect one optical setup."),
36
36
  _step("02", "Configure the objects", "Pick a source, scatterer, and—when needed—a detector. Each card exposes the fields supported by that model."),
37
37
  _step("03", "Enter values or sweeps", "Use one value for a focused run, a comma-separated list for selected values, or start:end:count for an evenly spaced sweep."),
38
38
  _step("04", "Render and inspect", "Run the calculation, select the X axis when several fields vary, and use Settings to tune the resulting plots."),
@@ -43,8 +43,9 @@ def build_documentation_page():
43
43
  html.Div(
44
44
  className="documentation-grid documentation-topic-grid",
45
45
  children=[
46
- _guide_card("yellow", "Parameter Sweep", "Sweep a parameter space", "Build a source, scatterer, and detector, then evaluate a measure over every combination.", ["X-axis is inferred from fields with multiple values.", "Detector-free runs are supported.", "Results can be exported as CSV."], "/experiment", "Open Parameter Sweep"),
47
- _guide_card("blue", "Particle Explorer", "Understand one setup", "Inspect angular amplitudes, polarization, phase functions, or far-field intensity for one source and scatterer.", ["Choose a representation.", "Control angular sampling.", "Render the figure on demand."], "/single", "Open Particle Explorer"),
46
+ _guide_card("yellow", "Parameter Scan", "Sweep a parameter space", "Build a source, scatterer, and detector, then evaluate a measure over every combination.", ["X-axis is inferred from fields with multiple values.", "Detector-free runs are supported.", "Results can be exported as CSV."], "/experiment", "Open Parameter Scan"),
47
+ _guide_card("blue", "Single Scatterer", "Understand one setup", "Inspect angular amplitudes, polarization, phase functions, or far-field intensity for one source and scatterer.", ["Choose a representation.", "Control angular sampling.", "Render the figure on demand."], "/single", "Open Single Scatterer"),
48
+ _guide_card("orange", "Ensemble Optics", "Calculate population properties", "Define a particle size distribution and concentration at one wavelength.", ["Gaussian, lognormal, uniform, or monodisperse sizes.", "Scattering, absorption, albedo, and averaged efficiencies.", "Export results and distribution weights as CSV."], "/population", "Open Ensemble Optics"),
48
49
  _guide_card("green", "Material model", "Sellmeier relation", "Understand dispersive refractive-index modeling and check the coefficient table used for material workflows.", ["See the Sellmeier equation.", "Review coefficient values.", "Map values to material inputs."], "/documentation/sellmeier", "Open Sellmeier reference"),
49
50
  _guide_card("blue", "Input reference", "Field syntax & vocabulary", "Learn compact field notation, detector definitions, and the model terms used throughout the dashboard.", ["Enter scalars, lists, and ranges.", "Understand detector choices.", "Review supported model families."], "/documentation/field-syntax", "Open field reference"),
50
51
  _guide_card("cyan", "Plot settings", "Make figures your own", "Set typography, line and marker sizes, grid visibility, legend visibility, and light or dark plot styling.", ["Preferences are saved in this browser.", "Settings apply to both workspaces.", "The application theme is controlled here."], "/settings", "Open Settings"),
@@ -1,4 +1,4 @@
1
- """Parameter Sweep page and sections."""
1
+ """Parameter Scan page and sections."""
2
2
 
3
3
  from .page import build_experiment_page
4
4
 
@@ -1,4 +1,4 @@
1
- """Parameter Sweep page composition."""
1
+ """Parameter Scan page composition."""
2
2
 
3
3
 
4
4
  from dash import dcc, html
@@ -10,7 +10,7 @@ from .sections import build_detector_section, build_scatterer_section, build_sou
10
10
 
11
11
 
12
12
  def build_experiment_page(default_measure_options: list[str], plot_settings: dict | None = None):
13
- """Build the isolated Parameter Sweep workspace."""
13
+ """Build the isolated Parameter Scan workspace."""
14
14
  settings = (plot_settings or {}).get("parameter_sweep", {})
15
15
  return html.Div(
16
16
  className="tab-content-stack experiment-tab-content",
@@ -39,7 +39,7 @@ def build_field_syntax_page():
39
39
  className=Card.classes(color="blue", extra="panel documentation-note"),
40
40
  children=[
41
41
  html.Div(className="card-header panel-header", children=[html.H2("Continue exploring")]),
42
- html.Div(className="card-body documentation-note-body", children=[html.P("Use these definitions while configuring a Parameter Sweep or Particle Explorer run."), html.A("Back to documentation →", href="/documentation", className="inline-action")]),
42
+ html.Div(className="card-body documentation-note-body", children=[html.P("Use these definitions while configuring a Parameter Scan or Single Scatterer run."), html.A("Back to documentation →", href="/documentation", className="inline-action")]),
43
43
  ],
44
44
  ),
45
45
  ],
@@ -38,13 +38,26 @@ def build_home_page(*, home_visits: int | float | None = None):
38
38
  html.Div(
39
39
  [
40
40
  _visit_metric(home_visits),
41
- html.A(
42
- [html.Span("\u2665", className="home-support-icon", **{"aria-hidden": "true"}), "Support Developer"],
43
- id="home-support-developer",
44
- href="https://github.com/sponsors/MartinPdeS",
45
- target="_blank",
46
- rel="noopener noreferrer",
47
- className="home-support-button",
41
+ html.Div(
42
+ [
43
+ html.A(
44
+ [html.Span("\u2665", className="home-support-icon", **{"aria-hidden": "true"}), "Support Developer"],
45
+ id="home-support-developer",
46
+ href="https://github.com/sponsors/MartinPdeS",
47
+ target="_blank",
48
+ rel="noopener noreferrer",
49
+ className="home-support-button",
50
+ ),
51
+ html.A(
52
+ [html.Span("\u2605", className="home-support-icon", **{"aria-hidden": "true"}), "Star PyMieSim on GitHub"],
53
+ id="home-star-repository",
54
+ href="https://github.com/MartinPdeS/PyMieSim",
55
+ target="_blank",
56
+ rel="noopener noreferrer",
57
+ className="home-support-button",
58
+ ),
59
+ ],
60
+ className="home-support-actions",
48
61
  ),
49
62
  ],
50
63
  className="home-hero-actions",
@@ -55,25 +68,35 @@ def build_home_page(*, home_visits: int | float | None = None):
55
68
  className="home-capability-grid",
56
69
  children=[
57
70
  _capability_card(
58
- "Particle Explorer",
71
+ "Single Scatterer",
59
72
  "Inspect one optical setup through angular scattering, polarization, phase functions, and field representations.",
60
73
  ["Configure source", "Configure scatterer", "Render representations"],
61
- "Open Particle Explorer",
74
+ "Open Single Scatterer",
62
75
  "/single",
63
76
  html.Img(src="/assets/home-spf-radial.gif", className="home-capability-preview home-capability-preview-image", alt="Rotating scattering phase function 3D radial surface"),
64
77
  "Scattering phase function — rotating 3D radial surface",
65
78
  "purple",
66
79
  ),
67
80
  _capability_card(
68
- "Parameter Sweep",
81
+ "Parameter Scan",
69
82
  "Run source, scatterer, and detector configurations across parameter sweeps and export structured results for analysis.",
70
83
  ["Configure source", "Configure scatterer and detector", "Run and export results"],
71
- "Open Parameter Sweep",
84
+ "Open Parameter Scan",
72
85
  "/experiment",
73
86
  _qsca_preview(),
74
87
  "Scattering-efficiency sweep",
75
88
  "green",
76
89
  ),
90
+ _capability_card(
91
+ "Ensemble Optics",
92
+ "Turn a particle size distribution and concentration into scattering, absorption, albedo, and averaged optical properties at one wavelength.",
93
+ ["Define particle properties", "Choose a distribution and concentration", "Compute all properties and export"],
94
+ "Open Ensemble Optics",
95
+ "/population",
96
+ _distribution_preview(),
97
+ "Gaussian and lognormal size distributions",
98
+ "blue",
99
+ ),
77
100
  ],
78
101
  ),
79
102
  ],
@@ -115,6 +138,7 @@ def _capability_card(
115
138
  className="home-capability-preview",
116
139
  )
117
140
  return html.Section(
141
+ id=f"home-workflow-{href.strip('/')}",
118
142
  className=Card.classes(color=color, extra="home-capability-card"),
119
143
  children=[
120
144
  html.Div(title, className="home-section-header"),
@@ -207,3 +231,21 @@ def _qsca_preview() -> go.Figure:
207
231
  margin={"t": 32},
208
232
  )
209
233
  return figure
234
+
235
+
236
+ def _distribution_preview() -> go.Figure:
237
+ """Illustrate Gaussian and lognormal particle-size probability densities."""
238
+ diameter = np.linspace(100, 1000, 240)
239
+ sigma_log = np.log(1.3)
240
+ lognormal = np.exp(-0.5 * (np.log(diameter / 450) / sigma_log) ** 2) / (diameter * sigma_log * np.sqrt(2 * np.pi))
241
+ gaussian = np.exp(-0.5 * ((diameter - 520) / 80) ** 2) / (80 * np.sqrt(2 * np.pi))
242
+ figure = go.Figure([
243
+ go.Scatter(x=diameter, y=lognormal, mode="lines", name="Lognormal", hoverinfo="skip",
244
+ line={"color": "#2672d6", "width": 3}, fill="tozeroy", fillcolor="rgba(38,114,214,.15)"),
245
+ go.Scatter(x=diameter, y=gaussian, mode="lines", name="Gaussian", hoverinfo="skip",
246
+ line={"color": "#b47b1d", "width": 3, "dash": "dot"}),
247
+ ])
248
+ figure = _preview_layout(figure, x_title="Diameter (nm)", y_title="Probability density (nm⁻¹)")
249
+ figure.update_layout(showlegend=True, margin={"t": 32},
250
+ legend={"orientation": "h", "x": 0.5, "xanchor": "center", "y": 1.13, "font": {"size": 10}})
251
+ return figure