# Quickstart

This page creates a project from the femforge template, solves it and opens the result in a browser. The project is one script: a disc magnet pulls on a steel plate across an air gap, solved at four gaps on the axisymmetric section.

## Before you start

You need [uv](<https://docs.astral.sh/uv/getting-started/installation/>), which installs Python and the packages, and either [Docker](<https://docs.docker.com/get-started/get-docker/>), which runs the Elmer solver, or a native Elmer install. [Install](<https://docs.femforge.io/overview/install/index.md>) covers all three.

## Create the project

```console
uvx femforge new magnet-plate
```

The command writes `main.py`, a `README.md` and a `.gitignore` into `magnet-plate/`, then prints the command that runs the project. It refuses a folder that is not empty unless you pass `--force`.

## Run it

```console
uv run magnet-plate/main.py
```

The first run downloads everything once:

1. uv reads the script's header and installs Python and `femforge[workbench]`: about 1.2 GB, a few minutes.
2. When Elmer is not installed natively, femforge pulls the solver's Docker image, about 1.3 GB. When the pull fails, it builds the image instead, which takes a few minutes longer.
3. The four solves run, each in seconds. The terminal logs the force on the plate for each gap and the run that holds it.
4. Your browser opens the workbench at http://localhost:8080 on the magnetic field of the newest run, the last gap. Pick another run in the **Run** list to compare.

Press `Ctrl+C` to stop. A later run loads each solved gap from `magnet-plate/runs/` instead of solving it again, and starts in seconds.

## The script

```python
# /// script
# requires-python = ">=3.12"
# dependencies = ["femforge[workbench]~=0.1.0"]
# ///
"""A disc magnet pulls on a steel plate across an air gap: solve four gaps, then browse the fields in a workbench."""

from pathlib import Path

import build123d as b3d

import femforge as ff


HERE = Path(__file__).parent
RUNS = HERE / "runs"
TELEMETRY = HERE / "runs-index"

GAPS = (0.5, 1.0, 2.0, 4.0)
"""`[mm]`: The air gaps to solve."""

# The results spell body names in capitals, so these stay uppercase.
MAGNET = "MAGNET"
PLATE = "PLATE"

N52 = ff.materials.N52()
STEEL = ff.materials.Steel_1080()
AIR = ff.materials.Air()


class MagnetAndPlate(ff.Build123dGeometry):
    """A disc magnet with a steel plate above it, both centered on the Z axis, inside a box of air."""

    gap: float
    """`[mm]`: Air gap between the top of the magnet and the bottom of the plate."""

    magnet_diameter: float = 20.0
    """`[mm]`: Diameter of the magnet disc."""

    magnet_thickness: float = 5.0
    """`[mm]`: Thickness of the magnet disc."""

    plate_diameter: float = 40.0
    """`[mm]`: Diameter of the steel plate."""

    plate_thickness: float = 5.0
    """`[mm]`: Thickness of the steel plate."""

    padding: float = 100.0
    """`[mm]`: Air added around the bodies, half on each side."""

    def model(self) -> ff.TaggedCompound:
        """The magnet from Z = 0 up, the plate ``gap`` above it, and the air around both."""

        def disc(diameter: float, thickness: float, bottom: float) -> b3d.Part:
            return b3d.Pos(0, 0, bottom + thickness / 2) * b3d.Cylinder(diameter / 2, thickness)

        magnet = ff.TaggedPart([disc(self.magnet_diameter, self.magnet_thickness, 0)], tags={ff.TagKey.MATERIAL: N52.tag}, name=MAGNET)
        plate = ff.TaggedPart([disc(self.plate_diameter, self.plate_thickness, self.magnet_thickness + self.gap)], tags={ff.TagKey.MATERIAL: STEEL.tag}, name=PLATE)

        box = ff.boundary_box(ff.TaggedCompound(children=[magnet, plate]), ff.Vec3(self.padding, self.padding, self.padding))
        air = ff.TaggedPart([box], tags={ff.TagKey.MATERIAL: AIR.tag})

        return ff.TaggedCompound(children=[magnet, plate, air])


def config(gap: float = 1.0) -> ff.SimulationConfig:
    """The magnetostatic solve of the magnet and the plate ``gap`` mm apart, on the axisymmetric section about Z."""
    # Mesh sizes are in metres; the geometry is in millimetres.
    fine = 0.0002

    return ff.SimulationConfig(
        meta=ff.RunMeta(description=f"{gap:g} mm gap"),
        geometry=MagnetAndPlate(gap=gap),
        frame=ff.Axisymmetric(),
        mesh=ff.MeshConfig(global_mesh_size=0.002),
        physics=ff.PhysicsSelection(selected=ff.Physics.MAGNETOSTATICS),
        domain=ff.DomainConfig(
            materials=[N52, STEEL, AIR],
            tags=[
                ff.EntityTag(selector={ff.TagKey.NAME: MAGNET}, conditions=[ff.Magnetization(direction=ff.Direction(0, 0, 1))], mesh_size=fine),
                ff.EntityTag(selector={ff.TagKey.NAME: PLATE}, conditions=[ff.ForceComponent()], mesh_size=fine),
            ],
        ),
    )


def solve() -> None:
    """Solve every gap and log the axial force on the plate, negative toward the magnet; a gap solved before loads from ``runs/``."""
    driver = ff.Driver(runs_dir=RUNS, telemetry=TELEMETRY)

    for gap in GAPS:
        result = driver.evaluate(config(gap))
        newtons = result.axial_force(PLATE).m_as("newton")
        ff.log.success(f"{gap:g} mm gap: {newtons:.1f} N on the plate, run {result.run_id}")


def viewer() -> ff.workbench.Split:
    """The field viewer on the newest run, coloring the flux density with the air hidden."""
    display = ff.DisplaySpec(field="magnetic flux density", mode=ff.DisplayMode.MAGNITUDE, hidden_bodies=("AIR",))
    runs = ff.find_runs(RUNS)

    return ff.workbench.field_viewer(runs[0] if runs else None, display=display, telemetry=TELEMETRY)


if __name__ == "__main__":
    solve()

    # A function, not a built dock, so every save of this file rebuilds the workbench.
    ff.workbench.show(viewer, title="Magnet and Plate", window=ff.workbench.Window.BROWSER)
```

- `MagnetAndPlate` is the geometry: a build123d model whose parts carry their material and name. See [Geometry and tags](<https://docs.femforge.io/user-guide/geometry/index.md>).
- `config()` builds one `SimulationConfig`: the axisymmetric frame, the mesh sizes, the materials and the conditions. See [Conditions and physics](<https://docs.femforge.io/user-guide/conditions/index.md>), [Frames](<https://docs.femforge.io/user-guide/frames/index.md>) and [Mesh](<https://docs.femforge.io/user-guide/mesh/index.md>).
- `solve()` runs each gap through a `Driver` and reads the force from the typed `Result`. See [Driver and runs](<https://docs.femforge.io/user-guide/driver/index.md>) and [Results](<https://docs.femforge.io/user-guide/results/index.md>).
- `viewer()` and `show()` serve the field viewer. See [Workbench](<https://docs.femforge.io/user-guide/workbench/index.md>).

## What to change next

- Change a dimension in `MagnetAndPlate`, or add a gap to `GAPS`, and run the script again. femforge solves only the gaps it has not solved before.
- Swap the magnet grade for `ff.materials.N42()`. See [Materials](<https://docs.femforge.io/user-guide/materials/index.md>).
- Change what the workbench shows first in `viewer()`, such as the colormap or the hidden bodies. A save of `main.py` rebuilds the open workbench.
- Pin every package at its current version with `uv lock --script magnet-plate/main.py`.

The [examples](<https://github.com/femforge-io/femforge/tree/main/examples>) go further: a bearing seat in 2D and 3D, a heated copper puck on an FR4 board, and a solenoid pulling a steel plunger.
