micress-micpy 0.3.2b2__py3-none-any.whl → 0.4.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
micpy/bin.py CHANGED
@@ -1,61 +1,79 @@
1
1
  """The `micpy.bin` module provides methods to read and write binary files."""
2
2
 
3
- from dataclasses import dataclass
4
- from typing import Callable, Generator, IO, List, Optional, Tuple, Union
3
+ import builtins
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+ from time import time as t
7
+ from typing import IO, List, Optional, Tuple, Union, overload
8
+ import os
5
9
 
6
10
  import gzip
7
- import os
8
- import sys
9
- import zlib
11
+ import rapidgzip
10
12
 
11
13
  import numpy as np
12
14
 
13
- from micpy import geo
14
- from micpy import utils
15
- from micpy.matplotlib import matplotlib, pyplot
16
-
17
-
18
- __all__ = ["File", "Field", "Series", "plot", "PlotArgs"]
19
-
20
-
21
- @dataclass
22
- class Chunk:
23
- """A chunk of uncompressed binary data."""
24
-
25
- DEFAULT_SIZE = 8388608
26
-
27
- data: bytes
28
- decompressobj: zlib.decompressobj = None
29
-
30
- @staticmethod
31
- def iterate(
32
- file: IO[bytes],
33
- chunk_size: int,
34
- compressed: bool = True,
35
- offset: int = 0,
36
- decompressobj: zlib.decompressobj = None,
37
- ) -> Generator["Chunk", None, None]:
38
- """Yield chunks of uncompressed binary data."""
39
- file.seek(offset)
15
+ try:
16
+ import matplotlib
17
+ from matplotlib.axes import Axes
18
+ from matplotlib.figure import Figure
19
+ from matplotlib.colorbar import Colorbar
20
+ from matplotlib import pyplot
21
+
22
+ HAS_MATPLOTLIB = True
23
+
24
+ def register_colormaps():
25
+ colors = [
26
+ "#1102d8",
27
+ "#3007ba",
28
+ "#500b9d",
29
+ "#6f0e81",
30
+ "#8d1364",
31
+ "#ac1748",
32
+ "#cb1b2b",
33
+ "#ea1e0f",
34
+ "#f83605",
35
+ "#fa600f",
36
+ "#fb8817",
37
+ "#fdb120",
38
+ "#ffda29",
39
+ "#ffed4d",
40
+ "#fff380",
41
+ "#fffbb4",
42
+ ]
43
+
44
+ create = matplotlib.colors.LinearSegmentedColormap.from_list
45
+
46
+ colormap = create(name="micpy", colors=colors, N=1024)
47
+ colormap_r = create(name="micpy_r", colors=colors[::-1], N=1024)
48
+
49
+ register = matplotlib.colormaps.register
50
+ register(colormap)
51
+ register(colormap_r)
52
+
53
+ if not HAS_MATPLOTLIB:
54
+ raise ImportError("Matplotlib is not installed.")
55
+
56
+ try:
57
+ register_colormaps()
58
+ except ValueError:
59
+ pass
60
+
61
+ except ImportError:
62
+ HAS_MATPLOTLIB = False
63
+
64
+ try:
65
+ from vtk import vtkImageData, vtkXMLImageDataWriter
66
+ from vtkmodules.util.numpy_support import numpy_to_vtk
67
+ HAS_VTK = True
68
+ except ImportError:
69
+ HAS_VTK = False
40
70
 
41
- if decompressobj:
42
- decompressobj = decompressobj.copy()
43
- else:
44
- decompressobj = (
45
- zlib.decompressobj(zlib.MAX_WBITS | 32) if compressed else None
46
- )
47
71
 
48
- while True:
49
- data = file.read(chunk_size)
50
- if data == b"":
51
- break
72
+ from micpy import geo
73
+ from micpy import utils
52
74
 
53
- if compressed:
54
- prev_decompressobj = decompressobj
55
- decompressobj = prev_decompressobj.copy()
56
- data = decompressobj.decompress(data)
57
75
 
58
- yield Chunk(data, prev_decompressobj if compressed else None)
76
+ __all__ = ["open", "read", "Field", "Series", "File"]
59
77
 
60
78
 
61
79
  class Footer:
@@ -102,161 +120,24 @@ class Header:
102
120
  ).tobytes()
103
121
 
104
122
  @staticmethod
105
- def read(filename: str, compressed: bool = True) -> "Header":
123
+ def read(file: IO[bytes]) -> "Header":
106
124
  """Read the header of a binary file."""
107
- file_open = gzip.open if compressed else open
108
-
109
- with file_open(filename, "rb") as file:
110
- file.seek(0)
111
- data = file.read(Header.SIZE)
112
- return Header.from_bytes(data)
113
-
114
-
115
- @dataclass
116
- class Position:
117
- """A field position in a binary file."""
118
-
119
- id: int
120
- time: float
121
- chunk_id: int
122
- chunk_offset: int
123
- decompressobj: zlib.decompressobj
124
- chunk_size: int
125
- field_size: int
126
- file: IO[bytes]
127
-
125
+ file.seek(0)
126
+ data = file.read(Header.SIZE)
127
+ return Header.from_bytes(data)
128
+
128
129
  @staticmethod
129
- def _iterate_params(
130
- file: IO[bytes], position: int, compressed: bool, chunk_size: int
131
- ):
132
- if position:
133
- return (
134
- position.file,
135
- position.decompressobj is not None,
136
- position.chunk_size,
137
- position.field_size,
138
- position.id,
139
- position.chunk_id[0],
140
- position.chunk_offset[0],
141
- position.decompressobj.copy() if position.decompressobj else None,
142
- position.chunk_id[0] * position.chunk_size,
143
- )
144
-
145
- header = Header.read(file.name, compressed)
146
- return (file, compressed, chunk_size, header.field_size, 0, 0, 0, None, 0)
147
-
148
- @staticmethod
149
- def _process_buffer(chunk_buffer: bytes, field_buffer: bytes, field_size: int):
150
- required_size = field_size - len(field_buffer)
151
- required_buffer = chunk_buffer[:required_size]
152
- field_buffer += required_buffer
153
- chunk_buffer = chunk_buffer[required_size:]
154
- return field_buffer, chunk_buffer, len(required_buffer)
155
-
156
- @staticmethod
157
- def iterate(
158
- file: IO[bytes] = None,
159
- compressed: bool = True,
160
- chunk_size: int = Chunk.DEFAULT_SIZE,
161
- position: "Position" = None,
162
- ) -> Generator["Position", None, None]:
163
- """Yield positions of fields in a binary file."""
164
-
165
- # File: [0100110001110101011010110110000101110011]
166
- # Chunks: [ 0 | 1 | 2 | 3 | 4 ]
167
- # Fields: [0 |1 |2 |3 |4 |5 ]
168
-
169
- (
170
- file,
171
- compressed,
172
- chunk_size,
173
- field_size,
174
- field_id,
175
- chunk_id,
176
- chunk_offset,
177
- decompressobj,
178
- offset,
179
- ) = Position._iterate_params(file, position, compressed, chunk_size)
180
-
181
- field_buffer = b""
182
- prev_chunk_id = chunk_id
183
- prev_chunk_offset = chunk_offset
184
-
185
- for chunk in Chunk.iterate(
186
- file,
187
- chunk_size=chunk_size,
188
- compressed=compressed,
189
- offset=offset,
190
- decompressobj=decompressobj,
191
- ):
192
- chunk_buffer = chunk.data
193
-
194
- if chunk_offset:
195
- chunk_buffer = chunk_buffer[chunk_offset:]
196
-
197
- while chunk_buffer:
198
- if len(field_buffer) == 0:
199
- decompressobj = chunk.decompressobj
200
- prev_chunk_id = chunk_id
201
- prev_chunk_offset = chunk_offset
202
-
203
- (field_buffer, chunk_buffer, consumed) = Position._process_buffer(
204
- chunk_buffer, field_buffer, field_size
205
- )
206
- chunk_offset += consumed
207
-
208
- if len(field_buffer) == field_size:
209
- if not (position and position.id == field_id):
210
- yield Position(
211
- id=field_id,
212
- time=Header.from_bytes(field_buffer).time,
213
- chunk_id=(prev_chunk_id, chunk_id),
214
- chunk_offset=(prev_chunk_offset, chunk_offset),
215
- chunk_size=chunk_size,
216
- field_size=field_size,
217
- decompressobj=decompressobj,
218
- file=file,
219
- )
220
- field_id += 1
221
- field_buffer = b""
222
-
223
- chunk_id += 1
224
- chunk_offset = 0
225
-
226
-
227
- class Index(List[Position]):
228
- """An index of fields in a binary file."""
229
-
230
- @staticmethod
231
- def from_file(
232
- file: IO[bytes],
233
- verbose: bool = True,
234
- chunk_size: int = Chunk.DEFAULT_SIZE,
235
- compressed: bool = True,
236
- position: Position = None,
237
- ):
238
- """Build an index from a binary file."""
239
- iterator = Position.iterate(
240
- file, chunk_size=chunk_size, compressed=compressed, position=position
241
- )
130
+ def read_at(file: IO[bytes], offset: int) -> "Header":
131
+ file.seek(offset)
132
+ data = file.read(Header.SIZE)
133
+ if len(data) < Header.SIZE:
134
+ raise EOFError("Unexpected end of file")
135
+ return Header.from_bytes(data)
242
136
 
243
- if verbose:
244
- iterator = utils.progress_indicator(
245
- iterator, description="Indexing", unit="Field"
246
- )
247
137
 
248
- return Index(iterator)
249
-
250
- @staticmethod
251
- def from_filename(
252
- filename: str,
253
- verbose: bool = True,
254
- chunk_size: int = Chunk.DEFAULT_SIZE,
255
- compressed: bool = True,
256
- ):
257
- """Build an index from a binary file."""
258
- file = open(filename, "rb")
259
- return Index.from_file(file, verbose, chunk_size, compressed)
138
+ def get_field_count(self, file_size: int) -> int:
139
+ """Get the number of fields in the file."""
140
+ return file_size // self.field_size
260
141
 
261
142
 
262
143
  class Field(np.ndarray):
@@ -290,8 +171,6 @@ class Field(np.ndarray):
290
171
 
291
172
  data = data[start:end]
292
173
  data = np.frombuffer(data, count=count, dtype="float32")
293
- if np.all(np.isclose(data, data.astype("int32"))):
294
- data = data.astype("int32")
295
174
 
296
175
  if shape is not None:
297
176
  data = data.reshape(shape)
@@ -321,36 +200,42 @@ class Field(np.ndarray):
321
200
  return 3
322
201
 
323
202
  @staticmethod
324
- def read(position: Position, shape=None, spacing=None) -> "Field":
203
+ def read(
204
+ file: IO[bytes],
205
+ field_index: int,
206
+ field_size: int,
207
+ shape=None,
208
+ spacing=None,
209
+ ) -> "Field":
325
210
  """Read a field from a binary file."""
326
- file_offset = position.chunk_id[0] * position.chunk_size
327
- position.file.seek(file_offset)
328
-
329
- decompressobj = (
330
- position.decompressobj.copy() if position.decompressobj else None
331
- )
332
211
 
333
- field_buffer = b""
334
-
335
- while True:
336
- chunk_data = position.file.read(position.chunk_size)
337
-
338
- if not chunk_data:
339
- break
340
-
341
- data = decompressobj.decompress(chunk_data) if decompressobj else chunk_data
342
-
343
- if field_buffer == b"":
344
- field_buffer = data[position.chunk_offset[0] :]
345
- else:
346
- field_buffer += data
347
-
348
- if len(field_buffer) >= position.field_size:
349
- break
212
+ if field_index < 0:
213
+ start = t()
214
+ _debug("Indexing file...")
215
+ file.seek(0, 2)
216
+ file_size = file.tell()
217
+ field_count = file_size // field_size
218
+ field_index += field_count
219
+ _debug(f"Index completed in {t() - start:.2f} seconds.")
220
+
221
+ start = t()
222
+ _debug(f"Seeking to field index {field_index}...")
223
+ offset = field_size * field_index
224
+ file.seek(offset)
225
+ _debug(f"Seek completed in {t() - start:.2f} seconds.")
350
226
 
351
- field_data = field_buffer[: position.field_size]
227
+ start = t()
228
+ _debug(f"Reading data for field index {field_index}...")
229
+ field_data = file.read(field_size)
230
+ if len(field_data) < field_size:
231
+ raise EOFError("Unexpected end of file")
232
+ _debug(f"Read completed in {t() - start:.2f} seconds.")
352
233
 
353
- return Field.from_bytes(field_data, shape=shape, spacing=spacing)
234
+ start = t()
235
+ _debug(f"Creating Field object for field index {field_index}...")
236
+ field = Field.from_bytes(field_data, shape=shape, spacing=spacing)
237
+ _debug(f"Field object created in {t() - start:.2f} seconds.")
238
+ return field
354
239
 
355
240
  def to_file(self, file: IO[bytes], geometry: bool = True):
356
241
  """Write the field to a binary file.
@@ -387,6 +272,220 @@ class Field(np.ndarray):
387
272
  with file_open(filename, "wb") as file:
388
273
  self.to_file(file, geometry)
389
274
 
275
+ def plot(
276
+ self,
277
+ axis: str = "y",
278
+ index: int = 0,
279
+ title: Optional[str] = None,
280
+ xlabel: Optional[str] = None,
281
+ ylabel: Optional[str] = None,
282
+ figsize: Optional[Tuple[float, float]] = None,
283
+ dpi: Optional[int] = None,
284
+ aspect: str = "equal",
285
+ ax: "Axes" = None,
286
+ cax: "Axes" = None,
287
+ vmin: Optional[float] = None,
288
+ vmax: Optional[float] = None,
289
+ cmap: str = "micpy",
290
+ alpha: float = 1.0,
291
+ interpolation: str = "none",
292
+ extent: Optional[Tuple[float, float, float, float]] = None,
293
+ ) -> Tuple["Figure", "Axes", "Colorbar"]:
294
+ """Plot a slice of the field using Matplotlib.
295
+
296
+ Args:
297
+ axis (str, optional): Axis to plot. Possible values are `x`, `y`, and `z`.
298
+ Defaults to `y`.
299
+ index (int, optional): Index of the slice. Defaults to `0`.
300
+ title (str, optional): Title of the plot. Defaults to `None`.
301
+ xlabel (str, optional): Label of the x-axis. Defaults to `None`.
302
+ ylabel (str, optional): Label of the y-axis. Defaults to `None`.
303
+ figsize (Tuple[float, float], optional): Figure size. Defaults to `None`.
304
+ dpi (int, optional): Figure DPI. Defaults to `None`.
305
+ aspect (str, optional): Aspect ratio. Defaults to `equal`.
306
+ ax (Axes, optional): Axes of the plot. Defaults to `None`.
307
+ cax (Axes, optional): Axes of the color bar. Defaults to `None`.
308
+ vmin (float, optional): Minimum value of the color bar. Defaults to `None`.
309
+ vmax (float, optional): Maximum value of the color bar. Defaults to `None`.
310
+ cmap (str, optional): Colormap. Defaults to `micpy`.
311
+ alpha (float, optional): Transparency of the plot. Defaults to `1.0`.
312
+ interpolation (str, optional): Interpolation method. Defaults to `none`.
313
+ extent (Tuple[float, float, float, float], optional): Extent of the plot.
314
+
315
+ Returns:
316
+ Matplotlib figure, axes, and color bar.
317
+ """
318
+
319
+ if not HAS_MATPLOTLIB:
320
+ raise ImportError("Matplotlib is not installed.")
321
+
322
+ if self.ndim != 3:
323
+ raise ValueError("'field' must be a 3D array")
324
+
325
+ if axis == "z":
326
+ x, y = "x", "y"
327
+ slice_2d = self[index, :, :]
328
+ elif axis == "y":
329
+ x, y = "x", "z"
330
+ slice_2d = self[:, index, :]
331
+ elif axis == "x":
332
+ x, y = "y", "z"
333
+ slice_2d = self[:, :, index]
334
+ else:
335
+ raise ValueError("'axis' must be 'x', 'y', or 'z'")
336
+
337
+ fig, ax = (
338
+ pyplot.subplots(figsize=figsize, dpi=dpi)
339
+ if ax is None
340
+ else (ax.get_figure(), ax)
341
+ )
342
+
343
+ if title is not None:
344
+ ax.set_title(title)
345
+ else:
346
+ ax.set_title(f"t={np.round(self.time, 7)}s")
347
+ if xlabel is not None:
348
+ ax.set_xlabel(xlabel)
349
+ else:
350
+ ax.set_xlabel(x)
351
+ if ylabel is not None:
352
+ ax.set_ylabel(ylabel)
353
+ else:
354
+ ax.set_ylabel(y)
355
+ if aspect is not None:
356
+ ax.set_aspect(aspect)
357
+ ax.set_frame_on(False)
358
+
359
+ image = ax.imshow(
360
+ slice_2d,
361
+ cmap=cmap,
362
+ vmin=vmin,
363
+ vmax=vmax,
364
+ interpolation=interpolation,
365
+ alpha=alpha,
366
+ extent=extent,
367
+ origin="lower",
368
+ )
369
+
370
+ cbar = pyplot.colorbar(image, ax=ax, cax=cax)
371
+ cbar.locator = matplotlib.ticker.MaxNLocator(
372
+ integer=np.issubdtype(slice_2d.dtype, np.integer)
373
+ )
374
+ cbar.outline.set_visible(False)
375
+ cbar.update_ticks()
376
+
377
+ return fig, ax, cbar
378
+
379
+ def as_vti(
380
+ self,
381
+ name: str = "values",
382
+ point_data: bool = False,
383
+ ) -> "vtkImageData":
384
+ """Convert the field to a VTK ImageData object.
385
+
386
+ Args:
387
+ name (str, optional): Name of the data array. Defaults to `values`.
388
+ point_data (bool, optional): `True` if data should be stored as PointData,
389
+ `False` if data should be stored as CellData. Defaults to `False`.
390
+ Returns:
391
+ VTK ImageData object.
392
+ """
393
+
394
+ if not HAS_VTK:
395
+ raise ImportError("VTK is not installed.")
396
+
397
+ cells = np.asarray(self)
398
+ if cells.ndim != 3:
399
+ raise ValueError("field must be 3D (nz, ny, nx)")
400
+
401
+ nz, ny, nx = cells.shape
402
+ dz, dy, dx = self.spacing
403
+
404
+ def cell_to_point_average(c: np.ndarray) -> np.ndarray:
405
+ nz_, ny_, nx_ = c.shape
406
+ acc = np.zeros((nz_ + 1, ny_ + 1, nx_ + 1),
407
+ dtype=np.result_type(c.dtype, np.float32))
408
+ w = np.zeros((nz_ + 1, ny_ + 1, nx_ + 1), dtype=np.float32)
409
+
410
+ acc[0:nz_, 0:ny_, 0:nx_ ] += c
411
+ acc[0:nz_, 0:ny_, 1:nx_+1] += c
412
+ acc[0:nz_, 1:ny_+1, 0:nx_ ] += c
413
+ acc[0:nz_, 1:ny_+1, 1:nx_+1] += c
414
+ acc[1:nz_+1, 0:ny_, 0:nx_ ] += c
415
+ acc[1:nz_+1, 0:ny_, 1:nx_+1] += c
416
+ acc[1:nz_+1, 1:ny_+1, 0:nx_ ] += c
417
+ acc[1:nz_+1, 1:ny_+1, 1:nx_+1] += c
418
+
419
+ w[0:nz_, 0:ny_, 0:nx_ ] += 1
420
+ w[0:nz_, 0:ny_, 1:nx_+1] += 1
421
+ w[0:nz_, 1:ny_+1, 0:nx_ ] += 1
422
+ w[0:nz_, 1:ny_+1, 1:nx_+1] += 1
423
+ w[1:nz_+1, 0:ny_, 0:nx_ ] += 1
424
+ w[1:nz_+1, 0:ny_, 1:nx_+1] += 1
425
+ w[1:nz_+1, 1:ny_+1, 0:nx_ ] += 1
426
+ w[1:nz_+1, 1:ny_+1, 1:nx_+1] += 1
427
+
428
+ return acc / w
429
+
430
+ if point_data:
431
+ data = cell_to_point_average(cells)
432
+ else:
433
+ data = cells
434
+
435
+ data_c = np.ascontiguousarray(data)
436
+ flat = data_c.ravel(order="C")
437
+
438
+ image = vtkImageData()
439
+ image.SetOrigin(0.0, 0.0, 0.0)
440
+ image.SetSpacing(float(dx), float(dy), float(dz))
441
+ image.SetDimensions(nx + 1, ny + 1, nz + 1)
442
+
443
+ vtk_arr = numpy_to_vtk(flat, deep=False)
444
+ vtk_arr.SetName(name)
445
+
446
+ if point_data:
447
+ image.GetPointData().SetScalars(vtk_arr)
448
+ else:
449
+ image.GetCellData().SetScalars(vtk_arr)
450
+
451
+ image._numpy_pin = data_c
452
+
453
+ return image
454
+
455
+
456
+ def save_vti(
457
+ self,
458
+ filename: str,
459
+ name: str = "values",
460
+ point_data: bool = False,
461
+ ) -> str:
462
+ """Save the field as a VTK ImageData file.
463
+
464
+ Args:
465
+ filename (str): Filename of the VTK ImageData file.
466
+ name (str, optional): Name of the data array. Defaults to `values`.
467
+ point_data (bool, optional): `True` if data should be stored as PointData
468
+ `False` if data should be stored as CellData. Defaults to `False`.
469
+ Returns:
470
+ Filename of the VTK ImageData file.
471
+ """
472
+
473
+ image = self.as_vti(name=name, point_data=point_data)
474
+
475
+ filename = str(Path(filename).with_suffix(".vti"))
476
+
477
+ writer = vtkXMLImageDataWriter()
478
+ writer.SetFileName(filename)
479
+
480
+ writer.SetInputData(image)
481
+ writer.SetDataModeToAppended()
482
+ writer.EncodeAppendedDataOff()
483
+
484
+ ok = writer.Write()
485
+ if ok != 1:
486
+ raise OSError(f"Failed to write {filename}")
487
+
488
+ return filename
390
489
 
391
490
  class Series(np.ndarray):
392
491
  def __new__(cls, fields: List[Field]):
@@ -403,16 +502,7 @@ class Series(np.ndarray):
403
502
  self.times = getattr(obj, "times", None)
404
503
  self.spacings = getattr(obj, "spacings", None)
405
504
 
406
- def iter_field(self):
407
- """Iterate over fields in the series.
408
-
409
- Yields:
410
- Field.
411
- """
412
- for item, time, spacing in zip(self, self.times, self.spacings):
413
- yield Field(item, time, spacing)
414
-
415
- def get_field(self, index: int) -> Field:
505
+ def field(self, index: int) -> Field:
416
506
  """Get a field from the series.
417
507
 
418
508
  Args:
@@ -423,22 +513,22 @@ class Series(np.ndarray):
423
513
  """
424
514
  return Field(self[index], self.times[index], self.spacings[index])
425
515
 
426
- def get_series(self, key: Union[int, slice, list]) -> "Series":
516
+ def series(self, key: Union[int, slice, list]) -> "Series":
427
517
  """Get a series of fields.
428
518
 
429
519
  Args:
430
- key (Union[int, slice, list]): Key to list of field IDs, a slice object, or a
431
- list of field IDs.
520
+ key (Union[int, slice, list]): Key to list of field indices, a slice object, or a
521
+ list of field indices.
432
522
 
433
523
  Returns:
434
524
  Series of fields.
435
525
  """
436
526
  if isinstance(key, int):
437
- return Series([self.get_field(key)])
527
+ return Series([self.field(key)])
438
528
  if isinstance(key, slice):
439
- return Series([self.get_field(i) for i in range(*key.indices(len(self)))])
529
+ return Series([self.field(i) for i in range(*key.indices(len(self)))])
440
530
  if isinstance(key, list):
441
- return Series([self.get_field(i) for i in key])
531
+ return Series([self.field(i) for i in key])
442
532
  raise TypeError("Invalid argument type")
443
533
 
444
534
  def write(self, filename: str, compressed: bool = True, geometry: bool = True):
@@ -454,55 +544,84 @@ class Series(np.ndarray):
454
544
 
455
545
  file_open = gzip.open if compressed else open
456
546
  with file_open(filename, "wb") as file:
457
- for field in self.iter_field():
458
- field.to_file(file, geometry)
547
+ for item, time, spacing in zip(self, self.times, self.spacings):
548
+ Field(item, time, spacing).to_file(file, geometry)
459
549
  geometry = False
460
550
 
461
551
 
552
+ DEBUG = False
553
+ WARN = True
554
+
555
+
556
+ def _debug(*args):
557
+ # TODO: Use logging module
558
+ if DEBUG:
559
+ print("Debug:", *args)
560
+
561
+
562
+ def _warn(*args):
563
+ # TODO: Use logging module
564
+ if WARN:
565
+ print("Warning:", *args)
566
+
567
+
568
+ def _index_filename(filename: str) -> Path:
569
+ return Path(f"{filename}.idx")
570
+
571
+
572
+ def _try_import_index(file, index: str):
573
+ path = Path(index)
574
+ if not path.is_file():
575
+ _debug("Index file not found.")
576
+ return
577
+ if not os.access(path, os.R_OK):
578
+ _debug("Index file is not readable.")
579
+ return
580
+ try:
581
+ file.import_index(str(path))
582
+ _debug("Index file imported.")
583
+ except ValueError:
584
+ _debug("Index file is invalid.")
585
+ return
586
+ except (PermissionError, FileNotFoundError, IsADirectoryError, OSError):
587
+ _debug("Failed to import index file.")
588
+ return
589
+
590
+
591
+ def _try_export_index(file, index: str):
592
+ path = Path(index)
593
+
594
+ if path.exists() and not path.is_file():
595
+ _warn("Index path is not a file.")
596
+ return
597
+ if not os.access(path.parent, os.W_OK):
598
+ _warn("Index file is not writable.")
599
+ return
600
+ try:
601
+ file.export_index(str(path))
602
+ _debug("Index file exported.")
603
+ except (PermissionError, IsADirectoryError, FileNotFoundError, OSError):
604
+ _warn("Failed to export index file.")
605
+ return
606
+
607
+
462
608
  class File:
463
609
  """A binary file."""
464
610
 
465
611
  def __init__(
466
612
  self,
467
613
  filename: str,
468
- chunk_size: int = Chunk.DEFAULT_SIZE,
469
- verbose: bool = True,
614
+ threads: int = None,
470
615
  ):
471
- """Initialize a binary file.
472
-
473
- Args:
474
- filename (str): File name.
475
- chunk_size (int, optional): Chunk size in bytes. Defaults to `8388608`
476
- (8 MiB).
477
- verbose (bool, optional): Verbose output. Defaults to `True`.
478
-
479
- Raises:
480
- `FileNotFoundError`: If file is not found.
481
- """
482
- if not os.path.isfile(filename):
483
- raise FileNotFoundError(f"File not found: {filename}")
484
-
485
- self._filename: str = filename
486
- self._chunk_size: int = chunk_size
487
- self._verbose: bool = verbose
616
+ self.shape: Tuple[int, int, int] = None
617
+ self.spacing: Tuple[float, float, float] = None
488
618
 
619
+ self._filename = filename
620
+ self._threads = threads if threads is not None else min(4, os.cpu_count())
489
621
  self._file: IO[bytes] = None
490
622
  self._compressed: bool = None
491
- self._created: float = None
492
- self._modified: float = None
493
- self._index: Index = None
494
-
495
- self.shape: np.ndarray[(3,), np.int32] = None
496
- self.spacing: np.ndarray[(3,), np.float32] = None
497
-
498
- try:
499
- self.find_geometry()
500
- except (geo.GeometryFileNotFoundError, geo.MultipleGeometryFilesError):
501
- if self._verbose:
502
- self._warn("Caution: A geometry file was not found.")
503
-
504
- def __getitem__(self, key: Union[int, slice, list, Callable[[Field], bool]]):
505
- return self.read(key)
623
+ self._indexed: bool = False
624
+ self._header: Header = None
506
625
 
507
626
  def __enter__(self):
508
627
  return self.open()
@@ -510,328 +629,184 @@ class File:
510
629
  def __exit__(self, exc_type, exc_value, traceback):
511
630
  self.close()
512
631
 
513
- def __iter__(self):
514
- return self.iterate()
515
-
516
- def _info(self, *args):
517
- if self._verbose:
518
- print(*args)
632
+ def __iter__(self) -> Iterator[Field]:
633
+ index = 0
634
+ while True:
635
+ try:
636
+ yield self._read_field(index)
637
+ index += 1
638
+ except (IndexError, EOFError):
639
+ break
519
640
 
520
- def _warn(self, *args):
521
- if self._verbose:
522
- print(*args, file=sys.stderr)
641
+ def open(self) -> "File":
642
+ """Open the file.
523
643
 
524
- def _open_file(self):
525
- self._file = open(self._filename, "rb")
644
+ Returns:
645
+ File: The opened binary file.
646
+ """
526
647
  self._compressed = utils.is_compressed(self._filename)
527
648
 
528
- def _close_file(self):
529
- if self._file:
530
- self._file.close()
531
- self._reset()
649
+ if self._compressed:
650
+ self._file = rapidgzip.open(self._filename, self._threads)
651
+ start = t()
652
+ _debug("Importing index file...")
653
+ _try_import_index(self._file, _index_filename(self._filename))
654
+ _debug(f"Index file imported in {t() - start:.2f} seconds.")
655
+ else:
656
+ self._file = builtins.open(self._filename, "rb")
532
657
 
533
- def _reset(self):
534
- self._file = None
535
- self._compressed = None
536
- self._created = None
537
- self._modified = None
538
- self._index = None
658
+ self._header = Header.read(self._file)
539
659
 
540
- def _update_timestamps(self):
541
- self._created = os.path.getctime(self._filename)
542
- self._modified = os.path.getmtime(self._filename)
660
+ try:
661
+ geometry = geo.read(geo.find(self._filename), type=geo.Type.BASIC)
662
+ self.shape = self.shape or geometry["shape"][::-1]
663
+ self.spacing = self.spacing or geometry["spacing"][::-1]
664
+ except (geo.GeometryFileNotFoundError, geo.MultipleGeometryFilesError):
665
+ _warn("Caution: A geometry file was not found.")
543
666
 
544
- def open(self):
545
- """Open the file."""
546
- if not self._file:
547
- self.create_index()
548
667
  return self
549
-
550
- def close(self):
551
- """Close the file."""
552
- self._close_file()
553
-
554
- def index(self):
555
- """Get the index of the file."""
556
- if self._created != os.path.getctime(self._filename):
557
- self.create_index()
558
- elif self._modified != os.path.getmtime(self._filename):
559
- self.update_index()
560
- return self._index
561
-
562
- def create_index(self):
563
- """Create an index of the file."""
564
- self._close_file()
565
- self._open_file()
566
- self._update_timestamps()
567
- self._index = Index.from_file(
568
- self._file,
569
- verbose=self._verbose,
570
- chunk_size=self._chunk_size,
571
- compressed=self._compressed,
572
- )
573
-
574
- def update_index(self):
575
- """Update the index of the file."""
576
- self._update_timestamps()
577
- index = Index.from_file(
578
- self._file,
579
- verbose=self._verbose,
580
- chunk_size=self._chunk_size,
581
- compressed=self._compressed,
582
- position=self._index[-1],
583
- )
584
- self._index.extend(index)
585
-
586
- def times(self) -> List[float]:
587
- """Get the times of the fields in the file.
668
+
669
+ def times(self) -> list[float]:
670
+ """Get the time steps from the binary file.
588
671
 
589
672
  Returns:
590
- List of times.
673
+ list[float]: List of time steps.
591
674
  """
592
- return [position.time for position in self.index()]
593
-
594
- def set_geometry(
595
- self, shape: Tuple[int, int, int], spacing: Tuple[float, float, float]
596
- ):
597
- """Set the geometry.
675
+ if not self._file:
676
+ self.open()
598
677
 
599
- Args:
600
- shape (Tuple[int, int, int]): Shape of the geometry (z, y, x).
601
- spacing (Tuple[float, float, float]): Spacing of the geometry (dz, dy, dx) in μm.
602
- """
678
+ field_size = self._header.field_size
603
679
 
604
- self.shape = np.array(shape)
605
- self.spacing = np.array(spacing)
680
+ cur = self._file.tell()
681
+ self._file.seek(0, 2)
682
+ file_size = self._file.tell()
683
+ field_count = file_size // field_size
684
+ self._file.seek(cur)
606
685
 
607
- self.print_geometry()
686
+ times: list[float] = []
608
687
 
609
- def read_geometry(self, filename: str, compressed: Optional[bool] = None):
610
- """Read geometry from a file.
688
+ for i in range(int(field_count)):
689
+ hdr = Header.read_at(self._file, i * field_size)
690
+ times.append(hdr.time)
611
691
 
612
- Args:
613
- filename (str): Filename of a geometry file.
614
- compressed (bool, optional): `True` if file is compressed, `False`
615
- otherwise. Defaults to `None` (auto).
616
- """
617
- if compressed is None:
618
- compressed = utils.is_compressed(filename)
692
+ return times
619
693
 
620
- geometry = geo.read(filename, type=geo.Type.BASIC, compressed=compressed)
694
+ def _read_field(self, key: int) -> Field:
695
+ if not self._file:
696
+ self.open()
697
+ if isinstance(key, int):
698
+ return Field.read(
699
+ file=self._file,
700
+ field_index=key,
701
+ field_size=Header.read(self._file).field_size,
702
+ shape=self.shape,
703
+ spacing=self.spacing,
704
+ )
705
+ raise TypeError("Invalid argument type")
621
706
 
622
- shape = geometry["shape"][::-1]
623
- spacing = geometry["spacing"][::-1]
707
+ def _read_series(self, key: list[int] = None) -> Series:
708
+ if key is None:
709
+ fields: list[Field] = list(self)
710
+ return Series(fields)
711
+ if isinstance(key, list):
712
+ return Series([self._read_field(i) for i in key])
713
+ raise TypeError("Invalid argument type")
624
714
 
625
- self.set_geometry(shape, spacing)
715
+ @overload
716
+ def read(self, key: int, threads: int = None) -> Field: ...
717
+ @overload
718
+ def read(self, key: list[int], threads: int = None) -> Series: ...
719
+ @overload
720
+ def read(self, key: None = None, threads: int = None) -> Series: ...
626
721
 
627
- def find_geometry(self, compressed: Optional[bool] = None):
628
- """Find geometry file and read it.
722
+ def read(self, key: Union[int, list[int]] = None, threads: int = None) -> Union[Field, Series]:
723
+ """Read a field or series from a binary file.
629
724
 
630
725
  Args:
631
- compressed (bool, optional): True if file is compressed, False otherwise.
632
- Defaults to `None` (auto).
633
-
634
- Raises:
635
- `GeometryFileNotFoundError`: If no geometry file is found.
636
- `MultipleGeometryFilesError`: If multiple geometry files are found.
637
- """
638
- filename = geo.find(self._filename)
639
-
640
- self.read_geometry(filename, compressed=compressed)
641
-
642
- def print_geometry(self):
643
- """Get a string representation of the geometry."""
644
-
645
- if self.shape is None or self.spacing is None:
646
- self._info("Geometry: None")
647
- return
648
-
649
- dimensions = Field.dimensions(self.shape)
650
- cells = self.shape
651
- spacing = 1e4 * np.round(self.spacing.astype(float), 7)
652
- size = cells * spacing
653
-
654
- self._info(f"Geometry: {dimensions}-Dimensional Grid")
655
- self._info(f"Grid Size [μm]: {tuple(size)}")
656
- self._info(f"Grid Shape (Cell Count): {tuple(cells)}")
657
- self._info(f"Grid Spacing (Cell Size) [μm]: {tuple(spacing)}")
658
-
659
- def iterate(self) -> Generator[Field, None, None]:
660
- """Iterate over fields in the file.
661
-
726
+ key (Union[int, list[int]], optional): Key to a field index or a list of field
727
+ indices. Defaults to `None`, which reads all fields.
728
+ threads (int, optional): Number of threads to use for reading compressed files.
729
+ Defaults to `None`, which uses up to 4 threads.
662
730
  Returns:
663
- A generator of fields.
731
+ Union[Field, Series]: Field or series of fields.
664
732
  """
665
- for position in self.index():
666
- yield Field.read(position, shape=self.shape, spacing=self.spacing)
667
-
668
- def read_field(self, field_id: int) -> Field:
669
- """Read a field from the file.
733
+ if key is None:
734
+ return self._read_series()
735
+ if isinstance(key, int):
736
+ return self._read_field(key)
737
+ if isinstance(key, list):
738
+ return self._read_series(key)
739
+ raise TypeError("Invalid argument type")
670
740
 
671
- Args:
672
- field_id (int): Field ID.
741
+ def close(self):
742
+ """Close the file."""
743
+ if not self._file:
744
+ return
673
745
 
674
- Returns:
675
- Field.
676
- """
677
- self.index()
746
+ if self._compressed:
747
+ start = t()
748
+ _debug("Exporting index file...")
749
+ _try_export_index(self._file, _index_filename(self._filename))
750
+ _debug(f"Index file exported in {t() - start:.2f} seconds.")
678
751
 
679
- position = self._index[field_id]
680
- return Field.read(position, shape=self.shape, spacing=self.spacing)
752
+ self._file.close()
753
+ self._file = None
681
754
 
682
- def read(
683
- self, key: Optional[Union[int, slice, list, Callable[[Field], bool]]] = None
684
- ) -> Series:
685
- """Read a series of fields from the file.
686
755
 
687
- Args:
688
- key (Union[int, slice, list, Callable[[Field], bool]]): Key to list of
689
- field IDs, a slice object, a list of field IDs, or a function that filters
690
- fields. Defaults to `None`.
756
+ def open(filename: str, threads: int = None) -> File:
757
+ """Open a binary file.
691
758
 
692
- Returns:
693
- Series of fields.
694
- """
759
+ Args:
760
+ filename (str): Filename of the binary file.
761
+ threads (int, optional): Number of threads to use for reading compressed files.
762
+ Defaults to `None`, which uses up to 4 threads.
763
+ Returns:
764
+ File: Binary file.
765
+ """
766
+ return File(filename, threads).open()
695
767
 
696
- def iterable(iterable):
697
- if self._verbose:
698
- return utils.progress_indicator(
699
- iterable, description="Reading", unit="Field"
700
- )
701
- return iterable
702
768
 
703
- self.index()
769
+ def times(filename: str, threads: int = None) -> list[float]:
770
+ """Get the time steps from a binary file.
771
+
772
+ Args:
773
+ filename (str): Filename of the binary file.
774
+ threads (int, optional): Number of threads to use for reading compressed files.
775
+ Defaults to `None`, which uses up to 4 threads.
776
+ Returns:
777
+ list[float]: List of time steps.
778
+ """
704
779
 
705
- if key is None:
706
- fields = list(field for field in iterable(self.iterate()))
707
- elif isinstance(key, int):
708
- fields = [self.read_field(key)]
709
- elif isinstance(key, slice):
710
- indices = range(*key.indices(len(self._index)))
711
- fields = [self.read_field(i) for i in iterable(indices)]
712
- elif isinstance(key, list):
713
- fields = [self.read_field(i) for i in iterable(key)]
714
- elif isinstance(key, Callable):
715
- fields = [field for field in iterable(self.iterate()) if key(field)]
716
- else:
717
- raise TypeError("Invalid argument type")
718
- return Series(fields)
780
+ with open(filename, threads) as file:
781
+ return file.times()
719
782
 
783
+ @overload
784
+ def read(filename: str, key: int, threads: int = None) -> Field: ...
785
+ @overload
786
+ def read(filename: str, key: list[int], threads: int = None) -> Series: ...
787
+ @overload
788
+ def read(filename: str, key: None = None, threads: int = None) -> Series: ...
720
789
 
721
- @dataclass
722
- class PlotArgs:
723
- """Arguments for plotting a field.
790
+ def read(filename: str, key: Union[int, list[int]] = None, threads: int = None) -> Union[Field, Series]:
791
+ """Read a field or series from a binary file.
724
792
 
725
793
  Args:
726
- title (str, optional): Title of the plot. Defaults to `None`.
727
- xlabel (str, optional): Label of the x-axis. Defaults to `None`.
728
- ylabel (str, optional): Label of the y-axis. Defaults to `None`.
729
- figsize (Tuple[float, float], optional): Figure size. Defaults to `None`.
730
- dpi (int, optional): Figure DPI. Defaults to `None`.
731
- aspect (str, optional): Aspect ratio. Defaults to `equal`.
732
- ax (matplotlib.Axes, optional): Axes of the plot. Defaults to `None`.
733
- cax (matplotlib.Axes, optional): Axes of the color bar. Defaults to `None`.
734
- vmin (float, optional): Minimum value of the color bar. Defaults to `None`.
735
- vmax (float, optional): Maximum value of the color bar. Defaults to `None`.
736
- cmap (str, optional): Colormap. Defaults to `micpy`.
737
- alpha (float, optional): Transparency of the plot. Defaults to `1.0`.
738
- interpolation (str, optional): Interpolation method. Defaults to `none`.
794
+ filename (str): Filename of the binary file.
795
+ key (Union[int, list[int]], optional): Key to a field index or a list of field
796
+ indices. Defaults to `None`, which reads all fields.
797
+ threads (int, optional): Number of threads to use for reading compressed files.
798
+ Defaults to `None`, which uses up to 4 threads.
799
+ Returns:
800
+ Union[Field, Series]: Field or series of fields.
739
801
  """
802
+ with open(filename, threads) as file:
803
+ return file.read(key)
740
804
 
741
- title: Optional[str] = None
742
- xlabel: Optional[str] = None
743
- ylabel: Optional[str] = None
744
- figsize: Optional[Tuple[float, float]] = None
745
- dpi: Optional[int] = None
746
- aspect: str = "equal"
747
- ax: "matplotlib.Axes" = None
748
- cax: "matplotlib.Axes" = None
749
- vmin: Optional[float] = None
750
- vmax: Optional[float] = None
751
- cmap: str = "micpy"
752
- alpha: float = 1.0
753
- interpolation: str = "none"
754
-
755
-
756
- def plot(
757
- field: Field,
758
- axis: str = "y",
759
- index: int = 0,
760
- args: Optional[PlotArgs] = None,
761
- ) -> Tuple["matplotlib.Figure", "matplotlib.Axes"]:
762
- """Plot a slice of the field.
763
805
 
764
- Args:
765
- field (Field): Field to plot.
766
- axis (str, optional): Axis to plot. Possible values are `x`, `y`, and `z`.
767
- Defaults to `y`.
768
- index (int, optional): Index of the slice. Defaults to `0`.
769
- args (PlotArgs, optional): Arguments for plotting. Defaults to `None`.
806
+ def pv_plugin_path() -> str:
807
+ """Get the path to the ParaView plugin directory for MicPy.
770
808
 
771
809
  Returns:
772
- Matplotlib figure, axes, and color bar.
810
+ str: Path to the ParaView plugin directory for MicPy.
773
811
  """
774
-
775
- if matplotlib is None:
776
- raise ImportError("matplotlib is not installed")
777
-
778
- if axis not in ["x", "y", "z"]:
779
- raise ValueError("Invalid axis")
780
-
781
- if field.ndim != 3:
782
- raise ValueError("Invalid field shape")
783
-
784
- if args is None:
785
- args = PlotArgs()
786
-
787
- if axis == "z":
788
- x, y = "x", "y"
789
- slice_2d = field[index, :, :]
790
- elif axis == "y":
791
- x, y = "x", "z"
792
- slice_2d = field[:, index, :]
793
- elif axis == "x":
794
- x, y = "y", "z"
795
- slice_2d = field[:, :, index]
796
-
797
- fig, ax = (
798
- pyplot.subplots(figsize=args.figsize, dpi=args.dpi)
799
- if args.ax is None
800
- else (args.ax.get_figure(), args.ax)
801
- )
802
-
803
- if args.title is not None:
804
- ax.set_title(args.title)
805
- else:
806
- if isinstance(field, Field):
807
- ax.set_title(f"t={np.round(field.time, 7)}s")
808
- if args.xlabel is not None:
809
- ax.set_xlabel(args.xlabel)
810
- else:
811
- ax.set_xlabel(x)
812
- if args.ylabel is not None:
813
- ax.set_ylabel(args.ylabel)
814
- else:
815
- ax.set_ylabel(y)
816
- if args.aspect is not None:
817
- ax.set_aspect(args.aspect)
818
- ax.set_frame_on(False)
819
-
820
- image = ax.imshow(
821
- slice_2d,
822
- cmap=args.cmap,
823
- vmin=args.vmin,
824
- vmax=args.vmax,
825
- interpolation=args.interpolation,
826
- alpha=args.alpha,
827
- origin="lower",
828
- )
829
-
830
- bar = pyplot.colorbar(image, ax=ax, cax=args.cax)
831
- bar.locator = matplotlib.ticker.MaxNLocator(
832
- integer=np.issubdtype(slice_2d.dtype, np.integer)
833
- )
834
- bar.outline.set_visible(False)
835
- bar.update_ticks()
836
-
837
- return fig, ax, bar
812
+ return str(Path(__file__).parent / "paraview")