3. XTC文件结构概览

说实话,XTC文件的结构并不复杂,但你把它拆开看的时候,细节还挺多的。我个人习惯先把整体骨架画出来,再一个个填肉。这一章我们就干这件事——先搞清楚XTC文件从哪开始、到哪结束,每个部分长什么样。

3.1 整体布局

XTC是一个序列化的二进制轨道文件。你想想看,它里面存的是分子模拟的一帧又一帧坐标。每帧都包含:这一帧的时间步信息、周期性盒子(Box)、原子个数,然后是一大坨压缩后的坐标数据。文件开头有个文件头,告诉解析器“嘿,这是个XTC文件”。

我最早读这个文件时,对着hexdump看了半天,发现它并不像文本格式那样一行一行排列,而是一段连续二进制流。所以我把它的结构画成了一张流程图,你们一眼就能看明白。

XTC文件整体结构 文件头 (魔数+版本+预留) 时间步 1 ~ N(每帧重复) 时间步信息 step / time / box 原子数目 natoms (int32) 坐标数据块 压缩后的 XYZ 坐标 (float32 × 3 × natoms) 循环直到文件尾 EOF 每个时间步都包含上述三部分(顺序固定)

从上图可以看出,文件只包含一个文件头,后面跟着多个重复的时间步帧。每一帧里,先读取时间步信息,再读原子数目,最后读坐标。我当年调试时犯过一个低级错误:以为原子数目在整个文件里只出现一次,结果解析到第二帧的时候坐标数量全乱了。嗯,这里要注意——每帧都独立存了 natoms,所以原子数可以在不同帧之间变化(虽然实际模拟很少变)。

3.2 文件头(File Header)

文件头是入口。标准XTC文件的前几个字节长这样:

偏移(字节)字段类型说明
0magic numberuint32固定值 1995(小端)
4版本号uint32通常为 0(早期)或 1,未严格使用
8文件大小uint32从文件头后到文件尾的字节数(可选)
12预留/填充uint32 × 3通常是 0xFFFFFFFF,忽略即可

我曾经在项目中拿到一个非标准的XTC,魔数被写成了 1994,解析器直接报错。排查了俩小时才发现是上游工具写文件时字节序没处理好。所以第一步一定要校验魔数,不然后面全是垃圾数据。

读取文件头的示例代码:
import struct

with open('traj.xtc', 'rb') as f:
    data = f.read(24)
    magic, ver, file_size, zero1, zero2, zero3 = struct.unpack('<IIIIII', data)
    assert magic == 1995, f"Not a valid XTC file, magic={magic}"

3.3 原子数目(natoms)

原子数目是紧随文件头后、每一帧里都会出现的int32值。它决定后续坐标数据块包含多少个原子的XYZ三元组。我个人习惯在解析开始时,先读第一个帧的natoms作为“期望原子数”,然后后面每帧都做一次校验。

注意:natoms在文件中以小端序(little-endian)存储。如果在大端机器上直接读,会得到一个天文数字,然后你的内存可能瞬间爆掉。

举个例子,如果读取到的int32是 10000,那么坐标数据块里应该有 10000 × 3 = 30000 个float32。

3.4 时间步信息(Step, Time, Box)

每个帧的第一步就是读这个结构。它描述当前帧的模拟步数、模拟时间以及周期性盒子的大小。盒子是一个 3×3 的矩阵,用 9 个float64 存储。

# 读取一个时间步信息的例子
import struct

def read_frame_header(f):
    # step (int32), time (float32), box (3×3 float64)
    header = f.read(4 + 4 + 9*8)
    step, time = struct.unpack('<If', header[:8])
    box = struct.unpack('<9d', header[8:])
    # box 是 9 个 double,按列优先排列
    return step, time, box

你可能会问:为什么box用double而坐标用float?这是历史原因,GROMACS在早期设计时为了精度保留盒子为双精度,而坐标为了节省空间用了单精度。我刚开始写解析器时,box用float32去读,结果盒子向量变形导致了轨迹的周期性镜像全错,那个bug花了我一个周末。所以千万别省这8字节。

3.5 坐标数据块

坐标数据块是整个文件最核心也最昂贵的部分。它包含 natoms × 3 个float32,但是!XTC为了减小体积,对这些坐标做了 整型缩放+位压缩。直接裸读是读不出来的。这部分我们会在第7章详细讲压缩算法,这里你只需要知道:

  • 每个帧的坐标块之前,先有一个int32 size 表示压缩后的字节数。
  • 然后是压缩后的二进制流,需要解压才能得到原始float32坐标。
  • 解压时需要用到上一帧的坐标做参考(或者第一帧用绝对坐标)。
知识点:XTC坐标压缩是“无损”的(实际有可忽略的精度损失),但比直接存float32小 40%~60%。

我曾经在固态硬盘上测试过,一个包含 10 万帧、5 万原子的XTC文件,未压缩的话大约 60 GB,压缩后只有 10 GB。所以不要嫌弃压缩算法复杂,它省下的磁盘和带宽是实实在在的。

3.6 整体读取流程

结合上面各部分,一个完整的XTC读取伪代码像这样:

func read_xtc(filename):
    f = open(filename, 'rb')
    
    # 1. 文件头
    magic = f.read(4).unpack('<I')
    if magic != 1995: raise Error
    f.seek(20, SEEK_CUR)  # 跳过剩余文件头
    
    frames = []
    while not f.eof():
        # 2. 时间步信息
        step, time, box = read_frame_header(f)
        
        # 3. 原子数目
        natoms = f.read(4).unpack('<I')
        
        # 4. 坐标数据块
        size = f.read(4).unpack('<I')
        compressed = f.read(size)
        coords = decompress(compressed, natoms)  # 返回 list of (x,y,z)
        
        frames.append(Frame(step, time, box, coords))
    
    return frames

有的框架会把原子数目放在时间步信息之前,但根据XDR标准,顺序是 step → time → box → natoms → coord_block。各位同学如果手里有其他解析库,可以打开对比一下,我保证它们都遵循这个顺序。

调试技巧:先用一个已知小轨迹(比如 3 帧、100 原子)手动验证每一段的字节数。我在做XTC兼容层时,就是靠写hexdump脚本逐段对比的,定位问题非常高效。