Skip to content
Open
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ Repository = "https://github.com/faccts/opi"
Issues = "https://github.com/faccts/opi/issues"
Changelog = "https://github.com/faccts/opi/blob/main/CHANGELOG.md"

[project.optional-dependencies]
molbar = [
"molbar==1.1.3",
]

[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"
Expand Down
196 changes: 196 additions & 0 deletions src/opi/input/structures/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from opi.input.structures.coordinates import Coordinates
from opi.utils.element import ATOMIC_MASSES_FROM_ELEMENT, Element
from opi.utils.molbar import MolBarMode, call_molbar, requires_molbar
from opi.utils.rotconst import (
PrincipalMoments,
RotationalConstants,
Expand Down Expand Up @@ -1110,6 +1111,123 @@ def from_lists(

return cls(atoms=atoms, charge=charge, multiplicity=multiplicity)

# ------------------------------------------------------------------ #
# MOLBAR #
# ------------------------------------------------------------------ #

@requires_molbar
def calculate_molbar(
self,
*,
mode: "MolBarMode | str" = MolBarMode.MB,
) -> str:
"""
Compute the MolBar barcode string for this `Structure`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add the link to the paper here? With the doi?


MolBar is a molecular identifier analogous to InChI, encoding the
topology and geometry of a molecule into a canonical string.
See the MolBar documentation for a full description of the barcode format.

Only real `Atom` entries are passed to MolBar; `EmbeddingPotential`,
`GhostAtom`, and `PointCharge` instances are silently skipped. Note
that `GhostAtom` is a subclass of `Atom`, so an exact type check is
used rather than `isinstance`.

The total charge is taken from `charge`.

MolBar is **not** a dependency of OPI and must be installed separately::

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you write molbar is not a dependency and below you write it is optional. We should clearly decide for one thing and go with it. Please make it an optional dependency and pin a version number we want to use.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it as an optional dependency please document it accordingly.


pip install molbar

Parameters
----------
mode : MolBarMode | str, default MolBarMode.MB
MolBar calculation mode. Accepts a `MolBarMode` member or a
plain string (case-insensitive). `"mb"` computes the full barcode;
`"topo"` computes only the topology part.

Returns
-------
str
The MolBar barcode string.

Raises
------
ImportError
If MolBar is not installed.
ValueError
If *mode* is not a valid `MolBarMode` value.
ValueError
If this structure contains no real atoms.

See Also
--------
calculate_molbar_data : Returns the barcode together with the full MolBar data dictionary.
"""
return cast(
str,
self._get_molbar_from_coordinates(self._validate_molbar_mode(mode), return_data=False),
)

@requires_molbar
def calculate_molbar_data(
self,
*,
mode: "MolBarMode | str" = MolBarMode.MB,
) -> "tuple[str, dict[str, Any]]":
"""
Compute the MolBar barcode string and full data dictionary for this
`Structure`.

Behaves identically to `calculate_molbar` regarding MolBar installation,
atom filtering, and mode selection — see `calculate_molbar` for details.

The data dictionary contains the following top-level keys:

`"molbar"`
The barcode string, identical to the `calculate_molbar` return value.
`"atoms"`
Per-atom information after MolBar's internal geometry normalisation,
including `"atomic_numbers"`, `"positions"` (Å), and
`"partial_charges"`.
`"bonds"`
Detected bond graph: `"bond_indices"` (pairs) and `"bond_orders"`.
`"fragments"`
List of disconnected fragments found in the structure.
`"topo"`
Topology-only barcode (the first component of the full barcode).

See the MolBar documentation for an authoritative and up-to-date description of every key.

Parameters
----------
mode : MolBarMode | str, default MolBarMode.MB
MolBar calculation mode. See `calculate_molbar` for details.

Returns
-------
tuple[str, dict[str, Any]]
A two-element tuple of the MolBar barcode string and the full
MolBar data dictionary.

Raises
------
ImportError
If MolBar is not installed.
ValueError
If *mode* is not a valid `MolBarMode` value.
ValueError
If this structure contains no real atoms.

See Also
--------
calculate_molbar : Returns only the barcode string.
"""
return cast(
"tuple[str, dict[str, Any]]",
self._get_molbar_from_coordinates(self._validate_molbar_mode(mode), return_data=True),
)

# ------------------------------------------------------------------ #
# RMSD #
# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -1424,6 +1542,84 @@ def _iter_xyz_structures(
if n_struc_limit and n_struc >= n_struc_limit:
break

def _get_molbar_from_coordinates(
self,
mode: MolBarMode,
return_data: bool,
) -> "str | tuple[str, dict[str, Any]]":
"""
Shared implementation for `calculate_molbar` and `calculate_molbar_data`.

Collects elements and coordinates from `real_atoms` via
`get_coordinates`, and delegates to `call_molbar`.

Parameters
----------
mode : MolBarMode
Already-validated calculation mode.
return_data : bool
If `True` returns `tuple[str, dict[str, Any]]`; if `False` returns `str`.

Returns
-------
str | tuple[str, dict[str, Any]]
Either the barcode string or the barcode string together with the
full MolBar data dictionary, depending on *return_data*.

Raises
------
ValueError
If this structure contains no real atoms.
"""
if not self.real_atoms:
raise ValueError(
f"{self.__class__.__name__}: structure contains no real atoms; "
"cannot build MolBar input."
)

# Only real Atom instances are passed to MolBar; EmbeddingPotential,
# GhostAtom, and PointCharge entries are excluded via real_atoms.
# Note that GhostAtom is a subclass of Atom, so type(a) is Atom is
# used rather than isinstance inside the real_atoms property.
real_indices = [i for i, a in enumerate(self.atoms) if type(a) is Atom]
elements: list[str] = [atom.element.value for atom in self.real_atoms]
coordinates: list[list[float]] = self.get_coordinates(only_atoms=real_indices).tolist()

return call_molbar(
elements=elements,
coordinates=coordinates,
total_charge=self.charge,
mode=mode,
return_data=return_data,
)

def _validate_molbar_mode(self, mode: "MolBarMode | str") -> MolBarMode:
"""
Validate and normalise a MolBar calculation mode.

Parameters
----------
mode : MolBarMode | str
Calculation mode to validate.

Returns
-------
MolBarMode
The validated and normalised mode.

Raises
------
ValueError
If *mode* is not a valid `MolBarMode` value.
"""
# Normalise and validate mode (case-insensitive via StringEnum._missing_)
try:
return MolBarMode(mode)
except ValueError:
raise ValueError(
f"Invalid mode {mode!r}. Must be one of {[m.value for m in MolBarMode]}"
)

def _filtered_atoms(
self,
only_atoms: Sequence[int],
Expand Down
126 changes: 126 additions & 0 deletions src/opi/utils/molbar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""
Optional interface to MolBar (https://git.rwth-aachen.de/bannwarthlab/molbar).

MolBar is not a dependency of OPI. Install it separately::

pip install molbar
"""

from __future__ import annotations

from functools import wraps
from typing import Any, Callable, TypeVar, cast

from opi.models.string_enum import StringEnum

__all__ = (
"MolBarMode",
"requires_molbar",
)

# ============================================================
# MolBar optional import
# ============================================================

# MolBar is an optional dependency. The decorator below guards every method
# that calls it and raises a clear ImportError if it is not installed.
try:
from molbar.barcode import get_molbar_from_coordinates # type: ignore

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes molbar get imported whenever opi.utils.molbar is imported and since structure.py imports it, that means on any from opi.core import Calculator etc. it will be imported. Molbar is a large package with heavy dependencies, so for users who have it installed this slows down every OPI import even if they never compute a barcode. Suggestion: move the import into call_molbar() so it only happens on first use. Note that requires_molbar currently relies on this module-level import (it checks get_molbar_from_coordinates() is None), so it would need to switch to something like importlib.util.find_spec("molbar").

except ImportError:
# Ensure the name exists in the global namespace so the decorator can
# check it unconditionally without a NameError.
get_molbar_from_coordinates = None # type: ignore[assignment,unused-ignore]

# ============================================================
# Mode classification
# ============================================================


class MolBarMode(StringEnum):
"""Valid calculation modes for `Structure.to_molbar`.

Matching is case-insensitive: pass `"MB"`, `"mb"`, or `"Mb"`
and all will be accepted.

Attributes
----------
MB : str
Full MolBar barcode (`"mb"`).
TOPO : str
Topology-only barcode (`"topo"`).
"""

MB = "mb"
TOPO = "topo"


# ============================================================
# Decorator
# ============================================================

_T = TypeVar("_T")


def requires_molbar(func: Callable[..., _T]) -> Callable[..., _T]:
"""
Decorator that raises `ImportError` if MolBar is not installed.

Apply to any method that calls `molbar.barcode.get_molbar_from_coordinates`
to ensure a clear error message is raised at call time rather than at
import time.
"""

@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> _T:
if get_molbar_from_coordinates is None:
raise ImportError("MolBar is not installed. Install it with: pip install molbar")
return func(*args, **kwargs)

return wrapper


# ============================================================
# Free helper function
# ============================================================


def call_molbar(
elements: list[str],
coordinates: list[list[float]],
total_charge: int,
mode: MolBarMode,
return_data: bool,
) -> "str | tuple[str, dict[str, Any]]":
"""
Thin wrapper around `molbar.barcode.get_molbar_from_coordinates`.

Parameters
----------
elements : list[str]
Element symbols for all real atoms.
coordinates : list[list[float]]
Cartesian coordinates in Ångström, shape (N, 3).
total_charge : int
Total charge of the structure.
mode : MolBarMode
Already-validated calculation mode.
return_data : bool
If `True` returns `tuple[str, dict[str, Any]]`; if `False` returns `str`.

Returns
-------
str | tuple[str, dict[str, Any]]
Either the barcode string or the barcode string together with the
full MolBar data dictionary, depending on *return_data*.
"""

return cast(
"str | tuple[str, dict[str, Any]]",
get_molbar_from_coordinates( # type: ignore[operator,unused-ignore]
coordinates,
elements,
total_charge=total_charge,
return_data=return_data,
mode=mode,
),
)
Loading
Loading