Bearing seat¶

A neodymium disc magnet holds a steel bearing ball in a spherical seat. The example compares the pull on the ball with and without a steel shell around the magnet, for 2, 4 and 8 mm magnets, on the axisymmetric 2D section. It then solves the 4 mm magnet with the shell in 3D. A browser workbench lists each run as it finishes.
Run it¶
You need Docker running and a clone of this repository. The install guide covers Docker and uv. From the repository root, run:
uv sets up the environment on the first run. The seven solves then take about two minutes. The terminal logs the pull for each case, and your browser opens the workbench at http://localhost:8080 while they run. Select a run in the Runs list to see its field. Press Ctrl+C to stop.
uv run examples/bearing-seat/sweeps.py lifts the ball off its seat in 1/8 mm steps and writes a ParaView animation to runs/z-sweep/animation.pvd.
Files¶
geometry.pybuilds the seat, the ball and the shell in build123d.main.pyconfigures the solve, runs the shell comparison and the 3D check, and starts the workbench.ui.pylays out the workbench.sweeps.pyruns the lift sweep.paths.pynames the folders the runs and the run index land in,runs/andruns-index/next to these files.
The code¶
geometry.py
"""The bearing seat: a magnet disc with a spherical seat, the steel ball it holds, and an optional steel shell."""
from math import sqrt
import build123d as b3d
import femforge as ff
class EntityNames:
"""Body names shared by the solve setup and the workbench."""
SEAT = "SEAT"
BEARING = "BEARING"
SHELL = "SHELL"
SHELL_GAP = 0.1
"""`[mm]`: Clearance between the shell and the seat."""
class BearingSeat(ff.Build123dGeometry):
"""A magnet disc with a spherical seat holding a steel bearing ball, optionally cupped in a steel shell; Z is the axis of symmetry."""
outer_diameter: float
"""`[mm]`: Outer diameter of the magnet disc."""
inner_diameter: float
"""`[mm]`: Bore diameter of the magnet disc."""
thickness: float
"""`[mm]`: Axial thickness of the magnet disc."""
bearing_diameter: float
"""`[mm]`: Diameter of the bearing ball."""
seating_diameter: float
"""`[mm]`: Diameter where the ball meets the disc top, measured like a countersink."""
seating_airgap: float
"""`[mm]`: Extra lift of the ball off its seat, emulating misalignment or surface roughness."""
domain_padding: float
"""`[mm]`: Air added around the bodies to bound the simulation domain."""
shell_thickness: float | None = None
"""`[mm]`: Wall thickness of the steel shell around the disc; None for no shell."""
def disc_face(self) -> b3d.Face:
"""The annular disc profile."""
return b3d.Face(
outer_wire=b3d.Wire.make_circle(self.outer_diameter / 2),
inner_wires=[b3d.Wire.make_circle(self.inner_diameter / 2)],
)
def seat_part(self) -> b3d.Part:
"""The magnet disc, from Z = 0 up, with the ball cut out of its top."""
disc = b3d.extrude(self.disc_face(), self.thickness)
part = disc - self.bearing_part()
assert isinstance(part, b3d.Part), "Failed to create the bearing seat part"
return part
def bearing_part(self) -> b3d.Part:
"""The ball, resting with its surface on the disc top at the seating diameter."""
sphere = b3d.Sphere(self.bearing_diameter / 2)
center_height = self.thickness + sqrt((self.bearing_diameter / 2) ** 2 - (self.seating_diameter / 2) ** 2)
return sphere.located(b3d.Location((0, 0, center_height)))
def shell_part(self, thickness: float) -> b3d.Part:
"""A cup of wall ``thickness`` around the disc's side and bottom, ``SHELL_GAP`` away from it."""
shell_outer_diameter = self.outer_diameter + 2 * (thickness + SHELL_GAP)
shell_inner_diameter = self.outer_diameter + 2 * SHELL_GAP
ring = b3d.Face(
outer_wire=b3d.Wire.make_circle(shell_outer_diameter / 2),
inner_wires=[b3d.Wire.make_circle(shell_inner_diameter / 2)],
)
sides = b3d.extrude(ring, self.thickness + SHELL_GAP)
assert isinstance(sides, b3d.Part), "Failed to create the shell part"
bottom_face = b3d.Face(outer_wire=b3d.Wire.make_circle(shell_outer_diameter / 2))
bottom = b3d.extrude(bottom_face, -thickness)
cup = sides + bottom
return b3d.Part([cup]).located(b3d.Location((0, 0, -SHELL_GAP)))
def bodies(self) -> list[ff.TaggedPart]:
"""The seat, the ball lifted by ``seating_airgap`` and, when ``shell_thickness`` is set, the shell, each tagged with its material and name."""
seat = ff.TaggedPart([self.seat_part()], tags={ff.TagKey.MATERIAL: ff.materials.N52().tag}, name=EntityNames.SEAT)
lifted_ball = self.bearing_part().translate((0, 0, self.seating_airgap))
bearing = ff.TaggedPart([lifted_ball], tags={ff.TagKey.MATERIAL: ff.materials.Steel_1080().tag}, name=EntityNames.BEARING)
bodies = [seat, bearing]
if self.shell_thickness is not None:
shell = ff.TaggedPart([self.shell_part(self.shell_thickness)], tags={ff.TagKey.MATERIAL: ff.materials.Steel_1080().tag}, name=EntityNames.SHELL)
bodies.append(shell)
return bodies
def model(self) -> ff.TaggedCompound:
"""The bodies inside a padded box of air."""
bodies = self.bodies()
padding = ff.Vec3(self.domain_padding, self.domain_padding, self.domain_padding)
air_box = ff.boundary_box(ff.TaggedCompound(children=bodies), padding)
air = ff.TaggedPart([air_box], tags={ff.TagKey.MATERIAL: ff.materials.Air().tag})
return ff.TaggedCompound(children=[*bodies, air])
main.py
"""A magnet holds a steel bearing in its seat: compare the pull with and without a steel shell in 2D, check one case in 3D, and watch the runs land in a workbench."""
import threading
import femforge as ff
from geometry import BearingSeat, EntityNames
from paths import RUNS_DIR, TELEMETRY_DIR
from ui import workbench_ui
def config_for(
as_2d: bool = False,
magnet_thickness: float = 2.0,
airgap: float = 0.25,
mesh_size: float = 1.0,
domain_padding: float = 70.0,
shell_thickness: float | None = None,
description: str = "Bearing Seat",
) -> ff.SimulationConfig:
"""A magnetostatic solve of the bearing seat, in 3D or as its axisymmetric 2D section.
Args:
as_2d: Solve the axisymmetric section about Z instead of the 3D model.
magnet_thickness: Axial thickness of the magnet disc, in millimetres.
airgap: Lift of the ball off its seat, in millimetres.
mesh_size: Element size on the seat, the ball and the shell, in millimetres; the air grows five times coarser.
domain_padding: Air around the bodies, in millimetres.
shell_thickness: Wall thickness of the steel shell in millimetres; None for no shell.
description: The run description the workbench lists.
"""
geometry = BearingSeat(
outer_diameter=15.0,
inner_diameter=3.5,
thickness=magnet_thickness,
bearing_diameter=12.0,
seating_diameter=9.0,
seating_airgap=airgap,
domain_padding=domain_padding,
shell_thickness=shell_thickness,
)
# A uniform fine mesh fills the 3D air box with millions of elements; the field that sets the pull lives in and around the bodies.
body_size = mesh_size * ff.U.mm
air_size = body_size * 5
mesh = ff.MeshConfig(
global_mesh_size=air_size,
gmsh_options=ff.GmshOptions(mesh_size_max=air_size * 2),
)
domain = ff.DomainConfig(
materials=[ff.materials.N52(), ff.materials.Steel_1080(), ff.materials.Air()],
tags=[
ff.EntityTag(
selector={ff.TagKey.NAME: EntityNames.SEAT},
conditions=[ff.Magnetization(direction=ff.Direction(0, 0, 1))],
mesh_size=body_size,
),
ff.EntityTag(
selector={ff.TagKey.NAME: EntityNames.BEARING},
conditions=[ff.ForceComponent(calculate_magnetic_force=True)],
mesh_size=body_size,
),
ff.EntityTag(
selector={ff.TagKey.NAME: EntityNames.SHELL},
conditions=[ff.ForceComponent(calculate_magnetic_force=True)],
mesh_size=body_size,
),
],
)
return ff.SimulationConfig(
meta=ff.RunMeta(description=description),
geometry=geometry,
frame=ff.Axisymmetric() if as_2d else ff.Solid(),
mesh=mesh,
physics=ff.PhysicsSelection(selected=ff.Physics.MAGNETOSTATICS),
domain=domain,
)
def evaluate() -> ff.Driver:
"""Solve 2, 4 and 8 mm magnets with and without the shell in 2D, then the 4 mm magnet with the shell in 3D, logging the bearing's axial pull."""
driver = ff.Driver(runs_dir=RUNS_DIR, telemetry=TELEMETRY_DIR, docker=True)
def bearing_pull(magnet_thickness: float, shell_thickness: float | None) -> float:
shell = "with" if shell_thickness is not None else "without"
config = config_for(
as_2d=True,
magnet_thickness=magnet_thickness,
mesh_size=0.5,
shell_thickness=shell_thickness,
description=f"{magnet_thickness:g} mm magnet {shell} shell",
)
result = driver.evaluate(config)
return result.axial_force(EntityNames.BEARING).m_as("newton")
lines = [f"{'magnet [mm]':>12} {'with shell [N]':>15} {'without [N]':>12} {'change':>8}"]
for magnet_thickness in (2.0, 4.0, 8.0):
with_shell = bearing_pull(magnet_thickness, shell_thickness=2.0)
without_shell = bearing_pull(magnet_thickness, shell_thickness=None)
change = with_shell / without_shell - 1
lines.append(f"{magnet_thickness:>12g} {with_shell:>15.3f} {without_shell:>12.3f} {change:>8.1%}")
ff.log.success("Evaluation complete!")
ff.log.info("Bearing axial pull, with vs without the shell:\n" + "\n".join(lines))
# A 3D run has no symmetry axis, so the axial pull projects onto Z.
config_3d = config_for(
as_2d=False,
magnet_thickness=4.0,
mesh_size=1.0,
shell_thickness=2.0,
description="4 mm magnet with shell (3D)",
)
result_3d = driver.evaluate(config_3d)
pull_3d = result_3d.axial_force(EntityNames.BEARING, ff.Axis.Z).m_as("newton")
ff.log.info(f"3D evaluation axial pull: {pull_3d:.3f} N")
return driver
def main() -> None:
"""Solve the shell comparison in the background while the workbench shows the runs as they land."""
threading.Thread(target=evaluate, daemon=True).start()
# A factory, not a built Split, so the workbench rebuilds the UI on every save of ui.py.
ff.workbench.show(workbench_ui, title="Bearing Seat Workbench", window=ff.workbench.Window.BROWSER)
if __name__ == "__main__":
main()
ui.py
"""The bearing seat workbench: the run list beside the field viewer, with the selected run's details below it."""
import femforge as ff
from geometry import EntityNames
from paths import TELEMETRY_DIR
def workbench_ui() -> ff.workbench.Split:
"""The runs on the left; the field viewer and the run details stacked on the right, both following the selected run."""
store = ff.Telemetry(TELEMETRY_DIR)
artifacts = ff.workbench.artifact_store(store)
display_defaults = ff.DisplaySpec(
field="magnetic flux density",
mode=ff.DisplayMode.MAGNITUDE,
colormap="Linear Green (Gr4L)",
clim=None,
opacity_map=(0.05, 0.75), # Weak field fades out, on the bodies and the field lines alike
hidden_bodies=("AIR",),
opacity=0.5,
body_styles=(
ff.BodyStyle(body=EntityNames.SEAT, paint=ff.BodyPaint.SOLID, color="#4C00FF"),
ff.BodyStyle(body=EntityNames.BEARING, paint=ff.BodyPaint.SOLID, color="#C800FF"),
ff.BodyStyle(body=EntityNames.SHELL, paint=ff.BodyPaint.SOLID, color="#00FFB3"),
),
streamlines=ff.FieldLineOptions(seed_placement=ff.SeedPlacement.VOLUME, line_count=14, max_length=0.4),
)
def runs(app: ff.workbench.Workbench, id: str, title: str) -> ff.workbench.RunBrowserPanel:
return ff.workbench.RunBrowserPanel(app, id, title, telemetry=store)
def run_detail(app: ff.workbench.Workbench, id: str, title: str) -> ff.workbench.RunDetailPanel:
return ff.workbench.RunDetailPanel(app, id, title, telemetry=store, store=artifacts)
# Splits alternate their axis with depth, so the viewer nested two levels down keeps its controls beside the 3D view.
return ff.workbench.Split(
[
ff.workbench.Panel("Runs", runs, id="runs-panel", weight=0.25),
ff.workbench.Split(
[
ff.workbench.field_viewer(display=display_defaults, telemetry=TELEMETRY_DIR),
ff.workbench.Panel("Run", run_detail, id="run-detail-panel", weight=0.5),
]
),
]
)
sweeps.py
"""Lift the ball off its seat step by step in the shelled 2D seat and write a ParaView animation of the field."""
from pathlib import Path
import numpy as np
import femforge as ff
from main import config_for
from paths import RUNS_DIR, TELEMETRY_DIR
def z_sweep() -> ff.Driver:
"""Solve the shelled 2D seat as the ball lifts from 0.25 to 8 mm, then write a ParaView animation of the steps."""
driver = ff.Driver(runs_dir=RUNS_DIR, telemetry=TELEMETRY_DIR, docker=True)
vtu_files: list[Path] = []
for airgap in np.arange(0.25, 8.0, 0.125):
config = config_for(
as_2d=True,
airgap=float(airgap),
mesh_size=1.0,
domain_padding=50.0,
shell_thickness=2.0,
)
result = driver.evaluate(config)
vtu = ff.Run.from_result(result).vtu_path if result.run_dir is not None else None
if vtu is None:
ff.log.bind(run_id=result.run_id, airgap=float(airgap)).warning("Run has no field output; left out of the animation.")
continue
vtu_files.append(vtu)
datasets = [f' <DataSet file="{vtu}" timestep="{step}" />' for step, vtu in enumerate(vtu_files)]
collection = [
'<?xml version="1.0"?>',
'<VTKFile type="Collection" version="0.1" byte_order="LittleEndian">',
" <Collection>",
*datasets,
" </Collection>",
"</VTKFile>",
]
animation = RUNS_DIR / "z-sweep" / "animation.pvd"
animation.parent.mkdir(parents=True, exist_ok=True)
_ = animation.write_text("\n".join(collection) + "\n", encoding="utf-8")
ff.log.bind(path=str(animation), steps=len(vtu_files)).info("Animation written.")
return driver
if __name__ == "__main__":
_ = z_sweep()