pydrex.cli

PyDRex: Entry points and argument handling for command line tools.

All CLI handlers should be registered in the CLI_HANDLERS namedtuple, which ensures that they will be installed as executable scripts alongside the package.

  1"""> PyDRex: Entry points and argument handling for command line tools.
  2
  3All CLI handlers should be registered in the `CLI_HANDLERS` namedtuple,
  4which ensures that they will be installed as executable scripts alongside the package.
  5
  6"""
  7
  8import argparse
  9import os
 10from collections import namedtuple
 11from zipfile import ZipFile
 12
 13from pydrex import exceptions as _err
 14from pydrex import io as _io
 15from pydrex import logger as _log
 16from pydrex import minerals as _minerals
 17from pydrex import stats as _stats
 18from pydrex import visualisation as _vis
 19
 20
 21class CliTool:
 22    """Base class for CLI tools defining the required interface."""
 23
 24    def __call__(self):
 25        return NotImplementedError
 26
 27    def _get_args(self) -> argparse.Namespace | type[NotImplementedError]:
 28        return NotImplementedError
 29
 30
 31class MeshGenerator(CliTool):
 32    """PyDRex script to generate various simple meshes.
 33
 34    Only rectangular (2D) meshes are currently supported. The RESOLUTION must be a comma
 35    delimited set of directives of the form `<LOC>:<RES>` where `<LOC>` is a location
 36    specifier, i.e. either "G" (global) or a compas direction like "N", "S", "NE", etc.,
 37    and `<RES>` is a floating point value to be set as the resolution at that location.
 38
 39    """
 40
 41    def __call__(self):
 42        try:  # This one is dangerous, especially in CI.
 43            from pydrex import mesh as _mesh
 44        except ImportError:
 45            raise _err.MissingDependencyError(
 46                "missing optional meshing dependencies."
 47                + " Have you installed the package with 'pip install pydrex[mesh]'?"
 48            )
 49
 50        args = self._get_args()
 51
 52        if args.kind == "rectangle":
 53            if args.center is None:
 54                center = (0, 0)
 55            else:
 56                center = [float(s) for s in args.center.split(",")]
 57                assert len(center) == 2
 58
 59            width, height = map(float, args.size.split(","))
 60            _loc_map = {
 61                "G": "global",
 62                "N": "north",
 63                "S": "south",
 64                "E": "east",
 65                "W": "west",
 66                "NE": "north-east",
 67                "NW": "north-west",
 68                "SE": "south-east",
 69                "SW": "south-west",
 70            }
 71            try:
 72                resolution = {
 73                    _loc_map[k]: float(v)
 74                    for k, v in map(lambda s: s.split(":"), args.resolution.split(","))
 75                }
 76            except KeyError:
 77                raise KeyError(
 78                    "invalid or unsupported location specified in resolution directive"
 79                ) from None
 80            except ValueError:
 81                raise ValueError(
 82                    "invalid resolution value. The format should be '<LOC1>:<RES1>,<LOC2>:<RES2>,...'"
 83                ) from None
 84            _mesh.rectangle(
 85                args.output[:-4],
 86                (args.ref_axes[0], args.ref_axes[1]),
 87                center,
 88                width,
 89                height,
 90                resolution,
 91            )
 92
 93    def _get_args(self) -> argparse.Namespace:
 94        assert self.__doc__ is not None, f"missing docstring for {self}"
 95        description, epilog = self.__doc__.split(os.linesep + os.linesep, 1)
 96        parser = argparse.ArgumentParser(description=description, epilog=epilog)
 97        parser.add_argument("size", help="width,height[,depth] of the mesh")
 98        parser.add_argument(
 99            "-r",
100            "--resolution",
101            help="resolution for the mesh (edge length hint(s) for gmsh)",
102            required=True,
103        )
104        parser.add_argument("output", help="output file (.msh)")
105        parser.add_argument(
106            "-c",
107            "--center",
108            help="center of the mesh as 2 or 3 comma-separated coordinates. default: (0, 0[, 0])",
109            default=None,
110        )
111        parser.add_argument(
112            "-a",
113            "--ref-axes",
114            help=(
115                "two letters from {'x', 'y', 'z'} that specify"
116                + " the horizontal and vertical axes of the mesh"
117            ),
118            default="xz",
119        )
120        parser.add_argument(
121            "-k", "--kind", help="kind of mesh, e.g. 'rectangle'", default="rectangle"
122        )
123        return parser.parse_args()
124
125
126class H5partExtractor(CliTool):
127    """PyDRex script to extract raw CPO data from Fluidity .h5part files.
128
129    Fluidity saves data stored on model `particles` to an `.h5part` file.
130    This script converts that file to canonical serialisation formats:
131    - a `.npz` file containing the raw CPO orientations and (surrogate) grain sizes
132    - an `.scsv` file containing the pathline positions and accumulated strain
133
134    It is assumed that CPO data is stored in keys called 'CPO_<N>' in the .h5part
135    data, where `<N>` is an integer in the range 1—`n_grains`. The accumulated strain is
136    read from the attribute `CPO_<S>` where S=`ngrains`+1. Particle positions are read
137    from the attributes `x`, `y`, and `z`.
138
139    At the moment, dynamic changes in fabric or phase are not supported.
140
141    """
142
143    def __call__(self):
144        args = self._get_args()
145        _io.extract_h5part(
146            args.input, args.phase, args.fabric, args.ngrains, args.output
147        )
148
149    def _get_args(self) -> argparse.Namespace:
150        assert self.__doc__ is not None, f"missing docstring for {self}"
151        description, epilog = self.__doc__.split(os.linesep + os.linesep, 1)
152        parser = argparse.ArgumentParser(description=description, epilog=epilog)
153        parser.add_argument("input", help="input file (.h5part)")
154        parser.add_argument(
155            "-p",
156            "--phase",
157            help="type of `pydrex.MineralPhase` (as an ordinal number); 0 by default",
158            default=0,
159        )
160        parser.add_argument(
161            "-f",
162            "--fabric",
163            type=int,
164            help="type of `pydrex.MineralFabric` (as an ordinal number); 0 by default",
165            default=0,
166        )
167        parser.add_argument(
168            "-n",
169            "--ngrains",
170            help="number of grains used in the Fluidity simulation",
171            type=int,
172            required=True,
173        )
174        parser.add_argument(
175            "-o",
176            "--output",
177            help="filename for the output NPZ file (stem also used for the .scsv)",
178            required=True,
179        )
180        return parser.parse_args()
181
182
183class NPZFileInspector(CliTool):
184    """PyDRex script to show information about serialized CPO data.
185
186    Lists the keys that should be used for the `postfix` in `pydrex.Mineral.load` and
187    `pydrex.Mineral.from_file`.
188
189    """
190
191    def __call__(self):
192        args = self._get_args()
193        with ZipFile(args.input) as npz:
194            names = npz.namelist()
195            print("NPZ file with keys:")
196            for name in names:
197                if not (
198                    name.startswith("meta")
199                    or name.startswith("fractions")
200                    or name.startswith("orientations")
201                ):
202                    _log.warning(f"found unknown NPZ key '{name}' in '{args.input}'")
203                print(f" - {name}")
204
205    def _get_args(self) -> argparse.Namespace:
206        assert self.__doc__ is not None, f"missing docstring for {self}"
207        description, epilog = self.__doc__.split(os.linesep + os.linesep, 1)
208        parser = argparse.ArgumentParser(description=description, epilog=epilog)
209        parser.add_argument("input", help="input file (.npz)")
210        return parser.parse_args()
211
212
213class PoleFigureVisualiser(CliTool):
214    """PyDRex script to plot pole figures of serialized CPO data.
215
216    Produces [100], [010] and [001] pole figures for serialized `pydrex.Mineral`s.
217    If the range of indices is not specified,
218    a maximum of 25 of each pole figure will be produced by default.
219
220    """
221
222    def __call__(self):
223        try:
224            args = self._get_args()
225            if args.range is None:
226                i_range = None
227            else:
228                start, stop_ex, step = (int(s) for s in args.range.split(":"))
229                # Make command line start:stop:step stop-inclusive, it's more intuitive.
230                i_range = range(start, stop_ex + step, step)
231
232            density_kwargs = {"kernel": args.kernel}
233            if args.smoothing is not None:
234                density_kwargs["σ"] = args.smoothing
235
236            mineral = _minerals.Mineral.from_file(args.input, postfix=args.postfix)
237            if i_range is None:
238                i_range = range(0, len(mineral.orientations))
239                if len(i_range) > 25:
240                    _log.warning(
241                        "truncating to 25 timesteps (out of %s total)", len(i_range)
242                    )
243                    i_range = range(0, 25)
244
245            orientations_resampled, _ = _stats.resample_orientations(
246                mineral.orientations[i_range.start : i_range.stop : i_range.step],
247                mineral.fractions[i_range.start : i_range.stop : i_range.step],
248            )
249            if args.scsv is None:
250                strains = None
251            else:
252                strains = _io.read_scsv(args.scsv).strain[
253                    i_range.start : i_range.stop : i_range.step
254                ]
255            _vis.polefigures(
256                orientations_resampled,
257                ref_axes=args.ref_axes,
258                i_range=i_range,
259                density=args.density,
260                savefile=args.out,
261                strains=strains,
262                **density_kwargs,
263            )
264        except (argparse.ArgumentError, ValueError, _err.Error) as e:
265            _log.error(str(e))
266
267    def _get_args(self) -> argparse.Namespace:
268        assert self.__doc__ is not None, f"missing docstring for {self}"
269        description, epilog = self.__doc__.split(os.linesep + os.linesep, 1)
270        parser = argparse.ArgumentParser(description=description, epilog=epilog)
271        parser.add_argument("input", help="input file (.npz)")
272        parser.add_argument(
273            "-r",
274            "--range",
275            help="range of strain indices to be plotted, in the format start:stop:step",
276            default=None,
277        )
278        parser.add_argument(
279            "-f",
280            "--scsv",
281            help=(
282                "path to SCSV file with a column named 'strain'"
283                + " that lists shear strain percentages for each strain index"
284            ),
285            default=None,
286        )
287        parser.add_argument(
288            "-p",
289            "--postfix",
290            help=(
291                "postfix of the mineral to load,"
292                + " required if the input file contains data for multiple minerals"
293            ),
294            default=None,
295        )
296        parser.add_argument(
297            "-d",
298            "--density",
299            help="toggle contouring of pole figures using point density estimation",
300            default=False,
301            action="store_true",
302        )
303        parser.add_argument(
304            "-k",
305            "--kernel",
306            help=(
307                "kernel function for point density estimation, one of:"
308                + f" {list(_stats.SPHERICAL_COUNTING_KERNELS.keys())}"
309            ),
310            default="linear_inverse_kamb",
311        )
312        parser.add_argument(
313            "-s",
314            "--smoothing",
315            help="smoothing parameter for Kamb type density estimation kernels",
316            default=None,
317            type=float,
318            metavar="σ",
319        )
320        parser.add_argument(
321            "-a",
322            "--ref-axes",
323            help=(
324                "two letters from {'x', 'y', 'z'} that specify"
325                + " the horizontal and vertical axes of the pole figures"
326            ),
327            default="xz",
328        )
329        parser.add_argument(
330            "-o",
331            "--out",
332            help="name of the output file, with either .png or .pdf extension",
333            default="polefigures.png",
334        )
335        return parser.parse_args()
336
337
338# These are not the final names of the executables (those are set in pyproject.toml).
339_CLI_HANDLERS = namedtuple(
340    "_CLI_HANDLERS",
341    (
342        "pole_figure_visualiser",
343        "npz_file_inspector",
344        "mesh_generator",
345        "h5part_extractor",
346    ),
347)
348CLI_HANDLERS = _CLI_HANDLERS(
349    pole_figure_visualiser=PoleFigureVisualiser(),
350    npz_file_inspector=NPZFileInspector(),
351    mesh_generator=MeshGenerator(),
352    h5part_extractor=H5partExtractor(),
353)
class CliTool:
22class CliTool:
23    """Base class for CLI tools defining the required interface."""
24
25    def __call__(self):
26        return NotImplementedError
27
28    def _get_args(self) -> argparse.Namespace | type[NotImplementedError]:
29        return NotImplementedError

Base class for CLI tools defining the required interface.

class MeshGenerator(CliTool):
 32class MeshGenerator(CliTool):
 33    """PyDRex script to generate various simple meshes.
 34
 35    Only rectangular (2D) meshes are currently supported. The RESOLUTION must be a comma
 36    delimited set of directives of the form `<LOC>:<RES>` where `<LOC>` is a location
 37    specifier, i.e. either "G" (global) or a compas direction like "N", "S", "NE", etc.,
 38    and `<RES>` is a floating point value to be set as the resolution at that location.
 39
 40    """
 41
 42    def __call__(self):
 43        try:  # This one is dangerous, especially in CI.
 44            from pydrex import mesh as _mesh
 45        except ImportError:
 46            raise _err.MissingDependencyError(
 47                "missing optional meshing dependencies."
 48                + " Have you installed the package with 'pip install pydrex[mesh]'?"
 49            )
 50
 51        args = self._get_args()
 52
 53        if args.kind == "rectangle":
 54            if args.center is None:
 55                center = (0, 0)
 56            else:
 57                center = [float(s) for s in args.center.split(",")]
 58                assert len(center) == 2
 59
 60            width, height = map(float, args.size.split(","))
 61            _loc_map = {
 62                "G": "global",
 63                "N": "north",
 64                "S": "south",
 65                "E": "east",
 66                "W": "west",
 67                "NE": "north-east",
 68                "NW": "north-west",
 69                "SE": "south-east",
 70                "SW": "south-west",
 71            }
 72            try:
 73                resolution = {
 74                    _loc_map[k]: float(v)
 75                    for k, v in map(lambda s: s.split(":"), args.resolution.split(","))
 76                }
 77            except KeyError:
 78                raise KeyError(
 79                    "invalid or unsupported location specified in resolution directive"
 80                ) from None
 81            except ValueError:
 82                raise ValueError(
 83                    "invalid resolution value. The format should be '<LOC1>:<RES1>,<LOC2>:<RES2>,...'"
 84                ) from None
 85            _mesh.rectangle(
 86                args.output[:-4],
 87                (args.ref_axes[0], args.ref_axes[1]),
 88                center,
 89                width,
 90                height,
 91                resolution,
 92            )
 93
 94    def _get_args(self) -> argparse.Namespace:
 95        assert self.__doc__ is not None, f"missing docstring for {self}"
 96        description, epilog = self.__doc__.split(os.linesep + os.linesep, 1)
 97        parser = argparse.ArgumentParser(description=description, epilog=epilog)
 98        parser.add_argument("size", help="width,height[,depth] of the mesh")
 99        parser.add_argument(
100            "-r",
101            "--resolution",
102            help="resolution for the mesh (edge length hint(s) for gmsh)",
103            required=True,
104        )
105        parser.add_argument("output", help="output file (.msh)")
106        parser.add_argument(
107            "-c",
108            "--center",
109            help="center of the mesh as 2 or 3 comma-separated coordinates. default: (0, 0[, 0])",
110            default=None,
111        )
112        parser.add_argument(
113            "-a",
114            "--ref-axes",
115            help=(
116                "two letters from {'x', 'y', 'z'} that specify"
117                + " the horizontal and vertical axes of the mesh"
118            ),
119            default="xz",
120        )
121        parser.add_argument(
122            "-k", "--kind", help="kind of mesh, e.g. 'rectangle'", default="rectangle"
123        )
124        return parser.parse_args()

PyDRex script to generate various simple meshes.

Only rectangular (2D) meshes are currently supported. The RESOLUTION must be a comma delimited set of directives of the form <LOC>:<RES> where <LOC> is a location specifier, i.e. either "G" (global) or a compas direction like "N", "S", "NE", etc., and <RES> is a floating point value to be set as the resolution at that location.

class H5partExtractor(CliTool):
127class H5partExtractor(CliTool):
128    """PyDRex script to extract raw CPO data from Fluidity .h5part files.
129
130    Fluidity saves data stored on model `particles` to an `.h5part` file.
131    This script converts that file to canonical serialisation formats:
132    - a `.npz` file containing the raw CPO orientations and (surrogate) grain sizes
133    - an `.scsv` file containing the pathline positions and accumulated strain
134
135    It is assumed that CPO data is stored in keys called 'CPO_<N>' in the .h5part
136    data, where `<N>` is an integer in the range 1—`n_grains`. The accumulated strain is
137    read from the attribute `CPO_<S>` where S=`ngrains`+1. Particle positions are read
138    from the attributes `x`, `y`, and `z`.
139
140    At the moment, dynamic changes in fabric or phase are not supported.
141
142    """
143
144    def __call__(self):
145        args = self._get_args()
146        _io.extract_h5part(
147            args.input, args.phase, args.fabric, args.ngrains, args.output
148        )
149
150    def _get_args(self) -> argparse.Namespace:
151        assert self.__doc__ is not None, f"missing docstring for {self}"
152        description, epilog = self.__doc__.split(os.linesep + os.linesep, 1)
153        parser = argparse.ArgumentParser(description=description, epilog=epilog)
154        parser.add_argument("input", help="input file (.h5part)")
155        parser.add_argument(
156            "-p",
157            "--phase",
158            help="type of `pydrex.MineralPhase` (as an ordinal number); 0 by default",
159            default=0,
160        )
161        parser.add_argument(
162            "-f",
163            "--fabric",
164            type=int,
165            help="type of `pydrex.MineralFabric` (as an ordinal number); 0 by default",
166            default=0,
167        )
168        parser.add_argument(
169            "-n",
170            "--ngrains",
171            help="number of grains used in the Fluidity simulation",
172            type=int,
173            required=True,
174        )
175        parser.add_argument(
176            "-o",
177            "--output",
178            help="filename for the output NPZ file (stem also used for the .scsv)",
179            required=True,
180        )
181        return parser.parse_args()

PyDRex script to extract raw CPO data from Fluidity .h5part files.

Fluidity saves data stored on model particles to an .h5part file. This script converts that file to canonical serialisation formats:

  • a .npz file containing the raw CPO orientations and (surrogate) grain sizes
  • an .scsv file containing the pathline positions and accumulated strain

It is assumed that CPO data is stored in keys called 'CPO_' in the .h5part data, where <N> is an integer in the range 1—n_grains. The accumulated strain is read from the attribute CPO_<S> where S=ngrains+1. Particle positions are read from the attributes x, y, and z.

At the moment, dynamic changes in fabric or phase are not supported.

class NPZFileInspector(CliTool):
184class NPZFileInspector(CliTool):
185    """PyDRex script to show information about serialized CPO data.
186
187    Lists the keys that should be used for the `postfix` in `pydrex.Mineral.load` and
188    `pydrex.Mineral.from_file`.
189
190    """
191
192    def __call__(self):
193        args = self._get_args()
194        with ZipFile(args.input) as npz:
195            names = npz.namelist()
196            print("NPZ file with keys:")
197            for name in names:
198                if not (
199                    name.startswith("meta")
200                    or name.startswith("fractions")
201                    or name.startswith("orientations")
202                ):
203                    _log.warning(f"found unknown NPZ key '{name}' in '{args.input}'")
204                print(f" - {name}")
205
206    def _get_args(self) -> argparse.Namespace:
207        assert self.__doc__ is not None, f"missing docstring for {self}"
208        description, epilog = self.__doc__.split(os.linesep + os.linesep, 1)
209        parser = argparse.ArgumentParser(description=description, epilog=epilog)
210        parser.add_argument("input", help="input file (.npz)")
211        return parser.parse_args()

PyDRex script to show information about serialized CPO data.

Lists the keys that should be used for the postfix in pydrex.Mineral.load and pydrex.Mineral.from_file.

class PoleFigureVisualiser(CliTool):
214class PoleFigureVisualiser(CliTool):
215    """PyDRex script to plot pole figures of serialized CPO data.
216
217    Produces [100], [010] and [001] pole figures for serialized `pydrex.Mineral`s.
218    If the range of indices is not specified,
219    a maximum of 25 of each pole figure will be produced by default.
220
221    """
222
223    def __call__(self):
224        try:
225            args = self._get_args()
226            if args.range is None:
227                i_range = None
228            else:
229                start, stop_ex, step = (int(s) for s in args.range.split(":"))
230                # Make command line start:stop:step stop-inclusive, it's more intuitive.
231                i_range = range(start, stop_ex + step, step)
232
233            density_kwargs = {"kernel": args.kernel}
234            if args.smoothing is not None:
235                density_kwargs["σ"] = args.smoothing
236
237            mineral = _minerals.Mineral.from_file(args.input, postfix=args.postfix)
238            if i_range is None:
239                i_range = range(0, len(mineral.orientations))
240                if len(i_range) > 25:
241                    _log.warning(
242                        "truncating to 25 timesteps (out of %s total)", len(i_range)
243                    )
244                    i_range = range(0, 25)
245
246            orientations_resampled, _ = _stats.resample_orientations(
247                mineral.orientations[i_range.start : i_range.stop : i_range.step],
248                mineral.fractions[i_range.start : i_range.stop : i_range.step],
249            )
250            if args.scsv is None:
251                strains = None
252            else:
253                strains = _io.read_scsv(args.scsv).strain[
254                    i_range.start : i_range.stop : i_range.step
255                ]
256            _vis.polefigures(
257                orientations_resampled,
258                ref_axes=args.ref_axes,
259                i_range=i_range,
260                density=args.density,
261                savefile=args.out,
262                strains=strains,
263                **density_kwargs,
264            )
265        except (argparse.ArgumentError, ValueError, _err.Error) as e:
266            _log.error(str(e))
267
268    def _get_args(self) -> argparse.Namespace:
269        assert self.__doc__ is not None, f"missing docstring for {self}"
270        description, epilog = self.__doc__.split(os.linesep + os.linesep, 1)
271        parser = argparse.ArgumentParser(description=description, epilog=epilog)
272        parser.add_argument("input", help="input file (.npz)")
273        parser.add_argument(
274            "-r",
275            "--range",
276            help="range of strain indices to be plotted, in the format start:stop:step",
277            default=None,
278        )
279        parser.add_argument(
280            "-f",
281            "--scsv",
282            help=(
283                "path to SCSV file with a column named 'strain'"
284                + " that lists shear strain percentages for each strain index"
285            ),
286            default=None,
287        )
288        parser.add_argument(
289            "-p",
290            "--postfix",
291            help=(
292                "postfix of the mineral to load,"
293                + " required if the input file contains data for multiple minerals"
294            ),
295            default=None,
296        )
297        parser.add_argument(
298            "-d",
299            "--density",
300            help="toggle contouring of pole figures using point density estimation",
301            default=False,
302            action="store_true",
303        )
304        parser.add_argument(
305            "-k",
306            "--kernel",
307            help=(
308                "kernel function for point density estimation, one of:"
309                + f" {list(_stats.SPHERICAL_COUNTING_KERNELS.keys())}"
310            ),
311            default="linear_inverse_kamb",
312        )
313        parser.add_argument(
314            "-s",
315            "--smoothing",
316            help="smoothing parameter for Kamb type density estimation kernels",
317            default=None,
318            type=float,
319            metavar="σ",
320        )
321        parser.add_argument(
322            "-a",
323            "--ref-axes",
324            help=(
325                "two letters from {'x', 'y', 'z'} that specify"
326                + " the horizontal and vertical axes of the pole figures"
327            ),
328            default="xz",
329        )
330        parser.add_argument(
331            "-o",
332            "--out",
333            help="name of the output file, with either .png or .pdf extension",
334            default="polefigures.png",
335        )
336        return parser.parse_args()

PyDRex script to plot pole figures of serialized CPO data.

Produces [100], [010] and [001] pole figures for serialized pydrex.Minerals. If the range of indices is not specified, a maximum of 25 of each pole figure will be produced by default.

CLI_HANDLERS = _CLI_HANDLERS(pole_figure_visualiser=<PoleFigureVisualiser object>, npz_file_inspector=<NPZFileInspector object>, mesh_generator=<MeshGenerator object>, h5part_extractor=<H5partExtractor object>)