Skip to content

Reading and writing .mol and .sdf files

V2000 molblocks and multi-record SDF, in both directions and in both dimensionalities. Stereochemistry survives the round trip.

parse_molblock(text) one molblock — the contents of a .mol, or one record of an .sdf
read_sdf(text) every record of an SDF, in order
Mol.to_molblock_2d() write a 2D molblock — layout and wedges are computed for you
Conformer.to_molblock() write a 3D molblock from generated coordinates

Writing 2D

>>> m = omgkit.parse_smiles("C[C@H](N)C(=O)O"); m.sanitize()
>>> print(m.to_molblock_2d(title="L-alanine"))
L-alanine
  omgkit

  6  5  0  0  0  0  0  0  0  0999 V2000
   -1.0000   -0.8660    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
   -0.5000   -0.0000    0.0000 C   0  0  0  0  0  4  0  0  0  0  0  0
   -1.0000    0.8660    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
    0.5000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
    1.0000   -0.8660    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
    1.0000    0.8660    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
  2  1  1  6  0  0  0
  2  3  1  0  0  0  0
  2  4  1  0  0  0  0
  4  5  2  3  0  0  0
  4  6  1  0  0  0  0
M  END

The 6 in the fourth column of the first bond line is a down wedge — that is where the @ went. to_molblock_2d runs the whole chain for you: sanitize, perceive double-bond geometry, lay out coordinates, assign wedges, and write the molecule that was drawn.

A double bond with no configuration is written as crossed

Laying a molecule out gives every double bond a definite geometry — the substituents have to go somewhere. If the input never said whether a bond is cis or trans, writing that geometry without saying "unknown" hands the reader a configuration the author never gave.

So a double bond that could have a configuration but does not gets stereo code 3 (a crossed double bond, the V2000 way of saying either). Bonds that already have a configuration are written with their real geometry, and bonds that cannot have one — inside a small ring, or with the same two substituents on one end — are not marked at all, since "unknown" would be just as untrue there.

This was measured: before the marks were written, 551 of 8831 molecules (6.2%) came back from a round trip carrying cis/trans that the input never had. The gate that watches it is harness/check_molblock_roundtrip.py.

The same applies to 3D: an embedded conformer gives every double bond a definite torsion, so Conformer.to_molblock() marks them too.

The molecule written may have one more atom than the one you passed

Some stereocentres have all three of their heavy bonds inside a ring; the only wedge that can express the configuration is on an explicit C–H, so the layout adds one. That hydrogen is in the file, and the wedge is on it. A centre whose configuration cannot be drawn is left unmarked rather than given an arbitrary wedge.

Writing 3D

Conformer.to_molblock() writes generated coordinates. There the stereochemistry is in the coordinates themselves and the wedge column is empty. Both kinds of file are valid; a reader tells them apart by whether any z is non-zero.

Reading

>>> block = m.to_molblock_2d(title="L-alanine")   # or: open("L-alanine.mol").read()
>>> rec = omgkit.parse_molblock(block)
>>> rec
<omgkit.Molblock 6 atoms 2D "L-alanine">
>>> rec.is_3d, rec.title
(False, 'L-alanine')
>>> rec.mol.to_canonical_smiles()
'C[C@@H](C(O)=O)N'
>>> rec.coords[0]
(-1.0, -0.866, 0.0)

Molblock keeps the molecule and its coordinates together on purpose. Half the stereochemistry of a molblock lives in the coordinates and wedges, which are not part of Mol; handing back only the molecule would drop that half without a sound.

Stereochemistry, both dimensionalities

Reading stereochemistry off a file takes two completely separate routes, and which one runs is decided by the coordinates rather than by the caller:

chirality double-bond geometry
2D the wedge bonds which side of the double-bond axis each reference atom is on
3D the signed volume of the four ligands the sign of the torsion
>>> conf = omgkit.parse_smiles("C[C@H](N)C(=O)O").conformer()
>>> back = omgkit.parse_molblock(conf.to_molblock())
>>> back.is_3d
True
>>> back.mol.to_canonical_smiles() == conf.mol.to_canonical_smiles()
True

Comparing a round trip: call remove_hs() first

Writing may add an explicit hydrogen to carry a wedge, so the molecule that comes back can have one more atom than the one you wrote. Merge those back before comparing:

back = omgkit.parse_molblock(m.to_molblock_2d()).mol
back.remove_hs()
back.to_canonical_smiles() == m.to_canonical_smiles()

Across the 8831-molecule corpus this holds for 8829; the two that differ are trivalent phosphorus, where omgkit reads a centre the external implementation does not.

Bonds the file marks as explicitly unknown — a crossed double bond, a wavy single bond — are left unassigned instead of being read off the drawing. "The author says the configuration is unknown" is information, and turning it into a configuration by measuring the picture destroys it.

It sanitizes for you — and it has to

The other parse functions hand back an un-sanitized molecule and leave the timing to you. This one cannot: reading wedges onto atoms needs implicit-hydrogen counts and symmetry classes, and both come out of sanitization. Leaving that step to the caller would mean one forgotten call silently drops the stereochemistry of a whole file — no error, same atom count, just no @ and no /.

Reading a whole SDF

for i, rec in enumerate(omgkit.read_sdf(open("library.sdf").read())):
    if rec.error:
        print(f"record {i}: {rec.error}")
        continue
    print(rec.block.mol.to_canonical_smiles(), rec.data)

A record that cannot be read does not raise, and does not vanish

Raising would stop at the bad record and throw away everything after it. Skipping would make the count quietly smaller than the file's — the caller counts records, gets a different number, and nothing anywhere reports it.

So every record keeps its place: a bad one has error set to a sentence and block set to None, and the records after it read fine.

>>> one = omgkit.parse_smiles("CCO"); one.sanitize()
>>> two = omgkit.parse_smiles("c1ccccc1"); two.sanitize()
>>> def record(mol, smi):
...     return mol.to_molblock_2d() + "> <SMILES>" + chr(10) + smi + chr(10) * 2 + "$$$$" + chr(10)
>>> text = record(one, "CCO") + record(two, "c1ccccc1")
>>> text += one.to_molblock_2d()[:40]     # third record: cut off mid-file
>>> recs = omgkit.read_sdf(text)          # three records, the last one truncated
>>> [(r.error, r.data) for r in recs]
[(None, [('SMILES', 'CCO')]),
 (None, [('SMILES', 'c1ccccc1')]),
 ('molblock 截断了:缺M  END', [])]

Real files have these. A ferrocene-type complex has more bonds on the metal than V2000 can express, so writers emit V3000 for it — and V3000 is refused here rather than misread as V2000.

Data fields are a list of pairs, not a dict

>>> recs[0].data
[('SMILES', 'CCO')]

Repeated field names do occur — a vendor writing one line per measurement — and a dict would silently keep only the last. Whether the names in your files are unique is your call to make: dict(rec.data) is one line away.

Writing an SDF

An SDF record is a molblock, then data fields, then $$$$:

with open("out.sdf", "w") as f:
    for smi in ["CCO", "c1ccccc1"]:
        m = omgkit.parse_smiles(smi); m.sanitize()
        f.write(m.to_molblock_2d(title=smi))
        f.write(f"> <SMILES>\n{smi}\n\n")
        f.write("$$$$\n")

The Rust API has omgkit_io::molblock::write_sdf_record, which does the same and refuses a data value that would break the record boundary — a blank line or a lone $$$$ inside a value would silently split one record into two.

What is not supported

V3000 is refused, not misread. A V3000 file raises with a message saying so. Writing V3000 is not implemented, so molecules with more than 999 atoms or bonds cannot be written as a molblock at all — that raises too.

Determinism

Writing the same molecule twice gives byte-identical output. The second line of the block is the program name with no timestamp, and the 2D layout has no random component: any SMILES for the same structure gives the same coordinates.