Coverage for pySDC/helpers/fieldsIO.py: 56%
302 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-20 10:09 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-20 10:09 +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.initMPI` (cf their docstring).
48Also, `Rectilinear.setHeader` **must be given the global grids coordinates**, wether the code is run in parallel or not.
50> ⚠️ Also : this module can only be imported with **Python 3.11 or higher** !
51"""
52import os
53import numpy as np
54from typing import Type, TypeVar
55import logging
56import itertools
58T = TypeVar("T")
60try:
61 try:
62 import dolfin as df # noqa: F841 (for some reason, dolfin always needs to be imported before mpi4py)
63 except ImportError:
64 pass
65 from mpi4py import MPI
66except ImportError:
68 class MPI:
69 COMM_WORLD = None
70 Intracomm = T
73# Supported data types
74DTYPES = {
75 0: np.float64, # double precision
76 1: np.complex128,
77}
78try:
79 DTYPES.update(
80 {
81 2: np.float128, # quadruple precision
82 3: np.complex256,
83 }
84 )
85except AttributeError:
86 logging.getLogger('FieldsIO').debug('Warning: Quadruple precision not available on this machine')
87try:
88 DTYPES.update(
89 {
90 4: np.float32, # single precision
91 5: np.complex64,
92 }
93 )
94except AttributeError:
95 logging.getLogger('FieldsIO').debug('Warning: Single precision not available on this machine')
97DTYPES_AVAIL = {val: key for key, val in DTYPES.items()}
99# Header dtype
100H_DTYPE = np.int8
101T_DTYPE = np.float64
104class FieldsIO:
105 """Abstract IO file handler"""
107 STRUCTS = {} # supported structures, modified dynamically
108 sID = None # structure ID of the FieldsIO class, modified dynamically
110 tSize = T_DTYPE().itemsize
112 ALLOW_OVERWRITE = False
114 def __init__(self, dtype, fileName):
115 """
116 Parameters
117 ----------
118 dtype : np.dtype
119 The data type of the fields values.
120 fileName : str
121 File.
122 """
123 assert dtype in DTYPES_AVAIL, f"{dtype=} not available. Supported on this machine: {list(DTYPES_AVAIL.keys())}"
124 self.dtype = dtype
125 self.fileName = fileName
126 self.initialized = False
128 # Initialized by the setHeader abstract method
129 self.header = None
130 self.nItems = None # number of values (dof) stored into one field
132 def __str__(self):
133 return f"FieldsIO[{self.__class__.__name__}|{self.dtype.__name__}|file:{self.fileName}]<{hex(id(self))}>"
135 def __repr__(self):
136 return self.__str__()
138 @classmethod
139 def fromFile(cls, fileName):
140 """
141 Read a file storing fields, and return the `FieldsIO` of the appropriate
142 field type (structure).
144 Parameters
145 ----------
146 fileName : str
147 Name of the binary file.
149 Returns
150 -------
151 fieldsIO : :class:`FieldsIO`
152 The specialized `FieldsIO` adapted to the file.
153 """
154 assert os.path.isfile(fileName), f"not a file ({fileName})"
155 with open(fileName, "rb") as f:
156 STRUCT, DTYPE = np.fromfile(f, dtype=H_DTYPE, count=2)
157 fieldsIO: FieldsIO = cls.STRUCTS[STRUCT](DTYPES[DTYPE], fileName)
158 fieldsIO.readHeader(f)
159 fieldsIO.initialized = True
160 return fieldsIO
162 @property
163 def hBase(self) -> np.ndarray:
164 """Base header into numpy array format"""
165 return np.array([self.sID, DTYPES_AVAIL[self.dtype]], dtype=H_DTYPE)
167 @classmethod
168 def register(cls, sID):
169 """
170 Decorator used to register a new class FieldsIO specialized class
172 Parameters
173 ----------
174 sID : int
175 Unique identifyer for the file, used in the binary file.
176 Since it's encoded on a 8-bytes signed integer,
177 it must be between -128 and 127
179 Example
180 -------
181 >>> # New specialized FieldsIO class
182 >>> @FieldsIO.register(sID=31)
183 >>> class HexaMesh2D(FieldsIO):
184 >>> pass # ... implementation
185 """
187 def wrapper(registered: Type[T]) -> Type[T]:
188 assert (
189 sID not in cls.STRUCTS
190 ), f"struct ID already taken by {cls.STRUCTS[sID]}, cannot use it for {registered}"
191 cls.STRUCTS[sID] = registered
192 registered.sID = sID
193 return registered
195 return wrapper
197 def initialize(self):
198 """Initialize the file handler : create the file with header, removing any existing file with the same name"""
199 assert self.header is not None, "header must be set before initializing FieldsIO"
200 assert not self.initialized, "FieldsIO already initialized"
202 if not self.ALLOW_OVERWRITE:
203 assert not os.path.isfile(
204 self.fileName
205 ), "file already exists, use FieldsIO.ALLOW_OVERWRITE = True to allow overwriting"
207 with open(self.fileName, "w+b") as f:
208 self.hBase.tofile(f)
209 for array in self.hInfos:
210 array.tofile(f)
211 self.initialized = True
213 def setHeader(self, **params):
214 """(Abstract) Set the header before creating a new file to store the fields"""
215 raise NotImplementedError()
217 @property
218 def hInfos(self) -> list[np.ndarray]:
219 """(Abstract) Array representing the grid structure to be written in the binary file."""
220 raise NotImplementedError()
222 def readHeader(self, f):
223 """
224 (Abstract) Read the header from the file storing the fields.
226 Parameters
227 ----------
228 f : `_io.TextIOWrapper`
229 File to read the header from.
230 """
231 raise NotImplementedError()
233 @property
234 def hSize(self):
235 """Size of the full header (in bytes)"""
236 return self.hBase.nbytes + sum(hInfo.nbytes for hInfo in self.hInfos)
238 @property
239 def itemSize(self):
240 """Size of one field value (in bytes)"""
241 return self.dtype().itemsize
243 @property
244 def fSize(self):
245 """Full size of a field (in bytes)"""
246 return self.nItems * self.itemSize
248 @property
249 def fileSize(self):
250 """Current size of the file (in bytes)"""
251 return os.path.getsize(self.fileName)
253 def addField(self, time, field):
254 """
255 Append one field solution at the end of the file with one given time.
257 Parameters
258 ----------
259 time : float-like
260 The associated time of the field solution.
261 field : np.ndarray
262 The field values.
263 """
264 assert self.initialized, "cannot add field to a non initialized FieldsIO"
265 field = np.asarray(field)
266 assert field.dtype == self.dtype, f"expected {self.dtype} dtype, got {field.dtype}"
267 assert field.size == self.nItems, f"expected {self.nItems} values, got {field.size}"
268 with open(self.fileName, "ab") as f:
269 np.array(time, dtype=T_DTYPE).tofile(f)
270 field.tofile(f)
272 @property
273 def nFields(self):
274 """Number of fields currently stored in the binary file"""
275 return int((self.fileSize - self.hSize) // (self.tSize + self.fSize))
277 def formatIndex(self, idx):
278 """Utility method to format a fields index to a positional integer (negative starts from last field index, like python lists)"""
279 nFields = self.nFields
280 if idx < 0:
281 idx = nFields + idx
282 assert idx < nFields, f"cannot read index {idx} from {nFields} fields"
283 assert idx >= 0, f"cannot read index {idx-nFields} from {nFields} fields"
284 return idx
286 @property
287 def times(self):
288 """Vector of all times stored in the binary file"""
289 times = []
290 with open(self.fileName, "rb") as f:
291 f.seek(self.hSize)
292 for i in range(self.nFields):
293 t = np.fromfile(f, dtype=T_DTYPE, count=1, offset=0 if i == 0 else self.fSize)[0]
294 times.append(float(t))
295 return times
297 def time(self, idx):
298 """Time stored at a given field index"""
299 idx = self.formatIndex(idx)
300 offset = self.hSize + idx * (self.tSize + self.fSize)
301 with open(self.fileName, "rb") as f:
302 t = np.fromfile(f, dtype=T_DTYPE, count=1, offset=offset)[0]
303 return float(t)
305 def readField(self, idx):
306 """
307 Read one field stored in the binary file, corresponding to the given
308 time index.
310 Parameters
311 ----------
312 idx : int
313 Positional index of the field.
315 Returns
316 -------
317 t : float
318 Stored time for this field.
319 field : np.ndarray
320 Read fields in a numpy array.
321 """
322 idx = self.formatIndex(idx)
323 offset = self.hSize + idx * (self.tSize + self.fSize)
324 with open(self.fileName, "rb") as f:
325 f.seek(offset)
326 t = float(np.fromfile(f, dtype=T_DTYPE, count=1)[0])
327 field = np.fromfile(f, dtype=self.dtype, count=self.nItems)
328 self.reshape(field)
329 return t, field
331 def reshape(self, field):
332 """Eventually reshape the field to correspond to the grid structure"""
333 pass
336@FieldsIO.register(sID=0)
337class Scalar(FieldsIO):
338 """FieldsIO handler storing a given number of scalar"""
340 # -------------------------------------------------------------------------
341 # Overridden methods
342 # -------------------------------------------------------------------------
343 def setHeader(self, nVar):
344 """
345 Set the descriptive grid structure to be stored in the file header.
347 Parameters
348 ----------
349 nVar : int
350 Number of scalar variable stored.
351 """
352 self.header = {"nVar": int(nVar)}
353 self.nItems = self.nVar
355 @property
356 def hInfos(self):
357 """Array representing the grid structure to be written in the binary file."""
358 return [np.array([self.nVar], dtype=np.int64)]
360 def readHeader(self, f):
361 """
362 Read the header from the binary file storing the fields.
364 Parameters
365 ----------
366 f : `_io.TextIOWrapper`
367 File to read the header from.
368 """
369 (nVar,) = np.fromfile(f, dtype=np.int64, count=1)
370 self.setHeader(nVar)
372 # -------------------------------------------------------------------------
373 # Class specifics
374 # -------------------------------------------------------------------------
375 @property
376 def nVar(self):
377 """Number of variables in a fields, as described in the header"""
378 return self.header["nVar"]
381@FieldsIO.register(sID=1)
382class Rectilinear(Scalar):
383 """FieldsIO handler storing a given number of scalar variables on a N-dimensional rectilinear grid"""
385 @staticmethod
386 def setupCoords(*coords):
387 """Utility function to setup grids in multiple dimensions, given the keyword arguments"""
388 coords = [np.asarray(coord, dtype=np.float64) for coord in coords]
389 for axis, coord in enumerate(coords):
390 assert coord.ndim == 1, f"coord for {axis=} must be one dimensional"
391 return coords
393 # -------------------------------------------------------------------------
394 # Overridden methods
395 # -------------------------------------------------------------------------
396 def setHeader(self, nVar, coords):
397 """
398 Set the descriptive grid structure to be stored in the file header.
400 Parameters
401 ----------
402 nVar : int
403 Number of 1D variables stored.
404 coords : np.1darray or list[np.1darray]
405 The grid coordinates in each dimensions.
407 Note
408 ----
409 When used in MPI decomposition mode, all coordinate **must** be the global grid.
410 """
411 if not isinstance(coords, (tuple, list)):
412 coords = [coords]
413 coords = self.setupCoords(*coords)
414 self.header = {"nVar": int(nVar), "coords": coords}
415 self.nItems = nVar * self.nDoF
417 @property
418 def hInfos(self):
419 """Array representing the grid structure to be written in the binary file."""
420 return [np.array([self.nVar, self.dim, *self.gridSizes], dtype=np.int32)] + [
421 np.array(coord, dtype=np.float64) for coord in self.header["coords"]
422 ]
424 def readHeader(self, f):
425 """
426 Read the header from the binary file storing the fields.
428 Parameters
429 ----------
430 f : `_io.TextIOWrapper`
431 File to read the header from.
432 """
433 nVar, dim = np.fromfile(f, dtype=np.int32, count=2)
434 gridSizes = np.fromfile(f, dtype=np.int32, count=dim)
435 coords = [np.fromfile(f, dtype=np.float64, count=n) for n in gridSizes]
436 self.setHeader(nVar, coords)
438 def reshape(self, fields: np.ndarray):
439 """Reshape the fields to a N-d array (inplace operation)"""
440 fields.shape = (self.nVar, *self.gridSizes)
442 # -------------------------------------------------------------------------
443 # Class specifics
444 # -------------------------------------------------------------------------
445 @property
446 def gridSizes(self):
447 """Number of points in y direction"""
448 return [coord.size for coord in self.header["coords"]]
450 @property
451 def dim(self):
452 """Number of grid dimensions"""
453 return len(self.gridSizes)
455 @property
456 def nDoF(self):
457 """Number of degrees of freedom for one variable"""
458 return np.prod(self.gridSizes)
460 def toVTR(self, baseName, varNames, idxFormat="{:06d}"):
461 """
462 Convert all 3D fields stored in binary format (FieldsIO) into a list
463 of VTR files, that can be read later with Paraview or equivalent to
464 make videos.
466 Parameters
467 ----------
468 baseName : str
469 Base name of the VTR file.
470 varNames : list[str]
471 Variable names of the fields.
472 idxFormat : str, optional
473 Formatting string for the index of the VTR file.
474 The default is "{:06d}".
476 Example
477 -------
478 >>> # Suppose the FieldsIO object is already writen into outputs.pysdc
479 >>> import os
480 >>> from pySDC.utils.fieldsIO import Rectilinear
481 >>> os.makedirs("vtrFiles") # to store all VTR files into a subfolder
482 >>> Rectilinear.fromFile("outputs.pysdc").toVTR(
483 >>> baseName="vtrFiles/field", varNames=["u", "v", "w", "T", "p"])
484 """
485 assert self.dim == 3, "can only be used with 3D fields"
486 from pySDC.helpers.vtkIO import writeToVTR
488 template = f"{baseName}_{idxFormat}"
489 for i in range(self.nFields):
490 _, u = self.readField(i)
491 writeToVTR(template.format(i), u, self.header["coords"], varNames)
493 # -------------------------------------------------------------------------
494 # MPI-parallel implementation
495 # -------------------------------------------------------------------------
496 comm: MPI.Intracomm = None
498 @classmethod
499 def setupMPI(cls, comm: MPI.Intracomm, iLoc, nLoc):
500 """
501 Setup the MPI mode for the files IO, considering a decomposition
502 of the 1D grid into contiuous subintervals.
504 Parameters
505 ----------
506 comm : MPI.Intracomm
507 The space decomposition communicator.
508 iLoc : list[int]
509 Starting index of the local sub-domain in the global coordinates.
510 nLoc : list[int]
511 Number of points in the local sub-domain.
512 """
513 cls.comm = comm
514 cls.iLoc = iLoc
515 cls.nLoc = nLoc
516 cls.mpiFile = None
518 @property
519 def MPI_ON(self):
520 """Wether or not MPI is activated"""
521 if self.comm is None:
522 return False
523 return self.comm.Get_size() > 1
525 @property
526 def MPI_ROOT(self):
527 """Wether or not the process is MPI Root"""
528 if self.comm is None:
529 return True
530 return self.comm.Get_rank() == 0
532 def MPI_FILE_OPEN(self, mode):
533 """Open the binary file in MPI mode"""
534 amode = {
535 "r": MPI.MODE_RDONLY,
536 "a": MPI.MODE_WRONLY | MPI.MODE_APPEND,
537 }[mode]
538 self.mpiFile = MPI.File.Open(self.comm, self.fileName, amode)
540 def MPI_WRITE(self, data):
541 """Write data (np.ndarray) in the binary file in MPI mode, at the current file cursor position."""
542 self.mpiFile.Write(data)
544 def MPI_WRITE_AT(self, offset, data: np.ndarray):
545 """
546 Write data in the binary file in MPI mode, with a given offset
547 **relative to the beginning of the file**.
549 Parameters
550 ----------
551 offset : int
552 Offset to write at, relative to the beginning of the file, in bytes.
553 data : np.ndarray
554 Data to be written in the binary file.
555 """
556 self.mpiFile.Write_at(offset, data)
558 def MPI_READ_AT(self, offset, data):
559 """
560 Read data from the binary file in MPI mode, with a given offset
561 **relative to the beginning of the file**.
563 Parameters
564 ----------
565 offset : int
566 Offset to read at, relative to the beginning of the file, in bytes.
567 data : np.ndarray
568 Array on which to read the data from the binary file.
569 """
570 self.mpiFile.Read_at(offset, data)
572 def MPI_FILE_CLOSE(self):
573 """Close the binary file in MPI mode"""
574 self.mpiFile.Close()
575 self.mpiFile = None
577 def initialize(self):
578 """Initialize the binary file (write header) in MPI mode"""
579 if self.MPI_ROOT:
580 try:
581 super().initialize()
582 except AssertionError as e:
583 if self.MPI_ON:
584 print(f"{type(e)}: {e}")
585 self.comm.Abort()
586 else:
587 raise e
589 if self.MPI_ON:
590 self.comm.Barrier() # Important, should not be removed !
591 self.initialized = True
593 def addField(self, time, field):
594 """
595 Append one field solution at the end of the file with one given time,
596 possibly using MPI.
598 Parameters
599 ----------
600 time : float-like
601 The associated time of the field solution.
602 field : np.ndarray
603 The (local) field values.
605 Note
606 ----
607 If a MPI decomposition is used, field **must be** the local field values.
608 """
609 if not self.MPI_ON:
610 return super().addField(time, field)
612 assert self.initialized, "cannot add field to a non initialized FieldsIO"
614 field = np.asarray(field)
615 assert field.dtype == self.dtype, f"expected {self.dtype} dtype, got {field.dtype}"
616 assert field.shape == (
617 self.nVar,
618 *self.nLoc,
619 ), f"expected {(self.nVar, *self.nLoc)} shape, got {field.shape}"
621 offset0 = self.fileSize
622 self.MPI_FILE_OPEN(mode="a")
623 if self.MPI_ROOT:
624 self.MPI_WRITE(np.array(time, dtype=T_DTYPE))
625 offset0 += self.tSize
627 for (iVar, *iBeg) in itertools.product(range(self.nVar), *[range(n) for n in self.nLoc[:-1]]):
628 offset = offset0 + self.iPos(iVar, iBeg) * self.itemSize
629 self.MPI_WRITE_AT(offset, field[iVar, *iBeg])
630 self.MPI_FILE_CLOSE()
632 def iPos(self, iVar, iX):
633 iPos = iVar * self.nDoF
634 for axis in range(self.dim - 1):
635 iPos += (self.iLoc[axis] + iX[axis]) * np.prod(self.gridSizes[axis + 1 :])
636 iPos += self.iLoc[-1]
637 return iPos
639 def readField(self, idx):
640 """
641 Read one field stored in the binary file, corresponding to the given
642 time index, using MPI in the eventuality of space parallel decomposition.
644 Parameters
645 ----------
646 idx : int
647 Positional index of the field.
649 Returns
650 -------
651 t : float
652 Stored time for this field.
653 field : np.ndarray
654 Read (local) fields in a numpy array.
656 Note
657 ----
658 If a MPI decomposition is used, it reads and returns the local fields values only.
659 """
660 if not self.MPI_ON:
661 return super().readField(idx)
663 idx = self.formatIndex(idx)
664 offset0 = self.hSize + idx * (self.tSize + self.fSize)
665 with open(self.fileName, "rb") as f:
666 t = float(np.fromfile(f, dtype=T_DTYPE, count=1, offset=offset0)[0])
667 offset0 += self.tSize
669 field = np.empty((self.nVar, *self.nLoc), dtype=self.dtype)
671 self.MPI_FILE_OPEN(mode="r")
672 for (iVar, *iBeg) in itertools.product(range(self.nVar), *[range(n) for n in self.nLoc[:-1]]):
673 offset = offset0 + self.iPos(iVar, iBeg) * self.itemSize
674 self.MPI_READ_AT(offset, field[iVar, *iBeg])
675 self.MPI_FILE_CLOSE()
677 return t, field
680# -----------------------------------------------------------------------------------------------
681# Utility functions used for testing
682# -----------------------------------------------------------------------------------------------
683def initGrid(nVar, gridSizes):
684 dim = len(gridSizes)
685 coords = [np.linspace(0, 1, num=n, endpoint=False) for n in gridSizes]
686 s = [None] * dim
687 u0 = np.array(np.arange(nVar) + 1)[:, *s]
688 for x in np.meshgrid(*coords, indexing="ij"):
689 u0 = u0 * x
690 return coords, u0
693def writeFields_MPI(fileName, dtypeIdx, algo, nSteps, nVar, gridSizes):
694 coords, u0 = initGrid(nVar, gridSizes)
696 from mpi4py import MPI
697 from pySDC.helpers.blocks import BlockDecomposition
698 from pySDC.helpers.fieldsIO import Rectilinear
700 comm = MPI.COMM_WORLD
701 MPI_SIZE = comm.Get_size()
702 MPI_RANK = comm.Get_rank()
704 blocks = BlockDecomposition(MPI_SIZE, gridSizes, algo, MPI_RANK)
706 iLoc, nLoc = blocks.localBounds
707 Rectilinear.setupMPI(comm, iLoc, nLoc)
708 s = [slice(i, i + n) for i, n in zip(iLoc, nLoc)]
709 u0 = u0[:, *s]
710 print(MPI_RANK, u0.shape)
712 f1 = Rectilinear(DTYPES[dtypeIdx], fileName)
713 f1.setHeader(nVar=nVar, coords=coords)
715 u0 = np.asarray(u0, dtype=f1.dtype)
716 f1.initialize()
718 times = np.arange(nSteps) / nSteps
719 for t in times:
720 ut = (u0 * t).astype(f1.dtype)
721 f1.addField(t, ut)
723 return u0
726def compareFields_MPI(fileName, u0, nSteps):
727 from pySDC.helpers.fieldsIO import FieldsIO
729 f2 = FieldsIO.fromFile(fileName)
731 times = np.arange(nSteps) / nSteps
732 for idx, t in enumerate(times):
733 u1 = u0 * t
734 t2, u2 = f2.readField(idx)
735 assert t2 == t, f"fields[{idx}] in {f2} has incorrect time ({t2} instead of {t})"
736 assert u2.shape == u1.shape, f"{idx}'s fields in {f2} has incorrect shape"
737 assert np.allclose(u2, u1), f"{idx}'s fields in {f2} has incorrect values"