From ab923ec302d70a06234ec94a0ba75b58677d0563 Mon Sep 17 00:00:00 2001 From: Patrick Kunzmann Date: Sat, 12 Sep 2026 09:56:01 +0200 Subject: [PATCH 1/4] Use NumPy 2.0 API --- pyproject.toml | 4 +- src/biotite/interface/pymol/shapes.py | 2 +- src/biotite/sequence/io/fasta/convert.py | 2 +- src/biotite/structure/alphabet/encoder.py | 4 +- src/biotite/structure/basepairs.py | 2 +- src/biotite/structure/box.py | 6 +- src/biotite/structure/filter.py | 2 +- src/biotite/structure/graphics/rna.py | 8 +- src/biotite/structure/info/groups.py | 6 +- src/biotite/structure/info/masses.py | 75 ++++++++++++-- src/biotite/structure/io/pdb/file.py | 6 +- src/biotite/structure/io/pdbqt/file.py | 6 +- src/biotite/structure/io/pdbx/cif.py | 118 +++++++++++++--------- src/biotite/structure/io/pdbx/compress.py | 5 +- src/biotite/structure/io/pdbx/convert.py | 6 +- src/biotite/structure/io/pdbx/encoding.py | 4 +- src/biotite/structure/mechanics.py | 6 +- src/biotite/structure/repair.py | 7 +- src/biotite/structure/sasa.py | 3 +- src/biotite/structure/superimpose.py | 2 +- src/biotite/structure/transform.py | 10 +- src/biotite/structure/util.py | 2 +- tests/structure/io/test_pdbx.py | 58 ++++++++--- 23 files changed, 227 insertions(+), 117 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e64fba87..96078cc3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,7 @@ classifiers = [ ] dependencies = [ - # Wheels compiled with NumPy 2.0 are backward compatible with NumPy 1.x - # https://numpy.org/devdocs/dev/depending_on_numpy.html#numpy-2-0-specific-advice - "numpy >= 1.25", + "numpy >= 2.1", "biotraj >= 1.0, < 2.0", "requests >= 2.12", "msgpack >= 0.5.6", diff --git a/src/biotite/interface/pymol/shapes.py b/src/biotite/interface/pymol/shapes.py index 48cb03441..fe2372178 100644 --- a/src/biotite/interface/pymol/shapes.py +++ b/src/biotite/interface/pymol/shapes.py @@ -85,7 +85,7 @@ def draw_arrows( color = _arrayfy(color, expected_length, 2) head_color = _arrayfy(head_color, expected_length, 2) - normal = (end - start) / np.linalg.norm(end - start, axis=-1)[:, np.newaxis] + normal = (end - start) / np.linalg.vector_norm(end - start, axis=-1)[:, np.newaxis] middle = end - normal * head_length[:, np.newaxis] cgo_list = [] diff --git a/src/biotite/sequence/io/fasta/convert.py b/src/biotite/sequence/io/fasta/convert.py index af60ea3e8..cbe07b8e6 100644 --- a/src/biotite/sequence/io/fasta/convert.py +++ b/src/biotite/sequence/io/fasta/convert.py @@ -382,7 +382,7 @@ def set_a3m_alignments( if not isinstance(alphabet, LetterAlphabet): raise ValueError("A3M requires a letter sequence") # Keep gaps in the query sequence as lower case letters - a3m_string_array[query_gaps] = np.char.lower( + a3m_string_array[query_gaps] = np.strings.lower( query.alphabet.decode_multiple(target_code[query_gaps], as_bytes=True) # pyright: ignore[reportCallIssue,reportArgumentType] ) # Matches/mismatches are indicated with upper case letters diff --git a/src/biotite/structure/alphabet/encoder.py b/src/biotite/structure/alphabet/encoder.py index 66fef6591..a6176b580 100644 --- a/src/biotite/structure/alphabet/encoder.py +++ b/src/biotite/structure/alphabet/encoder.py @@ -282,7 +282,7 @@ def _calc_conformation_descriptors( desc[i, 4] = numpy.sum(u1 * u4, axis=-1) desc[i, 5] = numpy.sum(u2 * u3, axis=-1) desc[i, 6] = numpy.sum(u1 * u3, axis=-1) - desc[i, 7] = numpy.linalg.norm(ca[i] - ca[j], axis=-1) + desc[i, 7] = numpy.linalg.vector_norm(ca[i] - ca[j], axis=-1) desc[i, 8] = numpy.clip(j - i, -4, 4) desc[i, 9] = numpy.copysign(numpy.log(numpy.abs(j - i) + 1), j - i) return desc @@ -380,5 +380,5 @@ def encode( def _normalize(x: numpy.ndarray, *, inplace: bool = False) -> numpy.ndarray: - norm = numpy.linalg.norm(x, axis=-1).reshape(*x.shape[:-1], 1) + norm = numpy.linalg.vector_norm(x, axis=-1).reshape(*x.shape[:-1], 1) return numpy.divide(x, norm, out=x if inplace else None, where=norm != 0) diff --git a/src/biotite/structure/basepairs.py b/src/biotite/structure/basepairs.py index 166edadfb..2acaae46d 100644 --- a/src/biotite/structure/basepairs.py +++ b/src/biotite/structure/basepairs.py @@ -948,7 +948,7 @@ def base_pairs( to_remove = [] # Get all bases that have non-unique pairing interactions - base_indices, occurrences = np.unique(basepairs, return_counts=True) + base_indices, occurrences = np.unique_counts(basepairs) for base_index, occurrence in zip(base_indices, occurrences): if occurrence > 1: # Write the non-unique base pairs to a dictionary as diff --git a/src/biotite/structure/box.py b/src/biotite/structure/box.py index 1815a068e..3f675d036 100644 --- a/src/biotite/structure/box.py +++ b/src/biotite/structure/box.py @@ -230,9 +230,9 @@ def unitcell_from_vectors( a = box[0] b = box[1] c = box[2] - len_a = linalg.norm(a) - len_b = linalg.norm(b) - len_c = linalg.norm(c) + len_a = linalg.vector_norm(a) + len_b = linalg.vector_norm(b) + len_c = linalg.vector_norm(c) alpha = np.arccos(np.dot(b, c) / (len_b * len_c)) beta = np.arccos(np.dot(a, c) / (len_a * len_c)) gamma = np.arccos(np.dot(a, b) / (len_a * len_b)) diff --git a/src/biotite/structure/filter.py b/src/biotite/structure/filter.py index 589f4004d..4e2feefee 100644 --- a/src/biotite/structure/filter.py +++ b/src/biotite/structure/filter.py @@ -376,7 +376,7 @@ def filter_linear_bond_continuity( Note that this function purely uses distances between consecutive atoms. A potential ``BondList`` is not considered here. """ - dist = np.linalg.norm(np.diff(array.coord, axis=0), axis=1) + dist = np.linalg.vector_norm(np.diff(array.coord, axis=0), axis=1) mask = (dist >= min_len) & (dist <= max_len) return np.append(mask, True) # pyright: ignore[reportReturnType] diff --git a/src/biotite/structure/graphics/rna.py b/src/biotite/structure/graphics/rna.py index 300a27b4d..9ccbee6bc 100644 --- a/src/biotite/structure/graphics/rna.py +++ b/src/biotite/structure/graphics/rna.py @@ -279,14 +279,16 @@ def plot_nucleotide_secondary_structure( coordinates[i - 1][1] - coordinates[i][1], ] ) - vector_to_previous = vector_to_previous / np.linalg.norm(vector_to_previous) + vector_to_previous = vector_to_previous / np.linalg.vector_norm( + vector_to_previous + ) vector_to_next = np.array( [ coordinates[i][0] - coordinates[i + 1][0], coordinates[i][1] - coordinates[i + 1][1], ] ) - vector_to_next = vector_to_next / np.linalg.norm(vector_to_next) + vector_to_next = vector_to_next / np.linalg.vector_norm(vector_to_next) vector = (vector_to_next + vector_to_previous) / 2 elif i > 0: # For the last base get the direction vector to the previous @@ -307,7 +309,7 @@ def plot_nucleotide_secondary_structure( ] ) # Normalize the vector - vector = vector / np.linalg.norm(vector) + vector = vector / np.linalg.vector_norm(vector) # Get the perpendicular vector vector = np.array([vector[1], -vector[0]]) # The annotations are offset in the direction of the diff --git a/src/biotite/structure/info/groups.py b/src/biotite/structure/info/groups.py index 1e0c0f158..9077c6c42 100644 --- a/src/biotite/structure/info/groups.py +++ b/src/biotite/structure/info/groups.py @@ -129,7 +129,7 @@ def ion_names() -> list[str]: formulas = chem_comp["formula"].as_array() # Simple filter: The formula must contain only one element - mask = np.char.isalpha(formulas) + mask = np.strings.isalpha(formulas) comp_ids = comp_ids[mask] formulas = formulas[mask] @@ -164,4 +164,6 @@ def _get_group_members(match_types: list[str]) -> list[str]: comp_ids = category["id"].as_array() types = category["type"].as_array() # Ignore case - return comp_ids[np.isin(np.char.lower(types), np.char.lower(match_types))].tolist() + return comp_ids[ + np.isin(np.strings.lower(types), np.strings.lower(match_types)) + ].tolist() diff --git a/src/biotite/structure/info/masses.py b/src/biotite/structure/info/masses.py index dc1a02f58..c9df93bdc 100644 --- a/src/biotite/structure/info/masses.py +++ b/src/biotite/structure/info/masses.py @@ -4,17 +4,19 @@ __name__ = "biotite.structure.info" __author__ = "Patrick Kunzmann" -__all__ = ["mass"] +__all__ = ["mass", "masses"] +import functools import json from pathlib import Path from typing import Any +import numpy as np from biotite.structure.atoms import Atom, AtomArray, AtomArrayStack from biotite.structure.info.ccd import get_from_ccd +from biotite.typing import N, NDArray1 # Masses are taken from http://www.sbcs.qmul.ac.uk/iupac/AtWt/ (2018/03/01) ATOM_MASSES_FILE = Path(__file__).parent / "atom_masses.json" -_atom_masses = None def mass( @@ -58,6 +60,10 @@ def mass( KeyError If the mass is unknown for some residue name. + See Also + -------- + masses : Get the mass of each element in an array. + References ---------- @@ -96,24 +102,20 @@ def mass( >>> print(mass("N")) 14.007 """ - global _atom_masses - with open(ATOM_MASSES_FILE, "r") as file: - _atom_masses = json.load(file) - if isinstance(item, str): if is_residue is None: - result_mass = _atom_masses.get(item.upper()) + result_mass = _atom_masses().get(item.upper()) if result_mass is None: result_mass = _mass_for_residue(item) elif not is_residue: - result_mass = _atom_masses.get(item.upper()) + result_mass = _atom_masses().get(item.upper()) else: result_mass = _mass_for_residue(item) elif isinstance(item, Atom): result_mass = mass(item.element, is_residue=False) elif isinstance(item, AtomArray) or isinstance(item, AtomArrayStack): - result_mass = sum((mass(element, is_residue=False) for element in item.element)) + result_mass = float(np.sum(masses(item))) else: raise TypeError(f"Cannot calculate mass for {type(item).__name__} objects") @@ -123,6 +125,61 @@ def mass( return result_mass +def masses( + elements: NDArray1[N, np.str_] | AtomArray[N] | AtomArrayStack[Any, N], +) -> NDArray1[N, np.floating]: + """ + Get the mass of each chemical element in the given array. + :footcite:`Meija2016` + + Parameters + ---------- + elements : ndarray, shape=(n,), dtype=str or AtomArray, shape=(n,) or AtomArrayStack, shape=(m,n) + The chemical elements to get the masses for. + If an :class:`AtomArray` or :class:`AtomArrayStack` is given, + its ``element`` annotation is used. + + Returns + ------- + masses : ndarray, shape=(n,), dtype=float + The mass of each element in *u*. + + Raises + ------ + KeyError + If the mass is unknown for some element. + + See Also + -------- + mass : Get the total mass of a single element, residue or molecule. + + References + ---------- + + .. footbibliography:: + + Examples + -------- + + >>> print(masses(atom_array[:5])) + [14.007 12.011 12.011 15.999 12.011] + """ + if isinstance(elements, (AtomArray, AtomArrayStack)): + elements = elements.element + # Look up each unique element only once instead of each element in the array + unique_elements, inverse = np.unique_inverse(elements) + unique_masses = np.array( + [mass(element, is_residue=False) for element in unique_elements.tolist()] + ) + return unique_masses[inverse] + + +@functools.cache +def _atom_masses() -> dict[str, float]: + with open(ATOM_MASSES_FILE, "r") as file: + return json.load(file) + + def _mass_for_residue(res_name: str) -> float: # Imported here to avoid circular import from biotite.structure.io.pdbx.component import MaskValue diff --git a/src/biotite/structure/io/pdb/file.py b/src/biotite/structure/io/pdb/file.py index 3a37489ff..a5bd34892 100644 --- a/src/biotite/structure/io/pdb/file.py +++ b/src/biotite/structure/io/pdb/file.py @@ -789,11 +789,11 @@ def _check_pdb_compatibility( warnings.warn(f"Residue IDs exceed {max_residues:,}, will be wrapped") if np.isnan(array.coord).any(): raise BadStructureError("Coordinates contain 'NaN' values") - if any([len(name) > 1 for name in array.chain_id]): + if (np.strings.str_len(array.chain_id) > 1).any(): raise BadStructureError("Some chain IDs exceed 1 character") - if any([len(name) > 3 for name in array.res_name]): + if (np.strings.str_len(array.res_name) > 3).any(): raise BadStructureError("Some residue names exceed 3 characters") - if any([len(name) > 4 for name in array.atom_name]): + if (np.strings.str_len(array.atom_name) > 4).any(): raise BadStructureError("Some atom names exceed 4 characters") for i, coord_name in enumerate(["x", "y", "z"]): n_coord_digits = number_of_integer_digits(array.coord[..., i]) diff --git a/src/biotite/structure/io/pdbqt/file.py b/src/biotite/structure/io/pdbqt/file.py index 9c4db6245..e88256ac8 100644 --- a/src/biotite/structure/io/pdbqt/file.py +++ b/src/biotite/structure/io/pdbqt/file.py @@ -405,11 +405,11 @@ def set_structure( warnings.warn(f"Residue IDs exceed {max_residues:,}") if np.isnan(atoms.coord).any(): raise BadStructureError("Coordinates contain 'NaN' values") - if any([len(name) > 1 for name in atoms.chain_id.tolist()]): + if (np.strings.str_len(atoms.chain_id) > 1).any(): raise BadStructureError("Some chain IDs exceed 1 character") - if any([len(name) > 3 for name in atoms.res_name.tolist()]): + if (np.strings.str_len(atoms.res_name) > 3).any(): raise BadStructureError("Some residue names exceed 3 characters") - if any([len(name) > 4 for name in atoms.atom_name.tolist()]): + if (np.strings.str_len(atoms.atom_name) > 4).any(): raise BadStructureError("Some atom names exceed 4 characters") if charges is None: diff --git a/src/biotite/structure/io/pdbx/cif.py b/src/biotite/structure/io/pdbx/cif.py index 3d9f21224..48748e895 100644 --- a/src/biotite/structure/io/pdbx/cif.py +++ b/src/biotite/structure/io/pdbx/cif.py @@ -11,7 +11,7 @@ import itertools from collections.abc import Iterator, Mapping, MutableMapping, Sequence from os import PathLike -from typing import IO, Any, Self, TypeAlias +from typing import IO, Any, Self, TypeAlias, overload import numpy as np from biotite.file import ( DeserializationError, @@ -551,10 +551,11 @@ def _serialize_single(self) -> list[str]: max_len = max(len(key) for key in keys) # "+3" Because of three whitespace chars after longest key req_len = max_len + 3 + values = _escape(np.array([column.as_item() for column in self.values()])) return [ # Remove potential terminal newlines from multiline values - (key.ljust(req_len) + _escape(column.as_item())).strip() - for key, column in zip(keys, self.values()) + (key.ljust(req_len) + value).strip() + for key, value in zip(keys, values.tolist()) ] def _serialize_looped(self) -> list[str]: @@ -564,29 +565,23 @@ def _serialize_looped(self) -> list[str]: raise SerializationError("Category row count is required") key_lines = ["_" + self._name + "." + key + " " for key in self.keys()] - column_arrays = [] + value_lines = None for column in self.values(): - array = column.as_array(str) # Quote before measuring the number of chars, # as the quote characters modify the length - array = np.array([_escape(element) for element in array]) - column_arrays.append(array) - - # Number of characters the longest string in the column needs - # This can be deduced from the dtype - # The "+1" is for the small whitespace column - column_n_chars = [ - array.dtype.itemsize // UNICODE_CHAR_SIZE + 1 for array in column_arrays - ] - value_lines = [""] * self._row_count - for i in range(self._row_count): - for j, array in enumerate(column_arrays): - value_lines[i] += array[i].ljust(column_n_chars[j]) - # Remove trailing justification of last column - # and potential terminal newlines from multiline values - value_lines[i] = value_lines[i].strip() + array = _escape(column.as_array(str)) + # Number of characters the longest string in the column needs + # The "+1" is for the small whitespace column + n_chars = int(np.strings.str_len(array).max(initial=0)) + 1 + padded = np.strings.ljust(array, n_chars) + value_lines = padded if value_lines is None else value_lines + padded + if value_lines is None: + raise SerializationError("Category contains no columns") + # Remove trailing justification of last column + # and potential terminal newlines from multiline values + value_lines = np.strings.strip(value_lines) - return ["loop_"] + key_lines + value_lines + return ["loop_"] + key_lines + value_lines.tolist() class CIFBlock(_Component, MutableMapping[str, CIFCategory]): @@ -1069,36 +1064,61 @@ def _to_single(lines: list[str]) -> list[str]: return processed_lines -def _escape(value: str) -> str: +def _escape(array: NDArray1[Any, np.str_]) -> NDArray1[Any, np.str_]: """ - Escape special characters in a value to make it compatible with CIF. + Escape special characters in values to make them compatible with CIF. """ - if "\n" in value: - # A value with linebreaks must be represented as multiline value - return _multiline(value) - elif "'" in value and '"' in value: - # If both quote types are present, you cannot use them for escaping - return _multiline(value) - elif len(value) == 0: - return "''" - elif value[0] == "_": - return "'" + value + "'" - elif "'" in value: - return '"' + value + '"' - elif '"' in value: - return "'" + value + "'" - elif " " in value: - return "'" + value + "'" - elif "\t" in value: - return "'" + value + "'" - else: - return value - - -def _multiline(value: str) -> str: + has_linebreak = np.strings.find(array, "\n") != -1 + has_single_quote = np.strings.find(array, "'") != -1 + has_double_quote = np.strings.find(array, '"') != -1 + has_whitespace = (np.strings.find(array, " ") != -1) | ( + np.strings.find(array, "\t") != -1 + ) + is_empty = np.strings.str_len(array) == 0 + starts_with_underscore = np.strings.startswith(array, "_") + + # A value with linebreaks or with both quote types must be represented + # as multiline value, as neither quote type can be used for escaping + multiline_mask = has_linebreak | (has_single_quote & has_double_quote) + empty_mask = ~multiline_mask & is_empty + # Values with an embedded single quote are escaped with double quotes, + # all other values that require escaping use single quotes + double_quote_mask = ( + ~multiline_mask & ~is_empty & ~starts_with_underscore & has_single_quote + ) + single_quote_mask = ( + ~multiline_mask + & ~is_empty + & ( + starts_with_underscore + | (~has_single_quote & (has_double_quote | has_whitespace)) + ) + ) + + # Quoting adds two characters + escaped = array.astype(f"U{array.dtype.itemsize // UNICODE_CHAR_SIZE + 2}") + escaped[empty_mask] = "''" + escaped[single_quote_mask] = "'" + array[single_quote_mask] + "'" + escaped[double_quote_mask] = '"' + array[double_quote_mask] + '"' + if multiline_mask.any(): + multiline_values = _multiline(array[multiline_mask]) + # Multiline values need more characters than the quoted ones + n_chars = max(escaped.dtype.itemsize, multiline_values.dtype.itemsize) + escaped = escaped.astype(f"U{n_chars // UNICODE_CHAR_SIZE}") + escaped[multiline_mask] = multiline_values + return escaped + + +@overload +def _multiline(value: str) -> str: ... +@overload +def _multiline(value: NDArray1[Any, np.str_]) -> NDArray1[Any, np.str_]: ... +def _multiline( + value: str | NDArray1[Any, np.str_], +) -> str | NDArray1[Any, np.str_]: """ - Convert a string that may contain linebreaks into CIF-compatible - multiline string. + Convert a string (or an array of strings) that may contain linebreaks + into CIF-compatible multiline string. """ return "\n;" + value + "\n;\n" diff --git a/src/biotite/structure/io/pdbx/compress.py b/src/biotite/structure/io/pdbx/compress.py index 4c65336c3..6fdd680dc 100644 --- a/src/biotite/structure/io/pdbx/compress.py +++ b/src/biotite/structure/io/pdbx/compress.py @@ -167,7 +167,10 @@ def _compress_data( encoding = StringArrayEncoding(data_encoding=[], offset_encoding=[]) # Run encode to initialize the data and offset arrays indices = encoding.encode(array) - offsets = np.cumsum([0] + [len(s) for s in encoding.strings]) # pyright: ignore[reportOptionalIterable] + offsets = np.cumulative_sum( + np.strings.str_len(encoding.strings), # pyright: ignore[reportArgumentType] + include_initial=True, + ) encoding.data_encoding = _find_best_integer_compression(indices) # pyright: ignore[reportArgumentType] encoding.offset_encoding = _find_best_integer_compression(offsets) return bcif.BinaryCIFData(array, [encoding]) diff --git a/src/biotite/structure/io/pdbx/convert.py b/src/biotite/structure/io/pdbx/convert.py index d3dc2e02d..aa47d43c0 100644 --- a/src/biotite/structure/io/pdbx/convert.py +++ b/src/biotite/structure/io/pdbx/convert.py @@ -804,7 +804,7 @@ def _find_matches_by_dense_array( # Duplicate matches indicate that an atom from the query cannot # be uniquely matched to an atom in the reference - unique_query_matches, counts = np.unique(query_matches, return_counts=True) + unique_query_matches, counts = np.unique_counts(query_matches) if np.any(counts > 1): ambiguous_query = unique_query_matches[np.where(counts > 1)[0][0]] raise InvalidFileError( @@ -1297,7 +1297,7 @@ def _set_intra_residue_bonds( return None # A structure contains far fewer distinct bond types than bonds, # hence the bond type is translated once per distinct type - unique_bond_types, type_indices = np.unique(bond_array[:, 2], return_inverse=True) + unique_bond_types, type_indices = np.unique_inverse(bond_array[:, 2]) unique_value_order = np.zeros(len(unique_bond_types), dtype="U4") unique_aromatic_flag = np.zeros(len(unique_bond_types), dtype="U1") for i, bond_type in enumerate(unique_bond_types): @@ -1307,8 +1307,6 @@ def _set_intra_residue_bonds( unique_value_order[i], unique_aromatic_flag[i] = _get_chem_comp_bond_type( bond_type ) - # The shape of the inverse indices depends on the NumPy version - type_indices = type_indices.reshape(-1) value_order = unique_value_order[type_indices] aromatic_flag = unique_aromatic_flag[type_indices] any_mask = bond_array[:, 2] == BondType.ANY diff --git a/src/biotite/structure/io/pdbx/encoding.py b/src/biotite/structure/io/pdbx/encoding.py index 6320bdc29..a9124e9f7 100644 --- a/src/biotite/structure/io/pdbx/encoding.py +++ b/src/biotite/structure/io/pdbx/encoding.py @@ -677,7 +677,9 @@ def serialize(self) -> dict: ) string_data = "".join(self.strings) - offsets = np.cumsum([0] + [len(s) for s in self.strings]) + offsets = np.cumulative_sum( + np.strings.str_len(self.strings), include_initial=True + ) return { "kind": "StringArray", diff --git a/src/biotite/structure/mechanics.py b/src/biotite/structure/mechanics.py index b7f3045ae..f14e7640d 100644 --- a/src/biotite/structure/mechanics.py +++ b/src/biotite/structure/mechanics.py @@ -15,7 +15,7 @@ import numpy as np from biotite.structure.atoms import AtomArray, AtomArrayStack from biotite.structure.geometry import distance -from biotite.structure.info.masses import mass +from biotite.structure.info.masses import masses as masses_from_elements from biotite.typing import XYZ, M, N, NDArray1, NDArray2 @@ -55,7 +55,7 @@ def gyration_radius( containing the radii of gyration for every model is returned. """ if masses is None: - masses = np.array([mass(element) for element in array.element]) + masses = masses_from_elements(array) center = mass_center(array, masses) radii = distance(array, center[..., np.newaxis, :]) inertia_moment = np.sum(masses * radii * radii, axis=-1) @@ -97,5 +97,5 @@ def mass_center( a (*n x 3*) :class:`ndarray` is returned. """ if masses is None: - masses = np.array([mass(element) for element in array.element]) + masses = masses_from_elements(array) return np.sum(masses[:, np.newaxis] * array.coord, axis=-2) / np.sum(masses) diff --git a/src/biotite/structure/repair.py b/src/biotite/structure/repair.py index af0cbb384..b8f98b078 100644 --- a/src/biotite/structure/repair.py +++ b/src/biotite/structure/repair.py @@ -108,8 +108,11 @@ def infer_elements( if isinstance(atoms, (AtomArray, AtomArrayStack)): atom_names = atoms.atom_name else: - atom_names = atoms - return np.array([_guess_element(name) for name in atom_names]) + atom_names = np.asarray(atoms) + # Guess each unique atom name only once instead of each atom + unique_names, inverse = np.unique_inverse(atom_names) + unique_elements = np.array([_guess_element(name) for name in unique_names.tolist()]) + return unique_elements[inverse] def create_atom_names( diff --git a/src/biotite/structure/sasa.py b/src/biotite/structure/sasa.py index 5a5042e4d..91d685e67 100644 --- a/src/biotite/structure/sasa.py +++ b/src/biotite/structure/sasa.py @@ -189,8 +189,7 @@ def _map_radii( ], dtype=np.float32, ) - # The shape of the inverse indices depends on the NumPy version - radii[indices] = unique_radii[inverse_indices.reshape(-1)] + radii[indices] = unique_radii[inverse_indices] return radii # pyright: ignore[reportReturnType] diff --git a/src/biotite/structure/superimpose.py b/src/biotite/structure/superimpose.py index 5e58e56ca..57fb47b5f 100755 --- a/src/biotite/structure/superimpose.py +++ b/src/biotite/structure/superimpose.py @@ -489,7 +489,7 @@ def _get_rotation_matrices( Both sets of coordinates must already be centered at origin. """ # Calculate cross-covariance matrices - cov = np.matmul(np.swapaxes(fixed, -1, -2), mobile) # (M, XYZ, XYZ) + cov = fixed.mT @ mobile # (M, XYZ, XYZ) v, s, w = np.linalg.svd(cov) # Remove possibility of reflected atom coordinates reflected_mask = np.linalg.det(v) * np.linalg.det(w) < 0 diff --git a/src/biotite/structure/transform.py b/src/biotite/structure/transform.py index 735acd992..9416d8305 100644 --- a/src/biotite/structure/transform.py +++ b/src/biotite/structure/transform.py @@ -491,7 +491,7 @@ def rotate_about_axis( # Normalize axis axis = np.asarray(axis, dtype=np.float32).copy() - if np.linalg.norm(axis) == 0: + if np.linalg.vector_norm(axis) == 0: raise ValueError("Length of the rotation axis is 0") norm_vector(axis) # Save some interim values, that are used repeatedly in the @@ -741,9 +741,9 @@ def align_vectors( raise ValueError( f"Expected target vector to have shape (3,), got {target_direction.shape}" ) - if np.linalg.norm(origin_direction) == 0: + if np.linalg.vector_norm(origin_direction) == 0: raise ValueError("Length of the origin vector is 0") - if np.linalg.norm(target_direction) == 0: + if np.linalg.vector_norm(target_direction) == 0: raise ValueError("Length of the target vector is 0") if origin_position is not None: origin_position = np.asarray(origin_position, dtype=np.float32) @@ -806,6 +806,4 @@ def _multi_matmul( Calculate the matrix multiplication of m matrices with m x n vectors. """ - return np.transpose( - np.matmul(matrices, np.transpose(vectors, axes=(0, 2, 1))), axes=(0, 2, 1) - ) + return (matrices @ vectors.mT).mT diff --git a/src/biotite/structure/util.py b/src/biotite/structure/util.py index 4a3108373..617b19d70 100644 --- a/src/biotite/structure/util.py +++ b/src/biotite/structure/util.py @@ -54,7 +54,7 @@ def norm_vector(v: np.ndarray) -> None: The array containing the vector(s). The vectors are represented by the last axis. """ - factor = np.linalg.norm(v, axis=-1) + factor = np.linalg.vector_norm(v, axis=-1) if isinstance(factor, np.ndarray): v /= factor[..., np.newaxis] else: diff --git a/tests/structure/io/test_pdbx.py b/tests/structure/io/test_pdbx.py index e834a8ab2..d63a1d205 100644 --- a/tests/structure/io/test_pdbx.py +++ b/tests/structure/io/test_pdbx.py @@ -17,6 +17,25 @@ from tests.util import data_dir +ESCAPE_STRINGS = [ + "", + " ", + " ", + "te xt", + "text", + "_text", + "'", + '"', + "te'xt", + 'te"xt', + "te\nxt", + "\t", + """single"anddouble"marks""", + """single' and double" marks with whitespace""", +] + + + @pytest.mark.parametrize("format", ["cif", "bcif"]) def test_get_model_count(format): """ @@ -33,21 +52,7 @@ def test_get_model_count(format): assert test_model_count == ref_model_count -@pytest.mark.parametrize( - "string", - [ - "", - " ", - " ", - "te xt", - "'", - '"', - "te\nxt", - "\t", - """single"anddouble"marks""", - """single' and double" marks with whitespace""", - ], -) +@pytest.mark.parametrize("string", ESCAPE_STRINGS) @pytest.mark.parametrize("looped", [False, True]) def test_escape(string, looped): """ @@ -67,6 +72,29 @@ def test_escape(string, looped): assert test_value == ref_value +@pytest.mark.parametrize("looped", [False, True]) +def test_escape_mixed(looped): + """ + Test escaping a category with mixed values, so that each value needs + a different type of escaping. + The values must be restored after deserialization. + """ + if looped: + ref_category = pdbx.CIFCategory({"test_col": ESCAPE_STRINGS}, "test_cat") + else: + ref_category = pdbx.CIFCategory( + {f"col_{i}": string for i, string in enumerate(ESCAPE_STRINGS)}, + "test_cat", + ) + + test_category = pdbx.CIFCategory.deserialize(ref_category.serialize()) + if looped: + test_values = test_category["test_col"].as_array(str).tolist() + else: + test_values = [column.as_item() for column in test_category.values()] + assert test_values == ESCAPE_STRINGS + + @pytest.mark.parametrize( ["cif_line", "expected_fields"], [ From 6a6ec4442a692df8a82391566c073732826fd1c1 Mon Sep 17 00:00:00 2001 From: Patrick Kunzmann Date: Sat, 12 Sep 2026 14:05:06 +0200 Subject: [PATCH 2/4] Fix `hbond()` being able to assigned donor-hydrogen pairs over chain borders --- src/biotite/structure/hbond.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/biotite/structure/hbond.py b/src/biotite/structure/hbond.py index d84d3b450..c7b8e5e1f 100644 --- a/src/biotite/structure/hbond.py +++ b/src/biotite/structure/hbond.py @@ -19,6 +19,7 @@ from biotite.structure.bonds import BondList from biotite.structure.filter import filter_heavy from biotite.structure.geometry import angle, distance +from biotite.structure.residues import get_all_residue_positions from biotite.typing import C3, XYZ, K, M, N, NDArray1, NDArray2, NDArray3 @@ -413,7 +414,7 @@ def _get_bonded_h_via_distance( CUTOFF = 1.5 coord = array.coord - res_id = array.res_id + residue_positions = get_all_residue_positions(array) hydrogen_mask = ~filter_heavy(array) donor_hydrogen_mask = np.zeros(len(array), dtype=bool) @@ -427,15 +428,12 @@ def _get_bonded_h_via_distance( associated_donor_indices, ) # pyright: ignore[reportReturnType] - # Sort the hydrogen atoms by residue ID, so that the candidates for each - # donor can be found via binary search instead of scanning all atoms - hydrogen_indices = hydrogen_indices[ - np.argsort(res_id[hydrogen_indices], kind="stable") - ] - hydrogen_res_ids = res_id[hydrogen_indices] - donor_res_ids = res_id[donor_indices] - candidate_start = np.searchsorted(hydrogen_res_ids, donor_res_ids, side="left") - candidate_stop = np.searchsorted(hydrogen_res_ids, donor_res_ids, side="right") + # The residue positions are already sorted in atom order, so the candidates + # for each donor can be found via binary search instead of scanning all atoms + hydrogen_residues = residue_positions[hydrogen_indices] + donor_residues = residue_positions[donor_indices] + candidate_start = np.searchsorted(hydrogen_residues, donor_residues, side="left") + candidate_stop = np.searchsorted(hydrogen_residues, donor_residues, side="right") # Flatten the ragged donor-to-candidate mapping into plain index arrays, # so that all distances can be computed in a single vectorized call @@ -447,7 +445,7 @@ def _get_bonded_h_via_distance( associated_donor_indices, ) # pyright: ignore[reportReturnType] donor_of_pair = np.repeat(np.arange(len(donor_indices)), candidate_count) - offsets = np.cumsum(candidate_count) - candidate_count + offsets = np.cumulative_sum(candidate_count, include_initial=True)[:-1] index_in_candidates = np.arange(total_count) - offsets[donor_of_pair] pair_hydrogen_i = hydrogen_indices[ candidate_start[donor_of_pair] + index_in_candidates From b353ae78c8e6957ef9ab5a0fec38171c45cd3aaf Mon Sep 17 00:00:00 2001 From: Patrick Kunzmann Date: Sun, 13 Sep 2026 13:43:40 +0200 Subject: [PATCH 3/4] Introduce `map_unique()` as generalized way to map a function to unique array elements only --- src/biotite/interface/openmm/system.py | 6 +- src/biotite/structure/connect.py | 14 ++-- src/biotite/structure/info/masses.py | 8 +-- src/biotite/structure/info/number.py | 11 ++- src/biotite/structure/io/pdbx/convert.py | 49 ++++++------- src/biotite/structure/repair.py | 6 +- src/biotite/structure/sasa.py | 25 +++---- src/biotite/util.py | 89 ++++++++++++++++++++++++ src/rust/structure/connect.rs | 4 +- tests/test_util.py | 36 ++++++++++ 10 files changed, 173 insertions(+), 75 deletions(-) create mode 100644 src/biotite/util.py create mode 100644 tests/test_util.py diff --git a/src/biotite/interface/openmm/system.py b/src/biotite/interface/openmm/system.py index e3c72cdbc..3ff8cde8a 100644 --- a/src/biotite/interface/openmm/system.py +++ b/src/biotite/interface/openmm/system.py @@ -20,7 +20,7 @@ from biotite.structure.chains import get_chain_starts from biotite.structure.error import BadStructureError from biotite.structure.filter import filter_amino_acids, filter_nucleotides -from biotite.structure.info.masses import mass +from biotite.structure.info.masses import masses from biotite.structure.residues import get_residue_starts from biotite.typing import XYZ, K, NDArray1, NDArray2 @@ -56,8 +56,8 @@ def to_system( """ system = System() - for element in atoms.element.tolist(): - system.addParticle(mass(element)) + for atom_mass in masses(atoms).tolist(): + system.addParticle(atom_mass) if atoms.box is not None: if isinstance(atoms, AtomArrayStack): diff --git a/src/biotite/structure/connect.py b/src/biotite/structure/connect.py index 1af99d154..11609b86b 100644 --- a/src/biotite/structure/connect.py +++ b/src/biotite/structure/connect.py @@ -39,6 +39,7 @@ from biotite.structure.bonds import BondList, BondType from biotite.structure.error import BadStructureError, InconsistentBondTypeWarning from biotite.typing import K, N, NDArray1 +from biotite.util import map_unique # Covalent radii (in Å) taken from Cordero et al. # For carbon the sp3 radius is used, for manganese, iron and cobalt the low-spin radius @@ -240,10 +241,9 @@ def connect_via_distances( # NaN for unknown elements ensures that they never form bonds, # as any comparison with NaN is false - radii = np.array( - [_COVALENT_RADII.get(element.upper(), np.nan) for element in atoms.element], - dtype=np.float32, - ) + radii = map_unique( + lambda element: _COVALENT_RADII.get(element.upper(), np.nan), atoms.element + ).astype(np.float32) residue_starts = get_residue_starts(atoms, add_exclusive_stop=True) @@ -559,7 +559,7 @@ def infer_bond_types( else: known_charges = None bond_list, charges, converged = rust_infer_bond_types( - [element.upper() for element in atoms.element], + atoms.element.tolist(), atoms.bonds, total_charge, known_charges, @@ -668,9 +668,7 @@ def _connect_inter_residue( # Avoid circular import from biotite.structure.info.misc import link_type - link_types = [ - link_type(res_name) for res_name in atoms.res_name[residue_starts[:-1]] - ] + link_types = map_unique(link_type, atoms.res_name[residue_starts[:-1]]).tolist() is_disconnected = ( # Residues are not inside the same chain (atoms.chain_id[residue_starts[1:-1]] != atoms.chain_id[residue_starts[:-2]]) diff --git a/src/biotite/structure/info/masses.py b/src/biotite/structure/info/masses.py index c9df93bdc..f42aba963 100644 --- a/src/biotite/structure/info/masses.py +++ b/src/biotite/structure/info/masses.py @@ -14,6 +14,7 @@ from biotite.structure.atoms import Atom, AtomArray, AtomArrayStack from biotite.structure.info.ccd import get_from_ccd from biotite.typing import N, NDArray1 +from biotite.util import map_unique # Masses are taken from http://www.sbcs.qmul.ac.uk/iupac/AtWt/ (2018/03/01) ATOM_MASSES_FILE = Path(__file__).parent / "atom_masses.json" @@ -166,12 +167,7 @@ def masses( """ if isinstance(elements, (AtomArray, AtomArrayStack)): elements = elements.element - # Look up each unique element only once instead of each element in the array - unique_elements, inverse = np.unique_inverse(elements) - unique_masses = np.array( - [mass(element, is_residue=False) for element in unique_elements.tolist()] - ) - return unique_masses[inverse] + return map_unique(functools.partial(mass, is_residue=False), elements) @functools.cache diff --git a/src/biotite/structure/info/number.py b/src/biotite/structure/info/number.py index 2a9700ce4..999d70f86 100644 --- a/src/biotite/structure/info/number.py +++ b/src/biotite/structure/info/number.py @@ -11,6 +11,7 @@ import numpy as np from biotite.structure.atoms import AtomArray from biotite.typing import N, NDArray1 +from biotite.util import map_unique # fmt: off _ATOMIC_NUMBERS = { @@ -69,10 +70,6 @@ def atomic_number( elements = atoms.element else: elements = atoms - # Look up each element only once instead of each atom - unique_elements, inverse = np.unique(elements, return_inverse=True) - unique_numbers = np.array( - [_ATOMIC_NUMBERS.get(element.upper(), 0) for element in unique_elements], - dtype=np.uint8, - ) - return unique_numbers[inverse] + return map_unique( + lambda element: _ATOMIC_NUMBERS.get(element.upper(), 0), elements + ).astype(np.uint8) diff --git a/src/biotite/structure/io/pdbx/convert.py b/src/biotite/structure/io/pdbx/convert.py index aa47d43c0..488045b9e 100644 --- a/src/biotite/structure/io/pdbx/convert.py +++ b/src/biotite/structure/io/pdbx/convert.py @@ -69,6 +69,7 @@ ) from biotite.structure.transform import AffineTransformation from biotite.typing import C2, XYZ, M, N, NDArray1, NDArray2 +from biotite.util import map_unique _PDBxFile: TypeAlias = CIFFile | CIFBlock | BinaryCIFFile | BinaryCIFBlock _Block: TypeAlias = CIFBlock | BinaryCIFBlock @@ -761,7 +762,7 @@ def _parse_inter_residue_bonds( bond_type_id = bond_type_id[covale_mask][mapping_exists_mask] # The type ID is always present in the dictionary, # as it was used to filter the applicable bonds - bond_types = [PDBX_BOND_TYPE_ID_TO_TYPE[type_id] for type_id in bond_type_id] + bond_types = map_unique(PDBX_BOND_TYPE_ID_TO_TYPE.__getitem__, bond_type_id) return BondList( atom_count if atom_count is not None else atom_site.row_count, @@ -1083,7 +1084,9 @@ def set_structure( atom_site["occupancy"] = np.copy(array.occupancy) if "charge" in annot_categories: atom_site["pdbx_formal_charge"] = Column( - np.array([f"{c:+d}" if c != 0 else "?" for c in array.charge]), + map_unique( + lambda charge: f"{charge:+d}" if charge != 0 else "?", array.charge + ), np.where(array.charge == 0, MaskValue.MISSING, MaskValue.PRESENT), ) if "entity_id" in annot_categories: @@ -1295,21 +1298,14 @@ def _set_intra_residue_bonds( bond_array = _filter_bonds(array, "intra") if len(bond_array) == 0: return None - # A structure contains far fewer distinct bond types than bonds, - # hence the bond type is translated once per distinct type - unique_bond_types, type_indices = np.unique_inverse(bond_array[:, 2]) - unique_value_order = np.zeros(len(unique_bond_types), dtype="U4") - unique_aromatic_flag = np.zeros(len(unique_bond_types), dtype="U1") - for i, bond_type in enumerate(unique_bond_types): - if bond_type == BondType.ANY: - # ANY bonds will be masked anyway, no need to set the value - continue - unique_value_order[i], unique_aromatic_flag[i] = _get_chem_comp_bond_type( - bond_type - ) - value_order = unique_value_order[type_indices] - aromatic_flag = unique_aromatic_flag[type_indices] any_mask = bond_array[:, 2] == BondType.ANY + value_order = np.zeros(len(bond_array), dtype="U4") + aromatic_flag = np.zeros(len(bond_array), dtype="U1") + # ANY bonds will be masked anyway, no need to set the value + if not np.all(any_mask): + value_order[~any_mask], aromatic_flag[~any_mask] = map_unique( + _get_chem_comp_bond_type, bond_array[~any_mask, 2] + ).T # Remove already existing residue and atom name combinations # These appear when the structure contains a residue multiple times @@ -1380,11 +1376,11 @@ def _set_inter_residue_bonds( struct_conn = Category() struct_conn["id"] = np.arange(1, len(bond_array) + 1) - struct_conn["conn_type_id"] = [ - PDBX_BOND_TYPE_TO_TYPE_ID[btype] for btype in bond_array[:, 2] - ] + struct_conn["conn_type_id"] = map_unique( + PDBX_BOND_TYPE_TO_TYPE_ID.__getitem__, bond_array[:, 2] + ) struct_conn["pdbx_value_order"] = Column( - np.array([PDBX_BOND_TYPE_TO_ORDER[btype] for btype in bond_array[:, 2]]), + map_unique(PDBX_BOND_TYPE_TO_ORDER.__getitem__, bond_array[:, 2]), np.where( np.isin(bond_array[:, 2], (BondType.ANY, BondType.COORDINATION)), MaskValue.MISSING, @@ -1696,19 +1692,16 @@ def set_component( if array.bonds is not None and array.bonds.get_bond_count() > 0: bond_array = array.bonds.as_array() - order_flags = [] - aromatic_flags = [] - for bond_type in bond_array[:, 2]: - order_flag, aromatic_flag = _get_chem_comp_bond_type(bond_type) - order_flags.append(order_flag) - aromatic_flags.append(aromatic_flag) + order_flags, aromatic_flags = map_unique( + _get_chem_comp_bond_type, bond_array[:, 2] + ).T bond_cat = Category() bond_cat["comp_id"] = np.full(len(bond_array), res_name) bond_cat["atom_id_1"] = array.atom_name[bond_array[:, 0]] bond_cat["atom_id_2"] = array.atom_name[bond_array[:, 1]] - bond_cat["value_order"] = np.array(order_flags) - bond_cat["pdbx_aromatic_flag"] = np.array(aromatic_flags) + bond_cat["value_order"] = order_flags + bond_cat["pdbx_aromatic_flag"] = aromatic_flags bond_cat["pdbx_ordinal"] = np.arange(1, len(bond_array) + 1).astype(str) block["chem_comp_bond"] = bond_cat diff --git a/src/biotite/structure/repair.py b/src/biotite/structure/repair.py index b8f98b078..19c09ab79 100644 --- a/src/biotite/structure/repair.py +++ b/src/biotite/structure/repair.py @@ -19,6 +19,7 @@ from biotite.structure.chains import get_chain_starts from biotite.structure.residues import get_residue_starts from biotite.typing import M, N, NDArray1 +from biotite.util import map_unique def create_continuous_res_ids( @@ -109,10 +110,7 @@ def infer_elements( atom_names = atoms.atom_name else: atom_names = np.asarray(atoms) - # Guess each unique atom name only once instead of each atom - unique_names, inverse = np.unique_inverse(atom_names) - unique_elements = np.array([_guess_element(name) for name in unique_names.tolist()]) - return unique_elements[inverse] + return map_unique(_guess_element, atom_names) def create_atom_names( diff --git a/src/biotite/structure/sasa.py b/src/biotite/structure/sasa.py index 91d685e67..a54e88e74 100644 --- a/src/biotite/structure/sasa.py +++ b/src/biotite/structure/sasa.py @@ -23,6 +23,7 @@ ) from biotite.structure.info.radii import vdw_radius_protor, vdw_radius_single from biotite.typing import XYZ, N, NDArray1, NDArray2 +from biotite.util import map_unique def sasa( @@ -171,25 +172,15 @@ def _map_radii( (e.g. residue/atom name pairs) than atoms, hence `radius_function` is only called once per distinct combination. """ + + def radius_or_default(*values: str) -> float: + radius = radius_function(*values) + return default_radius if radius is None else radius + radii = np.full(len(occlusion_filter), np.nan, dtype=np.float32) - indices = np.where(occlusion_filter)[0] - if len(indices) == 0: - return radii # pyright: ignore[reportReturnType] - combinations = np.stack([annot[indices] for annot in annotations], axis=-1) - unique_combinations, inverse_indices = np.unique( - combinations, axis=0, return_inverse=True - ) - unique_radii = np.array( - [ - default_radius if radius is None else radius - for radius in ( - radius_function(*(str(value) for value in combination)) - for combination in unique_combinations - ) - ], - dtype=np.float32, + radii[occlusion_filter] = map_unique( + radius_or_default, *(annot[occlusion_filter] for annot in annotations) ) - radii[indices] = unique_radii[inverse_indices] return radii # pyright: ignore[reportReturnType] diff --git a/src/biotite/util.py b/src/biotite/util.py new file mode 100644 index 000000000..fda248cd9 --- /dev/null +++ b/src/biotite/util.py @@ -0,0 +1,89 @@ +# This source code is part of the Biotite package and is distributed +# under the 3-Clause BSD License. Please see 'LICENSE.rst' for further +# information. + +__name__ = "biotite" +__author__ = "Patrick Kunzmann" +__all__ = ["map_unique"] + +from collections.abc import Callable +from typing import Any, overload +import numpy as np +from biotite.typing import K, N, NDArray1, NDArray2 + + +@overload +def map_unique( + function: Callable[..., tuple[Any, ...]], *arrays: NDArray1[N, Any] +) -> NDArray2[N, K, Any]: ... +@overload +def map_unique( + function: Callable[..., Any], *arrays: NDArray1[N, Any] +) -> NDArray1[N, Any]: ... +def map_unique( + function: Callable[..., Any], *arrays: NDArray1[N, Any] +) -> NDArray1[N, Any] | NDArray2[N, K, Any]: + """ + Apply a function to each element of the given arrays, but call it only + once per unique element. + + This is much faster than calling `function` for each element, if the + arrays contain only few unique elements, which is usually the case for + annotation arrays of an :class:`AtomArray`. + + Parameters + ---------- + function : Callable + The function to apply. + It is called with one positional argument per given array, i.e. the + respective element of each array as Python object. + It must return either a scalar value or a tuple. + *arrays : ndarray, shape=(n,) + The arrays to apply the function to. + If multiple arrays are given, `function` is called once per unique + combination of elements. + All arrays must have the same length. + + Returns + ------- + mapped : ndarray, shape=(n,) or shape=(n,k) + The return value of `function` for each element. + If `function` returns tuples of length *k*, the array has a second + dimension for the tuple elements. + + Examples + -------- + + >>> elements = np.array(["C", "N", "C", "O", "N"]) + >>> print(map_unique(str.lower, elements)) + ['c' 'n' 'c' 'o' 'n'] + >>> atom_names = np.array(["CA", "N", "CB", "O", "ND2"]) + >>> print(map_unique(lambda name, elem: (name + "_" + elem, len(name)), atom_names, elements)) + [['CA_C' '2'] + ['N_N' '1'] + ['CB_C' '2'] + ['O_O' '1'] + ['ND2_N' '3']] + """ + if len(arrays) == 0: + raise TypeError("At least one array is required") + length = len(arrays[0]) + if any(len(array) != length for array in arrays): + raise IndexError("All arrays must have the same length") + + if len(arrays) == 1: + keys = arrays[0] + else: + # Encode the elements of each array as integers, so that each + # combination of elements is represented by a single integer key + keys = np.zeros(length, dtype=np.int64) + for array in arrays: + unique_elements, codes = np.unique_inverse(array) + keys = keys * len(unique_elements) + codes + unique = np.unique_all(keys) + # The first occurrence of each unique key is representative for the key + unique_results = [ + function(*elements) + for elements in zip(*(array[unique.indices].tolist() for array in arrays)) + ] + return np.array(unique_results)[unique.inverse_indices] diff --git a/src/rust/structure/connect.rs b/src/rust/structure/connect.rs index 342bfbca9..887b3d0ab 100644 --- a/src/rust/structure/connect.rs +++ b/src/rust/structure/connect.rs @@ -341,7 +341,7 @@ pub fn connect_inter_residue<'py>( /// Parameters /// ---------- /// elements -/// The element of each atom in upper case. +/// The element of each atom. /// bonds /// The connectivity between the atoms. /// The bond types are ignored. @@ -408,7 +408,7 @@ pub fn infer_bond_types<'py>( if degrees[i] == 0 { return vec![Valence::new(degrees[i], 0)]; } - match allowed_valences(&elements[i], degrees[i]) { + match allowed_valences(&elements[i].to_ascii_uppercase(), degrees[i]) { Some(allowed) => { let valid: Vec = allowed .into_iter() diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 000000000..742e44933 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,36 @@ +# This source code is part of the Biotite package and is distributed +# under the 3-Clause BSD License. Please see 'LICENSE.rst' for further +# information. + +import numpy as np +import pytest +from biotite import map_unique + + +@pytest.mark.parametrize("seed", range(5)) +@pytest.mark.parametrize("n_arrays", [1, 2, 3]) +@pytest.mark.parametrize("n_results", [1, 2]) +def test_map_unique(seed, n_arrays, n_results): + """ + Check that :func:`map_unique()` gives the same result as applying the + function to each element. + """ + LENGTH = 1000 + N_UNIQUE = 5 + + def function(*values): + if n_results == 1: + return "-".join(values) + else: + return tuple(f"{i}:" + "-".join(values) for i in range(n_results)) + + rng = np.random.default_rng(seed) + arrays = [ + rng.choice(np.array([f"{i}_{j}" for j in range(N_UNIQUE)]), LENGTH) + for i in range(n_arrays) + ] + + test_mapped = map_unique(function, *arrays) + ref_mapped = np.array([function(*values) for values in zip(*arrays)]) + + assert test_mapped.tolist() == ref_mapped.tolist() From 9059962fac45da9ef2b1cefc1e6381b282a054cd Mon Sep 17 00:00:00 2001 From: Patrick Kunzmann Date: Tue, 22 Sep 2026 23:12:50 +0200 Subject: [PATCH 4/4] Fix CI failures --- src/biotite/application_v2/sra/app.py | 19 ++++++++----------- src/biotite/database/rcsb/query.py | 16 ++++++++-------- tests/structure/io/test_pdbx.py | 2 -- tests/test_util.py | 2 +- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/biotite/application_v2/sra/app.py b/src/biotite/application_v2/sra/app.py index 1ee57d777..93e4d54d6 100644 --- a/src/biotite/application_v2/sra/app.py +++ b/src/biotite/application_v2/sra/app.py @@ -20,7 +20,6 @@ from os.path import join from pathlib import Path from tempfile import TemporaryDirectory -from typing import Literal, TypeAlias import numpy as np from biotite.application_v2.localapp import ( CLIArgument, @@ -37,10 +36,6 @@ from biotite.sequence.io.fastq.file import FastqFile from biotite.sequence.seqtypes import NucleotideSequence -_OffsetFormat: TypeAlias = Literal[ - "Sanger", "Solexa", "Illumina-1.3", "Illumina-1.5", "Illumina-1.8" -] - @dataclass(frozen=True) class PrefetchDirectory: @@ -85,13 +80,15 @@ class FastqResult: default=None, repr=False, compare=False ) - def get_files(self, offset: int | _OffsetFormat = "Sanger") -> list[FastqFile]: + def get_files( + self, offset: int | FastqFile.Offset = FastqFile.Offset.SANGER + ) -> list[FastqFile]: """ Parse the extracted FASTQ files. Parameters ---------- - offset : int or {'Sanger', 'Solexa', 'Illumina-1.3', 'Illumina-1.5', 'Illumina-1.8'}, optional + offset : int or FastqFile.Offset, optional This value is subtracted from the FASTQ ASCII code to obtain the quality score. @@ -103,14 +100,14 @@ def get_files(self, offset: int | _OffsetFormat = "Sanger") -> list[FastqFile]: return [FastqFile.read(path, offset=offset) for path in self.file_paths] def get_sequences( - self, offset: int | _OffsetFormat = "Sanger" + self, offset: int | FastqFile.Offset = FastqFile.Offset.SANGER ) -> list[dict[str, NucleotideSequence]]: """ Get the reads from the extracted FASTQ files. Parameters ---------- - offset : int or {'Sanger', 'Solexa', 'Illumina-1.3', 'Illumina-1.5', 'Illumina-1.8'}, optional + offset : int or FastqFile.Offset, optional This value is subtracted from the FASTQ ASCII code to obtain the quality score. @@ -127,14 +124,14 @@ def get_sequences( ] def get_sequences_and_scores( - self, offset: int | _OffsetFormat = "Sanger" + self, offset: int | FastqFile.Offset = FastqFile.Offset.SANGER ) -> list[dict[str, tuple[NucleotideSequence, np.ndarray]]]: """ Get the reads and quality scores from the extracted FASTQ files. Parameters ---------- - offset : int or {'Sanger', 'Solexa', 'Illumina-1.3', 'Illumina-1.5', 'Illumina-1.8'}, optional + offset : int or FastqFile.Offset, optional This value is subtracted from the FASTQ ASCII code to obtain the quality score. diff --git a/src/biotite/database/rcsb/query.py b/src/biotite/database/rcsb/query.py index d5ba7aec1..6111b6c57 100644 --- a/src/biotite/database/rcsb/query.py +++ b/src/biotite/database/rcsb/query.py @@ -233,7 +233,7 @@ class FieldQuery(SingleQuery): >>> query = FieldQuery("reflns.d_resolution_high", less_or_equal=0.5) >>> print(sorted(search(query))) - ['3NIR', '4JLJ', '5D8V', '7R0H'] + ['30OH', '30OR', '3NIR', '4JLJ', '5D8V', '7R0H'] """ # Overloads expose the mutually exclusive operator keywords. @@ -886,10 +886,10 @@ def count( >>> query = FieldQuery("reflns.d_resolution_high", less_or_equal=0.5) >>> print(count(query)) - 4 + 6 >>> ids = search(query) >>> print(sorted(ids)) - ['3NIR', '4JLJ', '5D8V', '7R0H'] + ['30OH', '30OR', '3NIR', '4JLJ', '5D8V', '7R0H'] """ query_dict = _initialize_query_dict(query, return_type, group_by, content_types) @@ -1029,20 +1029,20 @@ def search( >>> query = FieldQuery("reflns.d_resolution_high", less_or_equal=0.5) >>> print(sorted(search(query))) - ['3NIR', '4JLJ', '5D8V', '7R0H'] + ['30OH', '30OR', '3NIR', '4JLJ', '5D8V', '7R0H'] >>> print(search(query, sort_by="rcsb_accession_info.initial_release_date")) - ['7R0H', '5D8V', '4JLJ', '3NIR'] + ['30OH', '30OR', '7R0H', '5D8V', '4JLJ', '3NIR'] >>> print(search( ... query, range=(1,4), sort_by="rcsb_accession_info.initial_release_date" ... )) - ['5D8V', '4JLJ', '3NIR'] + ['30OR', '7R0H', '5D8V'] >>> print(sorted(search(query, return_type="polymer_instance"))) - ['3NIR.A', '4JLJ.A', '4JLJ.B', '5D8V.A', '7R0H.A'] + ['30OH.A', '30OR.A', '3NIR.A', '4JLJ.A', '4JLJ.B', '5D8V.A', '7R0H.A'] >>> print(search( ... query, return_type="polymer_entity", return_groups=True, ... group_by=UniprotGrouping(sort_by="rcsb_accession_info.initial_release_date"), ... )) - {'P27707': ['4JLJ_1'], 'P80176': ['5D8V_1'], 'O29777': ['7R0H_1'], 'P01542': ['3NIR_1']} + {'Q9V099': ['30OR_1', '30OH_1'], 'P27707': ['4JLJ_1'], 'P80176': ['5D8V_1'], 'O29777': ['7R0H_1'], 'P01542': ['3NIR_1']} """ query_dict = _initialize_query_dict(query, return_type, group_by, content_types) diff --git a/tests/structure/io/test_pdbx.py b/tests/structure/io/test_pdbx.py index d63a1d205..fc5ef8855 100644 --- a/tests/structure/io/test_pdbx.py +++ b/tests/structure/io/test_pdbx.py @@ -16,7 +16,6 @@ from biotite.structure.io.pdbx.compress import _get_decimal_places as get_decimal_places from tests.util import data_dir - ESCAPE_STRINGS = [ "", " ", @@ -35,7 +34,6 @@ ] - @pytest.mark.parametrize("format", ["cif", "bcif"]) def test_get_model_count(format): """ diff --git a/tests/test_util.py b/tests/test_util.py index 742e44933..3bb2874eb 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from biotite import map_unique +from biotite.util import map_unique @pytest.mark.parametrize("seed", range(5))