Reading, inspecting and writing CIF files¶
The Crystallographic Information File (CIF) format is the standard exchange
format for crystal structures. httk-io provides a low-level CIF stack that
treats a CIF as what it literally is — a sequence of named data blocks, each a
set of tag/value pairs plus tabular loop_ sections — and refuses to guess what
any of it means. Interpreting the tags (turning a cell and an asymmetric unit
into a structure) is a separate, higher-level job.
Three pieces make up that stack, and this example walks through all of them.
read_cif(source)¶
Parses a CIF and returns a (data_blocks, header) tuple. data_blocks is a
list of (name, block) pairs — CIF permits several data_ blocks in one file,
so this is a list, not a dict. header is the run of comment lines at the very
top of the file, kept verbatim so that it survives a round trip.
A block is a flat mapping from lowercased tag name without its leading
underscore to value. Two rules make that mapping worth knowing:
Values are strings.
_cell_length_a 5.6402(3)becomes the string"5.6402(3)", not a float. Nothing is rounded, nothing is discarded, and the parenthesised standard uncertainty is still there for whoever wants it.Loops become columns. Each
loop_contributes one list-valued entry per column, all of equal length, plus a bookkeeping keyloop_0,loop_1, … listing that loop’s column names in file order. Those bookkeeping keys are what letwrite_cifreconstruct the loops, and what let a reader tell a one-row loop from a plain tag.
source may be a filename (str or os.PathLike), an already-open text
stream, or any iterable of lines. Filenames are opened through
httk.core.TextstreamFileView, so structure.cif.bz2 and structure.cif.gz
are decompressed transparently.
parse_cif_float(token) and parse_cif_int(token)¶
These convert one of the preserved strings into a number, honouring the numeric conventions CIF actually uses:
"5.6402(3)"— a value with a standard uncertainty (esd) on its last digits. The plain call returns just the central value5.6402; passingmeta=Truereturns(value, {"esd": ..., "precision": ...}), whereesdis the uncertainty in the value’s own units (3/10000here) andprecisionis the precision implied by how the number was written (0.0001for four decimals). The two are independent: a value with no esd still has a stated precision."?"— “unknown”, a legal CIF value. It parses toNonerather than raising, so a missing measurement stays representable. Its sibling"."(“not applicable”) is an error instead: there is no number to return."1/3"— a fraction, common in symmetry operations. Parsed exactly and reported with precisionNone, i.e. a stated value rather than a rounded decimal.
parse_cif_int follows the same conventions and returns the integral central
value: "295(2)" is 295, "3E2" is 300. It is strict by default, so a
genuinely fractional token such as "1.5" raises ValueError instead of
silently rounding.
write_cif(target, data_blocks, header)¶
Writes the parsed structure back out: the header comments first, then each block
as data_<name> followed by its tags and its reconstructed loops. Values the
writer cannot recognise as bare CIF numbers are quoted, which is why an
esd-bearing string such as 5.6402(3) comes back out as '5.6402(3)' —
re-reading strips the quotes, so the value is unchanged. Demonstrating that is
the point of the round-trip section below: read → write → read yields an
identical mapping.
The final section shows the same file arriving through httk.core.load, which
dispatches .cif (and .cif.bz2) to httk-io because importing httk.core
discovers the httk.registry.io.io registry package. By default, load(path) returns
the native ASUStructure when httk-atomistic is installed. This example uses
load(path, raw=True), which returns the neutral payload mapping instead. Blocks
that are not structures are reported rather than raising, since a CIF may hold
anything.
Run this file to see every step printed.
import bz2
import tempfile
from pathlib import Path
from typing import Any
import httk.core
from httk.core.register import known_extensions
from httk.io.cif.cif_parser import parse_cif_float, parse_cif_int
from httk.io.cif.cif_reader import read_cif
from httk.io.cif.cif_writer import write_cif
# A small, deliberately messy CIF: two header comment lines, mixed-case tags,
# quoted strings, values carrying standard uncertainties, an unknown value ('?'),
# and two loops of different widths.
NACL_CIF = """# Sodium chloride, rock-salt structure
# Toy data for the httk-io example -- not from a real refinement.
data_nacl
_chemical_formula_sum 'Na Cl'
_cell_length_a 5.6402(3)
_cell_length_b 5.6402(3)
_cell_length_c 5.6402(3)
_cell_angle_alpha 90.0
_cell_volume 179.43(2)
_cell_measurement_temperature 295(2)
_symmetry_space_group_name_H-M 'F m -3 m'
_symmetry_Int_Tables_number 225
_diffrn_ambient_pressure ?
loop_
_atom_site_label
_atom_site_type_symbol
_atom_site_fract_x
_atom_site_fract_y
_atom_site_fract_z
_atom_site_occupancy
Na1 Na 0.0 0.0 0.0 1.0
Cl1 Cl 0.5 0.5 0.5 1.0
loop_
_symmetry_equiv_pos_as_xyz
'x, y, z'
'-x, -y, z'
'x+1/2, y+1/2, z'
"""
def show_structure(data_blocks: list[Any], header: str) -> dict[str, Any]:
"""What `read_cif` hands back: the header, the blocks, the tags and the loops."""
print("== read_cif: the (data_blocks, header) tuple ==")
print("header lines: ", header.splitlines())
print("number of data blocks:", len(data_blocks))
name, block = data_blocks[0]
print("first block name: ", repr(name), "(the file's 'data_nacl', lowercased)")
# A block is flat: loop bookkeeping keys name the loop columns, and every
# remaining key that is not itself a loop column is a plain tag.
loop_keys = [key for key in block if key.startswith("loop_")]
loop_columns = {column for key in loop_keys for column in block[key]}
tags = [key for key in block if key not in loop_keys and key not in loop_columns]
print("\n-- plain tags (every value is a string) --")
for tag in tags:
print(f" {tag:<32} {block[tag]!r}")
print("\n-- loops (one list-valued key per column, plus the loop_N key) --")
for key in loop_keys:
columns = block[key]
print(f" {key} -> {columns}")
for row in zip(*(block[column] for column in columns)):
print(" ", " ".join(repr(value) for value in row))
print("\nTag names are normalised: the file writes _symmetry_space_group_name_H-M,")
print("the block stores", repr("symmetry_space_group_name_h-m"), "-- lowercased, underscore stripped.")
print()
return block
def show_numeric_fields(block: dict[str, Any]) -> None:
"""CIF numbers carry uncertainties and special values; the parsers know it."""
print("== parse_cif_float / parse_cif_int: CIF numeric conventions ==")
a_token = block["cell_length_a"]
a_value, a_meta = parse_cif_float(a_token, meta=True)
print(f"cell_length_a {a_token!r}")
print(f" value = {a_value}")
print(f" esd = {a_meta['esd']} (the '(3)' applies to the last digit: 0.0003 Angstrom)")
print(f" precision = {a_meta['precision']} (implied by writing four decimals)")
print(f" plain call, no meta: parse_cif_float({a_token!r}) -> {parse_cif_float(a_token)}")
t_token = block["cell_measurement_temperature"]
t_value, t_meta = parse_cif_float(t_token, meta=True)
print(f"temperature {t_token!r} -> {t_value} +/- {t_meta['esd']} K")
angle_token = block["cell_angle_alpha"]
angle_value, angle_meta = parse_cif_float(angle_token, meta=True)
print(
f"angle_alpha {angle_token!r} -> {angle_value}, esd {angle_meta['esd']}, "
f"precision {angle_meta['precision']} (no esd, but a stated precision)"
)
unknown_token = block["diffrn_ambient_pressure"]
print(f"unknown value {unknown_token!r} -> {parse_cif_float(unknown_token)} (CIF '?' means 'unknown')")
print("'.' (not applicable) has no numeric value at all: ", end="")
try:
parse_cif_float(".")
except Exception as exc: # the parser raises a bare Exception here; showing it is the point
print(f"{type(exc).__name__}: {exc}")
third = parse_cif_float("1/3")
_, third_meta = parse_cif_float("1/3", meta=True)
print(f"fraction '1/3' -> {third} (precision {third_meta['precision']}: exact, not a rounded decimal)")
print("\n-- parse_cif_int: the integral central value --")
tables_number = block["symmetry_int_tables_number"]
print(f" {tables_number!r:<8} -> {parse_cif_int(tables_number)} (the space-group number, as an int)")
print(f" {'295(2)'!r:<8} -> {parse_cif_int('295(2)')} (the esd is dropped)")
print(f" {'3E2'!r:<8} -> {parse_cif_int('3E2')} (exponents are honoured)")
print(f" {'1.5'!r:<8} -> ", end="")
try:
parse_cif_int("1.5")
except ValueError as exc:
print(f"ValueError: {exc}")
print()
def show_roundtrip(directory: Path, data_blocks: list[Any], header: str) -> None:
"""read -> write -> read gives back an identical mapping."""
print("== write_cif: writing the parsed data back out ==")
out_path = directory / "nacl-out.cif"
write_cif(str(out_path), data_blocks, header)
print(f"-- {out_path.name} --")
for line in out_path.read_text(encoding="utf-8").splitlines():
print(" |", line)
reread_blocks, reread_header = read_cif(str(out_path))
print()
print("re-read: identical header: ", reread_header == header)
print("re-read: identical block name: ", reread_blocks[0][0] == data_blocks[0][0])
print("re-read: identical block mapping:", dict(reread_blocks[0][1]) == dict(data_blocks[0][1]))
print()
print("The written file quotes the values the writer cannot see as bare numbers")
print("('5.6402(3)', '?'); re-reading strips the quotes, so nothing has changed.")
print()
def show_load_dispatch(directory: Path) -> None:
"""The interpreting reader, reached through httk.core.load — including compressed."""
print("== httk.core.load: the registered '.cif' loader ==")
print("registered extensions:", known_extensions())
print("Importing httk.core discovers httk.registry.io.io, which registers these.")
compressed = directory / "nacl.cif.bz2"
compressed.write_bytes(bz2.compress(NACL_CIF.encode("utf-8")))
payload = httk.core.load(str(compressed), raw=True)
print(f"load({compressed.name!r}) -> format={payload['format']!r}; .bz2 decompressed transparently")
print(" header first line: ", payload["header"].splitlines()[0])
print(" structural blocks: ", len(payload["blocks"]))
for item in payload["unparsed"]:
print(f" not a structure: block {item['block']!r} ({item['reason']})")
print("This block does not carry everything a structure needs, so no asymmetric unit")
print("could be built from it. Loading still succeeds and says why, because a CIF is")
print("not obliged to describe a crystal at all.")
# The tags themselves stay reachable through the low-level tokenizer.
data_blocks, _header = read_cif(str(compressed))
print(" read_cif tags: cell_length_a =", data_blocks[0][1]["cell_length_a"])
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
cif_path = directory / "nacl.cif"
cif_path.write_text(NACL_CIF, encoding="utf-8")
data_blocks, header = read_cif(str(cif_path))
block = show_structure(data_blocks, header)
show_numeric_fields(block)
show_roundtrip(directory, data_blocks, header)
show_load_dispatch(directory)
if __name__ == "__main__":
main()