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)
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.
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.
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_<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.
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
.
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.Mineral
s.
If the range of indices is not specified,
a maximum of 25 of each pole figure will be produced by default.