Spooky / fastapi_app /README.md
JavideuS's picture
Deploy a665039
beeea66 verified
|
Raw
History Blame Contribute Delete
12.3 kB
# fastapi_app
HTTP layer over Spooky. Exposes two independent endpoint families that share
the same map/solver config but don't share state:
- **`/v1/*` β€” stateless planner.** For external callers (e.g. a Go control
circuit). No robot registration, no per-call map upload. Maps and solver
instances live in server-side in-memory registries; a call is just
`map_id + solver + robots in, paths + cost out`.
- **`/robots/*` β€” stateful, per-robot sessions.** For driving Spooky itself
(animation tests, interactive experimentation from within the FastAPI
layer). A robot is registered, maps are uploaded into its own namespace,
and `/robots/{id}/plan` reuses that per-robot state across calls.
Both call the same underlying solve pipeline
(`builder.build()` β†’ `solver.solve_qubo(builder)` β†’
`solver.decode_path(...)`) β€” see `quantum/qubo_cli.py` for the reference
version of that pipeline (kept deliberately separate: `qubo_cli.py`'s solve
path is coupled to `argparse.Namespace` and isn't a good import target).
## Running
```bash
pip install -e ".[fastapi]" # fastapi, uvicorn, python-multipart
cd fastapi_app/
uvicorn api:app --reload
```
- **http://127.0.0.1:8000/demo** β€” interactive demo UI: pick a map and
solver, add robots, plan, and watch the solved paths animate. Easiest way
to try the service without writing any code β€” see "Demo page" below.
- **http://127.0.0.1:8000/docs** β€” Swagger UI for the raw `/v1/*` and
`/robots/*` endpoints, for programmatic callers.
`/` redirects to `/demo` (needed for HF Spaces' Docker SDK, which iframes
whatever the container serves at `/`) β€” this app is a backend/demo service
without its own landing page, so `/demo` is the closest thing to one.
### Docker
```bash
docker build -t spooky-fastapi . # or -f Dockerfile.gpu for CUDA
docker run -p 7860:7860 spooky-fastapi # http://localhost:7860/demo
```
Live demo: [huggingface.co/spaces/JavideuS/Spooky](https://huggingface.co/spaces/JavideuS/Spooky).
Must be run from `fastapi_app/` β€” config paths (`config/solvers.yaml`,
`config/maps.yaml`, `../quantum/config/config.yaml`) are resolved relative to
the working directory at startup.
## Config
- `config/solvers.yaml` β€” named solver profiles (`dwave.general`,
`pennylane.qaoa_QNG`, ...), keyed `backend.name`. No aliases β€” one
canonical name per solver. Loaded into `global_solver_configs` at startup
(`config_api.py`).
- `config/maps.yaml` β€” named map registry (`map_id -> {path, description}`),
paths relative to `quantum/`. Loaded into the in-memory map registry at
startup (`registry.py`).
- `../quantum/config/config.yaml` β€” penalty sets (`crash`, `swap`, ...),
shared with the core library. `crash` is the default for `/v1/plan`.
## Map registry (`registry.py`)
Maps are **not** loaded at startup, only indexed. Both representations
(`Grid` and `Graph`) are parsed from HDF5 together, in one file read, lazily
on first `GET`/`plan` request that references a given `map_id` β€” a map
that's never requested is never loaded (matters for the 1000x1000 synthetic
map). Synthetic maps generally carry both representations in the same HDF5
file, so requesting either one loads both.
- `GET /v1/maps` β€” list every registered `map_id` (curated `maps.yaml`
entries + runtime uploads), with `loaded: bool` and, once loaded,
`has_grid` / `has_graph`. Before first load, `has_grid`/`has_graph` show
`false` regardless of what the underlying file actually has β€” they only
reflect what's been parsed, not what's parseable.
- `POST /v1/maps/{map_id}` β€” upload a new HDF5 (+ optional materials YAML) at
runtime, added to the same in-memory registry. Not persisted back to
`maps.yaml` β€” it only lives for this process.
Solver instances are cached the same way (`registry.get_solver`), built once
per solver key on first use and reused across `/v1/plan` calls.
`GET /v1/maps/{map_id}/preview` renders the map's grid (obstacles + terrain,
no robots/paths) via `quantum/visualizer.py`. Two `embed` modes: `html`
(default) β€” a self-contained Plotly fragment, plotly.js via CDN, good for a
direct browser open or a Swagger link; `json` β€” `{"data": [...], "layout":
{...}}` for a page that already has `Plotly` loaded and wants to call
`Plotly.newPlot`/`Plotly.react` itself (this is what `/demo` uses, so it can
later update the same figure with a solved path instead of re-embedding a
whole new document). Grid-only β€” a graph-only map returns 400, since the
visualizer has no graph rendering.
`GET /v1/penalty-sets` lists the penalty_set names available to `/v1/plan`
(from `../quantum/config/config.yaml`), with `crash` as the documented
default.
## Grid vs. graph (`/v1/plan`'s `format` field)
`/v1/plan` accepts `format: "grid"` (default) or `format: "graph"`, selecting
`QUBOBuilder` vs. `GraphQUBO`. Positions in each entry of the `robots` list
(`start`/`goal`) are **always** `[row, col]`, even in graph mode β€”
the endpoint resolves them to node ids server-side via
`Graph.get_node_from_position` before building `RobotConfig` objects
(`GraphQUBO` and the shared windowing code in `base_qubo.py` expect
`RobotConfig.start`/`.goal` to already be node ids for graph problems, unlike
grid problems where they stay `(row, col)` tuples β€” see
`PathfindingProblem.from_graph_data` for the same conversion done for
single-robot CLI use). A position that isn't a node in the map's graph
returns a 400, not a silent grid-only fallback. Decoded paths come back the
same shape either way β€” `[[row, col], ...]` β€” since `decode_position`
already maps graph node ids back to their stored position.
## Coordinate convention
Spooky's core is **always matrix (row, col)** internally β€” row 0 is the top
row, row increases downward β€” regardless of what convention a caller uses.
That never changes; every builder, solver, and QUBO index encoding/decoding
works exclusively in matrix indices.
At the API boundary, though, `coordinate_format` is a per-robot, request-time
choice: `"matrix"` (default) or `"cartesian"` (robotics/Y-up: origin
bottom-left, y increasing upward). Set it per entry in `/v1/plan`'s `robots`
list (`RobotSpec.coordinate_format`), or once on `/robots/{id}/plan`
(`PlanRequest.coordinate_format`):
- **Input**: that robot's `start`/`goal` are read in the declared convention
and converted to matrix once, before solving
(`RobotConfig.resolve_coordinates`).
- **Output**: that robot's returned `path` is converted back to the same
convention (`RobotConfig.format_position` / `BaseSolver.format_output_path`)
β€” each `RobotPathResult`/`PlanResponse` echoes the `coordinate_format` it
used, so a caller never has to guess which frame a path is in.
- **Graph mode** doesn't support `"cartesian"` β€” positions there resolve
directly to node ids server-side, which have no coordinate frame of their
own to convert; a cartesian request against `format: "graph"` returns 400.
- `GET /v1/maps/{map_id}/preview` and `/v1/plan`'s `render: true` figure both
take/reflect the same `coordinate_format` too, but purely as a **display**
choice passed to `quantum/visualizer.py`'s `convention` param β€” it changes
axis labels/origin/direction, not the underlying grid data. Both obstacles
and paths go through the same conversion, so the rendered picture is
self-consistent in either mode (a wall doesn't visually move when you
relabel the axes describing it).
This is different from a **map's** own convention, which is fixed at the file
level, not a per-request choice β€” see `../quantum/maps/README.md`.
For anything outside this path (e.g. converting a plain path list before
handing it to an external robotics stack), use
`quantum/utils/coordinates.py` directly (`to_robotics_xy` / `to_matrix_rc` /
`path_to_robotics_xy` / `path_to_matrix_rc`), which needs the grid's row
count to flip the axis.
## Endpoints
| Method & path | Purpose |
|---|---|
| `GET /demo` | Self-contained demo UI β€” map/solver pickers, a robot form (or raw JSON), a live Plotly view. See below. |
| `GET /solvers` | List configured solver profiles. |
| `GET /v1/penalty-sets` | List penalty_set names available to `/v1/plan`. |
| `GET /v1/maps` | List the map registry (curated + uploaded). |
| `GET /v1/maps/{map_id}/preview` | Render the map's grid (obstacles/terrain) via Plotly. `?embed=html\|json&coordinate_format=matrix\|cartesian`. |
| `POST /v1/maps/{map_id}` | Upload/register a map at runtime. |
| `POST /v1/plan` | Stateless plan: `robots: [{id?, start, goal, coordinate_format?, ...}, ...]` (one entry for single-robot, more for multi-robot), `format: "grid"\|"graph"`, `render: bool`. Returns `{paths: [{robot_id, path, coordinate_format}], cost, ..., figure?}`. |
| `POST /robots` | Register a robot session. |
| `GET /robots` / `GET /robots/{id}` | List / inspect robot sessions. |
| `POST /robots/{id}/maps/{map_id}` | Upload a map into a robot's own namespace. |
| `GET /robots/{id}/maps[/{map_id}]` | List / inspect a robot's maps. |
| `DELETE /robots/{id}/maps/{map_id}` | Remove a map from a robot's namespace. |
| `POST /robots/{id}/plan` | Stateful single-robot plan using the robot's active/uploaded map + solver. |
## Demo page (`GET /demo`, `static/demo.html`)
A single self-contained HTML/JS page (no build step, no framework β€”
`plotly.js` via CDN `<script>` in `<head>`), served straight from disk by the
`/demo` route. It's a thin client over the existing `/v1/*` endpoints, not a
new code path.
**How to use it:**
1. Open `http://127.0.0.1:8000/demo`.
2. Pick a **map** (upper-left, above the preview) β€” it loads immediately.
3. Pick the **coordinate format** just below it β€” `matrix` (native, row/col)
or `cartesian` (robotics Y-up, x/y). This drives three things at once: the
preview/solved-plot axes, how the robot form's start/goal fields are
labeled, and the `coordinate_format` sent with every robot in the request.
Switching **Format** (below) to `graph` locks this back to `matrix`, since
graph mode has no coordinate frame to convert (positions resolve straight
to node ids).
4. Pick a **solver** in the sidebar; profiles tagged `"general"` are
preselected as the recommended default. Tags and description show below
the picker.
5. Add one or more **robots** (start/goal, labeled row/col or x/y depending on
step 3) via the form rows, or switch to the "Raw JSON" tab to hand-edit the
exact `/v1/plan` request body (start_time, priority, safety_radius, or
anything the form doesn't expose) β€” whichever tab is active when you click
"Plan path" is what gets sent.
6. Click **Plan path**. The result strip shows cost / planning time / solver;
each robot's result row also shows the `coordinate_format` its path came
back in. The visualization animates the solved paths with play/pause and a
timestep slider (drag it to scrub forward or back). Single-robot problems
render as Scooby; 2–4 robots get the ninja pack, matched by robot name
(name your robot `"kai"`, `"jay"`, `"lloyd"`, `"zane"`, or `"cole"` to pin
its character, otherwise they're assigned in pool order) β€” this comes from
`visualizer.py`'s `create_animated_plot`, not page-specific code.
Details on the wiring: map + solver `<select>`s are populated from
`GET /v1/maps` / `GET /solvers` (solvers grouped by backend β€” `dwave` /
`pennylane` / `qiskit` / `iqm`; qiskit and iqm are split out from `pennylane`
since they're remote hardware, not a local simulator). Picking a map (or
toggling coordinate format) fetches
`/v1/maps/{id}/preview?embed=json&coordinate_format=...` and renders it with
`Plotly.newPlot`. Planning posts to `/v1/plan` with `render: true`; the
response's `figure` (`data` + `layout` + `frames`) is drawn with
`Plotly.newPlot(...).then(() => Plotly.addFrames(...))` β€” a plain
`Plotly.react` call would silently drop the animation frames, since it only
takes `data`/`layout`.
There's no manual solver-recommendation logic beyond the `"general"` tag
match β€” a map-size/robot-count-aware recommendation is a possible future
addition, not something this page attempts.
## `web_page.py`
A standalone Dash dashboard prototype, not wired into `api.py` or the
FastAPI app β€” scratch/demo code, not part of the served API surface. `/demo`
above is the actively maintained one.