Live client-server simulator of the Wigner function — W(x, p, t) over 1D space, or W(x, y, pₓ, p_y, t) over 2D space — evolved by the spectral split-operator method of Cabrera, Bondar, Jacobs and Rabitz, Efficient method to generate time evolution of the Wigner function for open quantum systems (arXiv:1212.3406) — an O(N log N) FFT scheme that propagates W directly, without ever forming the density matrix.
Up to four propagator variants run side by side in lockstep — quantum (Moyal) and classical (Liouville), each non-relativistic (T = p²/2m) and relativistic (T = √(p²c² + m²c⁴) − mc²) — so the quantum-classical and relativistic corrections are visible as differences between panels at the same physical time, not as separate runs you have to line up afterwards.
A solver backend streams quantized frames over a binary WebSocket to a Vue SPA that paints them on the GPU; the same history can be scrubbed, replayed, and exported to mp4.
- 1D or 2D space, one solver. A session picks
ndim: 1D evolves W(x, p, t) on (Nx, Np), 2D evolves W(x, y, pₓ, p_y, t) on (Nx, Ny, Npx, Npy). There is no second solver — 1D isndim=1of the same generic code, deliberately, because this project navigates by diagnostics and two propagators that could quietly disagree would poison exactly that. A 4D array can be neither drawn nor sent, so each worker reduces it on the device to the six pairwise 2D projections and one marginal per axis, and the state itself never leaves the GPU: at 64⁴ that is 50 KiB per variant per record against 33 MiB for the raw state, so scrubbing stays instant and the history cap stops being a 2D constraint. Atndim=1the single plane's complement is empty — that plane is W — so 1D is the general case rather than a special one. - Four variants in lockstep. Each variant integrates with its own adaptive timestep but lands exactly on a shared record grid τₖ = t₁ + k·Δt_rec, so the same record index is the same physical time in every panel.
- Analytic potentials. Type U(x) — or U(x, y) in 2D — as an expression (sympy-parsed, screened, differentiated and lambdified) with an instant plot preview and per-variant validity reporting: a Heaviside step is quantum-valid but classically undefined, and the UI says so instead of producing nonsense. In 2D the editor draws the two axis cuts as separate charts, each labelled with the coordinate its cut was actually taken at.
- Initial conditions. Sums of Gaussians with independent σ per axis, and cat states with the analytic pairwise cross-Wigner term. Both families factorise over dimensions, so 2D needed no new closed form — a 2D pair's cross-Wigner is the outer product of the two 1D cores, and the 1D expressions are reused verbatim per dimension.
- Live parameters. U(x), c, mass, ħ_eff, tolerance and time direction apply at the computation frontier without a restart; every change is logged and reproduced in the export's metadata block.
- Auto-expanding domain. The spectral domain is a torus, so a spreading state eventually wraps and the run silently solves the wrong problem. Edge mass is watched every record — at either dimensionality — and with auto-expand on the grid regrids onto an exact fixed lattice (whole-cell shift or power-of-two doubling, never interpolated) before any mass wraps. In 2D a doubling doubles a multi-GiB working set, so the planner asks the driver whether the devices can afford it first, and says which one cannot when they cannot — a shift costs nothing and is never subject to that, so a drifting state keeps getting relief even on a full card.
- Diagnostics. Energy, one ΔQ·ΔK per dimension and purity γ = (2πħ)^ndim ∫W² per record, with a marginal per axis alongside the phase-space panels — plus ⟨L_z⟩(t) in 2D. Everything but the purity is computed from the same reductions the panels are drawn from, leaving ∫W² as the only pass over the full array.
- mp4 export. Any already-computed record range is rendered on
the backend (matplotlib → ffmpeg/libx264) into a video that reads like the
screen, with a metadata block documenting the run — and the same document
embedded in the file's
commenttag, so a kept video restores its own setup on import. - Multi-GPU. Variant workers spread across a pool of CUDA devices, the costliest variants to the fastest card (measured +41% for a 4-variant run at 1024² across an RTX 3090 + 2080 Ti pair). Runs CPU-only without CUDA.
Units are Hartree atomic units throughout (ħ = mₑ = e = 1, c ≈ 137.036); SI (fs / Å / eV) appears in display labels only.
Nothing. Every feature is available at either dimensionality: the relativistic variants and float32 (2026-07-27), mp4 export of a 2D run (2026-07-28) and auto-expand (2026-08-01) were each gated behind an explicit refusal while the work they named was outstanding, and all four gates are gone.
Two of them carry a caveat worth knowing. The relativistic variants restore the anharmonic-shear diagnostic in 2D; the one to know is that a massless (m = 0) relativistic run loses purity at a rate set by how well the momentum grid resolves the kink in T = c|p| — ~7e-6 per 100 steps at 32⁴, falling ~10× at 48⁴, independent of the timestep, so the remedy is a finer momentum axis and not a smaller dt.
float32 is the other, and in 2D it is a memory setting rather than a speed one: it costs 96 bytes per cell against float64's 176, but it is only 1.5–2.6× faster here against 3.3–3.8× in 1D, because the 4D step transforms two axes at a time and single precision gains far less on that. Two things to expect from it. It is still a preview mode, so purity and energy drift with the same secular signature as boundary wrap. And at a coarse 2D grid it moves enough mass outward by itself to raise the boundary warning on a perfectly contained state — measured 1e-3 of the integral at 32⁴ against 3e-5 for the same state in float64, within seconds. 48⁴ and above are clear. If that warning appears and purity has drifted too, the remedy is float64 or a finer grid, not a wider box.
Sizing a 2D run: a worker costs ~176 B per cell in float64 and ~96 in float32,
both flat across sizes, of which the state itself is only 5% (the rest is the
step's machinery at full shape). That is 0.17 GiB per worker at 32⁴ and 2.75 GiB
at 64⁴ in float64, or 0.09 and 1.50 in float32 — and 80⁴ (6.71 against 3.66) is
reachable only in single precision on these cards. Measured on an RTX 3090:
610 steps/s at 32⁴, 130 at 48⁴, 35.1 at 64⁴, 13.8 at 80⁴ — so 32⁴ is for
exploration and 64⁴ is a serious run. Reproduce any of it with
scripts/bench.py --ndim 2 --precision both [--footprint]. Whether a session starts is not
decided by a cell count but by asking the driver how much memory the cards its
workers actually land on have free; see WIGNERF_MAX_CELLS_2D below.
- Python 3.12 and uv
- Node.js 20+ (Vite 8)
- Optional, for GPU: an NVIDIA driver — no system CUDA Toolkit. CuPy
JIT-compiles its kernels through NVRTC and gets the headers from PyPI via the
[ctk]extra. - Optional, for mp4 export: a system
ffmpegwith libx264. Without it the export endpoint returns 503; everything else works.
git clone git@github.com:tigran123/wignerf.git
cd wignerfstart.sh only runs the server — it never installs or builds. Create the
venv and build the SPA once after cloning, and again after pulling. This keeps
the systemd service sandboxed with a read-only home; installing or building
inside the service would need write access to ~/.cache/uv, node_modules,
frontend/dist and more.
Backend — create the venv, install the pinned dependencies, and precompile
bytecode. The service runs with a read-only home, so Python can never write
__pycache__/*.pyc at runtime; precompiling here, while the tree is still
writable, keeps server startup fast. --compile-bytecode handles the venv,
compileall handles our own source:
cd backend
uv venv
uv pip sync --compile-bytecode requirements.txt requirements-dev.txt # add requirements-gpu.txt on a CUDA host
.venv/bin/python -m compileall main.py config.py core routersFrontend — install the node dependencies and build the SPA into
frontend/dist. For a deployment behind an nginx prefix, export
APP_ROOT_PATH first, so the build bakes in the right base and API path —
the runtime service's EnvironmentFile does not reach the build. On the dev
machine (prefix /) it can be omitted:
cd ../frontend
# export APP_ROOT_PATH=/wignerf # only for a prefixed prod build
npm ci
npm run buildGPU note. The pinned cupy-cuda13x[ctk] needs a CUDA 13 capable driver,
which dropped Maxwell, Pascal and Volta support. On an older card (e.g. a
GTX 1060) use cupy-cuda12x[ctk] in requirements-gpu.in instead.
Re-sync the backend dependencies in case the pins changed, and rebuild the SPA
from scratch — the old frontend/dist must be removed so a stale build is
never served:
cd backend
uv pip sync --compile-bytecode requirements.txt requirements-dev.txt # add requirements-gpu.txt on a CUDA host
.venv/bin/python -m compileall main.py config.py core routers
cd ../frontend
# export APP_ROOT_PATH=/wignerf # only for a prefixed prod build
rm -rf dist
npm ci
npm run buildThen restart: ./start.sh, or sudo systemctl restart wignerf.
To change dependencies, edit the .in file and recompile — never hand-edit a
.txt:
cd backend
uv pip compile requirements.in -o requirements.txt
uv pip sync --compile-bytecode requirements.txt requirements-dev.txtProduction-style — one process serving the API and the built SPA:
./start.sh # http://localhost:8010It errors out if backend/.venv or frontend/dist is missing.
Development, with SPA hot reload — two terminals, since Vite proxies /api
(WebSocket included) to the backend:
# terminal 1
cd backend && .venv/bin/uvicorn main:app --port 8010 --ws-per-message-deflate false
# terminal 2
cd frontend && npm run dev # http://localhost:5173Always pass
--ws-per-message-deflate false. uvicorn's default WebSocket compression zlib-squeezes every multi-MiB frame bundle on the event loop and caps the stream at ~10–25 records/s — measured 12× slower than uncompressed on localhost. Browsers negotiate the extension silently, so it presents as a rendering problem.start.shalready passes it.
All configuration is environment-driven (backend/config.py); wignerf.env
holds the per-machine values for the systemd unit.
| Variable | Default | Meaning |
|---|---|---|
WIGNERF_DEVICE |
auto |
Device pool: auto | cpu | cuda:N | comma list (cuda:1,cuda:0). auto = every CUDA device, fastest first; an explicit list is trusted as written, its order being the speed ranking. Indices follow PCI order, matching nvidia-smi. |
WIGNERF_PORT |
8010 |
Listen port. |
WIGNERF_HISTORY_MB |
32768 |
In-RAM frame-history cap per session — the scrub/replay window. 32 GiB ≈ 4000 four-variant records at 1024². Lower it on small hosts. |
WIGNERF_MAX_GRID |
4096 |
Per-axis Nx/Np ceiling for 1D sessions, at creation and for auto-expand alike. A 4096² working set is ~3.0 GiB per variant worker. |
WIGNERF_MAX_GRID_2D |
128 |
Per-axis ceiling for 2D sessions. A sanity rail only: a 4D array grows as N⁴, so a per-axis cap is no real guard — 128⁴ is ~44 GiB per worker with every axis inside the rail. |
WIGNERF_MAX_CELLS_2D |
derived | Total-cell rail for 2D, and it follows the device the session selects: unset, it is how many cells the memory INSTALLED on the roomiest device of that selection holds for one worker at that precision; set, it is honoured verbatim; 0 removes it. A host whose memory cannot be read falls back to 2²⁷ and says so in the refusal. Deliberately not the operative limit: that is a per-device fit check which runs the worker→device assignment, then compares the estimate against the driver's FREE memory, so the smaller card binds and unknown free memory never refuses. |
WIGNERF_FIT_MARGIN |
0.9 |
Fraction of a device's free memory a session may be given, validated to (0, 1]. The reserve covers another process — or this app's own IC preview, which builds at the session's grid — taking memory between the check and the first allocation. At the default it is often the term that refuses: a 22.0 GiB/worker grid is turned away by an idle 24 GiB card, and 1.0 means "use what is actually free". A refusal names this setting whenever the grid does fit the raw free memory. |
WIGNERF_PRECISION |
float64 |
Default spectral working precision (float64 | float32); a session may override it. float32 is a preview mode — ~3.3–3.8× faster in 1D but only 1.5–2.6× in 2D, and ~54–55% of the working set on CUDA, nothing at all on CPU — and it costs the diagnostics this project navigates by, so do not make it the host default anywhere a result might be read off. It refuses auto-expand and tol below 1e-5. |
WIGNERF_FFT_THREADS |
0 |
Threads per CPU FFT; 0 = auto. Irrelevant on GPU. |
WIGNERF_EXPORT_DIR |
<tempdir>/wignerf-exports |
Where mp4 exports are written before download; a file is deleted once downloaded, when its session closes, at shutdown, or 30 minutes after finishing. Under systemd's PrivateTmp=yes the default is a RAM tmpfs — point it at a disk path for long 4K renders. |
WIGNERF_EXPORT_ENCODER |
auto |
mp4 encoder: auto | cpu | nvenc. auto uses the GPU h264_nvenc encoder if a runtime probe succeeds, else libx264 -preset veryfast. Overridable per job from the Export panel. |
WIGNERF_EXPORT_WORKERS |
0 |
Processes rendering export frames; 0 = auto (min(cpu_count, 8)). Rendering, not encoding, is the export bottleneck, so frames are spread over a spawn process pool while one ffmpeg encodes the ordered stream. |
APP_ENV |
— | development enables uvicorn --reload. |
APP_ROOT_PATH |
/ |
URL prefix when the SPA is mounted behind a path (e.g. /wignerf). Drives both uvicorn --root-path and the Vite base, so it must be exported at build time too. |
cd backend && .venv/bin/pytest # GPU and ffmpeg tests skip when unavailable
cd frontend && npm run test && npm run buildbackend/tests/test_propagator.py holds the physics invariants and is the
correctness anchor — in particular, quantum ≡ classical for a harmonic
potential (the Moyal corrections vanish for a quadratic Hamiltonian). Run it
after touching the propagator, the grid, or the fftshift bookkeeping.
backend/tests/test_propagator2d.py is the 2D anchor, and its strongest check
is a different one, because quantum ≡ classical cannot detect the classic
multi-D error. The Bopp shift must move every spatial argument of U
simultaneously; the plausible wrong version — a sum of two independent 1D
differences — agrees with the correct one for every quadratic potential, cross
terms included. What separates them is
test_matches_an_independent_schroedinger_run: a coupled potential evolved by
an ordinary split-operator TDSE, a different method sharing nothing but numpy's
FFT, compared against the streamed (x, y) plane cell by cell. The correct shift
converges as O(dt²) while the wrong one sits on a flat, 228× larger error — so
the assertion is on the dt ratio, not on a tolerance. The other 2D anchors
are separability against two 1D runs, ⟨L_z⟩ conservation under a central
potential, and reductions-vs-naive over all six planes and four marginals,
which is what catches 4-axis fftshift bookkeeping.
Other useful scripts — both take --ndim 2, which is the cheapest way to
exercise the 2D record path against a real server and to measure the per-worker
VRAM that decides whether a 2D session can start at all:
.venv/bin/python scripts/ws_smoke.py # streaming smoke test against a live server
.venv/bin/python scripts/ws_smoke.py --ndim 2
.venv/bin/python scripts/bench.py cpu cuda:1
.venv/bin/python scripts/bench.py --ndim 2 -N 32,48,64,80 cuda:1
.venv/bin/python scripts/bench.py --ndim 2 --regrid -N 32,64 cuda:1 # doubling costwignerf.service is a systemd unit for the reference deployment (uvicorn on
127.0.0.1 behind nginx). Install it with:
sudo cp wignerf.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now wignerfThe unit's WorkingDirectory, EnvironmentFile and ExecStart are absolute
paths — adjust them if the checkout is not at /home/tigran/python/wignerf.
Two constraints are load-bearing and commented in the file: CacheDirectory
gives CuPy a persistent kernel cache — and matplotlib a persistent font cache
via MPLCONFIGDIR — because the read-only home plus PrivateTmp otherwise
leaves both rebuilding from scratch on every restart (a minute of CuPy
recompilation blocking the first frame; ~0.7 s of matplotlib font scanning);
and PrivateDevices must stay no — yes hides /dev/nvidia*, and the
solver falls back to CPU without saying anything.
docs/ is deliberately git-ignored and holds reference material rather than
part of the program:
Efficient-Method-2015.pdf— the method paper; fetch your own copy from arXiv:1212.3406.solve4D.py— an older batch 4D solver, kept as a historical reference. The 2D support here was written fresh rather than ported from it.
Claude Code keeps a project's session transcripts, subagent logs and project
memory in <config dir>/projects/<slugified cwd>/, which for this checkout
means ~/.claude/projects/-home-tigran-python-wignerf/. Here that path is a
symlink into the repo:
~/.claude/projects/-home-tigran-python-wignerf
-> /home/tigran/python/wignerf/.claude/projects/-home-tigran-python-wignerf
so the data sits beside the code it is about, and a copy of the working tree
takes the history with it. It is git-ignored (.gitignore ignores only
.claude/projects/, leaving room for a tracked project-scoped
.claude/settings.json later) — these are whole conversations, so git push
deliberately does not back them up. Anything that must survive a disk
failure needs a backup that copies the working tree.
There is no per-project setting for this, which is why it is a symlink. The
only relocation knob is CLAUDE_CONFIG_DIR, and it is the wrong instrument
twice over: it moves the entire ~/.claude — credentials, settings, every
other project — and the OAuth credential name is derived from a hash of that
path, so setting it logs you out. The project path itself is built as
configDir + "/projects/" + slug and the resolved location is never stored,
so every absolute path already recorded in the transcripts keeps its
~/.claude/… spelling and resolves through the link. A setting that moved the
directory outright would instead leave all of those dangling.
The failure mode to know about: if the symlink is lost — a fresh machine,
or restoring ~/.claude from a backup that predates it — Claude Code does not
error. It silently creates an empty real directory at that path, the repo-side
data is orphaned, and the project simply appears to have no history. Recreate
it with:
mv ~/.claude/projects/-home-tigran-python-wignerf{,-empty} # only if one was recreated
ln -s /home/tigran/python/wignerf/.claude/projects/-home-tigran-python-wignerf \
~/.claude/projects/-home-tigran-python-wignerfNote that ~/.claude/plans/, file-history/, tasks/ and the global
history.jsonl are keyed by session id rather than by project, so they stay
under ~/.claude and are not covered by this.
A personal research tool, under active development.
2D space (4D phase space) landed in July 2026 as a first cut with four features held back behind explicit refusals so the physics core could land verified. All four are now in: the relativistic variants and float32 (2026-07-27, the latter halving what a 2D worker holds), mp4 export of a 2D run (2026-07-28), and the automatic regrid (2026-08-01, which needed a per-device memory guard on the doubling and an allocation order that hands the old grid back before building the new one). The last of the four, on 2026-08-02, collapsed the propagator's two exponent slots into one by dividing each record into equal substeps instead of walking at the adaptive step and clamping a straggler onto the record boundary: −32 bytes per cell at both dimensionalities, 208 → 176 in 2D and 224 → 192 in 1D. Two further items are already scoped: cuts at fixed (y, p_y) alongside the projections, which are exact for separable states but average away the fringe contrast of precisely the entangled regime 2D exists to show; and merging the inverse/forward FFT pair across step boundaries, worth about +50% in 2D.
Beyond that: destructive forking (resume computation from any record, which needs periodic float64 checkpoints alongside the quantized display history), save/load of a whole simulation, then Lindblad dissipation.