3. XTC文件结构概览
说实话,XTC文件的结构并不复杂,但你把它拆开看的时候,细节还挺多的。我个人习惯先把整体骨架画出来,再一个个填肉。这一章我们就干这件事——先搞清楚XTC文件从哪开始、到哪结束,每个部分长什么样。
3.1 整体布局
XTC是一个序列化的二进制轨道文件。你想想看,它里面存的是分子模拟的一帧又一帧坐标。每帧都包含:这一帧的时间步信息、周期性盒子(Box)、原子个数,然后是一大坨压缩后的坐标数据。文件开头有个文件头,告诉解析器“嘿,这是个XTC文件”。
我最早读这个文件时,对着hexdump看了半天,发现它并不像文本格式那样一行一行排列,而是一段连续二进制流。所以我把它的结构画成了一张流程图,你们一眼就能看明白。
从上图可以看出,文件只包含一个文件头,后面跟着多个重复的时间步帧。每一帧里,先读取时间步信息,再读原子数目,最后读坐标。我当年调试时犯过一个低级错误:以为原子数目在整个文件里只出现一次,结果解析到第二帧的时候坐标数量全乱了。嗯,这里要注意——每帧都独立存了 natoms,所以原子数可以在不同帧之间变化(虽然实际模拟很少变)。
3.2 文件头(File Header)
文件头是入口。标准XTC文件的前几个字节长这样:
| 偏移(字节) | 字段 | 类型 | 说明 |
|---|---|---|---|
| 0 | magic number | uint32 | 固定值 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作为“期望原子数”,然后后面每帧都做一次校验。
举个例子,如果读取到的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坐标。
- 解压时需要用到上一帧的坐标做参考(或者第一帧用绝对坐标)。
我曾经在固态硬盘上测试过,一个包含 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。各位同学如果手里有其他解析库,可以打开对比一下,我保证它们都遵循这个顺序。