From 82ea6e75ed55c7aec750e6ac699803adc661f19c Mon Sep 17 00:00:00 2001 From: sukhmansaran <109511319+sukhmansaran@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:55:07 +0530 Subject: [PATCH 1/2] Add PandasMol2.parse_bonds() to parse the Mol2 BOND section Parses the BOND section into a DataFrame (bond_id, origin_atom_id, target_atom_id, bond_type, status_bits), also stored in PandasMol2.df_bonds. Bonds are parsed on request only, so read_mol2 is unchanged. Fixes #54. Co-Authored-By: Claude Opus 5.5 --- biopandas/mol2/pandas_mol2.py | 92 +++++++++ docs/CHANGELOG.md | 2 + ...g_with_MOL2_Structures_in_DataFrames.ipynb | 186 ++++++++++++++++++ ...king_with_MOL2_Structures_in_DataFrames.md | 146 ++++++++++++++ tests/mol2/test_pandas_mol2.py | 128 ++++++++++++ 5 files changed, 554 insertions(+) diff --git a/biopandas/mol2/pandas_mol2.py b/biopandas/mol2/pandas_mol2.py index 366de21..ad506df 100644 --- a/biopandas/mol2/pandas_mol2.py +++ b/biopandas/mol2/pandas_mol2.py @@ -25,6 +25,16 @@ COLUMN_TYPES = (int, str, float, float, float, str, int, str, float) +BOND_COLUMN_NAMES = ( + "bond_id", + "origin_atom_id", + "target_atom_id", + "bond_type", + "status_bits", +) + +BOND_COLUMN_TYPES = ("int64", "int64", "int64", str, str) + class PandasMol2(object): """ @@ -35,6 +45,10 @@ class PandasMol2(object): df : pandas.DataFrame DataFrame of a Mol2's ATOM section + df_bonds : pandas.DataFrame or None + DataFrame of a Mol2's BOND section. `None` until + `parse_bonds()` has been called. + mol2_text : str Mol2 file contents in string format @@ -48,6 +62,7 @@ class PandasMol2(object): def __init__(self): self._df = None + self._df_bonds = None self.mol2_text = "" self.header = "" self.code = "" @@ -68,6 +83,14 @@ def df(self, value): ) # self._df = value + @property + def df_bonds(self): + """Accesses the pandas DataFrame of the BOND section. + + `None` until `parse_bonds()` has been called. + """ + return self._df_bonds + def _load_mol2(self, mol2_lines, mol2_code, columns): """Load mol2 contents into assert_raise_message instance""" if columns is None: @@ -88,6 +111,38 @@ def _load_mol2(self, mol2_lines, mol2_code, columns): self.code = mol2_code.decode() self._df = self._construct_df(mol2_lines, col_names, col_types) + # Bonds from a previously loaded molecule no longer apply. + self._df_bonds = None + + def parse_bonds(self): + """Parses the BOND section of the loaded Mol2 into a DataFrame + + Bonds are not parsed by `read_mol2` or `read_mol2_from_list`, + since many analyses don't need them. Call this method after + reading a molecule to parse them. The result is also stored + in `PandasMol2.df_bonds`. + + Returns + --------- + pandas.DataFrame + DataFrame with one row per bond and the columns + 'bond_id', 'origin_atom_id', 'target_atom_id' (int64), + 'bond_type' and 'status_bits' (str). 'bond_type' is kept as + a string since Tripos bond types include non-numeric codes + ('am', 'ar', 'du', 'un', 'nc'). 'status_bits' is the empty + string for bonds that don't specify it. A molecule without + a BOND section gives an empty DataFrame with these columns. + + """ + if self._df is None: + raise AttributeError( + "No Mol2 structure loaded. Please read a Mol2 file " + "via `read_mol2` or `read_mol2_from_list` first." + ) + self._df_bonds = self._bondsection_to_pandas( + self._get_bondsection(self.mol2_text.splitlines()) + ) + return self._df_bonds def read_mol2(self, path, columns=None): """Reads Mol2 files (unzipped or gzipped) from local drive @@ -207,6 +262,43 @@ def _atomsection_to_pandas(mol2_atom_lst, col_names, col_types): return df + @staticmethod + def _get_bondsection(mol2_lst): + """Returns the lines of the BOND section from a mol2 provided as a + list of strings, without blank and comment lines. Returns an empty + list if there is no BOND section.""" + bond_lines = [] + started = False + for s in mol2_lst: + if s.startswith("@"): + if started: + break + started = s.startswith("@BOND") + elif started: + s = s.strip() + if s and not s.startswith("#"): + bond_lines.append(s) + return bond_lines + + @staticmethod + def _bondsection_to_pandas(mol2_bond_lst): + rows = [] + for line in mol2_bond_lst: + fields = line.split() + if len(fields) < 4: + raise ValueError( + "Could not parse BOND record %r. Expected at least 4 " + "fields: bond_id origin_atom_id target_atom_id " + "bond_type [status_bits]" % line + ) + rows.append(fields[:4] + [" ".join(fields[4:])]) + + df = pd.DataFrame(rows, columns=list(BOND_COLUMN_NAMES)) + for name, dtype in zip(BOND_COLUMN_NAMES, BOND_COLUMN_TYPES): + df[name] = df[name].astype(dtype) + + return df + @staticmethod def rmsd(df1, df2, heavy_only=True): """Compute the Root Mean Square Deviation between molecules diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6b65ad6..20dec3a 100755 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,8 @@ The CHANGELOG for the current development version is available at ### Unreleased +- Add `PandasMol2.parse_bonds()` to parse the Mol2 BOND section into a DataFrame (`bond_id`, `origin_atom_id`, `target_atom_id`, `bond_type`, `status_bits`), also stored in the new `PandasMol2.df_bonds` attribute. Bonds are parsed on request only, so `read_mol2` is unchanged. Addresses issue [#54](https://github.com/BioPandas/biopandas/issues/54). + - Add `PandasPdb.to_fasta()` to export observed ATOM protein sequences by chain from an explicitly selected model, with configurable wrapping and optional file output. - Fix: parse the PDB v3.3 atomic charge notation (columns 79-80, e.g. `2+`, `1-`) as integer charges instead of floats, and write it back in spec-compliant notation. Also restores consistent integer dtypes between the PDB and MMTF readers on Windows. Addresses issue [#151](https://github.com/BioPandas/biopandas/issues/151). PR [#160](https://github.com/BioPandas/biopandas/pull/160) diff --git a/docs/tutorials/Working_with_MOL2_Structures_in_DataFrames.ipynb b/docs/tutorials/Working_with_MOL2_Structures_in_DataFrames.ipynb index 1995544..e49b73c 100644 --- a/docs/tutorials/Working_with_MOL2_Structures_in_DataFrames.ipynb +++ b/docs/tutorials/Working_with_MOL2_Structures_in_DataFrames.ipynb @@ -894,6 +894,192 @@ " Cu copper\n" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Parsing the BOND Section\n", + "\n", + "By default, `read_mol2` only parses the ATOM section, since many analyses don't need the bonds. To also get the bonds between the atoms, call `parse_bonds()` after reading a molecule. It returns a DataFrame of the BOND section, which is also stored in the `df_bonds` attribute:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
bond_idorigin_atom_idtarget_atom_idbond_typestatus_bits
01122
12131
232111
343102
453121
\n", + "
" + ], + "text/plain": [ + " bond_id origin_atom_id target_atom_id bond_type status_bits\n", + "0 1 1 2 2 \n", + "1 2 1 3 1 \n", + "2 3 2 11 1 \n", + "3 4 3 10 2 \n", + "4 5 3 12 1 " + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pmol = PandasMol2().read_mol2('./data/1b5e_1.mol2')\n", + "pmol.parse_bonds()\n", + "pmol.df_bonds.head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `origin_atom_id` and `target_atom_id` columns refer to the `atom_id` column of `pmol.df`. The `bond_type` column holds the Tripos bond type as a string, since besides `'1'`, `'2'` and `'3'` (single, double and triple bonds) it can be `'am'` (amide), `'ar'` (aromatic), `'du'` (dummy), `'un'` (unknown) or `'nc'` (not connected). `status_bits` is the empty string for bonds that don't specify it.\n", + "\n", + "Since the bonds are a regular DataFrame, they can be combined with the atoms using pandas, for example to list the atom names of the amide bonds:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
bond_idorigin_atom_idtarget_atom_idbond_typestatus_bitsorigin_atom_nametarget_atom_name
67411amC4N2
\n", + "
" + ], + "text/plain": [ + " bond_id origin_atom_id ... origin_atom_name target_atom_name\n", + "6 7 4 ... C4 N2\n", + "\n", + "[1 rows x 7 columns]" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "atom_names = pmol.df.set_index('atom_id')['atom_name']\n", + "amide_bonds = pmol.df_bonds[pmol.df_bonds['bond_type'] == 'am'].copy()\n", + "amide_bonds['origin_atom_name'] = amide_bonds['origin_atom_id'].map(atom_names)\n", + "amide_bonds['target_atom_name'] = amide_bonds['target_atom_id'].map(atom_names)\n", + "amide_bonds" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/tutorials/Working_with_MOL2_Structures_in_DataFrames.md b/docs/tutorials/Working_with_MOL2_Structures_in_DataFrames.md index bb5e399..f71f26c 100644 --- a/docs/tutorials/Working_with_MOL2_Structures_in_DataFrames.md +++ b/docs/tutorials/Working_with_MOL2_Structures_in_DataFrames.md @@ -657,6 +657,152 @@ A list of all the allowed atom types that can be found in Tripos MOL2 files is p Cu copper +## Parsing the BOND Section + +By default, `read_mol2` only parses the ATOM section, since many analyses don't need the bonds. To also get the bonds between the atoms, call `parse_bonds()` after reading a molecule. It returns a DataFrame of the BOND section, which is also stored in the `df_bonds` attribute: + + +```python +pmol = PandasMol2().read_mol2('./data/1b5e_1.mol2') +pmol.parse_bonds() +pmol.df_bonds.head() +``` + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
bond_idorigin_atom_idtarget_atom_idbond_typestatus_bits
01122
12131
232111
343102
453121
+
+ + + +The `origin_atom_id` and `target_atom_id` columns refer to the `atom_id` column of `pmol.df`. The `bond_type` column holds the Tripos bond type as a string, since besides `'1'`, `'2'` and `'3'` (single, double and triple bonds) it can be `'am'` (amide), `'ar'` (aromatic), `'du'` (dummy), `'un'` (unknown) or `'nc'` (not connected). `status_bits` is the empty string for bonds that don't specify it. + +Since the bonds are a regular DataFrame, they can be combined with the atoms using pandas, for example to list the atom names of the amide bonds: + + +```python +atom_names = pmol.df.set_index('atom_id')['atom_name'] +amide_bonds = pmol.df_bonds[pmol.df_bonds['bond_type'] == 'am'].copy() +amide_bonds['origin_atom_name'] = amide_bonds['origin_atom_id'].map(atom_names) +amide_bonds['target_atom_name'] = amide_bonds['target_atom_id'].map(atom_names) +amide_bonds +``` + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
bond_idorigin_atom_idtarget_atom_idbond_typestatus_bitsorigin_atom_nametarget_atom_name
67411amC4N2
+
+ + + ## Plotting Since we are using pandas under the hood, which in turns uses matplotlib under the hood, we can produce quick summary plots of our MOL2 structures conveniently. Below are a few examples of how to visualize molecular properties. diff --git a/tests/mol2/test_pandas_mol2.py b/tests/mol2/test_pandas_mol2.py index ce6a322..d7e67dc 100644 --- a/tests/mol2/test_pandas_mol2.py +++ b/tests/mol2/test_pandas_mol2.py @@ -152,3 +152,131 @@ def run_code(): PandasMol2()._get_atomsection(["", ""]) assert_raises(ValueError, expect, run_code) + + +def test_parse_bonds(): + data_path = str(TEST_DATA.joinpath("1b5e_1.mol2")) + pdmol = PandasMol2().read_mol2(data_path) + + # Bonds are only parsed on request. + assert pdmol.df_bonds is None + + bonds = pdmol.parse_bonds() + assert bonds is pdmol.df_bonds + assert bonds.shape == (33, 5) + assert list(bonds.columns) == [ + "bond_id", + "origin_atom_id", + "target_atom_id", + "bond_type", + "status_bits", + ] + for col in ("bond_id", "origin_atom_id", "target_atom_id"): + assert bonds[col].dtype == "int64" + assert bonds.iloc[0].tolist() == [1, 1, 2, "2", ""] + # Non-numeric Tripos bond types are kept as strings. + assert bonds["bond_type"].value_counts().to_dict() == { + "1": 28, + "2": 4, + "am": 1, + } + # Every bond connects atoms of the ATOM section. + assert set(bonds["origin_atom_id"]) <= set(pdmol.df["atom_id"]) + assert set(bonds["target_atom_id"]) <= set(pdmol.df["atom_id"]) + + +def test_parse_bonds_matches_header_counts(): + """Bond counts match the MOLECULE header for every test molecule, + including gzipped files and files with CRLF line endings.""" + for fname in ( + "40_mol2_files.mol2", + "40_mol2_files.mol2.gz", + "1b5e_2.mol2", + "empty_line.mol2", + ): + data_path = str(TEST_DATA.joinpath(fname)) + for code, lines in split_multimol2(data_path): + pdmol = PandasMol2().read_mol2_from_list(lines, code) + header = [ + s for s in pdmol.mol2_text.splitlines() if s.strip() + ] + start = header.index( + next(s for s in header if s.startswith("@MOLECULE")) + ) + num_bonds = int(header[start + 2].split()[1]) + assert len(pdmol.parse_bonds()) == num_bonds, (fname, code) + + +def test_parse_bonds_status_bits_and_end_of_file(): + """Status bits are parsed, blank and comment lines are skipped, and a + BOND section at the end of the file is read completely.""" + mol2_lines = [ + "@MOLECULE\n", + "ethene\n", + "2 1 0 0 0\n", + "SMALL\n", + "NO_CHARGES\n", + "\n", + "@ATOM\n", + "1 C1 0.0000 0.0000 0.0000 C.2 1 ETH 0.0000\n", + "2 C2 1.3300 0.0000 0.0000 C.2 1 ETH 0.0000\n", + "\n", + "@BOND\n", + "# a comment\n", + "\n", + " 1 1 2 2 BACKBONE|DICT", + ] + pdmol = PandasMol2().read_mol2_from_list(mol2_lines, "ethene") + bonds = pdmol.parse_bonds() + assert bonds.shape == (1, 5) + assert bonds.iloc[0].tolist() == [1, 1, 2, "2", "BACKBONE|DICT"] + + +def test_parse_bonds_without_bond_section(): + mol2_lines = [ + "@MOLECULE\n", + "sodium\n", + "1 0 0 0 0\n", + "SMALL\n", + "USER_CHARGES\n", + "\n", + "@ATOM\n", + "1 Na 0.0000 0.0000 0.0000 Na 1 NA 1.0000\n", + "\n", + ] + pdmol = PandasMol2().read_mol2_from_list(mol2_lines, "sodium") + bonds = pdmol.parse_bonds() + assert bonds.shape == (0, 5) + assert bonds["bond_id"].dtype == "int64" + + +def test_parse_bonds_resets_on_new_read(): + pdmol = PandasMol2().read_mol2(str(TEST_DATA.joinpath("1b5e_1.mol2"))) + pdmol.parse_bonds() + pdmol.read_mol2(str(TEST_DATA.joinpath("40_mol2_files.mol2"))) + assert pdmol.df_bonds is None + + +def test_parse_bonds_raises(): + expect = ( + "No Mol2 structure loaded. Please read a Mol2 file " + "via `read_mol2` or `read_mol2_from_list` first." + ) + assert_raises(AttributeError, expect, PandasMol2().parse_bonds) + + mol2_lines = [ + "@MOLECULE\n", + "broken\n", + "@ATOM\n", + "1 C1 0.0000 0.0000 0.0000 C.3 1 UNK 0.0000\n", + "\n", + "@BOND\n", + "1 1 2\n", + ] + pdmol = PandasMol2().read_mol2_from_list(mol2_lines, "broken") + expect = ( + "Could not parse BOND record '1 1 2'. Expected at least 4 " + "fields: bond_id origin_atom_id target_atom_id " + "bond_type [status_bits]" + ) + assert_raises(ValueError, expect, pdmol.parse_bonds) From dd3cd3f59cec0bc237ed05a0342541988657245d Mon Sep 17 00:00:00 2001 From: sukhmansaran <109511319+sukhmansaran@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:58:26 +0530 Subject: [PATCH 2/2] Give clear errors for non-integer BOND ids and df_bonds assignment Co-Authored-By: Claude Opus 5.5 --- biopandas/mol2/pandas_mol2.py | 25 +++++++++++++----- tests/mol2/test_pandas_mol2.py | 46 +++++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/biopandas/mol2/pandas_mol2.py b/biopandas/mol2/pandas_mol2.py index ad506df..d3db27a 100644 --- a/biopandas/mol2/pandas_mol2.py +++ b/biopandas/mol2/pandas_mol2.py @@ -91,6 +91,15 @@ def df_bonds(self): """ return self._df_bonds + @df_bonds.setter + def df_bonds(self, value): + """Assign a new value to the BOND section DataFrame""" + raise AttributeError( + "Please use `PandasMol2._df_bonds = ... ` instead\n" + "of `PandasMol2.df_bonds = ... ` if you are sure that\n" + "you want to overwrite the `df_bonds` attribute." + ) + def _load_mol2(self, mol2_lines, mol2_code, columns): """Load mol2 contents into assert_raise_message instance""" if columns is None: @@ -285,13 +294,17 @@ def _bondsection_to_pandas(mol2_bond_lst): rows = [] for line in mol2_bond_lst: fields = line.split() - if len(fields) < 4: + try: + if len(fields) < 4: + raise ValueError + ids = [int(f) for f in fields[:3]] + except ValueError: raise ValueError( - "Could not parse BOND record %r. Expected at least 4 " - "fields: bond_id origin_atom_id target_atom_id " - "bond_type [status_bits]" % line - ) - rows.append(fields[:4] + [" ".join(fields[4:])]) + "Could not parse BOND record %r. Expected: bond_id " + "origin_atom_id target_atom_id (integers), bond_type " + "and optional status_bits" % line + ) from None + rows.append(ids + [fields[3], " ".join(fields[4:])]) df = pd.DataFrame(rows, columns=list(BOND_COLUMN_NAMES)) for name, dtype in zip(BOND_COLUMN_NAMES, BOND_COLUMN_TYPES): diff --git a/tests/mol2/test_pandas_mol2.py b/tests/mol2/test_pandas_mol2.py index d7e67dc..01fc400 100644 --- a/tests/mol2/test_pandas_mol2.py +++ b/tests/mol2/test_pandas_mol2.py @@ -264,19 +264,37 @@ def test_parse_bonds_raises(): ) assert_raises(AttributeError, expect, PandasMol2().parse_bonds) - mol2_lines = [ - "@MOLECULE\n", - "broken\n", - "@ATOM\n", - "1 C1 0.0000 0.0000 0.0000 C.3 1 UNK 0.0000\n", - "\n", - "@BOND\n", - "1 1 2\n", - ] - pdmol = PandasMol2().read_mol2_from_list(mol2_lines, "broken") + # Too few fields, and a non-integer atom id. + for record in ("1 1 2", "1 1 X 1"): + mol2_lines = [ + "@MOLECULE\n", + "broken\n", + "@ATOM\n", + "1 C1 0.0000 0.0000 0.0000 C.3 1 UNK 0.0000\n", + "\n", + "@BOND\n", + record + "\n", + ] + pdmol = PandasMol2().read_mol2_from_list(mol2_lines, "broken") + expect = ( + "Could not parse BOND record %r. Expected: bond_id " + "origin_atom_id target_atom_id (integers), bond_type " + "and optional status_bits" % record + ) + assert_raises(ValueError, expect, pdmol.parse_bonds) + + +def test_overwrite_df_bonds(): + pdmol = PandasMol2().read_mol2(str(TEST_DATA.joinpath("1b5e_1.mol2"))) + pdmol.parse_bonds() + + def overwrite(): + pdmol.df_bonds = pdmol.df_bonds[pdmol.df_bonds["bond_type"] == "1"] + expect = ( - "Could not parse BOND record '1 1 2'. Expected at least 4 " - "fields: bond_id origin_atom_id target_atom_id " - "bond_type [status_bits]" + "Please use `PandasMol2._df_bonds = ... `" + " instead\nof `PandasMol2.df_bonds = ... `" + " if you are sure that\nyou want" + " to overwrite the `df_bonds` attribute." ) - assert_raises(ValueError, expect, pdmol.parse_bonds) + assert_raises(AttributeError, expect, overwrite)