'''

This module contains a variety of tools used for parsing PHITS output files.

Specifically, it seeks to be a (nearly) universal PHITS output parser, supporting output from
all tallies, both normal "standard" output as well as dump file outputs (in ASCII and binary formats).
It is also capable of automatically parsing all such PHITS output files in a directory or listed in 
active tallies inside of a PHITS input file.
If a DCHAIN input file (output from the [T-Dchain] tally) or DCHAIN output `*.act` file is provided
to the main tally output processing function, an attempt will be made to import the [DCHAIN Tools module](https://github.com/Lindt8/DCHAIN-Tools)
and process the found DCHAIN output files too.

The functions contained in this module and brief descriptions of their functions are included below.
However, provided first is a description of the three different ways one can use and interface with this module.

### **How to use the PHITS_tools.py module**

There are three main ways one can use this Python module:

 1. As an **imported Python module**
      - In your own Python scripts, you can import this module as `from PHITS_tools import *` and call its main functions,
         which are listed in the next section below, or any of its other functions documented here.
 2. As a **command line interface (CLI)**
      - This module can be ran on the command line with the individual PHITS output file to be parsed (or a directory
          or PHITS input/phits.out file containing multiple files to be parsed) as the required argument.
          Execute `python PHITS_tools.py --help` to see all of the different options that can be used with this module
          to parse standard or dump PHITS output files (individually and directories containing them) via the CLI.
          This functionality can be used to automatically process all PHITS output whenever PHITS is ran; 
          see the **"Automatic processing at PHITS runtime"** section further below.
          How the CLI options explicitly translate to the functions documented here is also covered further below.
 3. As a **graphical user interface (GUI)**
      - When the module is executed without any additional arguments, `python PHITS_tools.py`, (or with the `--GUI` or `-g` flag in the CLI)
          a GUI will be launched to step you through selecting what "mode" you would like to run PHITS Tools in (`STANDARD`, `DUMP`, `DIRECTORY`, or `INPUT_FILE`),
          selecting a file to be parsed (or a directory containing multiple files to be parsed), and the various options for each mode.

The CLI and GUI options result in the parsed file's contents being saved to a pickle file, which can be reopened
and used later in a Python script.  When using the main functions below within a Python script which has imported the PHITS_tools
module, you can optionally choose not to save the pickle files (if desired) and only have the tally output/dump parsing
functions return the data objects they produce for your own further analyses.

### **Main PHITS Output Parsing Functions**

- `parse_tally_output_file`         : general parser for standard output files for all PHITS tallies
- `parse_tally_dump_file`           : parser for dump files from "dump" flag in PHITS [T-Cross], [T-Time], and [T-Track] tallies
- `parse_all_tally_output_in_dir`   : run `parse_tally_output_file()` over all standard output files in a directory (and, optionally, `parse_tally_dump_file()` over all dump files too)
- `parse_phitsout_file`             : creates a metadata dictionary of a PHITS run from its "phits.out" file

### General Purpose Functions

- `tally`                           : tally/histogram values (and their indices) falling within a desired binning structure (useful with "dump" files)
- `rebinner`                        : rebin a set of y-data to a new x-binning structure (edges need not necessarily be preserved)
- `autoplot_tally_results`          : make plot(s), saved as PDFs, of tally results from tally output Pandas DataFrame(s)
- `fetch_MC_material`               : returns a string of a formatted material for PHITS or MCNP (mostly those in [PNNL-15870 Rev. 1](https://www.osti.gov/biblio/1023125))
- `ICRP116_effective_dose_coeff`    : returns effective dose conversion coefficient of a mono-energetic particle of some species and some geometry; does coefficients are those in [ICRP 116](https://doi.org/10.1016/j.icrp.2011.10.001)
- `merge_dump_file_pickles`         : merge multiple dump file outputs into a single file (useful for dumps in MPI runs)
- `ZZZAAAM_to_nuclide_plain_str`    : returns a nuclide plaintext string for a given "ZZZAAAM" number (1000Z+10A+M)
- `nuclide_plain_str_to_latex_str`  : convert a plaintext string for a nuclide to a LaTeX formatted raw string
- `Element_Z_to_Sym`                : return an elemental symbol string given its proton number Z
- `Element_Sym_to_Z`                : returns an atomic number Z provided the elemental symbol
- `kfcode_to_common_name`           : converts a particle kf-code to a plaintext string
- `is_number`                       : returns Boolean denoting whether provided string is that of a number
- `find`                            : return index of the first instance of a value in a list

### Subfunctions for PHITS output parsing
(These are meant as dependencies more so than for standalone usage.)

- `determine_PHITS_output_file_type` : determine if a file is standard tally output or ASCII/binary dump file
- `search_for_dump_parameters`      : attempt to auto find "dump" parameters via possible standard tally output file
- `split_into_header_and_content`   : initial reading of PHITS tally output, dividing it into header and "content" sections
- `parse_tally_header`              : extract metadata from tally output header section
- `parse_tally_content`             : extract tally results/values from tally content section
- `extract_data_from_header_line`   : extract metadata key/value pairs from tally output header lines
- `split_str_of_equalities`         : split a string containing equalities (e.g., `reg = 100`) into a list of them
- `parse_group_string`              : split a string containing "groups" (e.g., regions) into a list of them
- `initialize_tally_array`          : initialize NumPy array for storing tally results
- `data_row_to_num_list`            : extract numeric values from a line in the tally content section
- `calculate_tally_absolute_errors` : calculate absolute uncertainties from read values and relative errors
- `build_tally_Pandas_dataframe`    : make Pandas dataframe from the main results NumPy array and the metadata
- `extract_tally_outputs_from_phits_input` : extract a dictionary of files produced by a PHITS run

### **CLI options**

Essentially, the CLI serves to interface with the core three functions of PHITS Tools: `parse_tally_output_file`,
 `parse_tally_dump_file`, and `parse_all_tally_output_in_dir`. 
 The required `file` argument is checked to see if it is a directory or a file, and, if the latter, whether the `-d` 
 option is used denoting a dump output file, otherwise defaulting to assuming it is a PHITS standard tally output file; 
 then `file` and the relevant settings are sent to the corresponding main function. 
 Explicitly, inclusion of the various CLI options have the following effects on the main functions' arguments and settings:

- Affecting all functions
      - `file` is passed to `tally_output_filepath`, `path_to_dump_file`, or `tally_output_dirpath`
      - `-skip` sets `prefer_reading_existing_pickle = True` (`False` if excluded)
- `parse_tally_output_file` (and passed to it via `parse_all_tally_output_in_dir`)
      - `-np` sets `make_PandasDF = False` (`True` if excluded)
      - `-na` sets `calculate_absolute_errors = False` (`True` if excluded)
      - `-lzma` sets `compress_pickle_with_lzma = True` (`False` if excluded)
      - `-po` sets `include_phitsout_in_metadata = True` (`False` if excluded)
      - `-p` sets `autoplot_tally_output = True` (`False` if excluded)
- `parse_tally_dump_file` (and passed to it via `parse_all_tally_output_in_dir`)
      - `-d` tells the CLI that `file` should be processed as a dump file (if it's not a directory)
      - `-dvals` passes the provided sequence of values to `dump_data_sequence` (`None` if excluded)
      - `-dbin` specifies that the file is binary (that `dump_data_number=len(dump_data_sequence)` and *is positive*)
      - `-dnmax` passes its value to `max_entries_read` (`None` if excluded)
      - `-ddir` sets `return_directional_info = True` (`False` if excluded)
      - `-ddeg` sets `use_degrees = True` (`False` if excluded)
      - `-dnsl` sets `save_namedtuple_list = False` (`True` if excluded)
      - `-dnsp` sets `save_Pandas_dataframe = False` (`True` if excluded)
      - `-dmaxGB` passes its value to `split_binary_dumps_over_X_GB` (`20` GB if excluded)
      - `-dsplit` passes its value to `merge_split_dump_handling` (`0` if excluded)
- `parse_all_tally_output_in_dir` exclusively
      - `-r` sets `include_subdirectories = True` (`False` if excluded)
      - `-fpre` passes its value to `output_file_prefix` (`''` if excluded)
      - `-fsuf` passes its value to `output_file_suffix` (`'.out'` if excluded)
      - `-fstr` passes its value to `output_file_required_string` (`''` if excluded)
      - `-d` sets `include_dump_files = True` (`False` if excluded)
      - `-dnmmpi` sets `dump_merge_MPI_subdumps = False` (`True` if excluded)
      - `-dndmpi` sets `dump_delete_MPI_subdumps_post_merge = False` (`True` if excluded)
      - `-m` sets `merge_tally_outputs = True` (`False` if excluded)
      - `-smo` sets `merge_tally_outputs = True` (`False` if excluded), `save_output_pickle = False` (`True` if excluded), and `save_pickle_of_merged_tally_outputs = True` (`None` if excluded)
      - `-pa` sets `autoplot_all_tally_output_in_dir = True` (`False` if excluded)

Below is a picture of all of these options available for use within the CLI and their descriptions.  

![](https://github.com/Lindt8/PHITS-Tools/blob/main/docs/PHITS_tools_CLI.png?raw=true "PHITS Tools CLI options")

### **Automatic processing at PHITS runtime**

PHITS Tools can be used to automatically process the output of every PHITS run executed with the "phits.bat" and "phits.sh" 
scripts found in the "phits/bin/" directory of your PHITS installation.  To do this, first you must identify the location 
of your "PHITS_tools.py" file.  If using the file directly downloaded from GitHub, this should be in a location of your choosing.
If you installed PHITS Tools via `pip install PHITS-Tools`, you can find its location with `pip show PHITS-Tools -f`. 
Once you have identified the location of PHITS_tools.py, for example "/path/locating/PHITS_Tools/PHITS_tools.py", you can
add the following line to your PHITS execution script:

On Windows, using "phits/bin/phits.bat":

- Scroll down toward the bottom of the script, to the section with the line `rem - Your file processing starts here.`
- After the if statement (right before the `rem - Your file processing ends here` line), insert a new line with the following command:
- `python "C:\path\locating\PHITS_Tools\PHITS_tools.py" "%%~nxF" -po -m -d -ddir -ddeg -lzma -p -pa`

On Linux/Mac, using "phits/bin/phits.sh":

- Scroll down toward the bottom of the script, to the section titled `# Run PHITS`
- On the line after the end of the if statement `fi`, add the following command:
- `python "/path/locating/PHITS_Tools/PHITS_tools.py" $1 -po -m -d -ddir -ddeg -lzma -p -pa`

(Of course, if necessary, replace "`python`" with however you typically call python in your environment, e.g. `py`, `python3`, etc.)

Adding this line causes the following to happen:

- After PHITS finishes running normally, the PHITS input file is passed to PHITS Tools.
- Since it is a PHITS input file, the CLI will have `parse_all_tally_output_in_dir()` handle it, in *[INPUT_FILE mode]*
- The input file and its produced "phits.out"-type file are scanned for output files from active tallies, including those inputted via the PHITS insert file function `infl:{}` too (using `extract_tally_outputs_from_phits_input()` and `parse_phitsout_file()`). 
      - This will include any dump files (**`-d`**) if present.
      - When the "phits.out" file (`file(6)` in the PHITS input [Parameters]) is parsed, its metadata&mdash;including the PHITS input echo&mdash;will be saved to a .pickle file, compressed with LZMA (**`-lzma`**) and with the extra ".xz" extension.
- Then, the standard tally outputs are processed.  For each standard tally output:
      - The `parse_tally_output_file()` function processes the tally output, producing a `tally_output` dictionary with keys for the produced NumPy array, Pandas DataFrame, and metadata dictionary.
      - The `tally_output['tally_metadata']` dictionary will have the phits.out metadata dictionary added to it (**`-po`**) under the `'phitsout'` key.
      - This `tally_output` dictionary object is saved to a .pickle file, compressed with LZMA (**`-lzma`**) and with the extra ".xz" extension.
      - A plot, saved in PDF and PNG formats, of the tally's output is generated (**`-p`**) by `autoplot_tally_results()`.
      - These files will share the same name as the tally output file, just with different extensions.
- Then, any encountered tally dump files are processed (**`-d`**).  For each tally dump output file:
      - The `parse_tally_dump_file()` function processes the dump file, automatically extracting the dump parameters from its parent tally's output file.
      - Provided direction vector information (u,v,w) is present, extra directional information is calculated (**`-ddir`**), using degrees for the unit of calculated angles (**`-ddeg`**)
      - The produced named tuple list and Pandas DataFrame are saved as two separate LZMA-compressed pickle files.
- Then, a merged (**`-m`**) dictionary object containing all of the `tally_output` dictionaries for each standard tally output processed is produced.
      - The dictionary is keyed with the `file` parameter of each tally in the PHITS input, with the values being the corresponding `tally_output` dictionaries.
      - This merged dictionary object is saved to a pickle file sharing the same name as the PHITS input file but ending in "_ALL_TALLY_OUTPUTS.pickle", compressed with LZMA (**`-lzma`**) and with the extra ".xz" extension.
- Then, a PDF containing plots from all standard tally outputs (**`-pa`**) is generated with `autoplot_tally_results()`.
      - This PDF of plots is saved to a file sharing the same name as the PHITS input file but ending in "_ALL_TALLY_OUTPUTS_PLOTTED.pdf"


You can edit the flags provided to the CLI for your desired default behavior.  For instance, to only save the pickle file of 
the merged output (rather than for every tally output too), replace `-m` with `-smo`. And to not bother with creating a merged output
and only save the outputs for each individual tally, just omit `-m`.  Given that the plotting is easily the slowest part of 
the execution of PHITS Tools in most cases, it may be desirable to omit the `-p` and/or `-pa` flags to not save plots of the tally 
outputs individually or all together in a single PDF, respectively.  Since dump files can be very large and sometimes solely 
created for reusage by PHITS (e.g., with a `s-type=17` [Source] section), it may also be desirable to exclude dump files from 
automatic processing by omitting `-d`.  As an example, a more "minimal" automatic processing would result from:

- Windows: `python "C:\path\locating\PHITS_Tools\PHITS_tools.py" "%%~nxF" -po -smo -lzma -pa`
- Linux/Mac: `python "/path/locating/PHITS_Tools/PHITS_tools.py" $1 -po -smo -lzma -pa`

This would only create the ".pickle.xz" file of the merged standard tally outputs and the PDF containing all of their 
plots together, skipping any processing of dump files.

'''
'''
Each function beings with a comment block containing the following sections:

    Description:


    Dependencies:


    Inputs:


    Outputs:

("Dependencies:" is omitted when there are none.)        
'''

import sys
import os
import numpy as np
from pathlib import Path

# default program settings
launch_GUI = False
run_with_CLI_inputs = False
in_debug_mode = False # toggles printing of debug messages throughout the code
#in_debug_mode = True # toggles printing of debug messages throughout the code
test_explicit_files_dirs = False # used for testing specific files at the bottom of this file
#test_explicit_files_dirs = True


if __name__ == "__main__":
    #in_debug_mode = True

    if test_explicit_files_dirs:
        in_debug_mode = True
        pass
    elif len(sys.argv) == 1:
        launch_GUI = True
    else:
        if '-g' in sys.argv or '--GUI' in sys.argv:
            launch_GUI = True
        else:
            run_with_CLI_inputs = True
            # CLI for PHITS Tools
            import argparse


if in_debug_mode:
    import pprint
    import time
    # Timer start
    start = time.time()



# use Path, get extension, check for existence of filename_err.extension


def parse_tally_output_file(tally_output_filepath, make_PandasDF = True, calculate_absolute_errors = True,
                            save_output_pickle=True, include_phitsout_in_metadata=False, 
                            prefer_reading_existing_pickle=False, compress_pickle_with_lzma=False, 
                            autoplot_tally_output=False):
    '''
    Description:
        Parse any PHITS tally output file, returning tally metadata and an array of its values (and optionally
        this data inside of a Pandas dataframe too).  Note the separate `parse_tally_dump_file` function for
        parsing PHITS dump files.  If a DCHAIN input file (output from the [T-Dchain] tally) or DCHAIN output
        `*.act` file is provided, an attempt will be made to import the [DCHAIN Tools module](https://github.com/Lindt8/DCHAIN-Tools) and process the found
        DCHAIN output files, returning the output dictionary object and (optionally) saving it to a pickle file.

    Dependencies:
        - `import numpy as np`
        - `import pandas as pd` (if `make_PandasDF = True`)
        - `import seaborn as sns` (if `autoplot_tally_output = True`)
        - `from munch import Munch` (will still run if package not found)

    Inputs:
       (required)

        - **`tally_output_filepath`** = string or Path object denoting the path to the tally output file to be parsed

    Inputs:
       (optional)

       - **`make_PandasDF`** = (D=`True`) A Boolean denoting whether a Pandas dataframe of the tally data array will be made 
       - **`calculate_absolute_errors`** = (D=`True`) A Boolean determining whether the absolute uncertainty of each tally output value
                      is to be calculated (simply as the product of the value and relative error); if `False`, the final
                      dimension of `tally_data`, `ierr`, will be of length-2 rather than length-3 
       - **`save_output_pickle`** = (D=`True`) A Boolean determining whether the `tally_output` dictionary object is saved as a pickle file;
                      if `True`, the file will be saved with the same path and name as the provided PHITS tally output file
                      but with the .pickle extension. 
       - **`include_phitsout_in_metadata`** = (D=`False`) A Boolean determining whether the "phits.out" file 
                      (`file(6)` in the [Parameters] section of a PHITS input file) in the same directory as `tally_output_filepath`, 
                      if found, should be processed via `parse_phitsout_file()` and have its informational dictionary 
                      about the PHITS run added to the `'tally_metadata'` dictionary under the key `'phitsout'`.
                      If `True`, this function assumes `file(6) = phits.out` (the default setting) in the PHITS input.
            -         If, instead of a Boolean, a dictionary-type object is provided, no search will be conducted and the provided
                      dictionary will be taken as that to be added as the `'phitsout'` key in the `'tally_metadata'` dictionary.
            -         Otherwise, if a string or [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) object 
                      is provided, this will be taken as the path to the "phits.out"-type file to be processed and have its 
                      informational dictionary added to the `'tally_metadata'` dictionary.
            -         Setting `include_phitsout_in_metadata = None` functions the same as `include_phitsout_in_metadata = True` 
                      but sets `include_input_echo = False` in the `parse_phitsout_file()` call.
       - **`prefer_reading_existing_pickle`** = (D=`False`) A Boolean determining what this function does if the pickle file this function
                      seeks to generate already exists.  If `False` (default behavior), this function will parse the PHITS
                      output files as usual and overwrite the existing pickle file.  If `True`, this function will instead
                      simply just read the existing found pickle file and return its stored `tally_output` contents. 
       - **`compress_pickle_with_lzma`** = (D=`False`; requires `save_output_pickle=True`) Boolean designating 
                      whether the pickle file to be saved will be compressed with 
                      [LZMA compression](https://docs.python.org/3/library/lzma.html) (included in
                      the baseline [Python standard library](https://docs.python.org/3/library/index.html)); if so, the 
                      pickle file's extension will be `'.pickle.xz'` instead of just `'.pickle'`.
                      A *.pickle.xz file can then be opened (after importing `pickle` and `lzma`) as:  
                      `with lzma.open(path_to_picklexz_file, 'rb') as file: tally_output = pickle.load(file)`.  
                      For most "normal" tallies, the pickle file sizes are likely to be small enough to not warrant 
                      compression, but LZMA compression can reduce the file size by several orders of magnitude, 
                      great for output from massive tallies.
       - **`autoplot_tally_output`** = (D=`False`; requires `make_PandasDF=True`) Boolean denoting whether 
                      the tally's output will be automatically plotted and saved to a PDF and PNG (of the same name/path as 
                      `tally_output_filepath` but ending in `.pdf`/`.png`) using the `autoplot_tally_results()` function.

    Output:
        - **`tally_output`** = a dictionary object with the below keys and values:
            - `'tally_data'` = a 10-dimensional NumPy array containing all tally results, explained in more detail below
            - `'tally_metadata'` = a dictionary/Munch&dagger; object with various data extracted from the tally output file, such as axis binning and units
            - `'tally_dataframe'` = a Pandas dataframe version of `tally_data` (`None` if `make_PandasDF = False`) 
       
       &dagger;_If you have the [Munch package](https://github.com/Infinidat/munch) installed, the `tally_metadata` dictionary
       will instead be a Munch object, which is identical to a dictionary but additionally allows attribute-style access 
       (e.g., `tally_metadata.mesh` instead of only `tally_metadata['mesh']`).  If you do not have Munch, then `tally_metadata`
       will just be a standard dictionary object.  If, for whatever reason, you have Munch installed but do not wish for 
       `tally_metadata` to be a Munch object, then in the first line of code for the `parse_tally_header()` function, 
       set `prefer_to_munch_meta_dict = False`._
    
       -----
    
       -----
    
    Notes:
    
       **Quick code for loading pickled tally output**
       
           import pickle, lzma; from pathlib import Path
           p = Path('path/to/pickle/file.pickle[.xz]')
           tally_output = pickle.load(lzma.open(p, 'rb') if p.name[-3:]=='.xz' else open(p, 'rb'))
           tally_data, tally_metadata, tally_dataframe = [tally_output[k] for k in tally_output.keys()]
       
       -----
       
       **The `'tally_data'` 10-dimensional NumPy array**

       Many quantities can be scored across the various tallies in the PHITS code.  This function outputs a "universal"
       array `tally_data` (`tally_data = tally_output['tally_data']`) that can accomodate all of the different 
       scoring geometry meshes, physical quantities with assigned meshes, and output axes provided within PHITS. 
       This is achieved with a 10-dimensional array accessible as
        
       `tally_data[ ir, iy, iz, ie, it, ia, il, ip, ic, ierr ]`, with indices explained below:

       Tally data indices and corresponding mesh/axis:

        - `0` | `ir`, Geometry mesh: `reg` / `x` / `r` / `tet` ([T-Cross] `ir surf` if `mesh=r-z` with `enclos=0`)
        - `1` | `iy`, Geometry mesh:  `1` / `y` / `1`
        - `2` | `iz`, Geometry mesh:  `1` / `z` / `z` ([T-Cross] `iz surf` if `mesh=xyz` or `mesh=r-z` with `enclos=0`)
        - `3` | `ie`, Energy mesh: `eng` ([T-Deposit2] `eng1`)
        - `4` | `it`, Time mesh
        - `5` | `ia`, Angle mesh
        - `6` | `il`, LET mesh
        - `7` | `ip`, Particle type (`part = `)
        - `8` | `ic`, Special: [T-Deposit2] `eng2`; [T-Yield] `mass`, `charge`, `chart`; [T-Interact] `act`
        - `9` | `ierr = 0/1/2`, Value / relative uncertainty / absolute uncertainty (expanded to `3/4/5`, or `2/3` if
        `calculate_absolute_errors = False`, for [T-Cross] `mesh=r-z` with `enclos=0` case; see notes further below)


       By default, all array dimensions are length-1 (except `ierr`, which is length-3).  These dimensions are set/corrected
       automatically when parsing the tally output file.  Thus, for very simple tallies, most of these indices will be
       set to 0 when accessing tally results, e.g. `tally_data[2,0,0,:,0,0,0,:,0,:]` to access the full energy spectrum
       in the third region for all scored particles / particle groups with the values and uncertainties.
       
       -----
       
       **The `'tally_metadata'` dictionary**

       The output `tally_metadata` dictionary contains all information needed to identify every bin along every
       dimension: region numbers/groups, particle names/groups, bin edges and midpoints for all mesh types
       (x, y, z, r, energy, angle, time, and LET) used in the tally.
       
       It typically contains quite a bit of information. To conveniently view its contents, one can import the built-in
       pprint library `import pprint` and then use `pprint.pp(dict(tally_output['tally_metadata']))`.
       
       At a basic level, the "header" of the PHITS output file (everything before the first instance of `newpage:`) is
       parsed and, in effect, a key+value pair is created for every line containing an equals sign `=`.
       
       The following keys exist denoting the lengths of the first 9 axes/dimensions of the `tally_data` NumPy array: 
       `'nreg'`/`'nx'`/`'nr'`/`'ntet'`, `'ny'`, `'nz'`, `'ne'`, `'nt'`, `'na'`, `'nl'`,  `'npart'`, and `'nc'`. 
       These keys are all initialized as `None` and assigned a value if found in the PHITS output file. Thus, if one of
       these retains its value of `None`, the corresponding array axis length should be 1.  The `'found_mesh_kinds'` key 
       lists which of these meshes are identified.
       For these values with a defined numerical mesh, taking energy `'e'` for example here, a number of different keys 
       and values will be read/generated for the metadata dictionary, such as: `'e-type'`, `'emin'`, `'emax'`, `'edel'`, 
       `'e-mesh_bin_edges'`, `'e-mesh_bin_mids'`, and `'e-mesh_bin_mids_log'`.
       
       Complications arise in PHITS allowing the flexibility of grouping regions and particles together for scoring.
       For example, something like `(1 2 5)` can be provided to `reg = ` or something like `(neutron photon)` can be 
       provided to `part = ` in a tally.  To accomodate this, some additional key+value pairs are introduced for the 
       metadata dictionary:
       
       For regions:
       
       - `'reg'` = the string following `reg = ` in the PHITS output
       - `'reg_groups'` = a list of strings of each "group" of region(s) provided to `reg = ` in the PHITS output
       - `'num_reg_groups'` = an integer of the length of `'reg_groups'`, which is also the length of the first dimension of the `tally_data` array and should equal `'nreg'`
       - `'nreg'` = an integer of the number of scoring regions, should equal `'num_reg_groups'`
       - `'reg_serial_num'` = a list of integers counting the region groups, starting at 1
       - `'reg_num'` = a list of unique strings of the region numbers found (for individual regions) or assigned by PHITS (for combined regions)
       
       For particles:
       
       - `'part'` = the string following `part = ` in the PHITS output
       - `'npart'` = the integer number of particle scoring groups provided to `part = ` in the PHITS output
       - `'part_groups'` = a list of strings of each "group" of particle(s) provided to `part = ` in the PHITS output
       - `'kf_groups'` = a list of strings like `'part_groups'` but containing the kf-code particle identification numbers (see PHITS manual Table 4.4) for each particle/group; `0` is assigned to `part = all`
       - `'part_serial_groups'` = a list of strings with the serialized name of each particle scoring group (`'p1-group'`, `'p2-group'`, etc.)
       
       Perhaps of use for quickly making plots using PHITS Tools, the following keys also exist:
       
       - `'title'` = the title string of the plots used in the .eps files
       - `'axis_dimensions'` = `1` or `2` denoting whether the value provided to `axis = ` indicated one or two dimensional plots
       - `'axis_index_of_tally_array'` = integer specifying what axis of `tally_data` should be accessed for the quantity provided to `axis = `
       - `'axis1_label'` = the string of the horizontal axis label of the plots used in the .eps files
       - `'value_label'` = the string of the vertical axis label of the 1D plots used in the .eps files
       - `'axis2_label'` = the string of the vertical axis label of the 2D plots used in the .eps files
       
       Note that for some tallies there may be additional special entries (like for [T-Point]) or that some of the key 
       names may differ slightly from those stated here (such as for [T-Deposit2]).
       
       If the `include_phitsout_in_metadata` setting is enabled, `parse_phitsout_file()` will be called to provide a 
       dictionary containing metadata about the PHITS run itself&mdash;by default also including the PHITS run's input 
       echo&mdash;added to this metadata dictionary under the `'phitsout'` key. 
       See the `parse_phitsout_file()` function's description for more details about the structure of this dictionary.
       
       -----
       
       **The `'tally_dataframe'` Pandas DataFrame**

       The `tally_dataframe` Pandas dataframe output functions as normal. The dataframe contains a number of rows equal
       to the number of Values in the NumPy array (i.e., the product of the lengths of the first nine dimensions of the
       ten-dimensional array) and extra columns for each array dimension of length greater than one (e.g., if the tally
       includes an energy and/or angle mesh, columns will be present stating the energy/angle bin of each row).
       A dictionary containing supplemental information that is common to all rows of the dataframe is included and
       can be accessed with `tally_dataframe.attrs`.

       -----
    
       -----
    
    Exceptions:

       **Unsupported tallies and DCHAIN**

       The following tallies are NOT supported by this function:

        - [T-WWG], [T-WWBG], and [T-Volume] (due to being "helper" tallies for generating text sections meant for reinsertion into a PHITS input file)
        - [T-Gshow], [T-Rshow], [T-3Dshow], and [T-4Dtrack] (due to being visualization tallies meant for ANGEL/PHIG-3D)
        - [T-Userdefined] (due to having no standard format)
        - [T-Dchain]&Dagger;

       &Dagger;If provided with the output file of [T-Dchain] (the input file for the DCHAIN code) or the `*.act` main
       output file produced by the DCHAIN code, this function will attempt to import the [DCHAIN Tools module](https://github.com/Lindt8/DCHAIN-Tools)
       and process the found DCHAIN output files, returning the output dictionary object and (optionally) saving it to a pickle file.
       Otherwise, for [T-Dchain] or [T-Yield] with `axis = dchain`, you can manually use the separate suite of parsing
       functions included in the [DCHAIN Tools](https://github.com/Lindt8/DCHAIN-Tools) module.

       -----

       **[T-Cross] special case**

       The [T-Cross] tally is unique (scoring across region boundaries rather than within regions), creating some
       additional challenges.
       In the `mesh = reg` case, much is the same except each region number is composed of the `r-from` and `r-to` values, e.g. `'100 - 101'`.

       For `xyz` and `r-z` meshes, an additional parameter is at play: `enclos`.
       By default, `enclos=0`.
       In the event `enclos=1` is set, the total number of geometric regions is still either `nx*ny*nz` or `nr*nz` for
       `xyz` and `r-z` meshes, respectively.
       For `enclos=0` in the `mesh = xyz` case, the length of the z dimension (`iz` index) is instead equal to `nzsurf`,
       which is simply one greater than `nz` (# regions = `nx*ny*(nz+1)`).

       For `enclos=0` in the `mesh = r-z` case, this is much more complicated as PHITS will output every combination of
       `nr*nzsurf` AND `nrsurf*nz`, noting `nzsurf=nz+1` and `nrsurf=nr+1` (or `nrsurf=nr` if the first radius bin edge
       is `r=0.0`).
       The solution implemented here is to, for only this circumstance (in only the `enclos=0 mesh=r-z` case),
       set the length of the `ir` and `iz` dimensions to `nrsurf` and `nzsurf`, respectively, and also
       to expand the length of the final dimension of `tally_data` from 3 to 6 (or from 2 to 4 if `calculate_absolute_errors=False`), where:

        - `ierr = 0/1/2` refer to the combinations of `nr` and `nzsurf` (or `0/1` if `calculate_absolute_errors=False`)
        - `ierr = 3/4/5` refer to the combinations of `nrsurf` and `nz` (or `2/3` if `calculate_absolute_errors=False`)

       In this case, the Pandas dataframe, if enabled, will contain 3 (or 2) extra columns `value2` and `rel.err.2` [and `abs.err.2`],
       which correspond to the combinations of `nrsurf` and `nz` (while the original columns without the "2" refer to
       values for combinations of and `nr` and `nzsurf`).

       -----

       **[T-Yield] special case**

       [T-Yield] is also a bit exceptional.  When setting the `axis` parameter equal to `charge`, `mass`, or `chart`,
       the `ic` dimension of `tally_data` is used for each entry of charge (proton number, Z), mass (A), or
       isotope/isomer, respectively.

       In the case of `axis = charge` or `axis = mass`, the value of `ic` refers to the actual charge/proton number Z
       or mass number A when accessing `tally_data`; for instance, `tally_data[:,:,:,:,:,:,:,:,28,:]`
       references results from nuclei with Z=28 if `axis = charge` or A=28 if `axis = mass`.  The length of the `ic`
       dimension is initialized as 130 or 320 but is later reduced to only just include the highest charge or mass value.

       In the case of `axis = chart`, the length of the `ic` dimension is initially set equal to the `mxnuclei` parameter
       in the [T-Yield] tally.  If `mxnuclei = 0` is set, then the length of the `ic` dimension is initially set to 10,000.
       This `ic` dimension length is later reduced to the total number of unique nuclides found in the output.
       Owing to the huge number of possible nuclides, a list of found nuclides with nonzero yield is assembled and
       added to `tally_metadata` under the keys `nuclide_ZZZAAAM_list` and `nuclide_isomer_list`, i.e.
       `tally_metadata['nuclide_ZZZAAAM_list']` and `tally_metadata['nuclide_isomer_list']`.
       These lists should be referenced to see what nuclide each of index `ic` refers to.
       The entries of the ZZZAAAM list are intergers calculated with the formula 10000&ast;Z + 10&ast;A + M, where M is the
       metastable state of the isomer (0 = ground state, 1 = 1st metastable/isomeric state, etc.).  The entries
       of the isomer list are these same nuclides in the same order but written as plaintext strings, e.g. `'Al-28'` and `'Xe-133m1'`.
       The lists are ordered in the same order nuclides are encountered while parsing the output file.
       Thus, to sensibly access the yield of a specific nuclide, one must first find its index `ic` in one of the two
       metadata lists of ZZZAAAM values or isomer names and then use that to access `tally_data`.  For example, to get
       the yield results of production of carbon-14 (C-14), one would use the following code:

       `ic = tally_metadata['nuclide_ZZZAAAM_list'].index(60140)`

       OR

       `ic = tally_metadata['nuclide_isomer_list'].index('C-14')`

       then

       `my_yield_values = tally_data[:,:,:,:,:,:,:,:,ic,:]`


    '''

    '''
    The old [T-Cross] mesh=r-z enclos=0 solution is written below:
        The solution implemented here uses `ir` to iterate `nr`, `iy` to iterate `nrsurf`, `iz` to
        iterate `nz`, and `ic` to iterate `nzsurf`.  Since only `rsurf*z [iy,iz]` and `r*zsurf [ir,ic]` pairs exist,
        when one pair is being written, the other will be `[-1,-1]`, thus the lengths of these dimensions for the array
        are increased by an extra 1 to prevent an overlap in the data written.
    '''
    tally_output_filepath = Path(tally_output_filepath)
    pickle_filepath = Path(tally_output_filepath.parent, tally_output_filepath.stem + '.pickle')
    picklexz_filepath = Path(tally_output_filepath.parent, tally_output_filepath.stem + '.pickle.xz')
    if prefer_reading_existing_pickle and (os.path.isfile(pickle_filepath) or os.path.isfile(picklexz_filepath)):
        import pickle
        if os.path.isfile(pickle_filepath):
            print('Reading found pickle file: ', pickle_filepath)
            with open(pickle_filepath, 'rb') as handle:
                tally_output = pickle.load(handle)
        else:
            print('Reading found pickle file: ', picklexz_filepath)
            import lzma
            with lzma.open(picklexz_filepath, 'rb') as handle:
                tally_output = pickle.load(handle)
        if autoplot_tally_output:
            if tally_output['tally_dataframe'] is not None:
                plot_filepath = Path(tally_output_filepath.parent, tally_output_filepath.stem + '.pdf')
                if not plot_filepath.is_file():  # only make plot if file doesn't already exist
                    from inspect import signature
                    max_num_values_to_plot = signature(autoplot_tally_results).parameters['max_num_values_to_plot'].default  # 1e7
                    tot_num_values = np.prod(np.shape(tally_output['tally_data'])[:-1])
                    if tot_num_values > max_num_values_to_plot:
                        print('WARNING: Tally output for ', tally_output['tally_metadata']['file'], ' is VERY LARGE (', tot_num_values,
                              ' elements), deemed too large for automatic plotting (default max of', max_num_values_to_plot, 'elements).')
                    else:
                        autoplot_tally_results(tally_output, output_filename=plot_filepath,
                                               plot_errorbars=calculate_absolute_errors,
                                               additional_save_extensions=['.png'])
        return tally_output

    # main toggled settings
    #calculate_absolute_errors = True
    construct_Pandas_frame_from_array = make_PandasDF
    #process_all_tally_out_files_in_directory = False
    save_pickle_files_of_output = save_output_pickle  # save metadata, array, and Pandas frame in a pickled dictionary object

    if construct_Pandas_frame_from_array: import pandas as pd

    # Check if is _err or _dmp file (or normal value file)
    is_val_file = False
    is_err_file = False
    is_dmp_file = False
    if tally_output_filepath.stem[-4:] == '_err':
        is_err_file = True
    elif tally_output_filepath.stem[-4:] == '_dmp':
        is_dmp_file = True
    else:
        is_val_file = True

    if is_dmp_file:
        print('ERROR: The provided file is a "dump" output file. Use the function titled "parse_tally_dump_file" to process it instead.')
        return None

    if is_err_file:
        print('WARNING: Provided file contains just relative uncertainties.',str(tally_output_filepath))
        potential_val_file = Path(tally_output_filepath.parent, tally_output_filepath.stem.replace('_err','') + tally_output_filepath.suffix)
        if potential_val_file.is_file():
            print('\t Instead, both it and the file with tally values will be parsed.')
            potential_err_file = tally_output_filepath
            tally_output_filepath = potential_val_file
            is_val_file = True
            is_err_file = False
        else:
            print('\t The corresponding file with tally values could not be found, so only these uncertainties will be parsed.')

    # Split content of output file into header and content
    if in_debug_mode: print("\nSplitting output into header and content...   ({:0.2f} seconds elapsed)".format(time.time() - start))
    tally_header, tally_content = split_into_header_and_content(tally_output_filepath)
    if in_debug_mode: print("\tComplete!   ({:0.2f} seconds elapsed)".format(time.time() - start))
    # print(len(tally_content))

    # Check if *_err file exists
    potential_err_file = Path(tally_output_filepath.parent, tally_output_filepath.stem + '_err' + tally_output_filepath.suffix)
    is_err_in_separate_file = potential_err_file.is_file()  # for some tallies/meshes, uncertainties are stored in a separate identically-formatted file

    # Extract tally metadata
    if in_debug_mode: print("\nExtracting tally metadata...   ({:0.2f} seconds elapsed)".format(time.time() - start))
    tally_metadata = parse_tally_header(tally_header, tally_content)
    if in_debug_mode: print("\tComplete!   ({:0.2f} seconds elapsed)".format(time.time() - start))
    if in_debug_mode: pprint.pp(dict(tally_metadata))
    # Check if tally_type is among those supported.
    unsupported_tally_types = ['[T-WWG]', '[T-WWBG]', '[T-Volume]', '[T-Userdefined]', '[T-Gshow]', '[T-Rshow]',
                               '[T-3Dshow]', '[T-4Dtrack]', '[T-Dchain]', 'UNKNOWN']
    if tally_metadata['tally_type'] in unsupported_tally_types:
        print('ERROR! tally type',tally_metadata['tally_type'],'is not supported by this function!')
        if tally_metadata['tally_type'] == '[T-Dchain]' or tally_output_filepath.suffix == '.act':
            print('Instead, the DCHAIN Tools module is used to process the DCHAIN output files with the same basename of the provided file.')
            dchain_tools_url = 'github.com/Lindt8/DCHAIN-Tools'
            dchain_tools_go_to_github_str = 'The DCHAIN Tools module ( '+dchain_tools_url+' ) is capable of parsing all DCHAIN-related output.'
            if tally_output_filepath.suffix != '.act':
                act_filepath = Path(tally_output_filepath.parent, tally_output_filepath.stem + '.act')
                if not act_filepath.is_file():
                    # DCHAIN output is not present in directory
                    print('Failed to find the main DCHAIN *.act output file:',act_filepath)
                    print('Aborting this process...')
                    return None
            try:
                from dchain_tools import process_dchain_simulation_output
            except:
                try:
                    from PHITS_tools.dchain_tools import process_dchain_simulation_output
                except:
                    print('Failed to import the DCHAIN Tools module; to parse DCHAIN output via PHITS Tools, please install DCHAIN Tools and configure it in your Python environment')
                    return None
            simulation_folder_path = str(Path(tally_output_filepath.parent)) + '\\'
            simulation_basename = str(tally_output_filepath.stem)
            dchain_output = process_dchain_simulation_output(simulation_folder_path,simulation_basename,process_DCS_file=True)
            if save_output_pickle:
                import pickle
                path_to_pickle_file = Path(tally_output_filepath.parent, tally_output_filepath.stem + '.pickle')
                if in_debug_mode: print("\nWriting output to pickle file...   ({:0.2f} seconds elapsed)".format(time.time() - start))
                with open(path_to_pickle_file, 'wb') as handle:
                    pickle.dump(dchain_output, handle, protocol=pickle.HIGHEST_PROTOCOL)
                    print('Pickle file written:', path_to_pickle_file, '\n')
                if in_debug_mode: print("\tComplete!   ({:0.2f} seconds elapsed)".format(time.time() - start))
            return dchain_output
        else:
            return None
    if (tally_metadata['tally_type'] == '[T-Yield]' or tally_metadata['tally_type'] == '[T-Track]') and tally_metadata['axis'] == 'dchain':
        dchain_tools_url = 'github.com/Lindt8/DCHAIN-Tools'
        print('This function does not support [T-Yield]/[T-Track] with setting "axis = dchain".')
        print('However, the DCHAIN Tools module (', dchain_tools_url, ') is capable of parsing all DCHAIN-related output.')
        return None

    # Initialize tally data array with zeros
    tally_data = initialize_tally_array(tally_metadata, include_abs_err=calculate_absolute_errors)

    # Parse tally data
    if is_val_file:
        err_mode = False
    else: # if is_err_file
        err_mode = True
    if in_debug_mode: print("\nParsing tally data...   ({:0.2f} seconds elapsed)".format(time.time() - start))
    if tally_metadata['tally_type']=='[T-Yield]' and tally_metadata['axis'] in ['chart','charge','mass']: # need to update metadata too
        tally_data, tally_metadata = parse_tally_content(tally_data, tally_metadata, tally_content, is_err_in_separate_file, err_mode=err_mode)
    else:
        tally_data = parse_tally_content(tally_data, tally_metadata, tally_content, is_err_in_separate_file, err_mode=err_mode)
    if in_debug_mode: print("\tComplete!   ({:0.2f} seconds elapsed)".format(time.time() - start))
    err_data_found = True
    if tally_metadata['axis_dimensions'] == 2 and tally_metadata['2D-type'] != 4:
        if is_err_file:
            err_data_found = False
        elif is_err_in_separate_file:
            err_tally_header, err_tally_content = split_into_header_and_content(potential_err_file)
            if in_debug_mode: print("\nParsing tally error...   ({:0.2f} seconds elapsed)".format(time.time() - start))
            if tally_metadata['tally_type'] == '[T-Yield]' and tally_metadata['axis'] in ['chart','charge','mass']:  # need to update metadata too
                tally_data, tally_metadata = parse_tally_content(tally_data, tally_metadata, err_tally_content, is_err_in_separate_file,err_mode=True)
            else:
                tally_data = parse_tally_content(tally_data, tally_metadata, err_tally_content, is_err_in_separate_file, err_mode=True)
            if in_debug_mode: print("\tComplete!   ({:0.2f} seconds elapsed)".format(time.time() - start))
        else:
            print('WARNING: A separate file ending in "_err" containing uncertainties should exist but was not found.')
            err_data_found = False
    if calculate_absolute_errors:
        if err_data_found:
            if in_debug_mode: print("\nCalculating absolute errors...   ({:0.2f} seconds elapsed)".format(time.time() - start))
            tally_data = calculate_tally_absolute_errors(tally_data)
            if in_debug_mode: print("\tComplete!   ({:0.2f} seconds elapsed)".format(time.time() - start))
        elif is_err_file:
            print('WARNING: Absolute errors not calculated since the main tally values file was not found.')
        else:
            print('WARNING: Absolute errors not calculated since the _err file was not found.')
    # Generate Pandas dataframe of tally results
    if construct_Pandas_frame_from_array:
        if in_debug_mode: print("\nConstructing Pandas dataframe...   ({:0.2f} seconds elapsed)".format(time.time() - start))
        tally_Pandas_df = build_tally_Pandas_dataframe(tally_data, tally_metadata)
        if in_debug_mode: print("\tComplete!   ({:0.2f} seconds elapsed)".format(time.time() - start))
    else:
        tally_Pandas_df = None

    phitsout_dict = {}
    include_input_echo = True
    if include_phitsout_in_metadata is None:
        include_input_echo = False
        include_phitsout_in_metadata = True
    if type(include_phitsout_in_metadata) == bool:  # True or False
        if include_phitsout_in_metadata: # True provided, need to search for phits.out
            phitsout_file = Path(tally_output_filepath.parent, 'phits.out')
            if phitsout_file.exists():
                phitsout_dict = parse_phitsout_file(phitsout_file,  save_phitsout_pickle=save_output_pickle, compress_pickle_with_lzma=compress_pickle_with_lzma, include_input_echo=include_input_echo)
    elif isinstance(include_phitsout_in_metadata, dict): # dictionary provided for phitsout
        phitsout_dict = include_phitsout_in_metadata
    else:  # assume path string/object pointing to phits.out is provided
        phitsout_dict = parse_phitsout_file(include_phitsout_in_metadata, save_phitsout_pickle=save_output_pickle, compress_pickle_with_lzma=compress_pickle_with_lzma)
    if phitsout_dict != {}:
        tally_metadata['phitsout'] = phitsout_dict
    
    tally_output = {
        'tally_data': tally_data,
        'tally_metadata': tally_metadata,
        'tally_dataframe': tally_Pandas_df,
    }

    if save_output_pickle:
        import pickle
        if compress_pickle_with_lzma:
            import lzma
            compression_file_extension = '.xz'
        else:
            compression_file_extension = ''
        path_to_pickle_file = Path(tally_output_filepath.parent, tally_output_filepath.stem + '.pickle' + compression_file_extension)
        if in_debug_mode: print("\nWriting output to pickle file...   ({:0.2f} seconds elapsed)".format(time.time() - start))
        if compress_pickle_with_lzma:
            with lzma.open(path_to_pickle_file, 'wb') as handle:
                pickle.dump(tally_output, handle, protocol=4)  # protocol=4 needed to pickle an object w/ a Pandas DF
        else:
            with open(path_to_pickle_file, 'wb') as handle:
                pickle.dump(tally_output, handle, protocol=pickle.HIGHEST_PROTOCOL)
        print('Pickle file written:', path_to_pickle_file, '\n')
        if in_debug_mode: print("\tComplete!   ({:0.2f} seconds elapsed)".format(time.time() - start))
    
    if autoplot_tally_output:
        if not make_PandasDF:
            print('Plotting via "autoplot_tally_output=True" requires also setting "make_PandasDF=True".')
        else:
            from inspect import signature
            max_num_values_to_plot = signature(autoplot_tally_results).parameters['max_num_values_to_plot'].default  # 1e7
            tot_num_values = np.prod(np.shape(tally_output['tally_data'])[:-1])
            if tot_num_values > max_num_values_to_plot:
                print('WARNING: Tally output for ', tally_output['tally_metadata']['file'], ' is VERY LARGE (', tot_num_values,
                      ' elements), deemed too large for automatic plotting (default max of',max_num_values_to_plot,'elements).')
            else:
                plot_filepath = Path(tally_output_filepath.parent, tally_output_filepath.stem + '.pdf')
                autoplot_tally_results(tally_output, output_filename=plot_filepath,
                                       plot_errorbars=calculate_absolute_errors, 
                                       additional_save_extensions=['.png'])
    
    return tally_output



def parse_tally_dump_file(path_to_dump_file, dump_data_number=None , dump_data_sequence=None, return_directional_info=False,
                          use_degrees=False,max_entries_read=None,return_namedtuple_list=True,
                          return_Pandas_dataframe=True, save_namedtuple_list=False, save_Pandas_dataframe=False,
                          compress_pickles_with_lzma=True, prefer_reading_existing_pickle=False,
                          split_binary_dumps_over_X_GB=20, merge_split_dump_handling=0):
    '''
    Description:
        Parses the dump file of a [T-Cross], [T-Product], or [T-Time] tally generated by PHITS, in ASCII or binary format.

    Dependencies:
        - `import numpy as np`
        - `from collections import namedtuple`
        - `from scipy.io import FortranFile`
        - `import pandas as pd` (if `return_Pandas_dataframe = True`)

    Inputs:
       (required)

        - **`path_to_dump_file`** = string or Path object denoting the path to the dump tally output file to be parsed
        - **`dump_data_number`** = integer number of data per row in dump file, binary if >0 and ASCII if <0.
                 This should match the value following `dump=` in the tally creating the dump file. (D=`None`)
                 If not specified, the `search_for_dump_parameters()` function will attempt to find it automatically.
        - **`dump_data_sequence`** = string or list of integers with the same number of entries as `dump_data_number`,
                 mapping each column in the dump file to their physical quantities.  (D=`None`)
                 This should match the line following the `dump=` line in the tally creating the dump file.
                 See PHITS manual section "6.7.22 dump parameter" for further explanations of these values.
                 If not specified, the `search_for_dump_parameters()` function will attempt to find it automatically.

    Inputs:
       (optional)

        - **`return_directional_info`** = (D=`False`) Boolean designating whether extra directional information
                 should be calculated and returned; these include: radial distance `r` from the origin in cm,
                 radial distance `rho` from the z-axis in cm,
                 polar angle `theta` between the direction vector and z-axis in radians [0,pi] (or degrees), and
                 azimuthal angle `phi` of the direction vector in radians [-pi,pi] (or degrees).
                 Note: This option requires all position and direction values [x,y,z,u,v,w] to be included in the dump file.
        - **`use_degrees`** = (D=`False`) Boolean designating whether angles `theta` and `phi` are returned
                 in units of degrees. Default setting is to return angles in radians.
        - **`max_entries_read`** = (D=`None`) integer number specifying the maximum number of entries/records
                 of the dump file to be read.  By default, all records in the dump file are read.
        - **`return_namedtuple_list`** = (D=`True`) Boolean designating whether `dump_data_list` is returned.
        - **`return_Pandas_dataframe`** = (D=`True`) Boolean designating whether `dump_data_frame` is returned.
        - **`prefer_reading_existing_pickle`** = (D=`False`) A Boolean determining what this function does if the 
                 pickle file(s) this function seeks to generate already exist.  If `False` (default behavior), 
                 this function will parse the PHITS dump files as usual and overwrite the existing pickle file(s). 
                 If `True`, this function will instead simply just read the existing found pickle file(s) and return the stored contents. 
                 Note that it can also be used for creating LZMA compressed/decompressed versions of the pickle file(s) 
                 if the file is set to be saved and the found existing file's compression (or lack thereof) contrasts
                 with the current setting of `compress_pickles_with_lzma`.
        - **`compress_pickles_with_lzma`** = (D=`True`) Boolean designating whether the pickle files to be saved of
                 the namedtuple list (if `save_namedtuple_list = True`) and/or the Pandas DataFrame (if `save_Pandas_dataframe = True`)
                 will be compressed with [LZMA compression](https://docs.python.org/3/library/lzma.html) (included within
                 the baseline [Python standard library](https://docs.python.org/3/library/index.html)); if so, the file
                 extension of the saved pickle file(s) will be `'.pickle.xz'` instead of just `'.pickle'`.
                 A *.pickle.xz file can then be opened (after importing `pickle` and `lzma`) as:
                 `with lzma.open(path_to_picklexz_file, 'rb') as file: dump_data_list = pickle.load(file)`.
                 While compression will notably slow down the file-saving process, owing to the often large size of
                 PHITS dump files the additional reduction in file size (often around a factor of 5) is generally preferred.
        - **`save_namedtuple_list`** = (D=`False`) Boolean designating whether `dump_data_list` is saved to a pickle file,
                which will by default be compressed with LZMA (built-in with Python; see the `compress_pickles_with_lzma` option).
                For complicated reasons&dagger;, the namedtuple list is converted to a [NumPy recarray](https://numpy.org/doc/stable/reference/generated/numpy.recarray.html) before being saved,
                which is nearly functionally identical to the list of namedtuples.  When viewing individual entries,
                the field names are not printed (but can be accessed with `dump_data_list.dtype.names`), but you can access
                the fields in exactly the same atribute access way as namedtuples (e.g., `dump_data_list[0].time` to
                view the time column value for the first entry in the list of events) as well as in a dictionary-like
                way (e.g., `dump_data_list[0]['time']` for that same time value).   
                &dagger;_Pickle could not save the list of namedtuples without error; the dill library could but
                adding the extra external dependency was undesirable. Furthermore, the time performance of saving the
                NumPy recarray was substantially superior, approximately a factor of 2, relative to just using dill to
                save the list of namedtuples, also using the LZMA compression in both cases._
        - **`save_Pandas_dataframe`** = (D=`False`) Boolean designating whether `dump_data_frame` is saved to a pickle
                file (via the Pandas `.to_pickle()` method), which will by default
                be compressed with LZMA (built-in with Python; see the `compress_pickles_with_lzma` option). This pickle 
                can then be accessed via the [`pandas.read_pickle(path_to_DF_pickle)`](https://pandas.pydata.org/docs/reference/api/pandas.read_pickle.html) function.
        - **`split_binary_dumps_over_X_GB`** = (D=`20` GB) an integer/float specifying the number of gigabytes over 
                which a _binary_ dump file will be split into chunks of this size for processing&Dagger;.
        - **`merge_split_dump_handling`** = (D=`0`) For instances where binary dump files are to be split, 
                described above for `split_binary_dumps_over_X_GB` and the the note below&Dagger;, input one of the following 
                numbers to determine what will happen to the intermediate pickle files created from each split chunk:  
           - `0` = Merge the intermediate pickle files (via `merge_dump_file_pickles()`), then, if successful, delete them.  
           - `1` = Do not merge the intermediate pickle files.  
           - `2` = Merge the intermediate pickle files but do not delete them afterward.
    
    Outputs:
        - **`dump_data_list`** = List of length equal to the number of records contained in the file. Each entry in the list
                 is a namedtuple containing all of the physical information in the dump file for a given particle event,
                 in the same order as specified in `dump_data_sequence` and using the same naming conventions for keys as
                 described in the PHITS manual section "6.7.22 dump parameter"
                 (`kf`, `x`, `y`, `z`, `u`, `v`, `w`, `e`, `wt`, `time`, `c1`, `c2`, `c3`, `sx`, `sy`, `sz`, `name`, `nocas`, `nobch`, `no`).
                 If `return_directional_info = True`, `r`, `rho`, `theta`, and `phi` are appended to the end of this namedtuple, in that order.
        - **`dump_data_frame`** = A Pandas dataframe created from `dump_data_list` with columns for each physical quantity
                 and rows for each record included in the dump file.
                 
    Notes:

       &Dagger;For extremely large dump files, having sufficient available memory (RAM) can become an issue. 
       This function has a built-in feature for automatically splitting _binary_ dump files over a certain size 
       (20 GB by default) into smaller chunks to be processed individually and then merged at the end. 
       You can control when/if this splitting is triggered and how the resulting files are merged/handled 
       with the `split_binary_dumps_over_X_GB` and `merge_split_dump_handling` inputs to this function. 
       If running PHITS simulations yielding absolutely massive ASCII dump files, consider setting the tally's `dump` parameter to 
       a positive number to write _binary_ (much more space efficient) dump files instead, unless you have a good 
       reason for needing them as ASCII.
       For more information on converting an existing ASCII dump file to a binary one, 
       see the documentation within the following directory of your PHITS installation: /phits/utility/dump-a/
       
    '''
    # The below variables handle if/when absolutely massive binary dump files are forcibly split to not exceed RAM limits.
    # This only applies to dump files stored in binary.
    force_split_handling_of_massive_dumps = True  # If False, this function will attempt to ingest the whole dump file, regardless of how big.
    max_bytes_to_handle_per_dump_part = split_binary_dumps_over_X_GB * 1073741824  # There are 1073741824 bytes in 1 GB.
    # max_bytes_to_handle_per_dump_part sets how much of the dump file is processed at a time, the default above is 20 GB.
    merge_split_parts_at_end = True  # determines if split pickles will be merged at the end
    delete_merged_parts_at_end = True  # if merging, determines if component parts will be deleted or not.
    if merge_split_dump_handling == 1: merge_split_parts_at_end = False
    if merge_split_dump_handling == 2: delete_merged_parts_at_end = False
    
    # Initializing this as False
    split_dump = False  # This will be set to True if the dump file's size is found to exceed max_bytes_to_handle_per_dump_part
    
    from collections import namedtuple
    #from typing import NamedTuple
    from scipy.io import FortranFile
    if return_Pandas_dataframe or save_Pandas_dataframe:
        import pandas as pd
    if save_Pandas_dataframe or save_namedtuple_list or prefer_reading_existing_pickle:
        import pickle
        import lzma

    if not return_namedtuple_list and not return_Pandas_dataframe and not save_namedtuple_list and not save_Pandas_dataframe:
        raise ValueError('ERROR: All "return_namedtuple_list", "return_Pandas_dataframe", "save_namedtuple_list", and "save_Pandas_dataframe" are False. Enable at least one to use this function.')

    if dump_data_number == None or dump_data_sequence == None:
        dump_data_number, dump_data_sequence = search_for_dump_parameters(path_to_dump_file)
    if dump_data_number == None or dump_data_sequence == None:
        raise ValueError("Please manually specify 'dump_data_number' and 'dump_data_sequence'; these were not inputted and could not be automatically found from an origin tally standard output file.")
        #return None

    if isinstance(dump_data_sequence, str):
        dump_data_sequence = dump_data_sequence.split()
        dump_data_sequence = [int(i) for i in dump_data_sequence]
    dump_file_is_binary = True if (dump_data_number > 0) else False  # if not binary, file will be ASCII
    data_values_per_line = abs(dump_data_number)
    if data_values_per_line != len(dump_data_sequence):
        raise ValueError('ERROR: Number of values in "dump_data_sequence" is not equal to "dump_data_number"')

    path_to_dump_file = Path(path_to_dump_file)
    dump_file_suffixes = path_to_dump_file.suffixes
    MPI_subdump_num_str = ''
    if len(dump_file_suffixes) > 1 and is_number(dump_file_suffixes[-1][1:]) and '_dmp' in path_to_dump_file.stem:  # MPI dump found
        MPI_subdump_num_str = dump_file_suffixes[-1]  # [1:]
        
    if compress_pickles_with_lzma:
        compression_file_extension = '.xz'
    else:
        compression_file_extension = ''
    
    pandas_df_pickle_filepath = Path(path_to_dump_file.parent, path_to_dump_file.stem + MPI_subdump_num_str + '_Pandas_df.pickle' + compression_file_extension)
    recarray_pickle_filepath = Path(path_to_dump_file.parent, path_to_dump_file.stem + MPI_subdump_num_str + '_namedtuple_list.pickle' + compression_file_extension)
    
    if prefer_reading_existing_pickle:
        df_exists = pandas_df_pickle_filepath.is_file()
        read_df_path = pandas_df_pickle_filepath
        if not df_exists:
            if compress_pickles_with_lzma: 
                tmp_path = Path(pandas_df_pickle_filepath.parent, pandas_df_pickle_filepath.name.replace('.xz',''))
            else:
                tmp_path = Path(pandas_df_pickle_filepath.parent, pandas_df_pickle_filepath.name + '.xz')
            if tmp_path.is_file():
                df_exists = True
                read_df_path = tmp_path
        ra_exists = recarray_pickle_filepath.is_file()
        read_ra_path = recarray_pickle_filepath
        read_ra_uses_lzma = compress_pickles_with_lzma
        if not ra_exists:
            if compress_pickles_with_lzma:
                tmp_path = Path(recarray_pickle_filepath.parent, recarray_pickle_filepath.name.replace('.xz', ''))
            else:
                tmp_path = Path(recarray_pickle_filepath.parent, recarray_pickle_filepath.name + '.xz')
            if tmp_path.is_file():
                ra_exists = True
                read_ra_path = tmp_path
                read_ra_uses_lzma = not compress_pickles_with_lzma
        # Perform any desired compression/decompression of pickles
        if ra_exists:
            if compress_pickles_with_lzma and read_ra_path.name[-3:]!='.xz' and save_namedtuple_list: # save a compressed version
                with open(read_ra_path, 'rb') as file: records_list = pickle.load(file)
                with lzma.open(recarray_pickle_filepath, 'wb') as handle: pickle.dump(records_list, handle, protocol=pickle.HIGHEST_PROTOCOL)
            if not compress_pickles_with_lzma and read_ra_path.name[-3:]=='.xz' and save_namedtuple_list: # save an uncompressed version
                with lzma.open(read_ra_path, 'rb') as file: records_list = pickle.load(file)
                with open(recarray_pickle_filepath, 'wb') as handle: pickle.dump(records_list, handle, protocol=pickle.HIGHEST_PROTOCOL)
            save_namedtuple_list = False
        if df_exists:
            if (compress_pickles_with_lzma and read_df_path.name[-3:]!='.xz' and save_Pandas_dataframe) or \
                    (not compress_pickles_with_lzma and read_df_path.name[-3:]=='.xz' and save_Pandas_dataframe):  # save a compressed or uncompressed version
                records_df = pd.read_pickle(read_df_path)
                records_df.to_pickle(pandas_df_pickle_filepath)
            save_Pandas_dataframe = False
        # Determine if function can end here or if the dump file needs to be read and reprocessed
        if (ra_exists and return_namedtuple_list and not save_namedtuple_list) and (df_exists and return_Pandas_dataframe and not save_Pandas_dataframe):
            if read_ra_uses_lzma:
                with lzma.open(read_ra_path, 'rb') as file: records_list = pickle.load(file)
            else:
                with open(read_ra_path, 'rb') as file: records_list = pickle.load(file)
            records_df = pd.read_pickle(read_df_path)
            return records_list, records_df 
        elif (ra_exists and return_namedtuple_list and not save_namedtuple_list):
            if read_ra_uses_lzma:
                with lzma.open(read_ra_path, 'rb') as file: records_list = pickle.load(file)
            else:
                with open(read_ra_path, 'rb') as file: records_list = pickle.load(file)
            return records_list 
        elif (df_exists and return_Pandas_dataframe and not save_Pandas_dataframe):
            records_df = pd.read_pickle(read_df_path)
            return records_df
        elif not return_namedtuple_list and not return_Pandas_dataframe and not save_namedtuple_list and not save_Pandas_dataframe:
            # nothing more to be done in the function, everything to be saved already exists
            return None
    
    
    # Generate NamedTuple for storing record information
    # See PHITS manual section "6.7.22 dump parameter" for descriptions of these values
    dump_quantities = ['kf', 'x', 'y', 'z', 'u', 'v', 'w', 'e', 'wt', 'time', 'c1', 'c2', 'c3', 'sx', 'sy', 'sz',
                       'name', 'nocas', 'nobch', 'no']
    dump_col_dtypes = [int, float, float, float, float, float, float, float, float, float, int, int, int, float, float, float,
                       int, int, int, int]
    ordered_record_entries_list = [dump_quantities[i - 1] for i in dump_data_sequence]
    rawRecord = namedtuple('rawRecord', ordered_record_entries_list)
    if return_directional_info:
        ordered_record_entries_list += ['r', 'rho', 'theta', 'phi']
        angle_units_mult = 1
        if use_degrees: angle_units_mult = 180 / np.pi
    Record = namedtuple('Record', ordered_record_entries_list)

    records_list = []
    if dump_file_is_binary:
        # Read binary dump file; extract each record (particle)
        file_size_bytes = os.path.getsize(path_to_dump_file)
        record_size_bytes = (data_values_per_line + 1) * 8  # each record has 8 bytes per data value plus an 8-byte record end
        num_records = int(file_size_bytes / record_size_bytes)
        if max_entries_read != None:
            if max_entries_read < num_records:
                num_records = max_entries_read
        bytes_to_read = num_records*record_size_bytes
        current_record_count = 0
        if force_split_handling_of_massive_dumps and bytes_to_read > max_bytes_to_handle_per_dump_part:
            #num_total_parts = np.ceil(bytes_to_read/max_bytes_to_handle_per_dump_part) 
            max_records_per_part = np.floor(max_bytes_to_handle_per_dump_part/record_size_bytes)
            num_total_parts = np.ceil(num_records/max_records_per_part)
            remaining_total_records_to_read = num_records
            remaining_records_to_read_in_this_part = max_records_per_part
            num_current_part = 1
            current_bytes_read = 0
            split_dump = True
            paths_to_split_recarrays = [] 
            paths_to_split_pandas_dfs = []
            paths_to_split_fake_dump_files = []
            if not save_namedtuple_list and not save_Pandas_dataframe:
                raise ValueError('ERROR: "save_namedtuple_list" and "save_Pandas_dataframe" are False. Enable at least one to parse a dump file to be split.')
            print('WARNING: Dump file will be split and then remerged to not exceed memory limits.')
            #if return_namedtuple_list or return_Pandas_dataframe:
            #    print('WARNING: When splitting a huge dump, "return_namedtuple_list" and "return_Pandas_dataframe" are set to False.')
            #    return_namedtuple_list, return_Pandas_dataframe = False, False
            with FortranFile(path_to_dump_file, 'r') as f:
                while current_record_count < num_records:
                    current_record_count += 1
                    remaining_records_to_read_in_this_part -= 1
                    raw_values = f.read_reals(float)
                    rawrecord = rawRecord(*raw_values)
                    if return_directional_info:
                        # calculate r, rho, theta (w.r.t. z-axis), and phi (w.r.t. x axis)
                        r = np.sqrt(rawrecord.x ** 2 + rawrecord.y ** 2 + rawrecord.z ** 2)
                        rho = np.sqrt(rawrecord.x ** 2 + rawrecord.y ** 2)
                        dir_vector = [rawrecord.u, rawrecord.v, rawrecord.w]
                        theta = np.arccos(np.clip(np.dot(dir_vector, [0, 0, 1]), -1.0, 1.0)) * angle_units_mult
                        phi = np.arctan2(rawrecord.y, rawrecord.x) * angle_units_mult
                        record = Record(*raw_values, r, rho, theta, phi)
                        records_list.append(record)
                    else:
                        records_list.append(rawrecord)
                    if remaining_records_to_read_in_this_part<=0:  # time to write dump part
                        paths_to_split_fake_dump_files.append(Path(path_to_dump_file.parent,path_to_dump_file.stem + MPI_subdump_num_str + '_part-{:g}of{:g}'.format(num_current_part, num_total_parts) + '.out'))
                        num_records = num_records - current_record_count
                        remaining_total_records_to_read -= current_record_count
                        remaining_records_to_read_in_this_part = min(remaining_total_records_to_read,max_records_per_part)
                        current_record_count = 0
                        if save_namedtuple_list:
                            pickle_path = Path(path_to_dump_file.parent,path_to_dump_file.stem + MPI_subdump_num_str + '_part-{:g}of{:g}'.format(num_current_part,num_total_parts) + '_namedtuple_list.pickle' + compression_file_extension)
                            paths_to_split_recarrays.append(pickle_path)
                            record_type = [(ordered_record_entries_list[i], dump_col_dtypes[i]) for i in range(len(ordered_record_entries_list))]
                            records_np_array = np.array(records_list, dtype=record_type)
                            records_np_array = records_np_array.view(np.recarray)
                            if compress_pickles_with_lzma:
                                with lzma.open(pickle_path, 'wb') as handle:
                                    pickle.dump(records_np_array, handle, protocol=pickle.HIGHEST_PROTOCOL)
                            else:
                                with open(pickle_path, 'wb') as handle:
                                    pickle.dump(records_np_array, handle, protocol=pickle.HIGHEST_PROTOCOL)
                            del records_np_array
                        if save_Pandas_dataframe:
                            records_df = pd.DataFrame(records_list, columns=Record._fields)
                            path_to_dump_file = Path(path_to_dump_file)
                            pickle_path = Path(path_to_dump_file.parent,path_to_dump_file.stem + MPI_subdump_num_str + '_part-{:g}of{:g}'.format(num_current_part, num_total_parts) + '_Pandas_df.pickle' + compression_file_extension)
                            paths_to_split_pandas_dfs.append(pickle_path)
                            records_df.to_pickle(pickle_path)
                            del records_df
                        num_current_part += 1
                        records_list = []
        else:
            # Normal flow, if not split dump
            if return_directional_info:
                with FortranFile(path_to_dump_file, 'r') as f:
                    while current_record_count < num_records:
                        current_record_count += 1
                        raw_values = f.read_reals(float)
                        rawrecord = rawRecord(*raw_values)
                        # calculate r, rho, theta (w.r.t. z-axis), and phi (w.r.t. x axis)
                        r = np.sqrt(rawrecord.x ** 2 + rawrecord.y ** 2 + rawrecord.z ** 2)
                        rho = np.sqrt(rawrecord.x ** 2 + rawrecord.y ** 2)
                        dir_vector = [rawrecord.u, rawrecord.v, rawrecord.w]
                        theta = np.arccos(np.clip(np.dot(dir_vector, [0, 0, 1]), -1.0, 1.0)) * angle_units_mult
                        phi = np.arctan2(rawrecord.y, rawrecord.x) * angle_units_mult
                        record = Record(*raw_values, r, rho, theta, phi)
                        records_list.append(record)
            else: # just return data in dump file
                with FortranFile(path_to_dump_file, 'r') as f:
                    while current_record_count < num_records:
                        current_record_count += 1
                        raw_values = f.read_reals(float)
                        record = Record(*raw_values)
                        records_list.append(record)
    else: # file is ASCII
        if max_entries_read == None:
            max_entries_read = np.inf
        if return_directional_info:
            with open(path_to_dump_file, 'r') as f:
                current_record_count = 0
                for line in f:
                    current_record_count += 1
                    if current_record_count > max_entries_read: break
                    line_str_values = line.replace('D', 'E').split()
                    raw_values = [float(i) for i in line_str_values]
                    rawrecord = rawRecord(*raw_values)
                    # calculate r, rho, theta (w.r.t. z-axis), and phi (w.r.t. x axis)
                    r = np.sqrt(rawrecord.x ** 2 + rawrecord.y ** 2 + rawrecord.z ** 2)
                    rho = np.sqrt(rawrecord.x ** 2 + rawrecord.y ** 2)
                    dir_vector = [rawrecord.u, rawrecord.v, rawrecord.w]
                    theta = np.arccos(np.clip(np.dot(dir_vector, [0, 0, 1]), -1.0, 1.0)) * angle_units_mult
                    phi = np.arctan2(rawrecord.y, rawrecord.x) * angle_units_mult
                    record = Record(*raw_values, r, rho, theta, phi)
                    records_list.append(record)
        else: # just return data in dump file
            with open(path_to_dump_file, 'r') as f:
                current_record_count = 0
                for line in f:
                    current_record_count += 1
                    if current_record_count > max_entries_read: break
                    line_str_values = line.replace('D', 'E').split()
                    raw_values = [float(i) for i in line_str_values]
                    record = Record(*raw_values)
                    records_list.append(record)
    #print(record)

    if split_dump: # Now attempt to merge dumps back together
        if merge_split_parts_at_end:
            merged_dump_base_filepath = Path(path_to_dump_file.parent,path_to_dump_file.stem + MPI_subdump_num_str)
            merge_dump_file_pickles(paths_to_split_fake_dump_files, merged_dump_base_filepath=merged_dump_base_filepath,
                                        delete_pre_merge_pickles=delete_merged_parts_at_end, compress_pickles_with_lzma=compress_pickles_with_lzma)
        if return_namedtuple_list:
            path_to_recarray_pickle_file = recarray_pickle_filepath
            if compress_pickles_with_lzma:
                with lzma.open(path_to_recarray_pickle_file, 'rb') as file: records_list = pickle.load(file)
            else:
                with open(path_to_recarray_pickle_file, 'rb') as file: records_list = pickle.load(file)
        if return_Pandas_dataframe:
            records_df = pd.read_pickle(Path(path_to_dump_file.parent,path_to_dump_file.stem + MPI_subdump_num_str + '_Pandas_df.pickle'))
    else: # Normal (non-split) dump saving
        if save_namedtuple_list:
            path_to_dump_file = Path(path_to_dump_file)
            pickle_path = recarray_pickle_filepath
            #num_records = len(records_list)
            record_type = [(ordered_record_entries_list[i], dump_col_dtypes[i]) for i in range(len(ordered_record_entries_list))]
            records_np_array = np.array(records_list, dtype=record_type)
            records_np_array = records_np_array.view(np.recarray)
            if compress_pickles_with_lzma:
                with lzma.open(pickle_path, 'wb') as handle:
                    pickle.dump(records_np_array, handle, protocol=pickle.HIGHEST_PROTOCOL)
            else:
                with open(pickle_path, 'wb') as handle:
                    pickle.dump(records_np_array, handle, protocol=pickle.HIGHEST_PROTOCOL)
            print('Pickle file written:', pickle_path, '\n')
            del records_np_array  # In the event this is huge in memory, best to delete it here before the Pandas DF is made
    
        if return_Pandas_dataframe or save_Pandas_dataframe:
            # Make Pandas dataframe from list of records
            records_df = pd.DataFrame(records_list, columns=Record._fields)
            if save_Pandas_dataframe:
                pickle_path = pandas_df_pickle_filepath
                records_df.to_pickle(pandas_df_pickle_filepath)
                #with open(pickle_path, 'wb') as handle:
                #    pickle.dump(records_df, handle, protocol=pickle.HIGHEST_PROTOCOL)
                #    print('Pickle file written:', pickle_path, '\n')
                print('Pickle file written:', pandas_df_pickle_filepath, '\n')

    if return_namedtuple_list and return_Pandas_dataframe:
        return records_list, records_df
    elif return_namedtuple_list:
        return records_list
    elif return_Pandas_dataframe:
        return records_df
    else:
        return None




def parse_all_tally_output_in_dir(tally_output_dirpath, output_file_suffix = None, output_file_prefix = '',
                                  output_file_required_string='', include_subdirectories=False,  return_tally_output=False,
                                  make_PandasDF=True, calculate_absolute_errors=True,
                                  save_output_pickle=True, include_phitsout_in_metadata=False,
                                  prefer_reading_existing_pickle=False, compress_pickle_with_lzma=False,
                                  merge_tally_outputs=False, save_pickle_of_merged_tally_outputs=None,
                                  include_dump_files=False,
                                  dump_data_number=None , dump_data_sequence=None,
                                  dump_return_directional_info=False, dump_use_degrees=False,
                                  dump_max_entries_read=None,
                                  dump_save_namedtuple_list=True, dump_save_Pandas_dataframe=True,
                                  dump_merge_MPI_subdumps=True, dump_delete_MPI_subdumps_post_merge=True,
                                  split_binary_dumps_over_X_GB=20, merge_split_dump_handling=0,
                                  autoplot_tally_output=False, autoplot_all_tally_output_in_dir=False
                                  ):
    '''
    Description:
        Parse all standard PHITS tally output files in a directory *[DIRECTORY mode]*, returning either a list of dictionaries containing
        tally metadata and an array of values from each tally output (and optionally this data inside of a Pandas dataframe too)
        or a list of filepaths to pickle files containing these dictionaries, as created with the `parse_tally_output_file()` function.
        Alternatively, if provided a PHITS input file or its produced "phits.out" file instead of a directory *[INPUT_FILE mode]*, 
        this function will instead scan 
        the provided file for all tally output that should have been produced by the PHITS run and parse all of those files instead.
        
        This function allows selective processing of files in the directory by specification of strings which must
        appear at the start, end, and/or anywhere within each filename.
        Even if a file satisfies all of these naming criteria, the function will also use `determine_PHITS_output_file_type()`
        to check if it is a valid tally output file or phits.out file (meaning, it will skip files like batch.out).
        It will also skip over "_err" uncertainty files as these are automatically found by the `parse_tally_output_file()`
        function after it processes that tally's main output file.
        
        This function will mainly process standard tally output files, but it can optionally process tally "dump" files too,
        though it can only save the dump outputs to its pickle files and not return the (quite large) dump data objects.
        The filenames of saved dump data will not be included in the returned list.

    Dependencies:
        - `import numpy as np`
        - `import pandas as pd` (if `make_PandasDF = True`)
        - `import seaborn as sns` (if `autoplot_tally_output=True` or `autoplot_all_tally_output_in_dir=True`)
        - `from munch import Munch` (will still run if package not found)
        - `from collections import namedtuple` (if processing tally dump files)
        - `from scipy.io import FortranFile` (if processing tally dump files)

    Inputs:
       (required)

        - **`tally_output_dirpath`** = Path (string or [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) object) to the tally output directory to be searched and parsed 
                      or to a PHITS input file or its "phits.out" file (`file(6)` in the [Parameters] section of a PHITS input file) 
                      whose produced outputs are to be parsed.  If provided a directory, this function will operate in *[DIRECTORY mode]*; 
                      otherwise, if provided a PHITS input or phits.out file, it will operate in *[INPUT_FILE mode]*.
                      Both of these modes are nearly identical in their function, but their differences are highlighted 
                      here in this function's documentation.

    Inputs:
       (optional)

       - **`output_file_suffix`** = (*[DIRECTORY mode]* D=`'.out'` / *[INPUT_FILE mode]* D=`''`) 
                      A string specifying what characters processed filenames (including the file extension)
                      must end in to be included.  This condition is not enforced if set to an empty string `''`. 
                      Note that if the filename contains "_dmp" and has a final extension of the form ".###" (where "###" 
                      is an arbitrarily long numeric string of digits 0-9), as is the case for dump files from PHITS 
                      ran in MPI parallelization, this ".###" final extension is pretended not to exist when enforcing 
                      this `output_file_suffix` parameter, allowing these dump files to be found and processed the same 
                      regardless of whether MPI was used or not when running PHITS.
       - **`output_file_prefix`** = (D=`''`) A string specifying what characters processed filenames (including the file extension)
                      must begin with to be included.  This condition is not enforced if set to an empty string `''`. 
       - **`output_file_required_string`** = (D=`''`) A string which must be present anywhere within processed filenames (including the
                      file extension) to be included.  This condition is not enforced if set to an empty string `''`. 
       - **`include_subdirectories`** = (D=`False`) A Boolean determining whether this function searches and processes all included
                      tally output files in this directory AND deeper subdirectories if set to `True`
                      or only the files directly within the provided directory `tally_output_dirpath` if set to `False`
       - **`return_tally_output`** = (D=`False`) A Boolean determining whether this function returns (if `True`) a list of `tally_output` dictionaries
                      or (if `False`) just a list of filepaths to the pickle files containing these dictionaries
       - **`include_dump_files`** = (D=`False`) A Boolean determining whether dump files will be processed too or skipped.
                      Settings to be applied to all encountered dump files can be specified per the optional inputs
                      detailed below which are simply passed to the `parse_tally_dump_file()` function.  Note that parameters
                      `return_namedtuple_list` and `return_Pandas_dataframe` will always be `False` when dump files are
                      processed in a directory with this function; instead, `save_namedtuple_list` and `save_Pandas_dataframe`
                      are by default set to `True` when parsing dump files in a directory with this function.  (Be warned,
                      if the dump file is large, the produced files from parsing them will be too.)  
       - **`prefer_reading_existing_pickle`** = (D=`False`) A Boolean determining what this function does if the pickle file this function
                      seeks to generate already exists.  If `False` (default behavior), this function will parse the PHITS
                      output files as usual and overwrite the existing pickle file.  If `True`, this function will instead
                      simply just read the existing found pickle file, extracting its stored `tally_output` contents 
                      (and, if for dump file pickles, they'll simply be skipped and not reprocessed). 
       - **`merge_tally_outputs`** = (D=`False`) A Boolean determining whether this function will merge all 
                      of the produced `tally_output` dictionaries (and "phitsout" dictionaries if `include_phitsout_in_metadata=True`) 
                      into a single larger dictionary object.  The output filenames are used as the keys in this dictionary. 
                      In *[DIRECTORY mode]*, this is the relative filepath (from the provided dirctory) of each output file, converted to a string; 
                      in *[INPUT_FILE mode]*, this is the string provided to each tally's `file` parameter in the PHITS input.
                      This dictionary will respect the `save_pickle_of_merged_tally_outputs` and `compress_pickle_with_lzma` input arguments 
                      for saving and compression, with path/name "`tally_output_dirpath`/ALL_TALLY_OUTPUTS.pickle[.xz]" by default.
                      If in *[INPUT_FILE mode]*, the default filename of this file will be prefixed by either the PHITS 
                      input filename or "phitsout_", depending on which type of file is provided to this function.
                      If a string is provided instead of a Boolean, this setting will be set to `True` and the string 
                      will be used as the saved pickle file's filename, with 
                      the file name and path as "`tally_output_dirpath`/`merge_tally_outputs`.pickle[.xz]".
                      If `return_tally_output` is set to `True`, 
                      this single dictionary will be returned instead of a list of produced `tally_output` dictionaries.
       - **`save_pickle_of_merged_tally_outputs`** = (D=`None`; requires `merge_tally_outputs=True`) Boolean denoting whether
                      the merged dictionary containing all PHITS tally output created when `merge_tally_outputs=True` should 
                      be saved as a pickle file, allowing independent control of saving this pickle file versus those of the 
                      constituent tallies.  If left at its default value of `None`, this option will be set to match 
                      the `save_output_pickle` (D=`True`) option. If set to `True` and `return_tally_output=False` and 
                      `save_output_pickle=False`, this function will return the path to this saved pickle file.
       - **`autoplot_all_tally_output_in_dir`** = (D=`False`; requires `make_PandasDF=True`) Boolean denoting, 
                      for all tally outputs included in this function's call, whether the tallies' outputs will be automatically 
                      plotted and saved to a single PDF, with path/name "`tally_output_dirpath`/ALL_TALLY_OUTPUTS_PLOTTED.pdf" by default, 
                      using the `autoplot_tally_results()` function.  If a string is provided instead of a Boolean, 
                      this setting will be set to `True` and the string will be used as the plot PDF's filename, with 
                      the file name and path as "`tally_output_dirpath`/`autoplot_all_tally_output_in_dir`.pdf".
                      If in *[INPUT_FILE mode]*, the default filename of this PDF will be prefixed by either the PHITS 
                      input filename or "phitsout_", depending on which is provided to this function.
       
                      
                      

    Inputs:
       (optional, the same as in and directly passed to the `parse_tally_output_file()` function)

       - **`make_PandasDF`** = (D=`True`) A Boolean determining whether a Pandas dataframe of the tally data array will be made 
       - **`calculate_absolute_errors`** = (D=`True`) A Boolean determining whether the absolute uncertainty of each tally output value
                      is to be calculated (simply as the product of the value and relative error); if `False`, the final
                      dimension of `tally_data`, `ierr`, will be of length-2 rather than length-3 
       - **`save_output_pickle`** = (D=`True`) A Boolean determining whether the `tally_output` dictionary object is saved as a pickle file;
                      if `True`, the file will be saved with the same path and name as the provided PHITS tally output file
                      but with the .pickle extension. 
       - **`include_phitsout_in_metadata`** = (D=`False`) A Boolean determining whether the "phits.out" file 
                      (`file(6)` in the [Parameters] section of a PHITS input file) in the same directory as `tally_output_dirpath`, 
                      if found, should be processed via `parse_phitsout_file()` and have its informational dictionary 
                      about the PHITS run added to the `'tally_metadata'` dictionary under the key `'phitsout'`. 
            -         This only works if a single "phits.out"-type file is found; if multiple are encountered (as determined by `determine_PHITS_output_file_type()`), 
                      they will still be processed with `parse_phitsout_file()` (if `save_output_pickle=True` or `merge_tally_outputs=True`) but not added to 
                      any tally output metadata dictionaries. In this circumstance and if `merge_tally_outputs=True`, 
                      the multiple phits.out dictionaries will be added to the merged dictionary.
            -         If, instead of a Boolean, a dictionary-type object is provided, no search will be conducted and the provided
                      dictionary will be taken as that to be added as the `'phitsout'` key in the `'tally_metadata'` dictionary.
            -         Otherwise, if a string or [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) object 
                      is provided, this will be taken as the path to the "phits.out"-type file to be processed and have its 
                      informational dictionary added to the `'tally_metadata'` dictionary. 
            -         Note that this is automatically set to `True` if in *[INPUT_FILE mode]* (if `tally_output_dirpath` 
                      is a file, rather than a directory, and deemed to be a PHITS input file or "phits.out"-type file, 
                      as determined by `determine_PHITS_output_file_type()`);
                      to override this automatic behavior (i.e., to behave as if set to `False` even when provided a PHITS 
                      input or phits.out file), set `include_phitsout_in_metadata = {}`.
            -         Setting `include_phitsout_in_metadata = None` functions the same as `include_phitsout_in_metadata = True` 
                      but sets `include_input_echo = False` in the `parse_phitsout_file()` call.
       - **`compress_pickle_with_lzma`** = (D=`False`; requires `save_output_pickle=True`) Boolean designating 
                      whether the pickle file to be saved will be compressed with 
                      [LZMA compression](https://docs.python.org/3/library/lzma.html) (included in
                      the baseline [Python standard library](https://docs.python.org/3/library/index.html)); if so, the 
                      pickle file's extension will be `'.pickle.xz'` instead of just `'.pickle'`.
                      A *.pickle.xz file can then be opened (after importing `pickle` and `lzma`) as:  
                      `with lzma.open(path_to_picklexz_file, 'rb') as file: tally_output = pickle.load(file)`.  
                      For most "normal" tallies, the pickle file sizes are likely to be small enough to not warrant 
                      compression, but LZMA compression can reduce the file size by several orders of magnitude, 
                      great for output from massive tallies.
       - **`autoplot_tally_output`** = (D=`False`; requires `make_PandasDF=True`) Boolean denoting whether, for each tally, 
                      the tally's output will be automatically plotted and saved to a PDF and PNG (of the same name/path as 
                      `tally_output_filepath` but ending in `.pdf`/`.png`) using the `autoplot_tally_results()` function. 

    Inputs:
       (optional, the same as in and directly passed to the `parse_tally_dump_file()` function)

       - **`dump_data_number`** = (D=`None`) integer number of data per row in dump file, binary if >0 and ASCII if <0.
                This should match the value following `dump=` in the tally creating the dump file. 
                If not specified, the search_for_dump_parameters() function will attempt to find it automatically.
       - **`dump_data_sequence`** = (D=`None`) string or list of integers with the same number of entries as `dump_data_number`,
                mapping each column in the dump file to their physical quantities. 
                This should match the line following the `dump=` line in the tally creating the dump file.
                See PHITS manual section "6.7.22 dump parameter" for further explanations of these values.
                If not specified, the search_for_dump_parameters() function will attempt to find it automatically.
       - **`dump_return_directional_info`** = (D=`False`) Boolean designating whether extra directional information
                should be calculated and returned; these include: radial distance `r` from the origin in cm,
                radial distance `rho` from the z-axis in cm,
                polar angle `theta` between the direction vector and z-axis in radians [0,pi] (or degrees), and
                azimuthal angle `phi` of the direction vector in radians [-pi,pi] (or degrees).
                Note: This option requires all position and direction values [x,y,z,u,v,w] to be included in the dump file.
       - **`dump_use_degrees`** = (D=`False`) Boolean designating whether angles `theta` and `phi` are returned
                in units of degrees. Default setting is to return angles in radians.
       - **`dump_max_entries_read`** = (D=`None`) integer number specifying the maximum number of entries/records
                of the dump file to be read.  By default, all records in the dump file are read.
       - **`dump_save_namedtuple_list`** = (D=`True`) Boolean designating whether `dump_data_list` is saved to a pickle file
               (for complicated reasons, the namedtuple list is converted to a [NumPy recarray](https://numpy.org/doc/stable/reference/generated/numpy.recarray.html) before being saved,
               which is nearly functionally identical to the list of namedtuples.
               For more info and the full explanation, see the documentation for the `parse_tally_dump_file()` function's
               `save_namedtuple_list` argument, which is what this argument is passed to anyways).
       - **`dump_save_Pandas_dataframe`** = (D=`True`) Boolean designating whether `dump_data_frame` is saved to a pickle
               file (via Pandas .to_pickle()).
       - **`split_binary_dumps_over_X_GB`** = (D=`20` GB) an integer/float specifying the number of gigabytes over 
               which a _binary_ dump file will be split into chunks of this size for processing.
       - **`merge_split_dump_handling`** = (D=`0`) For instances where binary dump files are to be split, 
               as determined by the `split_binary_dumps_over_X_GB` setting, input one of the following 
               numbers to determine what will happen to the intermediate pickle files created from each split chunk:  
           - `0` = Merge the intermediate pickle files (via `merge_dump_file_pickles()`), then, if successful, delete them.  
           - `1` = Do not merge the intermediate pickle files.  
           - `2` = Merge the intermediate pickle files but do not delete them afterward.
       - **`dump_merge_MPI_subdumps`** = (D=`True`) Boolean designating whether the pickled namedtuple lists and/or
               Pandas DataFrames for all "sub-dumps" from an MPI run of PHITS should be merged into single namedtuple 
               list and/or Pandas DataFrame pickle file(s) (with `merge_dump_file_pickles()`). When a dump file is written in an MPI execution of PHITS, 
               it is split into N "sub dump" files, one per MPI process, and given an extra final extension of the 
               form ".###" (where "###" is an arbitrarily long numeric string of digits 0-9 designating the MPI process).
               This option is only active when such files are encountered.
       - **`dump_delete_MPI_subdumps_post_merge`** = (D=`True`) Requires `dump_merge_MPI_subdumps=True`, Boolean 
               designating whether the numerous "sub dump" pickle files merged when `dump_merge_MPI_subdumps=True` 
               (and MPI dump files are found) are deleted after the merged version combining all of them is saved.

    Output:
        - **`tally_output_list`** = a list of `tally_output` dictionary objects (if `return_tally_output=True`) with the 
             below keys and values / a list of file paths to pickle files containing `tally_output` dictionary objects:
            - `'tally_data'` = a 10-dimensional NumPy array containing all tally results, explained in more detail below
            - `'tally_metadata'` = a dictionary/Munch&dagger; object with various data extracted from the tally output file, such as axis binning and units
            - `'tally_dataframe'` = (optionally included if setting `make_PandasDF = True`) a Pandas dataframe version of `tally_data`
        - This list of `tally_output` dictionary objects will be returned as a larger merged dictionary/Munch&dagger; object, 
               keyed using the tally output file names, instead if `merge_tally_outputs=True` and `return_tally_output=True`.  
               If `merge_tally_outputs=True`, `return_tally_output=False`, `save_output_pickle=False`, and `save_pickle_of_merged_tally_outputs=True`, 
               the path to this saved merged tally output dictionary pickle file will be returned instead. 
    
    &dagger;_If you have the [Munch package](https://github.com/Infinidat/munch) installed, the `tally_metadata` dictionary
       and merged tally output dictionary will instead be Munch objects, which are identical to a dictionary but additionally 
       allows attribute-style access (e.g., `tally_metadata.mesh` instead of only `tally_metadata['mesh']`). 
       If you do not have Munch, then these will just be standard dictionary objects. 
       If, for whatever reason, you have Munch installed but do not wish for these to be Munch objects, 
       then in the first line of code for both this and the `parse_tally_header()` function, set 
       `prefer_to_munch_merged_dict = False` and `prefer_to_munch_meta_dict = False`, respectively._
    
    '''
    prefer_to_munch_merged_dict = True
    import os
    from inspect import signature
    if prefer_to_munch_merged_dict:
        try:
            from munch import Munch
            merged_outputs_dict = Munch({})
        except:
            merged_outputs_dict = {}
    else:
        merged_outputs_dict = {}
    if save_pickle_of_merged_tally_outputs is None:
        save_pickle_of_merged_tally_outputs = save_output_pickle
    
    operating_in_directory_mode = True # otherwise, if False, assume INPUT_FILE mode

    is_file6_phitsout_file, is_PHITS_input_file = False, False
    include_THIS_phitsout_dict_in_metadata = False
    include_input_echo = True 
    if include_phitsout_in_metadata is None: include_input_echo = False
    if not os.path.isdir(tally_output_dirpath):
        #print('The provided path to "tally_output_dir" is not a directory:', tally_output_dirpath)
        PHITS_file_type = determine_PHITS_output_file_type(tally_output_dirpath)
        if PHITS_file_type['is_file6_phitsout_file'] or PHITS_file_type['is_PHITS_input_file']:
            is_file6_phitsout_file, is_PHITS_input_file = PHITS_file_type['is_file6_phitsout_file'], PHITS_file_type['is_PHITS_input_file']
            file_type_str = 'PHITS input' if is_PHITS_input_file else 'phits.out'
            #print('However, it is a valid '+file_type_str+' file, thus the tally outputs from this PHITS run will be processed.')
            operating_in_directory_mode = False
            original_input_path = tally_output_dirpath
            head, tail = os.path.split(tally_output_dirpath)
            tally_output_dirpath = head
        elif os.path.isfile(tally_output_dirpath):
            print('The provided path to "tally_output_dir" is not a directory:', tally_output_dirpath)
            head, tail = os.path.split(tally_output_dirpath)
            tally_output_dirpath = head
            print('However, it is a valid path to a file; thus, its parent directory will be used:',tally_output_dirpath)
        else:
            print('The provided path to "tally_output_dir" is not a directory:', tally_output_dirpath)
            print('Nor is it a valid path to a file. ERROR! Aborting...')
            return None

    if output_file_suffix is None:
        if operating_in_directory_mode:
            output_file_suffix = '.out'
        else:
            output_file_suffix = ''
        
    if is_file6_phitsout_file or is_PHITS_input_file: 
        # Only list out files produced by this PHITS run
        if is_PHITS_input_file:
            files_dict = extract_tally_outputs_from_phits_input(original_input_path)
            phitsout_dict = parse_phitsout_file(files_dict['phitsout'],  save_phitsout_pickle=save_output_pickle, compress_pickle_with_lzma=compress_pickle_with_lzma, include_input_echo=include_input_echo)
            if not (isinstance(include_phitsout_in_metadata, dict) and include_phitsout_in_metadata=={}): include_THIS_phitsout_dict_in_metadata = True
            if files_dict['active_infl_found']: 
                print('An active "infl:{}" section was found in the PHITS input; therefore its phits.out file will be used for obtaining the complete list of generated output files.')
                files_dict = phitsout_dict['produced_files']
        else:
            phitsout_file = Path(original_input_path)
            phitsout_dict = parse_phitsout_file(phitsout_file,  save_phitsout_pickle=save_output_pickle, compress_pickle_with_lzma=compress_pickle_with_lzma, include_input_echo=include_input_echo)
            if not (isinstance(include_phitsout_in_metadata, dict) and include_phitsout_in_metadata=={}): include_THIS_phitsout_dict_in_metadata = True
            files_dict = phitsout_dict['produced_files']
        files_in_dir = files_dict['standard_output']
        if include_dump_files: files_in_dir += files_dict['dump_output']
    elif include_subdirectories:
        # Get paths to all files in this dir and subdirs
        files_in_dir = []
        for path, subdirs, files in os.walk(tally_output_dirpath):
            for name in files:
                files_in_dir.append(os.path.join(path, name))
    else:
        # Just get paths to files in this dir
        files_in_dir = [os.path.join(tally_output_dirpath, f) for f in os.listdir(tally_output_dirpath) if os.path.isfile(os.path.join(tally_output_dirpath, f))]

    if is_PHITS_input_file:
        autoplot_dir_filename = Path(original_input_path).stem + "_ALL_TALLY_OUTPUTS_PLOTTED.pdf"
        merged_output_pickle_filename = Path(original_input_path).stem + "_ALL_TALLY_OUTPUTS.pickle"
    elif is_file6_phitsout_file:
        autoplot_dir_filename = "phitsout_ALL_TALLY_OUTPUTS_PLOTTED.pdf"
        merged_output_pickle_filename = "phitsout_ALL_TALLY_OUTPUTS.pickle"
    else:
        autoplot_dir_filename = "ALL_TALLY_OUTPUTS_PLOTTED.pdf"
        merged_output_pickle_filename = "ALL_TALLY_OUTPUTS.pickle"
    if isinstance(autoplot_all_tally_output_in_dir, str):
        autoplot_dir_filename = autoplot_all_tally_output_in_dir + '.pdf'
        autoplot_all_tally_output_in_dir = True
    if isinstance(merge_tally_outputs, str):
        merged_output_pickle_filename = merge_tally_outputs + '.pickle'
        merge_tally_outputs = True
        
    
    # Determine which files should be parsed
    filepaths_to_process = []
    dump_filepaths_to_process = []
    phitsout_files_to_process = []
    len_suffix = len(output_file_suffix)
    len_prefix = len(output_file_prefix)
    len_reqstr = len(output_file_required_string)
    MPI_subdump_files_found = []
    MPI_subdump_filestems_found = []
    for f in files_in_dir:
        head, tail = os.path.split(f)
        file_suffixes = tail.split('.')[1:]
        if len(file_suffixes) >= 1 and is_number(file_suffixes[-1][1:]) and '_dmp' in tail:  # MPI dump found
            MPI_subdump_files_found.append(f)
            tail = tail[:-1 * (1+len(file_suffixes[-1]))]
            if Path(head,tail) not in MPI_subdump_filestems_found: MPI_subdump_filestems_found.append(Path(head,tail))
        if len_suffix > 0 and tail[-len_suffix:] != output_file_suffix: continue
        if len_prefix > 0 and tail[:len_prefix] != output_file_prefix: continue
        if len_reqstr > 0 and output_file_required_string not in tail: continue
        if tail[(-4-len_suffix):] == '_err' + output_file_suffix: continue
        this_file_type = determine_PHITS_output_file_type(f)
        if this_file_type['is_standard_tally_output']:
            filepaths_to_process.append(f)
        elif include_dump_files and tail[(-4 - len_suffix):] == '_dmp' + output_file_suffix and (
                this_file_type['is_binary_tally_dump'] or this_file_type['is_binary_tally_dump']):
            dump_filepaths_to_process.append(f)
        elif this_file_type['is_file6_phitsout_file']:
            phitsout_files_to_process.append(f)
        else:
            continue
        # with open(f) as ff:
        #     try:
        #         first_line = ff.readline().strip()
        #     except: # triggered if encountering binary / non ASCII or UTF-8 file
        #         if include_dump_files and tail[(-4-len_suffix):] == '_dmp' + output_file_suffix:
        #             dump_filepaths_to_process.append(f)
        #         continue
        #     if len(first_line) == 0: continue
        #     if first_line[0] != '[' :
        #         if '_________________________________________________________' in first_line: # phits.out file
        #             phitsout_files_to_process.append(f)
        #         if include_dump_files and tail[(-4-len_suffix):] == '_dmp' + output_file_suffix:
        #             dump_filepaths_to_process.append(f)
        #         continue
        # filepaths_to_process.append(f)
    
    # If there is only one phits.out file, we can safely assume it belongs to all other processed output
    if len(phitsout_files_to_process) == 1 and (save_output_pickle or (include_phitsout_in_metadata is None or (type(include_phitsout_in_metadata) == bool and include_phitsout_in_metadata))):
        phitsout_dict = parse_phitsout_file(phitsout_files_to_process[0], include_input_echo=include_input_echo, save_phitsout_pickle=save_output_pickle)
        if include_phitsout_in_metadata is None or (type(include_phitsout_in_metadata) == bool and include_phitsout_in_metadata): 
            include_THIS_phitsout_dict_in_metadata = True
        # if merge_tally_outputs:
        #     key = str(f.relative_to(tally_output_dirpath))
        #     merged_outputs_dict[key] = phitsout_dict
    elif len(phitsout_files_to_process) > 1 and (save_output_pickle or merge_tally_outputs): # Otherwise, if more, only process if pickles are to be saved
        for f in phitsout_files_to_process:
            temp_phitsout_dict = parse_phitsout_file(f, save_phitsout_pickle=save_output_pickle, compress_pickle_with_lzma=compress_pickle_with_lzma)
            if merge_tally_outputs:
                key = str(Path(f).relative_to(tally_output_dirpath))
                merged_outputs_dict[key] = temp_phitsout_dict
    if include_THIS_phitsout_dict_in_metadata:  # provide a phitsout dictionary created in THIS function to parse_tally_output_file, otherwise pass provided include_phitsout_in_metadata argument 
        include_phitsout_in_metadata = phitsout_dict
    
    tally_output_pickle_path_list = []
    tally_output_list = []
    tally_include_in_plotting_list = []
    max_num_values_to_plot = signature(autoplot_tally_results).parameters['max_num_values_to_plot'].default  # 1e7
    for f in filepaths_to_process:
        f = Path(f)
        xz_text = ''
        if compress_pickle_with_lzma: xz_text = '.xz'
        path_to_pickle_file = Path(f.parent, f.stem + '.pickle' + xz_text)
        tally_output = parse_tally_output_file(f, make_PandasDF=make_PandasDF,
                                               calculate_absolute_errors=calculate_absolute_errors,
                                               save_output_pickle=save_output_pickle,
                                               include_phitsout_in_metadata=include_phitsout_in_metadata,
                                               prefer_reading_existing_pickle=prefer_reading_existing_pickle,
                                               compress_pickle_with_lzma=compress_pickle_with_lzma,
                                               autoplot_tally_output=autoplot_tally_output)
        if tally_output is not None:
            tally_output_pickle_path_list.append(path_to_pickle_file)
            tot_num_values = np.prod(np.shape(tally_output['tally_data'])[:-1])
            if tot_num_values > max_num_values_to_plot:
                tally_include_in_plotting_list.append(False)
                if autoplot_all_tally_output_in_dir and not autoplot_tally_output:  # only print this message if not already printed
                    print('WARNING: Tally output for ', tally_output['tally_metadata']['file'], ' is VERY LARGE (', tot_num_values,
                          ' elements), deemed too large for automatic plotting (default max of', max_num_values_to_plot, 'elements).')
            else:
                tally_include_in_plotting_list.append(True)
        if return_tally_output or (merge_tally_outputs and not save_output_pickle and autoplot_all_tally_output_in_dir): 
            tally_output_list.append(tally_output)
        if merge_tally_outputs:
            if operating_in_directory_mode:
                key = str(Path(f).relative_to(tally_output_dirpath))
            else:
                key = tally_output['tally_metadata']['file']
            merged_outputs_dict[key] = tally_output

    if include_dump_files:
        for f in dump_filepaths_to_process:
            f = Path(f)
            parse_tally_dump_file(f, dump_data_number=dump_data_number, dump_data_sequence=dump_data_number,
                                  return_directional_info=dump_return_directional_info, use_degrees=dump_use_degrees,
                                  max_entries_read=dump_max_entries_read,
                                  return_namedtuple_list=False, return_Pandas_dataframe=False,
                                  save_namedtuple_list=dump_save_namedtuple_list,
                                  save_Pandas_dataframe=dump_save_Pandas_dataframe,
                                  prefer_reading_existing_pickle=prefer_reading_existing_pickle,
                                  split_binary_dumps_over_X_GB=split_binary_dumps_over_X_GB, 
                                  merge_split_dump_handling=merge_split_dump_handling)
        if dump_merge_MPI_subdumps:
            for id in MPI_subdump_filestems_found: # identified independent tallies with dumps
                dumps_to_merge = [] 
                id_head, id_tail = os.path.split(id)
                for f in MPI_subdump_files_found:
                    head, tail = os.path.split(f)
                    tail = tail[:-1 * (1 + len(tail.split('.')[-1]))]
                    if tail == id_tail:
                        dumps_to_merge.append(f)
                merged_fp = Path(Path(id_head),id_tail)
                merge_succeeded = merge_dump_file_pickles(dumps_to_merge, merged_dump_base_filepath=merged_fp, 
                                                          delete_pre_merge_pickles=dump_delete_MPI_subdumps_post_merge)
                
    if merge_tally_outputs and save_pickle_of_merged_tally_outputs:
        # created dictionary of merged tally outputs
        import pickle, lzma
        merged_pickle_path = Path(tally_output_dirpath, merged_output_pickle_filename)
        if compress_pickle_with_lzma:
            merged_pickle_path = Path(merged_pickle_path.parent, merged_pickle_path.name + '.xz')
            with lzma.open(merged_pickle_path, 'wb') as handle:
                pickle.dump(merged_outputs_dict, handle, protocol=pickle.HIGHEST_PROTOCOL)
        else:
            with open(merged_pickle_path, 'wb') as handle:
                pickle.dump(merged_outputs_dict, handle, protocol=pickle.HIGHEST_PROTOCOL)
        print('Pickle file written:', merged_pickle_path, '\n')
        if not return_tally_output:
            del merged_outputs_dict  # In the event this is huge in memory, best to delete it here before the Pandas DF is made
    
    
    if autoplot_all_tally_output_in_dir:
        if not make_PandasDF:
            print('Plotting via "autoplot_tally_output=True" or "autoplot_all_tally_output_in_dir=True" requires also setting "make_PandasDF=True".')
        else:
            print('Compiling PDF of plots...')
            plot_filepath = Path(tally_output_dirpath, autoplot_dir_filename)
            plot_list = []
            for i in range(len(tally_include_in_plotting_list)):
                if tally_include_in_plotting_list[i]:
                    if return_tally_output or (merge_tally_outputs and not save_output_pickle):
                        plot_list.append(tally_output_list[i])
                    else:
                        plot_list.append(tally_output_pickle_path_list[i])
            autoplot_tally_results(plot_list, plot_errorbars=calculate_absolute_errors, output_filename=plot_filepath)

    if return_tally_output:
        if merge_tally_outputs:
            return merged_outputs_dict
        else:
            return tally_output_list
    elif merge_tally_outputs and save_pickle_of_merged_tally_outputs and not save_output_pickle:
        return merged_pickle_path
    else:
        return tally_output_pickle_path_list


def parse_phitsout_file(phitsout_filepath, include_input_echo=True, save_phitsout_pickle=False, compress_pickle_with_lzma=False):
    '''
    Description:
        Extracts useful information from the "phits.out" file (`file(6)` in the PHITS [Parameters] section) 
        into a dictionary object, including the PHITS version number, time data (start/stop time, CPU time, MPI info, etc.), 
        event and particle details, papers to cite, and more.  Sections taking the approximate form of a table are 
        converted to Pandas DataFrames before being stored in the dictionary object.

    Dependencies:
        - `import pandas as pd`
        - `from munch import Munch` (will still run if package not found)

    Inputs:
        - `phitsout_filepath` = string or Path object denoting the path to the "phits.out" file to be parsed
        - `include_input_echo` = (optional, D=`True`) Boolean indicating whether the "Input Echo" block of text 
                should be included in the output dictionary.
        - `save_phitsout_pickle` = (optional, D=`False`) Boolean indicating whether a pickle file of the returned `phitsout_dict` 
                dictionary should be saved, named "phits_out.pickle[.xz]" and in the same directory as `phitsout_filepath`. 
                More specifically, the filename will be that of the "phits.out"-type file (`file(6)`) with "_out.pickle[.xz]" appended to it.
        - `compress_pickle_with_lzma` = (optional, D=`False`; requires `save_phitsout_pickle=True`) Boolean designating 
                whether the pickle file to be saved will be compressed with 
                [LZMA compression](https://docs.python.org/3/library/lzma.html) (included in
                the baseline [Python standard library](https://docs.python.org/3/library/index.html)); if so, the 
                pickle file's extension will be `'.pickle.xz'` instead of just `'.pickle'`.
                A *.pickle.xz file can then be opened (after importing `pickle` and `lzma`) as:  
                `with lzma.open(path_to_picklexz_file, 'rb') as file: tally_output = pickle.load(file)`.  
                For most "normal" PHITS runs, the pickle file sizes are likely to be small enough to not warrant 
                compression, but LZMA compression can reduce the file size by several orders of magnitude, 
                great for instances with very large geometry, materials, tally, etc. sections.

    Outputs:
        - `phitsout_dict` = a dictionary/Munch&dagger; object with various information/data extracted from the "phits.out" file. 
            It is organized with the following keys:
            - `'job'` : a dictionary of information about the run (PHITS version, start/end time, CPU time, MPI info, etc.)
            - `'summary'` : a dictionary of the various text tables in the "Summary for the end of job" section of the 
                    phits.out file. The sections clearly formatted as tables are generally converted into Pandas DataFrames.
            - `'memory'` : a string of the section of text in phits.out containing memory usage information
            - `'batch'` : a string of the section of text in phits.out containing information on each batch
            - `'input_echo'` : a string of the "Input Echo" text section of phits.out
            - `'produced_files'` : a dictionary of the output files produced by this PHITS run's tallies, catergorized 
                    with keys `'standard_output'` (list of [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) objects), 
                    `'dump_output'` (list of [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) objects), and 
                    `'phitsout'` (a single [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) object); 
                    this is the `files_dict` output returned by the `extract_tally_outputs_from_phits_input()` function. 
            - `'citation_request'` : a string of the "Citation Request" text section of phits.out listing additional papers 
                    beyond the main PHITS reference paper that should be cited in any publications using results from
                    this simulation owing to using dependent models, codes, etc.
            - `'extra_lines'` : a list of strings of any lines parsed by this function not classified as belonging to 
                    any other section (and clearly containing information; i.e., not just blank or delimiter lines)
        
       To conveniently view the contents of `phitsout_dict`, one can import the built-in
       pprint library `import pprint` and then use `pprint.pp(dict(phitsout_dict))`.
        
       &dagger;_If you have the [Munch package](https://github.com/Infinidat/munch) installed, the `phitsout_dict` dictionary
       will instead be a Munch object, which is identical to a dictionary but additionally allows attribute-style access. 
       If you do not have Munch, then `phitsout_dict` will just be a standard dictionary object. 
       If, for whatever reason, you have Munch installed but do not wish for 
       `phitsout_dict` to be a Munch object, then in the first line of code for the `parse_phitsout_file()` function, 
       set `prefer_to_munch_meta_dict = False`._
        
    '''
    prefer_to_munch_meta_dict = True
    import datetime
    import io
    import pandas as pd
    phitsout_filepath = Path(phitsout_filepath)
    starting_dict = {'job':{},'summary':{},'memory':{},'batch':{}}
    if prefer_to_munch_meta_dict:
        try:
            from munch import Munch
            meta = Munch(starting_dict)
        except:
            meta = starting_dict
    else:
        meta = starting_dict
    in_header = True 
    in_input_echo = False 
    in_footer = False
    header_lines = []
    input_echo = ''
    footer_lines = []
    # First, extract text from phits.out by section
    with open(phitsout_filepath, mode='r', encoding="utf-8") as f:
        for line in f:
            if ">>> Input Echo >>>=============================================================" in line:
                in_header = False
                in_input_echo = True 
                continue
            if "[END] of Input Echo <<<========================================================" in line:
                in_input_echo = False
                in_footer = True
                continue
            if in_header:
                header_lines.append(line)
            elif in_input_echo:
                input_echo += line
            elif in_footer:
                footer_lines.append(line)
    # Extract info from header
    for li, line in enumerate(header_lines):
        if 'Version =' in line:
            key, value = extract_data_from_header_line(line.replace('|','').strip())
            meta['job']['PHITS_version'] = value
        elif '[ Job Title ]' in line:
            meta['job']['title'] = header_lines[li+2].replace('|','').strip()
        elif 'Starting Date' in line:
            key, date = extract_data_from_header_line(line.replace('|', '').strip())
            year, month, day = [int(i) for i in date.split('-')]
            key, time = extract_data_from_header_line(header_lines[li+1].replace('|', '').strip())
            hour, minute, second = [int(i) for i in time.replace('h ',':').replace('m ',':').split(':')]
            meta['job']['datetime_start'] = datetime.datetime(year, month, day, hour, minute, second)
            meta['job']['datetime_start_ISO_str'] = meta['job']['datetime_start'].isoformat()
        elif 'Total PE =' in line:
            key, pe = extract_data_from_header_line(line.replace('|', '').strip())
            meta['job']['MPI_total_num_PE'] = pe
            meta['job']['MPI_executable_num_PE'] = pe - 1
    # input echo
    if include_input_echo:
        meta['input_echo'] = input_echo
    # produced files 
    meta['produced_files'] = extract_tally_outputs_from_phits_input({'sim_base_dir_path':phitsout_filepath.parent,
                                                                     'input_echo':input_echo}, 
                                                                    use_path_and_string_mode=True)
    # Extract info from footer
    extra_lines = []  # extra footer lines not added to any specific dictionary entry
    skip_lines = 0
    memory_report = ''
    batch_report = ''
    citation_request = ''
    line_of_dashes = '-------------------------------------------------------------------------------'
    for li, line in enumerate(footer_lines):
        if skip_lines>0:
            skip_lines -= 1
            continue
        if '<<< Report of' in line and 'memory' in line:
            for eli in range(5):
                memory_report += footer_lines[li + eli]
            skip_lines = 4
        elif line[:4] == 'bat[':
            for eli in range(5):
                batch_report += footer_lines[li + eli]
            skip_lines = 4
        elif line[:-1] == line_of_dashes and footer_lines[li + 2][:-1] == line_of_dashes: # encountered table
            in_special_events_table, in_special_MPI_table = False, False
            if "CPU time and number of event called in PHITS" in footer_lines[li + 1][:-1]:
                in_special_events_table = True
                # get CPU time
                total_cpu_time_sec = float(footer_lines[li + 5][:-1].split('=')[1].strip())
                meta['job']['CPU_time_sec_int'] = total_cpu_time_sec
                meta['job']['CPU_time_datetime_object'] = datetime.timedelta(seconds=total_cpu_time_sec)
                meta['job']['CPU_time_str'] = str(meta['job']['CPU_time_datetime_object'])
            elif "Final Parallel Status:" in footer_lines[li + 1][:-1]:
                in_special_MPI_table = True
            table_text = ''
            table_title = footer_lines[li + 1][:-1]
            column_header_line = table_title
            nsl = 0 # number of lines to skip in table content
            table_keyname = table_title.strip().replace(' ','_')
            if "number of analyz call vs ncol" in table_title:
                column_header_line = footer_lines[li + 3][:-1] # + '     description'
                nsl = 1
                table_keyname = 'analyz_calls_per_ncol'
            elif "List of transport particles" in table_title:
                column_header_line = footer_lines[li + 3][:-1]
                nsl = 1
                table_keyname = 'transport_particles'
            elif "CPU time and number of event called in PHITS" in table_title:
                column_header_line = '         event             count'
                nsl = 6
                table_keyname = 'events_and_models'
            reassign_key_to_first_col_name = False
            if column_header_line == table_title:
                reassign_key_to_first_col_name = True 
            column_header_line = column_header_line.replace('. p','._p')
            column_header_line = column_header_line.replace('source:','')
            col_headers_with_spaces = ['weight per source', 'average weight', 'total source']
            for x in col_headers_with_spaces:
                column_header_line = column_header_line.replace(x,x.replace(' ','_'))
            line_len = len(line)
            tesli = 3 + nsl
            eli = 0
            next_line = footer_lines[li + tesli + eli + 1]
            while len(next_line)>1 or eli==0:
                this_line = footer_lines[li + tesli + eli]
                next_line = footer_lines[li + tesli + eli + 1]
                if len(this_line)>1:
                    if in_special_events_table: 
                        this_line = this_line.replace('=',' ')
                        this_line = this_line.replace('photonucl lib', 'photonucl_lib')
                        this_line = this_line.replace('frag data', 'frag_data')
                    table_text += this_line  #.rstrip() + '\n'  #.replace(':','')
                eli += 1
                if in_special_events_table:
                    if len(next_line)<2 and '>>>' not in next_line and '---' not in next_line and '===' not in next_line:
                        this_line = footer_lines[li + tesli + eli]
                        next_line = footer_lines[li + tesli + eli + 1]
                        eli += 1
                    if '>>>' in next_line or '---' in next_line or '===' in next_line:
                        eli -= 1
                        break
            if in_special_MPI_table:
                # also grab final MPI stats 
                eli += 1
                while len(footer_lines[li + tesli + eli]) > 1:
                    key, value = extract_data_from_header_line(footer_lines[li + tesli + eli])
                    key = 'MPI_' + key.strip().replace(' ','_').replace('time','time_sec')
                    meta['job'][key] = value
                    eli += 1
            skip_lines = tesli + eli
            if '=' not in table_text:
                table_text = column_header_line + '\n' + table_text
                table_df = pd.read_csv(io.StringIO(table_text.replace(',','.')), comment=':', sep='\s+', on_bad_lines='skip')
                if ':' in table_text:
                    descriptions = [x.split(':', 1)[-1] for x in table_text.split('\n')[1:-1]]
                    table_df['description'] = descriptions
                if reassign_key_to_first_col_name:
                    if 'source: maxcas' in table_title:
                        table_keyname = 'source'
                    else:
                        table_keyname = table_df.columns[0]
                #print(table_text)
                #print(table_df.to_string())
                meta['summary'][table_keyname + '_df'] = table_df
                meta['summary'][table_keyname + '_text'] = table_text
            else:
                table_text = column_header_line + '\n' + table_text
                meta['summary'][table_keyname + '_text'] = table_text
        else:
            if 'job termination date' in line:
                date = line.split(':')[1].strip()
                year, month, day = [int(i) for i in date.split('/')]
                time = footer_lines[li + 1].split(':',1)[1].strip()
                hour, minute, second = [int(i) for i in time.split(':')]
                meta['job']['datetime_end'] = datetime.datetime(year, month, day, hour, minute, second)
                meta['job']['datetime_end_ISO_str'] = meta['job']['datetime_start'].isoformat()
                skip_lines = 1
            elif '>>> Citation Request >>>' in line:
                citation_request += line 
                in_citation_request = True 
                eli = 1
                while in_citation_request:
                    this_line = footer_lines[li + eli]
                    if this_line.strip() == line_of_dashes or this_line.strip() == 'END':
                        break
                    citation_request += this_line
                    eli += 1
                skip_lines = eli - 1
                meta['citation_request'] = citation_request
            elif 'initial random seed:' in line:
                meta['job']['initial_random_seed_bitrseed'] = footer_lines[li + 1].replace('bitrseed =','').strip()
                meta['job']['next_initial_random_seed_bitrseed'] = footer_lines[li + 3].replace('bitrseed =', '').strip()
                skip_lines = 4
            else:
                extra_lines.append(line)
    meta['memory']['report_text'] = memory_report
    meta['batch']['report_text'] = batch_report
    # Clean up extra lines
    extra_lines_cleaned = []
    for line in extra_lines:
        if len(line.strip()) == 0: continue 
        if line.strip() == line_of_dashes: continue 
        if 'Summary for the end of job' in line: continue
        if line.strip() == 'END': continue
        extra_lines_cleaned.append(line)
    meta['extra_lines'] = extra_lines_cleaned
    phitsout_dict = meta
    if save_phitsout_pickle:
        import pickle, lzma
        pickle_path = Path(phitsout_filepath.parent, phitsout_filepath.stem + '_out.pickle')
        if compress_pickle_with_lzma:
            pickle_path = Path(pickle_path.parent, pickle_path.name + '.xz')
            with lzma.open(pickle_path, 'wb') as handle:
                pickle.dump(phitsout_dict, handle, protocol=pickle.HIGHEST_PROTOCOL)
        else:
            with open(pickle_path, 'wb') as handle:
                pickle.dump(phitsout_dict, handle, protocol=pickle.HIGHEST_PROTOCOL)
        print('Pickle file written:', pickle_path, '\n')
    return phitsout_dict


def tally(data, bin_edges=[], min_bin_left_edge=None, max_bin_right_edge=None, nbins=None, bin_width=None, divide_by_bin_width=False, normalization=None, scaling_factor=1, place_overflow_at_ends=True, return_uncertainties=False, return_event_indices_histogram=False):
    '''
    Description:
        Tally number of incidences of values falling within a desired binning structure

    Inputs:
        - `data` = list of values to be tallied/histogrammed
        - `bin_edges` = list of N+1 bin edge values for a tally of N bins
        - `min_bin_left_edge` = left/minimum edge value of the first bin
        - `max_bin_right_edge` = right/maximum edge value of the last bin
        - `nbins` = number of equally-sized bins to be created from `min_bin_left_edge` to `max_bin_right_edge`
        - `bin_width` = constant width of bins to be created from `min_bin_left_edge` to `max_bin_right_edge`
        - `divide_by_bin_width` = Boolean denoting whether final bin values are divided by their bin widths (D=`False`)
        - `normalization` = determine how the resulting histogram is normalized (D=`None`), options are:
                       `[None, 'unity-sum', 'unity-max-val']`.  If `None`, no additional normalization is done.
                       If `unity-sum`, the data is normalized such that its sum will be 1.  If `unity-max-val`, the
                       data is normalized such that the maximum value is 1.  The operation occurs after any bin
                       width normalization from `divide_by_bin_width` but before any scaling from `scaling_factor`.
        - `scaling_factor` = value which all final bins are multiplied/scaled by (D=`1`)
        - `place_overflow_at_ends` = handling of values outside of binning range (D=`True`); if `True` extreme
                       values are tallied in the first/last bin, if `False` extreme values are discarded
        - `return_uncertainties` = Boolean denoting if should return an extra N-length list whose elements
                       are the statistical uncertainties (square root w/ normalizations) of the tally bins (D=`False`)
        - `return_event_indices_histogram` = Boolean denoting if should return an extra N-length list whose elements
                       are each a list of the event indices corresponding to each bin (D=`False`)

    Notes:
        Regarding the binning structure, this function only needs to be provided `bin_edges` directly (takes priority)
        or the information needed to calculate `bin_edges`, that is: `min_bin_left_edge` and `max_bin_right_edge` and
        either `nbins` or `bin_width`.  (Priority is given to `nbins` if both are provided.)

    Outputs:
        - `tallied_hist` = N-length list of tallied data
        - `bin_edges` = list of N+1 bin edge values for a tally of N bins
        - `tallied_hist_err` = (optional) N-length list of statistical uncertainties of tallied data
        - `tallied_event_indicies` = (optional) N-length list of, for each bin, a list of the event indices populating it
    '''

    normalization_valid_entries = [None, 'unity-sum', 'unity-max-val']
    if normalization not in normalization_valid_entries:
        print("Entered normalization option of ",normalization," is not a valid option; please select from the following: [None, 'unity-sum', 'unity-max-val']".format())

    if len(bin_edges)!=0:
        bin_edges = np.array(bin_edges)
    else:
        if nbins != None:
            bin_edges = np.linspace(min_bin_left_edge,max_bin_right_edge,num=nbins+1)
        else:
            bin_edges = np.arange(min_bin_left_edge,max_bin_right_edge+bin_width,step=bin_width)

    nbins = len(bin_edges) - 1

    if return_event_indices_histogram:
        tallied_event_indicies = []
        tallied_hist = np.zeros(nbins)
        for i in range(nbins):
            tallied_event_indicies.append([])
        # events must be histogrammed manually
        for i, val in enumerate(data):
            if val < bin_edges[0]:
                if place_overflow_at_ends:
                    tallied_hist[0] += 1
                    tallied_event_indicies[0].append(i)
                continue
            if val > bin_edges[-1]:
                if place_overflow_at_ends:
                    tallied_hist[-1] += 1
                    tallied_event_indicies[-1].append(i)
                continue
            for j, be in enumerate(bin_edges):
                if be > val: # found right edge of bin containing val
                    tallied_hist[j-1] += 1
                    tallied_event_indicies[j-1].append(i)
                    break



    else:
        tallied_hist, bins = np.histogram(data,bins=bin_edges)

    if return_uncertainties:
        tallied_hist_err = np.sqrt(tallied_hist)
        if divide_by_bin_width: tallied_hist_err = tallied_hist_err/(bin_edges[1:]-bin_edges[:-1])
        if normalization=='unity-sum': tallied_hist_err = tallied_hist_err/np.sum(tallied_hist)
        if normalization=='unity-max-val': tallied_hist_err = tallied_hist_err/np.max(tallied_hist)
        if scaling_factor != 1: tallied_hist_err = tallied_hist_err*scaling_factor

    if divide_by_bin_width: tallied_hist = tallied_hist/(bin_edges[1:]-bin_edges[:-1])
    if normalization=='unity-sum': tallied_hist = tallied_hist/np.sum(tallied_hist)
    if normalization=='unity-max-val': tallied_hist = tallied_hist/np.max(tallied_hist)
    if scaling_factor != 1: tallied_hist = tallied_hist*scaling_factor

    if return_event_indices_histogram:
        if return_uncertainties:
            return tallied_hist,bin_edges,tallied_hist_err,tallied_event_indicies
        else:
            return tallied_hist,bin_edges,tallied_event_indicies
    else:
        if return_uncertainties:
            return tallied_hist,bin_edges,tallied_hist_err
        else:
            return tallied_hist,bin_edges





def rebinner(output_xbins,input_xbins,input_ybins):
    """
    Description:
        The purpose of this function is to rebin a set of y values corresponding to a set of x bins to a new set of x bins.
        The function seeks to be as generalized as possible, meaning bin sizes do not need to be consistent nor do the
        new bin edges necessarily need to line up exactly with the old bin edges.

    Dependencies:
        `import numpy as np`

    Inputs:
      - `output_xbins` = output array containing bounds of x bins of length N; first entry is leftmost bin boundary
      - `input_xbins`  = input array containing bounds of x bins of length M; first entry is leftmost bin boundary
      - `input_ybins`  = input array containing y values of length M-1

    Outputs:
      - `output_ybins` = output array containing y values of length N-1
    """

    N = len(output_xbins)
    M = len(input_xbins)
    output_ybins = np.zeros(N-1)

    for i in range(0,N-1):
        # For each output bin
        lxo = output_xbins[i]   # lower x value of output bin
        uxo = output_xbins[i+1] # upper x value of output bin
        dxo = uxo - lxo         # width of current x output bin

        # Scan input x bins to see if any fit in this output bin
        for j in range(0,M-1):
            lxi = input_xbins[j]    # lower x value of input bin
            uxi = input_xbins[j+1]  # upper x value of input bin
            dxi = uxi - lxi         # width of current x input bin

            if uxi<lxo or lxi>uxo:
                # no bins are aligned
                continue
            elif lxi >= lxo and lxi < uxo:
                # start of an input bin occurs in this output bin
                if lxi >= lxo and uxi <= uxo:
                    # input bin completely encompassed by output bin
                    output_ybins[i] = output_ybins[i] + input_ybins[j]
                else:
                    # input bin spans over at least one output bin
                    # count fraction in current output x bin
                    f_in_dxo = (uxo-lxi)/dxi
                    output_ybins[i] = output_ybins[i] + f_in_dxo*input_ybins[j]
            elif lxi < lxo and uxi > uxo:
                # output bin is completely encompassed by input bin
                f_in_dxo = (uxo-lxo)/dxi
                output_ybins[i] = output_ybins[i] + f_in_dxo*input_ybins[j]
            elif lxi < lxo and uxi > lxo and uxi <= uxo:
                # tail of input bin is located in this output bin
                f_in_dxo = (uxi-lxo)/dxi
                output_ybins[i] = output_ybins[i] + f_in_dxo*input_ybins[j]

    return output_ybins


def autoplot_tally_results(tally_output_list,plot_errorbars=True,output_filename='results.pdf',
                           additional_save_extensions=[],show_plots=False,return_fg_list=False,
                           max_num_values_to_plot=1e7,rasterizesize_threshold=5e4,rasterize_dpi=300):
    '''
    Description:
        Generates visualizations/plots of the data in the output Pandas DataFrames from the `parse_tally_output_file()` 
        function in an automated fashion.  Note that this function only seeks to accomplish exactly this. 
        It is not a function for generating customized plots; it exists to automate creating visualizations of PHITS 
        output using sets of predetermined rules and settings, principally for initial checking of results. 
        Generally, it does not respect plotting-relevant settings provided to PHITS tallies (e.g., `samepage`, `axis`, `angel`, etc.), 
        though it will use the `title` parameter and the plot axis titles for ANGEL included in the .out files, which are
        influenced by some tally parameters, such as `unit` and `y-txt`.
        This function seeks to compile plots of results from one or multiple tallies into a single PDF file (and individual files in other image formats). 
        The [seaborn](https://seaborn.pydata.org/) package's [relplot](https://seaborn.pydata.org/generated/seaborn.relplot.html) function is used for generating these plots.
        This function is primarily intended to be called by `parse_tally_output_file()` and `parse_all_tally_output_in_dir()`. 
        However, if you wish to make modifications to the automatically generated figures, you can use the `return_fg_list=True` setting 
        and apply your desired modifications to the returned FacetGrid objects.  (This is demonstrated in the 
        [example](https://github.com/Lindt8/PHITS-Tools/tree/main/example) distributed with PHITS Tools.)
        
    Dependencies:
        - `import seaborn as sns`
        - `import pandas as pd`
        - `import matplotlib.pyplot as plt`
        - `from matplotlib.colors import LogNorm, SymLogNorm, Normalize`
        - `from matplotlib.backends.backend_pdf import PdfPages`

    Inputs:
        - `tally_output_list` = the `tally_output` output from the `parse_tally_output_file()` function, a string/Path
                object pointing to the pickle file of such output, or a list of such outputs or pickle filepaths.
        - `plot_errorbars` = (optional, D=`True`, requires `calculate_absolute_errors=True` to have been set in the 
                `parse_tally_output_file()` call producing the `tally_output`) Boolean determining if errorbars will be 
                displayed in plots.  Note that owing to shortcomings in the [seaborn](https://seaborn.pydata.org/) package's
                handling of error bars (i.e., not supporting externally calculated error bars) that a workaround has 
                instead been implemented but is only functional for "line"-type and "2D"-type plots.
        - `output_filename` = (optional, D=`results.pdf`) String or Path object designating the name/path where the 
                PDF of plots will be saved.
        - `additional_save_extensions` = (optional, D=`[]`) a list of strings of file extensions, e.g., 
                `['.png', '.svg']` compatible with [matplotlib.savefig](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.savefig.html) 
                to also save the plots as.
                (options include '.eps', '.jpg', '.jpeg', '.pdf', '.pgf', '.png', '.ps', '.raw', '.rgba', '.svg', '.svgz', '.tif', '.tiff', and '.webp'; 
                see `plt.gcf().canvas.get_supported_filetypes()`) 
        - `show_plots` = (optional, D=`False`) Boolean denoting whether this function will make a `plt.show()` call
                immediately before the `return` statement.
        - `return_fg_list` = (optional, D=`False`) Boolean denoting whether a list of the generated seaborn FacetGrid 
                objects returned by the sns.relplot() calls should be returned by this function, allowing modification 
                of the automatically generated plots.  (Strictly speaking, the objects are copies of the FacetGrid objects 
                made using the built-in [copy.deepcopy()](https://docs.python.org/3/library/copy.html#copy.deepcopy) function.)  If `False`, this function returns `None`.
        - `max_num_values_to_plot` = (optional, D=`1e7`) integer denoting the maximum number of data points to be 
                plotted by this function for a single tally output, which, when exceeded, will cause the function to 
                skip creating the plot&dagger;. 
                The number of data points to be plotted is calculated as the product of the axis lengths of the 
                `tally_output['tally_data']` Numpy array (excluding the final axis for values/errors).
        - `rasterizesize_threshold` = (optional, D=`5e4`) integer denoting the maximum number of data points to be 
                plotted by this function for a single tally output before setting `rasterized=True` to the `sns.relplot()` 
                calls generating plots&Dagger;. 
                The number of data points to be plotted is calculated as the product of the axis lengths of the 
                `tally_output['tally_data']` Numpy array (excluding the final axis for values/errors).
        - `rasterize_dpi` = (optional, D=`300`) integer denoting the DPI to be used in the `savefig()` calls when 
                the `rasterizesize_threshold` is exceeded or when `additional_save_extensions` is used to save a plot 
                in a non-vectorized format.
        
    Outputs:
        - `None` if `return_fg_list=False` (default) or `fg_list` if `return_fg_list=True`; see description of the `return_fg_list` input argument
        - (and the saved file(s) of plot(s) specified by `output_filename`)
    
    --------
    
    Notes:
        All plots generated by this function will be one of three types:
        
        - 'scatter' = a scatterplot (error bars not available)
        - 'line' = a line plot (error bars available)
        - 'pseudo 2D' = a 2D color image plot (relative errors displayed as an additional 2D plot)
        
        How the plot type is chosen and structured is dependent on the number of plotting axes found and their types. 
        The number of plotting axes is taken as the count of axes in the `tally_output['tally_data']` Numpy array of 
        length greater than 1 (excluding the final axis for values/errors).  Each axis is categorized as being either 
        'numerical' (energy, time, angle, radius, x, etc.) or 'categorical' (particle, region number, etc.) in nature. 
        If all plotting axes are categorical in nature, then the plot type will be of 'scatter' type.  Otherwise, the 
        longest numerical axis is taken to be the horizontal plotting axis on either the 'line' or 'pseudo 2D' plot. 
        If the next longest numerical axis has length >6 (or, in the event the total number of plot axes is 4 or greater, 
        catergorical axes are also considered in this step), the plot type will be 'pseudo 2D' with that
        axis taken as the vertical axis; otherwise, the plot type will be 'line' with `'value'` as the vertical axis. 
        
        The seaborn relplot `hue`, `style`, `row`, `col`, and `size` variables, loosely in that order, are assigned to 
        the next longest axes in order of descending length (except for the 'pseudo 2D' plots, where `hue` is used for 
        `'value'` and `style` and `size` are unused). 
        For the 'scatter' and 'line' plots, `hue` and `style` are typically assigned to the same axis for better visual distinction.
        
        The 'pseudo 2D' plots are called "pseudo" here as they are still made with the seaborn replot function, which 
        actually does not support making this type of plot.  The 2D plots are achieved by using the [matplotlib.markers "verts"](https://matplotlib.org/stable/api/markers_api.html) 
        functionality to make markers of the correct aspect ratio to tile together nicely when scaled up (or down) 
        sufficiently in size `s` to form the illusion of a 2D colormap plot, hence the "pseudo" in the name.
        
        &dagger;This function also calculates the total number of values (bins) in the tally output to be plotted, the product 
        of the axis lengths of the `tally_output['tally_data']` Numpy array (excluding the final axis for values/errors). 
        If this value exceeds 10 million (controlled by the `max_num_values_to_plot` input in this function), 
        this function will skip attempting to make the plot.  This limit is set to avoid potential crashes from 
        insufficient memory in scenarios where these plots are unlikely to actually be desired anyways. 
        
        &Dagger;Furthermore, if the total number of values exceeds 50 thousand (controlled by the `rasterizesize_threshold` input 
        in this function), rasterization will be employed in the plotting area (passing `rasterized = True` to 
        the `sns.relplot()` call) using a default DPI of 300 (controlled by the `rasterize_dpi` input in this function).
    
    '''
    '''
    Behavior of this function within PHITS Tools:
        - The option to execute this function on each produced output should be available for both `parse_tally_output_file()`
                and `parse_all_tally_output_in_dir()` (one PDF file per tally output file) 
        - Also for `parse_all_tally_output_in_dir()` should be an option to execute this function on ALL tally outputs 
                handled by the dir parsing function (combining all plots into a single PDF)
        - By default, both of these options should be disabled (set False) by default when PHITS Tools is used as an imported module.
        - For GUI/CLI usage, checkboxes/flags for these three options should appear
        - For GUI/CLI usage of `parse_tally_output_file()`, use of this function should be enabled/disabled by default.
        - For GUI/CLI usage of `parse_all_tally_output_in_dir()`, use of this function should be enabled/disabled by default 
                only for combining all output in a directory; making individual PDFs of each handled tally output should
                be disabled/disabled by default.
    '''
    
    import seaborn as sns
    import matplotlib.pyplot as plt
    from matplotlib.colors import LogNorm, SymLogNorm, Normalize
    from matplotlib.backends.backend_pdf import PdfPages
    import pickle, lzma
    import datetime
    import copy
    
    #max_num_values_to_plot = 1e7  # if the number of data points to plot exceeds this, plotting is skipped for that tally
    #rasterizesize_threshold = 5e4  # use "rasterize" option if the number of data points to be plotted exceeds this 
    #rasterize_dpi = 300  # if using rasterization, use this DPI
    
    fig_aspect_ratio = 1.414  # width / height
    fig_height = 4  # inches
    figi = 10000  # figure number
    fg_list = []  # list of generated Seaborn FacetGrid objects returned by the sns.relplot() calls
    output_filename = Path(output_filename)
    
    # Convert provided input to a list of `tally_output` dictionary objects
    if isinstance(tally_output_list, dict):  # single tally output, tally output object
        tally_output_list = [tally_output_list]
    elif not isinstance(tally_output_list, list):  # single tally output, pickle file of tally output object
        p = Path(tally_output_list)
        tally_output = pickle.load(lzma.open(p, 'rb') if p.name[-3:] == '.xz' else open(p, 'rb'))
        tally_output_list = [tally_output]
    else:  # list of tally output objects and/or pickles
        for i, to in enumerate(tally_output_list): # open any pickle files provided
            if not isinstance(to, dict):
                if to is None:
                    print("WARNING: 'None' tally output encountered in tally_output_list in autoplot_tally_results()!")
                    continue
                p = Path(to)
                tally_output_list[i] = pickle.load(lzma.open(p, 'rb') if p.name[-3:] == '.xz' else open(p, 'rb'))
    plotting_multiple_tallies = len(tally_output_list) > 1
    
    def are_bins_linearly_spaced(bin_mids):
        '''
        Return True/False designating whether bin centers appear to be linearly spaced or not
        '''
        diff = np.diff(bin_mids).round(decimals=8)
        vals, counts = np.unique(diff, return_counts=True)
        counts, vals = zip(*sorted(zip(counts, vals), reverse=True))
        if 0 in vals:
            izero = vals.index(0)
            counts, vals = list(counts), list(vals)
            counts.pop(izero)
            vals.pop(izero)
        if len(vals) <= 1: # all diff values are the same
            bins_are_lin_spaced = True 
        else:
            if counts[0]/sum(counts) > 0.5: # most frequent difference is more than half of all differences
                bins_are_lin_spaced = True 
            else:
                bins_are_lin_spaced = False
        return bins_are_lin_spaced
    
    tex_replace_strs = {'^2':'$^2$', '^3':'$^3$', r'\sigma':r'$\sigma$', r'\Omega':r'$\Omega$'}
    
    with PdfPages(output_filename) as pdf:
        for toi, tally_output in enumerate(tally_output_list):
            tally_data, tally_metadata, tally_dataframe = [tally_output[k] for k in tally_output.keys()]
            # determine number of plots necessary
            ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max, ierr_max = np.shape(tally_data)
            if ierr_max==2 or ierr_max==4: plot_errorbars = False
            special_tcross_case = False
            if ierr_max>3: special_tcross_case = True
            if special_tcross_case:
                df_cols = tally_dataframe.columns.values.tolist()
                #print(df_cols)
                df1 = tally_dataframe.copy().drop(['r_surf', 'z_mid', 'value2', 'rel.err.2', 'abs.err.2'], axis=1)
                df2 = tally_dataframe.copy().drop(['r_mid', 'z_surf', 'value', 'rel.err.', 'abs.err.'], axis=1).rename(columns={'value2':'value', 'rel.err.2':'rel.err.', 'abs.err.2':'abs.err.'})
                #print(df1.columns.values.tolist())
                #print(df2.columns.values.tolist())
                tally_df_list = [df1, df2]
                extra_df_fname_text = ['_rsurf-zmid', '_rmid-zsurf']
            else:
                tally_df_list = [tally_dataframe]
                extra_df_fname_text = ['']
            for tdfi, tally_df in enumerate(tally_df_list):
                df_cols = tally_df.columns.values.tolist()
                array_axes_lens = [ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max]
                tot_plot_axes = sum(1 for i in array_axes_lens if i > 1)
                tot_num_values = np.prod(array_axes_lens)
                
                if tot_num_values > max_num_values_to_plot:
                    print('WARNING: Tally output for ',tally_metadata['file'],' is VERY LARGE (',tot_num_values,' elements), deemed too large for automatic plotting.')
                    if return_fg_list: fg_list.append(None)
                    continue
                
                if tot_num_values > rasterizesize_threshold:
                    use_rasterization = True
                else:
                    use_rasterization = False

                if tot_plot_axes==0: # case where the tally is only scoring a single value
                    fig = plt.figure(figi,(fig_aspect_ratio*fig_height,fig_height))
                    plt.errorbar(1,tally_data[0,0,0,0,0,0,0,0,0,0],yerr=tally_data[0,0,0,0,0,0,0,0,0,2],marker='o')
                    plt.title(tally_metadata['tally_type'] + ', ' + tally_metadata['file'] + '\n' + tally_metadata['title'])
                    plt.ylabel('value')
                    plt.tight_layout()
                    # add PHITS Tools info
                    fontdict = {'color': '#666666', 'weight': 'normal', 'size': 8, 'style': 'italic'}
                    fig.text(0.005, 0.005, 'Figure generated by PHITS Tools, github.com/Lindt8/PHITS-Tools',
                             fontdict=fontdict, ha='left', va='bottom', url='https://github.com/Lindt8/PHITS-Tools')
                    pdf.savefig()
                    if return_fg_list: fg_list.append(copy.deepcopy(fig))
                    if not show_plots: plt.close()
                    figi += 1
                    continue
                
                # We can divide axes into two categories:
                # - categorical: 'reg', 'tet', 'point#', 'ring#', 'particle','nuclide', 'ZZZAAAM'
                # - numerical: 'r_mid', 'r_surf', 'x_mid', 'y_mid', 'z_surf', 'z_mid', 'e_mid', 't_mid', 'a_mid', 'LET_mid', 'ic/Z/charge', 'ic/A/mass'
                plot_axes = []
                plot_axes_lens = []
                plot_axes_ivars = []
                num_axes = []
                cat_axes = []
                # assign all possible dimensions a "priority" score to compare for tiebreakers (dimensions of equal length)
                ax_priority_vals = {'reg': 100, 'tet': 100, 'point#': 101, 'ring#': 101,
                                    'r_mid': 100, 'r_surf': 101, 'x_mid': 100,
                                    'y_mid': 90, 'z_mid': 80, 'z_surf': 81,
                                    'e_mid': 110, 'e1_mid': 111,
                                    't_mid': 70,
                                    'a_mid': 60,
                                    'LET_mid': 109,
                                    'particle': 95,
                                    'nuclide': 20, 'ZZZAAAM': 19,
                                    'ic/Z/charge': 30, 'ic/A/mass': 30,
                                    '#Interactions': 10, 'e2_mid': 109
                                    }
                cat_ax_to_index = {'reg': 'ir', 'tet': 'ir', 'point#': 'ir', 'ring#': 'ir',
                                   'particle': 'ip',
                                   'nuclide': 'ic', 'ZZZAAAM': 'ic'
                                   }
                # For the sake of making the plot labels nicer, also determine appropriate label for each axis
                a_units = 'Angle [degrees]'
                if 'a-type' in tally_metadata:
                    if tally_metadata['a-type']>0:
                        a_units = 'cos(Angle)'
                    elif tally_metadata['axis'] == 'rad':
                            a_units = 'Angle [radians]'
                value_label_from_phits = 'value'
                if tally_metadata['axis_dimensions'] == 1:
                    if tally_metadata['value_label'] != '':
                        value_label_from_phits = tally_metadata['value_label'] 
                    elif tally_metadata['axis2_label'] != '':
                        value_label_from_phits = tally_metadata['axis2_label'] 
                else:
                    if tally_metadata['value_label'] != '':
                        value_label_from_phits = tally_metadata['value_label'] 
                if value_label_from_phits != 'value':
                    for itx in tex_replace_strs:
                        value_label_from_phits = r'{}'.format(value_label_from_phits.replace(itx,tex_replace_strs[itx]))
                ax_labels = {'reg':'Region #', 'tet':'Tet #', 'point#':'Point det #', 'ring#':'Ring det #',
                                    'r_mid':'r [cm]', 'r_surf':'r_surface [cm]', 'x_mid':'x [cm]',
                                    'y_mid':'y [cm]', 'z_mid':'z [cm]', 'z_surf':'z_surface [cm]',
                                    'e_mid':'Energy [MeV]', 'e1_mid':'Energy 1 [MeV]',
                                    't_mid':'Time [ns]',
                                    'a_mid':a_units,
                                    'LET_mid':r'LET [keV/$\mu$m]',
                                    'particle':'Particle',
                                    'nuclide':'Nuclide', 'ZZZAAAM':'ZZZAAAM',
                                    'ic/Z/charge':'Z (proton #)', 'ic/A/mass':'A (mass #)', 
                                    '#Interactions':'Number of interactions', 'e2_mid':'Energy 2 [MeV]',
                                    'value':value_label_from_phits, 'value2':value_label_from_phits,
                                    'rel.err.':'relative error', 'rel.err.2':'relative error'
                                    }
                # now determine what axes are getting plotted
                for i, leni in enumerate(array_axes_lens):
                    if leni==1: continue
                    if i==0: # 'reg', 'tet', 'point#', 'ring#', 'r_mid', 'r_surf', 'x_mid'
                        if any(a in ['reg', 'point#', 'ring#'] for a in df_cols):  # categorical
                            col = [a for a in ['reg', 'point#', 'ring#'] if a in df_cols][0]
                            cat_axes.append(col)
                        else:  # numerical 
                            col = [a for a in ['r_mid', 'r_surf', 'x_mid', 'tet'] if a in df_cols][0]
                            num_axes.append(col)
                            if col=='tet': # convert tet number from string to float
                                tally_df['tet'] = tally_df['tet'].astype(float)
                    elif i==1: # 'y_mid'
                        col = 'y_mid'
                        num_axes.append(col)
                    elif i==2: # 'z_mid', 'z_surf'
                        col = [a for a in ['z_mid', 'z_surf'] if a in df_cols][0]
                        num_axes.append(col)
                    elif i==3: # 'e_mid'
                        col = [a for a in ['e_mid', 'e1_mid'] if a in df_cols][0]
                        num_axes.append(col)
                    elif i==4: # 't_mid'
                        col = 't_mid'
                        num_axes.append(col)
                    elif i==5: # 'a_mid'
                        col = 'a_mid'
                        num_axes.append(col)
                    elif i==6: # 'LET_mid'
                        col = 'LET_mid'
                        num_axes.append(col)
                    elif i==7: # 'particle'
                        col = 'particle'
                        cat_axes.append(col)
                    elif i==8: # 'nuclide', 'ZZZAAAM', 'ic/Z/charge', 'ic/A/mass', 'act'
                        if any(a in ['nuclide', 'ZZZAAAM'] for a in df_cols):  # categorical
                            col = [a for a in ['nuclide', 'ZZZAAAM'] if a in df_cols][0]
                            cat_axes.append(col)
                        else:  # numerical
                            col = [a for a in ['ic/Z/charge', 'ic/A/mass', '#Interactions', 'e2_mid'] if a in df_cols][0]
                            num_axes.append(col)
                    plot_axes.append(col)
                    plot_axes_lens.append(leni)
                # sort the lists in order of descending lengths, with "higher priority" axes coming first when equal in length
                plot_axes_sorted = []
                plot_axes_lens_sorted = []
                num_axes_sorted = []
                num_lens_sorted = []
                cat_axes_sorted = []
                cat_lens_sorted = []
                ax_props = [(plot_axes[i],plot_axes_lens[i],ax_priority_vals[plot_axes[i]]) for i in range(len(plot_axes))]
                ax_props.sort(key=lambda x: (-x[1], -x[2])) # sort first by length then priority, both in descending order
                for i,tup in enumerate(ax_props):
                    plot_axes_sorted.append(tup[0])
                    plot_axes_lens_sorted.append(tup[1])
                    if tup[0] in cat_axes:
                        cat_axes_sorted.append(tup[0])
                        cat_lens_sorted.append(tup[1])
                    else:
                        num_axes_sorted.append(tup[0])
                        num_lens_sorted.append(tup[1])
                cat = cat_axes_sorted 
                num = num_axes_sorted
                
                # Now determine how each axis will be represented in the plot
                '''
                How we represent the data will depend on:
                - how many axes there are to represent
                - the specific combination of A cat and B num
                - whether the longest axes are cat or num
                
                For the total number axes variables (r, y, z, e, t, a, l, p, and c) with length > 1:
                [unless stated otherwise, y='value' for all options]
                [charts become difficult to read if length of axis passed to hue and (especially) style are too long;
                 therefore, maxlen = 6 is set, meaning if this length is exceeded for an axis bound for hue/style, 
                 instead it will be split in row/col and/or cause a shift from a 1D line plot to a 2D scatter plot.]
                - 0 : set x to 1, y to 'value'
                - 1 : 1 cat + 0 num : scatter(line?) : x = i_cat1, hue=style=cat1
                - 1 : 0 cat + 1 num : line : x = num1
                - 2 : 2 cat + 0 num : scatter(line?) : x = i_cat1, hue=cat1, style=cat2
                - 2 : 1 cat + 1 num : line : x = num1, hue=style=cat1
                - 2 : 0 cat + 2 num : line : x = num1, hue=style=num2 (suitable if len(num2) <= maxlen)
                      OR            : scatter : x = num1, y = num2, hue='value' (increase marker size for pseudo 2D plot)
                - 3 : 3 cat + 0 num : scatter(line?) : x = i_cat1, hue=cat1, style=cat2, row=cat3 (note: this scenario is extremely unlikely to happen)
                - 3 : 2 cat + 1 num : line : x = num1, hue=cat1, style=cat2
                - 3 : 1 cat + 2 num : line : x = num1, hue=num2, row=cat1 (suitable if len(num2) <= maxlen)
                      OR            : scatter : x = num1, y = num2, hue='value', row=cat1
                - 3 : 0 cat + 3 num : line : x = num1, hue=num2, row=num3 (suitable if len(num2) <= maxlen)
                      OR            : scatter : x = num1, y = num2, hue='value', row=num3
                - 4 : 3 cat + 1 num : line : x = num1, hue=cat1, style=cat2, row=cat3
                - 4 : 2 cat + 2 num : line : x = num1, hue=num2, style=cat1, row=cat2 (suitable if len(num2) <= maxlen and len(cat1) <= maxlen)
                      OR            : line : x = num1, hue=num2, row=cat1, col=cat2 (if len(num2) <= maxlen but len(cat1) > maxlen)
                      OR            : scatter : x = num1, y = num2, hue='value', row=cat1, col=cat2
                - 4 : 1 cat + 3 num : line : x = num1, hue=num2, style=cat1, row=num3 (suitable if len(num2) <= maxlen and len(cat1) <= maxlen)
                      OR            : line : x = num1, hue=num2, row=num3, col=cat1 (if len(num2) <= maxlen but len(cat1) > maxlen)
                      OR            : scatter : x = num1, y = num2, hue='value', row=num3, col=cat1
                - 4 : 0 cat + 4 num : line : x = num1, hue=num2, style=num3, row=num4 (suitable if len(num2) <= maxlen and len(num3) <= maxlen)
                      OR            : line : x = num1, hue=num2, row=num3, col=num4 (if len(num2) <= maxlen but len(num3) > maxlen)
                      OR            : scatter : x = num1, y = num2, hue='value', row=num3, col=num4
                - 5 : any 5 cat/num : line : x = num1, hue=num2, style=num5, row=num3, col=num4
                - 6 : any 6 cat/num : line : x = num1, hue=num2, style=num5, size=num6, row=num3, col=num4
                - 7+ : no plot will be generated, output will be skipped. If you have a tally this complex, you already have usage ideas.
                '''
                maxlen = 6  # if an axis length exceeds this, it may need to be reassigned to row/col or the plot type changed
                maxlines = 16 # will modify plotting strategy if combining two axes on a line plot results in more lines than this
                
                plot_kind = 'line'  # 'line' or 'scatter'
                y_var = 'value'
                x_var = None
                hue_var = None
                style_var = None
                size_var = None
                row_var = None
                col_var = None
                pseudo_2d_plot = False
                xvar_original_label = None
                yvar_original_label = 'value'
                
                if tot_plot_axes==0:
                    pass
                elif tot_plot_axes==1:
                    if len(cat)==1:  # 1 cat + 0 num
                        plot_kind = 'scatter'
                        x_var = cat_ax_to_index[cat_axes_sorted[0]]
                        xvar_original_label = cat_axes_sorted[0]
                        cat.append(x_var)
                        hue_var = cat[0]
                        style_var = cat[0]
                    else:  # 0 cat + 1 num
                        plot_kind = 'line'
                        x_var = num[0]
                elif tot_plot_axes==2:
                    if len(cat)==2:  # 2 cat + 0 num
                        plot_kind = 'scatter'
                        x_var = cat_ax_to_index[cat_axes_sorted[0]]
                        xvar_original_label = cat_axes_sorted[0]
                        cat.append(x_var)
                        hue_var = cat[0]
                        style_var = cat[1]
                    elif len(cat)==1:  # 1 cat + 1 num
                        plot_kind = 'line'
                        x_var = num[0]
                        hue_var = cat[0]
                        style_var = cat[0]
                    elif len(num)==2:  # 0 cat + 2 num
                        if num_lens_sorted[1] <= maxlen:
                            plot_kind = 'line'
                            x_var = num[0]
                            hue_var = num[1]
                            style_var = num[1]
                        else:
                            pseudo_2d_plot = True
                            plot_kind = 'scatter'
                            x_var = num[0]
                            y_var = num[1]
                            hue_var = 'value'
                elif tot_plot_axes==3:
                    if len(cat) == 3:  # 3 cat + 0 num
                        plot_kind = 'scatter'
                        x_var = cat_ax_to_index[cat_axes_sorted[0]]
                        xvar_original_label = cat_axes_sorted[0]
                        cat.append(x_var)
                        hue_var = cat[0]
                        style_var = cat[1]
                        row_var = cat[2]
                    elif len(cat) == 2:  # 2 cat + 1 num
                        plot_kind = 'line'
                        x_var = num[0]
                        hue_var = cat[0]
                        style_var = cat[1]
                    elif len(cat) == 1:  # 1 cat + 2 num
                        if num_lens_sorted[1] <= maxlen:
                            plot_kind = 'line'
                            x_var = num[0]
                            hue_var = num[1]
                            style_var = num[1]
                            row_var = cat[0]
                        else:
                            pseudo_2d_plot = True 
                            plot_kind = 'scatter'
                            x_var = num[0]
                            y_var = num[1]
                            hue_var = 'value'
                            row_var = cat[0]
                    elif len(num) == 3:  # 0 cat + 3 num
                        if num_lens_sorted[1] <= maxlen:
                            plot_kind = 'line'
                            x_var = num[0]
                            hue_var = num[1]
                            style_var = num[1]
                            row_var = num[2]
                        else:
                            pseudo_2d_plot = True
                            plot_kind = 'scatter'
                            x_var = num[0]
                            y_var = num[1]
                            hue_var = 'value'
                            row_var = num[2]
                    '''
                    if plot_axes_sorted[0] in num:
                        x_var = plot_axes_sorted[0]
                    else:
                        x_var = plot_axes_sorted[0]
                        #x_var = cat_ax_to_index[cat_axes_sorted[0]]
                        #cat.append(x_var)
                        style_var = plot_axes_sorted[0]
                    if plot_axes_lens_sorted[1] <= maxlen:
                        plot_kind = 'line'
                        hue_var = plot_axes_sorted[1]
                        if plot_axes_lens_sorted[1]*plot_axes_lens_sorted[2] <= maxlines:
                            style_var = plot_axes_sorted[2]
                        else:
                            if style_var==None: style_var = plot_axes_sorted[1]
                            row_var = plot_axes_sorted[2]
                    else:
                        pseudo_2d_plot = True
                        plot_kind = 'scatter'
                        if plot_axes_sorted[1] in num:
                            y_var = plot_axes_sorted[1]
                        else:
                            y_var = cat_ax_to_index[cat_axes_sorted[1]]
                            cat.append(y_var)
                        hue_var = 'value'
                        row_var = plot_axes_sorted[2]
                    '''
                elif tot_plot_axes==4:
                    if plot_axes_sorted[0] in num:
                        x_var = plot_axes_sorted[0]
                    else:
                        x_var = cat_ax_to_index[cat_axes_sorted[0]]
                        xvar_original_label = cat_axes_sorted[0]
                        cat.append(x_var)
                        style_var = plot_axes_sorted[0]
                    if plot_axes_lens_sorted[1] <= maxlen:
                        plot_kind = 'line'
                        hue_var = plot_axes_sorted[1]
                        if style_var==None: style_var = plot_axes_sorted[1]
                        row_var = plot_axes_sorted[2]
                        col_var = plot_axes_sorted[3]
                    else:
                        pseudo_2d_plot = True
                        plot_kind = 'scatter'
                        if plot_axes_sorted[1] in num:
                            y_var = plot_axes_sorted[1]
                        else:
                            y_var = cat_ax_to_index[cat_axes_sorted[1]]
                            yvar_original_label = cat_axes_sorted[1]
                            cat.append(y_var)
                        hue_var = 'value'
                        row_var = plot_axes_sorted[2]
                        col_var = plot_axes_sorted[3]
                elif tot_plot_axes==5:
                    plot_kind = 'line'
                    if plot_axes_sorted[0] in num:
                        x_var = plot_axes_sorted[0]
                    else:
                        x_var = cat_ax_to_index[cat_axes_sorted[0]]
                        xvar_original_label = cat_axes_sorted[0]
                        cat.append(x_var)
                    hue_var = plot_axes_sorted[1]
                    row_var = plot_axes_sorted[2]
                    col_var = plot_axes_sorted[3]
                    style_var = plot_axes_sorted[4]
                elif tot_plot_axes==6:
                    plot_kind = 'line'
                    if plot_axes_sorted[0] in num:
                        x_var = plot_axes_sorted[0]
                    else:
                        x_var = cat_ax_to_index[cat_axes_sorted[0]]
                        xvar_original_label = cat_axes_sorted[0]
                        cat.append(x_var)
                    hue_var = plot_axes_sorted[1]
                    row_var = plot_axes_sorted[2]
                    col_var = plot_axes_sorted[3]
                    style_var = plot_axes_sorted[4]
                    size_var = plot_axes_sorted[5]
                else:
                    print('Cannot create plot with 7+ variables...')
                    continue
                '''
                listed below are all possible column headers in the dataframe 
                'ir','reg','reg#', 'tet', 'point#', 'ring#'
                'ip', 'particle', 'kf-code'
                'ix', 'iy', 'iz', 'x_mid', 'y_mid', 'z_surf', 'z_mid'
                'ir', 'r_mid', 'r_surf', 
                'ie','e_mid', 'it', 't_mid', 'ia', 'a_mid', 'il', 'LET_mid', 'e1_mid'
                'ic', 'nuclide', 'ZZZAAAM', 'ic/Z/charge', 'ic/A/mass', '#Interactions', 'e2_mid'
                'value', 'rel.err.', 'abs.err.'
                'value2', 'rel.err.2', 'abs.err.2'
                '''
                
                for ierr in range(2): # for pseudo 2d plots, also make a rel.err. plot
                    if ierr==1 and not pseudo_2d_plot: continue
                    extra_fname_text = extra_df_fname_text[tdfi] + ['','_err'][ierr]
                    if plotting_multiple_tallies: print('\tplotting:',tally_metadata['file'], extra_fname_text)
                    if ierr==0:
                        hue_palette_name_str = "mako_r"  # "cividis" # "rocket_r"
                    else:
                        hue_palette_name_str = "Reds"  # "cividis" # "rocket_r"
                        if hue_var=='value':
                            hue_var = 'rel.err.'
                        elif hue_var=='value2':
                            hue_var = 'rel.err.2'
                        else:
                            continue
                        
                    if x_var in cat: plot_kind='scatter'
                    if plot_kind=='scatter': plot_errorbars = False
                    if plot_errorbars:
                        # The following 3 lines are the "hack" to circumvent seaborn stupidly not supporting custom error values
                        duplicates = 100
                        df = tally_df.loc[tally_df.index.repeat(duplicates)].copy()
                        df['value'] = np.random.normal(df['value'].values, df['abs.err.'].values)
                        errorbar_arg = 'sd'
                    else:
                        df = tally_df.copy()
                        errorbar_arg = None
                        
                    if ierr==1 and pseudo_2d_plot: df[hue_var] = df[hue_var].replace({'0': '1', 0: 1.0})
            
                    # Determine lin/log/symlog usage for axes, with the following rules:
                    # - default to linear
                    # - if axis is already evenly linearly binned, keep it linear
                    # - otherwise, log scale if max/min > maxratio, maxratio = 101
                    # - if log but dataset contains is not of all same sign , use symlog
                    x_norm, y_norm = 'linear', 'linear'
                    hue_norm, size_norm = None, None
                    maxratio = 101
                    if x_var not in cat:
                        x_min, x_max = df[x_var].abs().replace(0, np.nan).min(), df[x_var].abs().replace(0, np.nan).max()
                        bins_are_lin_spaced = are_bins_linearly_spaced(df[x_var].to_numpy())
                        if not bins_are_lin_spaced and x_max/x_min>maxratio:
                            if len(np.sign(df[x_var]).unique())>1:
                                x_norm = 'log' #'symlog'
                            else:
                                x_norm = 'log'
                    if y_var not in cat:
                        y_min, y_max = df[y_var].abs().replace(0, np.nan).min(), df[y_var].abs().replace(0, np.nan).max()
                        bins_are_lin_spaced = are_bins_linearly_spaced(df[y_var].to_numpy())
                        if not bins_are_lin_spaced and y_max/y_min>maxratio:
                            if len(np.sign(df[y_var]).unique())>1:
                                y_norm = 'log' # 'symlog'
                            else:
                                y_norm = 'log'
                    use_colorbar = False
                    if hue_var != None:
                        if hue_var not in cat:
                            hue_min, hue_max = df[hue_var].abs().replace(0, np.nan).min(), df[hue_var].abs().replace(0, np.nan).max()
                            bins_are_lin_spaced = are_bins_linearly_spaced(df[hue_var].to_numpy())
                            if not bins_are_lin_spaced and hue_max/hue_min>maxratio:
                                if len(np.sign(df[hue_var]).unique())>1:
                                    hue_norm = SymLogNorm(linthresh=hue_min)
                                else:
                                    hue_norm = LogNorm()
                        if pseudo_2d_plot:
                            use_colorbar = True
                            if hue_norm == None: hue_norm = Normalize()
                            if ierr == 1: hue_norm = Normalize(vmin=0, vmax=1)
                            cmap = sns.color_palette(hue_palette_name_str, as_cmap=True)
                            sm = plt.cm.ScalarMappable(cmap=cmap, norm=hue_norm)
                    if size_var != None and size_var not in cat:
                        size_min, size_max = df[size_var].abs().replace(0, np.nan).min(), df[size_var].abs().replace(0,np.nan).max()
                        bins_are_lin_spaced = are_bins_linearly_spaced(df[size_var].to_numpy())
                        if not bins_are_lin_spaced and size_max/size_min>maxratio:
                            if len(np.sign(df[size_var]).unique())>1:
                                size_norm = SymLogNorm(linthresh=size_min)
                            else:
                                size_norm = LogNorm()
                    
                    # Right before plotting, let's rename Pandas DF columns to be nicer on plots
                    y_var_renamed = 'value'
                    x_var_renamed = None
                    hue_var_renamed = None
                    style_var_renamed = None
                    size_var_renamed = None
                    row_var_renamed = None
                    col_var_renamed = None
                    df_renamed = df.rename(columns=ax_labels)
                    if xvar_original_label != None:
                        x_var_new = ax_labels[xvar_original_label] + ' index'
                        df_renamed = df_renamed.rename(columns={x_var:x_var_new})
                        x_var_renamed = x_var_new
                    else:
                        x_var_renamed = ax_labels[x_var]
                    if yvar_original_label != 'value':
                        y_var_new = ax_labels[yvar_original_label] + ' index'
                        df_renamed = df_renamed.rename(columns={y_var:y_var_new})
                        y_var_renamed = y_var_new
                    else:
                        y_var_renamed = ax_labels[y_var]
                    if hue_var != None: hue_var_renamed = ax_labels[hue_var]
                    if style_var != None: style_var_renamed = ax_labels[style_var]
                    if size_var != None: size_var_renamed = ax_labels[size_var]
                    if row_var != None: row_var_renamed = ax_labels[row_var]
                    if col_var != None: col_var_renamed = ax_labels[col_var]
                    
                    if in_debug_mode:
                        print('plot_kind=', plot_kind, 
                              '\ny_var=', y_var, y_var_renamed, 
                              '\nx_var=', x_var, x_var_renamed, 
                              '\nhue_var=', hue_var, hue_var_renamed, '\nhue_norm=', hue_norm, 
                              '\nstyle_var=', style_var, style_var_renamed, 
                              '\nsize_var=', size_var, size_var_renamed, 
                              '\nrow_var=', row_var, row_var_renamed, 
                              '\ncol_var=', col_var, col_var_renamed, 
                              '\npseudo_2d_plot=', pseudo_2d_plot)
                        print(df_renamed.columns.values)

                    if plot_kind == 'line':
                        fg = sns.relplot(data=df_renamed, kind=plot_kind, #num=figi, 
                                         height=fig_height, aspect=fig_aspect_ratio, 
                                         x=x_var_renamed, y=y_var_renamed, 
                                         hue=hue_var_renamed, style=style_var_renamed, size=size_var_renamed,
                                         hue_norm=hue_norm, size_norm=size_norm,
                                         row=row_var_renamed, col=col_var_renamed,
                                         errorbar=errorbar_arg, legend='auto', # markers=False, 
                                         rasterized = use_rasterization,
                                         facet_kws={"legend_out": True, }
                                         )
                    else: # scatterplot
                        if pseudo_2d_plot:
                            num_cols = df_renamed[x_var_renamed].nunique()
                            num_rows = df_renamed[y_var_renamed].nunique()
                            # using horizontal direction
                            #fig_size_inch = fig_height * fig_aspect_ratio
                            #margin = 0.05  # 0.12
                            #n_markers = num_cols
                            #subplot_fraction = 0.7696385
                            # for a normal plot w/o rows/cols, widths are: fig=624.475, ax=535.807 (pre adjust) / 480.62 (post adjust), leg=58.875
                            if use_colorbar:
                                leg_arg = False
                                # using vertical direction
                                # these values are decent but could perhaps use better optimization
                                fig_size_inch = fig_height
                                margin = 0.06  # 0.2
                                n_markers = num_rows
                                subplot_fraction = 1 - 2 * margin
                                ar_ax_vs_fig_correction = 0.95
                            else:
                                leg_arg = 'auto'
                                # using vertical direction
                                fig_size_inch = fig_height
                                margin = 0.185  # 0.2
                                n_markers = num_rows
                                subplot_fraction = 1 - 2 * margin
                                ar_ax_vs_fig_correction = 1.3
                            ar_if_square = num_rows / num_cols
                            mar = fig_aspect_ratio * ar_if_square * ar_ax_vs_fig_correction  # marker aspect ratio, width/height
                            verts = [[-mar, -1], [mar, -1], [mar, 1], [-mar, 1], [-mar, -1]]
                            fig_ppi = 72  # 300 is default save dpi, different from draw dpi
                            marker_size = (subplot_fraction * fig_size_inch * fig_ppi / n_markers) ** 2 # Size of the marker, in points^2
                            s = marker_size  # universal marker size for plot
                            if num_rows <= 15:  # adjust vertical label spacing in legend, if needed, before plotting
                                sns.set(style=None, rc={'legend.labelspacing': 1.2})  
                                
                            fg = sns.relplot(data=df_renamed, kind=plot_kind,  # num=figi, 
                                             height=fig_height, aspect=fig_aspect_ratio,
                                             x=x_var_renamed, y=y_var_renamed, s=s, linewidth=0, #edgecolor=None, 
                                             hue=hue_var_renamed, style=style_var_renamed, size=size_var_renamed,
                                             hue_norm=hue_norm, size_norm=size_norm,
                                             row=row_var_renamed, col=col_var_renamed,
                                             legend=leg_arg,
                                             palette=hue_palette_name_str,
                                             marker=verts, alpha=1,
                                             rasterized = use_rasterization,
                                             #facet_kws={"legend_out": True, }
                                             )
                            #fg.set(aspect='equal')
                            #cbar = plt.colorbar(sm, ax=plt.gca())
                            #cbar = fg.fig.colorbar(sm, ax=fg.axes[:, -1])
                            for col in range(np.shape(fg.axes[:,:])[1]):
                                for row in range(np.shape(fg.axes[:,:])[0]):
                                    cbar = plt.colorbar(sm, ax=fg.axes[row,col])
                                    cbar.set_label(hue_var_renamed)
                        else:
                            fg = sns.relplot(data=df_renamed, kind=plot_kind,  # num=figi, 
                                             height=fig_height, aspect=fig_aspect_ratio,
                                             x=x_var_renamed, y=y_var_renamed,
                                             hue=hue_var_renamed, style=style_var_renamed, size=size_var_renamed,
                                             hue_norm=hue_norm, size_norm=size_norm,
                                             row=row_var_renamed, col=col_var_renamed,
                                             legend='auto', s=100, alpha=1,
                                             rasterized = use_rasterization,
                                             facet_kws={"legend_out": True, }
                                             )
                    
                    fig, axes = fg.fig, fg.axes
                    legend_exists = not all(v is None for v in [hue_var, style_var, size_var, col_var, row_var])
                    if legend_exists and not pseudo_2d_plot:
                        leg = fg._legend
                        leg.set_bbox_to_anchor([1, 0.5])  # Move legend to far right
                        leg._loc = 5 # https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.legend.html#matplotlib.axes.Axes.legend
                        # later figure will be rescaled to accommodate the legend
                        
                    title_str = tally_metadata['tally_type'] + ', ' + tally_metadata['file'] + '\n' + tally_metadata['title']
                    #xlabel_str = tally_metadata['axis1_label']
                    #ylabel_str = tally_metadata['value_label']
            
                    st = fg.fig.suptitle(title_str, fontsize=16, va='top', y=0.995)
                    #fg.fig.get_axes()[0].annotate(title_str, (0.5, 0.95), xycoords='figure fraction', ha='center', fontsize=16)
                    #fg.set_axis_labels(xlabel_str, ylabel_str)
                    for ax in fg.axes.flat:
                        ax.set_xscale(x_norm)
                        ax.set_yscale(y_norm)
                        ax.grid(which='both', linewidth=1, color='#EEEEEE', alpha=0.5)
                        #ax.set_facecolor((0, 0, 0, 0))
                        #ax.tick_params(labelbottom=True, labelleft=True)
                        #ax.set_xlabel(xlabel_str, visible=True)
                        #ax.set_ylabel(ylabel_str, visible=True)
                    
                    #sns.move_legend(fg, "upper left", bbox_to_anchor=(1, 1))
                    # Issue 1: tight_layout is needed to not have stuff clipped off edge
                    # Issue 2: tight_layout causes the legend, if placed outside the plot area, to be cut out
                    # Soltion: draw canvas early, figure out their relative widths, then adjust the right edge of the figure
                    plt.tight_layout() # pad=3.0
                    if legend_exists and not pseudo_2d_plot:
                        fig.canvas.draw()
                        fig_width = fig.get_window_extent().width
                        leg_width = leg.get_window_extent().width
                        #print(fig_width,ax.get_window_extent().width,leg_width)
                        fg.fig.subplots_adjust(right=0.98*(fig_width-leg_width)/fig_width)
                        #print(fg.fig.get_window_extent().width, fg.ax.get_window_extent().width, leg.get_window_extent().width)
                    #if pseudo_2d_plot:
                    #    fig.canvas.draw()
                    #    fig_width = fig.get_window_extent().width
                    #    #cbar_width = fg.fig.get_window_extent().width - fg.axes.get_window_extent().width
                    #    #fg.fig.subplots_adjust(right=0.98 * (fig_width - cbar_width) / fig_width)
                    #    fg.fig.subplots_adjust(right=0.98 * 0.8)
                        
                    # add PHITS Tools info
                    fontdict = {'color':'#666666', 'weight':'normal', 'size': 8, 'style':'italic'}
                    fig.text(0.005,0.005,'Figure generated by PHITS Tools, github.com/Lindt8/PHITS-Tools',fontdict=fontdict,ha='left', va='bottom', url='https://github.com/Lindt8/PHITS-Tools')
    
                    if use_rasterization:
                        pdf.savefig(bbox_extra_artists=[st], dpi=rasterize_dpi)
                    else:
                        pdf.savefig(bbox_extra_artists=[st])
                    if return_fg_list: fg_list.append(copy.deepcopy(fg))
                    for ext in additional_save_extensions:
                        if '.' not in ext: ext = '.'+ext  # assume user provides only the extension in event the . is missing
                        if ext.lower()=='.pdf': ext = '_.pdf'
                        img_save_path = Path(output_filename.parent, output_filename.stem + extra_fname_text + ext)
                        plt.savefig(img_save_path, bbox_extra_artists=[st], dpi=rasterize_dpi)
                    if not show_plots: plt.close(fg.fig)
                    figi += 1
            
        d = pdf.infodict()
        #d['Title'] = 'Multipage PDF Example'
        d['Author'] = 'PHITS Tools'
        d['Subject'] = 'https://github.com/Lindt8/PHITS-Tools/'
        #d['Keywords'] = 'PdfPages multipage keywords author title subject'
        d['CreationDate'] = datetime.datetime.today()
        d['ModDate'] = datetime.datetime.today()

    print('Plot PDF written:', output_filename, '\n')
    
    if show_plots: 
        plt.show()
    if return_fg_list:
        return fg_list 
    else:
        return None





def fetch_MC_material(matid=None,matname=None,matsource=None,concentration_type=None,particle=None):
    '''
    Description:
        Returns a materials definition string formatted for use in PHITS or MCNP (including a density estimate);
        most available materials are those found in [PNNL-15870 Rev. 1](https://www.osti.gov/biblio/1023125).

    Dependencies:
        - PYTHONPATH environmental variable must be set and one entry must contain the directory
                which contains the vital "MC_materials/Compiled_MC_materials.pkl" file.

    Inputs:
       (required to enter `matid` OR `matname`, with `matid` taking priority if conflicting)

       - `matid` = ID number in the "Compiled_MC_materials" file
       - `matname` = exact name of material in "Compiled_MC_materials" file
       - `matsource` = exact source of material in "Compiled_MC_materials" file, only used when multiple
                materials have identical names
       - `concentration_type` = selection between `'weight fraction'` (default if no chemical formula is present in database; e.g., "Incoloy-800") and
                `'atom fraction'` (default if a chemical formula is present; e.g. "Ethane" (Formula = C2H6)) to be returned
       - `particle` = selection of whether natural (`'photons'`, default) or isotopic (`'neutrons'`) elements are used
                Note that if "enriched" or "depleted" appears in the material's name, particle=`'neutrons'` is set automatically.

    Outputs:
       - `mat_str` = string containing the material's information, ready to be inserted directly into a PHITS/MCNP input file
    '''
    import os
    import pickle
    if not matid and not matname:
        print('Either "matid" or "matname" MUST be defined')
        return None

    # First, locate and open materials library
    try:
        user_paths = os.environ['PYTHONPATH'].split(os.pathsep)
        lib_file = None
        for i in user_paths:
            if 'phits_tools' in i.lower() or 'phits-tools' in i.lower():
                lib_file = i + r"\MC_materials\Compiled_MC_materials"
        if not lib_file:
            print('Could not find "PHITS_tools" folder in PYTHONPATH; this folder contains the vital "MC_materials/Compiled_MC_materials.pkl" file.')
    except KeyError:
        print('The PYTHONPATH environmental variable must be defined and contain the path to the directory holding "MC_materials/Compiled_MC_materials.pkl"')
        return None

    # Load materials library
    def load_obj(name ):
        with open(name + '.pkl', 'rb') as f:
            return pickle.load(f)
    all_mats_list = load_obj(lib_file)

    if matid: # use mat ID number
        mi = int(matid)-1
        matname = all_mats_list[mi]['name']
    else: # use material name and possibly source too
        # determine material
        mi = None
        # first check for exact matches
        matching_mi = []
        for i in range(len(all_mats_list)):
            if all_mats_list[i]['name'].lower()==matname.lower():
                matching_mi.append(i)
        if len(matching_mi)==1:
            mi = matching_mi[0]
        elif len(matching_mi)>1:
            print('Found multiple materials with this identical matname value:')
            for mmi in matching_mi:
                print('\tmatid={}  matname="{}"  source="{}"'.format(str(mmi+1),all_mats_list[mmi]['name'],all_mats_list[mmi]['source']))
                if all_mats_list[mmi]['source'] and all_mats_list[mmi]['source']==matsource:
                    mi = mmi
                    print('\t\t^ matches inputed "matsource" and will be used')
            if mi==None:
                print('Please enter a "matsource" value identical to one of these two (or the matid).')
                return None
        else: # Exact material name not found
            # search for similar entries
            similar_mi = []
            for i in range(len(all_mats_list)):
                if matname.lower() in all_mats_list[i]['name'].lower():
                    similar_mi.append(i)
            if len(similar_mi)==0:
                print('No materials with that exact name or names containing "matname" were found.')
                return None
            elif len(similar_mi)==1:
                mi = similar_mi[0]
                print('Found one similar material (matid={}  matname="{}"  source="{}"); using it.'.format(str(mi+1),all_mats_list[mi]['name'],all_mats_list[mi]['source']))
            else:
                print('Found no material with exact "matname" but {} with similar names:'.format(len(similar_mi)))
                for smi in similar_mi:
                    print('\tmatid={}  matname="{}"  source="{}"'.format(str(smi+1),all_mats_list[smi]['name'],all_mats_list[smi]['source']))
                print('The first of these will be used.  If another material was desired, please enter its "matid" or exact "matname".')
                mi = similar_mi[0]

    # Now that material ID has been found, generate text entry
    mat = all_mats_list[mi]
    banner_width = 60
    cc = '$'  # comment character

    entry_text  = '\n'+cc+'*'*banner_width + '\n'
    entry_text += cc+'  {:<3d} : {} \n'.format(mi+1,mat['name'])
    if mat['source'] and mat['source']!='-':
        entry_text += cc+'  Source = {} \n'.format(mat['source'])
    if mat['formula'] and mat['formula']!='-':
        entry_text += cc+'  Formula = {} \n'.format(mat['formula'])
    if mat['molecular weight'] and mat['molecular weight']!='-':
        entry_text += cc+'  Molecular weight (g/mole) = {} \n'.format(mat['molecular weight'])
    if mat['density'] and mat['density']!='-':
        entry_text += cc+'  Density (g/cm3) = {} \n'.format(mat['density'])
    if mat['total atom density'] and mat['total atom density']!='-':
        if isinstance(mat['total atom density'],str):
            entry_text += cc+'  Total atom density (atoms/b-cm) = {} \n'.format(mat['total atom density'])
        else:
            entry_text += cc+'  Total atom density (atoms/b-cm) = {:<13.4E} \n'.format(mat['total atom density'])

    if concentration_type==None: # user did not select this, determine which is more appropriate automatically
        if mat['formula'] and mat['formula']!='-':
            concentration_type = 'atom fraction'
        else:
            concentration_type = 'weight fraction'

    entry_text += cc+'  Composition by {} \n'.format(concentration_type)

    # Determine if neutron or photon entry will be used
    neutron_keyword_list = ['depleted','enriched',' heu',' leu','uranium','plutonium','uranyl']
    if particle==None: # user did not select this, determine which is more appropriate automatically
        neutron_kw_found_in_name = False
        for nki in neutron_keyword_list:
            if nki in matname.lower():
                neutron_kw_found_in_name = True
        if neutron_kw_found_in_name:
            particle = 'neutrons'
        else:
            particle = 'photons'


    for j in range(len(mat[particle][concentration_type]['ZA'])):

        if isinstance(mat[particle][concentration_type]['value'][j],str):
            entry_format = '{:4}    {:>7}  {:13}   '+cc+'  {}'  + '\n'
        else:
            entry_format = '{:4}    {:>7d}  {:<13.6f}   '+cc+'  {}'  + '\n'

        if j==0:
            mstr = 'M{:<3}'.format(mi+1)
        else:
            mstr = ' '*4

        ZZZAAA = mat[particle][concentration_type]['ZA'][j]
        if ZZZAAA == '-':
            ZZZAAA = mat['photons'][concentration_type]['ZA'][j]

        Z = int(str(ZZZAAA)[:-3])
        A = str(ZZZAAA)[-3:]
        sym = Element_Z_to_Sym(Z)
        if A != '000':
            isotope = sym+'-'+A.lstrip('0')
        else:
            isotope = sym

        entry_text += entry_format.format(mstr,ZZZAAA,mat[particle][concentration_type]['value'][j],isotope)
    entry_text  += cc+'*'*banner_width + '\n'

    return entry_text

def ICRP116_effective_dose_coeff(E=1.0,particle='photon',geometry='AP',interp_scale='log',interp_type='cubic',extrapolation_on=False):
    '''
    Description:
        For a given particle at a given energy in a given geometry, returns its
        effective dose conversion coefficient from [ICRP 116](https://doi.org/10.1016/j.icrp.2011.10.001)

    Dependencies:
        - `import numpy as np`
        - `from scipy.interpolate import CubicSpline, lagrange, interp1d`
        - `find` (function within the "PHITS Tools" package)

    Inputs:
       - `E` = energy of the particle in MeV (D=`1`)
       - `particle` = select particle (D=`'photon'`, options include: `['photon', 'electron', 'positron' ,'neutron' ,'proton', 'negmuon', 'posmuon', 'negpion', 'pospion', 'He3ion']`)
       - `geometry` = geometric arrangement (D=`'AP'`, options include: `['AP', 'PA', 'LLAT', 'RLAT', 'ROT', 'ISO', 'H*(10)']` (`'LLAT'`,`'RLAT'`,`'ROT'` only available for photon, proton, and neutron))
              - Meanings:
               AP, antero-posterior; PA, postero-anterior; LLAT, left lateral; RLAT, right lateral; ROT, rotational; ISO, isotropic.
              - Note: `'H*(10)'` ambient dose equivalent is available for photons only
       - `interp_scale` = interpolation scale (D=`'log'` to interpolate on a log scale, options include: `['log','lin']`, ICRP 74/116 suggest log-log cubic interpolation)
       - `interp_type`  = interpolation method (D=`'cubic'` to interpolate with a cubic spline, options include: `['cubic','linear']`, ICRP 74/116 suggest log-log cubic interpolation)
                                              technically, any options available for scipy.interpolate.interp1d() can be used: `['linear', 'nearest', 'zero', 'slinear', 'quadratic', 'cubic', 'previous']`
       - `extrapolation_on` = boolean designating whether values outside of the tabulated energies will be extrapolated (D=`False`)

             |                      |                                                                       |
             | -------------------- | --------------------------------------------------------------------- |
             | if False & E < E_min | f(E) = 0                                                              |
             | if False & E > E_max | f(E) = f(E_max)                                                       |
             | if True  & E < E_min | f(E) is linearly interpolated between (0,0) and (E_min,f(E_min))      |
             | if True  & E > E_max | f(E) is extrapolated using the specified interpolation scale and type |
    Outputs:
       - `f` = effective dose conversion coefficient in pSv*cm^2
    '''
    import numpy as np
    from scipy.interpolate import CubicSpline, lagrange, interp1d

    pars_list = ['photon','electron','positron','neutron','proton','negmuon','posmuon','negpion','pospion','He3ion']
    geo_list_all = ['AP','PA','LLAT','RLAT','ROT','ISO','H*(10)']
    geo_list_short = ['AP','PA','ISO']

    if particle not in pars_list or geometry not in geo_list_all:
        pstr = 'Please select a valid particle and geometry.\n'
        pstr += "Particle selected = {}, options include: ['photon','electron','positron','neutron','proton','negmuon','posmuon','negpion','pospion','He3ion']".format(particle)
        pstr += "Geometry selected = {}, options include: ['AP','PA','LLAT','RLAT','ROT','ISO'] ('LLAT','RLAT','ROT' only available for photon, proton, and neutron)"
        print(pstr)
        return None

    if (particle not in ['photon','neutron','proton'] and geometry in ['LLAT','RLAT','ROT']) or (particle!='photon' and geometry=='H*(10)'):
        if (particle!='photon' and geometry=='H*(10)'):
            pstr = "geometry = {} is only available for photons\n".format(geometry)
        else:
            pstr = "geometry = {} is only available for photon, neutron, and proton\n".format(geometry)
            pstr += "For selected particle = {}, please choose geometry from ['AP','PA','ISO']".format(particle)
        print(pstr)
        return None

    E_photon = [0.01, 0.015, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.511, 0.6, 0.662, 0.8, 1, 1.117, 1.33, 1.5, 2, 3, 4, 5, 6, 6.129, 8, 10, 15, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000]
    f_photon = [
    [0.0685, 0.156, 0.225, 0.313, 0.351, 0.37, 0.39, 0.413, 0.444, 0.519, 0.748, 1, 1.51, 2, 2.47, 2.52, 2.91, 3.17, 3.73, 4.49, 4.9, 5.59, 6.12, 7.48, 9.75, 11.7, 13.4, 15, 15.1, 17.8, 20.5, 26.1, 30.8, 37.9, 43.1, 47.1, 50.1, 54.5, 57.8, 63.3, 67.3, 72.3, 75.5, 77.5, 78.9, 80.5, 81.7, 83.8, 85.2, 86.9, 88.1, 88.9, 89.5, 90.2, 90.7],
    [0.0184, 0.0155, 0.026, 0.094, 0.161, 0.208, 0.242, 0.271, 0.301, 0.361, 0.541, 0.741, 1.16, 1.57, 1.98, 2.03, 2.38, 2.62, 3.13, 3.83, 4.22, 4.89, 5.39, 6.75, 9.12, 11.2, 13.1, 15, 15.2, 18.6, 22, 30.3, 38.2, 51.4, 62, 70.4, 76.9, 86.6, 93.2, 104, 111, 119, 124, 128, 131, 135, 138, 142, 145, 148, 150, 152, 153, 155, 155],
    [0.0189, 0.0416, 0.0655, 0.11, 0.14, 0.16, 0.177, 0.194, 0.214, 0.259, 0.395, 0.552, 0.888, 1.24, 1.58, 1.62, 1.93, 2.14, 2.59, 3.23, 3.58, 4.2, 4.68, 5.96, 8.21, 10.2, 12, 13.7, 13.9, 17, 20.1, 27.4, 34.4, 47.4, 59.2, 69.5, 78.3, 92.4, 103, 121, 133, 148, 158, 165, 170, 178, 183, 193, 198, 206, 212, 216, 219, 224, 228],
    [0.0182, 0.039, 0.0573, 0.0891, 0.114, 0.133, 0.15, 0.167, 0.185, 0.225, 0.348, 0.492, 0.802, 1.13, 1.45, 1.49, 1.78, 1.98, 2.41, 3.03, 3.37, 3.98, 4.45, 5.7, 7.9, 9.86, 11.7, 13.4, 13.6, 16.6, 19.7, 27.1, 34.4, 48.1, 60.9, 72.2, 82, 97.9, 110, 130, 143, 161, 172, 180, 186, 195, 201, 212, 220, 229, 235, 240, 244, 251, 255],
    [0.0337, 0.0664, 0.0986, 0.158, 0.199, 0.226, 0.248, 0.273, 0.297, 0.355, 0.528, 0.721, 1.12, 1.52, 1.92, 1.96, 2.3, 2.54, 3.04, 3.72, 4.1, 4.75, 5.24, 6.55, 8.84, 10.8, 12.7, 14.4, 14.6, 17.6, 20.6, 27.7, 34.4, 46.1, 56, 64.4, 71.2, 82, 89.7, 102, 111, 121, 128, 133, 136, 142, 145, 152, 156, 161, 165, 168, 170, 172, 175],
    [0.0288, 0.056, 0.0812, 0.127, 0.158, 0.18, 0.199, 0.218, 0.239, 0.287, 0.429, 0.589, 0.932, 1.28, 1.63, 1.67, 1.97, 2.17, 2.62, 3.25, 3.6, 4.2, 4.66, 5.9, 8.08, 10, 11.8, 13.5, 13.7, 16.6, 19.6, 26.8, 33.8, 46.1, 56.9, 66.2, 74.1, 87.2, 97.5, 116, 130, 147, 159, 168, 174, 185, 193, 208, 218, 232, 243, 251, 258, 268, 276],
    [0.061, 0.83, 1.05, 0.81, 0.64, 0.55, 0.51, 0.52, 0.53, 0.61, 0.89, 1.20, 1.80, 2.38, 2.93, 2.99, 3.44, 3.73, 4.38, 5.20, 5.60, 6.32, 6.90, 8.60, 11.10, 13.40, 15.50, 17.60, 17.86, 21.60, 25.60, 8.53, 8.29, 8.23, 8.26, 8.64, 8.71, 8.86, 9.00, 9.60, 10.20, 10.73, 11.27, 11.80, 11.78, 11.74, 11.70, 11.60, 11.50, 12.10, 12.70, 13.30, 13.08, 12.64, 12.20]
    ]

    E_electron = [0.01, 0.015, 0.02, 0.03, 0.04, 0.05, 0.06, 0.08, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1, 1.5, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000]
    f_electron = [
    [0.0269, 0.0404, 0.0539, 0.081, 0.108, 0.135, 0.163, 0.218, 0.275, 0.418, 0.569, 0.889, 1.24, 1.63, 2.05, 4.04, 7.1, 15, 22.4, 36.1, 48.2, 59.3, 70.6, 97.9, 125, 188, 236, 302, 329, 337, 341, 346, 349, 355, 359, 365, 369, 372, 375, 379, 382, 387, 391, 397, 401, 405, 407, 411, 414],
    [0.0268, 0.0402, 0.0535, 0.0801, 0.107, 0.133, 0.16, 0.213, 0.267, 0.399, 0.53, 0.787, 1.04, 1.28, 1.5, 1.68, 1.68, 1.62, 1.62, 1.95, 2.62, 3.63, 5.04, 9.46, 18.3, 53.1, 104, 220, 297, 331, 344, 358, 366, 379, 388, 399, 408, 414, 419, 428, 434, 446, 455, 468, 477, 484, 490, 499, 507],
    [0.0188, 0.0283, 0.0377, 0.0567, 0.0758, 0.0948, 0.114, 0.152, 0.191, 0.291, 0.393, 0.606, 0.832, 1.08, 1.35, 1.97, 2.76, 4.96, 7.24, 11.9, 16.4, 21, 25.5, 35.5, 46.7, 76.9, 106, 164, 212, 249, 275, 309, 331, 363, 383, 410, 430, 445, 457, 478, 495, 525, 549, 583, 608, 628, 646, 675, 699]
    ]

    E_positron = [0.01, 0.015, 0.02, 0.03, 0.04, 0.05, 0.06, 0.08, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1, 1.5, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000]
    f_positron = [
    [3.28, 3.29, 3.3, 3.33, 3.36, 3.39, 3.42, 3.47, 3.53, 3.67, 3.84, 4.16, 4.52, 4.9, 5.36, 7.41, 10.5, 18.3, 25.7, 39.1, 51, 61.7, 72.9, 99, 126, 184, 229, 294, 320, 327, 333, 339, 342, 349, 354, 362, 366, 369, 372, 376, 379, 385, 389, 395, 399, 402, 404, 408, 411],
    [1.62, 1.64, 1.65, 1.68, 1.71, 1.73, 1.76, 1.82, 1.87, 2.01, 2.14, 2.4, 2.65, 2.9, 3.12, 3.32, 3.37, 3.44, 3.59, 4.19, 5.11, 6.31, 8.03, 14, 23.6, 59, 111, 221, 291, 321, 334, 349, 357, 371, 381, 393, 402, 409, 415, 424, 430, 443, 451, 465, 473, 480, 486, 495, 503],
    [1.39, 1.4, 1.41, 1.43, 1.45, 1.47, 1.49, 1.53, 1.57, 1.67, 1.77, 1.98, 2.21, 2.45, 2.72, 3.38, 4.2, 6.42, 8.7, 13.3, 18, 22.4, 26.9, 36.7, 47.6, 75.5, 104, 162, 209, 243, 268, 302, 323, 356, 377, 405, 425, 440, 453, 474, 491, 522, 545, 580, 605, 627, 645, 674, 699]
    ]

    E_neutron = [1.00E-09, 1.00E-08, 2.50E-08, 1.00E-07, 2.00E-07, 5.00E-07, 1.00E-06, 2.00E-06, 5.00E-06, 1.00E-05, 2.00E-05, 5.00E-05, 1.00E-04, 2.00E-04, 5.00E-04, 0.001, 0.002, 0.005, 0.01, 0.02, 0.03, 0.05, 0.07, 0.1, 0.15, 0.2, 0.3, 0.5, 0.7, 0.9, 1, 1.2, 1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 15, 16, 18, 20, 21, 30, 50, 75, 100, 130, 150, 180, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 2000, 5000, 10000]
    f_neutron = [
    [3.09, 3.55, 4, 5.2, 5.87, 6.59, 7.03, 7.39, 7.71, 7.82, 7.84, 7.82, 7.79, 7.73, 7.54, 7.54, 7.61, 7.97, 9.11, 12.2, 15.7, 23, 30.6, 41.9, 60.6, 78.8, 114, 177, 232, 279, 301, 330, 365, 407, 458, 483, 494, 498, 499, 499, 500, 500, 499, 495, 493, 490, 484, 477, 474, 453, 433, 420, 402, 382, 373, 363, 359, 363, 389, 422, 457, 486, 508, 524, 537, 612, 716, 933],
    [1.85, 2.11, 2.44, 3.25, 3.72, 4.33, 4.73, 5.02, 5.3, 5.44, 5.51, 5.55, 5.57, 5.59, 5.6, 5.6, 5.62, 5.95, 6.81, 8.93, 11.2, 15.7, 20, 25.9, 34.9, 43.1, 58.1, 85.9, 112, 136, 148, 167, 195, 235, 292, 330, 354, 371, 383, 392, 398, 404, 412, 417, 419, 420, 422, 423, 423, 422, 428, 439, 444, 446, 446, 447, 448, 464, 496, 533, 569, 599, 623, 640, 654, 740, 924, 1.17E+03],
    [1.04, 1.15, 1.32, 1.7, 1.94, 2.21, 2.4, 2.52, 2.64, 2.65, 2.68, 2.66, 2.65, 2.66, 2.62, 2.61, 2.6, 2.74, 3.13, 4.21, 5.4, 7.91, 10.5, 14.4, 20.8, 27.2, 39.7, 63.7, 85.5, 105, 115, 130, 150, 179, 221, 249, 269, 284, 295, 303, 310, 316, 325, 333, 336, 338, 343, 347, 348, 360, 380, 399, 409, 416, 420, 425, 427, 441, 472, 510, 547, 579, 603, 621, 635, 730, 963, 1.23E+03],
    [0.893, 0.978, 1.12, 1.42, 1.63, 1.86, 2.02, 2.11, 2.21, 2.24, 2.26, 2.24, 2.23, 2.24, 2.21, 2.21, 2.2, 2.33, 2.67, 3.6, 4.62, 6.78, 8.95, 12.3, 17.9, 23.4, 34.2, 54.4, 72.6, 89.3, 97.4, 110, 128, 153, 192, 220, 240, 255, 267, 276, 284, 290, 301, 310, 313, 317, 323, 328, 330, 345, 370, 392, 404, 413, 418, 425, 429, 451, 483, 523, 563, 597, 620, 638, 651, 747, 979, 1.26E+03],
    [1.7, 2.03, 2.31, 2.98, 3.36, 3.86, 4.17, 4.4, 4.59, 4.68, 4.72, 4.73, 4.72, 4.67, 4.6, 4.58, 4.61, 4.86, 5.57, 7.41, 9.46, 13.7, 18, 24.3, 34.7, 44.7, 63.8, 99.1, 131, 160, 174, 193, 219, 254, 301, 331, 351, 365, 374, 381, 386, 390, 395, 398, 398, 399, 399, 398, 398, 395, 395, 402, 406, 411, 414, 418, 422, 443, 472, 503, 532, 558, 580, 598, 614, 718, 906, 1.14E+03],
    [1.29, 1.56, 1.76, 2.26, 2.54, 2.92, 3.15, 3.32, 3.47, 3.52, 3.54, 3.55, 3.54, 3.52, 3.47, 3.46, 3.48, 3.66, 4.19, 5.61, 7.18, 10.4, 13.7, 18.6, 26.6, 34.4, 49.4, 77.1, 102, 126, 137, 153, 174, 203, 244, 271, 290, 303, 313, 321, 327, 332, 339, 344, 346, 347, 350, 352, 353, 358, 371, 387, 397, 407, 412, 421, 426, 455, 488, 521, 553, 580, 604, 624, 642, 767, 1.01E+03, 1.32E+03]
    ]

    E_proton = [1, 1.5, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000]
    f_proton = [
    [5.46, 8.2, 10.9, 16.4, 21.9, 27.3, 32.8, 43.7, 54.9, 189, 428, 750, 1.02E+03, 1.18E+03, 1.48E+03, 2.16E+03, 2.51E+03, 2.38E+03, 1.77E+03, 1.38E+03, 1.23E+03, 1.15E+03, 1.16E+03, 1.11E+03, 1.09E+03, 1.15E+03, 1.12E+03, 1.23E+03, 1.27E+03, 1.23E+03, 1.37E+03, 1.45E+03, 1.41E+03],
    [5.47, 8.21, 10.9, 16.4, 21.9, 27.3, 32.8, 43.7, 54.6, 56.1, 43.6, 36.1, 45.5, 71.5, 156, 560, 1.19E+03, 2.82E+03, 1.93E+03, 1.45E+03, 1.30E+03, 1.24E+03, 1.23E+03, 1.23E+03, 1.23E+03, 1.25E+03, 1.28E+03, 1.34E+03, 1.40E+03, 1.45E+03, 1.53E+03, 1.65E+03, 1.74E+03],
    [2.81, 4.21, 5.61, 8.43, 11.2, 14, 16.8, 22.4, 28.1, 50.7, 82.8, 180, 290, 379, 500, 799, 994, 1.64E+03, 2.15E+03, 1.44E+03, 1.27E+03, 1.21E+03, 1.20E+03, 1.19E+03, 1.18E+03, 1.21E+03, 1.25E+03, 1.32E+03, 1.31E+03, 1.39E+03, 1.44E+03, 1.56E+03, 1.63E+03],
    [2.81, 4.2, 5.62, 8.41, 11.2, 14, 16.8, 22.4, 28.1, 48.9, 78.8, 172, 278, 372, 447, 602, 818, 1.46E+03, 2.18E+03, 1.45E+03, 1.28E+03, 1.21E+03, 1.20E+03, 1.20E+03, 1.20E+03, 1.23E+03, 1.25E+03, 1.32E+03, 1.33E+03, 1.41E+03, 1.45E+03, 1.59E+03, 1.67E+03],
    [4.5, 6.75, 8.98, 13.4, 17.8, 22.1, 26.3, 34.5, 50.1, 93.7, 165, 296, 422, 532, 687, 1.09E+03, 1.44E+03, 2.16E+03, 1.96E+03, 1.44E+03, 1.28E+03, 1.22E+03, 1.22E+03, 1.20E+03, 1.19E+03, 1.23E+03, 1.23E+03, 1.30E+03, 1.29E+03, 1.35E+03, 1.41E+03, 1.49E+03, 1.56E+03],
    [3.52, 5.28, 7.02, 10.5, 13.9, 17.3, 20.5, 26.8, 45.8, 80.1, 136, 249, 358, 451, 551, 837, 1.13E+03, 1.79E+03, 1.84E+03, 1.42E+03, 1.25E+03, 1.18E+03, 1.17E+03, 1.17E+03, 1.15E+03, 1.21E+03, 1.22E+03, 1.31E+03, 1.40E+03, 1.43E+03, 1.57E+03, 1.71E+03, 1.78E+03]
    ]

    E_negmuon = [1, 1.5, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000]
    f_negmuon = [
    [180, 180, 184, 188, 193, 205, 242, 293, 332, 414, 465, 657, 735, 755, 628, 431, 382, 340, 326, 319, 320, 321, 325, 327, 333, 331, 333, 336, 337, 337, 337, 337, 338],
    [75.2, 76.8, 78.3, 81.4, 84.8, 87.7, 86.7, 86.8, 88.6, 100, 122, 251, 457, 703, 775, 485, 402, 345, 329, 321, 321, 324, 326, 332, 337, 338, 341, 344, 345, 346, 346, 347, 347],
    [78.7, 79.5, 80.9, 83.7, 87.1, 91.5, 98.1, 113, 127, 161, 191, 275, 363, 446, 496, 498, 432, 354, 332, 321, 321, 323, 326, 331, 337, 338, 341, 344, 346, 347, 347, 348, 348]
    ]

    E_posmuon = [1, 1.5, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000]
    f_posmuon = [
    [194, 196, 198, 202, 207, 216, 251, 300, 340, 425, 481, 674, 751, 768, 635, 431, 381, 339, 326, 318, 319, 320, 322, 325, 327, 331, 333, 336, 337, 337, 337, 337, 339],
    [82.6, 84.1, 85.7, 88.9, 92.1, 94.3, 92.5, 92.8, 94.8, 108, 133, 265, 473, 721, 787, 483, 399, 345, 328, 320, 321, 323, 325, 330, 333, 339, 341, 344, 345, 346, 346, 347, 347],
    [85.2, 86.2, 87.5, 90.3, 93.6, 97.7, 103, 117, 132, 167, 199, 284, 373, 456, 506, 502, 432, 354, 332, 320, 320, 322, 324, 329, 333, 338, 341, 344, 346, 347, 347, 348, 348]
    ]

    E_negpion = [1, 1.5, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000, 15000, 20000, 30000, 40000, 50000, 60000, 80000, 100000, 150000, 200000]
    f_negpion = [
    [406, 422, 433, 458, 491, 528, 673, 965, 1.09E+03, 1.25E+03, 1.28E+03, 1.77E+03, 1.92E+03, 1.93E+03, 1.68E+03, 1.14E+03, 995, 927, 902, 848, 844, 869, 901, 947, 977, 1.03E+03, 1.05E+03, 1.03E+03, 1.03E+03, 1.06E+03, 1.09E+03, 1.14E+03, 1.17E+03, 1.21E+03, 1.24E+03, 1.30E+03, 1.35E+03, 1.39E+03, 1.42E+03, 1.48E+03, 1.54E+03, 1.67E+03, 1.78E+03],
    [194, 201, 210, 225, 233, 237, 208, 181, 178, 197, 244, 547, 1.02E+03, 1.70E+03, 1.99E+03, 1.31E+03, 991, 889, 871, 843, 850, 880, 917, 976, 1.02E+03, 1.08E+03, 1.12E+03, 1.11E+03, 1.13E+03, 1.18E+03, 1.22E+03, 1.29E+03, 1.34E+03, 1.41E+03, 1.47E+03, 1.56E+03, 1.63E+03, 1.70E+03, 1.75E+03, 1.86E+03, 1.95E+03, 2.15E+03, 2.33E+03],
    [176, 189, 198, 215, 232, 251, 271, 317, 361, 439, 508, 676, 868, 1.02E+03, 1.15E+03, 1.15E+03, 1.03E+03, 857, 815, 794, 807, 838, 875, 935, 979, 1.05E+03, 1.09E+03, 1.11E+03, 1.15E+03, 1.20E+03, 1.26E+03, 1.36E+03, 1.43E+03, 1.55E+03, 1.64E+03, 1.79E+03, 1.91E+03, 2.02E+03, 2.11E+03, 2.29E+03, 2.46E+03, 2.80E+03, 3.04E+03]
    ]

    E_pospion = [1, 1.5, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000, 15000, 20000, 30000, 40000, 50000, 60000, 80000, 100000, 150000, 200000]
    f_pospion = [
    [314, 324, 340, 379, 429, 489, 540, 717, 819, 1000, 1.10E+03, 1.52E+03, 1.75E+03, 1.83E+03, 1.66E+03, 1.22E+03, 1.13E+03, 1.22E+03, 1.25E+03, 1.07E+03, 969, 943, 952, 999, 1.04E+03, 1.10E+03, 1.10E+03, 1.06E+03, 1.06E+03, 1.07E+03, 1.10E+03, 1.14E+03, 1.17E+03, 1.22E+03, 1.25E+03, 1.30E+03, 1.34E+03, 1.38E+03, 1.42E+03, 1.48E+03, 1.54E+03, 1.67E+03, 1.78E+03],
    [121, 125, 133, 151, 170, 183, 185, 177, 179, 201, 247, 494, 906, 1.48E+03, 1.82E+03, 1.38E+03, 1.12E+03, 1.15E+03, 1.23E+03, 1.10E+03, 998, 970, 980, 1.04E+03, 1.09E+03, 1.16E+03, 1.19E+03, 1.16E+03, 1.16E+03, 1.20E+03, 1.24E+03, 1.31E+03, 1.35E+03, 1.42E+03, 1.48E+03, 1.57E+03, 1.64E+03, 1.70E+03, 1.75E+03, 1.84E+03, 1.94E+03, 2.14E+03, 2.33E+03],
    [151, 160, 168, 183, 198, 216, 233, 265, 296, 367, 439, 602, 787, 953, 1.09E+03, 1.16E+03, 1.10E+03, 1.05E+03, 1.08E+03, 1.02E+03, 953, 930, 938, 993, 1.05E+03, 1.13E+03, 1.16E+03, 1.16E+03, 1.18E+03, 1.23E+03, 1.28E+03, 1.37E+03, 1.43E+03, 1.55E+03, 1.64E+03, 1.79E+03, 1.90E+03, 2.01E+03, 2.10E+03, 2.27E+03, 2.42E+03, 2.76E+03, 3.07E+03]
    ]

    E_He3ion = [1, 2, 3, 5, 10, 14, 20, 30, 50, 75, 100, 150, 200, 300, 500, 700, 1000, 2000, 3000, 5000, 10000, 20000, 50000, 100000]
    f_He3ion = [
    [219, 438, 656, 1.09E+03, 2.19E+03, 4.61E+03, 1.72E+04, 3.01E+04, 4.75E+04, 8.05E+04, 1.01E+05, 9.25E+04, 6.74E+04, 5.14E+04, 4.27E+04, 4.11E+04, 4.00E+04, 4.02E+04, 4.08E+04, 4.12E+04, 4.56E+04, 5.12E+04, 6.12E+04, 7.14E+04],
    [219, 438, 657, 1.09E+03, 2.19E+03, 2.56E+03, 1.74E+03, 1.44E+03, 2.88E+03, 1.75E+04, 4.84E+04, 1.10E+05, 7.29E+04, 5.33E+04, 4.49E+04, 4.60E+04, 4.47E+04, 4.80E+04, 5.01E+04, 5.17E+04, 6.26E+04, 6.10E+04, 8.14E+04, 1.01E+05],
    [141, 281, 419, 689, 1.82E+03, 2.81E+03, 5.46E+03, 9.86E+03, 1.78E+04, 3.00E+04, 4.55E+04, 6.95E+04, 7.01E+04, 5.25E+04, 4.27E+04, 4.19E+04, 4.09E+04, 4.31E+04, 4.50E+04, 4.76E+04, 5.73E+04, 7.10E+04, 9.67E+04, 1.24E+05]
    ]


    E_all = [E_photon, E_electron, E_positron, E_neutron, E_proton, E_negmuon, E_posmuon, E_negpion, E_pospion, E_He3ion]
    f_all = [f_photon, f_electron, f_positron, f_neutron, f_proton, f_negmuon, f_posmuon, f_negpion, f_pospion, f_He3ion]

    pi = find(particle, pars_list)
    if particle in ['photon','neutron','proton']:
        gi = find(geometry, geo_list_all)
    else:
        gi = find(geometry, geo_list_short)

    E_list = E_all[pi]
    f_list = f_all[pi][gi]

    # Interpolate f given E
    if E in E_list:
        f = f_list[find(E,E_list)]
    else:
        if not extrapolation_on and (E < E_list[0] or E > E_list[-1]):  # E is outside of bounds and extrapolation is off
            if E < E_list[0]:
                f = 0   # assume negligibly low energy particle
            if E > E_list[-1]:
                f = f_list[-1]  # just set equal to max energy particle's coefficient
        else:
            if E < E_list[0]:
                E_list = [0] + E_list
                f_list = [0] + f_list
                interp_scale = 'linear'

            if interp_scale=='log':
                cs = interp1d(np.log10(np.array(E_list)),np.log10(np.array(f_list)), kind=interp_type,fill_value='extrapolate')
                f = 10**cs(np.log10(E))
            else:
                cs = interp1d(np.array(E_list),np.array(f_list), kind=interp_type,fill_value='extrapolate')
                f = cs(E)

            # for sake of sanity, return zero for values quite below minimum coefficients
            if f < 1e-4:
                f = 0.0


        #if interp_type=='cubic':
        #    if interp_scale=='log':
        #        cs = interp1d(np.log10(np.array(E_list)),np.log10(np.array(f_list)), kind='cubic',fill_value='extrapolate')
        #        f = 10**cs(np.log10(E))
        #    else:
        #        cs = interp1d(np.array(E_list),np.array(f_list), kind='cubic',fill_value='extrapolate')
        #        f = cs(E)
        #else:
        #    if interp_scale=='log':
        #        f = 10**np.interp(np.log10(E),np.log10(np.array(E_list)),np.log10(np.array(f_list)))
        #    else:
        #        f = np.interp(E,np.array(E_list),np.array(f_list))

        #if interp_type=='cubic':
        #    if interp_scale=='log':
        #        cs = lagrange(np.log10(np.array(E_list)),np.log10(np.array(f_list)))
        #        f = 10**cs(np.log10(E))
        #    else:
        #        cs = lagrange(np.array(E_list),np.array(f_list))
        #        f = cs(E)
        #if interp_type=='cubic':
        #    if interp_scale=='log':
        #        cs = CubicSpline(np.log10(np.array(E_list)),np.log10(np.array(f_list)))
        #        f = 10**cs(np.log10(E))
        #    else:
        #        cs = CubicSpline(np.array(E_list),np.array(f_list))
        #        f = cs(E)

    return f


def merge_dump_file_pickles(dump_filepath_list, merged_dump_base_filepath='merged_dump', 
                            delete_pre_merge_pickles=False, compress_pickles_with_lzma=True):
    '''
    Description:
        Merge the pickle files (namedtuple lists and/or PandasDataFrames) belonging to numerous PHITS dump output files.

    Inputs:
        - `dump_filepath_list` = list of Path objects or strings denoting filepaths to the PHITS dump files 
                 (e.g., "*_dmp.out") to be merged.  Note that all dump files must be structured in the same way in 
                 terms of number of columns and their meanings.
        - `merged_dump_base_filepath` = (D=`os.cwd()+'merged_dump'`) Path object or string designating the base file 
                 path name to be used for the merged pickle files, 
                 which will take the form `merged_dump_base_filepath` + "_namedtuple_list.pickle[.xz]" and/or 
                 "_Pandas_df.pickle[.xz]" (the ".xz" being contingent on LZMA usage via `compress_pickles_with_lzma`).
        - `delete_pre_merge_pickles` = (optional, D=`False`) Boolean designating whether the numerous pickle files to be 
                 merged into one should be deleted after the merge is successful. (Note: File deletion will only succeed 
                 if you have permissions to delete these files on your system.)
        - `compress_pickles_with_lzma` = (optional, D=`True`) Boolean designating whether the pickle files to be saved of
                 the merged namedtuple lists and/or the Pandas DataFrames 
                 will be compressed with [LZMA compression](https://docs.python.org/3/library/lzma.html) (included within
                 the baseline [Python standard library](https://docs.python.org/3/library/index.html)); if so, the file
                 extension of the saved pickle file(s) will be `'.pickle.xz'` instead of just `'.pickle'`.
                 A *.pickle.xz file can then be opened (after importing `pickle` and `lzma`) as:
                 `with lzma.open(path_to_picklexz_file, 'rb') as file: dump_data_list = pickle.load(file)`.
                 While compression will notably slow down the file-saving process, owing to the often large size of
                 PHITS dump files the additional reduction in file size (often around a factor of 5) is generally preferred.

    Notes:
        For each dump file provided, this function will check for the existence of the same filename/path but with
        "_namedtuple_list.pickle[.xz]" and "_Pandas_df.pickle[.xz]" at the end.  If neither file for a provided 
        dump file is found but the dump file itself is found to exist, `parse_tally_dump_file()` will be called on it
        with default settings except `save_namedtuple_list=True` and  `save_Pandas_dataframe=True`.

    Outputs:
        - `merge_success` = Boolean designating whether merging succeeded or not.
    '''
    import lzma
    import pickle
    merge_success = False
    if compress_pickles_with_lzma:
        compression_file_extension = '.xz'
    else:
        compression_file_extension = ''
    # Scan to see what files are available
    namedtuple_list_paths = [] 
    pandads_DF_paths = []
    for f in dump_filepath_list:
        fp = Path(f)
        filebasename = fp.name
        if filebasename[-4:]=='.out':
            filebasename = filebasename[:-4]
        nt_found, pd_found = False, False
        nt_path = Path(fp.parent, filebasename + '_namedtuple_list.pickle.xz')
        if nt_path.is_file(): 
            nt_found = True 
        else:
            nt_path = Path(fp.parent, filebasename + '_namedtuple_list.pickle')
            if nt_path.is_file(): 
                nt_found = True
        pd_path = Path(fp.parent, filebasename + '_Pandas_df.pickle.xz')
        if pd_path.is_file():
            pd_found = True
        else:
            pd_path = Path(fp.parent, filebasename + '_Pandas_df.pickle')
            if pd_path.is_file():
                pd_found = True
        if not nt_found and not pd_found and fp.is_file():
            parse_tally_dump_file(fp,return_namedtuple_list=False, return_Pandas_dataframe=False, save_namedtuple_list=True, save_Pandas_dataframe=True)
            nt_path = Path(fp.parent, filebasename + '_namedtuple_list.pickle.xz')
            pd_path = Path(fp.parent, filebasename + '_Pandas_df.pickle.xz')
            nt_found, pd_found = True, True
        if nt_found: namedtuple_list_paths.append(nt_path)
        if pd_found: pandads_DF_paths.append(pd_path)

    # Merge namedtuple lists
    if len(namedtuple_list_paths) > 1:
        from numpy.lib.recfunctions import stack_arrays
        if len(namedtuple_list_paths) != len(dump_filepath_list):
            print('WARNING: While multiple "_namedtuple_list.pickle[.xz]" files were found, some were missing from provided dump file list.')
        for i, f in enumerate(namedtuple_list_paths):
            if f.suffixes[-1] == '.xz':
                with lzma.open(f, 'rb') as file: 
                    dump_data_list = pickle.load(file)
            else:
                with open(f, 'rb') as file:
                    dump_data_list = pickle.load(file)
            if i==0:
                a = dump_data_list 
                continue
            b = dump_data_list
            records_np_array = stack_arrays((a, b), asrecarray=True, usemask=False)
            a = records_np_array
        del a, b  # release memory for this, as it can be very, very big
        path_to_dump_file = Path(merged_dump_base_filepath)
        pickle_path = Path(path_to_dump_file.parent,
                           path_to_dump_file.stem + '_namedtuple_list.pickle' + compression_file_extension)
        if compress_pickles_with_lzma:
            with lzma.open(pickle_path, 'wb') as handle:
                pickle.dump(records_np_array, handle, protocol=pickle.HIGHEST_PROTOCOL)
        else:
            with open(pickle_path, 'wb') as handle:
                pickle.dump(records_np_array, handle, protocol=pickle.HIGHEST_PROTOCOL)
        print('Pickle file written:', pickle_path, '\n')
        merge_success = True
        del records_np_array  # release memory for this, as it can be very, very big
        if delete_pre_merge_pickles:
            for f in namedtuple_list_paths:
                try:
                    f.unlink()
                    print('\tPickle file deleted:', f)
                except:
                    pass
            print()

    # Merge Pandas DataFrames
    if len(pandads_DF_paths) > 1:
        import pandas as pd
        if len(pandads_DF_paths) != len(dump_filepath_list):
            print('WARNING: While multiple "_Pandas_df.pickle[.xz]" files were found, some were missing from provided dump file list.')
        dfs_to_concat = []
        for i, f in enumerate(pandads_DF_paths):
            if f.suffixes[-1] == '.xz':
                with lzma.open(f, 'rb') as file: 
                    dump_dataframe = pickle.load(file)
            else:
                with open(f, 'rb') as file:
                    dump_dataframe = pickle.load(file)
            dfs_to_concat.append(dump_dataframe)
        combined_df = pd.concat(dfs_to_concat, ignore_index=True)
        del dfs_to_concat  # release memory for this, as it can be very, very big
        path_to_dump_file = Path(merged_dump_base_filepath)
        pickle_path = Path(path_to_dump_file.parent,
                           path_to_dump_file.stem + '_Pandas_df.pickle' + compression_file_extension)
        combined_df.to_pickle(pickle_path)
        print('Pickle file written:', pickle_path, '\n')
        merge_success = True
        del combined_df  # release memory for this, as it can be very, very big
        if delete_pre_merge_pickles:
            for f in pandads_DF_paths:
                try:
                    f.unlink()
                    print('\tPickle file deleted:', f)
                except:
                    pass
            print()

    return merge_success



def ZZZAAAM_to_nuclide_plain_str(ZZZAAAM,include_Z=False,ZZZAAA=False,delimiter='-'):
    '''
    Description:
        Converts a plaintext string of a nuclide from an integer ZZZAAAM = 10000&ast;Z + 10&ast;A + M

    Dependencies:
        `Element_Z_to_Sym` (function within the "PHITS Tools" package)

    Input:
       - `ZZZAAAM` = integer equal to 10000&ast;Z + 10&ast;A + M, where M designates the metastable state (0=ground)
       - `include_Z` = Boolean denoting whether the Z number should be included in the output string (D=`False`)
       - `ZZZAAA` = Boolean denoting whether the input should be interpreted as a ZZZAAA value (1000Z+A) instead (D=`False`)
       - `delimiter` = string which will be used to separate elements of the output string (D=`-`)

    Output:
       - `nuc_str` = string describing the input nuclide formatted as [Z]-[Symbol]-[A][m]
    '''
    ZZZAAAM = int(ZZZAAAM)
    if ZZZAAA:
        ZZZAAAM = ZZZAAAM*10
    m = ZZZAAAM % 10
    A = (ZZZAAAM % 10000) // 10
    Z = ZZZAAAM // 10000
    symbol = Element_Z_to_Sym(Z)

    m_str = ''
    if m>0:
        m_str = 'm' + str(m)

    nuc_str = ''
    if include_Z:
        nuc_str += str(Z) + delimiter
    nuc_str += symbol + delimiter + str(A) + m_str

    return nuc_str


def nuclide_plain_str_to_latex_str(nuc_str,include_Z=False):
    '''
    Description:
        Converts a plaintext string of a nuclide to a LaTeX-formatted raw string
        Note: if you already have the Z, A, and isomeric state information determined, the "nuclide_to_Latex_form" function can be used instead

    Dependencies:
        - `Element_Z_to_Sym` (function within the "PHITS Tools" package) (only required if `include_Z = True`)

    Input:
        (required)

       - `nuc_str` = string to be converted; a huge variety of formats are supported, but they all must follow the following rules:
           + Isomeric/metastable state characters must always immediately follow the atomic mass characters.
               Isomeric state labels MUST either:
               - (1) be a single lower-case character OR
               - (2) begin with any non-numeric character and end with a number
           + Atomic mass numbers must be nonnegative integers OR the string `"nat"` (in which case no metastable states can be written)
           + Elemental symbols MUST begin with an upper-case character

    Input:
       (optional)

       - `include_Z` = `True`/`False` determining whether the nuclide's atomic number Z will be printed as a subscript beneath the atomic mass

    Output:
        - LaTeX-formatted raw string of nuclide
    '''
    tex_str = r''

    # remove unwanted characters from provided string
    delete_characters_list = [' ', '-', '_']
    for dc in delete_characters_list:
        nuc_str = nuc_str.replace(dc,'')

    # determine which characters are letters versus numbers
    isalpha_list = []
    isdigit_list = []
    for c in nuc_str:
        isalpha_list.append(c.isalpha())
        isdigit_list.append(c.isdigit())

    symbol = ''
    mass = ''
    isost = ''

    # string MUST begin with either mass number or elemental symbol
    if isdigit_list[0] or nuc_str[0:3]=='nat': # mass first
        mass_first = True
    else:
        mass_first = False

    if mass_first:
        if nuc_str[0:3]=='nat':
            mass = 'nat'
            ci = 3
        else:
            ci = 0
            while isdigit_list[ci]:
                mass += nuc_str[ci]
                ci += 1
            mass = str(int(mass)) # eliminate any extra leading zeros
            # encountered a non-numeric character, end of mass
            # now, determine if metastable state is listed or if element is listed next
            # first, check to see if any other numerals are in string
            lni = 0 # last numeral index
            for i in range(ci,len(nuc_str)):
                if isdigit_list[i]:
                    lni = i
            if lni != 0:
                # grab all characters between ci and last numeral as metastable state
                isost = nuc_str[ci:lni+1]
                ci = lni + 1
            else: # no more numerals in string, now check for single lower-case letter
                if isalpha_list[ci] and nuc_str[ci].islower():
                    isost = nuc_str[ci]
                    ci += 1

            # Now extract elemental symbol
            for i in range(ci,len(nuc_str)):
                if isalpha_list[i]:
                    symbol += nuc_str[i]

    else: # if elemental symbol is listed first
        if 'nat' in nuc_str:
            mass = 'nat'
            nuc_str = nuc_str.replace('nat','')

        ci = 0
        # Extract all characters before first number as the elemental symbol
        while nuc_str[ci].isalpha():
            symbol += nuc_str[ci]
            ci += 1

        # now, extract mass
        if mass != 'nat':
            while nuc_str[ci].isdigit():
                mass += nuc_str[ci]
                ci += 1
                if ci == len(nuc_str):
                    break

            # lastly, extract isomeric state, if present
            if ci != len(nuc_str):
                isost = nuc_str[ci:]

    # treating the cases of lowercase-specified particles (n, d, t, etc.)
    if symbol == '' and isost != '':
        symbol = isost
        isost = ''

    # Now assemble LaTeX string for nuclides
    if include_Z:
        if symbol == 'n':
            Z = 0
        elif symbol == 'p' or symbol == 'd' or symbol == 't':
            Z = 1
        else:
            Z = Element_Sym_to_Z(symbol)
        Z = str(int(Z))
        tex_str = r"$^{{{}{}}}_{{{}}}$".format(mass,isost,Z) + "{}".format(symbol)
    else:
        tex_str = r"$^{{{}{}}}$".format(mass,isost) + "{}".format(symbol)

    return tex_str


def Element_Z_to_Sym(Z):
    '''
    Description:
        Returns elemental symbol for a provided atomic number Z

    Inputs:
        - `Z` = atomic number

    Outputs:
        - `sym` = string of elemental symbol for element of atomic number Z
    '''
    elms = ["n ",\
            "H ","He","Li","Be","B ","C ","N ","O ","F ","Ne",\
            "Na","Mg","Al","Si","P ","S ","Cl","Ar","K ","Ca",\
            "Sc","Ti","V ","Cr","Mn","Fe","Co","Ni","Cu","Zn",\
            "Ga","Ge","As","Se","Br","Kr","Rb","Sr","Y ","Zr",\
            "Nb","Mo","Tc","Ru","Rh","Pd","Ag","Cd","In","Sn",\
            "Sb","Te","I ","Xe","Cs","Ba","La","Ce","Pr","Nd",\
            "Pm","Sm","Eu","Gd","Tb","Dy","Ho","Er","Tm","Yb",\
            "Lu","Hf","Ta","W ","Re","Os","Ir","Pt","Au","Hg",\
            "Tl","Pb","Bi","Po","At","Rn","Fr","Ra","Ac","Th",\
            "Pa","U ","Np","Pu","Am","Cm","Bk","Cf","Es","Fm",\
            "Md","No","Lr","Rf","Db","Sg","Bh","Hs","Mt","Ds",\
            "Rg","Cn","Nh","Fl","Mc","Lv","Ts","Og"]
    i = int(Z)
    if i < 0 or i > len(elms):
        print('Z={} is not valid, please select a number from 0 to 118 (inclusive).'.format(str(Z)))
        return None
    return elms[i].strip()

def Element_Sym_to_Z(sym):
    '''
    Description:
        Returns atomic number Z for a provided elemental symbol

    Dependencies:
        `find` (function within the "PHITS Tools" package)

    Inputs:
        - `sym` = string of elemental symbol for element of atomic number Z

    Outputs:
        - `Z` = atomic number
    '''
    elms = ["n ",\
            "H ","He","Li","Be","B ","C ","N ","O ","F ","Ne",\
            "Na","Mg","Al","Si","P ","S ","Cl","Ar","K ","Ca",\
            "Sc","Ti","V ","Cr","Mn","Fe","Co","Ni","Cu","Zn",\
            "Ga","Ge","As","Se","Br","Kr","Rb","Sr","Y ","Zr",\
            "Nb","Mo","Tc","Ru","Rh","Pd","Ag","Cd","In","Sn",\
            "Sb","Te","I ","Xe","Cs","Ba","La","Ce","Pr","Nd",\
            "Pm","Sm","Eu","Gd","Tb","Dy","Ho","Er","Tm","Yb",\
            "Lu","Hf","Ta","W ","Re","Os","Ir","Pt","Au","Hg",\
            "Tl","Pb","Bi","Po","At","Rn","Fr","Ra","Ac","Th",\
            "Pa","U ","Np","Pu","Am","Cm","Bk","Cf","Es","Fm",\
            "Md","No","Lr","Rf","Db","Sg","Bh","Hs","Mt","Ds",\
            "Rg","Cn","Nh","Fl","Mc","Lv","Ts","Og"]

    if len(sym.strip())>2:
        print('Please provide a valid elemental symbol (1 or 2 characters), {} is too long'.format(sym))
        return -1

    # handle exception for neutron first
    if sym.strip()=='XX':
        return 0

    # make sure string is formatted to match entries in elms list
    sym2 = sym.strip()
    if len(sym2)==1: sym2 += ' '
    sym2 = sym2[0].upper() + sym2[1].lower()

    Z = find(sym2,elms)

    if Z==None:
        print('Z could not be found for element "{}"; please make sure entry is correct.'.format(sym))
        return -1

    return Z

def kfcode_to_common_name(kf_code):
    '''
        Description:
            Converts an integer kf-code to plaintext string of a particle/nuclide

        Input:
           - `kf_code` = integer kf-code particle identification number (see PHITS manual Table 4.4)

        Output:
           - `par_nuc_str` = string either naming the particle or describing the input nuclide formatted as [Symbol]-[A]
        '''
    kf_code = int(kf_code)
    named_kf_codes =     [2212    ,2112     ,22      ,11        ,-11       ,211    ,111    ,-211   ,-13    ,13     ,321    ,311    ,-321   ]
    named_kf_code_strs = ['proton','neutron','photon','electron','positron','pion+','pion0','pion-','muon+','muon-','kaon+','kaon0','kaon-']
    if abs(kf_code) < 1000000:
        # specifically named particles
        if kf_code in named_kf_codes:
            i = find(kf_code,named_kf_codes)
            par_nuc_str = named_kf_code_strs[i]
        else:
            par_nuc_str = str(kf_code)
    else:
        A = kf_code % 1000000
        Z = (kf_code-A) / 1000000
        ZZZAAA = 1000*Z + A
        par_nuc_str = ZZZAAAM_to_nuclide_plain_str(ZZZAAA, ZZZAAA=True)

    return par_nuc_str

def is_number(n):
    '''
    Description:
        Determine if a string is that of a number or not.

    Inputs:
        - `n` = string to be tested

    Outputs:
        - `True` if value is a number (can be converted to float() without an error)
        - `False` otherwise
    '''
    try:
        float(n)
    except ValueError:
        return False
    return True

def find(target, myList):
    '''
    Description:
        Search for and return the index of the first occurance of a value in a list.

    Inputs:
        - `target` = value to be searched for
        - `myList` = list of values

    Output:
        - index of first instance of `target` in `myList`
    '''
    for i in range(len(myList)):
        if myList[i] == target:
            return i



def determine_PHITS_output_file_type(output_file):
    '''
    Description:
        Determine what kind of PHITS file is being hanlded (tally standard output, binary tally dump, ASCII tally dump, etc.)

    Inputs:
        - `output_file` = a file/filepath (string or Path object) to be judged

    Outputs:
        - `PHITS_file_type` = a dictionary of Booleans detailing what kind of file `output_file` is (and isn't) with
            the following keys (each with a value set to `True` or `False`):
            `'is_standard_tally_output'`, `'is_binary_tally_dump'`, `'is_ASCII_tally_dump'`,
            `'is_PHITS_input_file'`, `'is_file6_phitsout_file'`,
            `'is_unknown_file_type'`, and `'file_does_not_exist'`.  By default, all are set to `False` except for
            `'is_unknown_file_type'` which is `True` by default.

    Notes:
        - Dump files are identified with the presence of '_dmp' in their filename. Whether it is ASCII or binary is
             simply determined by attempting to read the first line of the file in a try/except statement.
        - Standard tally output files are identified by `[` being the very first character in the first line.
        - PHITS input files are identified by having a file extension in the following list (case insensitive): `['.inp','.in','.input','.i']`
        - 'phits.out' files (`file(6)` in the PHITS [Parameters] section) are identified by its first line consisting of
             11 spaces followed by 57 underscores.

    '''
    import re
    PHITS_file_type = {'is_standard_tally_output': False,
                       'is_binary_tally_dump': False,
                       'is_ASCII_tally_dump': False,
                       'is_PHITS_input_file': False,
                       'is_file6_phitsout_file': False,
                       'is_unknown_file_type': True,
                       'file_does_not_exist': False
                       }
    output_file = Path(output_file)
    if not output_file.is_file():
        print('Provided output file',output_file,'was determined to not be a file!')
        PHITS_file_type['is_unknown_file_type'] = False
        PHITS_file_type['file_does_not_exist'] = True
        return PHITS_file_type
    with open(output_file, 'r', encoding='utf-8') as f:
        try:
            first_line = f.readline().strip()
            first_line_lower_no_spaces = re.sub(r"\s+", "", first_line, flags=re.UNICODE).lower()
        except:  # triggered if encountering binary / non ASCII or UTF-8 file
            if '_dmp' in output_file.stem:
                PHITS_file_type['is_binary_tally_dump'] = True
                PHITS_file_type['is_unknown_file_type'] = False
                return PHITS_file_type
        if first_line_lower_no_spaces[:3] == '[t-':
            PHITS_file_type['is_standard_tally_output'] = True
            PHITS_file_type['is_unknown_file_type'] = False
        elif '_dmp' in output_file.stem:
            PHITS_file_type['is_ASCII_tally_dump'] = True
            PHITS_file_type['is_unknown_file_type'] = False
        elif output_file.suffix.lower() in ['.inp','.in','.input','.i']:
            PHITS_file_type['is_PHITS_input_file'] = True
            PHITS_file_type['is_unknown_file_type'] = False
        elif '_________________________________________________________' in first_line:
            PHITS_file_type['is_file6_phitsout_file'] = True
            PHITS_file_type['is_unknown_file_type'] = False
    return PHITS_file_type


def search_for_dump_parameters(output_file):
    '''
    Description:
        Try to determine the dump settings used for a dump file by searching for the same file without "_dmp" and parsing
        its header for the "dump = " line and subsequent line specifying the column ordering.

    Inputs:
        - `output_file` = a file/filepath (string or Path object) to be judged

    Outputs:
        - `dump_data_number` = value following "dump = " in the PHITS tally (integer from -20 to 20, excluding 0) (D=`None`)
        - `dump_data_sequence` = list of integers specifying the order and meaning of the dump file columns (D=`None`)
    '''
    dump_data_number, dump_data_sequence = None, None
    output_file = Path(output_file)
    output_file_suffixes = output_file.suffixes
    if len(output_file_suffixes) > 1 and is_number(output_file_suffixes[-1][1:]) and '_dmp' in output_file.stem: # MPI dump found
        origin_tally_file = Path(output_file.parent, output_file.stem.replace('_dmp', ''))
    else:
        origin_tally_file = Path(output_file.parent, output_file.stem.replace('_dmp','') + output_file.suffix)
    PHITS_file_type = determine_PHITS_output_file_type(origin_tally_file)
    if PHITS_file_type['file_does_not_exist']:
        print("Could not find this dump file's companion original standard tally output file",origin_tally_file)
        return dump_data_number, dump_data_sequence
    elif not PHITS_file_type['is_standard_tally_output']:
        print("Found dump file's suspected companion original standard tally output file, but it does not seem to actually be formatted as a standard tally output file",origin_tally_file)
        return dump_data_number, dump_data_sequence
    tally_header, tally_content = split_into_header_and_content(origin_tally_file)
    for li, line in enumerate(tally_header):
        if "dump =" in line:
            if line[0] == '#':  # commented line
                key, value = extract_data_from_header_line(line[1:])
            else:
                key, value = extract_data_from_header_line(line)
            dump_data_number = int(value)
            dump_data_sequence_str_list = tally_header[li+1].strip().split()
            dump_data_sequence = [int(i) for i in dump_data_sequence_str_list]
            break
    if dump_data_number == None and dump_data_sequence == None:
        print('Was unable to locate dump specification information in tally output file',origin_tally_file)
    return dump_data_number, dump_data_sequence



def split_into_header_and_content(output_file_path):
    '''
    Description:
        Initial parsing of a PHITS tally output file to isolate its header section (containing metadata) and main
        tally results "content" section for later processing.

    Inputs:
        - `output_file_path` = path to a PHITS tally output file

    Outputs:
        - `header` = list of lines belonging to the tally output's header section
        - `content` = list of lists of remaining lines after the tally output's header section; the top level list is
                broken into "blocks" ("newpage:"-separated) which are lists of lines belonging to each block/page.

    '''
    in_content = False
    header, content = [], [[]]
    with open(output_file_path, mode='rb') as f:
        for line in f:
            if b'\x00' in line:
                line = line.replace(b"\x00", b"")
            #line = line.decode()
            try: # skip lines with invalid characters in them...
                line = line.decode()
            except:
                continue
            #if "\x00" in line: line = line.replace("\x00", "")
            if '#newpage:' in line:
                in_content = True
                continue
            if in_content:
                if 'newpage:' in line:
                    content.append([])
                    continue
                content[-1].append(line.strip())
            else:
                header.append(line.strip())
    # add "footer" to peel off last bit of "content" section?
    if in_debug_mode: print(len(content), 'content blocks (pages) found in tally output')
    return header, content

def parse_tally_header(tally_header,tally_content):
    '''
    Description:
        Extracts metadata from PHITS tally output header (and some extra info from its contents section)

    Dependencies:
        - `extract_data_from_header_line` (function within the "PHITS tools" package)
        - `parse_group_string` (function within the "PHITS tools" package)

    Inputs:
        - `tally_header` = list of lines belonging to the tally output's header section
        - `tally_content` = list of lists of remaining lines after the tally output's header section; the top level list is
                broken into "blocks" ("newpage:"-separated) which are lists of lines belonging to each block/page.

    Outputs:
        - `meta` = Munch object / dictionary containing tally metadata

    '''
    prefer_to_munch_meta_dict = True
    if prefer_to_munch_meta_dict:
        try:
            from munch import Munch
            use_munch = True
        except:
            use_munch = False
    else:
        use_munch = False
    nlines = len(tally_header)
    tally_type = tally_header[0].replace(' ','').replace('off','')
    if '[' not in tally_type and ']' not in tally_type: # file is not PHITS tally output
        if 'htitle' in tally_type:
            tally_type = '[T-Dchain]'
        else:
            tally_type = 'UNKNOWN'
    if use_munch:
        meta = Munch({})
    else:
        meta = {}
    meta['tally_type'] = tally_type
    unsupported_tally_types = ['[T-WWG]', '[T-WWBG]', '[T-Volume]', '[T-Userdefined]', '[T-Gshow]', '[T-Rshow]',
                               '[T-3Dshow]', '[T-4Dtrack]', '[T-Dchain]', 'UNKNOWN']
    if tally_type in unsupported_tally_types:
        return meta
    # Initialize variables for possible array
    mesh_types = ['e','t','x','y','z','r','a','l']
    for m in mesh_types: meta['n'+m] = None
    meta['reg'] = None
    meta['part'] = None
    meta['npart'] = None
    meta['nc'] = None
    meta['samepage'] = 'part'
    found_mesh_kinds = []

    reading_axis_data = False
    reading_regions = False
    in_exceptional_mesh_kind = False
    for li, line in enumerate(tally_header):
        #if line[0]=='#': # commented line
        if 'data =' in line: # data section to parse
            reading_axis_data = True
            n_values_to_read = meta['n'+current_data_mesh_kind] + 1
            remaining_n_values_to_read = n_values_to_read
            data_values = []
            in_exceptional_mesh_kind = False
            #print('read ',n_values_to_read,current_data_mesh_kind,' values')
            continue
        elif '=' in line:
            if line[0] == '#':  # commented line
                key, value = extract_data_from_header_line(line[1:])
                if key=='file' and key in meta and '_dmp' in value: continue # do not overwrite existing file parameter with dump file
            else:
                key, value = extract_data_from_header_line(line)
            if in_exceptional_mesh_kind:
                if key[0]=='e':
                    key = current_data_mesh_kind + key[1:]
                elif key=='ne':
                    key = 'n' + current_data_mesh_kind
            meta[key] = value
            if (tally_type=='[T-Yield]' or tally_type=='[T-Track]') and key=='axis' and value=='dchain':
                # Unsupported tally output!
                return meta

            if 'type' in key:
                current_data_mesh_kind = key.replace('-type','')
                if current_data_mesh_kind == 'se': current_data_mesh_kind = 'e'
                current_data_mesh_type = value
                found_mesh_kinds.append(current_data_mesh_kind)
                if current_data_mesh_kind in ['e1','e2']:
                    in_exceptional_mesh_kind = True
                #print(current_data_mesh_kind,current_data_mesh_type)
            if key=='part':
                part_groups = parse_group_string(str(value))
                kf_groups = parse_group_string(tally_header[li + 1].split(':')[1])
                if meta['npart'] == None: # first instance of "part"
                    meta['part_groups'] = part_groups
                    meta['kf_groups'] = kf_groups
                    meta['npart'] = len(part_groups)
                    meta['part_serial_groups'] = ['p'+str(gi+1)+'-group' for gi in range(len(part_groups))]
                else: # an additional occurance of part?
                    if 'multiplier' not in tally_header[li - 1]: # the multiplier can also be followed by an erroneous "part" specification
                        for pi,pg in enumerate(part_groups):
                            if pg not in meta['part_groups']:
                                meta['part_groups'] += [pg]
                                meta['kf_groups'] += kf_groups[pi]
                                meta['npart'] += 1
                                meta['part_serial_groups'] += ['p' + str(pi + 1) + '-group']
            if key=='reg':
                if meta['tally_type']=='[T-Cross]':
                    num_regs = value
                    meta['num_reg_groups'] = num_regs
                    meta['reg_groups'] = []
                    # manually read in reg groups
                    li_start = li+2
                    li_stop = li_start + num_regs
                    for lii in range(li_start,li_stop):
                        non, rfrom, rto, area = tally_header[lii].split()
                        meta['reg_groups'].append(rfrom+' - '+rto)
                else:
                    reg_groups = parse_group_string(str(value))
                    eli = 0 # extra line index
                    if '=' not in tally_header[eli+li+1] and 'volume' not in tally_header[eli+li+1]: # reg specification continues to next line
                        while '=' not in tally_header[eli+li+1] and 'volume' not in tally_header[eli+li+1]:
                            reg_groups += parse_group_string(tally_header[eli+li+1].strip())
                            eli += 1
                    if 'all' in reg_groups and 'volume' in tally_header[li+1] and '=' not in tally_header[eli+li+1]:
                        # parse table of regions...
                        found_reg_grps = []
                        meta['reg_groups_inputted'] = reg_groups
                        reg_lines = tally_header[li+3:]
                        for reg_line in reg_lines:
                            if '=' in reg_line: break
                            line_parts = reg_line.split('#')
                            if len(line_parts) >= 2:
                                found_reg_grps.append(line_parts[1].strip())
                            else:
                                found_reg_grps.append(line_parts[0].split()[1])
                        meta['reg_groups'] = found_reg_grps
                        meta['num_reg_groups'] = len(found_reg_grps)
                    else:
                        meta['reg_groups'] = reg_groups
                        meta['num_reg_groups'] = len(reg_groups)
            if key == 'point':
                num_regs = value
                meta['point_detectors'] = {'non':[], 'x':[], 'y':[], 'z':[], 'r0':[]} # [T-Point] points
                li_start = li + 2
                li_stop = li_start + num_regs
                for lii in range(li_start, li_stop):
                    non, tppx, tppy, tppz, tppr0 = tally_header[lii].split()
                    meta['point_detectors']['non'].append(non)
                    meta['point_detectors']['x'].append(tppx)
                    meta['point_detectors']['y'].append(tppy)
                    meta['point_detectors']['z'].append(tppz)
                    meta['point_detectors']['r0'].append(tppr0)
            if key == 'ring':
                num_regs = value
                meta['point_detectors'] = {'non':[], 'axis':[], 'ar':[], 'rr':[], 'r0':[]} # [T-Point] points
                li_start = li + 2
                li_stop = li_start + num_regs
                for lii in range(li_start, li_stop):
                    non, tppx, tppy, tppz, tppr0 = tally_header[lii].split()
                    meta['point_detectors']['non'].append(non)
                    meta['point_detectors']['axis'].append(tppx)
                    meta['point_detectors']['ar'].append(tppy)
                    meta['point_detectors']['rr'].append(tppz)
                    meta['point_detectors']['r0'].append(tppr0)
        elif reading_axis_data:
            values = line.replace('#','').strip().split()
            for val in values:
                data_values.append(float(val))
                remaining_n_values_to_read += -1
            if remaining_n_values_to_read <= 0:
                reading_axis_data = False
                data_values = np.array(data_values)
                meta[current_data_mesh_kind+'-mesh_bin_edges'] = data_values
                meta[current_data_mesh_kind+'-mesh_bin_mids'] = 0.5*(data_values[1:]+data_values[:-1])
                #meta[current_data_mesh_kind+'-mesh_bin_mids_log'] = np.sqrt(data_values[1:]*data_values[:-1])
                # generate log-centered bin mids
                bin_mids_log = []
                for i in range(len(data_values)-1):
                    if data_values[i+1]<=0 or data_values[i]<=0: # if one or both edges <= 0
                        if data_values[i+1]<0 and data_values[i]<0: # both values are negative
                            bin_mids_log.append(-1*np.sqrt(data_values[i]*data_values[i+1]))
                        elif data_values[i+1]==0 or data_values[i]==0: # one value is zero
                            # use linear center instead...
                            bin_mids_log.append(0.5*(data_values[i]+data_values[i+1]))
                        elif data_values[i+1]<0 or data_values[i]<0: # bin straddles zero
                            # use linear center instead...
                            bin_mids_log.append(0.5*(data_values[i]+data_values[i+1]))
                        else:
                            print('unknown binning encountered, skipping generation of log-scale bin mids for '+current_data_mesh_kind+'-mesh')
                            break
                    else:
                        bin_mids_log.append(np.sqrt(data_values[i]*data_values[i+1]))
                meta[current_data_mesh_kind+'-mesh_bin_mids_log'] = np.array(bin_mids_log)
            continue
        else:
            continue

    meta['found_mesh_kinds'] = found_mesh_kinds

    if meta['tally_type']=='[T-Cross]':
        if meta['mesh']=='xyz':
            if 'enclos' in meta and meta['enclos']==1:
                pass # total items remains nx*ny*nz
            else:
                meta['nz_original'] = meta['nz']
                meta['nz'] += 1 # zmesh surfaces are scored, making array nx*ny*(nz+1)
        elif meta['mesh']=='r-z':
            if 'enclos' in meta and meta['enclos']==1:
                pass # total items remains nr*nz
            else:
                # Current solution addresses this by expanding the ierr axis
                meta['nr_original'] = meta['nr']
                meta['nz_original'] = meta['nz']
                meta['nr'] = meta['nr'] + 1
                meta['nz'] = meta['nz'] + 1
                # OLD SOLUTION IMPLEMENTED IS BELOW
                # max total num of pages = nrsurf*nz + nzsurf*nr = (nr+1)*nz + nr*(nz+1) = 2*nr*nz + nr + nz
                # if one radius is 0, this becomes = nr*nz + nr*(nz+1) = 2*nr*nz + nr
                # Solution used here:
                # use ir to iterate nr, use iy to iterate nrsurf, use iz to iterate nz, use ic to iterate nzsurf
                # since only rsurf*z [iy,iz] and r*zsurf [ir,ic] pairs exist, when one pair is being written
                # the other will be [-1,-1], hence the dimensions for the array are increased by an extra 1 to prevent overlap
                #meta['nr_original'] = meta['nr']
                #meta['nz_original'] = meta['nz']
                #meta['ny_original'] = meta['ny']
                ##meta['nc_original'] = meta['nc']
                #meta['ny'] = meta['nr'] + 1 + 1
                #meta['nc'] = meta['nz'] + 1 + 1
                #meta['nr'] = meta['nr'] + 1
                #meta['nz'] = meta['nz'] + 1

    if meta['tally_type'] == '[T-Point]':
        if 'mesh' not in meta:
            if 'point' in meta:
                meta['mesh'] = 'point'
                meta['nreg'] = meta['point']
            elif 'ring' in meta:
                meta['mesh'] = 'ring'
                meta['nreg'] = meta['ring']


    axes_1D = ['eng','reg','x','y','z','r','t','cos','the','mass','charge','let','tet','eng1','eng2','sed','rad','deg','act']
    axes_2D = ['xy','yz','zx','rz','chart','dchain','t-eng','eng-t','t-e1','e1-t','t-e2','e2-t','e12','e21','xz','yx','zy','zr']

    axes_ital_1D = [3,   0,  0,  1,  2,  0,  4,    5,    5,     8,       8,    6,    0,     3,     8,    3,    5,    5,   8]
    axes_ital_2D = [ [0,1],[1,2],[2,0],[0,2],[None,None],[None,None],[4,3],[3,4],[4,3],[3,4],[4,8],[8,4],[3,8],[8,3],[0,2],[1,0],[2,1],[2,0]]


    if meta['axis'] in axes_1D:
        meta['axis_dimensions'] = 1
        meta['axis_index_of_tally_array'] = axes_ital_1D[axes_1D.index(meta['axis'])]
    elif meta['axis'] in axes_2D:
        meta['axis_dimensions'] = 2
        meta['axis_index_of_tally_array'] = axes_ital_2D[axes_2D.index(meta['axis'])]
    else:
        print("WARNING: axis value of ",meta['axis']," is not in list of known/registered values")
        meta['axis_dimensions'] = None
        meta['axis_index_of_tally_array'] = None




    # Now extract portion of metadata only available from tally content

    if meta['mesh'] == 'reg' or meta['mesh'] == 'tet':
        num, reg, vol = [], [], []
        if meta['axis']=='reg' or meta['axis']=='tet':  # get number of regions and region data from first block of tally content
            outblock = tally_content[0]
            in_reg_list = False
            for line in outblock:
                if '#' in line and ' num ' in line:
                    cols = line[1:].split()
                    #print(cols)
                    in_reg_list = True
                    continue
                if len(line.split()) == 0 or '{' in line:
                    in_reg_list = False
                if in_reg_list:
                    vals = line.split()
                    if meta['tally_type'] == '[T-Cross]':
                        num.append(vals[0])
                        reg.append(vals[0])
                        vol.append(vals[1])
                    else:
                        num.append(vals[0])
                        reg.append(vals[1])
                        vol.append(vals[2])
        else: # scan output for region numbers:
            regcount = 0
            for outblock in tally_content:
                for line in outblock:
                    if 'reg =' in line or 'reg  =' in line:
                        eq_strs = split_str_of_equalities(line[1:])
                        reg_eq_str = ''
                        for eqsi in eq_strs:
                            if 'reg' in eqsi:
                                reg_eq_str = eqsi
                                break
                        regnum = reg_eq_str.split('=')[1].strip()
                        #regnum = line.strip().split('reg =')[1].strip().replace("'",'')
                        if regnum not in reg:
                            regcount += 1
                            num.append(regcount)
                            reg.append(regnum)
                            vol.append(None)
                        continue
        if meta['mesh'] == 'reg':
            meta['reg_serial_num'] = num
            meta['reg_num'] = reg
            if meta['tally_type'] == '[T-Cross]':
                meta['reg_area'] = vol
            else:
                meta['reg_volume'] = vol
            meta['nreg'] = len(reg)
        elif meta['mesh'] == 'tet':
            meta['tet_serial_num'] = num
            meta['tet_num'] = reg
            meta['reg_num'] = reg
            #meta['tet_volume'] = vol
            if meta['tally_type'] == '[T-Cross]':
                meta['tet_area'] = vol
            else:
                meta['tet_volume'] = vol
            meta['ntet'] = len(reg)

        #if meta['tally_type'] == '[T-Cross]':
        #    meta['reg_groups'] = reg



    elif meta['mesh'] == 'tet':
        num, reg, vol = [], [], []
        if meta['axis'] == 'tet':
            pass
        else:
            pass
        print('mesh=tet has not been tested!')
        meta['ntet'] = 0

    axis1_label = ''
    axis2_label = ''
    value_label = ''
    hc_passed = False # passed colorbar definition line
    outblock = tally_content[0]
    for line in outblock:
        if len(line) == 0: continue
        if line[:2] == 'x:':
            axis1_label = line[2:].strip()
        if line[:2] == 'y:':
            if meta['axis_dimensions'] == 1:
                value_label = line[2:].strip()
                #break
            elif meta['axis_dimensions'] == 2:
                if hc_passed: # second instance of y:
                    value_label = line[2:].strip()
                    break
                else: # first instance of y:
                    axis2_label = line[2:].strip()
                    hc_passed = True
        #if line[:3] == 'hc:':
        #    hc_passed = True
        h_line_str = ''
        if line[0] == 'h' and (line[1] == ':' or line[2] == ':'):
            if meta['axis_dimensions'] == 1:
                ndatacol = line.count(' y')
                if ndatacol != 1:  # multiple columns are present "samepage"
                    # get first string with y
                    col_groups = parse_group_string(line)
                    i_first_y = next((i for i,v in enumerate(col_groups) if v[0]=='y'), None) # index of first column with "y"
                    first_data_col_header = col_groups[i_first_y][2:]
                    for m in mesh_types:
                        if first_data_col_header[0] == m:
                            if m == 'e':
                                meta['samepage'] = 'eng'
                            elif m == 'r':
                                if first_data_col_header[:3] == 'reg':
                                    meta['samepage'] = 'reg'
                                else:
                                    meta['samepage'] = m
                            elif m == 'l':
                                meta['samepage'] = 'let'
                            elif m == 'a':
                                if first_data_col_header[:3] not in ['all','alp']:
                                    meta['samepage'] = 'the' # or cos
                            else:
                                meta['samepage'] = m
                    if meta['samepage'] == 'part':  # still is default value
                        # double check to see if it could be region numbers vs particle names
                        if ndatacol != meta['npart']:
                            if 'num_reg_groups' in meta and ndatacol == meta['num_reg_groups']:
                                meta['samepage'] = 'reg'
                            else:
                                print('"samepage" was not correctly identified; needs to be implemented')
                    if meta['samepage'] == 'reg':
                        hcols = parse_group_string(line[3:])
                        num, reg, vol = [], [], []
                        reg_ser_num = 1
                        for hcol in hcols:
                            if hcol[0] == 'y':
                                num.append(reg_ser_num)
                                reg_ser_num += 1
                                reg.append(hcol.split(')')[0].replace('y(reg',''))
                                vol.append(None)
                        meta['reg_serial_num'] = num
                        meta['reg_num'] = reg
                        meta['reg_volume'] = vol
                        meta['nreg'] = len(reg)

                break
    meta['axis1_label'] = axis1_label
    meta['axis2_label'] = axis2_label
    meta['value_label'] = value_label

    # Now do any final overrides for specific tallies / circumstances

    if meta['tally_type'] == '[T-Deposit2]':
        meta['nreg'] = 1
        meta['reg_serial_num'] = [1]
        meta['reg_num'] = ['1']
        meta['reg_volume'] = [None]
        if meta['num_reg_groups'] > 1:
            meta['num_reg_groups'] = 1
            meta['reg_groups'] = [meta['reg_groups'][0] + ' ' + meta['reg_groups'][1]]

    if meta['tally_type'] == '[T-Heat]':
        if 'npart' not in meta or meta['npart'] == None: meta['npart'] = 1
        if 'part_groups' not in meta: meta['part_groups'] = ['all']

    return meta

def parse_tally_content(tdata,meta,tally_blocks,is_err_in_separate_file,err_mode=False):
    '''
    Description:
        Parses the PHITS tally output content section and extract its results

    Dependencies:
        - `split_str_of_equalities` (function within the "PHITS tools" package)
        - `parse_group_string` (function within the "PHITS tools" package)
        - `data_row_to_num_list` (function within the "PHITS tools" package)

    Inputs:
        - `tdata` = 10-dimensional NumPy array of zeros of correct size to hold tally output/results
        - `meta` = Munch object / dictionary containing tally metadata
        - `tally_blocks` = blocks of tally output as outputted by the `split_into_header_and_content` function
        - `is_err_in_separate_file` = Boolean denoting whether the tally's relative errors are located in a separate file
        - `err_mode` = Boolean (D=`False`) used for manually forcing all read values to be regarded as relative uncertainties
                as is necessary when processing dedicated *_err files.

    Outputs:
        - `tdata` = updated `tdata` array containing read/extracted tally results

    '''
    global ir, iy, iz, ie, it, ia, il, ip, ic, ierr
    global ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max, ierr_max
    ierr = 0
    if is_err_in_separate_file and err_mode:
        ierr = 1

    mesh_kind_chars = ['e', 't', 'x', 'y', 'z', 'r', 'a', 'l']
    mesh_kind_iax = [3, 4, 0, 1, 2, 0, 5, 6]
    tdata_ivar_strs = ['ir', 'iy', 'iz', 'ie', 'it', 'ia', 'il', 'ip', 'ic']
    ir, iy, iz, ie, it, ia, il, ip, ic = 0, 0, 0, 0, 0, 0, 0, 0, 0

    ignored_eq_strs = ['axis','axs','ar','rr','m jm','Z','cmax nmax']
    replace_eq_strs_dict = {'ang':'a'}
    
    ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max, ierr_max = np.shape(tdata)

    axes_1D = ['eng', 'reg', 'x', 'y', 'z', 'r', 't', 'cos', 'the', 'mass', 'charge', 'let', 'tet', 'eng1', 'eng2',
               'sed', 'rad', 'deg', 'act']
    axes_2D = ['xy', 'yz', 'zx', 'rz', 'chart', 'dchain',
               't-eng', 'eng-t', 't-e1', 'e1-t', 't-e2', 'e2-t',
               'e12', 'e21', 'xz', 'yx', 'zy', 'zr']

    axes_ital_1D = [3, 0, 0, 1, 2, 0, 4, 5, 5, 8, 8, 6, 0, 3, 8,
                    3, 5, 5, 8]
    axes_ital_2D = [[0, 1], [1, 2], [2, 0], [0, 2], [None, None], [None, None],
                    [4, 3], [3, 4], [4, 3], [3, 4], [4, 8], [8, 4],
                    [3, 8], [8, 3], [0, 2], [1, 0], [2, 1], [2, 0]]

    ierr_mod = 0 # add to ierr for weird [T-Cross], mesh=r-z, enclos=0 case

    banked_uninterpreted_lines = [] # store lines with equalities that may be useful but are skipped owing to being a bit exceptional
    i_metastable = 0
    ZZZAAAM_list = []

    if meta['axis_dimensions']==1:
        for bi, block in enumerate(tally_blocks):
            hli, fli = 0,0
            ierr_mod = 0
            hli_found = False
            for li, line in enumerate(block):
                if len(line) == 0: continue
                if line[:2].lower() == 'h:':  # start of data is here
                    hli = li
                    hli_found = True
                    continue
                if hli_found and (line[:12] == '#   sum over' or line[:7] == '#   sum' or line[:5] == '#----' or line[:12] == '#   overflow' or (len(block[li-1]) == 0 and hli != 0 and li>hli+2) or "'" in line or '{' in line):
                    fli = li
                    if (len(block[li-1]) == 0 and hli != 0 and li>hli+2): fli = li - 1 # triggered by blank line after data
                    #if "'" in line or '{' in line:
                    #    fli = li-1
                    break

            data_header = block[:hli]
            data_table = block[hli:fli]
            data_footer = block[fli:]

            if bi == len(tally_blocks) - 1:
                ffli = len(data_footer)
                for li, line in enumerate(data_footer):
                    if line[:37] == '# Information for Restart Calculation':
                        ffli = li
                        break
                data_footer = data_footer[:ffli]

            # print(data_header)
            #print(data_table)
            # print(data_footer)

            hash_line_already_evaluated = False

            # try to get relevant indices data from header and footer blocks
            for li, line in enumerate(data_header+data_footer):
                if len(line) == 0: continue

                if '=' in line and (line[0] == "'" or (line[0] == "#" and ('no.' in line or 'i' in line or 'reg' in line or 'part' in line))):
                    if line[0] == "#":
                        hash_line_already_evaluated = True
                    elif line[0] == "'" and hash_line_already_evaluated:
                        if meta['samepage'] == 'part':
                            continue  # '-starting lines tend to have more problematic formatting, best skipped if possible
                        elif meta['npart'] == 1:
                            continue  # can still skip if only one particle group tallied
                        else:
                            pass  # but this needs to be parsed if not using samepage = part and npart > 1
                    parts = split_str_of_equalities(line)
                    #print(line)
                    for part in parts:
                        mesh_char = part.split('=')[0].strip().replace('i','')
                        #print(mesh_char)
                        if mesh_char == 'no.':
                            if '***' in part and bi<998:
                                break # this is a bugged line (or "no." is 4+ digits, which is fine)
                            continue
                        elif mesh_char == 'part.' or mesh_char == 'partcle' or mesh_char == 'part':
                            part_grp_name = part.split('=')[1].strip()
                            if part_grp_name in meta['part_groups']:
                                ip = (meta['part_groups']).index(part_grp_name)
                            elif part_grp_name in meta['part_serial_groups']:
                                ip = (meta['part_serial_groups']).index(part_grp_name)
                            else:
                                raise ValueError('ERROR! Particle "'+part_grp_name+'" could not be identified.')
                        elif mesh_char == 'reg':
                            regnum = part.split('=')[1].strip()
                            ir = (meta['reg_num']).index(regnum)
                        elif mesh_char == 'pont' or mesh_char == 'rng': # [T-Point]
                            value_str = part.split('=')[1].strip()
                            ir = int(value_str) - 1
                        elif mesh_char == 'e1': # [T-Deposit2]
                            value_str = part.split('=')[1].strip()
                            ie = int(value_str) - 1
                        elif mesh_char == 'e2': # [T-Deposit2]
                            value_str = part.split('=')[1].strip()
                            ic = int(value_str) - 1
                        elif mesh_char in mesh_kind_chars or mesh_char in replace_eq_strs_dict:
                            if mesh_char in replace_eq_strs_dict:
                                mesh_char = replace_eq_strs_dict[mesh_char]
                            if 'i'+mesh_char not in part: continue # only looking for indices for meshes, not values
                            imesh = mesh_kind_chars.index(mesh_char)
                            itdata_axis = mesh_kind_iax[imesh]
                            tdata_ivar_str = tdata_ivar_strs[itdata_axis]
                            value_str = part.split('=')[1].strip()
                            if ' - ' in value_str:
                                vals = value_str.split('-')
                                if int(vals[0]) == int(vals[1]):
                                    value_str = vals[0]
                                else:  # samepage axis
                                    value_str = vals[0]  # this will be overwritten later
                            value = str(int(value_str)-1)
                            exec(tdata_ivar_str + ' = ' + value, globals())
                        elif mesh_char in ignored_eq_strs:
                            continue
                        elif meta['tally_type']=='[T-Cross]':
                            if meta['mesh'] == 'xyz' and mesh_char=='z surf':
                                #imesh = mesh_kind_chars.index('z')
                                itdata_axis = 2 #mesh_kind_iax[imesh]
                                tdata_ivar_str = tdata_ivar_strs[itdata_axis]
                                value_str = part.split('=')[1].strip()
                                value = str(int(value_str) - 1)
                                exec(tdata_ivar_str + ' = ' + value, globals())
                            elif meta['mesh'] == 'r-z':
                                if mesh_char=='r surf':
                                    itdata_axis = 0  # mesh_kind_iax[imesh]
                                    #itdata_axis = 1  # set to iy
                                    ierr_mod = int(ierr_max/2)
                                    #ir, ic = -1, -1
                                    # imesh = mesh_kind_chars.index('y')
                                elif mesh_char == 'z surf':
                                    itdata_axis = 2  # mesh_kind_iax[imesh]
                                    #itdata_axis = 8  # set to ic
                                    ierr_mod = 0
                                    #iy, iz = -1, -1
                                    # imesh = mesh_kind_chars.index('c')
                                else:
                                    raise ValueError('ERROR! Unregistered potential index ['+ part.split('=')[0].strip()+'] found')
                                tdata_ivar_str = tdata_ivar_strs[itdata_axis]
                                value_str = part.split('=')[1].strip()
                                if ' - ' in value_str:
                                    vals = value_str.split('-')
                                    if int(vals[0]) == int(vals[1]):
                                        value_str = vals[0]
                                    else: # samepage axis
                                        value_str = vals[0] # this will be overwritten later
                                value = str(int(value_str) - 1)
                                exec(tdata_ivar_str + ' = ' + value, globals())
                            else:
                                raise ValueError('ERROR! Unregistered potential index ['+ part.split('=')[0].strip()+ '] found')
                        elif meta['tally_type'] == '[T-Heat]':
                            banked_uninterpreted_lines.append(line)
                        else:
                            raise ValueError('ERROR! Unregistered potential index ['+part.split('=')[0].strip()+'] found')

            if in_debug_mode:
                print('\tcontent block',bi,', indices:', ir, iy, iz, ie, it, ia, il, ip, ic)
            
            # extract data from table
            # determine meaning of table rows
            row_ivar = tdata_ivar_strs[meta['axis_index_of_tally_array']]
            # determine meaning of table columns
            hcols = parse_group_string(data_table[0][3:])
            nhcols = len(hcols)
            col_names_line_str = data_table[1][1:]
            icol_mod = 0 # account for weirdness in column presence/absence
            if 'r surface position' in col_names_line_str:
                icol_mod = -1
                ierr_mod = int(ierr_max / 2)
            # Test for error in hcols
            num_data_vals_in_first_row = len(data_row_to_num_list(data_table[2])) # first row of data
            if num_data_vals_in_first_row != nhcols:
                if num_data_vals_in_first_row == (nhcols+1):
                    # most likely issue is hcol string is missing the "n" for the ?-lower column
                    nhcols = nhcols + 1
                    icol_mod = 1
                elif nhcols > num_data_vals_in_first_row: # likely extra hcol columns present
                    hcols_new = []
                    for ih, h in enumerate(hcols):
                        if len(h) > 1 and h[:2] in ['dy', 'dx'] and len(hcols_new) > 1 and hcols_new[-1][:2] in ['ny', 'nx']:
                            hcols_new[-1] = h 
                        else:
                            hcols_new.append(h)
                    hcols = hcols_new
                    nhcols = len(hcols)
            is_col_data = np.full(nhcols,False)
            data_col_indices = []
            is_col_err = np.full(nhcols,False)
            err_col_indices = []
            for iii in range(len(hcols)):
                if hcols[iii][0] == 'y':
                    is_col_data[iii+icol_mod] = True
                    is_col_err[iii+1+icol_mod] = True
                    data_col_indices.append(iii+icol_mod)
                    err_col_indices.append(iii+1+icol_mod)
            #print('is_col_data', is_col_data)
            #print('is_col_err ', is_col_err)
            cols = data_table[1][1:].strip().split()
            ncols = len(cols)
            ndata_cols = np.sum(is_col_data) # number of data values per row
            # determine what variable this corresponds to, should be val of samepage
            # by default, this is usually particles (samepage = part by default)
            if meta['samepage'] == 'part':
                if meta['npart'] != ndata_cols:
                    raise ValueError('ERROR! samepage number of particle types ('+str(meta['npart'])+') not equal to number of data columns y(part) = '+str(ndata_cols))
                data_ivar = 'ip'
                data_ivar_indices = [j for j in range(ndata_cols)]
            else: # figure out what axis samepage is on
                if meta['samepage'] not in axes_1D:
                    raise ValueError('ERROR! samepage parameter ('+str(meta['samepage'])+') must be "part" or one of valid options for "axis" parameter')
                data_ivar = tdata_ivar_strs[axes_ital_1D[axes_1D.index(meta['samepage'])]]
                if ndata_cols != eval(data_ivar+'_max'):
                    if meta['tally_type']=='[T-Cross]' and ndata_cols+1 == eval(data_ivar+'_max'):
                        # This is fine; for T-Cross, ndata cols can be one less than max length...
                        pass
                    elif meta['tally_type']=='[T-Cross]' and data_ivar == 'ir' and ndata_cols+2 == eval(data_ivar+'_max'):
                        # This is fine; for T-Cross, ndata cols for radius can be two less than max length if rmin=0...
                        pass
                    else:
                        raise ValueError('ERROR! number of data columns ('+str(ndata_cols)+') not equal to tally array dimension for '+str(data_ivar)+', '+str(eval(data_ivar+'_max')))
                data_ivar_indices = [j for j in range(ndata_cols)]
            #print(cols)
            #print(ndata_cols)
            for li, line in enumerate(data_table[2:]):
                if len(line)==0: continue
                #print(line)
                rowi = li
                exec(row_ivar + '=' + str(rowi),globals())
                #print(row_ivar + '=' + str(rowi))
                values = data_row_to_num_list(line)
                dcoli = 0
                ecoli = 0
                for vi, value in enumerate(values):
                    if is_col_data[vi]:
                        exec(data_ivar + '=' + str(dcoli),globals())
                        #print(data_ivar + '=' + str(dcoli))
                        tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 0+ierr_mod] = value
                        dcoli += 1
                    if is_col_err[vi]:
                        exec(data_ivar + '=' + str(ecoli),globals())
                        #print(data_ivar + '=' + str(ecoli))
                        tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 1+ierr_mod] = value
                        ecoli += 1





    elif meta['axis_dimensions']==2:
        for bi, block in enumerate(tally_blocks):
            hli, fli = 0 , 0
            data_keyword_found = False
            for li, line in enumerate(block):
                if meta['2D-type'] in [1, 2, 3, 6, 7]:
                    if len(line) == 0: continue
                    if line[:3].lower() in ['hc:', 'h2:', 'hd:', 'hc2']:  # start of data is here
                        hli = li
                    if line[:12] == '#-----------':
                        fli = li
                        #if bi != len(tally_blocks) - 1:
                        break
                elif meta['2D-type'] == 4:
                    if line == '' and hli != 0:
                        fli = li
                        #if bi != len(tally_blocks) - 1:
                        break
                    elif line == '':  # start of data is here
                        hli = li
                elif meta['2D-type'] == 5:
                    if 'data' in line:
                        hli = li + 3
                    if line == '' and hli != 0 and li>hli+2:
                        fli = li
                        #if bi != len(tally_blocks) - 1:
                        break

            data_header = block[:hli]
            data_table = block[hli:fli]
            data_footer = block[fli:]

            #print(data_header)
            #print(data_table)
            #print(data_footer)

            hash_line_already_evaluated = False

            if bi == len(tally_blocks) - 1:
                for li, line in enumerate(data_footer):
                    if line[:37] == '# Information for Restart Calculation':
                        ffli = li
                        break
                data_footer = data_footer[:ffli]

            # try to get relevant indices data from header block
            for li, line in enumerate(data_header+data_footer): # +data_footer
                if len(line) == 0: continue
                #if 'reg =' in line:
                #    regnum = line.strip().split('reg =')[1].strip()
                #    ir = (meta.reg_num).index(regnum)
                #    # print(ir)
                if '=' in line and (line[0] == "'" or (line[0] == "#" and ('no.' in line or 'i' in line or 'reg' in line or 'part' in line))):
                    if line[0] == "#":
                        hash_line_already_evaluated = True
                    elif line[0] == "'" and hash_line_already_evaluated:
                        if meta['samepage'] == 'part':
                            continue # '-starting lines tend to have more problematic formatting, best skipped if possible
                        elif meta['npart'] == 1:
                            continue # can still skip if only one particle group tallied
                        else:
                            pass # but this needs to be parsed if not using samepage = part and npart > 1
                    parts = split_str_of_equalities(line)
                    for part in parts:
                        mesh_char = part.split('=')[0].strip().replace('i', '')
                        #print(mesh_char)
                        if mesh_char == 'no.':
                            continue
                        elif mesh_char == 'part.' or mesh_char == 'partcle':
                            part_grp_name = part.split('=')[1].strip()
                            try:
                                ip = (meta['part_groups']).index(part_grp_name)
                            except:
                                ip = (meta['part_serial_groups']).index(part_grp_name)
                        elif mesh_char == 'reg': # and meta['samepage'] != 'reg':
                            regnum = part.split('=')[1].strip()
                            ir = (meta['reg_num']).index(regnum)
                        elif mesh_char == 'e1': # [T-Deposit2]
                            value_str = part.split('=')[1].strip()
                            ie = int(value_str) - 1
                        elif mesh_char == 'e2': # [T-Deposit2]
                            value_str = part.split('=')[1].strip()
                            ic = int(value_str) - 1
                        elif mesh_char in mesh_kind_chars or mesh_char in replace_eq_strs_dict:
                            if mesh_char in replace_eq_strs_dict:
                                mesh_char = replace_eq_strs_dict[mesh_char]
                            if 'i'+mesh_char not in part: continue # only looking for indices for meshes, not values
                            imesh = mesh_kind_chars.index(mesh_char)
                            itdata_axis = mesh_kind_iax[imesh]
                            tdata_ivar_str = tdata_ivar_strs[itdata_axis]
                            value = str(int(part.split('=')[1].strip()) - 1)
                            if mesh_char == 'l' and meta['tally_type'] == '[T-Yield]' and meta['axis'] == 'chart':
                                i_metastable = int(value) + 1
                                il = 0
                            else:
                                exec(tdata_ivar_str + ' = ' + value, globals())
                        elif mesh_char in ignored_eq_strs:
                            continue
                        elif meta['tally_type']=='[T-Cross]':
                            ierr_mod = 0
                            if meta['mesh'] == 'xyz' and mesh_char=='z surf':
                                #imesh = mesh_kind_chars.index('z')
                                itdata_axis = 2 #mesh_kind_iax[imesh]
                                tdata_ivar_str = tdata_ivar_strs[itdata_axis]
                                value = str(int(part.split('=')[1].strip()) - 1)
                                exec(tdata_ivar_str + ' = ' + value, globals())
                            elif meta['mesh'] == 'r-z':
                                if mesh_char=='r surf':
                                    # imesh = mesh_kind_chars.index('y')
                                    itdata_axis = 0 #1  # mesh_kind_iax[imesh]
                                    tdata_ivar_str = tdata_ivar_strs[itdata_axis]
                                    value = str(int(part.split('=')[1].strip()) - 1)
                                    exec(tdata_ivar_str + ' = ' + value, globals())
                                    #ir, ic = -1, -1
                                    ierr_mod = int(ierr_max / 2)
                                elif mesh_char=='z surf':
                                    # imesh = mesh_kind_chars.index('c')
                                    itdata_axis = 2 #8  # mesh_kind_iax[imesh]
                                    tdata_ivar_str = tdata_ivar_strs[itdata_axis]
                                    value = str(int(part.split('=')[1].strip()) - 1)
                                    exec(tdata_ivar_str + ' = ' + value, globals())
                                    iy, iz = -1, -1
                                    ierr_mod = 0
                                else:
                                    raise ValueError('ERROR! Unregistered potential index ['+ part.split('=')[0].strip()+'] found')
                            else:
                                raise ValueError('ERROR! Unregistered potential index ['+ part.split('=')[0].strip()+ '] found')
                        else:
                            raise ValueError('ERROR! Unregistered potential index ['+part.split('=')[0].strip()+'] found')


            # Now read data_table, with formatting dependent on 2D-type, and can be inferred from last line of header
            axis1_ivar = meta['axis_index_of_tally_array'][0]
            axis2_ivar = meta['axis_index_of_tally_array'][1]
            if meta['tally_type'] == '[T-Yield]' and meta['axis'] == 'chart': # this setting does not respect 2D-type and uses its own formatting
                data_write_format_str = data_table[0][3:]
                Z_y_segment = data_write_format_str.split(';')[0]
                N_x_segment = data_write_format_str.split(';')[1]
                Z_y_vals = Z_y_segment.replace('=','').replace('to','').replace('by','').replace('y','').strip().split()
                N_x_vals = N_x_segment.replace('=','').replace('to','').replace('by','').replace('x','').strip().split()
                Z_y_max, Z_y_min, Z_y_increment = int(Z_y_vals[0]), int(Z_y_vals[1]), int(Z_y_vals[2])
                N_x_max, N_x_min, N_x_increment = int(N_x_vals[1]), int(N_x_vals[0]), int(N_x_vals[2])
                #print(Z_y_max, Z_y_min, Z_y_increment, N_x_max, N_x_min, N_x_increment )
            elif meta['2D-type'] != 4:
                data_write_format_str = data_header[-2][1:]
                if 'data' not in data_write_format_str:
                    for line in data_header[::-1]:
                        if 'data' in line:
                            data_write_format_str = line[1:]
                            break
                #print(data_write_format_str)
                if 'data' not in data_write_format_str:
                    # failed to find a "data" line telling us how the values are ordered
                    # have to make guesses about output ordering...
                    # axis variable should give us a hint
                    axis = meta['axis']
                    if 'eng' in axis or 'e1' in axis or 'e2' in axis:
                        if axis == 'e12' or axis == 'e21':
                            ax1_ivar = 'ie'
                            ax2_ivar = 'ie'
                        else: # energy vs time
                            if axis[0] == 't':
                                ax1_ivar = 'it'
                                ax2_ivar = 'ie'
                            else:
                                ax1_ivar = 'ie'
                                ax2_ivar = 'it'
                    else:
                        if 'axis1_label' in meta and meta['axis1_label'][0] in axis:
                            # we know horizontal axis variable
                            ax1_ivar = 'i' + meta['axis1_label'][0]
                            ax2_ivar = 'i' + axis.replace(meta['axis1_label'][0],'')
                        else:
                            ax1_ivar = 'i' + meta['axis'][1]
                            ax2_ivar = 'i' + meta['axis'][0]
                else:
                    # We can, with confidence, determine output value ordering :)
                    for dsi in data_write_format_str.split():
                        if 'data' in dsi:
                            data_index_str = dsi
                            ax_vars = data_index_str.replace('data','').replace('(','').replace(')','')
                            #print(data_index_str)
                            #print(ax_vars)
                            ax1_ivar, ax2_ivar = ax_vars.split(',')[:2]
                            ax1_ivar = 'i' + ax1_ivar
                            ax2_ivar = 'i' + ax2_ivar
                    #print(data_write_format_str)
            else:  # 2D-type = 4
                cols = data_table[1][1:].split()
                ax1_ivar, ax2_ivar = cols[0], cols[1]
                ax1_ivar = 'i' + ax1_ivar
                ax2_ivar = 'i' + ax2_ivar

            # manually fix [T-Deposit2] axes
            if meta['tally_type'] == '[T-Deposit2]':
                if meta['axis'] == 'e12':
                    ax1_ivar, ax2_ivar = 'ie', 'ic'
                elif meta['axis'] == 'e21':
                    ax1_ivar, ax2_ivar = 'ic', 'ie'
                elif meta['axis'] == 't-e1':
                    ax1_ivar, ax2_ivar = 'it', 'ie'
                elif meta['axis'] == 't-e2':
                    ax1_ivar, ax2_ivar = 'it', 'ic'
                elif meta['axis'] == 'e1-t':
                    ax1_ivar, ax2_ivar = 'ie', 'it'
                elif meta['axis'] == 'e2-t':
                    ax1_ivar, ax2_ivar = 'ic', 'it'

            if meta['tally_type'] == '[T-Yield]' and meta['axis'] == 'chart':
                remaining_ndata_to_read = (Z_y_max - Z_y_min + 1) * (N_x_max - N_x_min + 1)
            else:
                # check if this is one of the backwards instances
                expected_ax1_ivar = tdata_ivar_strs[axis1_ivar]
                expected_ax2_ivar = tdata_ivar_strs[axis2_ivar]
                if meta['mesh']=='xyz':
                    if expected_ax1_ivar == 'ir': expected_ax1_ivar = 'ix'
                    if expected_ax2_ivar == 'ir': expected_ax1_ivar = 'ix'
                if ax1_ivar==expected_ax1_ivar and ax2_ivar==expected_ax2_ivar:
                    pass # all is correct as is
                elif ax2_ivar == expected_ax1_ivar and ax1_ivar == expected_ax2_ivar:
                    axis1_ivar_temp = axis1_ivar
                    axis1_ivar = axis2_ivar
                    axis2_ivar = axis1_ivar_temp
                    #axis1_ivar = tdata_ivar_strs.index(ax1_ivar)
                    #axis2_ivar = tdata_ivar_strs.index(ax2_ivar)
                    #print('backwards!')
                else:
                    raise ValueError('ERROR! Unknown axes ('+ax1_ivar+' '+ax2_ivar +
                                     ') encountered that did not match expected axes (' +
                                     tdata_ivar_strs[meta['axis_index_of_tally_array'][0]]+' ' +
                                     tdata_ivar_strs[meta['axis_index_of_tally_array'][1]]+')')

                axis1_ivar_str = tdata_ivar_strs[axis1_ivar]
                axis2_ivar_str = tdata_ivar_strs[axis2_ivar]
                axis1_size = np.shape(tdata)[axis1_ivar]
                axis2_size = np.shape(tdata)[axis2_ivar]
                ndata_to_read = axis1_size*axis2_size
                #print(axis1_ivar_str,axis2_ivar_str)
                #print(axis1_size,axis2_size,ndata_to_read)
                remaining_ndata_to_read = ndata_to_read
                iax1 = 0
                iax2 = axis2_size - 1

            if meta['tally_type'] == '[T-Yield]' and meta['axis'] == 'chart':
                #Z_y_max, Z_y_min, Z_y_increment # big, 1, -1
                #N_x_max, N_x_min, N_x_increment # big, 1, 1
                current_Z = Z_y_max
                current_N = N_x_min - N_x_increment
                ic = 0
                for line in data_table[1:]:
                    values = data_row_to_num_list(line)
                    for value in values:
                        remaining_ndata_to_read += -1
                        current_N += N_x_increment
                        if current_N > N_x_max:
                            current_N = N_x_min
                            current_Z += Z_y_increment
                        #print('Z=',current_Z,', N=',current_N)

                        if value != 0:
                            ZZZAAAM = 10000*current_Z + 10*(current_Z+current_N) + i_metastable
                            if ZZZAAAM not in ZZZAAAM_list:
                                ic = len(ZZZAAAM_list)
                                ZZZAAAM_list.append(ZZZAAAM)
                            else:
                                ic = ZZZAAAM_list.index(ZZZAAAM)
                            #print(ic, i_metastable)
                            #print(ic,value)
                            tdata[ir, iy, iz, ie, it, ia, il, ip, ic, ierr + ierr_mod] = value

                        if remaining_ndata_to_read <= 0:
                            break







            elif meta['2D-type'] in [1,2,3,6,7]:
                for line in data_table[1:]:
                    values = data_row_to_num_list(line)
                    #print(line)
                    for value in values:
                        exec(axis1_ivar_str + ' = ' + str(iax1), globals())
                        exec(axis2_ivar_str + ' = ' + str(iax2), globals())
                        #print(ir, iy, iz, ie, it, ia, il, ip, ic, ierr, '\t', value)
                        tdata[ir, iy, iz, ie, it, ia, il, ip, ic, ierr + ierr_mod] = value
                        remaining_ndata_to_read += -1
                        #print(iax1, iax2)
                        iax1 += 1
                        if iax1 == axis1_size:
                            iax1 = 0
                            iax2 += -1
                    if remaining_ndata_to_read <= 0:
                        break

            elif meta['2D-type'] == 4:
                iax2 = 0
                for line in data_table[2:]:
                    values = data_row_to_num_list(line)
                    value = values[2]
                    value_err = values[3]
                    exec(axis1_ivar_str + ' = ' + str(iax1), globals())
                    exec(axis2_ivar_str + ' = ' + str(iax2), globals())
                    tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 0 + ierr_mod] = value
                    tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 1 + ierr_mod] = value_err
                    # print(ir, iy, iz, ie, it, ia, il, ip, ic, ierr,'\t',value)
                    remaining_ndata_to_read += -1
                    # print(iax1, iax2)
                    iax1 += 1
                    if iax1 == axis1_size:
                        iax1 = 0
                        iax2 += 1

                    if remaining_ndata_to_read <= 0:
                        break

            elif meta['2D-type'] == 5:
                for line in data_table[2:]:
                    values = data_row_to_num_list(line)
                    #print(line)
                    for vi, value in enumerate(values):
                        if vi==0: continue # header column
                        exec(axis1_ivar_str + ' = ' + str(iax1), globals())
                        exec(axis2_ivar_str + ' = ' + str(iax2), globals())
                        #print(ir, iy, iz, ie, it, ia, il, ip, ic, ierr, '\t', value)
                        tdata[ir, iy, iz, ie, it, ia, il, ip, ic, ierr + ierr_mod] = value
                        remaining_ndata_to_read += -1
                        # print(iax1, iax2)
                        iax1 += 1
                        if iax1 == axis1_size:
                            iax1 = 0
                            iax2 += -1
                    if remaining_ndata_to_read <= 0:
                        break

            else:
                raise ValueError('ERROR! unsupported 2D-type of '+str(meta['2D-type'])+' provided; legal values are [1,2,3,4,5,6,7]')

    else:
        raise ValueError(str(meta['axis_dimensions'])+'axis dimensions is unknown, ERROR!')

    if len(banked_uninterpreted_lines) != 0:
        print('The following potentially useful output lines were found but not stored anywhere:')
        for line in banked_uninterpreted_lines:
            print('\t'+line)

    return_updated_metadata_too = False
    if meta['tally_type'] == '[T-Yield]' and meta['axis'] in ['chart','charge','mass']:
        return_updated_metadata_too = True
        if meta['axis'] == 'chart':
            meta['nuclide_ZZZAAAM_list'] = ZZZAAAM_list
            meta['nuclide_isomer_list'] = [ZZZAAAM_to_nuclide_plain_str(i) for i in ZZZAAAM_list]
            nc_max = len(ZZZAAAM_list) #+ 1
            meta['nc'] = nc_max
            tdata = tdata[:,:,:,:,:,:,:,:,:nc_max,:]
        elif meta['axis'] == 'charge' or meta['axis'] == 'mass':
            ic_axis_tdata_sum = tdata.sum(axis=(0,1,2,3,4,5,6,7,9))
            nc_max = np.max(np.nonzero(ic_axis_tdata_sum)) + 1
            meta['nc'] = nc_max
            tdata = tdata[:, :, :, :, :, :, :, :, :nc_max, :]

    if return_updated_metadata_too:
        return tdata, meta
    else:
        return tdata


def extract_data_from_header_line(line):
    '''
    Description:
        Extract a "key" and its corresponding value from a PHITS tally output header line

    Dependencies:
        - `is_number` (function within the "PHITS tools" package)

    Inputs:
        - `line` = string to be processed

    Outputs:
        - `key` = a string "key" to become a key in the metadata dictionary
        - `value` = corresponding value they "key" is equal to; dtype is string, int, or float
    '''
    if '#' in line:
        info, trash = line.split('#',1)
    else:
        info = line
    key, value = info.split('=')
    key = key.strip()
    value = value.strip()
    if is_number(value):
        if '.' in value:
            value = float(value)
        else:
            value = int(value)
    return key, value

def split_str_of_equalities(text):
    '''
    Description:
        Extract relevant regions, indices, etc. from somewhat inconsistently formatted lines in PHITS tally output content section.

    Dependencies:
        - `is_number` (function within the "PHITS tools" package)

    Inputs:
        - `text` = string to be processed

    Outputs:
        - `equalities_str_list` = list of strings of equalities each of the format "key = value"

    '''
    equalities_str_list = []
    original_text = text
    #if text[0] == "'": # more loosely formatted text
    #    problem_strs = ['tot DPA']
    if text[0] == "#" and 'no. =***' in text and text[-1]=='=': return [] # skip broken lines
    text = text.replace("'",'').replace(',',' ').replace('#','').replace('=',' = ').replace('***','999999999')
    text_pieces = text.split()
    #i_equal_sign = [i for i, x in enumerate(text_pieces) if x == "="]
    is_i_equal_sign = [x=='=' for x in text_pieces]
    #i_is_number = [i for i, x in enumerate(text_pieces) if is_number(x)]
    is_i_number = [is_number(x) for x in text_pieces]
    #num_equalities = len(i_equal_sign)
    #remaining_equalities = num_equalities
    equality_str = ''
    # the only condition enforced is that the last item in each value be numeric or )
    current_equality_contains_equalsign = False
    for i in reversed(range(len(text_pieces))): # easiest to build from right to left
        equality_str = text_pieces[i] + ' ' + equality_str
        if is_i_equal_sign[i]:
            current_equality_contains_equalsign = True
        elif current_equality_contains_equalsign: # looking to terminate if next item is numeric
            if i==0 or (is_i_number[i-1] or text_pieces[i-1][-1]==')'): # either final equality completed or next item belongs to next equality
                equalities_str_list.insert(0,equality_str.strip())
                equality_str = ''
                current_equality_contains_equalsign = False
    if '(' in text: # need to break up potential (ia,ib) pairs
        new_eq_str_list = []
        for x in equalities_str_list:
            if '(' in x:
                keys, values = x.split('=')
                keys = keys.strip().replace('(','').replace(')','').split()
                values = values.strip().replace('(','').replace(')','').split()
                for i in range(len(keys)):
                    new_eq_str = keys[i].strip() + ' = ' + values[i].strip()
                    new_eq_str_list.append(new_eq_str)
            else:
                new_eq_str_list.append(x)
        equalities_str_list = new_eq_str_list
    #print(equalities_str_list)
    return equalities_str_list

def parse_group_string(text):
    '''
    Description:
        Separate "groups" in a string, wherein a group is a standalone value or a series of values inside parentheses.

    Inputs:
        - `text` = string to be processed

    Outputs:
        - `groups` = a list of strings extracted from `text`
    '''
    # returns list of items from PHITS-formatted string, e.g. w/ ()
    parts = text.strip().split()
    #print(parts)
    groups = []
    curly_vals = []
    in_brackets_group = False
    in_curly_brace_group = False
    num_group_members = 0
    for i in parts:
        if '(' in i and ')' in i:
            in_brackets_group = False
            groups.append(i)
        elif '(' in i:
            in_brackets_group = True
            groups.append(i)
        elif ')' in i:
            in_brackets_group = False
            num_group_members = 0
            groups[-1] += i
        elif '{' in i:
            in_curly_brace_group = True
            curly_vals = []
        elif '}' in i:
            in_curly_brace_group = False
            curly_int_strs = [str(j) for j in range(int(curly_vals[0]), int(curly_vals[-1])+1)]
            curly_vals = []
            groups += curly_int_strs
        else:
            if in_brackets_group or in_curly_brace_group:
                if in_brackets_group:
                    if num_group_members>0: groups[-1] += ' '
                    groups[-1] += i
                    num_group_members += 1
                if in_curly_brace_group:
                    if i != '-':
                        curly_vals.append(i)
            else:
                groups.append(i)
    #print(groups)
    return groups


def initialize_tally_array(tally_metadata,include_abs_err=True):
    '''
    Description:
        Initializes main tally data array in which tally results will be stored when read

    Dependencies:
        - `import numpy as np`

    Inputs:
        - `tally_metadata` = Munch object / dictionary containing tally metadata
        - `include_abs_err` = a Boolean (D=`True`) on whether absolute error will be calculated; the final dimension of `tdata` is
                `3/2` if this value is `True/False`

    Outputs:
        - `tdata` = 10-dimensional NumPy array of zeros of correct size for holding tally results

    '''
    ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max = 1, 1, 1, 1, 1, 1, 1, 1, 1
    if include_abs_err:
        ierr_max = 3
    else:
        ierr_max = 2
    if tally_metadata['mesh'] == 'reg':
        ir_max = tally_metadata['nreg']
    elif tally_metadata['mesh'] == 'xyz':
        ir_max = tally_metadata['nx']
        iy_max = tally_metadata['ny']
        iz_max = tally_metadata['nz']
    elif tally_metadata['mesh'] == 'r-z':
        ir_max = tally_metadata['nr']
        iz_max = tally_metadata['nz']
        if 'ny' in tally_metadata and tally_metadata['ny'] != None: iy_max = tally_metadata['ny']
        if 'nc' in tally_metadata and tally_metadata['nc'] != None: ic_max = tally_metadata['nc']
    elif tally_metadata['mesh'] == 'tet':
        ir_max = tally_metadata['ntet']
    elif tally_metadata['mesh'] == 'point' or tally_metadata['mesh'] == 'ring':
        ir_max = tally_metadata['nreg']
    else:
        raise ValueError('ERROR! Unknown geometry mesh:'+ str(tally_metadata['mesh']))

    if tally_metadata['na'] != None: ia_max = tally_metadata['na']
    if tally_metadata['nt'] != None: it_max = tally_metadata['nt']
    if tally_metadata['nl'] != None: il_max = tally_metadata['nl']
    if 'nc' in tally_metadata and tally_metadata['nc'] != None: ic_max = tally_metadata['nc']
    #if 'npart' in tally_metadata and tally_metadata.npart != None: ip_max = tally_metadata.np

    if tally_metadata['ne'] == None:
        if tally_metadata['tally_type'] == '[T-Deposit2]':
            if 'ne1' in tally_metadata:
                ie_max = tally_metadata['ne1']
            if 'ne2' in tally_metadata:
                ic_max = tally_metadata['ne2']
        elif 'e1' in tally_metadata['axis'] or 'e2' in tally_metadata['axis']:  # This should now be redundant?
            if tally_metadata['axis'] == 'e12':
                ie_max = tally_metadata['ne1']
                ic_max = tally_metadata['ne2']
            elif tally_metadata['axis'] == 'e21':
                ie_max = tally_metadata['ne1']
                ic_max = tally_metadata['ne2']
            elif 'e1' in tally_metadata['axis'] or 'eng1' in tally_metadata['axis']:
                ie_max = tally_metadata['ne1']
                if 'ne2' in tally_metadata:
                    ic_max = tally_metadata['ne2']
            elif 'e2' in tally_metadata['axis'] or 'eng2' in tally_metadata['axis']:
                ic_max = tally_metadata['ne2']
                if 'ne1' in tally_metadata:
                    ie_max = tally_metadata['ne1']
            else:
                if 'ne1' in tally_metadata:
                    ie_max = tally_metadata['ne1']
                if 'ne2' in tally_metadata:
                    ic_max = tally_metadata['ne2']

    else:
        ie_max = tally_metadata['ne']

    ip_max = tally_metadata['npart']

    if tally_metadata['tally_type'] == '[T-Cross]' and tally_metadata['mesh'] == 'r-z':
        if 'enclos' in tally_metadata and tally_metadata['enclos'] == 1:
            pass
        else: # enclos = 0 case
            ierr_max = 2*ierr_max

    if tally_metadata['tally_type'] == '[T-Yield]':
        if tally_metadata['axis'] == 'charge':
            ic_max = 130
        elif tally_metadata['axis'] == 'mass':
            ic_max = 320
        elif tally_metadata['axis'] == 'chart':
            if int(tally_metadata['mxnuclei']) == 0:
                ic_max = 10000
            else:
                ic_max = int(tally_metadata['mxnuclei'])

    if tally_metadata['tally_type'] == '[T-Interact]' and tally_metadata['axis'] == 'act':
        ic_max = 100
        if 'maxact' in tally_metadata:
            ic_max = tally_metadata['maxact']

    if in_debug_mode:
        dims_str = 'tally dims: nr={:g}, ny={:g}, nz={:g}, ne={:g}, nt={:g}, na={:g}, nl={:g}, np={:g}, nc={:g}, nerr={:g}'
        print(dims_str.format(ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max, ierr_max))
    tally_data = np.zeros((ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max, ierr_max))
    return tally_data

def data_row_to_num_list(line):
    '''
    Description:
        Extract numeric values from line of text from PHITS tally output content section

    Dependencies:
        - `is_number` (function within the "PHITS tools" package)

    Inputs:
        - `line` = string to be processed

    Outputs:
        - `values` = a list of ints and/or floats of numeric values in `line`
    '''
    value_strs = line.strip().split()
    values = []
    for value in value_strs:
        if is_number(value):
            if '.' in value:
                value = float(value)
            else:
                value = int(value)
        values.append(value)
    return values

def calculate_tally_absolute_errors(tdata):
    '''
    Description:
        Calculates the absolute uncertainty for every value in the PHITS tally data array

    Inputs:
        - `tdata` = 10-dimensional NumPy array containing read/extracted tally results

    Outputs:
        - `tdata` = updated `tdata` array now with absolute uncertainties in `ierr = 2` index

    '''

    ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max, ierr_max = np.shape(tdata)
    for ir in range(ir_max):
        for iy in range(iy_max):
            for iz in range(iz_max):
                for ie in range(ie_max):
                    for it in range(it_max):
                        for ia in range(ia_max):
                            for il in range(il_max):
                                for ip in range(ip_max):
                                    for ic in range(ic_max):
                                        tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 2] = \
                                            tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 0] * \
                                            tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 1]
    if ierr_max==6:
        for ir in range(ir_max):
            for iy in range(iy_max):
                for iz in range(iz_max):
                    for ie in range(ie_max):
                        for it in range(it_max):
                            for ia in range(ia_max):
                                for il in range(il_max):
                                    for ip in range(ip_max):
                                        for ic in range(ic_max):
                                            tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 5] = \
                                                tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 3] * \
                                                tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 4]

    return tdata





def build_tally_Pandas_dataframe(tdata,meta):
    '''
    Description:
        Calculates the absolute uncertainty for every value in the PHITS tally data array

    Dependencies:
        - `import pandas as pd`

    Inputs:
        - `tdata` = 10-dimensional NumPy array containing read/extracted tally results
        - `meta` = Munch object / dictionary containing tally metadata

    Outputs:
        - `tally_df` = Pandas dataframe containing the entire contents of the `tdata` array;
                note that tally_df.attrs returns values which are the same for all rows

    '''
    import pandas as pd
    ir_max, iy_max, iz_max, ie_max, it_max, ia_max, il_max, ip_max, ic_max, ierr_max = np.shape(tdata)
    num_df_rows = ir_max * iy_max * iz_max * ie_max * it_max * ia_max * il_max * ip_max * ic_max
    # determine what columns to include, based on what info was specified vs left at default values
    col_names_list = []

    in_irregular_TCross_rz_mesh = False
    in_irregular_TCross_xyz_mesh = False
    ierr_mod = 0
    if meta['tally_type'] == '[T-Cross]' and (meta['mesh'] == 'xyz' or meta['mesh'] == 'r-z'):
        if 'enclos' in meta and meta['enclos'] == 1:
            pass
        else:
            if meta['mesh'] == 'r-z':
                in_irregular_TCross_rz_mesh = True
                min_r_is_zero = False
                if meta['r-mesh_bin_edges'][0]==0:
                    min_r_is_zero = True
                ierr_mod = int(ierr_max / 2)
            else:
                in_irregular_TCross_xyz_mesh = True


    # region columns
    if meta['mesh'] == 'reg':
        reg_cols = ['ir','reg','reg#'] # use meta.reg_groups and meta.reg_num
    elif meta['mesh'] == 'xyz':
        if in_irregular_TCross_xyz_mesh:
            reg_cols = ['ix', 'iy', 'iz', 'x_mid', 'y_mid', 'z_surf']
        else:
            reg_cols = ['ix','iy','iz','x_mid','y_mid','z_mid']
    elif meta['mesh'] == 'r-z':
        if in_irregular_TCross_rz_mesh:
            #reg_cols = ['ir', 'ic', 'r_mid', 'z_surf', 'iy', 'iz', 'r_surf', 'z_mid']
            reg_cols = ['ir', 'iz', 'r_mid', 'z_surf', 'r_surf', 'z_mid']
        else:
            reg_cols = ['ir','iz','r_mid','z_mid']
    elif meta['mesh'] == 'tet':
        reg_cols = ['ir','tet'] #,'tet#']
    elif meta['mesh'] == 'point':
        reg_cols = ['ir','point#']
    elif meta['mesh'] == 'ring':
        reg_cols = ['ir','ring#']
    col_names_list += reg_cols



    # Determine what other columns will be present
    ecols, tcols, acols, lcols, pcols, ccols = False, False, False, False, False, False
    single_specified_bin_axes = [] # log axes which are provided by user but only contain 1 bin
    single_bin_ranges_or_values = []
    if meta['ne'] != None or ie_max>1: #  
        if meta['ne']==1:
            single_specified_bin_axes.append('e')
            single_bin_ranges_or_values.append(['Energy',meta['e-mesh_bin_edges']])
        elif meta['tally_type']=='[T-Deposit2]':
            if meta['ne1']==1:
                single_specified_bin_axes.append('e1')
                single_bin_ranges_or_values.append(['Energy1', meta['e1-mesh_bin_edges']])
            else:
                ecols = True
                ecol_names_list = ['ie', 'e1_mid']
                col_names_list += ecol_names_list
        else:
            ecols = True
            ecol_names_list = ['ie','e_mid']
            col_names_list += ecol_names_list
    else:
        single_bin_ranges_or_values.append(['Energy','default/all'])
    if meta['nt'] != None:
        if meta['nt']==1:
            single_specified_bin_axes.append('t')
            single_bin_ranges_or_values.append(['Time',meta['t-mesh_bin_edges']])
        else:
            tcols = True
            tcol_names_list = ['it', 't_mid']
            col_names_list += tcol_names_list
    else:
        single_bin_ranges_or_values.append(['Time','default/all'])
    if meta['na'] != None:
        if meta['na']==1:
            single_specified_bin_axes.append('a')
            single_bin_ranges_or_values.append(['Angle',meta['a-mesh_bin_edges']])
        else:
            acols = True
            acol_names_list = ['ia', 'a_mid']
            col_names_list += acol_names_list
    else:
        single_bin_ranges_or_values.append(['Angle','default/all'])
    if meta['nl'] != None:
        if meta['nl']==1:
            single_specified_bin_axes.append('l')
            single_bin_ranges_or_values.append(['LET',meta['l-mesh_bin_edges']])
        else:
            lcols = True
            lcol_names_list = ['il', 'LET_mid']
            col_names_list += lcol_names_list
    else:
        single_bin_ranges_or_values.append(['LET','default/all'])

    if meta['nc'] != None or ic_max>1:
        if meta['nc'] == 1 or ic_max==1:
            pass
        else:
            ccols = True
            if meta['tally_type'] == '[T-Yield]':
                if meta['axis'] == 'chart':
                    ccol_names_list = ['ic', 'nuclide', 'ZZZAAAM']
                    col_names_list += ccol_names_list
                elif meta['axis'] == 'charge':
                    ccol_names_list = ['ic/Z/charge']
                    col_names_list += ccol_names_list
                elif meta['axis'] == 'mass':
                    ccol_names_list = ['ic/A/mass']
                    col_names_list += ccol_names_list
            elif meta['tally_type'] == '[T-Interact]':
                if meta['axis'] == 'act':
                    ccol_names_list = ['ic', '#Interactions']
                    col_names_list += ccol_names_list
            elif meta['tally_type'] == '[T-Deposit2]':
                if meta['ne2'] == 1:
                    single_specified_bin_axes.append('e2')
                    single_bin_ranges_or_values.append(['Energy2', meta['e2-mesh_bin_edges']])
                else:
                    ccol_names_list = ['ic', 'e2_mid']
                    col_names_list += ccol_names_list

    if meta['npart'] != None: # and meta['part_groups'][0]=='all':
        if meta['npart']==1:
            single_specified_bin_axes.append('p')
            single_bin_ranges_or_values.append(['Particle',meta['part_groups'][0]])
        else:
            pcols = True
            pcol_names_list = ['ip', 'particle', 'kf-code']
            col_names_list += pcol_names_list
    else:
        single_bin_ranges_or_values.append(['Particle','default/all'])

    # HANDLE SPECIAL COLUMNS HERE (ic / ccols)


    # value columns come last
    val_names_list = ['value', 'rel.err.']
    if ierr_max == 3 or ierr_max == 6: val_names_list += ['abs.err.']
    if ierr_max >= 4: val_names_list += ['value2', 'rel.err.2']
    if ierr_max == 6: val_names_list += ['abs.err.2']
    col_names_list += val_names_list

    # Initialize dictionary
    df_dict = {}
    for col in col_names_list:
        df_dict[col] = []


    # Populate dictionary
    for ir in range(ir_max):
        for iy in range(iy_max):
            for iz in range(iz_max):
                for ie in range(ie_max):
                    for it in range(it_max):
                        for ia in range(ia_max):
                            for il in range(il_max):
                                for ip in range(ip_max):
                                    for ic in range(ic_max):
                                        # Region columns
                                        if in_irregular_TCross_rz_mesh:
                                            if (ir == ir_max - 1 and iz == iz_max - 1): # only index that should be empty
                                                continue
                                            # ['ir', 'iz', 'r_mid', 'z_surf', 'r_surf', 'z_mid']
                                            df_dict[reg_cols[0]].append(ir)
                                            df_dict[reg_cols[1]].append(iz)
                                            if ir==ir_max-1:
                                                df_dict[reg_cols[2]].append(None)
                                            else:
                                                df_dict[reg_cols[2]].append(meta['r-mesh_bin_mids'][ir])
                                            df_dict[reg_cols[3]].append(meta['z-mesh_bin_edges'][iz])
                                            df_dict[reg_cols[4]].append(meta['r-mesh_bin_edges'][ir])
                                            if iz == iz_max - 1:
                                                df_dict[reg_cols[5]].append(None)
                                            else:
                                                df_dict[reg_cols[5]].append(meta['z-mesh_bin_mids'][iz])
                                            # OLD IMPLEMENTATION IS BELOW:
                                            '''
                                            # skip unwritten indices
                                            # reg_cols = ['ir', 'ic', 'r_mid', 'z_surf', 'iy', 'iz', 'r_surf', 'z_mid']
                                            if (ir==ir_max-1 and ic==ic_max-1):
                                                if (iy == iy_max - 1 or iz == iz_max - 1): continue
                                                if min_r_is_zero and iy==0: continue # surface vals not written for r=0.0
                                                df_dict[reg_cols[0]].append(None)
                                                df_dict[reg_cols[1]].append(None)
                                                df_dict[reg_cols[2]].append(None)
                                                df_dict[reg_cols[3]].append(None)
                                                df_dict[reg_cols[4]].append(iy)
                                                df_dict[reg_cols[5]].append(iz)
                                                df_dict[reg_cols[6]].append(meta['r-mesh_bin_edges'][iy])
                                                df_dict[reg_cols[7]].append(meta['z-mesh_bin_mids'][iz])
                                            elif (iy==iy_max-1 and iz==iz_max-1):
                                                if (ir == ir_max - 1 or ic == ic_max - 1): continue
                                                df_dict[reg_cols[0]].append(ir)
                                                df_dict[reg_cols[1]].append(ic)
                                                df_dict[reg_cols[2]].append(meta['r-mesh_bin_mids'][ir])
                                                df_dict[reg_cols[3]].append(meta['z-mesh_bin_edges'][ic])
                                                df_dict[reg_cols[4]].append(None)
                                                df_dict[reg_cols[5]].append(None)
                                                df_dict[reg_cols[6]].append(None)
                                                df_dict[reg_cols[7]].append(None)
                                            else: # all other indices should not have any content written into them
                                                continue
                                            '''
                                        else:
                                            if meta['mesh'] == 'reg': #reg_cols = ['ir','reg', 'reg#']  # use meta.reg_groups and meta.reg_num
                                                df_dict[reg_cols[0]].append(ir)
                                                df_dict[reg_cols[1]].append(meta['reg_groups'][ir])
                                                df_dict[reg_cols[2]].append(meta['reg_num'][ir])
                                            elif meta['mesh'] == 'xyz':
                                                #reg_cols = ['ix', 'iy', 'iz', 'xmid', 'ymid', 'zmid']
                                                df_dict[reg_cols[0]].append(ir)
                                                df_dict[reg_cols[1]].append(iy)
                                                df_dict[reg_cols[2]].append(iz)
                                                df_dict[reg_cols[3]].append(meta['x-mesh_bin_mids'][ir])
                                                df_dict[reg_cols[4]].append(meta['y-mesh_bin_mids'][iy])
                                                if in_irregular_TCross_xyz_mesh:
                                                    df_dict[reg_cols[5]].append(meta['z-mesh_bin_edges'][iz])
                                                else:
                                                    df_dict[reg_cols[5]].append(meta['z-mesh_bin_mids'][iz])
                                            elif meta['mesh'] == 'r-z':
                                                #reg_cols = ['ir', 'iz', 'rmid', 'zmid']
                                                df_dict[reg_cols[0]].append(ir)
                                                df_dict[reg_cols[1]].append(iz)
                                                df_dict[reg_cols[2]].append(meta['r-mesh_bin_mids'][ir])
                                                df_dict[reg_cols[3]].append(meta['z-mesh_bin_mids'][iz])
                                            elif meta['mesh'] == 'tet':
                                                #reg_cols = ['ir','tet']
                                                df_dict[reg_cols[0]].append(ir)
                                                df_dict[reg_cols[1]].append(meta['tet_num'][ir])
                                            elif meta['mesh'] == 'point':
                                                #reg_cols = ['ir','point#']
                                                df_dict[reg_cols[0]].append(ir)
                                                df_dict[reg_cols[1]].append(str(ir+1))
                                            elif meta['mesh'] == 'ring':
                                                #reg_cols = ['ir','ring#']
                                                df_dict[reg_cols[0]].append(ir)
                                                df_dict[reg_cols[1]].append(str(ir+1))

                                        #ecols, tcols, acols, lcols, pcols, ccols
                                        if pcols: # pcol_names_list = ['ip', 'particle', 'kf-code']
                                            df_dict[pcol_names_list[0]].append(ip)
                                            df_dict[pcol_names_list[1]].append(meta['part_groups'][ip])
                                            df_dict[pcol_names_list[2]].append(meta['kf_groups'][ip])

                                        if ecols: # ecol_names_list = ['ie','e_mid']
                                            df_dict[ecol_names_list[0]].append(ie)
                                            if meta['tally_type'] == '[T-Deposit2]':
                                                df_dict[ecol_names_list[1]].append(meta['e1-mesh_bin_mids'][ie])
                                            else:
                                                df_dict[ecol_names_list[1]].append(meta['e-mesh_bin_mids'][ie])
                                        if tcols: # tcol_names_list = ['it','t_mid']
                                            df_dict[tcol_names_list[0]].append(it)
                                            df_dict[tcol_names_list[1]].append(meta['t-mesh_bin_mids'][it])
                                        if acols: # acol_names_list = ['ia','a_mid']
                                            df_dict[acol_names_list[0]].append(ia)
                                            df_dict[acol_names_list[1]].append(meta['a-mesh_bin_mids'][ia])
                                        if lcols: # lcol_names_list = ['il','LET_mid']
                                            df_dict[lcol_names_list[0]].append(il)
                                            df_dict[lcol_names_list[1]].append(meta['l-mesh_bin_mids'][il])

                                        if ccols:
                                            if meta['tally_type'] == '[T-Yield]':
                                                if meta['axis'] == 'chart':
                                                    #ccol_names_list = ['ic', 'nuclide', 'ZZZAAAM']
                                                    df_dict[ccol_names_list[0]].append(ic)
                                                    df_dict[ccol_names_list[1]].append(meta['nuclide_isomer_list'][ic])
                                                    df_dict[ccol_names_list[2]].append(meta['nuclide_ZZZAAAM_list'][ic])
                                                elif meta['axis'] == 'charge':
                                                    #ccol_names_list = ['ic/Z/charge']
                                                    df_dict[ccol_names_list[0]].append(ic)
                                                elif meta['axis'] == 'mass':
                                                    #ccol_names_list = ['ic/A/mass']
                                                    df_dict[ccol_names_list[0]].append(ic)
                                            elif meta['tally_type'] == '[T-Interact]':
                                                if meta['axis'] == 'act':
                                                    #ccol_names_list = ['act']
                                                    df_dict[ccol_names_list[0]].append(ic)
                                                    df_dict[ccol_names_list[1]].append(ic+1)
                                            elif meta['tally_type'] == '[T-Deposit2]':
                                                df_dict[ccol_names_list[0]].append(ic)
                                                df_dict[ccol_names_list[1]].append(meta['e2-mesh_bin_mids'][ic])

                                        # Value columns
                                        #val_names_list = ['value', 'rel.err.','abs.err.']
                                        df_dict[val_names_list[0]].append(tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 0])
                                        df_dict[val_names_list[1]].append(tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 1])
                                        if ierr_max == 3 or ierr_max == 6:
                                            df_dict[val_names_list[2]].append(tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 2])
                                        if in_irregular_TCross_rz_mesh:
                                            df_dict[val_names_list[0+ierr_mod]].append(tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 0+ierr_mod])
                                            df_dict[val_names_list[1+ierr_mod]].append(tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 1+ierr_mod])
                                            if ierr_max == 6:
                                                df_dict[val_names_list[2+ierr_mod]].append(tdata[ir, iy, iz, ie, it, ia, il, ip, ic, 2 + ierr_mod])


    # Convert dictionary to Pandas dataframe
    #for key in df_dict.keys():
    #    print(key,len(df_dict[key]))
    #sys.exit()
    tally_df = pd.DataFrame(df_dict)

    # store information on settings provided by user that are different from default but same for all rows
    if len(single_bin_ranges_or_values) > 0:
        for i in single_bin_ranges_or_values:
            col, val = i
            tally_df.attrs[col] = val

    #with pd.option_context('display.max_rows', None, 'display.max_columns', None): print(tally_df)
    if in_debug_mode:
        #print(tally_df.to_string())
        print(tally_df.attrs)
    return tally_df




def extract_tally_outputs_from_phits_input(phits_input, use_path_and_string_mode=False, only_seek_phitsout=False):
    '''
    Description:
        Extract a list of output files produced from a PHITS input file (or its "phits.out" file, using its input echo). 
        In cases where the PHITS `infl:{*}` function is used to insert text files of PHITS input (namely input for 
        tallies), it is strongly recommended to pass this function the "phits.out" file (`file(6)` in the PHITS 
        [Parameters] section) to have access to the complete input echo including all inserted files.  Note that this 
        function's output will only include files that actually exist.

    Inputs:
        - `phits_input` = string or Path object denoting the path to the PHITS input file to be parsed (or the 
                "phits.out" file, which should be located in the same directory as the PHITS input producing it)
        - `use_path_and_string_mode` = (optional, D=`False`) Boolean for special use case by `parse_phitsout_file()`. 
                If `True`, `phits_input` should instead be a dictionary with the following keys:
               - `'sim_base_dir_path'` : a [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
                   object pointing to the directory containing the PHITS input file and phits.out file
               - `'input_echo'` : a string of the input echo as generated by `parse_phitsout_file()`
        - `only_seek_phitsout` = (optional, D=`False`) Boolean designating if this function should _only_ search for
                the phits.out file and no other outputs.

    Outputs:
        - `files_dict` = a dictionary organizing and listing files that were to be produced by the PHITS input and were 
                found to exist, with the following keys:
            - `'standard_output'` : a list of [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
                    objects pointing to the locations of all of the standard tally output files found.
            - `'dump_output'` : a list of [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
                    objects pointing to the locations of all of the dump tally output files found.
            - `'phitsout'` : a single [`pathlib.Path()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
                    object pointing to the location of the "phits.out" file.
            - `'active_infl_found'` : a Boolean denoting whether an active `infl:{*}` insert file function was found 
                    in the PHITS input.  If `True`, it is possible this files dictionary is missing some produced 
                    output files from tallies / tally portions included in the input file via `infl:{*}`.
                    This should always be `False` when provided a "phits.out" file since it includes inserted file contents. 
        
        To conveniently view the contents of `files_dict`, one can import the built-in
        pprint library `import pprint` and then use `pprint.pp(dict(files_dict))`.
    '''
    '''
    Basically, look for "file =", accounting for presence of "dump =" in the tally and whether "off" is present in the
    tally's first line.  Off tallies should be excluded from the list.  
    Once an initial list of files is formed, search for them actually existing (including dump files / MPI splits) and 
    return a list of the existing files.
    '''
    import io
    def merge_paths_if_not_absolute(known_directory,path_segment):
        if not Path(path_segment).is_absolute():
            path = Path(known_directory,path_segment)
        else:
            path = Path(path_segment)
        if not path.exists():
            path = None
        return path
    if use_path_and_string_mode:
        sim_base_dir_path = phits_input['sim_base_dir_path']
        file = io.StringIO(phits_input['input_echo'])
    else:
        sim_base_dir_path = Path(phits_input).parent
        PHITS_file_type = determine_PHITS_output_file_type(phits_input)
        if PHITS_file_type['is_file6_phitsout_file']:  # if determined to be a phits.out-type file
            file = io.StringIO(parse_phitsout_file(phits_input['input_echo']))
        else:  # otherwise, assume an input file was passed
            file = open(Path(phits_input), 'r', encoding="utf-8")
    files_dict = {'standard_output':[], 'dump_output':[], 'phitsout':None}
    in_valid_tally = False
    in_parameters_section = False
    phitsout_file = None
    tally_out_files = []
    tally_has_dump = []
    # scan tally input for files produced by PHITS
    active_infl_found = False
    if only_seek_phitsout:
        for line in file:
            if len(line.strip())==0: continue
            if line.strip()[0] == '[' and ']' in line: # in a section header block
                in_parameters_section = False
                line_section_title_slug = line.lower().replace(' ', '')
                if '[parameters]' in line_section_title_slug and ']off' not in line_section_title_slug: 
                    in_parameters_section = True
            if in_parameters_section and line.strip()[:7] == 'file(6)':
                    phitsout_file = extract_data_from_header_line(line)[1]
    else:
        for line in file:
            if len(line.strip()) == 0: continue
            if line.strip()[0] in ['$', '#']: continue  # comment line
            if line.lstrip()[0] == 'c' and 'c ' in line[:min(6,len(line))]: continue  # comment line
            if line.strip()[0] == '[' and ']' in line:  # in a section header block
                line_section_title_slug = line.lower().replace(' ', '')
                in_valid_tally = False
                in_parameters_section = False
                if '[t-' in line_section_title_slug and ']off' not in line_section_title_slug:
                    in_valid_tally = True
                    tally_has_dump.append(False)
                elif '[parameters]' in line_section_title_slug and ']off' not in line_section_title_slug:
                    in_parameters_section = True
            elif 'infl:' in line:
                active_infl_found = True
            if in_valid_tally:
                if line.strip()[0] == '#': continue
                if line.strip()[:4]=='file':
                    key, value = extract_data_from_header_line(line)
                    tally_out_files.append(value)
                if line.strip()[:4] == 'dump': 
                    tally_has_dump[-1] = True
            elif in_parameters_section:
                if line.strip()[:7] == 'file(6)':
                    key, value = extract_data_from_header_line(line)
                    phitsout_file = value
    file.close()
    files_dict['active_infl_found'] = active_infl_found
    if phitsout_file is None:
        phitsout_file = 'phits.out'
    files_dict['phitsout'] = merge_paths_if_not_absolute(sim_base_dir_path, phitsout_file)
    if not only_seek_phitsout:
        for i, file in enumerate(tally_out_files):
            file_path = merge_paths_if_not_absolute(sim_base_dir_path, file)
            if file_path is not None:
                files_dict['standard_output'].append(file_path)
                if tally_has_dump[i]:
                    dump_files_for_this_tally = list(set(file_path.parent.glob(file_path.stem+"_dmp*"))-set(file_path.parent.glob(file_path.stem+"*.pickle*")))
                    files_dict['dump_output'] += dump_files_for_this_tally
    return files_dict







if run_with_CLI_inputs:
    def validate_file(arg):
        if (file := Path(arg)).is_file():
            return file
        else:
            if (file := Path(arg)).is_dir():
                return file
            else:
                raise FileNotFoundError(arg)
    parser = argparse.ArgumentParser()
    parser.add_argument("file", type=validate_file, help="path to PHITS output file to parse or directory (or PHITS input or phits.out file) containing files to parse (relative or absolute path)")
    # Flags for standard output files
    parser.add_argument("-g", "--GUI", help="Launch the PHITS Tools GUI and ignore all other command line inputs", action="store_true")
    parser.add_argument("-np", "--disable_PandasDF", help="[standard output] disable automatic creation of Pandas DataFrame of PHITS output", action="store_true")
    parser.add_argument("-na", "--disable_abs_err_calc", help="[standard output] disable automatic calculation of absolute errors", action="store_true")
    parser.add_argument("-lzma", "--use_lzma_compression", help="[standard output] compress tally output pickle with LZMA", action="store_true")
    parser.add_argument("-po", "--parse_phitsout", help="[standard output] parse the phits.out file and add it to the metadata dictionary", action="store_true")
    parser.add_argument("-p", "--plot", help="[standard output] save a plot of the tally's output to a PDF and PNG", action="store_true")
    parser.add_argument("-skip", "--skip_existing_pickles", help="[all output] skip files where output pickle already exists", action="store_true")
    # Not going to add below option. Why would you ever run this in CLI if not trying to generate the pickle file?
    # parser.add_argument("-ns", "--disable_saving_pickle", help="disable saving of pickle of of PHITS output", action="store_true")
    # Flags for dump files
    parser.add_argument("-d", "--is_dump_file", action="store_true", help="add this flag if the file is a dump file, omit if standard PHITS tally output; if inputting a directory path to 'file' (or a PHITS input or phits.out file), this flag specifies that dump files will be read too (by default, they will be skipped), and if so the below flags will be applied to the settings used when parsing them")
    parser.add_argument('-dvals', '--dump_data_sequence', nargs='+', type=int, help='[dump output] provide a series of integers separated by spaces that match the line after "dump = " in the tally whose dump file is being parsed, detailing how the columns of the dump file are to be interpreted. (REQUIRED for dump files, but an attempt to assign automatically will be made first if left unspecified)')
    parser.add_argument("-dbin", "--dump_file_is_binary", action="store_true", help="[dump output] specify that the provided dump file is binary; otherwise it is assumed to be ASCII (REQUIRED for dump files, but an attempt to assign automatically will be made first if left unspecified)")
    parser.add_argument("-dnmax", "--dump_max_entries_read", type=int, help="[dump output] specify maximum integer number of entries to read (read all by default)")
    parser.add_argument("-ddir", "--dump_return_directional_info", action="store_true", help="[dump output] return extra directional information: radial distance r from the origin in cm, radial distance rho from the z-axis in cm, polar angle theta between the direction vector and z-axis in radians [0,pi] (or degrees), and azimuthal angle phi of the direction vector in radians [-pi,pi] (or degrees). Note: This option requires all position and direction values [x,y,z,u,v,w] to be included in the dump file.")
    parser.add_argument("-ddeg", "--dump_use_degrees", action="store_true", help="[dump output] angular quantities will be in degrees instead of radians")
    parser.add_argument("-dnsl", "--dump_no_save_namedtuple_list", action="store_true", help="[dump output] do NOT save parsed dump file info to list of namedtuples to pickle file (-dnsl and -dnsp cannot both be enabled if parsing a dump file)")
    parser.add_argument("-dnsp", "--dump_no_save_Pandas_dataframe", action="store_true", help="[dump output] do NOT save parsed dump file info to Pandas DataFrame to pickle file (-dnsl and -dnsp cannot both be enabled if parsing a dump file)")
    parser.add_argument("-dmaxGB", "--dump_max_GB_per_chunk", type=float, default=20, help="[dump output] split binary dump files larger than inputted number of GB for processing (20 GB by default)")
    parser.add_argument("-dsplit", "--dump_split_handling", type=int, default=0, help="[dump output] (0 by default) how split/merged dump files are handled; enter 0 (merge at end, delete parts), 1 (no merge), or 2 (merge, keep parts)")
    # Flags for processing files in a directory
    parser.add_argument("-dnmmpi", "--dump_no_merge_MPI_dumps", action="store_true", help="[directory with dump output] do NOT merge the dump namedtuple list / Pandas DataFrame pickle files for dump files from the same tally split up by MPI")
    parser.add_argument("-dndmpi", "--dump_no_delete_MPI_subpickles", action="store_true", help="[directory with dump output] do NOT delete the individual dump namedtuple list / Pandas DataFrame pickle files for dump files from the same tally split up by MPI after they have been merged (ignored if -dnmmpi is used)")
    parser.add_argument("-r", "--recursive_search", action="store_true", help="[directory parsing] If the provided 'file' is a directory, also recursively search subdirectories for files to process.")
    parser.add_argument("-fpre", "--file_prefix", default='', help="[directory parsing] A string specifying what characters processed filenames (including the file extension) must begin with to be included. This condition is not enforced if set to an empty string (default).")
    parser.add_argument("-fsuf", "--file_suffix", type=str, default=None, help="[directory parsing] A string specifying what characters processed filenames (including the file extension) must end in to be included. This condition is not enforced if set to an empty string. This is '.out' by deault unless `file` is a PHITS input or phits.out file, in which case it defaults to an empty string.")
    parser.add_argument("-fstr", "--file_required_string", default='', help="[directory parsing] A string which must be present anywhere within processed filenames (including the file extension) to be included. This condition is not enforced if set to an empty string (default).")
    parser.add_argument("-m", "--merge_tally_outputs", help="[directory parsing] merge all tally outputs into a single pickled dictionary object", action="store_true")
    parser.add_argument("-smo", "--save_merged_only", help="[directory parsing] same as -m but saves ONLY this merged pickle file; do not save pickles for each tally output", action="store_true")
    parser.add_argument("-pa", "--plot_all", help="[directory parsing] save plots of all tally outputs to a single PDF", action="store_true")
    
    args = parser.parse_args()

    output_file_path = Path(args.file)
    is_dump_file = args.is_dump_file

    is_path_a_dir = output_file_path.is_dir()
    is_path_a_file = output_file_path.is_file()
    
    if is_path_a_file:
        PHITS_output_file_type = determine_PHITS_output_file_type(output_file_path)
        if PHITS_output_file_type['is_PHITS_input_file'] or PHITS_output_file_type['is_file6_phitsout_file']:
            is_path_a_dir = True
            is_path_a_file = False

    if not is_path_a_file and not is_path_a_dir:
        raise ValueError("ERROR! The inputted filepath is neither a recognized file nor directory.")

    # directory options
    recursive_search = args.recursive_search
    file_suffix = args.file_suffix
    file_prefix = args.file_prefix
    file_reqstr = args.file_required_string

    # Standard output options
    make_PandasDF = not args.disable_PandasDF
    calculate_absolute_errors = not args.disable_abs_err_calc

    # Dump output options
    dump_data_sequence = args.dump_data_sequence
    if dump_data_sequence != None:
        dump_data_number = len(dump_data_sequence)
        if not args.dump_file_is_binary:
            dump_data_number = -1 * dump_data_number
    else:
        dump_data_number = None
    return_namedtuple_list = False
    return_Pandas_dataframe = False
    max_entries_read = args.dump_max_entries_read
    return_directional_info = args.dump_return_directional_info
    use_degrees = args.dump_use_degrees
    no_save_namedtuple_list = args.dump_no_save_namedtuple_list
    no_save_Pandas_dataframe = args.dump_no_save_Pandas_dataframe
    dump_no_merge_MPI_dumps = args.dump_no_merge_MPI_dumps
    dump_no_delete_MPI_subpickles = args.dump_no_delete_MPI_subpickles
    use_lzma_compression = args.use_lzma_compression
    skip_existing_pickles = args.skip_existing_pickles
    dump_max_GB_per_chunk = args.dump_max_GB_per_chunk
    dump_split_handling = args.dump_split_handling
    autoplot_tally_output = args.plot
    autoplot_all_tally_output_in_dir = args.plot_all
    include_phitsout_in_metadata = args.parse_phitsout
    merge_tally_outputs = args.merge_tally_outputs
    save_merged_only = args.save_merged_only
    
    save_namedtuple_list = not no_save_namedtuple_list
    save_Pandas_dataframe = not no_save_Pandas_dataframe
    dump_merge_MPI_subdumps = not dump_no_merge_MPI_dumps
    dump_delete_MPI_subdumps_post_merge = not dump_no_delete_MPI_subpickles
    compress_pickle_with_lzma = use_lzma_compression
    prefer_reading_existing_pickle = skip_existing_pickles
    split_binary_dumps_over_X_GB = dump_max_GB_per_chunk
    merge_split_dump_handling = dump_split_handling
    
    dir_mode_save_output_pickle=True
    save_pickle_of_merged_tally_outputs=None
    if save_merged_only:
        dir_mode_save_output_pickle = False
        merge_tally_outputs = True
        save_pickle_of_merged_tally_outputs = True

    if is_path_a_dir:
        parse_all_tally_output_in_dir(output_file_path, output_file_suffix=file_suffix, output_file_prefix=file_prefix,
                                      output_file_required_string=file_reqstr, include_subdirectories=recursive_search,
                                      return_tally_output=False,
                                      make_PandasDF=make_PandasDF, calculate_absolute_errors=calculate_absolute_errors,
                                      save_output_pickle=dir_mode_save_output_pickle, 
                                      prefer_reading_existing_pickle=prefer_reading_existing_pickle,
                                      compress_pickle_with_lzma=compress_pickle_with_lzma,
                                      autoplot_tally_output=autoplot_tally_output,
                                      include_dump_files=is_dump_file,
                                      dump_data_number=dump_data_number, dump_data_sequence=dump_data_sequence,
                                      dump_return_directional_info=return_directional_info, dump_use_degrees=use_degrees,
                                      dump_max_entries_read=max_entries_read,
                                      dump_save_namedtuple_list=save_namedtuple_list,
                                      dump_save_Pandas_dataframe=save_Pandas_dataframe,
                                      dump_merge_MPI_subdumps=dump_merge_MPI_subdumps,
                                      dump_delete_MPI_subdumps_post_merge=dump_delete_MPI_subdumps_post_merge,
                                      split_binary_dumps_over_X_GB=split_binary_dumps_over_X_GB, 
                                      merge_split_dump_handling=merge_split_dump_handling,
                                      autoplot_all_tally_output_in_dir=autoplot_all_tally_output_in_dir,
                                      include_phitsout_in_metadata=include_phitsout_in_metadata,
                                      merge_tally_outputs=merge_tally_outputs,
                                      save_pickle_of_merged_tally_outputs=save_pickle_of_merged_tally_outputs
                                      )
    else: # if is_path_a_file
        if is_dump_file:
            if dump_data_number == None:
                dump_data_number, dump_data_sequence = search_for_dump_parameters(output_file_path)
                if dump_data_number == None or dump_data_sequence == None:
                    raise ValueError('You MUST provide a space-delimited list of integers to the -dvals / --dump_data_sequence input specifying ' +
                          'how the data columns in the dump file are to be interpreted, the same as the line following "dump = " in your PHITS tally input. ' +
                          'An attempt was made to automatically find these values, but it failed (thus, manual specification is required).')
            if no_save_namedtuple_list and no_save_Pandas_dataframe:
                raise ValueError('You MUST select how the dump file data is to be saved by disabling either or both of the following flags:' +
                      ' -dsl / --dump_save_namedtuple_list AND/OR -dsp / --dump_save_Pandas_dataframe')
            parse_tally_dump_file(output_file_path, dump_data_number, dump_data_sequence,
                                  return_directional_info=return_directional_info, use_degrees=use_degrees,
                                  max_entries_read=max_entries_read,
                                  return_namedtuple_list=return_namedtuple_list,
                                  return_Pandas_dataframe=return_Pandas_dataframe,
                                  save_namedtuple_list=save_namedtuple_list,
                                  save_Pandas_dataframe=save_Pandas_dataframe,
                                  prefer_reading_existing_pickle=prefer_reading_existing_pickle,
                                  split_binary_dumps_over_X_GB=split_binary_dumps_over_X_GB, 
                                  merge_split_dump_handling=merge_split_dump_handling)
        else:
            parse_tally_output_file(output_file_path, make_PandasDF=make_PandasDF,
                                    calculate_absolute_errors=calculate_absolute_errors,
                                    save_output_pickle=True,
                                    include_phitsout_in_metadata=include_phitsout_in_metadata,
                                    prefer_reading_existing_pickle=prefer_reading_existing_pickle,
                                    compress_pickle_with_lzma=compress_pickle_with_lzma,
                                    autoplot_tally_output=autoplot_tally_output)

elif launch_GUI:
    import tkinter as tk
    from tkinter import filedialog
    from tkinter import messagebox
    from tkinter import ttk
    import warnings
    import sys


    # Function to issue a warning on unexpected closure and then exit the program
    def on_closing(window):
        window.destroy()
        warnings.warn("Window closed unexpectedly", UserWarning)
        sys.exit()

    opt3_def_pady = 0

    # Initialize the settings dictionary
    settings = {}

    standard_mode_short_text = "[STANDARD mode]"
    dump_mode_short_text = "[DUMP mode]"
    directory_mode_short_text = "[DIRECTORY mode]"
    input_file_mode_short_text = "[INPUT_FILE mode]"

    standard_mode_full_text = standard_mode_short_text + " for processing a single standard PHITS tally output file"
    dump_mode_full_text = dump_mode_short_text + " for processing a single PHITS tally dump output file (*_dmp.out)"
    directory_mode_full_text = directory_mode_short_text + " for processing all PHITS output files in a directory"
    input_file_mode_full_text = input_file_mode_short_text + " for processing all PHITS output files generated by \na single PHITS run (as identified in the PHITS input or its phits.out file)"


    def on_option_selected():
        option = selected_option.get()
        file_chosen = None

        try:
            if option in [1, 2]:
                if option == 1:
                    window_name_str = 'Select standard PHITS tally output file'
                else:  #  option == 2
                    window_name_str = 'Select PHITS tally dump output file'
                file_chosen = filedialog.askopenfilename(title=window_name_str)
                if not file_chosen:
                    raise ValueError("File selection is required")
                settings['file'] = file_chosen
            elif option in [3, 4]:
                if option == 3:
                    directory_chosen = filedialog.askdirectory(title="Select Directory of PHITS outputs to parse")
                    if not directory_chosen:
                        raise ValueError("Directory selection is required")
                else:   # option == 4
                    directory_chosen = filedialog.askopenfilename(title='Select PHITS input file or its "phits.out" file')
                    if not directory_chosen:
                        raise ValueError("File selection is required")
                settings['directory'] = directory_chosen
        except:
            raise ValueError("User closed the file/directory dialog")
        else:
            root.withdraw()
            create_secondary_gui(option)


    def create_secondary_gui(option):
        secondary_gui = tk.Toplevel(root)
        if option in [3, 4]: # This window tends to be tall and should be placed at top of screen
            x = root.winfo_x()
            # y = root.winfo_y()
            secondary_gui.geometry("+%d+%d" % (x + 10, 10))

        def on_closing_secondary_gui():
            on_closing(secondary_gui)

        secondary_gui.protocol("WM_DELETE_WINDOW", on_closing_secondary_gui)



        inputs = {
            1: ['Checkbox 1', 'Checkbox 2', 'Checkbox 3'],
            2: ['Checkbox A', 'Checkbox B', 'Radio 1', 'Radio 2', 'Radio 3', 'Input 1 (str)', 'Input 2 (int)',
                'Input 3 (int)'],
            3: ['Checkbox 1', 'Checkbox 2', 'Checkbox 3', 'Checkbox A', 'Checkbox B', 'Radio 1', 'Radio 2', 'Radio 3',
                'Input 1 (str)', 'Input 2 (int)', 'Input 3 (int)', 'Input 4 (str)', 'Input 5 (str)', 'Input 6 (str)',
                'Extra Checkbox 1', 'Extra Checkbox 2']
        }
        inputs[4] = inputs[3]

        def save_settings():
            settings.update({
                'main_mode': selected_option.get()
            })
            if option == 1:
                settings.update({
                    'option_1_cb1': cb1_var.get(),
                    'option_1_cb2': cb2_var.get(),
                    'option_1_cb3': cb3_var.get(),
                    'option_1_cb4': cb4_var.get()
                })
            elif option == 2:
                settings.update({
                    'option_2_cb1': cb1_var.get(),
                    'option_2_cb2': cb2_var.get(),
                    'radio': radio_var.get(),
                    'input_str': entry_str.get() or None,
                    'input_int1': entry_int1.get() or None,
                    'input_int2': entry_int2.get() or None,
                })
            elif option in [3, 4]:
                settings.update({
                    'option_3_cb1': cb1_var.get(), 'option_3_cb2': cb2_var.get(),
                    'option_3_cb3': cb3_var.get(), 'option_3_cb4': cb4_var.get(),
                    'option_3_cb5': cb5_var.get(), 'option_3_cb6': cb6_var.get(),
                    'option_3_cb7': cb7_var.get(), 'option_3_cb8': cb8_var.get(),
                    'radio': radio_var.get(),
                    'input_str_1': secondary_entry_str1.get() or None,
                    'input_int_1': secondary_entry_int1.get() or None,
                    'input_int_2': secondary_entry_int2.get() or None,
                    'input_str_2': secondary_entry_str2.get() or None,
                    'input_str_3': secondary_entry_str3.get() or None,
                    'input_str_6': extra_entry_str1.get() or None,  # Renamed 'Extra Input 1' to 'Input 6'
                    'extra_cb1': extra_cb1_var.get(),
                    'extra_cb2': extra_cb2_var.get(),
                })
            secondary_gui.destroy()
            root.destroy()  # Ensure root window is destroyed after closing secondary GUI

        common_widgets = []

        if option == 1:
            sample_text_label = tk.Label(secondary_gui, text=standard_mode_full_text,
                                         anchor=tk.W, font='16')
            sample_text_label.pack(anchor=tk.W, padx=10, pady=2)
            cb1_var = tk.BooleanVar()
            cb2_var = tk.BooleanVar()
            cb3_var = tk.BooleanVar()
            cb4_var = tk.BooleanVar()
            common_widgets.append(tk.Checkbutton(secondary_gui, text="Also make and save Pandas DataFrame object of results (in addition to default NumPy array)", variable=cb1_var, anchor=tk.W))
            common_widgets[-1].select()  # This makes the checkbox be ticked by default
            common_widgets.append(tk.Checkbutton(secondary_gui, text="Also calculate absolute uncertainties", variable=cb2_var, anchor=tk.W))
            common_widgets[-1].select()  # This makes the checkbox be ticked by default
            common_widgets.append(tk.Checkbutton(secondary_gui, text='Also include metadata from the "phits.out" file', variable=cb4_var, anchor=tk.W))
            common_widgets[-1].select()  # This makes the checkbox be ticked by default
            common_widgets.append(tk.Checkbutton(secondary_gui, text="Also make a plot of the tally's output and save it as a PDF and PNG", variable=cb3_var, anchor=tk.W))
            common_widgets[-1].select()  # This makes the checkbox be ticked by default

        elif option == 2:
            sample_text_label = tk.Label(secondary_gui, text=dump_mode_full_text,
                                         anchor=tk.W, font='16')
            sample_text_label.pack(anchor=tk.W, padx=10, pady=2)
            cb1_var = tk.BooleanVar()
            cb2_var = tk.BooleanVar()
            radio_var = tk.IntVar(value=3)
            entry_str = tk.Entry(secondary_gui, width=50)
            entry_int1 = tk.Entry(secondary_gui)
            entry_int2 = tk.Entry(secondary_gui)

            dir_info_str = "Return extra directional information (relative to the origin and z-axis); \nthis requires all position and direction values [x,y,z,u,v,w] to be included in the dump file."
            common_widgets.append(tk.Checkbutton(secondary_gui, text=dir_info_str, variable=cb1_var, anchor=tk.W, justify='left'))
            common_widgets.append(tk.Checkbutton(secondary_gui, text="Use degrees (instead of radians) for extra directional information", variable=cb2_var, anchor=tk.W))

            options_frame = tk.LabelFrame(secondary_gui, text="Data output format options")
            options_frame.pack(padx=10, pady=10, anchor=tk.W)

            tk.Radiobutton(options_frame, text="Save only a pickle file of a list of namedtuples with dump event information", variable=radio_var, value=1, anchor=tk.W).pack(anchor=tk.W)
            tk.Radiobutton(options_frame, text="Save only a pickle file of a Pandas DataFrame of dump event information", variable=radio_var, value=2, anchor=tk.W).pack(anchor=tk.W)
            tk.Radiobutton(options_frame, text="Save both the namedtuples list pickle file and the Pandas DataFrame pickle file", variable=radio_var, value=3, anchor=tk.W).pack(anchor=tk.W)

            dump_instrcutions = 'If in the same directory as your dump file exists the corresponding standard tally output file,\n' + \
                                'and the only difference in their file names is the "_dmp" at the end of the dump file, the \n' + \
                                'below two fields can be left blank as PHITS Tools should automatically find this information.\n' + \
                                'Otherwise, in the below two boxes, place what you entered following "dump = " in your PHITS tally.\n' + \
                                'In the first box, enter a nonzero integer between -20 and 20 specifying the number of dump\n' + \
                                'columns and whether the data will be in ASCII (<0) or binary (>0) format.\n' + \
                                'In the second box, enter a sequence of that many numbers, separated by spaces, describing \n' + \
                                'the column order of the dump file.'
            common_widgets.append(tk.Label(secondary_gui, text=dump_instrcutions, anchor=tk.W, justify="left"))
            common_widgets.append(entry_int1)
            #common_widgets.append(tk.Label(secondary_gui, text="Input 1 (string)", anchor=tk.W))
            common_widgets.append(entry_str)



            common_widgets.append(tk.Label(secondary_gui, text="\nMaximum number of dump entries to read. Leave blank to read all.", anchor=tk.W))
            common_widgets.append(entry_int2)

        elif option in [3, 4]:
            cb1_var = tk.BooleanVar()
            cb2_var = tk.BooleanVar()
            cb3_var = tk.BooleanVar()
            cb4_var = tk.BooleanVar()
            cb5_var = tk.BooleanVar()
            cb6_var = tk.BooleanVar()
            cb7_var = tk.BooleanVar()
            cb8_var = tk.BooleanVar()
            radio_var = tk.IntVar(value=3)

            secondary_entry_str1 = tk.Entry(secondary_gui, width=50)  # Extra width added here
            secondary_entry_int1 = tk.Entry(secondary_gui)
            secondary_entry_int2 = tk.Entry(secondary_gui)
            secondary_entry_str2 = tk.Entry(secondary_gui)
            if option == 3:
                secondary_entry_str2.insert(0, ".out") # this is how default values have to be specified for tkinter...
            secondary_entry_str3 = tk.Entry(secondary_gui)

            extra_entry_str1 = tk.Entry(secondary_gui)
            extra_cb1_var = tk.BooleanVar()
            extra_cb2_var = tk.BooleanVar()

            # Add extra sample text label at the top of the secondary GUI
            if option == 3:
                top_sample_label = tk.Label(secondary_gui, text=directory_mode_full_text, anchor=tk.W, font='16')
            else:  # option == 4
                top_sample_label = tk.Label(secondary_gui, text=input_file_mode_short_text+' for processing all output files in a PHITS run', anchor=tk.W, font='16')
            top_sample_label.pack(anchor=tk.W, padx=10, pady=10)

            common_widgets.append(tk.Checkbutton(secondary_gui, text="Also include contents of all subdirectories", variable=cb1_var, anchor=tk.W))
            common_widgets.append(tk.Checkbutton(secondary_gui, text="Include dump files (otherwise, they will be skipped)", variable=cb2_var, anchor=tk.W))
            common_widgets.append(tk.Checkbutton(secondary_gui, text="Merge all tally outputs into a combined dictionary object", variable=cb8_var, anchor=tk.W))
            common_widgets.append(tk.Checkbutton(secondary_gui, text="Save plots of all tally outputs to a single PDF", variable=cb6_var, anchor=tk.W))
            common_widgets[-1].select()  # This makes the checkbox be ticked by default

        # Pack common widgets with left alignment.
        for widget in common_widgets:
            widget.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)

        if option in [3, 4]:
            name_instructions_str = 'In the below 3 fields, specify what characters processed filenames (including the file extension)\n' + \
                                    'must either end with, start with, or contain in order to be processed. Leave blank to ignore.'
            tk.Label(secondary_gui, text=name_instructions_str, anchor=tk.W, justify='left').pack(anchor=tk.W, padx=10, pady=opt3_def_pady)

            tk.Label(secondary_gui, text="End of filename character string (suffix)", anchor=tk.W).pack(anchor=tk.W, padx=10, pady=opt3_def_pady)
            secondary_entry_str2.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)

            tk.Label(secondary_gui, text="Start of filename character string (prefix)", anchor=tk.W).pack(anchor=tk.W, padx=10, pady=opt3_def_pady)
            secondary_entry_str3.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)

            tk.Label(secondary_gui, text="String which must appear in filename (anywhere)", anchor=tk.W).pack(anchor=tk.W, padx=10, pady=opt3_def_pady)
            extra_entry_str1.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)


            # Add horizontal separator immediately beneath "Checkbox 2"
            separator = ttk.Separator(secondary_gui, orient='horizontal')
            separator.pack(fill=tk.X, padx=10, pady=10)

            sample_text_label2 = tk.Label(secondary_gui, text="Options for processing standard PHITS tally output files",
                                         anchor=tk.W, font='14')
            sample_text_label2.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)

            cb3obj = tk.Checkbutton(secondary_gui, text="Also make and save Pandas DataFrame object of results (in addition to default NumPy array)", variable=cb3_var, anchor=tk.W)
            cb3obj.select() # This makes the checkbox be ticked by default
            cb3obj.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)
            cb4obj = tk.Checkbutton(secondary_gui, text="Also calculate absolute uncertainties", variable=cb4_var, anchor=tk.W)
            cb4obj.select() # This makes the checkbox be ticked by default
            cb4obj.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)
            cb7obj = tk.Checkbutton(secondary_gui, text='Also include metadata from the "phits.out" file', variable=cb7_var, anchor=tk.W)
            cb7obj.select()  # This makes the checkbox be ticked by default
            cb7obj.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)
            cb5obj = tk.Checkbutton(secondary_gui, text="Also make a plot of each tally's output and save it as a PDF and PNG", variable=cb5_var, anchor=tk.W)
            cb5obj.select()  # This makes the checkbox be ticked by default
            cb5obj.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)

            options_frame = tk.LabelFrame(secondary_gui, text="Data output format options for dump files")
            tk.Radiobutton(options_frame, text="Save only a pickle file of a list of namedtuples with dump event information", variable=radio_var, value=1, anchor=tk.W).pack(anchor=tk.W, pady=opt3_def_pady)
            tk.Radiobutton(options_frame, text="Save only a pickle file of a Pandas DataFrame of dump event information", variable=radio_var, value=2, anchor=tk.W).pack(anchor=tk.W, pady=opt3_def_pady)
            tk.Radiobutton(options_frame, text="Save both the namedtuples list pickle file and the Pandas DataFrame pickle file", variable=radio_var, value=3, anchor=tk.W).pack(anchor=tk.W, pady=opt3_def_pady)



            # Add horizontal separator immediately beneath "Input 3 (integer)"
            separator_1 = ttk.Separator(secondary_gui, orient='horizontal')
            separator_1.pack(fill=tk.X, padx=10, pady=10)

            sample_text_label = tk.Label(secondary_gui, text="Options for processing PHITS tally dump output files",
                                         anchor=tk.W, font='14')
            sample_text_label.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)

            options_frame.pack(padx=10, pady=10, anchor=tk.W) # radio buttons

            dir_info_str = "Return extra directional information (relative to the origin and z-axis); \nthis requires all position and direction values [x,y,z,u,v,w] to be included in the dump file."
            tk.Checkbutton(secondary_gui, text=dir_info_str, variable=extra_cb1_var, anchor=tk.W, justify='left').pack(
                anchor=tk.W, padx=10, pady=opt3_def_pady)
            tk.Checkbutton(secondary_gui, text="Use degrees (instead of radians) for extra directional information", variable=extra_cb2_var, anchor=tk.W).pack(
                anchor=tk.W, padx=10, pady=opt3_def_pady)

            dump_instrcutions = 'If in the same directory as the found dump file exists the corresponding standard tally output file,\n' + \
                                'and the only difference in their file names is the "_dmp" at the end of the dump file, the \n' + \
                                'below two fields can be left blank as PHITS Tools should automatically find this information.\n' + \
                                'Otherwise, in the below two boxes, place what you entered following "dump = " in your PHITS tally.\n' + \
                                'In the first box, enter a nonzero integer between -20 and 20 specifying the number of dump\n' + \
                                'columns and whether the data will be in ASCII (<0) or binary (>0) format.\n' + \
                                'In the second box, enter a sequence of that many numbers, separated by spaces, describing \n' + \
                                'the column order of the dump file.'

            tk.Label(secondary_gui, text=dump_instrcutions, anchor=tk.W, justify='left').pack(anchor=tk.W, padx=10, pady=opt3_def_pady)
            secondary_entry_int1.pack(anchor=tk.W, padx=10, pady=opt3_def_pady+1)
            #tk.Label(secondary_gui, text="Input 1 (string)", anchor=tk.W).pack(anchor=tk.W, padx=10, pady=2)
            secondary_entry_str1.pack(anchor=tk.W, padx=10, pady=opt3_def_pady+1)



            tk.Label(secondary_gui, text="\nMaximum number of dump entries to read. Leave blank to read all.", anchor=tk.W).pack(anchor=tk.W, padx=10, pady=2)
            secondary_entry_int2.pack(anchor=tk.W, padx=10, pady=opt3_def_pady)



        save_btn = tk.Button(secondary_gui, text="Run PHITS Tools with selected settings", command=save_settings)
        save_btn.pack(pady=10)


    root = tk.Tk()
    #root.eval('tk::PlaceWindow . center')
    #root.geometry("+%d+%d" % (30, 10))
    
    root.title('PHITS Tools')

    # protocol for main menu window to issue warning and exit if closed
    root.protocol("WM_DELETE_WINDOW", lambda: on_closing(root))

    selected_option = tk.IntVar(value=1)

    sample_text_label2 = tk.Label(text="Select what mode PHITS Tools should be ran in:",anchor=tk.W,font='16')
    sample_text_label2.pack(anchor=tk.W, padx=10, pady=2)

    tk.Radiobutton(root, text=standard_mode_full_text, variable=selected_option, value=1).pack(anchor=tk.W)
    tk.Radiobutton(root, text=dump_mode_full_text, variable=selected_option, value=2).pack(anchor=tk.W)
    tk.Radiobutton(root, text=directory_mode_full_text, variable=selected_option, value=3).pack(anchor=tk.W)
    tk.Radiobutton(root, text=input_file_mode_full_text, variable=selected_option, value=4).pack(anchor=tk.W)

    confirm_btn = tk.Button(root, text="Select", command=on_option_selected)
    confirm_btn.pack(pady=4)

    root.mainloop()

    # Print final settings dictionary
    if in_debug_mode:
        print("Settings:", settings)

    if settings['main_mode'] == 1: # standard tally mode
        make_PandasDF = settings['option_1_cb1']
        calculate_absolute_errors = settings['option_1_cb2']
        autoplot_tally_output = settings['option_1_cb3']
        include_phitsout_in_metadata = settings['option_1_cb4']
        parse_tally_output_file(Path(settings['file']), make_PandasDF=make_PandasDF,
                                calculate_absolute_errors=calculate_absolute_errors,
                                autoplot_tally_output=autoplot_tally_output,
                                include_phitsout_in_metadata=include_phitsout_in_metadata,
                                save_output_pickle=True, prefer_reading_existing_pickle=False)

    elif settings['main_mode'] == 2:  # dump tally mode
        output_file_path = Path(settings['file'])
        return_directional_info = settings['option_2_cb1']
        use_degrees = settings['option_2_cb2']
        save_namedtuple_list = False
        save_Pandas_dataframe = False
        if settings['radio'] == 1:
            save_namedtuple_list = True
        elif settings['radio'] == 2:
            save_Pandas_dataframe = True
        elif settings['radio'] == 3:
            save_namedtuple_list = True
            save_Pandas_dataframe = True
        dump_data_number = settings['input_int1']
        if dump_data_number != None: dump_data_number = int(dump_data_number)
        dump_data_sequence = settings['input_str']
        max_entries_read = settings['input_int2']
        if max_entries_read != None: max_entries_read = int(max_entries_read)

        if dump_data_number == None:
            dump_data_number, dump_data_sequence = search_for_dump_parameters(output_file_path)
            if dump_data_number == None or dump_data_sequence == None:
                raise ValueError(
                    'You MUST provide a space-delimited list of integers to the -dvals / --dump_data_sequence input specifying ' +
                    'how the data columns in the dump file are to be interpreted, the same as the line following "dump = " in your PHITS tally input. ' +
                    'An attempt was made to automatically find these values, but it failed (thus, manual specification is required).')
        parse_tally_dump_file(output_file_path, dump_data_number, dump_data_sequence,
                              return_directional_info=return_directional_info, use_degrees=use_degrees,
                              max_entries_read=max_entries_read,
                              return_namedtuple_list=False,
                              return_Pandas_dataframe=False,
                              save_namedtuple_list=save_namedtuple_list,
                              save_Pandas_dataframe=save_Pandas_dataframe)

    elif settings['main_mode'] == 3 or settings['main_mode'] == 4:  # directory mode or input file mode
        recursive_search = settings['option_3_cb1']
        include_dump_files = settings['option_3_cb2']
        make_PandasDF = settings['option_3_cb3']
        calculate_absolute_errors = settings['option_3_cb4']
        autoplot_tally_output = settings['option_3_cb5']
        autoplot_all_tally_output_in_dir = settings['option_3_cb6']
        include_phitsout_in_metadata = settings['option_3_cb7']
        merge_tally_outputs = settings['option_3_cb8']
        file_suffix = settings['input_str_2']
        if file_suffix == None: file_suffix = ''
        file_prefix = settings['input_str_3']
        if file_prefix == None: file_prefix = ''
        file_reqstr = settings['input_str_6']
        if file_reqstr == None: file_reqstr = ''

        save_namedtuple_list = False
        save_Pandas_dataframe = False
        if settings['radio'] == 1:
            save_namedtuple_list = True
        elif settings['radio'] == 2:
            save_Pandas_dataframe = True
        elif settings['radio'] == 3:
            save_namedtuple_list = True
            save_Pandas_dataframe = True
        dump_data_sequence = settings['input_str_1']
        dump_data_number = settings['input_int_1']
        if dump_data_number != None: dump_data_number = int(dump_data_number)
        max_entries_read = dump_data_number = settings['input_int_2']
        if max_entries_read != None: max_entries_read = int(max_entries_read)
        return_directional_info = settings['extra_cb1']
        use_degrees = settings['extra_cb2']

        parse_all_tally_output_in_dir(Path(settings['directory']),
                                      output_file_suffix=file_suffix, output_file_prefix=file_prefix,
                                      output_file_required_string=file_reqstr, include_subdirectories=recursive_search,
                                      autoplot_all_tally_output_in_dir=autoplot_all_tally_output_in_dir,
                                      return_tally_output=False,
                                      make_PandasDF=make_PandasDF, calculate_absolute_errors=calculate_absolute_errors,
                                      save_output_pickle=True, prefer_reading_existing_pickle=False,
                                      autoplot_tally_output=autoplot_tally_output,
                                      include_dump_files=include_dump_files,
                                      dump_data_number=dump_data_number, dump_data_sequence=dump_data_sequence,
                                      dump_return_directional_info=return_directional_info,
                                      dump_use_degrees=use_degrees,
                                      dump_max_entries_read=max_entries_read,
                                      dump_save_namedtuple_list=save_namedtuple_list,
                                      dump_save_Pandas_dataframe=save_Pandas_dataframe,
                                      include_phitsout_in_metadata=include_phitsout_in_metadata,
                                      merge_tally_outputs=merge_tally_outputs
                                      )

    else:
        raise ValueError('ERROR: Main mode for PHITS Tools not selected correctly in first GUI')








elif test_explicit_files_dirs:
    base_path = r'G:\Cloud\OneDrive\work\PHITS\test_tallies\tally\\'
    #output_file_path = Path(base_path + 't-deposit\deposit_reg.out')
    #output_file_path = Path(base_path + 't-deposit\deposit_eng_sp-reg.out')
    output_file_path = Path(base_path + 't-track\\track_reg.out')
    #output_file_path = Path(base_path + 't-track\\track_r-z.out')
    #output_file_path = Path(base_path + 't-track\\track_xyz-xy.out')
    #output_file_path = Path(base_path + r't-track\track_r-z_axis-rad.out')
    #output_file_path = Path(base_path + r't-track\track_r-z_axis-deg.out')
    #output_file_path = Path(base_path + 't-deposit\deposit_r-z.out')
    #output_file_path = Path(base_path + 't-deposit\deposit_r-z_2dtype4.out')
    #output_file_path = Path(base_path + 't-deposit\deposit_r-z_2dtype5.out')
    #output_file_path = Path(base_path + 't-deposit\deposit_xyz_2dtype5.out')
    #output_file_path = Path(base_path + 'tet_test\deposit-tet_axis-tet.out')
    #output_file_path = Path(base_path + 'tet_test\deposit-tet_axis-eng.out')
    #output_file_path = Path(base_path + 't-cross\cross_reg_axis-eng.out')
    #output_file_path = Path(base_path + 't-cross\cross_reg_axis-reg.out')
    #output_file_path = Path(base_path + 't-cross\cross_xyz_axis-eng.out')
    #output_file_path = Path(base_path + 't-cross\cross_xyz_axis-eng_enclosed.out')
    ###output_file_path = Path(base_path + 't-cross\cross_xyz_axis-reg.out')
    #output_file_path = Path(base_path + 't-cross\cross_xyz_axis-xy.out')
    #output_file_path = Path(base_path + 't-cross\cross-r-z_axis-eng.out')
    #output_file_path = Path(base_path + 't-cross\cross-r-z_axis-eng_0r.out')
    #output_file_path = Path(base_path + 't-cross\cross-r-z_axis-eng_enclosed.out')
    #output_file_path = Path(base_path + 't-cross\complex\proton_in_hist_rz.out')
    #output_file_path = Path(base_path + 't-cross\complex\\neutron_yield_rz-e-a-mesh.out')
    #output_file_path = Path(base_path + 't-cross\complex\\neutron_yield.out')
    #output_file_path = Path(base_path + 't-cross\complex\\xtra_neutron_yield_EvsTheta_whole-target.out')
    #output_file_path = Path(base_path + 't-dpa\dpa_reg.out')
    #output_file_path = Path(base_path + 't-dpa\dpa_xyz.out')
    #output_file_path = Path(base_path + 't-dpa\dpa_r-z.out')
    #output_file_path = Path(base_path + 'samepage\\proton_in_hist_rz_axis-eng_samepage-z.out')
    #output_file_path = Path(base_path + 'samepage\\proton_in_hist_rz_reduced.out') # has NULL characters in it
    #output_file_path = Path(base_path + 'samepage\\proton_in_hist_rz_sp-eng.out')
    #output_file_path = Path(base_path + 't-deposit2\deposit2_reg.out')
    #output_file_path = Path(base_path + 't-deposit2\deposit2_reg_axis-e21.out')
    #output_file_path = Path(base_path + 't-deposit2\deposit2_reg_axis-t-e1.out')
    #output_file_path = Path(base_path + 't-deposit2\deposit2_reg_axis-t-e2.out')
    #output_file_path = Path(base_path + 't-deposit2\deposit2_reg_axis-e1-t.out')
    #output_file_path = Path(base_path + 't-deposit2\deposit2_reg_axis-e2-t.out')
    #output_file_path = Path(base_path + 't-deposit2\deposit2_reg_axis-eng1.out')
    #output_file_path = Path(base_path + 't-deposit2\deposit2_reg_axis-eng2.out')
    #output_file_path = Path(base_path + 't-heat\heat_reg.out')
    #output_file_path = Path(base_path + 't-heat\heat_xyz.out')
    #output_file_path = Path(base_path + 't-interact\interact_reg.out')
    #output_file_path = Path(base_path + 't-interact\interact_reg_axis-act.out')
    #output_file_path = Path(base_path + 't-interact\interact_xyz.out')
    #output_file_path = Path(base_path + 't-let\let-distribution_reg.out')
    #output_file_path = Path(base_path + 't-let\let-distribution_r-z.out')
    #output_file_path = Path(base_path + r't-point\point.out')
    #output_file_path = Path(base_path + r't-point\ring.out')
    #output_file_path = Path(base_path + 't-product\product_reg.out')
    #output_file_path = Path(base_path + 't-sed\y-distribution_reg.out')
    #output_file_path = Path(base_path + 't-sed\y-distribution_xyz.out')
    #output_file_path = Path(base_path + r't-time\time_reg.out')
    #output_file_path = Path(base_path + r't-time\time_xyz.out')
    #output_file_path = Path(base_path + r't-yield\yield_reg_axis-charge.out')
    #output_file_path = Path(base_path + r't-yield\yield_reg_axis-mass.out')
    #output_file_path = Path(base_path + r't-yield\yield_reg_axis-chart.out')
    #output_file_path = Path(base_path + r't-yield\yield_xyz_axis-chart.out')

    #base_path = r'G:\Cloud\OneDrive\work\PHITS\test_tallies\\'
    #output_file_path = Path(base_path + r'tally\t-deposit\deposit_reg_spec-all.out')
    #output_file_path = Path(base_path + r'sample\icrp\mrcp\External\result\Dose_MRCP-AF_reg.out')
    #output_file_path = Path(base_path + r'sample\misc\batch_source\track_yz_001.out')
    #output_file_path = Path(base_path + r'sample\source\Cosmicray\GCR-ground\cross.out')
    #output_file_path = Path(base_path + r'recommendation\Fusion\track.out')
    #output_file_path = Path(base_path + r'sample\benchmark\Iwamoto-JNST2022\case5-isis800\output\rr_air_model.out')
    #output_file_path = Path(base_path + r'recommendation\muon\product.out')
    #output_file_path = Path(base_path + r'recommendation\BNCT\dose.out')
    #output_file_path = Path(base_path + r'recommendation\SemiConductor\deposit.out')
    #output_file_path = Path(base_path + r'recommendation\Shielding\track-rz.out')
    #output_file_path = Path(base_path + r'recommendation\TrackStructure\interact.out')
    #output_file_path = Path(base_path + r'recommendation\CosmicRay\dose.out') # single value output
    #output_file_path = Path(base_path + r'recommendation\CosmicRay\track.out') 
    #output_file_path = Path(base_path + r'sample\source\Cosmicray\Airshower\depthdose.out') 
    #output_file_path = Path(base_path + r'sample\misc\history_counter\track_yz_ch1.out')
    #output_file_path = Path(base_path + r'recommendation\Counter\track.out')
    #output_file_path = Path(base_path + r'MPI_dumps\\CT_1D_phantom\\neutron_yield.out')

    phitsout_file_path = Path(output_file_path.parent, 'phits.out')
    input_file_path = Path(r'G:\Cloud\OneDrive\work\PHITS\test_tallies\MPI_dumps\CT_1D_phantom\beam-on-target_phits-input.inp')
    #input_file_path = Path(r'G:\Cloud\OneDrive\work\PHITS\test_tallies\tally\t-cross\complex\beam-on-target.inp')
    phitsout_file_path = Path(input_file_path.parent, 'phits.out')

    test_parsing_of_dir = False #True
    if test_parsing_of_dir:
        dir_path = output_file_path = Path(base_path + r't-cross\complex\proton_in_hist_rz.out')
        dir_output_list = parse_all_tally_output_in_dir(dir_path)
        print(dir_output_list)
        sys.exit()

    test_parsing_of_dir_with_merge = False  # True
    if test_parsing_of_dir_with_merge:
        dir_path = output_file_path = Path(base_path + r't-cross\complex\proton_in_hist_rz.out')
        #dir_path = output_file_path = Path(base_path + r't-cross\complex\beam-on-target.inp')
        dir_output_dict = parse_all_tally_output_in_dir(dir_path, 
                                                        merge_tally_outputs=True, 
                                                        return_tally_output=True,
                                                        compress_pickle_with_lzma=True,
                                                        #include_phitsout_in_metadata=True,
                                                        include_subdirectories=False,
                                                        prefer_reading_existing_pickle=False,
                                                        save_output_pickle=False,
                                                        save_pickle_of_merged_tally_outputs=True
                                                        )
        print(dir_output_dict.keys())
        print(dir_output_dict['energy_deposit.out']['tally_metadata'].keys())
        #pprint.pp(dir_output_dict)
        sys.exit()
    
    test_large_dump_splitting = False
    if test_large_dump_splitting:
        base_path = r'G:\Cloud\OneDrive\work\PHITS\test_tallies\\'
        dump_file_path = Path(base_path + r'big_dumps\\proton_track_dmp.out')
        print('Elapsed time before reading dump file: {:0.2f}'.format(time.time() - start))
        x = parse_tally_dump_file(dump_file_path, save_namedtuple_list=True, save_Pandas_dataframe=True, 
                                  return_namedtuple_list=False, return_Pandas_dataframe=False)
        print('Elapsed time after saving dump file output containers: {:0.2f}'.format(time.time() - start))
        sys.exit()
    
    test_parsing_of_MPI_dump_dir = False
    if test_parsing_of_MPI_dump_dir:
        base_path = r'G:\Cloud\OneDrive\work\PHITS\test_tallies\\'
        dir_path = output_file_path = Path(base_path + r'MPI_dumps\\CT_1D_phantom\\')
        #dump_file_path = Path(dir_path, 'gamma_yield_dmp.out.004')
        #print(dump_file_path.is_file(),dump_file_path)
        #nt_list, df = parse_tally_dump_file(dump_file_path, save_namedtuple_list=True, save_Pandas_dataframe=True)
        dir_output_list = parse_all_tally_output_in_dir(dir_path, include_dump_files=True)
        print(dir_output_list)
        sys.exit()

    test_dump_file = False
    if test_dump_file:
        #dump_file_path = Path(base_path + 't-cross\complex\\neutron_yield_dmp.out')
        #dump_control_str = '2   3   4   5   6   7   8  10'
        dump_file_path = Path(base_path + r'tally\\t-product\\dump\\product_dmp.out')
        dump_control_str = '1  2 3 4 8 10 9  17   18    19    20'
        #nt_list, df = parse_tally_dump_file(dump_file_path,8,dump_control_str, save_namedtuple_list=True, save_Pandas_dataframe=True)
        # test automatic finding of dump parameters
        print('Elapsed time before reading dump file: {:0.2f}'.format(time.time() - start))
        nt_list, df = parse_tally_dump_file(dump_file_path, save_namedtuple_list=True, save_Pandas_dataframe=True)
        print('Elapsed time after saving dump file output containers: {:0.2f}'.format(time.time() - start))

        # test dill of namedtuple list
        #import dill
        import pickle
        import lzma
        #path_to_pickle_file = Path(base_path + 't-cross\complex\\neutron_yield_dmp_namedtuple_list.dill.xz')
        path_to_pickle_file = Path(base_path + r'tally\\t-product\\dump\\product_dmp_namedtuple_list.pickle.xz')
        with lzma.open(path_to_pickle_file, 'rb') as handle:
            nt_list_dill = pickle.load(handle)

        print(nt_list[0])
        print(nt_list_dill[0])
        print(nt_list_dill[0].kf, nt_list[0].kf)
        print(nt_list_dill[0]['kf']) #, nt_list[0]['kf'])
        print(nt_list_dill.dtype.names)

        if [tuple(t) for t in nt_list]==[tuple(t) for t in nt_list_dill]: print('It works!')

        sys.exit()
        
        
    testing_phitsout_parsing = False
    if testing_phitsout_parsing:
        phitsout_meta = parse_phitsout_file(phitsout_file_path)
        pprint.pp(dict(phitsout_meta))
        sys.exit()
    
    testing_phits_input_parsing = False
    if testing_phits_input_parsing:
        x = extract_tally_outputs_from_phits_input(input_file_path)
        pprint.pp(x)
        phitsout_file_path = Path(input_file_path.parent, 'phits.out')
        y = extract_tally_outputs_from_phits_input(phitsout_file_path)
        pprint.pp(y)
        print(x==y)
        sys.exit()
        
    testing_dir_mode_with_phits_input = False 
    if testing_dir_mode_with_phits_input:
        x = parse_all_tally_output_in_dir(phitsout_file_path, 
                                          include_dump_files=False,
                                          include_phitsout_in_metadata=None,
                                          autoplot_all_tally_output_in_dir=False)
        print(x)
        sys.exit()
    
    test_user_support_sims = False 
    if test_user_support_sims:
        tally_output_filepath = Path(r'G:\Cloud\OneDrive\work\PHITS\user_support_simulations\2025-05-21 Hamed PHITS Tools\flux_1_1_2_Spect.out')
        results_dict = parse_tally_output_file(tally_output_filepath)
        tally_df = results_dict['tally_dataframe']
        print(tally_df.to_string())
        sys.exit()
    

    tally_output_filepath = output_file_path
    tally_output = parse_tally_output_file(tally_output_filepath, make_PandasDF=True, calculate_absolute_errors=True,
                                           save_output_pickle=True)
    tally_data = tally_output['tally_data']
    tally_metadata = tally_output['tally_metadata']
    tally_df = tally_output['tally_dataframe']
    
    pprint.pp(dict(tally_metadata))
    
    testing_autoplot = False 
    if testing_autoplot:
        import matplotlib.pyplot as plt
        fg_list = autoplot_tally_results(tally_output, return_fg_list=True) #, additional_save_extensions=['.png','jpeg'])
        #fg = fg_list[0]
        #for ax in fg.axes.flat:
        #    ax.set_facecolor((0, 1, 0, 1))
        #    ax.set_xlabel('TEST', visible=True)
        #plt.show()
    
    
    sys.exit()
    
    #                ir, iy, iz, ie, it, ia, il, ip, ic, ierr
    print(tally_data[ :,  0,  0,  :,  0,  0,  0,  0,  0, 0])
    print(tally_data[ :,  0,  0,  :,  0,  0,  0,  0,  0, 1])
    print(tally_data[0, :, :, 0, 0, 0, 0, 0, 0, 0])
    print(np.shape(tally_data))

    #print(tally_data[ 1,  0,  0,  0,  0,  0,  0,  0,  :, 0])
    #print(tally_metadata['nuclide_ZZZAAAM_list'])
    #print(tally_metadata['nuclide_isomer_list'])

    #ic = tally_metadata['nuclide_ZZZAAAM_list'].index(10020)
    #print(tally_data[1, 0, 0, 0, 0, 0, 0, 0, ic, 0])







