Skip to content

Run

The run lifecycle: the driver, the content-addressed bundle it writes per solve, and the run and result readers.

femforge.run

Run lifecycle: the content-addressed bundle a Driver writes per solve and a Run opens for reading.

Modules:

  • artifacts –

    The run side of the built-in artifacts: rebinding a cached mesh's groups, the bundle file policy, and rehydration.

  • bundle –

    Read and write the runs/<run_id>/ bundle: a content-addressed run directory holding the manifest, the typed result and the pinned geometry.

  • convergence –

    Mesh convergence: re-run one configuration at increasing refinement, then Richardson-extrapolate the quantity of interest.

  • driver –

    The driver: assemble one run end-to-end (identity, reuse, mesh + sif + solve, parse, bundle, publish, retention); the generators (Mesher, Sim) stay run-ignorant.

  • electrical –

    Per-phase electrical quantities of a current-conduction run, from the electrode flux columns in Result.raw and boundaries.yml.

  • entities –

    Named bodies and boundaries of a run bundle, resolved to the mesh ids the VTU's GeometryIds cell array uses.

  • fields –

    Fluent numpy queries over a solved VTU's fields, keyed by body names, tags, and mesh ids.

  • inspect –

    The discoverable face of a run bundle: Run(sim_dir) opens typed results, named entities, components, and the VTU field query.

  • kinds –

    Run identity and the built-in artifact kinds: the STEP a geometry builds, the Elmer mesh a config meshes, and the bundle a run leaves.

  • magnetic –

    Per-component magnetic quantities of a magnetostatics run, joined from the SaveScalars columns in Result.raw and components.yml.

  • manifest –

    Run provenance: the self-describing run manifest and the git version stamp.

  • parse –

    Parse Elmer outputs into a typed Result: SaveScalars columns for the numbers, the solver log for status, residual and wall time.

  • purge –

    Post-hoc retention over existing run bundles: one bundle or a whole runs directory, dry-run by default.

  • reap –

    Abandoned runs: the bundles left running with no live claim on them, marked so and their claims broken.

  • result –

    The typed outcome of a solve: Elmer outputs parsed into validated, pint-typed fields.

  • retention –

    Run retention: which classes of bundle files leave after a run, the policy that says so, and the engine that applies it.

  • stages –

    Pipeline stage vocabulary: the driver's stages in execution order, plus the timed event a run records per stage.

Classes:

  • BundlePolicy –

    Which bundle files a published run bundle carries.

  • MeshGroupsFile –

    groups.json: the group records and element count a mesh artifact carries beside ElmerGrid's files.

  • MeshRebindError –

    A cached mesh cannot be bound to the current config's domain.

  • ConvergencePoint –

    One rung of the refinement ladder: the refinement level, the resulting quantity, and its relative change from the previous (coarser) rung.

  • RichardsonEstimate –

    Richardson extrapolation of a ladder's finest three rungs, assuming q(h) = q* + C·hᵖ.

  • Driver –

    Runs each config through the pipeline into a runs/<run_id>/ bundle and returns its typed Result.

  • Bodies –

    Name-keyed collection of a run's bodies.

  • Body –

    One named body group of a run.

  • Boundaries –

    Name-keyed collection of a run's boundaries, with adjacency lookups powered by the A__B name convention.

  • Boundary –

    One named boundary group of a run.

  • BoundaryKind –

    What a boundary group separates: two bodies, a body's external shell, or its trace on the 2D symmetry axis.

  • Entities –

    A bundle's named bodies and boundaries.

  • FieldMesh –

    A solved VTU as cell-space numpy arrays with named-body selection; open one via load or Run.mesh().

  • FieldQuery –

    One field array under an immutable cell mask: filters return refined copies, terminals return numpy values.

  • Run –

    One run bundle (runs/<run_id>/) opened for reading.

  • SifComponent –

    One sif Component block with its lumped force and torque.

  • MeshSpec –

    Everything that decides the Elmer mesh files and their group names, and nothing that only decides the solve.

  • RunSpec –

    The bundle kind's spec: the config a run solved.

  • RunManifest –

    The self-describing record of one run.

  • ReapedRun –

    One run reap_runs settled.

  • MeshStats –

    Per-run mesh size/quality summary, recorded in the manifest so a sweep can be audited for mesh consistency.

  • Result –

    The typed outcome of one solve.

  • BundleEntry –

    One file found in a bundle, with the retention class its name falls in.

  • Cleanup –

    When a file class leaves the bundle; definition order is escalation order.

  • CleanupRecord –

    One cleanup pass over a bundle: when, by whom, under which policy, and what left.

  • CleanupReport –

    The outcome of applying a plan: what left (or would have, in a dry run) and what could not be removed.

  • Preset –

    Named retention policies, from keep-everything to removing the run directory.

  • RetentionPolicy –

    One Cleanup rule per bundle file class; the default keeps everything the run cannot re-fetch.

  • Stage –

    The driver's pipeline stages, in execution order.

  • StageEvent –

    One stage's timing record: created at stage start, mutated with finished_at when the stage completes.

Functions:

  • bundle_files –

    The bundle-relative posix paths policy publishes from sim_dir.

  • rebind_groups –

    Rebuild a cached mesh's groups against this config's domain, matching materials by tag and EntityTag objects by selector; ids stay placeholders until reconcile_ids.

  • rehydrate_bundle –

    Fetch every store-sourced input the manifest names back into the bundle.

  • load_manifest –

    Read a bundle's manifest.json back into a typed RunManifest — a delegate to RunManifest.load.

  • load_result –

    Read a bundle's result.json back into a typed Result — a delegate to Result.load.

  • write_atomic –

    Replace path atomically: write a .tmp sibling, then os.replace — a reader never sees a half-written file.

  • write_bundle –

    Write manifest.json and result.json into sim_dir, pin the source STEP as geometry.step unless the run already holds one, and return the bundle.

  • write_manifest –

    Atomically write (or overwrite) manifest.json in sim_dir (created if needed) and return its path.

  • convergence_ladder –

    Run base_config at each refinement level and report the quantity of interest plus its relative change between rungs.

  • richardson –

    Richardson-extrapolate the finest three rungs of a ladder whose scales are mesh-size multipliers (larger is coarser) paired with values.

  • boundary_fluxes –

    Elmer bc index -> integrated conductive flux (A) from the SaveScalars columns in Result.raw.

  • electrode_currents –

    Boundary block name -> terminal current (A), joining the flux columns against the bc-index name map.

  • phase_currents –

    Phase index ("0", "1", ...) -> terminal current (A): the largest-magnitude current among each phase's named electrode currents.

  • read_boundary_names –

    The boundaries.yml map the Sim writes (<bc index>: <boundary block name>), as a dict.

  • resistance –

    R = ΔV / I for one phase; raises on a (numerically) zero current.

  • load_entities –

    Read a bundle's bodies/boundaries: entities.json when present, else mesh.names enriched from the manifest config, else empty.

  • write_entities –

    Write entities.json mapping each group's reconciled mesh id to its named record; call after reconcile_ids.

  • select_array –

    Resolve a VTU array name for a solver variable: exact (nodal), then the elemental "<name> e", then substring — VTU names come back lowercased.

  • find_runs –

    The bundles under runs_dir whose metadata matches, newest first; this reads every manifest, so prefer RunIndex over thousands of bundles.

  • mesh_spec –

    The mesh artifact spec of config (step_key_override when the driver already resolved the geometry).

  • step_digest –

    SHA-256 of a STEP with its FILE_NAME header (the export timestamp) masked; of the path string when the file is absent, so pure-logic keys stay deterministic.

  • component_forces –

    Component name -> lumped magnetic force (N, per radian in an axisymmetric run), joining the force triples against the components.yml map.

  • component_torques –

    Component name -> lumped magnetic torque (N*m about the authored origin/axis, per radian in an axisymmetric run), joining the torque triples against the components.yml map.

  • eddy_current_power –

    The total eddy current power (W, per radian in an axisymmetric run), or None when the run exported none (static solves have no eddy losses).

  • force_triples –

    Column stem -> per-component force Vec3 (N, per radian in an axisymmetric run) from the SaveScalars columns in Result.raw.

  • magnetic_field_energy –

    The total magnetic field energy (J, per radian in an axisymmetric run), or None when the run exported none.

  • read_component_names –

    The components.yml map the Sim writes (<component id>: <Component name>) — the same flat format as boundaries.yml.

  • cache_key –

    Full-run identity: every result-affecting input plus RUN_PRODUCER_VERSION; never the code commit, the machine, or the run metadata.

  • git_sha –

    The current commit, with -dirty appended when the working tree has uncommitted changes; None without git or outside a repository.

  • mesh_key –

    The mesh artifact key of config: invariant to materials' properties, conditions, physics, and metadata.

  • step_key –

    The solve-frame geometry identity a run and its mesh key on: the STEP artifact key of step_spec(config).

  • parse_result –

    Parse a finished run directory into a typed Result, revolving force and torque to full-revolution totals under an Axisymmetric frame.

  • result_vtu –

    The run's newest ParaView output: the .pvtu master when the solve was partitioned, else the newest .vtu; None when there is none.

  • reap_runs –

    Mark every bundle under runs_dir left running with no live claim on it as abandoned, and break every claim that went silent.

  • apply_cleanup –

    Remove every planned path; never raises, so a path that cannot be removed lands in report.failed.

  • bundle_inventory –

    Every file under a bundle with the retention class its name matches; a name no class claims is filed under RunFile.RUN_DIR, the only rule that reaches it.

  • plan_cleanup –

    Resolve policy against one bundle into the sized paths each applying rule removes; refuses a symlinked or junctioned directory.

Attributes:

  • SolverFn –

    A solve step that fills sim_dir with Elmer outputs and reports the mesh it built; injecting one replaces the gmsh and Elmer pipeline.

  • GEOMETRY_IDS_ARRAY –

    The Int cell array ResultOutputSolver (Save Geometry Ids) writes: each cell's mesh id, bodies and boundaries alike.

  • ELMER_MESH (ArtifactKind[MeshSpec]) –

    ElmerGrid's mesh files plus groups.json; produced by the driver, which owns gmsh and ElmerGrid.

  • RUN_BUNDLE (ArtifactKind[RunSpec]) –

    A finished run bundle, keyed by cache_key so its store key and its run_id agree.

  • STEP (ArtifactKind[StepSpec]) –

    The solve-frame STEP of a parametric geometry or an authored file, keyed by its source (HashExclude fields left out) and the frame.

  • ManifestStatus –

    A manifest's lifecycle status: "running" while the pipeline executes, then the terminal RunStatus, or "abandoned" when its claim went silent first.

  • RunStatus –

    The terminal state of a solve: completed cleanly, failed to converge, or errored out.

SolverFn module-attribute

A solve step that fills sim_dir with Elmer outputs and reports the mesh it built; injecting one replaces the gmsh and Elmer pipeline.

GEOMETRY_IDS_ARRAY module-attribute

GEOMETRY_IDS_ARRAY = 'GeometryIds'

The Int cell array ResultOutputSolver (Save Geometry Ids) writes: each cell's mesh id, bodies and boundaries alike.

ELMER_MESH module-attribute

ELMER_MESH: ArtifactKind[MeshSpec] = ArtifactKind(name=BuiltinKind.ELMER_MESH, spec_type=MeshSpec, producer_version=MESH_PRODUCER_VERSION, required_files=(BundleFile.MESH_HEADER, BundleFile.MESH_NODES, BundleFile.MESH_ELEMENTS, BundleFile.MESH_BOUNDARY, BundleFile.MESH_NAMES, BundleFile.GROUPS))

ElmerGrid's mesh files plus groups.json; produced by the driver, which owns gmsh and ElmerGrid.

RUN_BUNDLE module-attribute

RUN_BUNDLE: ArtifactKind[RunSpec] = ArtifactKind(name=BuiltinKind.RUN_BUNDLE, spec_type=RunSpec, producer_version=RUN_PRODUCER_VERSION, required_files=(BundleFile.MANIFEST, BundleFile.RESULT), key=lambda spec: cache_key(spec.config), in_place=True)

A finished run bundle, keyed by cache_key so its store key and its run_id agree.

STEP module-attribute

STEP: ArtifactKind[StepSpec] = ArtifactKind(name=BuiltinKind.STEP, spec_type=StepSpec, producer_version=STEP_PRODUCER_VERSION, required_files=(BundleFile.GEOMETRY_STEP,), produce=_build_step)

The solve-frame STEP of a parametric geometry or an authored file, keyed by its source (HashExclude fields left out) and the frame.

ManifestStatus module-attribute

ManifestStatus = Literal['running', 'abandoned'] | RunStatus

A manifest's lifecycle status: "running" while the pipeline executes, then the terminal RunStatus, or "abandoned" when its claim went silent first.

RunStatus module-attribute

RunStatus = Literal['ok', 'diverged', 'error']

The terminal state of a solve: completed cleanly, failed to converge, or errored out.

BundlePolicy

Bases: StrEnum

Which bundle files a published run bundle carries.

Attributes:

  • CORE –

    Manifest, result, sidecars, sif, scalars, logs, geometry: everything but fields and meshes.

  • FIELDS –

    Core plus the VTU output.

  • FULL –

    Every bundle file, meshes and partitioning included.

  • classes (tuple[RunFile, ...]) –

    The retention classes this policy publishes.

CORE class-attribute instance-attribute

CORE = 'core'

Manifest, result, sidecars, sif, scalars, logs, geometry: everything but fields and meshes.

FIELDS class-attribute instance-attribute

FIELDS = 'fields'

Core plus the VTU output.

FULL class-attribute instance-attribute

FULL = 'full'

Every bundle file, meshes and partitioning included.

classes property

classes: tuple[RunFile, ...]

The retention classes this policy publishes.

MeshGroupsFile pydantic-model

Bases: BaseModel

groups.json: the group records and element count a mesh artifact carries beside ElmerGrid's files.

Config:

  • use_attribute_docstrings: True

Fields:

bodies pydantic-field

The body group records, in mesher order.

boundaries pydantic-field

The boundary group records, in mesher order.

element_count pydantic-field

element_count: int = 0

Elements of the body dimension in the mesh.

capture classmethod

capture(physical_groups: Iterable[_BodySource], boundary_groups: Iterable[_BoundarySource], element_count: int) -> MeshGroupsFile

Record the mesher's groups.

Parameters:

  • physical_groups (Iterable[_BodySource]) –

    The body groups, in mesher order.

  • boundary_groups (Iterable[_BoundarySource]) –

    The boundary groups, in mesher order.

  • element_count (int) –

    Elements of the body dimension in the mesh.

Source code in src/femforge/meshing/groups.py
@classmethod
def capture(cls, physical_groups: Iterable[_BodySource], boundary_groups: Iterable[_BoundarySource], element_count: int) -> MeshGroupsFile:
    """Record the mesher's groups.

    Args:
        physical_groups: The body groups, in mesher order.
        boundary_groups: The boundary groups, in mesher order.
        element_count: Elements of the body dimension in the mesh.
    """
    return cls(
        bodies=[
            BodyGroupRecord(
                dim=group.dim,
                name=group.name,
                material=group.material.tag,
                selectors=[dict(tag.selector) for tag in group.tags],
                entity_tags=list(group.entity_tags),
                authored=group.authored,
            )
            for group in physical_groups
        ],
        boundaries=[
            BoundaryGroupRecord(
                dim=group.dim,
                name=group.name,
                bodies=tuple(group.bodies),
                face_tags=list(group.entity_tags),
                on_axis=group.on_axis,
            )
            for group in boundary_groups
        ],
        element_count=element_count,
    )

load classmethod

load(sim_dir: str | Path) -> MeshGroupsFile

Read groups.json from sim_dir.

Source code in src/femforge/meshing/groups.py
@classmethod
def load(cls, sim_dir: str | Path) -> MeshGroupsFile:
    """Read ``groups.json`` from ``sim_dir``."""
    return cls.model_validate_json((Path(sim_dir) / BundleFile.GROUPS).read_text(encoding="utf-8"))

write

write(sim_dir: str | Path) -> Path

Write groups.json into sim_dir and return its path.

Source code in src/femforge/meshing/groups.py
def write(self, sim_dir: str | Path) -> Path:
    """Write ``groups.json`` into ``sim_dir`` and return its path."""
    path: Path = Path(sim_dir) / BundleFile.GROUPS
    _ = path.write_text(self.model_dump_json(indent=2), encoding="utf-8")
    return path

MeshRebindError

Bases: ArtifactError

A cached mesh cannot be bound to the current config's domain.

ConvergencePoint pydantic-model

Bases: BaseModel

One rung of the refinement ladder: the refinement level, the resulting quantity, and its relative change from the previous (coarser) rung.

Config:

  • arbitrary_types_allowed: True
  • use_attribute_docstrings: True

Fields:

refinement pydantic-field

refinement: float

r [-]: Refinement factor applied to global_mesh_size (>1 = finer than the base).

quantity pydantic-field

quantity: Quantity

q [?]: The quantity of interest at this level (a pint quantity, e.g. force magnitude in N).

delta_vs_previous pydantic-field

delta_vs_previous: float | None = None

δq [-]: Relative change |q − q_prev| / |q_prev| vs the previous rung; None for the first (no baseline).

RichardsonEstimate pydantic-model

Bases: BaseModel

Richardson extrapolation of a ladder's finest three rungs, assuming q(h) = q* + C·hᵖ.

Config:

  • use_attribute_docstrings: True

Fields:

observed_order pydantic-field

observed_order: float

p [-]: Observed convergence order in q(h) = q* + C·hᵖ; inf when the finest two rungs are identical.

extrapolated_value pydantic-field

extrapolated_value: float

q* [?]: The h→0 value, in the ladder's own units.

uncertainty pydantic-field

uncertainty: float

|q* − q_finest| [?]: The size of the extrapolation correction, the honest error scale of the finest rung.

Driver

Driver(*, runs_dir: str | Path = 'runs', code_sha: str | None = None, solver: SolverFn | None = None, mpi_processes: int = 1, backend: ExecutionBackend | None = None, mesher: MeshBackend | None = None, docker: bool | None = None, docker_image: str | None = None, retention: RetentionPolicy | None = None, artifacts: ArtifactCache | None = None, reuse: bool = True, bundle_policy: BundlePolicy = FIELDS, telemetry: str | Path | None = None)

Runs each config through the pipeline into a runs/<run_id>/ bundle and returns its typed Result.

The config alone decides a run's identity: execution settings such as the backend, the mesher, the rank count, retention and telemetry never enter it.

Parameters:

  • runs_dir (str | Path, default: 'runs' ) –

    The directory each run's bundle is written under.

  • code_sha (str | None, default: None ) –

    The code version each manifest records; None reads it from git.

  • solver (SolverFn | None, default: None ) –

    A replacement for the staged pipeline, for tests that run without gmsh or Elmer.

  • mpi_processes (int, default: 1 ) –

    The ElmerSolver rank count; more than 1 needs a partitioned backend.

  • backend (ExecutionBackend | None, default: None ) –

    Where ElmerGrid and ElmerSolver run; None picks Docker or native Elmer from docker, docker_image and mpi_processes.

  • mesher (MeshBackend | None, default: None ) –

    Where gmsh meshes each STEP; None meshes in this process, and a WorkerMesher meshes in a child process.

  • docker (bool | None, default: None ) –

    True runs Elmer in Docker and False runs it natively; it cannot be combined with backend.

  • docker_image (str | None, default: None ) –

    An image tag to run Elmer in instead of femforge's own image; it cannot be combined with backend.

  • retention (RetentionPolicy | None, default: None ) –

    What leaves each bundle after its evaluation; evaluate(retention=...) overrides it for one call.

  • artifacts (ArtifactCache | None, default: None ) –

    The cache the STEP, mesh and bundle stages read from and publish to; None keeps runs store-free.

  • reuse (bool, default: True ) –

    Whether to return a finished bundle with the same identity instead of solving again.

  • bundle_policy (BundlePolicy, default: FIELDS ) –

    Which bundle files a publish carries, and so which files a reused bundle must still hold.

  • telemetry (str | Path | None, default: None ) –

    The telemetry root for each run's index row and log records; None falls back to FEMFORGE_TELEMETRY.

Methods:

  • evaluate –

    Return the finished bundle for config when one is reusable, else mesh + sif + solve it under a claim on its

  • run_index –

    The index this driver records into, or None when telemetry is off — which is also what keeps polars unimported.

  • publish –

    The Stage.PUBLISH default: commit the files bundle_policy selects, store-sourced inputs excluded, to the artifact cache under the run identity.

  • cleanup –

    The Stage.CLEANUP default: apply policy against the run's terminal status and record what left on the manifest.

  • on_stage_event –

    Default stage-event sink: record the event on the active run's manifest and persist it (override + super() to add behavior).

  • prepare_geometry –

    Materialize the solve-frame STEP into the run directory (from the artifact cache when one is configured) and repoint state.config at it.

  • pre_mesh –

    Hook before meshing (e.g. adjust state.config or drop extra inputs into state.sim_dir).

  • mesh –

    Mesh with mesher and convert with ElmerGrid, served from the artifact cache when one is configured, then bind the groups to this config.

  • post_mesh –

    Hook between meshing and sim construction (e.g. amend the Elmer mesh files in state.sim_dir).

  • build_sim –

    Populate state.sim: the typed Sim from the config's resolved solver stack, MPI-adjusted when partitioned.

  • configure_sim –

    Hook to edit the typed solver stack (state.sim.config) before the sif is generated. Prefer authoring

  • write_sif –

    Generate the sif object graph from the typed stack and write the sif into the run directory.

  • run_solver –

    Partition the mesh when the solve is parallel (a partitioned backend is checked at construction), then run ElmerSolver on backend.

  • mesh_stats –

    The built mesh's MeshStats: the meshed element count plus the config's size settings.

Source code in src/femforge/run/driver.py
def __init__(
    self,
    *,
    runs_dir: str | Path = "runs",
    code_sha: str | None = None,
    solver: SolverFn | None = None,
    mpi_processes: int = 1,
    backend: ExecutionBackend | None = None,
    mesher: MeshBackend | None = None,
    docker: bool | None = None,
    docker_image: str | None = None,
    retention: RetentionPolicy | None = None,
    artifacts: ArtifactCache | None = None,
    reuse: bool = True,
    bundle_policy: BundlePolicy = BundlePolicy.FIELDS,
    telemetry: str | Path | None = None,
) -> None:
    """Set where runs land and where each stage executes.

    Args:
        runs_dir: The directory each run's bundle is written under.
        code_sha: The code version each manifest records; None reads it from git.
        solver: A replacement for the staged pipeline, for tests that run without gmsh or Elmer.
        mpi_processes: The ElmerSolver rank count; more than 1 needs a partitioned backend.
        backend: Where ElmerGrid and ElmerSolver run; None picks Docker or native Elmer from ``docker``, ``docker_image`` and ``mpi_processes``.
        mesher: Where gmsh meshes each STEP; None meshes in this process, and a [`WorkerMesher`][femforge.meshing.backend.WorkerMesher] meshes in a child process.
        docker: True runs Elmer in Docker and False runs it natively; it cannot be combined with ``backend``.
        docker_image: An image tag to run Elmer in instead of femforge's own image; it cannot be combined with ``backend``.
        retention: What leaves each bundle after its evaluation; ``evaluate(retention=...)`` overrides it for one call.
        artifacts: The cache the STEP, mesh and bundle stages read from and publish to; None keeps runs store-free.
        reuse: Whether to return a finished bundle with the same identity instead of solving again.
        bundle_policy: Which bundle files a publish carries, and so which files a reused bundle must still hold.
        telemetry: The telemetry root for each run's index row and log records; None falls back to ``FEMFORGE_TELEMETRY``.
    """
    self.runs_dir: Path = Path(runs_dir)

    self.code_sha: str | None = code_sha if code_sha is not None else git_sha()

    self._solver: SolverFn = solver if solver is not None else self._default_solve

    if mpi_processes < 1:
        raise ValueError(f"mpi_processes must be >= 1, got {mpi_processes}")

    if backend is not None and (docker is not None or docker_image is not None):
        raise ValueError("Pass either backend or docker/docker_image, not both.")

    self.backend: ExecutionBackend = backend if backend is not None else default_backend(docker=docker, docker_image=docker_image, mpi_processes=mpi_processes)
    self.mesher: MeshBackend = mesher if mesher is not None else GmshMesher()

    if mpi_processes > 1 and not self.backend.partitioned:
        raise ValueError(f"mpi_processes > 1 needs a partitioned backend such as DockerElmer; {type(self.backend).__name__} is not one.")

    self.mpi_processes: int = mpi_processes
    self.retention: RetentionPolicy = retention if retention is not None else RetentionPolicy.default()
    self.artifacts: ArtifactCache | None = artifacts
    self.reuse: bool = reuse
    self.bundle_policy: BundlePolicy = bundle_policy
    self.telemetry: str | Path | None = telemetry

    self._active: tuple[RunManifest, Path] | None = None
    """The in-flight run [`on_stage_event`][] records into — a Driver executes one solve at a time; set by
    [`evaluate`][] before its first manifest write and cleared once the cleanup stage has recorded."""

evaluate

evaluate(config: SimulationConfig, *, retention: RetentionPolicy | None = None) -> Result

Return the finished bundle for config when one is reusable, else mesh + sif + solve it under a claim on its run directory, parse the result, write and publish the bundle, then apply the retention policy.

The run directory is bundle_name(cache_key) — a memorable name derived from the content hash — so re-evaluating identical inputs still targets the same bundle. retention overrides the driver's policy for this call.

Source code in src/femforge/run/driver.py
def evaluate(self, config: SimulationConfig, *, retention: RetentionPolicy | None = None) -> Result:
    """
    Return the finished bundle for ``config`` when one is reusable, else mesh + sif + solve it under a claim on its
    run directory, parse the result, write and publish the bundle, then apply the retention policy.

    The run directory is ``bundle_name(cache_key)`` — a memorable name derived from the content hash — so
    re-evaluating identical inputs still targets the same bundle. ``retention`` overrides the driver's policy for this call.
    """
    # Every record of this evaluation lands in this driver's telemetry root, not the process default.
    with telemetry_scope(self.telemetry):
        policy: RetentionPolicy = retention if retention is not None else self.retention

        key: ArtifactKey[RunSpec] = ArtifactKey[RunSpec](cache_key(config))
        run_id: str = bundle_name(key)
        sim_dir: Path = self.runs_dir / run_id

        reused: Result | None = self._reuse(config, key, sim_dir)
        if reused is not None:
            return reused

        # One producer per bundle: a second process asking for the same identity waits, then reuses what the first left.
        claim = DirLock(self.runs_dir / f"{run_id}{LOCK_SUFFIX}")
        while not claim.try_acquire():
            time.sleep(CLAIM_POLL_S)
            reused = self._reuse(config, key, sim_dir)
            if reused is not None:
                return reused

        try:
            with keep_alive(claim, HEARTBEAT_S):
                return self._solve_bundle(
                    config,
                    key,
                    run_id,
                    sim_dir,
                    policy,
                )
        finally:
            claim.release()

run_index

run_index() -> RunIndex | None

The index this driver records into, or None when telemetry is off — which is also what keeps polars unimported.

Source code in src/femforge/run/driver.py
def run_index(self) -> RunIndex | None:
    """The index this driver records into, or None when telemetry is off — which is also what keeps polars unimported."""
    if self.telemetry is None and not os.environ.get(TELEMETRY_ENV):
        return None

    from femforge.telemetry.paths import Telemetry, resolve_root
    from femforge.telemetry.runs import RunIndex

    # The guard above already proved one of the two sources resolve_root reads is set.
    return RunIndex(Telemetry(cast("Path", resolve_root(self.telemetry))), self.runs_dir)

publish

publish(manifest: RunManifest, sim_dir: Path, config: SimulationConfig, result: Result) -> ArtifactRef

The Stage.PUBLISH default: commit the files bundle_policy selects, store-sourced inputs excluded, to the artifact cache under the run identity.

The store keeps the manifest as it stands on disk at publish time.

Parameters:

  • manifest (RunManifest) –

    The run's manifest, which records the published reference.

  • sim_dir (Path) –

    The run directory the files come from.

  • config (SimulationConfig) –

    The config whose identity keys the published bundle.

  • result (Result) –

    The parsed result, whose Elmer version the store records.

Source code in src/femforge/run/driver.py
def publish(self, manifest: RunManifest, sim_dir: Path, config: SimulationConfig, result: Result) -> ArtifactRef:
    """The [`Stage.PUBLISH`][] default: commit the files `bundle_policy` selects, store-sourced inputs excluded, to the artifact cache under the run identity.

    The store keeps the manifest as it stands on disk at publish time.

    Args:
        manifest: The run's manifest, which records the published reference.
        sim_dir: The run directory the files come from.
        config: The config whose identity keys the published bundle.
        result: The parsed result, whose Elmer version the store records.
    """
    if self.artifacts is None:
        raise RuntimeError("publish requires an artifact cache.")

    files: list[str] = bundle_files(
        sim_dir,
        self.bundle_policy,
        exclude=manifest.cached_inputs(),
    )
    key: ArtifactKey[RunSpec] = self.artifacts.key(RUN_BUNDLE, RunSpec(config=config))
    manifest.published = ArtifactRef(
        kind=RUN_BUNDLE.name,
        key=key,
        origin=ArtifactOrigin.PRODUCED,
        files=files,
    )
    write_manifest(sim_dir, manifest)

    handle: ArtifactHandle[RunSpec] = self.artifacts.publish(
        RUN_BUNDLE,
        RunSpec(config=config),
        sim_dir,
        files,
        ToolVersions(code_sha=self.code_sha, elmer=result.elmer_version),
    )
    manifest.published = handle.ref
    log.bind(
        run_id=manifest.run_id,
        origin=handle.origin,
        files=len(files),
        policy=self.bundle_policy,
    ).info("Bundle published.")
    return handle.ref

cleanup

cleanup(manifest: RunManifest, sim_dir: Path, policy: RetentionPolicy) -> CleanupReport

The Stage.CLEANUP default: apply policy against the run's terminal status and record what left on the manifest.

Override it to push the bundle elsewhere first, then call super().

Parameters:

  • manifest (RunManifest) –

    The run's manifest, which records the cleanup.

  • sim_dir (Path) –

    The run directory to clean.

  • policy (RetentionPolicy) –

    The retention policy to apply.

Source code in src/femforge/run/driver.py
def cleanup(self, manifest: RunManifest, sim_dir: Path, policy: RetentionPolicy) -> CleanupReport:
    """The [`Stage.CLEANUP`][] default: apply ``policy`` against the run's terminal status and record what left on the manifest.

    Override it to push the bundle elsewhere first, then call ``super()``.

    Args:
        manifest: The run's manifest, which records the cleanup.
        sim_dir: The run directory to clean.
        policy: The retention policy to apply.
    """
    plan = plan_cleanup(
        sim_dir,
        policy,
        status=manifest.status,
        cached=manifest.cached_inputs(),
    )
    report: CleanupReport = apply_cleanup(plan)

    if report.removed:
        manifest.cleanups.append(
            CleanupRecord(
                applied_at=datetime.now(timezone.utc),
                trigger=CleanupTrigger.DRIVER,
                status=manifest.status,
                policy=policy,
                removed=report.removed,
            )
        )

    for relative, error in report.failed:
        log.bind(run_id=manifest.run_id, path=relative, error=error).warning("Cleanup could not remove a file.")

    log.bind(
        run_id=manifest.run_id,
        files=len(report.removed),
        bytes_freed=report.bytes_freed,
        remove_run_dir=plan.remove_run_dir,
    ).info("Cleanup applied.")

    return report

on_stage_event

on_stage_event(event: StageEvent) -> None

Default stage-event sink: record the event on the active run's manifest and persist it (override + super() to add behavior).

Source code in src/femforge/run/driver.py
def on_stage_event(self, event: StageEvent) -> None:
    """Default stage-event sink: record the event on the active run's manifest and persist it (override + ``super()`` to add behavior)."""
    if self._active is None:
        return

    manifest, sim_dir = self._active

    if event.finished_at is None:
        log.info(f"[{event.stage.name}] started.")
        manifest.stage_history.append(event)
    else:
        time_taken = (event.finished_at - event.started_at).total_seconds()
        log.info(f"[{event.stage.name}] completed ({round(time_taken, 2)}s).")

    write_manifest(sim_dir, manifest)

prepare_geometry

prepare_geometry(state: SolveState) -> None

Materialize the solve-frame STEP into the run directory (from the artifact cache when one is configured) and repoint state.config at it.

Source code in src/femforge/run/driver.py
def prepare_geometry(self, state: SolveState) -> None:
    """Materialize the solve-frame STEP into the run directory (from the artifact cache when one is configured) and repoint ``state.config`` at it."""
    spec: StepSpec = step_spec(state.config)
    state.step_key = artifact_key(STEP, spec)

    if self.artifacts is None or spec.passes_through:
        state.config = materialize_geometry(state.config, state.sim_dir)
        return

    handle: ArtifactHandle[StepSpec] = self.artifacts.get_or_produce(
        STEP,
        spec,
        into=state.sim_dir,
    )
    self._record_artifact(handle)
    state.step_key = handle.key
    state.config = repoint_step(state.config, state.sim_dir / BundleFile.GEOMETRY_STEP)

pre_mesh

pre_mesh(state: SolveState) -> None

Hook before meshing (e.g. adjust state.config or drop extra inputs into state.sim_dir).

Source code in src/femforge/run/driver.py
def pre_mesh(self, state: SolveState) -> None:
    """Hook before meshing (e.g. adjust ``state.config`` or drop extra inputs into ``state.sim_dir``)."""

mesh

mesh(state: SolveState) -> None

Mesh with mesher and convert with ElmerGrid, served from the artifact cache when one is configured, then bind the groups to this config.

Source code in src/femforge/run/driver.py
def mesh(self, state: SolveState) -> None:
    """Mesh with `mesher` and convert with ElmerGrid, served from the artifact cache when one is configured, then bind the groups to this config."""
    spec: MeshSpec = mesh_spec(state.config, state.step_key)
    if self.artifacts is None:
        _ = self._mesh_into(state, spec, state.sim_dir)
    else:
        handle: ArtifactHandle[MeshSpec] = self.artifacts.get_or_produce(
            ELMER_MESH,
            spec,
            into=state.sim_dir,
            produce=partial(self._produce_mesh, state),
        )
        self._record_artifact(handle)

    self._adopt_mesh(state, MeshGroupsFile.load(state.sim_dir))

post_mesh

post_mesh(state: SolveState) -> None

Hook between meshing and sim construction (e.g. amend the Elmer mesh files in state.sim_dir).

Source code in src/femforge/run/driver.py
def post_mesh(self, state: SolveState) -> None:
    """Hook between meshing and sim construction (e.g. amend the Elmer mesh files in ``state.sim_dir``)."""

build_sim

build_sim(state: SolveState) -> None

Populate state.sim: the typed Sim from the config's resolved solver stack, MPI-adjusted when partitioned.

Source code in src/femforge/run/driver.py
def build_sim(self, state: SolveState) -> None:
    """Populate ``state.sim``: the typed `Sim` from the config's resolved solver stack, MPI-adjusted when partitioned."""
    from femforge.elmer.config import prepare_for_mpi

    sim: Sim = Sim(
        state.physical_groups,
        physics=state.config.physics.selected,
        boundary_groups=state.boundary_groups,
        config=state.config.resolved_stack(),
        frame=state.config.frame,
    )
    if self.mpi_processes > 1:
        prepare_for_mpi(sim.config)
    state.sim = sim

configure_sim

configure_sim(state: SolveState) -> None

Hook to edit the typed solver stack (state.sim.config) before the sif is generated. Prefer authoring config.physics.overrides instead — edits made here happen after the run identity is computed.

Source code in src/femforge/run/driver.py
def configure_sim(self, state: SolveState) -> None:
    """
    Hook to edit the typed solver stack (``state.sim.config``) before the sif is generated. Prefer authoring
    ``config.physics.overrides`` instead — edits made here happen after the run identity is computed.
    """

write_sif

write_sif(state: SolveState) -> None

Generate the sif object graph from the typed stack and write the sif into the run directory.

Source code in src/femforge/run/driver.py
def write_sif(self, state: SolveState) -> None:
    """Generate the sif object graph from the typed stack and write the sif into the run directory."""
    if state.sim is None:
        raise RuntimeError("write_sif requires build_sim to have populated state.sim.")

    state.sim.generate().write(str(state.sim_dir))

run_solver

run_solver(state: SolveState) -> None

Partition the mesh when the solve is parallel (a partitioned backend is checked at construction), then run ElmerSolver on backend.

Source code in src/femforge/run/driver.py
def run_solver(self, state: SolveState) -> None:
    """Partition the mesh when the solve is parallel (a partitioned backend is checked at construction), then run ElmerSolver on `backend`."""
    from femforge.telemetry.convergence import ConvergenceTargetsFile

    if state.sim is None:
        raise RuntimeError("run_solver requires build_sim to have populated state.sim.")

    if self.mpi_processes > 1:
        # -halobc only when the stack needs it (coil electrode integrals): halo elements double-count the Component
        # force lumping of partition-cut bodies, and partition-split electrode boundaries bias the coil drive without them.
        halobc: bool = needs_halobc(state.sim.config)
        field_solvers = [solver for solver in state.sim.config.solvers if isinstance(solver, MagnetoDynamicsCalcFieldsSolver)]
        if halobc and any(solver.calculate_nodal_forces for solver in field_solvers):
            log.warning("Partitioned coil solve computes nodal forces: halo elements corrupt the Component force lumping — extract forces from a serial solve.")

        # The flat bundle is the Elmer mesh dir itself.
        _ = self.backend.grid_partition(state.sim_dir, self.mpi_processes, halobc=halobc)

    # The sif-authored iteration caps and tolerances contextualize the streamed convergence lines (iter/max, relc → tol, %),
    # and the file keeps them beside convergence.jsonl so the log can be read back the same way later.
    targets = convergence_targets(state.sim.config)
    _ = ConvergenceTargetsFile.of(targets).write(state.sim_dir)

    _ = solve_case(
        self.backend,
        state.sim_dir,
        n_procs=self.mpi_processes,
        targets=targets,
    )

mesh_stats

mesh_stats(state: SolveState) -> MeshStats

The built mesh's MeshStats: the meshed element count plus the config's size settings.

Source code in src/femforge/run/driver.py
def mesh_stats(self, state: SolveState) -> MeshStats:
    """The built mesh's [`MeshStats`][]: the meshed element count plus the config's size settings."""
    config: SimulationConfig = state.config
    sizes: list[float] = [tag.mesh_size for tag in config.domain.tags if tag.mesh_size is not None]
    if not sizes:
        sizes = [config.mesh.global_mesh_size]

    return MeshStats(
        element_count=state.element_count,
        min_element_size=min(sizes),
        max_element_size=max(sizes),
    )

Bodies

Bodies(bodies: Iterable[Body])

Name-keyed collection of a run's bodies.

Methods:

  • get –

    The body by exact name, or None.

  • with_material –

    Every body whose material tag matches (case-insensitive).

  • with_tag –

    Every body whose selector tags carry key=value.

Source code in src/femforge/run/entities.py
def __init__(self, bodies: Iterable[Body]) -> None:
    """Collect ``bodies`` by name."""
    self._by_name: dict[str, Body] = {body.name: body for body in bodies}

get

get(name: str) -> Body | None

The body by exact name, or None.

Source code in src/femforge/run/entities.py
def get(self, name: str) -> Body | None:
    """The body by exact name, or None."""
    return self._by_name.get(name)

with_material

with_material(tag: str) -> list[Body]

Every body whose material tag matches (case-insensitive).

Source code in src/femforge/run/entities.py
def with_material(self, tag: str) -> list[Body]:
    """Every body whose material tag matches (case-insensitive)."""
    want: str = tag.casefold()
    return [body for body in self if body.material is not None and body.material.casefold() == want]

with_tag

with_tag(key: TagKey | str, value: str) -> list[Body]

Every body whose selector tags carry key=value.

Source code in src/femforge/run/entities.py
def with_tag(self, key: TagKey | str, value: str) -> list[Body]:
    """Every body whose selector tags carry ``key=value``."""
    return [body for body in self if body.tags.get(str(key)) == value]

Body dataclass

Body(name: str, mesh_id: int, material: str | None = None, tags: Mapping[str, str] = dict[str, str]())

One named body group of a run.

Attributes:

  • name (str) –

    The body group's name, shared by the mesh, the sif and entities.json.

  • mesh_id (int) –

    The body's GeometryIds value in the VTU.

  • material (str | None) –

    The material tag the body was grouped by; None when unknown.

  • tags (Mapping[str, str]) –

    The selector tags the body was grouped by, merged into one mapping.

name instance-attribute

name: str

The body group's name, shared by the mesh, the sif and entities.json.

mesh_id instance-attribute

mesh_id: int

The body's GeometryIds value in the VTU.

material class-attribute instance-attribute

material: str | None = None

The material tag the body was grouped by; None when unknown.

tags class-attribute instance-attribute

tags: Mapping[str, str] = field(default_factory=dict[str, str])

The selector tags the body was grouped by, merged into one mapping.

Boundaries

Boundaries(boundaries: Iterable[Boundary])

Name-keyed collection of a run's boundaries, with adjacency lookups powered by the A__B name convention.

Methods:

  • get –

    The boundary by exact name, or None.

  • between –

    The interface boundary between two bodies, whichever order the mesher named it.

  • external –

    The body's external shell boundary (<BODY>__EXT).

  • axis –

    The body's symmetry-axis boundary (<BODY>__AXIS), or None when it has none.

  • of_body –

    Every boundary bordering the body.

Source code in src/femforge/run/entities.py
def __init__(self, boundaries: Iterable[Boundary]) -> None:
    """Collect ``boundaries`` by name."""
    self._by_name: dict[str, Boundary] = {boundary.name: boundary for boundary in boundaries}

get

get(name: str) -> Boundary | None

The boundary by exact name, or None.

Source code in src/femforge/run/entities.py
def get(self, name: str) -> Boundary | None:
    """The boundary by exact name, or None."""
    return self._by_name.get(name)

between

between(a: Body | str, b: Body | str) -> Boundary

The interface boundary between two bodies, whichever order the mesher named it.

Source code in src/femforge/run/entities.py
def between(self, a: Body | str, b: Body | str) -> Boundary:
    """The interface boundary between two bodies, whichever order the mesher named it."""
    first, second = _name_of(a), _name_of(b)
    for candidate in (f"{first}{BOUNDARY_SEP}{second}", f"{second}{BOUNDARY_SEP}{first}"):
        found: Boundary | None = self._by_name.get(candidate)
        if found is not None:
            return found

    near: list[str] = [boundary.name for boundary in self.of_body(first) + self.of_body(second)]
    raise KeyError(f"no boundary between {first!r} and {second!r}; their boundaries: {sorted(set(near))}")

external

external(body: Body | str) -> Boundary

The body's external shell boundary (<BODY>__EXT).

Source code in src/femforge/run/entities.py
def external(self, body: Body | str) -> Boundary:
    """The body's external shell boundary (``<BODY>__EXT``)."""
    return self[f"{_name_of(body)}{BOUNDARY_SEP}{EXTERNAL_SUFFIX}"]

axis

axis(body: Body | str) -> Boundary | None

The body's symmetry-axis boundary (<BODY>__AXIS), or None when it has none.

Source code in src/femforge/run/entities.py
def axis(self, body: Body | str) -> Boundary | None:
    """The body's symmetry-axis boundary (``<BODY>__AXIS``), or None when it has none."""
    return self.get(f"{_name_of(body)}{BOUNDARY_SEP}{AXIS_SUFFIX}")

of_body

of_body(body: Body | str) -> list[Boundary]

Every boundary bordering the body.

Source code in src/femforge/run/entities.py
def of_body(self, body: Body | str) -> list[Boundary]:
    """Every boundary bordering the body."""
    name: str = _name_of(body)
    return [boundary for boundary in self if name in boundary.bodies]

Boundary dataclass

Boundary(name: str, mesh_id: int, bodies: tuple[str, ...], kind: BoundaryKind, sif_index: int | None = None)

One named boundary group of a run.

Attributes:

  • name (str) –

    The boundary group's name, such as A__B or A__EXT.

  • mesh_id (int) –

    The boundary's GeometryIds value in the VTU.

  • bodies (tuple[str, ...]) –

    The names of the bodies the boundary borders.

  • kind (BoundaryKind) –

    What the boundary separates.

  • sif_index (int | None) –

    The boundary's Boundary Condition block number in the sif; None when no condition produced a block.

name instance-attribute

name: str

The boundary group's name, such as A__B or A__EXT.

mesh_id instance-attribute

mesh_id: int

The boundary's GeometryIds value in the VTU.

bodies instance-attribute

bodies: tuple[str, ...]

The names of the bodies the boundary borders.

kind instance-attribute

What the boundary separates.

sif_index class-attribute instance-attribute

sif_index: int | None = None

The boundary's Boundary Condition block number in the sif; None when no condition produced a block.

BoundaryKind

Bases: StrEnum

What a boundary group separates: two bodies, a body's external shell, or its trace on the 2D symmetry axis.

Attributes:

  • INTERFACE –

    The interface between two bodies (A__B).

  • EXTERNAL –

    A body's external shell (A__EXT).

  • AXIS –

    A body's trace on the 2D symmetry axis (A__AXIS).

INTERFACE class-attribute instance-attribute

INTERFACE = 'Interface'

The interface between two bodies (A__B).

EXTERNAL class-attribute instance-attribute

EXTERNAL = 'External'

A body's external shell (A__EXT).

AXIS class-attribute instance-attribute

AXIS = 'Axis'

A body's trace on the 2D symmetry axis (A__AXIS).

Entities dataclass

Entities(bodies: Bodies, boundaries: Boundaries)

A bundle's named bodies and boundaries.

Attributes:

bodies instance-attribute

bodies: Bodies

The named bodies.

boundaries instance-attribute

boundaries: Boundaries

The named boundaries.

FieldMesh

FieldMesh(*, arrays: Mapping[str, FloatArray], measure: FloatArray, geometry_ids: IntArray | None = None, centers: FloatArray | None = None, bodies: Bodies | None = None, grid: UnstructuredGrid | None = None)

A solved VTU as cell-space numpy arrays with named-body selection; open one via load or Run.mesh().

Parameters:

  • arrays (Mapping[str, FloatArray]) –

    Field array name -> per-cell values.

  • measure (FloatArray) –

    Per-cell measure: volume in 3D, area in 2D.

  • geometry_ids (IntArray | None, default: None ) –

    Per-cell GeometryIds mesh ids; None when the VTU carries none.

  • centers (FloatArray | None, default: None ) –

    Per-cell center coordinates; None disables position filters and revolved integrals.

  • bodies (Bodies | None, default: None ) –

    The run's named bodies; None disables selection by body, material or tag.

  • grid (UnstructuredGrid | None, default: None ) –

    The source pyvista grid; None disables extracting cells for plotting.

Methods:

  • load –

    Read the bundle's newest VTU (via result_vtu), attaching body names from the bundle's sidecars when not given.

  • from_grid –

    Extract every field array to cell space (nodal ones projected once), the cell measure, GeometryIds, and cell centers.

  • get –

    Start a query over one field array; all cells are selected until filtered (boundary overlay cells included).

  • extract_cells –

    The masked cells as a pyvista subgrid (for plotting); requires construction from a live grid.

Attributes:

Source code in src/femforge/run/fields.py
def __init__(
    self,
    *,
    arrays: Mapping[str, FloatArray],
    measure: FloatArray,
    geometry_ids: IntArray | None = None,
    centers: FloatArray | None = None,
    bodies: Bodies | None = None,
    grid: pv.UnstructuredGrid | None = None,
) -> None:
    """Wrap cell-space arrays already extracted from a solved VTU.

    Args:
        arrays: Field array name -> per-cell values.
        measure: Per-cell measure: volume in 3D, area in 2D.
        geometry_ids: Per-cell ``GeometryIds`` mesh ids; None when the VTU carries none.
        centers: Per-cell center coordinates; None disables position filters and revolved integrals.
        bodies: The run's named bodies; None disables selection by body, material or tag.
        grid: The source pyvista grid; None disables extracting cells for plotting.
    """
    self.arrays: dict[str, FloatArray] = dict(arrays)
    self.measure: FloatArray = measure
    self.geometry_ids: IntArray | None = geometry_ids
    self.centers: FloatArray | None = centers
    self.bodies: Bodies | None = bodies
    self._grid: pv.UnstructuredGrid | None = grid

array_names property

array_names: list[str]

Every available field array name.

load classmethod

load(sim_dir: str | Path, bodies: Bodies | None = None) -> FieldMesh

Read the bundle's newest VTU (via result_vtu), attaching body names from the bundle's sidecars when not given.

Source code in src/femforge/run/fields.py
@classmethod
def load(cls, sim_dir: str | Path, bodies: Bodies | None = None) -> FieldMesh:  # pragma: no cover - needs pyvista (viz extra); proven by the gated tests
    """Read the bundle's newest VTU (via ``result_vtu``), attaching body names from the bundle's sidecars when not given."""
    import pyvista as pv

    vtu: Path | None = result_vtu(sim_dir)
    if vtu is None:
        raise FileNotFoundError(f"no .vtu/.pvtu output in {sim_dir}")
    if bodies is None:
        bodies = load_entities(sim_dir).bodies

    grid = cast("pv.UnstructuredGrid", pv.read(str(vtu)))
    return cls.from_grid(grid, bodies=bodies)

from_grid classmethod

from_grid(grid: UnstructuredGrid, bodies: Bodies | None = None) -> FieldMesh

Extract every field array to cell space (nodal ones projected once), the cell measure, GeometryIds, and cell centers.

Source code in src/femforge/run/fields.py
@classmethod
def from_grid(cls, grid: pv.UnstructuredGrid, bodies: Bodies | None = None) -> FieldMesh:  # pragma: no cover - needs pyvista (viz extra); proven by the gated tests
    """Extract every field array to cell space (nodal ones projected once), the cell measure, GeometryIds, and cell centers."""
    arrays: dict[str, FloatArray] = {}
    for name in grid.cell_data.keys():
        if name != GEOMETRY_IDS_ARRAY:
            arrays[name] = np.asarray(grid.cell_data[name], dtype=np.float64)

    converted: pv.DataSet | None = None
    for name in grid.point_data.keys():
        if name in arrays:
            continue
        if converted is None:
            converted = cast("pv.DataSet", grid.point_data_to_cell_data())
        arrays[name] = np.asarray(converted.cell_data[name], dtype=np.float64)

    # The cell measure is volume in a 3D model; a 2D (axisymmetric) model's body cells all have zero volume, so area steps in.
    sized: pv.DataSet = cast("pv.DataSet", grid.compute_cell_sizes(length=False, area=True, volume=True))
    volume: FloatArray = np.abs(np.asarray(sized.cell_data["Volume"], dtype=np.float64))
    measure: FloatArray = volume if bool(np.any(volume > 0.0)) else np.abs(np.asarray(sized.cell_data["Area"], dtype=np.float64))

    geometry_ids: IntArray | None = None
    if GEOMETRY_IDS_ARRAY in grid.cell_data:
        geometry_ids = np.asarray(grid.cell_data[GEOMETRY_IDS_ARRAY], dtype=np.int64)

    centers: FloatArray = np.asarray(cast("pv.DataSet", grid.cell_centers()).points, dtype=np.float64)
    return cls(arrays=arrays, measure=measure, geometry_ids=geometry_ids, centers=centers, bodies=bodies, grid=grid)

get

get(variable: ElmerVariable | str) -> FieldQuery

Start a query over one field array; all cells are selected until filtered (boundary overlay cells included).

Source code in src/femforge/run/fields.py
def get(self, variable: ElmerVariable | str) -> FieldQuery:
    """Start a query over one field array; all cells are selected until filtered (boundary overlay cells included)."""
    name: str = select_array(self.arrays, variable)
    return FieldQuery(self, name, self.arrays[name], np.ones(len(self.measure), dtype=np.bool_))

extract_cells

extract_cells(mask: BoolArray) -> UnstructuredGrid

The masked cells as a pyvista subgrid (for plotting); requires construction from a live grid.

Source code in src/femforge/run/fields.py
def extract_cells(self, mask: BoolArray) -> pv.UnstructuredGrid:  # pragma: no cover - needs pyvista (viz extra); proven by the gated tests
    """The masked cells as a pyvista subgrid (for plotting); requires construction from a live grid."""
    if self._grid is None:
        raise RuntimeError("no source grid attached; load via FieldMesh.load / FieldMesh.from_grid")
    return cast("pv.UnstructuredGrid", self._grid.extract_cells(np.flatnonzero(mask)))  # pyright: ignore[reportUnknownMemberType]  # pyvista's extract_cells index parameter is partially untyped

FieldQuery

FieldQuery(mesh: FieldMesh, name: str, values: FloatArray, mask: BoolArray)

One field array under an immutable cell mask: filters return refined copies, terminals return numpy values.

Parameters:

  • mesh (FieldMesh) –

    The field mesh the array belongs to.

  • name (str) –

    The VTU array name.

  • values (FloatArray) –

    The array's per-cell values over the full grid.

  • mask (BoolArray) –

    One flag per cell of the full grid; True keeps the cell.

Methods:

  • where_body –

    Keep the named bodies' own cells (boundary overlay cells excluded).

  • where_material –

    Keep the cells of every body with this material tag (case-insensitive).

  • where_tag –

    Keep the cells of every body tagged key=value.

  • where_ids –

    Keep cells whose GeometryIds value is one of ids — raw mesh ids, so boundary cells are reachable too.

  • where_mask –

    AND an arbitrary cell mask into the selection (one flag per cell of the full grid).

  • where_pos –

    Keep cells whose center lies inside the inclusive per-axis (min, max) bounds.

  • magnitude –

    A scalar query of per-cell |value|: the row norm of a vector field, the absolute value of a scalar one.

  • sum –

    Plain sum of the selected values: a float for scalars, a per-component vector otherwise.

  • max –

    Component-wise maximum over the selection; raises when nothing matched.

  • min –

    Component-wise minimum over the selection; raises when nothing matched.

  • mean –

    Measure-weighted mean over the selection (so cell size doesn't bias it; boundary overlay cells weigh 0).

  • integrate –

    ∫ value · dV over the selection (in a 2D model: the plane-slice ∫ value · dA — see integrate_revolved for the axisymmetric volume integral).

  • integrate_revolved –

    ∫ value · 2π·r dA over a 2D axisymmetric selection — the full-revolution volume integral, with r the radial (x) cell-center coordinate.

  • total_measure –

    The selection's total volume (3D) or area (2D).

  • cells –

    The selected cells as a pyvista subgrid, for plotting.

Attributes:

  • values (FloatArray) –

    The selected cells' values, in cell order.

  • measures (FloatArray) –

    The selected cells' measure (volume in 3D, area in 2D); boundary overlay cells measure 0.

  • cell_indices (IntArray) –

    The selected cells' indices into the full grid.

Source code in src/femforge/run/fields.py
def __init__(self, mesh: FieldMesh, name: str, values: FloatArray, mask: BoolArray) -> None:
    """Select the cells ``mask`` flags from one field array; [`FieldMesh.get`][femforge.run.fields.FieldMesh.get] starts a query.

    Args:
        mesh: The field mesh the array belongs to.
        name: The VTU array name.
        values: The array's per-cell values over the full grid.
        mask: One flag per cell of the full grid; True keeps the cell.
    """
    self._mesh = mesh
    self.name = name
    self._values = values
    self._mask = mask

values property

values: FloatArray

The selected cells' values, in cell order.

measures property

measures: FloatArray

The selected cells' measure (volume in 3D, area in 2D); boundary overlay cells measure 0.

cell_indices property

cell_indices: IntArray

The selected cells' indices into the full grid.

where_body

where_body(*bodies: Body | str) -> FieldQuery

Keep the named bodies' own cells (boundary overlay cells excluded).

Source code in src/femforge/run/fields.py
def where_body(self, *bodies: Body | str) -> FieldQuery:
    """Keep the named bodies' own cells (boundary overlay cells excluded)."""
    resolved: list[Body] = [body if isinstance(body, Body) else self._catalog()[body] for body in bodies]
    return self._body_cells(resolved)

where_material

where_material(tag: str) -> FieldQuery

Keep the cells of every body with this material tag (case-insensitive).

Source code in src/femforge/run/fields.py
def where_material(self, tag: str) -> FieldQuery:
    """Keep the cells of every body with this material tag (case-insensitive)."""
    matched: list[Body] = self._catalog().with_material(tag)
    if not matched:
        raise KeyError(f"no bodies with material {tag!r}; bodies: {sorted(body.name for body in self._catalog())}")
    return self._body_cells(matched)

where_tag

where_tag(key: TagKey | str, value: str) -> FieldQuery

Keep the cells of every body tagged key=value.

Source code in src/femforge/run/fields.py
def where_tag(self, key: TagKey | str, value: str) -> FieldQuery:
    """Keep the cells of every body tagged ``key=value``."""
    matched: list[Body] = self._catalog().with_tag(key, value)
    if not matched:
        raise KeyError(f"no bodies tagged {key}={value}; bodies: {sorted(body.name for body in self._catalog())}")
    return self._body_cells(matched)

where_ids

where_ids(ids: Iterable[int]) -> FieldQuery

Keep cells whose GeometryIds value is one of ids — raw mesh ids, so boundary cells are reachable too.

Source code in src/femforge/run/fields.py
def where_ids(self, ids: Iterable[int]) -> FieldQuery:
    """Keep cells whose ``GeometryIds`` value is one of ``ids`` — raw mesh ids, so boundary cells are reachable too."""
    return self._refine(np.isin(self._geometry_ids(), list(ids)))

where_mask

where_mask(mask: BoolArray) -> FieldQuery

AND an arbitrary cell mask into the selection (one flag per cell of the full grid).

Source code in src/femforge/run/fields.py
def where_mask(self, mask: BoolArray) -> FieldQuery:
    """AND an arbitrary cell mask into the selection (one flag per cell of the full grid)."""
    incoming: BoolArray = np.asarray(mask, dtype=np.bool_)
    if incoming.shape != self._mask.shape:
        raise ValueError(f"mask has shape {incoming.shape}; expected {self._mask.shape} (one flag per cell of the full grid)")
    return self._refine(incoming)

where_pos

where_pos(x: Bounds | None = None, y: Bounds | None = None, z: Bounds | None = None) -> FieldQuery

Keep cells whose center lies inside the inclusive per-axis (min, max) bounds.

Parameters:

  • x (Bounds | None, default: None ) –

    The (min, max) bounds on x; None leaves x unbounded, and a None bound leaves its side open.

  • y (Bounds | None, default: None ) –

    The bounds on y, as for x.

  • z (Bounds | None, default: None ) –

    The bounds on z, as for x.

Source code in src/femforge/run/fields.py
def where_pos(self, x: Bounds | None = None, y: Bounds | None = None, z: Bounds | None = None) -> FieldQuery:
    """Keep cells whose center lies inside the inclusive per-axis ``(min, max)`` bounds.

    Args:
        x: The ``(min, max)`` bounds on x; None leaves x unbounded, and a None bound leaves its side open.
        y: The bounds on y, as for ``x``.
        z: The bounds on z, as for ``x``.
    """
    centers: FloatArray | None = self._mesh.centers
    if centers is None:
        raise RuntimeError("no cell centers available; load via FieldMesh.load / FieldMesh.from_grid")

    mask: BoolArray = np.ones(len(centers), dtype=np.bool_)
    for axis, bounds in enumerate((x, y, z)):
        if bounds is None:
            continue
        low, high = bounds
        if low is not None:
            mask &= centers[:, axis] >= low
        if high is not None:
            mask &= centers[:, axis] <= high
    return self._refine(mask)

magnitude

magnitude() -> FieldQuery

A scalar query of per-cell |value|: the row norm of a vector field, the absolute value of a scalar one.

Source code in src/femforge/run/fields.py
def magnitude(self) -> FieldQuery:
    """A scalar query of per-cell |value|: the row norm of a vector field, the absolute value of a scalar one."""
    magnitudes: FloatArray = np.linalg.norm(self._values, axis=1) if self._values.ndim == 2 else np.abs(self._values)
    return FieldQuery(self._mesh, self.name, magnitudes, self._mask)

sum

sum() -> float | FloatArray

Plain sum of the selected values: a float for scalars, a per-component vector otherwise.

Source code in src/femforge/run/fields.py
def sum(self) -> float | FloatArray:
    """Plain sum of the selected values: a float for scalars, a per-component vector otherwise."""
    total = self.values.sum(axis=0)
    return float(total) if np.ndim(total) == 0 else cast("FloatArray", total)

max

max() -> float | FloatArray

Component-wise maximum over the selection; raises when nothing matched.

Source code in src/femforge/run/fields.py
def max(self) -> float | FloatArray:
    """Component-wise maximum over the selection; raises when nothing matched."""
    largest = self._require_values("max").max(axis=0)
    return float(largest) if np.ndim(largest) == 0 else cast("FloatArray", largest)

min

min() -> float | FloatArray

Component-wise minimum over the selection; raises when nothing matched.

Source code in src/femforge/run/fields.py
def min(self) -> float | FloatArray:
    """Component-wise minimum over the selection; raises when nothing matched."""
    smallest = self._require_values("min").min(axis=0)
    return float(smallest) if np.ndim(smallest) == 0 else cast("FloatArray", smallest)

mean

mean() -> float | FloatArray

Measure-weighted mean over the selection (so cell size doesn't bias it; boundary overlay cells weigh 0).

Source code in src/femforge/run/fields.py
def mean(self) -> float | FloatArray:
    """Measure-weighted mean over the selection (so cell size doesn't bias it; boundary overlay cells weigh 0)."""
    values: FloatArray = self._require_values("mean")
    weights: FloatArray = self.measures
    total_weight: float = float(weights.sum())
    if total_weight <= 0.0:
        raise ValueError(f"mean() of {self.name!r} over cells with zero total measure")
    if values.ndim == 2:
        return cast("FloatArray", (values * weights[:, None]).sum(axis=0) / total_weight)
    return float((values * weights).sum() / total_weight)

integrate

integrate() -> float | FloatArray

∫ value · dV over the selection (in a 2D model: the plane-slice ∫ value · dA — see integrate_revolved for the axisymmetric volume integral).

Source code in src/femforge/run/fields.py
def integrate(self) -> float | FloatArray:
    """∫ value · dV over the selection (in a 2D model: the plane-slice ∫ value · dA — see [`integrate_revolved`][] for the axisymmetric volume integral)."""
    values: FloatArray = self.values
    weights: FloatArray = self.measures
    if values.ndim == 2:
        return cast("FloatArray", (values * weights[:, None]).sum(axis=0))
    return float((values * weights).sum())

integrate_revolved

integrate_revolved() -> float | FloatArray

∫ value · 2π·r dA over a 2D axisymmetric selection — the full-revolution volume integral, with r the radial (x) cell-center coordinate.

Source code in src/femforge/run/fields.py
def integrate_revolved(self) -> float | FloatArray:
    """∫ value · 2π·r dA over a 2D axisymmetric selection — the full-revolution volume integral, with r the radial (x) cell-center coordinate."""
    centers: FloatArray | None = self._mesh.centers
    if centers is None:
        raise RuntimeError("no cell centers available; load via FieldMesh.load / FieldMesh.from_grid")

    values: FloatArray = self.values
    weights: FloatArray = self.measures * (math.tau * centers[self._mask, 0])
    if values.ndim == 2:
        return cast("FloatArray", (values * weights[:, None]).sum(axis=0))
    return float((values * weights).sum())

total_measure

total_measure() -> float

The selection's total volume (3D) or area (2D).

Source code in src/femforge/run/fields.py
def total_measure(self) -> float:
    """The selection's total volume (3D) or area (2D)."""
    return float(self.measures.sum())

cells

cells() -> UnstructuredGrid

The selected cells as a pyvista subgrid, for plotting.

Source code in src/femforge/run/fields.py
def cells(self) -> pv.UnstructuredGrid:
    """The selected cells as a pyvista subgrid, for plotting."""
    return self._mesh.extract_cells(self._mask)

Run

Run(sim_dir: str | Path)

One run bundle (runs/<run_id>/) opened for reading.

Methods:

  • from_result –

    Open the bundle a Result was loaded from or written to (the driver and Result.load stamp run_dir).

  • latest –

    The most recently created bundle under a runs directory (by manifest created_at, then mtime).

  • purged –

    The newest cleanup pass that removed file_class (or the whole run directory), else None.

  • mesh –

    The solved VTU as a fluent field query, body names attached (lazy pyvista import; cached per Run); names the retention pass when the VTU was purged.

Attributes:

  • meta (RunMeta) –

    The run's searchable metadata (description + tags), as recorded in the manifest's config.

  • result (Result) –

    The typed result: result.json when written, else parsed fresh from the Elmer outputs in the bundle.

  • manifest (RunManifest) –

    The run's provenance manifest.

  • cleanups (list[CleanupRecord]) –

    Every retention pass recorded on the manifest (empty when the manifest is missing or unreadable).

  • entities (Entities) –

    Named bodies and boundaries from the richest available source (entities.json, else enriched mesh.names).

  • bodies (Bodies) –

    The run's named bodies (mesh_id == the VTU's GeometryIds values).

  • boundaries (Boundaries) –

    The run's named boundaries, with between/external/axis lookups.

  • components (list[SifComponent]) –

    Every sif Component with its lumped force/torque, joined from components.yml and the result.

  • component_forces (dict[str, VectorQuantity]) –

    Lumped magnetic force (N) per Component name.

  • component_torques (dict[str, VectorQuantity]) –

    Lumped magnetic torque (N·m) per Component name.

  • electrode_currents (dict[str, float]) –

    Terminal current (A) per named electrode boundary, from a conduction solve's flux columns.

  • phase_currents (dict[str, float]) –

    Terminal current (A) per phase index ("0", "1", ...), from the named electrode currents.

  • vtu_path (Path | None) –

    The run's ParaView output (the partitioned master when one exists), or None when the solve wrote none.

Source code in src/femforge/run/inspect.py
def __init__(self, sim_dir: str | Path) -> None:
    """Open the run bundle at ``sim_dir``."""
    self.path: Path = Path(sim_dir)
    if not self.path.is_dir():
        raise FileNotFoundError(f"no run bundle at {self.path}")

    self._mesh: FieldMesh | None = None

meta property

meta: RunMeta

The run's searchable metadata (description + tags), as recorded in the manifest's config.

result cached property

result: Result

The typed result: result.json when written, else parsed fresh from the Elmer outputs in the bundle.

manifest cached property

manifest: RunManifest

The run's provenance manifest.

cleanups property

cleanups: list[CleanupRecord]

Every retention pass recorded on the manifest (empty when the manifest is missing or unreadable).

entities cached property

entities: Entities

Named bodies and boundaries from the richest available source (entities.json, else enriched mesh.names).

bodies property

bodies: Bodies

The run's named bodies (mesh_id == the VTU's GeometryIds values).

boundaries property

boundaries: Boundaries

The run's named boundaries, with between/external/axis lookups.

components cached property

components: list[SifComponent]

Every sif Component with its lumped force/torque, joined from components.yml and the result.

component_forces property

component_forces: dict[str, VectorQuantity]

Lumped magnetic force (N) per Component name.

component_torques property

component_torques: dict[str, VectorQuantity]

Lumped magnetic torque (N·m) per Component name.

electrode_currents property

electrode_currents: dict[str, float]

Terminal current (A) per named electrode boundary, from a conduction solve's flux columns.

phase_currents property

phase_currents: dict[str, float]

Terminal current (A) per phase index ("0", "1", ...), from the named electrode currents.

vtu_path property

vtu_path: Path | None

The run's ParaView output (the partitioned master when one exists), or None when the solve wrote none.

from_result classmethod

from_result(result: Result) -> Run

Open the bundle a Result was loaded from or written to (the driver and Result.load stamp run_dir).

Source code in src/femforge/run/inspect.py
@classmethod
def from_result(cls, result: Result) -> Run:
    """Open the bundle a Result was loaded from or written to (the driver and ``Result.load`` stamp ``run_dir``)."""
    if result.run_dir is None:
        raise ValueError("result.run_dir is None; obtain the Result from Result.load or Driver.evaluate (a run directory removed by retention has none)")

    return cls(result.run_dir)

latest classmethod

latest(runs_dir: str | Path) -> Run

The most recently created bundle under a runs directory (by manifest created_at, then mtime).

Source code in src/femforge/run/inspect.py
@classmethod
def latest(cls, runs_dir: str | Path) -> Run:
    """The most recently created bundle under a runs directory (by manifest ``created_at``, then mtime)."""
    manifests: list[Path] = sorted(Path(runs_dir).glob(f"*/{BundleFile.MANIFEST}"), key=_manifest_order)
    if not manifests:
        raise FileNotFoundError(f"no run bundles under {runs_dir}")

    return cls(manifests[-1].parent)

purged

purged(file_class: RunFile) -> CleanupRecord | None

The newest cleanup pass that removed file_class (or the whole run directory), else None.

Source code in src/femforge/run/inspect.py
def purged(self, file_class: RunFile) -> CleanupRecord | None:
    """The newest cleanup pass that removed ``file_class`` (or the whole run directory), else None."""
    for record in reversed(self.cleanups):
        if record.covers(file_class):
            return record

    return None

mesh

mesh() -> FieldMesh

The solved VTU as a fluent field query, body names attached (lazy pyvista import; cached per Run); names the retention pass when the VTU was purged.

Source code in src/femforge/run/inspect.py
def mesh(self) -> FieldMesh:
    """The solved VTU as a fluent field query, body names attached (lazy pyvista import; cached per Run); names the retention pass when the VTU was purged."""
    if self._mesh is None:
        if self.vtu_path is None:
            record: CleanupRecord | None = self.purged(RunFile.FIELDS)
            if record is not None:
                raise FileNotFoundError(f"{self.path}: fields purged per retention on {record.applied_at:%Y-%m-%d} by {record.trigger}; re-solve to restore them")

        from femforge.run.fields import FieldMesh

        self._mesh = FieldMesh.load(self.path, bodies=self.bodies)
    return self._mesh

SifComponent dataclass

SifComponent(name: str, sif_index: int, force: VectorQuantity | None = None, torque: VectorQuantity | None = None)

One sif Component block with its lumped force and torque.

Attributes:

  • name (str) –

    The Component name, as components.yml records it.

  • sif_index (int) –

    The Component block number in the sif.

  • force (VectorQuantity | None) –

    F⃗ [N]: Lumped magnetic force on the Component; None when the solve exported none.

  • torque (VectorQuantity | None) –

    M⃗ [N·m]: Lumped magnetic torque on the Component; None when the solve exported none.

name instance-attribute

name: str

The Component name, as components.yml records it.

sif_index instance-attribute

sif_index: int

The Component block number in the sif.

force class-attribute instance-attribute

force: VectorQuantity | None = None

F⃗ [N]: Lumped magnetic force on the Component; None when the solve exported none.

torque class-attribute instance-attribute

torque: VectorQuantity | None = None

M⃗ [N·m]: Lumped magnetic torque on the Component; None when the solve exported none.

MeshSpec pydantic-model

Bases: BaseModel

Everything that decides the Elmer mesh files and their group names, and nothing that only decides the solve.

Config:

  • use_attribute_docstrings: True

Fields:

step_key pydantic-field

step_key: str

The geometry identity (step_key).

model_dim pydantic-field

model_dim: Literal[2, 3]

Body dimension of the STEP, from the frame: 2D cross-section or 3D solids.

global_mesh_size pydantic-field

global_mesh_size: float

h [m]: Domain-wide target element size.

gmsh_options pydantic-field

gmsh_options: GmshOptions

The gmsh knobs (thread count and terminal output are HashExclude and drop out).

deterministic pydantic-field

deterministic: bool

Whether meshing was pinned to one thread for reproducibility.

tags pydantic-field

The domain's tags in domain order (an unnamed body's group name concatenates tag names in that order).

materials pydantic-field

materials: list[str]

The sorted material tags; an unnamed body's group name starts with its material tag.

RunSpec pydantic-model

Bases: BaseModel

The bundle kind's spec: the config a run solved.

Config:

  • arbitrary_types_allowed: True
  • use_attribute_docstrings: True

Fields:

config pydantic-field

The full typed input.

RunManifest pydantic-model

Bases: BaseModel

The self-describing record of one run.

Config:

  • arbitrary_types_allowed: True
  • use_attribute_docstrings: True

Fields:

Validators:

  • _derive_mesh_name

run_id pydantic-field

run_id: str

The bundle directory name: <adjective>-<noun>-<hash8> derived from cache_key.

cache_key pydantic-field

cache_key: str

The full content hash of the result-affecting inputs; run_id is its derived name.

created_at pydantic-field

created_at: datetime

When this run was assembled (UTC). Provenance only — never feeds run_id.

git_sha pydantic-field

git_sha: str | None = None

Code version that produced the run (-dirty when uncommitted changes were present). Provenance only — never feeds run_id.

elmer_version pydantic-field

elmer_version: str | None = None

ElmerSolver version parsed from the log, if available.

config pydantic-field

The full typed input that drove both the mesh and the sif.

physics pydantic-field

physics: Physics

Which physics preset was solved.

physics_config pydantic-field

physics_config: PhysicsConfig

The resolved solver stack the run solved with (preset or authored overrides).

mesh_key pydantic-field

mesh_key: str

The mesh artifact key (geometry, mesh settings, body grouping).

mesh_name pydantic-field

mesh_name: str = ''

The mesh artifact's store directory name; always derived from mesh_key, never read from input.

mesh_stats pydantic-field

mesh_stats: MeshStats | None = None

Mesh size/quality summary for consistency auditing.

status pydantic-field

Lifecycle status: "running" while the pipeline executes, then the terminal solve status (mirrors Result.status), or "abandoned" when its claim went silent first.

stage_history pydantic-field

stage_history: list[StageEvent]

Per-stage start/finish timeline, appended as the pipeline runs.

artifacts pydantic-field

artifacts: list[ArtifactRef]

Store-sourced inputs this run consumed (STEP, mesh): kind, key, origin, and the bundle files they materialized — enough to re-hydrate the directory and for retention to know what is re-fetchable.

published pydantic-field

published: ArtifactRef | None = None

The bundle's own store entry (kind run_bundle, key == cache_key, the files its policy included); None when never published.

cleanups pydantic-field

cleanups: list[CleanupRecord]

Every retention pass applied to this bundle (the driver's cleanup stage, then any post-hoc purge), oldest first.

notes pydantic-field

notes: str = ''

Free-form provenance note. Cosmetic — never feeds run_id.

current_stage property

current_stage: Stage | None

The stage currently running — the first event without a finish time — or None when idle/finished; a crashed stage stays current through cleanup.

cached_inputs

cached_inputs() -> frozenset[str]

Bundle-relative paths the artifact store can re-fetch, so retention may drop them freely.

Source code in src/femforge/run/manifest.py
def cached_inputs(self) -> frozenset[str]:
    """Bundle-relative paths the artifact store can re-fetch, so retention may drop them freely."""
    return frozenset(name for ref in self.artifacts for name in ref.files)

load classmethod

load(path: str | Path) -> 'RunManifest'

Load a bundle's manifest.json (pass the bundle directory or the file itself).

Source code in src/femforge/run/manifest.py
@classmethod
def load(cls, path: str | Path) -> "RunManifest":
    """Load a bundle's ``manifest.json`` (pass the bundle directory or the file itself)."""
    file: Path = Path(path)
    if file.is_dir():
        file = file / BundleFile.MANIFEST
    if not file.is_file():
        raise FileNotFoundError(f"no {BundleFile.MANIFEST} at {file}")

    return cls.model_validate_json(file.read_text(encoding="utf-8"))

build classmethod

build(config: SimulationConfig, *, status: ManifestStatus, run_id: str | None = None, mesh_stats: MeshStats | None = None, code_sha: str | None = None, elmer_version: str | None = None, created_at: datetime | None = None, notes: str = '') -> 'RunManifest'

Assemble the manifest of config: its cache_key and mesh_key, and the physics and solver stack the config solves.

Parameters:

  • config (SimulationConfig) –

    The run's full typed input.

  • status (ManifestStatus) –

    The manifest's lifecycle status.

  • run_id (str | None, default: None ) –

    The bundle name to record verbatim; None derives it from the cache key.

  • mesh_stats (MeshStats | None, default: None ) –

    The mesh size summary, when known.

  • code_sha (str | None, default: None ) –

    The producing commit, stored as the manifest's git_sha.

  • elmer_version (str | None, default: None ) –

    The ElmerSolver version, when known.

  • created_at (datetime | None, default: None ) –

    When the run was assembled; None takes the current UTC time.

  • notes (str, default: '' ) –

    A free-form provenance note.

Source code in src/femforge/run/manifest.py
@classmethod
def build(
    cls,
    config: SimulationConfig,
    *,
    status: ManifestStatus,
    run_id: str | None = None,
    mesh_stats: MeshStats | None = None,
    code_sha: str | None = None,
    elmer_version: str | None = None,
    created_at: datetime | None = None,
    notes: str = "",
) -> "RunManifest":
    """Assemble the manifest of ``config``: its ``cache_key`` and ``mesh_key``, and the physics and solver stack the config solves.

    Args:
        config: The run's full typed input.
        status: The manifest's lifecycle status.
        run_id: The bundle name to record verbatim; None derives it from the cache key.
        mesh_stats: The mesh size summary, when known.
        code_sha: The producing commit, stored as the manifest's ``git_sha``.
        elmer_version: The ElmerSolver version, when known.
        created_at: When the run was assembled; None takes the current UTC time.
        notes: A free-form provenance note.
    """
    key: str = cache_key(config)
    return cls(
        run_id=run_id if run_id is not None else bundle_name(key),
        cache_key=key,
        created_at=created_at if created_at is not None else datetime.now(timezone.utc),
        git_sha=code_sha,
        elmer_version=elmer_version,
        config=config,
        physics=config.solved_physics(),
        physics_config=config.resolved_stack(),
        mesh_key=mesh_key(config),
        mesh_stats=mesh_stats,
        status=status,
        notes=notes,
    )

ReapedRun dataclass

ReapedRun(run_id: str, sim_dir: Path, owner: LockOwner | None, status: ManifestStatus | None)

One run reap_runs settled.

Attributes:

  • run_id (str) –

    The run's bundle name.

  • sim_dir (Path) –

    The run's bundle directory.

  • owner (LockOwner | None) –

    Who held the run's claim and when it last beat; None when nobody did.

  • status (ManifestStatus | None) –

    The status the run's bundle carries now; None without a readable manifest.

run_id instance-attribute

run_id: str

The run's bundle name.

sim_dir instance-attribute

sim_dir: Path

The run's bundle directory.

owner instance-attribute

owner: LockOwner | None

Who held the run's claim and when it last beat; None when nobody did.

status instance-attribute

status: ManifestStatus | None

The status the run's bundle carries now; None without a readable manifest.

MeshStats pydantic-model

Bases: BaseModel

Per-run mesh size/quality summary, recorded in the manifest so a sweep can be audited for mesh consistency.

Config:

  • use_attribute_docstrings: True

Fields:

element_count pydantic-field

element_count: int

Total number of volume elements in the solved mesh.

min_element_size pydantic-field

min_element_size: float

h_min [m]: Smallest element edge length.

max_element_size pydantic-field

max_element_size: float

h_max [m]: Largest element edge length.

Result pydantic-model

Bases: BaseModel

The typed outcome of one solve.

Quantities stay pint-typed; they are stripped to floats only at the analysis boundary (Study.to_dataframe).

Config:

  • arbitrary_types_allowed: True
  • use_attribute_docstrings: True
  • extra: forbid

Fields:

status pydantic-field

status: RunStatus

Terminal state of the solve.

run_id pydantic-field

run_id: str | None = None

The run's identity (== the manifest's run_id and the bundle directory name — the memorable name derived from the cache key); the driver stamps it after parsing, so a bare parse (no run lifecycle) leaves it None.

run_dir pydantic-field

run_dir: Path | None = None

The bundle directory this result lives in, stamped by the driver and load_result; never serialized, and None once retention removed the directory.

force pydantic-field

force: Force | None = None

F⃗ [N]: Net force on the moving array, as a vector pint quantity.

torque pydantic-field

torque: Torque | None = None

M⃗ [N·m]: Net torque on the moving array, as a vector pint quantity.

flux_density_max pydantic-field

flux_density_max: FluxDensity | None = None

|B|_max [T]: Peak flux density magnitude in the domain.

temperature_max pydantic-field

temperature_max: Temperature | None = None

T_max [K]: Peak temperature in the domain, from the thermal or coupled joule-heating solve — the max-current search observable.

residual pydantic-field

residual: float | None = None

Δ_rel [-]: Final linear/nonlinear relative change reported by the solver.

wall_time pydantic-field

wall_time: float | None = None

t_wall [s]: Solver wall-clock time.

elmer_version pydantic-field

elmer_version: str | None = None

ElmerSolver version parsed from the log, if present.

frame pydantic-field

frame: Frame

The run's solve frame. Every vector stays in solve coordinates; under Axisymmetric the typed force/torque fields are full-revolution totals with the radial component zeroed (zero net by symmetry), while raw keeps Elmer's per-radian columns.

component_forces pydantic-field

component_forces: dict[str, Force]

F⃗ [N]: Lumped magnetic force on each force-carrying sif Component, keyed by its components.yml name.

component_torques pydantic-field

component_torques: dict[str, Torque]

M⃗ [N·m]: Lumped magnetic torque (about the authored origin/axis) on each sif Component, keyed by its components.yml name.

raw pydantic-field

raw: dict[str, float]

[?]: Escape hatch for un-modelled scalars the parser found but Result doesn't type yet, in whatever unit Elmer printed them.

load classmethod

load(path: str | Path) -> Result

Load a bundle's result.json (pass the bundle directory or the file itself), stamping run_dir with where it was read from.

Source code in src/femforge/run/result.py
@classmethod
def load(cls, path: str | Path) -> Result:
    """Load a bundle's ``result.json`` (pass the bundle directory or the file itself), stamping ``run_dir`` with where it was read from."""
    file: Path = Path(path)
    if file.is_dir():
        file = file / BundleFile.RESULT
    if not file.is_file():
        raise FileNotFoundError(f"no {BundleFile.RESULT} at {file}")

    result: Result = cls.model_validate_json(file.read_text(encoding="utf-8"))
    result.run_dir = file.parent
    return result

axial_force

axial_force(body: str, axis: Axis | None = None) -> Quantity

F_ax [N]: Component body's force along the authored axis; an axisymmetric run defaults to its symmetry axis.

Source code in src/femforge/run/result.py
def axial_force(self, body: str, axis: Axis | None = None) -> Quantity:
    """`F_ax` `[N]`: Component ``body``'s force along the authored ``axis``; an axisymmetric run defaults to its symmetry axis."""
    force: Vec3 = self.component_forces[body].m_as("newton")

    match self.frame:
        case Axisymmetric():
            if axis is None:
                return quantity(force.y, "newton")

            cosine: float | None = self.frame.axis.along(axis.direction)

            if cosine is None:
                raise ValueError(f"An axisymmetric run has force only along its symmetry axis {self.frame.axis.direction}, not along {axis.direction}.")

            return quantity(force.y * cosine, "newton")
        case Solid():
            if axis is None:
                raise ValueError("A solid run has no symmetry axis; pass the axis to project onto.")

            return quantity(force.dot(axis.direction), "newton")

BundleEntry dataclass

BundleEntry(relative: str, size: int, file_class: RunFile)

One file found in a bundle, with the retention class its name falls in.

Attributes:

  • relative (str) –

    Bundle-relative posix path.

  • size (int) –

    File size in bytes.

  • file_class (RunFile) –

    The retention class the file's name falls in.

relative instance-attribute

relative: str

Bundle-relative posix path.

size instance-attribute

size: int

File size in bytes.

file_class instance-attribute

file_class: RunFile

The retention class the file's name falls in.

Cleanup

Bases: StrEnum

When a file class leaves the bundle; definition order is escalation order.

Methods:

  • applies –

    Whether this rule removes its files for a run that ended in status (ON_SUCCESS means ok only).

  • escalate –

    The stricter of two rules.

Attributes:

  • NEVER –

    Keep the files.

  • ON_SUCCESS –

    Remove the files when the run ended ok.

  • ALWAYS –

    Remove the files whatever the run's status.

NEVER class-attribute instance-attribute

NEVER = 'never'

Keep the files.

ON_SUCCESS class-attribute instance-attribute

ON_SUCCESS = 'on_success'

Remove the files when the run ended ok.

ALWAYS class-attribute instance-attribute

ALWAYS = 'always'

Remove the files whatever the run's status.

applies

applies(status: ManifestStatus) -> bool

Whether this rule removes its files for a run that ended in status (ON_SUCCESS means ok only).

Source code in src/femforge/run/retention.py
def applies(self, status: ManifestStatus) -> bool:
    """Whether this rule removes its files for a run that ended in ``status`` (``ON_SUCCESS`` means ``ok`` only)."""
    match self:
        case Cleanup.NEVER:
            return False
        case Cleanup.ON_SUCCESS:
            return status == "ok"
        case Cleanup.ALWAYS:
            return True

escalate

escalate(other: Cleanup) -> Cleanup

The stricter of two rules.

Source code in src/femforge/run/retention.py
def escalate(self, other: Cleanup) -> Cleanup:
    """The stricter of two rules."""
    members: list[Cleanup] = list(Cleanup)
    return self if members.index(self) >= members.index(other) else other

CleanupRecord pydantic-model

Bases: BaseModel

One cleanup pass over a bundle: when, by whom, under which policy, and what left.

Config:

  • use_attribute_docstrings: True

Fields:

applied_at pydantic-field

applied_at: datetime

When the pass ran (UTC).

trigger pydantic-field

trigger: CleanupTrigger

The driver's cleanup stage or a post-hoc purge.

status pydantic-field

The run status the ON_SUCCESS rules were resolved against.

policy pydantic-field

The policy that was applied.

removed pydantic-field

removed: list[RemovedFile]

Every path that left, with its class and the rule that removed it.

bytes_freed property

bytes_freed: int

Total bytes the pass removed.

covers

covers(file_class: RunFile) -> bool

Whether this pass removed anything of file_class (a removed run directory covers every class).

Source code in src/femforge/run/retention.py
def covers(self, file_class: RunFile) -> bool:
    """Whether this pass removed anything of ``file_class`` (a removed run directory covers every class)."""
    return any(entry.file_class in (file_class, RunFile.RUN_DIR) for entry in self.removed)

CleanupReport dataclass

CleanupReport(plan: CleanupPlan, removed: list[RemovedFile] = list(), failed: list[tuple[str, str]] = list(), dry_run: bool = False)

The outcome of applying a plan: what left (or would have, in a dry run) and what could not be removed.

Attributes:

  • plan (CleanupPlan) –

    The plan that was applied.

  • removed (list[RemovedFile]) –

    Every path that left, or would have in a dry run.

  • failed (list[tuple[str, str]]) –

    The (path, error) pairs of the paths that could not be removed.

  • dry_run (bool) –

    Whether the pass only reported, removing nothing.

  • bytes_freed (int) –

    Total bytes removed, or that a dry run would remove.

plan instance-attribute

plan: CleanupPlan

The plan that was applied.

removed class-attribute instance-attribute

removed: list[RemovedFile] = field(default_factory=list)

Every path that left, or would have in a dry run.

failed class-attribute instance-attribute

failed: list[tuple[str, str]] = field(default_factory=list)

The (path, error) pairs of the paths that could not be removed.

dry_run class-attribute instance-attribute

dry_run: bool = False

Whether the pass only reported, removing nothing.

bytes_freed property

bytes_freed: int

Total bytes removed, or that a dry run would remove.

Preset

Bases: StrEnum

Named retention policies, from keep-everything to removing the run directory.

Attributes:

  • DEFAULT –

    Keep everything the run cannot re-fetch; drop store-sourced inputs.

  • KEEP_ALL –

    Keep everything, store-sourced inputs included.

  • LEAN –

    Drop the regenerable meshes after a successful run.

  • MINIMAL –

    Lean plus the field output after a successful run.

  • NUKE –

    Remove the run directory whatever happened.

DEFAULT class-attribute instance-attribute

DEFAULT = 'default'

Keep everything the run cannot re-fetch; drop store-sourced inputs.

KEEP_ALL class-attribute instance-attribute

KEEP_ALL = 'keep_all'

Keep everything, store-sourced inputs included.

LEAN class-attribute instance-attribute

LEAN = 'lean'

Drop the regenerable meshes after a successful run.

MINIMAL class-attribute instance-attribute

MINIMAL = 'minimal'

Lean plus the field output after a successful run.

NUKE class-attribute instance-attribute

NUKE = 'nuke'

Remove the run directory whatever happened.

RetentionPolicy pydantic-model

Bases: ConfigArea

One Cleanup rule per bundle file class; the default keeps everything the run cannot re-fetch.

Fields:

geometry pydantic-field

geometry: Cleanup = Cleanup.NEVER

The geometry.step copy pinned into the bundle.

gmsh_mesh pydantic-field

gmsh_mesh: Cleanup = Cleanup.NEVER

The gmsh .msh (only in the bundle for a parametric geometry).

elmer_mesh pydantic-field

elmer_mesh: Cleanup = Cleanup.NEVER

ElmerGrid's mesh.header/nodes/elements/boundary.

partitioning pydantic-field

partitioning: Cleanup = Cleanup.NEVER

The partitioning.<n>/ directory and its log from a parallel solve.

sif pydantic-field

case.sif and ELMERSOLVER_STARTINFO.

fields pydantic-field

fields: Cleanup = Cleanup.NEVER

The VTU/PVTU field output, master and per-rank pieces together.

scalars pydantic-field

scalars: Cleanup = Cleanup.NEVER

SaveScalars .dat exports and their .names sidecars.

logs pydantic-field

ElmerGrid/ElmerSolver logs, convergence.jsonl, run.log.

sidecars pydantic-field

sidecars: Cleanup = Cleanup.NEVER

entities.json, boundaries.yml, components.yml, mesh.names, entities.sif.

run_dir pydantic-field

run_dir: Cleanup = Cleanup.NEVER

The whole bundle, manifest included; when it applies nothing else matters.

cached_inputs pydantic-field

cached_inputs: Cleanup = Cleanup.ALWAYS

Escalation for files the artifact store can re-fetch (manifest.artifacts); the stricter of this and the class rule wins.

rules

rules() -> list[tuple[RunFile, Cleanup]]

The (file class, rule) pairs, read off the Map() metadata.

Source code in src/femforge/run/retention.py
def rules(self) -> list[tuple[RunFile, Cleanup]]:
    """The ``(file class, rule)`` pairs, read off the ``Map()`` metadata."""
    rules: list[tuple[RunFile, Cleanup]] = []
    for key, value in mapped_items(self):
        if isinstance(value, Cleanup):
            rules.append((RunFile(key), value))
    return rules

default classmethod

default() -> RetentionPolicy

Keep everything the run cannot re-fetch; drop store-sourced inputs.

Source code in src/femforge/run/retention.py
@classmethod
def default(cls) -> RetentionPolicy:
    """Keep everything the run cannot re-fetch; drop store-sourced inputs."""
    return cls()

keep_all classmethod

keep_all() -> RetentionPolicy

Nothing leaves, store-sourced inputs included.

Source code in src/femforge/run/retention.py
@classmethod
def keep_all(cls) -> RetentionPolicy:
    """Nothing leaves, store-sourced inputs included."""
    return cls(cached_inputs=Cleanup.NEVER)

lean classmethod

lean() -> RetentionPolicy

Drop the regenerable meshes after a successful run.

Source code in src/femforge/run/retention.py
@classmethod
def lean(cls) -> RetentionPolicy:
    """Drop the regenerable meshes after a successful run."""
    return cls(
        gmsh_mesh=Cleanup.ON_SUCCESS,
        elmer_mesh=Cleanup.ON_SUCCESS,
        partitioning=Cleanup.ON_SUCCESS,
    )

minimal classmethod

minimal() -> RetentionPolicy

Lean plus the field output: keep only what re-reading the numbers needs.

Source code in src/femforge/run/retention.py
@classmethod
def minimal(cls) -> RetentionPolicy:
    """Lean plus the field output: keep only what re-reading the numbers needs."""
    return cls.lean().model_copy(update={"fields": Cleanup.ON_SUCCESS})

nuke classmethod

nuke() -> RetentionPolicy

Remove the run directory whatever happened (throwaway evaluations, bundles already pushed elsewhere).

Source code in src/femforge/run/retention.py
@classmethod
def nuke(cls) -> RetentionPolicy:
    """Remove the run directory whatever happened (throwaway evaluations, bundles already pushed elsewhere)."""
    return cls(
        geometry=Cleanup.ALWAYS,
        gmsh_mesh=Cleanup.ALWAYS,
        elmer_mesh=Cleanup.ALWAYS,
        partitioning=Cleanup.ALWAYS,
        sif=Cleanup.ALWAYS,
        fields=Cleanup.ALWAYS,
        scalars=Cleanup.ALWAYS,
        logs=Cleanup.ALWAYS,
        sidecars=Cleanup.ALWAYS,
        run_dir=Cleanup.ALWAYS,
    )

from_preset classmethod

from_preset(preset: Preset) -> RetentionPolicy

The policy a preset names.

Source code in src/femforge/run/retention.py
@classmethod
def from_preset(cls, preset: Preset) -> RetentionPolicy:
    """The policy a preset names."""
    match preset:
        case Preset.DEFAULT:
            return cls.default()
        case Preset.KEEP_ALL:
            return cls.keep_all()
        case Preset.LEAN:
            return cls.lean()
        case Preset.MINIMAL:
            return cls.minimal()
        case Preset.NUKE:
            return cls.nuke()

Stage

Bases: StrEnum

The driver's pipeline stages, in execution order.

Attributes:

  • PREPARE_GEOMETRY –

    Build or fetch the solve-frame STEP.

  • PRE_MESH –

    A hook before meshing.

  • MESH –

    Mesh the STEP and convert it with ElmerGrid, or fetch the cached mesh.

  • POST_MESH –

    A hook between meshing and building the sim.

  • BUILD_SIM –

    Build the typed sim from the resolved solver stack.

  • CONFIGURE_SIM –

    A hook to edit the solver stack before the sif is written.

  • WRITE_SIF –

    Write the sif into the run directory.

  • RUN_SOLVER –

    Run ElmerSolver, partitioning the mesh first for a parallel solve.

  • MESH_STATS –

    Record the mesh size summary.

  • PUBLISH –

    Commit the bundle to the artifact cache.

  • CLEANUP –

    Apply the retention policy.

PREPARE_GEOMETRY class-attribute instance-attribute

PREPARE_GEOMETRY = 'prepare_geometry'

Build or fetch the solve-frame STEP.

PRE_MESH class-attribute instance-attribute

PRE_MESH = 'pre_mesh'

A hook before meshing.

MESH class-attribute instance-attribute

MESH = 'mesh'

Mesh the STEP and convert it with ElmerGrid, or fetch the cached mesh.

POST_MESH class-attribute instance-attribute

POST_MESH = 'post_mesh'

A hook between meshing and building the sim.

BUILD_SIM class-attribute instance-attribute

BUILD_SIM = 'build_sim'

Build the typed sim from the resolved solver stack.

CONFIGURE_SIM class-attribute instance-attribute

CONFIGURE_SIM = 'configure_sim'

A hook to edit the solver stack before the sif is written.

WRITE_SIF class-attribute instance-attribute

WRITE_SIF = 'write_sif'

Write the sif into the run directory.

RUN_SOLVER class-attribute instance-attribute

RUN_SOLVER = 'run_solver'

Run ElmerSolver, partitioning the mesh first for a parallel solve.

MESH_STATS class-attribute instance-attribute

MESH_STATS = 'mesh_stats'

Record the mesh size summary.

PUBLISH class-attribute instance-attribute

PUBLISH = 'publish'

Commit the bundle to the artifact cache.

CLEANUP class-attribute instance-attribute

CLEANUP = 'cleanup'

Apply the retention policy.

StageEvent pydantic-model

Bases: BaseModel

One stage's timing record: created at stage start, mutated with finished_at when the stage completes.

Config:

  • use_attribute_docstrings: True

Fields:

stage pydantic-field

stage: Stage

Which pipeline stage this event times.

started_at pydantic-field

started_at: datetime

When the stage started (UTC).

finished_at pydantic-field

finished_at: datetime | None = None

When the stage finished (UTC); None while it runs — or forever, if it crashed.

bundle_files

bundle_files(sim_dir: str | Path, policy: BundlePolicy, *, exclude: Iterable[str] = ()) -> list[str]

The bundle-relative posix paths policy publishes from sim_dir.

Parameters:

  • sim_dir (str | Path) –

    The run bundle directory.

  • policy (BundlePolicy) –

    Which bundle files to publish.

  • exclude (Iterable[str], default: () ) –

    Bundle-relative paths to leave out, such as the store-sourced inputs.

Source code in src/femforge/run/artifacts.py
def bundle_files(sim_dir: str | Path, policy: BundlePolicy, *, exclude: Iterable[str] = ()) -> list[str]:
    """The bundle-relative posix paths ``policy`` publishes from ``sim_dir``.

    Args:
        sim_dir: The run bundle directory.
        policy: Which bundle files to publish.
        exclude: Bundle-relative paths to leave out, such as the store-sourced inputs.
    """
    bundle: Path = Path(sim_dir)
    skip: frozenset[str] = frozenset(exclude)
    names: set[str] = set()
    for file_class in policy.classes:
        for pattern in file_class.patterns:
            for match in bundle.glob(pattern):
                candidates: Iterable[Path] = (match,)
                if match.is_dir():
                    candidates = (child for child in match.rglob("*") if child.is_file())

                for path in candidates:
                    relative: str = path.relative_to(bundle).as_posix()
                    if relative not in skip and not relative.endswith(".tmp"):
                        names.add(relative)
    return sorted(names)

rebind_groups

rebind_groups(groups: MeshGroupsFile, domain: DomainConfig) -> tuple[list[PhysicalGroup], list[BoundaryGroup]]

Rebuild a cached mesh's groups against this config's domain, matching materials by tag and EntityTag objects by selector; ids stay placeholders until reconcile_ids.

Source code in src/femforge/run/artifacts.py
def rebind_groups(groups: MeshGroupsFile, domain: DomainConfig) -> tuple[list[PhysicalGroup], list[BoundaryGroup]]:
    """Rebuild a cached mesh's groups against this config's domain, matching materials by tag and ``EntityTag`` objects by selector; ids stay placeholders until ``reconcile_ids``."""
    bodies: list[PhysicalGroup] = []
    for record in groups.bodies:
        material: MaterialProperties = _material_for(record, domain)
        tags: list[EntityTag] = _tags_for(record, domain)
        expected: str = compose_group_name(material, tags, warn=lambda _message: None, authored=record.authored)
        if expected != record.name:
            raise MeshRebindError(f"cached mesh body {record.name!r} would be named {expected!r} by the current domain")

        bodies.append(
            PhysicalGroup(
                dim=record.dim,
                gmsh_id=0,
                name=record.name,
                material=material,
                tags=tags,
                entity_tags=list(record.entity_tags),
                authored=record.authored,
            )
        )

    boundaries: list[BoundaryGroup] = [
        BoundaryGroup(
            gmsh_id=0,
            name=record.name,
            bodies=tuple(record.bodies),
            face_tags=list(record.face_tags),
            dim=record.dim,
            on_axis=record.on_axis,
        )
        for record in groups.boundaries
    ]
    return bodies, boundaries

rehydrate_bundle

rehydrate_bundle(sim_dir: str | Path, cache: ArtifactCache) -> list[ArtifactHandle[StepSpec] | ArtifactHandle[MeshSpec]]

Fetch every store-sourced input the manifest names back into the bundle.

Source code in src/femforge/run/artifacts.py
def rehydrate_bundle(sim_dir: str | Path, cache: ArtifactCache) -> list[ArtifactHandle[StepSpec] | ArtifactHandle[MeshSpec]]:
    """Fetch every store-sourced input the manifest names back into the bundle."""
    bundle: Path = Path(sim_dir)
    handles: list[ArtifactHandle[StepSpec] | ArtifactHandle[MeshSpec]] = []
    for ref in RunManifest.load(bundle).artifacts:
        match ref.kind:
            case BuiltinKind.STEP:
                handles.append(cache.rehydrate(ref, bundle, STEP))
            case BuiltinKind.ELMER_MESH:
                handles.append(cache.rehydrate(ref, bundle, ELMER_MESH))
            case _:
                raise ArtifactMissing(f"{ref.kind}/{ref.key[:12]}: not a kind the driver materializes")
    return handles

load_manifest

load_manifest(sim_dir: str | Path) -> RunManifest

Read a bundle's manifest.json back into a typed RunManifest — a delegate to RunManifest.load.

Source code in src/femforge/run/bundle.py
def load_manifest(sim_dir: str | Path) -> RunManifest:
    """Read a bundle's ``manifest.json`` back into a typed [`RunManifest`][] — a delegate to [`RunManifest.load`][]."""
    return RunManifest.load(sim_dir)

load_result

load_result(sim_dir: str | Path) -> Result

Read a bundle's result.json back into a typed Result — a delegate to Result.load.

Source code in src/femforge/run/bundle.py
def load_result(sim_dir: str | Path) -> Result:
    """Read a bundle's ``result.json`` back into a typed [`Result`][] — a delegate to [`Result.load`][]."""
    return Result.load(sim_dir)

write_atomic

write_atomic(path: Path, text: str) -> None

Replace path atomically: write a .tmp sibling, then os.replace — a reader never sees a half-written file.

Source code in src/femforge/common/files.py
def write_atomic(path: Path, text: str) -> None:
    """Replace ``path`` atomically: write a ``.tmp`` sibling, then ``os.replace`` — a reader never sees a half-written file."""
    tmp: Path = path.with_name(path.name + TMP_SUFFIX)
    tmp.write_text(text, encoding="utf-8")

    replace_atomic(tmp, path)

write_bundle

write_bundle(sim_dir: str | Path, manifest: RunManifest, result: Result) -> Path

Write manifest.json and result.json into sim_dir, pin the source STEP as geometry.step unless the run already holds one, and return the bundle.

Parameters:

  • sim_dir (str | Path) –

    The run bundle directory, created when missing.

  • manifest (RunManifest) –

    The run's manifest.

  • result (Result) –

    The run's typed result.

Source code in src/femforge/run/bundle.py
def write_bundle(sim_dir: str | Path, manifest: RunManifest, result: Result) -> Path:
    """Write ``manifest.json`` and ``result.json`` into ``sim_dir``, pin the source STEP as ``geometry.step`` unless the run already holds one, and return the bundle.

    Args:
        sim_dir: The run bundle directory, created when missing.
        manifest: The run's manifest.
        result: The run's typed result.
    """
    bundle: Path = Path(sim_dir)
    bundle.mkdir(parents=True, exist_ok=True)

    write_atomic(bundle / BundleFile.MANIFEST, manifest.model_dump_json(indent=2))
    write_atomic(bundle / BundleFile.RESULT, result.model_dump_json(indent=2))

    # Pin the exact geometry used, so the bundle is reproducible even if the source STEP later changes. A geometry.step
    # already there is the solve-frame STEP the run meshed (possibly a hardlink into the store), so it is never overwritten.
    pinned: Path = bundle / BundleFile.GEOMETRY_STEP
    step: Path = Path(manifest.config.mesh.step)
    if step.is_file() and not pinned.exists():
        shutil.copyfile(step, pinned)

    return bundle

write_manifest

write_manifest(sim_dir: str | Path, manifest: RunManifest) -> Path

Atomically write (or overwrite) manifest.json in sim_dir (created if needed) and return its path.

Source code in src/femforge/run/bundle.py
def write_manifest(sim_dir: str | Path, manifest: RunManifest) -> Path:
    """Atomically write (or overwrite) ``manifest.json`` in ``sim_dir`` (created if needed) and return its path."""
    bundle: Path = Path(sim_dir)
    bundle.mkdir(parents=True, exist_ok=True)
    path: Path = bundle / BundleFile.MANIFEST
    write_atomic(path, manifest.model_dump_json(indent=2))
    return path

convergence_ladder

convergence_ladder(base_config: SimulationConfig, refinements: list[float], quantity_of: QuantityOf, evaluate: EvaluateFn) -> list[ConvergencePoint]

Run base_config at each refinement level and report the quantity of interest plus its relative change between rungs.

Parameters:

  • base_config (SimulationConfig) –

    The configuration to refine.

  • refinements (list[float]) –

    Factors that divide global_mesh_size; a factor above 1 is finer than the base.

  • quantity_of (QuantityOf) –

    Extracts the quantity of interest from a result.

  • evaluate (EvaluateFn) –

    Solves one configuration, such as a driver's evaluate.

Source code in src/femforge/run/convergence.py
def convergence_ladder(
    base_config: SimulationConfig,
    refinements: list[float],
    quantity_of: QuantityOf,
    evaluate: EvaluateFn,
) -> list[ConvergencePoint]:
    """Run ``base_config`` at each refinement level and report the quantity of interest plus its relative change between rungs.

    Args:
        base_config: The configuration to refine.
        refinements: Factors that divide ``global_mesh_size``; a factor above 1 is finer than the base.
        quantity_of: Extracts the quantity of interest from a result.
        evaluate: Solves one configuration, such as a driver's ``evaluate``.
    """
    points: list[ConvergencePoint] = []
    previous: Quantity | None = None

    for refinement in refinements:
        refined_size = base_config.mesh.global_mesh_size / refinement
        refined_mesh = base_config.mesh.model_copy(update={"global_mesh_size": refined_size})
        refined: SimulationConfig = base_config.model_copy(update={"mesh": refined_mesh})

        result: Result = evaluate(refined)
        quantity: Quantity = quantity_of(result)
        delta: float | None = None if previous is None else _relative_change(quantity, previous)

        points.append(ConvergencePoint(refinement=refinement, quantity=quantity, delta_vs_previous=delta))
        previous = quantity

    return points

richardson

richardson(scales: Sequence[float], values: Sequence[float]) -> RichardsonEstimate

Richardson-extrapolate the finest three rungs of a ladder whose scales are mesh-size multipliers (larger is coarser) paired with values.

Raises when fewer than three rungs are given, scales repeat, or the rung deltas are not shrinking.

Source code in src/femforge/run/convergence.py
def richardson(scales: Sequence[float], values: Sequence[float]) -> RichardsonEstimate:
    """Richardson-extrapolate the finest three rungs of a ladder whose ``scales`` are mesh-size multipliers (larger is coarser) paired with ``values``.

    Raises when fewer than three rungs are given, scales repeat, or the rung deltas are not shrinking.
    """
    if len(scales) != len(values):
        raise ValueError(f"scales and values differ in length ({len(scales)} vs {len(values)}).")
    if len(scales) < 3:
        raise ValueError(f"Richardson extrapolation needs at least three rungs, got {len(scales)}.")
    if any(scale <= 0 for scale in scales):
        raise ValueError("scales must be positive mesh-size multipliers.")
    if len(set(scales)) != len(scales):
        raise ValueError("scales repeat; each rung must be a distinct refinement.")

    ladder: list[tuple[float, float]] = sorted(zip(scales, values), key=lambda rung: -rung[0])
    (h1, q1), (h2, q2), (h3, q3) = ladder[-3:]

    d1: float = q1 - q2
    d2: float = q2 - q3
    if d2 == 0.0:
        return RichardsonEstimate(observed_order=math.inf, extrapolated_value=q3, uncertainty=0.0)

    ratio: float = d1 / d2
    if ratio <= 1.0:
        raise ValueError(f"rung deltas are not shrinking (ratio {ratio:.4g}); the finest three rungs are outside the asymptotic regime.")

    order: float = _observed_order(h1 / h2, h2 / h3, ratio)
    limit: float = q3 - d2 / (math.pow(h2 / h3, order) - 1.0)
    return RichardsonEstimate(observed_order=order, extrapolated_value=limit, uncertainty=abs(limit - q3))

boundary_fluxes

boundary_fluxes(raw: Mapping[str, float]) -> dict[int, float]

Elmer bc index -> integrated conductive flux (A) from the SaveScalars columns in Result.raw.

Source code in src/femforge/run/electrical.py
def boundary_fluxes(raw: Mapping[str, float]) -> dict[int, float]:
    """Elmer bc index -> integrated conductive flux (A) from the SaveScalars columns in ``Result.raw``."""
    fluxes: dict[int, float] = {}
    for name, value in raw.items():
        match: re.Match[str] | None = _FLUX_BC_RE.search(name)
        if match:
            fluxes[int(match.group(1))] = value
    return fluxes

electrode_currents

electrode_currents(raw: Mapping[str, float], names_by_bc: Mapping[int, str]) -> dict[str, float]

Boundary block name -> terminal current (A), joining the flux columns against the bc-index name map.

Source code in src/femforge/run/electrical.py
def electrode_currents(raw: Mapping[str, float], names_by_bc: Mapping[int, str]) -> dict[str, float]:
    """Boundary block name -> terminal current (A), joining the flux columns against the bc-index name map."""
    return {names_by_bc[bc]: flux for bc, flux in boundary_fluxes(raw).items() if bc in names_by_bc}

phase_currents

phase_currents(currents: Mapping[str, float]) -> dict[str, float]

Phase index ("0", "1", ...) -> terminal current (A): the largest-magnitude current among each phase's named electrode currents.

Source code in src/femforge/run/electrical.py
def phase_currents(currents: Mapping[str, float]) -> dict[str, float]:
    """Phase index ("0", "1", ...) -> terminal current (A): the largest-magnitude current among each phase's named electrode currents."""
    best: dict[str, float] = {}
    for name, current in currents.items():
        match: re.Match[str] | None = _PHASE_RE.search(name)
        if match is None:
            continue
        phase: str = match.group(1)
        # Elmer integrates an internal electrode's flux from one side, and the pad side can read about zero.
        if phase not in best or abs(current) > abs(best[phase]):
            best[phase] = current
    return best

read_boundary_names

read_boundary_names(path: str | Path) -> dict[int, str]

The boundaries.yml map the Sim writes (<bc index>: <boundary block name>), as a dict.

Source code in src/femforge/run/electrical.py
def read_boundary_names(path: str | Path) -> dict[int, str]:
    """The ``boundaries.yml`` map the Sim writes (``<bc index>: <boundary block name>``), as a dict."""
    names: dict[int, str] = {}
    for line in Path(path).read_text().splitlines():
        key, _, value = line.partition(":")
        if key.strip().isdigit() and value.strip():
            names[int(key.strip())] = value.strip()
    return names

resistance

resistance(delta_volts: float, current_amps: float) -> float

R = ΔV / I for one phase; raises on a (numerically) zero current.

Source code in src/femforge/run/electrical.py
def resistance(delta_volts: float, current_amps: float) -> float:
    """R = ΔV / I for one phase; raises on a (numerically) zero current."""
    if abs(current_amps) < 1e-30:
        raise ValueError(f"electrode current is zero (got {current_amps!r}); the flux integral found no current through the electrode.")
    return abs(delta_volts / current_amps)

load_entities

load_entities(sim_dir: str | Path, manifest: RunManifest | None = None) -> Entities

Read a bundle's bodies/boundaries: entities.json when present, else mesh.names enriched from the manifest config, else empty.

Source code in src/femforge/run/entities.py
def load_entities(sim_dir: str | Path, manifest: RunManifest | None = None) -> Entities:
    """Read a bundle's bodies/boundaries: ``entities.json`` when present, else ``mesh.names`` enriched from the manifest config, else empty."""
    path: Path = Path(sim_dir)

    boundaries_file: Path = path / BundleFile.BOUNDARIES
    names_by_bc: dict[int, str] = read_boundary_names(boundaries_file) if boundaries_file.is_file() else {}

    entities_file: Path = path / BundleFile.ENTITIES
    if entities_file.is_file():
        data: EntitiesFile = EntitiesFile.model_validate_json(entities_file.read_text(encoding="utf-8"))
        sif_by_group: dict[str, int] = _sif_index_by_group(names_by_bc, (record.name for record in data.boundaries.values()))
        bodies = Bodies(
            Body(
                name=record.name,
                mesh_id=mesh_id,
                material=record.material,
                tags=dict(record.tags),
            )
            for mesh_id, record in data.bodies.items()
        )
        boundaries = Boundaries(
            Boundary(
                name=record.name,
                mesh_id=mesh_id,
                bodies=tuple(record.bodies),
                kind=_boundary_kind(record.name, record.on_axis),
                sif_index=sif_by_group.get(record.name),
            )
            for mesh_id, record in data.boundaries.items()
        )
        return Entities(bodies=bodies, boundaries=boundaries)

    names_file: Path = path / BundleFile.MESH_NAMES
    if not names_file.is_file():
        return Entities(bodies=Bodies(()), boundaries=Boundaries(()))

    names = parse_mesh_names(names_file.read_text(encoding="utf-8"))
    materials, entity_tags = _config_vocabulary(path, manifest)

    body_list: list[Body] = []
    for name, mesh_id in names.bodies.items():
        material, tags = _match_body(name, materials, entity_tags)
        body_list.append(
            Body(
                name=name,
                mesh_id=mesh_id,
                material=material,
                tags=tags,
            )
        )

    sif_by_group = _sif_index_by_group(names_by_bc, names.boundaries)
    boundary_list: list[Boundary] = [
        Boundary(
            name=name,
            mesh_id=mesh_id,
            bodies=_bodies_from_name(name),
            kind=_boundary_kind(name, False),
            sif_index=sif_by_group.get(name),
        )
        for name, mesh_id in names.boundaries.items()
    ]
    return Entities(bodies=Bodies(body_list), boundaries=Boundaries(boundary_list))

write_entities

write_entities(sim_dir: str | Path, physical_groups: Iterable[_BodyGroupLike], boundary_groups: Iterable[_BoundaryGroupLike]) -> Path

Write entities.json mapping each group's reconciled mesh id to its named record; call after reconcile_ids.

Parameters:

  • sim_dir (str | Path) –

    The run bundle directory.

  • physical_groups (Iterable[_BodyGroupLike]) –

    The body groups, carrying ElmerGrid's ids.

  • boundary_groups (Iterable[_BoundaryGroupLike]) –

    The boundary groups, carrying ElmerGrid's ids.

Source code in src/femforge/run/entities.py
def write_entities(sim_dir: str | Path, physical_groups: Iterable[_BodyGroupLike], boundary_groups: Iterable[_BoundaryGroupLike]) -> Path:
    """Write ``entities.json`` mapping each group's reconciled mesh id to its named record; call after ``reconcile_ids``.

    Args:
        sim_dir: The run bundle directory.
        physical_groups: The body groups, carrying ElmerGrid's ids.
        boundary_groups: The boundary groups, carrying ElmerGrid's ids.
    """
    bodies: dict[int, BodyRecord] = {}
    for group in physical_groups:
        merged: TagDict = {}
        for tag in group.tags:
            merged.update(tag.selector)
        bodies[group.id] = BodyRecord(
            name=group.name,
            material=group.material.tag or None,
            tags=merged,
        )

    boundaries: dict[int, BoundaryRecord] = {
        group.id: BoundaryRecord(
            name=group.name,
            bodies=tuple(group.bodies),
            on_axis=group.on_axis,
        )
        for group in boundary_groups
    }

    path: Path = Path(sim_dir) / BundleFile.ENTITIES
    write_atomic(path, EntitiesFile(bodies=bodies, boundaries=boundaries).model_dump_json(indent=2))
    return path

select_array

select_array(names: Iterable[str], variable: ElmerVariable | str) -> str

Resolve a VTU array name for a solver variable: exact (nodal), then the elemental "<name> e", then substring — VTU names come back lowercased.

Source code in src/femforge/run/fields.py
def select_array(names: Iterable[str], variable: ElmerVariable | str) -> str:
    """Resolve a VTU array name for a solver variable: exact (nodal), then the elemental ``"<name> e"``, then substring — VTU names come back lowercased."""
    available: list[str] = list(names)
    lowered: dict[str, str] = {name.lower(): name for name in available}
    want: str = str(variable).lower()

    for candidate in (want, elemental(want)):
        if candidate in lowered:
            return lowered[candidate]

    for name in available:
        if want in name.lower():
            return name
    raise KeyError(f"no array matching {str(variable)!r}; available: {sorted(available)}")

find_runs

find_runs(runs_dir: str | Path, *, tags: Mapping[str, str] | None = None, text: str | None = None) -> list[Run]

The bundles under runs_dir whose metadata matches, newest first; this reads every manifest, so prefer RunIndex over thousands of bundles.

Parameters:

  • runs_dir (str | Path) –

    The runs directory to search; bundles with unreadable manifests are skipped.

  • tags (Mapping[str, str] | None, default: None ) –

    Tag pairs a run's metadata must all carry; None matches any tags.

  • text (str | None, default: None ) –

    Text to find, ignoring case, in the run name, description or notes; None matches any text.

Source code in src/femforge/run/inspect.py
def find_runs(runs_dir: str | Path, *, tags: Mapping[str, str] | None = None, text: str | None = None) -> list[Run]:
    """The bundles under ``runs_dir`` whose metadata matches, newest first; this reads every manifest, so prefer [`RunIndex`][femforge.telemetry.runs.RunIndex] over thousands of bundles.

    Args:
        runs_dir: The runs directory to search; bundles with unreadable manifests are skipped.
        tags: Tag pairs a run's metadata must all carry; None matches any tags.
        text: Text to find, ignoring case, in the run name, description or notes; None matches any text.
    """
    needle: str | None = text.casefold() if text is not None else None
    matches: list[tuple[tuple[float, float], Run]] = []

    for manifest_path in Path(runs_dir).glob(f"*/{BundleFile.MANIFEST}"):
        run = Run(manifest_path.parent)
        try:
            manifest: RunManifest = run.manifest
        except Exception:  # noqa: BLE001 - a foreign or schema-drifted bundle must not break the search
            continue

        meta: RunMeta = manifest.config.meta
        if tags is not None and not meta.matches(tags):
            continue

        if needle is not None and needle not in f"{manifest.run_id} {meta.description or ''} {manifest.notes}".casefold():
            continue

        matches.append((_manifest_order(manifest_path), run))

    return [run for _, run in sorted(matches, key=lambda entry: entry[0], reverse=True)]

mesh_spec

mesh_spec(config: SimulationConfig, step_key_override: str | None = None) -> MeshSpec

The mesh artifact spec of config (step_key_override when the driver already resolved the geometry).

Source code in src/femforge/run/kinds.py
def mesh_spec(config: SimulationConfig, step_key_override: str | None = None) -> MeshSpec:
    """The mesh artifact spec of ``config`` (``step_key_override`` when the driver already resolved the geometry)."""
    return MeshSpec(
        step_key=step_key_override if step_key_override is not None else step_key(config),
        model_dim=config.frame.model_dim,
        global_mesh_size=config.mesh.global_mesh_size,
        gmsh_options=config.mesh.gmsh_options,
        deterministic=config.mesh.deterministic,
        tags=[TagSelectorSpec(selector=dict(tag.selector), mesh_size=tag.mesh_size) for tag in config.domain.tags],
        materials=sorted(material.tag for material in config.domain.materials),
    )

step_digest

step_digest(path: str | Path) -> str

SHA-256 of a STEP with its FILE_NAME header (the export timestamp) masked; of the path string when the file is absent, so pure-logic keys stay deterministic.

Source code in src/femforge/run/kinds.py
def step_digest(path: str | Path) -> str:
    """SHA-256 of a STEP with its ``FILE_NAME`` header (the export timestamp) masked; of the path string when the file is absent, so pure-logic keys stay deterministic."""
    file: Path = Path(path)
    if not file.is_file():
        return hashlib.sha256(str(path).encode("utf-8")).hexdigest()

    return hashlib.sha256(_FILE_NAME_HEADER.sub(b"FILE_NAME();", file.read_bytes())).hexdigest()

component_forces

component_forces(raw: Mapping[str, float], names_by_component: Mapping[int, str]) -> dict[str, Vec3]

Component name -> lumped magnetic force (N, per radian in an axisymmetric run), joining the force triples against the components.yml map.

Source code in src/femforge/run/magnetic.py
def component_forces(raw: Mapping[str, float], names_by_component: Mapping[int, str]) -> dict[str, Vec3]:
    """Component name -> lumped magnetic force (N, per radian in an axisymmetric run), joining the force triples against the ``components.yml`` map."""
    return _join_components(force_triples(raw), names_by_component)

component_torques

component_torques(raw: Mapping[str, float], names_by_component: Mapping[int, str]) -> dict[str, Vec3]

Component name -> lumped magnetic torque (N*m about the authored origin/axis, per radian in an axisymmetric run), joining the torque triples against the components.yml map.

Source code in src/femforge/run/magnetic.py
def component_torques(raw: Mapping[str, float], names_by_component: Mapping[int, str]) -> dict[str, Vec3]:
    """Component name -> lumped magnetic torque (N*m about the authored origin/axis, per radian in an axisymmetric run), joining the torque triples against the ``components.yml`` map."""
    return _join_components(_vector_triples(raw, "torque"), names_by_component)

eddy_current_power

eddy_current_power(raw: Mapping[str, float]) -> float | None

The total eddy current power (W, per radian in an axisymmetric run), or None when the run exported none (static solves have no eddy losses).

Source code in src/femforge/run/magnetic.py
def eddy_current_power(raw: Mapping[str, float]) -> float | None:
    """The total eddy current power (W, per radian in an axisymmetric run), or None when the run exported none (static solves have no eddy losses)."""
    return next((value for name, value in raw.items() if ElmerVariable.EDDY_CURRENT_POWER.lower() in name.lower()), None)

force_triples

force_triples(raw: Mapping[str, float]) -> dict[str, Vec3]

Column stem -> per-component force Vec3 (N, per radian in an axisymmetric run) from the SaveScalars columns in Result.raw.

Source code in src/femforge/run/magnetic.py
def force_triples(raw: Mapping[str, float]) -> dict[str, Vec3]:
    """Column stem -> per-component force Vec3 (N, per radian in an axisymmetric run) from the SaveScalars columns in ``Result.raw``."""
    return _vector_triples(raw, "force")

magnetic_field_energy

magnetic_field_energy(raw: Mapping[str, float]) -> float | None

The total magnetic field energy (J, per radian in an axisymmetric run), or None when the run exported none.

Source code in src/femforge/run/magnetic.py
def magnetic_field_energy(raw: Mapping[str, float]) -> float | None:
    """The total magnetic field energy (J, per radian in an axisymmetric run), or None when the run exported none."""
    return next((value for name, value in raw.items() if ElmerVariable.MAGNETIC_FIELD_ENERGY.lower() in name.lower()), None)

read_component_names

read_component_names(path: str | Path) -> dict[int, str]

The components.yml map the Sim writes (<component id>: <Component name>) — the same flat format as boundaries.yml.

Source code in src/femforge/run/magnetic.py
def read_component_names(path: str | Path) -> dict[int, str]:
    """The ``components.yml`` map the Sim writes (``<component id>: <Component name>``) — the same flat format as ``boundaries.yml``."""
    return read_boundary_names(path)

cache_key

cache_key(config: SimulationConfig) -> str

Full-run identity: every result-affecting input plus RUN_PRODUCER_VERSION; never the code commit, the machine, or the run metadata.

Source code in src/femforge/run/kinds.py
def cache_key(config: SimulationConfig) -> str:
    """Full-run identity: every result-affecting input plus ``RUN_PRODUCER_VERSION``; never the code commit, the machine, or the run metadata."""
    return sha256_json({"kind": BuiltinKind.RUN_BUNDLE.value, "producer_version": RUN_PRODUCER_VERSION, "spec": run_spec_payload(config)})

git_sha

git_sha(*, short: bool = True) -> str | None

The current commit, with -dirty appended when the working tree has uncommitted changes; None without git or outside a repository.

Source code in src/femforge/run/manifest.py
def git_sha(*, short: bool = True) -> str | None:
    """The current commit, with ``-dirty`` appended when the working tree has uncommitted changes; None without git or outside a repository."""
    head: str | None = _git("rev-parse", "HEAD")
    if head is None:
        return None

    sha: str = head.strip()
    if not sha:
        return None

    if short:
        sha = sha[:12]
    status: str | None = _git("status", "--porcelain")
    if status is not None and status.strip():
        sha += "-dirty"
    return sha

mesh_key

mesh_key(config: SimulationConfig) -> str

The mesh artifact key of config: invariant to materials' properties, conditions, physics, and metadata.

Source code in src/femforge/run/kinds.py
def mesh_key(config: SimulationConfig) -> str:
    """The mesh artifact key of ``config``: invariant to materials' properties, conditions, physics, and metadata."""
    return artifact_key(ELMER_MESH, mesh_spec(config))

step_key

step_key(config: SimulationConfig) -> str

The solve-frame geometry identity a run and its mesh key on: the STEP artifact key of step_spec(config).

Source code in src/femforge/run/kinds.py
def step_key(config: SimulationConfig) -> str:
    """The solve-frame geometry identity a run and its mesh key on: the STEP artifact key of ``step_spec(config)``."""
    return artifact_key(STEP, step_spec(config))

parse_result

parse_result(sim_dir: str | Path, frame: Frame | None = None) -> Result

Parse a finished run directory into a typed Result, revolving force and torque to full-revolution totals under an Axisymmetric frame.

frame=None reads the frame from the bundle's manifest, falling back to Solid for a directory without one.

Source code in src/femforge/run/parse.py
def parse_result(sim_dir: str | Path, frame: Frame | None = None) -> Result:
    """Parse a finished run directory into a typed [`Result`][femforge.run.result.Result], revolving force and torque to full-revolution totals under an ``Axisymmetric`` frame.

    ``frame=None`` reads the frame from the bundle's manifest, falling back to ``Solid`` for a directory without one.
    """
    sim_path: Path = Path(sim_dir)
    columns: dict[str, float] = _parse_scalars(sim_path)
    status, residual, wall_time, elmer_version = _parse_log(sim_path)

    # Narrowed to the global CalcFields sums so per-Component force/torque triples can never silently absorb into
    # Result.force/.torque (run.magnetic reads those from Result.raw).
    force_vec: Vec3 | None = _gather_vector(columns, "nodal force")
    torque_vec: Vec3 | None = _gather_vector(columns, "nodal torque")
    flux_density_max: float | None = next(
        (value for name, value in columns.items() if ElmerVariable.MAGNETIC_FLUX_DENSITY.lower() in name.lower()),
        None,
    )
    # The thermal and coupled joule-heating stacks export a "max ... temperature" SaveScalars reduction.
    temperature_max: float | None = next(
        (value for name, value in columns.items() if "max" in name.lower() and ElmerVariable.TEMPERATURE.lower() in name.lower()),
        None,
    )

    # Per-component force/torque triples join against the components.yml the Sim wrote; without it they stay in raw only.
    force_by_component: dict[str, Vec3] = {}
    torque_by_component: dict[str, Vec3] = {}
    components_file: Path = sim_path / BundleFile.COMPONENTS
    if components_file.is_file():
        names_by_component: dict[int, str] = read_component_names(components_file)
        force_by_component = component_forces(columns, names_by_component)
        torque_by_component = component_torques(columns, names_by_component)

    if frame is None:
        has_manifest: bool = (sim_path / BundleFile.MANIFEST).is_file()
        frame = RunManifest.load(sim_path).config.frame if has_manifest else Solid()

    if isinstance(frame, Axisymmetric):
        force_vec = revolve(force_vec) if force_vec is not None else None
        torque_vec = revolve(torque_vec) if torque_vec is not None else None
        force_by_component = {name: revolve(vec) for name, vec in force_by_component.items()}
        torque_by_component = {name: revolve(vec) for name, vec in torque_by_component.items()}

    return Result(
        status=status,
        frame=frame,
        force=quantity(force_vec, "newton") if force_vec is not None else None,
        torque=quantity(torque_vec, "newton * meter") if torque_vec is not None else None,
        flux_density_max=quantity(flux_density_max, "tesla") if flux_density_max is not None else None,
        temperature_max=quantity(temperature_max, "kelvin") if temperature_max is not None else None,
        component_forces={name: quantity(vec, "newton") for name, vec in force_by_component.items()},
        component_torques={name: quantity(vec, "newton * meter") for name, vec in torque_by_component.items()},
        residual=residual,
        wall_time=wall_time,
        elmer_version=elmer_version,
        raw=dict(columns),
    )

result_vtu

result_vtu(sim_dir: str | Path) -> Path | None

The run's newest ParaView output: the .pvtu master when the solve was partitioned, else the newest .vtu; None when there is none.

Use it instead of a *.vtu glob, which also matches a partitioned run's per-rank pieces.

Source code in src/femforge/run/parse.py
def result_vtu(sim_dir: str | Path) -> Path | None:
    """The run's newest ParaView output: the ``.pvtu`` master when the solve was partitioned, else the newest ``.vtu``; None when there is none.

    Use it instead of a ``*.vtu`` glob, which also matches a partitioned run's per-rank pieces.
    """
    sim_path: Path = Path(sim_dir)
    for pattern in (BundleGlob.PVTU, BundleGlob.VTU):
        matches: list[Path] = sorted(sim_path.glob(pattern))
        if matches:
            return matches[-1]

    return None

reap_runs

reap_runs(runs_dir: str | Path, *, alive: Alive = pid_exists, host: str = HOST) -> list[ReapedRun]

Mark every bundle under runs_dir left running with no live claim on it as abandoned, and break every claim that went silent.

Parameters:

  • runs_dir (str | Path) –

    The runs directory holding the bundles and their claims.

  • alive (Alive, default: pid_exists ) –

    Whether a process id is alive on this host.

  • host (str, default: HOST ) –

    This host's name; a claim held here by a process that is no longer alive counts as silent.

Source code in src/femforge/run/reap.py
def reap_runs(runs_dir: str | Path, *, alive: Alive = psutil.pid_exists, host: str = HOST) -> list[ReapedRun]:
    """Mark every bundle under ``runs_dir`` left ``running`` with no live claim on it as ``abandoned``, and break every claim that went silent.

    Args:
        runs_dir: The runs directory holding the bundles and their claims.
        alive: Whether a process id is alive on this host.
        host: This host's name; a claim held here by a process that is no longer alive counts as silent.
    """
    directory = Path(runs_dir)
    if not directory.is_dir():
        return []

    def silent(lock: DirLock, owner: LockOwner | None) -> bool:
        if not lock.path.is_dir() or lock.is_stale():
            return True

        return owner is not None and owner.host == host and not alive(owner.pid)

    def claims_running(sim_dir: Path) -> bool:
        """Whether the manifest says ``running``, read without validating it: one key out of thousands of bundles."""
        try:
            data = cast("object", json.loads((sim_dir / BundleFile.MANIFEST).read_text(encoding="utf-8")))
        except (OSError, ValueError):
            return False

        return isinstance(data, dict) and cast("dict[str, object]", data).get("status") == "running"

    def settle(sim_dir: Path) -> ManifestStatus | None:
        try:
            manifest = RunManifest.load(sim_dir)
        except (OSError, ValueError):
            return None

        if manifest.status != "running":
            return manifest.status

        manifest.status = "abandoned"
        _ = write_manifest(sim_dir, manifest)

        return manifest.status

    with os.scandir(directory) as entries:
        names = sorted(entry.name for entry in entries if entry.is_dir())

    claimed = {name.removesuffix(LOCK_SUFFIX) for name in names if name.endswith(LOCK_SUFFIX)}
    running = {name for name in names if not name.endswith(LOCK_SUFFIX) and claims_running(directory / name)}

    reaped: list[ReapedRun] = []
    for run_id in sorted(claimed | running):
        sim_dir = directory / run_id
        lock = DirLock(directory / f"{run_id}{LOCK_SUFFIX}")
        owner = lock.owner()
        if not silent(lock, owner):
            continue

        status = settle(sim_dir)
        lock.release()

        held = owner.model_dump(mode="json") if owner is not None else {}
        log.bind(run_id=run_id, status=status, **held).info("Abandoned run reaped.")
        reaped.append(ReapedRun(run_id, sim_dir, owner, status))

    return reaped

apply_cleanup

apply_cleanup(plan: CleanupPlan, *, dry_run: bool = False) -> CleanupReport

Remove every planned path; never raises, so a path that cannot be removed lands in report.failed.

A planned run directory is only reported (as "."); the caller removes it with remove_run_dir after its last manifest write.

Source code in src/femforge/run/retention.py
def apply_cleanup(plan: CleanupPlan, *, dry_run: bool = False) -> CleanupReport:
    """Remove every planned path; never raises, so a path that cannot be removed lands in ``report.failed``.

    A planned run directory is only reported (as ``"."``); the caller removes it with ``remove_run_dir`` after its last manifest write.
    """
    report = CleanupReport(plan=plan, dry_run=dry_run)

    if plan.remove_run_dir:
        report.removed.append(
            RemovedFile(
                path=".",
                size=plan.run_dir_bytes,
                file_class=RunFile.RUN_DIR,
                reason=plan.policy.run_dir,
            )
        )
        return report

    for entry in plan.entries:
        if not dry_run:
            try:
                remove_path(entry.path)
            except OSError as error:
                report.failed.append((entry.relative, str(error)))
                continue

        report.removed.append(entry.record())

    return report

bundle_inventory

bundle_inventory(sim_dir: str | Path) -> list[BundleEntry]

Every file under a bundle with the retention class its name matches; a name no class claims is filed under RunFile.RUN_DIR, the only rule that reaches it.

Source code in src/femforge/run/retention.py
def bundle_inventory(sim_dir: str | Path) -> list[BundleEntry]:
    """Every file under a bundle with the retention class its name matches; a name no class claims is filed under ``RunFile.RUN_DIR``, the only rule that reaches it."""
    bundle: Path = Path(sim_dir)

    def classify(name: str) -> RunFile:
        for file_class in RunFile:
            if any(fnmatch(name, pattern) for pattern in file_class.patterns):
                return file_class

        return RunFile.RUN_DIR

    entries: list[BundleEntry] = []
    for path in sorted(bundle.rglob("*")):
        if not path.is_file():
            continue

        relative: Path = path.relative_to(bundle)
        entries.append(
            BundleEntry(
                relative=relative.as_posix(),
                size=path.stat().st_size,
                file_class=classify(relative.parts[0]),
            )
        )

    return entries

plan_cleanup

plan_cleanup(sim_dir: str | Path, policy: RetentionPolicy, *, status: ManifestStatus, cached: Iterable[str] = ()) -> CleanupPlan

Resolve policy against one bundle into the sized paths each applying rule removes; refuses a symlinked or junctioned directory.

Parameters:

  • sim_dir (str | Path) –

    The run bundle directory.

  • policy (RetentionPolicy) –

    The retention policy to resolve.

  • status (ManifestStatus) –

    The run status the ON_SUCCESS rules resolve against.

  • cached (Iterable[str], default: () ) –

    Bundle-relative paths the store can re-fetch; policy.cached_inputs escalates their rules.

Source code in src/femforge/run/retention.py
def plan_cleanup(sim_dir: str | Path, policy: RetentionPolicy, *, status: ManifestStatus, cached: Iterable[str] = ()) -> CleanupPlan:
    """Resolve ``policy`` against one bundle into the sized paths each applying rule removes; refuses a symlinked or junctioned directory.

    Args:
        sim_dir: The run bundle directory.
        policy: The retention policy to resolve.
        status: The run status the ``ON_SUCCESS`` rules resolve against.
        cached: Bundle-relative paths the store can re-fetch; ``policy.cached_inputs`` escalates their rules.
    """
    bundle: Path = Path(sim_dir)
    if bundle.is_symlink() or bundle.is_junction():
        raise ValueError(f"refusing to clean through a link: {bundle}")

    if policy.run_dir.applies(status):
        return CleanupPlan(
            sim_dir=bundle,
            policy=policy,
            status=status,
            entries=(),
            remove_run_dir=True,
            run_dir_bytes=tree_size(bundle),
        )

    cached_paths: frozenset[str] = frozenset(cached)
    seen: set[Path] = set()
    entries: list[PlannedRemoval] = []

    for file_class, rule in policy.rules():
        if file_class is RunFile.RUN_DIR:
            continue

        for pattern in file_class.patterns:
            for match in sorted(bundle.glob(pattern)):
                if match in seen or match.is_symlink() or _is_protected(match):
                    continue

                seen.add(match)

                relative: str = match.relative_to(bundle).as_posix()
                is_cached: bool = relative in cached_paths
                effective: Cleanup = rule.escalate(policy.cached_inputs) if is_cached else rule
                if not effective.applies(status):
                    continue

                entries.append(
                    PlannedRemoval(
                        path=match,
                        relative=relative,
                        size=tree_size(match),
                        file_class=file_class,
                        reason=effective,
                        cached=is_cached,
                    )
                )

    return CleanupPlan(
        sim_dir=bundle,
        policy=policy,
        status=status,
        entries=tuple(entries),
        remove_run_dir=False,
    )