Metadata-Version: 2.4
Name: ionforge
Version: 0.3.0
Summary: Open-source charged particle optics toolkit
Project-URL: Repository, https://github.com/ionforge-labs/ionforge
Project-URL: Issues, https://github.com/ionforge-labs/ionforge/issues
Author: Michael Giles, Puneet Matharu, Jacob Cable
License: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: BEM,XPS,charged particle optics,electron analyser,ion optics
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Physics
Requires-Python: >=3.11
Requires-Dist: numpy>=1.23
Requires-Dist: pydantic>=2.0
Provides-Extra: client
Requires-Dist: httpx>=0.27; extra == 'client'
Provides-Extra: dev
Requires-Dist: datamodel-code-generator[http]>=0.28; extra == 'dev'
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: matplotlib>=3.5; extra == 'dev'
Requires-Dist: pandas>=2.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.11; extra == 'dev'
Requires-Dist: ty>=0.0.1a; extra == 'dev'
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == 'pandas'
Provides-Extra: viz
Requires-Dist: matplotlib>=3.5; extra == 'viz'
Requires-Dist: plotly>=5.0; extra == 'viz'
Requires-Dist: pyvista>=0.42; extra == 'viz'
Provides-Extra: viz-plotly
Requires-Dist: plotly>=5.0; extra == 'viz-plotly'
Provides-Extra: viz-pyvista
Requires-Dist: pyvista>=0.42; extra == 'viz-pyvista'
Description-Content-Type: text/markdown

# IonForge

Open-source charged particle optics toolkit.

The SDK provides parametric geometry primitives for building simulation meshes, Pydantic v2 serialization models, STL mesh import/export with quality metrics, and a client for the IonForge cloud API.

## Installation

```bash
uv add ionforge
```

## Quick Start

```python
from ionforge.geometry import Geometry, Cylinder, AnnularDisk

geo = Geometry(bounding_box=(0.1, 0.1, 0.2))

geo.add(Cylinder(r=0.01, length=0.05, voltage=100, name="tube_1"))
geo.add(AnnularDisk(inner_radius=0.005, outer_radius=0.01, voltage=0, name="aperture"), z=0.06)
geo.add(Cylinder(r=0.01, length=0.05, voltage=50, name="tube_2"), z=0.07)

serialized = geo.to_serialized_geometry()
errors = serialized.validate_consistency()  # [] if valid
```

## Examples

Runnable scripts are in the [`examples/`](examples/) directory. Run any of them with:

```bash
uv run python examples/<name>.py
```

### Parametric primitives

Build geometry using `Cylinder`, `AnnularDisk`, `Cone`, and `Sphere` primitives. Each primitive takes a `voltage`, `name`, and `n_segments` (default 32) for mesh resolution.

Primitives extrude along the z axis (the optical axis) and are centred on it in x/y; lengths and positions are in metres. The axes, origin, and bounding-box conventions are documented in [`docs/parameters.md`](docs/parameters.md#axes-origin-and-the-bounding-box).

```python
from ionforge.geometry import Geometry, Cylinder, AnnularDisk, Cone, Sphere

geo = Geometry(bounding_box=(0.1, 0.1, 0.3))

# Drift tube
geo.add(Cylinder(r=0.01, length=0.05, voltage=100, name="tube_1"))

# Aperture plate
geo.add(
    AnnularDisk(inner_radius=0.003, outer_radius=0.01, voltage=0, name="aperture"),
    z=0.06,
)

# Tapered section
geo.add(
    Cone(bottom_radius=0.01, top_radius=0.005, length=0.03, voltage=-50, name="taper"),
    z=0.07,
)

# Second tube
geo.add(Cylinder(r=0.005, length=0.05, voltage=100, name="tube_2"), z=0.10)

serialized = geo.to_serialized_geometry()
```

See [`examples/build_geometry.py`](examples/build_geometry.py) for a full runnable version.

### STL import and export

Load a mesh from an STL file, inspect quality metrics, and re-export.

```python
from ionforge.geometry.stl_import import load_stl, mesh_stats, write_stl

# Load STL with mm -> metres conversion
triangles = load_stl("model.stl", scale_factor=1e-3)

# Print mesh quality statistics
stats = mesh_stats(triangles, verbose=True)
# Mesh statistics:
#   Triangles: 2  (0 degenerate, 2 valid)
#   Total area: 1.00 mm²
#   Edge range: 1.000 – 1.414 mm
#   Aspect ratio: mean=2.00  max=2.00

# Export as binary STL
write_stl("output.stl", triangles, name="my_mesh")
```

See [`examples/stl_round_trip.py`](examples/stl_round_trip.py) for a self-contained runnable version.

### Visualization

Geometry can be rendered in 3-D with three backends: **matplotlib** (default, no extra deps), **plotly** (interactive, great for Jupyter/Colab), and **pyvista** (full VTK).

```python
geo = Geometry(bounding_box=(0.06, 0.06, 0.12))
geo.add(Cylinder(r=0.01, length=0.03, voltage=0, name="tube"))
# ... build geometry ...

geo.visualize()                          # matplotlib (default)
geo.visualize(backend="plotly")          # interactive HTML widget
geo.visualize(backend="pyvista")         # VTK 3-D window
```

Color by group name or by voltage (auto-selected when every group has a voltage):

```python
geo.visualize(color_by="voltage")        # blue–white–red diverging colourmap
geo.visualize(color_by="group")          # per-group hex colours
```

| Colour by voltage | Colour by group |
|---|---|
| ![voltage](docs/images/viz_voltage.png) | ![group](docs/images/viz_group.png) |

You can also render a `SerializedGeometry` directly:

```python
from ionforge.geometry.visualization import render

render(serialized, backend="plotly", color_by="voltage", opacity=0.8)
```

Install optional visualization backends:

```bash
uv add ionforge --extra viz-plotly    # plotly only
uv add ionforge --extra viz-pyvista   # pyvista only
uv add ionforge --extra viz           # all visualization deps
```

Per-backend examples:

- [`examples/viz_matplotlib.py`](examples/viz_matplotlib.py) — static 3-D plot (no extra deps)
- [`examples/viz_plotly.py`](examples/viz_plotly.py) — interactive HTML widget
- [`examples/viz_pyvista.py`](examples/viz_pyvista.py) — full VTK 3-D window

See also [`examples/visualize_geometry.py`](examples/visualize_geometry.py) for a CLI version with `--backend` and `--color-by` flags.

### JSON round-trip

Geometry models use snake_case in Python and camelCase when serialized to JSON. Both naming conventions are accepted when parsing.

```python
import json
from ionforge.geometry import SerializedGeometry

# Serialize to camelCase JSON
camel_json = json.dumps(serialized.model_dump(by_alias=True), indent=2)
# {"version": 1, "units": "m", "boundingBox": {...}, "groupOrder": [...], ...}

# Parse from camelCase
parsed = SerializedGeometry.model_validate_json(camel_json)

# Parse from snake_case (also works)
parsed = SerializedGeometry.model_validate({
    "version": 1,
    "units": "m",
    "vertices": [],
    "edges": [],
    "faces": [],
    "bounding_box": {"size": [1, 1, 1], "voltage": 0},
    "groups": [],
    "group_order": [],
})
```

See [`examples/json_round_trip.py`](examples/json_round_trip.py) for the full runnable version.

### JSON Schema generation

Every serialization model is a Pydantic model, so you can emit a standard JSON Schema straight from it - handy for validation or for generating types in other languages:

```python
import json
from ionforge.geometry import SerializedGeometry

schema = SerializedGeometry.model_json_schema()
print(json.dumps(schema, indent=2))
```

See [`examples/export_schema.py`](examples/export_schema.py) for a runnable version that writes the schema to stdout:

```bash
uv run python examples/export_schema.py > geometry-schema.json
```

### Low-level model API

For full control, construct `SerializedGeometry` directly from vertices, edges, faces, and groups:

```python
from ionforge.geometry import (
    BoundingBox, Edge, Face, Group, SerializedGeometry, Vertex,
)

geo = SerializedGeometry(
    vertices=[
        Vertex(id="v0", position=(0.0, 0.0, 0.0)),
        Vertex(id="v1", position=(0.1, 0.0, 0.0)),
        Vertex(id="v2", position=(0.05, 0.1, 0.0)),
    ],
    edges=[
        Edge(id="e0", v0="v0", v1="v1", face_ids=["f0"]),
        Edge(id="e1", v0="v1", v1="v2", face_ids=["f0"]),
        Edge(id="e2", v0="v2", v1="v0", face_ids=["f0"]),
    ],
    faces=[
        Face(id="f0", vertex_ids=["v0", "v1", "v2"], edge_ids=["e0", "e1", "e2"]),
    ],
    bounding_box=BoundingBox(size=(0.2, 0.2, 0.2), voltage=0.0),
    groups=[
        Group(id="g0", name="plate", color="#ff0000", voltage=10.0, face_ids=["f0"]),
    ],
    group_order=["g0"],
)

errors = geo.validate_consistency()  # [] if valid
```

## API client

The SDK ships an optional client for the IonForge cloud API. Install it with the `client` extra:

```bash
uv add "ionforge[client]"
```

Authenticate with an API key. The client reads `IONFORGE_API_KEY` from the environment (or pass `api_key=` explicitly), and an optional `IONFORGE_BASE_URL` overrides the API endpoint:

```bash
export IONFORGE_API_KEY="ifk_..."
```

The workflow is geometry -> model -> run. Build a geometry locally, upload it, launch a simulation run, and pull down the results:

```python
from ionforge.client import IonForge
from ionforge.geometry import Geometry, Cylinder

with IonForge() as client:  # reads IONFORGE_API_KEY
    project = client.projects.create(name="Einzel lens study")

    geo = Geometry(bounding_box=(0.1, 0.1, 0.2))
    geo.add(Cylinder(r=0.01, length=0.05, voltage=100, name="tube"))
    geometry = client.upload_geometry(project.id, "lens-v1", geo)

    # Creates a model, launches a run, and waits for it to finish
    run = client.run_simulation(
        project_id=project.id,
        name="baseline",
        geometry_id=geometry.id,
    )

    paths = client.download_results(run.id, output_dir="results/")
```

An `AsyncIonForge` client with the same surface is available for asyncio code.

### Analysing results with pandas

Install the `pandas` extra (`uv add "ionforge[pandas]"`) to pull sweep and run results straight into a DataFrame for analysis and plotting. Sweep results give one row per point, with each swept parameter flattened to its own `param.`-prefixed dot-path column (`param.beam.E_nominal`) alongside the objective and per-point status:

```python
with IonForge() as client:
    # One row per sweep point; auto-paginates across result cursors.
    results = client.sweeps.list_results(sweep.id, mode="full")
    df = results.to_dataframe()

    # e.g. transmission vs beam energy
    df.plot.scatter(x="param.beam.E_nominal", y="summary.transmission")

    # A tabular view of every run in a project
    runs_df = client.runs.to_dataframe(project_id=project.id)
```

In `full` mode, per-point result-summary metrics appear as `summary.*` columns; `table` mode returns just the objective and status. Pass `max_rows=` to cap large pulls.

Downloaded result files parse into numpy arrays and DataFrames with `load_result`. Each result file carries the scalar run metrics (transmission, energy resolution, point spread), the per-particle exit and input arrays, and -- when the run stored them -- per-particle trajectories:

```python
from ionforge.client import IonForge, load_result

with IonForge() as client:
    paths = client.download_results(run.id, output_dir="results/")
    data = load_result(paths[0])

    if data.summary.transmission is not None:
        print(f"transmission: {data.summary.transmission:.1%}")
    eres = data.summary.energy_resolution
    if eres is not None and eres.fwhm_eV is not None:
        print(f"energy resolution FWHM: {eres.fwhm_eV:.3f} eV")

    # Per-particle numbers as numpy arrays.
    exit_energy_spread = data.exit_energies.std()

    # Stable per-particle frames: one row per launched particle, and one row
    # per transmitted particle. Their shapes don't flip when particles are lost.
    particles = data.particles_dataframe()  # column: input_energy
    exits = data.exits_dataframe()  # columns: exit_energy, exit_position

    # The energy-transmission curve, when present.
    curve = data.transmission_curve_dataframe()
```

`client.load_results(run.id, output_dir="results/")` downloads and parses in one call, returning a `RunResultData` per file.

Simulation runs are configured with `ModelParams` (beam, solver, integrator, and more). Every field, with its units, defaults, and conventions, is documented in [`docs/parameters.md`](docs/parameters.md).

See [`examples/run_simulation.py`](examples/run_simulation.py) for a complete, runnable end-to-end workflow that builds an einzel lens, uploads it, runs a simulation, and downloads the results.

## Development

```bash
uv sync --extra dev
uv run pytest
uv run ruff check .
uv run ty check
```

If you use an AI coding assistant (Claude Code, Cursor, etc.), see [`AGENTS.md`](AGENTS.md) for a concise, machine-readable orientation to the SDK's layout and conventions.

See [`RELEASING.md`](RELEASING.md) for how versions are cut and published to PyPI.
