Coverage for pySDC/helpers/fieldsIO.py: 72%
308 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 07:24 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 07:24 +0000
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4Generic utility class to write and read cartesian grid field solutions into binary files.
5It implements the base file handler class :class:`FieldsIO`, that is specialized into :
7- :class:`Scalar` : for 0D fields (scalar) with a given number of variables
8- :class:`Rectilinear` : for fields on N-dimensional rectilinear grids
10While each file handler need to be setup with specific parameters (grid, ...),
11each written file can be read using the same interface implemented in the
12base abstract class.
14Example
15-------
16>>> import numpy as np
17>>> from pySDC.helpers.fieldsIO import Rectilinear
18>>>
19>>> # Write some fields in files
20>>> x = np.linspace(0, 1, 128)
21>>> y = np.linspace(0, 1, 64)
22>>> fOut = Rectilinear(np.float64, "file.pysdc")
23>>> fOut.setHeader(nVar=2, coords=[x, y])
24>>> fOut.initialize()
25>>> times = [0, 1, 2]
26>>> xGrid, yGrid = np.meshgrid(x, y, indexing="ij")
27>>> u0 = np.array([-1, 1]).reshape((-1, 1, 1))*xGrid*yGrid
28>>> # u0 has shape [2, nX, nY]
29>>> for t in times:
30>>> fOut.addField(t, t*u0)
31>>>
32>>> # Read the file using the generic interface
33>>> from pySDC.helpers.fieldsIO import FieldsIO
34>>> fIn = FieldsIO.fromFile("file.pysdc")
35>>> times = fIn.times
36>>> assert len(times) == fIn.nFields
37>>> tEnd, uEnd = fIn.readField(-1)
38>>> assert tEnd == times[-1]
40Notes
41-----
42🚀 :class:`Rectilinear` is compatible with a MPI-based cartesian decomposition.
43See :class:`pySDC.helpers.fieldsIO.writeFields_MPI` for an illustrative example.
45Warning
46-------
47To use MPI collective writing, you need to call first the class methods :class:`Rectilinear.setupMPI` (cf their docstring).
48Also, `Rectilinear.setHeader` **must be given the global grids coordinates**, whether the code is run in parallel or not.
49"""
50import os
51import numpy as np
52from typing import Type, TypeVar
53import logging
55T = TypeVar("T")
57try:
58 try:
59 import dolfin as df # noqa: F841 (for some reason, dolfin always needs to be imported before mpi4py)
60 except ImportError:
61 pass
62 from mpi4py import MPI
63 from mpi4py.util.dtlib import from_numpy_dtype as MPI_DTYPE
64except ImportError:
66 class MPI:
67 COMM_WORLD = None
68 Intracomm = T
69 File = T
70 Datatype = T
72 def MPI_DTYPE():
73 pass
76# Supported data types
77DTYPES = {
78 0: np.float64, # double precision
79 1: np.complex128,
80}
81try:
82 DTYPES.update(
83 {
84 2: np.float128, # quadruple precision
85 3: np.complex256,
86 }
87 )
88except AttributeError:
89 logging.getLogger('FieldsIO').debug('Warning: Quadruple precision not available on this machine')
90try:
91 DTYPES.update(
92 {
93 4: np.float32, # single precision
94 5: np.complex64,
95 }
96 )
97except AttributeError:
98 logging.getLogger('FieldsIO').debug('Warning: Single precision not available on this machine')
100DTYPES_AVAIL = {val: key for key, val in DTYPES.items()}
102# Header dtype
103H_DTYPE = np.int8
104T_DTYPE = np.float64
107class FieldsIO:
108 """Abstract IO file handler"""
110 STRUCTS = {} # supported structures, modified dynamically
111 sID = None # structure ID of the FieldsIO class, modified dynamically
113 tSize = T_DTYPE().itemsize
115 ALLOW_OVERWRITE = False
117 def __init__(self, dtype, fileName):
118 """
119 Parameters
120 ----------
121 dtype : np.dtype
122 The data type of the fields values.
123 fileName : str
124 File.
125 """
126 assert dtype in DTYPES_AVAIL, f"{dtype=} not available. Supported on this machine: {list(DTYPES_AVAIL.keys())}"
127 self.dtype = dtype
128 self.fileName = fileName
129 self.initialized = False
131 # Initialized by the setHeader abstract method
132 self.header = None
133 self.nItems = None # number of values (dof) stored into one field
135 def __str__(self):
136 return f"FieldsIO[{self.__class__.__name__}|{self.dtype.__name__}|file:{self.fileName}]<{hex(id(self))}>"
138 def __repr__(self):
139 return self.__str__()
141 @classmethod
142 def fromFile(cls, fileName):
143 """
144 Read a file storing fields, and return the `FieldsIO` of the appropriate
145 field type (structure).
147 Parameters
148 ----------
149 fileName : str
150 Name of the binary file.
152 Returns
153 -------
154 fieldsIO : :class:`FieldsIO`
155 The specialized `FieldsIO` adapted to the file.
156 """
157 assert os.path.isfile(fileName), f"not a file ({fileName})"
158 with open(fileName, "rb") as f:
159 STRUCT, DTYPE = np.fromfile(f, dtype=H_DTYPE, count=2)
160 fieldsIO: FieldsIO = cls.STRUCTS[STRUCT](DTYPES[DTYPE], fileName)
161 fieldsIO.readHeader(f)
162 fieldsIO.initialized = True
163 return fieldsIO
165 @property
166 def hBase(self) -> np.ndarray:
167 """Base header into numpy array format"""
168 return np.array([self.sID, DTYPES_AVAIL[self.dtype]], dtype=H_DTYPE)
170 @classmethod
171 def register(cls, sID):
172 """
173 Decorator used to register a new class FieldsIO specialized class
175 Parameters
176 ----------
177 sID : int
178 Unique identifyer for the file, used in the binary file.
179 Since it's encoded on a 8-bytes signed integer,
180 it must be between -128 and 127
182 Example
183 -------
184 >>> # New specialized FieldsIO class
185 >>> @FieldsIO.register(sID=31)
186 >>> class HexaMesh2D(FieldsIO):
187 >>> pass # ... implementation
188 """
190 def wrapper(registered: Type[T]) -> Type[T]:
191 assert (
192 sID not in cls.STRUCTS
193 ), f"struct ID already taken by {cls.STRUCTS[sID]}, cannot use it for {registered}"
194 cls.STRUCTS[sID] = registered
195 registered.sID = sID
196 return registered
198 return wrapper
200 def initialize(self):
201 """Initialize the file handler : create the file with header, removing any existing file with the same name"""
202 assert self.header is not None, "header must be set before initializing FieldsIO"
203 assert not self.initialized, "FieldsIO already initialized"
205 if not self.ALLOW_OVERWRITE:
206 if os.path.isfile(self.fileName):
207 raise FileExistsError(
208 f"file {self.fileName!r} already exists, use FieldsIO.ALLOW_OVERWRITE = True to allow overwriting"
209 )
211 with open(self.fileName, "w+b") as f:
212 self.hBase.tofile(f)
213 for array in self.hInfos:
214 array.tofile(f)
215 self.initialized = True
217 def setHeader(self, **params):
218 """(Abstract) Set the header before creating a new file to store the fields"""
219 raise NotImplementedError()
221 @property
222 def hInfos(self) -> list[np.ndarray]:
223 """(Abstract) Array representing the grid structure to be written in the binary file."""
224 raise NotImplementedError()
226 def readHeader(self, f):
227 """
228 (Abstract) Read the header from the file storing the fields.
230 Parameters
231 ----------
232 f : `_io.TextIOWrapper`
233 File to read the header from.
234 """
235 raise NotImplementedError()
237 @property
238 def hSize(self):
239 """Size of the full header (in bytes)"""
240 return self.hBase.nbytes + sum(hInfo.nbytes for hInfo in self.hInfos)
242 @property
243 def itemSize(self):
244 """Size of one field value (in bytes)"""
245 return self.dtype().itemsize
247 @property
248 def fSize(self):
249 """Full size of a field (in bytes)"""
250 return self.nItems * self.itemSize
252 @property
253 def fileSize(self):
254 """Current size of the file (in bytes)"""
255 return os.path.getsize(self.fileName)
257 def addField(self, time, field):
258 """
259 Append one field solution at the end of the file with one given time.
261 Parameters
262 ----------
263 time : float-like
264 The associated time of the field solution.
265 field : np.ndarray
266 The field values.
267 """
268 assert self.initialized, "cannot add field to a non initialized FieldsIO"
269 field = np.asarray(field)
270 assert field.dtype == self.dtype, f"expected {self.dtype} dtype, got {field.dtype}"
271 assert field.size == self.nItems, f"expected {self.nItems} values, got {field.size}"
272 with open(self.fileName, "ab") as f:
273 np.array(time, dtype=T_DTYPE).tofile(f)
274 field.tofile(f)
276 @property
277 def nFields(self):
278 """Number of fields currently stored in the binary file"""
279 return int((self.fileSize - self.hSize) // (self.tSize + self.fSize))
281 def formatIndex(self, idx):
282 """Utility method to format a fields index to a positional integer (negative starts from last field index, like python lists)"""
283 nFields = self.nFields
284 if idx < 0:
285 idx = nFields + idx
286 assert idx < nFields, f"cannot read index {idx} from {nFields} fields"
287 assert idx >= 0, f"cannot read index {idx-nFields} from {nFields} fields"
288 return idx
290 @property
291 def times(self):
292 """Vector of all times stored in the binary file"""
293 times = []
294 with open(self.fileName, "rb") as f:
295 f.seek(self.hSize)
296 for i in range(self.nFields):
297 t = np.fromfile(f, dtype=T_DTYPE, count=1, offset=0 if i == 0 else self.fSize)[0]
298 times.append(float(t))
299 return times
301 def time(self, idx):
302 """Time stored at a given field index"""
303 idx = self.formatIndex(idx)
304 offset = self.hSize + idx * (self.tSize + self.fSize)
305 with open(self.fileName, "rb") as f:
306 t = np.fromfile(f, dtype=T_DTYPE, count=1, offset=offset)[0]
307 return float(t)
309 def readField(self, idx):
310 """
311 Read one field stored in the binary file, corresponding to the given
312 time index.
314 Parameters
315 ----------
316 idx : int
317 Positional index of the field.
319 Returns
320 -------
321 t : float
322 Stored time for this field.
323 field : np.ndarray
324 Read fields in a numpy array.
325 """
326 idx = self.formatIndex(idx)
327 offset = self.hSize + idx * (self.tSize + self.fSize)
328 with open(self.fileName, "rb") as f:
329 f.seek(offset)
330 t = float(np.fromfile(f, dtype=T_DTYPE, count=1)[0])
331 field = np.fromfile(f, dtype=self.dtype, count=self.nItems)
332 self.reshape(field)
333 return t, field
335 def reshape(self, field):
336 """Eventually reshape the field to correspond to the grid structure"""
337 pass
340@FieldsIO.register(sID=0)
341class Scalar(FieldsIO):
342 """FieldsIO handler storing a given number of scalar"""
344 # -------------------------------------------------------------------------
345 # Overridden methods
346 # -------------------------------------------------------------------------
347 def setHeader(self, nVar):
348 """
349 Set the descriptive grid structure to be stored in the file header.
351 Parameters
352 ----------
353 nVar : int
354 Number of scalar variable stored.
355 """
356 self.header = {"nVar": int(nVar)}
357 self.nItems = self.nVar
359 @property
360 def hInfos(self):
361 """Array representing the grid structure to be written in the binary file."""
362 return [np.array([self.nVar], dtype=np.int64)]
364 def readHeader(self, f):
365 """
366 Read the header from the binary file storing the fields.
368 Parameters
369 ----------
370 f : `_io.TextIOWrapper`
371 File to read the header from.
372 """
373 (nVar,) = np.fromfile(f, dtype=np.int64, count=1)
374 self.setHeader(nVar)
376 # -------------------------------------------------------------------------
377 # Class specifics
378 # -------------------------------------------------------------------------
379 @property
380 def nVar(self):
381 """Number of variables in a fields, as described in the header"""
382 return self.header["nVar"]
385@FieldsIO.register(sID=1)
386class Rectilinear(Scalar):
387 """FieldsIO handler storing a given number of scalar variables on a N-dimensional rectilinear grid"""
389 @staticmethod
390 def setupCoords(*coords):
391 """Utility function to setup grids in multiple dimensions, given the keyword arguments"""
392 coords = [np.asarray(coord, dtype=np.float64) for coord in coords]
393 for axis, coord in enumerate(coords):
394 assert coord.ndim == 1, f"coord for {axis=} must be one dimensional"
395 return coords
397 # -------------------------------------------------------------------------
398 # Overridden methods
399 # -------------------------------------------------------------------------
400 def setHeader(self, nVar, coords):
401 """
402 Set the descriptive grid structure to be stored in the file header.
404 Parameters
405 ----------
406 nVar : int
407 Number of 1D variables stored.
408 coords : np.1darray or list[np.1darray]
409 The grid coordinates in each dimensions.
411 Note
412 ----
413 When used in MPI decomposition mode, all coordinate **must** be the global grid.
414 """
415 if not isinstance(coords, (tuple, list)):
416 coords = [coords]
417 coords = self.setupCoords(*coords)
418 self.header = {"nVar": int(nVar), "coords": coords}
419 self.nItems = nVar * self.nDoF
421 @property
422 def hInfos(self):
423 """Array representing the grid structure to be written in the binary file."""
424 return [np.array([self.nVar, self.dim, *self.gridSizes], dtype=np.int32)] + [
425 np.array(coord, dtype=np.float64) for coord in self.header["coords"]
426 ]
428 def readHeader(self, f):
429 """
430 Read the header from the binary file storing the fields.
432 Parameters
433 ----------
434 f : `_io.TextIOWrapper`
435 File to read the header from.
436 """
437 nVar, dim = np.fromfile(f, dtype=np.int32, count=2)
438 gridSizes = np.fromfile(f, dtype=np.int32, count=dim)
439 coords = [np.fromfile(f, dtype=np.float64, count=n) for n in gridSizes]
440 self.setHeader(nVar, coords)
442 def reshape(self, fields: np.ndarray):
443 """Reshape the fields to a N-d array (inplace operation)"""
444 fields.shape = (self.nVar, *self.gridSizes)
446 # -------------------------------------------------------------------------
447 # Class specifics
448 # -------------------------------------------------------------------------
449 @property
450 def gridSizes(self):
451 """Number of points in y direction"""
452 return [coord.size for coord in self.header["coords"]]
454 @property
455 def dim(self):
456 """Number of grid dimensions"""
457 return len(self.gridSizes)
459 @property
460 def nDoF(self):
461 """Number of degrees of freedom for one variable"""
462 return np.prod(self.gridSizes)
464 def toVTR(self, baseName, varNames, idxFormat="{:06d}"):
465 """
466 Convert all 3D fields stored in binary format (FieldsIO) into a list
467 of VTR files, that can be read later with Paraview or equivalent to
468 make videos.
470 Parameters
471 ----------
472 baseName : str
473 Base name of the VTR file.
474 varNames : list[str]
475 Variable names of the fields.
476 idxFormat : str, optional
477 Formatting string for the index of the VTR file.
478 The default is "{:06d}".
480 Example
481 -------
482 >>> # Suppose the FieldsIO object is already written into outputs.pysdc
483 >>> import os
484 >>> from pySDC.utils.fieldsIO import Rectilinear
485 >>> os.makedirs("vtrFiles") # to store all VTR files into a subfolder
486 >>> Rectilinear.fromFile("outputs.pysdc").toVTR(
487 >>> baseName="vtrFiles/field", varNames=["u", "v", "w", "T", "p"])
488 """
489 assert self.dim == 3, "can only be used with 3D fields"
490 from pySDC.helpers.vtkIO import writeToVTR
492 template = f"{baseName}_{idxFormat}"
493 for i in range(self.nFields):
494 _, u = self.readField(i)
495 writeToVTR(template.format(i), u, self.header["coords"], varNames)
497 # -------------------------------------------------------------------------
498 # MPI-parallel implementation
499 # -------------------------------------------------------------------------
500 comm: MPI.Intracomm = None
502 @classmethod
503 def setupMPI(cls, comm: MPI.Intracomm, iLoc, nLoc):
504 """
505 Setup the MPI mode for the files IO, considering a decomposition
506 of the 1D grid into contiguous subintervals.
508 Parameters
509 ----------
510 comm : MPI.Intracomm
511 The space decomposition communicator.
512 iLoc : list[int]
513 Starting index of the local sub-domain in the global coordinates.
514 nLoc : list[int]
515 Number of points in the local sub-domain.
516 """
517 cls.comm = comm
518 cls.iLoc = iLoc
519 cls.nLoc = nLoc
520 cls.mpiFile: MPI.File = None
521 cls.mpiType: MPI.Datatype = None
522 cls.mpiFileType: MPI.Datatype = None
524 @property
525 def MPI_ON(self):
526 """Wether or not MPI is activated"""
527 if self.comm is None:
528 return False
529 return self.comm.Get_size() > 1
531 @property
532 def MPI_ROOT(self):
533 """Wether or not the process is MPI Root"""
534 if self.comm is None:
535 return True
536 return self.comm.Get_rank() == 0
538 def MPI_SETUP_FILETYPE(self):
539 """Setup subarray masks for each processes"""
540 self.mpiType = MPI_DTYPE(self.dtype)
541 self.mpiFileType = self.mpiType.Create_subarray(
542 [self.nVar, *self.gridSizes], # Global array sizes
543 [self.nVar, *self.nLoc], # Local array sizes
544 [0, *self.iLoc], # Global starting indices of local blocks
545 )
546 self.mpiFileType.Commit()
548 def MPI_FILE_OPEN(self, mode):
549 """Open the binary file in MPI mode"""
550 amode = {
551 "r": MPI.MODE_RDONLY,
552 "a": MPI.MODE_WRONLY | MPI.MODE_APPEND,
553 }[mode]
554 self.mpiFile = MPI.File.Open(self.comm, self.fileName, amode)
555 if self.mpiType is None:
556 self.MPI_SETUP_FILETYPE()
558 def MPI_WRITE(self, data):
559 """Write data (np.ndarray) in the binary file in MPI mode, at the current file cursor position."""
560 self.mpiFile.Write(data)
562 def MPI_WRITE_AT_ALL(self, offset, data: np.ndarray):
563 """
564 Write data in the binary file in MPI mode, with a given offset
565 **relative to the beginning of the file**.
567 Parameters
568 ----------
569 offset : int
570 Offset to write at, relative to the beginning of the file, in bytes.
571 data : np.ndarray
572 Data to be written in the binary file.
573 """
574 self.mpiFile.Set_view(disp=offset, etype=self.mpiType, filetype=self.mpiFileType)
575 self.mpiFile.Write_all(data)
577 def MPI_READ_AT_ALL(self, offset, data: np.ndarray):
578 """
579 Read data from the binary file in MPI mode, with a given offset
580 **relative to the beginning of the file**.
582 Parameters
583 ----------
584 offset : int
585 Offset to read at, relative to the beginning of the file, in bytes.
586 data : np.ndarray
587 Array on which to read the data from the binary file.
588 """
589 self.mpiFile.Set_view(disp=offset, etype=self.mpiType, filetype=self.mpiFileType)
590 self.mpiFile.Read_all(data)
592 def MPI_FILE_CLOSE(self):
593 """Close the binary file in MPI mode"""
594 self.mpiFile.Close()
595 self.mpiFile = None
597 def initialize(self):
598 """Initialize the binary file (write header) in MPI mode"""
599 if self.MPI_ROOT:
600 try:
601 super().initialize()
602 except AssertionError as e:
603 if self.MPI_ON:
604 print(f"{type(e)}: {e}")
605 self.comm.Abort()
606 else:
607 raise e
609 if self.MPI_ON:
610 self.comm.Barrier() # Important, should not be removed !
611 self.initialized = True
613 def addField(self, time, field):
614 """
615 Append one field solution at the end of the file with one given time,
616 possibly using MPI.
618 Parameters
619 ----------
620 time : float-like
621 The associated time of the field solution.
622 field : np.ndarray
623 The (local) field values.
625 Note
626 ----
627 If a MPI decomposition is used, field **must be** the local field values.
628 """
629 if not self.MPI_ON:
630 return super().addField(time, field)
632 assert self.initialized, "cannot add field to a non initialized FieldsIO"
634 field = np.asarray(field)
635 assert field.dtype == self.dtype, f"expected {self.dtype} dtype, got {field.dtype}"
636 assert field.shape == (
637 self.nVar,
638 *self.nLoc,
639 ), f"expected {(self.nVar, *self.nLoc)} shape, got {field.shape}"
641 offset = self.fileSize
642 self.MPI_FILE_OPEN(mode="a")
644 if self.MPI_ROOT:
645 self.MPI_WRITE(np.array(time, dtype=T_DTYPE))
646 offset += self.tSize
647 self.MPI_WRITE_AT_ALL(offset, field)
648 self.MPI_FILE_CLOSE()
650 def readField(self, idx):
651 """
652 Read one field stored in the binary file, corresponding to the given
653 time index, using MPI in the eventuality of space parallel decomposition.
655 Parameters
656 ----------
657 idx : int
658 Positional index of the field.
660 Returns
661 -------
662 t : float
663 Stored time for this field.
664 field : np.ndarray
665 Read (local) fields in a numpy array.
667 Note
668 ----
669 If a MPI decomposition is used, it reads and returns the local fields values only.
670 """
671 if not self.MPI_ON:
672 return super().readField(idx)
674 idx = self.formatIndex(idx)
675 offset = self.hSize + idx * (self.tSize + self.fSize)
676 with open(self.fileName, "rb") as f:
677 t = float(np.fromfile(f, dtype=T_DTYPE, count=1, offset=offset)[0])
678 offset += self.tSize
680 field = np.empty((self.nVar, *self.nLoc), dtype=self.dtype)
682 self.MPI_FILE_OPEN(mode="r")
683 self.MPI_READ_AT_ALL(offset, field)
684 self.MPI_FILE_CLOSE()
686 return t, field
689# -----------------------------------------------------------------------------------------------
690# Utility functions used for testing
691# -----------------------------------------------------------------------------------------------
692def initGrid(nVar, gridSizes):
693 dim = len(gridSizes)
694 coords = [np.linspace(0, 1, num=n, endpoint=False) for n in gridSizes]
695 s = [None] * dim
696 u0 = np.array(np.arange(nVar) + 1)[(slice(None), *s)]
697 for x in np.meshgrid(*coords, indexing="ij"):
698 u0 = u0 * x
699 return coords, u0
702def writeFields_MPI(fileName, dtypeIdx, algo, nSteps, nVar, gridSizes):
703 coords, u0 = initGrid(nVar, gridSizes)
705 from mpi4py import MPI
706 from pySDC.helpers.blocks import BlockDecomposition
707 from pySDC.helpers.fieldsIO import Rectilinear
709 comm = MPI.COMM_WORLD
710 MPI_SIZE = comm.Get_size()
711 MPI_RANK = comm.Get_rank()
713 blocks = BlockDecomposition(MPI_SIZE, gridSizes, algo, MPI_RANK)
715 iLoc, nLoc = blocks.localBounds
716 Rectilinear.setupMPI(comm, iLoc, nLoc)
717 s = [slice(i, i + n) for i, n in zip(iLoc, nLoc)]
718 u0 = u0[(slice(None), *s)]
720 f1 = Rectilinear(DTYPES[dtypeIdx], fileName)
721 f1.setHeader(nVar=nVar, coords=coords)
723 u0 = np.asarray(u0, dtype=f1.dtype)
724 f1.initialize()
726 times = np.arange(nSteps) / nSteps
727 for t in times:
728 ut = (u0 * t).astype(f1.dtype)
729 f1.addField(t, ut)
731 return u0
734def compareFields_MPI(fileName, u0, nSteps):
735 from pySDC.helpers.fieldsIO import FieldsIO
737 comm = MPI.COMM_WORLD
738 MPI_RANK = comm.Get_rank()
739 if MPI_RANK == 0:
740 print("Comparing fields with MPI")
742 f2 = FieldsIO.fromFile(fileName)
744 times = np.arange(nSteps) / nSteps
745 for idx, t in enumerate(times):
746 u1 = u0 * t
747 t2, u2 = f2.readField(idx)
748 assert t2 == t, f"fields[{idx}] in {f2} has incorrect time ({t2} instead of {t})"
749 assert u2.shape == u1.shape, f"{idx}'s fields in {f2} has incorrect shape"
750 assert np.allclose(u2, u1), f"{idx}'s fields in {f2} has incorrect values"