Metadata-Version: 2.4
Name: labforge
Version: 0.4.0
Summary: Wrap plain Python functions into a small scientific web app served on your own machine: theory, simulation, visualization, analysis.
Project-URL: Homepage, https://github.com/laroccod/labforge
Project-URL: Repository, https://github.com/laroccod/labforge
Project-URL: Issues, https://github.com/laroccod/labforge/issues
Project-URL: Changelog, https://github.com/laroccod/labforge/blob/main/CHANGELOG.md
Author: Daniel La Rocco
License-Expression: MIT
License-File: LICENSE
Keywords: fastapi,matplotlib,scientific-computing,simulation,visualization,web-app
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: Visualization
Requires-Python: >=3.10
Requires-Dist: fastapi>=0.110
Requires-Dist: matplotlib>=3.8
Requires-Dist: numpy>=2.0
Requires-Dist: uvicorn>=0.29
Provides-Extra: dev
Requires-Dist: black>=24.0; extra == 'dev'
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: web
Description-Content-Type: text/markdown

# `labforge`

*By Daniel La Rocco*

## **Turn plain Python scripts into small scientific apps.**

`labforge` wraps a simulation you already have — a function that produces data,
a function that plots it, a function that summarizes it — into a polished
four-section app following the **theory → simulation → visualization →
analysis** workflow. You write pure Python; `labforge` serves it as a web app
on your own machine — no HTML, no JavaScript, no callbacks to wire up.

You provide the science and `labforge` supplies the app shell, the parameter controls
generated from your function signatures, the parameter-scan engine,
LaTeX rendering for your theory notes, and three terminal-dashboard themes —
light, dark and matrix.

![The demo lab in the browser: the Simulation controls above the rendered histogram](https://raw.githubusercontent.com/laroccod/labforge/main/assets/screenshot.png)

## Quick start

```python
import matplotlib.pyplot as plt
import numpy as np

import labforge
from labforge import Lab, Param, ScanResult

lab = Lab("gausslab")
lab.set_theory("theory.md")   # markdown file or string; $$...$$ becomes LaTeX


def sample(mu=0.0, sigma=1.0, n=2000, seed=42):
    """Draw n Gaussian variates; reproducible for a given seed."""
    return np.random.default_rng(seed).normal(mu, sigma, n)


lab.add_worker(sample, {
    "mu": Param(default=0.0, bounds=(-5, 5), scan=True, help="Mean of the distribution"),
    "sigma": Param(default=1.0, bounds=(0.1, 4), scan=True),
    "n": Param(kind="int", default=2000, bounds=(10, 100_000)),
    "seed": "int",
})


def histogram(data, bins=40):
    fig, ax = plt.subplots(figsize=(7, 3.4))
    if isinstance(data, ScanResult):   # a parameter scan: one histogram per grid point
        for params, draws in data:
            ax.hist(draws, bins=bins, density=True, alpha=0.5,
                    label=", ".join(f"{k} = {params[k]:g}" for k in data.keys))
        ax.legend()
    else:
        ax.hist(data, bins=bins, density=True, color=labforge.palette().data)
    labforge.style(fig, ax)            # optional house treatment
    return fig, ax


lab.add_viz(histogram, "Histogram", "Density histogram of the draw.",
            {"bins": Param(kind="int", default=40, bounds=(5, 200))})


def moments(data):
    if isinstance(data, ScanResult):   # one table row per grid point
        return [{**params, "mean": float(np.mean(d)), "std": float(np.std(d))}
                for params, d in data]
    return {"mean": float(np.mean(data)), "std": float(np.std(data, ddof=1))}


lab.add_analysis(moments, "Moments", "Sample moments of the draw.")

lab.open()
```

That is the whole app. `lab.open()` serves it at `http://127.0.0.1:8600` and
opens your browser: one page with four sections — Theory, Simulation,
Visualization, Analysis — a slider for every bounded parameter, a Run button,
and tabs for each registered visualization and analysis. Content keeps a
book-like reading measure, results fade in rather than cut, and every section
is explorable before the first Run.

An extended version of this example lives at
[`examples/demo_lab.py`](examples/demo_lab.py). It grows the same lab into a
small statistics course: a fitted-density overlay, a Q-Q plot, a likelihood
map drawing joint confidence regions for μ and σ, and an inference tab with
bootstrap confidence intervals, a likelihood ratio test and a Jarque-Bera
normality check — plus a second, gamma-distributed worker whose fit tab sets
the method of moments beside the true maximum-likelihood estimate (Newton's
method, Fisher standard errors), all in plain numpy:

```bash
python examples/demo_lab.py                 # serve at http://127.0.0.1:8600
python examples/demo_lab.py --port 8550     # serve at http://127.0.0.1:8550
python examples/demo_lab.py --theme matrix
labforge serve examples/demo_lab.py         # the same thing, from the CLI
```

## Concepts

**Worker.** One function produces the data. Each keyword argument gets a UI
control from its `Param` spec — or from the signature default alone, if you
spec nothing:

| spec | control |
| --- | --- |
| `Param(default=1.0, bounds=(0, 5))` | slider with live readout |
| `Param(kind="int", default=100, bounds=(10, 1000))` | integer slider |
| `Param(default=1.0)` / `"scalar"` / `"int"` | validated text field |
| `Param(..., scan=True)` / `"scalar or array"` / `"int or array"` | scannable (see below) |
| `"N-tuple"` (e.g. `"2-tuple"`) | one field per element |
| `Param(kind="choice", options=["a", "b"])` | dropdown |

Every `Param` also takes `label=` to rename its control and `help=` for a
tooltip on the control's label. Pressing Enter in any text field runs the
section it belongs to, and an entry that fails to parse flags the field while
the last good value stays live.

Validation happens at registration: unknown spec keys, defaults outside
bounds, or a scan spec on a non-worker function raise `ValueError` when the
app is assembled, never mid-use.

**Parameter scans.** A kwarg declared `scan=True` gets a scan toggle (bounded)
or a comma-separated field (unbounded). Enter `0, 1, 2` and `labforge` calls the
worker once per point of the cartesian grid across all scanned parameters —
the worker itself always receives scalars. A long scan reports its progress in
the status line as the grid fills in. Downstream functions then receive a
`ScanResult`: a list of `(params, result)` records with `keys`, `values()` and
`axis(name)` helpers, distinguished with `isinstance(data, ScanResult)`.

**Visualizations.** Functions `viz(data, **kwargs)` returning a matplotlib
figure (bare or `(fig, ax)`). Each gets a tab with its own controls and a
Render button. Figures are yours — `labforge` only serializes them; the house
style (`labforge.style(fig, ax)`) is strictly opt-in.

**Analyses.** Functions `analysis(data, **kwargs)` — the return shape picks
the rendering:

| return | rendered as |
| --- | --- |
| `dict` | two-column quantity/value table |
| `list` of `dict`s, or a DataFrame | full table |
| `str` | markdown |
| Figure or `(fig, ax)` | image |
| anything else | its `repr` |

**Multiple workers.** Call `add_worker` more than once for a lab with several
workers. Each worker keeps its own workspace — its controls, its last result and
its per-tab settings — and the `add_viz` / `add_analysis` calls after each
`add_worker` attach that worker's own tabs, so switching workers and back
restores exactly what was there. Several workers need a way to choose among them
(checked when the app is served): a `selects_worker` model selector, or
`worker_view="tabs"` to lay the workers out as Simulation tabs.

The clean way is a Theory selector with `selects_worker=True`: its options name
the workers, so choosing one both swaps the theory and makes that worker
active — the Simulation, Visualization and Analysis sections all follow it,
showing only that worker's controls and tabs. The demo
(`examples/demo_lab.py`) uses this to switch between a normal and a gamma
model:

```python
lab.set_theory_selector(
    "model",
    Param(kind="choice", options=["Normal", "Gamma"]),
    theory_for,                 # theory_for(selection) -> markdown
    label="Distribution",
    selects_worker=True,
)
lab.add_worker(sample, {...}, name="Normal")   # its own viz/analysis tabs
lab.add_worker(sample_gamma, {...}, name="Gamma")
```

![The Theory model selector driving the active worker: choosing Gamma swaps the theory to the gamma distribution and the Simulation controls to the gamma worker's shape and scale](https://raw.githubusercontent.com/laroccod/labforge/main/assets/multi_worker.png)

**Theory.** A markdown file or string. Displayed `$$...$$` equations are
typeset in the browser with KaTeX.

## Serving and themes

`lab.open()` takes four independent arguments:

- `port` (default `8600`) and `host` (default `127.0.0.1`) — with a fixed port
  the URL is stable across restarts, and every browser tab gets its own
  isolated session.
- `open_browser` — set it `False` to serve without opening a browser.
- `theme` — one of three palettes. A theme sets the chrome and the plot palette
  together, so figures never drift from the app around them. Colour your own
  figures with `labforge.palette().data`, `.model`, `.highlight`; list the
  options with `labforge.themes()`:

| name | mode | look |
| --- | --- | --- |
| `light` | light | blue on warm paper white, darkened gold counterpoint (default) |
| `dark` | dark | terminal-dashboard gold on near-black, violet counterpoint |
| `matrix` | dark | phosphor terminal green on near-black |

The Theory section in each theme — the equations render with KaTeX:

| `light` | `dark` | `matrix` |
| --- | --- | --- |
| ![light](https://raw.githubusercontent.com/laroccod/labforge/main/assets/web_theme_light.png) | ![dark](https://raw.githubusercontent.com/laroccod/labforge/main/assets/web_theme_dark.png) | ![matrix](https://raw.githubusercontent.com/laroccod/labforge/main/assets/web_theme_matrix.png) |

## The command line

A lab file needs no launch code of its own:

```bash
labforge serve mylab.py
```

`serve` executes the file (its `if __name__ == "__main__"` block stays cold, so
it will not start a second server), finds the module-level `Lab`, and serves it
at `http://127.0.0.1:8600` — one page, four sections, in a terminal-dashboard
style. Equations render with KaTeX, scans stream `RUNNING · k/N` progress live
with a thin progress bar, every browser session keeps its own workspace, and a
theme button in the header cycles the palettes, re-rendering open figures so
the plots always match the chrome. Section links in the sticky header track
your scroll, slider readouts accept typed exact values, a scan's comma list
shows its point count as you type, figures open full-size on click, and
analysis tables download as CSV. Pick a theme with `--theme`, the port with
`--port`, and pass `--no-browser` to serve without opening one.

![The demo lab served to the browser in the dark theme](https://raw.githubusercontent.com/laroccod/labforge/main/assets/web_app.png)

The lab itself is available as data too: `lab.to_spec()` returns the whole
registration — params, tabs, theory markdown — as one JSON-safe dict, which is
also what the server publishes at `/api/spec`.

The server binds to localhost by design: a lab is your own Python code, so it
is served the way Jupyter serves a notebook — for you, on your machine.

## Install

Requires Python ≥ 3.10.

```bash
pip install labforge
```

Or from a clone, if you want to work on the code directly:

```bash
git clone https://github.com/laroccod/labforge.git
cd labforge
pip install -e .
```

Dependencies: `fastapi`, `uvicorn`, `numpy`, `matplotlib` — that is all.
Equations are typeset in the browser, so no LaTeX toolchain is needed.

## Development

```bash
pip install -e ".[dev]"
pytest
black --check --line-length 100 src tests examples
```

The suite runs the server offline over FastAPI's `TestClient` — the spec
endpoint, a run with its SSE progress stream, viz and analysis payloads, the
generated theme CSS and the error statuses — alongside unit tests for the
registration, scan, dispatch and executor layers, so most mistakes are caught
without serving anything.

## License

[MIT](LICENSE)
