File size: 12,333 Bytes
e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f beeea66 e516f1f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | # 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.
|