Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,31 +36,39 @@ jobs:
- '.github/workflows/ci.yml'

python:
name: Python
name: Python (${{ matrix.python }})
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# 3.12 is the supported floor (pyproject requires-python); 3.14 is the dev pin.
python: ["3.12", "3.14"]
steps:
- uses: actions/checkout@v4

- name: Set up uv (Python 3.14 from .python-version)
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Install project + deps
run: uv sync --extra service
run: uv sync --python ${{ matrix.python }}

# ruff lint/format are Python-version-agnostic; run once, on the floor.
- name: Lint (ruff)
if: ${{ matrix.python == '3.12' }}
run: uvx ruff check .

- name: Format check (ruff format)
if: ${{ matrix.python == '3.12' }}
run: uvx ruff format --check .

- name: Tests (pytest; tolerate "no tests collected" while skeletal)
run: |
set +e
uv run --with pytest pytest -q
uv run --python ${{ matrix.python }} --with pytest pytest -q
code=$?
set -e
# exit 5 = no tests collected yet (skeleton); treat as success
Expand All @@ -80,7 +88,7 @@ jobs:
enable-cache: true

- name: Install project + deps
run: uv sync --extra service
run: uv sync

- name: Typecheck (ty)
run: uv run ty check src/magnetics
Expand Down
71 changes: 71 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Release

# Build the wheel + sdist (GUI bundled), smoke-test the install, and publish to
# PyPI on a version tag. `workflow_dispatch` runs the build + smoke test only
# (no publish) so the pipeline can be exercised before a real release.
#
# Publishing uses PyPI Trusted Publishing (OIDC) — no API token. See
# docs/RELEASING.md for the one-time publisher setup.
on:
push:
tags: ["v*"]
workflow_dispatch: {}

jobs:
build:
name: Build + smoke test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: gui/web/package-lock.json

- uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Check tag matches project version
if: startsWith(github.ref, 'refs/tags/v')
run: |
ver="$(uv version --short)"
if [ "v$ver" != "$GITHUB_REF_NAME" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match project version v$ver"
exit 1
fi

# Build GUI → stage → sdist+wheel → verify bundle → smoke-test the wheel.
# Same script developers run locally, so the two can't drift.
- name: Build + smoke test the distribution
run: scripts/build-dist.sh --smoke

- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
name: Publish to PyPI
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # trusted publishing (OIDC)
contents: write # create the GitHub Release
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1

- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --generate-notes
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ frontend (its `dist/` is staged into `service/webapp/` for the wheel).
- **Verify GUI behavior changes with Playwright.** When a change affects the frontend's *behavior*
(control state/defaults, data flow, what renders, error handling) — not just styling — drive the
running app with Playwright and assert the actual DOM/behavior, rather than relying on `tsc` or
unit tests alone. Start the app (`./run.sh`), then script the interaction (e.g. select a device,
unit tests alone. Start the app (`./run-dev.sh`), then script the interaction (e.g. select a device,
read back the control values / node responses). This is how the NSTX device-aware `PullControl`
and the live pull were validated.
- **Run the CI gates locally before committing frontend/typing changes:** `uv run ty check src/magnetics`
Expand Down
86 changes: 78 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,94 @@
# magnetics (analysis)
# magnetics

Device-agnostic Python library for 3D magnetic-sensor analysis of tokamak MHD
instabilities. See `../docs/VISION.md` for context.
Device-agnostic Python library **and** GUI for 3D magnetic-sensor analysis of
tokamak MHD instabilities — quasi-stationary (locked) modes and rapidly-rotating
modes. See [`docs/VISION.md`](docs/VISION.md) for context.

## Setup
## Quick start (no dev setup)

The GUI ships inside the package — no Node or build toolchain needed.

```sh
uvx magnetics # zero-install: uv provisions Python + runs it
# or
pip install magnetics # into an existing environment
magnetics # start the app, open it in your browser
```

`magnetics` starts the service and opens the GUI in your browser. Useful flags:

```sh
magnetics --port 8000 # fixed port (default: first free port from 8000)
magnetics --no-browser # headless (e.g. on a server)
magnetics --data-dir PATH # where shot data lives (see below)
```

Two more console scripts come with the install:

```sh
magnetics-fetch --shot 184927 # pull a shot to the local data dir
magnetics-service # start just the API/service (honors HOST/PORT)
```

### Where shot data lives

Fetched shots are cached persistently so you don't re-pull them. The location is,
in order:

1. `$MAGNETICS_DATA_DIR` (or `--data-dir`) if set — **the** knob to relocate it;
2. the repo's `data/` dir when run from a source checkout;
3. otherwise a per-user data dir (`~/.local/share/magnetics` on Linux,
`~/Library/Application Support/magnetics` on macOS).

**On a cluster,** set `MAGNETICS_DATA_DIR` to scratch/project space (home dirs are
usually quota'd and shot files are large) — e.g. in your shell profile:

```sh
export MAGNETICS_DATA_DIR=$SCRATCH/magnetics
```

## Development

The Python project is the repo root (a uv project); the React GUI is in `gui/web/`.

```sh
uv sync --group dev
uv run nbstripout --install # strip notebook outputs on commit (run once per clone)
./run-dev.sh # live: FastAPI service + GUI dev server (hot reload)
./run-dev.sh static # GUI only, static mock fixtures (offline frontend work)
./run-dev.sh --prod # build the GUI and serve it on one port (preview the wheel)

scripts/build-dist.sh # build the distributable wheel + sdist (GUI bundled) into dist/
scripts/build-dist.sh --smoke # …and smoke-test the wheel in a clean venv
```

## Layout
### Running a built wheel in isolation

To try a freshly built wheel the way an end user would — in a throwaway
environment that touches neither your `.venv` nor the repo's `data/` — run it with
`uvx --from` (the installed package lives in uv's cache, so it uses the per-user
data dir, not this checkout):

```sh
uvx --from ./dist/magnetics-*.whl magnetics
uvx --from ./dist/magnetics-*.whl magnetics --data-dir "$(mktemp -d)" # touch nothing persistent
uvx --reinstall --from ./dist/magnetics-*.whl magnetics # after a rebuild at the same version
```

### Layout

- `src/magnetics/core/` — pure, device-agnostic analysis (e.g. `spectral.py`).
- `tests/` — pytest suite; fixtures in `tests/fixtures/`.
- `src/magnetics/service/` — FastAPI app; the built GUI is bundled at `service/webapp/`.
- `gui/web/` — React + Vite + TypeScript frontend.
- `tests/` — pytest suite (synthetic fixtures; no real tokamak data committed).
- `examples/` — runnable Jupyter notebooks.

## Common commands
### Common commands

```sh
uv run pytest # run tests
uv run pytest # run the Python tests
uv run jupyter lab examples/ # open the example notebooks
cd gui/web && npm test # run the frontend tests
```

Releasing to PyPI is documented in [`docs/RELEASING.md`](docs/RELEASING.md).
64 changes: 64 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Releasing `magnetics` to PyPI

The [`Release` workflow](../.github/workflows/release.yml) builds the wheel + sdist
(with the GUI bundled), smoke-tests the install in a clean Python 3.12 venv, and
publishes to PyPI on a version tag. Publishing uses **PyPI Trusted Publishing**
(OIDC) — there is no API token to manage.

## One-time setup (before the first release)

1. **Register the pending publisher on PyPI.** Log in to <https://pypi.org> as an
owner of the project (or as whoever will own it), go to
**Your projects → (project) → Publishing**, or for a brand-new name use
**Account → Publishing → Add a pending publisher**, and enter:
- **PyPI project name:** `magnetics`
- **Owner:** `OpenFUSIONToolkit`
- **Repository name:** `Magnetics`
- **Workflow name:** `release.yml`
- **Environment name:** `pypi`

The project name `magnetics` is currently unclaimed; the pending publisher
reserves it and lets the first tag push create the project.

2. **Create the `pypi` GitHub environment.** In the repo, **Settings →
Environments → New environment → `pypi`**. Optionally add required reviewers so
a human must approve each publish. The environment name must match the workflow
(`environment: pypi`) and the pending publisher above.

> The pending publisher **must exist before the first tag push**, or the publish
> step fails auth. That failure is harmless — finish the setup and re-run the job.

## Cutting a release

Follows the repo's GitFlow-lite (see `CLAUDE.md`). Versioning is SemVer; the tag
must match `version` in `pyproject.toml` (the workflow enforces this).

1. On a branch off `develop`, bump `version` in `pyproject.toml` (and keep
`src/magnetics/__init__.py:__version__` in step). Open a PR into `develop`.
2. Open a release PR `develop` → `main` and merge it.
3. Tag the merge commit on `main` and push the tag:
```bash
git checkout main && git pull
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. The `Release` workflow builds, smoke-tests, publishes to PyPI, and creates a
GitHub Release with the artifacts attached.

## Dry run

Trigger the workflow manually (**Actions → Release → Run workflow**, or
`gh workflow run release.yml`). `workflow_dispatch` runs the **build + smoke test
only** and skips publishing, so you can validate the pipeline (GUI bundling, wheel
install, launcher boot) without cutting a release.

## What "installed" looks like for users

```bash
uvx magnetics # zero-install, ephemeral (uv provisions Python)
# or
pip install magnetics && magnetics # into an existing environment
```

Both start the service and open the bundled GUI in a browser. Shot data is written
to a per-user data directory (or `$MAGNETICS_DATA_DIR`); see the README.
4 changes: 2 additions & 2 deletions gui/web/src/components/tabs/QuasiStationaryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type Plotly from "plotly.js-dist-min";
import { useStore } from "../../store";
import { apiBase, startFetch } from "../../lib/api";
import { apiBase, startFetch, usingLiveBackend } from "../../lib/api";
import { useNode } from "../../lib/useNode";
import NodeView from "../../lib/NodeView";
import Plot from "../../lib/Plot";
Expand Down Expand Up @@ -386,7 +386,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) {
const plotCustomSignals = useCallback(() => {
const names = customText.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
if (!names.length || names.some(n => !POINTNAME_RE.test(n))) return;
if (!apiBase()) { setCustomMsg("✗ no live backend configured — set VITE_API_BASE to fetch data"); return; }
if (!usingLiveBackend()) { setCustomMsg("✗ no live backend configured — run the packaged app or set VITE_API_BASE"); return; }
if (credsMissing) { setCustomMsg(`✗ ${CREDS_HINT}`); return; }
setCustomBusy(true); setCustomFrac(0); setCustomMsg("fetching…");
void (async () => {
Expand Down
Loading
Loading