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