From c2c92cfbbe29a0a03d37542a0cf269abc998f3db Mon Sep 17 00:00:00 2001 From: hayk Date: Wed, 6 Nov 2024 14:50:48 -0500 Subject: [PATCH 1/9] new data reading layout --- .gitignore | 9 +- nt2/__init__.py | 5 +- nt2/containers/__init__.py | 0 nt2/containers/container.py | 148 ++++++ nt2/containers/fields.py | 229 +++++++++ nt2/containers/particles.py | 178 +++++++ nt2/containers/spectra.py | 138 +++++ nt2/containers/utils.py | 38 ++ nt2/plotters/__init__.py | 0 nt2/{ => plotters}/plot.py | 12 +- nt2/plotters/polarplot.py | 488 ++++++++++++++++++ nt2/read.py | 987 +----------------------------------- pyrightconfig.json | 3 + 13 files changed, 1255 insertions(+), 980 deletions(-) create mode 100644 nt2/containers/__init__.py create mode 100644 nt2/containers/container.py create mode 100644 nt2/containers/fields.py create mode 100644 nt2/containers/particles.py create mode 100644 nt2/containers/spectra.py create mode 100644 nt2/containers/utils.py create mode 100644 nt2/plotters/__init__.py rename nt2/{ => plotters}/plot.py (94%) create mode 100644 nt2/plotters/polarplot.py create mode 100644 pyrightconfig.json diff --git a/.gitignore b/.gitignore index 4a1d483..ae1febe 100644 --- a/.gitignore +++ b/.gitignore @@ -151,11 +151,6 @@ dmypy.json # Cython debug symbols cython_debug/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ test/ -temp/ \ No newline at end of file +temp/ +*.bak diff --git a/nt2/__init__.py b/nt2/__init__.py index 3d26edf..90d4cd0 100644 --- a/nt2/__init__.py +++ b/nt2/__init__.py @@ -1 +1,4 @@ -__version__ = "0.4.1" +__version__ = "0.5.0" + +from nt2.read import Data as Data +from nt2.plotters import polarplot as polarplot diff --git a/nt2/containers/__init__.py b/nt2/containers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nt2/containers/container.py b/nt2/containers/container.py new file mode 100644 index 0000000..07904d5 --- /dev/null +++ b/nt2/containers/container.py @@ -0,0 +1,148 @@ +import h5py +import numpy as np +from typing import Any +from dask.distributed import Client + + +def _read_attribs_SingleFile(file: h5py.File): + attribs = {} + for k in file.attrs.keys(): + attr = file.attrs[k] + if type(attr) is bytes or type(attr) is np.bytes_: + attribs[k] = attr.decode("UTF-8") + else: + attribs[k] = attr + return attribs + + +class Container: + def __init__( + self, path, single_file=False, pickle=True, greek=False, dask_props={} + ): + super(Container, self).__init__() + + self.client = Client(**dask_props) + if self.client.status == "running": + print("Dask client launched:") + print(self.client) + + self.configs: dict[str, Any] = { + "single_file": single_file, + "use_pickle": pickle, + "use_greek": greek, + } + self.path = path + self.metadata = {} + self.mesh = None + if self.configs["single_file"]: + try: + self.master_file: h5py.File | None = h5py.File(self.path, "r") + except OSError: + raise OSError(f"Could not open file {self.path}") + else: + self.master_file: h5py.File | None = None + raise NotImplementedError("Multiple files not yet supported") + + self.attrs = _read_attribs_SingleFile(self.master_file) + + if self.configs["single_file"]: + self.configs["ngh"] = int(self.master_file.attrs.get("NGhosts", 0)) + self.configs["layout"] = ( + "right" if self.master_file.attrs.get("LayoutRight", 1) == 1 else "left" + ) + self.configs["dimension"] = int(self.master_file.attrs.get("Dimension", 1)) + self.configs["coordinates"] = self.master_file.attrs.get( + "Coordinates", b"cart" + ).decode("UTF-8") + if self.configs["coordinates"] == "qsph": + self.configs["coordinates"] = "sph" + # if coordinates == "sph": + # self.metric = SphericalMetric() + # else: + # self.metric = MinkowskiMetric() + + def plotGrid(self, ax, **kwargs): + from matplotlib import patches + + xlim, ylim = ax.get_xlim(), ax.get_ylim() + options = { + "lw": 1, + "color": "k", + "ls": "-", + } + options.update(kwargs) + + if self.configs["coordinates"] == "cart": + for x in self.attrs["X1"]: + ax.plot([x, x], [self.attrs["X2Min"], self.attrs["X2Max"]], **options) + for y in self.attrs["X2"]: + ax.plot([self.attrs["X1Min"], self.attrs["X1Max"]], [y, y], **options) + else: + for r in self.attrs["X1"]: + ax.add_patch( + patches.Arc( + (0, 0), + 2 * r, + 2 * r, + theta1=-90, + theta2=90, + fill=False, + **options, + ) + ) + for th in self.attrs["X2"]: + ax.plot( + [ + self.attrs["X1Min"] * np.sin(th), + self.attrs["X1Max"] * np.sin(th), + ], + [ + self.attrs["X1Min"] * np.cos(th), + self.attrs["X1Max"] * np.cos(th), + ], + **options, + ) + ax.set(xlim=xlim, ylim=ylim) + + def print_container(self) -> str: + return f"Client {self.client}\n" + + # + # def makeMovie(self, plot, makeframes=True, **kwargs): + # """ + # Makes a movie from a plot function + # + # Parameters + # ---------- + # plot : function + # The plot function to use; accepts output timestep and dataset as arguments. + # makeframes : bool, optional + # Whether to make the frames, or just proceed to making the movie. Default is True. + # num_cpus : int, optional + # The number of CPUs to use for making the frames. Default is None. + # **kwargs : + # Additional keyword arguments passed to `ffmpeg`. + # """ + # import numpy as np + # + # if makeframes: + # makemovie = all( + # exp.makeFrames( + # plot, + # np.arange(len(self.t)), + # f"{self.attrs['simulation.name']}/frames", + # data=self, + # num_cpus=kwargs.pop("num_cpus", None), + # ) + # ) + # else: + # makemovie = True + # if makemovie: + # exp.makeMovie( + # input=f"{self.attrs['simulation.name']}/frames/", + # overwrite=True, + # output=f"{self.attrs['simulation.name']}.mp4", + # number=5, + # **kwargs, + # ) + # return True diff --git a/nt2/containers/fields.py b/nt2/containers/fields.py new file mode 100644 index 0000000..33bc355 --- /dev/null +++ b/nt2/containers/fields.py @@ -0,0 +1,229 @@ +import h5py +import xarray as xr +import numpy as np +from dask.array.core import from_array +from dask.array.core import stack + +from nt2.containers.container import Container +from nt2.containers.utils import _read_category_metadata_SingleFile + + +def _read_coordinates_SingleFile(coords: list[str], file: h5py.File): + for st in file: + group = file[st] + if isinstance(group, h5py.Group): + if any([k.startswith("X") for k in group if k is not None]): + # cell-centered coords + xc = { + c: ( + np.asarray(xi[:]) + if isinstance(xi := group[f"X{i+1}"], h5py.Dataset) and xi + else None + ) + for i, c in enumerate(coords[::-1]) + } + # cell edges + xe_min = { + f"{c}_1": ( + c, + ( + np.asarray(xi[:-1]) + if isinstance((xi := group[f"X{i+1}e"]), h5py.Dataset) + else None + ), + ) + for i, c in enumerate(coords[::-1]) + } + xe_max = { + f"{c}_2": ( + c, + ( + np.asarray(xi[1:]) + if isinstance((xi := group[f"X{i+1}e"]), h5py.Dataset) + else None + ), + ) + for i, c in enumerate(coords[::-1]) + } + return {"x_c": xc, "x_emin": xe_min, "x_emax": xe_max} + else: + raise ValueError(f"Unexpected type {type(file[st])}") + raise ValueError("Could not find coordinates in file") + + +def _preload_field_SingleFile( + k: str, + dim: int, + ngh: int, + outsteps: list[int], + times: list[float], + steps: list[int], + coords: list[str], + xc_coords: dict[str, str], + xe_min_coords: dict[str, str], + xe_max_coords: dict[str, str], + coord_replacements: list[tuple[str, str]], + field_replacements: list[tuple[str, str]], + layout: str, + file: h5py.File, +): + if dim == 1: + noghosts = slice(ngh, -ngh) if ngh > 0 else slice(None) + elif dim == 2: + noghosts = (slice(ngh, -ngh), slice(ngh, -ngh)) if ngh > 0 else slice(None) + elif dim == 3: + noghosts = ( + (slice(ngh, -ngh), slice(ngh, -ngh), slice(ngh, -ngh)) + if ngh > 0 + else slice(None) + ) + else: + raise ValueError("Invalid dimension") + + dask_arrays = [] + for s in outsteps: + dset = file[f"{s}/{k}"] + if isinstance(dset, h5py.Dataset): + array = from_array(np.transpose(dset) if layout == "right" else dset) + dask_arrays.append(array[noghosts]) + else: + raise ValueError(f"Unexpected type {type(dset)}") + + k_ = k[1:] + for c in coord_replacements: + if "_" not in k_: + k_ = k_.replace(c[0], c[1]) + else: + k_ = "_".join([k_.split("_")[0].replace(c[0], c[1])] + k_.split("_")[1:]) + for f in field_replacements: + k_ = k_.replace(*f) + + return k_, xr.DataArray( + stack(dask_arrays, axis=0), + dims=["t", *coords], + name=k_, + coords={ + "t": times, + "s": ("t", steps), + **xc_coords, + **xe_min_coords, + **xe_max_coords, + }, + ) + + +class FieldsContainer(Container): + def __init__(self, **kwargs): + super(FieldsContainer, self).__init__(**kwargs) + QuantityDict = { + "Ttt": "E", + "Ttx": "Px", + "Tty": "Py", + "Ttz": "Pz", + } + CoordinateDict = { + "cart": {"x": "x", "y": "y", "z": "z", "1": "x", "2": "y", "3": "z"}, + "sph": { + "r": "r", + "theta": "θ" if self.configs["use_greek"] else "th", + "phi": "φ" if self.configs["use_greek"] else "ph", + "1": "r", + "2": "θ" if self.configs["use_greek"] else "th", + "3": "φ" if self.configs["use_greek"] else "ph", + }, + } + if self.configs["single_file"]: + assert self.master_file is not None, "Master file not found" + self.metadata["fields"] = _read_category_metadata_SingleFile( + "f", self.master_file + ) + else: + try: + raise NotImplementedError("Multiple files not yet supported") + except OSError: + raise OSError(f"Could not open file {self.path}") + + coords = list(CoordinateDict[self.configs["coordinates"]].values())[::-1][ + -self.configs["dimension"] : + ] + + if self.configs["single_file"]: + self.mesh = _read_coordinates_SingleFile(coords, self.master_file) + else: + raise NotImplementedError("Multiple files not yet supported") + + self.fields = xr.Dataset() + + if len(self.metadata["fields"]["outsteps"]) > 0: + if self.configs["single_file"]: + for k in self.metadata["fields"]["quantities"]: + name, dset = _preload_field_SingleFile( + k, + dim=self.configs["dimension"], + ngh=self.configs["ngh"], + outsteps=self.metadata["fields"]["outsteps"], + times=self.metadata["fields"]["times"], + steps=self.metadata["fields"]["steps"], + coords=coords, + xc_coords=self.mesh["x_c"], + xe_min_coords=self.mesh["x_emin"], + xe_max_coords=self.mesh["x_emax"], + coord_replacements=list( + CoordinateDict[self.configs["coordinates"]].items() + ), + field_replacements=list(QuantityDict.items()), + layout=self.configs["layout"], + file=self.master_file, + ) + self.fields[name] = dset + else: + raise NotImplementedError("Multiple files not yet supported") + + def __del__(self): + if self.configs["single_file"] and self.master_file is not None: + self.master_file.close() + else: + raise NotImplementedError("Multiple files not yet supported") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + if self.configs["single_file"] and self.master_file is not None: + self.master_file.close() + else: + raise NotImplementedError("Multiple files not yet supported") + + def print_fields(self) -> str: + def sizeof_fmt(num, suffix="B"): + for unit in ("", "K", "M", "G", "T", "P", "E", "Z"): + if abs(num) < 1e3: + return f"{num:3.1f} {unit}{suffix}" + num /= 1e3 + return f"{num:.1f} Y{suffix}" + + def compactify(lst): + c = "" + cntr = 0 + for l_ in lst: + if cntr > 5: + c += "\n " + cntr = 0 + c += l_ + ", " + cntr += 1 + return c[:-2] + + string = "" + field_keys = list(self.fields.data_vars.keys()) + + if len(field_keys) > 0: + string += "Fields:\n" + string += f" - data axes: {compactify(self.fields.indexes.keys())}\n" + string += f" - timesteps: {self.fields[field_keys[0]].shape[0]}\n" + string += f" - shape: {self.fields[field_keys[0]].shape[1:]}\n" + string += f" - quantities: {compactify(self.fields.data_vars.keys())}\n" + string += f" - total size: {sizeof_fmt(self.fields.nbytes)}\n" + else: + string += "Fields: empty\n" + + return string diff --git a/nt2/containers/particles.py b/nt2/containers/particles.py new file mode 100644 index 0000000..4440b9d --- /dev/null +++ b/nt2/containers/particles.py @@ -0,0 +1,178 @@ +import h5py +import numpy as np +import xarray as xr +from dask.array.core import from_array + + +from nt2.containers.container import Container +from nt2.containers.utils import _read_category_metadata_SingleFile + + +def _list_to_ragged(arr): + max_len = np.max([len(a) for a in arr]) + return map( + lambda a: np.concatenate([a, np.full(max_len - len(a), np.nan)]), + arr, + ) + + +def _read_species_SingleFile(first_step: int, file: h5py.File): + group = file[first_step] + if not isinstance(group, h5py.Group): + raise ValueError(f"Unexpected type {type(group)}") + species = np.unique( + [int(pq.split("_")[1]) for pq in group.keys() if pq.startswith("p")] + ) + return species + + +def _preload_particle_species_SingleFile( + s: int, + quantities: list[str], + coord_type: str, + outsteps: list[int], + times: list[float], + steps: list[int], + coord_replacements: dict[str, str], + file: h5py.File, +): + prtl_data = {} + for q in [ + f"X1_{s}", + f"X2_{s}", + f"X3_{s}", + f"U1_{s}", + f"U2_{s}", + f"U3_{s}", + f"W_{s}", + ]: + if q[0] in ["X", "U"]: + q_ = coord_replacements[q.split("_")[0]] + else: + q_ = q.split("_")[0] + if "p" + q not in quantities: + continue + if q not in prtl_data.keys(): + prtl_data[q_] = [] + for step_k in outsteps: + group = file[step_k] + if isinstance(group, h5py.Group): + if "p" + q in group.keys(): + prtl_data[q_].append(group["p" + q]) + else: + prtl_data[q_].append(np.full_like(prtl_data[q_][-1], np.nan)) + else: + raise ValueError(f"Unexpected type {type(file[step_k])}") + prtl_data[q_] = _list_to_ragged(prtl_data[q_]) + prtl_data[q_] = from_array(list(prtl_data[q_])) + prtl_data[q_] = xr.DataArray( + prtl_data[q_], + dims=["t", "id"], + name=q_, + coords={"t": times, "s": ("t", steps)}, + ) + if coord_type == "sph": + prtl_data["x"] = ( + prtl_data[coord_replacements["X1"]] + * np.sin(prtl_data[coord_replacements["X2"]]) + * np.cos(prtl_data[coord_replacements["X3"]]) + ) + prtl_data["y"] = ( + prtl_data[coord_replacements["X1"]] + * np.sin(prtl_data[coord_replacements["X2"]]) + * np.sin(prtl_data[coord_replacements["X3"]]) + ) + prtl_data["z"] = prtl_data[coord_replacements["X1"]] * np.cos( + prtl_data[coord_replacements["X2"]] + ) + return xr.Dataset(prtl_data) + + +class ParticleContainer(Container): + def __init__(self, **kwargs): + super(ParticleContainer, self).__init__(**kwargs) + PrtlDict = { + "cart": { + "X1": "x", + "X2": "y", + "X3": "z", + "U1": "ux", + "U2": "uy", + "U3": "uz", + }, + "sph": { + "X1": "r", + "X2": "θ" if self.configs["use_greek"] else "th", + "X3": "φ" if self.configs["use_greek"] else "ph", + "U1": "ur", + "U2": "uΘ" if self.configs["use_greek"] else "uth", + "U3": "uφ" if self.configs["use_greek"] else "uph", + }, + } + + if self.configs["single_file"]: + assert self.master_file is not None, "Master file not found" + self.metadata["particles"] = _read_category_metadata_SingleFile( + "p", self.master_file + ) + self._particles = {} + + if len(self.metadata["particles"]["outsteps"]) > 0: + if self.configs["single_file"]: + assert self.master_file is not None, "Master file not found" + species = _read_species_SingleFile( + self.metadata["particles"]["outsteps"][0], self.master_file + ) + for s in species: + self._particles[s] = _preload_particle_species_SingleFile( + s=s, + quantities=self.metadata["particles"]["quantities"], + coord_type=self.configs["coordinates"], + outsteps=self.metadata["particles"]["outsteps"], + times=self.metadata["particles"]["times"], + steps=self.metadata["particles"]["steps"], + coord_replacements=PrtlDict[self.configs["coordinates"]], + file=self.master_file, + ) + + @property + def particles(self): + return self._particles + + def print_particles(self) -> str: + def sizeof_fmt(num, suffix="B"): + for unit in ("", "K", "M", "G", "T", "P", "E", "Z"): + if abs(num) < 1e3: + return f"{num:3.1f} {unit}{suffix}" + num /= 1e3 + return f"{num:.1f} Y{suffix}" + + def compactify(lst): + c = "" + cntr = 0 + for l_ in lst: + if cntr > 5: + c += "\n " + cntr = 0 + c += l_ + ", " + cntr += 1 + return c[:-2] + + string = "" + if self.particles != {}: + species = [int(i) for i in self.particles.keys()] + string += "Particles:\n" + string += f" - species: {species}\n" + string += f" - data axes: {compactify(self.particles[species[0]].indexes.keys())}\n" + string += f" - timesteps: {self.particles[species[0]][list(self.particles[species[0]].data_vars.keys())[0]].shape[0]}\n" + string += f" - quantities: {compactify(self.particles[species[0]].data_vars.keys())}\n" + size = 0 + for s in species: + keys = list(self.particles[s].data_vars.keys()) + string += f" - species [{s}]:\n" + string += f" - number: {self.particles[s][keys[0]].shape[1]}\n" + size += self.particles[s].nbytes + string += f" - total size: {sizeof_fmt(size)}\n" + else: + string += "Particles: empty\n" + return string diff --git a/nt2/containers/spectra.py b/nt2/containers/spectra.py new file mode 100644 index 0000000..8825e9c --- /dev/null +++ b/nt2/containers/spectra.py @@ -0,0 +1,138 @@ +import h5py +import numpy as np +import xarray as xr +from dask.array.core import from_array +from dask.array.core import stack + +from nt2.containers.container import Container +from nt2.containers.utils import _read_category_metadata_SingleFile + + +def _read_species_SingleFile(first_step: int, file: h5py.File): + group = file[first_step] + if not isinstance(group, h5py.Group): + raise ValueError(f"Unexpected type {type(group)}") + species = np.unique( + [int(pq.split("_")[1]) for pq in group.keys() if pq.startswith("sN")] + ) + return species + + +def _read_spectra_bins_SingleFile(first_step: int, log_bins: bool, file: h5py.File): + group = file[first_step] + if not isinstance(group, h5py.Group): + raise ValueError(f"Unexpected type {type(group)}") + e_bins = group["sEbn"] + if not isinstance(e_bins, h5py.Dataset): + raise ValueError(f"Unexpected type {type(e_bins)}") + if log_bins: + e_bins = np.sqrt(e_bins[1:] * e_bins[:-1]) + else: + e_bins = (e_bins[1:] + e_bins[:-1]) / 2 + return e_bins + + +def _preload_spectra_SingleFile( + sp: int, + e_bins: np.ndarray, + outsteps: list[int], + times: list[float], + steps: list[int], + file: h5py.File, +): + dask_arrays = [] + for st in outsteps: + array = from_array(file[f"{st}/sN_{sp}"]) + dask_arrays.append(array) + + return xr.DataArray( + stack(dask_arrays, axis=0), + dims=["t", "e"], + name=f"n_{sp}", + coords={ + "t": times, + "s": ("t", steps), + "e": e_bins, + }, + ) + + +class SpectraContainer(Container): + def __init__(self, **kwargs): + super(SpectraContainer, self).__init__(**kwargs) + assert "single_file" in self.configs + assert "use_pickle" in self.configs + assert "use_greek" in self.configs + assert "path" in self.__dict__ + assert "metadata" in self.__dict__ + assert "mesh" in self.__dict__ + assert "attrs" in self.__dict__ + + if self.configs["single_file"]: + assert self.master_file is not None, "Master file not found" + self.metadata["spectra"] = _read_category_metadata_SingleFile( + "s", self.master_file + ) + self._spectra = xr.Dataset() + log_bins = self.attrs["output.spectra.log_bins"] + + if len(self.metadata["spectra"]["outsteps"]) > 0: + if self.configs["single_file"]: + assert self.master_file is not None, "Master file not found" + species = _read_species_SingleFile( + self.metadata["spectra"]["outsteps"][0], self.master_file + ) + else: + raise NotImplementedError("Multiple files not yet supported") + + e_bins = _read_spectra_bins_SingleFile( + self.metadata["spectra"]["outsteps"][0], log_bins, self.master_file + ) + + for sp in species: + self._spectra[f"n_{sp}"] = _preload_spectra_SingleFile( + sp, + e_bins, + self.metadata["spectra"]["outsteps"], + self.metadata["spectra"]["times"], + self.metadata["spectra"]["steps"], + self.master_file, + ) + + @property + def spectra(self): + return self._spectra + + def print_spectra(self) -> str: + def sizeof_fmt(num, suffix="B"): + for unit in ("", "K", "M", "G", "T", "P", "E", "Z"): + if abs(num) < 1e3: + return f"{num:3.1f} {unit}{suffix}" + num /= 1e3 + return f"{num:.1f} Y{suffix}" + + def compactify(lst): + c = "" + cntr = 0 + for l_ in lst: + if cntr > 5: + c += "\n " + cntr = 0 + c += l_ + ", " + cntr += 1 + return c[:-2] + + string = "" + spec_keys = list(self.spectra.data_vars.keys()) + + if len(spec_keys) > 0: + string += "Spectra:\n" + string += f" - data axes: {compactify(self.spectra.indexes.keys())}\n" + string += f" - timesteps: {self.spectra[spec_keys[0]].shape[0]}\n" + string += f" - # of bins: {self.spectra[spec_keys[0]].shape[1]}\n" + string += f" - quantities: {compactify(self.spectra.data_vars.keys())}\n" + string += f" - total size: {sizeof_fmt(self.spectra.nbytes)}\n" + else: + string += "Spectra: empty\n" + + return string diff --git a/nt2/containers/utils.py b/nt2/containers/utils.py new file mode 100644 index 0000000..d5a9718 --- /dev/null +++ b/nt2/containers/utils.py @@ -0,0 +1,38 @@ +import h5py +import numpy as np + + +def _read_category_metadata_SingleFile(prefix: str, file: h5py.File): + f_outsteps = [] + f_steps = [] + f_times = [] + f_quantities = None + for st in file: + group = file[st] + if isinstance(group, h5py.Group): + if any([k.startswith(prefix) for k in group if k is not None]): + if f_quantities is None: + f_quantities = [k for k in group.keys() if k.startswith(prefix)] + f_outsteps.append(st) + time_ds = group["Time"] + if isinstance(time_ds, h5py.Dataset): + f_times.append(time_ds[()]) + else: + raise ValueError(f"Unexpected type {type(time_ds)}") + step_ds = group["Step"] + if isinstance(step_ds, h5py.Dataset): + f_steps.append(int(step_ds[()])) + else: + raise ValueError(f"Unexpected type {type(step_ds)}") + + else: + raise ValueError(f"Unexpected type {type(file[st])}") + f_outsteps = sorted(f_outsteps, key=lambda x: int(x.replace("Step", ""))) + f_steps = sorted(f_steps) + f_times = np.array(sorted(f_times), dtype=np.float64) + return { + "quantities": f_quantities, + "outsteps": f_outsteps, + "steps": f_steps, + "times": f_times, + } diff --git a/nt2/plotters/__init__.py b/nt2/plotters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nt2/plot.py b/nt2/plotters/plot.py similarity index 94% rename from nt2/plot.py rename to nt2/plotters/plot.py index 69b12c4..7843fd1 100644 --- a/nt2/plot.py +++ b/nt2/plotters/plot.py @@ -2,7 +2,8 @@ def annotatePulsar( ax, data, rmax, rstar=1.1, ti=None, time=None, attrs={}, ax_props={}, star_props={} ): import numpy as np - import matplotlib as mpl + from matplotlib import lines + from matplotlib import patches if ti is None and time is None: raise ValueError("Must provide either ti or time") @@ -21,6 +22,7 @@ def annotatePulsar( "WARNING: No spinup time or spin period found, please specify explicitly as `attrs = {'psr_omega': ..., 'psr_spinup_time': ...}`" ) demo_rotation = False + phase = 0 else: phase = ( omega @@ -45,7 +47,7 @@ def annotatePulsar( for i in range(-int(rmax * 0.8) // 2 - 1, int(rmax * 0.8) // 2): if i != -1: ax.add_artist( - mpl.lines.Line2D( + lines.Line2D( [0, -0.1], [2 * (i + 1), 2 * (i + 1)], color=ax_props.get("color", "k"), @@ -85,7 +87,7 @@ def annotatePulsar( xs = np.concatenate([xs1, xs2[::-1]]) ys = np.concatenate([ys1, ys2[::-1]]) ax.add_artist( - mpl.patches.Polygon( + patches.Polygon( (rstar + 0.02) * np.array([xs, ys]).T, color=star_props.get("c1", "r"), lw=0, @@ -94,7 +96,7 @@ def annotatePulsar( ) ) ax.add_artist( - mpl.patches.Polygon( + patches.Polygon( (rstar + 0.02) * np.array([-xs, ys]).T, color=star_props.get("c2", "b"), lw=0, @@ -103,7 +105,7 @@ def annotatePulsar( ) ) ax.add_artist( - mpl.patches.Circle( + patches.Circle( (0, 0), rstar, color=star_props.get("color", "royalblue"), diff --git a/nt2/plotters/polarplot.py b/nt2/plotters/polarplot.py new file mode 100644 index 0000000..465872a --- /dev/null +++ b/nt2/plotters/polarplot.py @@ -0,0 +1,488 @@ +from dask.delayed import delayed +import xarray as xr +import numpy as np +from typing import Any + + +def DataIs2DPolar(ds): + return ("r" in ds.dims and ("θ" in ds.dims or "th" in ds.dims)) and len( + ds.dims + ) == 2 + + +def DipoleSampling(**kwargs): + """ + Returns an array of angles sampled from a dipole distribution. + + Parameters + ---------- + nth : int, optional + The number of angles to sample. Default is 30. + pole : float, optional + The fraction of the angles to sample from the poles. Default is 1/16. + + Returns + ------- + ndarray + An array of angles sampled from a dipole distribution. + """ + nth = kwargs.get("nth", 30) + pole = kwargs.get("pole", 1 / 16) + + nth_poles = int(nth * pole) + nth_equator = (nth - 2 * nth_poles) // 2 + return np.concatenate( + [ + np.linspace(0, np.pi * pole, nth_poles + 1)[1:], + np.linspace(np.pi * pole, np.pi / 2, nth_equator + 2)[1:-1], + np.linspace(np.pi * (1 - pole), np.pi, nth_poles + 1)[:-1], + ] + ) + + +def MonopoleSampling(**kwargs): + """ + Returns an array of angles sampled from a monopole distribution. + + Parameters + ---------- + nth : int, optional + The number of angles to sample. Default is 30. + + Returns + ------- + ndarray + An array of angles sampled from a monopole distribution. + """ + nth = kwargs.get("nth", 30) + + return np.linspace(0, np.pi, nth + 2)[1:-1] + + +@xr.register_dataset_accessor("polar") +class DatasetPolarPlotAccessor: + def __init__(self, xarray_obj): + self._obj = xarray_obj + + def pcolor(self, value, **kwargs): + assert "t" not in self._obj[value].dims, "Time must be specified" + assert DataIs2DPolar(self._obj), "Data must be 2D polar" + self._obj[value].polar.pcolor(**kwargs) + + def fieldplot( + self, + fr, + fth, + start_points=None, + sample=None, + invert_x=False, + invert_y=False, + **kwargs, + ): + """ + Plot field lines of a vector field defined by functions fr and fth. + + Parameters + ---------- + fr : string + Radial component of the vector field. + fth : string + Azimuthal component of the vector field. + start_points : array_like, optional + Starting points for the field lines. Either this or `sample` must be specified. + sample : dict, optional + Sampling template for generating starting points. Either this or `start_points` must be specified. + The template can be "dipole" or "monopole". The dict also contains the starting `radius`, + and the number of points in theta `nth` key. + invert_x : bool, optional + Whether to invert the x-axis. Default is False. + invert_y : bool, optional + Whether to invert the y-axis. Default is False. + **kwargs : + Additional keyword arguments passed to `fieldlines` and `ax.plot`. + + Raises + ------ + ValueError + If neither `start_points` nor `sample` are specified or if an unknown sampling template is given. + + Returns + ------- + None + + Examples + -------- + >>> ds.polar.fieldplot("Br", "Bth", sample={"template": "dipole", "nth": 30, "radius": 2.0}) + """ + import matplotlib.pyplot as plt + + if start_points is None and sample is None: + raise ValueError("Either start_points or sample must be specified") + elif start_points is None and sample is not None: + radius = sample.pop("radius", 1.5) + template = sample.pop("template", "dipole") + if template == "dipole": + start_points = [[radius, th] for th in DipoleSampling(**sample)] + elif template == "monopole": + start_points = [[radius, th] for th in MonopoleSampling(**sample)] + else: + raise ValueError("Unknown sampling template: " + template) + + fieldlines = self.fieldlines(fr, fth, start_points, **kwargs).compute() + ax = kwargs.pop("ax", plt.gca()) + for fieldline in fieldlines: + if invert_x: + fieldline[:, 0] = -fieldline[:, 0] + if invert_y: + fieldline[:, 1] = -fieldline[:, 1] + ax.plot(*fieldline.T, **kwargs) + + @delayed + def fieldlines(self, fr, fth, start_points, **kwargs): + """ + Compute field lines of a vector field defined by functions fr and fth. + + Parameters + ---------- + fr : string + Radial component of the vector field. + fth : string + Azimuthal component of the vector field. + start_points : array_like + Starting points for the field lines. + direction : str, optional + Direction to integrate in. Can be "both", "forward" or "backward". Default is "both". + stopWhen : callable, optional + Function that takes the current position and returns True if the integration should stop. Default is to never stop. + ds : float, optional + Integration step size. Default is 0.1. + maxsteps : int, optional + Maximum number of integration steps. Default is 1000. + + Returns + ------- + list + List of field lines. + + Examples + -------- + >>> ds.polar.fieldlines("Br", "Bth", [[2.0, np.pi / 4], [2.0, 3 * np.pi / 4]], stopWhen = lambda xy, rth: rth[0] > 5.0) + """ + + import numpy as np + from scipy.interpolate import RegularGridInterpolator + + assert "t" not in self._obj[fr].dims, "Time must be specified" + assert "t" not in self._obj[fth].dims, "Time must be specified" + assert DataIs2DPolar(self._obj), "Data must be 2D polar" + + useGreek = "θ" in self._obj.coords.keys() + + r, th = ( + self._obj.coords["r"].values, + self._obj.coords["θ" if useGreek else "th"].values, + ) + _, ths = np.meshgrid(r, th) + fxs = self._obj[fr] * np.sin(ths) + self._obj[fth] * np.cos(ths) + fys = self._obj[fr] * np.cos(ths) - self._obj[fth] * np.sin(ths) + + props: dict[str, Any] = { + "method": "nearest", + "bounds_error": False, + "fill_value": 0, + } + interpFx = RegularGridInterpolator((th, r), fxs.values, **props) + interpFy = RegularGridInterpolator((th, r), fys.values, **props) + return [ + self._fieldline(interpFx, interpFy, rth, **kwargs) for rth in start_points + ] + + def _fieldline(self, interp_fx, interp_fy, r_th_start, **kwargs): + import numpy as np + from copy import copy + + direction = kwargs.pop("direction", "both") + stopWhen = kwargs.pop("stopWhen", lambda _, __: False) + ds = kwargs.pop("ds", 0.1) + maxsteps = kwargs.pop("maxsteps", 1000) + + rmax = self._obj.r.max() + rmin = self._obj.r.min() + + def stop(xy, rth): + return ( + stopWhen(xy, rth) + or (rth[0] < rmin) + or (rth[0] > rmax) + or (rth[1] < 0) + or (rth[1] > np.pi) + ) + + def integrate(delta, counter): + r0, th0 = copy(r_th_start) + XY = np.array([r0 * np.sin(th0), r0 * np.cos(th0)]) + RTH = [r0, th0] + fieldline = np.array([XY]) + with np.errstate(divide="ignore", invalid="ignore"): + while range(counter, maxsteps): + x, y = XY + r = np.sqrt(x**2 + y**2) + th = np.arctan2(-y, x) + np.pi / 2 + RTH = [r, th] + vx = interp_fx((th, r))[()] + vy = interp_fy((th, r))[()] + vmag = np.sqrt(vx**2 + vy**2) + XY = XY + delta * np.array([vx, vy]) / vmag + if stop(XY, RTH) or np.isnan(XY).any() or np.isinf(XY).any(): + break + else: + fieldline = np.append(fieldline, [XY], axis=0) + return fieldline + + if direction == "forward": + return integrate(ds, 0) + elif direction == "backward": + return integrate(-ds, 0) + else: + cntr = 0 + f1 = integrate(ds, cntr) + f2 = integrate(-ds, cntr) + return np.append(f2[::-1], f1, axis=0) + + +@xr.register_dataarray_accessor("polar") +class PolarPlotAccessor: + def __init__(self, xarray_obj): + self._obj = xarray_obj + + def pcolor(self, **kwargs): + """ + Plots a pseudocolor plot of 2D polar data on a rectilinear projection. + + Parameters + ---------- + ax : Axes object, optional + The axes on which to plot. Default is the current axes. + cell_centered : bool, optional + Whether the data is cell-centered. Default is True. + cell_size : float, optional + If not cell_centered, defines the fraction of the cell to use for coloring. Default is 0.75. + cbar_size : str, optional + The size of the colorbar. Default is "5%". + cbar_pad : float, optional + The padding between the colorbar and the plot. Default is 0.05. + cbar_position : str, optional + The position of the colorbar. Default is "right". + cbar_ticksize : int or float, optional + The size of the ticks on the colorbar. Default is None. + title : str, optional + The title of the plot. Default is None. + invert_x : bool, optional + Whether to invert the x-axis. Default is False. + invert_y : bool, optional + Whether to invert the y-axis. Default is False. + ylabel : str, optional + The label for the y-axis. Default is "y". + xlabel : str, optional + The label for the x-axis. Default is "x". + label : str, optional + The label for the plot. Default is None. + + Returns + ------- + matplotlib.collections.Collection + The pseudocolor plot. + + Raises + ------ + AssertionError + If `ax` is a polar projection or if time is not specified or if data is not 2D polar. + + Notes + ----- + Additional keyword arguments are passed to `pcolormesh`. + """ + + import matplotlib.pyplot as plt + from matplotlib import colors + from matplotlib import tri + import matplotlib as mpl + from mpl_toolkits.axes_grid1 import make_axes_locatable + + useGreek = "θ" in self._obj.coords.keys() + + ax = kwargs.pop("ax", plt.gca()) + cbar_size = kwargs.pop("cbar_size", "5%") + cbar_pad = kwargs.pop("cbar_pad", 0.05) + cbar_pos = kwargs.pop("cbar_position", "right") + cbar_orientation = ( + "vertical" if cbar_pos == "right" or cbar_pos == "left" else "horizontal" + ) + cbar_ticksize = kwargs.pop("cbar_ticksize", None) + title = kwargs.pop("title", None) + invert_x = kwargs.pop("invert_x", False) + invert_y = kwargs.pop("invert_y", False) + ylabel = kwargs.pop("ylabel", "y") + xlabel = kwargs.pop("xlabel", "x") + label = kwargs.pop("label", None) + cell_centered = kwargs.pop("cell_centered", True) + cell_size = kwargs.pop("cell_size", 0.75) + + assert ax.name != "polar", "`ax` must be a rectilinear projection" + assert "t" not in self._obj.dims, "Time must be specified" + assert DataIs2DPolar(self._obj), "Data must be 2D polar" + ax.grid(False) + if type(kwargs.get("norm", None)) is colors.LogNorm: + cm = kwargs.get("cmap", "viridis") + cm = mpl.colormaps[cm] + cm.set_bad(cm(0)) + kwargs["cmap"] = cm + + vals = self._obj.values.flatten() + vals = np.concatenate((vals, vals)) + if not cell_centered: + drs = self._obj.coords["r_2"] - self._obj.coords["r_1"] + dths = ( + self._obj.coords["θ_2" if useGreek else "th_2"] + - self._obj.coords["θ_1" if useGreek else "th_1"] + ) + r1s = self._obj.coords["r_1"] - drs * cell_size / 2 + r2s = self._obj.coords["r_1"] + drs * cell_size / 2 + th1s = ( + self._obj.coords["θ_1" if useGreek else "th_1"] - dths * cell_size / 2 + ) + th2s = ( + self._obj.coords["θ_1" if useGreek else "th_1"] + dths * cell_size / 2 + ) + rs = np.ravel(np.column_stack((r1s, r2s))) + ths = np.ravel(np.column_stack((th1s, th2s))) + nr = len(rs) + nth = len(ths) + rs, ths = np.meshgrid(rs, ths) + rs = rs.flatten() + ths = ths.flatten() + points_1 = np.arange(nth * nr).reshape(nth, -1)[:-1:2, :-1:2].flatten() + points_2 = np.arange(nth * nr).reshape(nth, -1)[:-1:2, 1::2].flatten() + points_3 = np.arange(nth * nr).reshape(nth, -1)[1::2, 1::2].flatten() + points_4 = np.arange(nth * nr).reshape(nth, -1)[1::2, :-1:2].flatten() + + else: + rs = np.append(self._obj.coords["r_1"], self._obj.coords["r_2"][-1]) + ths = np.append( + self._obj.coords["θ_1" if useGreek else "th_1"], + self._obj.coords["θ_2" if useGreek else "th_2"][-1], + ) + nr = len(rs) + nth = len(ths) + rs, ths = np.meshgrid(rs, ths) + rs = rs.flatten() + ths = ths.flatten() + points_1 = np.arange(nth * nr).reshape(nth, -1)[:-1, :-1].flatten() + points_2 = np.arange(nth * nr).reshape(nth, -1)[:-1, 1:].flatten() + points_3 = np.arange(nth * nr).reshape(nth, -1)[1:, 1:].flatten() + points_4 = np.arange(nth * nr).reshape(nth, -1)[1:, :-1].flatten() + x, y = rs * np.sin(ths), rs * np.cos(ths) + if invert_x: + x = -x + if invert_y: + y = -y + triang = tri.Triangulation( + x, + y, + triangles=np.concatenate( + [ + np.array([points_1, points_2, points_3]).T, + np.array([points_1, points_3, points_4]).T, + ], + axis=0, + ), + ) + ax.set( + aspect="equal", + xlabel=xlabel, + ylabel=ylabel, + ) + im = ax.tripcolor(triang, vals, rasterized=True, shading="flat", **kwargs) + if cbar_pos is not None: + divider = make_axes_locatable(ax) + cax = divider.append_axes(cbar_pos, size=cbar_size, pad=cbar_pad) + _ = plt.colorbar( + im, + cax=cax, + label=self._obj.name if label is None else label, + orientation=cbar_orientation, + ) + if cbar_orientation == "vertical": + axis = cax.yaxis + else: + axis = cax.xaxis + axis.set_label_position(cbar_pos) + axis.set_ticks_position(cbar_pos) + if cbar_ticksize is not None: + cax.tick_params("both", labelsize=cbar_ticksize) + ax.set_title( + f"t={self._obj.coords['t'].values[()]:.2f}" if title is None else title + ) + return im + + def contour(self, **kwargs): + """ + Plots a pseudocolor plot of 2D polar data on a rectilinear projection. + + Parameters + ---------- + ax : Axes object, optional + The axes on which to plot. Default is the current axes. + invert_x : bool, optional + Whether to invert the x-axis. Default is False. + invert_y : bool, optional + Whether to invert the y-axis. Default is False. + + Returns + ------- + matplotlib.contour.QuadContourSet + The contour plot. + + Raises + ------ + AssertionError + If `ax` is a polar projection or if time is not specified or if data is not 2D polar. + + Notes + ----- + Additional keyword arguments are passed to `contour`. + """ + + import warnings + import matplotlib.pyplot as plt + + useGreek = "θ" in self._obj.coords.keys() + + ax = kwargs.pop("ax", plt.gca()) + title = kwargs.pop("title", None) + invert_x = kwargs.pop("invert_x", False) + invert_y = kwargs.pop("invert_y", False) + + assert ax.name != "polar", "`ax` must be a rectilinear projection" + assert "t" not in self._obj.dims, "Time must be specified" + assert DataIs2DPolar(self._obj), "Data must be 2D polar" + ax.grid(False) + r, th = np.meshgrid( + self._obj.coords["r"], self._obj.coords["θ" if useGreek else "th"] + ) + x, y = r * np.sin(th), r * np.cos(th) + if invert_x: + x = -x + if invert_y: + y = -y + ax.set( + aspect="equal", + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + im = ax.contour(x, y, self._obj.values, **kwargs) + + ax.set_title( + f"t={self._obj.coords['t'].values[()]:.2f}" if title is None else title + ) + return im diff --git a/nt2/read.py b/nt2/read.py index dd9a427..5c6af32 100644 --- a/nt2/read.py +++ b/nt2/read.py @@ -1,977 +1,30 @@ -import xarray as xr +from nt2.containers.fields import FieldsContainer +from nt2.containers.particles import ParticleContainer +from nt2.containers.spectra import SpectraContainer -import nt2.export as exp -useGreek = False -usePickle = False - - -def configure(use_greek=False, use_pickle=False): - global useGreek - global usePickle - useGreek = use_greek - usePickle = use_pickle - - -def DataIs2DPolar(ds): - return ("r" in ds.dims and ("θ" in ds.dims or "th" in ds.dims)) and len( - ds.dims - ) == 2 - - -def DipoleSampling(**kwargs): - """ - Returns an array of angles sampled from a dipole distribution. - - Parameters - ---------- - nth : int, optional - The number of angles to sample. Default is 30. - pole : float, optional - The fraction of the angles to sample from the poles. Default is 1/16. - - Returns - ------- - ndarray - An array of angles sampled from a dipole distribution. - """ - import numpy as np - - nth = kwargs.get("nth", 30) - pole = kwargs.get("pole", 1 / 16) - - nth_poles = int(nth * pole) - nth_equator = (nth - 2 * nth_poles) // 2 - return np.concatenate( - [ - np.linspace(0, np.pi * pole, nth_poles + 1)[1:], - np.linspace(np.pi * pole, np.pi / 2, nth_equator + 2)[1:-1], - np.linspace(np.pi * (1 - pole), np.pi, nth_poles + 1)[:-1], - ] - ) - - -def MonopoleSampling(**kwargs): +class Data(FieldsContainer, ParticleContainer, SpectraContainer): """ - Returns an array of angles sampled from a monopole distribution. - - Parameters - ---------- - nth : int, optional - The number of angles to sample. Default is 30. - - Returns - ------- - ndarray - An array of angles sampled from a monopole distribution. + A class to load Entity data and store it as a lazily loaded xarray Dataset. """ - import numpy as np - - nth = kwargs.get("nth", 30) - - return np.linspace(0, np.pi, nth + 2)[1:-1] - - -@xr.register_dataset_accessor("polar") -class DatasetPolarPlotAccessor: - import dask - - def __init__(self, xarray_obj): - self._obj = xarray_obj - - def pcolor(self, value, **kwargs): - assert "t" not in self._obj[value].dims, "Time must be specified" - assert DataIs2DPolar(self._obj), "Data must be 2D polar" - self._obj[value].polar.pcolor(**kwargs) - - def fieldplot( - self, - fr, - fth, - start_points=None, - sample=None, - invert_x=False, - invert_y=False, - **kwargs, - ): - """ - Plot field lines of a vector field defined by functions fr and fth. - - Parameters - ---------- - fr : string - Radial component of the vector field. - fth : string - Azimuthal component of the vector field. - start_points : array_like, optional - Starting points for the field lines. Either this or `sample` must be specified. - sample : dict, optional - Sampling template for generating starting points. Either this or `start_points` must be specified. - The template can be "dipole" or "monopole". The dict also contains the starting `radius`, - and the number of points in theta `nth` key. - invert_x : bool, optional - Whether to invert the x-axis. Default is False. - invert_y : bool, optional - Whether to invert the y-axis. Default is False. - **kwargs : - Additional keyword arguments passed to `fieldlines` and `ax.plot`. - - Raises - ------ - ValueError - If neither `start_points` nor `sample` are specified or if an unknown sampling template is given. - - Returns - ------- - None - - Examples - -------- - >>> ds.polar.fieldplot("Br", "Bth", sample={"template": "dipole", "nth": 30, "radius": 2.0}) - """ - import matplotlib.pyplot as plt - - if start_points is None and sample is None: - raise ValueError("Either start_points or sample must be specified") - elif start_points is None: - radius = sample.pop("radius", 1.5) - template = sample.pop("template", "dipole") - if template == "dipole": - start_points = [[radius, th] for th in DipoleSampling(**sample)] - elif template == "monopole": - start_points = [[radius, th] for th in MonopoleSampling(**sample)] - else: - raise ValueError("Unknown sampling template: " + template) - - fieldlines = self.fieldlines(fr, fth, start_points, **kwargs).compute() - ax = kwargs.pop("ax", plt.gca()) - for fieldline in fieldlines: - if invert_x: - fieldline[:, 0] = -fieldline[:, 0] - if invert_y: - fieldline[:, 1] = -fieldline[:, 1] - ax.plot(*fieldline.T, **kwargs) - - @dask.delayed - def fieldlines(self, fr, fth, start_points, **kwargs): - """ - Compute field lines of a vector field defined by functions fr and fth. - - Parameters - ---------- - fr : string - Radial component of the vector field. - fth : string - Azimuthal component of the vector field. - start_points : array_like - Starting points for the field lines. - direction : str, optional - Direction to integrate in. Can be "both", "forward" or "backward". Default is "both". - stopWhen : callable, optional - Function that takes the current position and returns True if the integration should stop. Default is to never stop. - ds : float, optional - Integration step size. Default is 0.1. - maxsteps : int, optional - Maximum number of integration steps. Default is 1000. - - Returns - ------- - list - List of field lines. - Examples - -------- - >>> ds.polar.fieldlines("Br", "Bth", [[2.0, np.pi / 4], [2.0, 3 * np.pi / 4]], stopWhen = lambda xy, rth: rth[0] > 5.0) - """ + def __init__(self, **kwargs): + super(Data, self).__init__(**kwargs) - import numpy as np - from scipy.interpolate import RegularGridInterpolator - - assert "t" not in self._obj[fr].dims, "Time must be specified" - assert "t" not in self._obj[fth].dims, "Time must be specified" - assert DataIs2DPolar(self._obj), "Data must be 2D polar" - - r, th = ( - self._obj.coords["r"].values, - self._obj.coords["θ" if useGreek else "th"].values, - ) - _, ths = np.meshgrid(r, th) - fxs = self._obj[fr] * np.sin(ths) + self._obj[fth] * np.cos(ths) - fys = self._obj[fr] * np.cos(ths) - self._obj[fth] * np.sin(ths) - - props = dict(method="nearest", bounds_error=False, fill_value=0) - interpFx = RegularGridInterpolator((th, r), fxs.values, **props) - interpFy = RegularGridInterpolator((th, r), fys.values, **props) - return [ - self._fieldline(interpFx, interpFy, rth, **kwargs) for rth in start_points - ] - - def _fieldline(self, interp_fx, interp_fy, r_th_start, **kwargs): - import numpy as np - from copy import copy - - direction = kwargs.pop("direction", "both") - stopWhen = kwargs.pop("stopWhen", lambda xy, rth: False) - ds = kwargs.pop("ds", 0.1) - maxsteps = kwargs.pop("maxsteps", 1000) - - rmax = self._obj.r.max() - rmin = self._obj.r.min() - - stop = ( - lambda xy, rth: stopWhen(xy, rth) - or (rth[0] < rmin) - or (rth[0] > rmax) - or (rth[1] < 0) - or (rth[1] > np.pi) - ) - - def integrate(delta, counter): - r0, th0 = copy(r_th_start) - XY = np.array([r0 * np.sin(th0), r0 * np.cos(th0)]) - RTH = [r0, th0] - fieldline = np.array([XY]) - with np.errstate(divide="ignore", invalid="ignore"): - while range(counter, maxsteps): - x, y = XY - r = np.sqrt(x**2 + y**2) - th = np.arctan2(-y, x) + np.pi / 2 - RTH = [r, th] - vx = interp_fx((th, r))[()] - vy = interp_fy((th, r))[()] - vmag = np.sqrt(vx**2 + vy**2) - XY = XY + delta * np.array([vx, vy]) / vmag - if stop(XY, RTH) or np.isnan(XY).any() or np.isinf(XY).any(): - break - else: - fieldline = np.append(fieldline, [XY], axis=0) - return fieldline - - if direction == "forward": - return integrate(ds, 0) - elif direction == "backward": - return integrate(-ds, 0) - else: - cntr = 0 - f1 = integrate(ds, cntr) - f2 = integrate(-ds, cntr) - return np.append(f2[::-1], f1, axis=0) - - -@xr.register_dataarray_accessor("polar") -class PolarPlotAccessor: - def __init__(self, xarray_obj): - self._obj = xarray_obj - - def pcolor(self, **kwargs): - """ - Plots a pseudocolor plot of 2D polar data on a rectilinear projection. - - Parameters - ---------- - ax : Axes object, optional - The axes on which to plot. Default is the current axes. - cell_centered : bool, optional - Whether the data is cell-centered. Default is True. - cell_size : float, optional - If not cell_centered, defines the fraction of the cell to use for coloring. Default is 0.75. - cbar_size : str, optional - The size of the colorbar. Default is "5%". - cbar_pad : float, optional - The padding between the colorbar and the plot. Default is 0.05. - cbar_position : str, optional - The position of the colorbar. Default is "right". - cbar_ticksize : int or float, optional - The size of the ticks on the colorbar. Default is None. - title : str, optional - The title of the plot. Default is None. - invert_x : bool, optional - Whether to invert the x-axis. Default is False. - invert_y : bool, optional - Whether to invert the y-axis. Default is False. - ylabel : str, optional - The label for the y-axis. Default is "y". - xlabel : str, optional - The label for the x-axis. Default is "x". - label : str, optional - The label for the plot. Default is None. - - Returns - ------- - matplotlib.collections.Collection - The pseudocolor plot. - - Raises - ------ - AssertionError - If `ax` is a polar projection or if time is not specified or if data is not 2D polar. - - Notes - ----- - Additional keyword arguments are passed to `pcolormesh`. - """ - - import numpy as np - import matplotlib.pyplot as plt - import matplotlib as mpl - from mpl_toolkits.axes_grid1 import make_axes_locatable - - ax = kwargs.pop("ax", plt.gca()) - cbar_size = kwargs.pop("cbar_size", "5%") - cbar_pad = kwargs.pop("cbar_pad", 0.05) - cbar_pos = kwargs.pop("cbar_position", "right") - cbar_orientation = ( - "vertical" if cbar_pos == "right" or cbar_pos == "left" else "horizontal" - ) - cbar_ticksize = kwargs.pop("cbar_ticksize", None) - title = kwargs.pop("title", None) - invert_x = kwargs.pop("invert_x", False) - invert_y = kwargs.pop("invert_y", False) - ylabel = kwargs.pop("ylabel", "y") - xlabel = kwargs.pop("xlabel", "x") - label = kwargs.pop("label", None) - cell_centered = kwargs.pop("cell_centered", True) - cell_size = kwargs.pop("cell_size", 0.75) - - assert ax.name != "polar", "`ax` must be a rectilinear projection" - assert "t" not in self._obj.dims, "Time must be specified" - assert DataIs2DPolar(self._obj), "Data must be 2D polar" - ax.grid(False) - if type(kwargs.get("norm", None)) == mpl.colors.LogNorm: - cm = kwargs.get("cmap", "viridis") - cm = mpl.colormaps[cm] - cm.set_bad(cm(0)) - kwargs["cmap"] = cm - - vals = self._obj.values.flatten() - vals = np.concatenate((vals, vals)) - if not cell_centered: - drs = self._obj.coords["r_2"] - self._obj.coords["r_1"] - dths = ( - self._obj.coords["θ_2" if useGreek else "th_2"] - - self._obj.coords["θ_1" if useGreek else "th_1"] - ) - r1s = self._obj.coords["r_1"] - drs * cell_size / 2 - r2s = self._obj.coords["r_1"] + drs * cell_size / 2 - th1s = ( - self._obj.coords["θ_1" if useGreek else "th_1"] - dths * cell_size / 2 - ) - th2s = ( - self._obj.coords["θ_1" if useGreek else "th_1"] + dths * cell_size / 2 - ) - rs = np.ravel(np.column_stack((r1s, r2s))) - ths = np.ravel(np.column_stack((th1s, th2s))) - nr = len(rs) - nth = len(ths) - rs, ths = np.meshgrid(rs, ths) - rs = rs.flatten() - ths = ths.flatten() - points_1 = np.arange(nth * nr).reshape(nth, -1)[:-1:2, :-1:2].flatten() - points_2 = np.arange(nth * nr).reshape(nth, -1)[:-1:2, 1::2].flatten() - points_3 = np.arange(nth * nr).reshape(nth, -1)[1::2, 1::2].flatten() - points_4 = np.arange(nth * nr).reshape(nth, -1)[1::2, :-1:2].flatten() - - else: - rs = np.append(self._obj.coords["r_1"], self._obj.coords["r_2"][-1]) - ths = np.append( - self._obj.coords["θ_1" if useGreek else "th_1"], - self._obj.coords["θ_2" if useGreek else "th_2"][-1], - ) - nr = len(rs) - nth = len(ths) - rs, ths = np.meshgrid(rs, ths) - rs = rs.flatten() - ths = ths.flatten() - points_1 = np.arange(nth * nr).reshape(nth, -1)[:-1, :-1].flatten() - points_2 = np.arange(nth * nr).reshape(nth, -1)[:-1, 1:].flatten() - points_3 = np.arange(nth * nr).reshape(nth, -1)[1:, 1:].flatten() - points_4 = np.arange(nth * nr).reshape(nth, -1)[1:, :-1].flatten() - x, y = rs * np.sin(ths), rs * np.cos(ths) - if invert_x: - x = -x - if invert_y: - y = -y - triang = mpl.tri.Triangulation( - x, - y, - triangles=np.concatenate( - [ - np.array([points_1, points_2, points_3]).T, - np.array([points_1, points_3, points_4]).T, - ], - axis=0, - ), + def __repr__(self) -> str: + return ( + self.print_container() + + "\n" + + self.print_fields() + + "\n" + + self.print_particles() + + "\n" + + self.print_spectra() ) - ax.set( - aspect="equal", - xlabel=xlabel, - ylabel=ylabel, - ) - im = ax.tripcolor(triang, vals, rasterized=True, shading="flat", **kwargs) - if cbar_pos is not None: - divider = make_axes_locatable(ax) - cax = divider.append_axes(cbar_pos, size=cbar_size, pad=cbar_pad) - _ = plt.colorbar( - im, - cax=cax, - label=self._obj.name if label is None else label, - orientation=cbar_orientation, - ) - if cbar_orientation == "vertical": - axis = cax.yaxis - else: - axis = cax.xaxis - axis.set_label_position(cbar_pos) - axis.set_ticks_position(cbar_pos) - if cbar_ticksize is not None: - cax.tick_params("both", labelsize=cbar_ticksize) - ax.set_title( - f"t={self._obj.coords['t'].values[()]:.2f}" if title is None else title - ) - return im - - def contour(self, **kwargs): - """ - Plots a pseudocolor plot of 2D polar data on a rectilinear projection. - - Parameters - ---------- - ax : Axes object, optional - The axes on which to plot. Default is the current axes. - invert_x : bool, optional - Whether to invert the x-axis. Default is False. - invert_y : bool, optional - Whether to invert the y-axis. Default is False. - - Returns - ------- - matplotlib.contour.QuadContourSet - The contour plot. - - Raises - ------ - AssertionError - If `ax` is a polar projection or if time is not specified or if data is not 2D polar. - - Notes - ----- - Additional keyword arguments are passed to `contour`. - """ - - import warnings - import numpy as np - import matplotlib.pyplot as plt - import matplotlib as mpl - from mpl_toolkits.axes_grid1 import make_axes_locatable - ax = kwargs.pop("ax", plt.gca()) - title = kwargs.pop("title", None) - invert_x = kwargs.pop("invert_x", False) - invert_y = kwargs.pop("invert_y", False) - - assert ax.name != "polar", "`ax` must be a rectilinear projection" - assert "t" not in self._obj.dims, "Time must be specified" - assert DataIs2DPolar(self._obj), "Data must be 2D polar" - ax.grid(False) - r, th = np.meshgrid( - self._obj.coords["r"], self._obj.coords["θ" if useGreek else "th"] - ) - x, y = r * np.sin(th), r * np.cos(th) - if invert_x: - x = -x - if invert_y: - y = -y - ax.set( - aspect="equal", - ) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - im = ax.contour(x, y, self._obj.values, **kwargs) - - return im - - -class Metric: - def __init__(self, base): - self.base = base - - -class MinkowskiMetric(Metric): - def __init__(self): - super().__init__("minkowski") - - def sqrt_h(self, **coords): - return 1 - - def h_11(self, **coords): - return 1 - - def h_22(self, **coords): - return 1 - - def h_33(self, **coords): - return 1 - - -class SphericalMetric(Metric): - def __init__(self): - super().__init__("spherical") - - def sqrt_h(self, r, th): - import numpy as np - - return r**2 * np.sin(th) - - def h_11(self, r, th): - return 1 - - def h_22(self, r, th): - return r**2 - - def h_33(self, r, th): - import numpy as np - - return r**2 * np.sin(th) ** 2 - - -class Data: - """ - A class to load data from the Entity single-HDF5 file and store it as a lazily loaded xarray Dataset. - - Parameters - ---------- - fname : str - The name of the HDF5 file to read. - - Attributes - ---------- - fname : str - The name of the HDF5 file. - file : h5py.File - The HDF5 file object. - dataset : xr.Dataset - The xarray Dataset containing the loaded data. - particles: list - The list of particle species in the simulation. Each element is an Xarray Dataset. - - Methods - ------- - __del__() - Closes the HDF5 file. - __getattr__(name) - Gets an attribute from the xarray Dataset. - __getitem__(name) - Gets an item from the xarray Dataset. - - Examples - -------- - >>> import nt2.read as nt2r - >>> data = nt2r.Data("Sim.h5") - >>> data.Bx.sel(t=10.0, method="nearest").plot() - """ - - def __init__(self, fname): - if usePickle: - import h5pickle as h5py - else: - import h5py - import dask.array as da - from functools import reduce - import numpy as np - - QuantityDict = { - "Ttt": "E", - "Ttx": "Px", - "Tty": "Py", - "Ttz": "Pz", - } - CoordinateDict = { - "cart": {"x": "x", "y": "y", "z": "z", "1": "x", "2": "y", "3": "z"}, - "sph": { - "r": "r", - "theta": "θ" if useGreek else "th", - "phi": "φ" if useGreek else "ph", - "1": "r", - "2": "θ" if useGreek else "th", - "3": "φ" if useGreek else "ph", - }, - } - PrtlDict = { - "cart": { - "X1": "x", - "X2": "y", - "X3": "z", - "U1": "ux", - "U2": "uy", - "U3": "uz", - }, - "sph": { - "X1": "r", - "X2": "θ" if useGreek else "th", - "X3": "φ" if useGreek else "ph", - "U1": "ur", - "U2": "uΘ" if useGreek else "uth", - "U3": "uφ" if useGreek else "uph", - }, - } - self.fname = fname - try: - self.file = h5py.File(self.fname, "r") - except OSError: - raise OSError(f"Could not open file {self.fname}") - ngh = int(self.file.attrs["NGhosts"]) - layout = "right" if self.file.attrs["LayoutRight"] == 1 else "left" - dimension = int(self.file.attrs["Dimension"]) - coordinates = self.file.attrs["Coordinates"].decode("UTF-8") - if coordinates == "qsph": - coordinates = "sph" - if coordinates == "sph": - self.metric = SphericalMetric() - else: - self.metric = MinkowskiMetric() - coords = list(CoordinateDict[coordinates].values())[::-1][-dimension:] - - for s in self.file.keys(): - if any([k.startswith("X") for k in self.file[s].keys()]): - # cell-centered coords - cc_coords = { - c: self.file[s][f"X{i+1}"] for i, c in enumerate(coords[::-1]) - } - # cell edges - cell_1 = { - f"{c}_1": ( - c, - self.file[s][f"X{i+1}e"][:-1], - ) - for i, c in enumerate(coords[::-1]) - } - cell_2 = { - f"{c}_2": ( - c, - self.file[s][f"X{i+1}e"][1:], - ) - for i, c in enumerate(coords[::-1]) - } - break - - if dimension == 1: - noghosts = slice(ngh, -ngh) if ngh > 0 else slice(None) - elif dimension == 2: - noghosts = (slice(ngh, -ngh), slice(ngh, -ngh)) if ngh > 0 else slice(None) - elif dimension == 3: - noghosts = ( - (slice(ngh, -ngh), slice(ngh, -ngh), slice(ngh, -ngh)) - if ngh > 0 - else slice(None) - ) - - self.dataset = xr.Dataset() - - # -------------------------------- load fields ------------------------------- # - fields = None - f_outsteps = [] - f_steps = [] - f_times = [] - for s in self.file.keys(): - if any([k.startswith("f") for k in self.file[s].keys()]): - if fields is None: - fields = [k for k in self.file[s].keys() if k.startswith("f")] - f_outsteps.append(s) - f_times.append(self.file[s]["Time"][()]) - f_steps.append(self.file[s]["Step"][()]) - - f_outsteps = sorted(f_outsteps, key=lambda x: int(x.replace("Step", ""))) - f_steps = sorted(f_steps) - f_times = np.array(sorted(f_times), dtype=np.float64) - - for k in self.file.attrs.keys(): - if ( - type(self.file.attrs[k]) == bytes - or type(self.file.attrs[k]) == np.bytes_ - ): - self.dataset.attrs[k] = self.file.attrs[k].decode("UTF-8") - else: - self.dataset.attrs[k] = self.file.attrs[k] - - for k in fields: - dask_arrays = [] - for s in f_outsteps: - array = da.from_array( - np.transpose(self.file[f"{s}/{k}"]) - if layout == "right" - else self.file[f"{s}/{k}"] - ) - dask_arrays.append(array[noghosts]) - - k_ = reduce( - lambda x, y: ( - x.replace(*y) - if "_" not in x - else "_".join([x.split("_")[0].replace(*y)] + x.split("_")[1:]) - ), - [k, *list(CoordinateDict[coordinates].items())], - ) - k_ = reduce( - lambda x, y: x.replace(*y), - [k_, *list(QuantityDict.items())], - )[1:] - x = xr.DataArray( - da.stack(dask_arrays, axis=0), - dims=["t", *coords], - name=k_, - coords={ - "t": f_times, - "s": ("t", f_steps), - **cc_coords, - **cell_1, - **cell_2, - }, - ) - self.dataset[k_] = x - - # ------------------------------ load particles ------------------------------ # - particles = None - p_outsteps = [] - p_steps = [] - p_times = [] - for s in self.file.keys(): - if any([k.startswith("p") for k in self.file[s].keys()]): - if particles is None: - particles = [k for k in self.file[s].keys() if k.startswith("p")] - p_outsteps.append(s) - p_times.append(self.file[s]["Time"][()]) - p_steps.append(self.file[s]["Step"][()]) - - p_outsteps = sorted(p_outsteps, key=lambda x: int(x.replace("Step", ""))) - p_steps = sorted(p_steps) - p_times = np.array(sorted(p_times), dtype=np.float64) - - self._particles = {} - - if len(p_outsteps) > 0: - species = np.unique( - [ - int(pq.split("_")[1]) - for pq in self.file[p_outsteps[0]].keys() - if pq.startswith("p") - ] - ) - - def list_to_ragged(arr): - max_len = np.max([len(a) for a in arr]) - return map( - lambda a: np.concatenate([a, np.full(max_len - len(a), np.nan)]), - arr, - ) - - for s in species: - prtl_data = {} - for q in [ - f"X1_{s}", - f"X2_{s}", - f"X3_{s}", - f"U1_{s}", - f"U2_{s}", - f"U3_{s}", - f"W_{s}", - ]: - if q[0] in ["X", "U"]: - q_ = PrtlDict[coordinates][q.split("_")[0]] - else: - q_ = q.split("_")[0] - if "p" + q not in particles: - continue - if q not in prtl_data.keys(): - prtl_data[q_] = [] - for step_k in p_outsteps: - if "p" + q in self.file[step_k].keys(): - prtl_data[q_].append(self.file[step_k]["p" + q]) - else: - prtl_data[q_].append( - np.full_like(prtl_data[q_][-1], np.nan) - ) - prtl_data[q_] = list_to_ragged(prtl_data[q_]) - prtl_data[q_] = da.from_array(list(prtl_data[q_])) - prtl_data[q_] = xr.DataArray( - prtl_data[q_], - dims=["t", "id"], - name=q_, - coords={"t": p_times, "s": ("t", p_steps)}, - ) - if coordinates == "sph": - prtl_data["x"] = ( - prtl_data[PrtlDict[coordinates]["X1"]] - * np.sin(prtl_data[PrtlDict[coordinates]["X2"]]) - * np.cos(prtl_data[PrtlDict[coordinates]["X3"]]) - ) - prtl_data["y"] = ( - prtl_data[PrtlDict[coordinates]["X1"]] - * np.sin(prtl_data[PrtlDict[coordinates]["X2"]]) - * np.sin(prtl_data[PrtlDict[coordinates]["X3"]]) - ) - prtl_data["z"] = prtl_data[PrtlDict[coordinates]["X1"]] * np.cos( - prtl_data[PrtlDict[coordinates]["X2"]] - ) - self._particles[s] = xr.Dataset(prtl_data) - - # ------------------------------- load spectra ------------------------------- # - spectra = None - s_outsteps = [] - s_steps = [] - s_times = [] - for s in self.file.keys(): - if any([k.startswith("s") for k in self.file[s].keys()]): - if spectra is None: - spectra = [k for k in self.file[s].keys() if k.startswith("s")] - s_outsteps.append(s) - s_times.append(self.file[s]["Time"][()]) - s_steps.append(self.file[s]["Step"][()]) - - s_outsteps = sorted(s_outsteps, key=lambda x: int(x.replace("Step", ""))) - s_steps = sorted(s_steps) - s_times = np.array(sorted(s_times), dtype=np.float64) - - self._spectra = xr.Dataset() - log_bins = self.file.attrs["output.spectra.log_bins"] - - if len(s_outsteps) > 0: - species = np.unique( - [ - int(pq.split("_")[1]) - for pq in self.file[s_outsteps[0]].keys() - if pq.startswith("sN") - ] - ) - e_bins = self.file[s_outsteps[0]]["sEbn"] - if log_bins: - e_bins = np.sqrt(e_bins[1:] * e_bins[:-1]) - else: - e_bins = (e_bins[1:] + e_bins[:-1]) / 2 - - for sp in species: - dask_arrays = [] - for st in s_outsteps: - array = da.from_array(self.file[f"{st}/sN_{sp}"]) - dask_arrays.append(array) - - x = xr.DataArray( - da.stack(dask_arrays, axis=0), - dims=["t", "e"], - name=f"n_{sp}", - coords={ - "t": s_times, - "s": ("t", s_steps), - "e": e_bins, - }, - ) - self._spectra[f"n_{sp}"] = x + def __str__(self) -> str: + return self.__repr__() def __del__(self): - self.file.close() - - def __getattr__(self, name): - return getattr(self.dataset, name) - - def __getitem__(self, name): - return self.dataset[name] - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.file.close() - - @property - def particles(self): - return self._particles - - @property - def spectra(self): - return self._spectra - - def plotGrid(self, ax, **kwargs): - import matplotlib as mpl - import numpy as np - - coordinates = self.file.attrs["Coordinates"].decode("UTF-8") - if coordinates == "qsph": - coordinates = "sph" - - xlim, ylim = ax.get_xlim(), ax.get_ylim() - options = { - "lw": 1, - "color": "k", - "ls": "-", - } - options.update(kwargs) - - if coordinates == "cart": - for x in self.attrs["X1"]: - ax.plot([x, x], [self.attrs["X2Min"], self.attrs["X2Max"]], **options) - for y in self.attrs["X2"]: - ax.plot([self.attrs["X1Min"], self.attrs["X1Max"]], [y, y], **options) - else: - for r in self.attrs["X1"]: - ax.add_patch( - mpl.patches.Arc( - (0, 0), - 2 * r, - 2 * r, - theta1=-90, - theta2=90, - fill=False, - **options, - ) - ) - for th in self.attrs["X2"]: - ax.plot( - [ - self.attrs["X1Min"] * np.sin(th), - self.attrs["X1Max"] * np.sin(th), - ], - [ - self.attrs["X1Min"] * np.cos(th), - self.attrs["X1Max"] * np.cos(th), - ], - **options, - ) - ax.set(xlim=xlim, ylim=ylim) - - def makeMovie(self, plot, makeframes=True, **kwargs): - """ - Makes a movie from a plot function - - Parameters - ---------- - plot : function - The plot function to use; accepts output timestep and dataset as arguments. - makeframes : bool, optional - Whether to make the frames, or just proceed to making the movie. Default is True. - num_cpus : int, optional - The number of CPUs to use for making the frames. Default is None. - **kwargs : - Additional keyword arguments passed to `ffmpeg`. - """ - import numpy as np - - if makeframes: - makemovie = all( - exp.makeFrames( - plot, - np.arange(len(self.t)), - f"{self.attrs['simulation.name']}/frames", - data=self, - num_cpus=kwargs.pop("num_cpus", None), - ) - ) - else: - makemovie = True - if makemovie: - exp.makeMovie( - input=f"{self.attrs['simulation.name']}/frames/", - overwrite=True, - output=f"{self.attrs['simulation.name']}.mp4", - number=5, - **kwargs, - ) - return True + self.client.close() + super().__del__() diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..bdfd610 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "extraPath": ["./"], +} From 8f142cd6b7f37d48429faf9e2b58c9a7399230b2 Mon Sep 17 00:00:00 2001 From: hayk Date: Wed, 6 Nov 2024 16:56:45 -0500 Subject: [PATCH 2/9] multifile support --- nt2/containers/container.py | 35 ++-- nt2/containers/fields.py | 188 +++++------------ nt2/containers/particles.py | 140 ++++--------- nt2/containers/spectra.py | 109 ++++------ nt2/containers/utils.py | 391 ++++++++++++++++++++++++++++++++++-- nt2/read.py | 9 +- 6 files changed, 531 insertions(+), 341 deletions(-) diff --git a/nt2/containers/container.py b/nt2/containers/container.py index 07904d5..c3c7fa6 100644 --- a/nt2/containers/container.py +++ b/nt2/containers/container.py @@ -1,3 +1,4 @@ +import os import h5py import numpy as np from typing import Any @@ -40,22 +41,29 @@ def __init__( except OSError: raise OSError(f"Could not open file {self.path}") else: - self.master_file: h5py.File | None = None - raise NotImplementedError("Multiple files not yet supported") + field_path = os.path.join(self.path, "fields") + file = os.path.join(field_path, os.listdir(field_path)[0]) + try: + self.master_file: h5py.File | None = h5py.File(file, "r") + except OSError: + raise OSError(f"Could not open file {file}") self.attrs = _read_attribs_SingleFile(self.master_file) - if self.configs["single_file"]: - self.configs["ngh"] = int(self.master_file.attrs.get("NGhosts", 0)) - self.configs["layout"] = ( - "right" if self.master_file.attrs.get("LayoutRight", 1) == 1 else "left" - ) - self.configs["dimension"] = int(self.master_file.attrs.get("Dimension", 1)) - self.configs["coordinates"] = self.master_file.attrs.get( - "Coordinates", b"cart" - ).decode("UTF-8") - if self.configs["coordinates"] == "qsph": - self.configs["coordinates"] = "sph" + self.configs["ngh"] = int(self.master_file.attrs.get("NGhosts", 0)) + self.configs["layout"] = ( + "right" if self.master_file.attrs.get("LayoutRight", 1) == 1 else "left" + ) + self.configs["dimension"] = int(self.master_file.attrs.get("Dimension", 1)) + self.configs["coordinates"] = self.master_file.attrs.get( + "Coordinates", b"cart" + ).decode("UTF-8") + if self.configs["coordinates"] == "qsph": + self.configs["coordinates"] = "sph" + + if not self.configs["single_file"]: + self.master_file.close() + self.master_file = None # if coordinates == "sph": # self.metric = SphericalMetric() # else: @@ -107,7 +115,6 @@ def plotGrid(self, ax, **kwargs): def print_container(self) -> str: return f"Client {self.client}\n" - # # def makeMovie(self, plot, makeframes=True, **kwargs): # """ # Makes a movie from a plot function diff --git a/nt2/containers/fields.py b/nt2/containers/fields.py index 33bc355..8e9c04e 100644 --- a/nt2/containers/fields.py +++ b/nt2/containers/fields.py @@ -1,115 +1,13 @@ +import os import h5py import xarray as xr -import numpy as np -from dask.array.core import from_array -from dask.array.core import stack from nt2.containers.container import Container -from nt2.containers.utils import _read_category_metadata_SingleFile - - -def _read_coordinates_SingleFile(coords: list[str], file: h5py.File): - for st in file: - group = file[st] - if isinstance(group, h5py.Group): - if any([k.startswith("X") for k in group if k is not None]): - # cell-centered coords - xc = { - c: ( - np.asarray(xi[:]) - if isinstance(xi := group[f"X{i+1}"], h5py.Dataset) and xi - else None - ) - for i, c in enumerate(coords[::-1]) - } - # cell edges - xe_min = { - f"{c}_1": ( - c, - ( - np.asarray(xi[:-1]) - if isinstance((xi := group[f"X{i+1}e"]), h5py.Dataset) - else None - ), - ) - for i, c in enumerate(coords[::-1]) - } - xe_max = { - f"{c}_2": ( - c, - ( - np.asarray(xi[1:]) - if isinstance((xi := group[f"X{i+1}e"]), h5py.Dataset) - else None - ), - ) - for i, c in enumerate(coords[::-1]) - } - return {"x_c": xc, "x_emin": xe_min, "x_emax": xe_max} - else: - raise ValueError(f"Unexpected type {type(file[st])}") - raise ValueError("Could not find coordinates in file") - - -def _preload_field_SingleFile( - k: str, - dim: int, - ngh: int, - outsteps: list[int], - times: list[float], - steps: list[int], - coords: list[str], - xc_coords: dict[str, str], - xe_min_coords: dict[str, str], - xe_max_coords: dict[str, str], - coord_replacements: list[tuple[str, str]], - field_replacements: list[tuple[str, str]], - layout: str, - file: h5py.File, -): - if dim == 1: - noghosts = slice(ngh, -ngh) if ngh > 0 else slice(None) - elif dim == 2: - noghosts = (slice(ngh, -ngh), slice(ngh, -ngh)) if ngh > 0 else slice(None) - elif dim == 3: - noghosts = ( - (slice(ngh, -ngh), slice(ngh, -ngh), slice(ngh, -ngh)) - if ngh > 0 - else slice(None) - ) - else: - raise ValueError("Invalid dimension") - - dask_arrays = [] - for s in outsteps: - dset = file[f"{s}/{k}"] - if isinstance(dset, h5py.Dataset): - array = from_array(np.transpose(dset) if layout == "right" else dset) - dask_arrays.append(array[noghosts]) - else: - raise ValueError(f"Unexpected type {type(dset)}") - - k_ = k[1:] - for c in coord_replacements: - if "_" not in k_: - k_ = k_.replace(c[0], c[1]) - else: - k_ = "_".join([k_.split("_")[0].replace(c[0], c[1])] + k_.split("_")[1:]) - for f in field_replacements: - k_ = k_.replace(*f) - - return k_, xr.DataArray( - stack(dask_arrays, axis=0), - dims=["t", *coords], - name=k_, - coords={ - "t": times, - "s": ("t", steps), - **xc_coords, - **xe_min_coords, - **xe_max_coords, - }, - ) +from nt2.containers.utils import ( + _read_category_metadata, + _read_coordinates, + _preload_field, +) class FieldsContainer(Container): @@ -134,50 +32,64 @@ def __init__(self, **kwargs): } if self.configs["single_file"]: assert self.master_file is not None, "Master file not found" - self.metadata["fields"] = _read_category_metadata_SingleFile( - "f", self.master_file + self.metadata["fields"] = _read_category_metadata( + True, "f", self.master_file ) else: + field_path = os.path.join(self.path, "fields") + files = sorted(os.listdir(field_path)) try: - raise NotImplementedError("Multiple files not yet supported") + self.fields_files = [ + h5py.File(os.path.join(field_path, f), "r") for f in files + ] except OSError: - raise OSError(f"Could not open file {self.path}") + raise OSError(f"Could not open file in {field_path}") + self.metadata["fields"] = _read_category_metadata( + False, "f", self.fields_files + ) coords = list(CoordinateDict[self.configs["coordinates"]].values())[::-1][ -self.configs["dimension"] : ] if self.configs["single_file"]: - self.mesh = _read_coordinates_SingleFile(coords, self.master_file) + assert self.master_file is not None, "Master file not found" + self.mesh = _read_coordinates(coords, self.master_file) else: - raise NotImplementedError("Multiple files not yet supported") + self.mesh = _read_coordinates(coords, self.fields_files[0]) - self.fields = xr.Dataset() + self._fields = xr.Dataset() if len(self.metadata["fields"]["outsteps"]) > 0: - if self.configs["single_file"]: - for k in self.metadata["fields"]["quantities"]: - name, dset = _preload_field_SingleFile( - k, - dim=self.configs["dimension"], - ngh=self.configs["ngh"], - outsteps=self.metadata["fields"]["outsteps"], - times=self.metadata["fields"]["times"], - steps=self.metadata["fields"]["steps"], - coords=coords, - xc_coords=self.mesh["x_c"], - xe_min_coords=self.mesh["x_emin"], - xe_max_coords=self.mesh["x_emax"], - coord_replacements=list( - CoordinateDict[self.configs["coordinates"]].items() - ), - field_replacements=list(QuantityDict.items()), - layout=self.configs["layout"], - file=self.master_file, - ) - self.fields[name] = dset - else: - raise NotImplementedError("Multiple files not yet supported") + for k in self.metadata["fields"]["quantities"]: + name, dset = _preload_field( + single_file=self.configs["single_file"], + k=k, + dim=self.configs["dimension"], + ngh=self.configs["ngh"], + outsteps=self.metadata["fields"]["outsteps"], + times=self.metadata["fields"]["times"], + steps=self.metadata["fields"]["steps"], + coords=coords, + xc_coords=self.mesh["x_c"], + xe_min_coords=self.mesh["x_emin"], + xe_max_coords=self.mesh["x_emax"], + coord_replacements=list( + CoordinateDict[self.configs["coordinates"]].items() + ), + field_replacements=list(QuantityDict.items()), + layout=self.configs["layout"], + file=( + self.master_file + if self.configs["single_file"] and self.master_file is not None + else self.fields_files + ), + ) + self.fields[name] = dset + + @property + def fields(self): + return self._fields def __del__(self): if self.configs["single_file"] and self.master_file is not None: diff --git a/nt2/containers/particles.py b/nt2/containers/particles.py index 4440b9d..a9b68b2 100644 --- a/nt2/containers/particles.py +++ b/nt2/containers/particles.py @@ -1,91 +1,11 @@ +import os import h5py -import numpy as np -import xarray as xr -from dask.array.core import from_array - - from nt2.containers.container import Container -from nt2.containers.utils import _read_category_metadata_SingleFile - - -def _list_to_ragged(arr): - max_len = np.max([len(a) for a in arr]) - return map( - lambda a: np.concatenate([a, np.full(max_len - len(a), np.nan)]), - arr, - ) - - -def _read_species_SingleFile(first_step: int, file: h5py.File): - group = file[first_step] - if not isinstance(group, h5py.Group): - raise ValueError(f"Unexpected type {type(group)}") - species = np.unique( - [int(pq.split("_")[1]) for pq in group.keys() if pq.startswith("p")] - ) - return species - - -def _preload_particle_species_SingleFile( - s: int, - quantities: list[str], - coord_type: str, - outsteps: list[int], - times: list[float], - steps: list[int], - coord_replacements: dict[str, str], - file: h5py.File, -): - prtl_data = {} - for q in [ - f"X1_{s}", - f"X2_{s}", - f"X3_{s}", - f"U1_{s}", - f"U2_{s}", - f"U3_{s}", - f"W_{s}", - ]: - if q[0] in ["X", "U"]: - q_ = coord_replacements[q.split("_")[0]] - else: - q_ = q.split("_")[0] - if "p" + q not in quantities: - continue - if q not in prtl_data.keys(): - prtl_data[q_] = [] - for step_k in outsteps: - group = file[step_k] - if isinstance(group, h5py.Group): - if "p" + q in group.keys(): - prtl_data[q_].append(group["p" + q]) - else: - prtl_data[q_].append(np.full_like(prtl_data[q_][-1], np.nan)) - else: - raise ValueError(f"Unexpected type {type(file[step_k])}") - prtl_data[q_] = _list_to_ragged(prtl_data[q_]) - prtl_data[q_] = from_array(list(prtl_data[q_])) - prtl_data[q_] = xr.DataArray( - prtl_data[q_], - dims=["t", "id"], - name=q_, - coords={"t": times, "s": ("t", steps)}, - ) - if coord_type == "sph": - prtl_data["x"] = ( - prtl_data[coord_replacements["X1"]] - * np.sin(prtl_data[coord_replacements["X2"]]) - * np.cos(prtl_data[coord_replacements["X3"]]) - ) - prtl_data["y"] = ( - prtl_data[coord_replacements["X1"]] - * np.sin(prtl_data[coord_replacements["X2"]]) - * np.sin(prtl_data[coord_replacements["X3"]]) - ) - prtl_data["z"] = prtl_data[coord_replacements["X1"]] * np.cos( - prtl_data[coord_replacements["X2"]] - ) - return xr.Dataset(prtl_data) +from nt2.containers.utils import ( + _read_category_metadata, + _read_particle_species, + _preload_particle_species, +) class ParticleContainer(Container): @@ -112,28 +32,48 @@ def __init__(self, **kwargs): if self.configs["single_file"]: assert self.master_file is not None, "Master file not found" - self.metadata["particles"] = _read_category_metadata_SingleFile( - "p", self.master_file + self.metadata["particles"] = _read_category_metadata( + True, "p", self.master_file + ) + else: + particle_path = os.path.join(self.path, "particles") + files = sorted(os.listdir(particle_path)) + try: + self.particle_files = [ + h5py.File(os.path.join(particle_path, f), "r") for f in files + ] + except OSError: + raise OSError(f"Could not open file in {particle_path}") + self.metadata["particles"] = _read_category_metadata( + False, "p", self.particle_files ) self._particles = {} if len(self.metadata["particles"]["outsteps"]) > 0: if self.configs["single_file"]: assert self.master_file is not None, "Master file not found" - species = _read_species_SingleFile( + species = _read_particle_species( self.metadata["particles"]["outsteps"][0], self.master_file ) - for s in species: - self._particles[s] = _preload_particle_species_SingleFile( - s=s, - quantities=self.metadata["particles"]["quantities"], - coord_type=self.configs["coordinates"], - outsteps=self.metadata["particles"]["outsteps"], - times=self.metadata["particles"]["times"], - steps=self.metadata["particles"]["steps"], - coord_replacements=PrtlDict[self.configs["coordinates"]], - file=self.master_file, - ) + else: + species = _read_particle_species("Step0", self.particle_files[0]) + self.metadata["particles"]["species"] = species + for s in species: + self._particles[s] = _preload_particle_species( + self.configs["single_file"], + s=s, + quantities=self.metadata["particles"]["quantities"], + coord_type=self.configs["coordinates"], + outsteps=self.metadata["particles"]["outsteps"], + times=self.metadata["particles"]["times"], + steps=self.metadata["particles"]["steps"], + coord_replacements=PrtlDict[self.configs["coordinates"]], + file=( + self.master_file + if self.configs["single_file"] and self.master_file is not None + else self.particle_files + ), + ) @property def particles(self): diff --git a/nt2/containers/spectra.py b/nt2/containers/spectra.py index 8825e9c..652688f 100644 --- a/nt2/containers/spectra.py +++ b/nt2/containers/spectra.py @@ -1,60 +1,14 @@ +import os import h5py -import numpy as np import xarray as xr -from dask.array.core import from_array -from dask.array.core import stack from nt2.containers.container import Container -from nt2.containers.utils import _read_category_metadata_SingleFile - - -def _read_species_SingleFile(first_step: int, file: h5py.File): - group = file[first_step] - if not isinstance(group, h5py.Group): - raise ValueError(f"Unexpected type {type(group)}") - species = np.unique( - [int(pq.split("_")[1]) for pq in group.keys() if pq.startswith("sN")] - ) - return species - - -def _read_spectra_bins_SingleFile(first_step: int, log_bins: bool, file: h5py.File): - group = file[first_step] - if not isinstance(group, h5py.Group): - raise ValueError(f"Unexpected type {type(group)}") - e_bins = group["sEbn"] - if not isinstance(e_bins, h5py.Dataset): - raise ValueError(f"Unexpected type {type(e_bins)}") - if log_bins: - e_bins = np.sqrt(e_bins[1:] * e_bins[:-1]) - else: - e_bins = (e_bins[1:] + e_bins[:-1]) / 2 - return e_bins - - -def _preload_spectra_SingleFile( - sp: int, - e_bins: np.ndarray, - outsteps: list[int], - times: list[float], - steps: list[int], - file: h5py.File, -): - dask_arrays = [] - for st in outsteps: - array = from_array(file[f"{st}/sN_{sp}"]) - dask_arrays.append(array) - - return xr.DataArray( - stack(dask_arrays, axis=0), - dims=["t", "e"], - name=f"n_{sp}", - coords={ - "t": times, - "s": ("t", steps), - "e": e_bins, - }, - ) +from nt2.containers.utils import ( + _read_category_metadata, + _read_spectra_species, + _read_spectra_bins, + _preload_spectra, +) class SpectraContainer(Container): @@ -70,8 +24,20 @@ def __init__(self, **kwargs): if self.configs["single_file"]: assert self.master_file is not None, "Master file not found" - self.metadata["spectra"] = _read_category_metadata_SingleFile( - "s", self.master_file + self.metadata["spectra"] = _read_category_metadata( + True, "s", self.master_file + ) + else: + spectra_path = os.path.join(self.path, "spectra") + files = sorted(os.listdir(spectra_path)) + try: + self.spectra_files = [ + h5py.File(os.path.join(spectra_path, f), "r") for f in files + ] + except OSError: + raise OSError(f"Could not open file {spectra_path}") + self.metadata["spectra"] = _read_category_metadata( + False, "s", self.spectra_files ) self._spectra = xr.Dataset() log_bins = self.attrs["output.spectra.log_bins"] @@ -79,24 +45,33 @@ def __init__(self, **kwargs): if len(self.metadata["spectra"]["outsteps"]) > 0: if self.configs["single_file"]: assert self.master_file is not None, "Master file not found" - species = _read_species_SingleFile( - self.metadata["spectra"]["outsteps"][0], self.master_file + species = _read_spectra_species( + f'Step{self.metadata["spectra"]["outsteps"][0]}', self.master_file + ) + e_bins = _read_spectra_bins( + f'Step{self.metadata["spectra"]["outsteps"][0]}', + log_bins, + self.master_file, ) else: - raise NotImplementedError("Multiple files not yet supported") + species = _read_spectra_species("Step0", self.spectra_files[0]) + e_bins = _read_spectra_bins("Step0", log_bins, self.spectra_files[0]) - e_bins = _read_spectra_bins_SingleFile( - self.metadata["spectra"]["outsteps"][0], log_bins, self.master_file - ) + self.metadata["spectra"]["species"] = species for sp in species: - self._spectra[f"n_{sp}"] = _preload_spectra_SingleFile( + self._spectra[f"n_{sp}"] = _preload_spectra( + self.configs["single_file"], sp, - e_bins, - self.metadata["spectra"]["outsteps"], - self.metadata["spectra"]["times"], - self.metadata["spectra"]["steps"], - self.master_file, + e_bins=e_bins, + outsteps=self.metadata["spectra"]["outsteps"], + times=self.metadata["spectra"]["times"], + steps=self.metadata["spectra"]["steps"], + file=( + self.master_file + if self.configs["single_file"] and self.master_file is not None + else self.spectra_files + ), ) @property diff --git a/nt2/containers/utils.py b/nt2/containers/utils.py index d5a9718..a6f1313 100644 --- a/nt2/containers/utils.py +++ b/nt2/containers/utils.py @@ -1,38 +1,387 @@ import h5py import numpy as np +import xarray as xr +from dask.array.core import from_array +from dask.array.core import stack -def _read_category_metadata_SingleFile(prefix: str, file: h5py.File): - f_outsteps = [] - f_steps = [] - f_times = [] - f_quantities = None - for st in file: - group = file[st] +def _read_category_metadata( + single_file: bool, prefix: str, file: h5py.File | list[h5py.File] +): + outsteps = [] + steps = [] + times = [] + quantities = None + for i, st in enumerate(file): + if single_file: + assert isinstance(file, h5py.File) + group = file[st] + else: + assert isinstance(file[i], h5py.File) + group = st["Step0"] if isinstance(group, h5py.Group): if any([k.startswith(prefix) for k in group if k is not None]): - if f_quantities is None: - f_quantities = [k for k in group.keys() if k.startswith(prefix)] - f_outsteps.append(st) + if quantities is None: + quantities = [k for k in group.keys() if k.startswith(prefix)] + outsteps.append(st if single_file else f"Step{i}") time_ds = group["Time"] if isinstance(time_ds, h5py.Dataset): - f_times.append(time_ds[()]) + times.append(time_ds[()]) else: raise ValueError(f"Unexpected type {type(time_ds)}") step_ds = group["Step"] if isinstance(step_ds, h5py.Dataset): - f_steps.append(int(step_ds[()])) + steps.append(int(step_ds[()])) else: raise ValueError(f"Unexpected type {type(step_ds)}") - else: - raise ValueError(f"Unexpected type {type(file[st])}") - f_outsteps = sorted(f_outsteps, key=lambda x: int(x.replace("Step", ""))) - f_steps = sorted(f_steps) - f_times = np.array(sorted(f_times), dtype=np.float64) + raise ValueError("Unexpected type") + outsteps = sorted(outsteps, key=lambda x: int(x.replace("Step", ""))) + steps = sorted(steps) + times = np.array(sorted(times), dtype=np.float64) return { - "quantities": f_quantities, - "outsteps": f_outsteps, - "steps": f_steps, - "times": f_times, + "quantities": quantities, + "outsteps": outsteps, + "steps": steps, + "times": times, } + + +# def _read_category_metadata_SingleFile(prefix: str, file: h5py.File): +# outsteps = [] +# steps = [] +# times = [] +# quantities = None +# for st in file: +# group = file[st] +# if isinstance(group, h5py.Group): +# if any([k.startswith(prefix) for k in group if k is not None]): +# if quantities is None: +# quantities = [k for k in group.keys() if k.startswith(prefix)] +# outsteps.append(st) +# time_ds = group["Time"] +# if isinstance(time_ds, h5py.Dataset): +# times.append(time_ds[()]) +# else: +# raise ValueError(f"Unexpected type {type(time_ds)}") +# step_ds = group["Step"] +# if isinstance(step_ds, h5py.Dataset): +# steps.append(int(step_ds[()])) +# else: +# raise ValueError(f"Unexpected type {type(step_ds)}") +# +# else: +# raise ValueError(f"Unexpected type {type(file[st])}") +# outsteps = sorted(outsteps, key=lambda x: int(x.replace("Step", ""))) +# steps = sorted(steps) +# times = np.array(sorted(times), dtype=np.float64) +# return { +# "quantities": quantities, +# "outsteps": outsteps, +# "steps": steps, +# "times": times, +# } +# +# +# def _read_category_metadata_MultipleFiles(prefix: str, files: list[h5py.File]): +# outsteps = [] +# steps = [] +# times = [] +# quantities = None +# for i, f in enumerate(files): +# group = f["Step0"] +# if isinstance(group, h5py.Group): +# if any([k.startswith(prefix) for k in group if k is not None]): +# if quantities is None: +# quantities = [k for k in group.keys() if k.startswith(prefix)] +# outsteps.append(f"Step{i}") +# time_ds = group["Time"] +# if isinstance(time_ds, h5py.Dataset): +# times.append(time_ds[()]) +# else: +# raise ValueError(f"Unexpected type {type(time_ds)}") +# step_ds = group["Step"] +# if isinstance(step_ds, h5py.Dataset): +# steps.append(int(step_ds[()])) +# else: +# raise ValueError(f"Unexpected type {type(step_ds)}") +# outsteps = sorted(outsteps, key=lambda x: int(x.replace("Step", ""))) +# steps = sorted(steps) +# times = np.array(sorted(times), dtype=np.float64) +# return { +# "quantities": quantities, +# "outsteps": outsteps, +# "steps": steps, +# "times": times, +# } +# + + +# fields +def _read_coordinates(coords: list[str], file: h5py.File): + for st in file: + group = file[st] + if isinstance(group, h5py.Group): + if any([k.startswith("X") for k in group if k is not None]): + # cell-centered coords + xc = { + c: ( + np.asarray(xi[:]) + if isinstance(xi := group[f"X{i+1}"], h5py.Dataset) and xi + else None + ) + for i, c in enumerate(coords[::-1]) + } + # cell edges + xe_min = { + f"{c}_1": ( + c, + ( + np.asarray(xi[:-1]) + if isinstance((xi := group[f"X{i+1}e"]), h5py.Dataset) + else None + ), + ) + for i, c in enumerate(coords[::-1]) + } + xe_max = { + f"{c}_2": ( + c, + ( + np.asarray(xi[1:]) + if isinstance((xi := group[f"X{i+1}e"]), h5py.Dataset) + else None + ), + ) + for i, c in enumerate(coords[::-1]) + } + return {"x_c": xc, "x_emin": xe_min, "x_emax": xe_max} + else: + raise ValueError(f"Unexpected type {type(file[st])}") + raise ValueError("Could not find coordinates in file") + + +def _preload_field( + single_file: bool, + k: str, + dim: int, + ngh: int, + outsteps: list[int], + times: list[float], + steps: list[int], + coords: list[str], + xc_coords: dict[str, str], + xe_min_coords: dict[str, str], + xe_max_coords: dict[str, str], + coord_replacements: list[tuple[str, str]], + field_replacements: list[tuple[str, str]], + layout: str, + file: h5py.File | list[h5py.File], +): + if dim == 1: + noghosts = slice(ngh, -ngh) if ngh > 0 else slice(None) + elif dim == 2: + noghosts = (slice(ngh, -ngh), slice(ngh, -ngh)) if ngh > 0 else slice(None) + elif dim == 3: + noghosts = ( + (slice(ngh, -ngh), slice(ngh, -ngh), slice(ngh, -ngh)) + if ngh > 0 + else slice(None) + ) + else: + raise ValueError("Invalid dimension") + + dask_arrays = [] + if single_file: + for s in outsteps: + assert isinstance(file, h5py.File) + dset = file[f"{s}/{k}"] + if isinstance(dset, h5py.Dataset): + array = from_array(np.transpose(dset) if layout == "right" else dset) + dask_arrays.append(array[noghosts]) + else: + raise ValueError(f"Unexpected type {type(dset)}") + else: + for f in file: + assert isinstance(f, h5py.File) + dset = f[f"Step0/{k}"] + if isinstance(dset, h5py.Dataset): + array = from_array(np.transpose(dset) if layout == "right" else dset) + dask_arrays.append(array[noghosts]) + else: + raise ValueError(f"Unexpected type {type(dset)}") + + k_ = k[1:] + for c in coord_replacements: + if "_" not in k_: + k_ = k_.replace(c[0], c[1]) + else: + k_ = "_".join([k_.split("_")[0].replace(c[0], c[1])] + k_.split("_")[1:]) + for f in field_replacements: + k_ = k_.replace(*f) + + return k_, xr.DataArray( + stack(dask_arrays, axis=0), + dims=["t", *coords], + name=k_, + coords={ + "t": times, + "s": ("t", steps), + **xc_coords, + **xe_min_coords, + **xe_max_coords, + }, + ) + + +# particles +def _list_to_ragged(arr): + max_len = np.max([len(a) for a in arr]) + return map( + lambda a: np.concatenate([a, np.full(max_len - len(a), np.nan)]), + arr, + ) + + +def _read_particle_species(first_step: str, file: h5py.File): + group = file[first_step] + if not isinstance(group, h5py.Group): + raise ValueError(f"Unexpected type {type(group)}") + species = np.unique( + [int(pq.split("_")[1]) for pq in group.keys() if pq.startswith("p")] + ) + return species + + +def _preload_particle_species( + single_file: bool, + s: int, + quantities: list[str], + coord_type: str, + outsteps: list[int], + times: list[float], + steps: list[int], + coord_replacements: dict[str, str], + file: h5py.File | list[h5py.File], +): + prtl_data = {} + for q in [ + f"X1_{s}", + f"X2_{s}", + f"X3_{s}", + f"U1_{s}", + f"U2_{s}", + f"U3_{s}", + f"W_{s}", + ]: + if q[0] in ["X", "U"]: + q_ = coord_replacements[q.split("_")[0]] + else: + q_ = q.split("_")[0] + if "p" + q not in quantities: + continue + if q not in prtl_data.keys(): + prtl_data[q_] = [] + if single_file: + assert isinstance(file, h5py.File) + for step_k in outsteps: + group = file[step_k] + if isinstance(group, h5py.Group): + if "p" + q in group.keys(): + prtl_data[q_].append(group["p" + q]) + else: + prtl_data[q_].append(np.full_like(prtl_data[q_][-1], np.nan)) + else: + raise ValueError(f"Unexpected type {type(file[step_k])}") + else: + for f in file: + assert isinstance(f, h5py.File) + group = f["Step0"] + if isinstance(group, h5py.Group): + if "p" + q in group.keys(): + prtl_data[q_].append(group["p" + q]) + else: + prtl_data[q_].append(np.full_like(prtl_data[q_][-1], np.nan)) + else: + raise ValueError(f"Unexpected type {type(group)}") + prtl_data[q_] = _list_to_ragged(prtl_data[q_]) + prtl_data[q_] = from_array(list(prtl_data[q_])) + prtl_data[q_] = xr.DataArray( + prtl_data[q_], + dims=["t", "id"], + name=q_, + coords={"t": times, "s": ("t", steps)}, + ) + if coord_type == "sph": + prtl_data["x"] = ( + prtl_data[coord_replacements["X1"]] + * np.sin(prtl_data[coord_replacements["X2"]]) + * np.cos(prtl_data[coord_replacements["X3"]]) + ) + prtl_data["y"] = ( + prtl_data[coord_replacements["X1"]] + * np.sin(prtl_data[coord_replacements["X2"]]) + * np.sin(prtl_data[coord_replacements["X3"]]) + ) + prtl_data["z"] = prtl_data[coord_replacements["X1"]] * np.cos( + prtl_data[coord_replacements["X2"]] + ) + return xr.Dataset(prtl_data) + + +# spectra +def _read_spectra_species(first_step: str, file: h5py.File): + group = file[first_step] + if not isinstance(group, h5py.Group): + raise ValueError(f"Unexpected type {type(group)}") + species = np.unique( + [int(pq.split("_")[1]) for pq in group.keys() if pq.startswith("sN")] + ) + return species + + +def _read_spectra_bins(first_step: str, log_bins: bool, file: h5py.File): + group = file[first_step] + if not isinstance(group, h5py.Group): + raise ValueError(f"Unexpected type {type(group)}") + e_bins = group["sEbn"] + if not isinstance(e_bins, h5py.Dataset): + raise ValueError(f"Unexpected type {type(e_bins)}") + if log_bins: + e_bins = np.sqrt(e_bins[1:] * e_bins[:-1]) + else: + e_bins = (e_bins[1:] + e_bins[:-1]) / 2 + return e_bins + + +def _preload_spectra( + single_file: bool, + sp: int, + e_bins: np.ndarray, + outsteps: list[int], + times: list[float], + steps: list[int], + file: h5py.File | list[h5py.File], +): + dask_arrays = [] + if single_file: + assert isinstance(file, h5py.File) + for st in outsteps: + array = from_array(file[f"{st}/sN_{sp}"]) + dask_arrays.append(array) + else: + for f in file: + assert isinstance(f, h5py.File) + array = from_array(f[f"Step0/sN_{sp}"]) + dask_arrays.append(array) + + return xr.DataArray( + stack(dask_arrays, axis=0), + dims=["t", "e"], + name=f"n_{sp}", + coords={ + "t": times, + "s": ("t", steps), + "e": e_bins, + }, + ) diff --git a/nt2/read.py b/nt2/read.py index 5c6af32..62b0aaf 100644 --- a/nt2/read.py +++ b/nt2/read.py @@ -12,8 +12,15 @@ def __init__(self, **kwargs): super(Data, self).__init__(**kwargs) def __repr__(self) -> str: + help = "Usage: \n" + help += " data = Data(path, ...)\n" + help += " data.fields\n" + help += " data.particles\n" + help += " data.spectra\n" return ( - self.print_container() + help + + "\n" + + self.print_container() + "\n" + self.print_fields() + "\n" From 741235db0dc43f4760b1aa872d7b1f334df8396b Mon Sep 17 00:00:00 2001 From: haykh Date: Thu, 7 Nov 2024 13:10:14 -0500 Subject: [PATCH 3/9] minor bugs fixed --- nt2/containers/container.py | 6 ++++++ nt2/containers/fields.py | 35 ++++++++++++++++------------------- nt2/containers/particles.py | 31 ++++++++++++++++++++----------- nt2/containers/spectra.py | 28 +++++++++++++++++----------- nt2/read.py | 1 - 5 files changed, 59 insertions(+), 42 deletions(-) diff --git a/nt2/containers/container.py b/nt2/containers/container.py index c3c7fa6..bc245d7 100644 --- a/nt2/containers/container.py +++ b/nt2/containers/container.py @@ -69,6 +69,12 @@ def __init__( # else: # self.metric = MinkowskiMetric() + def __del__(self): + if self.master_file is not None: + self.master_file.close() + if self.client.status == "running": + self.client.close() + def plotGrid(self, ax, **kwargs): from matplotlib import patches diff --git a/nt2/containers/fields.py b/nt2/containers/fields.py index 8e9c04e..69ab5fd 100644 --- a/nt2/containers/fields.py +++ b/nt2/containers/fields.py @@ -37,16 +37,17 @@ def __init__(self, **kwargs): ) else: field_path = os.path.join(self.path, "fields") - files = sorted(os.listdir(field_path)) - try: - self.fields_files = [ - h5py.File(os.path.join(field_path, f), "r") for f in files - ] - except OSError: - raise OSError(f"Could not open file in {field_path}") - self.metadata["fields"] = _read_category_metadata( - False, "f", self.fields_files - ) + if os.path.isdir(field_path): + files = sorted(os.listdir(field_path)) + try: + self.fields_files = [ + h5py.File(os.path.join(field_path, f), "r") for f in files + ] + except OSError: + raise OSError(f"Could not open file in {field_path}") + self.metadata["fields"] = _read_category_metadata( + False, "f", self.fields_files + ) coords = list(CoordinateDict[self.configs["coordinates"]].values())[::-1][ -self.configs["dimension"] : @@ -60,7 +61,7 @@ def __init__(self, **kwargs): self._fields = xr.Dataset() - if len(self.metadata["fields"]["outsteps"]) > 0: + if "fields" in self.metadata and len(self.metadata["fields"]["outsteps"]) > 0: for k in self.metadata["fields"]["quantities"]: name, dset = _preload_field( single_file=self.configs["single_file"], @@ -92,19 +93,15 @@ def fields(self): return self._fields def __del__(self): - if self.configs["single_file"] and self.master_file is not None: - self.master_file.close() - else: - raise NotImplementedError("Multiple files not yet supported") + if not self.configs["single_file"]: + for f in self.fields_files: + f.close() def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): - if self.configs["single_file"] and self.master_file is not None: - self.master_file.close() - else: - raise NotImplementedError("Multiple files not yet supported") + self.__del__() def print_fields(self) -> str: def sizeof_fmt(num, suffix="B"): diff --git a/nt2/containers/particles.py b/nt2/containers/particles.py index a9b68b2..43be4ec 100644 --- a/nt2/containers/particles.py +++ b/nt2/containers/particles.py @@ -37,19 +37,23 @@ def __init__(self, **kwargs): ) else: particle_path = os.path.join(self.path, "particles") - files = sorted(os.listdir(particle_path)) - try: - self.particle_files = [ - h5py.File(os.path.join(particle_path, f), "r") for f in files - ] - except OSError: - raise OSError(f"Could not open file in {particle_path}") - self.metadata["particles"] = _read_category_metadata( - False, "p", self.particle_files - ) + if os.path.isdir(particle_path): + files = sorted(os.listdir(particle_path)) + try: + self.particle_files = [ + h5py.File(os.path.join(particle_path, f), "r") for f in files + ] + except OSError: + raise OSError(f"Could not open file in {particle_path}") + self.metadata["particles"] = _read_category_metadata( + False, "p", self.particle_files + ) self._particles = {} - if len(self.metadata["particles"]["outsteps"]) > 0: + if ( + "particles" in self.metadata + and len(self.metadata["particles"]["outsteps"]) > 0 + ): if self.configs["single_file"]: assert self.master_file is not None, "Master file not found" species = _read_particle_species( @@ -79,6 +83,11 @@ def __init__(self, **kwargs): def particles(self): return self._particles + def __del__(self): + if not self.configs["single_file"]: + for f in self.particle_files: + f.close() + def print_particles(self) -> str: def sizeof_fmt(num, suffix="B"): for unit in ("", "K", "M", "G", "T", "P", "E", "Z"): diff --git a/nt2/containers/spectra.py b/nt2/containers/spectra.py index 652688f..9d14b40 100644 --- a/nt2/containers/spectra.py +++ b/nt2/containers/spectra.py @@ -29,20 +29,21 @@ def __init__(self, **kwargs): ) else: spectra_path = os.path.join(self.path, "spectra") - files = sorted(os.listdir(spectra_path)) - try: - self.spectra_files = [ - h5py.File(os.path.join(spectra_path, f), "r") for f in files - ] - except OSError: - raise OSError(f"Could not open file {spectra_path}") - self.metadata["spectra"] = _read_category_metadata( - False, "s", self.spectra_files - ) + if os.path.isdir(spectra_path): + files = sorted(os.listdir(spectra_path)) + try: + self.spectra_files = [ + h5py.File(os.path.join(spectra_path, f), "r") for f in files + ] + except OSError: + raise OSError(f"Could not open file {spectra_path}") + self.metadata["spectra"] = _read_category_metadata( + False, "s", self.spectra_files + ) self._spectra = xr.Dataset() log_bins = self.attrs["output.spectra.log_bins"] - if len(self.metadata["spectra"]["outsteps"]) > 0: + if "spectra" in self.metadata and len(self.metadata["spectra"]["outsteps"]) > 0: if self.configs["single_file"]: assert self.master_file is not None, "Master file not found" species = _read_spectra_species( @@ -74,6 +75,11 @@ def __init__(self, **kwargs): ), ) + def __del__(self): + if not self.configs["single_file"]: + for f in self.spectra_files: + f.close() + @property def spectra(self): return self._spectra diff --git a/nt2/read.py b/nt2/read.py index 62b0aaf..8bdc0ab 100644 --- a/nt2/read.py +++ b/nt2/read.py @@ -33,5 +33,4 @@ def __str__(self) -> str: return self.__repr__() def __del__(self): - self.client.close() super().__del__() From 39e06ebf59ee8f1c04059019a9eb16132e1780fe Mon Sep 17 00:00:00 2001 From: haykh Date: Thu, 7 Nov 2024 14:00:45 -0500 Subject: [PATCH 4/9] pickling removed --- nt2/containers/utils.py | 70 --------------------------------------- nt2/plotters/polarplot.py | 3 +- 2 files changed, 1 insertion(+), 72 deletions(-) diff --git a/nt2/containers/utils.py b/nt2/containers/utils.py index a6f1313..470c5d4 100644 --- a/nt2/containers/utils.py +++ b/nt2/containers/utils.py @@ -47,76 +47,6 @@ def _read_category_metadata( } -# def _read_category_metadata_SingleFile(prefix: str, file: h5py.File): -# outsteps = [] -# steps = [] -# times = [] -# quantities = None -# for st in file: -# group = file[st] -# if isinstance(group, h5py.Group): -# if any([k.startswith(prefix) for k in group if k is not None]): -# if quantities is None: -# quantities = [k for k in group.keys() if k.startswith(prefix)] -# outsteps.append(st) -# time_ds = group["Time"] -# if isinstance(time_ds, h5py.Dataset): -# times.append(time_ds[()]) -# else: -# raise ValueError(f"Unexpected type {type(time_ds)}") -# step_ds = group["Step"] -# if isinstance(step_ds, h5py.Dataset): -# steps.append(int(step_ds[()])) -# else: -# raise ValueError(f"Unexpected type {type(step_ds)}") -# -# else: -# raise ValueError(f"Unexpected type {type(file[st])}") -# outsteps = sorted(outsteps, key=lambda x: int(x.replace("Step", ""))) -# steps = sorted(steps) -# times = np.array(sorted(times), dtype=np.float64) -# return { -# "quantities": quantities, -# "outsteps": outsteps, -# "steps": steps, -# "times": times, -# } -# -# -# def _read_category_metadata_MultipleFiles(prefix: str, files: list[h5py.File]): -# outsteps = [] -# steps = [] -# times = [] -# quantities = None -# for i, f in enumerate(files): -# group = f["Step0"] -# if isinstance(group, h5py.Group): -# if any([k.startswith(prefix) for k in group if k is not None]): -# if quantities is None: -# quantities = [k for k in group.keys() if k.startswith(prefix)] -# outsteps.append(f"Step{i}") -# time_ds = group["Time"] -# if isinstance(time_ds, h5py.Dataset): -# times.append(time_ds[()]) -# else: -# raise ValueError(f"Unexpected type {type(time_ds)}") -# step_ds = group["Step"] -# if isinstance(step_ds, h5py.Dataset): -# steps.append(int(step_ds[()])) -# else: -# raise ValueError(f"Unexpected type {type(step_ds)}") -# outsteps = sorted(outsteps, key=lambda x: int(x.replace("Step", ""))) -# steps = sorted(steps) -# times = np.array(sorted(times), dtype=np.float64) -# return { -# "quantities": quantities, -# "outsteps": outsteps, -# "steps": steps, -# "times": times, -# } -# - - # fields def _read_coordinates(coords: list[str], file: h5py.File): for st in file: diff --git a/nt2/plotters/polarplot.py b/nt2/plotters/polarplot.py index 465872a..d4bbd02 100644 --- a/nt2/plotters/polarplot.py +++ b/nt2/plotters/polarplot.py @@ -128,7 +128,7 @@ def fieldplot( else: raise ValueError("Unknown sampling template: " + template) - fieldlines = self.fieldlines(fr, fth, start_points, **kwargs).compute() + fieldlines = self.fieldlines(fr, fth, start_points, **kwargs) ax = kwargs.pop("ax", plt.gca()) for fieldline in fieldlines: if invert_x: @@ -137,7 +137,6 @@ def fieldplot( fieldline[:, 1] = -fieldline[:, 1] ax.plot(*fieldline.T, **kwargs) - @delayed def fieldlines(self, fr, fth, start_points, **kwargs): """ Compute field lines of a vector field defined by functions fr and fth. From 723ce111b729f2b3b6c4b478b7271834dfc53164 Mon Sep 17 00:00:00 2001 From: hayk Date: Thu, 6 Mar 2025 17:10:55 -0500 Subject: [PATCH 5/9] major update --- README.md | 96 ++++++- nt2/__init__.py | 3 +- nt2/containers/container.py | 105 ++++---- nt2/containers/fields.py | 54 ++++ nt2/containers/particles.py | 20 ++ nt2/containers/spectra.py | 20 ++ nt2/containers/utils.py | 16 ++ nt2/data.py | 91 +++++++ nt2/export.py | 73 +++++- nt2/plotters/{plot.py => annotations.py} | 0 nt2/plotters/inspect.py | 320 +++++++++++++++++++++++ nt2/plotters/movie.py | 33 +++ nt2/plotters/{polarplot.py => polar.py} | 22 +- nt2/read.py | 36 --- 14 files changed, 766 insertions(+), 123 deletions(-) create mode 100644 nt2/data.py rename nt2/plotters/{plot.py => annotations.py} (100%) create mode 100644 nt2/plotters/inspect.py create mode 100644 nt2/plotters/movie.py rename nt2/plotters/{polarplot.py => polar.py} (96%) delete mode 100644 nt2/read.py diff --git a/README.md b/README.md index 9e1fe80..85e4955 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,103 @@ ## nt2.py -Python package for visualization and post-processing of the [`Entity`](https://github.com/entity-toolkit/entity) simulation data. For usage, please refer to the [documentation](https://entity-toolkit.github.io/entity/howto/vis/#nt2py). The package is distributed via [`PyPI`](https://pypi.org/project/nt2py/): +Python package for visualization and post-processing of the [`Entity`](https://github.com/entity-toolkit/entity) simulation data. For usage, please refer to the [documentation](https://entity-toolkit.github.io/wiki/getting-started/vis/#nt2py). The package is distributed via [`PyPI`](https://pypi.org/project/nt2py/): ```sh pip install nt2py ``` +### Usage + +The Library works both with single-file output as well as with separate files. In either case, the location of the data is passed via `path` keyword argument. + +```python +import nt2 + +data = nt2.Data(path="path/to/data") +# example: +# data = nt2.Data(path="path/to/shock.h5") : for single-file +# data = nt2.Data(path="path/to/shock") : for multi-file +``` + +The data is stored in specialized containers which can be accessed via corresponding attributes: + +```python +data.fields # < xr.Dataset +data.particles # < dict[int : xr.Dataset] +data.spectra # < xr.Dataset +``` + +#### Examples + +Plot a field (in cartesian space) at a specific time (or output step): + +```python +data.fields.Ex.sel(t=10.0, method="nearest").plot() # time ~ 10 +data.fields.Ex.isel(t=5).plot() # output step = 5 +``` + +Plot a slice or time-averaged field quantities: + +```python +data.fields.Bz.mean("t").plot() +data.fields.Bz.sel(t=10.0, x=0.5, method="nearest").plot() +``` + +Plot in spherical coordinates (+ combine several fields): + +```python +e_dot_b = (data.fields.Er * data.fields.Br +\ + data.fields.Eth * data.fields.Bth +\ + data.fields.Eph * data.fields.Bph) +bsqr = data.fields.Br**2 + data.fields.Bth**2 + data.fields.Bph**2 +# only plot radial extent of up to 10 +(e_dot_b / bsqr).sel(t=50.0, method="nearest").sel(r=slice(None, 10)).polar.pcolor() +``` + +You can also quickly plot the fields at a specific time using the handy `.inspect` accessor: + +```python +data.fields\ + .sel(t=3.0, method="nearest")\ + .sel(x=slice(-0.2, 0.2))\ + .inspect.plot(only_fields=["E", "B"]) +# Hint: use `<...>.plot?` to see all options +``` + +Or if no time is specified, it will create a quick movie (need to also provide a `name` in that case): + +```python +data.fields\ + .sel(x=slice(-0.2, 0.2))\ + .inspect.plot(name="inspect", only_fields=["E", "B", "N"]) +``` + +You can also create a movie of a single field quantity (can be custom): + +```python +(data.fields.Ex * data.fields.Bx).sel(x=slice(None, 0.2)).movie.plot(name="ExBx", vmin=-0.01, vmax=0.01, cmap="BrBG") +``` + +You may also combine different quantities and plots (e.g., fields & particles) to produce a more customized movie: + +```python +def plot(t, data): + fig, ax = mpl.pyplot.subplots() + data.fields.Ex.sel(t=t, method="nearest").sel(x=slice(None, 0.2)).plot( + ax=ax, vmin=-0.001, vmax=0.001, cmap="BrBG" + ) + for sp in range(1, 3): + ax.scatter( + data.particles[sp].sel(t=t, method="nearest").x, + data.particles[sp].sel(t=t, method="nearest").y, + c="r" if sp == 1 else "b", + ) + ax.set_aspect(1) +data.makeMovie(plot) +``` + +> If using Jupyter notebook, you can quickly preview the loaded metadata by simply running a cell with just `data` in it (or in regular python, by doing `print(data)`). + ### Features 1. Lazy loading and parallel processing of the simulation data with [`dask`](https://dask.org/). @@ -16,4 +108,4 @@ pip install nt2py - [ ] Unit tests - [ ] Plugins for other simulation data formats -- [ ] Usage examples +- [x] Usage examples diff --git a/nt2/__init__.py b/nt2/__init__.py index 90d4cd0..c368e63 100644 --- a/nt2/__init__.py +++ b/nt2/__init__.py @@ -1,4 +1,3 @@ __version__ = "0.5.0" -from nt2.read import Data as Data -from nt2.plotters import polarplot as polarplot +from nt2.data import Data as Data diff --git a/nt2/containers/container.py b/nt2/containers/container.py index bc245d7..0270c57 100644 --- a/nt2/containers/container.py +++ b/nt2/containers/container.py @@ -2,7 +2,6 @@ import h5py import numpy as np from typing import Any -from dask.distributed import Client def _read_attribs_SingleFile(file: h5py.File): @@ -17,16 +16,62 @@ def _read_attribs_SingleFile(file: h5py.File): class Container: + """ + * * * * Container * * * * + + Parent class for all data containers. + + Args + ---- + path : str + The path to the data. + + Kwargs + ------ + single_file : bool, optional + Whether the data is stored in a single file. Default is False. + + pickle : bool, optional + Whether to use pickle for reading the data. Default is True. + + greek : bool, optional + Whether to use Greek letters for the spherical coordinates. Default is False. + + dask_props : dict, optional + Additional properties for Dask [NOT IMPLEMENTED]. Default is {}. + + Attributes + ---------- + path : str + The path to the data. + + configs : dict + The configuration settings for the data. + + metadata : dict + The metadata for the data. + + mesh : dict + Coordinate grid of the domain (cell-centered & edges). + + master_file : h5py.File + The master file for the data (from which the main attributes are read). + + attrs : dict + The attributes of the master file. + + Methods + ------- + plotGrid(ax, **kwargs) + Plots the gridlines of the domain. + + """ + def __init__( self, path, single_file=False, pickle=True, greek=False, dask_props={} ): super(Container, self).__init__() - self.client = Client(**dask_props) - if self.client.status == "running": - print("Dask client launched:") - print(self.client) - self.configs: dict[str, Any] = { "single_file": single_file, "use_pickle": pickle, @@ -64,16 +109,10 @@ def __init__( if not self.configs["single_file"]: self.master_file.close() self.master_file = None - # if coordinates == "sph": - # self.metric = SphericalMetric() - # else: - # self.metric = MinkowskiMetric() def __del__(self): if self.master_file is not None: self.master_file.close() - if self.client.status == "running": - self.client.close() def plotGrid(self, ax, **kwargs): from matplotlib import patches @@ -117,45 +156,3 @@ def plotGrid(self, ax, **kwargs): **options, ) ax.set(xlim=xlim, ylim=ylim) - - def print_container(self) -> str: - return f"Client {self.client}\n" - - # def makeMovie(self, plot, makeframes=True, **kwargs): - # """ - # Makes a movie from a plot function - # - # Parameters - # ---------- - # plot : function - # The plot function to use; accepts output timestep and dataset as arguments. - # makeframes : bool, optional - # Whether to make the frames, or just proceed to making the movie. Default is True. - # num_cpus : int, optional - # The number of CPUs to use for making the frames. Default is None. - # **kwargs : - # Additional keyword arguments passed to `ffmpeg`. - # """ - # import numpy as np - # - # if makeframes: - # makemovie = all( - # exp.makeFrames( - # plot, - # np.arange(len(self.t)), - # f"{self.attrs['simulation.name']}/frames", - # data=self, - # num_cpus=kwargs.pop("num_cpus", None), - # ) - # ) - # else: - # makemovie = True - # if makemovie: - # exp.makeMovie( - # input=f"{self.attrs['simulation.name']}/frames/", - # overwrite=True, - # output=f"{self.attrs['simulation.name']}.mp4", - # number=5, - # **kwargs, - # ) - # return True diff --git a/nt2/containers/fields.py b/nt2/containers/fields.py index 69ab5fd..ba6a823 100644 --- a/nt2/containers/fields.py +++ b/nt2/containers/fields.py @@ -9,8 +9,62 @@ _preload_field, ) +from nt2.plotters.polar import ( + _datasetPolarPlotAccessor, + _polarPlotAccessor, +) + +from nt2.plotters.inspect import _datasetInspectPlotAccessor +from nt2.plotters.movie import _moviePlotAccessor + +from nt2.containers.utils import InheritClassDocstring + + +@xr.register_dataset_accessor("polar") +@InheritClassDocstring +class DatasetPolarPlotAccessor(_datasetPolarPlotAccessor): + pass + + +@xr.register_dataarray_accessor("polar") +@InheritClassDocstring +class PolarPlotAccessor(_polarPlotAccessor): + pass + + +@xr.register_dataset_accessor("inspect") +@InheritClassDocstring +class DatasetInspectPlotAccessor(_datasetInspectPlotAccessor): + pass + + +@xr.register_dataarray_accessor("movie") +@InheritClassDocstring +class MoviePlotAccessor(_moviePlotAccessor): + pass + class FieldsContainer(Container): + """ + * * * * FieldsContainer : Container * * * * + + Class for hodling the field (grid-based) data. + + Attributes + ---------- + fields : xarray.Dataset + The xarray dataset for all the field quantities. + + fields_files : list + The list of opened fields files. + + Methods + ------- + print_fields() + Prints the basic information about the field data. + + """ + def __init__(self, **kwargs): super(FieldsContainer, self).__init__(**kwargs) QuantityDict = { diff --git a/nt2/containers/particles.py b/nt2/containers/particles.py index 43be4ec..61543e6 100644 --- a/nt2/containers/particles.py +++ b/nt2/containers/particles.py @@ -9,6 +9,26 @@ class ParticleContainer(Container): + """ + * * * * ParticleContainer : Container * * * * + + Class for holding the particle data. + + Attributes + ---------- + particles : dict + The dictionary of particle species. + + particle_files : list + The list of opened particle files. + + Methods + ------- + print_particles() + Prints the basic information about the particle data. + + """ + def __init__(self, **kwargs): super(ParticleContainer, self).__init__(**kwargs) PrtlDict = { diff --git a/nt2/containers/spectra.py b/nt2/containers/spectra.py index 9d14b40..27d43ad 100644 --- a/nt2/containers/spectra.py +++ b/nt2/containers/spectra.py @@ -12,6 +12,26 @@ class SpectraContainer(Container): + """ + * * * * SpectraContainer : Container * * * * + + Class for holding the spectra (energy distribution) data. + + Attributes + ---------- + spectra : xarray.Dataset + The xarray dataset of particle distributions. + + spectra_files : list + The list of opened spectra files. + + Methods + ------- + print_spectra() + Prints the basic information about the spectra data. + + """ + def __init__(self, **kwargs): super(SpectraContainer, self).__init__(**kwargs) assert "single_file" in self.configs diff --git a/nt2/containers/utils.py b/nt2/containers/utils.py index 470c5d4..1188ccb 100644 --- a/nt2/containers/utils.py +++ b/nt2/containers/utils.py @@ -3,6 +3,22 @@ import xarray as xr from dask.array.core import from_array from dask.array.core import stack +import inspect + + +def InheritClassDocstring(cls): + if cls.__doc__ is None: + cls.__doc__ = "" + for base in inspect.getmro(cls): + if base.__doc__ is not None: + cls.__doc__ += base.__doc__ + return cls + + +def _dataIs2DPolar(ds): + return ("r" in ds.dims and ("θ" in ds.dims or "th" in ds.dims)) and len( + ds.dims + ) == 2 def _read_category_metadata( diff --git a/nt2/data.py b/nt2/data.py new file mode 100644 index 0000000..da64bc5 --- /dev/null +++ b/nt2/data.py @@ -0,0 +1,91 @@ +from nt2.containers.fields import FieldsContainer +from nt2.containers.particles import ParticleContainer +from nt2.containers.spectra import SpectraContainer + +from nt2.containers.utils import InheritClassDocstring +from nt2.export import _makeFramesAndMovie + + +@InheritClassDocstring +class Data(FieldsContainer, ParticleContainer, SpectraContainer): + """ + * * * * Data : FieldsContainer, ParticleContainer, SpectraContainer * * * * + + Master class for holding the whole simulation data. + Inherits attributes & methods from more specialized classes. + + """ + + def __init__(self, **kwargs): + """ + Kwargs + ------ + single_file : bool, optional + Whether the data is stored in a single file. Default is False. + + pickle : bool, optional + Whether to use pickle for reading the data. Default is True. + + greek : bool, optional + Whether to use Greek letters for the spherical coordinates. Default is False. + + dask_props : dict, optional + Additional properties for Dask [NOT IMPLEMENTED]. Default is {}. + + """ + super(Data, self).__init__(**kwargs) + if "path" not in kwargs: + raise ValueError('Usage example: data = nt2.Data(path="...", ...)') + + def __repr__(self) -> str: + help = "Usage: \n" + help += ' data = Data(path="...", ...)\n' + help += " data.fields\n" + help += " data.particles\n" + help += " data.spectra\n" + return ( + help + + "\n" + + self.print_fields() + + "\n" + + self.print_particles() + + "\n" + + self.print_spectra() + ) + + def __str__(self) -> str: + return self.__repr__() + + def __del__(self): + super().__del__() + + def makeMovie(self, plot, times=None, **kwargs): + """ + Makes a movie from a plot function + + Parameters + ---------- + plot : function + The plot function to use; accepts output timestep indices or timestamps and, optionally, + the dataset as arguments. + + times : array_like, optional + Either time indices or timestamps to use for generating the movie. Default is None. + If None, will use timestamps from the fields, + which might not coincide with values from other quantities. + + + **kwargs : + Additional keyword arguments passed to `ffmpeg`. + + """ + + if times is None: + times = self.fields.t.values + return _makeFramesAndMovie( + name=self.attrs["simulation.name"], + data=self, + plot=plot, + times=times, + **kwargs, + ) diff --git a/nt2/export.py b/nt2/export.py index 6d164e8..2dfb9a3 100644 --- a/nt2/export.py +++ b/nt2/export.py @@ -1,43 +1,76 @@ -def makeMovie(**ffmpeg_kwargs): +def _makeFramesAndMovie(name, data, plot, times, **kwargs): + num_cpus = kwargs.pop("num_cpus", None) + if all( + makeFrames( + plot=plot, + times=times, + fpath=f"{name}/frames", + data=data, + num_cpus=num_cpus, + ) + ): + print(f"Frames saved in {name}/frames") + output = kwargs.pop("output", f"{name}.mp4") + if makeMovie( + input=f"{name}/frames/", + overwrite=True, + output=output, + number=5, + **kwargs, + ): + print(f"Movie {name}.mp4 created successfully") + return True + else: + return False + else: + raise ValueError("Failed to make frames") + + +def makeMovie(**kwargs): """ Create a movie from frames using the `ffmpeg` command-line tool. + Parameters ---------- - ffmpeg_kwargs : dict + kwargs : dict Keyword arguments for the `ffmpeg` command-line tool. + Returns ------- bool True if the movie was created successfully, False otherwise. + Notes ----- This function uses the `subprocess` module to execute the `ffmpeg` command-line tool with the given arguments. + Examples -------- >>> makeMovie(ffmpeg="/path/to/ffmpeg", framerate="30", start="0", input="step_", number=3, extension="png", compression="1", overwrite=True, output="anim.mp4") + """ import subprocess command = [ - ffmpeg_kwargs.get("ffmpeg", "ffmpeg"), + kwargs.get("ffmpeg", "ffmpeg"), "-nostdin", "-framerate", - ffmpeg_kwargs.get("framerate", "30"), + kwargs.get("framerate", "30"), "-start_number", - ffmpeg_kwargs.get("start", "0"), + kwargs.get("start", "0"), "-i", - ffmpeg_kwargs.get("input", "step_") - + f"%0{ffmpeg_kwargs.get('number', 3)}d.{ffmpeg_kwargs.get('extension', 'png')}", + kwargs.get("input", "step_") + + f"%0{kwargs.get('number', 3)}d.{kwargs.get('extension', 'png')}", "-c:v", "libx264", "-crf", - ffmpeg_kwargs.get("compression", "1"), + kwargs.get("compression", "1"), "-filter_complex", "[0:v]format=yuv420p,pad=ceil(iw/2)*2:ceil(ih/2)*2", - "-y" if ffmpeg_kwargs.get("overwrite", False) else None, - ffmpeg_kwargs.get("output", "anim.mp4"), + "-y" if kwargs.get("overwrite", False) else None, + kwargs.get("output", "anim.mp4"), ] command = [str(c) for c in command if c is not None] print("Command:\n", " ".join(command)) @@ -51,37 +84,49 @@ def makeMovie(**ffmpeg_kwargs): return False -def makeFrames(plot, steps, fpath, data=None, num_cpus=None): +def makeFrames(plot, times, fpath, data=None, num_cpus=None): """ Create plot frames from a set of timesteps of the same dataset. + Parameters ---------- plot : function A function that generates and saves the plot. The function must take a time index - as an argument. - steps : array_like, optional + or a timestamp as an argument and, optionally, the data object. + + times : array_like, optional The time indices to use for generating the movie. + Can either be timestep indices or timestamps. + Must coincide with the time accepted by the `plot` function. + fpath : str The file path to save the frames. + data : xarray.Dataset, optional The dataset to use for generating the movie (passed to plot as the second argument) + num_cpus : int, optional The number of CPUs to use for parallel processing. If None, use all available CPUs. + Returns ------- list A list of results returned by the `plot` function, one for each time index. + Raises ------ ValueError If `plot` is not a callable function. + Notes ----- This function uses the `multiprocessing` module to parallelize the generation of the plots, and `tqdm` module to display a progress bar. + Examples -------- >>> makeFrames(plot_func, range(100), 'output/', num_cpus=16) + """ from tqdm import tqdm @@ -113,6 +158,6 @@ def plotAndSave(ti, t, fpath): if not os.path.exists(fpath): os.makedirs(fpath) - tasks = [[ti, t, fpath] for ti, t in enumerate(steps)] + tasks = [[ti, t, fpath] for ti, t in enumerate(times)] results = [pool.apply_async(plotAndSave, t) for t in tasks] return [result.get() for result in tqdm(results)] diff --git a/nt2/plotters/plot.py b/nt2/plotters/annotations.py similarity index 100% rename from nt2/plotters/plot.py rename to nt2/plotters/annotations.py diff --git a/nt2/plotters/inspect.py b/nt2/plotters/inspect.py new file mode 100644 index 0000000..6fcc084 --- /dev/null +++ b/nt2/plotters/inspect.py @@ -0,0 +1,320 @@ +from nt2.containers.utils import _dataIs2DPolar +from nt2.export import _makeFramesAndMovie + + +class _datasetInspectPlotAccessor: + def __init__(self, xarray_obj): + self._obj = xarray_obj + + def plot( + self, + fig=None, + name=None, + skip_fields=[], + only_fields=[], + fig_kwargs={}, + plot_kwargs={}, + movie_kwargs={}, + ): + """ + Plots the overview plot for fields at a given time or step (or as a movie). + + Kwargs + ------ + fig : matplotlib.figure.Figure, optional + The figure to plot the data (if None, a new figure is created). Default is None. + + name : string, optional + Used when saving the frames and the movie. Default is None. + + skip_fields : list, optional + The list of fields to skip in the plotting (can contain regex). Default is []. + + only_fields : list, optional + The list of fields to plot (con contain regex). Default is []. + If empty, all fields are plotted unless contained in skip_fields). + Overrides skip_fields. + + fig_kwargs : dict, optional + Additional keyword arguments for plt.figure. Default is { dpi: 200 }. + + plot_kwargs : dict, optional + Keyword arguments for each plot. Default is {}. + Key is a regex pattern to match the field name. Value is dict of kwargs. + + movie_kwargs : dict, optional + Additional keyword arguments for makeMovie. Default is {}. + + Returns + ------- + figure : matplotlib.figure.Figure | boolean + The figure with the plotted data (if single timestep) or True/False. + + """ + if "t" in self._obj.dims: + if name is None: + raise ValueError( + "Please provide a name for saving the frames and movie" + ) + + def plot_func(ti, _): + self.plot_frame( + self._obj.isel(t=ti), + None, + skip_fields, + only_fields, + fig_kwargs, + plot_kwargs, + ) + + return _makeFramesAndMovie( + name=name, + data=self._obj, + plot=plot_func, + times=list(range(len(self._obj.t))), + **movie_kwargs, + ) + else: + return self.plot_frame( + self._obj, fig, skip_fields, only_fields, fig_kwargs, plot_kwargs + ) + + def plot_frame(self, data, fig, skip_fields, only_fields, fig_kwargs, plot_kwargs): + if len(data.dims) != 2: + raise ValueError("Pass 2D data; use .sel or .isel to reduce dimension.") + + x1, x2 = data.dims + + import matplotlib.pyplot as plt + from matplotlib.gridspec import GridSpec, GridSpecFromSubplotSpec + import matplotlib.colors as mcolors + import numpy as np + import re + import math + + # count the number of subplots + nfields = len(data.data_vars) + if nfields > 0: + if len(only_fields) == 0: + fields_to_plot = [ + f + for f in list(data.keys()) + if not any([re.match(sf, f) for sf in skip_fields]) + ] + else: + fields_to_plot = [ + f + for f in list(data.keys()) + if any([re.match(sf, f) for sf in only_fields]) + ] + else: + fields_to_plot = [] + + if fields_to_plot == []: + raise ValueError("No fields to plot.") + + nfields = len(fields_to_plot) + + aspect = 1 + if _dataIs2DPolar(data): + aspect = 0.5 + else: + aspect = len(data[x1]) / len(data[x2]) + + ncols = 3 if aspect <= 1.15 else int(math.ceil(nfields / 3)) + nrows = 3 if aspect > 1.15 else int(math.ceil(nfields / 3)) + + figsize0 = 3 + + if fig is None: + dpi = fig_kwargs.pop("dpi", 200) + fig = plt.figure( + figsize=( + figsize0 * ncols * aspect * (1 + 0.2 / aspect), + figsize0 * nrows, + ), + dpi=dpi, + **fig_kwargs, + ) + + gs = GridSpec(nrows, ncols, wspace=0.2 / aspect) + gs_for_axes = [ + GridSpecFromSubplotSpec( + 1, + 2, + subplot_spec=gs[i], + width_ratios=[1, max(0.025 / aspect, 0.025)], + wspace=0.01, + ) + for i in range(nfields) + ] + if aspect <= 1.15: + axes = [ + fig.add_subplot(gs_for_axes[i * ncols + j][0]) + for i in range(nrows) + for j in range(ncols) + if i * ncols + j < nfields + ] + cbars = [ + fig.add_subplot(gs_for_axes[i * ncols + j][1]) + for i in range(nrows) + for j in range(ncols) + if i * ncols + j < nfields + ] + else: + axes = [ + fig.add_subplot(gs_for_axes[i * ncols + j][0]) + for j in range(ncols) + for i in range(nrows) + if i * ncols + j < nfields + ] + cbars = [ + fig.add_subplot(gs_for_axes[i * ncols + j][1]) + for j in range(ncols) + for i in range(nrows) + if i * ncols + j < nfields + ] + + # find minmax for all components + minmax: dict[str, None | tuple] = { + "E": None, + "B": None, + "J": None, + "N": None, + "T": None, + } + for fld in fields_to_plot: + vmin, vmax = ( + data[fld].min().values[()], + data[fld].max().values[()], + ) + if fld[0] in "EBJNT": + if minmax[fld[0]] is None: + minmax[fld[0]] = (vmin, vmax) + else: + minmax[fld[0]] = ( + min(minmax[fld[0]][0], vmin), + max(minmax[fld[0]][1], vmax), + ) + for f, vv in minmax.items(): + if vv is not None: + (vmin, vmax) = vv + if vmin < 0 or f in "EBJ": + if abs(vmin) > vmax: + vmax = abs(vmin) + else: + vmin = -vmax + minmax[f] = (vmin, vmax) + + kwargs = {} + for fld in fields_to_plot: + cmap = "viridis" + if fld.startswith("N"): + cmap = "inferno" + elif fld.startswith("E"): + cmap = "seismic" + elif fld.startswith("B"): + cmap = "BrBG" + elif fld.startswith("J"): + cmap = "coolwarm" + if fld[0] in "EBJNT": + if minmax[fld[0]] is not None: + vmin, vmax = minmax[fld[0]] + else: + raise ValueError(f"Field {fld} not found in minmax.") + else: + vmin, vmax = ( + data[fld].min().values[()], + data[fld].max().values[()], + ) + if vmin < 0: + if abs(vmin) > vmax: + vmax = abs(vmin) + else: + vmin = -vmax + + default_kwargs = { + "cmap": cmap, + "vmin": vmin, + "vmax": vmax, + } + kwargs[fld] = default_kwargs + for fld_kwargs in plot_kwargs: + if re.match(fld_kwargs, fld): + kwargs[fld] = {**default_kwargs, **plot_kwargs[fld_kwargs]} + break + if "norm" in kwargs[fld]: + kwargs[fld].pop("vmin") + kwargs[fld].pop("vmax") + + if _dataIs2DPolar(data): + raise NotImplementedError("Polar plots for inspect not implemented yet.") + else: + for fld, ax in zip(fields_to_plot, axes): + data[fld].plot(ax=ax, add_colorbar=False, **kwargs[fld]) + + for i, (ax, cbar, fld) in enumerate(zip(axes, cbars, fields_to_plot)): + cbar.set(xticks=[], xlabel=None, ylabel=None) + cbar.yaxis.tick_right() + vmin, vmax = ax.collections[0].get_clim() + if vmin == vmax: + vmin = -1 + vmax = 1 + data_norm = None + coeff_pow = 0 + if abs(vmax) < 0.1 or abs(vmax) > 999: + coeff_pow = int(np.log10(abs(vmax))) - 1 + coeff = 10**coeff_pow + vmin /= coeff + vmax /= coeff + if isinstance(ax.collections[0].norm, mcolors.LogNorm): + cbar.set(ylim=(vmin, vmax), yscale="log") + data_norm = mcolors.LogNorm(vmin=vmin, vmax=vmax) + ys = np.logspace(np.log10(vmin), np.log10(vmax)) + cbar.pcolor( + [0, 1], + ys, + np.transpose([ys] * 2), + cmap=kwargs[fld]["cmap"], + rasterized=True, + norm=data_norm, + ) + elif isinstance(ax.collections[0].norm, mcolors.SymLogNorm): + raise NotImplementedError("SymLogNorm not implemented yet.") + else: + cbar.set(ylim=(vmin, vmax)) + ys = np.linspace(vmin, vmax) + cbar.pcolor( + [0, 1], + ys, + np.transpose([ys] * 2), + cmap=kwargs[fld]["cmap"], + rasterized=True, + norm=mcolors.Normalize(vmin=vmin, vmax=vmax), + ) + ax.set( + title=f"{fld}{'' if coeff_pow == 0 else fr' [$\cdot 10^{-coeff_pow}$]'}" + ) + + for n, ax in enumerate(axes): + if aspect > 1.15: + i = n % nrows + j = n // nrows + else: + i = n // ncols + j = n % ncols + + if j != 0: + ax.set( + ylabel=None, + yticklabels=[], + ) + if (nfields - i * ncols - j) > ncols: + ax.set( + xlabel=None, + xticklabels=[], + ) + ax.set(aspect=1) + + fig.suptitle(f"t = {data.t.values[()]:.2f}", y=0.95) + return fig diff --git a/nt2/plotters/movie.py b/nt2/plotters/movie.py new file mode 100644 index 0000000..277e96a --- /dev/null +++ b/nt2/plotters/movie.py @@ -0,0 +1,33 @@ +from nt2.export import _makeFramesAndMovie + + +class _moviePlotAccessor: + def __init__(self, xarray_obj): + self._obj = xarray_obj + + def plot(self, name, movie_kwargs={}, *args, **kwargs): + if "t" not in self._obj.dims: + raise ValueError("The dataset does not have a time dimension.") + + import matplotlib.pyplot as plt + + def plot_func(ti, _): + if len(self._obj.isel(t=ti).dims) == 2: + x1, x2 = self._obj.isel(t=ti).dims + nx1, nx2 = len(self._obj.isel(t=ti)[x1]), len(self._obj.isel(t=ti)[x2]) + aspect = nx1 / nx2 + plt.figure(figsize=(6, 4 * aspect)) + self._obj.isel(t=ti).plot(*args, **kwargs) + if len(self._obj.isel(t=ti).dims) == 2: + plt.gca().set_aspect("equal") + plt.tight_layout() + + num_cpus = movie_kwargs.pop("num_cpus", None) + return _makeFramesAndMovie( + name=name, + data=self._obj, + plot=plot_func, + times=list(range(len(self._obj.t))), + num_cpus=num_cpus, + **movie_kwargs, + ) diff --git a/nt2/plotters/polarplot.py b/nt2/plotters/polar.py similarity index 96% rename from nt2/plotters/polarplot.py rename to nt2/plotters/polar.py index d4bbd02..c5a9b5d 100644 --- a/nt2/plotters/polarplot.py +++ b/nt2/plotters/polar.py @@ -1,13 +1,7 @@ -from dask.delayed import delayed -import xarray as xr import numpy as np from typing import Any - -def DataIs2DPolar(ds): - return ("r" in ds.dims and ("θ" in ds.dims or "th" in ds.dims)) and len( - ds.dims - ) == 2 +from nt2.containers.utils import _dataIs2DPolar def DipoleSampling(**kwargs): @@ -59,14 +53,13 @@ def MonopoleSampling(**kwargs): return np.linspace(0, np.pi, nth + 2)[1:-1] -@xr.register_dataset_accessor("polar") -class DatasetPolarPlotAccessor: +class _datasetPolarPlotAccessor: def __init__(self, xarray_obj): self._obj = xarray_obj def pcolor(self, value, **kwargs): assert "t" not in self._obj[value].dims, "Time must be specified" - assert DataIs2DPolar(self._obj), "Data must be 2D polar" + assert _dataIs2DPolar(self._obj), "Data must be 2D polar" self._obj[value].polar.pcolor(**kwargs) def fieldplot( @@ -173,7 +166,7 @@ def fieldlines(self, fr, fth, start_points, **kwargs): assert "t" not in self._obj[fr].dims, "Time must be specified" assert "t" not in self._obj[fth].dims, "Time must be specified" - assert DataIs2DPolar(self._obj), "Data must be 2D polar" + assert _dataIs2DPolar(self._obj), "Data must be 2D polar" useGreek = "θ" in self._obj.coords.keys() @@ -249,8 +242,7 @@ def integrate(delta, counter): return np.append(f2[::-1], f1, axis=0) -@xr.register_dataarray_accessor("polar") -class PolarPlotAccessor: +class _polarPlotAccessor: def __init__(self, xarray_obj): self._obj = xarray_obj @@ -329,7 +321,7 @@ def pcolor(self, **kwargs): assert ax.name != "polar", "`ax` must be a rectilinear projection" assert "t" not in self._obj.dims, "Time must be specified" - assert DataIs2DPolar(self._obj), "Data must be 2D polar" + assert _dataIs2DPolar(self._obj), "Data must be 2D polar" ax.grid(False) if type(kwargs.get("norm", None)) is colors.LogNorm: cm = kwargs.get("cmap", "viridis") @@ -464,7 +456,7 @@ def contour(self, **kwargs): assert ax.name != "polar", "`ax` must be a rectilinear projection" assert "t" not in self._obj.dims, "Time must be specified" - assert DataIs2DPolar(self._obj), "Data must be 2D polar" + assert _dataIs2DPolar(self._obj), "Data must be 2D polar" ax.grid(False) r, th = np.meshgrid( self._obj.coords["r"], self._obj.coords["θ" if useGreek else "th"] diff --git a/nt2/read.py b/nt2/read.py deleted file mode 100644 index 8bdc0ab..0000000 --- a/nt2/read.py +++ /dev/null @@ -1,36 +0,0 @@ -from nt2.containers.fields import FieldsContainer -from nt2.containers.particles import ParticleContainer -from nt2.containers.spectra import SpectraContainer - - -class Data(FieldsContainer, ParticleContainer, SpectraContainer): - """ - A class to load Entity data and store it as a lazily loaded xarray Dataset. - """ - - def __init__(self, **kwargs): - super(Data, self).__init__(**kwargs) - - def __repr__(self) -> str: - help = "Usage: \n" - help += " data = Data(path, ...)\n" - help += " data.fields\n" - help += " data.particles\n" - help += " data.spectra\n" - return ( - help - + "\n" - + self.print_container() - + "\n" - + self.print_fields() - + "\n" - + self.print_particles() - + "\n" - + self.print_spectra() - ) - - def __str__(self) -> str: - return self.__repr__() - - def __del__(self): - super().__del__() From da9ee2c883069420fdd8cd8b235e500780e6597e Mon Sep 17 00:00:00 2001 From: hayk Date: Thu, 6 Mar 2025 17:46:43 -0500 Subject: [PATCH 6/9] minor changes in makeMovie --- nt2/export.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/nt2/export.py b/nt2/export.py index 2dfb9a3..7f28d82 100644 --- a/nt2/export.py +++ b/nt2/export.py @@ -26,7 +26,7 @@ def _makeFramesAndMovie(name, data, plot, times, **kwargs): raise ValueError("Failed to make frames") -def makeMovie(**kwargs): +def makeMovie(**ffmpeg_kwargs): """ Create a movie from frames using the `ffmpeg` command-line tool. @@ -47,30 +47,29 @@ def makeMovie(**kwargs): Examples -------- - >>> makeMovie(ffmpeg="/path/to/ffmpeg", framerate="30", start="0", input="step_", number=3, - extension="png", compression="1", overwrite=True, output="anim.mp4") - + >>> makeMovie(ffmpeg="/path/to/ffmpeg", framerate=30, start=0, input="step_", number=3, + extension="png", compression=1, overwrite=True, output="anim.mp4") """ import subprocess command = [ - kwargs.get("ffmpeg", "ffmpeg"), + ffmpeg_kwargs.get("ffmpeg", "ffmpeg"), "-nostdin", "-framerate", - kwargs.get("framerate", "30"), + str(ffmpeg_kwargs.get("framerate", 30)), "-start_number", - kwargs.get("start", "0"), + str(ffmpeg_kwargs.get("start", 0)), "-i", - kwargs.get("input", "step_") - + f"%0{kwargs.get('number', 3)}d.{kwargs.get('extension', 'png')}", + ffmpeg_kwargs.get("input", "step_") + + f"%0{ffmpeg_kwargs.get('number', 3)}d.{ffmpeg_kwargs.get('extension', 'png')}", "-c:v", "libx264", "-crf", - kwargs.get("compression", "1"), + str(ffmpeg_kwargs.get("compression", 1)), "-filter_complex", "[0:v]format=yuv420p,pad=ceil(iw/2)*2:ceil(ih/2)*2", - "-y" if kwargs.get("overwrite", False) else None, - kwargs.get("output", "anim.mp4"), + "-y" if ffmpeg_kwargs.get("overwrite", False) else None, + ffmpeg_kwargs.get("output", "movie.mp4"), ] command = [str(c) for c in command if c is not None] print("Command:\n", " ".join(command)) From 18b1e02bec47a0a66cf314c9b93ffe750a48b989 Mon Sep 17 00:00:00 2001 From: hayk Date: Thu, 6 Mar 2025 17:54:01 -0500 Subject: [PATCH 7/9] built dist + LICENSE --- LICENSE | 2 +- dist/nt2py-0.5.0-py3-none-any.whl | Bin 0 -> 25674 bytes dist/nt2py-0.5.0.tar.gz | Bin 0 -> 21079 bytes pyproject.toml | 48 +++++++++++++++--------------- requirements.txt | 1 - 5 files changed, 25 insertions(+), 26 deletions(-) create mode 100644 dist/nt2py-0.5.0-py3-none-any.whl create mode 100644 dist/nt2py-0.5.0.tar.gz diff --git a/LICENSE b/LICENSE index 9f01e33..3ac8c81 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2023, Entity toolkit +Copyright (c) 2025, Entity development team Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/dist/nt2py-0.5.0-py3-none-any.whl b/dist/nt2py-0.5.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..f6cde0fa73a60e3d489c9e5262fffb6a96b09d08 GIT binary patch literal 25674 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%0DFpWC=hu@S=##S>2+c9eM}BVG+iN>t*WscZ=Gh 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 z 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

*2#+8gWG>j4+VY+|9< zYp?vQ`^p1vCr|4X*L%97YEFmZuu@I~2u8K%M(8DUMCksvKlmkE1x6myqxszDX7;_# z=c5DQxmnL69;)K%Y$NQr77R;#2!ptWoBYr_Y1i03RMJDFaTHd&@#(%Mbo%@YPDl{yXX#V7jW9Ffm*Jj#WI!2WgZ}YZ70w$APs=*Qj|CE^{cAt@I zUO&zqkaVHBHzULp@Iav}9nLe4$0@)YMBdDAb} zKBFzsyV~`!ADsmXL$EGQ_G$0lZ`98AI`RdtANExfO`bDz$#g zXK7v)WGd&~(vD?L!vTz$@xi0`a(`L39>jUctWGLaCJPBVf7Ish15ORXqt2ovrbKj5 zbhYoupCx*634-F{2}b98Z=a0QV^gNflLU#9L0?s-gcE}s&4_LYn?iYAED@}$TZy-kgZi*||kj8|8 z*l(6jK6_|knS&>ap*iK70mAtAaHit-OT)-ksfI(knnr{N;s&hQ7$V_P$ju5g8fYmT zYCLWwO!vlV?f7EsZuQ#qnQ_{Y)N9Kzra8o{dr9XfnX{seQnDG=p3Bb~SA>i#gHYVX z&|WP&b$xH(>u({tK}1w+J`Ujvh&X;a&`$_Ay?n+9UmctM2G!nM(5?yN4>HKa6E-!* zCdlft<=B-SDYygLCwOW(a^*qRP{`eUWXZPUkSkuD$ZviKD)@{_{**|Fz^1TP+6!7F zFpVw}O(7Ll|93BQ`Kj`a!n}fO!A}QJKnhkM#~hHH*PdoiVs&9FpPi9 zoW|4ZQMK^Vh_M%wvK~?i&CmuSd?;OfemfRT{zQUpSDEX*=U8QnV&D^hugR7F;Y$VI zarNgWx48E-=DIfz{OH=xod?49Wj?NC@7JYn2iG?qy&30H^2d4)#*WiWln`n|X>G!^ zg(=rUU@r>KS$)JBP9!nt7Vx)!?0+cOs&8ajF|liK@Y9q!+3n~^^m!V8a^CjC(^(56 zpcVWkmo-UMY+?SrJ)db){;1h?=K@rDh>eA=D>6wl!rH^6K(k%I#s($e*??vYe{H`h zZhYVzsBT)@@wA?Vw6SzC^<4;T^byKw8X3j?vnRJiDy{H64UO9{5Rg0C4pD#WW89tytl?67yKIX7!j zk0?bcJR}ddKPb8kv(xA$wLAE>3Xhxpnq!{UBj~^VwD-(xY*Z_9(j}aISXk~Ta@X+4 zX!mpMaZ@*ymtlu}vB5sauTzflD;1e|6xrh4BqDmP59^!Ix6`M0#tB!i}7 z#Y-DUG1aHT4wX>#dQcF0{9+sK1Fl({0wjAP_5}o25y&bLCMTv`1UVm2QKap?wP_Fb z{l(p-G-C;hT~^Rty=y~SQE>to*Y5Uk1rd)KA9lsb8tL?aYQ)2y%csLJiy1loBZ1H% zBxO1Iswv|J@K8mF~%W*-=L;c;&uA!3DbmzwOb|)>U zVZ3m556Bn-nFyKnT`QB>+>5Nsl%q6#UD}pC>|p4+rLtA>If>);YWmH6U%GyheTd-<@(0{05PtvU7Afcc zaN>!nCy3{{2E=ob$!k;&uvEgnqW`uPDMCP@B^ey|@Kz_Qjky^q3T!dU-fFYzXs#0J(fAU9*QQo*6mf_Arz|)r|cP^ zQ99_!v{g7SLhv8E-T@Ue+a}b~d%)Wt=ESfO1DU#wH5MR-^dO15hfhjGKej)m>lxQ| zw=q1v(&7P%%1KmTT4A-9$G>&J|4&P-CLt;+gE%fBJ35K{XB2R?BGU}(s)O99G`$qv zAY+x{fCQc7*udb3G^`X(8st{MIY`=xKhhaeDpJvrC7}dEDnd%B5%6}p6a#@K+8J~X zQglWK(KNnFNK*|bWX5{Q3EqCO$iDl>4&BP!(AdV&*pX32@`LC{CDA0smz+kXKfJP0 zT$PH{6=8^97JIDefsz%O^!z61DVi4RH1@8w&sn(j!^xpSU-nH*+U7>IO;6WkJuTT*26^EcwU?A$0~*?H&g5-x%74f^~B68xFbHAmla+Lhrzu zg`mKVAT!-g&;PpFT1Fndf#MasQi6zOIP8o)oBIlJ;JxEgW(`N;Z&G7dV|W6nAS4if zUghx$@(xV&j3fyS^mXdyG<%%j#!Fw9x9z6tTOMCEl|;W;s!smlI`5he5yin)5D%8& z`78u9oeo4z6$xPHWCr6R!O_>AMqXmnjH~2lE34m#s$5uMCz(@$8^@c_aRuo3#tS1z z3_cI}MK+JsQ)#Nnq;%Nr(ubT>asgJLc$>DFTA>K*(W%Ojz7?XfQkO(3DHEnIe@-b#fWBj$6ftP(6A=7>hmc3cDC888??)&yG4m zEFUPATOGH&UcFmc`!N*BO$clH)YE1RIKrxUWg`? zB=p6lpNEtDRnklW=BuJ1dWgB3EgY0SZoTXu+YFRfd$D@vq~J@lm0ymD0S##qlQ9Z| z2V5i1J#)|?4Gcm^0dHVS6VHm(7?5{(%}kA5fkmUUrYDfn$hJUjL@Fz+jYzQ}Z*Od* zxuY$tQd~3xF@ZIl_tBAIjx2+emnEP9Js7-o-Tg^CCGU|c*3qBrmJgebC3{$gCLXT} zbS}lrJ(EK9jW%A&Bdi=^)UH;RAyw0>uo6C!Zo{%o#J2ny4gQ#-G)X}0R28PmoULdLpU4unMpdB!|4>Ffk&2NI$M!Bcy_h4!>qYQ(4i~Jx?eIQAIWGOP zJcnW={$kS149R#6e#{r=8W~=I3Y|bX14noUR3li{lH_M^^S&iZ_r?Sqjivy{Mb<0A z|GxI67+lV!dl^}dFC&ZKA4is==m$APkk%JNB1id{Bc&rRPIGeS-&z{r5;co~ll`PCX3JkCv)V zFVtF8n-dS^byw}`c+whgRL|nPjbTSXXg>}8OcIajY-&GNp~$3FE=z4i4WdhuBGSs7f1mo*1hT=crr0hoKf#3-SxMhfQ8fA$O9O8 z@A{{&dcWNS!wDoc^g<66T<(`?F(d@ZNWET0~>zLlHrPD6j zJLjdUomhH77{;ur=2Kfj6i)mUF=&n_onMA2-siGWD%A|BR8NTJhxTQOC^u34?##mH z-)}+*^wip(o}9uIbpa|7Vl;M%7F2b{)mKezxjHv5>pR=-&mmN;3YiWc4WA331anf6o%Hcm(v~S zOs)o+!X>anS04c}y%*%bnl6OS$c*%Q;}b6(0hh_fN5O5TCMrkOj?@UDKf=JzuE8#} ztHVu&W5F~DiXnSh;-2$e)arvCY*U`5-t^H6=UJUO_W0{9|Cqn-vLZ1qs44Qyng~M= z);aJ`?d>1c7&*37^AnHQSc}wHAEy*>&(98wXypxMw_MsR!bH971xb9j;FRD=f*&JV z-kWl6*Y;7?l{j>*Ft;_n>uE(akAs_}_o%rlOteuHaOmy3&IGwlEp=*PQhjD`CC~9Q z&_MB-YpN3)w>=_OU=`0fa70=~v!?&yHdd#>*W6gejq*jOt7u^`6(kaYsUCCR?TkL! z(#g3^{~i{Q!e!}u!e(2@(7M&mKz@?*o*BE0)sk!*vRk2i8Csd$O-?*^d7|u-_RC!n zV1%ZS@SXpqv+RNoJJnFDv#j78ZAa7Lc2|lh3;wrqS&l^#!^2j~AjjfVltk(Wk{`xj z)=3F{Y>`D5nt#pZ%JnwUoz<=2cCd=8U6=DLXOX3(#zOmta+vZ}9Kpe~0G3E_@xa z{ku@_MX&V7yZ#-x{krszm;Ps*>F-j8m!$qbO8?F!{jYG-*PPekroTC6|I7Is`J$P5 z4SJo<_ZxKj=6`nR@2tMpl-K!4zbQ2@_V8DS^`B`;uR*U9JAQ*K3I7N5JHO*K=Cwcl zH>QW|e=vW!)n7yYDEjNO`orz@*OKSoO#3I~Z%NvJ{C~r1%4_e}Zwlx&<-eU^uR*Vk z9KS)fOs{t5x2@wfFPUiZ|$S@$n(_RClA|6%>B!+s5V-3|W+MX3K1=yh-Wn)$j9_|4qZ`X}a} aU4gtb^h;(Y7}(pFb^qm8MN03Ft^Wg=AIlg3 literal 0 HcmV?d00001 diff --git a/dist/nt2py-0.5.0.tar.gz b/dist/nt2py-0.5.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..b9cb352f393e592059fffdcaf6527b82004a84de GIT binary patch literal 21079 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 01cd5e1..cecff60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,20 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "nt2py" dynamic = ["version"] dependencies = [ -"types-setuptools", -"dask", -"xarray", -"numpy", -"scipy", -"h5py", -"h5pickle", -"matplotlib", -"tqdm", -"contourpy", + "types-setuptools", + "dask", + "xarray", + "numpy", + "scipy", + "h5py", + "matplotlib", + "tqdm", + "contourpy", ] requires-python = ">=3.8" authors = [{ name = "Hayk", email = "haykh.astro@gmail.com" }] @@ -24,18 +23,18 @@ description = "Post-processing & visualization toolkit for the Entity PIC code" readme = "README.md" license = { file = "LICENSE" } classifiers = [ -"Development Status :: 5 - Production/Stable", -"Intended Audience :: Science/Research", -"Intended Audience :: Education", -"Topic :: Scientific/Engineering :: Physics", -"Topic :: Scientific/Engineering :: Astronomy", -"License :: OSI Approved :: BSD License", -"Programming Language :: Python :: 3 :: Only", -"Programming Language :: Python :: 3.8", -"Programming Language :: Python :: 3.9", -"Programming Language :: Python :: 3.10", -"Programming Language :: Python :: 3.11", -"Programming Language :: Python :: 3.12", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Science/Research", + "Intended Audience :: Education", + "Topic :: Scientific/Engineering :: Physics", + "Topic :: Scientific/Engineering :: Astronomy", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] [project.urls] @@ -45,4 +44,5 @@ Repository = "https://github.com/entity-toolkit/nt2py" path = "nt2/__init__.py" [tool.hatch.build.targets.wheel] -packages = ["nt2"] \ No newline at end of file +packages = ["nt2"] + diff --git a/requirements.txt b/requirements.txt index 00300c4..452e04a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,6 @@ cycler>=0.11.0 dask>=2023.1.0 fonttools>=4.38.0 fsspec>=2023.1.0 -h5pickle>=0.4.2 h5py>=3.8.0 holoviews>=1.15.4 hvplot>=0.8.2 From cf464ef85888468d9327a80eb6e3dcf2ed0a5014 Mon Sep 17 00:00:00 2001 From: hayk Date: Thu, 6 Mar 2025 18:02:49 -0500 Subject: [PATCH 8/9] dashboard --- README.md | 11 +++++++++++ dist/nt2py-0.5.0-py3-none-any.whl | Bin 25674 -> 26169 bytes dist/nt2py-0.5.0.tar.gz | Bin 21079 -> 21343 bytes nt2/__init__.py | 1 + nt2/dashboard.py | 18 ++++++++++++++++++ 5 files changed, 30 insertions(+) create mode 100644 nt2/dashboard.py diff --git a/README.md b/README.md index 85e4955..850bdd4 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,17 @@ data.makeMovie(plot) > If using Jupyter notebook, you can quickly preview the loaded metadata by simply running a cell with just `data` in it (or in regular python, by doing `print(data)`). +### Dashboard + +Support for the dask dashboard is still in beta, but you can access it by first launching the dashboard client: + +```python +import nt2 +nt2.Dashboard() +``` + +This will output the port where the dashboard server is running, e.g., `Dashboard: http://127.0.0.1:8787/status`. Click on it (or enter in your browser) to open the dashboard. + ### Features 1. Lazy loading and parallel processing of the simulation data with [`dask`](https://dask.org/). diff --git a/dist/nt2py-0.5.0-py3-none-any.whl b/dist/nt2py-0.5.0-py3-none-any.whl index f6cde0fa73a60e3d489c9e5262fffb6a96b09d08..b2ef16fbe6043b1b73e861ae3cc76306bf72cd6b 100644 GIT binary patch delta 4488 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 M-r#GWbIZT%Urcdz%>V!Z delta 3984 zcmZu!bx;(J)?F4*I;5mqVCn8|1W7?)VWnL{N?KTuM$#puC005Y>F$z}lnx1r1(uNh ze82hX&AdBvXU?5-&b|NLnbS0iF+7MtrS}{YiyQy|;QVbRJ8WD=F;BPzhZ zNcejz8l(+|w>DAsk*@h19sq!(1^^fU@EW_P@CO}S*efT`jX{dLv8u-KvijGQVTaLW zPik|pLS>BcFxLf{2T%G3Vc?*va(w7ndEllYFzbKd>~a?}_x=T{N)=rKz-Eve&UB@NSQCd~}EwhKq4u-0kL$fvb-9WZJ&utX8w zZ{@P09<|4ck~GqI?FP2d$7nwX89z6A?@jCiOL3lh-6UjmQuZ#$KS+PkUpkFg$Ve0G z@t_8&tOLWaAK9;|7$%^Zd64uRuAEM+uoN*$!h#B^lG!POOh% z$@$u}TI`$76jAB^YZbf_H9`v25p4>n-VHJ(&XJ$utT~JeINyUfKWKa%^IROuU3^4) zp|JS#5&1Ut!Ci>oO==CoRWtz)wKF^}aUe!>qtvyea7-R$5EGUJWm38$XQH*>wi?f) z$_hg1={_>XGaTSF6@%yIW|@_^5q)?ClO5t)y|+ zbDQH8dCz?r2ySv);}`SFq@A0mkBE;VRt7Q!m(N=0eI)XYt3qd1e0ME$`U81j$>ywX zsXjvKUuIfZFXy@@=;Mmz*-+jS=S<{g5fv-*p6a5f{)b0}o9A+~I3J4V7!BuI&ik2Y zFP+NYbDT&@pI7b8($~8lBfWlkwVh_@>IaCAs`#>;2vLsV2T3<5+^Fg+TZjGnb5c2zdw@`Hr7U%G!vW?owN9m) zjjIfG%UR7T%8kjJMg8a)KIg3tD|!pFoy>||q7jKRpCyR`CeQbyXKZ0xrh08F^Yla< zp*3@;mmz}=o#$+|%O9fR60 zLxvAL?d>7!7=5xbyh!poIi>eT}2J)LcG+{53;bRXxnU&RSlN-Iwh?DV~ zoBsX&bUy9_uB7m3%xx_!vEDVtO=8ieJcvF_Vx&2ktc!bHM7f3+m@Za=^pT01C>7HA z{>(xz`;lLC0VnPJ=kU|u@sIkAOXX$@GKqpxpn0&s*76atrh0pzGXWx%<=o^kUHF3b z9HZZ~R->qrg0Or-!@#i|e!0b0mY7BgAJx{;-l#XlTbyV@bY9oc=u-W+f1LQ6kUO4vG&u^c%Ka({}+{aYghSK zatS#VE>C2fptWHz2i?Hd4oWCU3=uyaM8lSKb6&p@CVnpXidmsj2NQjI2bS$eOby`@ z&k!Z2#dNZDckJk$B)^xCM<*uHeV^~Uy0^k~phT#u=bHuQ= zvR~XMJv<{Yh)PDM7(CR4g)ZFFYc=E~GTIbuB&K-|CE$ToII$XxW)uFImZ(Ad&*TaA zzNPOEuBLUDfvlHYI>^?&H^~cfbGiT7@)&fj#U5_MC+*$y$x| z*_1@M4agqG#~3`rqn9_lxFRMM%h!_j#Tsw?YqUTa*YXIV^Jkl3OEX*geRXT$JaUz2 zEy5OkkSS@Jn2D5cxxkHGdIznVsBZ(9H#-shk#l_|-XWh+crWD?l|7@{q@|PT60q`M zdO^=GG=wEthW);Er@o&Ax8VxdgJ$3m3G#?u#D5A4w!pr}`_m_5hnMTw5X?xFCRU(_cEKX-kzq-79miaK-$h6=E3Y5)U2{PL z9z(_EVz?K!MCTA!s+z{EXe|43tzaBliP^P+2Qju1z3_oocBKS^WR7;O5OLb2RZW|X zPU^6cYHPmn7ui2(Aic(pL8kHUYFaMCFC#O-2t4JArH3bDajf@DRBkl|fqR~{u0+;B zNjGK^Mao&#GDpZy&Ay3TAgJY^TUj#jL)X5ddu5Oh3)hC$)zHS~T_FElTG3eF{@78v zy&>MWNba{$U`6)zh{tCor@}#MjVMiWo<$h#_Uv!v;$IC-LaX*}jUK^fTBzO5&g7u` z@q4fBU>b|{C^~NWFSG6oSp=v#GBNh$8E{M&u8Wb)Hg^RCJY6%3B+cA|aT0 zUt=3+OLcA4wW7vnvsDSg89B;er;Y5UZpbSMr5t-H!P^|71sSQJ`_&zO$?spSUdwk| zoGY~>+HK<1V$L~g7^{DqI?{1#;5{M=f~0hKz}ghY-~5~t`~jgGxO% zg3K3Pabq(LIgFJpZyv@Yk4HReuo@s(K!~i`rr)4X&Xye0o{D=h-9-%H%NTo0niQ0xe%%KReZv~g>_Fk{wigyEw7rEZ-xw3M#>@P@sgZTZ@1T;A!xYIm!9 zxcqa5S5rpEJ9DfNnrKmf%y>HO810Q+XZx9gvz+s^!*nZ4?wz!1WQ-3l{?q7l2Dh6* zdPN665AFk!E;LoMZfi0H39P+J>@rM>CNwDtYS$q?wT^M8$^n6Gbv9qX1YC*I??yb# zw(-Je($e0k38_HS9DZ@sE(wcSWVm)ys{JaLXlHTfca@B#P^%iuBi`bTiNB~KNczUV z7@NiA1u4+c=WE&itW_;fX;`pk?rziyD_V%)IKwi6QVw|EGv})7!=xkc`%(Nw+HYDg zGH{702-?8brMg_Yr8FPYxwmtkHGZTvx0r#>3`K4@89Aq(GI?&VW&FA6&#+2ykFc30 zRHmLn(+*7jVix~Bl8kZ)KubN;p{35UrERN}oD8Whc)$Gms6xkPt{I+q`{ZR*Nb;?? z!D{~4hV9bC1v?ggkP~VGaO$BV-7HqJokx!)>B#+ka{3v%ZvDvFX zhl9zFfxwt*X00P2HeooPu6&@swUo1szjzBc@Z=#q8ut&qDm$Mhd7BrRC^=4NAH=dR zcj|VK_j`r$kHv3}NGRiQVNdO@MF6+mCUWNKlXy`)ypH3@PfQ zetxI5T{wZg!!PKc`1962YN)*D8;7(cHWpJ5&0c|1z-bny+&ketWEd`sTENrqfy#m9SWvNswIngmuRJRIE%DZ{!YrcZ6+BKB z8zvM}WpDh|OGqYUz@8luV7~oha*9IL8(~OK-qfvHT-%k{P&@U@8!P+x1@9~937&T` zhaI?RRU1rvfI}qghz3p2VvFILxK6xWbS!5w2**_-m}00$XaY8?1fLtX8zd)x4#i8zzI{&g26$!`thJo@@Cv(dg& z%U$La9G^tnSo4FeO^Jf$n(NiZT@M-ch1K)-JqgxGU3h1G$LdXGTAFGliLxxZOBZ27 zpV2BY6tCiMdc4TKw|0qd^Kv~_N|SJcp+sCu_}YGT@Uf!uxdf>yg`HToF?;l@Ru3XL^+1rymaK}xI3E4+^c*&MVv zg?W~JCQ9bKWtz3i+F+&+awSwwN(VN%fc6IIq3d9 z@BYtS0H6?KSNp%yFAiMB4g|b0`DYBa;|IpP`U@rK{`W;-0|0dY=lpBkGXFm#8w&u?g8WPIU-#>j00000 diff --git a/dist/nt2py-0.5.0.tar.gz b/dist/nt2py-0.5.0.tar.gz index b9cb352f393e592059fffdcaf6527b82004a84de..0e003fb7718e79dee632fd4efabb6776edc3e04e 100644 GIT binary patch delta 20955 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%0D!asv=4mO|g3b(Rp*s+Jjc>dJrkcJARo@#6 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 OKmR|X)qT^v delta 20626 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

n;m0V&Kh&28~2-p#2YV7f2 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 diff --git a/nt2/__init__.py b/nt2/__init__.py index c368e63..0066643 100644 --- a/nt2/__init__.py +++ b/nt2/__init__.py @@ -1,3 +1,4 @@ __version__ = "0.5.0" from nt2.data import Data as Data +from nt2.dashboard import Dashboard as Dashboard diff --git a/nt2/dashboard.py b/nt2/dashboard.py new file mode 100644 index 0000000..d5390d7 --- /dev/null +++ b/nt2/dashboard.py @@ -0,0 +1,18 @@ +class Dashboard: + def __init__(self, **kwargs): + from dask.distributed import Client + + self._client = Client(**kwargs) + + def restart(self): + self._client.restart() + + def close(self): + self._client.close() + + @property + def client(self): + return self._client + + def _repr_html_(self): + return self.client._repr_html_() From f57b3ea5f7f008e868c7058dd75cb38d8aca94fc Mon Sep 17 00:00:00 2001 From: hayk Date: Thu, 6 Mar 2025 18:06:06 -0500 Subject: [PATCH 9/9] dependencies --- dist/nt2py-0.5.0-py3-none-any.whl | Bin 26169 -> 26185 bytes dist/nt2py-0.5.0.tar.gz | Bin 21343 -> 21508 bytes pyproject.toml | 5 +- requirements.txt | 104 +++++++++++++++++------------- 4 files changed, 62 insertions(+), 47 deletions(-) diff --git a/dist/nt2py-0.5.0-py3-none-any.whl b/dist/nt2py-0.5.0-py3-none-any.whl index b2ef16fbe6043b1b73e861ae3cc76306bf72cd6b..97af17a341a0813a874d8436a7da202df54500d9 100644 GIT binary patch delta 3943 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 ByJ!Fa delta 3927 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 diff --git a/dist/nt2py-0.5.0.tar.gz b/dist/nt2py-0.5.0.tar.gz index 0e003fb7718e79dee632fd4efabb6776edc3e04e..9fdcccc5c161ea2f49f00425d390b8f4571eb3f5 100644 GIT binary patch delta 20997 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 E05lXX+5i9m delta 20811 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 a-%}YA`|Liu&+hYeKmR|X)qT=6.0.0 -bokeh>=2.4.3 -cachetools>=5.3.0 -certifi>=2022.12.7 -charset-normalizer>=3.0.1 -click>=8.1.3 -cloudpickle>=2.2.1 -colorcet>=3.0.1 -contourpy>=1.0.7 -cycler>=0.11.0 -dask>=2023.1.0 -fonttools>=4.38.0 -fsspec>=2023.1.0 -h5py>=3.8.0 -holoviews>=1.15.4 -hvplot>=0.8.2 -idna>=3.4 -importlib-metadata>=6.0.0 -Jinja2>=3.1.2 -kiwisolver>=1.4.4 +bleach>=6.2.0 +bokeh>=3.6.3 +build>=1.2.2.post1 +cachetools>=5.5.2 +certifi>=2025.1.31 +charset-normalizer>=3.4.1 +click>=8.1.8 +cloudpickle>=3.1.1 +colorcet>=3.1.0 +contourpy>=1.3.1 +cycler>=0.12.1 +dask>=2025.2.0 +fonttools>=4.56.0 +fsspec>=2025.2.0 +h5pickle>=0.4.2 +h5py>=3.13.0 +hatchling>=1.27.0 +holoviews>=1.20.1 +hvplot>=0.11.2 +idna>=3.10 +importlib_metadata>=8.6.1 +Jinja2>=3.1.5 +kiwisolver>=1.4.8 +linkify-it-py>=2.0.3 locket>=1.0.0 -Markdown>=3.4.1 -MarkupSafe>=2.1.2 -matplotlib>=3.6.3 -numpy>=1.24.1 -packaging>=23.0 -pandas>=1.5.3 -panel>=0.14.2 -param>=1.12.3 -partd>=1.3.0 -Pillow>=9.4.0 -pyct>=0.4.8 -pyparsing>=3.0.9 -python-dateutil>=2.8.2 -pytz>=2022.7.1 -pyviz-comms>=2.2.1 -PyYAML>=6.0 -requests>=2.28.2 -six>=1.16.0 -toolz>=0.12.0 -tornado>=6.2 -tqdm>=4.64.1 -typing_extensions>=4.4.0 -urllib3>=1.26.14 +Markdown>=3.7 +markdown-it-py>=3.0.0 +MarkupSafe>=3.0.2 +matplotlib>=3.10.1 +mdit-py-plugins>=0.4.2 +mdurl>=0.1.2 +numpy>=2.2.3 +packaging>=24.2 +pandas>=2.2.3 +panel>=1.6.1 +param>=2.2.0 +partd>=1.4.2 +pathspec>=0.12.1 +pillow>=11.1.0 +pluggy>=1.5.0 +pyct>=0.5.0 +pyparsing>=3.2.1 +pyproject_hooks>=1.2.0 +python-dateutil>=2.9.0.post0 +pytz>=2025.1 +pyviz_comms>=3.0.4 +PyYAML>=6.0.2 +requests>=2.32.3 +six>=1.17.0 +toolz>=1.0.0 +tornado>=6.4.2 +tqdm>=4.67.1 +trove-classifiers>=2025.3.3.18 +typing_extensions>=4.12.2 +tzdata>=2025.1 +uc-micro-py>=1.0.3 +urllib3>=2.3.0 webencodings>=0.5.1 -xarray>=2023.1.0 -zipp>=3.11.0 +xarray>=2025.1.2 +xyzservices>=2025.1.0 +zipp>=3.21.0