Comparing sparse interactions across structures#

Use this recipe when a detector or another source has already produced chemically classified observations. It compares selected structures and atom sets without building a dense atom-pair matrix. The numbers below are synthetic examples, not scientific detections.

import molsysmt as msm

records = [
    {"structure_index": 0, "interaction_type": "hbond",
     "participants": [
         {"role": "donor", "atom_indices": [0]},
         {"role": "hydrogen", "atom_indices": [1]},
         {"role": "acceptor", "atom_indices": [2]},
     ], "measurements": {"distance": 0.20}},
    {"structure_index": 3, "interaction_type": "pi_pi",
     "participants": [
         {"role": "ring", "atom_indices": [3, 4, 5]},
         {"role": "ring", "atom_indices": [6, 7, 8]},
     ], "measurements": {"distance": 0.36}},
]
interactions = msm.Interactions.from_records(
    records, n_atoms=9, n_structures=5,
    evaluated_structure_indices=[0, 1, 3],
    method="example", measure_units={"distance": "nm"},
)

The selected structures may be nonconsecutive. Repeating structure 3 does not duplicate its occurrence; structure 1 remains present in coverage with zero occurrences. Structure 2 was never evaluated.

Keep these selections as integer indices. A fractional value such as 3.5 raises instead of selecting structure 3. Leave skip_digestion at its default False when accepting input from a user or a file; it is a trusted-delegation option, not a way to repair malformed data. See Querying interaction results.

view = interactions.query(structure_indices=[3, 1, 0, 3])
columns = view.to_dict()
assert columns["structure_indices"].tolist() == [3, 0]
assert columns["occurrence_indices"].tolist() == [1, 0]
assert columns["evaluated_structure_indices"].tolist() == [3, 1, 0]

ring_between = interactions.between_selections([3, 4, 5], [6, 7, 8], exclusive=True)
assert ring_between.n_interactions == 1
assert interactions.query(atom_indices=[3], mode="across_selection_boundary").n_interactions == 1

columns = msm.convert(interactions, to_form="molsysmt.InteractionsDict")
reconstructed = msm.convert(columns, to_form="molsysmt.Interactions")
assert reconstructed.query(structure_indices=[1]).n_interactions == 0

For an inspector, keep view and materialize only the rows being displayed:

page = view.to_page(limit=1)
assert page["occurrence_indices"].tolist() == [1]
assert page["participant_atoms"].tolist() == [3, 4, 5, 6, 7, 8]
assert page["measure_units"]["distance"] == "nm"
next_page = view.to_page(offset=page["next_offset"], limit=1)
assert next_page["occurrence_indices"].tolist() == [0]
assert next_page["next_offset"] is None

The page retains complete-analysis occurrence indices, while its offset counts rows in the query’s order. Count and evaluated coverage remain available even for empty pages. A participant-atom budget guards large compound groups before copying them. See Inspecting bounded pages for the compact relation catalog and the separate query/index memory costs.

Attach the complete result to a native molecular system to keep its atom and structure index spaces together. H5MSM 0.5 stores the named analysis alongside the structures. The same query works after loading it:

import numpy as np
from pathlib import Path
from tempfile import TemporaryDirectory
from molsysmt.native import MolSys, Structures

molsys = MolSys(n_atoms=9)
molsys.structures = Structures(
    coordinates=msm.pyunitwizard.quantity(np.zeros((5, 9, 3)), "nm")
)
molsys.interactions = {"example": interactions}

with TemporaryDirectory() as directory:
    filename = str(Path(directory) / "observations.h5msm")
    msm.h5msm.write(molsys, filename)
    loaded = msm.h5msm.read(filename)
    stored = loaded.interactions["example"]
    assert stored.query(structure_indices=[3, 1, 0, 3]).to_dict()[
        "structure_indices"
    ].tolist() == [3, 0]
    assert stored.query(structure_indices=[1]).to_dict()[
        "evaluated_structure_indices"
    ].tolist() == [1]
    assert stored.query(structure_indices=[2]).to_dict()[
        "evaluated_structure_indices"
    ].size == 0

    analysis_file = str(Path(directory) / "analysis_only.h5msm")
    msm.h5msm.write_layers(
        analysis_file, interactions={"example": interactions}
    )
    partial = msm.h5msm.read(analysis_file)
    assert partial.topology is None
    assert partial.structures is None
    subset = partial.extract(
        atom_indices=[2, 0, 1], structure_indices=[3, 0]
    )
    assert subset.interactions["example"].query(
        structure_indices=[1]
    ).n_interactions == 1

read_layers(filename, layers="interactions") can load the interaction layer without the other layers. Both read and read_layers currently materialize the selected interaction result in memory; they are not file-backed query objects.

Editing a structure#

An atom that had no previous interactions can acquire one after moving. Reset the evaluated status of the affected frames before a new calculation. Native MolSys coordinate and box setters do this for all attached named analyses, even for a frame previously evaluated with no observations:

molsys_edited = molsys.copy()
previous_view = molsys_edited.interactions["example"].query(structure_indices=[3])
msm.set(molsys_edited, selection=[0], structure_indices=[1],
        coordinates=msm.pyunitwizard.quantity([[[0.1, 0.0, 0.0]]], "nm"))
current = molsys_edited.interactions["example"]
assert current.query(structure_indices=[1]).n_interactions == 0
assert current.query(structure_indices=[1]).to_dict()[
    "evaluated_structure_indices"
].size == 0  # It now needs a calculation.
assert current.query(structure_indices=[3]).n_interactions == 1
assert previous_view.n_interactions == 1

with TemporaryDirectory() as directory:
    filename = str(Path(directory) / "edited.h5msm")
    msm.h5msm.write(molsys_edited, filename)
    restored = msm.h5msm.read(filename).interactions["example"]
    assert restored.evaluated_structure_indices.tolist() == [0, 3]
    assert restored.query(structure_indices=[1]).to_dict()[
        "evaluated_structure_indices"
    ].size == 0

Now simulate a recalculation of frame 1 with one synthetic hydrogen bond:

fresh_record = {**records[0], "structure_index": 1,
                "measurements": {"distance": 0.24}}
fresh = msm.Interactions.from_records(
    [fresh_record], n_atoms=9, n_structures=5,
    evaluated_structure_indices=[1], method="example",
    measure_units={"distance": "nm"},
)
updated = current.replace_structures(fresh)
molsys_edited.interactions = {"example": updated}
assert updated.query(structure_indices=[3, 1, 0]).to_dict()[
    "occurrence_indices"
].tolist() == [2, 1, 0]
assert updated.query(atom_indices=[2]).n_interactions == 2
assert previous_view.n_interactions == 1
compacted = updated.compact()
assert compacted.query(structure_indices=[3, 1, 0]).to_dict()[
    "occurrence_indices"
].tolist() == [2, 1, 0]
molsys_edited.interactions = {"example": compacted}
with TemporaryDirectory() as directory:
    filename = str(Path(directory) / "recalculated.h5msm")
    msm.convert(molsys_edited, to_form="file:h5msm", output_filename=filename)
    restored = msm.convert(filename, to_form="molsysmt.MolSys").interactions["example"]
    assert restored.query(structure_indices=[3, 1, 0]).to_dict()[
        "occurrence_indices"
    ].tolist() == [2, 1, 0]

replace_structures replaces every frame evaluated by the new result, including frames recalculated with zero observations. It checks axes, source maps, method, scientific parameters, producer versions, units and atom scope. Execution mode and block counts may differ; restored.execution_records preserves the producing calculation for each evaluated frame, including empty ones. Recalculation must retain the original system axes and scope; an extracted frame has different local axes. The unchanged observation blocks are shared. New occurrence handles belong to the updated version; old views remain snapshots of the earlier result. The recipe explicitly attaches updated.compact() to release references to retired row blocks while preserving the updated handles. Release updated, current and any older views too if you want their buffers reclaimed. Building the compacted result requires memory for new columns; unused relation definitions stay in its catalog. Compaction is optional before saving.

Frame invalidation shares read-only columns and does not recalculate anything. Selected queries and HDF5 saving avoid packing the complete surviving analysis. Saving traverses active blocks in numeric windows and preserves any existing complete-column cache. Typed dictionary export, pickle and remapping may still materialize all active rows temporarily. Earlier snapshots can keep shared storage alive. For direct domain/array writes, explicit invalidation and recalculation/attachment rules, see Changing coordinates.

Attaching a separately loaded analysis declares that its local atom and structure indices correspond to the target system. You are responsible for that correspondence; matching axis sizes and a source_id label do not prove it. Align reordered axes before attachment using a supported remap or extraction. The association guide explains the current checks and limits. The writer is responsible for the correspondence of system and analyses stored together in H5MSM.

Inspect stored.software or a query projection’s "software" field to recover the versions that produced the observations. MolSysMT’s hydrogen-bond and disulfide adapters capture their version during calculation; H5MSM preserves it even when saved or loaded by another version. {} means the producer version was not recorded, as in these manually constructed synthetic records.

Detector-produced analyses also retain a compact bibliography in analysis.parameters["attribution"]. H5MSM preserves the original references and producer versions independently of whether Ackredit is installed when reading the file. Loading does not credit a new calculation. The synthetic records in this recipe have no attribution because no scientific detector was run. See Methods and attribution for method/profile names and optional workflow reporting. If optional tracking fails, the scientific result and detached bibliography remain available, including under warnings-as-errors. A failed attribution diagnostic uses a fallback log carrying its signal code and both error reasons. Inspect that diagnostic rather than assuming session tracking succeeded. Scientific errors and unrelated warnings retain their usual behavior.

The synthetic records above show the container without requiring a detector. For a molecular system with eligible cysteine sulfur atoms, the disulfide candidate detector can instead return an Interactions result with output_type="molsysmt.Interactions". Attach that result under a name in molsys.interactions before writing H5MSM 0.5. Its geometric S–S candidates do not assert that a covalent bond exists in the chemical state.

msm.interactions.hbonds.get_buch_hbonds(..., output_type="molsysmt.Interactions") supplies the same workflow for Buch hydrogen bonds, with donor, hydrogen, and acceptor roles and H-A distances in nm. Coverage includes evaluated frames with no bonds. A covalently attached donor hydrogen remains a participant even if the atom selection named only its donor. The optional result supports automatic roles in one selection or two disjoint participant universes; it does not yet stream large trajectories.

msm.interactions.hbonds.get_luzard_chandler_hbonds(..., output_type="molsysmt.Interactions") returns the same sparse result contract, with D-A distance in nm and H-D-A angle in rad. Both D-H and D-A periodic images use the donor as reference and reproduce the angular calculation. It preserves empty evaluated frames and the calculation-time software version.

Use the interaction result guide for role, unit, and evaluated atom-scope conventions. The example uses the default internal scope over all nine atoms. For a detector restricted to one selection, pass its actual evaluation_mode, selected atoms, and searched atom universe to from_records. A complete result can be attached to MolSys under an analysis name; extraction remaps its atom and structure indices. Geometry edits through msm.set(molsys, coordinates=..., structure_indices=[...]) or its box setter automatically replace affected named results with snapshots whose edited frames are unevaluated. They remove old observations and preserve untouched frames and previous query views. This is conservative per-structure invalidation, even when only one atom moved; it does not calculate new results. If you edit molsys.structures separately, explicitly call analysis.invalidate_structures([structure_index]) and replace each affected named result before re-evaluating. An unevaluated frame differs from one evaluated with zero observations.

The synthetic system above demonstrates the automatic boundary:

old_view = molsys.interactions['example'].query(structure_indices=[0])
msm.set(molsys, selection=[2], structure_indices=[0],
        coordinates=msm.pyunitwizard.quantity([[[1, 0, 0]]], 'nm'))
updated = molsys.interactions['example']
assert updated.query(structure_indices=[0]).to_dict()[
    'evaluated_structure_indices'
].size == 0
assert updated.query(structure_indices=[1]).to_dict()[
    'evaluated_structure_indices'
].tolist() == [1]
assert updated.query(structure_indices=[3]).n_interactions == 1
assert old_view.n_interactions == 1

Chemical-state assignments have a wider scope. Changing a formal charge or aromaticity through msm.set(molsys, ...) invalidates every covered structure in every named analysis. Replacing molsys.chemical_states does the same. The library does not infer which methods depend on the edited assignment. Changing structure_chemical_state_index instead invalidates only the selected structures. Bond identifiers and empty selections preserve analyses.

The records in this recipe remain synthetic; this charge edit illustrates invalidation without assigning a scientific interpretation to those records:

previous_ring_view = molsys.interactions['example'].query(structure_indices=[3])
msm.set(molsys, element='atom', selection=[0], formal_charge=0)
assert molsys.interactions['example'].evaluated_structure_indices.size == 0
assert molsys.interactions['example'].n_interactions == 0
assert previous_ring_view.n_interactions == 1

Direct Topology/ChemicalStates aliases, topology replacement, mechanics changes and raw table/array writes need explicit owner invalidation. Use analysis.invalidate_structures(...) to replace each affected named result before calculating new evidence.

An observed proximity is separate from the chemical state’s covalent graph. For example, explicitly adding a covalent bond changes the ChemicalStates domain and the compatible Topology.bonds view together:

from molsysmt.native import MolSys

molsys = MolSys(n_atoms=2)
molsys.topology.add_bonds([[0, 1]])
assert molsys.chemical_states.get_bonds() is molsys.topology.bonds