Source code for femorph_solver.exceptions

"""Typed exception hierarchy for femorph-solver.

A small, audit-flagged set of exception classes — every error a
caller might want to catch by *kind* (mesh shape, BC mismatch,
solver unavailable, didn't converge) is its own subclass of
:class:`FemorphSolverError`.  Bare ``ValueError`` / ``RuntimeError``
remain reserved for genuinely-unexpected internal-invariant
violations that the caller can't recover from.

Why this hierarchy exists
-------------------------

The audit (issue #750 + the second-pass audit) flagged that AI
agents and downstream services calling ``femorph_solver`` from
LLM-orchestrated workflows must currently catch bare ``ValueError``
and string-match the message to know what failed.  That's fragile
and breaks when the message wording is polished.

After this hierarchy lands, the contract is:

* Caller wants "any femorph problem" → ``except FemorphSolverError``.
* Caller wants "user-fixable mesh / BC issue" → ``except (MeshError,
  ConstraintError, MaterialError)``.
* Caller wants "I'll retry on a different backend" →
  ``except SolverUnavailableError``.
* Caller wants "I'll retry with looser tolerance / more modes" →
  ``except ConvergenceError``.

The hierarchy is intentionally shallow (one level deep) — each
concrete class is its own intent, not an inheritance ladder.
``SolverUnavailableError`` still subclasses ``RuntimeError`` for
back-compat with code that catches it that way today.
"""

from __future__ import annotations

__all__ = [
    "ConstraintError",
    "ConvergenceError",
    "FemorphSolverError",
    "MaterialError",
    "MeshError",
    "SolverUnavailableError",
    "ValidationError",
]


class FemorphSolverError(Exception):
    """Root exception for every error femorph-solver itself raises.

    Catching this catches every typed error the library raises but
    nothing else — it does not subsume bare ``ValueError`` from
    third-party libraries (numpy, scipy, pyvista) that propagate
    through.  For "anything that went wrong inside a solve," catch
    ``FemorphSolverError``; for "anything at all," catch the broader
    ``Exception``.
    """


class MeshError(FemorphSolverError):
    """Mesh topology / connectivity problem.

    Examples: an element type is not registered, a cell has the
    wrong vertex count for its declared element type, the mesh has
    no points, or the cyclic-pair detection couldn't pair the low /
    high faces.
    """


class MaterialError(FemorphSolverError):
    """Material assignment or property-table problem.

    Examples: an element has no material assigned, a material lacks
    a required property (e.g. ``EX``), or a value is non-physical
    (negative density).
    """


class ConstraintError(FemorphSolverError):
    """Boundary-condition / DOF / coupling problem.

    Examples: a Dirichlet constraint refers to a node that doesn't
    exist, a coupling chain forms a cycle, an invalid DOF label,
    or all DOFs are constrained leaving an empty free system.
    """


class ConvergenceError(FemorphSolverError):
    """Iterative solver failed to converge within tolerance.

    Carries optional ``residual`` and ``n_iter`` attributes so the
    caller can decide whether to retry with looser tolerance, more
    iterations, or a different backend.
    """

    def __init__(
        self,
        message: str,
        *,
        residual: float | None = None,
        n_iter: int | None = None,
    ) -> None:
        super().__init__(message)
        self.residual = residual
        self.n_iter = n_iter


[docs] class SolverUnavailableError(FemorphSolverError, RuntimeError): """A registered solver backend isn't available on this install. Typical case: a CHOLMOD / Pardiso / MUMPS optional dependency isn't pip-installed. Carries an ``install_hint`` attribute the caller can show the user to recover. Subclasses ``RuntimeError`` as well as ``FemorphSolverError`` so pre-PR-N code that does ``except RuntimeError`` keeps working. """ def __init__(self, message: str, *, install_hint: str = "") -> None: super().__init__(message) self.install_hint = install_hint
class ValidationError(FemorphSolverError): """Aggregated validation failure raised by ``Model.validate(strict=True)``. Carries the full ``findings`` list so callers can inspect each individual problem. When ``strict=False`` the validator returns findings instead of raising. """ def __init__(self, findings: list[str]) -> None: n = len(findings) super().__init__(f"Model has {n} validation issue(s):\n - " + "\n - ".join(findings)) self.findings = list(findings)