PyMieSimX 0.1.5__tar.gz → 0.1.6__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 (94) hide show
  1. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.github/dependabot.yml +1 -2
  2. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.github/workflows/deploy_anaconda.yml +2 -0
  3. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.github/workflows/quality.yml +9 -9
  4. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.github/workflows/tests.yml +4 -3
  5. pymiesimx-0.1.6/AGENTS.md +81 -0
  6. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PKG-INFO +25 -2
  7. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/dashboard.css +81 -0
  8. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/callbacks.py +1 -1
  9. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/computation.py +35 -5
  10. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/home.py +39 -5
  11. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/usage_metrics.py +6 -0
  12. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX.egg-info/PKG-INFO +25 -2
  13. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX.egg-info/SOURCES.txt +2 -0
  14. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX.egg-info/scm_file_list.json +2 -0
  15. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX.egg-info/scm_version.json +2 -2
  16. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/README.rst +24 -1
  17. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/conda.recipe/meta.yaml +3 -1
  18. pymiesimx-0.1.6/tests/browser/test_dashboard.py +132 -0
  19. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tests/gui/test_dash_callbacks.py +53 -0
  20. pymiesimx-0.1.6/tests/gui/test_home.py +41 -0
  21. pymiesimx-0.1.5/tests/browser/test_dashboard.py +0 -50
  22. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.coveragerc +0 -0
  23. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.flake8 +0 -0
  24. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.github/workflows/deploy_PyPi.yml +0 -0
  25. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.github/workflows/deploy_coverage.yml +0 -0
  26. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.gitignore +0 -0
  27. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/.pre-commit-config.yaml +0 -0
  28. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/CONTRIBUTING.md +0 -0
  29. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/Dockerfile +0 -0
  30. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/LICENSE +0 -0
  31. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/__init__.py +0 -0
  32. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/__main__.py +0 -0
  33. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/api.py +0 -0
  34. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/application/__init__.py +0 -0
  35. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/application/wsgi.py +0 -0
  36. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/__init__.py +0 -0
  37. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/admin.py +0 -0
  38. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/card-state.js +0 -0
  39. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/default-settings.json +0 -0
  40. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/home-spf-radial.gif +0 -0
  41. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/home-spf-radial.png +0 -0
  42. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/logo.png +0 -0
  43. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/materials-stock.json +0 -0
  44. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/pymiesim-favicon.svg +0 -0
  45. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/pymiesim-logo-dark.svg +0 -0
  46. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/assets/pymiesim-logo.svg +0 -0
  47. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/callback_helpers.py +0 -0
  48. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/components/__init__.py +0 -0
  49. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/components/cards.py +0 -0
  50. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/defaults.py +0 -0
  51. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/experiment_service.py +0 -0
  52. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/interface.py +0 -0
  53. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/jobs.py +0 -0
  54. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/layout.py +0 -0
  55. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/material_catalog.py +0 -0
  56. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/models.py +0 -0
  57. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/__init__.py +0 -0
  58. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/citation.py +0 -0
  59. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/documentation.py +0 -0
  60. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/experiment/__init__.py +0 -0
  61. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/experiment/page.py +0 -0
  62. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/experiment/sections/__init__.py +0 -0
  63. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/experiment/sections/configuration.py +0 -0
  64. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/field_syntax.py +0 -0
  65. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/install_local.py +0 -0
  66. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/sellmeier.py +0 -0
  67. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/settings.py +0 -0
  68. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/single/__init__.py +0 -0
  69. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/single/page.py +0 -0
  70. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/single/sections/__init__.py +0 -0
  71. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/single/sections/representations.py +0 -0
  72. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/pages/single/sections/setup.py +0 -0
  73. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/parsing.py +0 -0
  74. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/plotting.py +0 -0
  75. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/schemas.py +0 -0
  76. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/services.py +0 -0
  77. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/single_service.py +0 -0
  78. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX/gui/validation.py +0 -0
  79. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX.egg-info/dependency_links.txt +0 -0
  80. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX.egg-info/entry_points.txt +0 -0
  81. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX.egg-info/requires.txt +0 -0
  82. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/PyMieSimX.egg-info/top_level.txt +0 -0
  83. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/makefile +0 -0
  84. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/pyproject.toml +0 -0
  85. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/pytest.ini +0 -0
  86. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/setup.cfg +0 -0
  87. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tests/gui/cli.py +0 -0
  88. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tests/gui/test_admin.py +0 -0
  89. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tests/gui/test_api.py +0 -0
  90. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tests/gui/test_services.py +0 -0
  91. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tests/gui/test_usage_metrics.py +0 -0
  92. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tools/generate_home_spf.py +0 -0
  93. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tools/next_release_version.py +0 -0
  94. {pymiesimx-0.1.5 → pymiesimx-0.1.6}/tools/release_tag.py +0 -0
@@ -6,6 +6,5 @@ updates:
6
6
  directory: "/"
7
7
  target-branch: "master"
8
8
  schedule:
9
- interval: "monthly"
10
- day: "sunday"
9
+ interval: "quarterly"
11
10
  time: "09:00"
@@ -1,6 +1,7 @@
1
1
  name: Publish Conda package
2
2
 
3
3
  on:
4
+ workflow_dispatch:
4
5
  push:
5
6
  tags: ["v*"]
6
7
 
@@ -16,5 +17,6 @@ jobs:
16
17
  package_name: pymiesimx
17
18
  recipe_path: conda.recipe
18
19
  test_import: PyMieSimX
20
+ test_command: "python -c 'import PyMieSimX' && pymiesimx --help"
19
21
  secrets:
20
22
  ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }}
@@ -16,12 +16,12 @@ jobs:
16
16
  matrix:
17
17
  python-version: ["3.11", "3.13"]
18
18
  steps:
19
- - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
20
- - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
19
+ - name: Set up Python and CI dependencies
20
+ uses: MartinPdeS/MPSActions/actions/setup-python@0f5927acf88b25b5d253421e508387897096a2cc
21
21
  with:
22
- python-version: ${{ matrix.python-version }}
23
- - name: Install package and quality tools
24
- run: python -m pip install --upgrade pip && python -m pip install -e ".[testing,dev]"
22
+ python-version: "${{ matrix.python-version }}"
23
+ install-command: >-
24
+ python -m pip install --upgrade pip && python -m pip install -e ".[testing,dev]"
25
25
  - name: Initialize material catalog
26
26
  run: python -m PyOptik setup --no-progress
27
27
  - name: Ruff
@@ -34,12 +34,12 @@ jobs:
34
34
  browser-tests:
35
35
  runs-on: ubuntu-latest
36
36
  steps:
37
- - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
38
- - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
37
+ - name: Set up Python and CI dependencies
38
+ uses: MartinPdeS/MPSActions/actions/setup-python@0f5927acf88b25b5d253421e508387897096a2cc
39
39
  with:
40
40
  python-version: "3.11"
41
- - name: Install package and browser test tools
42
- run: python -m pip install --upgrade pip && python -m pip install -e ".[testing,browser]"
41
+ install-command: >-
42
+ python -m pip install --upgrade pip && python -m pip install -e ".[testing,browser]"
43
43
  - name: Initialize material catalog
44
44
  run: python -m PyOptik setup --no-progress
45
45
  - name: Browser workflow tests
@@ -13,10 +13,11 @@ jobs:
13
13
  tests:
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
- - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
17
- - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
16
+ - name: Set up Python and CI dependencies
17
+ uses: MartinPdeS/MPSActions/actions/setup-python@0f5927acf88b25b5d253421e508387897096a2cc
18
18
  with:
19
19
  python-version: "3.11"
20
- - run: python -m pip install --upgrade pip && python -m pip install -e ".[testing]"
20
+ install-command: >-
21
+ python -m pip install --upgrade pip && python -m pip install -e ".[testing]"
21
22
  - run: python -m PyOptik setup --no-progress
22
23
  - run: python -m pytest -q
@@ -0,0 +1,81 @@
1
+ # Working on PyMieSimX
2
+
3
+ PyMieSimX provides a Dash GUI and Python API for PyMieSim. The hosted service is
4
+ https://pymiesim.onrender.com/. Keep browser use, local installation, and the
5
+ Python API working together.
6
+
7
+ ## Repository map
8
+
9
+ - `PyMieSimX/api.py` exposes the computational API. Preserve compatibility for
10
+ single-measure callers when extending sweep results.
11
+ - `PyMieSimX/gui/computation.py`, `parsing.py`, `validation.py`, and `schemas.py`
12
+ handle scientific inputs, calculations, result serialization, and plotting.
13
+ Delegate scientific solvers to PyMieSim and optical materials to PyOptik.
14
+ - `PyMieSimX/gui/jobs.py` manages bounded background sweep jobs;
15
+ `callbacks.py` connects job state, validation, plotting, and exports to Dash.
16
+ - `PyMieSimX/gui/pages/`, `components/`, and `layout.py` compose the interface.
17
+ CSS and browser assets live in `PyMieSimX/gui/assets/`.
18
+ - `PyMieSimX/application/wsgi.py` is the hosted-server entry point;
19
+ `PyMieSimX/__main__.py` provides the `pymiesimx` launcher.
20
+ - `tests/gui/` covers Python behavior and Dash callbacks. `tests/browser/`
21
+ exercises the real browser workflow separately.
22
+
23
+ ## Setup and checks
24
+
25
+ Use Python 3.11 or newer and a virtual environment. Reuse an existing suitable
26
+ environment when available; do not modify sibling repositories to set up this
27
+ one.
28
+
29
+ ```sh
30
+ python -m pip install -e ".[testing,dev,browser]"
31
+ python -m PyOptik setup --no-progress
32
+ make quality PYTHON=python
33
+ python -m pytest
34
+ python -m pytest tests/browser --config-file=pytest.ini --headless --cov=PyMieSimX --cov-report=term --cov-fail-under=0
35
+ git diff --check
36
+ ```
37
+
38
+ Browser tests need Chrome and a compatible ChromeDriver. The default pytest
39
+ suite runs `tests/gui` and enforces the coverage threshold in `.coveragerc`;
40
+ the separate browser command intentionally disables that threshold. Use a
41
+ writable `MPLCONFIGDIR` when the default Matplotlib cache is unavailable.
42
+
43
+ Run checks relevant to the change. For UI interaction changes, run the browser
44
+ suite as well as affected callback tests. Wait for observable UI state and CSS
45
+ transitions; avoid fixed sleeps, JavaScript clicks that bypass actual hit
46
+ testing, or removing assertions to make tests pass. Use explicit viewport sizes
47
+ when testing desktop and responsive layouts.
48
+
49
+ ## Implementation conventions
50
+
51
+ - Use four spaces and follow the existing Python style. Keep Ruff and the
52
+ scoped Mypy checks in `make quality` passing.
53
+ - Keep parsing and scientific computation separate from Dash rendering so the
54
+ public Python API remains usable without constructing a GUI.
55
+ - Preserve physical units, dataframe index information, and all selected
56
+ measures through serialization, plotting, and CSV export.
57
+ - Validate sweep and result-size limits before expensive work where possible.
58
+ Keep queued/running/completed/error states visible and make Run recover after
59
+ validation failures, missing jobs, cancellation, and computation errors.
60
+ - Keep usage metrics optional for local runs and protect the administration
61
+ page with its server-side token. Never commit database URLs or secrets.
62
+ - Add regression coverage for behavior changes; use asymmetric data when
63
+ checking axis orientation and multiple measures when checking sweep output.
64
+
65
+ ## Documentation, packaging, and CI
66
+
67
+ - `README.rst` uses reStructuredText. Check new sections and links render
68
+ correctly, including heading underline lengths.
69
+ - Dependencies and the console entry point are declared in `pyproject.toml`.
70
+ Keep `conda.recipe/meta.yaml` consistent with them. Conda builds are
71
+ platform-specific for Python 3.11–3.13 on Linux, macOS, and Windows; retain
72
+ the Windows dependency selectors and explicit `pymiesimx` entry point.
73
+ - The Conda workflow supports manual build verification. Publishing to Conda
74
+ and PyPI occurs on version-tag pushes. Verify the installed Conda artifact,
75
+ including both import and CLI startup, rather than only the checkout.
76
+ - Versions come from Git tags through setuptools-scm. Use the existing release
77
+ tools/Makefile when a release is requested; a normal commit/push does not
78
+ imply creating a release tag.
79
+ - Inspect failing Actions job logs and fix the underlying cause. After pushing
80
+ a CI fix, check the run for the new commit and report any remaining failures.
81
+ - Preserve unrelated working-tree changes and commit only the requested work.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyMieSimX
3
- Version: 0.1.5
3
+ Version: 0.1.6
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
@@ -36,6 +36,20 @@ PyMieSimX is the standalone graphical interface for `PyMieSim
36
36
  configuring optical setups, running parameter sweeps, exploring individual
37
37
  particles, and exporting results.
38
38
 
39
+ Try the live webapp
40
+ ----------------------
41
+
42
+ **Run Mie scattering simulations in your browser—no installation required.**
43
+
44
+ .. image:: https://img.shields.io/badge/Launch_Webapp-Open_in_browser-2672d6?style=for-the-badge
45
+ :target: https://pymiesim.onrender.com/
46
+ :alt: Launch the PyMieSimX webapp in your browser
47
+
48
+ `Launch the PyMieSimX webapp <https://pymiesim.onrender.com/>`_ to configure
49
+ optical setups, run parameter sweeps, explore individual particles, and download
50
+ your results as CSV files. The hosted service provides direct access to the
51
+ graphical interface; local installation is also available below.
52
+
39
53
  Source-model note
40
54
  -----------------
41
55
 
@@ -157,10 +171,19 @@ PyMieSimX writes namespaced counters to the shared ``metrics_counters`` table:
157
171
  backend is explicitly configured and available. Local installations do not
158
172
  persist usage counters.
159
173
 
174
+ To show a small **Webapp visits** box in the homepage's top card, set this
175
+ environment variable on the Render service and redeploy::
176
+
177
+ SHOW_METRIC=true
178
+
179
+ The box is hidden by default and requires the PostgreSQL configuration above.
180
+ It shows the total home-page visit count, including repeat visits, rather than
181
+ unique visitors. If the database is unavailable, the box stays hidden.
182
+
160
183
  Administration page
161
184
  -------------------
162
185
 
163
- Usage counters are shown only on the hidden, token-protected administration
186
+ Detailed usage counters are shown on the hidden, token-protected administration
164
187
  page. Configure a server-side token with::
165
188
 
166
189
  PYMIESIMX_ADMIN_TOKEN=<a long random value>
@@ -3865,6 +3865,87 @@ html .single-workspace .representation-card {
3865
3865
  flex-basis: auto;
3866
3866
  }
3867
3867
  }
3868
+ .home-page-hero {
3869
+ display: flex;
3870
+ align-items: center;
3871
+ justify-content: space-between;
3872
+ gap: 24px;
3873
+ flex-wrap: wrap;
3874
+ }
3875
+
3876
+ .home-hero-copy {
3877
+ flex: 1 1 360px;
3878
+ min-width: 0;
3879
+ }
3880
+
3881
+ .home-hero-actions {
3882
+ display: flex;
3883
+ align-items: center;
3884
+ justify-content: flex-end;
3885
+ flex-wrap: wrap;
3886
+ gap: 14px;
3887
+ margin-left: auto;
3888
+ }
3889
+
3890
+ .home-visit-metric {
3891
+ display: flex;
3892
+ flex-direction: column;
3893
+ align-items: center;
3894
+ gap: 3px;
3895
+ padding: 9px 16px;
3896
+ border: 1px solid #cddcec;
3897
+ border-radius: 9px;
3898
+ background: #f3f7fc;
3899
+ color: #2f4358;
3900
+ }
3901
+
3902
+ .home-visit-label {
3903
+ font-size: .72rem;
3904
+ font-weight: 600;
3905
+ }
3906
+
3907
+ .home-visit-value {
3908
+ font-size: 1.2rem;
3909
+ line-height: 1.2;
3910
+ font-variant-numeric: tabular-nums;
3911
+ }
3912
+
3913
+ .home-support-button {
3914
+ display: inline-flex;
3915
+ align-items: center;
3916
+ gap: 9px;
3917
+ flex: 0 0 auto;
3918
+ margin-left: auto;
3919
+ padding: 10px 17px;
3920
+ border: 1px solid #e8c56b;
3921
+ border-radius: 9px;
3922
+ background: linear-gradient(135deg, #fff8e3, #ffe8a9);
3923
+ color: #704912;
3924
+ font-size: .92rem;
3925
+ font-weight: 700;
3926
+ text-decoration: none;
3927
+ white-space: nowrap;
3928
+ box-shadow: 0 3px 9px rgba(151, 104, 23, .10);
3929
+ transition: background 150ms ease, box-shadow 150ms ease;
3930
+ }
3931
+
3932
+ .home-support-button:hover {
3933
+ background: #ffe49a;
3934
+ color: #58350b;
3935
+ text-decoration: none;
3936
+ box-shadow: 0 4px 12px rgba(151, 104, 23, .18);
3937
+ }
3938
+
3939
+ .home-support-button:focus-visible {
3940
+ outline: 3px solid #2672d6;
3941
+ outline-offset: 3px;
3942
+ }
3943
+
3944
+ .home-support-icon {
3945
+ color: #ad6515;
3946
+ font-size: 1.1rem;
3947
+ }
3948
+
3868
3949
  .admin-page {
3869
3950
  max-width: 1180px;
3870
3951
  margin: 0 auto;
@@ -213,7 +213,7 @@ def register_callbacks(app: Dash, default_measure_options: list[str]) -> None:
213
213
  active["experiment"] += " active"
214
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
215
215
  active["home"] += " active"
216
- return build_page_with_footer(build_home_page()), *(active[key] for key in ("home", "experiment", "single", "documentation", "settings")), home_visits
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
217
217
 
218
218
  @app.callback(
219
219
  Output("admin-home-page-visits", "children"),
@@ -3,6 +3,7 @@
3
3
 
4
4
  import logging
5
5
  import json
6
+ from base64 import b64decode
6
7
  from dataclasses import dataclass
7
8
  from math import prod
8
9
  from typing import Any, Dict
@@ -733,6 +734,16 @@ def export_result_to_csv(result: dict[str, Any] | None) -> str:
733
734
  return frame.to_csv(index=False)
734
735
 
735
736
 
737
+ def _plotly_array(value: Any) -> np.ndarray:
738
+ """Read plain arrays and Plotly 6's base64 typed-array payloads."""
739
+ if isinstance(value, dict) and "bdata" in value:
740
+ array = np.frombuffer(b64decode(value["bdata"]), dtype=value["dtype"])
741
+ if "shape" in value:
742
+ array = array.reshape(tuple(int(size) for size in value["shape"].split(",")))
743
+ return array
744
+ return np.asarray(value)
745
+
746
+
736
747
  def export_single_result_to_csv(result: dict[str, Any] | None) -> str:
737
748
  """Serialize a Particle Explorer figure payload to CSV text."""
738
749
  if not result or not result.get("figure"):
@@ -743,17 +754,36 @@ def export_single_result_to_csv(result: dict[str, Any] | None) -> str:
743
754
  rows: list[dict[str, Any]] = []
744
755
  for trace in traces:
745
756
  name = trace.get("name", "value")
757
+ if trace.get("type") == "scatterpolar":
758
+ theta_values = _plotly_array(trace.get("theta", []))
759
+ r_values = _plotly_array(trace.get("r", []))
760
+ rows.extend({"series": name, "theta": theta, "r": radius} for theta, radius in zip(theta_values, r_values))
761
+ continue
762
+
746
763
  if "z" in trace:
747
- z_values = np.asarray(trace["z"])
748
- x_values = trace.get("x") or list(range(z_values.shape[1]))
749
- y_values = trace.get("y") or list(range(z_values.shape[0]))
764
+ z_values = _plotly_array(trace["z"])
765
+ x_values = _plotly_array(trace["x"]) if "x" in trace else np.arange(z_values.shape[1])
766
+ y_values = _plotly_array(trace["y"]) if "y" in trace else np.arange(z_values.shape[0])
767
+ if trace.get("type") == "surface":
768
+ # Surface coordinates may be full grids; z is geometry, while
769
+ # surfacecolor carries the computed optical field/intensity.
770
+ values = _plotly_array(trace.get("surfacecolor", trace["z"]))
771
+ for y_index, x_index in np.ndindex(z_values.shape):
772
+ rows.append({
773
+ "series": name,
774
+ "x": x_values[y_index, x_index] if x_values.ndim == 2 else x_values[x_index],
775
+ "y": y_values[y_index, x_index] if y_values.ndim == 2 else y_values[y_index],
776
+ "z": z_values[y_index, x_index],
777
+ "value": values[y_index, x_index],
778
+ })
779
+ continue
750
780
  for y_index, y_value in enumerate(y_values):
751
781
  for x_index, x_value in enumerate(x_values):
752
782
  rows.append({"series": name, "x": x_value, "y": y_value, "value": z_values[y_index, x_index]})
753
783
  continue
754
784
 
755
- y_values = trace.get("y", [])
756
- x_values = trace.get("x") or list(range(len(y_values)))
785
+ y_values = _plotly_array(trace.get("y", []))
786
+ x_values = _plotly_array(trace["x"]) if "x" in trace else np.arange(len(y_values))
757
787
  rows.extend({"series": name, "x": x_value, "value": y_value} for x_value, y_value in zip(x_values, y_values))
758
788
 
759
789
  return pd.DataFrame(rows).to_csv(index=False) if rows else ""
@@ -1,14 +1,16 @@
1
1
  """Home page composition."""
2
2
 
3
3
  from importlib.metadata import PackageNotFoundError, version
4
+ from math import isfinite
4
5
 
5
6
  from dash import dcc, html
6
7
  import numpy as np
7
8
  import plotly.graph_objects as go
8
9
 
9
10
  from PyMieSimX.gui.components import Card
11
+ from PyMieSimX.gui.usage_metrics import show_public_metrics
10
12
 
11
- def build_home_page():
13
+ def build_home_page(*, home_visits: int | float | None = None):
12
14
  """Build the landing page and its workflow cards."""
13
15
  return html.Div(
14
16
  className="page-content-stack",
@@ -23,10 +25,29 @@ def build_home_page():
23
25
  html.Section(
24
26
  className="page-hero home-page-hero",
25
27
  children=[
26
- html.H1("PyMieSim"),
27
- html.P(
28
- "An open-source library for fast and flexible far-field Mie scattering simulations.",
29
- className="hero-text",
28
+ html.Div(
29
+ [
30
+ html.H1("PyMieSim"),
31
+ html.P(
32
+ "An open-source library for fast and flexible far-field Mie scattering simulations.",
33
+ className="hero-text",
34
+ ),
35
+ ],
36
+ className="home-hero-copy",
37
+ ),
38
+ html.Div(
39
+ [
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",
48
+ ),
49
+ ],
50
+ className="home-hero-actions",
30
51
  ),
31
52
  ],
32
53
  ),
@@ -58,6 +79,19 @@ def build_home_page():
58
79
  ],
59
80
  )
60
81
 
82
+ def _visit_metric(home_visits: int | float | None):
83
+ """Show an available server count without displaying local or failed metrics."""
84
+ if not show_public_metrics() or home_visits is None or not isfinite(home_visits) or home_visits < 0:
85
+ return None
86
+ return html.Div(
87
+ [
88
+ html.Span("Webapp visits", className="home-visit-label"),
89
+ html.Strong(f"{int(home_visits):,}", id="home-public-visit-count", className="home-visit-value"),
90
+ ],
91
+ className="home-visit-metric",
92
+ )
93
+
94
+
61
95
  def _package_version() -> str:
62
96
  try:
63
97
  return version("PyMieSimX")
@@ -15,6 +15,7 @@ except Exception: # pragma: no cover - exercised through fallback behavior.
15
15
  LOGGER = logging.getLogger(__name__)
16
16
 
17
17
  METRICS_BACKEND_ENV_VAR = "PYMIESIMX_USAGE_METRICS_BACKEND"
18
+ SHOW_METRIC_ENV_VAR = "SHOW_METRIC"
18
19
  METRICS_DATABASE_URL_ENV_VAR = "PYMIESIMX_USAGE_METRICS_DATABASE_URL"
19
20
  DATABASE_URL_ENV_VAR = "DATABASE_URL"
20
21
  METRICS_TABLE = "metrics_counters"
@@ -48,6 +49,11 @@ class UsageMetrics:
48
49
  }
49
50
 
50
51
 
52
+ def show_public_metrics() -> bool:
53
+ """Expose the visit counter only when explicitly enabled for a hosted backend."""
54
+ return os.getenv(SHOW_METRIC_ENV_VAR, "false").strip().lower() in {"true", "1", "yes", "on"} and _use_postgres_backend()
55
+
56
+
51
57
  def load_usage_metrics() -> UsageMetrics:
52
58
  """Load hosted counters, or return unavailable values when run locally."""
53
59
  if _use_postgres_backend():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyMieSimX
3
- Version: 0.1.5
3
+ Version: 0.1.6
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
@@ -36,6 +36,20 @@ PyMieSimX is the standalone graphical interface for `PyMieSim
36
36
  configuring optical setups, running parameter sweeps, exploring individual
37
37
  particles, and exporting results.
38
38
 
39
+ Try the live webapp
40
+ ----------------------
41
+
42
+ **Run Mie scattering simulations in your browser—no installation required.**
43
+
44
+ .. image:: https://img.shields.io/badge/Launch_Webapp-Open_in_browser-2672d6?style=for-the-badge
45
+ :target: https://pymiesim.onrender.com/
46
+ :alt: Launch the PyMieSimX webapp in your browser
47
+
48
+ `Launch the PyMieSimX webapp <https://pymiesim.onrender.com/>`_ to configure
49
+ optical setups, run parameter sweeps, explore individual particles, and download
50
+ your results as CSV files. The hosted service provides direct access to the
51
+ graphical interface; local installation is also available below.
52
+
39
53
  Source-model note
40
54
  -----------------
41
55
 
@@ -157,10 +171,19 @@ PyMieSimX writes namespaced counters to the shared ``metrics_counters`` table:
157
171
  backend is explicitly configured and available. Local installations do not
158
172
  persist usage counters.
159
173
 
174
+ To show a small **Webapp visits** box in the homepage's top card, set this
175
+ environment variable on the Render service and redeploy::
176
+
177
+ SHOW_METRIC=true
178
+
179
+ The box is hidden by default and requires the PostgreSQL configuration above.
180
+ It shows the total home-page visit count, including repeat visits, rather than
181
+ unique visitors. If the database is unavailable, the box stays hidden.
182
+
160
183
  Administration page
161
184
  -------------------
162
185
 
163
- Usage counters are shown only on the hidden, token-protected administration
186
+ Detailed usage counters are shown on the hidden, token-protected administration
164
187
  page. Configure a server-side token with::
165
188
 
166
189
  PYMIESIMX_ADMIN_TOKEN=<a long random value>
@@ -2,6 +2,7 @@
2
2
  .flake8
3
3
  .gitignore
4
4
  .pre-commit-config.yaml
5
+ AGENTS.md
5
6
  CONTRIBUTING.md
6
7
  Dockerfile
7
8
  LICENSE
@@ -82,6 +83,7 @@ tests/gui/cli.py
82
83
  tests/gui/test_admin.py
83
84
  tests/gui/test_api.py
84
85
  tests/gui/test_dash_callbacks.py
86
+ tests/gui/test_home.py
85
87
  tests/gui/test_services.py
86
88
  tests/gui/test_usage_metrics.py
87
89
  tools/generate_home_spf.py
@@ -10,6 +10,7 @@
10
10
  ".github/workflows/tests.yml",
11
11
  ".gitignore",
12
12
  ".pre-commit-config.yaml",
13
+ "AGENTS.md",
13
14
  "CONTRIBUTING.md",
14
15
  "Dockerfile",
15
16
  "LICENSE",
@@ -76,6 +77,7 @@
76
77
  "tests/gui/test_admin.py",
77
78
  "tests/gui/test_api.py",
78
79
  "tests/gui/test_dash_callbacks.py",
80
+ "tests/gui/test_home.py",
79
81
  "tests/gui/test_services.py",
80
82
  "tests/gui/test_usage_metrics.py",
81
83
  "tools/generate_home_spf.py",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.1.5",
2
+ "tag": "0.1.6",
3
3
  "distance": 0,
4
- "node": "gd609c6cff7e32e562d54244f48e9fe337e64b543",
4
+ "node": "gc49c47686da32457f0e821e18ec1838d9f9ca16d",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-02"
@@ -6,6 +6,20 @@ PyMieSimX is the standalone graphical interface for `PyMieSim
6
6
  configuring optical setups, running parameter sweeps, exploring individual
7
7
  particles, and exporting results.
8
8
 
9
+ Try the live webapp
10
+ ----------------------
11
+
12
+ **Run Mie scattering simulations in your browser—no installation required.**
13
+
14
+ .. image:: https://img.shields.io/badge/Launch_Webapp-Open_in_browser-2672d6?style=for-the-badge
15
+ :target: https://pymiesim.onrender.com/
16
+ :alt: Launch the PyMieSimX webapp in your browser
17
+
18
+ `Launch the PyMieSimX webapp <https://pymiesim.onrender.com/>`_ to configure
19
+ optical setups, run parameter sweeps, explore individual particles, and download
20
+ your results as CSV files. The hosted service provides direct access to the
21
+ graphical interface; local installation is also available below.
22
+
9
23
  Source-model note
10
24
  -----------------
11
25
 
@@ -127,10 +141,19 @@ PyMieSimX writes namespaced counters to the shared ``metrics_counters`` table:
127
141
  backend is explicitly configured and available. Local installations do not
128
142
  persist usage counters.
129
143
 
144
+ To show a small **Webapp visits** box in the homepage's top card, set this
145
+ environment variable on the Render service and redeploy::
146
+
147
+ SHOW_METRIC=true
148
+
149
+ The box is hidden by default and requires the PostgreSQL configuration above.
150
+ It shows the total home-page visit count, including repeat visits, rather than
151
+ unique visitors. If the database is unavailable, the box stays hidden.
152
+
130
153
  Administration page
131
154
  -------------------
132
155
 
133
- Usage counters are shown only on the hidden, token-protected administration
156
+ Detailed usage counters are shown on the hidden, token-protected administration
134
157
  page. Configure a server-side token with::
135
158
 
136
159
  PYMIESIMX_ADMIN_TOKEN=<a long random value>
@@ -7,7 +7,8 @@ source:
7
7
 
8
8
  build:
9
9
  number: 0
10
- noarch: python
10
+ entry_points:
11
+ - pymiesimx = PyMieSimX.__main__:main
11
12
  script: "{{ PYTHON }} -m pip install . -vv --no-deps --no-build-isolation"
12
13
 
13
14
  requirements:
@@ -46,6 +47,7 @@ test:
46
47
  - pytest >=8.0
47
48
  - pytest-cov >=5.0
48
49
  commands:
50
+ - pymiesimx --help
49
51
  - python -m pytest --config-file=./pytest.ini
50
52
  source_files:
51
53
  - tests
@@ -0,0 +1,132 @@
1
+ """Small browser-level smoke suite for the critical dashboard workflow."""
2
+
3
+ import pytest
4
+ import numpy as np
5
+ import pandas as pd
6
+ from pathlib import Path
7
+ from selenium.webdriver.common.keys import Keys
8
+ from selenium.webdriver.support.ui import WebDriverWait
9
+
10
+ from PyMieSimX.gui.interface import create_dash_app
11
+ from PyMieSimX.gui.jobs import ExperimentJobManager
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def isolated_experiment_jobs(dash_duo, monkeypatch):
16
+ """Finish each worker before Dash's runner tears down its server threads."""
17
+ manager = ExperimentJobManager()
18
+ monkeypatch.setattr("PyMieSimX.gui.callbacks.experiment_jobs", manager)
19
+ yield manager
20
+ manager._executor.shutdown(wait=True)
21
+
22
+
23
+ def _wait_for_sidebar_transition(dash_duo, sidebar_id="experiment-right-sidebar"):
24
+ """Wait for the panel to settle before clicking its controls or the page."""
25
+ WebDriverWait(dash_duo.driver, 10).until(
26
+ lambda driver: driver.execute_script(
27
+ "return document.getElementById(arguments[0]).getAnimations()"
28
+ ".every(animation => animation.playState !== 'running')", sidebar_id,
29
+ )
30
+ )
31
+
32
+
33
+ @pytest.mark.parametrize("viewport", [(1440, 1000), (960, 1000)], ids=["desktop", "compact"])
34
+ def test_navigation_validation_execution_plot_and_export_controls(dash_duo, viewport):
35
+ # Chrome's default headless window puts Run below the visible compact header.
36
+ dash_duo.driver.set_window_size(*viewport)
37
+ dash_duo.start_server(create_dash_app())
38
+ dash_duo.wait_for_text_to_equal("h1", "PyMieSim")
39
+ assert dash_duo.find_elements(".home-metric-value") == []
40
+
41
+ dash_duo.find_element("#sidebar-link-experiment").click()
42
+ dash_duo.wait_for_element("#result-graph")
43
+ dash_duo.wait_for_element("#export-csv")
44
+ dash_duo.wait_for_text_to_equal("#experiment-run-status .status-banner span:last-child", "Ready. Click Run to start a sweep.")
45
+ dash_duo.wait_for_element("#experiment-tab-source").click()
46
+ dash_duo.wait_for_contains_class("#experiment-right-sidebar", "open")
47
+ _wait_for_sidebar_transition(dash_duo)
48
+ field = dash_duo.wait_for_element("#source-fields .field-input")
49
+
50
+ input_element = field if field.tag_name == "input" else field.find_element("css selector", "input")
51
+ input_element.click()
52
+ input_element.clear()
53
+ input_element.send_keys("-1", Keys.ENTER)
54
+ dash_duo.wait_for_element("#source-fields .field-input-invalid")
55
+ dash_duo.find_element("#experiment-tab-source").click()
56
+ dash_duo.wait_for_class_to_equal("#experiment-right-sidebar", "right-sidebar-panel")
57
+ _wait_for_sidebar_transition(dash_duo)
58
+ dash_duo.find_element("#run-experiment-button").click()
59
+ dash_duo.wait_for_element("#experiment-run-status .status-banner.error")
60
+ assert "wavelength" in dash_duo.find_element("#experiment-run-status").text
61
+
62
+ dash_duo.wait_for_class_to_equal("#experiment-right-sidebar", "right-sidebar-panel")
63
+ dash_duo.find_element("#experiment-tab-source").click()
64
+ dash_duo.wait_for_contains_class("#experiment-right-sidebar", "open")
65
+ _wait_for_sidebar_transition(dash_duo)
66
+ input_element.click()
67
+ input_element.clear()
68
+ input_element.send_keys("600:700:3", Keys.ENTER)
69
+ dash_duo.wait_for_no_elements("#source-fields .field-input-invalid")
70
+ dash_duo.find_element("#experiment-tab-source").click()
71
+ dash_duo.wait_for_class_to_equal("#experiment-right-sidebar", "right-sidebar-panel")
72
+ _wait_for_sidebar_transition(dash_duo)
73
+ for measure in ("Qabs", "Qext"):
74
+ dash_duo.select_dcc_dropdown("#measure-select", measure)
75
+ dash_duo.find_element(".graph-axis-title").click()
76
+ dash_duo.find_element("#run-experiment-button").click()
77
+ dash_duo.wait_for_element("#experiment-run-status .status-banner.success", timeout=30)
78
+ assert "6 result rows" in dash_duo.find_element("#experiment-run-status").text
79
+ assert dash_duo.find_element("#run-experiment-button").is_enabled()
80
+ assert dash_duo.find_element("#export-csv").is_enabled()
81
+ dash_duo.wait_for_element("#result-graph .scatterlayer .trace")
82
+ groups = dash_duo.driver.execute_script("return document.querySelector('#result-graph .js-plotly-plot').data.map(trace => trace.legendgroup)")
83
+ assert set(groups) == {"Qsca", "Qabs", "Qext"}
84
+
85
+ dash_duo.find_element("#sidebar-link-single").click()
86
+ dash_duo.wait_for_element("#single-graph .plotly")
87
+ dash_duo.wait_for_element("#export-single-csv")
88
+
89
+
90
+ @pytest.mark.parametrize("representation, projection, columns, row_count", [
91
+ ("S1 / S2 amplitudes", None, ["series", "x", "value"], 48),
92
+ ("Stokes I intensity", "2D heatmap", ["series", "x", "y", "value"], 576),
93
+ ("Stokes I intensity", "3D radial surface", ["series", "x", "y", "z", "value"], 576),
94
+ ])
95
+ def test_particle_explorer_downloads_csv(dash_duo, representation, projection, columns, row_count):
96
+ dash_duo.driver.set_window_size(1440, 1000)
97
+ dash_duo.start_server(create_dash_app())
98
+ dash_duo.find_element("#sidebar-link-single").click()
99
+ dash_duo.wait_for_element("#export-single-csv")
100
+ assert not dash_duo.find_element("#export-single-csv").is_enabled()
101
+ dash_duo.select_dcc_dropdown("#single-representation", representation)
102
+ if projection:
103
+ dash_duo.find_element("#single-tab-plot-options").click()
104
+ dash_duo.wait_for_contains_class("#single-right-sidebar", "open")
105
+ _wait_for_sidebar_transition(dash_duo, "single-right-sidebar")
106
+ dash_duo.select_dcc_dropdown("#single-projection", projection)
107
+ dash_duo.find_element("#single-tab-plot-options").click()
108
+ dash_duo.wait_for_class_to_equal("#single-right-sidebar", "right-sidebar-panel")
109
+ _wait_for_sidebar_transition(dash_duo, "single-right-sidebar")
110
+ sampling = dash_duo.find_element("#single-sampling")
111
+ sampling.clear()
112
+ sampling.send_keys("24", Keys.ENTER)
113
+ dash_duo.find_element("#run-single-button").click()
114
+ WebDriverWait(dash_duo.driver, 30).until(lambda _: dash_duo.find_element("#export-single-csv").is_enabled())
115
+ dash_duo.find_element("#export-single-csv").click()
116
+ downloaded = Path(dash_duo.download_path) / "pymiesim_particle_explorer.csv"
117
+ WebDriverWait(dash_duo.driver, 10).until(lambda _: downloaded.exists() and downloaded.stat().st_size > 0)
118
+ frame = pd.read_csv(downloaded)
119
+ assert list(frame.columns) == columns
120
+ assert len(frame) == row_count
121
+ assert np.isfinite(frame[columns[1:]].to_numpy()).all()
122
+ if representation == "S1 / S2 amplitudes":
123
+ assert set(frame.series) == {"|S1|", "|S2|"}
124
+ assert frame.x.min() == pytest.approx(-180)
125
+ assert frame.x.max() == pytest.approx(180)
126
+ elif projection == "2D heatmap":
127
+ assert frame.x.nunique() == 24
128
+ assert frame.y.nunique() == 24
129
+ else:
130
+ assert (frame.value >= 0).all()
131
+ assert frame.value.nunique() > 1
132
+ assert dash_duo.get_logs() == []
@@ -1,7 +1,11 @@
1
1
  """Integration tests for registered Dash callback behavior."""
2
2
 
3
3
  from math import isnan
4
+ from io import StringIO
4
5
 
6
+ import numpy as np
7
+ import pandas as pd
8
+ import plotly.graph_objects as go
5
9
  import pytest
6
10
  from dash import no_update
7
11
 
@@ -46,6 +50,17 @@ def test_navigation_and_local_metrics(monkeypatch):
46
50
  assert callbacks._counter(float("nan")) == 0
47
51
 
48
52
 
53
+ def test_home_route_uses_newly_recorded_server_count(monkeypatch):
54
+ application = create_dash_app()
55
+ monkeypatch.setattr(usage_metrics, "record_home_page_visit", lambda: usage_metrics.UsageMetrics(home_page_visit_count=4321))
56
+ displayed = []
57
+ original = callbacks.build_home_page
58
+ monkeypatch.setattr(callbacks, "build_home_page", lambda **kwargs: displayed.append(kwargs["home_visits"]) or original(**kwargs))
59
+ home = _callback(application, "_route_pages")("/", "", 999999, 0, 0, {"theme": "light"}, {})
60
+ assert home[-1] == 4321
61
+ assert displayed == [4321]
62
+
63
+
49
64
  def test_validation_submission_cancellation_and_polling(monkeypatch):
50
65
  application = create_dash_app()
51
66
  validate = _callback(application, "_validate_fields")
@@ -105,6 +120,44 @@ def test_plot_and_csv_callbacks():
105
120
  assert "Qsca" in download["content"]
106
121
 
107
122
 
123
+ @pytest.mark.parametrize("encoded", [False, True], ids=["plain", "typed-arrays"])
124
+ @pytest.mark.parametrize("kind", ["scatter", "implicit-x", "scatterpolar", "heatmap", "surface"])
125
+ def test_particle_explorer_csv_preserves_coordinates_and_values(kind, encoded):
126
+ def array(values):
127
+ return np.array(values, dtype=float) if encoded else values
128
+
129
+ if kind in {"scatter", "implicit-x", "scatterpolar"}:
130
+ x, y = [10, 20, 40], [1, 3, 7]
131
+ if kind == "scatterpolar":
132
+ trace = go.Scatterpolar(theta=array(x), r=array(y), name="field")
133
+ expected = {"series": ["field"] * 3, "theta": x, "r": y}
134
+ else:
135
+ trace = go.Scatter(y=array(y), name="field")
136
+ if kind == "scatter":
137
+ trace.x = array(x)
138
+ expected = {"series": ["field"] * 3, "x": x if kind == "scatter" else [0, 1, 2], "value": y}
139
+ elif kind == "heatmap":
140
+ trace = go.Heatmap(x=array([10, 20, 40]), y=array([-5, 15]), z=array([[1, 2, 3], [7, 8, 9]]), name="field")
141
+ expected = {"series": ["field"] * 6, "x": [10, 20, 40] * 2, "y": [-5] * 3 + [15] * 3, "value": [1, 2, 3, 7, 8, 9]}
142
+ else:
143
+ trace = go.Surface(
144
+ x=array([[10, 20, 40], [50, 60, 80]]), y=array([[-5, -4, -3], [15, 16, 17]]),
145
+ z=array([[1, 2, 3], [7, 8, 9]]), surfacecolor=array([[11, 12, 13], [21, 22, 23]]), name="field",
146
+ )
147
+ expected = {
148
+ "series": ["field"] * 6, "x": [10, 20, 40, 50, 60, 80], "y": [-5, -4, -3, 15, 16, 17],
149
+ "z": [1, 2, 3, 7, 8, 9], "value": [11, 12, 13, 21, 22, 23],
150
+ }
151
+
152
+ result = {"figure": go.Figure(trace).to_plotly_json()}
153
+ export = _callback(create_dash_app(), "_export_single_csv")
154
+ assert export(0, result) is no_update
155
+ assert export(1, None) is no_update
156
+ download = export(1, result)
157
+ assert download["filename"] == "pymiesim_particle_explorer.csv"
158
+ pd.testing.assert_frame_equal(pd.read_csv(StringIO(download["content"])), pd.DataFrame(expected), check_dtype=False)
159
+
160
+
108
161
  @pytest.mark.parametrize("selected, expected", [
109
162
  ("Qsca", ["Qsca"]),
110
163
  (["Qsca", "coupling", "Qext"], ["Qsca", "Qext"]),
@@ -0,0 +1,41 @@
1
+ """Public visit metrics must be explicitly enabled for a hosted backend."""
2
+
3
+ import pytest
4
+
5
+ from PyMieSimX.gui import usage_metrics
6
+ from PyMieSimX.gui.pages.home import build_home_page
7
+
8
+
9
+ def _visit_values(component):
10
+ if getattr(component, "id", None) == "home-public-visit-count":
11
+ yield component.children
12
+ children = getattr(component, "children", None)
13
+ for child in children if isinstance(children, (list, tuple)) else [children]:
14
+ if child is not None and not isinstance(child, (str, int, float)):
15
+ yield from _visit_values(child)
16
+
17
+
18
+ @pytest.mark.parametrize("flag,backend,database", [
19
+ (None, "postgres", "postgresql://example/metrics"),
20
+ ("false", "postgres", "postgresql://example/metrics"),
21
+ ("true", "file", ""),
22
+ ("true", "postgres", ""),
23
+ ])
24
+ def test_visit_box_hidden_without_explicit_hosted_configuration(monkeypatch, flag, backend, database):
25
+ monkeypatch.delenv("SHOW_METRIC", raising=False)
26
+ if flag is not None:
27
+ monkeypatch.setenv("SHOW_METRIC", flag)
28
+ monkeypatch.setenv("PYMIESIMX_USAGE_METRICS_BACKEND", backend)
29
+ monkeypatch.setenv("DATABASE_URL", database)
30
+ monkeypatch.delenv("PYMIESIMX_USAGE_METRICS_DATABASE_URL", raising=False)
31
+ monkeypatch.setattr(usage_metrics, "psycopg", object())
32
+ assert list(_visit_values(build_home_page(home_visits=1234))) == []
33
+
34
+
35
+ @pytest.mark.parametrize("count,expected", [(0, ["0"]), (12345, ["12,345"]), (float("nan"), []), (None, []), (-1, [])])
36
+ def test_hosted_visit_box_formats_available_counts(monkeypatch, count, expected):
37
+ monkeypatch.setenv("SHOW_METRIC", "true")
38
+ monkeypatch.setenv("PYMIESIMX_USAGE_METRICS_BACKEND", "postgres")
39
+ monkeypatch.setenv("PYMIESIMX_USAGE_METRICS_DATABASE_URL", "postgresql://example/metrics")
40
+ monkeypatch.setattr(usage_metrics, "psycopg", object())
41
+ assert list(_visit_values(build_home_page(home_visits=count))) == expected
@@ -1,50 +0,0 @@
1
- """Small browser-level smoke suite for the critical dashboard workflow."""
2
-
3
- from selenium.webdriver.common.keys import Keys
4
-
5
- from PyMieSimX.gui.interface import create_dash_app
6
-
7
-
8
- def test_navigation_validation_execution_plot_and_export_controls(dash_duo):
9
- dash_duo.start_server(create_dash_app())
10
- dash_duo.wait_for_text_to_equal("h1", "PyMieSim")
11
- assert dash_duo.find_elements(".home-metric-value") == []
12
-
13
- dash_duo.find_element("#sidebar-link-experiment").click()
14
- dash_duo.wait_for_element("#result-graph")
15
- dash_duo.wait_for_element("#export-csv")
16
- dash_duo.wait_for_text_to_equal("#experiment-run-status .status-banner span:last-child", "Ready. Click Run to start a sweep.")
17
- dash_duo.wait_for_element("#experiment-tab-source").click()
18
- dash_duo.wait_for_contains_class("#experiment-right-sidebar", "open")
19
- field = dash_duo.wait_for_element("#source-fields .field-input")
20
-
21
- input_element = field if field.tag_name == "input" else field.find_element("css selector", "input")
22
- input_element.click()
23
- input_element.send_keys(Keys.CONTROL, "a")
24
- input_element.send_keys("-1", Keys.ENTER)
25
- dash_duo.wait_for_element("#source-fields .field-input-invalid")
26
- dash_duo.find_element("#run-experiment-button").click()
27
- dash_duo.wait_for_element("#experiment-run-status .status-banner.error")
28
- assert "wavelength" in dash_duo.find_element("#experiment-run-status").text
29
-
30
- dash_duo.wait_for_class_to_equal("#experiment-right-sidebar", "right-sidebar-panel")
31
- dash_duo.find_element("#experiment-tab-source").click()
32
- dash_duo.wait_for_contains_class("#experiment-right-sidebar", "open")
33
- input_element.click()
34
- input_element.send_keys(Keys.CONTROL, "a")
35
- input_element.send_keys("600:700:3", Keys.ENTER)
36
- for measure in ("Qabs", "Qext"):
37
- dash_duo.select_dcc_dropdown("#measure-select", measure)
38
- dash_duo.find_element(".graph-axis-title").click()
39
- dash_duo.find_element("#run-experiment-button").click()
40
- dash_duo.wait_for_element("#experiment-run-status .status-banner.success", timeout=30)
41
- assert "6 result rows" in dash_duo.find_element("#experiment-run-status").text
42
- assert dash_duo.find_element("#run-experiment-button").is_enabled()
43
- assert dash_duo.find_element("#export-csv").is_enabled()
44
- dash_duo.wait_for_element("#result-graph .scatterlayer .trace")
45
- groups = dash_duo.driver.execute_script("return document.querySelector('#result-graph .js-plotly-plot').data.map(trace => trace.legendgroup)")
46
- assert set(groups) == {"Qsca", "Qabs", "Qext"}
47
-
48
- dash_duo.find_element("#sidebar-link-single").click()
49
- dash_duo.wait_for_element("#single-graph .plotly")
50
- dash_duo.wait_for_element("#export-single-csv")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes