Source code for damask._table

# SPDX-License-Identifier: AGPL-3.0-or-later
import copy
import re
from os import PathLike
from io import TextIOBase
from pathlib import Path
from typing import Iterable, Mapping, Sequence, Union, TextIO, BinaryIO, Any, cast

import numpy as np
import pandas as pd

from . import util
from ._typehints import FileHandleText, FileHandleBinary


[docs] class Table: """Manipulate multi-dimensional spreadsheet-like data.""" def __init__(self, shapes: Mapping[str,Union[Union[int,np.integer],tuple[Union[int,np.integer],...]]] = {}, data: np.ndarray | pd.DataFrame | None = None, comments: str | Iterable[str] | None = None, dtypes: Mapping[str,str | np.dtype] | None = None): """ New spreadsheet. Parameters ---------- shapes : dict with str:tuple pairs, optional Shapes of the data columns. Mandatory if 'data' is given. For instance, 'F':(3,3) for a deformation gradient, or 'r':(1,) for a scalar. data : numpy.ndarray or pandas.DataFrame, optional Data. Existing column labels of a pandas.DataFrame will be replaced. comments : (iterable of) str, optional Additional, human-readable information. dtypes : dict, optional Data type of each column. If given, overwrites existing data types inferred from numpy.ndarray or pandas.DataFrame. """ self.comments = [] if comments is None else \ [comments] if isinstance(comments,str) else \ [str(c) for c in comments] self.shapes = { k:(v,) if isinstance(v,(np.integer,int)) else v for k,v in shapes.items() } self.data = pd.DataFrame(data=data) self._relabel('uniform') if dtypes is not None: self.data = self.data.astype({k:v for k,v in dtypes.items() if k in set(self.shapes.keys())}) def __repr__(self) -> str: """ Return repr(self). Give short, human-readable summary. """ self._relabel('shapes') data_repr = self.data.__repr__() self._relabel('uniform') return '\n'.join(['# '+c for c in self.comments])+'\n'+data_repr def __eq__(self, other: object) -> bool: """ Return self==other. Test equality of other. Parameters ---------- other : Table Table to check for equality. Returns ------- equal : bool Whether both arguments are equal. """ return NotImplemented if not isinstance(other,Table) else \ self.shapes == other.shapes and self.data.equals(other.data) def __getitem__(self, item) -> 'Table': """ Return self[item]. Return slice according to item. Parameters ---------- item : row and/or column indexer Slice to select from Table. Returns ------- slice : damask.Table Sliced part of the Table. Examples -------- >>> import damask >>> import numpy as np >>> tbl = damask.Table(shapes=dict(colA=(1,),colB=(1,),colC=(1,)), ... data=np.arange(12).reshape((4,3))) >>> tbl['colA','colB'] colA colB 0 0 1 1 3 4 2 6 7 3 9 10 >>> tbl[3] colA colB colC 3 9 10 11 >>> tbl[::2,['colB','colA']] colB colA 0 1 0 2 7 6 >>> tbl[[True,False,False,True],'colB'] colB 0 1 3 10 """ item_ = (item,slice(None,None,None)) if isinstance(item,(slice,np.ndarray)) else \ (np.array(item),slice(None,None,None)) if isinstance(item,list) and np.array(item).dtype == np.bool_ else \ (slice(int(item),int(item)),slice(None,None,None)) if isinstance(item,(int,np.integer)) else \ (np.array(item[0]),item[1]) if isinstance(item[0],list) else \ (item[0], item[1]) if isinstance(item[0],(slice,np.ndarray)) else \ (slice(None,None,None),item) sliced = self.data.loc[item_] cols = np.array(sliced.columns if isinstance(sliced,pd.DataFrame) else [item_[1]]) _,idx = np.unique(cols,return_index=True) return self.__class__(shapes={k:self.shapes[k] for k in cols[np.sort(idx)]}, data=sliced, comments=self.comments) def __len__(self) -> int: """ Return len(self). Number of rows. """ return len(self.data) def __copy__(self) -> 'Table': """ Return deepcopy(self). Create deep copy. """ return copy.deepcopy(self) copy = __copy__ def _label(self, what: str | Sequence[str], how: str) -> list[str]: """ Expand labels according to data shape. Parameters ---------- what : (sequence of) str Labels to expand. how : {'uniform, 'shapes', 'linear'} Mode of labeling. 'uniform' ==> v v v 'shapes' ==> 3:v v v 'linear' ==> 1_v 2_v 3_v 'export' ==> 3x3:1_t 3x3:2_t 3x3:3_t 3x3:4_t 3x3:5_t 3x3:6_t 3x3:7_t 3x3:8_t 3x3:9_t """ what = [what] if isinstance(what,str) else what labels = [] for label in what: shape = self.shapes[label] size = np.prod(shape,dtype=np.int64) # type: ignore if how == 'uniform': labels += [label] * size elif how == 'shapes': labels += [('' if size == 1 or i>0 else f'{util.srepr(shape,"x")}:')+label for i in range(size)] elif how == 'linear': labels += [('' if size == 1 else f'{i+1}_')+label for i in range(size)] elif how == 'export': labels += [ ('' if len(shape)<2 else f'{util.srepr(shape,"x")}:') +('' if size == 1 else f'{i+1}_') +label for i in range(size)] else: raise KeyError return labels def _relabel(self, how: str): """ Modify labeling of data in-place. Parameters ---------- how : {'uniform, 'shapes', 'linear'} Mode of labeling. 'uniform' ==> v v v 'shapes' ==> 3:v v v 'linear' ==> 1_v 2_v 3_v 'export' ==> 3x3:1_t 3x3:2_t 3x3:3_t 3x3:4_t 3x3:5_t 3x3:6_t 3x3:7_t 3x3:8_t 3x3:9_t """ self.data.columns = self._label(self.shapes,how) # type: ignore
[docs] def isclose(self, other: 'Table', rtol: float = 1e-5, atol: float = 1e-8, equal_nan: bool = True) -> np.ndarray: """ Report where values are approximately equal to corresponding ones of other Table. Parameters ---------- other : damask.Table Table to compare against. rtol : float, optional Relative tolerance of equality. atol : float, optional Absolute tolerance of equality. equal_nan : bool, optional Consider matching NaN values as equal. Defaults to True. Returns ------- mask : numpy.ndarray of bool Mask indicating where corresponding table values are close. """ return np.isclose( self.data.to_numpy(), other.data.to_numpy(), rtol=rtol, atol=atol, equal_nan=equal_nan)
[docs] def allclose(self, other: 'Table', rtol: float = 1e-5, atol: float = 1e-8, equal_nan: bool = True) -> bool: """ Test whether all values are approximately equal to corresponding ones of other Table. Parameters ---------- other : damask.Table Table to compare against. rtol : float, optional Relative tolerance of equality. atol : float, optional Absolute tolerance of equality. equal_nan : bool, optional Consider matching NaN values as equal. Defaults to True. Returns ------- answer : bool Whether corresponding values are close between both tables. """ return np.allclose( self.data.to_numpy(), other.data.to_numpy(), rtol=rtol, atol=atol, equal_nan=equal_nan)
[docs] @staticmethod def load(fname: FileHandleText) -> 'Table': """ Load a table from text table or NumPy archive format. For path-like inputs, a case-insensitive ``.npz`` suffix selects NumPy archive format; all other suffixes select text table format. Open binary streams are interpreted as NumPy archives, while open text streams are interpreted as text tables. Text tables contain whitespace-separated fields. Leading comment lines begin with ``#``, and the first non-comment line contains the column labels. Component labels encode the shape of vector and tensor data: - Vector components are labeled ``1_v, 2_v, ..., n_v``. - Tensor components are labeled ``3x3:1_T, 3x3:2_T, ..., 3x3:9_T``. Parameters ---------- fname : str, path-like, or file-like object Input path or open stream. Text streams must contain a text table, and binary streams must contain a NumPy archive. Returns ------- table : damask.Table Loaded table. """ def infer_shapes(labels: list[str]) -> dict[str, tuple[int, ...]]: shapes: dict[str, tuple[int, ...]] = {} for label in labels: if (tensor_column := re.search(r'[0-9,x]*?:[0-9]*?_', label)) is not None: shape = tensor_column.group().split(':', 1)[0].split('x') shapes[label.split('_', 1)[1]] = tuple(int(d) for d in shape) elif re.match(r'[0-9]*?_', label) is not None: shapes[label.split('_',1)[1]] = (int(label.split('_',1)[0]),) else: shapes[label] = (1,) return shapes def load_npz(source: FileHandleBinary) -> 'Table': if not isinstance(source, (str, PathLike)): source.seek(0) if not isinstance(loaded := np.load(source), np.lib.npyio.NpzFile): raise ValueError('expected a NumPy .npz archive, not a .npy array') with loaded as content: return Table( shapes=infer_shapes(content.files), data=np.column_stack([content[name] for name in content.files]) if content.files else np.empty((0, 0)), ) def load_text(stream: TextIO) -> 'Table': stream.seek(0) comments: list[str] = [] while (line := stream.readline().strip()).startswith('#'): comments.append(line.removeprefix('#').strip()) labels = line.split() return Table( shapes=infer_shapes(labels), data=pd.read_csv(stream,names=list(range(len(labels))),sep=r'\s+'), comments=comments, ) if isinstance(fname, (str, PathLike)): if Path(fname).suffix.casefold() == '.npz': return load_npz(fname) with util.open_text(fname) as stream: return load_text(stream) if isinstance(fname, TextIOBase): return load_text(cast(TextIO,fname)) return load_npz(cast(BinaryIO,fname))
[docs] @staticmethod def load_ang(fname: FileHandleText, shapes = {'eu':3, 'pos':2, 'IQ':1, 'CI':1, 'ID':1, 'intensity':1, 'fit':1}) -> 'Table': """ Load from ANG file. Regular ANG files feature the following columns: - Euler angles (Bunge notation) in radians, 3 floats, label 'eu'. - Spatial position in meters, 2 floats, label 'pos'. - Image quality, 1 float, label 'IQ'. - Confidence index, 1 float, label 'CI'. - Phase ID, 1 int, label 'ID'. - SEM signal, 1 float, label 'intensity'. - Fit, 1 float, label 'fit'. Parameters ---------- fname : file, str, or pathlib.Path Filename or file to read. shapes : dict with str:int pairs, optional Column labels and their width. Defaults to standard TSL ANG format. Returns ------- loaded : damask.Table Table data from file. """ with util.open_text(fname) as f: f.seek(0) content = f.readlines() comments = [util.execution_stamp('Table','from_ang')] for i,line in enumerate(content): if line.startswith('#'): comments.append(line.split('#',1)[1].strip()) else: break data = np.loadtxt(content[i:]) if (remainder := data.shape[1]-sum(shapes.values())) > 0: shapes['unknown'] = remainder return Table(shapes,data,comments)
@property def labels(self) -> list[str]: return list(self.shapes)
[docs] def get(self, label: str) -> np.ndarray: """ Get column data. Parameters ---------- label : str Column label. Returns ------- data : numpy.ndarray Array of column data. """ data = self.data[label].to_numpy().reshape((-1,)+self.shapes[label]) return data.astype(type(data.flatten()[0]))
[docs] def set(self, label: str, data: np.ndarray, info: str | None = None) -> 'Table': """ Add new or replace existing column data. Parameters ---------- label : str Column label. data : numpy.ndarray Column data. First dimension needs to match number of rows. info : str, optional Human-readable information about the data. Returns ------- updated : damask.Table Updated table. """ def add_comment(label: str, shape: tuple[int, ...],info: str) -> list[str]: specific = f'{label}{" "+str(shape) if np.prod(shape,dtype=np.int64) > 1 else ""}: {info}' general = util.execution_stamp('Table') return [f'{specific} / {general}'] dup = self.copy() if info is not None: dup.comments += add_comment(label,data.shape[1:],info) key = m.group(1) if (m := re.match(r'(.*)\[((\d+,)*(\d+))\]',label)) else label if key in dup.shapes: if m: idx = np.ravel_multi_index(tuple(map(int,m.group(2).split(","))), self.shapes[key]) iloc = dup.data.columns.get_loc(key).tolist().index(True) + idx # type: ignore dup.data.iloc[:,iloc] = data else: dup.data[label] = data.reshape(dup.data[label].shape) else: dup.shapes[label] = data.shape[1:] if data.ndim > 1 else (1,) size = np.prod(data.shape[1:],dtype=np.int64) new = pd.DataFrame(data=data.reshape((-1,size)), columns=[label]*size, ) if not dup.data.index.empty: new.index = dup.data.index dup.data = pd.concat([dup.data,new],axis=1) return dup
[docs] def delete(self, label: str) -> 'Table': """ Delete column data. Parameters ---------- label : str Column label. Returns ------- updated : damask.Table Updated table. """ dup = self.copy() dup.data.drop(columns=label,inplace=True) del dup.shapes[label] return dup
[docs] def rename(self, old: str | Iterable[str], new: str | Iterable[str], info: str | None = None) -> 'Table': """ Rename column data. Parameters ---------- old : (iterable of) str Old column labels. new : (iterable of) str New column labels. Returns ------- updated : damask.Table Updated table. """ dup = self.copy() columns = dict(zip([old] if isinstance(old,str) else old, [new] if isinstance(new,str) else new)) dup.data.rename(columns=columns,inplace=True) dup.comments.append(f'{old} => {new}'+('' if info is None else f': {info}')) dup.shapes = {(label if label not in columns else columns[label]):dup.shapes[label] for label in dup.shapes} return dup
[docs] def sort_by(self, labels: str | list[str], ascending: bool | list[bool] = True) -> 'Table': """ Sort table by data of given columns. Parameters ---------- labels : (list of) str Column labels for sorting. ascending : (list of) bool, optional Set sort order. Defaults to True. Returns ------- updated : damask.Table Updated table. """ labels_ = util.to_list(labels) for i,l in enumerate(labels_): if m := re.match(r'(.*)\[((\d+,)*(\d+))\]',l): idx = np.ravel_multi_index(tuple(map(int,m.group(2).split(','))), self.shapes[m.group(1)]) labels_[i] = f'{1+idx}_{m.group(1)}' dup = self.copy() dup._relabel('linear') dup.data.sort_values(labels_,axis=0,inplace=True,ascending=ascending) dup._relabel('uniform') dup.comments.append(f'sorted {"ascending" if ascending else "descending"} by {labels}') return dup
[docs] def unique(self, return_index: bool = False, return_inverse: bool = False, return_counts: bool = False, ): """ Return the unique elements across the table in order of appearance. There are three optional outputs in addition to the unique elements: * the indices of the input table that give the unique values * the indices of the unique table that reconstruct the input table * the number of times each unique value comes up in the input table Parameters ---------- return_index : bool, optional If True, also return the indices that result in the unique table. Defaults to False. return_inverse : bool, optional If True, also return the indices of the unique table that can be used to reconstruct the original table. Defaults to False. return_counts : bool, optional If True, also return the number of times each unique value combination appears. Defaults to False. Returns ------- unique : damask.Table Table containing the sorted unique value combinations of the selected columns. unique_indices : numpy.ndarray of int, optional The indices of the first occurrence of the unique values in the input table. Only provided if return_index is True. unique_inverse : numpy.ndarray of int, optional The indices to reconstruct the input table from the unique table. Only provided if return_inverse is True. unique_counts : numpy.ndarray of int, optional The number of times each unique value comes up in the input Table. Only provided if return_counts is True. """ _,idx,inv,cnt = np.unique( np.hstack([self.get(l) for l in self.labels]), return_index=True, return_inverse=True, return_counts=True, axis=0, ) s = np.sort(idx) m = np.argsort(idx) M = np.argsort(m) dup = self[s] dup.data.reset_index(drop=True,inplace=True) dup.comments.append('selecting unique combinations') if return_index or return_inverse or return_counts: return (dup,) \ + ((s,) if return_index else ()) \ + ((M[inv.reshape(-1)],) if return_inverse else ()) \ + ((cnt[m],) if return_counts else ()) else: return dup
[docs] def append(self, other: 'Table') -> 'Table': """ Append other table vertically (similar to numpy.vstack). Requires matching labels/shapes and order. Parameters ---------- other : damask.Table Table to append. Returns ------- updated : damask.Table Updated table. """ if self.shapes != other.shapes or not self.data.columns.equals(other.data.columns): raise KeyError('mismatch of shapes or labels or their order') dup = self.copy() dup.data = pd.concat([dup.data,other.data],ignore_index=True) return dup
[docs] def join(self, other: 'Table') -> 'Table': """ Append other table horizontally (similar to numpy.hstack). Requires matching number of rows and no common labels. Parameters ---------- other : damask.Table Table to join. Returns ------- updated : damask.Table Updated table. """ if set(self.shapes) & set(other.shapes) or self.data.shape[0] != other.data.shape[0]: raise KeyError('duplicated keys or row count mismatch') dup = self.copy() dup.data = dup.data.join(other.data) for key in other.shapes: dup.shapes[key] = other.shapes[key] return dup
[docs] def save(self, fname: FileHandleText | FileHandleBinary) -> None: """ Save the table in text table or NumPy archive format. For path-like outputs, a case-insensitive ``.npz`` suffix selects NumPy archive format; all other suffixes select text table format. Open binary streams produce NumPy archives, while open text streams produce text tables. NumPy archives are written in compressed ``.npz`` format. Text tables contain leading comments marked by ``#``, a column-label line, and whitespace-separated fields. Parameters ---------- fname : str, path-like, or file-like object Output path or open stream. Text streams must be opened in text mode, and NumPy archive streams must be opened in binary mode. """ labels = self._label(self.labels, "export") data = self.data.to_numpy() def save_npz(target: FileHandleBinary) -> None: arrays = { label: column for label, column in zip(labels, data.T) } # These names collide with parameters of savez_compressed(). reserved = arrays.keys() & {'file', 'allow_pickle'} if reserved: names = ', '.join(sorted(reserved)) raise ValueError( f'column labels reserved by ' f'np.savez_compressed: {names}' ) # NumPy uses arbitrary keyword arguments as archive member names. np.savez_compressed( target, **cast(dict[str, Any], arrays), ) def save_text(stream: TextIO) -> None: header = '\n'.join( [f'# {comment}' for comment in self.comments] + [' '.join(labels)] ) stream.write(header + '\n') csv_options = { 'sep': ' ', 'na_rep': 'nan', 'index': False, 'header': False, } try: # pandas >= 1.5 self.data.to_csv( stream, **csv_options, lineterminator='\n', ) # type: ignore[call-overload] except TypeError: # Backward compatibility with pandas < 1.5. self.data.to_csv( stream, **csv_options, line_terminator='\n', ) # type: ignore[call-overload] if isinstance(fname, (str, PathLike)): if Path(fname).suffix.casefold() == '.npz': save_npz(fname) else: with util.open_text(fname, 'w') as stream: save_text(stream) elif isinstance(fname, TextIOBase): save_text(cast(TextIO, fname)) else: save_npz(cast(FileHandleBinary, fname))