Skip to content

Commit d74e924

Browse files
bond_list parsing and test
1 parent c8f4c68 commit d74e924

File tree

3 files changed

+181
-7
lines changed

3 files changed

+181
-7
lines changed

src/nomad_simulation_parsers/parsers/gromacs/parser.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -539,17 +539,44 @@ def get_outputs(self) -> list[dict[str, Any]]:
539539
outputs.append(data)
540540
return outputs
541541

542+
def get_bond_list(self) -> np.ndarray | None:
543+
"""
544+
Extract bond list from MDAnalysis interactions.
545+
Returns numpy array of shape (n_bonds, 2) with atom indices.
546+
"""
547+
result = None
548+
interactions = self.data_object.get_interactions()
549+
if not interactions:
550+
return result
551+
552+
# Filter for bond interactions only
553+
bonds = []
554+
for interaction in interactions:
555+
if interaction.get('type') == 'bond':
556+
atom_indices = interaction.get('atom_indices')
557+
if atom_indices is not None and len(atom_indices) == 2:
558+
bonds.append(list(atom_indices))
559+
560+
if bonds:
561+
result = np.array(bonds, dtype=int)
562+
563+
return result
564+
542565
def get_configurations(self) -> list[dict[str, Any]]:
543566
configurations = []
544567
for n, _ in enumerate(self._trajectory_steps_sampled):
545-
configurations.append(
546-
dict(
547-
labels=self.get_atom_labels(n),
548-
positions=self.data_object.get_positions(n),
549-
velocities=self.data_object.get_velocities(n),
550-
lattice_vectors=self.data_object.get_lattice_vectors(n),
551-
)
568+
config = dict(
569+
labels=self.get_atom_labels(n),
570+
positions=self.data_object.get_positions(n),
571+
velocities=self.data_object.get_velocities(n),
572+
lattice_vectors=self.data_object.get_lattice_vectors(n),
552573
)
574+
# Add bond_list only to first configuration (topology is time-independent)
575+
if n == 0:
576+
bond_list = self.get_bond_list()
577+
if bond_list is not None:
578+
config['bond_list'] = bond_list
579+
configurations.append(config)
553580
return configurations
554581

555582
def get_force_field_contributions(self) -> list[dict[str, Any]]:

src/nomad_simulation_parsers/schema_packages/gromacs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class AtomicCell(model_system.Representation):
4747
class ModelSystem(model_system.ModelSystem):
4848
add_mapping_annotation(model_system.ModelSystem.velocities, TPR_KEY, '.velocities')
4949
add_mapping_annotation(model_system.ModelSystem.positions, TPR_KEY, '.positions')
50+
add_mapping_annotation(model_system.ModelSystem.bond_list, TPR_KEY, '.bond_list')
5051
add_mapping_annotation(model_system.AtomsState.m_def, TPR_KEY, '.labels')
5152
add_mapping_annotation(model_system.Representation.m_def, LOG_KEY, '.@')
5253
add_mapping_annotation(model_system.Representation.m_def, TPR_KEY, '.@')

tests/parsers/test_gromacs_parser.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ def get_atom_labels(self, idx):
3131
n = int(np.asarray(self._positions[idx]).shape[0])
3232
return ['H'] * n
3333

34+
def get_interactions(self):
35+
# Return empty list for stub (no bonds by default)
36+
return []
37+
3438

3539
@pytest.fixture
3640
def simple_mdanalysis_parser():
@@ -358,6 +362,148 @@ def test_get_coordinate_save_frequency():
358362
assert lp.get_coordinate_save_frequency(None) is None
359363

360364

365+
def test_get_bond_list():
366+
"""Test bond list extraction from MDAnalysis interactions."""
367+
mdap = gromacs_parser.GromacsMDAnalysisParser()
368+
369+
# Test with bond interactions
370+
mock_interactions = [
371+
{'type': 'bond', 'atom_indices': [0, 1], 'atom_labels': ['O', 'H']},
372+
{'type': 'bond', 'atom_indices': [0, 2], 'atom_labels': ['O', 'H']},
373+
{'type': 'bond', 'atom_indices': [3, 4], 'atom_labels': ['O', 'H']},
374+
{'type': 'angle', 'atom_indices': [1, 0, 2], 'atom_labels': ['H', 'O', 'H']},
375+
]
376+
377+
mdap.data_object = type(
378+
'obj', (object,), {'get_interactions': lambda self: mock_interactions}
379+
)()
380+
381+
bond_list = mdap.get_bond_list()
382+
383+
assert bond_list is not None
384+
assert isinstance(bond_list, np.ndarray)
385+
assert bond_list.shape == (3, 2)
386+
assert np.array_equal(bond_list[0], [0, 1])
387+
assert np.array_equal(bond_list[1], [0, 2])
388+
assert np.array_equal(bond_list[2], [3, 4])
389+
390+
391+
def test_get_bond_list_no_bonds():
392+
"""Test bond list extraction when no bonds are present."""
393+
mdap = gromacs_parser.GromacsMDAnalysisParser()
394+
395+
# Only angles, no bonds
396+
mock_interactions = [
397+
{'type': 'angle', 'atom_indices': [0, 1, 2], 'atom_labels': ['H', 'O', 'H']},
398+
]
399+
400+
mdap.data_object = type(
401+
'obj', (object,), {'get_interactions': lambda self: mock_interactions}
402+
)()
403+
404+
bond_list = mdap.get_bond_list()
405+
assert bond_list is None
406+
407+
408+
def test_get_bond_list_empty_interactions():
409+
"""Test bond list extraction with empty interactions."""
410+
mdap = gromacs_parser.GromacsMDAnalysisParser()
411+
412+
mdap.data_object = type('obj', (object,), {'get_interactions': lambda self: []})()
413+
414+
bond_list = mdap.get_bond_list()
415+
assert bond_list is None
416+
417+
418+
def test_get_bond_list_invalid_bond_indices():
419+
"""Test bond list extraction filters out invalid bond entries."""
420+
mdap = gromacs_parser.GromacsMDAnalysisParser()
421+
422+
# Mix of valid and invalid bond interactions
423+
mock_interactions = [
424+
{'type': 'bond', 'atom_indices': [0, 1], 'atom_labels': ['O', 'H']},
425+
{'type': 'bond', 'atom_indices': None, 'atom_labels': ['O', 'H']},
426+
{'type': 'bond', 'atom_indices': [2, 3, 4], 'atom_labels': ['O', 'H', 'C']},
427+
{'type': 'bond', 'atom_indices': [4, 5], 'atom_labels': ['O', 'H']},
428+
]
429+
430+
mdap.data_object = type(
431+
'obj', (object,), {'get_interactions': lambda self: mock_interactions}
432+
)()
433+
434+
bond_list = mdap.get_bond_list()
435+
436+
assert bond_list is not None
437+
assert bond_list.shape == (2, 2)
438+
assert np.array_equal(bond_list[0], [0, 1])
439+
assert np.array_equal(bond_list[1], [4, 5])
440+
441+
442+
def test_integration_bond_list_in_parsed_system():
443+
"""Test that bond_list is populated in parsed model_system from TPR file."""
444+
base = Path(__file__).parent.parent / 'data' / 'gromacs' / 'water'
445+
tpr_file = os.path.join(base, 'topol.tpr')
446+
447+
if not os.path.exists(tpr_file):
448+
pytest.skip(f'TPR file not found: {tpr_file}')
449+
450+
archive = EntryArchive()
451+
parser = gromacs_parser.GromacsParser()
452+
parser.parse(tpr_file, archive)
453+
454+
assert archive.data is not None
455+
assert len(archive.data.model_system) > 0
456+
457+
system = archive.data.model_system[0]
458+
assert system.bond_list is not None, 'Bond list should be populated from TPR'
459+
assert isinstance(system.bond_list, np.ndarray)
460+
assert system.bond_list.shape[1] == 2, 'Bond list should have shape (n_bonds, 2)'
461+
assert system.bond_list.shape[0] > 0, 'Should have at least one bond'
462+
# Water molecules have 2 O-H bonds each (432 bonds for 144 water molecules)
463+
assert system.bond_list.shape[0] == 432
464+
465+
466+
def test_get_configurations_includes_bond_list():
467+
"""Test that get_configurations includes bond_list in first frame only."""
468+
mdap = gromacs_parser.GromacsMDAnalysisParser()
469+
mdap._trajectory_steps_sampled = [0, 1]
470+
471+
# Mock interactions with bonds
472+
mock_interactions = [
473+
{'type': 'bond', 'atom_indices': [0, 1], 'atom_labels': ['O', 'H']},
474+
{'type': 'bond', 'atom_indices': [0, 2], 'atom_labels': ['O', 'H']},
475+
]
476+
477+
# Mock data object
478+
class MockDataObject:
479+
def get_positions(self, idx):
480+
return np.zeros((3, 3))
481+
482+
def get_velocities(self, idx):
483+
return np.zeros((3, 3))
484+
485+
def get_lattice_vectors(self, idx):
486+
return np.eye(3)
487+
488+
def get_atom_labels(self, idx):
489+
return ['O', 'H', 'H']
490+
491+
def get_interactions(self):
492+
return mock_interactions
493+
494+
mdap.data_object = MockDataObject()
495+
496+
configs = mdap.get_configurations()
497+
498+
assert len(configs) == 2
499+
# First configuration should have bond_list
500+
assert 'bond_list' in configs[0]
501+
assert isinstance(configs[0]['bond_list'], np.ndarray)
502+
assert configs[0]['bond_list'].shape == (2, 2)
503+
# Second configuration should NOT have bond_list (topology is time-independent)
504+
assert 'bond_list' not in configs[1]
505+
506+
361507
def test_get_thermodynamic_ensemble():
362508
"""Test ensemble determination from thermostat and barostat settings."""
363509
lp = gromacs_parser.GromacsLogParser()

0 commit comments

Comments
 (0)