Skip to content

Commit 9cc20e8

Browse files
committed
molecule can live with old-style mapping or with mapping divided into mapping and naming. It will allow us to migrate gradually to new mapping structure.
(cherry picked from commit 453170de7b67f3444052b802602f0fa82a30aac4)
1 parent df4c737 commit 9cc20e8

5 files changed

Lines changed: 261 additions & 18 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
NMRlipids:
2+
id: BOG
3+
name: beta-octyl D-glucopyranoside
4+
charge: 0
5+
sameAs:
6+
ChEBI: CHEBI:41128
7+
pubchem.compound: 62852
8+
metabolights: MTBLC41128
9+
pdb.ligand: BOG
10+
ChEMBL: CHEMBL446037
11+
bioschema_properties:
12+
iupacName: (2R,3S,4S,5R,6R)-2-(hydroxymethyl)-6-octoxyoxane-3,4,5-triol
13+
smiles: CCCCCCCCO[C@H]1[C@@H]([C@H]([C@@H]([C@H](O1)CO)O)O)O
14+
inChI: InChI=1S/C14H28O6/c1-2-3-4-5-6-7-8-19-14-13(18)12(17)11(16)10(9-15)20-14/h10-18H,2-9H2,1H3/t10-,11-,12+,13-,14-/m1/s1
15+
inChIKey: HEGSGKPQLMEBJL-RKQHYHRCSA-N
16+
molecularFormula: C14H28O6
17+
molecularWeight: 292.37
18+
name: (2R,3S,4S,5R,6R)-2-(hydroxymethyl)-6-octoxyoxane-3,4,5-triol
19+
image: https://www.ebi.ac.uk/chembl/api/data/image/CHEMBL446037?dimensions=200
20+
alternateName:
21+
- β-octylglucoside
22+
- 1-octyl-β-D-glucopyranoside
23+
- 1-O-n-octyl-β-D-glucopyranoside
24+
- β-D-octyl glucoside
25+
- 1-O-octyl-β-D-glucopyranoside
26+
- octyl-β-D-glucoside
27+
- Oct β-Glc
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
M_G0C1_M:
2+
FRAGMENT: glucose
3+
SMILEIDX: 9
4+
M_G0C1H1_M:
5+
FRAGMENT: glucose
6+
M_G0C1O1_M:
7+
FRAGMENT: glucose
8+
SMILEIDX: 8
9+
M_G0C2_M:
10+
FRAGMENT: glucose
11+
SMILEIDX: 10
12+
M_G0C2H1_M:
13+
FRAGMENT: glucose
14+
M_G0C2O1_M:
15+
FRAGMENT: glucose
16+
SMILEIDX: 19
17+
M_G0C2O1H1_M:
18+
FRAGMENT: glucose
19+
M_G0C3_M:
20+
FRAGMENT: glucose
21+
SMILEIDX: 11
22+
M_G0C3H1_M:
23+
FRAGMENT: glucose
24+
M_G0C3O1_M:
25+
FRAGMENT: glucose
26+
SMILEIDX: 18
27+
M_G0C3O1H1_M:
28+
FRAGMENT: glucose
29+
M_G0C4_M:
30+
FRAGMENT: glucose
31+
SMILEIDX: 12
32+
M_G0C4H1_M:
33+
FRAGMENT: glucose
34+
M_G0C4O1_M:
35+
FRAGMENT: glucose
36+
SMILEIDX: 17
37+
M_G0C4O1H1_M:
38+
FRAGMENT: glucose
39+
M_G0C5_M:
40+
FRAGMENT: glucose
41+
SMILEIDX: 13
42+
M_G0C5H1_M:
43+
FRAGMENT: glucose
44+
M_G0C5O1_M:
45+
FRAGMENT: glucose
46+
SMILEIDX: 14
47+
M_G0C6_M:
48+
FRAGMENT: glucose
49+
SMILEIDX: 15
50+
M_G0C6H1_M:
51+
FRAGMENT: glucose
52+
M_G0C6H2_M:
53+
FRAGMENT: glucose
54+
M_G0C6O1_M:
55+
FRAGMENT: glucose
56+
SMILEIDX: 16
57+
M_G0C6O1H1_M:
58+
FRAGMENT: glucose
59+
M_C1_M:
60+
FRAGMENT: tail
61+
SMILEIDX: 7
62+
M_C1H1_M:
63+
FRAGMENT: tail
64+
M_C1H2_M:
65+
FRAGMENT: tail
66+
M_C2_M:
67+
FRAGMENT: tail
68+
SMILEIDX: 6
69+
M_C2H1_M:
70+
FRAGMENT: tail
71+
M_C2H2_M:
72+
FRAGMENT: tail
73+
M_C3_M:
74+
FRAGMENT: tail
75+
SMILEIDX: 5
76+
M_C3H1_M:
77+
FRAGMENT: tail
78+
M_C3H2_M:
79+
FRAGMENT: tail
80+
M_C4_M:
81+
FRAGMENT: tail
82+
SMILEIDX: 4
83+
M_C4H1_M:
84+
FRAGMENT: tail
85+
M_C4H2_M:
86+
FRAGMENT: tail
87+
M_C5_M:
88+
FRAGMENT: tail
89+
SMILEIDX: 3
90+
M_C5H1_M:
91+
FRAGMENT: tail
92+
M_C5H2_M:
93+
FRAGMENT: tail
94+
M_C6_M:
95+
FRAGMENT: tail
96+
SMILEIDX: 2
97+
M_C6H1_M:
98+
FRAGMENT: tail
99+
M_C6H2_M:
100+
FRAGMENT: tail
101+
M_C7_M:
102+
FRAGMENT: tail
103+
SMILEIDX: 1
104+
M_C7H1_M:
105+
FRAGMENT: tail
106+
M_C7H2_M:
107+
FRAGMENT: tail
108+
M_C8_M:
109+
FRAGMENT: tail
110+
SMILEIDX: 0
111+
M_C8H1_M:
112+
FRAGMENT: tail
113+
M_C8H2_M:
114+
FRAGMENT: tail
115+
M_C8H3_M:
116+
FRAGMENT: tail

src/fairmd/lipids/molecules.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,13 @@ class MoleculeMappingError(MoleculeError):
4040
def __init__(self, message: str, mol=None) -> None:
4141
if mol is None:
4242
msg = message
43-
elif not mol.is_mapping_registered:
43+
elif not mol._can_load_mapping():
4444
msg = f"From {mol}: {message}"
4545
else:
46-
disp_name = os.path.relpath(mol._mapping_fpath, FMDL_MOL_PATH)
46+
if mol._mapping_fpath is None:
47+
disp_name = "naming.yaml"
48+
else:
49+
disp_name = os.path.relpath(mol._mapping_fpath, FMDL_MOL_PATH)
4750
msg = f"From {mol}[{disp_name}]: {message}" if mol is not None else message
4851
super().__init__(msg, mol=mol)
4952

@@ -114,18 +117,22 @@ def check_mapping(self, u: mda.Universe, name: str) -> bool:
114117
@property
115118
def mapping_dict(self) -> dict:
116119
"""Return mapping dictionary (load on first call)"""
117-
if self._mapping_fpath is None:
120+
if not self._can_load_mapping():
118121
msg = "Mapping file is not registered!"
119122
raise MoleculeError(msg, mol=self)
120-
if self._mapping_dict is None:
121-
try:
122-
with open(self._mapping_fpath) as yaml_file:
123-
self._mapping_dict = yaml.safe_load(yaml_file) # yaml.load(yaml_file, Loader=yaml.FullLoader)
124-
except OSError as e:
125-
msg = "Error opening mapping-file!"
126-
raise MoleculeError(msg, mol=self) from e
123+
if self._mapping_dict is None: # load on first request
124+
self._load_mapping_dict()
127125
return self._mapping_dict
128126

127+
def _load_mapping_dict(self) -> None:
128+
"""Load mapping dictionary from the registered mapping file."""
129+
try:
130+
with open(self._mapping_fpath) as yaml_file:
131+
self._mapping_dict = yaml.safe_load(yaml_file) # yaml.load(yaml_file, Loader=yaml.FullLoader)
132+
except OSError as e:
133+
msg = "Error opening mapping-file!"
134+
raise MoleculeError(msg, mol=self) from e
135+
129136
def md2uan(self, mdatomname: str, mdresname: str | None = None) -> str:
130137
"""
131138
Convert MD atom name to the Universal Atom Name.
@@ -134,6 +141,12 @@ def md2uan(self, mdatomname: str, mdresname: str | None = None) -> str:
134141
:return: Universal Atom Name (str)
135142
"""
136143
for universal_name, mrecord in self.mapping_dict.items():
144+
if "ATOMNAME" not in mrecord:
145+
msg = (
146+
f"ATOMNAME field is missing for {universal_name} in mapping dictionary." # !
147+
" MD mapping is not possible."
148+
)
149+
raise MoleculeMappingError(msg, mol=self)
137150
mapping_aname = mrecord["ATOMNAME"]
138151
# MDAnalysis uses fnmatch patterns for selection language
139152
# https://userguide.mdanalysis.org/stable/selections.html
@@ -153,6 +166,9 @@ def uan2selection(self, uname: str, resname: str) -> str:
153166
:raises KeyError: if the universal name is not found in the mapping.
154167
:return: selection string for MDAnalysis
155168
"""
169+
if "ATOMNAME" not in self.mapping_dict[uname]:
170+
msg = f"ATOMNAME field is missing for {uname} in mapping dictionary. MD mapping is not possible."
171+
raise MoleculeMappingError(msg, mol=self)
156172
anm = self.mapping_dict[uname]["ATOMNAME"]
157173
selstr = f"name {anm}"
158174
if "RESIDUE" in self.mapping_dict[uname]:
@@ -211,8 +227,7 @@ def name(self) -> str:
211227
"""Molecule name"""
212228
return self._molname
213229

214-
@property
215-
def is_mapping_registered(self) -> bool:
230+
def _can_load_mapping(self) -> bool:
216231
"""Is mapping registered for the molecule?"""
217232
return self._mapping_fpath is not None
218233

@@ -253,6 +268,40 @@ def _populate_meta_data(self) -> None:
253268
msg = f"Metadata file not found for {self.name}."
254269
raise FileNotFoundError(msg)
255270

271+
def _can_load_mapping(self) -> bool:
272+
super_is_reg = super()._can_load_mapping()
273+
naming_path = os.path.join(self._get_path(), "naming.yaml")
274+
return super_is_reg or os.path.isfile(naming_path)
275+
276+
def _load_mapping_dict(self) -> None:
277+
if super()._can_load_mapping():
278+
super()._load_mapping_dict()
279+
else:
280+
self._mapping_dict = {}
281+
self._load_naming_dict()
282+
283+
def _load_naming_dict(self) -> None:
284+
"""
285+
Load naming dictionary from `naming.yaml` file if it exists.
286+
287+
Updates existing mapping dictionary.
288+
"""
289+
naming_path = os.path.join(self._get_path(), "naming.yaml")
290+
if os.path.isfile(naming_path):
291+
with open(naming_path) as yaml_file:
292+
_naming_dict = yaml.load(yaml_file, Loader=yaml.FullLoader)
293+
for unm, record in _naming_dict.items():
294+
self._mapping_dict.setdefault(unm, {}).update(record)
295+
296+
@property
297+
def fragments(self) -> list[str]:
298+
"""Return list of fragments for the lipid."""
299+
frags = set()
300+
for mrecord in self.mapping_dict.values():
301+
frag = mrecord.get("FRAGMENT", "total")
302+
frags.add(frag)
303+
return sorted(frags)
304+
256305
@property
257306
def metadata(self) -> dict:
258307
"""

tests/test_molecules.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# run only on sim2 mocking data
88
pytestmark = [pytest.mark.sim2, pytest.mark.min]
99

10-
LIPIDS_SET_LENGTH = 5
10+
LIPIDS_SET_LENGTH = 6
1111
POPE_MOLECULAR_WEIGHT = 717.5
1212

1313

@@ -33,15 +33,35 @@ def toy_mols_no_mapping() -> dict:
3333
from fairmd.lipids import FMDL_MOL_PATH
3434
from fairmd.lipids.molecules import lipids_set
3535

36-
mol1 = lipids_set.get("POPE")
37-
mol2 = lipids_set.get("POPC")
36+
mol1 = lipids_set.get("BOG")
3837

39-
return {"pope": mol1, "popc": mol2}
38+
return {"bog": mol1}
4039

4140

42-
def tests_molecule_fragments(toy_mols_no_mapping):
43-
pope = toy_mols_no_mapping["pope"]
41+
def tests_molecule_fragments(toy_mols_no_mapping, toy_mols_w_mapping):
42+
bog = toy_mols_no_mapping["bog"]
43+
check.is_instance(bog.fragments, list, "Should return a list of fragment names")
44+
check.equal(bog.fragments, ["glucose", "tail"], "BOG fragments should be ['head', 'tail']")
45+
46+
pope = toy_mols_w_mapping["pope/charmm"]
4447
check.is_instance(pope.fragments, list, "Should return a list of fragment names")
48+
check.equal(
49+
sorted(pope.fragments),
50+
sorted(["headgroup", "glycerol backbone", "sn-1", "sn-2"]),
51+
"POPE fragments are improper",
52+
)
53+
54+
55+
def tests_try_access_nomap(toy_mols_no_mapping):
56+
from fairmd.lipids.molecules import Lipid, MoleculeMappingError
57+
58+
bog: Lipid = toy_mols_no_mapping["bog"]
59+
bog.mapping_dict # should not raise because mapping naming mapping exists
60+
with pytest.raises(MoleculeMappingError, match="MD mapping is not possible"):
61+
_ = bog.uan2selection("M_G0C1_M", "BOG")
62+
# md2uan should also raise
63+
with pytest.raises(MoleculeMappingError, match="MD mapping is not possible"):
64+
_ = bog.md2uan("C1", "BOG")
4565

4666

4767
def test_mapping_dict():

tests/test_molecules_rdkit.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,39 @@ def test_lipid_smarts():
3030
with check.raises(KeyError):
3131
popc.atoms_by("N", 1) # 0 is max here
3232

33+
popc.register_mapping("mappingPOPCcharmm.yaml")
34+
3335
# Test that we can retrieve atoms by SMARTS
3436
nitrg_atoms = popc.atoms_by("N", 0) # can call
3537
check.is_instance(nitrg_atoms, list, "Should return a list of atom universal names")
3638
check.equal(len(nitrg_atoms), 1, "Should have at least one nitrogen atom")
3739
check.equal(nitrg_atoms[0], "M_G3N6_M", "Bad atom selected by N from POPC")
40+
41+
42+
def test_lipid_nomapping():
43+
"""Test atoms_by() method with SMARTS queries."""
44+
from fairmd.lipids.molecules import Lipid, MoleculeError
45+
46+
popc = Lipid("BOG")
47+
_ = popc.rdkit_object # rdkit object can be created without mapping
48+
49+
_save = popc._metadata["bioschema_properties"]["smiles"]
50+
del popc._metadata["bioschema_properties"]["smiles"]
51+
with check.raises(MoleculeError):
52+
_ = popc.rdkit_object # rdkit object cannot be created without smiles
53+
popc._metadata["bioschema_properties"]["smiles"] = _save
54+
55+
with check.raises(ValueError):
56+
popc.atoms_by("~!x[", 0)
57+
58+
with check.raises(KeyError):
59+
popc.atoms_by("N", -1)
60+
61+
with check.raises(KeyError):
62+
popc.atoms_by("N", 1) # 0 is max here
63+
64+
# Test that we can retrieve atoms by SMARTS
65+
ox_atoms = popc.atoms_by("O", 0) # can call
66+
check.is_instance(ox_atoms, list, "Should return a list of atom universal names")
67+
check.equal(len(ox_atoms), 6, "Should have 6 oxygen atoms")
68+
check.equal(ox_atoms[0], "M_G0C1O1_M", "Bad atom selected by O from BOG")

0 commit comments

Comments
 (0)