Skip to content

Commit 81e425b

Browse files
Add MRC file writing support
1 parent 8b55865 commit 81e425b

File tree

3 files changed

+266
-4
lines changed

3 files changed

+266
-4
lines changed

gridData/core.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ def __init__(self, grid=None, edges=None, origin=None, delta=None,
203203
'PKL': self._export_python,
204204
'PICKLE': self._export_python, # compatibility
205205
'PYTHON': self._export_python, # compatibility
206+
'MRC': self._export_mrc,
206207
}
207208
self._loaders = {
208209
'CCP4': self._load_mrc,
@@ -590,6 +591,8 @@ def export(self, filename, file_format=None, type=None, typequote='"'):
590591
591592
dx
592593
:mod:`OpenDX`
594+
mrc
595+
:mod:`mrc` MRC/CCP4 format
593596
pickle
594597
pickle (use :meth:`Grid.load` to restore); :meth:`Grid.save`
595598
is simpler than ``export(format='python')``.
@@ -599,7 +602,7 @@ def export(self, filename, file_format=None, type=None, typequote='"'):
599602
filename : str
600603
name of the output file
601604
602-
file_format : {'dx', 'pickle', None} (optional)
605+
file_format : {'dx', 'pickle', 'mrc', None} (optional)
603606
output file format, the default is "dx"
604607
605608
type : str (optional)
@@ -676,6 +679,41 @@ def _export_dx(self, filename, type=None, typequote='"', **kwargs):
676679
if ext == '.gz':
677680
filename = root + ext
678681
dx.write(filename)
682+
683+
def _export_mrc(self, filename, **kwargs):
684+
"""Export the density grid to an MRC/CCP4 file.
685+
686+
The MRC2014 file format is used via the mrcfile library.
687+
688+
Parameters
689+
----------
690+
filename : str
691+
Output filename
692+
**kwargs
693+
Additional keyword arguments (currently ignored)
694+
695+
Notes
696+
-----
697+
* Only orthorhombic unit cells are supported
698+
* If the Grid was loaded from an MRC file, the original header
699+
information (including axis ordering) is preserved
700+
* For new grids, standard ordering (mapc=1, mapr=2, maps=3) is used
701+
702+
.. versionadded:: 0.8.0
703+
"""
704+
# Create MRC object and populate with Grid data
705+
mrc_file = mrc.MRC()
706+
mrc_file.array = self.grid
707+
mrc_file.delta = numpy.diag(self.delta)
708+
mrc_file.origin = self.origin
709+
mrc_file.rank = 3
710+
711+
# Transfer header if it exists (preserves axis ordering and other metadata)
712+
if hasattr(self, '_mrc_header'):
713+
mrc_file.header = self._mrc_header
714+
715+
# Write to file
716+
mrc_file.write(filename)
679717

680718
def save(self, filename):
681719
"""Save a grid object to `filename` and add ".pickle" extension.

gridData/mrc.py

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class MRC(object):
7979
-----
8080
* Only volumetric (3D) densities are read.
8181
* Only orthorhombic unitcells supported (other raise :exc:`ValueError`)
82-
* Only reading is currently supported.
82+
* Reading and writing are supported.
8383
8484
8585
.. versionadded:: 0.7.0
@@ -148,5 +148,106 @@ def histogramdd(self):
148148
"""Return array data as (edges,grid), i.e. a numpy nD histogram."""
149149
return (self.array, self.edges)
150150

151-
152-
151+
def write(self, filename):
152+
"""Write grid data to MRC/CCP4 file format.
153+
154+
Parameters
155+
----------
156+
filename : str
157+
Output filename for the MRC file
158+
159+
Notes
160+
-----
161+
The data array should be in xyz order (axis 0=X, axis 1=Y, axis 2=Z).
162+
163+
If the MRC object was created by reading an existing file, the original
164+
header information (including mapc, mapr, maps ordering) is preserved.
165+
Otherwise, standard ordering (mapc=1, mapr=2, maps=3) is used.
166+
167+
.. versionadded:: 0.8.0
168+
"""
169+
if filename is not None:
170+
self.filename = filename
171+
172+
# Preserve header if it exists, otherwise use defaults
173+
if hasattr(self, 'header'):
174+
# File was read - preserve original ordering
175+
h = self.header
176+
axes_order = np.hstack([h.mapc, h.mapr, h.maps])
177+
mapc, mapr, maps = int(h.mapc), int(h.mapr), int(h.maps)
178+
else:
179+
# New file - use standard ordering
180+
axes_order = np.array([1, 2, 3])
181+
mapc, mapr, maps = 1, 2, 3
182+
h = None
183+
184+
# Reverse the transformation done in read()
185+
transpose_order = np.argsort(axes_order[::-1])
186+
inverse_transpose_order = np.argsort(transpose_order)
187+
188+
# Transform our xyz array back to the file's native ordering
189+
data_for_file = np.transpose(self.array, axes=inverse_transpose_order)
190+
191+
# Ensure proper data type (float32 is standard for mode 2)
192+
data_for_file = data_for_file.astype(np.float32)
193+
194+
# Create new MRC file
195+
with mrcfile.new(filename, overwrite=True) as mrc:
196+
mrc.set_data(data_for_file)
197+
198+
# Set voxel size from delta (diagonal elements)
199+
voxel_size = np.diag(self.delta).astype(np.float32)
200+
mrc.voxel_size = tuple(voxel_size)
201+
202+
# Set map ordering
203+
mrc.header.mapc = mapc
204+
mrc.header.mapr = mapr
205+
mrc.header.maps = maps
206+
207+
# Handle nstart and origin
208+
if h is not None:
209+
# Preserve original header values
210+
nxstart = int(h.nxstart)
211+
nystart = int(h.nystart)
212+
nzstart = int(h.nzstart)
213+
header_origin_xyz = np.array([h.origin.x, h.origin.y, h.origin.z], dtype=np.float32)
214+
215+
mrc.header.mx = int(h.mx)
216+
mrc.header.my = int(h.my)
217+
mrc.header.mz = int(h.mz)
218+
219+
# Preserve cell dimensions
220+
if hasattr(h, 'cella'):
221+
mrc.header.cella.x = float(h.cella.x)
222+
mrc.header.cella.y = float(h.cella.y)
223+
mrc.header.cella.z = float(h.cella.z)
224+
if hasattr(h, 'cellb'):
225+
mrc.header.cellb.alpha = float(h.cellb.alpha)
226+
mrc.header.cellb.beta = float(h.cellb.beta)
227+
mrc.header.cellb.gamma = float(h.cellb.gamma)
228+
# Copy space group if available
229+
if hasattr(h, 'ispg'):
230+
mrc.header.ispg = int(h.ispg)
231+
else:
232+
# For new files, calculate nstart from origin
233+
if np.any(voxel_size <= 0):
234+
raise ValueError(f"Voxel size must be positive, got {voxel_size}")
235+
236+
# Set header.origin = 0 and encode everything in nstart
237+
header_origin_xyz = np.zeros(3, dtype=np.float32)
238+
nxstart = int(np.round(self.origin[0] / voxel_size[0]))
239+
nystart = int(np.round(self.origin[1] / voxel_size[1]))
240+
nzstart = int(np.round(self.origin[2] / voxel_size[2]))
241+
242+
# Set the start positions
243+
mrc.header.nxstart = nxstart
244+
mrc.header.nystart = nystart
245+
mrc.header.nzstart = nzstart
246+
247+
# Set explicit origin
248+
mrc.header.origin.x = float(header_origin_xyz[0])
249+
mrc.header.origin.y = float(header_origin_xyz[1])
250+
mrc.header.origin.z = float(header_origin_xyz[2])
251+
252+
# Update statistics only
253+
mrc.update_header_stats()

gridData/tests/test_mrc.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,126 @@ def test_origin(self, grid, ccp4data):
133133

134134
def test_data(self, grid, ccp4data):
135135
assert_allclose(grid.grid, ccp4data.array)
136+
137+
class TestMRCWrite:
138+
"""Tests for MRC write functionality"""
139+
140+
def test_mrc_write_roundtrip(self, ccp4data, tmpdir):
141+
"""Test writing and reading back preserves data"""
142+
outfile = str(tmpdir / "roundtrip.mrc")
143+
144+
# Write the file
145+
ccp4data.write(outfile)
146+
147+
# Read it back
148+
mrc_read = mrc.MRC(outfile)
149+
150+
# Check data matches
151+
assert_allclose(mrc_read.array, ccp4data.array)
152+
assert_allclose(mrc_read.origin, ccp4data.origin, rtol=1e-5, atol=1e-3)
153+
assert_allclose(mrc_read.delta, ccp4data.delta, rtol=1e-5)
154+
155+
def test_mrc_write_header_preserved(self, ccp4data, tmpdir):
156+
"""Test that header fields are preserved"""
157+
outfile = str(tmpdir / "header.mrc")
158+
159+
ccp4data.write(outfile)
160+
mrc_read = mrc.MRC(outfile)
161+
162+
# Check axis ordering preserved
163+
assert mrc_read.header.mapc == ccp4data.header.mapc
164+
assert mrc_read.header.mapr == ccp4data.header.mapr
165+
assert mrc_read.header.maps == ccp4data.header.maps
166+
167+
# Check offsets preserved
168+
assert mrc_read.header.nxstart == ccp4data.header.nxstart
169+
assert mrc_read.header.nystart == ccp4data.header.nystart
170+
assert mrc_read.header.nzstart == ccp4data.header.nzstart
171+
172+
def test_mrc_write_new_file(self, tmpdir):
173+
"""Test creating new MRC file from scratch"""
174+
outfile = str(tmpdir / "new.mrc")
175+
176+
# Create new MRC object
177+
mrc_new = mrc.MRC()
178+
mrc_new.array = np.arange(24).reshape(2, 3, 4).astype(np.float32)
179+
mrc_new.delta = np.diag([1.0, 2.0, 3.0])
180+
mrc_new.origin = np.array([5.0, 10.0, 15.0])
181+
mrc_new.rank = 3
182+
183+
# Write and read back
184+
mrc_new.write(outfile)
185+
mrc_read = mrc.MRC(outfile)
186+
187+
# Verify
188+
assert_allclose(mrc_read.array, mrc_new.array, rtol=1e-5)
189+
assert_allclose(mrc_read.origin, mrc_new.origin, rtol=1e-4)
190+
assert_allclose(np.diag(mrc_read.delta), np.diag(mrc_new.delta), rtol=1e-5)
191+
192+
def test_mrc_write_zero_voxel_raises(self, tmpdir):
193+
"""Test that zero voxel size raises ValueError"""
194+
outfile = str(tmpdir / "invalid.mrc")
195+
196+
mrc_obj = mrc.MRC()
197+
mrc_obj.array = np.ones((2, 2, 2), dtype=np.float32)
198+
mrc_obj.delta = np.diag([0.0, 1.0, 1.0])
199+
mrc_obj.origin = np.array([0.0, 0.0, 0.0])
200+
mrc_obj.rank = 3
201+
202+
with pytest.raises(ValueError, match="Voxel size must be positive"):
203+
mrc_obj.write(outfile)
204+
205+
206+
class TestGridMRCWrite:
207+
"""Tests for Grid.export() with MRC format"""
208+
209+
def test_grid_export_mrc(self, tmpdir):
210+
"""Test Grid.export() with file_format='mrc'"""
211+
outfile = str(tmpdir / "grid.mrc")
212+
213+
# Create simple grid
214+
data = np.arange(60).reshape(3, 4, 5).astype(np.float32)
215+
g = Grid(data, origin=[0, 0, 0], delta=[1.0, 1.0, 1.0])
216+
217+
# Export and read back
218+
g.export(outfile, file_format='mrc')
219+
g_read = Grid(outfile)
220+
221+
# Verify
222+
assert_allclose(g_read.grid, g.grid, rtol=1e-5)
223+
assert_allclose(g_read.origin, g.origin, rtol=1e-4)
224+
assert_allclose(g_read.delta, g.delta, rtol=1e-5)
225+
226+
def test_grid_export_mrc_roundtrip(self, tmpdir):
227+
"""Test MRC → Grid → export → Grid preserves data"""
228+
outfile = str(tmpdir / "roundtrip_grid.mrc")
229+
230+
# Load original
231+
g_orig = Grid(datafiles.CCP4_1JZV)
232+
233+
# Export and reload
234+
g_orig.export(outfile, file_format='mrc')
235+
g_read = Grid(outfile)
236+
237+
# Verify
238+
assert_allclose(g_read.grid, g_orig.grid, rtol=1e-5)
239+
assert_allclose(g_read.origin, g_orig.origin, rtol=1e-4)
240+
assert_allclose(g_read.delta, g_orig.delta, rtol=1e-5)
241+
assert_equal(g_read.grid.shape, g_orig.grid.shape)
242+
243+
def test_grid_export_mrc_preserves_header(self, tmpdir):
244+
"""Test that Grid preserves MRC header through export"""
245+
outfile = str(tmpdir / "header_grid.mrc")
246+
247+
g_orig = Grid(datafiles.CCP4_1JZV)
248+
orig_mapc = g_orig._mrc_header.mapc
249+
orig_mapr = g_orig._mrc_header.mapr
250+
orig_maps = g_orig._mrc_header.maps
251+
252+
# Export and check
253+
g_orig.export(outfile, file_format='mrc')
254+
g_read = Grid(outfile)
255+
256+
assert g_read._mrc_header.mapc == orig_mapc
257+
assert g_read._mrc_header.mapr == orig_mapr
258+
assert g_read._mrc_header.maps == orig_maps

0 commit comments

Comments
 (0)