Skip to content

micpy.bin

The micpy.bin module provides tools to read, process, and visualize MICRESS binary output.

Field

Bases: ndarray

A field.

Source code in micpy\bin.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
class Field(np.ndarray):
    """A field."""

    def __new__(cls, data, time: float, spacing: Tuple[float, float, float]):
        obj = np.asarray(data).view(cls)
        obj.time = time
        obj.spacing = spacing
        return obj

    def __array_finalize__(self, obj):
        if obj is None:
            return

        # pylint: disable=attribute-defined-outside-init
        self.time = getattr(obj, "time", None)
        self.spacing = getattr(obj, "spacing", None)

    @property
    def spacing_cm(self) -> Tuple[float, float, float] | None:
        """Get the spacing in centimeters."""
        return self.spacing

    @property
    def spacing_mm(self) -> Tuple[float, float, float] | None:
        """Get the spacing in millimeters."""
        if self.spacing is None:
            return None
        return tuple(x * 1e-1 for x in self.spacing)

    @property
    def spacing_m(self) -> Tuple[float, float, float] | None:
        """Get the spacing in meters."""
        if self.spacing is None:
            return None
        return tuple(x * 1e-2 for x in self.spacing)

    @property
    def spacing_um(self) -> Tuple[float, float, float] | None:
        """Get the spacing in micrometers."""
        if self.spacing is None:
            return None
        return tuple(x * 1e4 for x in self.spacing)

    @staticmethod
    def _boundary_indices(
        n: int,
        lower: int,
        upper: int,
        lower_bc: str,
        upper_bc: str,
    ) -> np.ndarray:
        """Map an extended axis to source indices for MICRESS boundary conditions."""
        if n <= 0:
            raise ValueError("Axis length must be positive.")
        if lower < 0 or upper < 0:
            raise ValueError("Boundary extension must be non-negative.")

        valid = {"periodic", "insulating", "symmetric"}
        if lower_bc not in valid or upper_bc not in valid:
            raise ValueError(
                "Boundary condition must be 'periodic', 'insulating', or 'symmetric'."
            )
        if (lower_bc == "periodic") != (upper_bc == "periodic"):
            raise ValueError(
                "Periodic boundary conditions must be used on both sides of an axis."
            )

        positions = np.arange(-lower, n + upper, dtype=np.int64)
        if lower_bc == upper_bc == "periodic":
            return np.mod(positions, n)

        indices = positions.copy()
        while True:
            low = indices < 0
            high = indices >= n
            if not (np.any(low) or np.any(high)):
                return indices

            if np.any(low):
                if lower_bc == "insulating":
                    indices[low] = -indices[low] - 1
                else:  # symmetric
                    indices[low] = -indices[low]

            if np.any(high):
                if upper_bc == "insulating":
                    indices[high] = 2 * n - 1 - indices[high]
                else:  # symmetric
                    indices[high] = 2 * n - 2 - indices[high]

    @classmethod
    def _extend_array(cls, array, extension_zyx, boundary_zyx):
        """Extend the first three axes of an array using MICRESS boundary conditions."""
        values = np.asarray(array)
        if values.ndim < 3:
            raise ValueError("array must have at least three dimensions (z, y, x)")

        indices = []
        for axis, n, pad, bc in zip(
            "ZYX", values.shape[:3], extension_zyx, boundary_zyx
        ):
            lower, upper = (int(v) for v in pad)
            if n == 1 and (lower or upper):
                raise ValueError(
                    f"Cannot extend degenerate {axis} axis (dimension is 1). "
                    "Set its lower/upper extension to 0."
                )
            lower_bc, upper_bc = bc
            indices.append(cls._boundary_indices(n, lower, upper, lower_bc, upper_bc))

        result = values[np.ix_(*indices)]
        # np.ix_ covers the spatial axes only; retain any trailing component axes.
        if values.ndim > 3:
            result = values[np.ix_(*indices, *[np.arange(n) for n in values.shape[3:]])]
        return result

    def extend(
        self,
        x=(0, 0),
        y=(0, 0),
        z=(0, 0),
        boundary_x=("periodic", "periodic"),
        boundary_y=("periodic", "periodic"),
        boundary_z=("periodic", "periodic"),
    ) -> "Field":
        """Return a field extended using MICRESS-style boundary conditions.

        Args:
            x (tuple of int, optional): Lower and upper extension in the x
                direction. Defaults to ``(0, 0)``.
            y (tuple of int, optional): Lower and upper extension in the y
                direction. Defaults to ``(0, 0)``.
            z (tuple of int, optional): Lower and upper extension in the z
                direction. Defaults to ``(0, 0)``.
            boundary_x (tuple of str, optional): Lower and upper boundary
                conditions for x. Defaults to ``("periodic", "periodic")``.
            boundary_y (tuple of str, optional): Lower and upper boundary
                conditions for y. Defaults to ``("periodic", "periodic")``.
            boundary_z (tuple of str, optional): Lower and upper boundary
                conditions for z. Defaults to ``("periodic", "periodic")``.

                ``"insulating"`` mirrors while duplicating the outermost
                sample, ``"symmetric"`` mirrors without duplicating it, and
                ``"periodic"`` wraps. Extensions and boundary pairs are
                specified in physical x/y/z order.

        Returns:
            Field: Extended field with the original time and spacing.

        Raises:
            ValueError: If the field is not three-dimensional, an extension is
                negative, a boundary condition is invalid, or periodic
                conditions are not used on both sides of an axis.
        """
        if self.ndim != 3:
            raise ValueError("field must be 3D (nz, ny, nx)")

        data = self._extend_array(
            np.asarray(self),
            extension_zyx=(z, y, x),
            boundary_zyx=(boundary_z, boundary_y, boundary_x),
        )
        return Field(data, time=self.time, spacing=self.spacing)

    def resample(
        self,
        shape: Tuple[int, int, int],
        method: str = "nearest",
    ) -> "Field":
        """Resample the field to a new cell geometry.

        The physical extent of the field is preserved. Consequently, changing
        the number of cells changes the spacing accordingly.

        Args:
            shape (Tuple[int, int, int]): Target shape in NumPy axis order,
                i.e. ``(z, y, x)``.
            method (str, optional): Resampling method. Defaults to ``"nearest"``.
                ``"nearest"`` samples the nearest source cell, preserving the
                input dtype and suiting categorical data such as grain or phase
                IDs. ``"linear"`` interpolates between source cell centres and
                returns floating-point data for continuous fields. ``"mean"``
                computes an overlap-weighted mean of source cells covered by
                each target cell, returning floating-point data for conservative
                coarsening of continuous cell data.

        Returns:
            Field: Resampled field with updated spacing and preserved time.

        Raises:
            ValueError: If the target shape or method is invalid, or if
                ``"mean"`` would refine an axis.
        """
        if self.ndim != 3:
            raise ValueError("resample() requires a three-dimensional Field")

        try:
            shape = tuple(int(n) for n in shape)
        except (TypeError, ValueError) as exc:
            raise ValueError("shape must contain three positive integers") from exc

        if len(shape) != 3 or any(n <= 0 for n in shape):
            raise ValueError("shape must contain three positive integers")

        method = method.lower()
        if method not in {"nearest", "linear", "mean"}:
            raise ValueError("method must be 'nearest', 'linear', or 'mean'")

        old_shape = tuple(self.shape)

        if method == "mean" and any(new > old for old, new in zip(old_shape, shape)):
            raise ValueError(
                "'mean' can only be used for coarsening; "
                "use 'nearest' or 'linear' when refining a field"
            )

        data = np.asarray(self)

        if shape == old_shape:
            result = data.copy()

        elif method == "nearest":
            result = data

            for axis, (old_n, new_n) in enumerate(zip(old_shape, shape)):
                if old_n == new_n:
                    continue

                # Map target cell centres into source-cell coordinates.
                coords = (
                    np.arange(new_n, dtype=np.float64) + 0.5
                ) * old_n / new_n - 0.5

                indices = np.floor(coords + 0.5).astype(np.intp)
                indices = np.clip(indices, 0, old_n - 1)

                result = np.take(result, indices, axis=axis)

        elif method == "linear":
            result = data.astype(np.result_type(data.dtype, np.float64), copy=False)

            for axis, (old_n, new_n) in enumerate(zip(old_shape, shape)):
                if old_n == new_n:
                    continue

                # A singleton axis contains no information from which to
                # interpolate. Repeating the sole value is the natural result.
                if old_n == 1:
                    result = np.repeat(result, new_n, axis=axis)
                    continue

                # Coordinates of target cell centres expressed in source
                # cell-centre index coordinates.
                coords = (
                    np.arange(new_n, dtype=np.float64) + 0.5
                ) * old_n / new_n - 0.5

                # Constant extrapolation at the physical boundaries.
                coords = np.clip(coords, 0.0, old_n - 1.0)

                lower = np.floor(coords).astype(np.intp)
                upper = np.minimum(lower + 1, old_n - 1)
                weight = coords - lower

                lower_values = np.take(result, lower, axis=axis)
                upper_values = np.take(result, upper, axis=axis)

                weight_shape = [1] * result.ndim
                weight_shape[axis] = new_n
                weight = weight.reshape(weight_shape)

                result = lower_values * (1.0 - weight) + upper_values * weight

        else:  # method == "mean"
            result = data.astype(np.result_type(data.dtype, np.float64), copy=False)

            for axis, (old_n, new_n) in enumerate(zip(old_shape, shape)):
                if old_n == new_n:
                    continue

                # Source cells occupy intervals [i, i + 1]. Target cells cover
                # the same physical interval [0, old_n], divided into new_n
                # equal cells.
                target_edges = np.linspace(0.0, float(old_n), new_n + 1)

                source_left = np.arange(old_n, dtype=np.float64)
                source_right = source_left + 1.0

                target_left = target_edges[:-1, None]
                target_right = target_edges[1:, None]

                overlap = np.maximum(
                    0.0,
                    np.minimum(target_right, source_right)
                    - np.maximum(target_left, source_left),
                )

                # Divide by target-cell width so every row sums to one.
                weights = overlap / (float(old_n) / new_n)

                # Move the current spatial axis to the front, apply the
                # one-dimensional overlap operator, then restore the axis.
                moved = np.moveaxis(result, axis, 0)
                moved_shape = moved.shape

                result = weights @ moved.reshape(old_n, -1)
                result = result.reshape((new_n,) + moved_shape[1:])
                result = np.moveaxis(result, 0, axis)

        spacing = None
        if self.spacing is not None:
            spacing = tuple(
                old_spacing * old_n / new_n
                for old_spacing, old_n, new_n in zip(self.spacing, old_shape, shape)
            )

        return Field(result, time=self.time, spacing=spacing)

    @staticmethod
    def from_bytes(data: bytes, shape=None, spacing=None):
        """Create a new time step from bytes."""

        header = Header.from_bytes(data)

        start, end, count = (
            header.SIZE,
            header.field_size,
            header.body_length,
        )

        data = data[start:end]
        data = np.frombuffer(data, count=count, dtype="float32")

        if shape is not None:
            data = data.reshape(shape)

        return Field(data, time=header.time, spacing=spacing)

    def to_bytes(self):
        """Convert the field to bytes."""
        header = Header(
            size=self.size * self.itemsize + 8,
            time=self.time,
            length=self.size,
        )
        footer = Footer(length=self.size)

        return header.to_bytes() + self.tobytes() + footer.to_bytes()

    @staticmethod
    def dimensions(shape: Tuple[int, int, int]) -> int:
        """Get the number of dimensions of a shape."""
        x, y, _ = shape

        if y == 1:
            if x == 1:
                return 1
            return 2
        return 3

    @staticmethod
    def read(
        file: IO[bytes],
        field_index: int,
        field_size: int,
        shape=None,
        spacing=None,
    ) -> "Field":
        """Read a field from a binary file."""

        if field_index < 0:
            start = t()
            _debug("Indexing file...")
            file.seek(0, 2)
            file_size = file.tell()
            field_count = file_size // field_size
            field_index += field_count
            _debug(f"Index completed in {t() - start:.2f} seconds.")

        start = t()
        _debug(f"Seeking to field index {field_index}...")
        offset = field_size * field_index
        file.seek(offset)
        _debug(f"Seek completed in {t() - start:.2f} seconds.")

        start = t()
        _debug(f"Reading data for field index {field_index}...")
        field_data = file.read(field_size)
        if len(field_data) < field_size:
            raise EOFError("Unexpected end of file")
        _debug(f"Read completed in {t() - start:.2f} seconds.")

        start = t()
        _debug(f"Creating Field object for field index {field_index}...")
        field = Field.from_bytes(field_data, shape=shape, spacing=spacing)
        _debug(f"Field object created in {t() - start:.2f} seconds.")
        return field

    def to_file(self, file: IO[bytes], geometry: bool = True):
        """Write the field to a binary file.

        Args:
            file (IO[bytes]): Binary file.
            geometry (bool, optional): `True` if geometry should be written, `False`
                otherwise. Defaults to `True`.
        """

        file.write(self.to_bytes())

        if geometry:
            geo_filename, geo_data = geo.build(file.name, self.shape, self.spacing)
            geo.write(geo_filename, geo_data, geo.Type.BASIC)

    def write(
        self,
        filename: str,
        compressed: bool = True,
        geometry: bool = True,
    ):
        """Write the field to a binary file.

        Args:
            filename (str): Filename of the binary file.
            compressed (bool, optional): `True` if file should be compressed, `False`
                otherwise. Defaults to `True`.
            geometry (bool, optional): `True` if geometry should be written, `False`
                otherwise. Defaults to `True`.
        """

        file_open = gzip.open if compressed else open
        with file_open(filename, "wb") as file:
            self.to_file(file, geometry)

    def plot(
        self,
        axis: str = "y",
        index: int = 0,
        length_unit: str = "um",
        title: Optional[str] = None,
        xlabel: Optional[str] = None,
        ylabel: Optional[str] = None,
        figsize: Optional[Tuple[float, float]] = None,
        dpi: Optional[int] = None,
        aspect: str = "equal",
        ax: "Axes" = None,
        cax: "Axes" = None,
        vmin: Optional[float] = None,
        vmax: Optional[float] = None,
        cmap: str = "micpy",
        alpha: float = 1.0,
        interpolation: str = "none",
        scalebar: bool = True,
        scalebar_fraction: float = 0.2,
        scalebar_loc: str = "lower right",
        scalebar_pad: float = 0.3,
        scalebar_borderpad: float = 0.0,
        scalebar_sep: float = 2,
        scalebar_frameon: bool = True,
        scalebar_alpha: float = 0.5,
    ) -> Tuple["Figure", "Axes", "Colorbar"]:
        """Plot a slice of the field using Matplotlib.

        Args:
            axis (str, optional): Axis to plot. Possible values are `x`, `y`, and `z`.
                Defaults to `y`.
            index (int, optional): Index of the slice. Defaults to `0`.
            length_unit (str, optional): Physical length unit for axes and scalebar.
                One of `um`, `mm`, `cm`, or `m`. Defaults to `um`.
            title (str, optional): Title of the plot. Defaults to `None`.
            xlabel (str, optional): Label of the x-axis. Defaults to `None`.
            ylabel (str, optional): Label of the y-axis. Defaults to `None`.
            figsize (Tuple[float, float], optional): Figure size. Defaults to `None`.
            dpi (int, optional): Figure DPI. Defaults to `None`.
            aspect (str, optional): Aspect ratio. Defaults to `equal`.
            ax (Axes, optional): Axes of the plot. Defaults to `None`.
            cax (Axes, optional): Axes of the color bar. Defaults to `None`.
            vmin (float, optional): Minimum value of the color bar. Defaults to `None`.
            vmax (float, optional): Maximum value of the color bar. Defaults to `None`.
            cmap (str, optional): Colormap. Defaults to `micpy`.
            alpha (float, optional): Transparency of the plot. Defaults to `1.0`.
            interpolation (str, optional): Interpolation method. Defaults to `none`.
            scalebar (bool, optional): `True` if a scale bar should be added,
                `False` otherwise. Defaults to `True`.
            scalebar_fraction (float, optional): Fraction of the visible width used
                for the automatically chosen scalebar length. Defaults to `0.2`.
            scalebar_loc (str, optional): Location of the scale bar.
                Defaults to `lower right`.
            scalebar_pad (float, optional): Padding between the scale bar and the plot.
                Defaults to `0.3`.
            scalebar_borderpad (float, optional): Padding between the frame and content.
                Defaults to `0.0`.
            scalebar_sep (float, optional): Separation between scale bar and label.
                Defaults to `2`.
            scalebar_frameon (bool, optional): `True` if the scale bar should have a
                background frame, `False` otherwise. Defaults to `True`.
            scalebar_alpha (float, optional): Transparency of the scale bar
                background. Defaults to `0.5`.

        Returns:
            Matplotlib figure, axes, and color bar.
        """
        if not HAS_MATPLOTLIB:
            raise ImportError("Matplotlib is not installed.")

        if self.ndim != 3:
            raise ValueError("'field' must be a 3D array")

        if self.spacing is None:
            raise ValueError("'spacing' is required for plotting.")

        if axis == "z":
            x, y = "x", "y"
            slice_2d = self[index, :, :]
        elif axis == "y":
            x, y = "x", "z"
            slice_2d = self[:, index, :]
        elif axis == "x":
            x, y = "y", "z"
            slice_2d = self[:, :, index]
        else:
            raise ValueError("'axis' must be 'x', 'y', or 'z'")

        fig, ax = (
            pyplot.subplots(figsize=figsize, dpi=dpi)
            if ax is None
            else (ax.get_figure(), ax)
        )

        unit_scale = _length_unit_scale_from_cm(length_unit)
        unit_label = _length_unit_label(length_unit)

        spacing_native_cm = self.spacing[0]
        spacing_display = spacing_native_cm * unit_scale

        ny, nx = slice_2d.shape
        width_display = nx * spacing_display
        height_display = ny * spacing_display
        extent = (0.0, width_display, 0.0, height_display)

        if title is not None:
            ax.set_title(title)
        else:
            ax.set_title(f"t={np.round(self.time, 7)}s")

        ax.set_xlabel(xlabel if xlabel is not None else f"{x} [{unit_label}]")
        ax.set_ylabel(ylabel if ylabel is not None else f"{y} [{unit_label}]")

        if aspect is not None:
            ax.set_aspect(aspect)

        ax.set_frame_on(False)

        image = ax.imshow(
            slice_2d,
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
            interpolation=interpolation,
            alpha=alpha,
            extent=extent,
            origin="lower",
        )

        if scalebar:
            scalebar_length = _nice_length(width_display * scalebar_fraction)

            sbar = AnchoredSizeBar(
                transform=ax.transData,
                size=scalebar_length,
                label=_format_length_in_unit(scalebar_length, length_unit),
                loc=scalebar_loc,
                pad=scalebar_pad,
                borderpad=scalebar_borderpad,
                sep=scalebar_sep,
                frameon=scalebar_frameon,
            )

            ax.add_artist(sbar)
            ax.scalebar = sbar

            if scalebar_frameon:
                patch = sbar.patch
                patch.set_facecolor(ax.get_facecolor())
                patch.set_alpha(scalebar_alpha)
                patch.set_edgecolor("none")

        cbar = pyplot.colorbar(image, ax=ax, cax=cax)
        cbar.locator = matplotlib.ticker.MaxNLocator(
            integer=np.issubdtype(slice_2d.dtype, np.integer)
        )
        cbar.outline.set_visible(False)
        cbar.update_ticks()

        return fig, ax, cbar

    def as_vti(
        self,
        name: str = "values",
        data_mode: DataMode = "cell",
        length_unit: str = "cm",
        *,
        point_data: bool | None = None,
    ) -> "vtkImageData":
        """Convert the field to a VTK ImageData object.

        Args:
            name:
                Name of the VTK data array.
            data_mode:
                How MICRESS cell values are represented:
                - ``"cell"``: Store the original values as VTK CellData.
                - ``"point_average"``: Convert the cell values to PointData by averaging adjacent cells.
                - ``"point_direct"``: Store each original field value directly as a VTK point value, without averaging.
            length_unit:
                The unit of length for the VTK image spacing. Supported units
                are "um" (micrometers), "mm" (millimeters), "cm" (centimeters),
                and "m" (meters).
            point_data:
                Deprecated. Use ``data_mode`` instead.

        Returns:
            A ``vtkImageData`` object containing the field values.
        """

        if point_data is not None:
            warnings.warn(
                "'point_data' is deprecated and will be removed in MicPy 0.5.0. "
                "Use data_mode='cell' or data_mode='point_average' instead.",
                DeprecationWarning,
                stacklevel=2,
            )

            data_mode = "point_average" if point_data else "cell"

        if not HAS_VTK:
            raise ImportError("VTK is not installed.")

        cells = np.asarray(self)
        if cells.ndim != 3:
            raise ValueError("field must be 3D (nz, ny, nx)")

        if self.spacing is None:
            raise ValueError("spacing is required for VTK conversion")

        if data_mode not in {"cell", "point_average", "point_direct"}:
            raise ValueError(
                "data_mode must be 'cell', 'point_average', or 'point_direct'"
            )

        nz, ny, nx = cells.shape
        dz, dy, dx = self.spacing
        length_scale = _length_unit_scale_from_cm(length_unit)
        dz, dy, dx = (dz * length_scale, dy * length_scale, dx * length_scale)

        def cell_to_point_average(c: np.ndarray) -> np.ndarray:
            nz_, ny_, nx_ = c.shape

            dtype = np.result_type(c.dtype, np.float32)

            acc = np.zeros(
                (nz_ + 1, ny_ + 1, nx_ + 1),
                dtype=dtype,
            )
            weights = np.zeros(
                (nz_ + 1, ny_ + 1, nx_ + 1),
                dtype=np.float32,
            )

            for z_offset in (0, 1):
                for y_offset in (0, 1):
                    for x_offset in (0, 1):
                        z_slice = slice(z_offset, z_offset + nz_)
                        y_slice = slice(y_offset, y_offset + ny_)
                        x_slice = slice(x_offset, x_offset + nx_)

                        acc[z_slice, y_slice, x_slice] += c
                        weights[z_slice, y_slice, x_slice] += 1.0

            return acc / weights

        if data_mode == "cell":
            data = cells
            dimensions = (nx + 1, ny + 1, nz + 1)
            vtk_attributes = "cell"

        elif data_mode == "point_average":
            data = cell_to_point_average(cells)
            dimensions = (nx + 1, ny + 1, nz + 1)
            vtk_attributes = "point"

        else:
            data = cells
            dimensions = (nx, ny, nz)
            vtk_attributes = "point"

        data_c = np.ascontiguousarray(data)
        flat = data_c.ravel(order="C")

        image = vtkImageData()
        image.SetOrigin(0.0, 0.0, 0.0)
        image.SetSpacing(float(dx), float(dy), float(dz))
        image.SetDimensions(*dimensions)

        vtk_arr = numpy_to_vtk(flat, deep=False)
        vtk_arr.SetName(name)

        if vtk_attributes == "point":
            image.GetPointData().SetScalars(vtk_arr)
        else:
            image.GetCellData().SetScalars(vtk_arr)

        # Keep the NumPy allocation alive because numpy_to_vtk used deep=False.
        image._numpy_pin = data_c

        return image

    def save_vti(
        self,
        filename: str,
        name: str = "values",
        data_mode: DataMode = "cell",
        file_format: VTKFileFormat = "xml",
        *,
        point_data: bool | None = None,
    ) -> str:
        """Save the field as VTK ImageData.

        Args:
            filename:
                Filename of the VTK file.
            name:
                Name of the VTK data array.
            data_mode:
                How MICRESS cell values are represented:
                - ``"cell"``: Store the original values as VTK CellData.
                - ``"point_average"``: Convert the cell values to PointData by
                averaging adjacent cells.
                - ``"point_direct"``: Store each original field value directly
                as a VTK point value, without averaging.
            file_format:
                VTK file format:
                - ``"xml"``: VTK XML ImageData format (``.vti``).
                - ``"legacy"``: Legacy VTK file format (typically ``.vtk``).
            point_data:
                Deprecated. Use ``data_mode`` instead.

        Returns:
            The filename of the saved VTK file.
        """

        image = self.as_vti(
            name=name,
            data_mode=data_mode,
            point_data=point_data,
        )

        filename = str(Path(filename))

        if file_format == "xml":
            writer = vtkXMLImageDataWriter()
            writer.SetDataModeToAppended()
            writer.EncodeAppendedDataOff()

        elif file_format == "legacy":
            writer = vtkDataSetWriter()
            writer.SetFileTypeToBinary()

        else:
            raise ValueError("file_format must be 'xml' or 'legacy'")

        writer.SetFileName(filename)
        writer.SetInputData(image)

        if writer.Write() != 1:
            raise OSError(f"Failed to write {filename}")

        return filename

spacing_cm property

Get the spacing in centimeters.

spacing_m property

Get the spacing in meters.

spacing_mm property

Get the spacing in millimeters.

spacing_um property

Get the spacing in micrometers.

as_vti(name='values', data_mode='cell', length_unit='cm', *, point_data=None)

Convert the field to a VTK ImageData object.

Parameters:

Name Type Description Default
name str

Name of the VTK data array.

'values'
data_mode DataMode

How MICRESS cell values are represented: - "cell": Store the original values as VTK CellData. - "point_average": Convert the cell values to PointData by averaging adjacent cells. - "point_direct": Store each original field value directly as a VTK point value, without averaging.

'cell'
length_unit str

The unit of length for the VTK image spacing. Supported units are "um" (micrometers), "mm" (millimeters), "cm" (centimeters), and "m" (meters).

'cm'
point_data bool | None

Deprecated. Use data_mode instead.

None

Returns:

Type Description
vtkImageData

A vtkImageData object containing the field values.

Source code in micpy\bin.py
def as_vti(
    self,
    name: str = "values",
    data_mode: DataMode = "cell",
    length_unit: str = "cm",
    *,
    point_data: bool | None = None,
) -> "vtkImageData":
    """Convert the field to a VTK ImageData object.

    Args:
        name:
            Name of the VTK data array.
        data_mode:
            How MICRESS cell values are represented:
            - ``"cell"``: Store the original values as VTK CellData.
            - ``"point_average"``: Convert the cell values to PointData by averaging adjacent cells.
            - ``"point_direct"``: Store each original field value directly as a VTK point value, without averaging.
        length_unit:
            The unit of length for the VTK image spacing. Supported units
            are "um" (micrometers), "mm" (millimeters), "cm" (centimeters),
            and "m" (meters).
        point_data:
            Deprecated. Use ``data_mode`` instead.

    Returns:
        A ``vtkImageData`` object containing the field values.
    """

    if point_data is not None:
        warnings.warn(
            "'point_data' is deprecated and will be removed in MicPy 0.5.0. "
            "Use data_mode='cell' or data_mode='point_average' instead.",
            DeprecationWarning,
            stacklevel=2,
        )

        data_mode = "point_average" if point_data else "cell"

    if not HAS_VTK:
        raise ImportError("VTK is not installed.")

    cells = np.asarray(self)
    if cells.ndim != 3:
        raise ValueError("field must be 3D (nz, ny, nx)")

    if self.spacing is None:
        raise ValueError("spacing is required for VTK conversion")

    if data_mode not in {"cell", "point_average", "point_direct"}:
        raise ValueError(
            "data_mode must be 'cell', 'point_average', or 'point_direct'"
        )

    nz, ny, nx = cells.shape
    dz, dy, dx = self.spacing
    length_scale = _length_unit_scale_from_cm(length_unit)
    dz, dy, dx = (dz * length_scale, dy * length_scale, dx * length_scale)

    def cell_to_point_average(c: np.ndarray) -> np.ndarray:
        nz_, ny_, nx_ = c.shape

        dtype = np.result_type(c.dtype, np.float32)

        acc = np.zeros(
            (nz_ + 1, ny_ + 1, nx_ + 1),
            dtype=dtype,
        )
        weights = np.zeros(
            (nz_ + 1, ny_ + 1, nx_ + 1),
            dtype=np.float32,
        )

        for z_offset in (0, 1):
            for y_offset in (0, 1):
                for x_offset in (0, 1):
                    z_slice = slice(z_offset, z_offset + nz_)
                    y_slice = slice(y_offset, y_offset + ny_)
                    x_slice = slice(x_offset, x_offset + nx_)

                    acc[z_slice, y_slice, x_slice] += c
                    weights[z_slice, y_slice, x_slice] += 1.0

        return acc / weights

    if data_mode == "cell":
        data = cells
        dimensions = (nx + 1, ny + 1, nz + 1)
        vtk_attributes = "cell"

    elif data_mode == "point_average":
        data = cell_to_point_average(cells)
        dimensions = (nx + 1, ny + 1, nz + 1)
        vtk_attributes = "point"

    else:
        data = cells
        dimensions = (nx, ny, nz)
        vtk_attributes = "point"

    data_c = np.ascontiguousarray(data)
    flat = data_c.ravel(order="C")

    image = vtkImageData()
    image.SetOrigin(0.0, 0.0, 0.0)
    image.SetSpacing(float(dx), float(dy), float(dz))
    image.SetDimensions(*dimensions)

    vtk_arr = numpy_to_vtk(flat, deep=False)
    vtk_arr.SetName(name)

    if vtk_attributes == "point":
        image.GetPointData().SetScalars(vtk_arr)
    else:
        image.GetCellData().SetScalars(vtk_arr)

    # Keep the NumPy allocation alive because numpy_to_vtk used deep=False.
    image._numpy_pin = data_c

    return image

dimensions(shape) staticmethod

Get the number of dimensions of a shape.

Source code in micpy\bin.py
@staticmethod
def dimensions(shape: Tuple[int, int, int]) -> int:
    """Get the number of dimensions of a shape."""
    x, y, _ = shape

    if y == 1:
        if x == 1:
            return 1
        return 2
    return 3

extend(x=(0, 0), y=(0, 0), z=(0, 0), boundary_x=('periodic', 'periodic'), boundary_y=('periodic', 'periodic'), boundary_z=('periodic', 'periodic'))

Return a field extended using MICRESS-style boundary conditions.

Parameters:

Name Type Description Default
x tuple of int

Lower and upper extension in the x direction. Defaults to (0, 0).

(0, 0)
y tuple of int

Lower and upper extension in the y direction. Defaults to (0, 0).

(0, 0)
z tuple of int

Lower and upper extension in the z direction. Defaults to (0, 0).

(0, 0)
boundary_x tuple of str

Lower and upper boundary conditions for x. Defaults to ("periodic", "periodic").

('periodic', 'periodic')
boundary_y tuple of str

Lower and upper boundary conditions for y. Defaults to ("periodic", "periodic").

('periodic', 'periodic')
boundary_z tuple of str

Lower and upper boundary conditions for z. Defaults to ("periodic", "periodic").

"insulating" mirrors while duplicating the outermost sample, "symmetric" mirrors without duplicating it, and "periodic" wraps. Extensions and boundary pairs are specified in physical x/y/z order.

('periodic', 'periodic')

Returns:

Name Type Description
Field Field

Extended field with the original time and spacing.

Raises:

Type Description
ValueError

If the field is not three-dimensional, an extension is negative, a boundary condition is invalid, or periodic conditions are not used on both sides of an axis.

Source code in micpy\bin.py
def extend(
    self,
    x=(0, 0),
    y=(0, 0),
    z=(0, 0),
    boundary_x=("periodic", "periodic"),
    boundary_y=("periodic", "periodic"),
    boundary_z=("periodic", "periodic"),
) -> "Field":
    """Return a field extended using MICRESS-style boundary conditions.

    Args:
        x (tuple of int, optional): Lower and upper extension in the x
            direction. Defaults to ``(0, 0)``.
        y (tuple of int, optional): Lower and upper extension in the y
            direction. Defaults to ``(0, 0)``.
        z (tuple of int, optional): Lower and upper extension in the z
            direction. Defaults to ``(0, 0)``.
        boundary_x (tuple of str, optional): Lower and upper boundary
            conditions for x. Defaults to ``("periodic", "periodic")``.
        boundary_y (tuple of str, optional): Lower and upper boundary
            conditions for y. Defaults to ``("periodic", "periodic")``.
        boundary_z (tuple of str, optional): Lower and upper boundary
            conditions for z. Defaults to ``("periodic", "periodic")``.

            ``"insulating"`` mirrors while duplicating the outermost
            sample, ``"symmetric"`` mirrors without duplicating it, and
            ``"periodic"`` wraps. Extensions and boundary pairs are
            specified in physical x/y/z order.

    Returns:
        Field: Extended field with the original time and spacing.

    Raises:
        ValueError: If the field is not three-dimensional, an extension is
            negative, a boundary condition is invalid, or periodic
            conditions are not used on both sides of an axis.
    """
    if self.ndim != 3:
        raise ValueError("field must be 3D (nz, ny, nx)")

    data = self._extend_array(
        np.asarray(self),
        extension_zyx=(z, y, x),
        boundary_zyx=(boundary_z, boundary_y, boundary_x),
    )
    return Field(data, time=self.time, spacing=self.spacing)

from_bytes(data, shape=None, spacing=None) staticmethod

Create a new time step from bytes.

Source code in micpy\bin.py
@staticmethod
def from_bytes(data: bytes, shape=None, spacing=None):
    """Create a new time step from bytes."""

    header = Header.from_bytes(data)

    start, end, count = (
        header.SIZE,
        header.field_size,
        header.body_length,
    )

    data = data[start:end]
    data = np.frombuffer(data, count=count, dtype="float32")

    if shape is not None:
        data = data.reshape(shape)

    return Field(data, time=header.time, spacing=spacing)

plot(axis='y', index=0, length_unit='um', title=None, xlabel=None, ylabel=None, figsize=None, dpi=None, aspect='equal', ax=None, cax=None, vmin=None, vmax=None, cmap='micpy', alpha=1.0, interpolation='none', scalebar=True, scalebar_fraction=0.2, scalebar_loc='lower right', scalebar_pad=0.3, scalebar_borderpad=0.0, scalebar_sep=2, scalebar_frameon=True, scalebar_alpha=0.5)

Plot a slice of the field using Matplotlib.

Parameters:

Name Type Description Default
axis str

Axis to plot. Possible values are x, y, and z. Defaults to y.

'y'
index int

Index of the slice. Defaults to 0.

0
length_unit str

Physical length unit for axes and scalebar. One of um, mm, cm, or m. Defaults to um.

'um'
title str

Title of the plot. Defaults to None.

None
xlabel str

Label of the x-axis. Defaults to None.

None
ylabel str

Label of the y-axis. Defaults to None.

None
figsize Tuple[float, float]

Figure size. Defaults to None.

None
dpi int

Figure DPI. Defaults to None.

None
aspect str

Aspect ratio. Defaults to equal.

'equal'
ax Axes

Axes of the plot. Defaults to None.

None
cax Axes

Axes of the color bar. Defaults to None.

None
vmin float

Minimum value of the color bar. Defaults to None.

None
vmax float

Maximum value of the color bar. Defaults to None.

None
cmap str

Colormap. Defaults to micpy.

'micpy'
alpha float

Transparency of the plot. Defaults to 1.0.

1.0
interpolation str

Interpolation method. Defaults to none.

'none'
scalebar bool

True if a scale bar should be added, False otherwise. Defaults to True.

True
scalebar_fraction float

Fraction of the visible width used for the automatically chosen scalebar length. Defaults to 0.2.

0.2
scalebar_loc str

Location of the scale bar. Defaults to lower right.

'lower right'
scalebar_pad float

Padding between the scale bar and the plot. Defaults to 0.3.

0.3
scalebar_borderpad float

Padding between the frame and content. Defaults to 0.0.

0.0
scalebar_sep float

Separation between scale bar and label. Defaults to 2.

2
scalebar_frameon bool

True if the scale bar should have a background frame, False otherwise. Defaults to True.

True
scalebar_alpha float

Transparency of the scale bar background. Defaults to 0.5.

0.5

Returns:

Type Description
Tuple[Figure, Axes, Colorbar]

Matplotlib figure, axes, and color bar.

Source code in micpy\bin.py
def plot(
    self,
    axis: str = "y",
    index: int = 0,
    length_unit: str = "um",
    title: Optional[str] = None,
    xlabel: Optional[str] = None,
    ylabel: Optional[str] = None,
    figsize: Optional[Tuple[float, float]] = None,
    dpi: Optional[int] = None,
    aspect: str = "equal",
    ax: "Axes" = None,
    cax: "Axes" = None,
    vmin: Optional[float] = None,
    vmax: Optional[float] = None,
    cmap: str = "micpy",
    alpha: float = 1.0,
    interpolation: str = "none",
    scalebar: bool = True,
    scalebar_fraction: float = 0.2,
    scalebar_loc: str = "lower right",
    scalebar_pad: float = 0.3,
    scalebar_borderpad: float = 0.0,
    scalebar_sep: float = 2,
    scalebar_frameon: bool = True,
    scalebar_alpha: float = 0.5,
) -> Tuple["Figure", "Axes", "Colorbar"]:
    """Plot a slice of the field using Matplotlib.

    Args:
        axis (str, optional): Axis to plot. Possible values are `x`, `y`, and `z`.
            Defaults to `y`.
        index (int, optional): Index of the slice. Defaults to `0`.
        length_unit (str, optional): Physical length unit for axes and scalebar.
            One of `um`, `mm`, `cm`, or `m`. Defaults to `um`.
        title (str, optional): Title of the plot. Defaults to `None`.
        xlabel (str, optional): Label of the x-axis. Defaults to `None`.
        ylabel (str, optional): Label of the y-axis. Defaults to `None`.
        figsize (Tuple[float, float], optional): Figure size. Defaults to `None`.
        dpi (int, optional): Figure DPI. Defaults to `None`.
        aspect (str, optional): Aspect ratio. Defaults to `equal`.
        ax (Axes, optional): Axes of the plot. Defaults to `None`.
        cax (Axes, optional): Axes of the color bar. Defaults to `None`.
        vmin (float, optional): Minimum value of the color bar. Defaults to `None`.
        vmax (float, optional): Maximum value of the color bar. Defaults to `None`.
        cmap (str, optional): Colormap. Defaults to `micpy`.
        alpha (float, optional): Transparency of the plot. Defaults to `1.0`.
        interpolation (str, optional): Interpolation method. Defaults to `none`.
        scalebar (bool, optional): `True` if a scale bar should be added,
            `False` otherwise. Defaults to `True`.
        scalebar_fraction (float, optional): Fraction of the visible width used
            for the automatically chosen scalebar length. Defaults to `0.2`.
        scalebar_loc (str, optional): Location of the scale bar.
            Defaults to `lower right`.
        scalebar_pad (float, optional): Padding between the scale bar and the plot.
            Defaults to `0.3`.
        scalebar_borderpad (float, optional): Padding between the frame and content.
            Defaults to `0.0`.
        scalebar_sep (float, optional): Separation between scale bar and label.
            Defaults to `2`.
        scalebar_frameon (bool, optional): `True` if the scale bar should have a
            background frame, `False` otherwise. Defaults to `True`.
        scalebar_alpha (float, optional): Transparency of the scale bar
            background. Defaults to `0.5`.

    Returns:
        Matplotlib figure, axes, and color bar.
    """
    if not HAS_MATPLOTLIB:
        raise ImportError("Matplotlib is not installed.")

    if self.ndim != 3:
        raise ValueError("'field' must be a 3D array")

    if self.spacing is None:
        raise ValueError("'spacing' is required for plotting.")

    if axis == "z":
        x, y = "x", "y"
        slice_2d = self[index, :, :]
    elif axis == "y":
        x, y = "x", "z"
        slice_2d = self[:, index, :]
    elif axis == "x":
        x, y = "y", "z"
        slice_2d = self[:, :, index]
    else:
        raise ValueError("'axis' must be 'x', 'y', or 'z'")

    fig, ax = (
        pyplot.subplots(figsize=figsize, dpi=dpi)
        if ax is None
        else (ax.get_figure(), ax)
    )

    unit_scale = _length_unit_scale_from_cm(length_unit)
    unit_label = _length_unit_label(length_unit)

    spacing_native_cm = self.spacing[0]
    spacing_display = spacing_native_cm * unit_scale

    ny, nx = slice_2d.shape
    width_display = nx * spacing_display
    height_display = ny * spacing_display
    extent = (0.0, width_display, 0.0, height_display)

    if title is not None:
        ax.set_title(title)
    else:
        ax.set_title(f"t={np.round(self.time, 7)}s")

    ax.set_xlabel(xlabel if xlabel is not None else f"{x} [{unit_label}]")
    ax.set_ylabel(ylabel if ylabel is not None else f"{y} [{unit_label}]")

    if aspect is not None:
        ax.set_aspect(aspect)

    ax.set_frame_on(False)

    image = ax.imshow(
        slice_2d,
        cmap=cmap,
        vmin=vmin,
        vmax=vmax,
        interpolation=interpolation,
        alpha=alpha,
        extent=extent,
        origin="lower",
    )

    if scalebar:
        scalebar_length = _nice_length(width_display * scalebar_fraction)

        sbar = AnchoredSizeBar(
            transform=ax.transData,
            size=scalebar_length,
            label=_format_length_in_unit(scalebar_length, length_unit),
            loc=scalebar_loc,
            pad=scalebar_pad,
            borderpad=scalebar_borderpad,
            sep=scalebar_sep,
            frameon=scalebar_frameon,
        )

        ax.add_artist(sbar)
        ax.scalebar = sbar

        if scalebar_frameon:
            patch = sbar.patch
            patch.set_facecolor(ax.get_facecolor())
            patch.set_alpha(scalebar_alpha)
            patch.set_edgecolor("none")

    cbar = pyplot.colorbar(image, ax=ax, cax=cax)
    cbar.locator = matplotlib.ticker.MaxNLocator(
        integer=np.issubdtype(slice_2d.dtype, np.integer)
    )
    cbar.outline.set_visible(False)
    cbar.update_ticks()

    return fig, ax, cbar

read(file, field_index, field_size, shape=None, spacing=None) staticmethod

Read a field from a binary file.

Source code in micpy\bin.py
@staticmethod
def read(
    file: IO[bytes],
    field_index: int,
    field_size: int,
    shape=None,
    spacing=None,
) -> "Field":
    """Read a field from a binary file."""

    if field_index < 0:
        start = t()
        _debug("Indexing file...")
        file.seek(0, 2)
        file_size = file.tell()
        field_count = file_size // field_size
        field_index += field_count
        _debug(f"Index completed in {t() - start:.2f} seconds.")

    start = t()
    _debug(f"Seeking to field index {field_index}...")
    offset = field_size * field_index
    file.seek(offset)
    _debug(f"Seek completed in {t() - start:.2f} seconds.")

    start = t()
    _debug(f"Reading data for field index {field_index}...")
    field_data = file.read(field_size)
    if len(field_data) < field_size:
        raise EOFError("Unexpected end of file")
    _debug(f"Read completed in {t() - start:.2f} seconds.")

    start = t()
    _debug(f"Creating Field object for field index {field_index}...")
    field = Field.from_bytes(field_data, shape=shape, spacing=spacing)
    _debug(f"Field object created in {t() - start:.2f} seconds.")
    return field

resample(shape, method='nearest')

Resample the field to a new cell geometry.

The physical extent of the field is preserved. Consequently, changing the number of cells changes the spacing accordingly.

Parameters:

Name Type Description Default
shape Tuple[int, int, int]

Target shape in NumPy axis order, i.e. (z, y, x).

required
method str

Resampling method. Defaults to "nearest". "nearest" samples the nearest source cell, preserving the input dtype and suiting categorical data such as grain or phase IDs. "linear" interpolates between source cell centres and returns floating-point data for continuous fields. "mean" computes an overlap-weighted mean of source cells covered by each target cell, returning floating-point data for conservative coarsening of continuous cell data.

'nearest'

Returns:

Name Type Description
Field Field

Resampled field with updated spacing and preserved time.

Raises:

Type Description
ValueError

If the target shape or method is invalid, or if "mean" would refine an axis.

Source code in micpy\bin.py
def resample(
    self,
    shape: Tuple[int, int, int],
    method: str = "nearest",
) -> "Field":
    """Resample the field to a new cell geometry.

    The physical extent of the field is preserved. Consequently, changing
    the number of cells changes the spacing accordingly.

    Args:
        shape (Tuple[int, int, int]): Target shape in NumPy axis order,
            i.e. ``(z, y, x)``.
        method (str, optional): Resampling method. Defaults to ``"nearest"``.
            ``"nearest"`` samples the nearest source cell, preserving the
            input dtype and suiting categorical data such as grain or phase
            IDs. ``"linear"`` interpolates between source cell centres and
            returns floating-point data for continuous fields. ``"mean"``
            computes an overlap-weighted mean of source cells covered by
            each target cell, returning floating-point data for conservative
            coarsening of continuous cell data.

    Returns:
        Field: Resampled field with updated spacing and preserved time.

    Raises:
        ValueError: If the target shape or method is invalid, or if
            ``"mean"`` would refine an axis.
    """
    if self.ndim != 3:
        raise ValueError("resample() requires a three-dimensional Field")

    try:
        shape = tuple(int(n) for n in shape)
    except (TypeError, ValueError) as exc:
        raise ValueError("shape must contain three positive integers") from exc

    if len(shape) != 3 or any(n <= 0 for n in shape):
        raise ValueError("shape must contain three positive integers")

    method = method.lower()
    if method not in {"nearest", "linear", "mean"}:
        raise ValueError("method must be 'nearest', 'linear', or 'mean'")

    old_shape = tuple(self.shape)

    if method == "mean" and any(new > old for old, new in zip(old_shape, shape)):
        raise ValueError(
            "'mean' can only be used for coarsening; "
            "use 'nearest' or 'linear' when refining a field"
        )

    data = np.asarray(self)

    if shape == old_shape:
        result = data.copy()

    elif method == "nearest":
        result = data

        for axis, (old_n, new_n) in enumerate(zip(old_shape, shape)):
            if old_n == new_n:
                continue

            # Map target cell centres into source-cell coordinates.
            coords = (
                np.arange(new_n, dtype=np.float64) + 0.5
            ) * old_n / new_n - 0.5

            indices = np.floor(coords + 0.5).astype(np.intp)
            indices = np.clip(indices, 0, old_n - 1)

            result = np.take(result, indices, axis=axis)

    elif method == "linear":
        result = data.astype(np.result_type(data.dtype, np.float64), copy=False)

        for axis, (old_n, new_n) in enumerate(zip(old_shape, shape)):
            if old_n == new_n:
                continue

            # A singleton axis contains no information from which to
            # interpolate. Repeating the sole value is the natural result.
            if old_n == 1:
                result = np.repeat(result, new_n, axis=axis)
                continue

            # Coordinates of target cell centres expressed in source
            # cell-centre index coordinates.
            coords = (
                np.arange(new_n, dtype=np.float64) + 0.5
            ) * old_n / new_n - 0.5

            # Constant extrapolation at the physical boundaries.
            coords = np.clip(coords, 0.0, old_n - 1.0)

            lower = np.floor(coords).astype(np.intp)
            upper = np.minimum(lower + 1, old_n - 1)
            weight = coords - lower

            lower_values = np.take(result, lower, axis=axis)
            upper_values = np.take(result, upper, axis=axis)

            weight_shape = [1] * result.ndim
            weight_shape[axis] = new_n
            weight = weight.reshape(weight_shape)

            result = lower_values * (1.0 - weight) + upper_values * weight

    else:  # method == "mean"
        result = data.astype(np.result_type(data.dtype, np.float64), copy=False)

        for axis, (old_n, new_n) in enumerate(zip(old_shape, shape)):
            if old_n == new_n:
                continue

            # Source cells occupy intervals [i, i + 1]. Target cells cover
            # the same physical interval [0, old_n], divided into new_n
            # equal cells.
            target_edges = np.linspace(0.0, float(old_n), new_n + 1)

            source_left = np.arange(old_n, dtype=np.float64)
            source_right = source_left + 1.0

            target_left = target_edges[:-1, None]
            target_right = target_edges[1:, None]

            overlap = np.maximum(
                0.0,
                np.minimum(target_right, source_right)
                - np.maximum(target_left, source_left),
            )

            # Divide by target-cell width so every row sums to one.
            weights = overlap / (float(old_n) / new_n)

            # Move the current spatial axis to the front, apply the
            # one-dimensional overlap operator, then restore the axis.
            moved = np.moveaxis(result, axis, 0)
            moved_shape = moved.shape

            result = weights @ moved.reshape(old_n, -1)
            result = result.reshape((new_n,) + moved_shape[1:])
            result = np.moveaxis(result, 0, axis)

    spacing = None
    if self.spacing is not None:
        spacing = tuple(
            old_spacing * old_n / new_n
            for old_spacing, old_n, new_n in zip(self.spacing, old_shape, shape)
        )

    return Field(result, time=self.time, spacing=spacing)

save_vti(filename, name='values', data_mode='cell', file_format='xml', *, point_data=None)

Save the field as VTK ImageData.

Parameters:

Name Type Description Default
filename str

Filename of the VTK file.

required
name str

Name of the VTK data array.

'values'
data_mode DataMode

How MICRESS cell values are represented: - "cell": Store the original values as VTK CellData. - "point_average": Convert the cell values to PointData by averaging adjacent cells. - "point_direct": Store each original field value directly as a VTK point value, without averaging.

'cell'
file_format VTKFileFormat

VTK file format: - "xml": VTK XML ImageData format (.vti). - "legacy": Legacy VTK file format (typically .vtk).

'xml'
point_data bool | None

Deprecated. Use data_mode instead.

None

Returns:

Type Description
str

The filename of the saved VTK file.

Source code in micpy\bin.py
def save_vti(
    self,
    filename: str,
    name: str = "values",
    data_mode: DataMode = "cell",
    file_format: VTKFileFormat = "xml",
    *,
    point_data: bool | None = None,
) -> str:
    """Save the field as VTK ImageData.

    Args:
        filename:
            Filename of the VTK file.
        name:
            Name of the VTK data array.
        data_mode:
            How MICRESS cell values are represented:
            - ``"cell"``: Store the original values as VTK CellData.
            - ``"point_average"``: Convert the cell values to PointData by
            averaging adjacent cells.
            - ``"point_direct"``: Store each original field value directly
            as a VTK point value, without averaging.
        file_format:
            VTK file format:
            - ``"xml"``: VTK XML ImageData format (``.vti``).
            - ``"legacy"``: Legacy VTK file format (typically ``.vtk``).
        point_data:
            Deprecated. Use ``data_mode`` instead.

    Returns:
        The filename of the saved VTK file.
    """

    image = self.as_vti(
        name=name,
        data_mode=data_mode,
        point_data=point_data,
    )

    filename = str(Path(filename))

    if file_format == "xml":
        writer = vtkXMLImageDataWriter()
        writer.SetDataModeToAppended()
        writer.EncodeAppendedDataOff()

    elif file_format == "legacy":
        writer = vtkDataSetWriter()
        writer.SetFileTypeToBinary()

    else:
        raise ValueError("file_format must be 'xml' or 'legacy'")

    writer.SetFileName(filename)
    writer.SetInputData(image)

    if writer.Write() != 1:
        raise OSError(f"Failed to write {filename}")

    return filename

to_bytes()

Convert the field to bytes.

Source code in micpy\bin.py
def to_bytes(self):
    """Convert the field to bytes."""
    header = Header(
        size=self.size * self.itemsize + 8,
        time=self.time,
        length=self.size,
    )
    footer = Footer(length=self.size)

    return header.to_bytes() + self.tobytes() + footer.to_bytes()

to_file(file, geometry=True)

Write the field to a binary file.

Parameters:

Name Type Description Default
file IO[bytes]

Binary file.

required
geometry bool

True if geometry should be written, False otherwise. Defaults to True.

True
Source code in micpy\bin.py
def to_file(self, file: IO[bytes], geometry: bool = True):
    """Write the field to a binary file.

    Args:
        file (IO[bytes]): Binary file.
        geometry (bool, optional): `True` if geometry should be written, `False`
            otherwise. Defaults to `True`.
    """

    file.write(self.to_bytes())

    if geometry:
        geo_filename, geo_data = geo.build(file.name, self.shape, self.spacing)
        geo.write(geo_filename, geo_data, geo.Type.BASIC)

write(filename, compressed=True, geometry=True)

Write the field to a binary file.

Parameters:

Name Type Description Default
filename str

Filename of the binary file.

required
compressed bool

True if file should be compressed, False otherwise. Defaults to True.

True
geometry bool

True if geometry should be written, False otherwise. Defaults to True.

True
Source code in micpy\bin.py
def write(
    self,
    filename: str,
    compressed: bool = True,
    geometry: bool = True,
):
    """Write the field to a binary file.

    Args:
        filename (str): Filename of the binary file.
        compressed (bool, optional): `True` if file should be compressed, `False`
            otherwise. Defaults to `True`.
        geometry (bool, optional): `True` if geometry should be written, `False`
            otherwise. Defaults to `True`.
    """

    file_open = gzip.open if compressed else open
    with file_open(filename, "wb") as file:
        self.to_file(file, geometry)

File

A binary file.

Source code in micpy\bin.py
class File:
    """A binary file."""

    def __init__(
        self,
        filename: str,
        threads: int = None,
    ):
        self.shape: Tuple[int, int, int] = None
        self.spacing: Tuple[float, float, float] = None

        self._filename = filename
        self._threads = threads if threads is not None else min(4, os.cpu_count())
        self._file: IO[bytes] = None
        self._compressed: bool = None
        self._indexed: bool = False
        self._header: Header = None

    def __enter__(self):
        return self.open()

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()

    def __iter__(self) -> Iterator[Field]:
        index = 0
        while True:
            try:
                yield self._read_field(index)
                index += 1
            except (IndexError, EOFError):
                break

    def open(self) -> "File":
        """Open the file.

        Returns:
            File: The opened binary file.
        """
        self._compressed = utils.is_compressed(self._filename)

        if self._compressed:
            self._file = rapidgzip.open(self._filename, self._threads)
            start = t()
            _debug("Importing index file...")
            _try_import_index(self._file, _index_filename(self._filename))
            _debug(f"Index file imported in {t() - start:.2f} seconds.")
        else:
            self._file = builtins.open(self._filename, "rb")

        self._header = Header.read(self._file)

        try:
            geometry = geo.read(geo.find(self._filename), type=geo.Type.BASIC)
            self.shape = self.shape or geometry["shape"][::-1]
            self.spacing = self.spacing or geometry["spacing"][::-1]
        except (geo.GeometryFileNotFoundError, geo.MultipleGeometryFilesError):
            _warn("Caution: A geometry file was not found.")

        return self

    def times(self) -> list[float]:
        """Get the time steps from the binary file.

        Returns:
            list[float]: List of time steps.
        """
        if not self._file:
            self.open()

        field_size = self._header.field_size

        cur = self._file.tell()
        self._file.seek(0, 2)
        file_size = self._file.tell()
        field_count = file_size // field_size
        self._file.seek(cur)

        times: list[float] = []

        for i in range(int(field_count)):
            hdr = Header.read_at(self._file, i * field_size)
            times.append(hdr.time)

        return times

    def _read_field(self, key: int) -> Field:
        if not self._file:
            self.open()
        if isinstance(key, int):
            return Field.read(
                file=self._file,
                field_index=key,
                field_size=Header.read(self._file).field_size,
                shape=self.shape,
                spacing=self.spacing,
            )
        raise TypeError("Invalid argument type")

    def _read_series(self, key: list[int] = None) -> Series:
        if key is None:
            fields: list[Field] = list(self)
            return Series(fields)
        if isinstance(key, list):
            return Series([self._read_field(i) for i in key])
        raise TypeError("Invalid argument type")

    @overload
    def read(self, key: int, threads: int = None) -> Field: ...
    @overload
    def read(self, key: list[int], threads: int = None) -> Series: ...
    @overload
    def read(self, key: None = None, threads: int = None) -> Series: ...

    def read(
        self, key: Union[int, list[int]] = None, threads: int = None
    ) -> Union[Field, Series]:
        """Read a field or series from a binary file.

        Args:
            key (Union[int, list[int]], optional): Key to a field index or a list of field
                indices. Defaults to `None`, which reads all fields.
            threads (int, optional): Number of threads to use for reading compressed files.
                Defaults to `None`, which uses up to 4 threads.
        Returns:
            Union[Field, Series]: Field or series of fields.
        """
        if key is None:
            return self._read_series()
        if isinstance(key, int):
            return self._read_field(key)
        if isinstance(key, list):
            return self._read_series(key)
        raise TypeError("Invalid argument type")

    def close(self):
        """Close the file."""
        if not self._file:
            return

        if self._compressed:
            start = t()
            _debug("Exporting index file...")
            _try_export_index(self._file, _index_filename(self._filename))
            _debug(f"Index file exported in {t() - start:.2f} seconds.")

        self._file.close()
        self._file = None

close()

Close the file.

Source code in micpy\bin.py
def close(self):
    """Close the file."""
    if not self._file:
        return

    if self._compressed:
        start = t()
        _debug("Exporting index file...")
        _try_export_index(self._file, _index_filename(self._filename))
        _debug(f"Index file exported in {t() - start:.2f} seconds.")

    self._file.close()
    self._file = None

open()

Open the file.

Returns:

Name Type Description
File File

The opened binary file.

Source code in micpy\bin.py
def open(self) -> "File":
    """Open the file.

    Returns:
        File: The opened binary file.
    """
    self._compressed = utils.is_compressed(self._filename)

    if self._compressed:
        self._file = rapidgzip.open(self._filename, self._threads)
        start = t()
        _debug("Importing index file...")
        _try_import_index(self._file, _index_filename(self._filename))
        _debug(f"Index file imported in {t() - start:.2f} seconds.")
    else:
        self._file = builtins.open(self._filename, "rb")

    self._header = Header.read(self._file)

    try:
        geometry = geo.read(geo.find(self._filename), type=geo.Type.BASIC)
        self.shape = self.shape or geometry["shape"][::-1]
        self.spacing = self.spacing or geometry["spacing"][::-1]
    except (geo.GeometryFileNotFoundError, geo.MultipleGeometryFilesError):
        _warn("Caution: A geometry file was not found.")

    return self

read(key=None, threads=None)

read(key: int, threads: int = None) -> Field
read(key: list[int], threads: int = None) -> Series
read(key: None = None, threads: int = None) -> Series

Read a field or series from a binary file.

Parameters:

Name Type Description Default
key Union[int, list[int]]

Key to a field index or a list of field indices. Defaults to None, which reads all fields.

None
threads int

Number of threads to use for reading compressed files. Defaults to None, which uses up to 4 threads.

None

Returns: Union[Field, Series]: Field or series of fields.

Source code in micpy\bin.py
def read(
    self, key: Union[int, list[int]] = None, threads: int = None
) -> Union[Field, Series]:
    """Read a field or series from a binary file.

    Args:
        key (Union[int, list[int]], optional): Key to a field index or a list of field
            indices. Defaults to `None`, which reads all fields.
        threads (int, optional): Number of threads to use for reading compressed files.
            Defaults to `None`, which uses up to 4 threads.
    Returns:
        Union[Field, Series]: Field or series of fields.
    """
    if key is None:
        return self._read_series()
    if isinstance(key, int):
        return self._read_field(key)
    if isinstance(key, list):
        return self._read_series(key)
    raise TypeError("Invalid argument type")

times()

Get the time steps from the binary file.

Returns:

Type Description
list[float]

list[float]: List of time steps.

Source code in micpy\bin.py
def times(self) -> list[float]:
    """Get the time steps from the binary file.

    Returns:
        list[float]: List of time steps.
    """
    if not self._file:
        self.open()

    field_size = self._header.field_size

    cur = self._file.tell()
    self._file.seek(0, 2)
    file_size = self._file.tell()
    field_count = file_size // field_size
    self._file.seek(cur)

    times: list[float] = []

    for i in range(int(field_count)):
        hdr = Header.read_at(self._file, i * field_size)
        times.append(hdr.time)

    return times

Footer

A field footer.

Source code in micpy\bin.py
class Footer:
    """A field footer."""

    TYPE = [("length", np.int32)]
    SIZE = np.dtype(TYPE).itemsize

    def __init__(self, length: int = 0):
        data = np.array((length,), dtype=self.TYPE)
        self.body_length = data["length"]

    def to_bytes(self):
        """Convert the footer to bytes."""
        return np.array((self.body_length,), dtype=self.TYPE).tobytes()

to_bytes()

Convert the footer to bytes.

Source code in micpy\bin.py
def to_bytes(self):
    """Convert the footer to bytes."""
    return np.array((self.body_length,), dtype=self.TYPE).tobytes()

Header

A field header.

Source code in micpy\bin.py
class Header:
    """A field header."""

    TYPE = [("size", np.int32), ("time", np.float32), ("length", np.int32)]
    SIZE = np.dtype(TYPE).itemsize

    def __init__(self, size: int, time: float, length: int):
        self.size = size
        self.time = round(float(time), 7)
        self.body_length = length

        self.field_size = Header.SIZE + 4 * self.body_length + Footer.SIZE

        if not self.size == self.field_size - 8:
            raise ValueError("Invalid header")

    @staticmethod
    def from_bytes(data: bytes):
        """Create a new header from bytes."""
        kwargs = np.frombuffer(data[: Header.SIZE], dtype=Header.TYPE)
        return Header(*kwargs[0].item())

    def to_bytes(self):
        """Convert the header to bytes."""
        return np.array(
            (self.size, self.time, self.body_length), dtype=Header.TYPE
        ).tobytes()

    @staticmethod
    def read(file: IO[bytes]) -> "Header":
        """Read the header of a binary file."""
        file.seek(0)
        data = file.read(Header.SIZE)
        return Header.from_bytes(data)

    @staticmethod
    def read_at(file: IO[bytes], offset: int) -> "Header":
        file.seek(offset)
        data = file.read(Header.SIZE)
        if len(data) < Header.SIZE:
            raise EOFError("Unexpected end of file")
        return Header.from_bytes(data)

    def get_field_count(self, file_size: int) -> int:
        """Get the number of fields in the file."""
        return file_size // self.field_size

from_bytes(data) staticmethod

Create a new header from bytes.

Source code in micpy\bin.py
@staticmethod
def from_bytes(data: bytes):
    """Create a new header from bytes."""
    kwargs = np.frombuffer(data[: Header.SIZE], dtype=Header.TYPE)
    return Header(*kwargs[0].item())

get_field_count(file_size)

Get the number of fields in the file.

Source code in micpy\bin.py
def get_field_count(self, file_size: int) -> int:
    """Get the number of fields in the file."""
    return file_size // self.field_size

read(file) staticmethod

Read the header of a binary file.

Source code in micpy\bin.py
@staticmethod
def read(file: IO[bytes]) -> "Header":
    """Read the header of a binary file."""
    file.seek(0)
    data = file.read(Header.SIZE)
    return Header.from_bytes(data)

to_bytes()

Convert the header to bytes.

Source code in micpy\bin.py
def to_bytes(self):
    """Convert the header to bytes."""
    return np.array(
        (self.size, self.time, self.body_length), dtype=Header.TYPE
    ).tobytes()

Series

Bases: ndarray

Source code in micpy\bin.py
class Series(np.ndarray):
    def __new__(cls, fields: List[Field]):
        obj = np.asarray(fields).view(cls)
        obj.times = [field.time for field in fields]
        obj.spacings = [field.spacing for field in fields]
        return obj

    def __array_finalize__(self, obj):
        if obj is None:
            return

        # pylint: disable=attribute-defined-outside-init
        self.times = getattr(obj, "times", None)
        self.spacings = getattr(obj, "spacings", None)

    def field(self, index: int) -> Field:
        """Get a field from the series.

        Args:
            index (int): Index of the field.

        Returns:
            Field.
        """
        return Field(self[index], self.times[index], self.spacings[index])

    def series(self, key: Union[int, slice, list]) -> "Series":
        """Get a series of fields.

        Args:
            key (Union[int, slice, list]): Key to list of field indices, a slice object, or a
                list of field indices.

        Returns:
            Series of fields.
        """
        if isinstance(key, int):
            return Series([self.field(key)])
        if isinstance(key, slice):
            return Series([self.field(i) for i in range(*key.indices(len(self)))])
        if isinstance(key, list):
            return Series([self.field(i) for i in key])
        raise TypeError("Invalid argument type")

    def write(self, filename: str, compressed: bool = True, geometry: bool = True):
        """Write the series to a binary file.

        Args:
            filename (str): Filename of the binary file.
            compressed (bool, optional): `True` if file should be compressed, `False`
                otherwise. Defaults to `True`.
            geometry (bool, optional): `True` if geometry should be written, `False`
                otherwise. Defaults to `True`.
        """

        file_open = gzip.open if compressed else open
        with file_open(filename, "wb") as file:
            for item, time, spacing in zip(self, self.times, self.spacings):
                Field(item, time, spacing).to_file(file, geometry)
                geometry = False

field(index)

Get a field from the series.

Parameters:

Name Type Description Default
index int

Index of the field.

required

Returns:

Type Description
Field

Field.

Source code in micpy\bin.py
def field(self, index: int) -> Field:
    """Get a field from the series.

    Args:
        index (int): Index of the field.

    Returns:
        Field.
    """
    return Field(self[index], self.times[index], self.spacings[index])

series(key)

Get a series of fields.

Parameters:

Name Type Description Default
key Union[int, slice, list]

Key to list of field indices, a slice object, or a list of field indices.

required

Returns:

Type Description
Series

Series of fields.

Source code in micpy\bin.py
def series(self, key: Union[int, slice, list]) -> "Series":
    """Get a series of fields.

    Args:
        key (Union[int, slice, list]): Key to list of field indices, a slice object, or a
            list of field indices.

    Returns:
        Series of fields.
    """
    if isinstance(key, int):
        return Series([self.field(key)])
    if isinstance(key, slice):
        return Series([self.field(i) for i in range(*key.indices(len(self)))])
    if isinstance(key, list):
        return Series([self.field(i) for i in key])
    raise TypeError("Invalid argument type")

write(filename, compressed=True, geometry=True)

Write the series to a binary file.

Parameters:

Name Type Description Default
filename str

Filename of the binary file.

required
compressed bool

True if file should be compressed, False otherwise. Defaults to True.

True
geometry bool

True if geometry should be written, False otherwise. Defaults to True.

True
Source code in micpy\bin.py
def write(self, filename: str, compressed: bool = True, geometry: bool = True):
    """Write the series to a binary file.

    Args:
        filename (str): Filename of the binary file.
        compressed (bool, optional): `True` if file should be compressed, `False`
            otherwise. Defaults to `True`.
        geometry (bool, optional): `True` if geometry should be written, `False`
            otherwise. Defaults to `True`.
    """

    file_open = gzip.open if compressed else open
    with file_open(filename, "wb") as file:
        for item, time, spacing in zip(self, self.times, self.spacings):
            Field(item, time, spacing).to_file(file, geometry)
            geometry = False

open(filename, threads=None)

Open a binary file.

Parameters:

Name Type Description Default
filename str

Filename of the binary file.

required
threads int

Number of threads to use for reading compressed files. Defaults to None, which uses up to 4 threads.

None

Returns: File: Binary file.

Source code in micpy\bin.py
def open(filename: str, threads: int = None) -> File:
    """Open a binary file.

    Args:
        filename (str): Filename of the binary file.
        threads (int, optional): Number of threads to use for reading compressed files.
            Defaults to `None`, which uses up to 4 threads.
    Returns:
        File: Binary file.
    """
    return File(filename, threads).open()

pv_plugin_path()

Get the path to the ParaView plugin directory for MicPy.

Returns:

Name Type Description
str str

Path to the ParaView plugin directory for MicPy.

Source code in micpy\bin.py
def pv_plugin_path() -> str:
    """Get the path to the ParaView plugin directory for MicPy.

    Returns:
        str: Path to the ParaView plugin directory for MicPy.
    """
    return str(Path(__file__).parent / "paraview")

read(filename, key=None, threads=None)

read(filename: str, key: int, threads: int = None) -> Field
read(filename: str, key: list[int], threads: int = None) -> Series
read(filename: str, key: None = None, threads: int = None) -> Series

Read a field or series from a binary file.

Parameters:

Name Type Description Default
filename str

Filename of the binary file.

required
key Union[int, list[int]]

Key to a field index or a list of field indices. Defaults to None, which reads all fields.

None
threads int

Number of threads to use for reading compressed files. Defaults to None, which uses up to 4 threads.

None

Returns: Union[Field, Series]: Field or series of fields.

Source code in micpy\bin.py
def read(
    filename: str, key: Union[int, list[int]] = None, threads: int = None
) -> Union[Field, Series]:
    """Read a field or series from a binary file.

    Args:
        filename (str): Filename of the binary file.
        key (Union[int, list[int]], optional): Key to a field index or a list of field
            indices. Defaults to `None`, which reads all fields.
        threads (int, optional): Number of threads to use for reading compressed files.
            Defaults to `None`, which uses up to 4 threads.
    Returns:
        Union[Field, Series]: Field or series of fields.
    """
    with open(filename, threads) as file:
        return file.read(key)

times(filename, threads=None)

Get the time steps from a binary file.

Parameters:

Name Type Description Default
filename str

Filename of the binary file.

required
threads int

Number of threads to use for reading compressed files. Defaults to None, which uses up to 4 threads.

None

Returns: list[float]: List of time steps.

Source code in micpy\bin.py
def times(filename: str, threads: int = None) -> list[float]:
    """Get the time steps from a binary file.

    Args:
        filename (str): Filename of the binary file.
        threads (int, optional): Number of threads to use for reading compressed files.
            Defaults to `None`, which uses up to 4 threads.
    Returns:
        list[float]: List of time steps.
    """

    with open(filename, threads) as file:
        return file.times()