nt2gui 0.1.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.
- nt2gui-0.1.0/.gitignore +229 -0
- nt2gui-0.1.0/PKG-INFO +56 -0
- nt2gui-0.1.0/README.md +35 -0
- nt2gui-0.1.0/hatch_build.py +17 -0
- nt2gui-0.1.0/nt2gui/__init__.py +8 -0
- nt2gui-0.1.0/nt2gui/__main__.py +5 -0
- nt2gui-0.1.0/nt2gui/api/__init__.py +12 -0
- nt2gui-0.1.0/nt2gui/api/common.py +69 -0
- nt2gui-0.1.0/nt2gui/api/fields.py +59 -0
- nt2gui-0.1.0/nt2gui/api/particles.py +62 -0
- nt2gui-0.1.0/nt2gui/api/simulations.py +24 -0
- nt2gui-0.1.0/nt2gui/api/state.py +79 -0
- nt2gui-0.1.0/nt2gui/app.py +51 -0
- nt2gui-0.1.0/nt2gui/cli.py +108 -0
- nt2gui-0.1.0/nt2gui/config.py +14 -0
- nt2gui-0.1.0/nt2gui/data/__init__.py +0 -0
- nt2gui-0.1.0/nt2gui/data/arrays.py +9 -0
- nt2gui-0.1.0/nt2gui/data/expressions.py +217 -0
- nt2gui-0.1.0/nt2gui/data/metadata.py +110 -0
- nt2gui-0.1.0/nt2gui/data/particles.py +212 -0
- nt2gui-0.1.0/nt2gui/data/quantities.py +149 -0
- nt2gui-0.1.0/nt2gui/errors.py +27 -0
- nt2gui-0.1.0/nt2gui/plots/__init__.py +5 -0
- nt2gui-0.1.0/nt2gui/plots/line.py +74 -0
- nt2gui-0.1.0/nt2gui/plots/particle_histogram.py +17 -0
- nt2gui-0.1.0/nt2gui/plots/particle_phase.py +37 -0
- nt2gui-0.1.0/nt2gui/plots/pseudocolor.py +76 -0
- nt2gui-0.1.0/nt2gui/render.py +103 -0
- nt2gui-0.1.0/nt2gui/session/__init__.py +0 -0
- nt2gui-0.1.0/nt2gui/session/files.py +106 -0
- nt2gui-0.1.0/nt2gui/session/schema.py +240 -0
- nt2gui-0.1.0/nt2gui/session/store.py +112 -0
- nt2gui-0.1.0/nt2gui/sources/__init__.py +92 -0
- nt2gui-0.1.0/nt2gui/sources/cache.py +27 -0
- nt2gui-0.1.0/nt2gui/sources/demo.py +144 -0
- nt2gui-0.1.0/nt2gui/sources/nt2.py +158 -0
- nt2gui-0.1.0/nt2gui/static/assets/index-CMkhsKnH.js +21 -0
- nt2gui-0.1.0/nt2gui/static/assets/index-CqdFF03j.css +1 -0
- nt2gui-0.1.0/nt2gui/static/index.html +13 -0
- nt2gui-0.1.0/nt2gui/workspace.py +138 -0
- nt2gui-0.1.0/pyproject.toml +46 -0
- nt2gui-0.1.0/tests/conftest.py +13 -0
- nt2gui-0.1.0/tests/helpers.py +22 -0
- nt2gui-0.1.0/tests/test_api.py +523 -0
- nt2gui-0.1.0/tests/test_cli.py +63 -0
- nt2gui-0.1.0/tests/test_expressions.py +147 -0
- nt2gui-0.1.0/tests/test_fields.py +219 -0
- nt2gui-0.1.0/tests/test_nt2loader.py +136 -0
- nt2gui-0.1.0/tests/test_particles.py +196 -0
- nt2gui-0.1.0/tests/test_pseudocolor.py +105 -0
- nt2gui-0.1.0/tests/test_render.py +67 -0
- nt2gui-0.1.0/tests/test_session_files.py +171 -0
- nt2gui-0.1.0/tests/test_session_store.py +164 -0
- nt2gui-0.1.0/tests/test_sources.py +98 -0
- nt2gui-0.1.0/tests/test_summary.py +62 -0
nt2gui-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
|
|
204
|
+
# Ruff stuff:
|
|
205
|
+
.ruff_cache/
|
|
206
|
+
|
|
207
|
+
# PyPI configuration file
|
|
208
|
+
.pypirc
|
|
209
|
+
|
|
210
|
+
# Marimo
|
|
211
|
+
marimo/_static/
|
|
212
|
+
marimo/_lsp/
|
|
213
|
+
__marimo__/
|
|
214
|
+
|
|
215
|
+
# Streamlit
|
|
216
|
+
.streamlit/secrets.toml
|
|
217
|
+
node_modules/
|
|
218
|
+
dist/
|
|
219
|
+
|
|
220
|
+
# devenv
|
|
221
|
+
.devenv*
|
|
222
|
+
devenv.local.nix
|
|
223
|
+
.pre-commit-config.yaml
|
|
224
|
+
|
|
225
|
+
# nt2gui session state
|
|
226
|
+
.nt2gui/
|
|
227
|
+
|
|
228
|
+
# Built client, bundled into the Python package
|
|
229
|
+
nt2gui/static/
|
nt2gui-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nt2gui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Browser UI for visualizing Entity simulation output, served from where the data lives.
|
|
5
|
+
Project-URL: Repository, https://github.com/haykh/nt2gui
|
|
6
|
+
Author-email: Hayk Hakobyan <hhakobyan@flatironinstitute.org>
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: flask>=3.0
|
|
9
|
+
Requires-Dist: matplotlib>=3.8
|
|
10
|
+
Requires-Dist: nt2py[hdf5]>=1.6
|
|
11
|
+
Requires-Dist: numpy>=1.26
|
|
12
|
+
Requires-Dist: pandas>=2.1
|
|
13
|
+
Requires-Dist: pydantic>=2.5
|
|
14
|
+
Requires-Dist: waitress>=3.0
|
|
15
|
+
Requires-Dist: xarray>=2024.1
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: build; extra == 'dev'
|
|
18
|
+
Requires-Dist: pyright; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# nt2gui
|
|
23
|
+
|
|
24
|
+
A browser UI for visualizing [Entity](https://github.com/entity-toolkit/entity) simulation output, read with [nt2py](https://github.com/entity-toolkit/nt2py).
|
|
25
|
+
|
|
26
|
+
The server runs where the data lives (e.g. a cluster node), does all the data processing, and serves the UI. Your browser connects to it through an SSH tunnel, so nothing needs to be installed on your laptop.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
On the cluster:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python -m venv ~/nt2gui-env
|
|
34
|
+
~/nt2gui-env/bin/pip install nt2gui
|
|
35
|
+
~/nt2gui-env/bin/nt2gui serve # --port, --host, --state-file, --threads
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
It prints the tunnel command to run on your machine, e.g.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ssh -N -L 8765:localhost:8765 you@node042 # add -J you@login-node if needed
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
then open <http://localhost:8765/>.
|
|
45
|
+
|
|
46
|
+
By default the server listens on `127.0.0.1` only. The UI session is saved to `.nt2gui/session.json` in the working directory (override with `--state-file` or `$NT2GUI_STATE_FILE`) and restored on restart.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -r requirements.txt # or: pip install -e ".[dev]"
|
|
52
|
+
npm i
|
|
53
|
+
npm run dev # Flask on :5000 and vite with /api proxied to it
|
|
54
|
+
npm run test # vitest + pytest
|
|
55
|
+
npm run package # build the client into nt2gui/static, then the wheel/sdist into dist/
|
|
56
|
+
```
|
nt2gui-0.1.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# nt2gui
|
|
2
|
+
|
|
3
|
+
A browser UI for visualizing [Entity](https://github.com/entity-toolkit/entity) simulation output, read with [nt2py](https://github.com/entity-toolkit/nt2py).
|
|
4
|
+
|
|
5
|
+
The server runs where the data lives (e.g. a cluster node), does all the data processing, and serves the UI. Your browser connects to it through an SSH tunnel, so nothing needs to be installed on your laptop.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
On the cluster:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
python -m venv ~/nt2gui-env
|
|
13
|
+
~/nt2gui-env/bin/pip install nt2gui
|
|
14
|
+
~/nt2gui-env/bin/nt2gui serve # --port, --host, --state-file, --threads
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
It prints the tunnel command to run on your machine, e.g.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
ssh -N -L 8765:localhost:8765 you@node042 # add -J you@login-node if needed
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
then open <http://localhost:8765/>.
|
|
24
|
+
|
|
25
|
+
By default the server listens on `127.0.0.1` only. The UI session is saved to `.nt2gui/session.json` in the working directory (override with `--state-file` or `$NT2GUI_STATE_FILE`) and restored on restart.
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -r requirements.txt # or: pip install -e ".[dev]"
|
|
31
|
+
npm i
|
|
32
|
+
npm run dev # Flask on :5000 and vite with /api proxied to it
|
|
33
|
+
npm run test # vitest + pytest
|
|
34
|
+
npm run package # build the client into nt2gui/static, then the wheel/sdist into dist/
|
|
35
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Refuse to build a wheel without the client, which would serve no UI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomBuildHook(BuildHookInterface):
|
|
9
|
+
def initialize(self, version, build_data):
|
|
10
|
+
if version == "editable": # served from the checkout; built on demand
|
|
11
|
+
return
|
|
12
|
+
index = Path(self.root) / "nt2gui" / "static" / "index.html"
|
|
13
|
+
if not index.is_file():
|
|
14
|
+
raise RuntimeError(
|
|
15
|
+
f"{index} is missing: run `npm run build` before building the wheel "
|
|
16
|
+
"(or use `npm run package`)."
|
|
17
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""nt2gui: a browser UI for Entity (nt2py) simulation output."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("nt2gui")
|
|
7
|
+
except PackageNotFoundError: # running from a source checkout
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The JSON API, served under `/api`: one blueprint per area."""
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint
|
|
4
|
+
|
|
5
|
+
from . import fields, particles, simulations, state
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_api() -> Blueprint:
|
|
9
|
+
api = Blueprint("api", __name__)
|
|
10
|
+
for area in (state, simulations, fields, particles):
|
|
11
|
+
api.register_blueprint(area.bp)
|
|
12
|
+
return api
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Request helpers shared by the API blueprints.
|
|
2
|
+
|
|
3
|
+
Helpers raise `UserError` (400) / `NotFound` (404); the app's error handlers
|
|
4
|
+
turn those, and pydantic `ValidationError`s, into JSON error responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
import xarray as xr
|
|
11
|
+
from flask import current_app, request
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
|
|
14
|
+
from ..data.particles import particle_dataset
|
|
15
|
+
from ..errors import UserError
|
|
16
|
+
from ..sources import SimulationSource
|
|
17
|
+
from ..workspace import Workspace
|
|
18
|
+
|
|
19
|
+
EXTENSION_KEY = "nt2gui.workspace"
|
|
20
|
+
Model = TypeVar("Model", bound=BaseModel)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def workspace() -> Workspace:
|
|
24
|
+
return current_app.extensions[EXTENSION_KEY]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def simulation(simulation_id: str) -> SimulationSource:
|
|
28
|
+
return workspace().simulation(simulation_id)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def json_object() -> dict[str, Any]:
|
|
32
|
+
"""The request body, which must be a JSON object (missing counts as {})."""
|
|
33
|
+
payload = request.get_json(silent=True)
|
|
34
|
+
if payload is None:
|
|
35
|
+
return {}
|
|
36
|
+
if not isinstance(payload, dict):
|
|
37
|
+
raise UserError("Expected a JSON object")
|
|
38
|
+
return payload
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def require_time(payload: dict[str, Any]) -> float:
|
|
42
|
+
time = payload.get("time")
|
|
43
|
+
# bool is an int subclass, but never a valid time.
|
|
44
|
+
if isinstance(time, bool) or not isinstance(time, (int, float)):
|
|
45
|
+
raise UserError("Time value is required")
|
|
46
|
+
return float(time)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_text(payload: dict[str, Any], key: str, message: str) -> str:
|
|
50
|
+
value = payload.get(key)
|
|
51
|
+
if not isinstance(value, str) or not value.strip():
|
|
52
|
+
raise UserError(message)
|
|
53
|
+
return value.strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse(model: type[Model], value: Any, what: str) -> Model:
|
|
57
|
+
try:
|
|
58
|
+
return model.model_validate(value)
|
|
59
|
+
except ValidationError as error:
|
|
60
|
+
raise UserError(
|
|
61
|
+
f"Invalid {what}", details=json.loads(error.json(include_url=False))
|
|
62
|
+
) from error
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def particles_at(source: SimulationSource, time: float) -> tuple[xr.Dataset, float]:
|
|
66
|
+
"""Particles of the output nearest to `time`, and that output's time."""
|
|
67
|
+
if not source.species:
|
|
68
|
+
raise UserError("This simulation has no particle output")
|
|
69
|
+
return particle_dataset(source.particles(time)), source.particle_time(time)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Field metadata, custom-quantity checks, and field plots."""
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint, jsonify
|
|
4
|
+
|
|
5
|
+
from ..data.metadata import field_metadata
|
|
6
|
+
from ..data.quantities import quantity_dims
|
|
7
|
+
from ..plots.line import render_line
|
|
8
|
+
from ..plots.pseudocolor import render_pseudocolor
|
|
9
|
+
from ..session.schema import LineConfig, PseudocolorConfig, Range, ZoomWindow
|
|
10
|
+
from .common import json_object, parse, require_text, require_time, simulation
|
|
11
|
+
|
|
12
|
+
bp = Blueprint("fields", __name__, url_prefix="/simulations/<simulation_id>")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@bp.get("/fields")
|
|
16
|
+
def get_fields(simulation_id: str):
|
|
17
|
+
return jsonify(field_metadata(simulation(simulation_id).fields))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@bp.post("/quantities/check")
|
|
21
|
+
def check_quantity(simulation_id: str):
|
|
22
|
+
"""Validate a field name or expression and report its spatial dims."""
|
|
23
|
+
source = simulation(simulation_id)
|
|
24
|
+
expression = require_text(json_object(), "expression", "An expression is required")
|
|
25
|
+
dims = quantity_dims(expression, source.fields)
|
|
26
|
+
return jsonify({"expression": expression, "dims": dims})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@bp.post("/render/line")
|
|
30
|
+
def line_plot(simulation_id: str):
|
|
31
|
+
source = simulation(simulation_id)
|
|
32
|
+
payload = json_object()
|
|
33
|
+
time = require_time(payload)
|
|
34
|
+
config = parse(LineConfig, payload.get("config"), "line plot request")
|
|
35
|
+
value_range = (
|
|
36
|
+
parse(Range, payload["range"], "zoom range").to_wire()
|
|
37
|
+
if payload.get("range") is not None
|
|
38
|
+
else None
|
|
39
|
+
)
|
|
40
|
+
fields = source.fields
|
|
41
|
+
return jsonify(
|
|
42
|
+
render_line(
|
|
43
|
+
fields, config.quantities, config.axis, config.slices, time, value_range
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@bp.post("/render/pseudocolor")
|
|
49
|
+
def pseudocolor_plot(simulation_id: str):
|
|
50
|
+
source = simulation(simulation_id)
|
|
51
|
+
payload = json_object()
|
|
52
|
+
time = require_time(payload)
|
|
53
|
+
config = parse(PseudocolorConfig, payload.get("config"), "pseudocolor request")
|
|
54
|
+
viewport = (
|
|
55
|
+
parse(ZoomWindow, payload["viewport"], "zoom window")
|
|
56
|
+
if payload.get("viewport") is not None
|
|
57
|
+
else None
|
|
58
|
+
)
|
|
59
|
+
return jsonify(render_pseudocolor(source.fields, config, time, viewport))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Particle metadata, custom-quantity checks, and particle plots."""
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint, jsonify
|
|
4
|
+
|
|
5
|
+
from ..data.metadata import particle_metadata
|
|
6
|
+
from ..data.particles import particle_dataset, quantity_values
|
|
7
|
+
from ..errors import UserError
|
|
8
|
+
from ..plots.particle_histogram import render_particle_histogram
|
|
9
|
+
from ..plots.particle_phase import render_particle_phase
|
|
10
|
+
from ..session.schema import ParticleHistogramConfig, ParticlePhaseConfig
|
|
11
|
+
from ..sources import first_particles
|
|
12
|
+
from .common import (
|
|
13
|
+
json_object,
|
|
14
|
+
parse,
|
|
15
|
+
particles_at,
|
|
16
|
+
require_text,
|
|
17
|
+
require_time,
|
|
18
|
+
simulation,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
bp = Blueprint("particles", __name__, url_prefix="/simulations/<simulation_id>")
|
|
22
|
+
|
|
23
|
+
# Expressions are checked on a few particles; validity doesn't depend on values.
|
|
24
|
+
CHECK_SAMPLE_SIZE = 16
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@bp.get("/particles")
|
|
28
|
+
def get_particles(simulation_id: str):
|
|
29
|
+
return jsonify(particle_metadata(simulation(simulation_id)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@bp.post("/particles/check")
|
|
33
|
+
def check_particle_quantity(simulation_id: str):
|
|
34
|
+
source = simulation(simulation_id)
|
|
35
|
+
expression = require_text(json_object(), "expression", "An expression is required")
|
|
36
|
+
if not source.species:
|
|
37
|
+
raise UserError("This simulation has no particle output")
|
|
38
|
+
sample = particle_dataset(first_particles(source).head(CHECK_SAMPLE_SIZE))
|
|
39
|
+
quantity_values(expression, sample)
|
|
40
|
+
return jsonify({"expression": expression})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@bp.post("/render/particles/histogram")
|
|
44
|
+
def histogram_plot(simulation_id: str):
|
|
45
|
+
source = simulation(simulation_id)
|
|
46
|
+
payload = json_object()
|
|
47
|
+
time = require_time(payload)
|
|
48
|
+
config = parse(ParticleHistogramConfig, payload.get("config"), "histogram request")
|
|
49
|
+
particles, timestamp = particles_at(source, time)
|
|
50
|
+
return jsonify(
|
|
51
|
+
{**render_particle_histogram(particles, config), "timestamp": timestamp}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@bp.post("/render/particles/phase")
|
|
56
|
+
def phase_plot(simulation_id: str):
|
|
57
|
+
source = simulation(simulation_id)
|
|
58
|
+
payload = json_object()
|
|
59
|
+
time = require_time(payload)
|
|
60
|
+
config = parse(ParticlePhaseConfig, payload.get("config"), "phase-space request")
|
|
61
|
+
particles, timestamp = particles_at(source, time)
|
|
62
|
+
return jsonify({**render_particle_phase(particles, config), "timestamp": timestamp})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Opening and closing simulations, and what each contains."""
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint, jsonify
|
|
4
|
+
|
|
5
|
+
from ..data.metadata import summarize
|
|
6
|
+
from .common import json_object, require_text, simulation, workspace
|
|
7
|
+
|
|
8
|
+
bp = Blueprint("simulations", __name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@bp.post("/simulations")
|
|
12
|
+
def add_simulation():
|
|
13
|
+
path = require_text(json_object(), "path", "Simulation path is required")
|
|
14
|
+
return jsonify(workspace().add(path))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@bp.delete("/simulations/<simulation_id>")
|
|
18
|
+
def remove_simulation(simulation_id: str):
|
|
19
|
+
return jsonify(workspace().remove(simulation_id))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@bp.get("/simulations/<simulation_id>/summary")
|
|
23
|
+
def get_summary(simulation_id: str):
|
|
24
|
+
return jsonify(summarize(simulation(simulation_id)))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The synced UI state, and saving/loading whole sessions."""
|
|
2
|
+
|
|
3
|
+
from flask import Blueprint, current_app, jsonify
|
|
4
|
+
|
|
5
|
+
from ..errors import Conflict, NotFound, UserError
|
|
6
|
+
from ..session.files import (
|
|
7
|
+
parse_document,
|
|
8
|
+
read_document,
|
|
9
|
+
resolve_session_path,
|
|
10
|
+
write_document,
|
|
11
|
+
)
|
|
12
|
+
from ..session.schema import UiState
|
|
13
|
+
from .common import json_object, parse, require_text, workspace
|
|
14
|
+
|
|
15
|
+
bp = Blueprint("state", __name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@bp.get("/state")
|
|
19
|
+
def get_state():
|
|
20
|
+
return jsonify(workspace().store.snapshot())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@bp.put("/state")
|
|
24
|
+
def put_state():
|
|
25
|
+
payload = json_object()
|
|
26
|
+
revision = payload.get("revision")
|
|
27
|
+
if isinstance(revision, bool) or not isinstance(revision, int):
|
|
28
|
+
raise UserError("Expected {revision: int, state: object}")
|
|
29
|
+
state = parse(UiState, payload.get("state"), "state")
|
|
30
|
+
return jsonify(workspace().store.replace_state(state, revision))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@bp.get("/session/export")
|
|
34
|
+
def export_session():
|
|
35
|
+
return jsonify(workspace().store.document().to_wire())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@bp.post("/session/import")
|
|
39
|
+
def import_session():
|
|
40
|
+
try:
|
|
41
|
+
document = parse_document(json_object().get("session"))
|
|
42
|
+
except ValueError as error:
|
|
43
|
+
raise UserError(f"Invalid session file: {error}") from error
|
|
44
|
+
return jsonify(workspace().apply_document(document))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@bp.post("/session/save")
|
|
48
|
+
def save_session():
|
|
49
|
+
payload = json_object()
|
|
50
|
+
path = resolve_session_path(
|
|
51
|
+
require_text(payload, "path", "A session file path is required")
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
write_document(
|
|
55
|
+
path, workspace().store.document(), bool(payload.get("overwrite"))
|
|
56
|
+
)
|
|
57
|
+
except FileExistsError as error:
|
|
58
|
+
raise Conflict("File already exists", path=str(path)) from error
|
|
59
|
+
except OSError as error:
|
|
60
|
+
raise UserError(f"Could not write {path}: {error.strerror or error}") from error
|
|
61
|
+
current_app.logger.info("Session saved to %s", path)
|
|
62
|
+
return jsonify({**workspace().store.snapshot(), "path": str(path)})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@bp.post("/session/load")
|
|
66
|
+
def load_session():
|
|
67
|
+
path = resolve_session_path(
|
|
68
|
+
require_text(json_object(), "path", "A session file path is required")
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
document = read_document(path)
|
|
72
|
+
except FileNotFoundError as error:
|
|
73
|
+
raise NotFound("File not found", path=str(path)) from error
|
|
74
|
+
except ValueError as error:
|
|
75
|
+
raise UserError(f"Invalid session file: {error}") from error
|
|
76
|
+
except OSError as error:
|
|
77
|
+
raise UserError(f"Could not read file: {error.strerror or error}") from error
|
|
78
|
+
current_app.logger.info("Session loaded from %s", path)
|
|
79
|
+
return jsonify({**workspace().apply_document(document), "path": str(path)})
|