diff --git a/backend/src/spicebook/engine/__init__.py b/backend/src/spicebook/engine/__init__.py index e69de29..894c30f 100644 --- a/backend/src/spicebook/engine/__init__.py +++ b/backend/src/spicebook/engine/__init__.py @@ -0,0 +1,13 @@ +"""SPICE simulation engine registry.""" + +from fastapi import HTTPException + +from spicebook.engine.base import SpiceEngine +from spicebook.engine.ngspice import NgspiceEngine + + +def get_engine(engine_name: str) -> SpiceEngine: + """Resolve a simulation engine by name.""" + if engine_name == "ngspice": + return NgspiceEngine() + raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'") diff --git a/backend/src/spicebook/main.py b/backend/src/spicebook/main.py index ca3b3fc..e01edd1 100644 --- a/backend/src/spicebook/main.py +++ b/backend/src/spicebook/main.py @@ -10,7 +10,7 @@ from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from spicebook.config import settings -from spicebook.routers import notebooks, schematics, simulation, waveforms +from spicebook.routers import compose, notebooks, schematics, simulation, waveforms logger = logging.getLogger("spicebook") @@ -59,6 +59,9 @@ def create_app() -> FastAPI: ) return response + # compose MUST be registered before notebooks so that + # POST /api/notebooks/compose matches before {notebook_id} + application.include_router(compose.router) application.include_router(notebooks.router) application.include_router(schematics.router) application.include_router(simulation.router) diff --git a/backend/src/spicebook/models/notebook.py b/backend/src/spicebook/models/notebook.py index 1eedeb7..066e843 100644 --- a/backend/src/spicebook/models/notebook.py +++ b/backend/src/spicebook/models/notebook.py @@ -67,3 +67,16 @@ class UpdateCellRequest(BaseModel): class ReorderCellsRequest(BaseModel): cell_ids: list[str] # Ordered list of all cell IDs + + +class ComposeCellInput(BaseModel): + type: CellType + source: str = "" + + +class ComposeNotebookRequest(BaseModel): + title: str = Field("Untitled Notebook", max_length=256) + engine: str = "ngspice" + tags: list[str] = Field(default_factory=list, max_length=20) + cells: list[ComposeCellInput] = Field(..., max_length=100) + run: bool = False diff --git a/backend/src/spicebook/routers/compose.py b/backend/src/spicebook/routers/compose.py new file mode 100644 index 0000000..1222d1f --- /dev/null +++ b/backend/src/spicebook/routers/compose.py @@ -0,0 +1,94 @@ +"""Compose endpoint — create a fully-populated notebook in one call.""" + +import logging +import tempfile +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, HTTPException + +from spicebook.config import settings +from spicebook.engine import get_engine +from spicebook.models.notebook import ( + Cell, + CellOutput, + CellType, + ComposeNotebookRequest, + Notebook, + NotebookMetadata, +) +from spicebook.models.simulation import SimulationResponse +from spicebook.storage.filesystem import generate_notebook_id, save_notebook + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/notebooks", tags=["compose"]) + + +@router.post("/compose", status_code=201) +async def compose_notebook(req: ComposeNotebookRequest): + """Create a notebook with pre-populated cells, optionally running SPICE cells.""" + if not req.cells: + raise HTTPException(status_code=400, detail="At least one cell is required") + + nb_id = generate_notebook_id(req.title) + now = datetime.now(timezone.utc).isoformat() + + cells: list[Cell] = [] + for item in req.cells: + cells.append( + Cell( + id=f"cell-{uuid.uuid4().hex[:12]}", + type=item.type, + source=item.source, + ) + ) + + notebook = Notebook( + metadata=NotebookMetadata( + title=req.title, + engine=req.engine, + tags=req.tags, + created=now, + modified=now, + ), + cells=cells, + ) + + if req.run: + engine = get_engine(req.engine) + for cell in notebook.cells: + if cell.type != CellType.SPICE: + continue + if not cell.source.strip(): + continue + + try: + with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir: + result = await engine.run(cell.source, Path(tmpdir)) + except Exception: + logger.exception("Simulation failed for cell %s", cell.id) + result = SimulationResponse( + success=False, + error="Internal simulation error", + elapsed_seconds=0.0, + ) + + run_ts = datetime.now(timezone.utc).isoformat() + cell.outputs.append( + CellOutput( + output_type="simulation_result" if result.success else "error", + data={ + "success": result.success, + "waveform": result.waveform.model_dump() if result.waveform else None, + "log": result.log, + "error": result.error, + "elapsed_seconds": result.elapsed_seconds, + }, + timestamp=run_ts, + ) + ) + + save_notebook(settings.notebook_dir, nb_id, notebook) + return {"id": nb_id, **notebook.model_dump()} diff --git a/backend/src/spicebook/routers/simulation.py b/backend/src/spicebook/routers/simulation.py index 8833522..1c9b879 100644 --- a/backend/src/spicebook/routers/simulation.py +++ b/backend/src/spicebook/routers/simulation.py @@ -7,7 +7,7 @@ from pathlib import Path from fastapi import APIRouter, HTTPException from spicebook.config import settings -from spicebook.engine.ngspice import NgspiceEngine +from spicebook.engine import get_engine from spicebook.models.notebook import CellOutput, CellType from spicebook.models.simulation import SimulationRequest, SimulationResponse from spicebook.storage.filesystem import load_notebook, save_notebook @@ -15,17 +15,10 @@ from spicebook.storage.filesystem import load_notebook, save_notebook router = APIRouter(prefix="/api", tags=["simulation"]) -def _get_engine(engine_name: str) -> NgspiceEngine: - """Resolve engine by name. Only ngspice is supported in Phase 1.""" - if engine_name == "ngspice": - return NgspiceEngine() - raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'") - - @router.post("/simulate", response_model=SimulationResponse) async def simulate(req: SimulationRequest): """Run a standalone SPICE simulation.""" - engine = _get_engine(req.engine) + engine = get_engine(req.engine) with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir: result = await engine.run(req.netlist, Path(tmpdir)) @@ -61,7 +54,7 @@ async def run_cell(notebook_id: str, cell_id: str): if not cell.source.strip(): raise HTTPException(status_code=400, detail="Cell source is empty") - engine = _get_engine(nb.metadata.engine) + engine = get_engine(nb.metadata.engine) with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir: result = await engine.run(cell.source, Path(tmpdir)) diff --git a/backend/src/spicebook/storage/filesystem.py b/backend/src/spicebook/storage/filesystem.py index 476967f..0c264d0 100644 --- a/backend/src/spicebook/storage/filesystem.py +++ b/backend/src/spicebook/storage/filesystem.py @@ -33,7 +33,7 @@ def _slugify(text: str) -> str: return slug or "notebook" -def _generate_notebook_id(title: str) -> str: +def generate_notebook_id(title: str) -> str: """Generate a notebook ID from title, with short UUID suffix for uniqueness.""" slug = _slugify(title) short_uid = uuid.uuid4().hex[:8] @@ -105,7 +105,7 @@ def save_notebook(directory: Path, notebook_id: str, notebook: Notebook) -> None def create_notebook(directory: Path, title: str, engine: str = "ngspice") -> tuple[str, Notebook]: """Create a new notebook and save it. Returns (notebook_id, notebook).""" - nb_id = _generate_notebook_id(title) + nb_id = generate_notebook_id(title) now = _now_iso() notebook = Notebook( diff --git a/frontend/public/llms.txt b/frontend/public/llms.txt new file mode 100644 index 0000000..f98c904 --- /dev/null +++ b/frontend/public/llms.txt @@ -0,0 +1,546 @@ +# SpiceBook + +> Notebook interface for SPICE circuit simulation. Create, edit, and run SPICE netlists in a cell-based notebook UI with waveform visualization and schematic generation. Powered by ngspice. + +Base URL: `https://spicebook.warehack.ing` + +## API Reference + +All endpoints accept and return JSON unless noted otherwise. Prefix all paths with the base URL. + +--- + +### Notebooks + +#### List notebooks + +``` +GET /api/notebooks +``` + +**Response** `200` +```json +[ + { + "id": "rc-low-pass-a1b2c3d4", + "title": "RC Low-Pass Filter", + "engine": "ngspice", + "tags": ["filter", "rc"], + "cell_count": 3, + "modified": "2026-02-13T18:30:00+00:00" + } +] +``` + +#### Create notebook + +``` +POST /api/notebooks +``` + +**Request body** +```json +{ + "title": "My Circuit", + "engine": "ngspice" +} +``` + +Both fields are optional. Defaults: title = `"Untitled Notebook"`, engine = `"ngspice"`. + +**Response** `201` +```json +{ + "id": "my-circuit-f8e2a91b", + "spicebook_version": "2026-02-13", + "metadata": { + "title": "My Circuit", + "engine": "ngspice", + "tags": [], + "created": "2026-02-13T18:30:00+00:00", + "modified": "2026-02-13T18:30:00+00:00" + }, + "cells": [ + { + "id": "cell-a1b2c3d4e5f6", + "type": "markdown", + "source": "# My Circuit\n\nAdd SPICE cells below to begin simulating.", + "outputs": [] + } + ] +} +``` + +#### Get notebook + +``` +GET /api/notebooks/{notebook_id} +``` + +**Response** `200` — Full `Notebook` object (same shape as create response, without `id` wrapper). + +#### Update notebook + +``` +PUT /api/notebooks/{notebook_id} +``` + +**Request body** — Full `Notebook` object (replaces entire notebook). + +**Response** `200` — The saved `Notebook`. + +#### Delete notebook + +``` +DELETE /api/notebooks/{notebook_id} +``` + +**Response** `204` — No content. Only user-created notebooks can be deleted. + +--- + +### Cells + +Cells are ordered elements within a notebook. Each cell has a `type` (`markdown`, `spice`, `python`, `schematic`) and `source` (text content). + +#### Add cell + +``` +POST /api/notebooks/{notebook_id}/cells +``` + +**Request body** +```json +{ + "type": "spice", + "source": "V1 1 0 DC 5\nR1 1 0 1k\n.op\n.end", + "after_cell_id": "cell-a1b2c3d4e5f6" +} +``` + +`after_cell_id` is optional — omit to append at end. + +**Response** `201` +```json +{ + "id": "cell-b2c3d4e5f6a7", + "type": "spice", + "source": "V1 1 0 DC 5\nR1 1 0 1k\n.op\n.end", + "outputs": [] +} +``` + +#### Update cell + +``` +PUT /api/notebooks/{notebook_id}/cells/{cell_id} +``` + +**Request body** +```json +{ + "source": "V1 1 0 DC 10\nR1 1 0 2k\n.op\n.end", + "type": "spice" +} +``` + +Both fields optional — only provided fields are updated. + +**Response** `200` — Updated `Cell`. + +#### Delete cell + +``` +DELETE /api/notebooks/{notebook_id}/cells/{cell_id} +``` + +**Response** `204` + +#### Reorder cells + +``` +PUT /api/notebooks/{notebook_id}/cells/reorder +``` + +**Request body** +```json +{ + "cell_ids": ["cell-b2c3d4e5f6a7", "cell-a1b2c3d4e5f6"] +} +``` + +Must include every cell ID exactly once. + +**Response** `200` — Array of `Cell` objects in new order. + +--- + +### Simulation + +#### Run standalone simulation + +``` +POST /api/simulate +``` + +**Request body** +```json +{ + "netlist": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.op\n.end", + "engine": "ngspice" +} +``` + +**Response** `200` +```json +{ + "success": true, + "waveform": { + "variables": [ + {"name": "v(1)", "type": "voltage"}, + {"name": "v(2)", "type": "voltage"} + ], + "points": 1, + "x_data": [0.0], + "y_data": {"v(1)": [5.0], "v(2)": [3.333]}, + "x_type": "time", + "is_complex": false, + "y_magnitude_db": null, + "y_phase_deg": null + }, + "log": "ngspice output...", + "error": null, + "elapsed_seconds": 0.42 +} +``` + +#### Run cell in notebook + +``` +POST /api/notebooks/{notebook_id}/cells/{cell_id}/run +``` + +No request body — uses the cell's `source` as the netlist and the notebook's `engine`. + +**Response** `200` — Same `SimulationResponse` shape. The cell's outputs are updated in the saved notebook. + +--- + +### Schematics + +#### Generate schematic from cell + +``` +POST /api/notebooks/{notebook_id}/cells/{cell_id}/schematic +``` + +No request body. Cell must be type `spice`. + +**Response** `200` +```json +{ + "svg": "...", + "success": true, + "error": null, + "component_map": {"R1": "resistor", "V1": "voltage_source"} +} +``` + +--- + +### Waveforms + +#### Generate SVG plot (JSON-wrapped) + +``` +POST /api/waveforms/svg +``` + +**Request body** +```json +{ + "waveform": { "...WaveformData from simulation response..." }, + "title": "Output Voltage", + "width": 800, + "height": 500, + "signals": ["v(2)"] +} +``` + +`signals` is optional — omit to plot all signals. `width`, `height`, `title` have defaults. + +**Response** `200` +```json +{ + "svg": "..." +} +``` + +#### Generate SVG plot (raw) + +``` +POST /api/waveforms/svg/raw +``` + +Same request body as above. + +**Response** `200` with `Content-Type: image/svg+xml` — raw SVG string. + +--- + +### Compose (convenience) + +Create a fully-populated notebook with multiple cells in a single call. + +#### Compose notebook + +``` +POST /api/notebooks/compose +``` + +**Request body** +```json +{ + "title": "RC Low-Pass Filter", + "engine": "ngspice", + "tags": ["filter", "rc", "analog"], + "cells": [ + { + "type": "markdown", + "source": "# RC Low-Pass Filter\n\nA simple first-order low-pass filter." + }, + { + "type": "spice", + "source": "V1 in 0 AC 1\nR1 in out 1k\nC1 out 0 1u\n.ac dec 100 1 1meg\n.end" + }, + { + "type": "markdown", + "source": "## Analysis\n\nThe -3dB cutoff is at f = 1/(2*pi*R*C) = 159 Hz." + } + ], + "run": false +} +``` + +| Field | Type | Default | Description | +|----------|-----------------|----------------------|------------------------------------------------------| +| title | string | "Untitled Notebook" | Notebook title | +| engine | string | "ngspice" | Simulation engine | +| tags | list of strings | [] | Searchable tags | +| cells | list of objects | (required) | Cells to create, each with `type` and `source` | +| run | bool | false | If true, execute each SPICE cell after creation | + +**Response** `201` — Same shape as `POST /api/notebooks` (notebook with `id` at top level). + +When `run` is `true`, each `spice` cell is executed sequentially using the notebook's engine. Simulation results are stored in each cell's `outputs` array. Non-SPICE cells are unaffected. + +--- + +## Data Models + +### CellType (enum) + +`"markdown"` | `"spice"` | `"python"` | `"schematic"` + +### Cell + +```json +{ + "id": "cell-a1b2c3d4e5f6", + "type": "spice", + "source": "V1 1 0 DC 5\n.op\n.end", + "outputs": [ + { + "output_type": "simulation_result", + "data": { "success": true, "waveform": {...}, "log": "...", "error": null, "elapsed_seconds": 0.3 }, + "timestamp": "2026-02-13T18:35:00+00:00" + } + ] +} +``` + +### Notebook + +```json +{ + "spicebook_version": "2026-02-13", + "metadata": { + "title": "string", + "engine": "ngspice", + "tags": ["string"], + "created": "ISO-8601 datetime", + "modified": "ISO-8601 datetime" + }, + "cells": [Cell, ...] +} +``` + +### SimulationResponse + +```json +{ + "success": true, + "waveform": WaveformData | null, + "log": "string", + "error": "string | null", + "elapsed_seconds": 0.0 +} +``` + +### WaveformData + +```json +{ + "variables": [{"name": "v(out)", "type": "voltage"}], + "points": 100, + "x_data": [0.0, 0.001, ...], + "y_data": {"v(out)": [0.0, 0.5, ...]}, + "x_type": "time", + "is_complex": false, + "y_magnitude_db": null, + "y_phase_deg": null +} +``` + +For AC analysis (`is_complex: true`), `y_magnitude_db` and `y_phase_deg` contain per-signal arrays. `x_type` will be `"frequency"` and `x_data` holds frequency values in Hz. + +--- + +## SPICE Netlist Primer + +SpiceBook uses **ngspice** as its simulation engine. Netlists are plain text describing a circuit and the analysis to perform. + +### Basic structure + +```spice +* Title line (optional comment) +V1 node_pos node_neg DC 5 * DC voltage source +R1 node_a node_b 1k * Resistor: 1 kilo-ohm +C1 node_a node_b 100n * Capacitor: 100 nanofarads +L1 node_a node_b 10m * Inductor: 10 millihenrys + +.analysis_type parameters +.end +``` + +Node `0` is always ground. + +### Supported analysis types + +| Command | Description | Example | +|---------|-------------|---------| +| `.op` | DC operating point | `.op` | +| `.dc` | DC sweep | `.dc V1 0 5 0.1` | +| `.tran` | Transient (time-domain) | `.tran 1u 10m` | +| `.ac` | AC frequency sweep | `.ac dec 100 1 1meg` | + +### Engineering suffixes + +| Suffix | Multiplier | Example | +|--------|-----------|---------| +| T | 10^12 | `1T` = 1 tera | +| G | 10^9 | `2.2G` = 2.2 giga | +| meg | 10^6 | `1meg` = 1 mega (note: not `M` — that's milli in SPICE) | +| k | 10^3 | `4.7k` = 4700 | +| m | 10^-3 | `10m` = 0.01 | +| u | 10^-6 | `100u` = 100 micro | +| n | 10^-9 | `47n` = 47 nano | +| p | 10^-12 | `10p` = 10 pico | +| f | 10^-15 | `1f` = 1 femto | + +### Common sources + +```spice +V1 node+ node- DC 5 * DC voltage +V2 node+ node- AC 1 * AC source (for .ac analysis) +V3 node+ node- PULSE(0 5 0 1n 1n 5u 10u) * Pulse source +I1 node+ node- DC 1m * DC current source +``` + +--- + +## Example Workflow + +### 1. Create a notebook with the compose endpoint + +```bash +curl -X POST https://spicebook.warehack.ing/api/notebooks/compose \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Voltage Divider", + "engine": "ngspice", + "tags": ["resistive", "dc", "beginner"], + "cells": [ + { + "type": "markdown", + "source": "# Voltage Divider\n\nTwo resistors divide a 5V supply." + }, + { + "type": "spice", + "source": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.op\n.end" + }, + { + "type": "markdown", + "source": "## Expected Result\n\nV(2) = 5 * 2k / (1k + 2k) = 3.333V" + } + ], + "run": true + }' +``` + +Response includes the notebook `id` and all cells. Because `run: true`, the SPICE cell's `outputs` array will contain the simulation result with `v(2) = 3.333`. + +### 2. View the notebook in the browser + +Open `https://spicebook.warehack.ing/notebook/{id}` — the notebook renders with the markdown cells formatted and the SPICE cell showing its simulation output. + +### 3. Add another cell + +```bash +curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells \ + -H "Content-Type: application/json" \ + -d '{ + "type": "spice", + "source": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.dc V1 0 10 0.5\n.end" + }' +``` + +### 4. Run the new cell + +```bash +curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells/{cell_id}/run +``` + +### 5. Generate a schematic + +```bash +curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells/{cell_id}/schematic +``` + +### 6. Visualize waveform data + +Take the `waveform` from the simulation response and POST it to the waveform endpoint: + +```bash +curl -X POST https://spicebook.warehack.ing/api/waveforms/svg/raw \ + -H "Content-Type: application/json" \ + -d '{ + "waveform": { ...waveform object from simulation response... }, + "title": "DC Sweep", + "signals": ["v(2)"] + }' \ + -o plot.svg +``` + +--- + +## Health Check + +``` +GET /health +``` + +**Response** `200` +```json +{"status": "ok", "version": "2026.02.13"} +```